diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 15763888..487745f7 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "errors" "flag" "fmt" "io/fs" @@ -21,6 +22,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/go-hclog" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -46,6 +48,8 @@ import ( "github.com/Silo-Server/silo-server/internal/libraryingest" "github.com/Silo-Server/silo-server/internal/logfilter" "github.com/Silo-Server/silo-server/internal/logstream" + "github.com/Silo-Server/silo-server/internal/markers" + "github.com/Silo-Server/silo-server/internal/markers/introdb" "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/metadata" "github.com/Silo-Server/silo-server/internal/models" @@ -418,6 +422,39 @@ func main() { slog.Default(), ) } + if deps.DB != nil { + markerRegistry := markers.NewRegistry(slog.Default()) + introdbAPIKey, err := settingsRepo.Get(appCtx, "introdb.api_key") + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + slog.Warn("load introdb.api_key from settings failed; provider will run with no key", + "error", err) + introdbAPIKey = "" + } + introdbClient := introdb.NewClient(introdbAPIKey) + if err := markerRegistry.Register(introdb.NewProvider(introdbClient)); err != nil { + log.Fatalf("register introdb marker provider: %v", err) + } + deps.OnServerSettingUpdated = func(_ context.Context, key, value string) { + if key == "introdb.api_key" { + introdbClient.SetAPIKey(value) + } + } + if eventBus != nil { + _ = eventBus.Subscribe(appCtx, cache.ChannelAdmin, func(event cache.Event) { + if event.Type != cache.EventSettingsChanged || event.Payload != "introdb.api_key" { + return + } + value, loadErr := settingsRepo.Get(context.Background(), "introdb.api_key") + if loadErr != nil { + slog.Warn("introdb api key reload failed", "error", loadErr) + return + } + introdbClient.SetAPIKey(value) + }) + } + deps.MarkerRegistry = markerRegistry + deps.MarkerResolver = markers.NewDBExternalIDResolver(deps.DB) + } var watchProviderService *watchsync.Service if deps.DB != nil { watchProviderRegistry := watchsync.NewRegistry() diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 356c73f3..c5b02de0 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -90,6 +90,7 @@ type AdminHandler struct { BootstrapSensitiveConfigured map[string]bool BootstrapSensitiveValues map[string]string OnUserSessionsRevoked func(ctx context.Context, userID int) + OnServerSettingUpdated func(ctx context.Context, key, value string) } // NewAdminHandler creates a new AdminHandler backed by the given @@ -1022,6 +1023,7 @@ var sensitiveSettingKeys = map[string]bool{ "recommendations.openai_api_key": true, "recommendations.embedding_auth_token": true, "tmdb.api_key": true, + "introdb.api_key": true, "mdblist.api_key": true, "watchsync.trakt.client_id": true, "watchsync.trakt.client_secret": true, @@ -1890,7 +1892,10 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques if h.EventBus != nil { _ = h.EventBus.Publish(r.Context(), cache.ChannelAdmin, - cache.Event{Type: cache.EventSettingsChanged}) + cache.Event{Type: cache.EventSettingsChanged, Payload: key}) + } + if h.OnServerSettingUpdated != nil { + h.OnServerSettingUpdated(r.Context(), key, req.Value) } if sensitiveSettingKeys[key] { diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index e0b9d602..ad4ec350 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -130,6 +130,8 @@ type PlaybackHandler struct { IntroAnalyzer IntroEpisodeAnalyzer IntroRepository PlaybackIntroEligibilityChecker MarkerRegistry *markers.Registry + MarkerResolver markers.ExternalIDResolver + MarkerUpserter PlaybackMarkerUpserter MarkerUpdateNotifier PlaybackMarkerUpdateNotifier MarkerLazyContext context.Context MarkerLazyInFlight sync.Map diff --git a/internal/api/handlers/playback_lazy_markers.go b/internal/api/handlers/playback_lazy_markers.go index d294ee3d..0c36b5b2 100644 --- a/internal/api/handlers/playback_lazy_markers.go +++ b/internal/api/handlers/playback_lazy_markers.go @@ -9,18 +9,27 @@ import ( "github.com/Silo-Server/silo-server/internal/markers" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/scanner" ) const playbackLazyMarkerTimeout = 10 * time.Minute type PlaybackIntroEligibilityChecker interface { IntroDetectionEligibleForPlayback(ctx context.Context, fileID int) (bool, error) + IsFileInEnabledLibrary(ctx context.Context, fileID int) (bool, error) } type PlaybackMarkerUpdateNotifier interface { MarkersUpdated(ctx context.Context, file *models.MediaFile) } +// PlaybackMarkerUpserter narrows scanner.FileRepository down to just the +// marker write path so tests can supply a fake without dragging in the +// full repository. +type PlaybackMarkerUpserter interface { + UpsertMarkers(ctx context.Context, fileID int, update scanner.MarkerUpdate) (bool, error) +} + func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( ctx context.Context, session *playback.Session, @@ -29,10 +38,12 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( if h == nil || session == nil || file == nil || file.ID <= 0 { return } - if file.IntroStart != nil && file.IntroEnd != nil { + if file.MediaFolderID <= 0 { return } - if strings.TrimSpace(file.EpisodeID) == "" || file.MediaFolderID <= 0 { + isEpisode := strings.TrimSpace(file.EpisodeID) != "" + isMovie := !isEpisode && strings.TrimSpace(file.ContentID) != "" + if !isEpisode && !isMovie { return } if h.SettingsRepo == nil || h.IntroRepository == nil { @@ -52,7 +63,6 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( return } - mode := markers.ModeLocal rawMode, err := h.SettingsRepo.Get(ctx, markers.SettingMode) if err != nil { slog.Warn("playback lazy markers: load marker mode failed", @@ -62,7 +72,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( "error", err) return } - mode = markers.NormalizeMode(rawMode) + mode := markers.NormalizeMode(rawMode) if mode == markers.ModeOff { slog.Debug("playback lazy markers: skipped; marker mode is off", "session_id", session.ID, @@ -71,39 +81,48 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( return } - eligible, err := h.IntroRepository.IntroDetectionEligibleForPlayback(ctx, file.ID) - if err != nil { - slog.Warn("playback lazy markers: eligibility check failed", - "session_id", session.ID, - "file_id", file.ID, - "episode_id", file.EpisodeID, - "mode", mode, - "error", err) - return - } - if !eligible { - return - } - hasOnline := h.hasOnlineMarkerProviders() shouldRunLocal := markers.ShouldRunLocal(mode) - if mode == markers.ModeOnline && !hasOnline { - slog.Debug("playback lazy markers: skipped; online-only mode has no providers", - "session_id", session.ID, - "file_id", file.ID, - "episode_id", file.EpisodeID) - return + shouldRunOnline := (mode == markers.ModeOnline || mode == markers.ModeBoth) && hasOnline + if shouldRunOnline && hasOnlineSourcedMarkers(file) { + shouldRunOnline = false } - if !shouldRunLocal && mode != markers.ModeOnline { - slog.Debug("playback lazy markers: skipped; marker mode does not allow playback detection", - "session_id", session.ID, - "file_id", file.ID, - "episode_id", file.EpisodeID, - "mode", mode) - return + + if shouldRunOnline { + // Online providers work for any enabled library (movies and series alike). + ok, err := h.IntroRepository.IsFileInEnabledLibrary(ctx, file.ID) + if err != nil { + slog.Warn("playback lazy markers: online eligibility check failed", + "session_id", session.ID, + "file_id", file.ID, + "error", err) + shouldRunOnline = false + } + if !ok { + shouldRunOnline = false + } } - if shouldRunLocal && h.IntroAnalyzer == nil { - slog.Warn("playback lazy markers: local analyzer unavailable", + + if shouldRunLocal { + // Local chromaprint is only meaningful for series libraries that + // opted in to expensive fingerprinting and requires an analyzer. + ok, err := h.IntroRepository.IntroDetectionEligibleForPlayback(ctx, file.ID) + if err != nil { + slog.Warn("playback lazy markers: local eligibility check failed", + "session_id", session.ID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode, + "error", err) + shouldRunLocal = false + } + if !ok || h.IntroAnalyzer == nil || !isEpisode { + shouldRunLocal = false + } + } + + if !shouldRunOnline && !shouldRunLocal { + slog.Debug("playback lazy markers: skipped; no eligible detection path", "session_id", session.ID, "file_id", file.ID, "episode_id", file.EpisodeID, @@ -121,11 +140,19 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( "session_id", sessionID, "file_id", file.ID, "episode_id", file.EpisodeID, - "mode", mode) - go h.runLazyPlaybackMarkers(sessionID, &fileSnapshot, mode) + "mode", mode, + "run_online", shouldRunOnline, + "run_local", shouldRunLocal) + go h.runLazyPlaybackMarkers(sessionID, &fileSnapshot, mode, shouldRunOnline, shouldRunLocal) } -func (h *PlaybackHandler) runLazyPlaybackMarkers(sessionID string, file *models.MediaFile, mode markers.Mode) { +func (h *PlaybackHandler) runLazyPlaybackMarkers( + sessionID string, + file *models.MediaFile, + mode markers.Mode, + runOnline bool, + runLocal bool, +) { if file == nil { return } @@ -144,8 +171,7 @@ func (h *PlaybackHandler) runLazyPlaybackMarkers(sessionID string, file *models. "episode_id", file.EpisodeID, "mode", mode) - hasOnline := h.hasOnlineMarkerProviders() - if (mode == markers.ModeOnline || mode == markers.ModeBoth) && hasOnline { + if runOnline { wrote, err := h.fetchOnlineMarkersForPlayback(ctx, file) if err != nil { slog.Warn("playback lazy markers: online fetch failed", @@ -156,28 +182,25 @@ func (h *PlaybackHandler) runLazyPlaybackMarkers(sessionID string, file *models. "error", err) } if wrote { - if refreshed := h.reloadPlaybackMarkerFile(ctx, file.ID); hasIntroMarker(refreshed) { + if refreshed := h.reloadPlaybackMarkerFile(ctx, file.ID); hasAnyMarker(refreshed) { h.notifyPlaybackMarkers(ctx, sessionID, refreshed, mode) - return + if !runLocal || hasLocalDetectionMarkers(refreshed) { + return + } } } } - refreshed := h.reloadPlaybackMarkerFile(ctx, file.ID) - if hasIntroMarker(refreshed) { + // A concurrent session may have populated markers since we queued; check + // before falling through to the (expensive) local analyzer. + if refreshed := h.reloadPlaybackMarkerFile(ctx, file.ID); hasAnyMarker(refreshed) { h.notifyPlaybackMarkers(ctx, sessionID, refreshed, mode) - return - } - - if markers.ShouldRunLocal(mode) { - if h.IntroAnalyzer == nil { - slog.Warn("playback lazy markers: local analyzer unavailable", - "session_id", sessionID, - "file_id", file.ID, - "episode_id", file.EpisodeID, - "mode", mode) + if !runLocal || hasLocalDetectionMarkers(refreshed) { return } + } + + if runLocal { slog.Info("playback lazy markers: local analyzer started", "session_id", sessionID, "file_id", file.ID, @@ -206,8 +229,7 @@ func (h *PlaybackHandler) runLazyPlaybackMarkers(sessionID string, file *models. "fingerprints_computed", summary.FingerprintsComputed, "errors", len(summary.Errors)) - refreshed = h.reloadPlaybackMarkerFile(ctx, file.ID) - if hasIntroMarker(refreshed) { + if refreshed := h.reloadPlaybackMarkerFile(ctx, file.ID); hasAnyMarker(refreshed) { h.notifyPlaybackMarkers(ctx, sessionID, refreshed, mode) } } @@ -217,20 +239,56 @@ func (h *PlaybackHandler) hasOnlineMarkerProviders() bool { return h != nil && h.MarkerRegistry != nil && len(h.MarkerRegistry.Providers()) > 0 } +// fetchOnlineMarkersForPlayback resolves external IDs for the given file, +// asks the marker registry for the first hit, and persists the result via +// the scanner upserter. Returns true when at least one segment was +// actually written to storage. func (h *PlaybackHandler) fetchOnlineMarkersForPlayback(ctx context.Context, file *models.MediaFile) (bool, error) { if h == nil || file == nil || !h.hasOnlineMarkerProviders() { return false, nil } + if h.MarkerResolver == nil || h.MarkerUpserter == nil { + slog.Debug("playback lazy markers: online fetch skipped; resolver or upserter missing", + "file_id", file.ID) + return false, nil + } - // The provider abstraction exists, but this repo does not yet wire durable - // external IDs into playback file state. Keep online playback fetch as a - // structured no-op until a marker provider contract can be supplied safely. - slog.Debug("playback lazy markers: online fetch skipped; provider request identity unavailable", - "file_id", file.ID, - "episode_id", file.EpisodeID, - "season_number", file.SeasonNumber, - "episode_number", file.EpisodeNumber) - return false, nil + ids, err := h.MarkerResolver.ResolveForFile(ctx, file) + if err != nil { + return false, err + } + if !ids.HasAnyID() { + slog.Debug("playback lazy markers: online fetch skipped; no external IDs available", + "file_id", file.ID, + "episode_id", file.EpisodeID, + "content_id", file.ContentID) + return false, nil + } + + req := markers.Request{ + Kind: ids.Kind, + ExternalIDs: ids.AsRequestMap(), + SeasonNumber: ids.SeasonNumber, + EpisodeNumber: ids.EpisodeNumber, + Duration: time.Duration(file.Duration) * time.Second, + } + result, ok, err := h.MarkerRegistry.FetchFirstHit(ctx, req) + if err != nil { + return false, err + } + if !ok || len(result.Markers) == 0 { + return false, nil + } + + payload := markers.BuildUpdatePayload(result) + if !payload.HasAnySegment() { + return false, nil + } + wrote, err := h.MarkerUpserter.UpsertMarkers(ctx, file.ID, markerUpdateFromPayload(payload)) + if err != nil { + return false, err + } + return wrote, nil } func (h *PlaybackHandler) reloadPlaybackMarkerFile(ctx context.Context, fileID int) *models.MediaFile { @@ -262,6 +320,70 @@ func (h *PlaybackHandler) notifyPlaybackMarkers( "mode", mode) } -func hasIntroMarker(file *models.MediaFile) bool { - return file != nil && file.IntroStart != nil && file.IntroEnd != nil +// hasOnlineSourcedMarkers reports whether the file already has at least one +// marker written by a non-local source. We short-circuit lazy online fetch +// in that case to avoid refetching every playback start — TheIntroDB often +// returns only intro+credits and never recap/preview for a given episode, so +// requiring all four kinds before skipping would loop forever on partial data. +// Markers from the scanner/s3 path remain refetchable since online sources +// outrank them. +func hasOnlineSourcedMarkers(file *models.MediaFile) bool { + if file == nil { + return false + } + isOnlineSource := func(source *string) bool { + if source == nil { + return false + } + switch *source { + case models.MarkerSourceOnline, models.MarkerSourcePlugin, models.MarkerSourceManual: + return true + } + return false + } + return isOnlineSource(file.IntroMarkersSource) || + isOnlineSource(file.CreditsMarkersSource) || + isOnlineSource(file.RecapMarkersSource) || + isOnlineSource(file.PreviewMarkersSource) +} + +// hasAnyMarker reports whether the file has at least one populated marker +// segment. Used to decide whether to emit a markers_updated event. +func hasAnyMarker(file *models.MediaFile) bool { + if file == nil { + return false + } + return (file.IntroStart != nil && file.IntroEnd != nil) || + (file.CreditsStart != nil && file.CreditsEnd != nil) || + (file.RecapStart != nil && file.RecapEnd != nil) || + (file.PreviewStart != nil && file.PreviewEnd != nil) +} + +func hasLocalDetectionMarkers(file *models.MediaFile) bool { + if file == nil { + return false + } + return (file.IntroStart != nil && file.IntroEnd != nil) || + (file.CreditsStart != nil && file.CreditsEnd != nil) +} + +// markerUpdateFromPayload adapts the generic markers.MarkerUpdatePayload to +// the scanner.MarkerUpdate shape consumed by FileRepository.UpsertMarkers. +// Kept narrow on purpose — the packages it bridges should not need to import +// each other. +func markerUpdateFromPayload(p markers.MarkerUpdatePayload) scanner.MarkerUpdate { + return scanner.MarkerUpdate{ + IntroStart: p.IntroStart, + IntroEnd: p.IntroEnd, + CreditsStart: p.CreditsStart, + CreditsEnd: p.CreditsEnd, + RecapStart: p.RecapStart, + RecapEnd: p.RecapEnd, + PreviewStart: p.PreviewStart, + PreviewEnd: p.PreviewEnd, + MarkersSource: p.Source, + MarkersProvider: p.Provider, + MarkersConfidence: p.Confidence, + MarkersAlgorithm: p.Algorithm, + } } diff --git a/internal/api/handlers/playback_lazy_markers_test.go b/internal/api/handlers/playback_lazy_markers_test.go index 4eb09940..b3b73a10 100644 --- a/internal/api/handlers/playback_lazy_markers_test.go +++ b/internal/api/handlers/playback_lazy_markers_test.go @@ -69,6 +69,10 @@ func (e fakePlaybackIntroEligibility) IntroDetectionEligibleForPlayback(context. return e.eligible, e.err } +func (e fakePlaybackIntroEligibility) IsFileInEnabledLibrary(context.Context, int) (bool, error) { + return e.eligible, e.err +} + type fakePlaybackMarkerFileResolver struct { mu sync.Mutex file *models.MediaFile diff --git a/internal/api/handlers/profiles.go b/internal/api/handlers/profiles.go index d66c19f4..e073e92b 100644 --- a/internal/api/handlers/profiles.go +++ b/internal/api/handlers/profiles.go @@ -52,6 +52,8 @@ type createProfileRequest struct { SubtitleMode string `json:"subtitle_mode,omitempty"` AutoSkipIntro bool `json:"auto_skip_intro"` AutoSkipCredits bool `json:"auto_skip_credits"` + AutoSkipRecap bool `json:"auto_skip_recap"` + AutoPlayNextPreview bool `json:"auto_play_next_preview"` ShowForcedSubtitles *bool `json:"show_forced_subtitles,omitempty"` LibraryRestrictionsEnabled bool `json:"library_restrictions_enabled"` AllowedLibraryIDs []int `json:"allowed_library_ids"` @@ -70,6 +72,8 @@ type updateProfileRequest struct { SubtitleMode *string `json:"subtitle_mode,omitempty"` AutoSkipIntro *bool `json:"auto_skip_intro,omitempty"` AutoSkipCredits *bool `json:"auto_skip_credits,omitempty"` + AutoSkipRecap *bool `json:"auto_skip_recap,omitempty"` + AutoPlayNextPreview *bool `json:"auto_play_next_preview,omitempty"` ShowForcedSubtitles *bool `json:"show_forced_subtitles,omitempty"` LibraryRestrictionsEnabled *bool `json:"library_restrictions_enabled,omitempty"` AllowedLibraryIDs *[]int `json:"allowed_library_ids,omitempty"` @@ -96,6 +100,8 @@ type profileResponse struct { SubtitleMode string `json:"subtitle_mode,omitempty"` AutoSkipIntro bool `json:"auto_skip_intro"` AutoSkipCredits bool `json:"auto_skip_credits"` + AutoSkipRecap bool `json:"auto_skip_recap"` + AutoPlayNextPreview bool `json:"auto_play_next_preview"` ShowForcedSubtitles bool `json:"show_forced_subtitles"` LibraryRestrictionsEnabled bool `json:"library_restrictions_enabled"` AllowedLibraryIDs []int `json:"allowed_library_ids"` @@ -354,6 +360,8 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ SubtitleMode: req.SubtitleMode, AutoSkipIntro: req.AutoSkipIntro, AutoSkipCredits: req.AutoSkipCredits, + AutoSkipRecap: req.AutoSkipRecap, + AutoPlayNextPreview: req.AutoPlayNextPreview, ShowForcedSubtitles: showForcedSubtitles, LibraryRestrictionsEnabled: req.LibraryRestrictionsEnabled, AllowedLibraryIDs: req.AllowedLibraryIDs, @@ -501,6 +509,8 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ SubtitleMode: req.SubtitleMode, AutoSkipIntro: req.AutoSkipIntro, AutoSkipCredits: req.AutoSkipCredits, + AutoSkipRecap: req.AutoSkipRecap, + AutoPlayNextPreview: req.AutoPlayNextPreview, ShowForcedSubtitles: req.ShowForcedSubtitles, LibraryRestrictionsEnabled: req.LibraryRestrictionsEnabled, AllowedLibraryIDs: req.AllowedLibraryIDs, @@ -678,6 +688,8 @@ func (h *ProfileHandler) toProfileResponse(ctx context.Context, p userstore.Prof SubtitleMode: p.SubtitleMode, AutoSkipIntro: p.AutoSkipIntro, AutoSkipCredits: p.AutoSkipCredits, + AutoSkipRecap: p.AutoSkipRecap, + AutoPlayNextPreview: p.AutoPlayNextPreview, ShowForcedSubtitles: p.ShowForcedSubtitles, LibraryRestrictionsEnabled: p.LibraryRestrictionsEnabled, AllowedLibraryIDs: append([]int(nil), p.AllowedLibraryIDs...), diff --git a/internal/api/handlers/settings.go b/internal/api/handlers/settings.go index 0630b048..4529df1a 100644 --- a/internal/api/handlers/settings.go +++ b/internal/api/handlers/settings.go @@ -131,6 +131,16 @@ var settingsRegistry = map[string]settingSpec{ DefaultValue: "false", Validate: validateBoolSetting("playback.auto_skip_credits"), }, + "playback.auto_skip_recap": { + Scope: scopeDevice, + DefaultValue: "false", + Validate: validateBoolSetting("playback.auto_skip_recap"), + }, + "playback.auto_play_next_preview": { + Scope: scopeDevice, + DefaultValue: "false", + Validate: validateBoolSetting("playback.auto_play_next_preview"), + }, "playback.auto_play_next": { Scope: scopeDevice, DefaultValue: "true", diff --git a/internal/api/router.go b/internal/api/router.go index cb80bce5..8b39d1c5 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -34,6 +34,7 @@ import ( "github.com/Silo-Server/silo-server/internal/intromarkers" "github.com/Silo-Server/silo-server/internal/libraryingest" "github.com/Silo-Server/silo-server/internal/logstream" + "github.com/Silo-Server/silo-server/internal/markers" "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/metadata" "github.com/Silo-Server/silo-server/internal/metadata/tmdb" @@ -112,6 +113,8 @@ type Dependencies struct { TaskManager *taskmanager.TaskManager // task manager (may be nil) IntroRepository *intromarkers.Repository IntroAnalyzer *intromarkers.Analyzer + MarkerRegistry *markers.Registry + MarkerResolver markers.ExternalIDResolver WatchProviderService handlers.WatchProviderService PluginService *plugins.Service PluginHTTPProxy *plugins.HTTPProxy @@ -128,6 +131,7 @@ type Dependencies struct { ChapterThumbnailQueuer catalog.ChapterThumbnailQueuer PlaybackRealtimeHub *playback.RealtimeHub OnUserSessionsRevoked func(ctx context.Context, userID int) + OnServerSettingUpdated func(ctx context.Context, key, value string) // UserCollectionSync handles per-profile imported collections (TMDB / // Trakt / MDBList) — the user-facing analogue of CollectionService. @@ -570,6 +574,11 @@ func NewRouter(deps Dependencies) chi.Router { playbackHandler.CommandDispatcher = playback.NewCommandDispatcher(deps.SessionMgr, realtimeHub, commandTracker) playbackHandler.IntroAnalyzer = deps.IntroAnalyzer playbackHandler.IntroRepository = deps.IntroRepository + playbackHandler.MarkerRegistry = deps.MarkerRegistry + playbackHandler.MarkerResolver = deps.MarkerResolver + if deps.FileRepo != nil { + playbackHandler.MarkerUpserter = deps.FileRepo + } playbackHandler.MarkerUpdateNotifier = playback.NewMarkerUpdateNotifier(deps.SessionMgr, realtimeHub) adminPlaybackControlHandler = handlers.NewAdminPlaybackControlHandler(playbackHandler) @@ -620,6 +629,9 @@ func NewRouter(deps Dependencies) chi.Router { if deps.OnUserSessionsRevoked != nil { adminHandler.OnUserSessionsRevoked = deps.OnUserSessionsRevoked } + if deps.OnServerSettingUpdated != nil { + adminHandler.OnServerSettingUpdated = deps.OnServerSettingUpdated + } } if deps.DB != nil { jobRepo := adminjob.NewRepository(deps.DB) diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index c2961251..99661385 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -131,9 +131,11 @@ type ItemDetail struct { // Aggregated subtitles across all versions. Subtitles []SubtitleInfo `json:"subtitles"` - // Intro/credits markers (from first file that has them). + // Intro/credits/recap/preview markers (from first file that has them). Intro *Marker `json:"intro,omitempty"` Credits *Marker `json:"credits,omitempty"` + Recap *Marker `json:"recap,omitempty"` + Preview *Marker `json:"preview,omitempty"` // Effective subtitle defaults for episode playback derived from // profile, library, and series-level preferences. @@ -231,6 +233,8 @@ type FileVersion struct { Chapters []VersionChapter `json:"chapters,omitempty"` Intro *Marker `json:"intro,omitempty"` Credits *Marker `json:"credits,omitempty"` + Recap *Marker `json:"recap,omitempty"` + Preview *Marker `json:"preview,omitempty"` } // PlaybackVariant is one logical watch choice, optionally spanning multiple ordered parts. @@ -332,6 +336,8 @@ type WatchDetail struct { Subtitles []SubtitleInfo `json:"subtitles"` Intro *Marker `json:"intro,omitempty"` Credits *Marker `json:"credits,omitempty"` + Recap *Marker `json:"recap,omitempty"` + Preview *Marker `json:"preview,omitempty"` UserData *SeasonUserData `json:"user_data,omitempty"` SeriesID string `json:"series_id,omitempty"` SeriesTitle string `json:"series_title,omitempty"` @@ -826,7 +832,7 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M files = FilterMediaFilesByAccess(files, filter) files = s.preparePlaybackFiles(ctx, files) - detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits = s.buildPlaybackInfo( + detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits, detail.Recap, detail.Preview = s.buildPlaybackInfo( ctx, files, filter, @@ -1096,7 +1102,7 @@ func (s *DetailService) buildEpisodeDetail(ctx context.Context, episode *models. } files = FilterMediaFilesByAccess(files, filter) files = s.preparePlaybackFiles(ctx, files) - detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits = s.buildPlaybackInfo( + detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits, detail.Recap, detail.Preview = s.buildPlaybackInfo( ctx, files, filter, @@ -1256,7 +1262,7 @@ func (s *DetailService) newWatchDetail( filter AccessFilter, audioPreferenceContentID string, ) *WatchDetail { - versions, playbackVariants, subtitles, intro, credits := s.buildPlaybackInfo( + versions, playbackVariants, subtitles, intro, credits, recap, preview := s.buildPlaybackInfo( ctx, files, filter, @@ -1272,6 +1278,8 @@ func (s *DetailService) newWatchDetail( Subtitles: subtitles, Intro: intro, Credits: credits, + Recap: recap, + Preview: preview, } } @@ -1547,16 +1555,25 @@ func bestPlayableFile(files []*models.MediaFile) *models.MediaFile { return best } +// markerFromRange converts a (start, end) pair into a *Marker, returning nil +// when either bound is missing. Used to lift the four per-file segment kinds +// (intro/credits/recap/preview) into the api response shape. +func markerFromRange(start, end *float64) *Marker { + if start == nil || end == nil { + return nil + } + return &Marker{Start: *start, End: *end} +} + func (s *DetailService) buildPlaybackInfo( ctx context.Context, files []*models.MediaFile, filter AccessFilter, audioPreferenceContentID string, -) ([]FileVersion, []PlaybackVariant, []SubtitleInfo, *Marker, *Marker) { +) ([]FileVersion, []PlaybackVariant, []SubtitleInfo, *Marker, *Marker, *Marker, *Marker) { versions := make([]FileVersion, 0, len(files)) subtitleSet := make(map[string]SubtitleInfo) - var firstIntro *Marker - var firstCredits *Marker + var firstIntro, firstCredits, firstRecap, firstPreview *Marker for _, f := range files { if f == nil { @@ -1568,19 +1585,21 @@ func (s *DetailService) buildPlaybackInfo( audioPreferenceContentID, f, ) - var versionIntro *Marker - if f.IntroStart != nil && f.IntroEnd != nil { - versionIntro = &Marker{Start: *f.IntroStart, End: *f.IntroEnd} - if firstIntro == nil { - firstIntro = versionIntro - } + versionIntro := markerFromRange(f.IntroStart, f.IntroEnd) + if versionIntro != nil && firstIntro == nil { + firstIntro = versionIntro } - var versionCredits *Marker - if f.CreditsStart != nil && f.CreditsEnd != nil { - versionCredits = &Marker{Start: *f.CreditsStart, End: *f.CreditsEnd} - if firstCredits == nil { - firstCredits = versionCredits - } + versionCredits := markerFromRange(f.CreditsStart, f.CreditsEnd) + if versionCredits != nil && firstCredits == nil { + firstCredits = versionCredits + } + versionRecap := markerFromRange(f.RecapStart, f.RecapEnd) + if versionRecap != nil && firstRecap == nil { + firstRecap = versionRecap + } + versionPreview := markerFromRange(f.PreviewStart, f.PreviewEnd) + if versionPreview != nil && firstPreview == nil { + firstPreview = versionPreview } versions = append(versions, FileVersion{ @@ -1611,6 +1630,8 @@ func (s *DetailService) buildPlaybackInfo( Chapters: s.buildVersionChapters(ctx, f), Intro: versionIntro, Credits: versionCredits, + Recap: versionRecap, + Preview: versionPreview, }) for _, sub := range f.SubtitleTracks { @@ -1649,20 +1670,19 @@ func (s *DetailService) buildPlaybackInfo( variants := buildPlaybackVariants(versions, filter.SelectedFileID) selectedVersionExists := playbackVersionExists(versions, filter.SelectedFileID) - intro := selectedPlaybackMarker(versions, variants, filter.SelectedFileID, func(v FileVersion) *Marker { - return v.Intro - }) - if intro == nil && !selectedVersionExists { - intro = firstIntro - } - credits := selectedPlaybackMarker(versions, variants, filter.SelectedFileID, func(v FileVersion) *Marker { - return v.Credits - }) - if credits == nil && !selectedVersionExists { - credits = firstCredits + pick := func(field func(v FileVersion) *Marker, fallback *Marker) *Marker { + m := selectedPlaybackMarker(versions, variants, filter.SelectedFileID, field) + if m == nil && !selectedVersionExists { + return fallback + } + return m } + intro := pick(func(v FileVersion) *Marker { return v.Intro }, firstIntro) + credits := pick(func(v FileVersion) *Marker { return v.Credits }, firstCredits) + recap := pick(func(v FileVersion) *Marker { return v.Recap }, firstRecap) + preview := pick(func(v FileVersion) *Marker { return v.Preview }, firstPreview) - return versions, variants, subtitles, intro, credits + return versions, variants, subtitles, intro, credits, recap, preview } func playbackVersionExists(versions []FileVersion, selectedFileID int) bool { diff --git a/internal/catalog/detail_version_prefs_test.go b/internal/catalog/detail_version_prefs_test.go index a3fc0f92..bda87185 100644 --- a/internal/catalog/detail_version_prefs_test.go +++ b/internal/catalog/detail_version_prefs_test.go @@ -219,7 +219,7 @@ func TestBuildPlaybackInfo_SetsEffectiveAudioLanguageFromOriginalWhenTrackLangua } service.SetUserStoreProvider(testDetailUserStoreProvider{store: store}) - versions, _, _, _, _ := service.buildPlaybackInfo(context.Background(), []*models.MediaFile{ + versions, _, _, _, _, _, _ := service.buildPlaybackInfo(context.Background(), []*models.MediaFile{ { ID: 7, ContentID: "movie-1", @@ -246,7 +246,7 @@ func TestBuildPlaybackInfo_SetsEffectiveAudioLanguageFromOriginalWhenTrackLangua func TestBuildPlaybackInfo_GroupsMultipartVariants(t *testing.T) { service := &DetailService{} - _, variants, _, _, _ := service.buildPlaybackInfo(context.Background(), []*models.MediaFile{ + _, variants, _, _, _, _, _ := service.buildPlaybackInfo(context.Background(), []*models.MediaFile{ { ID: 11, ContentID: "movie-1", @@ -294,7 +294,7 @@ func TestBuildPlaybackInfo_SelectedFileWithoutIntroDoesNotInheritAnotherVersionI introStart := 12.5 introEnd := 42.75 - versions, _, _, intro, _ := service.buildPlaybackInfo(context.Background(), []*models.MediaFile{ + versions, _, _, intro, _, _, _ := service.buildPlaybackInfo(context.Background(), []*models.MediaFile{ { ID: 11, ContentID: "movie-1", @@ -330,7 +330,7 @@ func TestBuildPlaybackInfo_NoSelectedFileKeepsAvailableIntroFallback(t *testing. introStart := 12.5 introEnd := 42.75 - _, _, _, intro, _ := service.buildPlaybackInfo(context.Background(), []*models.MediaFile{ + _, _, _, intro, _, _, _ := service.buildPlaybackInfo(context.Background(), []*models.MediaFile{ { ID: 11, ContentID: "movie-1", @@ -382,7 +382,7 @@ func TestBuildPlaybackInfo_StoresCreditsOnFileVersion(t *testing.T) { creditsStart := 1400.0 creditsEnd := 1500.0 - versions, _, _, _, credits := service.buildPlaybackInfo(context.Background(), []*models.MediaFile{ + versions, _, _, _, credits, _, _ := service.buildPlaybackInfo(context.Background(), []*models.MediaFile{ { ID: 11, ContentID: "movie-1", diff --git a/internal/intromarkers/repository.go b/internal/intromarkers/repository.go index e2b2a6b2..0c01f6ff 100644 --- a/internal/intromarkers/repository.go +++ b/internal/intromarkers/repository.go @@ -172,6 +172,29 @@ func (r *Repository) IntroDetectionEligibleForPlayback(ctx context.Context, file return true, nil } +// IsFileInEnabledLibrary returns true when the file lives in any enabled +// library, regardless of folder type or intro_detection_enabled. Online +// marker providers (TheIntroDB, etc.) gate on this rather than the +// stricter local-chromaprint-only check so movie libraries can participate +// without opting into expensive audio fingerprinting. +func (r *Repository) IsFileInEnabledLibrary(ctx context.Context, fileID int) (bool, error) { + var id int + err := r.pool.QueryRow(ctx, ` + SELECT mf.id + FROM media_files mf + JOIN media_folders folders ON folders.id = mf.media_folder_id + WHERE mf.id = $1 + AND folders.enabled = true + AND mf.missing_since IS NULL`, fileID).Scan(&id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("checking playback online marker eligibility: %w", err) + } + return true, nil +} + func scanCandidates(rows pgx.Rows) ([]Candidate, error) { defer rows.Close() var candidates []Candidate diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index 709b7f1a..f0d6d459 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -14,6 +14,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/Silo-Server/silo-server/internal/catalog" @@ -40,6 +41,9 @@ type ItemsHandler struct { accessFilter AccessFilterResolver subtitleRepo subtitles.Repository recommender recommendations.Recommender + // FileResolver is optional; when set, /MediaSegments returns real intro/ + // credits/recap/preview segments for any file that has them. + FileResolver FilePathResolver } // NewItemsHandler creates a new items handler. @@ -556,6 +560,135 @@ func (h *ItemsHandler) HandleItemStub(w http.ResponseWriter, r *http.Request) { }) } +// mediaSegmentDTO mirrors Jellyfin's MediaSegmentDto shape. Times are +// expressed in 100-nanosecond ticks (the convention shared with RunTimeTicks +// and chapter position fields). +type mediaSegmentDTO struct { + Id string `json:"Id"` + ItemId string `json:"ItemId"` + Type string `json:"Type"` + StartTicks int64 `json:"StartTicks"` + EndTicks int64 `json:"EndTicks"` +} + +// mediaSegmentsResultDTO is the paged envelope for /MediaSegments responses. +type mediaSegmentsResultDTO struct { + Items []mediaSegmentDTO `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` + StartIndex int `json:"StartIndex"` +} + +// HandleMediaSegments returns the intro/credits/recap/preview ranges for an +// item as a Jellyfin MediaSegments payload. Used by Jellyfin clients +// (JellyCon, Findroid, Infuse) to render skip buttons. +func (h *ItemsHandler) HandleMediaSegments(w http.ResponseWriter, r *http.Request) { + session := SessionFromContext(r.Context()) + if session == nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") + return + } + raw := chiURLParam(r, "id") + if raw == "" { + writeError(w, http.StatusBadRequest, "BadRequest", "Missing item id") + return + } + contentID, err := h.codec.DecodeStringID(EncodedIDItem, raw) + var requestedFileID int + if err != nil { + if fileID, fileErr := h.codec.DecodeIntID(EncodedIDMediaSource, raw); fileErr == nil { + if owner, ok := h.codec.LookupMediaSourceOwner(fileID); ok { + contentID = owner + requestedFileID = int(fileID) + } + } + } + if contentID == "" { + slog.Debug("jellycompat: media segments lookup with undecodable id", "raw_id", raw) + writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{Items: []mediaSegmentDTO{}}) + return + } + + detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil) + if err != nil { + slog.Warn("jellycompat: media segments item lookup failed", + "content_id", contentID, + "error", err) + writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{Items: []mediaSegmentDTO{}}) + return + } + if detail == nil { + writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{Items: []mediaSegmentDTO{}}) + return + } + // Versions carry the same Intro/Credits/Recap/Preview that the native API + // surfaces; the default-playback version owns the segments shown to clients. + var version *catalog.FileVersion + if requestedFileID > 0 { + for i := range detail.Versions { + if detail.Versions[i].FileID == requestedFileID { + version = &detail.Versions[i] + break + } + } + } + if requestedFileID == 0 { + for i := range detail.Versions { + if version == nil || (detail.Versions[i].FileID != 0 && version.FileID == 0) { + version = &detail.Versions[i] + } + } + } + if version == nil { + writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{Items: []mediaSegmentDTO{}}) + return + } + + segments := buildMediaSegmentDTOs(raw, version) + writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{ + Items: segments, + TotalRecordCount: len(segments), + StartIndex: 0, + }) +} + +// buildMediaSegmentDTOs converts the four optional marker ranges on a file +// version into the flat segment list shape Jellyfin clients expect. +func buildMediaSegmentDTOs(itemUUID string, version *catalog.FileVersion) []mediaSegmentDTO { + if version == nil { + return nil + } + segments := make([]mediaSegmentDTO, 0, 4) + add := func(kind string, marker *catalog.Marker) { + if marker == nil { + return + } + segments = append(segments, mediaSegmentDTO{ + Id: deriveSegmentID(itemUUID, kind), + ItemId: itemUUID, + Type: kind, + StartTicks: secondsToTicks(marker.Start), + EndTicks: secondsToTicks(marker.End), + }) + } + add("Intro", version.Intro) + add("Outro", version.Credits) + add("Recap", version.Recap) + add("Preview", version.Preview) + return segments +} + +// mediaSegmentIDNamespace is the fixed UUIDv5 namespace under which segment +// IDs are minted. A bespoke namespace ensures these IDs never collide with +// other UUIDs minted elsewhere in the codec, while remaining deterministic +// across processes and restarts. +var mediaSegmentIDNamespace = uuid.MustParse("9f1b2f4a-3c0d-5e16-9a87-2c4f8d0a1d9b") + +// deriveSegmentID produces a stable UUID for a (item, kind) pair so repeated +// GETs return the same Id (Jellyfin clients cache by Id). +func deriveSegmentID(itemUUID, kind string) string { + return uuid.NewSHA1(mediaSegmentIDNamespace, []byte(itemUUID+":"+kind)).String() +} + // HandleGroupingOptionsStub serves GET /UserViews/GroupingOptions with an empty array. // Jellyfin returns []SpecialViewOptionDto; Silo doesn't support library grouping. func (h *ItemsHandler) HandleGroupingOptionsStub(w http.ResponseWriter, r *http.Request) { diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index 07a4b44b..0cb16a92 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -145,7 +145,7 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/Shows/{id}/Episodes", itemsHandler.HandleEpisodes) r.Get("/Shows/NextUp", itemsHandler.HandleNextUp) r.Get("/Shows/Upcoming", itemsHandler.HandleUpcoming) - r.Get("/MediaSegments/{id}", itemsHandler.HandleItemStub) + r.Get("/MediaSegments/{id}", itemsHandler.HandleMediaSegments) r.Get("/Episode/{id}/Timestamps", itemsHandler.HandleItemStub) r.Get("/Episode/{id}/IntroTimestamps", itemsHandler.HandleItemStub) r.Get("/UserItems/Resume", itemsHandler.HandleResume) diff --git a/internal/markers/introdb/client.go b/internal/markers/introdb/client.go new file mode 100644 index 00000000..4a7f27ed --- /dev/null +++ b/internal/markers/introdb/client.go @@ -0,0 +1,225 @@ +package introdb + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/Silo-Server/silo-server/internal/cache" + "golang.org/x/time/rate" +) + +const ( + maxRetries = 3 + maxResponseBody = 1 << 20 // 1 MB + defaultTimeout = 15 * time.Second + defaultCacheTTL = 24 * time.Hour +) + +// Client is an HTTP client for the TheIntroDB /v3/media endpoint. Each +// instance has its own rate limiter and response cache; concurrent fetches +// for the same lookup key collapse to a single HTTP round trip via the cache. +type Client struct { + httpClient *http.Client + mu sync.RWMutex + apiKey string + baseURL string + limiter *rate.Limiter + cache *cache.TTLCache[*mediaResponse] + cacheTTL time.Duration +} + +// NewClient builds a Client with the canonical rate limit and cache TTL. +// The apiKey may be empty — TheIntroDB serves read traffic without a key, +// the key only gates access to the caller's own pending submissions. +func NewClient(apiKey string) *Client { + return &Client{ + httpClient: &http.Client{Timeout: defaultTimeout}, + apiKey: strings.TrimSpace(apiKey), + baseURL: DefaultBaseURL, + // TheIntroDB documents 30 requests / 10 seconds per IP. We stay + // conservatively below that: 2 req/s sustained, burst 5. + limiter: rate.NewLimiter(2, 5), + cache: cache.NewTTLCache[*mediaResponse](), + cacheTTL: defaultCacheTTL, + } +} + +// SetBaseURL overrides the API base URL (used by tests). +func (c *Client) SetBaseURL(u string) { + c.mu.Lock() + c.baseURL = u + c.mu.Unlock() +} + +// SetAPIKey rotates the bearer token in-place. Safe to call concurrently +// with in-flight requests; subsequent requests use the new key. +func (c *Client) SetAPIKey(apiKey string) { + c.mu.Lock() + c.apiKey = strings.TrimSpace(apiKey) + c.mu.Unlock() +} + +// Close releases the background sweeper goroutine inside the response cache. +func (c *Client) Close() { + if c.cache != nil { + c.cache.Close() + } +} + +// FetchEpisode looks up segment timestamps for a TV episode. +// At least one of tmdbID or imdbID must be non-empty. +func (c *Client) FetchEpisode(ctx context.Context, tmdbID, imdbID string, season, episode int, durationMS int64) (*mediaResponse, error) { + if tmdbID == "" && imdbID == "" { + return nil, fmt.Errorf("introdb: tmdb_id or imdb_id required") + } + if season <= 0 || episode <= 0 { + return nil, fmt.Errorf("introdb: episode lookup requires season and episode > 0 (got %d/%d)", season, episode) + } + q := url.Values{} + if tmdbID != "" { + q.Set("tmdb_id", tmdbID) + } else { + q.Set("imdb_id", imdbID) + } + q.Set("season", strconv.Itoa(season)) + q.Set("episode", strconv.Itoa(episode)) + if durationMS > 0 { + q.Set("duration_ms", strconv.FormatInt(durationMS, 10)) + } + return c.fetch(ctx, q, cacheKeyEpisode(tmdbID, imdbID, season, episode, durationMS)) +} + +// FetchMovie looks up segment timestamps for a movie. +// At least one of tmdbID or imdbID must be non-empty. +func (c *Client) FetchMovie(ctx context.Context, tmdbID, imdbID string, durationMS int64) (*mediaResponse, error) { + if tmdbID == "" && imdbID == "" { + return nil, fmt.Errorf("introdb: tmdb_id or imdb_id required") + } + q := url.Values{} + if tmdbID != "" { + q.Set("tmdb_id", tmdbID) + } else { + q.Set("imdb_id", imdbID) + } + if durationMS > 0 { + q.Set("duration_ms", strconv.FormatInt(durationMS, 10)) + } + return c.fetch(ctx, q, cacheKeyMovie(tmdbID, imdbID, durationMS)) +} + +func (c *Client) fetch(ctx context.Context, q url.Values, key string) (*mediaResponse, error) { + if cached, ok := c.cache.Get(key); ok { + return cached, nil + } + + if err := c.limiter.Wait(ctx); err != nil { + return nil, err + } + + c.mu.RLock() + baseURL := c.baseURL + apiKey := c.apiKey + c.mu.RUnlock() + + reqURL := baseURL + "/media?" + q.Encode() + + for attempt := 0; attempt <= maxRetries; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("introdb: create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Silo-Server/markers") + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("introdb: request failed: %w", err) + } + + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + // Cache negatives too so the next playback start doesn't trigger + // another fetch for known-empty content. + c.cache.Set(key, nil, c.cacheTTL) + return nil, nil + } + + if resp.StatusCode == http.StatusTooManyRequests { + resp.Body.Close() + if attempt < maxRetries { + backoff := retryAfterOrDefault(resp, attempt) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return nil, ctx.Err() + } + continue + } + return nil, fmt.Errorf("introdb: rate limited after %d retries", maxRetries) + } + + if resp.StatusCode >= 500 { + resp.Body.Close() + if attempt < maxRetries { + backoff := time.Duration(1<= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + resp.Body.Close() + return nil, fmt.Errorf("introdb: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var out mediaResponse + decodeErr := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&out) + resp.Body.Close() + if decodeErr != nil { + return nil, fmt.Errorf("introdb: decode response: %w", decodeErr) + } + c.cache.Set(key, &out, c.cacheTTL) + return &out, nil + } + return nil, fmt.Errorf("introdb: max retries exceeded") +} + +func retryAfterOrDefault(resp *http.Response, attempt int) time.Duration { + if val := resp.Header.Get("Retry-After"); val != "" { + if secs, err := strconv.Atoi(val); err == nil && secs > 0 { + return time.Duration(secs) * time.Second + } + } + return time.Duration(1< series) and movies (joined +// through media_items directly). +type ExternalIDResolver interface { + ResolveForFile(ctx context.Context, file *models.MediaFile) (ExternalIDs, error) +} + +// DBExternalIDResolver issues a single query per file against the postgres +// pool. Episodes pull from `episodes`; movies fall back to `media_items` +// via the file's content_id. +type DBExternalIDResolver struct { + pool *pgxpool.Pool +} + +// NewDBExternalIDResolver constructs a resolver backed by the supplied pool. +func NewDBExternalIDResolver(pool *pgxpool.Pool) *DBExternalIDResolver { + return &DBExternalIDResolver{pool: pool} +} + +// ResolveForFile fetches the external IDs for the given file. Returns the +// zero ExternalIDs and a nil error when the file cannot be resolved (e.g. +// unmatched media); callers should treat that as "no online lookup possible". +func (r *DBExternalIDResolver) ResolveForFile(ctx context.Context, file *models.MediaFile) (ExternalIDs, error) { + if r == nil || r.pool == nil || file == nil { + return ExternalIDs{}, nil + } + + episodeID := strings.TrimSpace(file.EpisodeID) + contentID := strings.TrimSpace(file.ContentID) + + if episodeID != "" { + // TheIntroDB indexes episode markers by show + season/episode, so we + // prefer the series-level external IDs and fall back to the episode + // row only if the series isn't matched yet. The episode row's + // season/episode numbers are authoritative; the media_files copy + // can drift during multi-version moves. + var epTmdb, epImdb, epTvdb, showTmdb, showImdb, showTvdb string + var season, episode int + err := r.pool.QueryRow(ctx, ` + SELECT COALESCE(NULLIF(e.tmdb_id, ''), ''), + COALESCE(NULLIF(e.imdb_id, ''), ''), + COALESCE(NULLIF(e.tvdb_id, ''), ''), + COALESCE(e.season_number, 0), + COALESCE(e.episode_number, 0), + COALESCE(NULLIF(mi.tmdb_id, ''), ''), + COALESCE(NULLIF(mi.imdb_id, ''), ''), + COALESCE(NULLIF(mi.tvdb_id, ''), '') + FROM episodes e + LEFT JOIN media_items mi ON mi.content_id = e.series_id + WHERE e.content_id = $1`, episodeID).Scan( + &epTmdb, &epImdb, &epTvdb, &season, &episode, + &showTmdb, &showImdb, &showTvdb, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ExternalIDs{}, nil + } + return ExternalIDs{}, fmt.Errorf("resolve episode external ids: %w", err) + } + tmdb := showTmdb + if tmdb == "" { + tmdb = epTmdb + } + imdb := showImdb + if imdb == "" { + imdb = epImdb + } + tvdb := showTvdb + if tvdb == "" { + tvdb = epTvdb + } + if season <= 0 { + season = file.SeasonNumber + } + if episode <= 0 { + episode = file.EpisodeNumber + } + return ExternalIDs{ + Kind: ItemKindEpisode, + TmdbID: tmdb, + ImdbID: imdb, + TvdbID: tvdb, + SeasonNumber: season, + EpisodeNumber: episode, + }, nil + } + + if contentID == "" { + return ExternalIDs{}, nil + } + + var tmdb, imdb, tvdb, itemType string + err := r.pool.QueryRow(ctx, ` + SELECT COALESCE(NULLIF(tmdb_id, ''), ''), + COALESCE(NULLIF(imdb_id, ''), ''), + COALESCE(NULLIF(tvdb_id, ''), ''), + COALESCE(type, '') + FROM media_items WHERE content_id = $1`, contentID).Scan(&tmdb, &imdb, &tvdb, &itemType) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ExternalIDs{}, nil + } + return ExternalIDs{}, fmt.Errorf("resolve movie external ids: %w", err) + } + if itemType != "movie" { + return ExternalIDs{}, nil + } + return ExternalIDs{ + Kind: ItemKindMovie, + TmdbID: tmdb, + ImdbID: imdb, + TvdbID: tvdb, + }, nil +} diff --git a/internal/markers/types.go b/internal/markers/types.go index 1cdcad61..a5dbb404 100644 --- a/internal/markers/types.go +++ b/internal/markers/types.go @@ -44,6 +44,16 @@ type MarkerKind int const ( MarkerKindIntro MarkerKind = iota + 1 MarkerKindCredits + MarkerKindRecap + MarkerKindPreview +) + +// Canonical keys for Request.ExternalIDs. Providers consult these so we +// don't scatter raw "tmdb"/"imdb" string literals across the codebase. +const ( + ExternalIDKeyTMDB = "tmdb" + ExternalIDKeyIMDB = "imdb" + ExternalIDKeyTVDB = "tvdb" ) type Request struct { @@ -57,6 +67,7 @@ type Request struct { type Result struct { SourceClass string ProviderID string + Algorithm string Markers []Marker } diff --git a/internal/markers/write.go b/internal/markers/write.go new file mode 100644 index 00000000..5435c93f --- /dev/null +++ b/internal/markers/write.go @@ -0,0 +1,109 @@ +package markers + +import ( + "strings" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// CanWriteMarker reports whether a new marker write should be accepted given +// the existing source/confidence. A strictly higher-priority source always +// wins; an equal-priority source wins only if its confidence is strictly +// higher than what's already stored. Unknown/empty existing source is treated +// as priority zero so any defined source can replace it. +func CanWriteMarker(existingSource *string, existingConfidence *float64, newSource string, newConfidence *float64) bool { + currentSource := "" + if existingSource != nil { + currentSource = *existingSource + } + existingPriority := models.MarkerSourcePriority(currentSource) + newPriority := models.MarkerSourcePriority(newSource) + if newPriority > existingPriority { + return true + } + if newPriority < existingPriority { + return false + } + if existingConfidence != nil && newConfidence != nil { + return *newConfidence > *existingConfidence + } + return false +} + +// MarkerUpdatePayload is the storage-agnostic shape produced from a provider +// Result. Repositories convert it into their concrete write column set. +// Pointer fields are nil when the result didn't carry that segment kind. +type MarkerUpdatePayload struct { + IntroStart, IntroEnd *float64 + CreditsStart, CreditsEnd *float64 + RecapStart, RecapEnd *float64 + PreviewStart, PreviewEnd *float64 + Source string + Provider *string + Confidence *float64 + Algorithm string +} + +// HasAnySegment reports whether the payload carries at least one segment +// range. Callers can short-circuit empty writes without touching the DB. +func (p MarkerUpdatePayload) HasAnySegment() bool { + return p.IntroStart != nil || p.IntroEnd != nil || + p.CreditsStart != nil || p.CreditsEnd != nil || + p.RecapStart != nil || p.RecapEnd != nil || + p.PreviewStart != nil || p.PreviewEnd != nil +} + +// BuildUpdatePayload converts a provider Result into the storage payload. +// All four segment kinds in the result map to the corresponding *Start/*End +// pointer pair; absent kinds remain nil. The Result's Algorithm field is +// authoritative; the falls back to `external:` if absent so writes +// always carry an algorithm tag for provenance. +// +// Confidence: the highest per-marker confidence in the result is promoted to +// the shared write-time confidence. Per-segment confidence values are not +// preserved individually — the write path uses a single confidence per +// upsert, and providers today return uniform confidence across all segments +// of a single fetch. +func BuildUpdatePayload(result Result) MarkerUpdatePayload { + payload := MarkerUpdatePayload{ + Source: result.SourceClass, + Algorithm: result.Algorithm, + } + if payload.Algorithm == "" && payload.Source != "" { + payload.Algorithm = "external:" + payload.Source + } + if provider := strings.TrimSpace(result.ProviderID); provider != "" { + payload.Provider = &provider + } + + var maxConfidence float64 + for _, m := range result.Markers { + start := m.Start.Seconds() + end := m.End.Seconds() + if end <= start { + continue + } + startPtr, endPtr := start, end + switch m.Kind { + case MarkerKindIntro: + payload.IntroStart = &startPtr + payload.IntroEnd = &endPtr + case MarkerKindCredits: + payload.CreditsStart = &startPtr + payload.CreditsEnd = &endPtr + case MarkerKindRecap: + payload.RecapStart = &startPtr + payload.RecapEnd = &endPtr + case MarkerKindPreview: + payload.PreviewStart = &startPtr + payload.PreviewEnd = &endPtr + } + if m.Confidence > maxConfidence { + maxConfidence = m.Confidence + } + } + if maxConfidence > 0 { + payload.Confidence = &maxConfidence + } + return payload +} diff --git a/internal/markers/write_test.go b/internal/markers/write_test.go new file mode 100644 index 00000000..74c9d201 --- /dev/null +++ b/internal/markers/write_test.go @@ -0,0 +1,80 @@ +package markers + +import ( + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestBuildUpdatePayloadAggregatesConfidence(t *testing.T) { + result := Result{ + ProviderID: "introdb", + SourceClass: models.MarkerSourceOnline, + Algorithm: "introdb:v3", + Markers: []Marker{ + {Kind: MarkerKindIntro, Start: 10 * time.Second, End: 60 * time.Second, Confidence: 0.7}, + {Kind: MarkerKindCredits, Start: 1500 * time.Second, End: 1790 * time.Second, Confidence: 0.9}, + {Kind: MarkerKindRecap, Start: 0, End: 30 * time.Second, Confidence: 0.5}, + }, + } + + payload := BuildUpdatePayload(result) + + if payload.Confidence == nil { + t.Fatal("confidence should be populated") + } + if *payload.Confidence != 0.9 { + t.Errorf("confidence = %v, want 0.9 (max across markers)", *payload.Confidence) + } + if payload.Algorithm != "introdb:v3" { + t.Errorf("algorithm = %q, want introdb:v3", payload.Algorithm) + } + if payload.IntroStart == nil || *payload.IntroStart != 10 { + t.Errorf("intro start = %v, want 10", payload.IntroStart) + } + if payload.RecapStart == nil || *payload.RecapStart != 0 { + t.Errorf("recap start = %v, want 0", payload.RecapStart) + } + if payload.PreviewStart != nil { + t.Errorf("preview start = %v, want nil (no preview marker)", payload.PreviewStart) + } +} + +func TestBuildUpdatePayloadFallsBackAlgorithm(t *testing.T) { + result := Result{ + ProviderID: "custom", + SourceClass: models.MarkerSourceOnline, + Markers: []Marker{{Kind: MarkerKindIntro, Start: 0, End: 10 * time.Second}}, + } + payload := BuildUpdatePayload(result) + if payload.Algorithm != "external:online" { + t.Errorf("algorithm = %q, want external:online fallback", payload.Algorithm) + } +} + +func TestCanWriteMarkerEqualPriorityRequiresHigherConfidence(t *testing.T) { + existing := models.MarkerSourceOnline + low, high := 0.5, 0.9 + + if CanWriteMarker(&existing, &high, models.MarkerSourceOnline, &low) { + t.Error("equal priority with lower new confidence should not write") + } + if !CanWriteMarker(&existing, &low, models.MarkerSourceOnline, &high) { + t.Error("equal priority with strictly higher new confidence should write") + } + if CanWriteMarker(&existing, &high, models.MarkerSourceOnline, &high) { + t.Error("equal priority with equal confidence should not write") + } +} + +func TestCanWriteMarkerHigherPriorityWinsRegardless(t *testing.T) { + existing := models.MarkerSourceScanner + if !CanWriteMarker(&existing, nil, models.MarkerSourceOnline, nil) { + t.Error("higher priority should win even without confidence") + } + online := models.MarkerSourceOnline + if CanWriteMarker(&online, nil, models.MarkerSourceScanner, nil) { + t.Error("lower priority should not overwrite higher") + } +} diff --git a/internal/models/marker_source.go b/internal/models/marker_source.go index 5d4379a1..8b5cd7a7 100644 --- a/internal/models/marker_source.go +++ b/internal/models/marker_source.go @@ -23,3 +23,4 @@ func MarkerSourcePriority(source string) int { return 0 } } + diff --git a/internal/models/media.go b/internal/models/media.go index 319de842..54c64a82 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -65,6 +65,10 @@ type MediaFile struct { IntroEnd *float64 CreditsStart *float64 CreditsEnd *float64 + RecapStart *float64 + RecapEnd *float64 + PreviewStart *float64 + PreviewEnd *float64 MarkersSource *string MarkersConfidence *float64 IntroMarkersSource *string @@ -77,6 +81,16 @@ type MediaFile struct { CreditsMarkersConfidence *float64 CreditsMarkersAlgorithm *string CreditsMarkersDetectedAt *time.Time + RecapMarkersSource *string + RecapMarkersProvider *string + RecapMarkersConfidence *float64 + RecapMarkersAlgorithm *string + RecapMarkersDetectedAt *time.Time + PreviewMarkersSource *string + PreviewMarkersProvider *string + PreviewMarkersConfidence *float64 + PreviewMarkersAlgorithm *string + PreviewMarkersDetectedAt *time.Time EditionRaw string EditionKey string EditionConfidence *float64 diff --git a/internal/playback/marker_update_notifier.go b/internal/playback/marker_update_notifier.go index 0716f018..a693e9dc 100644 --- a/internal/playback/marker_update_notifier.go +++ b/internal/playback/marker_update_notifier.go @@ -33,15 +33,17 @@ func (n *MarkerUpdateNotifier) MarkersUpdated(_ context.Context, file *models.Me return } - var intro *TimeRangePayload - if file.IntroStart != nil && file.IntroEnd != nil { - intro = &TimeRangePayload{Start: *file.IntroStart, End: *file.IntroEnd} + rangePayload := func(start, end *float64) *TimeRangePayload { + if start == nil || end == nil { + return nil + } + return &TimeRangePayload{Start: *start, End: *end} } - var credits *TimeRangePayload - if file.CreditsStart != nil && file.CreditsEnd != nil { - credits = &TimeRangePayload{Start: *file.CreditsStart, End: *file.CreditsEnd} - } - if intro == nil && credits == nil { + intro := rangePayload(file.IntroStart, file.IntroEnd) + credits := rangePayload(file.CreditsStart, file.CreditsEnd) + recap := rangePayload(file.RecapStart, file.RecapEnd) + preview := rangePayload(file.PreviewStart, file.PreviewEnd) + if intro == nil && credits == nil && recap == nil && preview == nil { return } @@ -49,7 +51,7 @@ func (n *MarkerUpdateNotifier) MarkersUpdated(_ context.Context, file *models.Me if session == nil || session.ID == "" || !session.HasRealtimeConnection { continue } - event, err := NewMarkersUpdatedEvent(session.ID, file.ID, intro, credits) + event, err := NewMarkersUpdatedEvent(session.ID, file.ID, intro, credits, recap, preview) if err != nil { slog.Warn( "failed to encode markers updated realtime event", diff --git a/internal/playback/realtime.go b/internal/playback/realtime.go index 6108d1f3..d070f147 100644 --- a/internal/playback/realtime.go +++ b/internal/playback/realtime.go @@ -121,6 +121,8 @@ type MarkersUpdatedPayload struct { FileID int `json:"file_id"` Intro *TimeRangePayload `json:"intro"` Credits *TimeRangePayload `json:"credits"` + Recap *TimeRangePayload `json:"recap"` + Preview *TimeRangePayload `json:"preview"` } // NewEventEnvelope creates a validated realtime event envelope. @@ -167,12 +169,16 @@ func NewMarkersUpdatedEvent( fileID int, intro *TimeRangePayload, credits *TimeRangePayload, + recap *TimeRangePayload, + preview *TimeRangePayload, ) (EventEnvelope, error) { payload, err := json.Marshal(MarkersUpdatedPayload{ SessionID: sessionID, FileID: fileID, Intro: intro, Credits: credits, + Recap: recap, + Preview: preview, }) if err != nil { return EventEnvelope{}, err diff --git a/internal/playback/realtime_test.go b/internal/playback/realtime_test.go index ac57fd8f..f1f28b4b 100644 --- a/internal/playback/realtime_test.go +++ b/internal/playback/realtime_test.go @@ -42,6 +42,8 @@ func TestNewMarkersUpdatedEvent(t *testing.T) { 42, &TimeRangePayload{Start: 12, End: 75}, nil, + nil, + nil, ) if err != nil { t.Fatalf("NewMarkersUpdatedEvent() error = %v", err) diff --git a/internal/scanner/file_repo.go b/internal/scanner/file_repo.go index e119674f..81cea60b 100644 --- a/internal/scanner/file_repo.go +++ b/internal/scanner/file_repo.go @@ -8,11 +8,11 @@ import ( "strings" "time" + "github.com/Silo-Server/silo-server/internal/markers" + "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/pathscope" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" - - "github.com/Silo-Server/silo-server/internal/models" ) // Sentinel errors for file repository operations. @@ -41,9 +41,11 @@ const fileColumns = `id, content_id, episode_id, season_number, episode_number, codec_video, codec_audio, resolution, audio_channels, hdr, container, duration, bitrate, video_tracks, audio_tracks, subtitle_tracks, external_subtitles, chapters, chapter_thumbnail_retry_after, chapter_thumbnail_failure_count, chapter_thumbnail_last_error, - intro_start, intro_end, credits_start, credits_end, markers_source, markers_confidence, + intro_start, intro_end, credits_start, credits_end, recap_start, recap_end, preview_start, preview_end, markers_source, markers_confidence, intro_markers_source, intro_markers_provider, intro_markers_confidence, intro_markers_algorithm, intro_markers_detected_at, credits_markers_source, credits_markers_provider, credits_markers_confidence, credits_markers_algorithm, credits_markers_detected_at, + recap_markers_source, recap_markers_provider, recap_markers_confidence, recap_markers_algorithm, recap_markers_detected_at, + preview_markers_source, preview_markers_provider, preview_markers_confidence, preview_markers_algorithm, preview_markers_detected_at, edition_raw, edition_key, edition_confidence, edition_source, presentation_kind, presentation_group_key, presentation_part_index, presentation_part_total, multi_episode_start, multi_episode_end, @@ -58,9 +60,11 @@ const mfFileColumns = `mf.id, mf.content_id, mf.episode_id, mf.season_number, mf mf.codec_video, mf.codec_audio, mf.resolution, mf.audio_channels, mf.hdr, mf.container, mf.duration, mf.bitrate, mf.video_tracks, mf.audio_tracks, mf.subtitle_tracks, mf.external_subtitles, mf.chapters, mf.chapter_thumbnail_retry_after, mf.chapter_thumbnail_failure_count, mf.chapter_thumbnail_last_error, - mf.intro_start, mf.intro_end, mf.credits_start, mf.credits_end, mf.markers_source, mf.markers_confidence, + mf.intro_start, mf.intro_end, mf.credits_start, mf.credits_end, mf.recap_start, mf.recap_end, mf.preview_start, mf.preview_end, mf.markers_source, mf.markers_confidence, mf.intro_markers_source, mf.intro_markers_provider, mf.intro_markers_confidence, mf.intro_markers_algorithm, mf.intro_markers_detected_at, mf.credits_markers_source, mf.credits_markers_provider, mf.credits_markers_confidence, mf.credits_markers_algorithm, mf.credits_markers_detected_at, + mf.recap_markers_source, mf.recap_markers_provider, mf.recap_markers_confidence, mf.recap_markers_algorithm, mf.recap_markers_detected_at, + mf.preview_markers_source, mf.preview_markers_provider, mf.preview_markers_confidence, mf.preview_markers_algorithm, mf.preview_markers_detected_at, mf.edition_raw, mf.edition_key, mf.edition_confidence, mf.edition_source, mf.presentation_kind, mf.presentation_group_key, mf.presentation_part_index, mf.presentation_part_total, mf.multi_episode_start, mf.multi_episode_end, @@ -81,6 +85,8 @@ func scanMediaFile(row pgx.Row) (*models.MediaFile, error) { var codecVideo, codecAudio, resolution, container, probeSource *string var markersSource, introMarkersSource, introMarkersProvider, introMarkersAlgorithm *string var creditsMarkersSource, creditsMarkersProvider, creditsMarkersAlgorithm *string + var recapMarkersSource, recapMarkersProvider, recapMarkersAlgorithm *string + var previewMarkersSource, previewMarkersProvider, previewMarkersAlgorithm *string var chapterThumbnailLastError *string var editionRaw, editionKey, editionSource *string var audioChannels *int @@ -88,7 +94,9 @@ func scanMediaFile(row pgx.Row) (*models.MediaFile, error) { var duration, bitrate *int var chapterThumbnailFailureCount *int var markersConfidence, introMarkersConfidence, creditsMarkersConfidence *float64 + var recapMarkersConfidence, previewMarkersConfidence *float64 var introMarkersDetectedAt, creditsMarkersDetectedAt *time.Time + var recapMarkersDetectedAt, previewMarkersDetectedAt *time.Time var editionConfidence *float64 var presentationPartIndex, presentationPartTotal *int var multiEpisodeStart, multiEpisodeEnd *int @@ -136,6 +144,10 @@ func scanMediaFile(row pgx.Row) (*models.MediaFile, error) { &f.IntroEnd, &f.CreditsStart, &f.CreditsEnd, + &f.RecapStart, + &f.RecapEnd, + &f.PreviewStart, + &f.PreviewEnd, &markersSource, &markersConfidence, &introMarkersSource, @@ -148,6 +160,16 @@ func scanMediaFile(row pgx.Row) (*models.MediaFile, error) { &creditsMarkersConfidence, &creditsMarkersAlgorithm, &creditsMarkersDetectedAt, + &recapMarkersSource, + &recapMarkersProvider, + &recapMarkersConfidence, + &recapMarkersAlgorithm, + &recapMarkersDetectedAt, + &previewMarkersSource, + &previewMarkersProvider, + &previewMarkersConfidence, + &previewMarkersAlgorithm, + &previewMarkersDetectedAt, &editionRaw, &editionKey, &editionConfidence, @@ -294,6 +316,16 @@ func scanMediaFile(row pgx.Row) (*models.MediaFile, error) { f.CreditsMarkersConfidence = creditsMarkersConfidence f.CreditsMarkersAlgorithm = creditsMarkersAlgorithm f.CreditsMarkersDetectedAt = creditsMarkersDetectedAt + f.RecapMarkersSource = recapMarkersSource + f.RecapMarkersProvider = recapMarkersProvider + f.RecapMarkersConfidence = recapMarkersConfidence + f.RecapMarkersAlgorithm = recapMarkersAlgorithm + f.RecapMarkersDetectedAt = recapMarkersDetectedAt + f.PreviewMarkersSource = previewMarkersSource + f.PreviewMarkersProvider = previewMarkersProvider + f.PreviewMarkersConfidence = previewMarkersConfidence + f.PreviewMarkersAlgorithm = previewMarkersAlgorithm + f.PreviewMarkersDetectedAt = previewMarkersDetectedAt if len(videoTracksJSON) > 0 { if err := json.Unmarshal(videoTracksJSON, &f.VideoTracks); err != nil { @@ -358,6 +390,8 @@ func scanMediaFiles(rows pgx.Rows) ([]*models.MediaFile, error) { var codecVideo, codecAudio, resolution, container, probeSource *string var markersSource, introMarkersSource, introMarkersProvider, introMarkersAlgorithm *string var creditsMarkersSource, creditsMarkersProvider, creditsMarkersAlgorithm *string + var recapMarkersSource, recapMarkersProvider, recapMarkersAlgorithm *string + var previewMarkersSource, previewMarkersProvider, previewMarkersAlgorithm *string var chapterThumbnailLastError *string var editionRaw, editionKey, editionSource *string var audioChannels *int @@ -365,7 +399,9 @@ func scanMediaFiles(rows pgx.Rows) ([]*models.MediaFile, error) { var duration, bitrate *int var chapterThumbnailFailureCount *int var markersConfidence, introMarkersConfidence, creditsMarkersConfidence *float64 + var recapMarkersConfidence, previewMarkersConfidence *float64 var introMarkersDetectedAt, creditsMarkersDetectedAt *time.Time + var recapMarkersDetectedAt, previewMarkersDetectedAt *time.Time var editionConfidence *float64 var presentationPartIndex, presentationPartTotal *int var multiEpisodeStart, multiEpisodeEnd *int @@ -413,6 +449,10 @@ func scanMediaFiles(rows pgx.Rows) ([]*models.MediaFile, error) { &f.IntroEnd, &f.CreditsStart, &f.CreditsEnd, + &f.RecapStart, + &f.RecapEnd, + &f.PreviewStart, + &f.PreviewEnd, &markersSource, &markersConfidence, &introMarkersSource, @@ -425,6 +465,16 @@ func scanMediaFiles(rows pgx.Rows) ([]*models.MediaFile, error) { &creditsMarkersConfidence, &creditsMarkersAlgorithm, &creditsMarkersDetectedAt, + &recapMarkersSource, + &recapMarkersProvider, + &recapMarkersConfidence, + &recapMarkersAlgorithm, + &recapMarkersDetectedAt, + &previewMarkersSource, + &previewMarkersProvider, + &previewMarkersConfidence, + &previewMarkersAlgorithm, + &previewMarkersDetectedAt, &editionRaw, &editionKey, &editionConfidence, @@ -567,6 +617,16 @@ func scanMediaFiles(rows pgx.Rows) ([]*models.MediaFile, error) { f.CreditsMarkersConfidence = creditsMarkersConfidence f.CreditsMarkersAlgorithm = creditsMarkersAlgorithm f.CreditsMarkersDetectedAt = creditsMarkersDetectedAt + f.RecapMarkersSource = recapMarkersSource + f.RecapMarkersProvider = recapMarkersProvider + f.RecapMarkersConfidence = recapMarkersConfidence + f.RecapMarkersAlgorithm = recapMarkersAlgorithm + f.RecapMarkersDetectedAt = recapMarkersDetectedAt + f.PreviewMarkersSource = previewMarkersSource + f.PreviewMarkersProvider = previewMarkersProvider + f.PreviewMarkersConfidence = previewMarkersConfidence + f.PreviewMarkersAlgorithm = previewMarkersAlgorithm + f.PreviewMarkersDetectedAt = previewMarkersDetectedAt if len(videoTracksJSON) > 0 { if err := json.Unmarshal(videoTracksJSON, &f.VideoTracks); err != nil { @@ -905,6 +965,82 @@ func (r *FileRepository) SetChapterThumbnailFailure( return nil } +// segmentState tracks the mutable per-segment fields used by UpsertMarkers. +// Each segment kind (intro, credits, recap, preview) has an independent state +// that the apply step mutates if the priority check allows the write. +type segmentState struct { + start *float64 + end *float64 + source *string + provider *string + confidence *float64 + algorithm *string +} + +// applySegmentPatch merges the patched start/end into the segment state, then +// gates the write on the shared priority check. Returns true if the state was +// mutated. The legacy `markers_source` field is consulted as a fallback when +// the segment-specific source is nil but the segment already has a range. +func applySegmentPatch( + state *segmentState, + legacySharedSource *string, + update MarkerUpdate, + patchStart, patchEnd *float64, + duration float64, + segmentName string, +) (bool, error) { + if patchStart == nil && patchEnd == nil { + return false, nil + } + + nextStart := state.start + nextEnd := state.end + if patchStart != nil { + nextStart = patchStart + } + if patchEnd != nil { + nextEnd = patchEnd + } + if nextStart == nil || nextEnd == nil { + return false, nil + } + if *nextStart < 0 || *nextEnd <= *nextStart { + return false, fmt.Errorf("invalid %s marker range %.3f-%.3f", segmentName, *nextStart, *nextEnd) + } + if duration > 0 && *nextEnd > duration+1 { + return false, fmt.Errorf("%s marker end %.3f exceeds duration %.3f", segmentName, *nextEnd, duration) + } + + effectiveSource := state.source + if effectiveSource == nil && state.start != nil && state.end != nil { + effectiveSource = legacySharedSource + } + if !markers.CanWriteMarker(effectiveSource, state.confidence, update.MarkersSource, update.MarkersConfidence) { + return false, nil + } + + src := update.MarkersSource + algo := markerAlgorithm(update) + state.start = nextStart + state.end = nextEnd + state.source = &src + state.provider = update.MarkersProvider + state.confidence = update.MarkersConfidence + state.algorithm = &algo + return true, nil +} + +// segmentEqual reports whether two segment states are byte-equivalent. Used to +// detect no-op writes so the transaction can short-circuit without a SQL UPDATE. +func segmentEqual(a, b segmentState) bool { + return ptrFloatEqual(a.start, b.start) && + ptrFloatEqual(a.end, b.end) && + ptrStringEqual(a.source, b.source) && + ptrStringEqual(a.provider, b.provider) && + ptrFloatEqual(a.confidence, b.confidence) && + ptrStringEqual(a.algorithm, b.algorithm) +} + // UpsertMarkers updates only marker fields while enforcing source priority. func (r *FileRepository) UpsertMarkers(ctx context.Context, fileID int, update MarkerUpdate) (bool, error) { if update.MarkersSource == "" { @@ -918,21 +1054,13 @@ func (r *FileRepository) UpsertMarkers(ctx context.Context, fileID int, update M defer tx.Rollback(ctx) var ( - duration float64 - existingSource *string - existingConfidence *float64 - existingIntroStart *float64 - existingIntroEnd *float64 - existingCreditsStart *float64 - existingCreditsEnd *float64 - existingIntroSource *string - existingIntroProvider *string - existingIntroConfidence *float64 - existingIntroAlgorithm *string - existingCreditsSource *string - existingCreditsProvider *string - existingCreditsConf *float64 - existingCreditsAlgo *string + duration float64 + existingSource *string + existingConfidence *float64 + intro segmentState + credits segmentState + recap segmentState + preview segmentState ) if err := tx.QueryRow(ctx, `SELECT COALESCE(duration, 0), @@ -940,34 +1068,38 @@ func (r *FileRepository) UpsertMarkers(ctx context.Context, fileID int, update M markers_confidence, intro_start, intro_end, - credits_start, - credits_end, intro_markers_source, intro_markers_provider, intro_markers_confidence, intro_markers_algorithm, + credits_start, + credits_end, credits_markers_source, credits_markers_provider, credits_markers_confidence, - credits_markers_algorithm + credits_markers_algorithm, + recap_start, + recap_end, + recap_markers_source, + recap_markers_provider, + recap_markers_confidence, + recap_markers_algorithm, + preview_start, + preview_end, + preview_markers_source, + preview_markers_provider, + preview_markers_confidence, + preview_markers_algorithm FROM media_files WHERE id = $1 FOR UPDATE`, fileID, ).Scan( &duration, &existingSource, &existingConfidence, - &existingIntroStart, - &existingIntroEnd, - &existingCreditsStart, - &existingCreditsEnd, - &existingIntroSource, - &existingIntroProvider, - &existingIntroConfidence, - &existingIntroAlgorithm, - &existingCreditsSource, - &existingCreditsProvider, - &existingCreditsConf, - &existingCreditsAlgo, + &intro.start, &intro.end, &intro.source, &intro.provider, &intro.confidence, &intro.algorithm, + &credits.start, &credits.end, &credits.source, &credits.provider, &credits.confidence, &credits.algorithm, + &recap.start, &recap.end, &recap.source, &recap.provider, &recap.confidence, &recap.algorithm, + &preview.start, &preview.end, &preview.source, &preview.provider, &preview.confidence, &preview.algorithm, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return false, ErrFileNotFound @@ -975,101 +1107,34 @@ func (r *FileRepository) UpsertMarkers(ctx context.Context, fileID int, update M return false, fmt.Errorf("load existing marker source: %w", err) } - nextIntroStart := existingIntroStart - nextIntroEnd := existingIntroEnd - nextIntroSource := existingIntroSource - nextIntroProvider := existingIntroProvider - nextIntroConfidence := existingIntroConfidence - nextIntroAlgorithm := existingIntroAlgorithm - writeIntro := update.IntroStart != nil && update.IntroEnd != nil - introApplied := false - if writeIntro { - if *update.IntroStart < 0 || *update.IntroEnd <= *update.IntroStart { - return false, fmt.Errorf("invalid intro marker range %.3f-%.3f", *update.IntroStart, *update.IntroEnd) - } - if duration > 0 && *update.IntroEnd > duration+1 { - return false, fmt.Errorf("intro marker end %.3f exceeds duration %.3f", *update.IntroEnd, duration) - } - currentIntroSource := "" - if existingIntroSource != nil { - currentIntroSource = *existingIntroSource - } else if existingIntroStart != nil && existingIntroEnd != nil && existingSource != nil { - currentIntroSource = *existingSource - } - canWriteIntro := models.MarkerSourcePriority(currentIntroSource) <= models.MarkerSourcePriority(update.MarkersSource) - if canWriteIntro && models.MarkerSourcePriority(currentIntroSource) == models.MarkerSourcePriority(update.MarkersSource) && - existingIntroConfidence != nil && update.MarkersConfidence != nil && - *existingIntroConfidence >= *update.MarkersConfidence { - canWriteIntro = false - } - if canWriteIntro { - nextIntroStart = update.IntroStart - nextIntroEnd = update.IntroEnd - nextIntroSource = &update.MarkersSource - nextIntroProvider = update.MarkersProvider - nextIntroConfidence = update.MarkersConfidence - algorithm := markerAlgorithm(update) - nextIntroAlgorithm = &algorithm - introApplied = true - } - } + originalIntro, originalCredits, originalRecap, originalPreview := intro, credits, recap, preview - nextCreditsStart := existingCreditsStart - nextCreditsEnd := existingCreditsEnd - nextCreditsSource := existingCreditsSource - nextCreditsProvider := existingCreditsProvider - nextCreditsConfidence := existingCreditsConf - nextCreditsAlgorithm := existingCreditsAlgo - creditsApplied := false - currentCreditsSource := "" - if existingCreditsSource != nil { - currentCreditsSource = *existingCreditsSource - } else if existingCreditsStart != nil && existingCreditsEnd != nil && existingSource != nil { - currentCreditsSource = *existingSource - } - canWriteCredits := models.MarkerSourcePriority(currentCreditsSource) <= models.MarkerSourcePriority(update.MarkersSource) - if canWriteCredits && update.CreditsStart != nil { - nextCreditsStart = update.CreditsStart - creditsApplied = true - } - if canWriteCredits && update.CreditsEnd != nil { - nextCreditsEnd = update.CreditsEnd - creditsApplied = true - } - if err := validateMarkerRange("credits", nextCreditsStart, nextCreditsEnd, duration); err != nil { + introApplied, err := applySegmentPatch(&intro, existingSource, update, update.IntroStart, update.IntroEnd, duration, "intro") + if err != nil { return false, err } - if creditsApplied { - nextCreditsSource = &update.MarkersSource - nextCreditsProvider = update.MarkersProvider - nextCreditsConfidence = update.MarkersConfidence - algorithm := markerAlgorithm(update) - nextCreditsAlgorithm = &algorithm + creditsApplied, err := applySegmentPatch(&credits, existingSource, update, update.CreditsStart, update.CreditsEnd, duration, "credits") + if err != nil { + return false, err + } + recapApplied, err := applySegmentPatch(&recap, existingSource, update, update.RecapStart, update.RecapEnd, duration, "recap") + if err != nil { + return false, err + } + previewApplied, err := applySegmentPatch(&preview, existingSource, update, update.PreviewStart, update.PreviewEnd, duration, "preview") + if err != nil { + return false, err } - nextSource := existingSource - nextConfidence := existingConfidence - nextSource, nextConfidence = nextSharedMarkerAttribution( - nextSource, - nextConfidence, - update, - introApplied || creditsApplied, - ) + anyApplied := introApplied || creditsApplied || recapApplied || previewApplied + nextSource, nextConfidence := nextSharedMarkerAttribution(existingSource, existingConfidence, update, anyApplied) - if ptrFloatEqual(existingIntroStart, nextIntroStart) && - ptrFloatEqual(existingIntroEnd, nextIntroEnd) && - ptrFloatEqual(existingCreditsStart, nextCreditsStart) && - ptrFloatEqual(existingCreditsEnd, nextCreditsEnd) && + if segmentEqual(intro, originalIntro) && + segmentEqual(credits, originalCredits) && + segmentEqual(recap, originalRecap) && + segmentEqual(preview, originalPreview) && ptrStringEqual(existingSource, nextSource) && - ptrFloatEqual(existingConfidence, nextConfidence) && - ptrStringEqual(existingIntroSource, nextIntroSource) && - ptrStringEqual(existingIntroProvider, nextIntroProvider) && - ptrFloatEqual(existingIntroConfidence, nextIntroConfidence) && - ptrStringEqual(existingIntroAlgorithm, nextIntroAlgorithm) && - ptrStringEqual(existingCreditsSource, nextCreditsSource) && - ptrStringEqual(existingCreditsProvider, nextCreditsProvider) && - ptrFloatEqual(existingCreditsConf, nextCreditsConfidence) && - ptrStringEqual(existingCreditsAlgo, nextCreditsAlgorithm) { + ptrFloatEqual(existingConfidence, nextConfidence) { if err := tx.Commit(ctx); err != nil { return false, fmt.Errorf("commit marker no-op transaction: %w", err) } @@ -1082,38 +1147,45 @@ func (r *FileRepository) UpsertMarkers(ctx context.Context, fileID int, update M intro_end = $3, credits_start = $4, credits_end = $5, - markers_source = $6, - markers_confidence = $7, - intro_markers_source = $8, - intro_markers_provider = $9, - intro_markers_confidence = $10, - intro_markers_algorithm = $11, - intro_markers_detected_at = CASE WHEN $12 THEN NOW() ELSE intro_markers_detected_at END, - credits_markers_source = $13, - credits_markers_provider = $14, - credits_markers_confidence = $15, - credits_markers_algorithm = $16, - credits_markers_detected_at = CASE WHEN $17 THEN NOW() ELSE credits_markers_detected_at END, + recap_start = $6, + recap_end = $7, + preview_start = $8, + preview_end = $9, + markers_source = $10, + markers_confidence = $11, + intro_markers_source = $12, + intro_markers_provider = $13, + intro_markers_confidence = $14, + intro_markers_algorithm = $15, + intro_markers_detected_at = CASE WHEN $16 THEN NOW() ELSE intro_markers_detected_at END, + credits_markers_source = $17, + credits_markers_provider = $18, + credits_markers_confidence = $19, + credits_markers_algorithm = $20, + credits_markers_detected_at = CASE WHEN $21 THEN NOW() ELSE credits_markers_detected_at END, + recap_markers_source = $22, + recap_markers_provider = $23, + recap_markers_confidence = $24, + recap_markers_algorithm = $25, + recap_markers_detected_at = CASE WHEN $26 THEN NOW() ELSE recap_markers_detected_at END, + preview_markers_source = $27, + preview_markers_provider = $28, + preview_markers_confidence = $29, + preview_markers_algorithm = $30, + preview_markers_detected_at = CASE WHEN $31 THEN NOW() ELSE preview_markers_detected_at END, updated_at = NOW() WHERE id = $1 `, fileID, - nextIntroStart, - nextIntroEnd, - nextCreditsStart, - nextCreditsEnd, - nextSource, - nextConfidence, - nextIntroSource, - nextIntroProvider, - nextIntroConfidence, - nextIntroAlgorithm, - introApplied, - nextCreditsSource, - nextCreditsProvider, - nextCreditsConfidence, - nextCreditsAlgorithm, - creditsApplied, + intro.start, intro.end, + credits.start, credits.end, + recap.start, recap.end, + preview.start, preview.end, + nextSource, nextConfidence, + intro.source, intro.provider, intro.confidence, intro.algorithm, introApplied, + credits.source, credits.provider, credits.confidence, credits.algorithm, creditsApplied, + recap.source, recap.provider, recap.confidence, recap.algorithm, recapApplied, + preview.source, preview.provider, preview.confidence, preview.algorithm, previewApplied, ) if err != nil { return false, fmt.Errorf("updating media markers: %w", err) @@ -1135,18 +1207,6 @@ func ptrFloatEqual(a, b *float64) bool { return *a == *b } -func validateMarkerRange(kind string, start, end *float64, duration float64) error { - if start == nil || end == nil { - return nil - } - if *start < 0 || *end <= *start { - return fmt.Errorf("invalid %s marker range %.3f-%.3f", kind, *start, *end) - } - if duration > 0 && *end > duration+1 { - return fmt.Errorf("%s marker end %.3f exceeds duration %.3f", kind, *end, duration) - } - return nil -} func nextSharedMarkerAttribution( existingSource *string, diff --git a/internal/scanner/file_repo_test.go b/internal/scanner/file_repo_test.go index 1395a6f1..1a01e0fc 100644 --- a/internal/scanner/file_repo_test.go +++ b/internal/scanner/file_repo_test.go @@ -78,12 +78,3 @@ func TestNextSharedMarkerAttributionDoesNotDowngradeConfidenceWhenMarkerRejected } } -func TestValidateMarkerRangeRejectsInvalidCreditsBounds(t *testing.T) { - start := 900.0 - end := 800.0 - - err := validateMarkerRange("credits", &start, &end, 1800) - if err == nil { - t.Fatal("expected invalid credits range error") - } -} diff --git a/internal/scanner/types.go b/internal/scanner/types.go index a421d198..adad0dfb 100644 --- a/internal/scanner/types.go +++ b/internal/scanner/types.go @@ -116,8 +116,21 @@ type MarkerUpdate struct { IntroEnd *float64 CreditsStart *float64 CreditsEnd *float64 + RecapStart *float64 + RecapEnd *float64 + PreviewStart *float64 + PreviewEnd *float64 MarkersSource string MarkersProvider *string MarkersConfidence *float64 MarkersAlgorithm string } + +// HasAnySegment reports whether the update would write at least one segment. +// An update with no segment bounds set is a no-op and skipped by UpsertMarkers. +func (u MarkerUpdate) HasAnySegment() bool { + return u.IntroStart != nil || u.IntroEnd != nil || + u.CreditsStart != nil || u.CreditsEnd != nil || + u.RecapStart != nil || u.RecapEnd != nil || + u.PreviewStart != nil || u.PreviewEnd != nil +} diff --git a/internal/userdb/profiles.go b/internal/userdb/profiles.go index e6ea64bd..09cf606e 100644 --- a/internal/userdb/profiles.go +++ b/internal/userdb/profiles.go @@ -56,12 +56,14 @@ func CreateProfile(db *sql.DB, p Profile) error { INSERT INTO profiles ( id, name, avatar, pin_hash, is_child, is_primary, max_content_rating, quality_preference, language, subtitle_language, subtitle_mode, - auto_skip_intro, auto_skip_credits, show_forced_subtitles, + auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, + show_forced_subtitles, library_restrictions_enabled, max_playback_quality, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.ID, p.Name, p.Avatar, p.PINHash, p.IsChild, p.IsPrimary, p.MaxContentRating, p.QualityPreference, p.Language, p.SubtitleLanguage, p.SubtitleMode, - p.AutoSkipIntro, p.AutoSkipCredits, p.ShowForcedSubtitles, p.LibraryRestrictionsEnabled, + p.AutoSkipIntro, p.AutoSkipCredits, p.AutoSkipRecap, p.AutoPlayNextPreview, + p.ShowForcedSubtitles, p.LibraryRestrictionsEnabled, p.MaxPlaybackQuality, p.CreatedAt, p.UpdatedAt, ) if err != nil { @@ -80,13 +82,13 @@ func GetProfile(db *sql.DB, id string) (*Profile, error) { err := db.QueryRow(` SELECT id, name, avatar, pin_hash, is_child, is_primary, max_content_rating, quality_preference, language, subtitle_language, subtitle_mode, - auto_skip_intro, auto_skip_credits, show_forced_subtitles, + auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, show_forced_subtitles, library_restrictions_enabled, max_playback_quality, created_at, updated_at FROM profiles WHERE id = ?`, id, ).Scan( &p.ID, &p.Name, &p.Avatar, &p.PINHash, &p.IsChild, &p.IsPrimary, &p.MaxContentRating, &p.QualityPreference, &p.Language, &p.SubtitleLanguage, &p.SubtitleMode, - &p.AutoSkipIntro, &p.AutoSkipCredits, &p.ShowForcedSubtitles, + &p.AutoSkipIntro, &p.AutoSkipCredits, &p.AutoSkipRecap, &p.AutoPlayNextPreview, &p.ShowForcedSubtitles, &p.LibraryRestrictionsEnabled, &p.MaxPlaybackQuality, &p.CreatedAt, &p.UpdatedAt, ) if err == sql.ErrNoRows { @@ -107,7 +109,7 @@ func ListProfiles(db *sql.DB) ([]Profile, error) { rows, err := db.Query(` SELECT id, name, avatar, pin_hash, is_child, is_primary, max_content_rating, quality_preference, language, subtitle_language, subtitle_mode, - auto_skip_intro, auto_skip_credits, show_forced_subtitles, + auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, show_forced_subtitles, library_restrictions_enabled, max_playback_quality, created_at, updated_at FROM profiles ORDER BY created_at ASC`) if err != nil { @@ -121,7 +123,7 @@ func ListProfiles(db *sql.DB) ([]Profile, error) { if err := rows.Scan( &p.ID, &p.Name, &p.Avatar, &p.PINHash, &p.IsChild, &p.IsPrimary, &p.MaxContentRating, &p.QualityPreference, &p.Language, &p.SubtitleLanguage, &p.SubtitleMode, - &p.AutoSkipIntro, &p.AutoSkipCredits, &p.ShowForcedSubtitles, + &p.AutoSkipIntro, &p.AutoSkipCredits, &p.AutoSkipRecap, &p.AutoPlayNextPreview, &p.ShowForcedSubtitles, &p.LibraryRestrictionsEnabled, &p.MaxPlaybackQuality, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scanning profile row: %w", err) @@ -195,6 +197,14 @@ func UpdateProfile(db *sql.DB, id string, u UpdateProfileInput) error { setClauses = append(setClauses, "auto_skip_intro = ?") args = append(args, *u.AutoSkipIntro) } + if u.AutoSkipRecap != nil { + setClauses = append(setClauses, "auto_skip_recap = ?") + args = append(args, *u.AutoSkipRecap) + } + if u.AutoPlayNextPreview != nil { + setClauses = append(setClauses, "auto_play_next_preview = ?") + args = append(args, *u.AutoPlayNextPreview) + } if u.AutoSkipCredits != nil { setClauses = append(setClauses, "auto_skip_credits = ?") args = append(args, *u.AutoSkipCredits) diff --git a/internal/userdb/schema.go b/internal/userdb/schema.go index 1eece3da..0c54eaf5 100644 --- a/internal/userdb/schema.go +++ b/internal/userdb/schema.go @@ -24,6 +24,8 @@ CREATE TABLE IF NOT EXISTS profiles ( subtitle_mode TEXT DEFAULT 'auto', auto_skip_intro BOOLEAN DEFAULT false, auto_skip_credits BOOLEAN DEFAULT false, + auto_skip_recap BOOLEAN DEFAULT false, + auto_play_next_preview BOOLEAN DEFAULT false, show_forced_subtitles BOOLEAN NOT NULL DEFAULT true, library_restrictions_enabled BOOLEAN DEFAULT false, max_playback_quality TEXT DEFAULT '', @@ -267,12 +269,45 @@ func InitSchema(db *sql.DB) error { if err := ensureDeviceSettingsProfileColumn(db); err != nil { return err } + if err := ensureAutoSkipRecapPreviewColumns(db); err != nil { + return err + } if err := ensureWatchHistoryIdentityColumn(db); err != nil { return err } return migratePlaybackSettingsToDeviceScope(db) } +func ensureAutoSkipRecapPreviewColumns(db *sql.DB) error { + columns := []struct { + name string + definition string + }{ + {name: "auto_skip_recap", definition: "BOOLEAN DEFAULT false"}, + {name: "auto_play_next_preview", definition: "BOOLEAN DEFAULT false"}, + } + + for _, column := range columns { + var count int + if err := db.QueryRow( + "SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?", + "profiles", + column.name, + ).Scan(&count); err != nil { + return fmt.Errorf("checking profiles.%s column: %w", column.name, err) + } + if count > 0 { + continue + } + if _, err := db.Exec( + fmt.Sprintf("ALTER TABLE profiles ADD COLUMN %s %s", column.name, column.definition), + ); err != nil { + return fmt.Errorf("adding profiles.%s column: %w", column.name, err) + } + } + return nil +} + func ensureWatchHistoryIdentityColumn(db *sql.DB) error { var count int if err := db.QueryRow( diff --git a/internal/userstore/pgstore/profiles.go b/internal/userstore/pgstore/profiles.go index 8e7a7521..e2a8cc8f 100644 --- a/internal/userstore/pgstore/profiles.go +++ b/internal/userstore/pgstore/profiles.go @@ -21,7 +21,8 @@ func scanProfile(scanner interface { err := scanner.Scan( &p.ID, &p.Name, &p.Avatar, &p.PINHash, &p.IsChild, &p.IsPrimary, &p.MaxContentRating, &p.QualityPreference, &p.Language, &p.SubtitleLanguage, &p.SubtitleMode, - &p.AutoSkipIntro, &p.AutoSkipCredits, &p.LibraryRestrictionsEnabled, + &p.AutoSkipIntro, &p.AutoSkipCredits, &p.AutoSkipRecap, &p.AutoPlayNextPreview, + &p.LibraryRestrictionsEnabled, &p.ShowForcedSubtitles, &p.MaxPlaybackQuality, &createdAt, &updatedAt, ) if err != nil { @@ -66,12 +67,14 @@ func (s *PostgresUserStore) CreateProfile(ctx context.Context, p userstore.Profi INSERT INTO user_profiles ( id, user_id, name, avatar, pin_hash, is_child, is_primary, max_content_rating, quality_preference, language, subtitle_language, subtitle_mode, - auto_skip_intro, auto_skip_credits, library_restrictions_enabled, + auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, + library_restrictions_enabled, show_forced_subtitles, max_playback_quality, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)`, p.ID, s.userID, p.Name, p.Avatar, p.PINHash, p.IsChild, p.IsPrimary, p.MaxContentRating, p.QualityPreference, p.Language, p.SubtitleLanguage, p.SubtitleMode, - p.AutoSkipIntro, p.AutoSkipCredits, p.LibraryRestrictionsEnabled, + p.AutoSkipIntro, p.AutoSkipCredits, p.AutoSkipRecap, p.AutoPlayNextPreview, + p.LibraryRestrictionsEnabled, p.ShowForcedSubtitles, p.MaxPlaybackQuality, p.CreatedAt, p.UpdatedAt, ) if err != nil { @@ -87,7 +90,7 @@ func (s *PostgresUserStore) GetProfile(ctx context.Context, id string) (*usersto row := s.pool.QueryRow(ctx, ` SELECT id, name, avatar, pin_hash, is_child, is_primary, max_content_rating, quality_preference, language, subtitle_language, subtitle_mode, - auto_skip_intro, auto_skip_credits, library_restrictions_enabled, + auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, library_restrictions_enabled, show_forced_subtitles, max_playback_quality, created_at, updated_at FROM user_profiles WHERE user_id = $1 AND id = $2`, s.userID, id) @@ -109,7 +112,7 @@ func (s *PostgresUserStore) ListProfiles(ctx context.Context) ([]userstore.Profi rows, err := s.pool.Query(ctx, ` SELECT id, name, avatar, pin_hash, is_child, is_primary, max_content_rating, quality_preference, language, subtitle_language, subtitle_mode, - auto_skip_intro, auto_skip_credits, library_restrictions_enabled, + auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, library_restrictions_enabled, show_forced_subtitles, max_playback_quality, created_at, updated_at FROM user_profiles WHERE user_id = $1 ORDER BY created_at ASC`, s.userID) if err != nil { @@ -194,6 +197,12 @@ func (s *PostgresUserStore) UpdateProfile(ctx context.Context, id string, u user if u.AutoSkipCredits != nil { addArg("auto_skip_credits", *u.AutoSkipCredits) } + if u.AutoSkipRecap != nil { + addArg("auto_skip_recap", *u.AutoSkipRecap) + } + if u.AutoPlayNextPreview != nil { + addArg("auto_play_next_preview", *u.AutoPlayNextPreview) + } if u.LibraryRestrictionsEnabled != nil { addArg("library_restrictions_enabled", *u.LibraryRestrictionsEnabled) } diff --git a/internal/userstore/storetest/suite.go b/internal/userstore/storetest/suite.go index b16e6d4f..c66bec53 100644 --- a/internal/userstore/storetest/suite.go +++ b/internal/userstore/storetest/suite.go @@ -71,6 +71,8 @@ func testProfiles(t *testing.T, newStore func(t *testing.T) userstore.UserStore) SubtitleMode: "auto", AutoSkipIntro: true, AutoSkipCredits: false, + AutoSkipRecap: false, + AutoPlayNextPreview: false, LibraryRestrictionsEnabled: true, AllowedLibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", diff --git a/internal/userstore/types.go b/internal/userstore/types.go index 8506cf1b..1f6603c5 100644 --- a/internal/userstore/types.go +++ b/internal/userstore/types.go @@ -17,6 +17,8 @@ type Profile struct { SubtitleMode string AutoSkipIntro bool AutoSkipCredits bool + AutoSkipRecap bool + AutoPlayNextPreview bool ShowForcedSubtitles bool LibraryRestrictionsEnabled bool AllowedLibraryIDs []int @@ -50,6 +52,8 @@ type UpdateProfileInput struct { SubtitleMode *string AutoSkipIntro *bool AutoSkipCredits *bool + AutoSkipRecap *bool + AutoPlayNextPreview *bool ShowForcedSubtitles *bool LibraryRestrictionsEnabled *bool AllowedLibraryIDs *[]int diff --git a/migrations/137_recap_preview_markers.down.sql b/migrations/137_recap_preview_markers.down.sql new file mode 100644 index 00000000..24ae4897 --- /dev/null +++ b/migrations/137_recap_preview_markers.down.sql @@ -0,0 +1,21 @@ +DELETE FROM server_settings WHERE key = 'introdb.api_key'; + +ALTER TABLE user_profiles + DROP COLUMN IF EXISTS auto_play_next_preview, + DROP COLUMN IF EXISTS auto_skip_recap; + +ALTER TABLE media_files + DROP COLUMN IF EXISTS preview_markers_detected_at, + DROP COLUMN IF EXISTS preview_markers_algorithm, + DROP COLUMN IF EXISTS preview_markers_confidence, + DROP COLUMN IF EXISTS preview_markers_provider, + DROP COLUMN IF EXISTS preview_markers_source, + DROP COLUMN IF EXISTS preview_end, + DROP COLUMN IF EXISTS preview_start, + DROP COLUMN IF EXISTS recap_markers_detected_at, + DROP COLUMN IF EXISTS recap_markers_algorithm, + DROP COLUMN IF EXISTS recap_markers_confidence, + DROP COLUMN IF EXISTS recap_markers_provider, + DROP COLUMN IF EXISTS recap_markers_source, + DROP COLUMN IF EXISTS recap_end, + DROP COLUMN IF EXISTS recap_start; diff --git a/migrations/137_recap_preview_markers.up.sql b/migrations/137_recap_preview_markers.up.sql new file mode 100644 index 00000000..0da65186 --- /dev/null +++ b/migrations/137_recap_preview_markers.up.sql @@ -0,0 +1,22 @@ +ALTER TABLE media_files + ADD COLUMN IF NOT EXISTS recap_start double precision, + ADD COLUMN IF NOT EXISTS recap_end double precision, + ADD COLUMN IF NOT EXISTS recap_markers_source text, + ADD COLUMN IF NOT EXISTS recap_markers_provider text, + ADD COLUMN IF NOT EXISTS recap_markers_confidence double precision, + ADD COLUMN IF NOT EXISTS recap_markers_algorithm text, + ADD COLUMN IF NOT EXISTS recap_markers_detected_at timestamp with time zone, + ADD COLUMN IF NOT EXISTS preview_start double precision, + ADD COLUMN IF NOT EXISTS preview_end double precision, + ADD COLUMN IF NOT EXISTS preview_markers_source text, + ADD COLUMN IF NOT EXISTS preview_markers_provider text, + ADD COLUMN IF NOT EXISTS preview_markers_confidence double precision, + ADD COLUMN IF NOT EXISTS preview_markers_algorithm text, + ADD COLUMN IF NOT EXISTS preview_markers_detected_at timestamp with time zone; + +ALTER TABLE user_profiles + ADD COLUMN IF NOT EXISTS auto_skip_recap boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS auto_play_next_preview boolean NOT NULL DEFAULT false; + +INSERT INTO server_settings (key, value) VALUES ('introdb.api_key', '') +ON CONFLICT (key) DO NOTHING; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3c13f064..53467b87 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -128,6 +128,8 @@ export interface Profile { show_forced_subtitles?: boolean; auto_skip_intro: boolean; auto_skip_credits: boolean; + auto_skip_recap?: boolean; + auto_play_next_preview?: boolean; library_restrictions_enabled: boolean; allowed_library_ids: number[] | null; max_playback_quality: string; @@ -153,6 +155,8 @@ export interface CreateProfileRequest { show_forced_subtitles?: boolean; auto_skip_intro?: boolean; auto_skip_credits?: boolean; + auto_skip_recap?: boolean; + auto_play_next_preview?: boolean; library_restrictions_enabled?: boolean; allowed_library_ids?: number[] | null; max_playback_quality?: string; @@ -690,6 +694,8 @@ export interface FileVersion { chapters?: VersionChapter[]; intro?: TimeRange | null; credits?: TimeRange | null; + recap?: TimeRange | null; + preview?: TimeRange | null; } export interface PlaybackVariantPart { @@ -856,6 +862,8 @@ export interface ItemDetail { subtitles: SubtitleInfo[]; intro: TimeRange | null; credits: TimeRange | null; + recap?: TimeRange | null; + preview?: TimeRange | null; effective_subtitle_language?: string; effective_subtitle_mode?: string; effective_show_forced_subtitles?: boolean; @@ -877,6 +885,8 @@ export interface WatchDetail { subtitles: SubtitleInfo[]; intro: TimeRange | null; credits: TimeRange | null; + recap?: TimeRange | null; + preview?: TimeRange | null; user_data?: LeafItemUserData; series_id?: string; series_title?: string; diff --git a/web/src/lib/settingsManifest.ts b/web/src/lib/settingsManifest.ts index 5a54a967..919cc430 100644 --- a/web/src/lib/settingsManifest.ts +++ b/web/src/lib/settingsManifest.ts @@ -80,6 +80,23 @@ const definitions: SettingDefinition[] = [ control: "switch", defaultValue: "false", }, + { + key: "playback.auto_skip_recap", + scope: "device", + label: "Auto-skip recaps", + description: "Skip 'previously on…' recaps automatically when Silo can detect them.", + control: "switch", + defaultValue: "false", + }, + { + key: "playback.auto_play_next_preview", + scope: "device", + label: "Start next episode at preview", + description: + "Begin playing the next episode when the current one reaches its next-episode preview teaser, rather than waiting for the end credits.", + control: "switch", + defaultValue: "false", + }, { key: "playback.auto_play_next", scope: "device", diff --git a/web/src/pages/admin-settings/IntegrationsSettings.tsx b/web/src/pages/admin-settings/IntegrationsSettings.tsx index eb5c5c37..37ded799 100644 --- a/web/src/pages/admin-settings/IntegrationsSettings.tsx +++ b/web/src/pages/admin-settings/IntegrationsSettings.tsx @@ -396,6 +396,54 @@ function MDBListCredentialCard() { ); } +function IntroDBCredentialCard() { + const { data: sensitive } = useAdminSensitiveStatus(); + const updateSetting = useUpdateServerSetting(); + const [apiKey, setApiKey] = useState(""); + const configured = new Set(sensitive?.configured ?? []).has("introdb.api_key"); + + function save() { + void updateSetting.mutateAsync({ key: "introdb.api_key", value: apiKey }).then(() => { + setApiKey(""); + }); + } + + return ( +
+
+
+

TheIntroDB

+

+ Community-sourced intro, recap, credits, and preview timestamps. Read access is free and + requires no key. Supplying your{" "} + + TheIntroDB + {" "} + API key lets the server use your pending submissions before they're community-verified. +

+
+ +
+ + +
+ ); +} + export default function IntegrationsSettings() { return (
@@ -410,6 +458,9 @@ export default function IntegrationsSettings() {
+
+ +
); diff --git a/web/src/pages/settings/PlaybackSettings.tsx b/web/src/pages/settings/PlaybackSettings.tsx index 7149ed65..4ddff7aa 100644 --- a/web/src/pages/settings/PlaybackSettings.tsx +++ b/web/src/pages/settings/PlaybackSettings.tsx @@ -141,6 +141,8 @@ export default function PlaybackSettings() { language?: string; auto_skip_intro?: boolean; auto_skip_credits?: boolean; + auto_skip_recap?: boolean; + auto_play_next_preview?: boolean; }) => { updateMutation.mutate( { id: profile.id, body }, @@ -246,6 +248,36 @@ export default function PlaybackSettings() { )} /> + ( +
+ saveProfileField({ auto_skip_recap: checked })} + /> +
+ )} + /> + + ( +
+ + saveProfileField({ auto_play_next_preview: checked }) + } + /> +
+ )} + /> + diff --git a/web/src/pages/watchRouteHelpers.ts b/web/src/pages/watchRouteHelpers.ts index 42e60094..e002314b 100644 --- a/web/src/pages/watchRouteHelpers.ts +++ b/web/src/pages/watchRouteHelpers.ts @@ -241,7 +241,11 @@ export function buildWatchPageProps({ const intro: PlayerTimeRange | null = item.intro ?? null; const credits: PlayerTimeRange | null = item.credits ?? null; + const recap: PlayerTimeRange | null = item.recap ?? null; + const preview: PlayerTimeRange | null = item.preview ?? null; const autoSkipIntro = currentProfile?.auto_skip_intro ?? false; + const autoSkipRecap = currentProfile?.auto_skip_recap ?? false; + const autoPlayNextPreview = currentProfile?.auto_play_next_preview ?? false; const initialPosition = request.restart ? 0 : item.user_data?.played === true @@ -294,6 +298,10 @@ export function buildWatchPageProps({ intro, autoSkipIntro, credits, + recap, + preview, + autoSkipRecap, + autoPlayNextPreview, seriesContext: item.series_id ? { seriesId: item.series_id, diff --git a/web/src/player/components/IntroSkipButton.tsx b/web/src/player/components/IntroSkipButton.tsx index ad5eec30..dce75bd9 100644 --- a/web/src/player/components/IntroSkipButton.tsx +++ b/web/src/player/components/IntroSkipButton.tsx @@ -1,18 +1,19 @@ interface IntroSkipButtonProps { onSkip: () => void; + label?: string; } /** * "Skip Intro" button visible during the intro time range. */ -export function IntroSkipButton({ onSkip }: IntroSkipButtonProps) { +export function IntroSkipButton({ onSkip, label = "Skip Intro" }: IntroSkipButtonProps) { return ( ); } diff --git a/web/src/player/components/VideoPlayer.tsx b/web/src/player/components/VideoPlayer.tsx index 38a1d0b2..116cdcb2 100644 --- a/web/src/player/components/VideoPlayer.tsx +++ b/web/src/player/components/VideoPlayer.tsx @@ -72,6 +72,10 @@ interface VideoPlayerProps { intro: PlayerTimeRange | null; autoSkipIntro?: boolean; credits: PlayerTimeRange | null; + recap?: PlayerTimeRange | null; + autoSkipRecap?: boolean; + preview?: PlayerTimeRange | null; + autoPlayNextPreview?: boolean; duration?: number; seriesContext?: SeriesContext; onNavigateEpisode?: (contentId: string) => void; @@ -91,6 +95,7 @@ interface VideoPlayerProps { onPlaybackTransportReady?: (transport: PlayerPlaybackTransport | null) => void; onReturnFromPostRoll?: () => void; onRealtimeEvent?: (event: PlaybackRealtimeEventEnvelope) => void; + onRealtimeConnectionStateChange?: (state: "disconnected" | "connecting" | "connected") => void; watchTogetherRoomId?: string | null; watchTogetherConnection?: WatchTogetherRoomConnectionResult; } @@ -154,6 +159,10 @@ export function VideoPlayer({ intro, autoSkipIntro = false, credits, + recap = null, + autoSkipRecap = false, + preview = null, + autoPlayNextPreview = false, duration: propDuration, seriesContext, onNavigateEpisode, @@ -173,6 +182,7 @@ export function VideoPlayer({ onPlaybackTransportReady, onReturnFromPostRoll, onRealtimeEvent, + onRealtimeConnectionStateChange, watchTogetherRoomId, watchTogetherConnection, }: VideoPlayerProps) { @@ -190,6 +200,7 @@ export function VideoPlayer({ const backendDurationRef = useRef(propDuration ?? 0); const autoEnterPictureInPictureAttemptedRef = useRef(false); const autoSkippedIntroKeyRef = useRef(null); + const autoSkippedRecapKeyRef = useRef(null); const endedFiredRef = useRef(false); const [hasEnded, setHasEnded] = useState(false); const onEndedRef = useRef(onEnded); @@ -822,7 +833,7 @@ export function VideoPlayer({ ); const nextEpisode = useNextEpisode( - roomPlaybackActive ? null : credits, + roomPlaybackActive ? null : autoPlayNextPreview && preview ? preview : credits, roomPlaybackActive ? undefined : seriesContext, currentTime, handleNavigate, @@ -886,11 +897,16 @@ export function VideoPlayer({ // -- Intro skip -- const showIntroSkip = intro != null && currentTime >= intro.start && currentTime < intro.end; + const showRecapSkip = recap != null && currentTime >= recap.start && currentTime < recap.end; const skipIntro = useCallback(() => { if (intro) handlePlayerSeek(intro.end); }, [intro, handlePlayerSeek]); + const skipRecap = useCallback(() => { + if (recap) handlePlayerSeek(recap.end); + }, [recap, handlePlayerSeek]); + useEffect(() => { if (!autoSkipIntro || !intro || !isPlayerReady || awaitingFirstFrame) { return; @@ -926,6 +942,41 @@ export function VideoPlayer({ watchTogetherSync.attachedSessionId, ]); + useEffect(() => { + if (!autoSkipRecap || !recap || !isPlayerReady || awaitingFirstFrame) { + return; + } + if (currentTime < recap.start || currentTime >= recap.end) { + return; + } + if ( + roomPlaybackActive && + (!watchTogether.room?.self_can_manage_room || + watchTogetherSync.attachedSessionId !== sessionId) + ) { + return; + } + + const recapKey = `${sessionId}:${activeFileId ?? "unknown"}:${recap.start}:${recap.end}`; + if (autoSkippedRecapKeyRef.current === recapKey) { + return; + } + autoSkippedRecapKeyRef.current = recapKey; + handlePlayerSeek(recap.end); + }, [ + activeFileId, + autoSkipRecap, + awaitingFirstFrame, + currentTime, + handlePlayerSeek, + isPlayerReady, + recap, + roomPlaybackActive, + sessionId, + watchTogether.room?.self_can_manage_room, + watchTogetherSync.attachedSessionId, + ]); + // Stabilize the dependency – only the bitrate matters for buffer sizing. const selectedVersionBitrate = transcodeQuality.effectiveVersion?.bitrate ?? 0; @@ -1665,12 +1716,16 @@ export function VideoPlayer({ [handleExit, performPlayerSeek], ); - usePlaybackRealtime({ + const realtime = usePlaybackRealtime({ sessionId, onCommand: executeRealtimeCommand, onEvent: onRealtimeEvent, }); + useEffect(() => { + onRealtimeConnectionStateChange?.(realtime.connectionState); + }, [onRealtimeConnectionStateChange, realtime.connectionState]); + // -- Postroll mini-player resize -- const [miniPlayerWidth, setMiniPlayerWidth] = useState(320); const isDraggingRef = useRef(false); @@ -1972,6 +2027,7 @@ export function VideoPlayer({ {/* Intro skip button */} {!isDetached && showIntroSkip && } + {!isDetached && showRecapSkip && } {/* Next episode overlay */} {!isDetached && nextEpisode.showCountdown && nextEpisode.nextEpisode && ( diff --git a/web/src/player/components/WatchPage.tsx b/web/src/player/components/WatchPage.tsx index 6c5bb028..dadeaebd 100644 --- a/web/src/player/components/WatchPage.tsx +++ b/web/src/player/components/WatchPage.tsx @@ -77,6 +77,8 @@ export function WatchPage({ showForcedSubtitles, profileLanguage, autoSkipIntro, + autoSkipRecap, + autoPlayNextPreview, seriesContext, onNavigateEpisode, onEnded, @@ -97,7 +99,11 @@ export function WatchPage({ const playbackController = useWatchPlaybackController(); const chapterRefreshAttemptsRef = useRef>(new Set()); const handledSelectionRevisionRef = useRef(null); + const markerRealtimeReconcileKeyRef = useRef(null); const [playbackVersions, setPlaybackVersions] = useState(versions); + const [realtimeConnectionState, setRealtimeConnectionState] = useState< + "disconnected" | "connecting" | "connected" + >("disconnected"); const watchTogetherConnection = useWatchTogetherRoomConnection({ roomId: watchTogetherRoomId, roomToken: watchTogetherRoomToken, @@ -195,6 +201,7 @@ export function WatchPage({ useEffect(() => { chapterRefreshAttemptsRef.current.clear(); + markerRealtimeReconcileKeyRef.current = null; }, [contentId, playbackRequestKey]); useEffect(() => { @@ -272,6 +279,51 @@ export function WatchPage({ playbackVersions, ]); + useEffect(() => { + if ( + realtimeConnectionState !== "connected" || + !session.sessionId || + !session.mediaFileId || + session.loading || + session.replacing + ) { + return; + } + + const activeFileId = session.mediaFileId; + const reconcileKey = `${session.sessionId}:${activeFileId}`; + if (markerRealtimeReconcileKeyRef.current === reconcileKey) { + return; + } + markerRealtimeReconcileKeyRef.current = reconcileKey; + + let cancelled = false; + void queryClient + .fetchQuery({ + queryKey: itemKeys.watchDetail(contentId, activeFileId, libraryId), + queryFn: () => fetchWatchDetail(contentId, activeFileId, libraryId), + staleTime: 0, + }) + .then((detail) => { + if (!cancelled) { + setPlaybackVersions(detail.versions); + } + }); + + return () => { + cancelled = true; + }; + }, [ + contentId, + libraryId, + queryClient, + realtimeConnectionState, + session.loading, + session.mediaFileId, + session.replacing, + session.sessionId, + ]); + const handleRealtimeEvent = useCallback( (event: PlaybackRealtimeEventEnvelope) => { if (event.name === "chapter_thumbnail_ready") { @@ -296,13 +348,19 @@ export function WatchPage({ return; } - const { file_id, intro: nextIntro, credits: nextCredits } = event.payload; + const { + file_id, + intro: nextIntro, + credits: nextCredits, + recap: nextRecap, + preview: nextPreview, + } = event.payload; if (file_id !== session.mediaFileId) { return; } setPlaybackVersions((current) => - patchVersionMarkers(current, file_id, nextIntro, nextCredits), + patchVersionMarkers(current, file_id, nextIntro, nextCredits, nextRecap, nextPreview), ); }, [session.mediaFileId], @@ -405,6 +463,10 @@ export function WatchPage({ intro={activeMarkers.intro} autoSkipIntro={autoSkipIntro} credits={activeMarkers.credits} + recap={activeMarkers.recap} + autoSkipRecap={autoSkipRecap} + preview={activeMarkers.preview} + autoPlayNextPreview={autoPlayNextPreview} duration={selectedDuration} qualityPreference={qualityPreference} seriesContext={seriesContext} @@ -415,6 +477,7 @@ export function WatchPage({ onPlaybackStateChange={onPlaybackStateChange} onPlaybackTransportReady={onPlaybackTransportReady} onRealtimeEvent={handleRealtimeEvent} + onRealtimeConnectionStateChange={setRealtimeConnectionState} onExit={onExit} onMinimize={onMinimize} onEnded={handleEnded} diff --git a/web/src/player/hooks/useNextEpisode.ts b/web/src/player/hooks/useNextEpisode.ts index 5c080345..319a989d 100644 --- a/web/src/player/hooks/useNextEpisode.ts +++ b/web/src/player/hooks/useNextEpisode.ts @@ -10,11 +10,11 @@ interface NextEpisodeState { } /** - * Detects when the current playback position enters the credits region, + * Detects when the current playback position enters the configured trigger region, * starts a 10-second countdown, and provides the next episode reference. */ export function useNextEpisode( - credits: PlayerTimeRange | null, + triggerRegion: PlayerTimeRange | null, seriesContext: SeriesContext | undefined, currentTime: number, onNavigate: (contentId: string) => void, @@ -41,11 +41,11 @@ export function useNextEpisode( } }, [currentEpisodeKey]); - // Detect entry into credits region. + // Detect entry into the configured trigger region. useEffect(() => { - if (!credits || !nextEpisode || cancelledRef.current) return; + if (!triggerRegion || !nextEpisode || cancelledRef.current) return; - if (currentTime >= credits.start && !showCountdown) { + if (currentTime >= triggerRegion.start && !showCountdown) { setShowCountdown(true); setSecondsRemaining(10); @@ -60,7 +60,7 @@ export function useNextEpisode( }); }, 1000); } - }, [currentTime, credits, nextEpisode, showCountdown, onNavigate]); + }, [currentTime, triggerRegion, nextEpisode, showCountdown, onNavigate]); // Clean up interval on unmount. useEffect(() => { diff --git a/web/src/player/realtime-protocol.ts b/web/src/player/realtime-protocol.ts index e735e930..0893c6c2 100644 --- a/web/src/player/realtime-protocol.ts +++ b/web/src/player/realtime-protocol.ts @@ -62,6 +62,8 @@ export interface PlaybackMarkersUpdatedPayload { file_id: number; intro?: PlaybackTimeRangePayload | null; credits?: PlaybackTimeRangePayload | null; + recap?: PlaybackTimeRangePayload | null; + preview?: PlaybackTimeRangePayload | null; } export interface PlaybackRealtimeEventEnvelopeBase { @@ -149,12 +151,16 @@ function isTimeRangePayload(value: unknown): value is PlaybackTimeRangePayload { } function isMarkersUpdatedPayload(value: unknown): value is PlaybackMarkersUpdatedPayload { + const isOptionalRange = (range: unknown) => + range === undefined || range === null || isTimeRangePayload(range); return ( isRecord(value) && typeof value.session_id === "string" && typeof value.file_id === "number" && - (value.intro === undefined || value.intro === null || isTimeRangePayload(value.intro)) && - (value.credits === undefined || value.credits === null || isTimeRangePayload(value.credits)) + isOptionalRange(value.intro) && + isOptionalRange(value.credits) && + isOptionalRange(value.recap) && + isOptionalRange(value.preview) ); } diff --git a/web/src/player/types.ts b/web/src/player/types.ts index 3521e588..9946bc60 100644 --- a/web/src/player/types.ts +++ b/web/src/player/types.ts @@ -33,6 +33,8 @@ export interface PlayerFileVersion { chapters?: PlayerChapter[]; intro?: PlayerTimeRange | null; credits?: PlayerTimeRange | null; + recap?: PlayerTimeRange | null; + preview?: PlayerTimeRange | null; } export interface PlayerPlaybackVariantPart { @@ -240,6 +242,10 @@ export interface WatchPageProps { intro: PlayerTimeRange | null; autoSkipIntro?: boolean; credits: PlayerTimeRange | null; + recap?: PlayerTimeRange | null; + preview?: PlayerTimeRange | null; + autoSkipRecap?: boolean; + autoPlayNextPreview?: boolean; seriesContext?: SeriesContext; onNavigateEpisode?: (contentId: string) => void; onEnded?: (state?: PlaybackExitState) => void | Promise; diff --git a/web/src/player/utils/watchPageMarkers.test.ts b/web/src/player/utils/watchPageMarkers.test.ts index a094220d..736ddd6e 100644 --- a/web/src/player/utils/watchPageMarkers.test.ts +++ b/web/src/player/utils/watchPageMarkers.test.ts @@ -31,6 +31,8 @@ describe("resolveActiveVersionMarkers", () => { ).toEqual({ intro: null, credits: { start: 1500, end: 1790 }, + recap: null, + preview: null, }); }); @@ -38,6 +40,8 @@ describe("resolveActiveVersionMarkers", () => { expect(resolveActiveVersionMarkers(makeVersion({ intro: null, credits: null }))).toEqual({ intro: null, credits: null, + recap: null, + preview: null, }); }); }); diff --git a/web/src/player/utils/watchPageMarkers.ts b/web/src/player/utils/watchPageMarkers.ts index 19fa3f04..f788cbfc 100644 --- a/web/src/player/utils/watchPageMarkers.ts +++ b/web/src/player/utils/watchPageMarkers.ts @@ -1,12 +1,26 @@ import type { PlayerFileVersion, PlayerTimeRange } from "../types"; +// rangesEqual treats undefined and null as equivalent (both mean "absent") +// because the markers_updated event nulls out absent segments while the +// initial version state may have them undefined. +function rangesEqual(a: PlayerTimeRange | null | undefined, b: PlayerTimeRange | null | undefined) { + return (a?.start ?? null) === (b?.start ?? null) && (a?.end ?? null) === (b?.end ?? null); +} + export function patchVersionMarkers( versions: PlayerFileVersion[], fileId: number, intro?: PlayerTimeRange | null, credits?: PlayerTimeRange | null, + recap?: PlayerTimeRange | null, + preview?: PlayerTimeRange | null, ): PlayerFileVersion[] { - if (intro === undefined && credits === undefined) { + if ( + intro === undefined && + credits === undefined && + recap === undefined && + preview === undefined + ) { return versions; } @@ -18,11 +32,14 @@ export function patchVersionMarkers( const nextIntro = intro === undefined ? version.intro : intro; const nextCredits = credits === undefined ? version.credits : credits; - const introUnchanged = - version.intro?.start === nextIntro?.start && version.intro?.end === nextIntro?.end; - const creditsUnchanged = - version.credits?.start === nextCredits?.start && version.credits?.end === nextCredits?.end; - if (introUnchanged && creditsUnchanged) { + const nextRecap = recap === undefined ? version.recap : recap; + const nextPreview = preview === undefined ? version.preview : preview; + if ( + rangesEqual(version.intro, nextIntro) && + rangesEqual(version.credits, nextCredits) && + rangesEqual(version.recap, nextRecap) && + rangesEqual(version.preview, nextPreview) + ) { return version; } @@ -31,6 +48,8 @@ export function patchVersionMarkers( ...version, intro: nextIntro, credits: nextCredits, + recap: nextRecap, + preview: nextPreview, }; }); @@ -38,13 +57,17 @@ export function patchVersionMarkers( } export function resolveActiveVersionMarkers( - version: Pick | null | undefined, + version: Pick | null | undefined, ): { intro: PlayerTimeRange | null; credits: PlayerTimeRange | null; + recap: PlayerTimeRange | null; + preview: PlayerTimeRange | null; } { return { intro: version?.intro ?? null, credits: version?.credits ?? null, + recap: version?.recap ?? null, + preview: version?.preview ?? null, }; }