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
|
|
@ -512,3 +512,14 @@ func (c *Client) handleResponse(resp *http.Response, dst any) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmitSkipSegments uploads detected intro/credits segments after a library
|
||||||
|
// scan. Must run AFTER SyncLibrary — the server resolves file paths against
|
||||||
|
// the freshly-synced library_item rows.
|
||||||
|
func (c *Client) SubmitSkipSegments(ctx context.Context, req SkipSegmentsRequest) (*SkipSegmentsResponse, error) {
|
||||||
|
var resp SkipSegmentsResponse
|
||||||
|
if err := c.doPostWith(ctx, c.librarySyncClient, "/api/internal/agent/skip-segments", req, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("skip segments: %w", err)
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -592,3 +592,38 @@ type WatchProgressUpdate struct {
|
||||||
type WatchProgressResponse struct {
|
type WatchProgressResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skip-segment types (intro/credits detection — see library/skipdetect.go)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SkipSegmentRange is one detected skippable range inside a media file.
|
||||||
|
type SkipSegmentRange struct {
|
||||||
|
Category string `json:"category"` // "intro" | "credits"
|
||||||
|
StartSec float64 `json:"startSec"`
|
||||||
|
EndSec float64 `json:"endSec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkipSegmentItem carries the detected segments of one library file. The
|
||||||
|
// server resolves FilePath against the user's library_item rows (synced just
|
||||||
|
// before) to attach the segments to a content identity.
|
||||||
|
type SkipSegmentItem struct {
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Season int `json:"season,omitempty"`
|
||||||
|
Episode int `json:"episode,omitempty"`
|
||||||
|
DurationSec float64 `json:"durationSec"`
|
||||||
|
Segments []SkipSegmentRange `json:"segments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkipSegmentsRequest submits detected skip segments after a library scan.
|
||||||
|
type SkipSegmentsRequest struct {
|
||||||
|
AgentID string `json:"agentId,omitempty"`
|
||||||
|
Items []SkipSegmentItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkipSegmentsResponse reports how many segments the server stored.
|
||||||
|
type SkipSegmentsResponse struct {
|
||||||
|
Stored int `json:"stored"`
|
||||||
|
Unmatched int `json:"unmatched"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1073,7 +1073,7 @@ func runDaemonStart() error {
|
||||||
// Start auto-scan goroutine
|
// Start auto-scan goroutine
|
||||||
scanPaths := daemonCfg.ScanPaths
|
scanPaths := daemonCfg.ScanPaths
|
||||||
if len(scanPaths) > 0 && cfg.Library.AutoScan {
|
if len(scanPaths) > 0 && cfg.Library.AutoScan {
|
||||||
scanInterval := 24 * time.Hour
|
scanInterval := time.Hour
|
||||||
if cfg.Library.ScanInterval != "" {
|
if cfg.Library.ScanInterval != "" {
|
||||||
if parsed, err := time.ParseDuration(cfg.Library.ScanInterval); err == nil && parsed > 0 {
|
if parsed, err := time.ParseDuration(cfg.Library.ScanInterval); err == nil && parsed > 0 {
|
||||||
scanInterval = parsed
|
scanInterval = parsed
|
||||||
|
|
@ -1487,6 +1487,9 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration,
|
||||||
if err := library.SaveCache(mergedCache); err != nil {
|
if err := library.SaveCache(mergedCache); err != nil {
|
||||||
log.Printf("[auto-scan] save cache failed: %v", err)
|
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))
|
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'")
|
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 items []agent.LibrarySyncItem
|
||||||
|
var caches []*library.LibraryCache
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
cache, err := runScan(ctx, cfg, p, workers, ffprobe)
|
cache, err := runScan(ctx, cfg, p, workers, ffprobe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
caches = append(caches, cache)
|
||||||
items = append(items, library.BuildSyncItems(cache)...)
|
items = append(items, library.BuildSyncItems(cache)...)
|
||||||
}
|
}
|
||||||
if noSync || jsonOut {
|
if noSync || jsonOut {
|
||||||
return nil
|
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)
|
cache, err := runScan(ctx, cfg, args[0], workers, ffprobe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -72,7 +82,13 @@ to see available quality upgrades.`,
|
||||||
if noSync || jsonOut {
|
if noSync || jsonOut {
|
||||||
return nil
|
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
|
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
|
// syncToServer uploads the scanned items of THIS invocation as one sync
|
||||||
// session. roots lists every root the invocation scanned; fullCycle marks a
|
// session. roots lists every root the invocation scanned; fullCycle marks a
|
||||||
// no-args run that covered all configured roots (the server may then reap
|
// 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)
|
||||||
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ type LibraryConfig struct {
|
||||||
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
|
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
|
||||||
BackupDir string `toml:"backup_dir"` // for replaced files
|
BackupDir string `toml:"backup_dir"` // for replaced files
|
||||||
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
|
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
|
||||||
ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
|
ScanInterval string `toml:"scan_interval"` // e.g. "1h", "6h", "24h" (default "1h", like Plex/Jellyfin periodic scans)
|
||||||
AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk
|
AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk
|
||||||
|
|
||||||
// Sidecar caching: extract text subtitles (WebVTT) and thumbnail frames once
|
// Sidecar caching: extract text subtitles (WebVTT) and thumbnail frames once
|
||||||
|
|
@ -204,6 +204,13 @@ type LibraryConfig struct {
|
||||||
CacheSubtitles bool `toml:"cache_subtitles"` // default true
|
CacheSubtitles bool `toml:"cache_subtitles"` // default true
|
||||||
CacheThumbnails bool `toml:"cache_thumbnails"` // default true
|
CacheThumbnails bool `toml:"cache_thumbnails"` // default true
|
||||||
|
|
||||||
|
// Skip-segment detection: after each scan, find intro/credits ranges by
|
||||||
|
// comparing chromaprint audio fingerprints between episodes of a season
|
||||||
|
// (plus black-frame credits for movies) and submit them to the web so the
|
||||||
|
// player can offer "Skip intro" / "Skip credits". Cached per file; only
|
||||||
|
// new files do work. Default true.
|
||||||
|
SkipDetect bool `toml:"skip_detect"`
|
||||||
|
|
||||||
// Trickplay: at scan time, build ONE montage JPEG of frames sampled every
|
// Trickplay: at scan time, build ONE montage JPEG of frames sampled every
|
||||||
// Interval seconds (+ a JSON manifest), cached in .unarr next to the media.
|
// Interval seconds (+ a JSON manifest), cached in .unarr next to the media.
|
||||||
// The web scrubber shows tiles from it — no live ffmpeg during playback, so
|
// The web scrubber shows tiles from it — no live ffmpeg during playback, so
|
||||||
|
|
@ -314,10 +321,11 @@ func Default() Config {
|
||||||
},
|
},
|
||||||
Library: LibraryConfig{
|
Library: LibraryConfig{
|
||||||
AutoScan: true,
|
AutoScan: true,
|
||||||
ScanInterval: "24h",
|
ScanInterval: "1h",
|
||||||
Workers: 8,
|
Workers: 8,
|
||||||
CacheSubtitles: true,
|
CacheSubtitles: true,
|
||||||
CacheThumbnails: true,
|
CacheThumbnails: true,
|
||||||
|
SkipDetect: true,
|
||||||
Trickplay: TrickplayConfig{
|
Trickplay: TrickplayConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Interval: "10s",
|
Interval: "10s",
|
||||||
|
|
@ -396,6 +404,9 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
|
||||||
if !meta.IsDefined("library", "cache_thumbnails") {
|
if !meta.IsDefined("library", "cache_thumbnails") {
|
||||||
cfg.Library.CacheThumbnails = true
|
cfg.Library.CacheThumbnails = true
|
||||||
}
|
}
|
||||||
|
if !meta.IsDefined("library", "skip_detect") {
|
||||||
|
cfg.Library.SkipDetect = true
|
||||||
|
}
|
||||||
// Trickplay defaults ON for configs predating these keys (small sidecar JPEG;
|
// Trickplay defaults ON for configs predating these keys (small sidecar JPEG;
|
||||||
// makes the scrubber instant + contention-free). Explicit `enabled = false`
|
// makes the scrubber instant + contention-free). Explicit `enabled = false`
|
||||||
// is respected via meta.IsDefined.
|
// is respected via meta.IsDefined.
|
||||||
|
|
|
||||||
279
internal/library/mediainfo/chromaprint.go
Normal file
279
internal/library/mediainfo/chromaprint.go
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
package mediainfo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/bits"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chromaprint-based shared-audio detection. Episodes of the same season share
|
||||||
|
// an identical intro (OP) and credits (ED) audio track; fingerprinting a window
|
||||||
|
// of each episode and finding the longest aligned low-hamming-distance region
|
||||||
|
// between two episodes localizes those segments. Clean-room implementation of
|
||||||
|
// the approach popularized by Jellyfin's Intro Skipper plugin.
|
||||||
|
//
|
||||||
|
// Fingerprint stream: chromaprint emits one uint32 per ~0.1238s of audio
|
||||||
|
// (11025 Hz mono, FFT 4096, 2/3 overlap → ~8.08 points/second).
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ChromaprintSampleDur is seconds of audio per fingerprint point.
|
||||||
|
ChromaprintSampleDur = 0.1238
|
||||||
|
// maxHammingBits: two points are "similar" when their XOR popcount is below this.
|
||||||
|
maxHammingBits = 6
|
||||||
|
// maxTimeSkipSec: gap tolerance when growing a contiguous similar region.
|
||||||
|
maxTimeSkipSec = 3.5
|
||||||
|
)
|
||||||
|
|
||||||
|
// SkipSegmentRange is one detected skippable range inside a media file.
|
||||||
|
type SkipSegmentRange struct {
|
||||||
|
Category string `json:"category"` // "intro" | "credits"
|
||||||
|
StartSec float64 `json:"startSec"`
|
||||||
|
EndSec float64 `json:"endSec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FingerprintAudioWindow decodes [startSec, startSec+lengthSec] of the first
|
||||||
|
// audio track with ffmpeg and pipes the WAV into fpcalc -raw, returning the
|
||||||
|
// chromaprint point stream.
|
||||||
|
func FingerprintAudioWindow(ctx context.Context, ffmpegPath, fpcalcPath, mediaPath string, startSec, lengthSec float64) ([]uint32, error) {
|
||||||
|
ff := exec.CommandContext(ctx, ffmpegPath,
|
||||||
|
"-nostdin", "-loglevel", "error",
|
||||||
|
"-ss", strconv.FormatFloat(startSec, 'f', 3, 64),
|
||||||
|
"-i", mediaPath,
|
||||||
|
"-t", strconv.FormatFloat(lengthSec, 'f', 3, 64),
|
||||||
|
"-map", "0:a:0",
|
||||||
|
"-ac", "2",
|
||||||
|
"-f", "wav", "-",
|
||||||
|
)
|
||||||
|
fp := exec.CommandContext(ctx, fpcalcPath,
|
||||||
|
"-raw", "-length", strconv.Itoa(int(lengthSec)), "-")
|
||||||
|
|
||||||
|
pipe, err := ff.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ffmpeg pipe: %w", err)
|
||||||
|
}
|
||||||
|
fp.Stdin = pipe
|
||||||
|
var ffErr strings.Builder
|
||||||
|
ff.Stderr = &ffErr
|
||||||
|
|
||||||
|
if err := ff.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ffmpeg start: %w", err)
|
||||||
|
}
|
||||||
|
out, err := fp.Output()
|
||||||
|
// fpcalc stops reading once it has processed -length seconds and may exit
|
||||||
|
// WITHOUT draining the last buffered bytes. Close our read end so ffmpeg
|
||||||
|
// gets EPIPE and exits — otherwise it blocks forever on a full pipe whose
|
||||||
|
// only remaining reader is us (caught live: 5-min ctx kills, per file).
|
||||||
|
_ = pipe.Close()
|
||||||
|
// Always reap ffmpeg; early pipe close makes it exit non-zero — fine as
|
||||||
|
// long as fpcalc produced output.
|
||||||
|
_ = ff.Wait()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fpcalc: %w (ffmpeg: %s)", err, strings.TrimSpace(ffErr.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "FINGERPRINT="); ok {
|
||||||
|
parts := strings.Split(rest, ",")
|
||||||
|
points := make([]uint32, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
// fpcalc may print signed ints; parse wide and truncate.
|
||||||
|
v, perr := strconv.ParseInt(strings.TrimSpace(p), 10, 64)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("fpcalc output parse: %w", perr)
|
||||||
|
}
|
||||||
|
points = append(points, uint32(v))
|
||||||
|
}
|
||||||
|
if len(points) == 0 {
|
||||||
|
return nil, fmt.Errorf("fpcalc produced an empty fingerprint")
|
||||||
|
}
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no FINGERPRINT line in fpcalc output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedRegion is the longest aligned similar-audio region between two
|
||||||
|
// fingerprint streams, in seconds relative to each stream's start.
|
||||||
|
type SharedRegion struct {
|
||||||
|
AStart, AEnd float64
|
||||||
|
BStart, BEnd float64
|
||||||
|
Duration float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSharedRegion locates the longest contiguous region (bounded by
|
||||||
|
// minDur/maxDur seconds) where streams a and b carry near-identical audio at
|
||||||
|
// some alignment. Returns nil when no qualifying region exists.
|
||||||
|
func FindSharedRegion(a, b []uint32, minDur, maxDur float64) *SharedRegion {
|
||||||
|
if len(a) == 0 || len(b) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Inverted index of b: point value → last index seen.
|
||||||
|
indexB := make(map[uint32]int, len(b))
|
||||||
|
for i, v := range b {
|
||||||
|
indexB[v] = i
|
||||||
|
}
|
||||||
|
// Candidate alignments: exact value matches (±2 on the value tolerates
|
||||||
|
// quantization noise between encodes).
|
||||||
|
shifts := make(map[int]struct{})
|
||||||
|
for i, v := range a {
|
||||||
|
for d := -2; d <= 2; d++ {
|
||||||
|
if j, ok := indexB[v+uint32(d)]; ok {
|
||||||
|
shifts[j-i] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
minPoints := int(minDur / ChromaprintSampleDur)
|
||||||
|
gapSec := float64(maxTimeSkipSec)
|
||||||
|
gapPoints := int(gapSec / ChromaprintSampleDur)
|
||||||
|
var best *SharedRegion
|
||||||
|
|
||||||
|
for shift := range shifts {
|
||||||
|
i0 := 0
|
||||||
|
if shift < 0 {
|
||||||
|
i0 = -shift
|
||||||
|
}
|
||||||
|
i1 := len(a)
|
||||||
|
if len(b)-shift < i1 {
|
||||||
|
i1 = len(b) - shift
|
||||||
|
}
|
||||||
|
if i1-i0 < minPoints {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runStart, prev := -1, -1
|
||||||
|
flush := func(end int) {
|
||||||
|
if runStart < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dur := float64(end-runStart) * ChromaprintSampleDur
|
||||||
|
if dur >= minDur && dur <= maxDur && (best == nil || dur > best.Duration) {
|
||||||
|
best = &SharedRegion{
|
||||||
|
AStart: float64(runStart) * ChromaprintSampleDur,
|
||||||
|
AEnd: float64(end) * ChromaprintSampleDur,
|
||||||
|
BStart: float64(runStart+shift) * ChromaprintSampleDur,
|
||||||
|
BEnd: float64(end+shift) * ChromaprintSampleDur,
|
||||||
|
Duration: dur,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := i0; i < i1; i++ {
|
||||||
|
if bits.OnesCount32(a[i]^b[i+shift]) > maxHammingBits {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prev >= 0 && i-prev > gapPoints {
|
||||||
|
flush(prev)
|
||||||
|
runStart = i
|
||||||
|
} else if runStart < 0 {
|
||||||
|
runStart = i
|
||||||
|
}
|
||||||
|
prev = i
|
||||||
|
}
|
||||||
|
flush(prev)
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Black-frame credits detection (movies: no sibling episode to compare) ---
|
||||||
|
|
||||||
|
var blackframeRe = regexp.MustCompile(`frame:\d+\s+pblack:\d+\s+pts:\d+\s+t:([\d.]+)`)
|
||||||
|
|
||||||
|
// DetectBlackFrameRuns scans [startSec, startSec+lengthSec] with ffmpeg's
|
||||||
|
// blackframe filter and returns the timestamps (absolute seconds) of frames
|
||||||
|
// that are ≥minBlackPct black. Used to find the start of end credits in movies
|
||||||
|
// (classic credits roll on black).
|
||||||
|
func DetectBlackFrameRuns(ctx context.Context, ffmpegPath, mediaPath string, startSec, lengthSec float64, minBlackPct int) ([]float64, error) {
|
||||||
|
// Keyframe-only decode: credits-on-black lasts minutes, so sampling one
|
||||||
|
// frame every keyframe interval (~2-10s) finds the run at ~2% of the cost
|
||||||
|
// of a full decode — the difference between seconds and minutes per 4K film.
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegPath,
|
||||||
|
"-nostdin", "-loglevel", "info",
|
||||||
|
"-skip_frame", "nokey",
|
||||||
|
"-ss", strconv.FormatFloat(startSec, 'f', 3, 64),
|
||||||
|
"-i", mediaPath,
|
||||||
|
"-t", strconv.FormatFloat(lengthSec, 'f', 3, 64),
|
||||||
|
"-an", "-sn",
|
||||||
|
"-vf", fmt.Sprintf("blackframe=amount=%d:threshold=32", minBlackPct),
|
||||||
|
"-f", "null", "-",
|
||||||
|
)
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ffmpeg blackframe start: %w", err)
|
||||||
|
}
|
||||||
|
var times []float64
|
||||||
|
sc := bufio.NewScanner(stderr)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
if m := blackframeRe.FindStringSubmatch(sc.Text()); m != nil {
|
||||||
|
if t, perr := strconv.ParseFloat(m[1], 64); perr == nil {
|
||||||
|
times = append(times, startSec+t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ffmpeg blackframe: %w", err)
|
||||||
|
}
|
||||||
|
return times, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sidecar cache for detected segments ---
|
||||||
|
|
||||||
|
// skipSegmentsSidecarVersion bumps when the detection algorithm changes enough
|
||||||
|
// that cached results should be recomputed.
|
||||||
|
const skipSegmentsSidecarVersion = 1
|
||||||
|
|
||||||
|
// SkipSegmentsSidecar is the cached detection result for one media file.
|
||||||
|
type SkipSegmentsSidecar struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
DurationSec float64 `json:"durationSec"`
|
||||||
|
Segments []SkipSegmentRange `json:"segments"` // empty = analyzed, nothing found
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipSegmentsCachePath(mediaPath string) string {
|
||||||
|
return filepath.Join(sidecarDir(mediaPath), filepath.Base(mediaPath)+".skipseg.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCachedSkipSegments returns the cached detection result for mediaPath if
|
||||||
|
// fresh (newer than the media file) and of the current algorithm version.
|
||||||
|
func ReadCachedSkipSegments(mediaPath string) (*SkipSegmentsSidecar, bool) {
|
||||||
|
p := skipSegmentsCachePath(mediaPath)
|
||||||
|
if !sidecarFresh(p, mediaPath) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var sc SkipSegmentsSidecar
|
||||||
|
if err := json.Unmarshal(data, &sc); err != nil || sc.Version != skipSegmentsSidecarVersion {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &sc, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteCachedSkipSegments persists a detection result next to the media file.
|
||||||
|
func WriteCachedSkipSegments(mediaPath string, durationSec float64, segs []SkipSegmentRange) error {
|
||||||
|
if segs == nil {
|
||||||
|
segs = []SkipSegmentRange{}
|
||||||
|
}
|
||||||
|
sc := SkipSegmentsSidecar{Version: skipSegmentsSidecarVersion, DurationSec: durationSec, Segments: segs}
|
||||||
|
data, err := json.Marshal(sc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir := sidecarDir(mediaPath)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(skipSegmentsCachePath(mediaPath), data, 0o644)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
148
internal/library/mediainfo/fpcalc.go
Normal file
148
internal/library/mediainfo/fpcalc.go
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
package mediainfo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fpcalc (chromaprint) powers skip-segment detection: the ffmpeg static builds
|
||||||
|
// we download from ffbinaries do NOT include the chromaprint muxer, so audio
|
||||||
|
// fingerprinting pipes decoded WAV from our ffmpeg into a standalone fpcalc
|
||||||
|
// binary. acoustid publishes small (~2MB) static builds per platform.
|
||||||
|
|
||||||
|
const fpcalcVersion = "1.6.0"
|
||||||
|
|
||||||
|
var fpcalcDLClient = &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
|
||||||
|
const maxFpcalcArchiveSize = 50 * 1024 * 1024 // 50MB
|
||||||
|
|
||||||
|
// fpcalcDownloadURL returns the release asset URL for the current platform,
|
||||||
|
// and whether the asset is a zip (Windows) instead of tar.gz.
|
||||||
|
func fpcalcDownloadURL() (url string, isZip bool, err error) {
|
||||||
|
base := fmt.Sprintf("https://github.com/acoustid/chromaprint/releases/download/v%s/chromaprint-fpcalc-%s-", fpcalcVersion, fpcalcVersion)
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "amd64":
|
||||||
|
return base + "linux-x86_64.tar.gz", false, nil
|
||||||
|
case "arm64":
|
||||||
|
return base + "linux-arm64.tar.gz", false, nil
|
||||||
|
}
|
||||||
|
case "darwin":
|
||||||
|
return base + "macos-universal.tar.gz", false, nil
|
||||||
|
case "windows":
|
||||||
|
if runtime.GOARCH == "amd64" {
|
||||||
|
return base + "windows-x86_64.zip", true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false, fmt.Errorf("no fpcalc build for platform %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FpcalcCachePath returns the cached fpcalc binary path (same bin dir as the
|
||||||
|
// downloaded ffmpeg/ffprobe).
|
||||||
|
func FpcalcCachePath() (string, error) {
|
||||||
|
dir, err := FFprobeCacheDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name := "fpcalc"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name = "fpcalc.exe"
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFpcalc finds a usable fpcalc binary: PATH → cache dir → download.
|
||||||
|
func ResolveFpcalc() (string, error) {
|
||||||
|
if p, err := exec.LookPath("fpcalc"); err == nil {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
dest, err := FpcalcCachePath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dest); err == nil {
|
||||||
|
return dest, nil
|
||||||
|
}
|
||||||
|
return downloadFpcalc(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFpcalc(dest string) (string, error) {
|
||||||
|
url, isZip, err := fpcalcDownloadURL()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "fpcalc not found — downloading chromaprint %s...\n", fpcalcVersion)
|
||||||
|
|
||||||
|
resp, err := fpcalcDLClient.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fpcalc download failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("fpcalc download failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxFpcalcArchiveSize))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fpcalc download read failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := "fpcalc"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name = "fpcalc.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
var binary []byte
|
||||||
|
if isZip {
|
||||||
|
binary, err = extractFromZip(data, name)
|
||||||
|
} else {
|
||||||
|
binary, err = extractFromTarGz(data, name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot create cache directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, binary, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot write fpcalc binary: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "fpcalc installed to %s\n", dest)
|
||||||
|
return dest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFromTarGz(data []byte, target string) ([]byte, error) {
|
||||||
|
gz, err := gzip.NewReader(strings.NewReader(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot open downloaded archive: %w", err)
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read archive: %w", err)
|
||||||
|
}
|
||||||
|
if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == target {
|
||||||
|
return io.ReadAll(io.LimitReader(tr, maxFpcalcArchiveSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s not found in downloaded archive", target)
|
||||||
|
}
|
||||||
420
internal/library/skipdetect.go
Normal file
420
internal/library/skipdetect.go
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/torrentclaw/unarr/internal/library/mediainfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skip-segment detection: find intro (OP) and credits (ED) ranges by comparing
|
||||||
|
// chromaprint audio fingerprints between episodes of the same season (episodes
|
||||||
|
// share identical intro/credits audio), plus black-frame credits detection for
|
||||||
|
// movies (no sibling to compare). Results are cached as ".unarr" sidecars and
|
||||||
|
// submitted to the web, which shares them across all users by content identity.
|
||||||
|
|
||||||
|
const (
|
||||||
|
skipMinIntroSec = 15
|
||||||
|
skipMaxIntroSec = 120
|
||||||
|
skipMinCreditsSec = 15
|
||||||
|
skipMaxCreditsSec = 450
|
||||||
|
skipCreditsWindow = 450 // episodes: fingerprint the last N seconds
|
||||||
|
skipIntroWindowCap = 600 // episodes: fingerprint at most the first N seconds
|
||||||
|
skipMinRuntimeSec = 300 // ignore shorts/extras
|
||||||
|
|
||||||
|
movieCreditsWindow = 900 // movies: black-frame scan over the last N seconds
|
||||||
|
movieMinCreditsSec = 60
|
||||||
|
movieMinRuntimeSec = 3600
|
||||||
|
)
|
||||||
|
|
||||||
|
// SkipDetectOptions configures DetectSkipSegments.
|
||||||
|
type SkipDetectOptions struct {
|
||||||
|
FFmpegPath string
|
||||||
|
FpcalcPath string // empty disables episode (chromaprint) detection
|
||||||
|
Workers int // concurrent ffmpeg+fpcalc jobs; default 2
|
||||||
|
Movies bool // also detect movie end credits via black frames
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkipDetection is the outcome for one media file (only files with ≥1 segment
|
||||||
|
// are returned).
|
||||||
|
type SkipDetection struct {
|
||||||
|
Item LibraryItem
|
||||||
|
DurationSec float64
|
||||||
|
Segments []mediainfo.SkipSegmentRange
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectSkipSegments analyzes the scanned library and returns every file with
|
||||||
|
// detected skippable segments. Idempotent and best-effort: fresh sidecar
|
||||||
|
// results are reused without re-analysis, errors skip the file, ctx cancels
|
||||||
|
// cleanly.
|
||||||
|
func DetectSkipSegments(ctx context.Context, cache *LibraryCache, opts SkipDetectOptions) []SkipDetection {
|
||||||
|
if cache == nil || opts.FFmpegPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
workers := opts.Workers
|
||||||
|
if workers < 1 {
|
||||||
|
workers = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []SkipDetection
|
||||||
|
var outMu sync.Mutex
|
||||||
|
add := func(item LibraryItem, dur float64, segs []mediainfo.SkipSegmentRange) {
|
||||||
|
if len(segs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outMu.Lock()
|
||||||
|
out = append(out, SkipDetection{Item: item, DurationSec: dur, Segments: segs})
|
||||||
|
outMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
analyzed, cached := 0, 0
|
||||||
|
|
||||||
|
if opts.FpcalcPath != "" {
|
||||||
|
a, c := detectEpisodeGroups(ctx, cache, opts, workers, add)
|
||||||
|
analyzed += a
|
||||||
|
cached += c
|
||||||
|
}
|
||||||
|
if opts.Movies {
|
||||||
|
a, c := detectMovieCredits(ctx, cache, opts, workers, add)
|
||||||
|
analyzed += a
|
||||||
|
cached += c
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[skipdetect] %d file(s) analyzed (%d from cache), %d with segments, in %s",
|
||||||
|
analyzed, cached, len(out), time.Since(start).Round(time.Second))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// seasonEpisodeMarker locates the SxxEyy token in a parsed title so the group
|
||||||
|
// key uses only the SHOW part. Parsed titles keep the episode name + release
|
||||||
|
// tags ("Show S01E09 Embrace and Whisper BILI WEB DL…"), which differ per
|
||||||
|
// file — grouping on the raw title would leave every episode alone.
|
||||||
|
var seasonEpisodeMarker = regexp.MustCompile(`(?i)\bS\d{1,2}\s*E\d{1,4}\b`)
|
||||||
|
|
||||||
|
// seasonGroupKey groups episodes that can share intro/credits audio: same
|
||||||
|
// directory + same show-title prefix + same season. The directory bound keeps
|
||||||
|
// flat mixed folders from exploding into one giant group; cross-show pairs
|
||||||
|
// inside a dir fail closed anyway (unrelated audio never matches).
|
||||||
|
func seasonGroupKey(item LibraryItem) string {
|
||||||
|
title := strings.ToLower(strings.TrimSpace(item.Title))
|
||||||
|
if loc := seasonEpisodeMarker.FindStringIndex(title); loc != nil {
|
||||||
|
title = strings.TrimSpace(title[:loc[0]])
|
||||||
|
}
|
||||||
|
return filepath.Dir(item.FilePath) + "|" + title + "|s" + strconv.Itoa(item.Season)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itemDuration(item LibraryItem) float64 {
|
||||||
|
if item.MediaInfo != nil && item.MediaInfo.Video != nil {
|
||||||
|
return item.MediaInfo.Video.Duration
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectEpisodeGroups runs chromaprint comparison inside (title, season)
|
||||||
|
// groups. Returns (analyzed, fromCache) counters.
|
||||||
|
func detectEpisodeGroups(ctx context.Context, cache *LibraryCache, opts SkipDetectOptions, workers int, add func(LibraryItem, float64, []mediainfo.SkipSegmentRange)) (int, int) {
|
||||||
|
groups := make(map[string][]LibraryItem)
|
||||||
|
for _, item := range cache.Items {
|
||||||
|
if item.Season <= 0 || item.Episode <= 0 || item.FilePath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if itemDuration(item) < skipMinRuntimeSec {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups[seasonGroupKey(item)] = append(groups[seasonGroupKey(item)], item)
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzed, fromCache := 0, 0
|
||||||
|
for _, items := range groups {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Distinct episode numbers — two releases of the same episode carry
|
||||||
|
// identical full audio (a comparison would match the whole window).
|
||||||
|
eps := make(map[int]struct{})
|
||||||
|
for _, it := range items {
|
||||||
|
eps[it.Episode] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(eps) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached results short-circuit the whole group when complete.
|
||||||
|
needCompute := false
|
||||||
|
cachedSegs := make(map[string]*mediainfo.SkipSegmentsSidecar, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
if sc, ok := mediainfo.ReadCachedSkipSegments(it.FilePath); ok {
|
||||||
|
cachedSegs[it.FilePath] = sc
|
||||||
|
} else {
|
||||||
|
needCompute = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !needCompute {
|
||||||
|
for _, it := range items {
|
||||||
|
sc := cachedSegs[it.FilePath]
|
||||||
|
analyzed++
|
||||||
|
fromCache++
|
||||||
|
add(it, sc.DurationSec, sc.Segments)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprint every episode in the group (intro + credits windows).
|
||||||
|
fps := fingerprintGroup(ctx, items, opts, workers)
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool { return items[i].Episode < items[j].Episode })
|
||||||
|
for _, it := range items {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
analyzed++
|
||||||
|
if sc, ok := cachedSegs[it.FilePath]; ok {
|
||||||
|
fromCache++
|
||||||
|
add(it, sc.DurationSec, sc.Segments)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fp := fps[it.FilePath]
|
||||||
|
if fp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segs := detectForEpisode(it, fp, items, fps)
|
||||||
|
if err := mediainfo.WriteCachedSkipSegments(it.FilePath, fp.duration, segs); err != nil {
|
||||||
|
log.Printf("[skipdetect] sidecar write skipped (%q): %v", it.FilePath, err)
|
||||||
|
}
|
||||||
|
add(it, fp.duration, segs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return analyzed, fromCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// episodeFingerprints holds the two fingerprinted windows of one file.
|
||||||
|
type episodeFingerprints struct {
|
||||||
|
duration float64
|
||||||
|
intro []uint32
|
||||||
|
credits []uint32
|
||||||
|
creditsStart float64 // absolute offset of the credits window
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintGroup(ctx context.Context, items []LibraryItem, opts SkipDetectOptions, workers int) map[string]*episodeFingerprints {
|
||||||
|
fps := make(map[string]*episodeFingerprints, len(items))
|
||||||
|
var mu sync.Mutex
|
||||||
|
jobs := make(chan LibraryItem)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for it := range jobs {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dur := itemDuration(it)
|
||||||
|
introWin := math.Min(0.25*dur, skipIntroWindowCap)
|
||||||
|
credStart := math.Max(0, dur-skipCreditsWindow)
|
||||||
|
jctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||||
|
intro, err1 := mediainfo.FingerprintAudioWindow(jctx, opts.FFmpegPath, opts.FpcalcPath, it.FilePath, 0, introWin)
|
||||||
|
credits, err2 := mediainfo.FingerprintAudioWindow(jctx, opts.FFmpegPath, opts.FpcalcPath, it.FilePath, credStart, skipCreditsWindow)
|
||||||
|
cancel()
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
if err1 != nil {
|
||||||
|
log.Printf("[skipdetect] fingerprint failed (%q): %v", it.FilePath, err1)
|
||||||
|
} else {
|
||||||
|
log.Printf("[skipdetect] fingerprint failed (%q): %v", it.FilePath, err2)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
fps[it.FilePath] = &episodeFingerprints{duration: dur, intro: intro, credits: credits, creditsStart: credStart}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
// Skip already-cached files only if every OTHER episode can still find
|
||||||
|
// partners — fingerprinting cached files too keeps them available as
|
||||||
|
// comparison partners for the new ones, so always fingerprint.
|
||||||
|
select {
|
||||||
|
case jobs <- it:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
wg.Wait()
|
||||||
|
return fps
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectForEpisode compares one episode against partners (nearest different
|
||||||
|
// episode numbers first, up to 3) and returns its detected segments.
|
||||||
|
func detectForEpisode(it LibraryItem, fp *episodeFingerprints, items []LibraryItem, fps map[string]*episodeFingerprints) []mediainfo.SkipSegmentRange {
|
||||||
|
type partner struct {
|
||||||
|
fp *episodeFingerprints
|
||||||
|
dist int
|
||||||
|
}
|
||||||
|
var partners []partner
|
||||||
|
for _, other := range items {
|
||||||
|
if other.FilePath == it.FilePath || other.Episode == it.Episode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ofp := fps[other.FilePath]
|
||||||
|
if ofp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := other.Episode - it.Episode
|
||||||
|
if d < 0 {
|
||||||
|
d = -d
|
||||||
|
}
|
||||||
|
partners = append(partners, partner{fp: ofp, dist: d})
|
||||||
|
}
|
||||||
|
sort.Slice(partners, func(i, j int) bool { return partners[i].dist < partners[j].dist })
|
||||||
|
if len(partners) > 3 {
|
||||||
|
partners = partners[:3]
|
||||||
|
}
|
||||||
|
|
||||||
|
segs := make([]mediainfo.SkipSegmentRange, 0, 2)
|
||||||
|
|
||||||
|
for _, p := range partners {
|
||||||
|
r := mediainfo.FindSharedRegion(fp.intro, p.fp.intro, skipMinIntroSec, skipMaxIntroSec)
|
||||||
|
if r == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, end := r.AStart, r.AEnd
|
||||||
|
if start <= 5 { // OP at the head — snap to the very start
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
segs = append(segs, mediainfo.SkipSegmentRange{Category: "intro", StartSec: round1(start), EndSec: round1(end)})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range partners {
|
||||||
|
// A near-full-window match means the two files share ALL audio (same
|
||||||
|
// episode content) — not a credits segment.
|
||||||
|
r := mediainfo.FindSharedRegion(fp.credits, p.fp.credits, skipMinCreditsSec, skipMaxCreditsSec)
|
||||||
|
if r == nil || r.Duration >= 0.97*skipCreditsWindow {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segs = append(segs, mediainfo.SkipSegmentRange{
|
||||||
|
Category: "credits",
|
||||||
|
StartSec: round1(fp.creditsStart + r.AStart),
|
||||||
|
EndSec: round1(fp.creditsStart + r.AEnd),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectMovieCredits finds end-credits in movies via sustained black-frame
|
||||||
|
// runs (classic credits roll on black). Single-file, no fingerprinting.
|
||||||
|
func detectMovieCredits(ctx context.Context, cache *LibraryCache, opts SkipDetectOptions, workers int, add func(LibraryItem, float64, []mediainfo.SkipSegmentRange)) (int, int) {
|
||||||
|
var movies []LibraryItem
|
||||||
|
for _, item := range cache.Items {
|
||||||
|
if item.Season > 0 || item.Episode > 0 || item.FilePath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if itemDuration(item) < movieMinRuntimeSec {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
movies = append(movies, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzed, fromCache := 0, 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
jobs := make(chan LibraryItem)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for it := range jobs {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dur := itemDuration(it)
|
||||||
|
if sc, ok := mediainfo.ReadCachedSkipSegments(it.FilePath); ok {
|
||||||
|
mu.Lock()
|
||||||
|
analyzed++
|
||||||
|
fromCache++
|
||||||
|
mu.Unlock()
|
||||||
|
add(it, sc.DurationSec, sc.Segments)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
winStart := math.Max(0, dur-movieCreditsWindow)
|
||||||
|
jctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
|
times, err := mediainfo.DetectBlackFrameRuns(jctx, opts.FFmpegPath, it.FilePath, winStart, movieCreditsWindow, 85)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[skipdetect] blackframe failed (%q): %v", it.FilePath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segs := creditsFromBlackRuns(times, dur)
|
||||||
|
if werr := mediainfo.WriteCachedSkipSegments(it.FilePath, dur, segs); werr != nil {
|
||||||
|
log.Printf("[skipdetect] sidecar write skipped (%q): %v", it.FilePath, werr)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
analyzed++
|
||||||
|
mu.Unlock()
|
||||||
|
add(it, dur, segs)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for _, it := range movies {
|
||||||
|
select {
|
||||||
|
case jobs <- it:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
wg.Wait()
|
||||||
|
return analyzed, fromCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// creditsFromBlackRuns picks the credits start from black-frame timestamps:
|
||||||
|
// the longest run of black frames (gaps ≤30s between hits) that reaches the
|
||||||
|
// end of the file (within 90s — post-credits scenes break the run and are
|
||||||
|
// kept watchable). Requires ≥60s of credits to avoid fade-to-black scenes.
|
||||||
|
func creditsFromBlackRuns(times []float64, durationSec float64) []mediainfo.SkipSegmentRange {
|
||||||
|
if len(times) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
const maxGap = 30.0
|
||||||
|
bestStart, bestEnd := -1.0, -1.0
|
||||||
|
runStart := times[0]
|
||||||
|
prev := times[0]
|
||||||
|
flush := func(end float64) {
|
||||||
|
if end-runStart > bestEnd-bestStart {
|
||||||
|
bestStart, bestEnd = runStart, end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range times[1:] {
|
||||||
|
if t-prev > maxGap {
|
||||||
|
flush(prev)
|
||||||
|
runStart = t
|
||||||
|
}
|
||||||
|
prev = t
|
||||||
|
}
|
||||||
|
flush(prev)
|
||||||
|
|
||||||
|
if bestStart < 0 || bestEnd-bestStart < movieMinCreditsSec {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if durationSec-bestEnd > 90 { // run doesn't reach the end → mid-film scene
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []mediainfo.SkipSegmentRange{{Category: "credits", StartSec: round1(bestStart), EndSec: round1(durationSec)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func round1(v float64) float64 { return math.Round(v*10) / 10 }
|
||||||
70
internal/library/skipdetect_test.go
Normal file
70
internal/library/skipdetect_test.go
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue