From e8cadc3d4136f3b3ce7f9978e07a495409ff0fc8 Mon Sep 17 00:00:00 2001 From: Fedor S <55900130+fed1337@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:15:32 +0300 Subject: [PATCH] feat: audio fingerprint skip-intro engine via ffmpeg chromaprint --- cmd/server/main.go | 14 + go.mod | 4 +- go.sum | 4 + .../migrations/00022_skip_intro_segments.sql | 25 ++ internal/httpapi/config.go | 2 + internal/httpapi/play_handlers.go | 25 +- internal/httpapi/routes.go | 3 + internal/maintenance/runner.go | 17 + internal/maintenance/runner_test.go | 14 + internal/maintenance/service.go | 7 + internal/maintenance/service_test.go | 2 +- internal/maintenance/types.go | 5 +- internal/maintenance/types_test.go | 1 + internal/skipsegment/chapters.go | 25 +- internal/skipsegment/consensus.go | 151 +++++++ internal/skipsegment/detect.go | 423 ++++++++++++++++++ internal/skipsegment/detect_test.go | 277 ++++++++++++ internal/skipsegment/fingerprint.go | 95 ++++ internal/skipsegment/match.go | 192 ++++++++ internal/skipsegment/match_test.go | 122 +++++ internal/skipsegment/merge.go | 13 + internal/skipsegment/postgres/models.go | 22 + internal/skipsegment/postgres/store.go | 119 +++++ internal/skipsegment/postgres/store_test.go | 156 +++++++ internal/skipsegment/types.go | 50 ++- openapi/swagger.json | 2 +- openapi/swagger.yaml | 4 +- web/package.json | 6 +- web/pnpm-lock.yaml | 82 ++-- web/src/locales/en/translation.json | 2 + web/src/locales/ru/translation.json | 2 + web/src/pages/admin/libraries/detail.tsx | 37 ++ 32 files changed, 1849 insertions(+), 54 deletions(-) create mode 100644 internal/db/migrations/00022_skip_intro_segments.sql create mode 100644 internal/skipsegment/consensus.go create mode 100644 internal/skipsegment/detect.go create mode 100644 internal/skipsegment/detect_test.go create mode 100644 internal/skipsegment/fingerprint.go create mode 100644 internal/skipsegment/match.go create mode 100644 internal/skipsegment/match_test.go create mode 100644 internal/skipsegment/merge.go create mode 100644 internal/skipsegment/postgres/models.go create mode 100644 internal/skipsegment/postgres/store.go create mode 100644 internal/skipsegment/postgres/store_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 247ab98..1d80f52 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -33,6 +33,7 @@ import ( "sudoStream/internal/provider/tmdb" "sudoStream/internal/provider/tvdb" "sudoStream/internal/provider/tvmaze" + "sudoStream/internal/skipsegment" "sudoStream/internal/transcode" "sudoStream/internal/trash" "sudoStream/internal/version" @@ -47,6 +48,7 @@ import ( anilist "sudoStream/internal/provider/anilist" opensubtitles "sudoStream/internal/provider/opensubtitles" providerpostgres "sudoStream/internal/provider/postgres" + skipsegmentpostgres "sudoStream/internal/skipsegment/postgres" trashpostgres "sudoStream/internal/trash/postgres" watchpostgres "sudoStream/internal/watch/postgres" @@ -80,6 +82,7 @@ type authBundle struct { networkSettings *network.KVSettingsStore networkLive *network.Live trash *trash.Service + skipIntro *skipsegment.Service dlnaSettings *dlna.KVSettingsStore dlnaController *dlna.Controller database *db.Database @@ -157,6 +160,7 @@ func main() { //nolint:funlen // composition root wiring Favorite: authBundle.favorite, HomeShelf: authBundle.homeShelf, Trash: authBundle.trash, + SkipIntro: authBundle.skipIntro, DLNASettings: authBundle.dlnaSettings, DLNAController: authBundle.dlnaController, }, @@ -286,6 +290,12 @@ func initAuthServices( //nolint:funlen // composition root: DB + services wiring pool.GORM, authStore, ) + skipIntroService := skipsegment.NewService( + skipsegmentpostgres.NewStore(pool.GORM), + fsService, + accessService, + metadataService, + ) maintenanceService := maintenance.NewService( maintenancepostgres.NewStore(pool.GORM), maintenance.Deps{ @@ -294,6 +304,7 @@ func initAuthServices( //nolint:funlen // composition root: DB + services wiring Indexer: indexer, Trash: trashService, Providers: providerEnricher, + SkipIntro: skipIntroService, }, ) @@ -317,6 +328,7 @@ func initAuthServices( //nolint:funlen // composition root: DB + services wiring networkSettings, networkLive, trashService, + skipIntroService, pool, ), nil } @@ -336,6 +348,7 @@ func newAuthBundle( networkSettings *network.KVSettingsStore, networkLive *network.Live, trashService *trash.Service, + skipIntroService *skipsegment.Service, pool *db.Database, ) *authBundle { return &authBundle{ @@ -353,6 +366,7 @@ func newAuthBundle( networkSettings: networkSettings, networkLive: networkLive, trash: trashService, + skipIntro: skipIntroService, database: pool, } } diff --git a/go.mod b/go.mod index 6f8e3b9..038544f 100644 --- a/go.mod +++ b/go.mod @@ -33,11 +33,11 @@ require ( github.com/cloudwego/base64x v0.1.7 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/gin-contrib/sse v1.1.2 // indirect - github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/locales v0.14.2 // indirect github.com/go-playground/universal-translator v0.18.2 // indirect github.com/go-playground/validator/v10 v10.30.5 // indirect github.com/go-sql-driver/mysql v1.10.1 // indirect - github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-json v0.11.1 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index 40555d1..d820ac6 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,8 @@ github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lY github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY= +github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU= @@ -86,6 +88,8 @@ github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9 github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.11.1 h1:4FEh3QBVpTCIvrCDucNJU2LZYUM9sxxW5O0UuUhxumk= +github.com/goccy/go-json v0.11.1/go.mod h1:z7UbbpDz59QAZPnhVSNOjPyprGnfWu/gT3J3EpeLXGU= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= diff --git a/internal/db/migrations/00022_skip_intro_segments.sql b/internal/db/migrations/00022_skip_intro_segments.sql new file mode 100644 index 0000000..97d8275 --- /dev/null +++ b/internal/db/migrations/00022_skip_intro_segments.sql @@ -0,0 +1,25 @@ +-- +goose Up +CREATE TABLE skip_intro_segments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + library_id UUID NOT NULL REFERENCES libraries (id) ON DELETE CASCADE, + rel_path TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'intro', + start_ms BIGINT NOT NULL, + end_ms BIGINT NOT NULL, + source TEXT NOT NULL, + confidence DOUBLE PRECISION NOT NULL DEFAULT 0, + engine_version INT NOT NULL DEFAULT 1, + show_key TEXT NOT NULL DEFAULT '', + season INT NOT NULL DEFAULT 0, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT skip_intro_segments_range_chk CHECK (end_ms > start_ms) +); + +CREATE UNIQUE INDEX skip_intro_segments_library_path_kind_uidx + ON skip_intro_segments (library_id, rel_path, kind); + +CREATE INDEX skip_intro_segments_show_season_idx + ON skip_intro_segments (library_id, show_key, season); + +-- +goose Down +DROP TABLE IF EXISTS skip_intro_segments; diff --git a/internal/httpapi/config.go b/internal/httpapi/config.go index 9b9b309..0b84fd4 100644 --- a/internal/httpapi/config.go +++ b/internal/httpapi/config.go @@ -8,6 +8,7 @@ import ( "sudoStream/internal/maintenance" "sudoStream/internal/network" "sudoStream/internal/provider" + "sudoStream/internal/skipsegment" "sudoStream/internal/transcode" "sudoStream/internal/trash" "sudoStream/internal/usersub" @@ -29,6 +30,7 @@ type RouteConfig struct { HomeShelf *homeshelf.Service Trash *trash.Service UserSubtitle *usersub.Service + SkipIntro *skipsegment.Service DLNASettings dlna.SettingsStore DLNAController *dlna.Controller } diff --git a/internal/httpapi/play_handlers.go b/internal/httpapi/play_handlers.go index 5c2d663..03507ab 100644 --- a/internal/httpapi/play_handlers.go +++ b/internal/httpapi/play_handlers.go @@ -45,7 +45,7 @@ type PlaybackResponse struct { UserSubtitle *UserSubtitleTrack `json:"userSubtitle,omitempty"` // Chapters are container chapter atoms on any video file (not limited to film/series). Chapters []transcode.ChapterInfo `json:"chapters,omitempty"` - // SkipIntro is set when chapter metadata identifies an opening intro segment (E-23). + // SkipIntro is set when chapter metadata or offline audio detection finds an opening intro (E-23). SkipIntro *skipsegment.Intro `json:"skipIntro,omitempty"` // Series is set only for paths in a series library with resolvable S/E identity. Series *PlaybackSeriesContext `json:"series,omitempty"` @@ -479,7 +479,7 @@ func (h *handler) respondHLSPlayback( ProviderSubtitleTracks: h.providerSubtitleTracks(c.Request.Context(), mediaPath), UserSubtitle: h.userSubtitleTrack(c, mediaPath), Chapters: chapters, - SkipIntro: skipIntroFromChapters(chapters, playbackInfo.DurationSeconds), + SkipIntro: h.resolveSkipIntro(c, mediaPath, chapters, playbackInfo.DurationSeconds), Series: h.playbackSeriesContext(c, mediaPath), }) } @@ -540,11 +540,30 @@ func (h *handler) respondDirectPlay( ProviderSubtitleTracks: h.providerSubtitleTracks(c.Request.Context(), mediaPath), UserSubtitle: h.userSubtitleTrack(c, mediaPath), Chapters: chapters, - SkipIntro: skipIntroFromChapters(chapters, caps.DurationSeconds), + SkipIntro: h.resolveSkipIntro(c, mediaPath, chapters, caps.DurationSeconds), Series: h.playbackSeriesContext(c, mediaPath), }) } +func (h *handler) resolveSkipIntro( + c *gin.Context, + mediaPath string, + chapters []transcode.ChapterInfo, + durationSeconds float64, +) *skipsegment.Intro { + chapterIntro := skipIntroFromChapters(chapters, durationSeconds) + var stored *skipsegment.Intro + if h.skipIntro != nil && h.access != nil { + library, matched, err := h.access.LibraryForRelPath(c.Request.Context(), mediaPath) + if err == nil && matched { + intro := h.skipIntro.LookupIntro(c.Request.Context(), library.ID, mediaPath) + stored = intro + } + } + + return skipsegment.ResolveIntro(chapterIntro, stored) +} + func skipIntroFromChapters(chapters []transcode.ChapterInfo, durationSeconds float64) *skipsegment.Intro { if len(chapters) == 0 || durationSeconds <= 0 { return nil diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index b6c3f14..f803c96 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -22,6 +22,7 @@ import ( "sudoStream/internal/metadata" "sudoStream/internal/observability" "sudoStream/internal/playback" + "sudoStream/internal/skipsegment" "sudoStream/internal/thumbnail" "sudoStream/internal/transcode" "sudoStream/internal/trash" @@ -60,6 +61,7 @@ type handler struct { transcode *transcode.Service trash *trash.Service usersub *usersub.Service + skipIntro *skipsegment.Service posters *posterCache dbPing func(context.Context) error } @@ -119,6 +121,7 @@ func RegisterRoutes( transcode: transcodeSvc, trash: cfg.Trash, usersub: cfg.UserSubtitle, + skipIntro: cfg.SkipIntro, dbPing: cfg.DBPing, } if mediaHandler.usersub == nil { diff --git a/internal/maintenance/runner.go b/internal/maintenance/runner.go index 6a5e7ee..5f00b4f 100644 --- a/internal/maintenance/runner.go +++ b/internal/maintenance/runner.go @@ -16,8 +16,10 @@ var ( errPurgerUnavailable = errors.New("cache purger unavailable") errTrashUnavailable = errors.New("trash service unavailable") errProvidersUnavailable = errors.New("provider settings service unavailable") + errSkipIntroUnavailable = errors.New("skip intro detector unavailable") ) +//nolint:cyclop // dispatch table for all maintenance actions func (s *Service) runAction( ctx context.Context, action, libraryID string, @@ -39,6 +41,8 @@ func (s *Service) runAction( return s.runProviderTask(ctx, libraryID, provider.TaskPoster) case ActionProvidersSubtitles: return s.runProviderTask(ctx, libraryID, provider.TaskSubtitle) + case ActionPlaybackSkipDetect: + return s.runSkipIntroDetect(ctx, libraryID) default: return nil, ErrInvalidAction } @@ -158,3 +162,16 @@ func (s *Service) runProviderTask( return summary.Map(), nil } + +func (s *Service) runSkipIntroDetect(ctx context.Context, libraryID string) (map[string]any, error) { + if s.deps.SkipIntro == nil { + return nil, errSkipIntroUnavailable + } + + summary, err := s.deps.SkipIntro.DetectLibraryMap(ctx, libraryID) + if err != nil { + return summary, fmt.Errorf("skip intro detect: %w", err) + } + + return summary, nil +} diff --git a/internal/maintenance/runner_test.go b/internal/maintenance/runner_test.go index a1a12bb..20f203e 100644 --- a/internal/maintenance/runner_test.go +++ b/internal/maintenance/runner_test.go @@ -81,6 +81,15 @@ func (s stubTrash) PurgeExpired(_ context.Context) (int, error) { return s.purged, s.err } +type stubSkipIntro struct { + summary map[string]any + err error +} + +func (s stubSkipIntro) DetectLibraryMap(_ context.Context, _ string) (map[string]any, error) { + return s.summary, s.err +} + type stubProviderTasks struct { summary provider.RunSummary err error @@ -174,6 +183,11 @@ func TestRunAction_CoversRunners(t *testing.T) { assertRunSummary(ctx, t, providerSvc, action, lib.ID, "applied", 7) } + skipSvc := NewService(store, Deps{ + SkipIntro: stubSkipIntro{summary: map[string]any{"audioHits": 4}}, + }) + assertRunSummary(ctx, t, skipSvc, ActionPlaybackSkipDetect, lib.ID, "audioHits", 4) + _, err = svc.runAction(ctx, "unknown", "") if !errors.Is(err, ErrInvalidAction) { t.Fatalf("want ErrInvalidAction, got %v", err) diff --git a/internal/maintenance/service.go b/internal/maintenance/service.go index be2c0b3..0c642e3 100644 --- a/internal/maintenance/service.go +++ b/internal/maintenance/service.go @@ -57,6 +57,12 @@ type Deps struct { Purger CachePurger Trash TrashPurger Providers ProviderTasks + SkipIntro SkipIntroDetector +} + +// SkipIntroDetector runs offline intro detection for one library (E-23). +type SkipIntroDetector interface { + DetectLibraryMap(ctx context.Context, libraryID string) (map[string]any, error) } // TrashPurger permanently deletes expired recycle-bin items. @@ -220,6 +226,7 @@ func (s *Service) GetLibraryMaintenance( return s.actionStatuses(ctx, []string{ ActionMetadataScan, ActionThumbnailsWarm, ActionProvidersMetadata, ActionProvidersPosters, ActionProvidersSubtitles, + ActionPlaybackSkipDetect, }, &libraryID) } diff --git a/internal/maintenance/service_test.go b/internal/maintenance/service_test.go index 7fff638..38c91e5 100644 --- a/internal/maintenance/service_test.go +++ b/internal/maintenance/service_test.go @@ -304,7 +304,7 @@ func TestService_PatchAndStatus( if err != nil { t.Fatalf("library status: %v", err) } - if len(libStatus) != 5 { + if len(libStatus) != 6 { t.Fatalf("library actions=%d", len(libStatus)) } diff --git a/internal/maintenance/types.go b/internal/maintenance/types.go index fd487b7..1e15b67 100644 --- a/internal/maintenance/types.go +++ b/internal/maintenance/types.go @@ -21,6 +21,8 @@ const ( ActionProvidersPosters = "providers.posters" // ActionProvidersSubtitles runs the FI-1 subtitle provider task for a library. ActionProvidersSubtitles = "providers.subtitles" + // ActionPlaybackSkipDetect runs offline intro audio fingerprinting (E-23 v2). + ActionPlaybackSkipDetect = "playback.skip.detect" ) // Trigger identifies how a run was started. @@ -117,7 +119,8 @@ func IsGlobalAction(action string) bool { func IsLibraryAction(action string) bool { switch action { case ActionMetadataScan, ActionThumbnailsWarm, - ActionProvidersMetadata, ActionProvidersPosters, ActionProvidersSubtitles: + ActionProvidersMetadata, ActionProvidersPosters, ActionProvidersSubtitles, + ActionPlaybackSkipDetect: return true default: return false diff --git a/internal/maintenance/types_test.go b/internal/maintenance/types_test.go index abb1e61..0cae6ab 100644 --- a/internal/maintenance/types_test.go +++ b/internal/maintenance/types_test.go @@ -40,6 +40,7 @@ func TestLockKeyAndActionHelpers(t *testing.T) { } for _, action := range []string{ ActionProvidersMetadata, ActionProvidersPosters, ActionProvidersSubtitles, + ActionPlaybackSkipDetect, } { if !IsLibraryAction(action) || IsGlobalAction(action) { t.Fatalf("%s scope", action) diff --git a/internal/skipsegment/chapters.go b/internal/skipsegment/chapters.go index 01c7196..8c1e49d 100644 --- a/internal/skipsegment/chapters.go +++ b/internal/skipsegment/chapters.go @@ -11,7 +11,22 @@ const ( maxIntroSec = 150 maxScanWindowSec = 600 // 10 minutes maxScanRuntimeRatio = 0.30 + maxStartSkewSec = 180 // ±3 minutes across episodes + minSeasonAgreement = 0.50 msPerSecond = 1000 + + // Chromaprint hop for ffmpeg -algorithm 1 (AcoustID default / TEST2). + // frame 4096 with 2/3 overlap at 11025 Hz. + chromaprintFrameSamples = 4096.0 + chromaprintOverlapDiv = 3.0 + chromaprintSampleHz = 11025.0 + chromaprintItemDurationSec = chromaprintFrameSamples / (chromaprintOverlapDiv * chromaprintSampleHz) + + // bitMatchThreshold is the minimum local bit-agreement for a match run. + // Random unrelated uint32 pairs average ~0.5; keep clear margin above that. + bitMatchThreshold = 0.70 + // localWindowItems smooths per-item scores before run detection. + localWindowItems = 5 ) var ( @@ -73,7 +88,11 @@ func IntroFromChapters(chapters []ChapterCue, durationSeconds float64) *Intro { return nil } -func introScanWindowSec(durationSeconds float64) float64 { +// IntroScanWindowSec returns the audio/chapter scan cap for one episode. +func IntroScanWindowSec(durationSeconds float64) float64 { + if durationSeconds <= 0 { + return 0 + } windowSec := durationSeconds * maxScanRuntimeRatio if windowSec > maxScanWindowSec { windowSec = maxScanWindowSec @@ -82,6 +101,10 @@ func introScanWindowSec(durationSeconds float64) float64 { return windowSec } +func introScanWindowSec(durationSeconds float64) float64 { + return IntroScanWindowSec(durationSeconds) +} + func titleMatchesIntro(title string) bool { title = strings.TrimSpace(title) if title == "" { diff --git a/internal/skipsegment/consensus.go b/internal/skipsegment/consensus.go new file mode 100644 index 0000000..a127682 --- /dev/null +++ b/internal/skipsegment/consensus.go @@ -0,0 +1,151 @@ +package skipsegment + +import ( + "math" + "slices" + "sort" +) + +const minEpisodesForAudio = 2 + +// ConsensusHit is one episode's intro range derived from season audio matching. +type ConsensusHit struct { + RelPath string + StartMs int64 + EndMs int64 + Confidence float64 +} + +type introVote struct { + relPath string + startMs int64 + endMs int64 + score float64 +} + +// ConsensusIntros clusters pairwise fingerprint matches into per-episode intro +// ranges. Requires ≥2 episodes and ≥50% season agreement. +// + +func ConsensusIntros(episodes []EpisodeAudio) []ConsensusHit { + if len(episodes) < minEpisodesForAudio { + return nil + } + + votesByPath := collectPairwiseVotes(episodes) + participating := 0 + for _, votes := range votesByPath { + if len(votes) > 0 { + participating++ + } + } + if participating < minEpisodesForAudio || + float64(participating) < float64(len(episodes))*minSeasonAgreement { + return nil + } + + partnerFloor := min(max(int(math.Ceil(float64(len(episodes))*minSeasonAgreement)), 1), len(episodes)-1) + + hits := make([]ConsensusHit, 0, len(votesByPath)) + for path, votes := range votesByPath { + if len(votes) < partnerFloor { + continue + } + startMs, endMs, conf := medianRange(votes) + durationMs := endMs - startMs + if durationMs < int64(minIntroSec*msPerSecond) || + durationMs > int64(maxIntroSec*msPerSecond) { + continue + } + hits = append(hits, ConsensusHit{ + RelPath: path, + StartMs: startMs, + EndMs: endMs, + Confidence: conf, + }) + } + sort.Slice(hits, func(i, j int) bool { + return hits[i].RelPath < hits[j].RelPath + }) + + return hits +} + +func collectPairwiseVotes(episodes []EpisodeAudio) map[string][]introVote { + votesByPath := make(map[string][]introVote, len(episodes)) + for index, left := range episodes { + for other := index + 1; other < len(episodes); other++ { + right := episodes[other] + if len(left.Fingerprint) == 0 || len(right.Fingerprint) == 0 { + continue + } + match := LongestMatch(left.Fingerprint, right.Fingerprint, maxStartSkewSec) + if match == nil { + continue + } + if match.DurationSec < float64(minIntroSec) || match.DurationSec > float64(maxIntroSec) { + continue + } + leftStart := ItemIndexToMs(match.StartA) + leftEnd := ItemIndexToMs(match.EndA) + rightStart := ItemIndexToMs(match.StartB) + rightEnd := ItemIndexToMs(match.EndB) + if !validIntroBounds(leftStart, leftEnd, left.ScanWindowSec) || + !validIntroBounds(rightStart, rightEnd, right.ScanWindowSec) { + continue + } + votesByPath[left.RelPath] = append(votesByPath[left.RelPath], introVote{ + relPath: left.RelPath, + startMs: leftStart, + endMs: leftEnd, + score: match.Score, + }) + votesByPath[right.RelPath] = append(votesByPath[right.RelPath], introVote{ + relPath: right.RelPath, + startMs: rightStart, + endMs: rightEnd, + score: match.Score, + }) + } + } + + return votesByPath +} + +func validIntroBounds(startMs, endMs int64, scanWindowSec float64) bool { + if endMs <= startMs { + return false + } + durationMs := endMs - startMs + if durationMs < int64(minIntroSec*msPerSecond) || durationMs > int64(maxIntroSec*msPerSecond) { + return false + } + if startMs < 0 { + return false + } + if scanWindowSec > 0 && float64(startMs) > scanWindowSec*msPerSecond { + return false + } + + return true +} + +func medianRange(votes []introVote) (int64, int64, float64) { + starts := make([]int64, len(votes)) + ends := make([]int64, len(votes)) + sum := 0.0 + for index, item := range votes { + starts[index] = item.startMs + ends[index] = item.endMs + sum += item.score + } + slices.Sort(starts) + slices.Sort(ends) + mid := len(votes) / 2 //nolint:mnd // median index + confidence := sum / float64(len(votes)) + if confidence > 1 { + confidence = 1 + } + + return starts[mid], ends[mid], confidence +} diff --git a/internal/skipsegment/detect.go b/internal/skipsegment/detect.go new file mode 100644 index 0000000..9abe577 --- /dev/null +++ b/internal/skipsegment/detect.go @@ -0,0 +1,423 @@ +package skipsegment + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sudoStream/internal/access" + "sudoStream/internal/mediafs" + "sudoStream/internal/metadata" + "sudoStream/internal/transcode" + "time" +) + +var errServiceUnavailable = errors.New("skipsegment service unavailable") + +// Store persists detected intro segments. +type Store interface { + Upsert(ctx context.Context, segment StoredSegment) error + Get(ctx context.Context, libraryID, relPath string) (StoredSegment, bool, error) + DeleteForPath(ctx context.Context, libraryID, relPath string) error +} + +// Catalog lists series shows/seasons/episodes for offline detection. +type Catalog interface { + ListCatalogShowsPage( + ctx context.Context, + libraryID string, + limit, offset int, + ) ([]metadata.CatalogShowRow, int, error) + GetCatalogShowAgg( + ctx context.Context, + libraryID, showKey string, + ) (metadata.CatalogShowAgg, bool, error) + ListCatalogSeasonEpisodes( + ctx context.Context, + libraryID, showKey string, + season, limit, offset int, + ) ([]metadata.CatalogEpisodeRow, int, error) +} + +// ProbeDuration returns media duration seconds for scan-window sizing. +type ProbeDuration func(ctx context.Context, absPath string) (float64, error) + +// ProbeSourceFn inspects a media file (chapters + duration) for detect. +type ProbeSourceFn func(ctx context.Context, absPath string) (transcode.SourceInfo, error) + +// ExtractFn builds a chromaprint for the intro scan window. +type ExtractFn func( + ctx context.Context, + absPath string, + durationSeconds float64, +) ([]uint32, float64, error) + +// RunSummary counts for maintenance. +type RunSummary struct { + Seasons int `json:"seasons"` + Episodes int `json:"episodes"` + AudioHits int `json:"audioHits"` + Skipped int `json:"skipped"` + Errors int `json:"errors"` + ChapterSkip int `json:"chapterSkip"` +} + +// Map converts the summary for maintenance JSON. +func (s RunSummary) Map() map[string]any { + return map[string]any{ + "seasons": s.Seasons, + "episodes": s.Episodes, + "audioHits": s.AudioHits, + "skipped": s.Skipped, + "errors": s.Errors, + "chapterSkip": s.ChapterSkip, + } +} + +// LibraryLookup loads a library for detect scoping. +type LibraryLookup interface { + GetLibrary(ctx context.Context, libraryID string) (access.Library, error) +} + +// Service runs offline intro detection and serves stored segments. +type Service struct { + store Store + media *mediafs.Service + access LibraryLookup + catalog Catalog + probe ProbeDuration + source ProbeSourceFn + extract ExtractFn +} + +// NewService constructs a skip-intro detector. +func NewService( + store Store, + media *mediafs.Service, + accessService LibraryLookup, + catalog Catalog, +) *Service { + return &Service{ + store: store, + media: media, + access: accessService, + catalog: catalog, + probe: defaultProbeDuration, + source: transcode.ProbeSource, + extract: ExtractFingerprint, + } +} + +// SetProbeDuration replaces the duration probe (tests). +func (s *Service) SetProbeDuration(probe ProbeDuration) { + if s == nil { + return + } + s.probe = probe +} + +// SetProbeSource replaces the media probe (tests). +func (s *Service) SetProbeSource(probe ProbeSourceFn) { + if s == nil { + return + } + s.source = probe +} + +// SetExtract replaces chromaprint extraction (tests). +func (s *Service) SetExtract(extract ExtractFn) { + if s == nil { + return + } + s.extract = extract +} + +// LookupIntro returns a stored audio intro for playback merge. +func (s *Service) LookupIntro(ctx context.Context, libraryID, relPath string) *Intro { + if s == nil || s.store == nil || libraryID == "" || relPath == "" { + return nil + } + + segment, found, err := s.store.Get(ctx, libraryID, relPath) + if err != nil { + slog.Debug("skip intro store get failed", + slog.String("library_id", libraryID), + slog.String("path", relPath), + slog.String("err", err.Error()), + ) + + return nil + } + if !found || segment.EngineVersion != EngineVersion { + return nil + } + + return segment.ToIntro() +} + +// DetectLibraryMap runs DetectLibrary and returns a maintenance summary map. +func (s *Service) DetectLibraryMap(ctx context.Context, libraryID string) (map[string]any, error) { + summary, err := s.DetectLibrary(ctx, libraryID) + + return summary.Map(), err +} + +// DetectLibrary runs audio intro detection for one series library. +func (s *Service) DetectLibrary(ctx context.Context, libraryID string) (RunSummary, error) { + var summary RunSummary + if s == nil || s.catalog == nil || s.access == nil || s.media == nil || s.store == nil { + return summary, errServiceUnavailable + } + + library, err := s.access.GetLibrary(ctx, libraryID) + if err != nil { + return summary, fmt.Errorf("get library: %w", err) + } + if library.Type != access.LibraryTypeSeries { + summary.Skipped++ + + return summary, nil + } + + return s.detectAllShows(ctx, libraryID) +} + +func (s *Service) detectAllShows(ctx context.Context, libraryID string) (RunSummary, error) { + var summary RunSummary + const pageSize = 50 + offset := 0 + for { + err := ctx.Err() + if err != nil { + return summary, fmt.Errorf("skip intro detect canceled: %w", err) + } + shows, total, listErr := s.catalog.ListCatalogShowsPage(ctx, libraryID, pageSize, offset) + if listErr != nil { + return summary, fmt.Errorf("list shows: %w", listErr) + } + for _, show := range shows { + s.accumulateShow(ctx, libraryID, show.ShowKey, &summary) + } + offset += len(shows) + if offset >= total || len(shows) == 0 { + break + } + } + + return summary, nil +} + +func (s *Service) accumulateShow( + ctx context.Context, + libraryID, showKey string, + summary *RunSummary, +) { + agg, found, aggErr := s.catalog.GetCatalogShowAgg(ctx, libraryID, showKey) + if aggErr != nil { + summary.Errors++ + slog.Warn("skip intro show agg failed", + slog.String("library_id", libraryID), + slog.String("show_key", showKey), + slog.String("err", aggErr.Error()), + ) + + return + } + if !found { + return + } + for _, season := range agg.Seasons { + seasonSummary, seasonErr := s.detectSeason(ctx, libraryID, showKey, season.Season) + summary.Seasons++ + summary.Episodes += seasonSummary.Episodes + summary.AudioHits += seasonSummary.AudioHits + summary.Skipped += seasonSummary.Skipped + summary.Errors += seasonSummary.Errors + summary.ChapterSkip += seasonSummary.ChapterSkip + if seasonErr != nil { + summary.Errors++ + slog.Warn("skip intro season failed", + slog.String("library_id", libraryID), + slog.String("show_key", showKey), + slog.Int("season", season.Season), + slog.String("err", seasonErr.Error()), + ) + } + } +} + +func (s *Service) detectSeason( + ctx context.Context, + libraryID, showKey string, + season int, +) (RunSummary, error) { + var summary RunSummary + + episodes, _, err := s.catalog.ListCatalogSeasonEpisodes( + ctx, libraryID, showKey, season, 0, 0, + ) + if err != nil { + return summary, fmt.Errorf("list episodes: %w", err) + } + summary.Episodes = len(episodes) + if len(episodes) < minEpisodesForAudio { + summary.Skipped += len(episodes) + + return summary, nil + } + + audio, extractSummary := s.extractSeasonAudio(ctx, episodes) + summary.Skipped += extractSummary.Skipped + summary.Errors += extractSummary.Errors + summary.ChapterSkip += extractSummary.ChapterSkip + if len(audio) < minEpisodesForAudio { + summary.Skipped += len(audio) + + return summary, nil + } + + hits := ConsensusIntros(audio) + now := time.Now().UTC() + for _, hit := range hits { + segment := StoredSegment{ + LibraryID: libraryID, + RelPath: hit.RelPath, + Kind: KindIntro, + StartMs: hit.StartMs, + EndMs: hit.EndMs, + Source: SourceAudio, + Confidence: hit.Confidence, + EngineVersion: EngineVersion, + ShowKey: showKey, + Season: season, + DetectedAt: now, + } + upsertErr := s.store.Upsert(ctx, segment) + if upsertErr != nil { + summary.Errors++ + + continue + } + summary.AudioHits++ + } + + return summary, nil +} + +func (s *Service) extractSeasonAudio( + ctx context.Context, + episodes []metadata.CatalogEpisodeRow, +) ([]EpisodeAudio, RunSummary) { + var summary RunSummary + audio := make([]EpisodeAudio, 0, len(episodes)) + for _, episode := range episodes { + err := ctx.Err() + if err != nil { + summary.Errors++ + + break + } + item, status := s.extractEpisodeAudio(ctx, episode.RelPath) + switch status { + case extractOK: + audio = append(audio, item) + case extractChapter: + summary.ChapterSkip++ + case extractSkip: + summary.Skipped++ + case extractError: + summary.Errors++ + } + } + + return audio, summary +} + +type extractStatus int + +const ( + extractOK extractStatus = iota + extractChapter + extractSkip + extractError +) + +func (s *Service) extractEpisodeAudio( + ctx context.Context, + relPath string, +) (EpisodeAudio, extractStatus) { + absPath, pathErr := s.media.FilePath(relPath) + if pathErr != nil { + return EpisodeAudio{}, extractError + } + + sourceInfo, sourceErr := s.source(ctx, absPath) + if sourceErr == nil { + if chapterIntro := introFromProbe(sourceInfo); chapterIntro != nil { + return EpisodeAudio{}, extractChapter + } + } + + duration, ok := s.durationFromProbe(ctx, absPath, sourceInfo, sourceErr) + if !ok { + return EpisodeAudio{}, extractError + } + + fingerprint, window, fpErr := s.extract(ctx, absPath, duration) + if fpErr != nil { + if errors.Is(fpErr, ErrNoAudio) { + return EpisodeAudio{}, extractSkip + } + + return EpisodeAudio{}, extractError + } + + return EpisodeAudio{ + RelPath: relPath, + DurationSeconds: duration, + Fingerprint: fingerprint, + ScanWindowSec: window, + }, extractOK +} + +func (s *Service) durationFromProbe( + ctx context.Context, + absPath string, + sourceInfo transcode.SourceInfo, + sourceErr error, +) (float64, bool) { + if sourceErr == nil && sourceInfo.DurationSeconds > 0 { + return sourceInfo.DurationSeconds, true + } + duration, probeErr := s.probe(ctx, absPath) + if probeErr != nil || duration <= 0 { + return 0, false + } + + return duration, true +} + +func introFromProbe(info transcode.SourceInfo) *Intro { + if len(info.Chapters) == 0 || info.DurationSeconds <= 0 { + return nil + } + cues := make([]ChapterCue, len(info.Chapters)) + for index, chapter := range info.Chapters { + cues[index] = ChapterCue{ + StartSeconds: chapter.StartSeconds, + EndSeconds: chapter.EndSeconds, + Title: chapter.Title, + } + } + + return IntroFromChapters(cues, info.DurationSeconds) +} + +func defaultProbeDuration(ctx context.Context, absPath string) (float64, error) { + info, err := transcode.ProbeSource(ctx, absPath) + if err != nil { + return 0, fmt.Errorf("probe duration: %w", err) + } + + return info.DurationSeconds, nil +} diff --git a/internal/skipsegment/detect_test.go b/internal/skipsegment/detect_test.go new file mode 100644 index 0000000..1280cf8 --- /dev/null +++ b/internal/skipsegment/detect_test.go @@ -0,0 +1,277 @@ +package skipsegment + +import ( + "context" + "math/rand/v2" + "os" + "path/filepath" + "sudoStream/internal/access" + "sudoStream/internal/mediafs" + "sudoStream/internal/metadata" + "sudoStream/internal/transcode" + "testing" + + allure "github.com/allure-framework/allure-go/commons/gotest" +) + +func TestDetectLibrary_WritesAudioConsensus(t *testing.T) { + t.Parallel() + + allure.Test(t, "detect library writes consensus audio intros", func(a *allure.Context) { + t := a.T() + + root := t.TempDir() + for _, name := range []string{"a.mkv", "b.mkv", "c.mkv"} { + if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + media, err := mediafs.New(root) + if err != nil { + t.Fatal(err) + } + + rng := rand.New(rand.NewPCG(9, 10)) + theme := randomFingerprint(rng, 120) + fps := map[string][]uint32{ + "a.mkv": append(randomFingerprint(rng, 20), theme...), + "b.mkv": append(randomFingerprint(rng, 30), theme...), + "c.mkv": append(randomFingerprint(rng, 25), theme...), + } + + store := &memorySegmentStore{rows: map[string]StoredSegment{}} + catalog := &memoryCatalog{ + shows: []metadata.CatalogShowRow{{ShowKey: "demo", Name: "Demo", SeasonCount: 1}}, + agg: metadata.CatalogShowAgg{ + ShowKey: "demo", + Name: "Demo", + Seasons: []metadata.CatalogSeasonCount{{Season: 1, EpisodeCount: 3}}, + }, + episodes: []metadata.CatalogEpisodeRow{ + {RelPath: "a.mkv"}, + {RelPath: "b.mkv"}, + {RelPath: "c.mkv"}, + }, + } + libs := &memoryLibraries{lib: access.Library{ + ID: "lib-1", Type: access.LibraryTypeSeries, Slug: "series", + }} + + svc := NewService(store, media, libs, catalog) + svc.SetProbeSource(func(context.Context, string) (transcode.SourceInfo, error) { + return transcode.SourceInfo{DurationSeconds: 600}, nil + }) + svc.SetExtract(func(_ context.Context, absPath string, _ float64) ([]uint32, float64, error) { + base := filepath.Base(absPath) + + return fps[base], 600, nil + }) + + summary, err := svc.DetectLibrary(context.Background(), "lib-1") + if err != nil { + t.Fatalf("detect: %v", err) + } + if summary.AudioHits != 3 { + t.Fatalf("audioHits=%d summary=%+v store=%d", summary.AudioHits, summary, len(store.rows)) + } + if got := svc.LookupIntro(context.Background(), "lib-1", "a.mkv"); got == nil || got.Source != SourceAudio { + t.Fatalf("lookup: %+v", got) + } + mapped, err := svc.DetectLibraryMap(context.Background(), "lib-1") + if err != nil || mapped["audioHits"].(int) != 3 { + t.Fatalf("map: %+v err=%v", mapped, err) + } + }) +} + +func TestDetectLibrary_SkipsNonSeries(t *testing.T) { + t.Parallel() + + allure.Test(t, "detect library skips non-series libraries", func(a *allure.Context) { + t := a.T() + + root := t.TempDir() + media, err := mediafs.New(root) + if err != nil { + t.Fatal(err) + } + svc := NewService( + &memorySegmentStore{rows: map[string]StoredSegment{}}, + media, + &memoryLibraries{lib: access.Library{ID: "film", Type: access.LibraryTypeFilm}}, + &memoryCatalog{}, + ) + summary, err := svc.DetectLibrary(context.Background(), "film") + if err != nil { + t.Fatal(err) + } + if summary.Skipped != 1 || summary.AudioHits != 0 { + t.Fatalf("%+v", summary) + } + }) +} + +func TestDetectLibrary_SkipsChapterHits(t *testing.T) { + t.Parallel() + + allure.Test(t, "detect library skips episodes with chapter intros", func(a *allure.Context) { + t := a.T() + + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a.mkv"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "b.mkv"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + media, err := mediafs.New(root) + if err != nil { + t.Fatal(err) + } + store := &memorySegmentStore{rows: map[string]StoredSegment{}} + svc := NewService( + store, + media, + &memoryLibraries{lib: access.Library{ID: "lib-1", Type: access.LibraryTypeSeries}}, + &memoryCatalog{ + shows: []metadata.CatalogShowRow{{ShowKey: "demo"}}, + agg: metadata.CatalogShowAgg{ + ShowKey: "demo", + Seasons: []metadata.CatalogSeasonCount{{Season: 1, EpisodeCount: 2}}, + }, + episodes: []metadata.CatalogEpisodeRow{{RelPath: "a.mkv"}, {RelPath: "b.mkv"}}, + }, + ) + svc.SetProbeSource(func(context.Context, string) (transcode.SourceInfo, error) { + return transcode.SourceInfo{ + DurationSeconds: 600, + Chapters: []transcode.ChapterInfo{ + {StartSeconds: 0, EndSeconds: 10, Title: "Cold open"}, + {StartSeconds: 10, EndSeconds: 70, Title: "Opening Theme"}, + }, + }, nil + }) + summary, err := svc.DetectLibrary(context.Background(), "lib-1") + if err != nil { + t.Fatal(err) + } + if summary.ChapterSkip != 2 || summary.AudioHits != 0 { + t.Fatalf("%+v", summary) + } + }) +} + +func TestStoredSegment_ToIntro(t *testing.T) { + t.Parallel() + + allure.Test(t, "stored segment to intro rejects inverted range", func(a *allure.Context) { + t := a.T() + + if (&StoredSegment{StartMs: 10, EndMs: 5}).ToIntro() != nil { + t.Fatal("expected nil") + } + got := StoredSegment{StartMs: 1, EndMs: 2, Source: SourceAudio}.ToIntro() + if got == nil || got.Source != SourceAudio { + t.Fatalf("%+v", got) + } + }) +} + +func TestExtractFingerprint_NoAudioOrMissingBinary(t *testing.T) { + t.Parallel() + + allure.Test(t, "extract fingerprint handles missing file", func(a *allure.Context) { + t := a.T() + + _, _, err := ExtractFingerprint(context.Background(), "/no/such/file.mkv", 120) + if err == nil { + t.Fatal("expected error") + } + }) + + allure.Test(t, "extract fingerprint rejects short scan window", func(a *allure.Context) { + t := a.T() + + _, _, err := ExtractFingerprint(context.Background(), "/tmp/x", 5) + if err == nil { + t.Fatal("expected short window error") + } + }) +} + +func TestTruncateErrAndFormatSeconds(t *testing.T) { + t.Parallel() + + allure.Test(t, "truncateErr and formatSeconds helpers", func(a *allure.Context) { + t := a.T() + + if formatSeconds(1.5) != "1.500" { + t.Fatal(formatSeconds(1.5)) + } + long := make([]byte, 300) + for i := range long { + long[i] = 'a' + } + if len(truncateErr(string(long))) != maxFFmpegErrLogChars { + t.Fatal(len(truncateErr(string(long)))) + } + }) +} + +type memorySegmentStore struct { + rows map[string]StoredSegment +} + +func (m *memorySegmentStore) Upsert(_ context.Context, segment StoredSegment) error { + m.rows[segment.LibraryID+"|"+segment.RelPath] = segment + + return nil +} + +func (m *memorySegmentStore) Get(_ context.Context, libraryID, relPath string) (StoredSegment, bool, error) { + segment, ok := m.rows[libraryID+"|"+relPath] + + return segment, ok, nil +} + +func (m *memorySegmentStore) DeleteForPath(_ context.Context, libraryID, relPath string) error { + delete(m.rows, libraryID+"|"+relPath) + + return nil +} + +type memoryLibraries struct { + lib access.Library +} + +func (m *memoryLibraries) GetLibrary(_ context.Context, _ string) (access.Library, error) { + return m.lib, nil +} + +type memoryCatalog struct { + shows []metadata.CatalogShowRow + agg metadata.CatalogShowAgg + episodes []metadata.CatalogEpisodeRow +} + +func (m *memoryCatalog) ListCatalogShowsPage( + context.Context, string, int, int, +) ([]metadata.CatalogShowRow, int, error) { + return m.shows, len(m.shows), nil +} + +func (m *memoryCatalog) GetCatalogShowAgg( + context.Context, string, string, +) (metadata.CatalogShowAgg, bool, error) { + if m.agg.ShowKey == "" { + return metadata.CatalogShowAgg{}, false, nil + } + + return m.agg, true, nil +} + +func (m *memoryCatalog) ListCatalogSeasonEpisodes( + context.Context, string, string, int, int, int, +) ([]metadata.CatalogEpisodeRow, int, error) { + return m.episodes, len(m.episodes), nil +} diff --git a/internal/skipsegment/fingerprint.go b/internal/skipsegment/fingerprint.go new file mode 100644 index 0000000..6f2a0ab --- /dev/null +++ b/internal/skipsegment/fingerprint.go @@ -0,0 +1,95 @@ +package skipsegment + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "os/exec" + "strconv" + "strings" +) + +const ( + uint32Bytes = 4 + maxFFmpegErrLogChars = 240 + chromaprintSampleRate = "11025" +) + +// ErrNoAudio is returned when ffmpeg cannot produce a chromaprint (no audio track). +var ErrNoAudio = errors.New("no audio stream for chromaprint") + +var ( + errScanWindowTooShort = errors.New("scan window too short") + errChromaprintRawLen = errors.New("chromaprint raw length invalid") +) + +// ExtractFingerprint runs jellyfin-ffmpeg chromaprint over the intro scan window. +// Uses raw little-endian uint32 output (no compressed decode). Absolute path must +// already be resolved via mediafs. +func ExtractFingerprint( + ctx context.Context, + absPath string, + durationSeconds float64, +) ([]uint32, float64, error) { + scanWindowSec := IntroScanWindowSec(durationSeconds) + if scanWindowSec < float64(minIntroSec) { + return nil, scanWindowSec, fmt.Errorf("%w: %.1fs", errScanWindowTooShort, scanWindowSec) + } + + cmd := exec.CommandContext( + ctx, + "ffmpeg", + "-hide_banner", + "-nostats", + "-i", absPath, + "-t", formatSeconds(scanWindowSec), + "-vn", + "-map", "0:a:0?", + "-ac", "1", + "-ar", chromaprintSampleRate, + "-f", "chromaprint", + "-fp_format", "raw", + "-algorithm", "1", + "pipe:1", + ) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + runErr := cmd.Run() + if runErr != nil { + message := strings.TrimSpace(stderr.String()) + if strings.Contains(message, "Output file does not contain any stream") || + strings.Contains(message, "matches no streams") { + return nil, scanWindowSec, ErrNoAudio + } + + return nil, scanWindowSec, fmt.Errorf("ffmpeg chromaprint: %w (%s)", runErr, truncateErr(message)) + } + + raw := stdout.Bytes() + if len(raw) < uint32Bytes || len(raw)%uint32Bytes != 0 { + return nil, scanWindowSec, fmt.Errorf("%w: %d", errChromaprintRawLen, len(raw)) + } + + items := make([]uint32, len(raw)/uint32Bytes) + for index := range items { + items[index] = binary.LittleEndian.Uint32(raw[index*uint32Bytes : (index+1)*uint32Bytes]) + } + + return items, scanWindowSec, nil +} + +func formatSeconds(seconds float64) string { + return strconv.FormatFloat(seconds, 'f', 3, 64) +} + +func truncateErr(message string) string { + message = strings.ReplaceAll(message, "\n", " ") + if len(message) > maxFFmpegErrLogChars { + return message[:maxFFmpegErrLogChars] + } + + return message +} diff --git a/internal/skipsegment/match.go b/internal/skipsegment/match.go new file mode 100644 index 0000000..ea74951 --- /dev/null +++ b/internal/skipsegment/match.go @@ -0,0 +1,192 @@ +package skipsegment + +import ( + "math" + "math/bits" +) + +const ( + bitsPerFingerprintItem = 32 +) + +// MatchRange is a contiguous fingerprint alignment between two episodes. +type MatchRange struct { + StartA int // inclusive item index in A + EndA int // exclusive item index in A + StartB int + EndB int + Offset int // B index - A index at alignment + Score float64 + DurationSec float64 +} + +// LongestMatch finds the best contiguous high-similarity run between two +// chromaprint raw fingerprints, searching start offsets within ±maxSkewSec. +// Returns nil when no run meets minIntroSec / score policy. +func LongestMatch(left, right []uint32, maxSkewSec float64) *MatchRange { + if len(left) == 0 || len(right) == 0 { + return nil + } + + maxSkewItems := max(int(math.Ceil(maxSkewSec/chromaprintItemDurationSec)), 0) + + minItems := int(math.Ceil(float64(minIntroSec) / chromaprintItemDurationSec)) + maxItems := int(math.Floor(float64(maxIntroSec) / chromaprintItemDurationSec)) + if minItems < 1 { + minItems = 1 + } + if maxItems < minItems { + maxItems = minItems + } + + var best *MatchRange + for offset := -maxSkewItems; offset <= maxSkewItems; offset++ { + candidate := bestRunAtOffset(left, right, offset, minItems, maxItems) + if candidate == nil { + continue + } + if betterMatch(candidate, best) { + copyRange := *candidate + best = ©Range + } + } + + return best +} + +//nolint:cyclop // run scanner with max-window trim +func bestRunAtOffset(left, right []uint32, offset, minItems, maxItems int) *MatchRange { + startA := 0 + if offset < 0 { + startA = -offset + } + endA := min(len(left), len(right)-offset) + if endA-startA < minItems { + return nil + } + + scores := make([]float64, endA-startA) + for index := startA; index < endA; index++ { + scores[index-startA] = bitAgreement(left[index], right[index+offset]) + } + smoothed := smoothScores(scores, localWindowItems) + + var best *MatchRange + runStart := -1 + for index := 0; index <= len(smoothed); index++ { + above := index < len(smoothed) && smoothed[index] >= bitMatchThreshold + if above { + if runStart < 0 { + runStart = index + } + + continue + } + if runStart < 0 { + continue + } + runEnd := index + if runEnd-runStart > maxItems { + runStart, runEnd = bestSubwindow(smoothed, runStart, runEnd, maxItems) + } + if runEnd-runStart >= minItems { + candidate := matchFromRun(offset, startA+runStart, startA+runEnd, smoothed[runStart:runEnd]) + if betterMatch(candidate, best) { + best = candidate + } + } + runStart = -1 + } + + return best +} + +func betterMatch(candidate, best *MatchRange) bool { + if candidate == nil { + return false + } + if best == nil { + return true + } + if candidate.Score > best.Score { + return true + } + + return candidate.Score == best.Score && candidate.DurationSec > best.DurationSec +} + +func matchFromRun(offset, startA, endA int, windowScores []float64) *MatchRange { + sum := 0.0 + for _, score := range windowScores { + sum += score + } + avg := sum / float64(len(windowScores)) + duration := float64(endA-startA) * chromaprintItemDurationSec + + return &MatchRange{ + StartA: startA, + EndA: endA, + StartB: startA + offset, + EndB: endA + offset, + Offset: offset, + Score: avg, + DurationSec: duration, + } +} + +func bestSubwindow(scores []float64, runStart, runEnd, window int) (int, int) { + if runEnd-runStart <= window { + return runStart, runEnd + } + bestStart := runStart + bestSum := 0.0 + for index := runStart; index < runStart+window; index++ { + bestSum += scores[index] + } + sum := bestSum + for start := runStart + 1; start+window <= runEnd; start++ { + sum += scores[start+window-1] - scores[start-1] + if sum > bestSum { + bestSum = sum + bestStart = start + } + } + + return bestStart, bestStart + window +} + +func smoothScores(scores []float64, window int) []float64 { + if window <= 1 || len(scores) == 0 { + out := make([]float64, len(scores)) + copy(out, scores) + + return out + } + half := window / 2 //nolint:mnd // centered moving average + out := make([]float64, len(scores)) + for index := range scores { + start := max(index-half, 0) + end := min(index+half+1, len(scores)) + sum := 0.0 + for cursor := start; cursor < end; cursor++ { + sum += scores[cursor] + } + out[index] = sum / float64(end-start) + } + + return out +} + +func bitAgreement(left, right uint32) float64 { + return float64(bitsPerFingerprintItem-bits.OnesCount32(left^right)) / float64(bitsPerFingerprintItem) +} + +// ItemIndexToMs converts a chromaprint item index to content milliseconds +// within a scan window that starts at t=0. +func ItemIndexToMs(index int) int64 { + if index <= 0 { + return 0 + } + + return int64(math.Round(float64(index) * chromaprintItemDurationSec * msPerSecond)) +} diff --git a/internal/skipsegment/match_test.go b/internal/skipsegment/match_test.go new file mode 100644 index 0000000..6fe7576 --- /dev/null +++ b/internal/skipsegment/match_test.go @@ -0,0 +1,122 @@ +package skipsegment + +import ( + "math/rand/v2" + "testing" + + allure "github.com/allure-framework/allure-go/commons/gotest" +) + +func TestLongestMatch_FindsSharedBlock(t *testing.T) { + t.Parallel() + + allure.Test(t, "longest match finds shared fingerprint block", func(a *allure.Context) { + t := a.T() + + rng := rand.New(rand.NewPCG(1, 2)) + theme := randomFingerprint(rng, 100) + leftLead := randomFingerprint(rng, 40) + leftTail := randomFingerprint(rng, 20) + rightLead := randomFingerprint(rng, 55) + rightTail := randomFingerprint(rng, 10) + + left := append(append(leftLead, theme...), leftTail...) + right := append(append(rightLead, theme...), rightTail...) + + match := LongestMatch(left, right, maxStartSkewSec) + if match == nil { + t.Fatal("expected match") + } + if match.DurationSec < float64(minIntroSec) { + t.Fatalf("duration too short: %.2f", match.DurationSec) + } + if match.StartA < 35 || match.StartA > 45 { + t.Fatalf("startA=%d want ~40", match.StartA) + } + }) +} + +func TestLongestMatch_NoMatchOnNoise(t *testing.T) { + t.Parallel() + + allure.Test(t, "longest match returns nil for unrelated fingerprints", func(a *allure.Context) { + t := a.T() + + rng := rand.New(rand.NewPCG(3, 4)) + left := randomFingerprint(rng, 200) + right := randomFingerprint(rng, 200) + if match := LongestMatch(left, right, maxStartSkewSec); match != nil { + t.Fatalf("unexpected match: %+v", match) + } + }) +} + +func TestConsensusIntros_RequiresAgreement(t *testing.T) { + t.Parallel() + + allure.Test(t, "consensus intros — shared theme across three episodes", func(a *allure.Context) { + t := a.T() + + rng := rand.New(rand.NewPCG(5, 6)) + theme := randomFingerprint(rng, 120) + mk := func(path string, lead int) EpisodeAudio { + fp := append(randomFingerprint(rng, lead), theme...) + + return EpisodeAudio{ + RelPath: path, + Fingerprint: fp, + ScanWindowSec: 600, + } + } + hits := ConsensusIntros([]EpisodeAudio{ + mk("a.mkv", 20), + mk("b.mkv", 30), + mk("c.mkv", 25), + }) + if len(hits) != 3 { + t.Fatalf("hits=%d want 3: %+v", len(hits), hits) + } + }) + + allure.Test(t, "consensus intros — abstains with single episode", func(a *allure.Context) { + t := a.T() + + if hits := ConsensusIntros([]EpisodeAudio{{RelPath: "only.mkv", Fingerprint: []uint32{1, 2, 3}}}); hits != nil { + t.Fatalf("expected nil, got %+v", hits) + } + }) +} + +func TestResolveIntro_PrefersChapter(t *testing.T) { + t.Parallel() + + allure.Test(t, "resolve intro prefers chapter over stored audio", func(a *allure.Context) { + t := a.T() + + chapter := &Intro{StartMs: 10_000, EndMs: 70_000, Source: SourceChapter} + audio := &Intro{StartMs: 5_000, EndMs: 80_000, Source: SourceAudio} + got := ResolveIntro(chapter, audio) + if got == nil || got.Source != SourceChapter || got.StartMs != 10_000 { + t.Fatalf("got %+v", got) + } + }) + + allure.Test(t, "resolve intro falls back to stored audio", func(a *allure.Context) { + t := a.T() + + audio := &Intro{StartMs: 5_000, EndMs: 80_000, Source: SourceAudio} + got := ResolveIntro(nil, audio) + if got == nil || got.Source != SourceAudio { + t.Fatalf("got %+v", got) + } + }) +} + +func randomFingerprint(rng *rand.Rand, n int) []uint32 { + out := make([]uint32, n) + for index := range out { + out[index] = rng.Uint32() + } + + return out +} diff --git a/internal/skipsegment/merge.go b/internal/skipsegment/merge.go new file mode 100644 index 0000000..ab0a14a --- /dev/null +++ b/internal/skipsegment/merge.go @@ -0,0 +1,13 @@ +package skipsegment + +// ResolveIntro prefers a live chapter hit over a stored audio segment. +func ResolveIntro(chapterIntro *Intro, stored *Intro) *Intro { + if chapterIntro != nil && chapterIntro.EndMs > chapterIntro.StartMs { + return chapterIntro + } + if stored != nil && stored.EndMs > stored.StartMs { + return stored + } + + return nil +} diff --git a/internal/skipsegment/postgres/models.go b/internal/skipsegment/postgres/models.go new file mode 100644 index 0000000..705a883 --- /dev/null +++ b/internal/skipsegment/postgres/models.go @@ -0,0 +1,22 @@ +package postgres + +import "time" + +type skipIntroSegmentModel struct { + ID string `gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"` + LibraryID string `gorm:"column:library_id;type:uuid"` + RelPath string `gorm:"column:rel_path"` + Kind string `gorm:"column:kind"` + StartMs int64 `gorm:"column:start_ms"` + EndMs int64 `gorm:"column:end_ms"` + Source string `gorm:"column:source"` + Confidence float64 `gorm:"column:confidence"` + EngineVersion int `gorm:"column:engine_version"` + ShowKey string `gorm:"column:show_key"` + Season int `gorm:"column:season"` + DetectedAt time.Time `gorm:"column:detected_at"` +} + +func (skipIntroSegmentModel) TableName() string { + return "skip_intro_segments" +} diff --git a/internal/skipsegment/postgres/store.go b/internal/skipsegment/postgres/store.go new file mode 100644 index 0000000..cd2d0cc --- /dev/null +++ b/internal/skipsegment/postgres/store.go @@ -0,0 +1,119 @@ +// Package postgres persists skip-intro segments. +package postgres + +import ( + "context" + "errors" + "fmt" + "sudoStream/internal/skipsegment" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Store persists skip intro rows in PostgreSQL. +type Store struct { + db *gorm.DB +} + +// NewStore constructs a skip-intro store. +func NewStore(db *gorm.DB) *Store { + return &Store{db: db} +} + +// Upsert writes or replaces one intro segment for a media path. +func (s *Store) Upsert(ctx context.Context, segment skipsegment.StoredSegment) error { + if segment.Kind == "" { + segment.Kind = skipsegment.KindIntro + } + now := segment.DetectedAt + if now.IsZero() { + now = time.Now().UTC() + } + model := skipIntroSegmentModel{ + LibraryID: segment.LibraryID, + RelPath: segment.RelPath, + Kind: segment.Kind, + StartMs: segment.StartMs, + EndMs: segment.EndMs, + Source: segment.Source, + Confidence: segment.Confidence, + EngineVersion: segment.EngineVersion, + ShowKey: segment.ShowKey, + Season: segment.Season, + DetectedAt: now, + } + + err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "library_id"}, + {Name: "rel_path"}, + {Name: "kind"}, + }, + DoUpdates: clause.AssignmentColumns([]string{ + "start_ms", + "end_ms", + "source", + "confidence", + "engine_version", + "show_key", + "season", + "detected_at", + }), + }).Create(&model).Error + if err != nil { + return fmt.Errorf("upsert skip intro: %w", err) + } + + return nil +} + +// Get returns a stored intro segment when present. +func (s *Store) Get( + ctx context.Context, + libraryID, relPath string, +) (skipsegment.StoredSegment, bool, error) { + var model skipIntroSegmentModel + err := s.db.WithContext(ctx). + Where( + "library_id = ? AND rel_path = ? AND kind = ?", + libraryID, + relPath, + skipsegment.KindIntro, + ). + Take(&model).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return skipsegment.StoredSegment{}, false, nil + } + + return skipsegment.StoredSegment{}, false, fmt.Errorf("get skip intro: %w", err) + } + + return skipsegment.StoredSegment{ + LibraryID: model.LibraryID, + RelPath: model.RelPath, + Kind: model.Kind, + StartMs: model.StartMs, + EndMs: model.EndMs, + Source: model.Source, + Confidence: model.Confidence, + EngineVersion: model.EngineVersion, + ShowKey: model.ShowKey, + Season: model.Season, + DetectedAt: model.DetectedAt, + }, true, nil +} + +// DeleteForPath removes stored segments for a media path. +func (s *Store) DeleteForPath(ctx context.Context, libraryID, relPath string) error { + err := s.db.WithContext(ctx). + Where("library_id = ? AND rel_path = ?", libraryID, relPath). + Delete(&skipIntroSegmentModel{}).Error + if err != nil { + return fmt.Errorf("delete skip intro: %w", err) + } + + return nil +} diff --git a/internal/skipsegment/postgres/store_test.go b/internal/skipsegment/postgres/store_test.go new file mode 100644 index 0000000..d03f24b --- /dev/null +++ b/internal/skipsegment/postgres/store_test.go @@ -0,0 +1,156 @@ +package postgres + +import ( + "context" + "crypto/rand" + "encoding/hex" + "os" + "sudoStream/internal/db" + "sudoStream/internal/skipsegment" + "testing" + "time" + + allure "github.com/allure-framework/allure-go/commons/gotest" +) + +const ( + testSkipLibraryID = "00000000-0000-0000-0000-000000000311" + testSkipRelPath = "show/S01E01.mkv" +) + +func TestSkipIntroModel_TableName(t *testing.T) { + t.Parallel() + + allure.Test(t, "skip intro model maps to skip_intro_segments table", func(a *allure.Context) { + t := a.T() + if (skipIntroSegmentModel{}).TableName() != "skip_intro_segments" { + t.Fatalf("unexpected table: %q", (skipIntroSegmentModel{}).TableName()) + } + }) +} + +func TestStore_SkipIntroCRUD(t *testing.T) { + if os.Getenv("SUDOSTREAM_DATABASE_URL") == "" { + t.Skip("SUDOSTREAM_DATABASE_URL not set") + } + + allure.Test(t, "postgres skip intro store upsert get delete", func(a *allure.Context) { + t := a.T() + ctx := context.Background() + database := setupSkipIntroTestDatabase(ctx, t) + store := NewStore(database.GORM) + + _, found, err := store.Get(ctx, testSkipLibraryID, testSkipRelPath) + if err != nil { + t.Fatalf("get empty: %v", err) + } + if found { + t.Fatal("expected missing") + } + + err = store.Upsert(ctx, skipsegment.StoredSegment{ + LibraryID: testSkipLibraryID, + RelPath: testSkipRelPath, + Kind: skipsegment.KindIntro, + StartMs: 30_000, + EndMs: 90_000, + Source: skipsegment.SourceAudio, + Confidence: 0.91, + EngineVersion: skipsegment.EngineVersion, + ShowKey: "demo", + Season: 1, + DetectedAt: time.Now().UTC(), + }) + if err != nil { + t.Fatalf("upsert: %v", err) + } + + got, found, err := store.Get(ctx, testSkipLibraryID, testSkipRelPath) + if err != nil || !found { + t.Fatalf("get after upsert: found=%v err=%v", found, err) + } + if got.StartMs != 30_000 || got.Source != skipsegment.SourceAudio { + t.Fatalf("%+v", got) + } + + err = store.Upsert(ctx, skipsegment.StoredSegment{ + LibraryID: testSkipLibraryID, + RelPath: testSkipRelPath, + Kind: skipsegment.KindIntro, + StartMs: 32_000, + EndMs: 95_000, + Source: skipsegment.SourceAudio, + Confidence: 0.95, + EngineVersion: skipsegment.EngineVersion, + ShowKey: "demo", + Season: 1, + }) + if err != nil { + t.Fatalf("upsert replace: %v", err) + } + got, _, err = store.Get(ctx, testSkipLibraryID, testSkipRelPath) + if err != nil || got.StartMs != 32_000 { + t.Fatalf("after replace: %+v err=%v", got, err) + } + + err = store.DeleteForPath(ctx, testSkipLibraryID, testSkipRelPath) + if err != nil { + t.Fatalf("delete: %v", err) + } + _, found, err = store.Get(ctx, testSkipLibraryID, testSkipRelPath) + if err != nil || found { + t.Fatalf("after delete found=%v err=%v", found, err) + } + }) +} + +func setupSkipIntroTestDatabase(ctx context.Context, t *testing.T) *db.Database { + t.Helper() + + schema := provisionSkipIntroSchema(ctx, t) + opts := db.PoolOptionsFromEnv() + opts.SearchPath = schema + + database, err := db.Open(ctx, os.Getenv("SUDOSTREAM_DATABASE_URL"), opts) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + err = db.Migrate(database.SQLDB()) + if err != nil { + t.Fatalf("migrate: %v", err) + } + + _, err = database.SQLDB().ExecContext(ctx, ` + INSERT INTO libraries (id, slug, name, type) + VALUES ($1, 'series', 'Series', 'series') + `, testSkipLibraryID) + if err != nil { + t.Fatalf("seed library: %v", err) + } + + return database +} + +func provisionSkipIntroSchema(ctx context.Context, t *testing.T) string { + t.Helper() + + buf := make([]byte, 8) + _, err := rand.Read(buf) + if err != nil { + t.Fatalf("rand: %v", err) + } + schema := "skip_it_" + hex.EncodeToString(buf) + err = db.CreateSchema(ctx, os.Getenv("SUDOSTREAM_DATABASE_URL"), schema) + if err != nil { + t.Fatalf("create schema: %v", err) + } + t.Cleanup(func() { + dropCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = db.DropSchema(dropCtx, os.Getenv("SUDOSTREAM_DATABASE_URL"), schema) + }) + + return schema +} diff --git a/internal/skipsegment/types.go b/internal/skipsegment/types.go index c4a8ee5..f46db6a 100644 --- a/internal/skipsegment/types.go +++ b/internal/skipsegment/types.go @@ -1,8 +1,18 @@ // Package skipsegment detects skippable playback ranges (E-23 skip intro). package skipsegment -// SourceChapter marks a segment derived from container chapter metadata. -const SourceChapter = "chapter" +import "time" + +// Segment kind and detection source identifiers. +const ( + KindIntro = "intro" + + SourceChapter = "chapter" + SourceAudio = "audio" + + // EngineVersion bumps invalidate or force re-detect of stored audio hits. + EngineVersion = 1 +) // Intro is an opening segment the player may offer to skip. type Intro struct { @@ -17,3 +27,39 @@ type ChapterCue struct { EndSeconds float64 Title string } + +// StoredSegment is one persisted skip range for a media file. +type StoredSegment struct { + LibraryID string + RelPath string + Kind string + StartMs int64 + EndMs int64 + Source string + Confidence float64 + EngineVersion int + ShowKey string + Season int + DetectedAt time.Time +} + +// ToIntro maps a stored row to the playback DTO. +func (s StoredSegment) ToIntro() *Intro { + if s.EndMs <= s.StartMs { + return nil + } + + return &Intro{ + StartMs: s.StartMs, + EndMs: s.EndMs, + Source: s.Source, + } +} + +// EpisodeAudio is one episode's chromaprint within the intro scan window. +type EpisodeAudio struct { + RelPath string + DurationSeconds float64 + Fingerprint []uint32 + ScanWindowSec float64 +} diff --git a/openapi/swagger.json b/openapi/swagger.json index 068797f..8437c4b 100644 --- a/openapi/swagger.json +++ b/openapi/swagger.json @@ -538,7 +538,7 @@ }, "skipIntro": { "$ref": "#/components/schemas/sudoStream_internal_skipsegment.Intro", - "description": "SkipIntro is set when chapter metadata identifies an opening intro segment (E-23)." + "description": "SkipIntro is set when chapter metadata or offline audio detection finds an opening intro (E-23)." }, "status": { "$ref": "#/components/schemas/sudoStream_internal_transcode.Status" diff --git a/openapi/swagger.yaml b/openapi/swagger.yaml index f322624..ffa8c96 100644 --- a/openapi/swagger.yaml +++ b/openapi/swagger.yaml @@ -357,8 +357,8 @@ components: S/E identity. skipIntro: $ref: '#/components/schemas/sudoStream_internal_skipsegment.Intro' - description: SkipIntro is set when chapter metadata identifies an opening - intro segment (E-23). + description: SkipIntro is set when chapter metadata or offline audio detection + finds an opening intro (E-23). status: $ref: '#/components/schemas/sudoStream_internal_transcode.Status' streamUrl: diff --git a/web/package.json b/web/package.json index c1c9891..5fd52a1 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,7 @@ "@hookform/resolvers": "^5.9.1", "@radix-ui/themes": "^3.3.0", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-query": "^5.103.2", + "@tanstack/react-query": "^5.104.0", "@videojs/google-cast": "10.0.0-rc.4", "@videojs/hlsjs-video": "10.0.0-rc.4", "@videojs/react": "10.0.0-rc.4", @@ -35,7 +35,7 @@ "radix-ui": "^1.6.7", "react": "^19.3.0", "react-dom": "^19.3.0", - "react-hook-form": "^7.88.0", + "react-hook-form": "^7.89.0", "react-i18next": "^17.0.15", "react-router": "^8.4.0", "tailwind-merge": "^3.7.0", @@ -47,7 +47,7 @@ "devDependencies": { "@hey-api/openapi-ts": "0.0.0-next-20260824173136", "@hey-api/vite-plugin": "0.3.2", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index fce755b..57d94ce 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -171,16 +171,16 @@ importers: version: 5.3.0 '@hookform/resolvers': specifier: ^5.9.1 - version: 5.9.1(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.88.0(react@19.3.0))(zod@4.6.5) + version: 5.9.1(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0))(zod@4.6.5) '@radix-ui/themes': specifier: ^3.3.0 version: 3.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tailwindcss/vite': specifier: ^4.3.3 - version: 4.3.3(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) '@tanstack/react-query': - specifier: ^5.103.2 - version: 5.103.2(react@19.3.0) + specifier: ^5.104.0 + version: 5.104.0(react@19.3.0) '@videojs/google-cast': specifier: 10.0.0-rc.4 version: 10.0.0-rc.4 @@ -230,8 +230,8 @@ importers: specifier: ^19.3.0 version: 19.3.0(react@19.3.0) react-hook-form: - specifier: ^7.88.0 - version: 7.88.0(react@19.3.0) + specifier: ^7.89.0 + version: 7.89.0(@types/react@19.3.0)(react@19.3.0) react-i18next: specifier: ^17.0.15 version: 17.0.15(i18next@26.4.2(typescript@7.0.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) @@ -259,10 +259,10 @@ importers: version: 0.0.0-next-20260824173136 '@hey-api/vite-plugin': specifier: 0.3.2 - version: 0.3.2(@hey-api/openapi-ts@0.0.0-next-20260824173136)(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0)) + version: 0.3.2(@hey-api/openapi-ts@0.0.0-next-20260824173136)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) '@types/node': - specifier: ^26.6.2 - version: 26.6.2 + specifier: ^26.6.3 + version: 26.6.3 '@types/react': specifier: ^19.3.0 version: 19.3.0 @@ -271,7 +271,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.1.1 - version: 6.1.1(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) oxfmt: specifier: ^0.66.0 version: 0.66.0 @@ -289,7 +289,7 @@ importers: version: 7.0.2 vite: specifier: ^8.3.1 - version: 8.3.1(@types/node@26.6.2)(jiti@2.7.0) + version: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) packages: @@ -1802,11 +1802,11 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/query-core@5.103.2': - resolution: {integrity: sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A==} + '@tanstack/query-core@5.104.0': + resolution: {integrity: sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==} - '@tanstack/react-query@5.103.2': - resolution: {integrity: sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw==} + '@tanstack/react-query@5.104.0': + resolution: {integrity: sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==} peerDependencies: react: ^18 || ^19 @@ -1816,8 +1816,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@26.6.2': - resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} + '@types/node@26.6.3': + resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} @@ -2123,8 +2123,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bundle-name@4.1.0: - resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + bundle-name@4.1.1: + resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} engines: {node: '>=18'} bytes@3.1.2: @@ -3230,11 +3230,15 @@ packages: peerDependencies: react: ^19.3.0 - react-hook-form@7.88.0: - resolution: {integrity: sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==} + react-hook-form@7.89.0: + resolution: {integrity: sha512-vKcoCfy8RKZDrhSdqsRFp0uEOdS1AhLjOL6hR7Wmfzg1akNxEhaPf5WORx8SgEQPVXakdDyDkqQp+KNxVnnPHg==} engines: {node: '>=18.0.0'} peerDependencies: + '@types/react': '*' react: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true react-i18next@17.0.15: resolution: {integrity: sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==} @@ -3955,19 +3959,19 @@ snapshots: '@hey-api/types@0.1.4': {} - '@hey-api/vite-plugin@0.3.2(@hey-api/openapi-ts@0.0.0-next-20260824173136)(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0))': + '@hey-api/vite-plugin@0.3.2(@hey-api/openapi-ts@0.0.0-next-20260824173136)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0))': dependencies: '@hey-api/openapi-ts': 0.0.0-next-20260824173136 - vite: 8.3.1(@types/node@26.6.2)(jiti@2.7.0) + vite: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) '@hono/node-server@2.1.1(hono@4.13.9)': dependencies: hono: 4.13.9 - '@hookform/resolvers@5.9.1(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.88.0(react@19.3.0))(zod@4.6.5)': + '@hookform/resolvers@5.9.1(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0))(zod@4.6.5)': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.88.0(react@19.3.0) + react-hook-form: 7.89.0(@types/react@19.3.0)(react@19.3.0) optionalDependencies: ajv: 8.20.0 ajv-formats: 2.1.1(ajv@8.20.0) @@ -5041,18 +5045,18 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.3(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.3.1(@types/node@26.6.2)(jiti@2.7.0) + vite: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) - '@tanstack/query-core@5.103.2': {} + '@tanstack/query-core@5.104.0': {} - '@tanstack/react-query@5.103.2(react@19.3.0)': + '@tanstack/react-query@5.104.0(react@19.3.0)': dependencies: - '@tanstack/query-core': 5.103.2 + '@tanstack/query-core': 5.104.0 react: 19.3.0 '@ts-morph/common@0.27.0': @@ -5063,7 +5067,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@26.6.2': + '@types/node@26.6.3': dependencies: undici-types: 8.9.0 @@ -5199,10 +5203,10 @@ snapshots: '@videojs/utils@10.0.0-rc.4': {} - '@vitejs/plugin-react@6.1.1(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0))': + '@vitejs/plugin-react@6.1.1(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.1(@types/node@26.6.2)(jiti@2.7.0) + vite: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) accepts@2.0.0: dependencies: @@ -5276,7 +5280,7 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.1) - bundle-name@4.1.0: + bundle-name@4.1.1: dependencies: run-applescript: 7.1.0 @@ -5412,7 +5416,7 @@ snapshots: default-browser@5.5.1: dependencies: - bundle-name: 4.1.0 + bundle-name: 4.1.1 default-browser-id: 5.0.1 define-lazy-prop@2.0.0: {} @@ -6300,9 +6304,11 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 - react-hook-form@7.88.0(react@19.3.0): + react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0): dependencies: react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 react-i18next@17.0.15(i18next@26.4.2(typescript@7.0.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2): dependencies: @@ -6683,7 +6689,7 @@ snapshots: verkit@0.3.0: {} - vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0): + vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -6691,7 +6697,7 @@ snapshots: rolldown: 1.2.11 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.3 fsevents: 2.3.3 jiti: 2.7.0 diff --git a/web/src/locales/en/translation.json b/web/src/locales/en/translation.json index bec8bda..7ef514b 100644 --- a/web/src/locales/en/translation.json +++ b/web/src/locales/en/translation.json @@ -378,6 +378,8 @@ "metadataScanDescription": "Probe video files in this library and refresh cached metadata.", "thumbnailsWarmTitle": "Warm thumbnails", "thumbnailsWarmDescription": "Generate missing video posters for this library.", + "skipIntroDetectTitle": "Detect skip intro", + "skipIntroDetectDescription": "Fingerprint season audio offline to find shared opening themes when chapters are missing.", "librariesScanTitle": "Prune libraries", "librariesScanDescription": "Remove registry folders whose directories are missing on disk. Does not create libraries.", "cachePurgeTitle": "Purge playback cache", diff --git a/web/src/locales/ru/translation.json b/web/src/locales/ru/translation.json index dbfd150..86da707 100644 --- a/web/src/locales/ru/translation.json +++ b/web/src/locales/ru/translation.json @@ -378,6 +378,8 @@ "metadataScanDescription": "Проб видеофайлов в этой библиотеке и обновление кэша метаданных.", "thumbnailsWarmTitle": "Прогрев превью", "thumbnailsWarmDescription": "Создать недостающие постеры видео для этой библиотеки.", + "skipIntroDetectTitle": "Определение Skip intro", + "skipIntroDetectDescription": "Офлайн-отпечатки аудио сезона для поиска общей заставки, если нет глав.", "librariesScanTitle": "Очистить реестр", "librariesScanDescription": "Удаляет из реестра папки, которых уже нет на диске. Новые библиотеки не создаёт.", "cachePurgeTitle": "Очистка кэша воспроизведения", diff --git a/web/src/pages/admin/libraries/detail.tsx b/web/src/pages/admin/libraries/detail.tsx index df224db..901eda1 100644 --- a/web/src/pages/admin/libraries/detail.tsx +++ b/web/src/pages/admin/libraries/detail.tsx @@ -68,6 +68,7 @@ const THUMBS_ACTION = "thumbnails.warm"; const PROVIDERS_METADATA_ACTION = "providers.metadata"; const PROVIDERS_POSTERS_ACTION = "providers.posters"; const PROVIDERS_SUBTITLES_ACTION = "providers.subtitles"; +const SKIP_INTRO_ACTION = "playback.skip.detect"; const NONE_PROVIDER = "none"; const PROVIDER_LIBRARY_TYPES: LibraryType[] = ["film", "series"]; @@ -109,6 +110,7 @@ export default function AdminLibraryDetailPage() { const [providersSubtitlesValidationError, setProvidersSubtitlesValidationError] = useState< string | null >(null); + const [skipIntroValidationError, setSkipIntroValidationError] = useState(null); const librariesQuery = useQuery({ ...getApiAdminLibrariesOptions(), @@ -144,6 +146,8 @@ export default function AdminLibraryDetailPage() { const [providersPostersEnabled, setProvidersPostersEnabled] = useState(false); const [providersSubtitlesCron, setProvidersSubtitlesCron] = useState(""); const [providersSubtitlesEnabled, setProvidersSubtitlesEnabled] = useState(false); + const [skipIntroCron, setSkipIntroCron] = useState(""); + const [skipIntroEnabled, setSkipIntroEnabled] = useState(false); const [metadataProvider, setMetadataProvider] = useState(NONE_PROVIDER); const [posterProvider, setPosterProvider] = useState(NONE_PROVIDER); @@ -174,6 +178,7 @@ export default function AdminLibraryDetailPage() { const providersSubtitles = actions.find( (action) => action.action === PROVIDERS_SUBTITLES_ACTION, ); + const skipIntro = actions.find((action) => action.action === SKIP_INTRO_ACTION); setMetadataCron(metadata?.schedule?.cron ?? ""); setMetadataEnabled(Boolean(metadata?.schedule?.enabled)); setThumbsCron(thumbs?.schedule?.cron ?? ""); @@ -184,6 +189,8 @@ export default function AdminLibraryDetailPage() { setProvidersPostersEnabled(Boolean(providersPosters?.schedule?.enabled)); setProvidersSubtitlesCron(providersSubtitles?.schedule?.cron ?? ""); setProvidersSubtitlesEnabled(Boolean(providersSubtitles?.schedule?.enabled)); + setSkipIntroCron(skipIntro?.schedule?.cron ?? ""); + setSkipIntroEnabled(Boolean(skipIntro?.schedule?.enabled)); } if (providersQuery.data && hydratedProvidersId !== id) { @@ -387,6 +394,7 @@ export default function AdminLibraryDetailPage() { const providersSubtitlesStatus = actions.find( (action) => action.action === PROVIDERS_SUBTITLES_ACTION, ); + const skipIntroStatus = actions.find((action) => action.action === SKIP_INTRO_ACTION); const available: ProviderAvailability = providersQuery.data?.available ?? { metadata: [], poster: [], @@ -638,6 +646,35 @@ export default function AdminLibraryDetailPage() { }} /> + {library?.type === "series" ? ( + { + void saveMaintenance( + { + action: SKIP_INTRO_ACTION, + cron: skipIntroCron, + enabled: skipIntroEnabled, + }, + setSkipIntroValidationError, + false, + ); + }} + onRun={() => { + runMutation.mutate(SKIP_INTRO_ACTION); + }} + /> + ) : null} + {supportsProviders ? (