Merge pull request 'feat(library): extract track duration via ffprobe' (#13) from dev into main
This commit was merged in pull request #13.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user