feat(library): extract track duration via ffprobe
Scrubbing/seeking in clients was a no-op because every track shipped with duration_ms=0. Shell out to ffprobe (already in the image) per file during scan and record the parsed duration. ffprobe failures are warned + recorded as 0 so a single bad file doesn't sink the scan. Tightened the incremental skip to also require duration_ms > 0 so existing libraries get backfilled on the next rescan instead of needing a wipe. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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) {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
return fmt.Errorf("lookup: %w", err)
|
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++
|
stats.Skipped++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -148,11 +156,19 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
|||||||
|
|
||||||
trackNum, _ := meta.Track()
|
trackNum, _ := meta.Track()
|
||||||
discNum, _ := meta.Disc()
|
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{
|
params := dbq.UpsertTrackParams{
|
||||||
Title: trackTitle,
|
Title: trackTitle,
|
||||||
AlbumID: album.ID,
|
AlbumID: album.ID,
|
||||||
ArtistID: artist.ID,
|
ArtistID: artist.ID,
|
||||||
DurationMs: 0,
|
DurationMs: durationMs,
|
||||||
FilePath: path,
|
FilePath: path,
|
||||||
FileSize: info.Size(),
|
FileSize: info.Size(),
|
||||||
FileFormat: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."),
|
FileFormat: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."),
|
||||||
@@ -243,3 +259,39 @@ func sortKey(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user