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
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:
parent
59da949a53
commit
a710bc1626
11 changed files with 1223 additions and 5 deletions
121
internal/library/mediainfo/chromaprint_test.go
Normal file
121
internal/library/mediainfo/chromaprint_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue