Merge pull request #2 from Silo-Server/t3code/8d852355

Add online recap and preview marker support
This commit is contained in:
Quick
2026-05-24 00:49:42 -04:00
committed by GitHub
48 changed files with 1910 additions and 317 deletions
+37
View File
@@ -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()
+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] {
+2
View File
@@ -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
+187 -65
View File
@@ -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,
}
}
@@ -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
+12
View File
@@ -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...),
+10
View File
@@ -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",
+12
View File
@@ -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)
+51 -31
View File
@@ -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 {
@@ -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",
+23
View File
@@ -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
+133
View File
@@ -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) {
+1 -1
View File
@@ -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)
+225
View File
@@ -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<<attempt) * time.Second
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
continue
}
return nil, fmt.Errorf("introdb: server error %d after %d retries", resp.StatusCode, maxRetries)
}
if resp.StatusCode >= 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<<attempt) * time.Second
}
func cacheKeyEpisode(tmdbID, imdbID string, season, episode int, durationMS int64) string {
if tmdbID != "" {
return fmt.Sprintf("tmdb:%s:s%de%d:d%d", tmdbID, season, episode, durationMS)
}
return fmt.Sprintf("imdb:%s:s%de%d:d%d", imdbID, season, episode, durationMS)
}
func cacheKeyMovie(tmdbID, imdbID string, durationMS int64) string {
if tmdbID != "" {
return fmt.Sprintf("tmdb:movie:%s:d%d", tmdbID, durationMS)
}
return fmt.Sprintf("imdb:movie:%s:d%d", imdbID, durationMS)
}
+116
View File
@@ -0,0 +1,116 @@
package introdb
import (
"context"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
)
// Provider implements markers.Provider against the TheIntroDB API.
// Movies are looked up by TMDB or IMDB ID; episodes additionally need a
// season and episode number. The provider returns at most one Marker per
// segment kind — when TheIntroDB has multiple candidate ranges (multiple
// release versions), we pick the first usable one.
type Provider struct {
client *Client
}
// NewProvider constructs a Provider backed by the supplied client. Pass an
// already-configured *Client (from NewClient) so callers control the API
// key, base URL, and lifecycle.
func NewProvider(client *Client) *Provider {
return &Provider{client: client}
}
// ID satisfies markers.Provider; it returns the canonical provider tag
// stored in *_markers_provider columns and emitted in telemetry.
func (p *Provider) ID() string { return ProviderID }
// FetchMarkers issues a single GET /v3/media call and converts the
// response into a markers.Result. A nil Provider (or one with no usable
// IDs) returns an empty result rather than an error so callers can
// chain providers via the Registry.
func (p *Provider) FetchMarkers(ctx context.Context, req markers.Request) (markers.Result, error) {
if p == nil || p.client == nil {
return markers.Result{}, nil
}
tmdbID := strings.TrimSpace(req.ExternalIDs[markers.ExternalIDKeyTMDB])
imdbID := strings.TrimSpace(req.ExternalIDs[markers.ExternalIDKeyIMDB])
if tmdbID == "" && imdbID == "" {
return markers.Result{}, nil
}
durationMS := int64(req.Duration / time.Millisecond)
var resp *mediaResponse
var err error
switch req.Kind {
case markers.ItemKindEpisode:
if req.SeasonNumber <= 0 || req.EpisodeNumber <= 0 {
return markers.Result{}, nil
}
resp, err = p.client.FetchEpisode(ctx, tmdbID, imdbID, req.SeasonNumber, req.EpisodeNumber, durationMS)
case markers.ItemKindMovie:
resp, err = p.client.FetchMovie(ctx, tmdbID, imdbID, durationMS)
default:
return markers.Result{}, nil
}
if err != nil {
return markers.Result{}, err
}
if resp == nil {
return markers.Result{}, nil
}
result := markers.Result{
ProviderID: ProviderID,
SourceClass: models.MarkerSourceOnline,
Algorithm: Algorithm,
}
if m, ok := pickMarker(resp.Intro, markers.MarkerKindIntro, req.Duration, true); ok {
result.Markers = append(result.Markers, m)
}
if m, ok := pickMarker(resp.Credits, markers.MarkerKindCredits, req.Duration, false); ok {
result.Markers = append(result.Markers, m)
}
if m, ok := pickMarker(resp.Recap, markers.MarkerKindRecap, req.Duration, true); ok {
result.Markers = append(result.Markers, m)
}
if m, ok := pickMarker(resp.Preview, markers.MarkerKindPreview, req.Duration, false); ok {
result.Markers = append(result.Markers, m)
}
return result, nil
}
// pickMarker selects the first usable segment from a TheIntroDB response
// array. `requireEnd` is true for segments where the end timestamp is the
// load-bearing field (intro, recap) — they're allowed to start at 0 if
// `start_ms` is omitted. For trailing segments (credits, preview) the
// start is required but the end defaults to the file duration.
func pickMarker(stamps []segmentTimestamps, kind markers.MarkerKind, totalDuration time.Duration, requireEnd bool) (markers.Marker, bool) {
for _, s := range stamps {
start := time.Duration(0)
end := totalDuration
if s.StartMs != nil {
start = time.Duration(*s.StartMs) * time.Millisecond
}
if s.EndMs != nil {
end = time.Duration(*s.EndMs) * time.Millisecond
}
if requireEnd && s.EndMs == nil {
continue
}
if !requireEnd && s.StartMs == nil {
continue
}
if end <= start {
continue
}
return markers.Marker{Kind: kind, Start: start, End: end, Confidence: 0.9}, true
}
return markers.Marker{}, false
}
+41
View File
@@ -0,0 +1,41 @@
// Package introdb implements a markers.Provider against the public
// TheIntroDB API (https://theintrodb.org). The provider is read-only:
// it fetches intro/recap/credits/preview timestamps for episodes and
// movies. Submissions are intentionally not supported.
package introdb
// ProviderID is the canonical identifier stored in
// media_files.*_markers_provider for markers sourced from TheIntroDB.
const ProviderID = "introdb"
// Algorithm is the algorithm tag written alongside markers. The version
// suffix lets us invalidate or refresh markers if the upstream contract
// changes.
const Algorithm = "introdb:v3"
// DefaultBaseURL is the production TheIntroDB v3 endpoint. Overridable
// in tests via Client.SetBaseURL.
const DefaultBaseURL = "https://api.theintrodb.org/v3"
// mediaResponse mirrors the JSON shape returned by GET /v3/media.
// Each segment kind is an array of zero or more entries; absent fields
// are decoded as empty slices via Go's zero-value semantics.
type mediaResponse struct {
TmdbID int `json:"tmdb_id"`
Type string `json:"type"`
Season *int `json:"season,omitempty"`
Episode *int `json:"episode,omitempty"`
Intro []segmentTimestamps `json:"intro,omitempty"`
Recap []segmentTimestamps `json:"recap,omitempty"`
Credits []segmentTimestamps `json:"credits,omitempty"`
Preview []segmentTimestamps `json:"preview,omitempty"`
}
// segmentTimestamps is the per-occurrence shape returned by TheIntroDB.
// Either bound may be nil — for intro/recap, start may be omitted (segment
// begins at file start); for credits/preview, end may be omitted (segment
// runs to file end).
type segmentTimestamps struct {
StartMs *int64 `json:"start_ms,omitempty"`
EndMs *int64 `json:"end_ms,omitempty"`
}
+162
View File
@@ -0,0 +1,162 @@
package markers
import (
"context"
"errors"
"fmt"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ExternalIDs is the resolved identity for a media file at marker-fetch
// time. It bundles the external IDs needed by online providers (TMDB,
// IMDB, TVDB) with the kind-specific extras (season/episode for
// episodes). The zero value carries no IDs and signals "unresolvable".
type ExternalIDs struct {
Kind ItemKind
TmdbID string
ImdbID string
TvdbID string
SeasonNumber int
EpisodeNumber int
}
// HasAnyID reports whether at least one usable external identifier is
// present. Providers should refuse to issue requests when this is false.
func (e ExternalIDs) HasAnyID() bool {
return e.TmdbID != "" || e.ImdbID != "" || e.TvdbID != ""
}
// AsRequestMap exposes the external IDs in the Request.ExternalIDs shape.
func (e ExternalIDs) AsRequestMap() map[string]string {
out := make(map[string]string, 3)
if e.TmdbID != "" {
out[ExternalIDKeyTMDB] = e.TmdbID
}
if e.ImdbID != "" {
out[ExternalIDKeyIMDB] = e.ImdbID
}
if e.TvdbID != "" {
out[ExternalIDKeyTVDB] = e.TvdbID
}
return out
}
// ExternalIDResolver maps an internal media file to the external IDs an
// online marker provider can query against. Implementations must handle
// both episodes (joined through episodes -> 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
}
+11
View File
@@ -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
}
+109
View File
@@ -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:<source>` 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
}
+80
View File
@@ -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")
}
}
+1
View File
@@ -23,3 +23,4 @@ func MarkerSourcePriority(source string) int {
return 0
}
}
+14
View File
@@ -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
+11 -9
View File
@@ -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",
+6
View File
@@ -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
+2
View File
@@ -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)
+222 -162
View File
@@ -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,
-9
View File
@@ -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")
}
}
+13
View File
@@ -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
}
+17 -7
View File
@@ -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)
+35
View File
@@ -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(
+15 -6
View File
@@ -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)
}
+2
View File
@@ -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",
+4
View File
@@ -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
@@ -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;
@@ -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;
+10
View File
@@ -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;
+17
View File
@@ -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",
@@ -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 (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">TheIntroDB</h3>
<p className="text-muted-foreground text-xs">
Community-sourced intro, recap, credits, and preview timestamps. Read access is free and
requires no key. Supplying your{" "}
<a
href="https://theintrodb.org/profile"
target="_blank"
rel="noreferrer"
className="underline"
>
TheIntroDB
</a>{" "}
API key lets the server use your pending submissions before they're community-verified.
</p>
</div>
<SubtitleCredentialStatus configured={configured} />
</div>
<SettingField
label="API Key (optional)"
type="password"
value={apiKey}
onChange={setApiKey}
sensitiveConfigured={configured}
hint="Leave blank to use anonymous read access."
/>
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : "Save TheIntroDB API Key"}
</Button>
</div>
);
}
export default function IntegrationsSettings() {
return (
<div className="flex h-full flex-col">
@@ -410,6 +458,9 @@ export default function IntegrationsSettings() {
<div className="mb-8">
<MDBListCredentialCard />
</div>
<div className="mb-8">
<IntroDBCredentialCard />
</div>
<SubtitlesContent />
</div>
);
@@ -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() {
)}
/>
<SettingRow
label="Auto-skip recaps"
description="Skip 'previously on…' recaps automatically when Silo can detect them."
control={(id) => (
<div id={id}>
<Switch
checked={profile.auto_skip_recap ?? false}
disabled={updateMutation.isPending}
onCheckedChange={(checked) => saveProfileField({ auto_skip_recap: checked })}
/>
</div>
)}
/>
<SettingRow
label="Start next at preview"
description="Begin the next episode when the current one reaches its next-episode preview teaser, rather than waiting for the end credits."
control={(id) => (
<div id={id}>
<Switch
checked={profile.auto_play_next_preview ?? false}
disabled={updateMutation.isPending}
onCheckedChange={(checked) =>
saveProfileField({ auto_play_next_preview: checked })
}
/>
</div>
)}
/>
<AutoPlayNextSetting profileId={profile.id} />
<NextUpSetting />
+8
View File
@@ -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,
@@ -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>
);
}
+58 -2
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;
@@ -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<string | null>(null);
const autoSkippedRecapKeyRef = useRef<string | null>(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 && <IntroSkipButton onSkip={skipIntro} />}
{!isDetached && showRecapSkip && <IntroSkipButton onSkip={skipRecap} label="Skip Recap" />}
{/* Next episode overlay */}
{!isDetached && nextEpisode.showCountdown && nextEpisode.nextEpisode && (
+65 -2
View File
@@ -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<Set<number>>(new Set());
const handledSelectionRevisionRef = useRef<number | null>(null);
const markerRealtimeReconcileKeyRef = useRef<string | null>(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}
+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(() => {
+8 -2
View File
@@ -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)
);
}
+6
View File
@@ -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<void>;
@@ -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,
});
});
});
+30 -7
View File
@@ -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<PlayerFileVersion, "intro" | "credits"> | null | undefined,
version: Pick<PlayerFileVersion, "intro" | "credits" | "recap" | "preview"> | 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,
};
}