diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 4a3f48e9..487745f7 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -47,9 +47,9 @@ import ( "github.com/Silo-Server/silo-server/internal/jellycompat" "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/logstream" "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/metadata" "github.com/Silo-Server/silo-server/internal/models" @@ -434,6 +434,24 @@ func main() { 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) } 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_lazy_markers.go b/internal/api/handlers/playback_lazy_markers.go index 8780b980..0c36b5b2 100644 --- a/internal/api/handlers/playback_lazy_markers.go +++ b/internal/api/handlers/playback_lazy_markers.go @@ -38,9 +38,6 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( if h == nil || session == nil || file == nil || file.ID <= 0 { return } - if hasOnlineSourcedMarkers(file) { - return - } if file.MediaFolderID <= 0 { return } @@ -87,6 +84,9 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( hasOnline := h.hasOnlineMarkerProviders() shouldRunLocal := markers.ShouldRunLocal(mode) shouldRunOnline := (mode == markers.ModeOnline || mode == markers.ModeBoth) && hasOnline + if shouldRunOnline && hasOnlineSourcedMarkers(file) { + shouldRunOnline = false + } if shouldRunOnline { // Online providers work for any enabled library (movies and series alike). @@ -184,7 +184,9 @@ func (h *PlaybackHandler) runLazyPlaybackMarkers( if wrote { if refreshed := h.reloadPlaybackMarkerFile(ctx, file.ID); hasAnyMarker(refreshed) { h.notifyPlaybackMarkers(ctx, sessionID, refreshed, mode) - return + if !runLocal || hasLocalDetectionMarkers(refreshed) { + return + } } } } @@ -193,7 +195,9 @@ func (h *PlaybackHandler) runLazyPlaybackMarkers( // 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 !runLocal || hasLocalDetectionMarkers(refreshed) { + return + } } if runLocal { @@ -355,6 +359,14 @@ func hasAnyMarker(file *models.MediaFile) bool { (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 diff --git a/internal/api/router.go b/internal/api/router.go index 1495f117..8b39d1c5 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -131,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. @@ -628,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/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index c5796b78..f0d6d459 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -593,10 +593,12 @@ func (h *ItemsHandler) HandleMediaSegments(w http.ResponseWriter, r *http.Reques 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) } } } @@ -621,9 +623,19 @@ func (h *ItemsHandler) HandleMediaSegments(w http.ResponseWriter, r *http.Reques // 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 - for i := range detail.Versions { - if version == nil || (detail.Versions[i].FileID != 0 && version.FileID == 0) { - version = &detail.Versions[i] + 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 { diff --git a/internal/markers/introdb/client.go b/internal/markers/introdb/client.go index c041c321..4a7f27ed 100644 --- a/internal/markers/introdb/client.go +++ b/internal/markers/introdb/client.go @@ -9,6 +9,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/Silo-Server/silo-server/internal/cache" @@ -27,6 +28,7 @@ const ( // 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 @@ -51,11 +53,19 @@ func NewClient(apiKey string) *Client { } // SetBaseURL overrides the API base URL (used by tests). -func (c *Client) SetBaseURL(u string) { c.baseURL = u } +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.apiKey = strings.TrimSpace(apiKey) } +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() { @@ -84,7 +94,7 @@ func (c *Client) FetchEpisode(ctx context.Context, tmdbID, imdbID string, season if durationMS > 0 { q.Set("duration_ms", strconv.FormatInt(durationMS, 10)) } - return c.fetch(ctx, q, cacheKeyEpisode(tmdbID, imdbID, season, episode)) + return c.fetch(ctx, q, cacheKeyEpisode(tmdbID, imdbID, season, episode, durationMS)) } // FetchMovie looks up segment timestamps for a movie. @@ -102,7 +112,7 @@ func (c *Client) FetchMovie(ctx context.Context, tmdbID, imdbID string, duration if durationMS > 0 { q.Set("duration_ms", strconv.FormatInt(durationMS, 10)) } - return c.fetch(ctx, q, cacheKeyMovie(tmdbID, imdbID)) + return c.fetch(ctx, q, cacheKeyMovie(tmdbID, imdbID, durationMS)) } func (c *Client) fetch(ctx context.Context, q url.Values, key string) (*mediaResponse, error) { @@ -114,7 +124,12 @@ func (c *Client) fetch(ctx context.Context, q url.Values, key string) (*mediaRes return nil, err } - reqURL := c.baseURL + "/media?" + q.Encode() + 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) @@ -123,8 +138,8 @@ func (c *Client) fetch(ctx context.Context, q url.Values, key string) (*mediaRes } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "Silo-Server/markers") - if c.apiKey != "" { - req.Header.Set("Authorization", "Bearer "+c.apiKey) + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) } resp, err := c.httpClient.Do(req) @@ -195,16 +210,16 @@ func retryAfterOrDefault(resp *http.Response, attempt int) time.Duration { return time.Duration(1< *existingConfidence } - return true + return false } // MarkerUpdatePayload is the storage-agnostic shape produced from a provider diff --git a/internal/userdb/schema.go b/internal/userdb/schema.go index 2f6dcd16..0c54eaf5 100644 --- a/internal/userdb/schema.go +++ b/internal/userdb/schema.go @@ -269,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/web/src/api/types.ts b/web/src/api/types.ts index d89eb18b..53467b87 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -128,8 +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; + auto_skip_recap?: boolean; + auto_play_next_preview?: boolean; library_restrictions_enabled: boolean; allowed_library_ids: number[] | null; max_playback_quality: string; 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..86d8138a 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; @@ -154,6 +158,10 @@ export function VideoPlayer({ intro, autoSkipIntro = false, credits, + recap = null, + autoSkipRecap = false, + preview = null, + autoPlayNextPreview = false, duration: propDuration, seriesContext, onNavigateEpisode, @@ -190,6 +198,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 +831,7 @@ export function VideoPlayer({ ); const nextEpisode = useNextEpisode( - roomPlaybackActive ? null : credits, + roomPlaybackActive ? null : autoPlayNextPreview && preview ? preview : credits, roomPlaybackActive ? undefined : seriesContext, currentTime, handleNavigate, @@ -886,11 +895,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 +940,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; @@ -1972,6 +2021,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 572a7126..82d4f403 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, @@ -411,6 +413,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} 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(() => {