Add live introdb key reload and recap playback markers

- reload introdb API key on setting updates
- support recap/preview markers in playback and next-episode flow
- add profile defaults for recap and preview auto-play settings
This commit is contained in:
Silo Server Migration
2026-05-24 00:14:17 -04:00
parent 04a7e8c82d
commit d41ffd4d5e
13 changed files with 191 additions and 35 deletions
+19 -1
View File
@@ -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)
}
+6 -1
View File
@@ -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] {
+17 -5
View File
@@ -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
+4
View File
@@ -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)
+15 -3
View File
@@ -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 {
+28 -13
View File
@@ -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<<attempt) * time.Second
}
func cacheKeyEpisode(tmdbID, imdbID string, season, episode int) string {
func cacheKeyEpisode(tmdbID, imdbID string, season, episode int, durationMS int64) string {
if tmdbID != "" {
return fmt.Sprintf("tmdb:%s:s%de%d", tmdbID, season, episode)
return fmt.Sprintf("tmdb:%s:s%de%d:d%d", tmdbID, season, episode, durationMS)
}
return fmt.Sprintf("imdb:%s:s%de%d", imdbID, season, episode)
return fmt.Sprintf("imdb:%s:s%de%d:d%d", imdbID, season, episode, durationMS)
}
func cacheKeyMovie(tmdbID, imdbID string) string {
func cacheKeyMovie(tmdbID, imdbID string, durationMS int64) string {
if tmdbID != "" {
return "tmdb:movie:" + tmdbID
return fmt.Sprintf("tmdb:movie:%s:d%d", tmdbID, durationMS)
}
return "imdb:movie:" + imdbID
return fmt.Sprintf("imdb:movie:%s:d%d", imdbID, durationMS)
}
+1 -1
View File
@@ -27,7 +27,7 @@ func CanWriteMarker(existingSource *string, existingConfidence *float64, newSour
if existingConfidence != nil && newConfidence != nil {
return *newConfidence > *existingConfidence
}
return true
return false
}
// MarkerUpdatePayload is the storage-agnostic shape produced from a provider
+33
View File
@@ -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(
+2 -2
View File
@@ -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;
@@ -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 (
<button
onClick={onSkip}
type="button"
className="absolute right-6 bottom-24 z-50 rounded border border-white/40 bg-black/70 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20"
>
Skip Intro
{label}
</button>
);
}
+51 -1
View File
@@ -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<string | null>(null);
const autoSkippedRecapKeyRef = useRef<string | null>(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 && <IntroSkipButton onSkip={skipIntro} />}
{!isDetached && showRecapSkip && <IntroSkipButton onSkip={skipRecap} label="Skip Recap" />}
{/* Next episode overlay */}
{!isDetached && nextEpisode.showCountdown && nextEpisode.nextEpisode && (
+6
View File
@@ -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}
+6 -6
View File
@@ -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(() => {