Merge pull request #2 from Silo-Server/t3code/8d852355
Add online recap and preview marker support
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -23,3 +23,4 @@ func MarkerSourcePriority(source string) int {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user