diff --git a/internal/library/scanner.go b/internal/library/scanner.go index b4bb1b19..fc095265 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -6,8 +6,11 @@ import ( "fmt" "io/fs" "log/slog" + "math" "os" + "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -108,7 +111,12 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta if err != nil && !errors.Is(err, pgx.ErrNoRows) { return fmt.Errorf("lookup: %w", err) } - if knownTrack && !existing.UpdatedAt.Time.Before(mtime) { + // Incremental skip: only when the file hasn't changed AND we already have + // a real duration. The second clause lets older scans that recorded + // duration_ms=0 (before ffprobe was wired) get backfilled without forcing + // the operator to wipe the library. Once duration is set, subsequent + // scans short-circuit as before. + if knownTrack && !existing.UpdatedAt.Time.Before(mtime) && existing.DurationMs > 0 { stats.Skipped++ return nil } @@ -148,11 +156,19 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta trackNum, _ := meta.Track() discNum, _ := meta.Disc() + durationMs, err := probeDurationMs(ctx, path) + if err != nil { + // Missing duration is degraded UX (clients can't scrub) but not a + // blocker for ingestion. Record the file with 0ms; the next scan + // will retry via the backfill clause in the skip check above. + s.logger.Warn("library scan: ffprobe failed", "path", path, "err", err) + durationMs = 0 + } params := dbq.UpsertTrackParams{ Title: trackTitle, AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 0, + DurationMs: durationMs, FilePath: path, FileSize: info.Size(), FileFormat: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."), @@ -243,3 +259,39 @@ func sortKey(s string) string { } return s } + +// probeTimeout bounds how long ffprobe is allowed to inspect a single file. +// 10s is generous for local mp3/flac — hits are usually <100ms — but caps +// blast radius if a pathological file or a slow network mount stalls a scan. +const probeTimeout = 10 * time.Second + +// probeDurationMs shells out to ffprobe to extract a track's duration. We +// rely on ffmpeg being in the image (see Dockerfile). The CLI is slow per +// call (fork+exec) but scans are batch-mode; this is simpler than pulling +// a Go-side decoder library and handles every format ffmpeg does. +func probeDurationMs(ctx context.Context, path string) (int32, error) { + probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + cmd := exec.CommandContext(probeCtx, "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + path, + ) + out, err := cmd.Output() + if err != nil { + return 0, fmt.Errorf("ffprobe: %w", err) + } + seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) + if err != nil { + return 0, fmt.Errorf("parse ffprobe output %q: %w", out, err) + } + if seconds <= 0 || math.IsNaN(seconds) || math.IsInf(seconds, 0) { + return 0, fmt.Errorf("invalid duration: %v", seconds) + } + ms := seconds * 1000 + if ms > math.MaxInt32 { + ms = math.MaxInt32 + } + return int32(ms), nil +}