Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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{
Expand All @@ -294,6 +304,7 @@ func initAuthServices( //nolint:funlen // composition root: DB + services wiring
Indexer: indexer,
Trash: trashService,
Providers: providerEnricher,
SkipIntro: skipIntroService,
},
)

Expand All @@ -317,6 +328,7 @@ func initAuthServices( //nolint:funlen // composition root: DB + services wiring
networkSettings,
networkLive,
trashService,
skipIntroService,
pool,
), nil
}
Expand All @@ -336,6 +348,7 @@ func newAuthBundle(
networkSettings *network.KVSettingsStore,
networkLive *network.Live,
trashService *trash.Service,
skipIntroService *skipsegment.Service,
pool *db.Database,
) *authBundle {
return &authBundle{
Expand All @@ -353,6 +366,7 @@ func newAuthBundle(
networkSettings: networkSettings,
networkLive: networkLive,
trash: trashService,
skipIntro: skipIntroService,
database: pool,
}
}
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
25 changes: 25 additions & 0 deletions internal/db/migrations/00022_skip_intro_segments.sql
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions internal/httpapi/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
25 changes: 22 additions & 3 deletions internal/httpapi/play_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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),
})
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/httpapi/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -119,6 +121,7 @@ func RegisterRoutes(
transcode: transcodeSvc,
trash: cfg.Trash,
usersub: cfg.UserSubtitle,
skipIntro: cfg.SkipIntro,
dbPing: cfg.DBPing,
}
if mediaHandler.usersub == nil {
Expand Down
17 changes: 17 additions & 0 deletions internal/maintenance/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
14 changes: 14 additions & 0 deletions internal/maintenance/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions internal/maintenance/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -220,6 +226,7 @@ func (s *Service) GetLibraryMaintenance(
return s.actionStatuses(ctx, []string{
ActionMetadataScan, ActionThumbnailsWarm,
ActionProvidersMetadata, ActionProvidersPosters, ActionProvidersSubtitles,
ActionPlaybackSkipDetect,
}, &libraryID)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/maintenance/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
5 changes: 4 additions & 1 deletion internal/maintenance/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/maintenance/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion internal/skipsegment/chapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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 == "" {
Expand Down
Loading
Loading