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