From b24f1224b5d251b8f9a9913a866ab916bb94ee5d Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 17 Oct 2026 23:47:59 +0000 Subject: [PATCH 1/2] analysis: don't keep re-analyzing a track whose file is missing The web UI asks for a track's waveform on every render, and each request for an unanalyzed track queued an analysis. For a track whose file is missing, every one of those ran ffmpeg and failed the same way: a test session logged 14 failed runs in two minutes for one missing track. Queueing now checks that the file exists. A missing one is skipped and the track flagged as missing in the library (shown in the web UI); the check runs each time, so a file that comes back is analyzed then. The lazy analysis a deck's request triggers skips missing files too. --- analysis/analysis.go | 4 ++++ api/analysis_queue_test.go | 49 ++++++++++++++++++++++++++++++++++++-- api/api.go | 24 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/analysis/analysis.go b/analysis/analysis.go index 6726863..5c2829c 100644 --- a/analysis/analysis.go +++ b/analysis/analysis.go @@ -501,6 +501,10 @@ func (s *Store) AnalyzeInBackground(trackID uint32, filePath string, onDone func } } + if _, err := os.Stat(filePath); err != nil { + log.Printf("lazy-analysis: track %d not analyzed, its file is missing: %s", trackID, filePath) + return + } log.Printf("lazy-analysis: analyzing track %d (%s)...", trackID, filepath.Base(filePath)) s.SetStatus(fmt.Sprintf("Analyzing: %s", filepath.Base(filePath))) r, err := AnalyzeTrack(filePath) diff --git a/api/analysis_queue_test.go b/api/analysis_queue_test.go index 31cadb6..5a7c1ce 100644 --- a/api/analysis_queue_test.go +++ b/api/analysis_queue_test.go @@ -3,6 +3,8 @@ package api import ( + "os" + "path/filepath" "testing" "github.com/vynulldev/vynull/analysis" @@ -14,9 +16,11 @@ import ( // analysis, and the web UI queues the rows on screen in between; those queued // jobs used to overwrite the imported grids. func TestQueuedAnalysisKeepsImportedResult(t *testing.T) { + dir := t.TempDir() + one, two := audioFile(t, dir, "one.flac"), audioFile(t, dir, "two.flac") lib := library.New() - lib.AddTrack(&library.Track{ID: 1, Title: "Imported", FilePath: "/music/one.flac"}) - lib.AddTrack(&library.Track{ID: 2, Title: "New", FilePath: "/music/two.flac"}) + lib.AddTrack(&library.Track{ID: 1, Title: "Imported", FilePath: one}) + lib.AddTrack(&library.Track{ID: 2, Title: "New", FilePath: two}) store := analysis.NewStore() analyzed := map[uint32]int{} store.Importer = func(id uint32, _ string) *analysis.Result { @@ -51,3 +55,44 @@ func TestQueuedAnalysisKeepsImportedResult(t *testing.T) { t.Errorf("track 2 (no analysis): want analyzed once, got %+v after %d run(s)", got, analyzed[2]) } } + +// audioFile creates an (empty) audio file for a queue test; only its +// presence matters, since the tests' Importer stands in for analysis. +func audioFile(t *testing.T, dir, name string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, nil, 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// A track whose file is missing is not queued: the web UI asks for its +// waveform on every render, and each request used to start another ffmpeg +// run that failed. The track is flagged as missing, and queued again once +// its file is back. +func TestMissingFileIsNotQueuedForAnalysis(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gone.mp3") + lib := library.New() + lib.AddTrack(&library.Track{ID: 1, Title: "Gone", FilePath: path}) + srv := &Server{Library: lib, Analysis: analysis.NewStore()} + srv.analyzeOnce.Do(func() { srv.analyzeCh = make(chan analyzeJob, 4) }) + + for i := 0; i < 3; i++ { + if srv.tryQueueAnalysis(1, "") { + t.Fatalf("request %d queued a track whose file is missing", i+1) + } + } + if len(srv.analyzeCh) != 0 { + t.Errorf("%d job(s) queued, want 0", len(srv.analyzeCh)) + } + if !lib.Track(1).FileMissing { + t.Errorf("track not flagged as missing") + } + + audioFile(t, dir, "gone.mp3") + if !srv.tryQueueAnalysis(1, "") { + t.Errorf("track not queued once its file is back") + } +} diff --git a/api/api.go b/api/api.go index 1c106d8..5074e97 100644 --- a/api/api.go +++ b/api/api.go @@ -2075,11 +2075,35 @@ func (s *Server) tryQueueAnalysis(trackID uint32, filePath string) bool { s.applyAnalysisToTrack(trackID, r) return false } + // A missing file can't be analyzed. Without this check, every waveform + // request for the track (the web UI asks again on each render) queued + // another ffmpeg run that failed the same way. + if s.audioMissing(trackID, filePath) { + s.queuedAnalyses.Delete(trackID) + return false + } s.Analysis.IncPending() s.queueAnalysis(trackID, filePath) return true } +// audioMissing reports whether a track's audio file is absent, flagging the +// library track as missing when it wasn't already, so the web UI shows it. +// The file is checked each time, so one that reappears is analyzed then. +func (s *Server) audioMissing(trackID uint32, filePath string) bool { + if _, err := os.Stat(filePath); err == nil { + return false + } + if s.Library != nil { + if t := s.Library.Track(trackID); t != nil && !t.FileMissing { + t.FileMissing = true + s.Library.Save() + log.Printf("api: track %d's file is missing, not analyzing: %s", trackID, filePath) + } + } + return true +} + func (s *Server) analysisWorker() { for job := range s.analyzeCh { // Jobs are queued for tracks with no analysis yet. One may have From 5aa32ddd090a4e292a0d2e864ca27b8d95fe4bce Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sun, 18 Oct 2026 20:43:06 +0000 Subject: [PATCH 2/2] api: don't analyze a missing file on demand either Opening a missing track in the web UI's track drawer asks for its waveform and beat grid, and each request ran an on-demand analysis that failed in ffmpeg (three at a time in a test session). On-demand analysis now skips a missing file the same way queued analysis does; cached or imported analysis is still served. --- api/analysis_queue_test.go | 18 ++++++++++++++++++ api/api.go | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/api/analysis_queue_test.go b/api/analysis_queue_test.go index 5a7c1ce..fb75bb7 100644 --- a/api/analysis_queue_test.go +++ b/api/analysis_queue_test.go @@ -96,3 +96,21 @@ func TestMissingFileIsNotQueuedForAnalysis(t *testing.T) { t.Errorf("track not queued once its file is back") } } + +// On-demand analysis (the track drawer's waveform and grid requests) skips a +// missing file instead of running ffmpeg for it each time. +func TestMissingFileIsNotAnalyzedOnDemand(t *testing.T) { + path := filepath.Join(t.TempDir(), "gone.mp3") + lib := library.New() + lib.AddTrack(&library.Track{ID: 1, Title: "Gone", FilePath: path}) + store := analysis.NewStore() + ran := 0 + store.Importer = func(uint32, string) *analysis.Result { ran++; return nil } + srv := &Server{Library: lib, Analysis: store} + if r := srv.getOrAnalyze(1); r != nil || ran != 0 { + t.Errorf("getOrAnalyze = %v after %d analysis attempt(s), want nil and none", r, ran) + } + if !lib.Track(1).FileMissing { + t.Errorf("track not flagged as missing") + } +} diff --git a/api/api.go b/api/api.go index 5074e97..65d2ea7 100644 --- a/api/api.go +++ b/api/api.go @@ -2292,6 +2292,11 @@ func (s *Server) getOrAnalyze(trackID uint32) *analysis.Result { return r // disk cache was valid after SetPath } + // A missing file can't be analyzed; don't run ffmpeg for it on every + // drawer or waveform request (cached analysis above is still served). + if s.audioMissing(trackID, filePath) { + return nil + } log.Printf("api: on-demand analysis for track %d: %s", trackID, filePath) var r *analysis.Result var err error