From a710bc16264fc061a6ae813ee68e8b59c497ddd4 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 12 Jun 2026 19:46:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(library):=20detecci=C3=B3n=20de=20intro/cr?= =?UTF-8?q?=C3=A9ditos=20post-scan=20(skip=20segments)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tras cada scan, localiza la intro (OP) y los créditos (ED) comparando fingerprints chromaprint entre episodios de la misma temporada — reimplementación limpia del enfoque de Intro Skipper: índice invertido de uint32, alineamiento por shifts, Hamming ≤6/32, región contigua más larga (15-120s intro / 15-450s créditos). Películas: inicio de créditos por rachas de blackframe (solo keyframes, -skip_frame nokey) que llegan al final del fichero. - fpcalc se auto-descarga de las releases estáticas de acoustid (linux/macos/windows, ~2MB) con el mismo patrón que ffmpeg/ffprobe. - Resultados cacheados como sidecar .skipseg.json (mtime + versión de algoritmo); solo los ficheros nuevos trabajan. - Submit a /api/internal/agent/skip-segments DESPUÉS del library-sync, en dos fases (episodios primero, películas después) para que la fase rápida no espere a los blackframe lentos sobre NAS. - Agrupación por (dir + título-pre-SxxEyy + season): los títulos parseados arrastran nombre de episodio y tags de release. - Gotcha cazado en vivo: fpcalc -length sale sin drenar el pipe; hay que cerrar nuestro read-end o ffmpeg queda bloqueado para siempre. - config: library.skip_detect (default true, backfill) y scan_interval default 24h → 1h (estilo Plex). --- internal/agent/client.go | 11 + internal/agent/types.go | 35 ++ internal/cmd/daemon.go | 5 +- internal/cmd/scan.go | 33 +- internal/cmd/skipdetect.go | 91 ++++ internal/config/config.go | 15 +- internal/library/mediainfo/chromaprint.go | 279 ++++++++++++ .../library/mediainfo/chromaprint_test.go | 121 +++++ internal/library/mediainfo/fpcalc.go | 148 ++++++ internal/library/skipdetect.go | 420 ++++++++++++++++++ internal/library/skipdetect_test.go | 70 +++ 11 files changed, 1223 insertions(+), 5 deletions(-) create mode 100644 internal/cmd/skipdetect.go create mode 100644 internal/library/mediainfo/chromaprint.go create mode 100644 internal/library/mediainfo/chromaprint_test.go create mode 100644 internal/library/mediainfo/fpcalc.go create mode 100644 internal/library/skipdetect.go create mode 100644 internal/library/skipdetect_test.go diff --git a/internal/agent/client.go b/internal/agent/client.go index 62f62ed..897dfa3 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -512,3 +512,14 @@ func (c *Client) handleResponse(resp *http.Response, dst any) error { return nil } + +// SubmitSkipSegments uploads detected intro/credits segments after a library +// scan. Must run AFTER SyncLibrary — the server resolves file paths against +// the freshly-synced library_item rows. +func (c *Client) SubmitSkipSegments(ctx context.Context, req SkipSegmentsRequest) (*SkipSegmentsResponse, error) { + var resp SkipSegmentsResponse + if err := c.doPostWith(ctx, c.librarySyncClient, "/api/internal/agent/skip-segments", req, &resp); err != nil { + return nil, fmt.Errorf("skip segments: %w", err) + } + return &resp, nil +} diff --git a/internal/agent/types.go b/internal/agent/types.go index 3d3e363..1667793 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -592,3 +592,38 @@ type WatchProgressUpdate struct { type WatchProgressResponse struct { Success bool `json:"success"` } + +// --------------------------------------------------------------------------- +// Skip-segment types (intro/credits detection — see library/skipdetect.go) +// --------------------------------------------------------------------------- + +// SkipSegmentRange is one detected skippable range inside a media file. +type SkipSegmentRange struct { + Category string `json:"category"` // "intro" | "credits" + StartSec float64 `json:"startSec"` + EndSec float64 `json:"endSec"` +} + +// SkipSegmentItem carries the detected segments of one library file. The +// server resolves FilePath against the user's library_item rows (synced just +// before) to attach the segments to a content identity. +type SkipSegmentItem struct { + FilePath string `json:"filePath"` + Title string `json:"title,omitempty"` + Season int `json:"season,omitempty"` + Episode int `json:"episode,omitempty"` + DurationSec float64 `json:"durationSec"` + Segments []SkipSegmentRange `json:"segments"` +} + +// SkipSegmentsRequest submits detected skip segments after a library scan. +type SkipSegmentsRequest struct { + AgentID string `json:"agentId,omitempty"` + Items []SkipSegmentItem `json:"items"` +} + +// SkipSegmentsResponse reports how many segments the server stored. +type SkipSegmentsResponse struct { + Stored int `json:"stored"` + Unmatched int `json:"unmatched"` +} diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index e1ed1c2..b45ae7f 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -1073,7 +1073,7 @@ func runDaemonStart() error { // Start auto-scan goroutine scanPaths := daemonCfg.ScanPaths if len(scanPaths) > 0 && cfg.Library.AutoScan { - scanInterval := 24 * time.Hour + scanInterval := time.Hour if cfg.Library.ScanInterval != "" { if parsed, err := time.ParseDuration(cfg.Library.ScanInterval); err == nil && parsed > 0 { scanInterval = parsed @@ -1487,6 +1487,9 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, if err := library.SaveCache(mergedCache); err != nil { log.Printf("[auto-scan] save cache failed: %v", err) } + // Intro/credits detection AFTER the sync above — the server maps + // file paths to the library_item rows that sync just created. + detectAndSubmitSkipSegments(ctx, cfg, ac, mergedCache) } log.Printf("[auto-scan] synced %d items across %d path(s)", totalSynced, len(scanPaths)) diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index baf15d2..dbb30f8 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -53,17 +53,27 @@ to see available quality upgrades.`, return fmt.Errorf("usage: unarr scan \n\nNo scan paths configured. Provide a path or set up downloads.dir via 'unarr init'") } var items []agent.LibrarySyncItem + var caches []*library.LibraryCache for _, p := range paths { cache, err := runScan(ctx, cfg, p, workers, ffprobe) if err != nil { return err } + caches = append(caches, cache) items = append(items, library.BuildSyncItems(cache)...) } if noSync || jsonOut { return nil } - return syncToServer(ctx, cfg, items, paths, true) + if err := syncToServer(ctx, cfg, items, paths, true); err != nil { + return err + } + if ac := scanAPIClient(cfg); ac != nil { + for _, cache := range caches { + detectAndSubmitSkipSegments(ctx, cfg, ac, cache) + } + } + return nil } cache, err := runScan(ctx, cfg, args[0], workers, ffprobe) if err != nil { @@ -72,7 +82,13 @@ to see available quality upgrades.`, if noSync || jsonOut { return nil } - return syncToServer(ctx, cfg, library.BuildSyncItems(cache), []string{args[0]}, false) + if err := syncToServer(ctx, cfg, library.BuildSyncItems(cache), []string{args[0]}, false); err != nil { + return err + } + if ac := scanAPIClient(cfg); ac != nil { + detectAndSubmitSkipSegments(ctx, cfg, ac, cache) + } + return nil }, } @@ -183,6 +199,19 @@ func runScan(ctx context.Context, cfg config.Config, dirPath string, workers int return cache, nil } +// scanAPIClient builds the agent API client for post-scan submissions, using +// the same key resolution as syncToServer. Nil when no key is configured. +func scanAPIClient(cfg config.Config) *agent.Client { + apiKey := apiKeyFlag + if apiKey == "" { + apiKey = cfg.Auth.APIKey + } + if apiKey == "" { + return nil + } + return agent.NewClient(cfg.Auth.APIURL, apiKey, "unarr/"+Version) +} + // syncToServer uploads the scanned items of THIS invocation as one sync // session. roots lists every root the invocation scanned; fullCycle marks a // no-args run that covered all configured roots (the server may then reap diff --git a/internal/cmd/skipdetect.go b/internal/cmd/skipdetect.go new file mode 100644 index 0000000..4c76166 --- /dev/null +++ b/internal/cmd/skipdetect.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "context" + "log" + + "github.com/torrentclaw/unarr/internal/agent" + "github.com/torrentclaw/unarr/internal/config" + "github.com/torrentclaw/unarr/internal/library" + "github.com/torrentclaw/unarr/internal/library/mediainfo" +) + +// detectAndSubmitSkipSegments runs intro/credits detection over a scanned +// cache and uploads the results. Called AFTER the library sync (the server +// resolves file paths against the just-synced library_item rows). Best-effort: +// every failure logs and returns — a scan must never fail because of this. +func detectAndSubmitSkipSegments(ctx context.Context, cfg config.Config, ac *agent.Client, cache *library.LibraryCache) { + if !cfg.Library.SkipDetect || cache == nil || ctx.Err() != nil { + return + } + ffmpegPath, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath) + if err != nil { + log.Printf("[skipdetect] skipped: ffmpeg unavailable: %v", err) + return + } + fpcalcPath, err := mediainfo.ResolveFpcalc() + if err != nil { + // Movies-only still works (black frames need just ffmpeg). + log.Printf("[skipdetect] fpcalc unavailable (episode detection off): %v", err) + fpcalcPath = "" + } + + // Two phases so fast results don't wait on slow ones: episode fingerprinting + // is seconds per season (and often pure cache), while movie black-frame + // scans grind through 4K tails over the NAS — episodes submit first. + episodes := library.DetectSkipSegments(ctx, cache, library.SkipDetectOptions{ + FFmpegPath: ffmpegPath, + FpcalcPath: fpcalcPath, + Workers: 2, + }) + submitSkipSegments(ctx, cfg, ac, episodes) + + movies := library.DetectSkipSegments(ctx, cache, library.SkipDetectOptions{ + FFmpegPath: ffmpegPath, + Workers: 2, + Movies: true, + }) + submitSkipSegments(ctx, cfg, ac, movies) +} + +func submitSkipSegments(ctx context.Context, cfg config.Config, ac *agent.Client, detections []library.SkipDetection) { + if len(detections) == 0 || ac == nil || ctx.Err() != nil { + return + } + + items := make([]agent.SkipSegmentItem, 0, len(detections)) + for _, d := range detections { + segs := make([]agent.SkipSegmentRange, 0, len(d.Segments)) + for _, s := range d.Segments { + segs = append(segs, agent.SkipSegmentRange{Category: s.Category, StartSec: s.StartSec, EndSec: s.EndSec}) + } + items = append(items, agent.SkipSegmentItem{ + FilePath: d.Item.FilePath, + Title: d.Item.Title, + Season: d.Item.Season, + Episode: d.Item.Episode, + DurationSec: d.DurationSec, + Segments: segs, + }) + } + + const batchSize = 200 + stored, unmatched := 0, 0 + for start := 0; start < len(items); start += batchSize { + end := start + batchSize + if end > len(items) { + end = len(items) + } + res, err := ac.SubmitSkipSegments(ctx, agent.SkipSegmentsRequest{ + AgentID: cfg.Agent.ID, + Items: items[start:end], + }) + if err != nil { + log.Printf("[skipdetect] submit failed: %v", err) + return + } + stored += res.Stored + unmatched += res.Unmatched + } + log.Printf("[skipdetect] submitted %d file(s): %d segment(s) stored, %d unmatched", len(items), stored, unmatched) +} diff --git a/internal/config/config.go b/internal/config/config.go index 2e71a7c..93f82cc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -193,7 +193,7 @@ type LibraryConfig struct { FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder) BackupDir string `toml:"backup_dir"` // for replaced files AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true) - ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h") + ScanInterval string `toml:"scan_interval"` // e.g. "1h", "6h", "24h" (default "1h", like Plex/Jellyfin periodic scans) AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk // Sidecar caching: extract text subtitles (WebVTT) and thumbnail frames once @@ -204,6 +204,13 @@ type LibraryConfig struct { CacheSubtitles bool `toml:"cache_subtitles"` // default true CacheThumbnails bool `toml:"cache_thumbnails"` // default true + // Skip-segment detection: after each scan, find intro/credits ranges by + // comparing chromaprint audio fingerprints between episodes of a season + // (plus black-frame credits for movies) and submit them to the web so the + // player can offer "Skip intro" / "Skip credits". Cached per file; only + // new files do work. Default true. + SkipDetect bool `toml:"skip_detect"` + // Trickplay: at scan time, build ONE montage JPEG of frames sampled every // Interval seconds (+ a JSON manifest), cached in .unarr next to the media. // The web scrubber shows tiles from it — no live ffmpeg during playback, so @@ -314,10 +321,11 @@ func Default() Config { }, Library: LibraryConfig{ AutoScan: true, - ScanInterval: "24h", + ScanInterval: "1h", Workers: 8, CacheSubtitles: true, CacheThumbnails: true, + SkipDetect: true, Trickplay: TrickplayConfig{ Enabled: true, Interval: "10s", @@ -396,6 +404,9 @@ func applyDefaults(cfg *Config, meta toml.MetaData) { if !meta.IsDefined("library", "cache_thumbnails") { cfg.Library.CacheThumbnails = true } + if !meta.IsDefined("library", "skip_detect") { + cfg.Library.SkipDetect = true + } // Trickplay defaults ON for configs predating these keys (small sidecar JPEG; // makes the scrubber instant + contention-free). Explicit `enabled = false` // is respected via meta.IsDefined. diff --git a/internal/library/mediainfo/chromaprint.go b/internal/library/mediainfo/chromaprint.go new file mode 100644 index 0000000..14aff34 --- /dev/null +++ b/internal/library/mediainfo/chromaprint.go @@ -0,0 +1,279 @@ +package mediainfo + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "math/bits" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// Chromaprint-based shared-audio detection. Episodes of the same season share +// an identical intro (OP) and credits (ED) audio track; fingerprinting a window +// of each episode and finding the longest aligned low-hamming-distance region +// between two episodes localizes those segments. Clean-room implementation of +// the approach popularized by Jellyfin's Intro Skipper plugin. +// +// Fingerprint stream: chromaprint emits one uint32 per ~0.1238s of audio +// (11025 Hz mono, FFT 4096, 2/3 overlap → ~8.08 points/second). + +const ( + // ChromaprintSampleDur is seconds of audio per fingerprint point. + ChromaprintSampleDur = 0.1238 + // maxHammingBits: two points are "similar" when their XOR popcount is below this. + maxHammingBits = 6 + // maxTimeSkipSec: gap tolerance when growing a contiguous similar region. + maxTimeSkipSec = 3.5 +) + +// SkipSegmentRange is one detected skippable range inside a media file. +type SkipSegmentRange struct { + Category string `json:"category"` // "intro" | "credits" + StartSec float64 `json:"startSec"` + EndSec float64 `json:"endSec"` +} + +// FingerprintAudioWindow decodes [startSec, startSec+lengthSec] of the first +// audio track with ffmpeg and pipes the WAV into fpcalc -raw, returning the +// chromaprint point stream. +func FingerprintAudioWindow(ctx context.Context, ffmpegPath, fpcalcPath, mediaPath string, startSec, lengthSec float64) ([]uint32, error) { + ff := exec.CommandContext(ctx, ffmpegPath, + "-nostdin", "-loglevel", "error", + "-ss", strconv.FormatFloat(startSec, 'f', 3, 64), + "-i", mediaPath, + "-t", strconv.FormatFloat(lengthSec, 'f', 3, 64), + "-map", "0:a:0", + "-ac", "2", + "-f", "wav", "-", + ) + fp := exec.CommandContext(ctx, fpcalcPath, + "-raw", "-length", strconv.Itoa(int(lengthSec)), "-") + + pipe, err := ff.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("ffmpeg pipe: %w", err) + } + fp.Stdin = pipe + var ffErr strings.Builder + ff.Stderr = &ffErr + + if err := ff.Start(); err != nil { + return nil, fmt.Errorf("ffmpeg start: %w", err) + } + out, err := fp.Output() + // fpcalc stops reading once it has processed -length seconds and may exit + // WITHOUT draining the last buffered bytes. Close our read end so ffmpeg + // gets EPIPE and exits — otherwise it blocks forever on a full pipe whose + // only remaining reader is us (caught live: 5-min ctx kills, per file). + _ = pipe.Close() + // Always reap ffmpeg; early pipe close makes it exit non-zero — fine as + // long as fpcalc produced output. + _ = ff.Wait() + if err != nil { + return nil, fmt.Errorf("fpcalc: %w (ffmpeg: %s)", err, strings.TrimSpace(ffErr.String())) + } + + for _, line := range strings.Split(string(out), "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "FINGERPRINT="); ok { + parts := strings.Split(rest, ",") + points := make([]uint32, 0, len(parts)) + for _, p := range parts { + // fpcalc may print signed ints; parse wide and truncate. + v, perr := strconv.ParseInt(strings.TrimSpace(p), 10, 64) + if perr != nil { + return nil, fmt.Errorf("fpcalc output parse: %w", perr) + } + points = append(points, uint32(v)) + } + if len(points) == 0 { + return nil, fmt.Errorf("fpcalc produced an empty fingerprint") + } + return points, nil + } + } + return nil, fmt.Errorf("no FINGERPRINT line in fpcalc output") +} + +// SharedRegion is the longest aligned similar-audio region between two +// fingerprint streams, in seconds relative to each stream's start. +type SharedRegion struct { + AStart, AEnd float64 + BStart, BEnd float64 + Duration float64 +} + +// FindSharedRegion locates the longest contiguous region (bounded by +// minDur/maxDur seconds) where streams a and b carry near-identical audio at +// some alignment. Returns nil when no qualifying region exists. +func FindSharedRegion(a, b []uint32, minDur, maxDur float64) *SharedRegion { + if len(a) == 0 || len(b) == 0 { + return nil + } + // Inverted index of b: point value → last index seen. + indexB := make(map[uint32]int, len(b)) + for i, v := range b { + indexB[v] = i + } + // Candidate alignments: exact value matches (±2 on the value tolerates + // quantization noise between encodes). + shifts := make(map[int]struct{}) + for i, v := range a { + for d := -2; d <= 2; d++ { + if j, ok := indexB[v+uint32(d)]; ok { + shifts[j-i] = struct{}{} + } + } + } + + minPoints := int(minDur / ChromaprintSampleDur) + gapSec := float64(maxTimeSkipSec) + gapPoints := int(gapSec / ChromaprintSampleDur) + var best *SharedRegion + + for shift := range shifts { + i0 := 0 + if shift < 0 { + i0 = -shift + } + i1 := len(a) + if len(b)-shift < i1 { + i1 = len(b) - shift + } + if i1-i0 < minPoints { + continue + } + runStart, prev := -1, -1 + flush := func(end int) { + if runStart < 0 { + return + } + dur := float64(end-runStart) * ChromaprintSampleDur + if dur >= minDur && dur <= maxDur && (best == nil || dur > best.Duration) { + best = &SharedRegion{ + AStart: float64(runStart) * ChromaprintSampleDur, + AEnd: float64(end) * ChromaprintSampleDur, + BStart: float64(runStart+shift) * ChromaprintSampleDur, + BEnd: float64(end+shift) * ChromaprintSampleDur, + Duration: dur, + } + } + } + for i := i0; i < i1; i++ { + if bits.OnesCount32(a[i]^b[i+shift]) > maxHammingBits { + continue + } + if prev >= 0 && i-prev > gapPoints { + flush(prev) + runStart = i + } else if runStart < 0 { + runStart = i + } + prev = i + } + flush(prev) + } + return best +} + +// --- Black-frame credits detection (movies: no sibling episode to compare) --- + +var blackframeRe = regexp.MustCompile(`frame:\d+\s+pblack:\d+\s+pts:\d+\s+t:([\d.]+)`) + +// DetectBlackFrameRuns scans [startSec, startSec+lengthSec] with ffmpeg's +// blackframe filter and returns the timestamps (absolute seconds) of frames +// that are ≥minBlackPct black. Used to find the start of end credits in movies +// (classic credits roll on black). +func DetectBlackFrameRuns(ctx context.Context, ffmpegPath, mediaPath string, startSec, lengthSec float64, minBlackPct int) ([]float64, error) { + // Keyframe-only decode: credits-on-black lasts minutes, so sampling one + // frame every keyframe interval (~2-10s) finds the run at ~2% of the cost + // of a full decode — the difference between seconds and minutes per 4K film. + cmd := exec.CommandContext(ctx, ffmpegPath, + "-nostdin", "-loglevel", "info", + "-skip_frame", "nokey", + "-ss", strconv.FormatFloat(startSec, 'f', 3, 64), + "-i", mediaPath, + "-t", strconv.FormatFloat(lengthSec, 'f', 3, 64), + "-an", "-sn", + "-vf", fmt.Sprintf("blackframe=amount=%d:threshold=32", minBlackPct), + "-f", "null", "-", + ) + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("ffmpeg blackframe start: %w", err) + } + var times []float64 + sc := bufio.NewScanner(stderr) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + if m := blackframeRe.FindStringSubmatch(sc.Text()); m != nil { + if t, perr := strconv.ParseFloat(m[1], 64); perr == nil { + times = append(times, startSec+t) + } + } + } + if err := cmd.Wait(); err != nil { + return nil, fmt.Errorf("ffmpeg blackframe: %w", err) + } + return times, nil +} + +// --- Sidecar cache for detected segments --- + +// skipSegmentsSidecarVersion bumps when the detection algorithm changes enough +// that cached results should be recomputed. +const skipSegmentsSidecarVersion = 1 + +// SkipSegmentsSidecar is the cached detection result for one media file. +type SkipSegmentsSidecar struct { + Version int `json:"version"` + DurationSec float64 `json:"durationSec"` + Segments []SkipSegmentRange `json:"segments"` // empty = analyzed, nothing found +} + +func skipSegmentsCachePath(mediaPath string) string { + return filepath.Join(sidecarDir(mediaPath), filepath.Base(mediaPath)+".skipseg.json") +} + +// ReadCachedSkipSegments returns the cached detection result for mediaPath if +// fresh (newer than the media file) and of the current algorithm version. +func ReadCachedSkipSegments(mediaPath string) (*SkipSegmentsSidecar, bool) { + p := skipSegmentsCachePath(mediaPath) + if !sidecarFresh(p, mediaPath) { + return nil, false + } + data, err := os.ReadFile(p) + if err != nil { + return nil, false + } + var sc SkipSegmentsSidecar + if err := json.Unmarshal(data, &sc); err != nil || sc.Version != skipSegmentsSidecarVersion { + return nil, false + } + return &sc, true +} + +// WriteCachedSkipSegments persists a detection result next to the media file. +func WriteCachedSkipSegments(mediaPath string, durationSec float64, segs []SkipSegmentRange) error { + if segs == nil { + segs = []SkipSegmentRange{} + } + sc := SkipSegmentsSidecar{Version: skipSegmentsSidecarVersion, DurationSec: durationSec, Segments: segs} + data, err := json.Marshal(sc) + if err != nil { + return err + } + dir := sidecarDir(mediaPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return os.WriteFile(skipSegmentsCachePath(mediaPath), data, 0o644) +} diff --git a/internal/library/mediainfo/chromaprint_test.go b/internal/library/mediainfo/chromaprint_test.go new file mode 100644 index 0000000..4425893 --- /dev/null +++ b/internal/library/mediainfo/chromaprint_test.go @@ -0,0 +1,121 @@ +package mediainfo + +import ( + "math" + "testing" +) + +// lcg is a tiny deterministic pseudo-random stream for synthetic fingerprints. +type lcg struct{ state uint64 } + +func (l *lcg) next() uint32 { + l.state = l.state*6364136223846793005 + 1442695040888963407 + return uint32(l.state >> 32) +} + +func TestFindSharedRegion_DetectsAlignedSegment(t *testing.T) { + // Shared segment: 700 points ≈ 86.7s — a typical anime OP. + shared := make([]uint32, 700) + g := &lcg{state: 42} + for i := range shared { + shared[i] = g.next() + } + + // a: 80 points of unique noise, then the shared segment, then noise. + ga := &lcg{state: 1001} + a := make([]uint32, 0, 2000) + for i := 0; i < 80; i++ { + a = append(a, ga.next()) + } + a = append(a, shared...) + for len(a) < 2000 { + a = append(a, ga.next()) + } + + // b: 480 points of different noise, then the same shared segment. + gb := &lcg{state: 2002} + b := make([]uint32, 0, 2000) + for i := 0; i < 480; i++ { + b = append(b, gb.next()) + } + b = append(b, shared...) + for len(b) < 2000 { + b = append(b, gb.next()) + } + + r := FindSharedRegion(a, b, 15, 120) + if r == nil { + t.Fatal("expected a shared region, got nil") + } + wantAStart := 80 * ChromaprintSampleDur + wantBStart := 480 * ChromaprintSampleDur + if math.Abs(r.AStart-wantAStart) > 2 { + t.Errorf("AStart = %.1f, want ≈ %.1f", r.AStart, wantAStart) + } + if math.Abs(r.BStart-wantBStart) > 2 { + t.Errorf("BStart = %.1f, want ≈ %.1f", r.BStart, wantBStart) + } + wantDur := 700 * ChromaprintSampleDur + if math.Abs(r.Duration-wantDur) > 4 { + t.Errorf("Duration = %.1f, want ≈ %.1f", r.Duration, wantDur) + } +} + +func TestFindSharedRegion_NoMatchOnNoise(t *testing.T) { + ga, gb := &lcg{state: 7}, &lcg{state: 9} + a := make([]uint32, 1500) + b := make([]uint32, 1500) + for i := range a { + a[i] = ga.next() + b[i] = gb.next() + } + if r := FindSharedRegion(a, b, 15, 120); r != nil { + t.Fatalf("expected nil on unrelated noise, got %+v", r) + } +} + +func TestFindSharedRegion_FullMatchExceedsMaxDur(t *testing.T) { + // Two identical streams (same episode, two releases): the only region is + // the full window, which must be rejected by maxDur. + g := &lcg{state: 5} + a := make([]uint32, 2000) + for i := range a { + a[i] = g.next() + } + b := make([]uint32, 2000) + copy(b, a) + if r := FindSharedRegion(a, b, 15, 120); r != nil { + t.Fatalf("expected nil for identical streams (region > maxDur), got %+v", r) + } +} + +func TestFindSharedRegion_ToleratesBitNoise(t *testing.T) { + // Same shared segment but with ≤2 flipped bits per point (re-encode noise). + shared := make([]uint32, 600) + g := &lcg{state: 77} + for i := range shared { + shared[i] = g.next() + } + noisy := make([]uint32, len(shared)) + for i, v := range shared { + noisy[i] = v ^ (1 << uint(i%20)) // flip one bit + } + + ga, gb := &lcg{state: 100}, &lcg{state: 200} + a := append(make([]uint32, 0, 1500), shared...) + for len(a) < 1500 { + a = append(a, ga.next()) + } + b := append(make([]uint32, 0, 1500), noisy...) + for len(b) < 1500 { + b = append(b, gb.next()) + } + + r := FindSharedRegion(a, b, 15, 120) + if r == nil { + t.Fatal("expected match despite 1-bit noise, got nil") + } + if r.AStart > 2 { + t.Errorf("AStart = %.1f, want ≈ 0", r.AStart) + } +} diff --git a/internal/library/mediainfo/fpcalc.go b/internal/library/mediainfo/fpcalc.go new file mode 100644 index 0000000..040bc88 --- /dev/null +++ b/internal/library/mediainfo/fpcalc.go @@ -0,0 +1,148 @@ +package mediainfo + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// fpcalc (chromaprint) powers skip-segment detection: the ffmpeg static builds +// we download from ffbinaries do NOT include the chromaprint muxer, so audio +// fingerprinting pipes decoded WAV from our ffmpeg into a standalone fpcalc +// binary. acoustid publishes small (~2MB) static builds per platform. + +const fpcalcVersion = "1.6.0" + +var fpcalcDLClient = &http.Client{Timeout: 5 * time.Minute} + +const maxFpcalcArchiveSize = 50 * 1024 * 1024 // 50MB + +// fpcalcDownloadURL returns the release asset URL for the current platform, +// and whether the asset is a zip (Windows) instead of tar.gz. +func fpcalcDownloadURL() (url string, isZip bool, err error) { + base := fmt.Sprintf("https://github.com/acoustid/chromaprint/releases/download/v%s/chromaprint-fpcalc-%s-", fpcalcVersion, fpcalcVersion) + switch runtime.GOOS { + case "linux": + switch runtime.GOARCH { + case "amd64": + return base + "linux-x86_64.tar.gz", false, nil + case "arm64": + return base + "linux-arm64.tar.gz", false, nil + } + case "darwin": + return base + "macos-universal.tar.gz", false, nil + case "windows": + if runtime.GOARCH == "amd64" { + return base + "windows-x86_64.zip", true, nil + } + } + return "", false, fmt.Errorf("no fpcalc build for platform %s/%s", runtime.GOOS, runtime.GOARCH) +} + +// FpcalcCachePath returns the cached fpcalc binary path (same bin dir as the +// downloaded ffmpeg/ffprobe). +func FpcalcCachePath() (string, error) { + dir, err := FFprobeCacheDir() + if err != nil { + return "", err + } + name := "fpcalc" + if runtime.GOOS == "windows" { + name = "fpcalc.exe" + } + return filepath.Join(dir, name), nil +} + +// ResolveFpcalc finds a usable fpcalc binary: PATH → cache dir → download. +func ResolveFpcalc() (string, error) { + if p, err := exec.LookPath("fpcalc"); err == nil { + return p, nil + } + dest, err := FpcalcCachePath() + if err != nil { + return "", err + } + if _, err := os.Stat(dest); err == nil { + return dest, nil + } + return downloadFpcalc(dest) +} + +func downloadFpcalc(dest string) (string, error) { + url, isZip, err := fpcalcDownloadURL() + if err != nil { + return "", err + } + + fmt.Fprintf(os.Stderr, "fpcalc not found — downloading chromaprint %s...\n", fpcalcVersion) + + resp, err := fpcalcDLClient.Get(url) + if err != nil { + return "", fmt.Errorf("fpcalc download failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("fpcalc download failed: HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxFpcalcArchiveSize)) + if err != nil { + return "", fmt.Errorf("fpcalc download read failed: %w", err) + } + + name := "fpcalc" + if runtime.GOOS == "windows" { + name = "fpcalc.exe" + } + + var binary []byte + if isZip { + binary, err = extractFromZip(data, name) + } else { + binary, err = extractFromTarGz(data, name) + } + if err != nil { + return "", err + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", fmt.Errorf("cannot create cache directory: %w", err) + } + if err := os.WriteFile(dest, binary, 0o755); err != nil { + return "", fmt.Errorf("cannot write fpcalc binary: %w", err) + } + + fmt.Fprintf(os.Stderr, "fpcalc installed to %s\n", dest) + return dest, nil +} + +func extractFromTarGz(data []byte, target string) ([]byte, error) { + gz, err := gzip.NewReader(strings.NewReader(string(data))) + if err != nil { + return nil, fmt.Errorf("cannot open downloaded archive: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("cannot read archive: %w", err) + } + if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == target { + return io.ReadAll(io.LimitReader(tr, maxFpcalcArchiveSize)) + } + } + return nil, fmt.Errorf("%s not found in downloaded archive", target) +} diff --git a/internal/library/skipdetect.go b/internal/library/skipdetect.go new file mode 100644 index 0000000..03d5048 --- /dev/null +++ b/internal/library/skipdetect.go @@ -0,0 +1,420 @@ +package library + +import ( + "context" + "log" + "math" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/torrentclaw/unarr/internal/library/mediainfo" +) + +// Skip-segment detection: find intro (OP) and credits (ED) ranges by comparing +// chromaprint audio fingerprints between episodes of the same season (episodes +// share identical intro/credits audio), plus black-frame credits detection for +// movies (no sibling to compare). Results are cached as ".unarr" sidecars and +// submitted to the web, which shares them across all users by content identity. + +const ( + skipMinIntroSec = 15 + skipMaxIntroSec = 120 + skipMinCreditsSec = 15 + skipMaxCreditsSec = 450 + skipCreditsWindow = 450 // episodes: fingerprint the last N seconds + skipIntroWindowCap = 600 // episodes: fingerprint at most the first N seconds + skipMinRuntimeSec = 300 // ignore shorts/extras + + movieCreditsWindow = 900 // movies: black-frame scan over the last N seconds + movieMinCreditsSec = 60 + movieMinRuntimeSec = 3600 +) + +// SkipDetectOptions configures DetectSkipSegments. +type SkipDetectOptions struct { + FFmpegPath string + FpcalcPath string // empty disables episode (chromaprint) detection + Workers int // concurrent ffmpeg+fpcalc jobs; default 2 + Movies bool // also detect movie end credits via black frames +} + +// SkipDetection is the outcome for one media file (only files with ≥1 segment +// are returned). +type SkipDetection struct { + Item LibraryItem + DurationSec float64 + Segments []mediainfo.SkipSegmentRange +} + +// DetectSkipSegments analyzes the scanned library and returns every file with +// detected skippable segments. Idempotent and best-effort: fresh sidecar +// results are reused without re-analysis, errors skip the file, ctx cancels +// cleanly. +func DetectSkipSegments(ctx context.Context, cache *LibraryCache, opts SkipDetectOptions) []SkipDetection { + if cache == nil || opts.FFmpegPath == "" { + return nil + } + workers := opts.Workers + if workers < 1 { + workers = 2 + } + + var out []SkipDetection + var outMu sync.Mutex + add := func(item LibraryItem, dur float64, segs []mediainfo.SkipSegmentRange) { + if len(segs) == 0 { + return + } + outMu.Lock() + out = append(out, SkipDetection{Item: item, DurationSec: dur, Segments: segs}) + outMu.Unlock() + } + + start := time.Now() + analyzed, cached := 0, 0 + + if opts.FpcalcPath != "" { + a, c := detectEpisodeGroups(ctx, cache, opts, workers, add) + analyzed += a + cached += c + } + if opts.Movies { + a, c := detectMovieCredits(ctx, cache, opts, workers, add) + analyzed += a + cached += c + } + + log.Printf("[skipdetect] %d file(s) analyzed (%d from cache), %d with segments, in %s", + analyzed, cached, len(out), time.Since(start).Round(time.Second)) + return out +} + +// seasonEpisodeMarker locates the SxxEyy token in a parsed title so the group +// key uses only the SHOW part. Parsed titles keep the episode name + release +// tags ("Show S01E09 Embrace and Whisper BILI WEB DL…"), which differ per +// file — grouping on the raw title would leave every episode alone. +var seasonEpisodeMarker = regexp.MustCompile(`(?i)\bS\d{1,2}\s*E\d{1,4}\b`) + +// seasonGroupKey groups episodes that can share intro/credits audio: same +// directory + same show-title prefix + same season. The directory bound keeps +// flat mixed folders from exploding into one giant group; cross-show pairs +// inside a dir fail closed anyway (unrelated audio never matches). +func seasonGroupKey(item LibraryItem) string { + title := strings.ToLower(strings.TrimSpace(item.Title)) + if loc := seasonEpisodeMarker.FindStringIndex(title); loc != nil { + title = strings.TrimSpace(title[:loc[0]]) + } + return filepath.Dir(item.FilePath) + "|" + title + "|s" + strconv.Itoa(item.Season) +} + +func itemDuration(item LibraryItem) float64 { + if item.MediaInfo != nil && item.MediaInfo.Video != nil { + return item.MediaInfo.Video.Duration + } + return 0 +} + +// detectEpisodeGroups runs chromaprint comparison inside (title, season) +// groups. Returns (analyzed, fromCache) counters. +func detectEpisodeGroups(ctx context.Context, cache *LibraryCache, opts SkipDetectOptions, workers int, add func(LibraryItem, float64, []mediainfo.SkipSegmentRange)) (int, int) { + groups := make(map[string][]LibraryItem) + for _, item := range cache.Items { + if item.Season <= 0 || item.Episode <= 0 || item.FilePath == "" { + continue + } + if itemDuration(item) < skipMinRuntimeSec { + continue + } + groups[seasonGroupKey(item)] = append(groups[seasonGroupKey(item)], item) + } + + analyzed, fromCache := 0, 0 + for _, items := range groups { + if ctx.Err() != nil { + break + } + // Distinct episode numbers — two releases of the same episode carry + // identical full audio (a comparison would match the whole window). + eps := make(map[int]struct{}) + for _, it := range items { + eps[it.Episode] = struct{}{} + } + if len(eps) < 2 { + continue + } + + // Cached results short-circuit the whole group when complete. + needCompute := false + cachedSegs := make(map[string]*mediainfo.SkipSegmentsSidecar, len(items)) + for _, it := range items { + if sc, ok := mediainfo.ReadCachedSkipSegments(it.FilePath); ok { + cachedSegs[it.FilePath] = sc + } else { + needCompute = true + } + } + if !needCompute { + for _, it := range items { + sc := cachedSegs[it.FilePath] + analyzed++ + fromCache++ + add(it, sc.DurationSec, sc.Segments) + } + continue + } + + // Fingerprint every episode in the group (intro + credits windows). + fps := fingerprintGroup(ctx, items, opts, workers) + + sort.Slice(items, func(i, j int) bool { return items[i].Episode < items[j].Episode }) + for _, it := range items { + if ctx.Err() != nil { + break + } + analyzed++ + if sc, ok := cachedSegs[it.FilePath]; ok { + fromCache++ + add(it, sc.DurationSec, sc.Segments) + continue + } + fp := fps[it.FilePath] + if fp == nil { + continue + } + segs := detectForEpisode(it, fp, items, fps) + if err := mediainfo.WriteCachedSkipSegments(it.FilePath, fp.duration, segs); err != nil { + log.Printf("[skipdetect] sidecar write skipped (%q): %v", it.FilePath, err) + } + add(it, fp.duration, segs) + } + } + return analyzed, fromCache +} + +// episodeFingerprints holds the two fingerprinted windows of one file. +type episodeFingerprints struct { + duration float64 + intro []uint32 + credits []uint32 + creditsStart float64 // absolute offset of the credits window +} + +func fingerprintGroup(ctx context.Context, items []LibraryItem, opts SkipDetectOptions, workers int) map[string]*episodeFingerprints { + fps := make(map[string]*episodeFingerprints, len(items)) + var mu sync.Mutex + jobs := make(chan LibraryItem) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for it := range jobs { + if ctx.Err() != nil { + return + } + dur := itemDuration(it) + introWin := math.Min(0.25*dur, skipIntroWindowCap) + credStart := math.Max(0, dur-skipCreditsWindow) + jctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + intro, err1 := mediainfo.FingerprintAudioWindow(jctx, opts.FFmpegPath, opts.FpcalcPath, it.FilePath, 0, introWin) + credits, err2 := mediainfo.FingerprintAudioWindow(jctx, opts.FFmpegPath, opts.FpcalcPath, it.FilePath, credStart, skipCreditsWindow) + cancel() + if err1 != nil || err2 != nil { + if err1 != nil { + log.Printf("[skipdetect] fingerprint failed (%q): %v", it.FilePath, err1) + } else { + log.Printf("[skipdetect] fingerprint failed (%q): %v", it.FilePath, err2) + } + continue + } + mu.Lock() + fps[it.FilePath] = &episodeFingerprints{duration: dur, intro: intro, credits: credits, creditsStart: credStart} + mu.Unlock() + } + }() + } + for _, it := range items { + // Skip already-cached files only if every OTHER episode can still find + // partners — fingerprinting cached files too keeps them available as + // comparison partners for the new ones, so always fingerprint. + select { + case jobs <- it: + case <-ctx.Done(): + } + if ctx.Err() != nil { + break + } + } + close(jobs) + wg.Wait() + return fps +} + +// detectForEpisode compares one episode against partners (nearest different +// episode numbers first, up to 3) and returns its detected segments. +func detectForEpisode(it LibraryItem, fp *episodeFingerprints, items []LibraryItem, fps map[string]*episodeFingerprints) []mediainfo.SkipSegmentRange { + type partner struct { + fp *episodeFingerprints + dist int + } + var partners []partner + for _, other := range items { + if other.FilePath == it.FilePath || other.Episode == it.Episode { + continue + } + ofp := fps[other.FilePath] + if ofp == nil { + continue + } + d := other.Episode - it.Episode + if d < 0 { + d = -d + } + partners = append(partners, partner{fp: ofp, dist: d}) + } + sort.Slice(partners, func(i, j int) bool { return partners[i].dist < partners[j].dist }) + if len(partners) > 3 { + partners = partners[:3] + } + + segs := make([]mediainfo.SkipSegmentRange, 0, 2) + + for _, p := range partners { + r := mediainfo.FindSharedRegion(fp.intro, p.fp.intro, skipMinIntroSec, skipMaxIntroSec) + if r == nil { + continue + } + start, end := r.AStart, r.AEnd + if start <= 5 { // OP at the head — snap to the very start + start = 0 + } + segs = append(segs, mediainfo.SkipSegmentRange{Category: "intro", StartSec: round1(start), EndSec: round1(end)}) + break + } + + for _, p := range partners { + // A near-full-window match means the two files share ALL audio (same + // episode content) — not a credits segment. + r := mediainfo.FindSharedRegion(fp.credits, p.fp.credits, skipMinCreditsSec, skipMaxCreditsSec) + if r == nil || r.Duration >= 0.97*skipCreditsWindow { + continue + } + segs = append(segs, mediainfo.SkipSegmentRange{ + Category: "credits", + StartSec: round1(fp.creditsStart + r.AStart), + EndSec: round1(fp.creditsStart + r.AEnd), + }) + break + } + return segs +} + +// detectMovieCredits finds end-credits in movies via sustained black-frame +// runs (classic credits roll on black). Single-file, no fingerprinting. +func detectMovieCredits(ctx context.Context, cache *LibraryCache, opts SkipDetectOptions, workers int, add func(LibraryItem, float64, []mediainfo.SkipSegmentRange)) (int, int) { + var movies []LibraryItem + for _, item := range cache.Items { + if item.Season > 0 || item.Episode > 0 || item.FilePath == "" { + continue + } + if itemDuration(item) < movieMinRuntimeSec { + continue + } + movies = append(movies, item) + } + + analyzed, fromCache := 0, 0 + var mu sync.Mutex + jobs := make(chan LibraryItem) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for it := range jobs { + if ctx.Err() != nil { + return + } + dur := itemDuration(it) + if sc, ok := mediainfo.ReadCachedSkipSegments(it.FilePath); ok { + mu.Lock() + analyzed++ + fromCache++ + mu.Unlock() + add(it, sc.DurationSec, sc.Segments) + continue + } + winStart := math.Max(0, dur-movieCreditsWindow) + jctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + times, err := mediainfo.DetectBlackFrameRuns(jctx, opts.FFmpegPath, it.FilePath, winStart, movieCreditsWindow, 85) + cancel() + if err != nil { + log.Printf("[skipdetect] blackframe failed (%q): %v", it.FilePath, err) + continue + } + segs := creditsFromBlackRuns(times, dur) + if werr := mediainfo.WriteCachedSkipSegments(it.FilePath, dur, segs); werr != nil { + log.Printf("[skipdetect] sidecar write skipped (%q): %v", it.FilePath, werr) + } + mu.Lock() + analyzed++ + mu.Unlock() + add(it, dur, segs) + } + }() + } + for _, it := range movies { + select { + case jobs <- it: + case <-ctx.Done(): + } + if ctx.Err() != nil { + break + } + } + close(jobs) + wg.Wait() + return analyzed, fromCache +} + +// creditsFromBlackRuns picks the credits start from black-frame timestamps: +// the longest run of black frames (gaps ≤30s between hits) that reaches the +// end of the file (within 90s — post-credits scenes break the run and are +// kept watchable). Requires ≥60s of credits to avoid fade-to-black scenes. +func creditsFromBlackRuns(times []float64, durationSec float64) []mediainfo.SkipSegmentRange { + if len(times) == 0 { + return nil + } + const maxGap = 30.0 + bestStart, bestEnd := -1.0, -1.0 + runStart := times[0] + prev := times[0] + flush := func(end float64) { + if end-runStart > bestEnd-bestStart { + bestStart, bestEnd = runStart, end + } + } + for _, t := range times[1:] { + if t-prev > maxGap { + flush(prev) + runStart = t + } + prev = t + } + flush(prev) + + if bestStart < 0 || bestEnd-bestStart < movieMinCreditsSec { + return nil + } + if durationSec-bestEnd > 90 { // run doesn't reach the end → mid-film scene + return nil + } + return []mediainfo.SkipSegmentRange{{Category: "credits", StartSec: round1(bestStart), EndSec: round1(durationSec)}} +} + +func round1(v float64) float64 { return math.Round(v*10) / 10 } diff --git a/internal/library/skipdetect_test.go b/internal/library/skipdetect_test.go new file mode 100644 index 0000000..8df7229 --- /dev/null +++ b/internal/library/skipdetect_test.go @@ -0,0 +1,70 @@ +package library + +import ( + "testing" + + "github.com/torrentclaw/unarr/internal/library/mediainfo" +) + +func TestCreditsFromBlackRuns_DetectsCreditsRoll(t *testing.T) { + // Movie of 7200s; credits-on-black from 6900 to the end, black frame every + // ~2s, plus a stray fade-to-black at 5000s that must not win. + var times []float64 + times = append(times, 5000, 5001) // short mid-film fade + for tt := 6900.0; tt <= 7195; tt += 2 { + times = append(times, tt) + } + segs := creditsFromBlackRuns(times, 7200) + if len(segs) != 1 { + t.Fatalf("expected 1 credits segment, got %d", len(segs)) + } + s := segs[0] + if s.Category != "credits" { + t.Errorf("category = %q, want credits", s.Category) + } + if s.StartSec < 6890 || s.StartSec > 6910 { + t.Errorf("StartSec = %.1f, want ≈ 6900", s.StartSec) + } + if s.EndSec != 7200 { + t.Errorf("EndSec = %.1f, want 7200 (file end)", s.EndSec) + } +} + +func TestCreditsFromBlackRuns_RejectsShortFade(t *testing.T) { + // Only a 20s black run near the end — too short to be credits. + var times []float64 + for tt := 7170.0; tt <= 7190; tt += 2 { + times = append(times, tt) + } + if segs := creditsFromBlackRuns(times, 7200); len(segs) != 0 { + t.Fatalf("expected no segments for a 20s fade, got %+v", segs) + } +} + +func TestCreditsFromBlackRuns_RejectsRunNotReachingEnd(t *testing.T) { + // 120s black run that ends 300s before the file end (a long mid-film + // montage on black) — must not be flagged as credits. + var times []float64 + for tt := 6700.0; tt <= 6820; tt += 2 { + times = append(times, tt) + } + if segs := creditsFromBlackRuns(times, 7200); len(segs) != 0 { + t.Fatalf("expected no segments when run stops mid-film, got %+v", segs) + } +} + +func TestDetectForEpisode_PrefersDifferentEpisodePartners(t *testing.T) { + // Sanity: an episode with no partners (all same episode number) yields nil. + it := LibraryItem{FilePath: "/a/e1.mkv", Episode: 1, Season: 1} + dup := LibraryItem{FilePath: "/a/e1-other-release.mkv", Episode: 1, Season: 1} + fps := map[string]*episodeFingerprints{ + it.FilePath: {duration: 1400, intro: []uint32{1, 2, 3}, credits: []uint32{4, 5, 6}}, + dup.FilePath: {duration: 1400, intro: []uint32{1, 2, 3}, credits: []uint32{4, 5, 6}}, + } + segs := detectForEpisode(it, fps[it.FilePath], []LibraryItem{it, dup}, fps) + if len(segs) != 0 { + t.Fatalf("expected no segments without a different-episode partner, got %+v", segs) + } +} + +var _ = mediainfo.SkipSegmentRange{} // keep import for future use