feat(library): detección de intro/créditos post-scan (skip segments)
Some checks failed
CI / Test (push) Failing after 6m18s
CI / Build (push) Successful in 1m32s
CI / Build-1 (push) Successful in 1m55s
CI / Build-2 (push) Successful in 1m33s
CI / Build-3 (push) Successful in 1m32s
CI / Build-4 (push) Successful in 1m35s
CI / Build-5 (push) Successful in 1m33s
CI / Lint (push) Failing after 2m50s
CI / Coverage (push) Successful in 2m58s
CI / Vet (push) Successful in 2m7s

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).
This commit is contained in:
Deivid Soto 2026-06-12 19:46:07 +02:00
parent 59da949a53
commit a710bc1626
11 changed files with 1223 additions and 5 deletions

View file

@ -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