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
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -53,17 +53,27 @@ to see available quality upgrades.`,
|
|||
return fmt.Errorf("usage: unarr scan <path>\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
|
||||
|
|
|
|||
91
internal/cmd/skipdetect.go
Normal file
91
internal/cmd/skipdetect.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue