unarr/internal/library/mediainfo/chromaprint_test.go
Deivid Soto a710bc1626
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
feat(library): detección de intro/créditos post-scan (skip segments)
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).
2026-06-12 19:46:07 +02:00

121 lines
3.1 KiB
Go

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)
}
}