From e140bd94240d2935ccd20063f8a73881da816dd6 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:52:44 -0400 Subject: [PATCH] feat(metadata,scanner): trailers and extras for movies and series (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(metadata,scanner): trailers and extras for movies and series Remote provider videos (TMDB trailers/teasers/featurettes/...) are fetched through the unified match/refresh pipeline into the new item_videos table, filtered per-library via media_folders.trailer_kinds, merged across providers with site/provider dedup, and lockable via FieldVideos. The movie scanner stops discarding supplemental directories (Trailers/, Featurettes/, Behind The Scenes/, ...) and classifies them — plus Jellyfin-style filename suffixes (-trailer, -behindthescenes, ...) and series-root supplemental dirs — into the new media_extras entity backed by ordinary media_files rows (extra_id ownership, content_id/episode_id NULL so existing version/matching queries stay structurally blind to extras). Series Extras/SxxExx season-0 mapping is unchanged. Extras are playable watch targets via a GetWatchDetail fallback tier (episodes precedent), with contentid.ForLocal minting stable ids. API: ItemDetail gains additive videos/extras arrays (single + batch parity); library settings expose trailer_kinds. jellycompat now populates RemoteTrailers, LocalTrailerCount/SpecialFeatureCount, and serves real /LocalTrailers + /SpecialFeatures items playable through PlaybackInfo. Requires silo-plugin-sdk v0.9.0 (VideoRecord) before go.mod can bump; builds locally via go.work against the SDK feat/metadata-videos branch. Part of trailers/extras capability work. Co-Authored-By: Claude Fable 5 * feat(web): trailers and extras sections, library trailer-kinds setting TrailersSection (YouTube thumbnails + youtube-nocookie modal) and ExtrasSection (plays extras through the standard watch controller) on movie and series detail pages; admin library form gains a trailer-kinds allow-list synced with the server default (all provider kinds), now also honored on library create. Part of trailers/extras capability work. Co-Authored-By: Claude Fable 5 * fix(scanner): scan extra_id in scanMediaFiles; review cleanups scanMediaFiles (the plural row scanner behind GetByContentID/GetByFolder/ GetByExtraID and 20+ other queries) was missing the scan destination for the new extra_id column, which would have failed every media-file read at runtime with a column/destination count mismatch. Also: extend the batch equivalence test to seed item_videos/media_extras so the new videos/extras prefetch wiring is actually proven; drop the one-off pgxRows interface for the repo-wide pgx.Rows convention; reuse formatClock instead of a third duration formatter in ExtrasSection. Co-Authored-By: Claude Fable 5 * chore(deps): bump silo-plugin-sdk to v0.9.0 for VideoRecord Co-Authored-By: Claude Fable 5 * fix(matching): exclude extras files from match queues and bulk content linking Dev verification caught extras media_files rows (content_id NULL by design) being swept into the movie/series match queues and the root-claim bulk relink: a '-featurette' suffix extra was matched onto its parent as a version, and a Trailers/ file minted a spurious local skeleton item that shadowed the extra's watch target. Add 'extra_id IS NULL' to the queue eligibility conditions, root/group claim relinks, observed-root content assignment, and the admin unmatched-files listing. Co-Authored-By: Claude Fable 5 * fix(playback): authorize local extras files through their parent item Dev verification: playback/start (and the shared MediaFileAuthorizer used by markers/subtitles/ebook reader) resolved file ownership only via episode_id/content_id, so extras files (extra_id only) 404ed. Add an ExtraLookup tier that resolves media_extras and gates on the parent item's access, mirroring the episode->series pattern. Co-Authored-By: Claude Fable 5 * fix(catalog): resolve local extras through GetItemDetail for compat playback jellycompat PlaybackInfo (and any per-item consumer resolving arbitrary content ids) goes through GetItemDetail, which lacked the extras tier that GetWatchDetail has — so Jellyfin clients got zero MediaSources for extras. Add buildExtraItemDetail (minimal detail + ordinary playback surface, parent-gated access) as the fourth resolution tier, and map the extra type to Jellyfin's Video kind. Co-Authored-By: Claude Fable 5 * fix(web): allow youtube-nocookie embeds in CSP; trailer modal a11y The frontend CSP's frame-src blocked the trailer modal's youtube-nocookie.com iframe (found on dev verification). Also add the missing sr-only DialogDescription and drop the redundant allowFullScreen attribute. Co-Authored-By: Claude Fable 5 * fix: address PR review findings for trailers/extras - Extras watch/item detail no longer stamp SeriesID/SeriesTitle for movie-owned extras (players key episodic post-roll flows off series_id); series-owned extras keep them (Codex). - processExtraFiles resolves the parent and upserts media_extras before the unchanged fast-path, and the fast-path now also compares mtime, so rematched parents / reclassified kinds / same-size replacements converge (Codex + CodeRabbit). - media_files upsert clears content/episode linkage atomically when extra_id is set (ownership mutual exclusion in one statement); the now-redundant MarkFileAsExtra helper is removed (CodeRabbit). - ScanFile's extras branch runs syncPresentLibraryState + reconcileLibraryMemberships so converting a primary file to an extra cleans stale library membership immediately (CodeRabbit). - media_extras migration adds the media_files FK as NOT VALID + VALIDATE to avoid a full-scan exclusive lock on large tables (CodeRabbit). - trailer_kinds input is trimmed/lowercased/deduped and unknown values are dropped instead of silently widening the allow-list to 'other' (CodeRabbit). - Extras authorization branches match the episode branch's posture: unconfigured lookup is a config error, nil extra is a 404 (CodeRabbit). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- docs/wiki/admin/media-folder-and-naming.md | 24 +- go.mod | 2 +- go.sum | 4 +- internal/api/handlers/admin.go | 2 +- internal/api/handlers/libraries.go | 14 + internal/api/handlers/media_file_auth.go | 20 ++ internal/api/handlers/playback.go | 25 ++ internal/api/router.go | 6 + internal/catalog/detail.go | 284 +++++++++++++++++- .../catalog/detail_batch_equivalence_test.go | 44 +++ internal/catalog/extras_repo.go | 150 +++++++++ internal/catalog/folder_repo.go | 61 +++- internal/catalog/group_claim_repo.go | 1 + internal/catalog/root_claim_repo.go | 1 + internal/catalog/video_repo.go | 159 ++++++++++ internal/jellycompat/content_direct.go | 2 + internal/jellycompat/handlers_items.go | 101 ++++++- internal/jellycompat/mapping.go | 57 ++++ internal/jellycompat/router.go | 4 +- internal/jellycompat/upstream_types.go | 2 + internal/metadata/merge.go | 35 +++ internal/metadata/merge_videos_test.go | 82 +++++ internal/metadata/movie_match_queue_repo.go | 2 +- internal/metadata/plugin_provider.go | 37 +++ .../metadata/series_root_match_queue_repo.go | 22 +- internal/metadata/service.go | 124 ++++++++ internal/metadata/types.go | 21 ++ internal/models/extras.go | 81 +++++ internal/models/media.go | 19 +- internal/naming/extras.go | 60 ++++ internal/naming/extras_test.go | 49 +++ internal/pathscope/pathscope.go | 6 + internal/scanner/extras.go | 274 +++++++++++++++++ internal/scanner/extras_test.go | 86 ++++++ internal/scanner/file_repo.go | 178 +++++++++-- internal/scanner/root_observation_test.go | 12 +- internal/scanner/scan_state.go | 9 +- internal/scanner/scanner.go | 98 ++++-- internal/server/frontend.go | 4 +- migrations/sql/20260706201109_item_videos.sql | 38 +++ .../sql/20260706201110_media_extras.sql | 63 ++++ ...60706201112_media_folder_trailer_kinds.sql | 15 + web/src/api/types.ts | 31 ++ .../admin/libraries/LibraryFormSections.tsx | 37 +++ .../admin/libraries/useLibraryForm.ts | 13 + web/src/lib/extraKinds.ts | 56 ++++ web/src/pages/ItemDetail/MovieContent.tsx | 6 + web/src/pages/ItemDetail/SeriesContent.tsx | 6 + .../ItemDetail/components/ExtrasSection.tsx | 91 ++++++ .../ItemDetail/components/TrailerModal.tsx | 34 +++ .../ItemDetail/components/TrailersSection.tsx | 103 +++++++ 51 files changed, 2563 insertions(+), 92 deletions(-) create mode 100644 internal/catalog/extras_repo.go create mode 100644 internal/catalog/video_repo.go create mode 100644 internal/metadata/merge_videos_test.go create mode 100644 internal/models/extras.go create mode 100644 internal/naming/extras.go create mode 100644 internal/naming/extras_test.go create mode 100644 internal/scanner/extras.go create mode 100644 internal/scanner/extras_test.go create mode 100644 migrations/sql/20260706201109_item_videos.sql create mode 100644 migrations/sql/20260706201110_media_extras.sql create mode 100644 migrations/sql/20260706201112_media_folder_trailer_kinds.sql create mode 100644 web/src/lib/extraKinds.ts create mode 100644 web/src/pages/ItemDetail/components/ExtrasSection.tsx create mode 100644 web/src/pages/ItemDetail/components/TrailerModal.tsx create mode 100644 web/src/pages/ItemDetail/components/TrailersSection.tsx diff --git a/docs/wiki/admin/media-folder-and-naming.md b/docs/wiki/admin/media-folder-and-naming.md index 1baf430e..962e3750 100644 --- a/docs/wiki/admin/media-folder-and-naming.md +++ b/docs/wiki/admin/media-folder-and-naming.md @@ -199,13 +199,31 @@ Silo often sees sidecar files that mirror the media basename: These are common and expected. Episode naming guidance in this page refers to the media files themselves, but sidecars may legitimately reuse the same stem. -Some movie supplemental content is intentionally skipped, such as: +Supplemental directories next to a movie (and directly under a series root) +are scanned as **extras** attached to that item, following the Jellyfin/Plex +folder convention: +- `Trailers`, `Teasers` - `Featurettes` - `Behind the Scenes` - `Deleted Scenes` -- `Trailers` -- `Sample.mkv` +- `Clips`, `Shorts`, `Interviews`, `Scenes` +- `Extras`, `Other` + +Filename suffixes on files sitting next to the movie are also recognized: +`Movie (2020)-trailer.mkv`, `-teaser`, `-featurette`, `-clip`, +`-behindthescenes`, `-deleted`, `-interview`, `-short`, `-other`. + +Extras never appear as versions of the main title; they show in the item's +Extras section and play like any other file. Extras are bound to the item +owning the surrounding folder, so an extras directory at the library root is +ignored. For series libraries, `Extras/` files carrying a valid `SxxExx` +token keep their documented season-`0` mapping and are not treated as extras. + +Noise content is still intentionally skipped: + +- `Sample` / `Samples` directories and `Sample.mkv`-style files +- `Subs` / `Subtitles` directories (handled by subtitle detection) ## What The Dev Anime Library Validated diff --git a/go.mod b/go.mod index 6e4a0a46..78ea97b9 100644 --- a/go.mod +++ b/go.mod @@ -98,7 +98,7 @@ require ( ) require ( - github.com/Silo-Server/silo-plugin-sdk v0.8.1 + github.com/Silo-Server/silo-plugin-sdk v0.9.0 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect diff --git a/go.sum b/go.sum index cc9120f8..fb522141 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= -github.com/Silo-Server/silo-plugin-sdk v0.8.1 h1:6DLm2s/O3N9KTx2d09Nq3bbV8z4u9HJwO2gueQDPyPw= -github.com/Silo-Server/silo-plugin-sdk v0.8.1/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= +github.com/Silo-Server/silo-plugin-sdk v0.9.0 h1:fo4vUz3AcHO9tr5MYHdk+O3kLxIckf7rIBqv9Z5Ys+0= +github.com/Silo-Server/silo-plugin-sdk v0.9.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 19607291..d52cd55e 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -936,7 +936,7 @@ func (h *AdminHandler) HandleListUnmatched(w http.ResponseWriter, r *http.Reques rows, err := h.pool.Query(r.Context(), `SELECT id, media_folder_id, file_path, file_size, container FROM media_files - WHERE content_id IS NULL + WHERE content_id IS NULL AND extra_id IS NULL ORDER BY id ASC LIMIT $1 OFFSET $2`, limit, offset) if err != nil { diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index 8523226b..65dd81c6 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -183,6 +183,9 @@ type createLibraryRequest struct { MetadataLanguage string `json:"metadata_language,omitempty"` ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled,omitempty"` IntroDetectionEnabled bool `json:"intro_detection_enabled,omitempty"` + // TrailerKinds is the allow-list of remote video kinds fetched during + // metadata refresh; omitted = default (all provider kinds). + TrailerKinds []string `json:"trailer_kinds,omitempty"` } // updateLibraryRequest represents the JSON body for PUT /libraries/{id}. @@ -195,6 +198,9 @@ type updateLibraryRequest struct { AutoTranslateMetadata *bool `json:"auto_translate_metadata,omitempty"` ChapterThumbnailsEnabled *bool `json:"chapter_thumbnails_enabled,omitempty"` IntroDetectionEnabled *bool `json:"intro_detection_enabled,omitempty"` + // TrailerKinds is the allow-list of remote video kinds fetched during + // metadata refresh (ExtraKind values); empty array disables remote videos. + TrailerKinds *[]string `json:"trailer_kinds,omitempty"` } // scanRequest represents the JSON body for POST /scan. @@ -231,6 +237,7 @@ type libraryResponse struct { ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"` ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"` IntroDetectionEnabled bool `json:"intro_detection_enabled"` + TrailerKinds []string `json:"trailer_kinds"` SortOrder int `json:"sort_order"` PosterURL string `json:"poster_url,omitempty"` LastScannedAt *time.Time `json:"last_scanned_at,omitempty"` @@ -346,6 +353,10 @@ func toLibraryResponse(f *models.MediaFolder) libraryResponse { if paths == nil { paths = []string{} } + trailerKinds := f.TrailerKinds + if trailerKinds == nil { + trailerKinds = []string{} + } return libraryResponse{ ID: f.ID, Paths: paths, @@ -357,6 +368,7 @@ func toLibraryResponse(f *models.MediaFolder) libraryResponse { ChapterThumbnailsEnabled: f.ChapterThumbnailsEnabled, ChapterThumbnailsSupported: false, IntroDetectionEnabled: f.IntroDetectionEnabled, + TrailerKinds: trailerKinds, SortOrder: f.SortOrder, LastScannedAt: f.LastScannedAt, ScanWarningCode: f.ScanWarningCode, @@ -564,6 +576,7 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ MetadataLanguage: req.MetadataLanguage, ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled, IntroDetectionEnabled: req.IntroDetectionEnabled, + TrailerKinds: req.TrailerKinds, }) if err != nil { if errors.Is(err, catalog.ErrDuplicatePath) { @@ -661,6 +674,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ AutoTranslateMetadata: req.AutoTranslateMetadata, ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled, IntroDetectionEnabled: req.IntroDetectionEnabled, + TrailerKinds: req.TrailerKinds, }) if err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { diff --git a/internal/api/handlers/media_file_auth.go b/internal/api/handlers/media_file_auth.go index ef9b826a..68ba9ace 100644 --- a/internal/api/handlers/media_file_auth.go +++ b/internal/api/handlers/media_file_auth.go @@ -15,6 +15,7 @@ type MediaFileAuthorizer struct { FileResolver FilePathResolver ItemAccess PlaybackItemAccessChecker EpisodeLookup PlaybackEpisodeLookup + ExtraLookup PlaybackExtraLookup } // Authorize returns the media file when the caller may access it, or catalog.ErrItemNotFound. @@ -51,6 +52,25 @@ func (a *MediaFileAuthorizer) Authorize(r *http.Request, fileID int) (*models.Me if err := a.ItemAccess.EnsureAccessible(r.Context(), file.ContentID, filter); err != nil { return nil, err } + case file.ExtraID != "": + // Local extras authorize through their parent item, like episodes + // authorize through their series. + if a.ExtraLookup == nil { + return nil, fmt.Errorf("extra lookup not configured") + } + extra, err := a.ExtraLookup.GetByID(r.Context(), file.ExtraID) + if err != nil { + if errors.Is(err, catalog.ErrExtraNotFound) { + return nil, catalog.ErrItemNotFound + } + return nil, err + } + if extra == nil { + return nil, catalog.ErrItemNotFound + } + if err := a.ItemAccess.EnsureAccessible(r.Context(), extra.ParentID, filter); err != nil { + return nil, err + } default: return nil, catalog.ErrItemNotFound } diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index c6990341..bc7be4f0 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -68,6 +68,13 @@ type PlaybackEpisodeLookup interface { GetByID(ctx context.Context, contentID string) (*models.Episode, error) } +// PlaybackExtraLookup resolves local extras (media_extras) so their files +// authorize through the parent item, like episodes authorize through their +// series. +type PlaybackExtraLookup interface { + GetByID(ctx context.Context, contentID string) (*models.MediaExtra, error) +} + type PlaybackSessionSyncer interface { SyncNow(ctx context.Context) error } @@ -114,6 +121,7 @@ type PlaybackHandler struct { JWTSecret string // needed for signing stream tokens ItemAccess PlaybackItemAccessChecker // optional; enables file authorization checks EpisodeLookup PlaybackEpisodeLookup // optional; resolves episode files to their series + ExtraLookup PlaybackExtraLookup // optional; resolves extras files to their parent item OriginalLangLookup PlaybackOriginalLanguageLookup SettingsRepo PlaybackSettingsReader // optional; reads server settings (e.g., allow_4k_transcode) FileVersionFetcher PlaybackFileVersionFetcher // optional; queries sibling file versions for 4K guard @@ -2208,6 +2216,23 @@ func (h *PlaybackHandler) loadAuthorizedFile(r *http.Request, fileID int) (*mode if err := h.ItemAccess.EnsureAccessible(r.Context(), file.ContentID, filter); err != nil { return nil, err } + case file.ExtraID != "": + if h.ExtraLookup == nil { + return nil, fmt.Errorf("extra lookup not configured") + } + extra, err := h.ExtraLookup.GetByID(r.Context(), file.ExtraID) + if err != nil { + if errors.Is(err, catalog.ErrExtraNotFound) { + return nil, catalog.ErrItemNotFound + } + return nil, err + } + if extra == nil { + return nil, catalog.ErrItemNotFound + } + if err := h.ItemAccess.EnsureAccessible(r.Context(), extra.ParentID, filter); err != nil { + return nil, err + } default: return nil, catalog.ErrItemNotFound } diff --git a/internal/api/router.go b/internal/api/router.go index aeb5857a..c5ba8588 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -490,6 +490,7 @@ func NewRouter(deps Dependencies) chi.Router { var peopleHandler *handlers.PeopleHandler var itemRepo *catalog.ItemRepository var episodeRepo *catalog.EpisodeRepository + var extraRepo *catalog.ExtraRepository var providerIDRepo *catalog.ProviderIDRepository var seasonRepo *catalog.SeasonRepository var detailSvc *catalog.DetailService @@ -529,6 +530,7 @@ func NewRouter(deps Dependencies) chi.Router { catalog.SetActiveSearchIndexProvider(activeSearchProvider) itemRepo.WithSearchIndexEvents(searchIndexEvents) episodeRepo = catalog.NewEpisodeRepository(deps.DB) + extraRepo = catalog.NewExtraRepository(deps.DB) providerIDRepo = catalog.NewProviderIDRepository(deps.DB) calendarRepo = catalog.NewCalendarRepository(deps.DB) @@ -593,6 +595,7 @@ func NewRouter(deps Dependencies) chi.Router { FileResolver: deps.FileRepo, ItemAccess: itemRepo, EpisodeLookup: episodeRepo, + ExtraLookup: extraRepo, }) if ebookProgressStore != nil { ebookReaderHandler.ProgressStore = ebookProgressStore @@ -1052,6 +1055,7 @@ func NewRouter(deps Dependencies) chi.Router { FileResolver: deps.FileRepo, ItemAccess: itemRepo, EpisodeLookup: episodeRepo, + ExtraLookup: extraRepo, } } } @@ -2201,6 +2205,7 @@ func NewRouter(deps Dependencies) chi.Router { FileResolver: deps.FileRepo, ItemAccess: itemRepo, EpisodeLookup: episodeRepo, + ExtraLookup: extraRepo, } subtitleSearchHandler.FileAuthorizer = fileAuthorizer if subtitleAIHandler != nil { @@ -2234,6 +2239,7 @@ func NewRouter(deps Dependencies) chi.Router { if playbackHandler != nil { playbackHandler.ItemAccess = itemRepo playbackHandler.EpisodeLookup = episodeRepo + playbackHandler.ExtraLookup = extraRepo playbackHandler.OriginalLangLookup = itemRepo playbackHandler.FFmpegLogSink = deps.FFmpegLogSink diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index d647b213..42d06229 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -26,6 +26,13 @@ type FileVersionFetcher interface { GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error) } +// extraFileFetcher is the optional FileVersionFetcher extension used to +// resolve files backing local extras (media_extras rows). The concrete +// *scanner.FileRepository implements it; test fakes may omit it. +type extraFileFetcher interface { + GetByExtraID(ctx context.Context, extraID string) ([]*models.MediaFile, error) +} + type PlaybackProbeEnsurer interface { Ensure(ctx context.Context, file *models.MediaFile) (*models.MediaFile, error) } @@ -153,6 +160,14 @@ type ItemDetail struct { Versions []FileVersion `json:"versions"` PlaybackVariants []PlaybackVariant `json:"playback_variants,omitempty"` + // Remote provider videos (YouTube trailers, teasers, ...) for + // movies/series, ordered for display (trailers first, official first). + Videos []ItemVideoInfo `json:"videos,omitempty"` + + // Local extras (scanner-discovered trailers, featurettes, deleted + // scenes, ...) playable via their own content_id through /watch. + Extras []ItemExtraInfo `json:"extras,omitempty"` + // Root folder paths for series items (admin-only). FolderPaths []string `json:"folder_paths,omitempty"` @@ -195,6 +210,29 @@ type ItemDetail struct { Manga *MangaDetailExtension `json:"manga,omitempty"` } +// ItemVideoInfo is the API shape of a remote provider video. It exposes the +// site reference (site + site_key) rather than internal row identity so +// clients can build embed/watch URLs without further lookups. +type ItemVideoInfo struct { + Kind string `json:"kind"` + Site string `json:"site"` + SiteKey string `json:"site_key"` + Name string `json:"name,omitempty"` + Language string `json:"language,omitempty"` + IsOfficial bool `json:"is_official"` +} + +// ItemExtraInfo is the API shape of a local extra. ContentID is a playable +// watch target (same /watch flow as any item); FileID backs download/direct +// stream affordances. +type ItemExtraInfo struct { + ContentID string `json:"content_id"` + Kind string `json:"kind"` + Title string `json:"title,omitempty"` + DurationSeconds int `json:"duration_seconds,omitempty"` + FileID int `json:"file_id,omitempty"` +} + type AudiobookDetailExtension struct { Authors []AudiobookPerson `json:"authors"` Narrators []AudiobookPerson `json:"narrators"` @@ -545,6 +583,8 @@ type DetailService struct { GetByID(ctx context.Context, id int) (*models.MediaFolder, error) } fileFetcher FileVersionFetcher + videoRepo *VideoRepository + extraRepo *ExtraRepository rootClaimRepo *RootClaimRepository groupClaimRepo *GroupClaimRepository imageResolver ImageResolver @@ -571,6 +611,8 @@ func NewDetailService( itemLocRepo: NewMediaItemLocalizationRepository(itemRepo.pool), seasonLocRepo: NewSeasonLocalizationRepository(itemRepo.pool), episodeLocRepo: NewEpisodeLocalizationRepository(itemRepo.pool), + videoRepo: NewVideoRepository(itemRepo.pool), + extraRepo: NewExtraRepository(itemRepo.pool), fileFetcher: fileFetcher, } } @@ -932,7 +974,11 @@ func (s *DetailService) GetItemDetail(ctx context.Context, contentID string, fil episode, err := s.episodeRepo.GetByID(ctx, contentID) if err != nil { if errors.Is(err, ErrEpisodeNotFound) { - return nil, ErrItemNotFound + // Fourth tier: a local extra. Serving it from GetItemDetail keeps + // per-item consumers that resolve arbitrary content ids + // (jellycompat PlaybackInfo in particular) playable without a + // separate lookup path. + return s.buildExtraItemDetail(ctx, contentID, filter) } return nil, err } @@ -949,6 +995,72 @@ func (s *DetailService) GetItemDetail(ctx context.Context, contentID string, fil return s.buildEpisodeDetail(ctx, episode, seriesCtx, filter) } +// buildExtraItemDetail resolves a local extra as a minimal ItemDetail: +// title/kind plus the ordinary playback surface (versions, subtitles) built +// from its backing files. Access control is the parent item's. +func (s *DetailService) buildExtraItemDetail(ctx context.Context, contentID string, filter AccessFilter) (*ItemDetail, error) { + if s.extraRepo == nil { + return nil, ErrItemNotFound + } + extra, err := s.extraRepo.GetByID(ctx, contentID) + if err != nil { + if errors.Is(err, ErrExtraNotFound) { + return nil, ErrItemNotFound + } + return nil, err + } + if err := s.itemRepo.EnsureAccessible(ctx, extra.ParentID, filter); err != nil { + return nil, err + } + if err := s.validatePresentationItemAccess(ctx, filter, extra.ParentID); err != nil { + return nil, err + } + fetcher, ok := s.fileFetcher.(extraFileFetcher) + if !ok { + return nil, ErrItemNotFound + } + files, err := fetcher.GetByExtraID(ctx, extra.ContentID) + if err != nil { + return nil, fmt.Errorf("fetching extra files: %w", err) + } + files = FilterMediaFilesByAccess(files, filter) + files = s.preparePlaybackFiles(ctx, files) + + detail := &ItemDetail{ + ContentID: extra.ContentID, + Type: "extra", + Title: extra.Title, + Genres: []string{}, + Cast: []CastCredit{}, + Crew: []CrewCredit{}, + Studios: []string{}, + Networks: []string{}, + } + detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits, detail.Recap, detail.Preview = s.buildPlaybackInfo( + ctx, + files, + filter, + extra.ContentID, + ) + if parent, parentErr := s.itemRepo.GetByID(ctx, extra.ParentID); parentErr == nil { + if localized, locErr := s.LocalizeItemModel(ctx, parent, filter); locErr == nil { + parent = localized + } + if detail.Title == "" { + detail.Title = parent.Title + } + // Series fields only for series-owned extras: clients treat a + // populated series_id as episodic context (post-roll/next-episode + // flows), which is wrong for a movie's extras. + if parent.Type == "series" { + detail.SeriesID = extra.ParentID + detail.SeriesTitle = parent.Title + } + detail.Year = parent.Year + } + return detail, nil +} + // seriesDetailContext caches series-level lookups so a batched episode-detail // call doesn't redo them per episode. type seriesDetailContext struct { @@ -1142,6 +1254,30 @@ func (s *DetailService) GetItemDetailsByIDs(ctx context.Context, contentIDs []st } } + // Remote videos and local extras for movie/series items in two queries. + movieSeriesIDs := make([]string, 0, len(visible)) + for _, item := range visible { + if item.Type == "movie" || item.Type == "series" { + movieSeriesIDs = append(movieSeriesIDs, item.ContentID) + } + } + var videosByID map[string][]models.ItemVideo + var extrasByID map[string][]ExtraWithFile + if len(movieSeriesIDs) > 0 { + if s.videoRepo != nil { + videosByID, err = s.videoRepo.ListByContentIDs(ctx, movieSeriesIDs) + if err != nil { + return nil, err + } + } + if s.extraRepo != nil { + extrasByID, err = s.extraRepo.ListWithFilesByParentIDs(ctx, movieSeriesIDs) + if err != nil { + return nil, err + } + } + } + // Work summaries for the whole page in one query, when the provider supports // batching (ListSummariesForContentIDs is the documented batch equivalent of // GetSummaryForContentID). @@ -1178,6 +1314,16 @@ func (s *DetailService) GetItemDetailsByIDs(ctx context.Context, contentIDs []st pf.haveWorkSummary = true pf.workSummary = workSummaries[id] } + if item.Type == "movie" || item.Type == "series" { + if s.videoRepo != nil { + pf.haveVideos = true + pf.videos = videosByID[id] + } + if s.extraRepo != nil { + pf.haveExtras = true + pf.extras = extrasByID[id] + } + } detail, err := s.buildMediaItemDetail(ctx, item, id, filter, pf) if err != nil { // Skip rather than fail the batch; the caller falls back to list @@ -1190,6 +1336,67 @@ func (s *DetailService) GetItemDetailsByIDs(ctx context.Context, contentIDs []st return result, nil } +// fetchItemVideos returns the item's remote videos in API shape, honoring a +// batch prefetch when present. Lookup failures degrade to an empty section. +func (s *DetailService) fetchItemVideos(ctx context.Context, contentID string, pf *itemDetailPrefetch) []ItemVideoInfo { + var videos []models.ItemVideo + if pf != nil && pf.haveVideos { + videos = pf.videos + } else if s.videoRepo != nil { + fetched, err := s.videoRepo.GetByContentID(ctx, contentID) + if err != nil { + slog.WarnContext(ctx, "failed to fetch item videos", "content_id", contentID, "error", err) + return nil + } + videos = fetched + } + if len(videos) == 0 { + return nil + } + infos := make([]ItemVideoInfo, 0, len(videos)) + for _, v := range videos { + infos = append(infos, ItemVideoInfo{ + Kind: string(v.Kind), + Site: v.Site, + SiteKey: v.SiteKey, + Name: v.Name, + Language: v.Language, + IsOfficial: v.IsOfficial, + }) + } + return infos +} + +// fetchItemExtras returns the item's local extras in API shape, honoring a +// batch prefetch when present. Lookup failures degrade to an empty section. +func (s *DetailService) fetchItemExtras(ctx context.Context, contentID string, pf *itemDetailPrefetch) []ItemExtraInfo { + var extras []ExtraWithFile + if pf != nil && pf.haveExtras { + extras = pf.extras + } else if s.extraRepo != nil { + fetched, err := s.extraRepo.ListWithFilesByParentID(ctx, contentID) + if err != nil { + slog.WarnContext(ctx, "failed to fetch item extras", "content_id", contentID, "error", err) + return nil + } + extras = fetched + } + if len(extras) == 0 { + return nil + } + infos := make([]ItemExtraInfo, 0, len(extras)) + for _, e := range extras { + infos = append(infos, ItemExtraInfo{ + ContentID: e.ContentID, + Kind: string(e.Kind), + Title: e.Title, + DurationSeconds: e.Duration, + FileID: e.FileID, + }) + } + return infos +} + // fetchCredits returns cast and crew credits for the given content ID. func (s *DetailService) fetchCredits(ctx context.Context, contentID string) ([]CastCredit, []CrewCredit) { if s.personRepo == nil { @@ -1220,6 +1427,10 @@ type itemDetailPrefetch struct { files []*models.MediaFile haveWorkSummary bool workSummary *WorkSummary + haveVideos bool + videos []models.ItemVideo + haveExtras bool + extras []ExtraWithFile } func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.MediaItem, contentID string, filter AccessFilter, pf *itemDetailPrefetch) (*ItemDetail, error) { @@ -1317,6 +1528,12 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M detail.OverlaySummary = overlays.BuildSummary(files) } + // Trailers/extras apply to movies and series only. + if item.Type == "movie" || item.Type == "series" { + detail.Videos = s.fetchItemVideos(ctx, contentID, pf) + detail.Extras = s.fetchItemExtras(ctx, contentID, pf) + } + if item.Type == "audiobook" { detail.Audiobook = s.buildAudiobookExtension(ctx, item, detail.Versions, crewCredits, filter) } @@ -2227,6 +2444,11 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi episode, err := s.episodeRepo.GetByID(ctx, contentID) if err != nil { + // Third fallback tier: a local extra (media_extras). Access is the + // parent item's; playback reuses the ordinary file/versions pipeline. + if errors.Is(err, ErrEpisodeNotFound) { + return s.buildExtraWatchDetail(ctx, contentID, filter) + } return nil, err } if err := s.itemRepo.EnsureAccessible(ctx, episode.SeriesID, filter); err != nil { @@ -2291,6 +2513,66 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi return detail, nil } +// buildExtraWatchDetail resolves a local extra (media_extras) as a watch +// target. Access control is the parent item's library membership, mirroring +// how episodes gate on their series. +func (s *DetailService) buildExtraWatchDetail(ctx context.Context, contentID string, filter AccessFilter) (*WatchDetail, error) { + if s.extraRepo == nil { + return nil, ErrItemNotFound + } + extra, err := s.extraRepo.GetByID(ctx, contentID) + if err != nil { + if errors.Is(err, ErrExtraNotFound) { + return nil, ErrItemNotFound + } + return nil, err + } + if err := s.itemRepo.EnsureAccessible(ctx, extra.ParentID, filter); err != nil { + return nil, err + } + if err := s.validatePresentationItemAccess(ctx, filter, extra.ParentID); err != nil { + return nil, err + } + fetcher, ok := s.fileFetcher.(extraFileFetcher) + if !ok { + return nil, ErrItemNotFound + } + files, err := fetcher.GetByExtraID(ctx, extra.ContentID) + if err != nil { + return nil, fmt.Errorf("fetching extra watch files: %w", err) + } + files = FilterMediaFilesByAccess(files, filter) + files = s.preparePlaybackFiles(ctx, files) + s.queueWatchPlaybackFiles(ctx, extra.ContentID, "extra", files) + detail := s.newWatchDetail( + ctx, + extra.ContentID, + "extra", + extra.Title, + "", + files, + filter, + extra.ParentID, + ) + if parent, parentErr := s.itemRepo.GetByID(ctx, extra.ParentID); parentErr == nil { + if localized, locErr := s.LocalizeItemModel(ctx, parent, filter); locErr == nil { + parent = localized + } + if detail.Title == "" { + detail.Title = parent.Title + } + // Series fields only for series-owned extras: players treat a + // populated SeriesID as episodic context (post-roll/next-episode + // flows), which is wrong for a movie's extras. + if parent.Type == "series" { + detail.SeriesID = extra.ParentID + detail.SeriesTitle = parent.Title + } + detail.Year = parent.Year + } + return detail, nil +} + func (s *DetailService) newWatchDetail( ctx context.Context, contentID, diff --git a/internal/catalog/detail_batch_equivalence_test.go b/internal/catalog/detail_batch_equivalence_test.go index 1d074f69..a4d401a8 100644 --- a/internal/catalog/detail_batch_equivalence_test.go +++ b/internal/catalog/detail_batch_equivalence_test.go @@ -133,12 +133,17 @@ func TestGetItemDetailsByIDs_MatchesGetItemDetail(t *testing.T) { personActor1 := suffix personActor2 := suffix + 1 personDirector := suffix + 2 + extraA := fmt.Sprintf("batch-equiv-extra-%d", suffix) + extraFolderName := fmt.Sprintf("batch-equiv-folder-%d", suffix) t.Cleanup(func() { ids := []string{movieA, movieB, series, movieR} batchEquivExec(t, pool, `DELETE FROM item_people WHERE content_id = ANY($1)`, ids) batchEquivExec(t, pool, `DELETE FROM people WHERE id = ANY($1)`, []int64{personActor1, personActor2, personDirector}) batchEquivExec(t, pool, `DELETE FROM media_item_localizations WHERE content_id = ANY($1)`, ids) + // Deleting the folder cascades the extra's media_files row; deleting + // the items cascades item_videos and media_extras. + batchEquivExec(t, pool, `DELETE FROM media_folders WHERE name = $1`, extraFolderName) batchEquivExec(t, pool, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids) }) @@ -188,6 +193,35 @@ func TestGetItemDetailsByIDs_MatchesGetItemDetail(t *testing.T) { insertCredit(personActor2, personActor2, models.PersonKindActor, "Sidekick", 1) insertCredit(personDirector, personDirector, models.PersonKindDirector, "", 0) + // Remote videos on movieA and the series: exercises the batched + // videoRepo.ListByContentIDs prefetch against per-item GetByContentID. + insertVideo := func(id int64, contentID, providerKey, kind string, official bool, order int) { + batchEquivExec(t, pool, ` + INSERT INTO item_videos (id, content_id, provider, provider_key, kind, site, site_key, name, language, is_official, sort_order) + VALUES ($1, $2, 'tmdb', $3, $4, 'youtube', 'yt-' || $3, 'Video ' || $3, 'en', $5, $6) + `, id, contentID, providerKey, kind, official, order) + } + insertVideo(suffix+10, movieA, "v1", "trailer", true, 0) + insertVideo(suffix+11, movieA, "v2", "featurette", false, 1) + insertVideo(suffix+12, series, "v3", "teaser", false, 0) + + // A local extra on movieA backed by a live media_files row: exercises the + // batched extraRepo.ListWithFilesByParentIDs prefetch against the per-item + // ListWithFilesByParentID lookup. + var extraFolderID int + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name) VALUES ('movies', $1) RETURNING id`, + extraFolderName).Scan(&extraFolderID); err != nil { + t.Fatalf("seed extra folder: %v", err) + } + batchEquivExec(t, pool, ` + INSERT INTO media_extras (content_id, parent_id, kind, title) + VALUES ($1, $2, 'featurette', 'Making Of') + `, extraA, movieA) + batchEquivExec(t, pool, ` + INSERT INTO media_files (extra_id, media_folder_id, file_path, duration) + VALUES ($1, $2, $3, 120) + `, extraA, extraFolderID, fmt.Sprintf("/media/batch-equiv-extra-%d.mkv", suffix)) + fileFetcher := &batchEquivFileFetcher{files: map[string][]*models.MediaFile{ movieA: { {ID: 9001, ContentID: movieA, FilePath: "/media/movie-a-1080p.mkv", Container: "mkv", Resolution: "1080p", Duration: 6000, FileSize: 5_000_000}, @@ -278,4 +312,14 @@ func TestGetItemDetailsByIDs_MatchesGetItemDetail(t *testing.T) { if got := batch[series]; got.WorkID != "work-series-1" { t.Fatalf("series work summary not applied: WorkID=%q", got.WorkID) } + if got := batch[movieA]; len(got.Videos) != 2 || got.Videos[0].Kind != "trailer" || got.Videos[0].SiteKey != "yt-v1" { + t.Fatalf("movieA videos prefetch mismatch: %#v", got.Videos) + } + if got := batch[series]; len(got.Videos) != 1 || got.Videos[0].Kind != "teaser" { + t.Fatalf("series videos prefetch mismatch: %#v", got.Videos) + } + if got := batch[movieA]; len(got.Extras) != 1 || got.Extras[0].ContentID != extraA || + got.Extras[0].DurationSeconds != 120 || got.Extras[0].FileID == 0 { + t.Fatalf("movieA extras prefetch mismatch: %#v", got.Extras) + } } diff --git a/internal/catalog/extras_repo.go b/internal/catalog/extras_repo.go new file mode 100644 index 00000000..5436a608 --- /dev/null +++ b/internal/catalog/extras_repo.go @@ -0,0 +1,150 @@ +package catalog + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// ErrExtraNotFound is returned when a media_extras row does not exist. +var ErrExtraNotFound = errors.New("media extra not found") + +// ExtraRepository persists scanner-discovered local extras (media_extras). +// Extras are child entities of a movie/series item, playable via their own +// content_id through GetWatchDetail's fallback chain. +type ExtraRepository struct { + pool *pgxpool.Pool +} + +// NewExtraRepository creates an extra repository backed by the given pool. +func NewExtraRepository(pool *pgxpool.Pool) *ExtraRepository { + return &ExtraRepository{pool: pool} +} + +// Upsert inserts or refreshes an extra. The content_id is deterministic +// (contentid.ForLocal of the backing file path), so rescans converge on the +// same row; kind/title/parent follow the latest scan classification. +func (r *ExtraRepository) Upsert(ctx context.Context, extra models.MediaExtra) error { + if strings.TrimSpace(extra.ContentID) == "" { + return fmt.Errorf("extra content_id is required") + } + if strings.TrimSpace(extra.ParentID) == "" { + return fmt.Errorf("extra parent_id is required") + } + _, err := r.pool.Exec(ctx, ` + INSERT INTO media_extras (content_id, parent_id, kind, title, sort_order) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (content_id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + kind = EXCLUDED.kind, + title = EXCLUDED.title, + sort_order = EXCLUDED.sort_order, + updated_at = now()`, + extra.ContentID, extra.ParentID, string(extra.Kind), extra.Title, extra.SortOrder) + if err != nil { + return fmt.Errorf("upsert media extra: %w", err) + } + return nil +} + +// GetByID returns a single extra, or ErrExtraNotFound. +func (r *ExtraRepository) GetByID(ctx context.Context, contentID string) (*models.MediaExtra, error) { + var extra models.MediaExtra + var kind string + err := r.pool.QueryRow(ctx, ` + SELECT content_id, parent_id, kind, title, sort_order + FROM media_extras + WHERE content_id = $1`, contentID). + Scan(&extra.ContentID, &extra.ParentID, &kind, &extra.Title, &extra.SortOrder) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrExtraNotFound + } + return nil, fmt.Errorf("query media extra: %w", err) + } + extra.Kind = models.ExtraKind(kind) + return &extra, nil +} + +// ExtraWithFile pairs an extra with summary fields of its live backing file +// for detail listings. +type ExtraWithFile struct { + models.MediaExtra + FileID int + Duration int // seconds +} + +// ListWithFilesByParentID returns the parent's extras that have at least one +// live (non-missing) backing file, with per-extra file summary data. +func (r *ExtraRepository) ListWithFilesByParentID(ctx context.Context, parentID string) ([]ExtraWithFile, error) { + result, err := r.ListWithFilesByParentIDs(ctx, []string{parentID}) + if err != nil { + return nil, err + } + return result[parentID], nil +} + +// ListWithFilesByParentIDs is the batch form of ListWithFilesByParentID, +// keyed by parent content_id. Parents without live extras are absent. +func (r *ExtraRepository) ListWithFilesByParentIDs(ctx context.Context, parentIDs []string) (map[string][]ExtraWithFile, error) { + if len(parentIDs) == 0 { + return map[string][]ExtraWithFile{}, nil + } + // DISTINCT ON keeps one live file per extra (an extra is 1:1 with a file + // in practice; duplicates would only appear transiently mid-rescan). + rows, err := r.pool.Query(ctx, ` + SELECT DISTINCT ON (e.content_id) + e.content_id, e.parent_id, e.kind, e.title, e.sort_order, + f.id, COALESCE(f.duration, 0) + FROM media_extras e + JOIN media_files f ON f.extra_id = e.content_id AND f.missing_since IS NULL + WHERE e.parent_id = ANY($1) + ORDER BY e.content_id, f.id`, parentIDs) + if err != nil { + return nil, fmt.Errorf("query media extras with files: %w", err) + } + defer rows.Close() + + result := make(map[string][]ExtraWithFile, len(parentIDs)) + for rows.Next() { + var e ExtraWithFile + var kind string + if err := rows.Scan(&e.ContentID, &e.ParentID, &kind, &e.Title, &e.SortOrder, &e.FileID, &e.Duration); err != nil { + return nil, fmt.Errorf("scan media extra: %w", err) + } + e.Kind = models.ExtraKind(kind) + result[e.ParentID] = append(result[e.ParentID], e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate media extras: %w", err) + } + for _, extras := range result { + sortExtrasForDisplay(extras) + } + return result, nil +} + +// sortExtrasForDisplay orders trailers first, then the remaining kinds in +// vocabulary order, then title, keeping output stable for the API. +func sortExtrasForDisplay(extras []ExtraWithFile) { + rank := make(map[models.ExtraKind]int, len(models.AllExtraKinds)) + for i, k := range models.AllExtraKinds { + rank[k] = i + } + slices.SortStableFunc(extras, func(a, b ExtraWithFile) int { + if ra, rb := rank[a.Kind], rank[b.Kind]; ra != rb { + return ra - rb + } + if a.SortOrder != b.SortOrder { + return a.SortOrder - b.SortOrder + } + return strings.Compare(a.Title, b.Title) + }) +} diff --git a/internal/catalog/folder_repo.go b/internal/catalog/folder_repo.go index b3c1ab17..a5780fcb 100644 --- a/internal/catalog/folder_repo.go +++ b/internal/catalog/folder_repo.go @@ -96,6 +96,10 @@ type CreateFolderInput struct { MetadataLanguage string // ISO 639-1 code; defaults to "en" if empty ChapterThumbnailsEnabled bool IntroDetectionEnabled bool + // TrailerKinds is the allow-list of remote video kinds fetched during + // metadata refresh; nil applies the default (all provider kinds), an + // empty slice disables remote videos. + TrailerKinds []string } // FolderReorderEntry carries a folder ID and its new sort position. @@ -115,6 +119,7 @@ type UpdateFolderInput struct { AutoTranslateMetadata *bool ChapterThumbnailsEnabled *bool IntroDetectionEnabled *bool + TrailerKinds *[]string // nil = no change; empty slice disables remote videos } // FolderRepository provides CRUD operations for the media_folders table. @@ -141,9 +146,45 @@ func (r *FolderRepository) Pool() *pgxpool.Pool { return r.pool } +// defaultTrailerKinds mirrors the media_folders.trailer_kinds column default: +// every provider-reported kind (deleted_scene is local-only and never comes +// from providers). +func defaultTrailerKinds() []string { + kinds := make([]string, 0, len(models.AllExtraKinds)) + for _, k := range models.AllExtraKinds { + if k == models.ExtraKindDeletedScene { + continue + } + kinds = append(kinds, string(k)) + } + return kinds +} + +// normalizeTrailerKindsInput canonicalizes an allow-list from the API: +// trim/lowercase, drop values outside the known vocabulary (rather than +// folding them to "other", which would silently widen the allow-list), and +// dedupe while preserving order. +func normalizeTrailerKindsInput(kinds []string) []string { + normalized := make([]string, 0, len(kinds)) + seen := make(map[string]bool, len(kinds)) + for _, raw := range kinds { + kind := strings.ToLower(strings.TrimSpace(raw)) + if kind == "" || seen[kind] { + continue + } + if string(models.NormalizeExtraKind(kind)) != kind { + // Unknown value: NormalizeExtraKind would fold it to "other". + continue + } + seen[kind] = true + normalized = append(normalized, kind) + } + return normalized +} + // folderColumns is the list of columns returned by all SELECT queries. // Kept in one place so scanFolder stays in sync. -const folderColumns = `id, type, name, enabled, metadata_language, auto_translate_metadata, chapter_thumbnails_enabled, intro_detection_enabled, poster_path, last_scanned_at, +const folderColumns = `id, type, name, enabled, metadata_language, auto_translate_metadata, chapter_thumbnails_enabled, intro_detection_enabled, trailer_kinds, poster_path, last_scanned_at, scan_warning_code, scan_warning_message, scan_warning_at, allow_empty_cleanup_once, sort_order` // scanFolder scans a single row into a *models.MediaFolder. @@ -159,6 +200,7 @@ func scanFolder(row pgx.Row) (*models.MediaFolder, error) { &f.AutoTranslateMetadata, &f.ChapterThumbnailsEnabled, &f.IntroDetectionEnabled, + &f.TrailerKinds, &f.PosterPath, &f.LastScannedAt, &f.ScanWarningCode, @@ -192,6 +234,7 @@ func scanFolders(rows pgx.Rows) ([]*models.MediaFolder, error) { &f.AutoTranslateMetadata, &f.ChapterThumbnailsEnabled, &f.IntroDetectionEnabled, + &f.TrailerKinds, &f.PosterPath, &f.LastScannedAt, &f.ScanWarningCode, @@ -261,9 +304,15 @@ func (r *FolderRepository) Create(ctx context.Context, input CreateFolderInput) if metaLang == "" { metaLang = "en" } + trailerKinds := input.TrailerKinds + if trailerKinds == nil { + trailerKinds = defaultTrailerKinds() + } else { + trailerKinds = normalizeTrailerKindsInput(trailerKinds) + } - query := `INSERT INTO media_folders (type, name, metadata_language, chapter_thumbnails_enabled, intro_detection_enabled, sort_order) - VALUES ($1, $2, $3, $4, $5, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM media_folders)) + query := `INSERT INTO media_folders (type, name, metadata_language, chapter_thumbnails_enabled, intro_detection_enabled, trailer_kinds, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM media_folders)) RETURNING ` + folderColumns row := tx.QueryRow(ctx, query, @@ -272,6 +321,7 @@ func (r *FolderRepository) Create(ctx context.Context, input CreateFolderInput) metaLang, input.ChapterThumbnailsEnabled, input.IntroDetectionEnabled, + trailerKinds, ) folder, err := scanFolder(row) @@ -418,6 +468,11 @@ func (r *FolderRepository) Update(ctx context.Context, id int, input UpdateFolde args = append(args, *input.IntroDetectionEnabled) argIndex++ } + if input.TrailerKinds != nil { + setClauses = append(setClauses, fmt.Sprintf("trailer_kinds = $%d", argIndex)) + args = append(args, normalizeTrailerKindsInput(*input.TrailerKinds)) + argIndex++ + } if len(setClauses) > 0 { query := fmt.Sprintf("UPDATE media_folders SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argIndex) diff --git a/internal/catalog/group_claim_repo.go b/internal/catalog/group_claim_repo.go index 12afdb9a..0334f812 100644 --- a/internal/catalog/group_claim_repo.go +++ b/internal/catalog/group_claim_repo.go @@ -122,6 +122,7 @@ func (r *GroupClaimRepository) ClaimAndRelinkFiles( AND group_key_version = $3 AND content_group_key = $4 AND missing_since IS NULL + AND extra_id IS NULL AND (content_id IS NULL OR content_id = '') `, contentID, folderID, groupKeyVersion, contentGroupKey) if err != nil { diff --git a/internal/catalog/root_claim_repo.go b/internal/catalog/root_claim_repo.go index 01719723..d05119c5 100644 --- a/internal/catalog/root_claim_repo.go +++ b/internal/catalog/root_claim_repo.go @@ -184,6 +184,7 @@ func (r *RootClaimRepository) ClaimAndRelinkFiles(ctx context.Context, folderID SET content_id = $1, updated_at = NOW() WHERE media_folder_id = $2 AND missing_since IS NULL + AND extra_id IS NULL AND (content_id IS NULL OR content_id = '') AND (file_path = $3 OR file_path LIKE $4) `, contentID, folderID, rootPath, prefixLike) diff --git a/internal/catalog/video_repo.go b/internal/catalog/video_repo.go new file mode 100644 index 00000000..efd4c306 --- /dev/null +++ b/internal/catalog/video_repo.go @@ -0,0 +1,159 @@ +package catalog + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/idgen" + "github.com/Silo-Server/silo-server/internal/models" +) + +// VideoRepository persists remote provider videos (trailers, teasers, ...) in +// the item_videos table. The set is replaced wholesale on each metadata +// refresh, mirroring ItemRepository.ReplacePeople. +type VideoRepository struct { + pool *pgxpool.Pool +} + +// NewVideoRepository creates a video repository backed by the given pool. +func NewVideoRepository(pool *pgxpool.Pool) *VideoRepository { + return &VideoRepository{pool: pool} +} + +const itemVideoColumns = `content_id, provider, provider_key, kind, site, site_key, name, language, is_official, size_hint, published_at, sort_order` + +// ReplaceByContentID transactionally replaces every stored video for the item +// with the given set. An empty set clears the item's videos. +func (r *VideoRepository) ReplaceByContentID(ctx context.Context, contentID string, videos []models.ItemVideo) error { + contentID = strings.TrimSpace(contentID) + if contentID == "" { + return fmt.Errorf("content_id is required") + } + + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin replace videos transaction: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, "DELETE FROM item_videos WHERE content_id = $1", contentID); err != nil { + return fmt.Errorf("delete existing videos: %w", err) + } + + if len(videos) == 0 { + return tx.Commit(ctx) + } + + // Deduplicate by (provider, provider_key) — ON CONFLICT cannot handle the + // same row appearing twice within a single INSERT. + type dedupKey struct { + Provider string + ProviderKey string + } + seen := make(map[dedupKey]struct{}, len(videos)) + deduped := make([]models.ItemVideo, 0, len(videos)) + for _, v := range videos { + key := dedupKey{v.Provider, v.ProviderKey} + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + deduped = append(deduped, v) + } + + var sb strings.Builder + sb.WriteString("INSERT INTO item_videos (id, " + itemVideoColumns + ") VALUES ") + args := make([]interface{}, 0, len(deduped)*13) + for i, v := range deduped { + if i > 0 { + sb.WriteString(", ") + } + base := i * 13 + fmt.Fprintf(&sb, "($%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d)", + base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9, base+10, base+11, base+12, base+13) + + rowIDStr, err := idgen.NextID() + if err != nil { + return fmt.Errorf("generate item video id: %w", err) + } + rowID, _ := strconv.ParseInt(rowIDStr, 10, 64) + args = append(args, rowID, contentID, v.Provider, v.ProviderKey, string(v.Kind), v.Site, v.SiteKey, + v.Name, v.Language, v.IsOfficial, v.SizeHint, v.PublishedAt, i) + } + sb.WriteString(` ON CONFLICT (content_id, provider, provider_key) DO UPDATE SET + kind = EXCLUDED.kind, site = EXCLUDED.site, site_key = EXCLUDED.site_key, + name = EXCLUDED.name, language = EXCLUDED.language, is_official = EXCLUDED.is_official, + size_hint = EXCLUDED.size_hint, published_at = EXCLUDED.published_at, + sort_order = EXCLUDED.sort_order, updated_at = now()`) + + if _, err := tx.Exec(ctx, sb.String(), args...); err != nil { + return fmt.Errorf("insert videos: %w", err) + } + + return tx.Commit(ctx) +} + +// GetByContentID returns the item's videos ordered for display: official +// entries before unofficial within the stored sort order. +func (r *VideoRepository) GetByContentID(ctx context.Context, contentID string) ([]models.ItemVideo, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, `+itemVideoColumns+` + FROM item_videos + WHERE content_id = $1 + ORDER BY sort_order, id`, contentID) + if err != nil { + return nil, fmt.Errorf("query item videos: %w", err) + } + defer rows.Close() + return scanItemVideos(rows) +} + +// ListByContentIDs returns videos for a batch of items, keyed by content_id. +// Items without videos are absent from the map. +func (r *VideoRepository) ListByContentIDs(ctx context.Context, contentIDs []string) (map[string][]models.ItemVideo, error) { + if len(contentIDs) == 0 { + return map[string][]models.ItemVideo{}, nil + } + rows, err := r.pool.Query(ctx, ` + SELECT id, `+itemVideoColumns+` + FROM item_videos + WHERE content_id = ANY($1) + ORDER BY content_id, sort_order, id`, contentIDs) + if err != nil { + return nil, fmt.Errorf("query item videos batch: %w", err) + } + defer rows.Close() + + videos, err := scanItemVideos(rows) + if err != nil { + return nil, err + } + result := make(map[string][]models.ItemVideo, len(contentIDs)) + for _, v := range videos { + result[v.ContentID] = append(result[v.ContentID], v) + } + return result, nil +} + +func scanItemVideos(rows pgx.Rows) ([]models.ItemVideo, error) { + var videos []models.ItemVideo + for rows.Next() { + var v models.ItemVideo + var kind string + if err := rows.Scan(&v.ID, &v.ContentID, &v.Provider, &v.ProviderKey, &kind, &v.Site, &v.SiteKey, + &v.Name, &v.Language, &v.IsOfficial, &v.SizeHint, &v.PublishedAt, &v.SortOrder); err != nil { + return nil, fmt.Errorf("scan item video: %w", err) + } + v.Kind = models.ExtraKind(kind) + videos = append(videos, v) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate item videos: %w", err) + } + return videos, nil +} diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index b648b015..5eb8714c 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -1287,6 +1287,8 @@ func itemDetailToUpstream(d *catalog.ItemDetail) upstreamItemDetail { Versions: d.Versions, Cast: d.Cast, Crew: d.Crew, + Videos: d.Videos, + Extras: d.Extras, } if detail.Genres == nil { detail.Genres = []string{} diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index 3aded072..4bbba358 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -915,17 +915,108 @@ func (h *ItemsHandler) HandleFilters2Stub(w http.ResponseWriter, r *http.Request // HandleLocalTrailers serves GET /Items/{id}/LocalTrailers (and the legacy // /Users/{userId}/Items/{id}/LocalTrailers alias). Jellyfin returns a bare // BaseItemDto array — not the {Items,TotalRecordCount,StartIndex} envelope, so -// this cannot reuse HandleItemStub. Silo does not index local trailer files, so -// the result is always empty; returning [] matches Jellyfin's contract for an -// item with no local trailers and stops the chi 404 that clients (Infuse, -// Moonfin) otherwise hit on every item-detail load. +// this cannot reuse HandleItemStub. Returns the item's local extras of +// trailer/teaser kind as playable items; [] when it has none, which matches +// Jellyfin's contract and stops the chi 404 that clients (Infuse, Moonfin) +// otherwise hit on every item-detail load. func (h *ItemsHandler) HandleLocalTrailers(w http.ResponseWriter, r *http.Request) { + h.writeLocalExtras(w, r, true) +} + +// HandleSpecialFeatures serves GET /Items/{id}/SpecialFeatures (and the +// /Users/{userId}/... alias): the item's non-trailer local extras +// (featurettes, deleted scenes, ...) as a bare BaseItemDto array. +func (h *ItemsHandler) HandleSpecialFeatures(w http.ResponseWriter, r *http.Request) { + h.writeLocalExtras(w, r, false) +} + +// writeLocalExtras is the shared body of LocalTrailers/SpecialFeatures: +// Jellyfin splits one extras concept across two endpoints by kind. +func (h *ItemsHandler) writeLocalExtras(w http.ResponseWriter, r *http.Request, trailersOnly bool) { session := SessionFromContext(r.Context()) if session == nil { writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") return } - writeJSON(w, http.StatusOK, []baseItemDTO{}) + + if h.codec == nil || h.content == nil { + writeJSON(w, http.StatusOK, []baseItemDTO{}) + return + } + + rawID := chi.URLParam(r, "id") + contentID, err := h.codec.DecodeStringID(EncodedIDItem, rawID) + if err != nil { + writeJSON(w, http.StatusOK, []baseItemDTO{}) + return + } + + detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil) + if err != nil { + writeJSON(w, http.StatusOK, []baseItemDTO{}) + return + } + + items := []baseItemDTO{} + for _, extra := range detail.Extras { + if isLocalTrailerKind(extra.Kind) != trailersOnly { + continue + } + items = append(items, h.extraToBaseItem(extra, rawID)) + } + writeJSON(w, http.StatusOK, items) +} + +// extraToBaseItem maps a local extra onto a minimal playable BaseItemDto. The +// extra's content_id is a first-class watch target (GetWatchDetail resolves +// it through the extras fallback tier), so PlaybackInfo and the stream +// endpoints work on the encoded id with no extra plumbing. +func (h *ItemsHandler) extraToBaseItem(extra catalog.ItemExtraInfo, parentEncodedID string) baseItemDTO { + name := extra.Title + if name == "" { + name = extraKindDisplayName(extra.Kind) + } + itemType := "Video" + if isLocalTrailerKind(extra.Kind) { + itemType = "Trailer" + } + dto := baseItemDTO{ + ID: h.codec.EncodeStringID(EncodedIDItem, extra.ContentID), + Type: itemType, + IsFolder: false, + Name: name, + ServerID: h.mapper.serverID, + MediaType: "Video", + ParentID: parentEncodedID, + ImageTags: map[string]string{}, + } + if extra.DurationSeconds > 0 { + dto.RunTimeTicks = secondsToTicks(float64(extra.DurationSeconds)) + } + applyPlayableLocation(&dto, true) + return dto +} + +// extraKindDisplayName is the fallback item name for an untitled extra. +func extraKindDisplayName(kind string) string { + switch models.ExtraKind(kind) { + case models.ExtraKindTrailer: + return "Trailer" + case models.ExtraKindTeaser: + return "Teaser" + case models.ExtraKindFeaturette: + return "Featurette" + case models.ExtraKindClip: + return "Clip" + case models.ExtraKindBehindTheScenes: + return "Behind the Scenes" + case models.ExtraKindBloopers: + return "Bloopers" + case models.ExtraKindDeletedScene: + return "Deleted Scene" + default: + return "Extra" + } } // HandleLatest serves GET /Items/Latest. diff --git a/internal/jellycompat/mapping.go b/internal/jellycompat/mapping.go index f650f02d..9b172454 100644 --- a/internal/jellycompat/mapping.go +++ b/internal/jellycompat/mapping.go @@ -11,6 +11,7 @@ import ( "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/models" ) // allDetailFields is a sentinel passed to itemFromList so detail views include all fields. @@ -353,6 +354,14 @@ func (m *mapper) itemFromDetailWithFields(item upstreamItemDetail, isFavorite bo }) } } + // Remote provider trailers and local extras counts. The itemFromList base + // stamped RemoteTrailers as an empty slice; override with real data here + // on the detail path. + dto.RemoteTrailers = remoteTrailerDTOs(item.Videos) + localTrailers, specialFeatures := countLocalExtras(item.Extras) + dto.LocalTrailerCount = localTrailers + dto.SpecialFeatureCount = specialFeatures + if item.SeriesID != "" { dto.SeriesID = m.codec.EncodeStringID(EncodedIDItem, item.SeriesID) } @@ -586,6 +595,10 @@ func jellyfinItemType(native string) string { return "Episode" case "season": return "Season" + case "extra": + // Local extras have no dedicated BaseItemKind; plain Video is what + // Jellyfin uses for special features. + return "Video" default: if native == "" { return "" @@ -702,6 +715,50 @@ func compatChapters(chapters []catalog.VersionChapter, addedAt time.Time) []map[ return items } +// remoteTrailerDTOs maps remote provider videos of trailer kinds onto +// Jellyfin's RemoteTrailers MediaUrl shape ({Url, Name}). Non-trailer kinds +// (featurettes, clips, ...) have no Jellyfin remote surface and are omitted. +func remoteTrailerDTOs(videos []catalog.ItemVideoInfo) []map[string]any { + trailers := []map[string]any{} + for _, v := range videos { + if v.Kind != string(models.ExtraKindTrailer) && v.Kind != string(models.ExtraKindTeaser) { + continue + } + var url string + switch v.Site { + case "youtube": + url = "https://www.youtube.com/watch?v=" + v.SiteKey + case "vimeo": + url = "https://vimeo.com/" + v.SiteKey + default: + continue + } + trailers = append(trailers, map[string]any{ + "Url": url, + "Name": v.Name, + }) + } + return trailers +} + +// countLocalExtras splits local extras into Jellyfin's LocalTrailerCount +// (trailer/teaser kinds, surfaced via /LocalTrailers) and SpecialFeatureCount +// (everything else, surfaced via /SpecialFeatures). +func countLocalExtras(extras []catalog.ItemExtraInfo) (localTrailers, specialFeatures int) { + for _, e := range extras { + if isLocalTrailerKind(e.Kind) { + localTrailers++ + } else { + specialFeatures++ + } + } + return localTrailers, specialFeatures +} + +func isLocalTrailerKind(kind string) bool { + return kind == string(models.ExtraKindTrailer) || kind == string(models.ExtraKindTeaser) +} + func minutesToTicks(minutes int) int64 { return int64(minutes) * 600_000_000 } diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index de3718f2..8f3f0d54 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -183,12 +183,12 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/Shows/{id}/Similar", itemsHandler.HandleSimilar) r.Get("/Items/{id}/ThemeMedia", itemsHandler.HandleItemStub) r.Get("/Items/{id}/ThemeSongs", itemsHandler.HandleThemeSongsStub) - r.Get("/Items/{id}/SpecialFeatures", itemsHandler.HandleItemStub) + r.Get("/Items/{id}/SpecialFeatures", itemsHandler.HandleSpecialFeatures) r.Get("/Items/{id}/Intros", itemsHandler.HandleItemStub) r.Get("/Items/{id}/LocalTrailers", itemsHandler.HandleLocalTrailers) r.Get("/Users/{userId}/Items/{id}/ThemeMedia", itemsHandler.HandleItemStub) r.Get("/Users/{userId}/Items/{id}/ThemeSongs", itemsHandler.HandleThemeSongsStub) - r.Get("/Users/{userId}/Items/{id}/SpecialFeatures", itemsHandler.HandleItemStub) + r.Get("/Users/{userId}/Items/{id}/SpecialFeatures", itemsHandler.HandleSpecialFeatures) r.Get("/Users/{userId}/Items/{id}/Intros", itemsHandler.HandleItemStub) r.Get("/Users/{userId}/Items/{id}/LocalTrailers", itemsHandler.HandleLocalTrailers) r.Get("/Items/{id}", itemsHandler.HandleItem) diff --git a/internal/jellycompat/upstream_types.go b/internal/jellycompat/upstream_types.go index bdff2679..a241fecb 100644 --- a/internal/jellycompat/upstream_types.go +++ b/internal/jellycompat/upstream_types.go @@ -104,6 +104,8 @@ type upstreamItemDetail struct { Versions []catalog.FileVersion `json:"versions,omitempty"` Cast []catalog.CastCredit `json:"cast,omitempty"` Crew []catalog.CrewCredit `json:"crew,omitempty"` + Videos []catalog.ItemVideoInfo `json:"videos,omitempty"` + Extras []catalog.ItemExtraInfo `json:"extras,omitempty"` } type upstreamSeason struct { diff --git a/internal/metadata/merge.go b/internal/metadata/merge.go index 589fae6f..2e74beda 100644 --- a/internal/metadata/merge.go +++ b/internal/metadata/merge.go @@ -84,6 +84,12 @@ func MergeMetadata(source, target *MetadataResult, locked []MetadataField, mode mergeScalar(&target.LogoPath, source.LogoPath, mode) } + // Remote videos accumulate across providers (TMDB trailers + another + // provider's clips can coexist), deduped by provider identity. + if !isLocked(FieldVideos) { + mergeVideos(&target.Videos, source.Videos) + } + // Provider IDs always accumulate, never overwrite mergeProviderIDs(target, source) } @@ -138,10 +144,39 @@ func MergeGlobalMetadata(source, target *MetadataResult, locked []MetadataField, if !isLocked(FieldCast) || !isLocked(FieldCrew) { mergePeople(&target.People, source.People, mode) } + if !isLocked(FieldVideos) { + mergeVideos(&target.Videos, source.Videos) + } mergeProviderIDs(target, source) } +// mergeVideos accumulates source videos into target, skipping entries the +// target already has from the same provider (same provider video id) or that +// point at the same hosted video (same site/site_key). Unlike scalar fields +// this ignores MergeMode: the set is replaced wholesale at persist time each +// refresh, so within one refresh pass accumulation is always correct. +func mergeVideos(target *[]RemoteVideo, source []RemoteVideo) { + if len(source) == 0 { + return + } + seen := make(map[string]bool, len(*target)*2) + for _, v := range *target { + seen["p|"+v.Provider+"|"+v.ProviderKey] = true + seen["s|"+strings.ToLower(v.Site)+"|"+v.SiteKey] = true + } + for _, v := range source { + providerKey := "p|" + v.Provider + "|" + v.ProviderKey + siteKey := "s|" + strings.ToLower(v.Site) + "|" + v.SiteKey + if seen[providerKey] || (v.SiteKey != "" && seen[siteKey]) { + continue + } + seen[providerKey] = true + seen[siteKey] = true + *target = append(*target, v) + } +} + // MergeSeasonResult merges source into target using the standard metadata // fallback contract for season-level fields. func MergeSeasonResult(source, target *SeasonResult, mode MergeMode) { diff --git a/internal/metadata/merge_videos_test.go b/internal/metadata/merge_videos_test.go new file mode 100644 index 00000000..4a0f888e --- /dev/null +++ b/internal/metadata/merge_videos_test.go @@ -0,0 +1,82 @@ +package metadata + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestMergeVideosAccumulatesAndDedupes(t *testing.T) { + target := &MetadataResult{Videos: []RemoteVideo{ + {Provider: "tmdb", ProviderKey: "a1", Kind: models.ExtraKindTrailer, Site: "youtube", SiteKey: "yt-1"}, + }} + source := &MetadataResult{Videos: []RemoteVideo{ + // Same provider video id — dropped. + {Provider: "tmdb", ProviderKey: "a1", Kind: models.ExtraKindTrailer, Site: "youtube", SiteKey: "yt-1"}, + // Different provider, same hosted video — dropped. + {Provider: "tvdb", ProviderKey: "b7", Kind: models.ExtraKindTrailer, Site: "YouTube", SiteKey: "yt-1"}, + // Genuinely new — kept. + {Provider: "tvdb", ProviderKey: "b8", Kind: models.ExtraKindClip, Site: "youtube", SiteKey: "yt-2"}, + }} + + MergeMetadata(source, target, nil, MergeFillEmpty) + + if len(target.Videos) != 2 { + t.Fatalf("got %d videos, want 2: %+v", len(target.Videos), target.Videos) + } + if target.Videos[1].ProviderKey != "b8" { + t.Fatalf("expected new video appended, got %+v", target.Videos[1]) + } +} + +func TestMergeVideosRespectsFieldLock(t *testing.T) { + target := &MetadataResult{} + source := &MetadataResult{Videos: []RemoteVideo{ + {Provider: "tmdb", ProviderKey: "a1", Kind: models.ExtraKindTrailer, Site: "youtube", SiteKey: "yt-1"}, + }} + + MergeMetadata(source, target, []MetadataField{FieldVideos}, MergeReplaceUnlocked) + + if len(target.Videos) != 0 { + t.Fatalf("locked FieldVideos must block merge, got %+v", target.Videos) + } +} + +func TestFilterVideosByKinds(t *testing.T) { + videos := []RemoteVideo{ + {ProviderKey: "1", Kind: models.ExtraKindTrailer}, + {ProviderKey: "2", Kind: models.ExtraKindBloopers}, + } + // nil allow-list = everything. + if got := filterVideosByKinds(videos, nil); len(got) != 2 { + t.Fatalf("nil allow-list should pass all, got %d", len(got)) + } + // Empty allow-list = nothing (remote videos disabled). + if got := filterVideosByKinds(videos, map[models.ExtraKind]bool{}); len(got) != 0 { + t.Fatalf("empty allow-list should drop all, got %d", len(got)) + } + allowed := map[models.ExtraKind]bool{models.ExtraKindTrailer: true} + got := filterVideosByKinds(videos, allowed) + if len(got) != 1 || got[0].Kind != models.ExtraKindTrailer { + t.Fatalf("kind filter failed: %+v", got) + } +} + +func TestItemVideosFromRemoteOrdering(t *testing.T) { + rows := itemVideosFromRemote("movie-tmdb-1", []RemoteVideo{ + {ProviderKey: "c", Kind: models.ExtraKindClip}, + {ProviderKey: "t2", Kind: models.ExtraKindTrailer, IsOfficial: false}, + {ProviderKey: "t1", Kind: models.ExtraKindTrailer, IsOfficial: true}, + }) + if len(rows) != 3 { + t.Fatalf("got %d rows", len(rows)) + } + if rows[0].ProviderKey != "t1" || rows[1].ProviderKey != "t2" || rows[2].ProviderKey != "c" { + t.Fatalf("unexpected order: %s, %s, %s", rows[0].ProviderKey, rows[1].ProviderKey, rows[2].ProviderKey) + } + for i, row := range rows { + if row.SortOrder != i { + t.Fatalf("row %d has sort order %d", i, row.SortOrder) + } + } +} diff --git a/internal/metadata/movie_match_queue_repo.go b/internal/metadata/movie_match_queue_repo.go index e0d62422..ddaba109 100644 --- a/internal/metadata/movie_match_queue_repo.go +++ b/internal/metadata/movie_match_queue_repo.go @@ -79,7 +79,7 @@ const movieQueueFileEligibleCond = `folders.enabled = true lower(trim(folders.type)) IN ('movie', 'movies') OR (lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie') ) - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL AND ( mf.content_id IS NULL OR mf.content_id = '' OR lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous') diff --git a/internal/metadata/plugin_provider.go b/internal/metadata/plugin_provider.go index db19d524..3bb1bd09 100644 --- a/internal/metadata/plugin_provider.go +++ b/internal/metadata/plugin_provider.go @@ -262,6 +262,7 @@ func (p *PluginProvider) GetMetadata(ctx context.Context, req MetadataRequest) ( ContentRating: response.GetItem().GetContentRating(), Ratings: ratingsFromStruct(response.GetItem().GetRatings()), People: peopleFromRecords(response.GetItem().GetPeople()), + Videos: videosFromRecords(p.Slug(), response.GetItem().GetVideos()), PosterPath: response.GetItem().GetPosterPath(), PosterThumbhash: response.GetItem().GetPosterThumbhash(), BackdropPath: response.GetItem().GetBackdropPath(), @@ -589,6 +590,42 @@ func structFromStringMap(value map[string]string) (*structpb.Struct, error) { return structpb.NewStruct(converted) } +// videosFromRecords maps SDK VideoRecords into domain RemoteVideos, stamping +// the returning provider's slug and normalizing unknown kinds to "other". +// Plugins built against an SDK without the videos field simply return an +// empty list (proto3 zero value) — nil here, no error. +func videosFromRecords(providerSlug string, records []*pluginv1.VideoRecord) []RemoteVideo { + if len(records) == 0 { + return nil + } + + videos := make([]RemoteVideo, 0, len(records)) + for _, record := range records { + if record == nil || record.GetSiteKey() == "" { + continue + } + // The provider key is the DB dedup key; fall back to the site key so a + // plugin omitting provider ids cannot collide on the empty string. + providerKey := record.GetProviderKey() + if providerKey == "" { + providerKey = record.GetSiteKey() + } + videos = append(videos, RemoteVideo{ + Provider: providerSlug, + ProviderKey: providerKey, + Kind: models.NormalizeExtraKind(record.GetKind()), + Site: strings.ToLower(record.GetSite()), + SiteKey: record.GetSiteKey(), + Name: record.GetName(), + Language: record.GetLanguage(), + IsOfficial: record.GetIsOfficial(), + SizeHint: int(record.GetSizeHint()), + PublishedAt: record.GetPublishedAt(), + }) + } + return videos +} + func peopleFromRecords(records []*pluginv1.PersonRecord) []models.ItemPerson { if len(records) == 0 { return nil diff --git a/internal/metadata/series_root_match_queue_repo.go b/internal/metadata/series_root_match_queue_repo.go index e1c5c542..fcf92731 100644 --- a/internal/metadata/series_root_match_queue_repo.go +++ b/internal/metadata/series_root_match_queue_repo.go @@ -89,7 +89,7 @@ func (r *SeriesRootMatchQueueRepository) EnqueueSeriesRoot(ctx context.Context, AND mf.observed_root_path = $2 AND folders.enabled = true AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL AND mf.observed_root_path <> '' AND ( mf.content_id IS NULL OR mf.content_id = '' OR @@ -119,7 +119,7 @@ func (r *SeriesRootMatchQueueRepository) EnqueueSeriesRoot(ctx context.Context, lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR (lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series') ) - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL AND mf.observed_root_path <> '' AND ( mf.content_id IS NULL OR mf.content_id = '' OR @@ -171,7 +171,7 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR (lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series') ) - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL AND mf.observed_root_path IS NOT NULL AND mf.observed_root_path <> '' AND ( @@ -201,7 +201,7 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR (lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series') ) - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL AND mf.observed_root_path <> '' AND ( mf.content_id IS NULL OR mf.content_id = '' OR @@ -251,7 +251,7 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR (lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series') ) - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL AND mf.observed_root_path IS NOT NULL AND mf.observed_root_path <> '' AND ( @@ -312,7 +312,7 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR (lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series') ) - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL AND mf.observed_root_path <> '' AND ( mf.content_id IS NULL OR mf.content_id = '' OR @@ -355,7 +355,7 @@ func (r *SeriesRootMatchQueueRepository) Claim(ctx context.Context, limit int) ( lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR lower(trim(mf.base_type)) = 'series' ) - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL AND mf.observed_root_path <> '' AND ( mf.content_id IS NULL OR mf.content_id = '' OR @@ -385,7 +385,7 @@ func (r *SeriesRootMatchQueueRepository) Claim(ctx context.Context, limit int) ( FROM media_files mf WHERE mf.media_folder_id = u.media_folder_id AND mf.observed_root_path = u.observed_root_path - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL )) AS observed_file_count FROM updated u LEFT JOIN observed_media_locations loc @@ -439,7 +439,7 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix( FROM media_files mf WHERE mf.media_folder_id = q.media_folder_id AND mf.observed_root_path = q.observed_root_path - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\') ) ) @@ -453,7 +453,7 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix( lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR lower(trim(mf.base_type)) = 'series' ) - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL AND mf.observed_root_path <> '' AND ( mf.content_id IS NULL OR mf.content_id = '' OR @@ -484,7 +484,7 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix( FROM media_files mf WHERE mf.media_folder_id = u.media_folder_id AND mf.observed_root_path = u.observed_root_path - AND mf.missing_since IS NULL + AND mf.missing_since IS NULL AND mf.extra_id IS NULL )) AS observed_file_count FROM updated u LEFT JOIN observed_media_locations loc diff --git a/internal/metadata/service.go b/internal/metadata/service.go index e6034605..c03c020f 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -177,6 +177,12 @@ type metadataFolderRepo interface { GetByID(ctx context.Context, id int) (*models.MediaFolder, error) } +// metadataVideoRepo persists remote provider videos. The concrete +// *catalog.VideoRepository satisfies this. +type metadataVideoRepo interface { + ReplaceByContentID(ctx context.Context, contentID string, videos []models.ItemVideo) error +} + // AutoTranslator is the seam to the metadata AI translation service: after a // refresh, libraries that opted in get missing localizations filled by AI. // Implemented by *translation.Service; AutoEnqueue must be cheap and must @@ -294,6 +300,7 @@ type MetadataService struct { episodeLocalizationRepo *catalog.EpisodeLocalizationRepository autoTranslator AutoTranslator // optional; set via SetAutoTranslator personRepo *catalog.PersonRepository + videoRepo metadataVideoRepo fileRepo FileContentUpdater skippedRootRepo metadataSkippedRootRepo staleIDRepo metadataStaleIDRepo @@ -386,10 +393,12 @@ func NewMetadataService( var groupClaimRepo metadataGroupClaimRepo var groupOverrideRepo metadataGroupOverrideRepo var observedLocationRepo metadataObservedLocationRepo + var videoRepo metadataVideoRepo var dbPool *pgxpool.Pool if folderRepo != nil { pool := folderRepo.Pool() dbPool = pool + videoRepo = catalog.NewVideoRepository(pool) itemLocalizationRepo = catalog.NewMediaItemLocalizationRepository(pool) seasonLocalizationRepo = catalog.NewSeasonLocalizationRepository(pool) episodeLocalizationRepo = catalog.NewEpisodeLocalizationRepository(pool) @@ -413,6 +422,7 @@ func NewMetadataService( seasonLocalizationRepo: seasonLocalizationRepo, episodeLocalizationRepo: episodeLocalizationRepo, personRepo: personRepo, + videoRepo: videoRepo, fileRepo: fileRepo, skippedRootRepo: skippedRootRepo, staleIDRepo: staleIDRepo, @@ -691,6 +701,105 @@ func (s *MetadataService) resolveFolderLanguage(ctx context.Context, folderID in return strings.TrimSpace(folder.MetadataLanguage) } +// resolveAllowedVideoKinds returns the union of trailer_kinds across the +// libraries containing the item, or just the scoped folder's when a folder id +// is provided. The union (most-permissive) mirrors the multi-library language +// posture. A nil return means "allow all": unknown scope or a transient +// lookup failure must never wipe stored trailers. +func (s *MetadataService) resolveAllowedVideoKinds(ctx context.Context, contentID string, folderID int) map[models.ExtraKind]bool { + if s.folderRepo == nil { + return nil + } + var folderIDs []int + if folderID > 0 { + folderIDs = []int{folderID} + } else if s.libraryRepo != nil { + ids, err := s.libraryRepo.GetFolderIDsForItem(ctx, contentID) + if err != nil { + slog.Warn("metadata: resolving item libraries for video kinds failed", + "content_id", contentID, "error", err) + return nil + } + folderIDs = ids + } + if len(folderIDs) == 0 { + return nil + } + allowed := make(map[models.ExtraKind]bool) + resolvedAny := false + for _, id := range folderIDs { + folder, err := s.folderRepo.GetByID(ctx, id) + if err != nil || folder == nil { + continue + } + resolvedAny = true + for _, kind := range folder.TrailerKinds { + allowed[models.ExtraKind(kind)] = true + } + } + if !resolvedAny { + return nil + } + return allowed +} + +// filterVideosByKinds applies the per-library allow-list; a nil map allows +// everything, an empty map filters everything (remote videos disabled). +func filterVideosByKinds(videos []RemoteVideo, allowed map[models.ExtraKind]bool) []RemoteVideo { + if allowed == nil || len(videos) == 0 { + return videos + } + filtered := make([]RemoteVideo, 0, len(videos)) + for _, v := range videos { + if allowed[v.Kind] { + filtered = append(filtered, v) + } + } + return filtered +} + +// itemVideosFromRemote converts pipeline videos into item_videos rows with a +// stable display order: kinds in vocabulary order (trailers first), official +// before unofficial, provider order otherwise preserved. +func itemVideosFromRemote(contentID string, videos []RemoteVideo) []models.ItemVideo { + rank := make(map[models.ExtraKind]int, len(models.AllExtraKinds)) + for i, k := range models.AllExtraKinds { + rank[k] = i + } + ordered := append([]RemoteVideo(nil), videos...) + sort.SliceStable(ordered, func(i, j int) bool { + if ri, rj := rank[ordered[i].Kind], rank[ordered[j].Kind]; ri != rj { + return ri < rj + } + return ordered[i].IsOfficial && !ordered[j].IsOfficial + }) + + rows := make([]models.ItemVideo, 0, len(ordered)) + for i, v := range ordered { + var publishedAt *time.Time + if v.PublishedAt != "" { + if t, err := time.Parse(time.RFC3339, v.PublishedAt); err == nil { + publishedAt = &t + } + } + rows = append(rows, models.ItemVideo{ + ContentID: contentID, + Provider: v.Provider, + ProviderKey: v.ProviderKey, + Kind: v.Kind, + Site: v.Site, + SiteKey: v.SiteKey, + Name: v.Name, + Language: v.Language, + IsOfficial: v.IsOfficial, + SizeHint: v.SizeHint, + PublishedAt: publishedAt, + SortOrder: i, + }) + } + return rows +} + func prependUniqueString(values []string, value string) []string { value = strings.TrimSpace(value) if value == "" { @@ -1692,6 +1801,21 @@ func (s *MetadataService) mergeAndPersist( } } + // Persist remote videos (trailers etc.), filtered by the library's + // trailer_kinds allow-list. Replace-unlocked refreshes replace the whole + // set — including clearing it — so narrowing the allow-list converges on + // the next refresh; fill-empty refreshes only write when providers + // returned something, so a transient provider failure cannot wipe data. + if isCanonicalWrite && s.videoRepo != nil && !isFieldLocked(locked, FieldVideos) { + allowed := s.resolveAllowedVideoKinds(ctx, contentID, parseProcessFolderID(req.FolderID)) + filtered := filterVideosByKinds(accumulator.Videos, allowed) + if len(filtered) > 0 || mergeMode == MergeReplaceUnlocked { + if err := s.videoRepo.ReplaceByContentID(ctx, contentID, itemVideosFromRemote(contentID, filtered)); err != nil { + slog.Warn("metadata: failed to replace item videos", "content_id", contentID, "error", err) + } + } + } + // Ensure library membership exists after successful enrichment. This call // site runs inside mergeAndPersist, so the item is known to be matched at // this point; it uses the matched-only upsert as a secondary confirmation. diff --git a/internal/metadata/types.go b/internal/metadata/types.go index 2e27fa1c..47661854 100644 --- a/internal/metadata/types.go +++ b/internal/metadata/types.go @@ -40,6 +40,7 @@ const ( FieldContentRating FieldImages FieldAirSchedule + FieldVideos ) // RefreshPriority controls queue ordering. @@ -193,6 +194,26 @@ type MetadataResult struct { // ShowStatus is the publication/airing status ("Ongoing", "Completed", // "Continuing", "Ended") when the provider reports one. ShowStatus string + // Videos are remote promotional/supplemental videos (trailers, teasers, + // ...) hosted on external sites. Accumulated across providers with + // (Provider, ProviderKey) dedup, gated by FieldVideos and the library's + // trailer_kinds allow-list at persist time. + Videos []RemoteVideo +} + +// RemoteVideo describes a provider-reported external video (YouTube trailer +// etc.). It mirrors the SDK's VideoRecord and persists as an item_videos row. +type RemoteVideo struct { + Provider string // provider slug that returned this video + ProviderKey string // provider-native video id (dedup key) + Kind models.ExtraKind + Site string // hosting site, e.g. "youtube" + SiteKey string // site-native video key (YouTube video id) + Name string + Language string // ISO 639-1, empty when unknown + IsOfficial bool + SizeHint int // vertical resolution hint (e.g. 1080), 0 when unknown + PublishedAt string // RFC 3339, empty when unknown } // Ratings holds ratings from multiple sources. diff --git a/internal/models/extras.go b/internal/models/extras.go new file mode 100644 index 00000000..0433f0eb --- /dev/null +++ b/internal/models/extras.go @@ -0,0 +1,81 @@ +package models + +import "time" + +// ExtraKind classifies supplemental video content. It is the shared vocabulary +// between remote provider videos (item_videos.kind) and scanner-discovered +// local extras (media_extras.kind). Values are lowercase snake_case strings +// rather than integer enums so plugins and future providers can emit kinds the +// server does not know yet; NormalizeExtraKind folds those to ExtraKindOther. +type ExtraKind string + +const ( + ExtraKindTrailer ExtraKind = "trailer" + ExtraKindTeaser ExtraKind = "teaser" + ExtraKindFeaturette ExtraKind = "featurette" + ExtraKindClip ExtraKind = "clip" + ExtraKindBehindTheScenes ExtraKind = "behind_the_scenes" + ExtraKindBloopers ExtraKind = "bloopers" + // ExtraKindDeletedScene is local-only: TMDB has no matching video type, + // but Jellyfin/Plex folder conventions do. + ExtraKindDeletedScene ExtraKind = "deleted_scene" + ExtraKindOther ExtraKind = "other" +) + +// AllExtraKinds lists every known kind, in display order. This is the source +// of truth mirrored by the media_folders.trailer_kinds column default. +var AllExtraKinds = []ExtraKind{ + ExtraKindTrailer, + ExtraKindTeaser, + ExtraKindFeaturette, + ExtraKindClip, + ExtraKindBehindTheScenes, + ExtraKindBloopers, + ExtraKindDeletedScene, + ExtraKindOther, +} + +// NormalizeExtraKind maps an arbitrary kind string onto the known vocabulary, +// folding unknown values to ExtraKindOther so newer providers degrade safely. +func NormalizeExtraKind(raw string) ExtraKind { + switch ExtraKind(raw) { + case ExtraKindTrailer, ExtraKindTeaser, ExtraKindFeaturette, ExtraKindClip, + ExtraKindBehindTheScenes, ExtraKindBloopers, ExtraKindDeletedScene: + return ExtraKind(raw) + default: + return ExtraKindOther + } +} + +// ItemVideo is a row in item_videos: a remote promotional/supplemental video +// (YouTube trailer, teaser, ...) attached to a media item by a metadata +// provider. Rows are replaced wholesale on refresh, keyed for dedup by +// (ContentID, Provider, ProviderKey). +type ItemVideo struct { + ID int64 + ContentID string + Provider string // provider capability slug, e.g. "tmdb" + ProviderKey string // provider-native video id (dedup key) + Kind ExtraKind + Site string // hosting site, e.g. "youtube" + SiteKey string // site-native video key (YouTube video id) + Name string + Language string // ISO 639-1, empty when unknown + IsOfficial bool + SizeHint int // vertical resolution hint (e.g. 1080), 0 when unknown + PublishedAt *time.Time + SortOrder int +} + +// MediaExtra is a row in media_extras: a scanner-discovered local extra +// (featurette, deleted scene, local trailer file, ...) belonging to a parent +// movie or series. Its ContentID is minted with contentid.ForLocal on the +// backing file path, making it a playable watch target through +// GetWatchDetail's fallback chain; access control is the parent's. +type MediaExtra struct { + ContentID string + ParentID string // media_items.content_id of the owning movie/series + Kind ExtraKind + Title string + SortOrder int +} diff --git a/internal/models/media.go b/internal/models/media.go index b441825a..eaf3d013 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -16,13 +16,17 @@ type MediaFolder struct { AutoTranslateMetadata bool // AI-translate descriptions when providers lack this language ChapterThumbnailsEnabled bool IntroDetectionEnabled bool - PosterPath string // S3 key for library poster image - LastScannedAt *time.Time // nullable - ScanWarningCode *string - ScanWarningMessage *string - ScanWarningAt *time.Time - AllowEmptyCleanupOnce bool - SortOrder int + // TrailerKinds is the allow-list of remote video kinds (ExtraKind values) + // fetched during metadata refresh for this library. Empty disables remote + // videos entirely. + TrailerKinds []string + PosterPath string // S3 key for library poster image + LastScannedAt *time.Time // nullable + ScanWarningCode *string + ScanWarningMessage *string + ScanWarningAt *time.Time + AllowEmptyCleanupOnce bool + SortOrder int } // MediaFile represents a row in the media_files table. @@ -30,6 +34,7 @@ type MediaFile struct { ID int ContentID string // Sonyflake ID (nullable until matched) EpisodeID string // FK to episodes.content_id (nullable) + ExtraID string // FK to media_extras.content_id (nullable); set only for local extras files, which keep ContentID/EpisodeID empty SeasonNumber int // parsed from filename (nullable) EpisodeNumber int // parsed from filename (nullable) MediaFolderID int diff --git a/internal/naming/extras.go b/internal/naming/extras.go new file mode 100644 index 00000000..772be101 --- /dev/null +++ b/internal/naming/extras.go @@ -0,0 +1,60 @@ +package naming + +import ( + "path/filepath" + "strings" +) + +// extraSuffixKinds maps the Jellyfin/Plex filename-suffix convention onto the +// shared extra-kind vocabulary (models.ExtraKind values). The suffix is the +// final "-token" (or ".token") of the stem: "Movie (2020)-trailer.mkv". +var extraSuffixKinds = map[string]string{ + "trailer": "trailer", + "teaser": "teaser", + "featurette": "featurette", + "clip": "clip", + "behindthescenes": "behind_the_scenes", + "bloopers": "bloopers", + "deleted": "deleted_scene", + "deletedscene": "deleted_scene", + "interview": "other", + "scene": "other", + "short": "other", + "extra": "other", + "other": "other", +} + +// ParseExtraSuffix reports whether the file name (or stem) carries a +// Jellyfin/Plex-style extras suffix, returning the mapped extra kind. Only +// the exact final token after the last '-' or '.' separator counts, so titles +// merely containing words like "scene" do not misclassify. +func ParseExtraSuffix(fileName string) (kind string, ok bool) { + stem := strings.TrimSuffix(filepath.Base(fileName), filepath.Ext(fileName)) + idx := strings.LastIndexAny(stem, "-.") + if idx <= 0 || idx == len(stem)-1 { + return "", false + } + token := strings.ToLower(strings.TrimSpace(stem[idx+1:])) + mapped, found := extraSuffixKinds[token] + if !found { + return "", false + } + // A bare suffix with no title ("trailer.mkv" handled by ExtraTitleFromFile + // callers via directory classification) still counts: idx>0 ensures some + // title text precedes the separator. + return mapped, true +} + +// ExtraTitleFromFile derives a human-readable extra title from a file path, +// stripping a recognized extras suffix when present. +func ExtraTitleFromFile(filePath string) string { + stem := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + if idx := strings.LastIndexAny(stem, "-."); idx > 0 && idx < len(stem)-1 { + token := strings.ToLower(strings.TrimSpace(stem[idx+1:])) + if _, found := extraSuffixKinds[token]; found { + stem = stem[:idx] + } + } + stem = strings.NewReplacer(".", " ", "_", " ").Replace(stem) + return strings.Join(strings.Fields(stem), " ") +} diff --git a/internal/naming/extras_test.go b/internal/naming/extras_test.go new file mode 100644 index 00000000..dc3e66ef --- /dev/null +++ b/internal/naming/extras_test.go @@ -0,0 +1,49 @@ +package naming + +import "testing" + +func TestParseExtraSuffix(t *testing.T) { + cases := []struct { + name string + wantKind string + wantOK bool + }{ + {"Movie (2020)-trailer.mkv", "trailer", true}, + {"Movie (2020)-Trailer.mkv", "trailer", true}, + {"Movie.2020.behindthescenes.mkv", "behind_the_scenes", true}, + {"Movie-deleted.mkv", "deleted_scene", true}, + {"Movie-featurette.mp4", "featurette", true}, + {"Movie-interview.mkv", "other", true}, + {"Movie-short.mkv", "other", true}, + // The token must be the final suffix component. + {"Trailer Park Boys S01E01.mkv", "", false}, + {"The Deleted (2016).mkv", "", false}, + {"Movie (2020).mkv", "", false}, + // A bare token with no preceding title is not a suffix match. + {"trailer.mkv", "", false}, + {"-trailer.mkv", "", false}, + } + for _, tc := range cases { + kind, ok := ParseExtraSuffix(tc.name) + if ok != tc.wantOK || kind != tc.wantKind { + t.Errorf("ParseExtraSuffix(%q) = (%q, %v), want (%q, %v)", + tc.name, kind, ok, tc.wantKind, tc.wantOK) + } + } +} + +func TestExtraTitleFromFile(t *testing.T) { + cases := []struct { + path string + want string + }{ + {"/lib/Movie (2020)/Movie (2020)-trailer.mkv", "Movie (2020)"}, + {"/lib/Movie (2020)/Extras/Making Of.mkv", "Making Of"}, + {"/lib/Movie/Extras/behind.the.scenes_reel.mkv", "behind the scenes reel"}, + } + for _, tc := range cases { + if got := ExtraTitleFromFile(tc.path); got != tc.want { + t.Errorf("ExtraTitleFromFile(%q) = %q, want %q", tc.path, got, tc.want) + } + } +} diff --git a/internal/pathscope/pathscope.go b/internal/pathscope/pathscope.go index 6d27968b..05933b89 100644 --- a/internal/pathscope/pathscope.go +++ b/internal/pathscope/pathscope.go @@ -11,6 +11,12 @@ var likeEscaper = strings.NewReplacer( `_`, `\_`, ) +// EscapeLike escapes LIKE wildcards in a literal fragment for use with +// ESCAPE '\' queries. +func EscapeLike(literal string) string { + return likeEscaper.Replace(literal) +} + func PrefixLike(pathPrefix string) string { clean := filepath.Clean(pathPrefix) if clean == string(filepath.Separator) { diff --git a/internal/scanner/extras.go b/internal/scanner/extras.go new file mode 100644 index 00000000..5062ad5a --- /dev/null +++ b/internal/scanner/extras.go @@ -0,0 +1,274 @@ +package scanner + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/Silo-Server/silo-server/internal/contentid" + "github.com/Silo-Server/silo-server/internal/librarykind" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/naming" +) + +// extraCandidate is a walked file classified as a local extra rather than +// primary content. Extras bypass root/group inference and matching entirely: +// they bind to their parent item purely by directory structure. +type extraCandidate struct { + Path string + Kind models.ExtraKind + // SupplementalDir is the classified ancestor directory (Trailers/, + // Featurettes/, ...); empty when the file was classified by its filename + // suffix (-trailer, -behindthescenes, ...). + SupplementalDir string +} + +// extrasDirAncestorDepth bounds how far above a file the walk looks for a +// supplemental directory name. Two levels covers "Movie/Extras/file.mkv" and +// "Movie/Extras/Subdir/file.mkv" without letting a library that happens to +// live inside a directory named "Extras" classify everything beneath it. +const extrasDirAncestorDepth = 2 + +// classifyExtraPath reports whether the walked path is a local extra. +// +// Directory names win over filename suffixes. For non-movie libraries a file +// carrying a parseable SxxExx episode token is never an extra: series +// "Extras/SxxExx" files keep their documented season-0 mapping. +func classifyExtraPath(path, folderType string) (extraCandidate, bool) { + candidate := extraCandidate{Path: path} + + dir := filepath.Dir(path) + for depth := 0; depth < extrasDirAncestorDepth; depth++ { + label := normalizeScannerDirLabel(filepath.Base(dir)) + if kind, ok := extrasDirKinds[label]; ok { + candidate.Kind = kind + candidate.SupplementalDir = dir + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + if candidate.SupplementalDir == "" { + kind, ok := naming.ParseExtraSuffix(path) + if !ok { + return extraCandidate{}, false + } + candidate.Kind = models.NormalizeExtraKind(kind) + } + + // Preserve the documented series behavior: an episode-tokened file under + // Extras/ is a season-0 special, not an extra. + if !librarykind.IsMovie(folderType) { + if hints := naming.ParseFilename(path, folderType); hints != nil && + hints.Type == "series" && hints.EpisodeNum > 0 { + return extraCandidate{}, false + } + } + + return candidate, true +} + +// partitionExtraPaths splits walked paths into primary content and extras. +// Primary paths feed the existing root/group inference and matching pipeline +// untouched; extras are processed separately and never influence identity. +func partitionExtraPaths(paths []string, folderType string) ([]string, []extraCandidate) { + primary := paths[:0:0] + var extras []extraCandidate + for _, p := range paths { + if candidate, ok := classifyExtraPath(p, folderType); ok { + extras = append(extras, candidate) + continue + } + primary = append(primary, p) + } + return primary, extras +} + +// extrasScanStats aggregates processExtraFiles outcomes for the scan result. +type extrasScanStats struct { + New int + Updated int + Unchanged int + Skipped int + Errors int +} + +// processExtraFiles ingests classified extras: bind to a parent item, upsert +// the media_extras entity, and upsert the backing media_files row (probe data +// included) with extra_id set and content/episode ids cleared. Files whose +// parent cannot be resolved yet (parent unmatched or ambiguous) are skipped; +// the next scan retries once the parent has a content id. +func (s *Scanner) processExtraFiles( + ctx context.Context, + folder *models.MediaFolder, + walkRoots []string, + extras []extraCandidate, + existingByPath map[string]*scanStateFile, +) extrasScanStats { + var stats extrasScanStats + if len(extras) == 0 || s.extraRepo == nil { + return stats + } + + rootSet := make(map[string]bool, len(walkRoots)) + for _, root := range walkRoots { + rootSet[filepath.Clean(root)] = true + } + + for _, candidate := range extras { + if ctx.Err() != nil { + return stats + } + + info, err := os.Stat(candidate.Path) + if err != nil { + slog.Warn("scanner: extra stat failed", "path", candidate.Path, "error", err) + stats.Errors++ + continue + } + + extraID := contentid.ForLocal(candidate.Path) + parentID, err := s.resolveExtraParent(ctx, folder.ID, candidate, rootSet) + if err != nil { + slog.Warn("scanner: extra parent lookup failed", "path", candidate.Path, "error", err) + stats.Errors++ + continue + } + if parentID == "" { + slog.Debug("scanner: extra parent unresolved, deferring", + "path", candidate.Path, "kind", candidate.Kind) + stats.Skipped++ + continue + } + + // Upsert the entity before the unchanged check so parent/kind/title + // converge on every scan (a rematched parent or reclassified kind + // must not be masked by an unchanged file). + if err := s.extraRepo.Upsert(ctx, models.MediaExtra{ + ContentID: extraID, + ParentID: parentID, + Kind: candidate.Kind, + Title: naming.ExtraTitleFromFile(candidate.Path), + }); err != nil { + slog.Warn("scanner: extra upsert failed", "path", candidate.Path, "error", err) + stats.Errors++ + continue + } + + fileModifiedAt := normalizeFileModifiedAt(info.ModTime()) + existing := existingByPath[candidate.Path] + if existing != nil && existing.ExtraID == extraID && + existing.FileSize == info.Size() && + existing.FileModifiedAt != nil && existing.FileModifiedAt.Equal(fileModifiedAt) && + existing.ProbeUpdatedAt != nil && existing.MissingSince == nil { + stats.Unchanged++ + continue + } + + hints := s.gatherHints(candidate.Path) + probe, probeSource := s.probeFile(ctx, candidate.Path) + + mf := models.MediaFile{ + MediaFolderID: folder.ID, + FilePath: candidate.Path, + FileSize: info.Size(), + FileModifiedAt: &fileModifiedAt, + FileHash: hints.FileHash, + ExtraID: extraID, + } + if probe != nil { + applyProbeData(&mf, probe, probeSource) + } + if mf.SubtitleTracks == nil { + mf.SubtitleTracks = []models.SubtitleTrack{} + } + if mf.ExternalSubtitles == nil { + mf.ExternalSubtitles = []models.ExternalSubtitle{} + } + + // The upsert clears content/episode linkage atomically when extra_id + // is set, so a pre-existing primary row (e.g. a "-trailer" file + // previously scanned as a movie version) converts in one statement. + if _, err := s.fileRepo.Upsert(ctx, mf); err != nil { + slog.Warn("scanner: extra file upsert failed", "path", candidate.Path, "error", err) + stats.Errors++ + continue + } + + if existing == nil { + stats.New++ + } else { + stats.Updated++ + } + } + + if stats.New+stats.Updated+stats.Skipped+stats.Errors > 0 { + slog.Info("scanner: processed extras", + "folder_id", folder.ID, + "new", stats.New, + "updated", stats.Updated, + "unchanged", stats.Unchanged, + "deferred", stats.Skipped, + "errors", stats.Errors, + ) + } + return stats +} + +// resolveExtraParent finds the content id of the item owning an extra. +// +// Directory-classified extras bind to the directory containing the +// supplemental folder ("Movie (2020)/" for "Movie (2020)/Extras/x.mkv"), +// requiring that directory to hold exactly one item. Suffix-classified files +// first try the sibling primary file sharing their stem ("Movie A.mkv" for +// "Movie A-trailer.mkv"), so flat multi-movie folders bind correctly, then +// fall back to the unambiguous-directory rule. Library roots never bind. +func (s *Scanner) resolveExtraParent( + ctx context.Context, + folderID int, + candidate extraCandidate, + rootSet map[string]bool, +) (string, error) { + if candidate.SupplementalDir == "" { + dir := filepath.Dir(candidate.Path) + stem := strings.TrimSuffix(filepath.Base(candidate.Path), filepath.Ext(candidate.Path)) + if idx := strings.LastIndexAny(stem, "-."); idx > 0 { + stem = strings.TrimSpace(stem[:idx]) + } + if stem != "" { + parentID, err := s.fileRepo.FindParentContentIDForStem(ctx, folderID, dir, stem) + if err != nil { + return "", err + } + if parentID != "" { + return parentID, nil + } + } + if rootSet[filepath.Clean(dir)] { + return "", nil + } + return s.fileRepo.FindUnambiguousParentContentIDForDir(ctx, folderID, dir) + } + + parentDir := filepath.Dir(candidate.SupplementalDir) + // Walk supplemental nesting ("Extras/Behind The Scenes/") up to the first + // non-supplemental ancestor. + for extrasDirKinds[normalizeScannerDirLabel(filepath.Base(parentDir))] != "" { + next := filepath.Dir(parentDir) + if next == parentDir { + break + } + parentDir = next + } + if rootSet[filepath.Clean(parentDir)] { + // Supplemental dir sits at the library root — no single owner. + return "", nil + } + return s.fileRepo.FindUnambiguousParentContentIDForDir(ctx, folderID, parentDir) +} diff --git a/internal/scanner/extras_test.go b/internal/scanner/extras_test.go new file mode 100644 index 00000000..4702a8ef --- /dev/null +++ b/internal/scanner/extras_test.go @@ -0,0 +1,86 @@ +package scanner + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestClassifyExtraPathMovieLibrary(t *testing.T) { + cases := []struct { + path string + wantKind models.ExtraKind + wantDir string + wantOK bool + }{ + {"/movies/Heat (1995)/Trailers/teaser.mkv", models.ExtraKindTrailer, "/movies/Heat (1995)/Trailers", true}, + {"/movies/Heat (1995)/Behind The Scenes/doc.mkv", models.ExtraKindBehindTheScenes, "/movies/Heat (1995)/Behind The Scenes", true}, + {"/movies/Heat (1995)/Extras/Making Of.mkv", models.ExtraKindOther, "/movies/Heat (1995)/Extras", true}, + // Nested one level below a supplemental dir still classifies. + {"/movies/Heat (1995)/Extras/Sub/clip.mkv", models.ExtraKindOther, "/movies/Heat (1995)/Extras", true}, + // Suffix classification with no supplemental dir. + {"/movies/Heat (1995)/Heat (1995)-trailer.mkv", models.ExtraKindTrailer, "", true}, + // Plain movie files are not extras. + {"/movies/Heat (1995)/Heat (1995).mkv", "", "", false}, + // Ancestor lookup is depth-bounded: a library living under a dir + // named "Extras" must not classify everything. + {"/data/Extras/Movies/Heat (1995)/Heat (1995).mkv", "", "", false}, + } + for _, tc := range cases { + candidate, ok := classifyExtraPath(tc.path, "movies") + if ok != tc.wantOK { + t.Errorf("classifyExtraPath(%q) ok = %v, want %v", tc.path, ok, tc.wantOK) + continue + } + if !ok { + continue + } + if candidate.Kind != tc.wantKind || candidate.SupplementalDir != tc.wantDir { + t.Errorf("classifyExtraPath(%q) = (%q, %q), want (%q, %q)", + tc.path, candidate.Kind, candidate.SupplementalDir, tc.wantKind, tc.wantDir) + } + } +} + +func TestClassifyExtraPathSeriesKeepsSeasonZeroBehavior(t *testing.T) { + // Documented behavior: an episode-tokened file under Extras/ in a series + // library maps to season 0, so it must NOT classify as an extra. + if _, ok := classifyExtraPath("/tv/Show/Extras/Show S00E01 Special.mkv", "series"); ok { + t.Fatal("SxxExx file under Extras/ must remain a season-0 episode, not an extra") + } + // A non-tokened file under a series-root supplemental dir IS an extra. + candidate, ok := classifyExtraPath("/tv/Show/Trailers/season-preview.mkv", "series") + if !ok || candidate.Kind != models.ExtraKindTrailer { + t.Fatalf("series-root trailer dir should classify, got ok=%v kind=%q", ok, candidate.Kind) + } +} + +func TestPartitionExtraPaths(t *testing.T) { + paths := []string{ + "/movies/Heat (1995)/Heat (1995).mkv", + "/movies/Heat (1995)/Trailers/tease.mkv", + "/movies/Heat (1995)/Heat (1995)-featurette.mkv", + } + primary, extras := partitionExtraPaths(paths, "movies") + if len(primary) != 1 || primary[0] != paths[0] { + t.Fatalf("primary = %v, want just the main feature", primary) + } + if len(extras) != 2 { + t.Fatalf("extras = %d entries, want 2", len(extras)) + } +} + +func TestMovieSupplementalDirsNoLongerSkipExtras(t *testing.T) { + // The walk must still hard-skip noise dirs... + for _, dir := range []string{"/m/Movie/Sample", "/m/Movie/Subs"} { + if !shouldSkipMovieSupplementalDir(dir) { + t.Errorf("expected %q to remain skipped", dir) + } + } + // ...but extras-shaped dirs are walked now (classified downstream). + for _, dir := range []string{"/m/Movie/Trailers", "/m/Movie/Extras", "/m/Movie/Behind The Scenes"} { + if shouldSkipMovieSupplementalDir(dir) { + t.Errorf("expected %q to be walked for extras classification", dir) + } + } +} diff --git a/internal/scanner/file_repo.go b/internal/scanner/file_repo.go index 2c90147d..3099343e 100644 --- a/internal/scanner/file_repo.go +++ b/internal/scanner/file_repo.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "path/filepath" "strings" "time" @@ -43,7 +44,7 @@ func NewFileRepository(pool *pgxpool.Pool) *FileRepository { } // fileColumns is the list of columns returned by all SELECT queries. -const fileColumns = `id, content_id, episode_id, season_number, episode_number, +const fileColumns = `id, content_id, episode_id, extra_id, season_number, episode_number, media_folder_id, canonical_root_path, observed_root_path, content_group_key, group_key_version, base_title, base_year, base_type, identity_confidence, identity_json, file_path, file_size, file_modified_at, file_hash, @@ -66,7 +67,7 @@ const overlayFileColumns = `content_id, episode_id, media_folder_id, file_path, // mfFileColumns qualifies every column with the "mf" alias for use in JOIN queries // where unqualified "id" would be ambiguous. -const mfFileColumns = `mf.id, mf.content_id, mf.episode_id, mf.season_number, mf.episode_number, +const mfFileColumns = `mf.id, mf.content_id, mf.episode_id, mf.extra_id, mf.season_number, mf.episode_number, mf.media_folder_id, mf.canonical_root_path, mf.observed_root_path, mf.content_group_key, mf.group_key_version, mf.base_title, mf.base_year, mf.base_type, mf.identity_confidence, mf.identity_json, mf.file_path, mf.file_size, mf.file_modified_at, mf.file_hash, @@ -88,6 +89,7 @@ func scanMediaFile(row pgx.Row) (*models.MediaFile, error) { var f models.MediaFile var contentID *string var episodeID *string + var extraID *string var seasonNumber, episodeNumber *int var canonicalRootPath *string var observedRootPath, contentGroupKey, baseTitle, baseType, identityConfidence *string @@ -121,6 +123,7 @@ func scanMediaFile(row pgx.Row) (*models.MediaFile, error) { &f.ID, &contentID, &episodeID, + &extraID, &seasonNumber, &episodeNumber, &f.MediaFolderID, @@ -214,6 +217,9 @@ func scanMediaFile(row pgx.Row) (*models.MediaFile, error) { if episodeID != nil { f.EpisodeID = *episodeID } + if extraID != nil { + f.ExtraID = *extraID + } if seasonNumber != nil { f.SeasonNumber = *seasonNumber } @@ -393,6 +399,7 @@ func scanMediaFiles(rows pgx.Rows) ([]*models.MediaFile, error) { var f models.MediaFile var contentID *string var episodeID *string + var extraID *string var seasonNumber, episodeNumber *int var canonicalRootPath *string var observedRootPath, contentGroupKey, baseTitle, baseType, identityConfidence *string @@ -426,6 +433,7 @@ func scanMediaFiles(rows pgx.Rows) ([]*models.MediaFile, error) { &f.ID, &contentID, &episodeID, + &extraID, &seasonNumber, &episodeNumber, &f.MediaFolderID, @@ -515,6 +523,9 @@ func scanMediaFiles(rows pgx.Rows) ([]*models.MediaFile, error) { if episodeID != nil { f.EpisodeID = *episodeID } + if extraID != nil { + f.ExtraID = *extraID + } if seasonNumber != nil { f.SeasonNumber = *seasonNumber } @@ -819,6 +830,10 @@ func (r *FileRepository) Upsert(ctx context.Context, mf models.MediaFile) (*mode if mf.EpisodeID != "" { episodeID = &mf.EpisodeID } + var extraID *string + if mf.ExtraID != "" { + extraID = &mf.ExtraID + } var fileHash *string if mf.FileHash != "" { fileHash = &mf.FileHash @@ -845,7 +860,7 @@ func (r *FileRepository) Upsert(ctx context.Context, mf models.MediaFile) (*mode } query := `INSERT INTO media_files ( - content_id, episode_id, season_number, episode_number, + content_id, episode_id, extra_id, season_number, episode_number, media_folder_id, canonical_root_path, observed_root_path, content_group_key, group_key_version, base_title, base_year, base_type, identity_confidence, identity_json, file_path, file_size, file_modified_at, file_hash, @@ -857,23 +872,36 @@ func (r *FileRepository) Upsert(ctx context.Context, mf models.MediaFile) (*mode multi_episode_start, multi_episode_end, probe_source, probe_updated_at, missing_since ) VALUES ( - $1, $2, $3, $4, - $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14, - $15, $16, $17, $18, - $19, $20, $21, $22, $23, $24, - $25, $26, $27, $28, $29, $30, $31, - $32, $33, $34, $35, $36, $37, - $38, $39, $40, $41, - $42, $43, $44, $45, - $46, $47, - $48, $49, $50 + $1, $2, $3, $4, $5, + $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, $23, $24, $25, + $26, $27, $28, $29, $30, $31, $32, + $33, $34, $35, $36, $37, $38, + $39, $40, $41, $42, + $43, $44, $45, $46, + $47, $48, + $49, $50, $51 ) ON CONFLICT (file_path) DO UPDATE SET - content_id = COALESCE(EXCLUDED.content_id, media_files.content_id), - episode_id = COALESCE(EXCLUDED.episode_id, media_files.episode_id), - season_number = COALESCE(EXCLUDED.season_number, media_files.season_number), - episode_number = COALESCE(EXCLUDED.episode_number, media_files.episode_number), + content_id = CASE + WHEN EXCLUDED.extra_id IS NOT NULL THEN NULL + ELSE COALESCE(EXCLUDED.content_id, media_files.content_id) + END, + episode_id = CASE + WHEN EXCLUDED.extra_id IS NOT NULL THEN NULL + ELSE COALESCE(EXCLUDED.episode_id, media_files.episode_id) + END, + extra_id = EXCLUDED.extra_id, + season_number = CASE + WHEN EXCLUDED.extra_id IS NOT NULL THEN NULL + ELSE COALESCE(EXCLUDED.season_number, media_files.season_number) + END, + episode_number = CASE + WHEN EXCLUDED.extra_id IS NOT NULL THEN NULL + ELSE COALESCE(EXCLUDED.episode_number, media_files.episode_number) + END, media_folder_id = EXCLUDED.media_folder_id, canonical_root_path = EXCLUDED.canonical_root_path, observed_root_path = EXCLUDED.observed_root_path, @@ -920,6 +948,7 @@ func (r *FileRepository) Upsert(ctx context.Context, mf models.MediaFile) (*mode row := r.pool.QueryRow(ctx, query, contentID, episodeID, + extraID, nilIfZero(mf.SeasonNumber), nilIfZero(mf.EpisodeNumber), mf.MediaFolderID, @@ -1843,7 +1872,7 @@ func (r *FileRepository) GetByHash(ctx context.Context, hash string) (*models.Me func (r *FileRepository) GetUnmatched(ctx context.Context, limit int) ([]*models.MediaFile, error) { query := `SELECT ` + mfFileColumns + ` FROM media_files mf JOIN media_folders folders ON folders.id = mf.media_folder_id - WHERE (mf.content_id IS NULL OR mf.content_id = '') + WHERE (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -1881,7 +1910,7 @@ func (r *FileRepository) ClaimUnmatched(ctx context.Context, limit int) ([]*mode END AS is_series_group FROM media_files mf JOIN media_folders folders ON folders.id = mf.media_folder_id - WHERE (mf.content_id IS NULL OR mf.content_id = '') + WHERE (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -1912,7 +1941,7 @@ func (r *FileRepository) ClaimUnmatched(ctx context.Context, limit int) ([]*mode touched AS ( UPDATE media_files mf SET match_attempted_at = NOW() - WHERE (mf.content_id IS NULL OR mf.content_id = '') + WHERE (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND EXISTS ( @@ -1952,7 +1981,7 @@ func (r *FileRepository) ClaimUnmatchedNonSeries(ctx context.Context, limit int) SELECT mf.id FROM media_files mf JOIN media_folders folders ON folders.id = mf.media_folder_id - WHERE (mf.content_id IS NULL OR mf.content_id = '') + WHERE (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -1997,7 +2026,7 @@ func (r *FileRepository) ClaimUnmatchedMixed(ctx context.Context, limit int) ([] SELECT mf.id FROM media_files mf JOIN media_folders folders ON folders.id = mf.media_folder_id - WHERE (mf.content_id IS NULL OR mf.content_id = '') + WHERE (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -2065,7 +2094,7 @@ func (r *FileRepository) CountUnmatchedMatchBacklogByFolder(ctx context.Context, FROM media_files mf JOIN media_folders folders ON folders.id = mf.media_folder_id WHERE mf.media_folder_id = $1 - AND (mf.content_id IS NULL OR mf.content_id = '') + AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -2106,7 +2135,7 @@ func (r *FileRepository) ListUnmatchedMatchBacklogByFolder(ctx context.Context, FROM media_files mf JOIN media_folders folders ON folders.id = mf.media_folder_id WHERE mf.media_folder_id = $1 - AND (mf.content_id IS NULL OR mf.content_id = '') + AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -2145,7 +2174,7 @@ func (r *FileRepository) SuppressUnmatchedMatchBacklogByFolder(ctx context.Conte FROM media_folders folders WHERE folders.id = mf.media_folder_id AND mf.media_folder_id = $1 - AND (mf.content_id IS NULL OR mf.content_id = '') + AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -2177,7 +2206,7 @@ func (r *FileRepository) RetryUnmatchedMatchBacklogByFolder(ctx context.Context, FROM media_folders folders WHERE folders.id = mf.media_folder_id AND mf.media_folder_id = $1 - AND (mf.content_id IS NULL OR mf.content_id = '') + AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND folders.enabled = true AND ( @@ -2211,7 +2240,7 @@ func (r *FileRepository) GetUnmatchedByFolderAndPathPrefix(ctx context.Context, query := `SELECT ` + mfFileColumns + ` FROM media_files mf JOIN media_folders folders ON folders.id = mf.media_folder_id WHERE mf.media_folder_id = $1 - AND (mf.content_id IS NULL OR mf.content_id = '') + AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -2266,7 +2295,7 @@ func (r *FileRepository) ClaimUnmatchedByFolderAndPathPrefix( FROM media_files mf JOIN media_folders folders ON folders.id = mf.media_folder_id WHERE mf.media_folder_id = $1 - AND (mf.content_id IS NULL OR mf.content_id = '') + AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -2307,7 +2336,7 @@ func (r *FileRepository) ClaimUnmatchedByFolderAndPathPrefix( UPDATE media_files mf SET match_attempted_at = NOW() WHERE mf.media_folder_id = $1 - AND (mf.content_id IS NULL OR mf.content_id = '') + AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND EXISTS ( @@ -2360,7 +2389,7 @@ func (r *FileRepository) ClaimUnmatchedNonSeriesByFolderAndPathPrefix( FROM media_files mf JOIN media_folders folders ON folders.id = mf.media_folder_id WHERE mf.media_folder_id = $1 - AND (mf.content_id IS NULL OR mf.content_id = '') + AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -2427,7 +2456,7 @@ func (r *FileRepository) ClaimUnmatchedMixedByFolderAndPathPrefix( FROM media_files mf JOIN media_folders folders ON folders.id = mf.media_folder_id WHERE mf.media_folder_id = $1 - AND (mf.content_id IS NULL OR mf.content_id = '') + AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL AND mf.missing_since IS NULL AND mf.match_suppressed_at IS NULL AND folders.enabled = true @@ -2664,6 +2693,88 @@ func (r *FileRepository) GetByContentID(ctx context.Context, contentID string) ( return scanMediaFiles(rows) } +// GetByExtraID returns the live files backing a local extra +// (media_extras.content_id). Extras files carry no content_id/episode_id, so +// this is their only ownership lookup. +func (r *FileRepository) GetByExtraID(ctx context.Context, extraID string) ([]*models.MediaFile, error) { + query := `SELECT ` + fileColumns + ` FROM media_files + WHERE extra_id = $1 AND missing_since IS NULL + ORDER BY id ASC` + rows, err := r.pool.Query(ctx, query, extraID) + if err != nil { + return nil, fmt.Errorf("querying files by extra_id: %w", err) + } + defer rows.Close() + + return scanMediaFiles(rows) +} + +// FindParentContentIDForStem finds the owning content id of a primary file in +// dir whose filename stem matches exactly ("Movie A" matches "Movie A.mkv"). +// Used to bind suffix-classified extras ("Movie A-trailer.mkv") in flat +// multi-item directories. +func (r *FileRepository) FindParentContentIDForStem(ctx context.Context, folderID int, dir, stem string) (string, error) { + pattern := pathscope.EscapeLike(filepath.Join(dir, stem)) + ".%" + var parentID *string + err := r.pool.QueryRow(ctx, ` + SELECT COALESCE(e.series_id, mf.content_id) + FROM media_files mf + LEFT JOIN episodes e ON e.content_id = mf.episode_id + WHERE mf.media_folder_id = $1 + AND mf.file_path LIKE $2 ESCAPE '\' + AND mf.extra_id IS NULL + AND (mf.content_id IS NOT NULL OR mf.episode_id IS NOT NULL) + ORDER BY mf.id ASC + LIMIT 1`, folderID, pattern).Scan(&parentID) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("finding parent by stem: %w", err) + } + if parentID == nil { + return "", nil + } + return *parentID, nil +} + +// FindUnambiguousParentContentIDForDir returns the single content id owning +// the primary files under dir, or "" when the directory holds no matched +// content or more than one distinct item (ambiguous — caller defers). +func (r *FileRepository) FindUnambiguousParentContentIDForDir(ctx context.Context, folderID int, dir string) (string, error) { + rows, err := r.pool.Query(ctx, ` + SELECT DISTINCT COALESCE(e.series_id, mf.content_id) AS parent_id + FROM media_files mf + LEFT JOIN episodes e ON e.content_id = mf.episode_id + WHERE mf.media_folder_id = $1 + AND mf.file_path LIKE $2 ESCAPE '\' + AND mf.extra_id IS NULL + AND (mf.content_id IS NOT NULL OR mf.episode_id IS NOT NULL) + LIMIT 2`, folderID, pathPrefixLike(dir)) + if err != nil { + return "", fmt.Errorf("finding parent by dir: %w", err) + } + defer rows.Close() + + parents := make([]string, 0, 2) + for rows.Next() { + var parentID *string + if err := rows.Scan(&parentID); err != nil { + return "", fmt.Errorf("scanning parent id: %w", err) + } + if parentID != nil && *parentID != "" { + parents = append(parents, *parentID) + } + } + if err := rows.Err(); err != nil { + return "", fmt.Errorf("iterating parent ids: %w", err) + } + if len(parents) != 1 { + return "", nil + } + return parents[0], nil +} + // ListByContentIDs returns media files grouped by content ID for the given // content IDs, excluding files that are marked missing. func (r *FileRepository) ListByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaFile, error) { @@ -2816,7 +2927,7 @@ func (r *FileRepository) UpdateContentIDByPathPrefix(ctx context.Context, folder SET content_id = $1, updated_at = NOW() WHERE media_folder_id = $2 AND missing_since IS NULL - AND (content_id IS NULL OR content_id = '') + AND (content_id IS NULL OR content_id = '') AND extra_id IS NULL AND (file_path = $3 OR file_path LIKE $4 ESCAPE '\') `, contentID, folderID, pathPrefix, pathPrefixLike(pathPrefix)) if err != nil { @@ -2834,6 +2945,7 @@ func (r *FileRepository) UpdateContentIDByObservedRootPath(ctx context.Context, WHERE media_folder_id = $2 AND observed_root_path = $3 AND missing_since IS NULL + AND extra_id IS NULL AND (content_id IS NULL OR content_id <> $1) `, contentID, folderID, observedRootPath) if err != nil { diff --git a/internal/scanner/root_observation_test.go b/internal/scanner/root_observation_test.go index 396bbcb9..8d24bec9 100644 --- a/internal/scanner/root_observation_test.go +++ b/internal/scanner/root_observation_test.go @@ -125,8 +125,16 @@ func TestCollectScannedRoots_ContradictorySingleMovieFileBecomesAmbiguous(t *tes } func TestShouldSkipMovieSupplementalDir(t *testing.T) { - if !shouldSkipMovieSupplementalDir("/movies/Movie (2000)/Featurettes") { - t.Fatal("expected Featurettes directory to be skipped") + // Extras-shaped directories are walked now (classified into media_extras + // downstream); only never-playable noise stays skipped. + if shouldSkipMovieSupplementalDir("/movies/Movie (2000)/Featurettes") { + t.Fatal("expected Featurettes directory to be walked for extras classification") + } + if !shouldSkipMovieSupplementalDir("/movies/Movie (2000)/Sample") { + t.Fatal("expected Sample directory to be skipped") + } + if !shouldSkipMovieSupplementalDir("/movies/Movie (2000)/Subs") { + t.Fatal("expected Subs directory to be skipped") } if shouldSkipMovieSupplementalDir("/movies/Movie (2000)/Season 1") { t.Fatal("did not expect Season 1 directory to be skipped") diff --git a/internal/scanner/scan_state.go b/internal/scanner/scan_state.go index d736c6ef..747fd180 100644 --- a/internal/scanner/scan_state.go +++ b/internal/scanner/scan_state.go @@ -15,6 +15,7 @@ import ( type scanStateFile struct { ID int ContentID string + ExtraID string CanonicalRootPath string ObservedRootPath string ContentGroupKey string @@ -50,7 +51,7 @@ type scanStateFile struct { ExternalSubtitlePaths []string } -const scanStateColumns = `id, content_id, +const scanStateColumns = `id, content_id, extra_id, canonical_root_path, observed_root_path, content_group_key, group_key_version, base_title, base_year, base_type, identity_confidence, identity_json, file_path, file_size, file_modified_at, @@ -74,6 +75,7 @@ const scanStateColumns = `id, content_id, func scanScanStateRow(row pgx.Row) (*scanStateFile, error) { var state scanStateFile var contentID *string + var extraID *string var canonicalRootPath, observedRootPath, contentGroupKey, baseTitle, baseType *string var groupKeyVersion, baseYear *int var identityConfidence *string @@ -90,6 +92,7 @@ func scanScanStateRow(row pgx.Row) (*scanStateFile, error) { if err := row.Scan( &state.ID, &contentID, + &extraID, &canonicalRootPath, &observedRootPath, &contentGroupKey, @@ -130,6 +133,9 @@ func scanScanStateRow(row pgx.Row) (*scanStateFile, error) { if contentID != nil { state.ContentID = *contentID } + if extraID != nil { + state.ExtraID = *extraID + } if canonicalRootPath != nil { state.CanonicalRootPath = *canonicalRootPath } @@ -257,6 +263,7 @@ func scanStateFromMediaFile(file *models.MediaFile) *scanStateFile { return &scanStateFile{ ID: file.ID, ContentID: file.ContentID, + ExtraID: file.ExtraID, CanonicalRootPath: file.CanonicalRootPath, ObservedRootPath: file.ObservedRootPath, ContentGroupKey: file.ContentGroupKey, diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index e983cda1..d59e14a2 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -50,19 +50,40 @@ var ignoredDirNames = map[string]bool{ ".downloads": true, } +// ignoredMovieSupplementalDirNames holds movie-library directory names whose +// contents are never playable content (noise). Extras-shaped directories +// (Trailers/, Featurettes/, ...) are NOT in this set: they are walked and +// classified via extrasDirKinds instead of discarded. var ignoredMovieSupplementalDirNames = map[string]bool{ - "sample": true, - "samples": true, - "extra": true, - "extras": true, - "featurette": true, - "featurettes": true, - "behind the scenes": true, - "deleted scenes": true, - "trailer": true, - "trailers": true, - "subs": true, - "subtitles": true, + "sample": true, + "samples": true, + "subs": true, + "subtitles": true, +} + +// extrasDirKinds classifies supplemental directory names (normalized via +// normalizeScannerDirLabel) into the shared extra-kind vocabulary. The set +// mirrors the Jellyfin/Plex extras folder convention. +var extrasDirKinds = map[string]models.ExtraKind{ + "extra": models.ExtraKindOther, + "extras": models.ExtraKindOther, + "other": models.ExtraKindOther, + "others": models.ExtraKindOther, + "featurette": models.ExtraKindFeaturette, + "featurettes": models.ExtraKindFeaturette, + "behind the scenes": models.ExtraKindBehindTheScenes, + "deleted scene": models.ExtraKindDeletedScene, + "deleted scenes": models.ExtraKindDeletedScene, + "trailer": models.ExtraKindTrailer, + "trailers": models.ExtraKindTrailer, + "teaser": models.ExtraKindTeaser, + "teasers": models.ExtraKindTeaser, + "clip": models.ExtraKindClip, + "clips": models.ExtraKindClip, + "bloopers": models.ExtraKindBloopers, + "interviews": models.ExtraKindOther, + "scenes": models.ExtraKindOther, + "shorts": models.ExtraKindOther, } func normalizeScannerDirLabel(name string) string { @@ -120,6 +141,7 @@ type Scanner struct { itemRepo *catalog.ItemRepository personRepo *catalog.PersonRepository episodeRepo *catalog.EpisodeRepository + extraRepo *catalog.ExtraRepository ffprobePath string s3Client *s3client.Client // public assets bucket (may be nil) imageCacher scannerImageCacher @@ -217,6 +239,7 @@ func NewScanner(fileRepo *FileRepository, ffprobePath string, s3Client *s3client itemRepo: catalog.NewItemRepository(fileRepo.Pool()), personRepo: catalog.NewPersonRepository(fileRepo.Pool()), episodeRepo: catalog.NewEpisodeRepository(fileRepo.Pool()), + extraRepo: catalog.NewExtraRepository(fileRepo.Pool()), ffprobePath: ffprobePath, s3Client: s3Client, emptyTrashAfterScan: emptyTrashAfterScan, @@ -658,20 +681,23 @@ func (s *Scanner) scanPaths( }) // Track which paths we see on disk so we can detect missing files. + // Extras count as seen (they own media_files rows) but are partitioned + // out of identity inference and match processing below. seenPaths := make(map[string]bool, len(filePaths)) for _, p := range filePaths { seenPaths[p] = true } + primaryPaths, extraCandidates := partitionExtraPaths(filePaths, folder.Type) rootOverrides, err := s.loadRootOverrides(ctx, folder.ID, reconcileRoots) if err != nil { return nil, fmt.Errorf("loading root overrides: %w", err) } - rootInference := inferRootAssignments(filePaths, folder.Type, folder.ID, rootOverrides) + rootInference := inferRootAssignments(primaryPaths, folder.Type, folder.ID, rootOverrides) identityOverrides, err := s.loadIdentityOverrides(ctx, folder.ID) if err != nil { return nil, fmt.Errorf("loading identity overrides: %w", err) } - groupInference := inferGroupAssignments(filePaths, folder.Type, folder.ID, rootInference.Assignments, identityOverrides) + groupInference := inferGroupAssignments(primaryPaths, folder.Type, folder.ID, rootInference.Assignments, identityOverrides) groupOverrides, err := s.loadGroupOverrides(ctx, folder.ID) if err != nil { return nil, fmt.Errorf("loading group overrides: %w", err) @@ -785,7 +811,7 @@ func (s *Scanner) scanPaths( } }() - for _, p := range filePaths { + for _, p := range primaryPaths { pathCh <- p } close(pathCh) @@ -819,6 +845,12 @@ func (s *Scanner) scanPaths( return result, ctx.Err() } + extraStats := s.processExtraFiles(ctx, folder, walkRoots, extraCandidates, existingByPath) + result.New += extraStats.New + result.Updated += extraStats.Updated + result.Unchanged += extraStats.Unchanged + result.Errors += extraStats.Errors + if err := s.syncPresentLibraryState(ctx, folder.ID); err != nil { return nil, fmt.Errorf("syncing present library state for folder %d: %w", folder.ID, err) } @@ -1169,20 +1201,23 @@ func (s *Scanner) scanScope( FilesDiscovered: len(filePaths), }) + // Extras count as seen (so reconciliation keeps their rows) but are + // partitioned out of identity inference and match processing. seenPaths := make(map[string]bool, len(filePaths)) for _, p := range filePaths { seenPaths[p] = true } + primaryPaths, extraCandidates := partitionExtraPaths(filePaths, folder.Type) rootOverrides, err := s.loadRootOverrides(ctx, folder.ID, reconcileRoots) if err != nil { return nil, fmt.Errorf("loading root overrides: %w", err) } - rootInference := inferRootAssignments(filePaths, folder.Type, folder.ID, rootOverrides) + rootInference := inferRootAssignments(primaryPaths, folder.Type, folder.ID, rootOverrides) identityOverrides, err := s.loadIdentityOverrides(ctx, folder.ID) if err != nil { return nil, fmt.Errorf("loading identity overrides: %w", err) } - groupInference := inferGroupAssignments(filePaths, folder.Type, folder.ID, rootInference.Assignments, identityOverrides) + groupInference := inferGroupAssignments(primaryPaths, folder.Type, folder.ID, rootInference.Assignments, identityOverrides) groupOverrides, err := s.loadGroupOverrides(ctx, folder.ID) if err != nil { return nil, fmt.Errorf("loading group overrides: %w", err) @@ -1267,7 +1302,7 @@ func (s *Scanner) scanScope( } }() - for _, p := range filePaths { + for _, p := range primaryPaths { pathCh <- p } close(pathCh) @@ -1292,6 +1327,14 @@ func (s *Scanner) scanScope( Errors: result.Errors, }) + if ctx.Err() == nil { + extraStats := s.processExtraFiles(ctx, folder, walkRoots, extraCandidates, existingByPath) + result.New += extraStats.New + result.Updated += extraStats.Updated + result.Unchanged += extraStats.Unchanged + result.Errors += extraStats.Errors + } + return &scopedScan{ walkRoots: append([]string(nil), walkRoots...), reconcileRoots: append([]string(nil), reconcileRoots...), @@ -1612,6 +1655,25 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models. if err == nil { existingByPath[filePath] = scanStateFromMediaFile(existing) } + + // A local extra (Trailers/ dir, -trailer suffix, ...) bypasses identity + // inference and matching entirely. + if candidate, isExtra := classifyExtraPath(cleanFile, folder.Type); isExtra { + stats := s.processExtraFiles(ctx, folder, folder.Paths, []extraCandidate{candidate}, existingByPath) + if stats.Errors > 0 { + return fmt.Errorf("processing extra file %s failed", cleanFile) + } + // Converting a previously-primary row into an extra clears its + // content linkage; run the same membership cleanup a full scan would + // so stale library membership doesn't linger until the next scan. + if err := s.syncPresentLibraryState(ctx, folder.ID); err != nil { + return fmt.Errorf("syncing present library state for extra file: %w", err) + } + if _, _, _, err := s.reconcileLibraryMemberships(ctx, folder.ID); err != nil { + return fmt.Errorf("reconciling library membership after extra file scan: %w", err) + } + return nil + } existingContentStatuses, err := s.itemRepo.GetStatusByIDs(ctx, collectScanStateContentIDs([]*scanStateFile{existingByPath[filePath]})) if err != nil { return fmt.Errorf("loading item statuses for file: %w", err) diff --git a/internal/server/frontend.go b/internal/server/frontend.go index f582a5f8..c59f1b4f 100644 --- a/internal/server/frontend.go +++ b/internal/server/frontend.go @@ -40,6 +40,8 @@ var Branding *branding.Service // Plex auth (plex.tv), and HLS fetches against standalone worker origins. // - font-src blob: data: plus fonts.gstatic.com for Google Fonts; reader // book fonts load from blob: URLs. +// - frame-src youtube-nocookie.com: the item-detail trailer modal embeds +// remote trailers via YouTube's privacy-enhanced iframe host. const frontendContentSecurityPolicy = "default-src 'self'; " + "script-src 'self' 'wasm-unsafe-eval'; " + "style-src 'self' 'unsafe-inline' blob: https://fonts.googleapis.com; " + @@ -48,7 +50,7 @@ const frontendContentSecurityPolicy = "default-src 'self'; " + "media-src 'self' blob: http: https:; " + "connect-src 'self' ws: wss: http: https:; " + "worker-src 'self' blob:; " + - "frame-src 'self' blob:; " + + "frame-src 'self' blob: https://www.youtube-nocookie.com; " + "object-src 'none'; " + "base-uri 'self'; " + "form-action 'self'" diff --git a/migrations/sql/20260706201109_item_videos.sql b/migrations/sql/20260706201109_item_videos.sql new file mode 100644 index 00000000..aff84297 --- /dev/null +++ b/migrations/sql/20260706201109_item_videos.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- +goose StatementBegin +-- Remote promotional/supplemental videos (trailers, teasers, featurettes, ...) +-- discovered by metadata providers during match/refresh. Rows are replaced +-- wholesale per refresh (item_people pattern); the (content_id, provider, +-- provider_key) key dedupes re-fetches of the same provider video. +CREATE TABLE item_videos ( + id BIGINT PRIMARY KEY, + content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_key TEXT NOT NULL, + -- Lowercase snake_case kind vocabulary shared with media_extras.kind: + -- trailer, teaser, featurette, clip, behind_the_scenes, bloopers, other. + -- Deliberately CHECK-free: unknown provider kinds normalize to 'other' in + -- code, and new kinds must not require a migration. + kind TEXT NOT NULL, + site TEXT NOT NULL DEFAULT 'youtube', + site_key TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT '', + is_official BOOLEAN NOT NULL DEFAULT FALSE, + size_hint INTEGER NOT NULL DEFAULT 0, + published_at TIMESTAMPTZ, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (content_id, provider, provider_key) +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_item_videos_content_id ON item_videos (content_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS item_videos; +-- +goose StatementEnd diff --git a/migrations/sql/20260706201110_media_extras.sql b/migrations/sql/20260706201110_media_extras.sql new file mode 100644 index 00000000..8ad8f10e --- /dev/null +++ b/migrations/sql/20260706201110_media_extras.sql @@ -0,0 +1,63 @@ +-- +goose Up +-- +goose StatementBegin +-- Local extras (trailers, featurettes, behind-the-scenes, deleted scenes, ...) +-- discovered by the scanner alongside a movie or under a series root. Follows +-- the episodes precedent: an extra is a playable child entity with its own +-- content_id (minted via contentid.ForLocal on the file path) resolved through +-- GetWatchDetail's fallback chain, gated by the parent item's library access. +CREATE TABLE media_extras ( + content_id TEXT PRIMARY KEY, + parent_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + -- Shared kind vocabulary with item_videos.kind, plus local-only + -- 'deleted_scene'. CHECK-free on purpose (see item_videos). + kind TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_media_extras_parent_id ON media_extras (parent_id); +-- +goose StatementEnd + +-- +goose StatementBegin +-- Extras files keep content_id/episode_id NULL so every existing +-- content-keyed query (version picker, playback variants, downloads) is +-- structurally blind to them; ownership flows through extra_id instead. +ALTER TABLE media_files + ADD COLUMN IF NOT EXISTS extra_id TEXT; +-- +goose StatementEnd + +-- +goose StatementBegin +-- NOT VALID keeps the constraint addition from scanning the (large) +-- media_files table under an exclusive lock; VALIDATE below only takes +-- SHARE UPDATE EXCLUSIVE and the column is all-NULL at migration time. +ALTER TABLE media_files + ADD CONSTRAINT media_files_extra_id_fkey + FOREIGN KEY (extra_id) REFERENCES media_extras(content_id) ON DELETE SET NULL + NOT VALID; +-- +goose StatementEnd + +-- +goose StatementBegin +ALTER TABLE media_files VALIDATE CONSTRAINT media_files_extra_id_fkey; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX IF NOT EXISTS idx_media_files_extra_id + ON media_files (extra_id) WHERE extra_id IS NOT NULL; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_media_files_extra_id; +-- +goose StatementEnd + +-- +goose StatementBegin +ALTER TABLE media_files DROP COLUMN IF EXISTS extra_id; +-- +goose StatementEnd + +-- +goose StatementBegin +DROP TABLE IF EXISTS media_extras; +-- +goose StatementEnd diff --git a/migrations/sql/20260706201112_media_folder_trailer_kinds.sql b/migrations/sql/20260706201112_media_folder_trailer_kinds.sql new file mode 100644 index 00000000..87a59e29 --- /dev/null +++ b/migrations/sql/20260706201112_media_folder_trailer_kinds.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +-- Per-library policy for which remote video kinds are fetched/stored during +-- metadata match/refresh. Explicit allow-list: kinds absent from the array are +-- filtered before persisting. Defaults to every kind (the shared vocabulary in +-- internal/models/extras.go); an empty array disables remote videos entirely. +ALTER TABLE media_folders + ADD COLUMN IF NOT EXISTS trailer_kinds TEXT[] NOT NULL + DEFAULT ARRAY['trailer','teaser','featurette','clip','behind_the_scenes','bloopers','other']::text[]; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE media_folders DROP COLUMN IF EXISTS trailer_kinds; +-- +goose StatementEnd diff --git a/web/src/api/types.ts b/web/src/api/types.ts index f1c1dc71..7f6b55c9 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1081,6 +1081,29 @@ export type MarkerSegmentInput = { start?: number | null; end?: number | null }; */ export type SetMarkersRequest = Partial>; +/** + * Remote provider video (trailer, teaser, featurette, ...) attached to an + * item. Kinds: "trailer", "teaser", "featurette", "clip", "behind_the_scenes", + * "bloopers", "deleted_scene", "other". + */ +export interface ItemVideo { + kind: string; + site: string; + site_key: string; + name?: string; + language?: string; + is_official: boolean; +} + +/** Local extras file attached to an item; content_id is a watchable target. */ +export interface ItemExtra { + content_id: string; + kind: string; + title?: string; + duration_seconds?: number; + file_id?: number; +} + export interface ItemDetail { content_id: string; type: "movie" | "series" | "season" | "episode" | "audiobook" | "ebook" | "manga" | "podcast"; @@ -1148,6 +1171,11 @@ export interface ItemDetail { // Root folder paths for series items (admin-only). folder_paths?: string[]; + // Remote provider videos, pre-sorted (trailers first, official first). + videos?: ItemVideo[]; + // Local extras files, pre-sorted by kind. + extras?: ItemExtra[]; + // Playback. versions: FileVersion[]; playback_variants?: PlaybackVariant[]; @@ -2814,6 +2842,8 @@ export interface Library { chapter_thumbnails_enabled: boolean; chapter_thumbnails_supported: boolean; intro_detection_enabled: boolean; + /** Allow-list of video kinds fetched during metadata refresh; empty disables. */ + trailer_kinds: string[]; sort_order: number; poster_url?: string; last_scanned_at: string | null; @@ -2986,6 +3016,7 @@ export interface CreateLibraryRequest { auto_translate_metadata?: boolean; chapter_thumbnails_enabled?: boolean; intro_detection_enabled?: boolean; + trailer_kinds?: string[]; } export interface UpdateLibraryRequest extends Partial {} diff --git a/web/src/components/admin/libraries/LibraryFormSections.tsx b/web/src/components/admin/libraries/LibraryFormSections.tsx index 3bed5a67..23a7a454 100644 --- a/web/src/components/admin/libraries/LibraryFormSections.tsx +++ b/web/src/components/admin/libraries/LibraryFormSections.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; +import { extraKindGroupLabel, PROVIDER_TRAILER_KINDS } from "@/lib/extraKinds"; import { LANGUAGES } from "@/player/utils/languageNames"; import { LIBRARY_TYPES } from "./libraryTypes"; @@ -301,6 +302,42 @@ export function MetadataFields({ form }: { form: LibraryFormController }) { onCheckedChange={form.setAutoTranslateMetadata} /> +
+ +

+ Video types fetched from metadata providers during refresh. Uncheck everything to disable + remote trailers for this library. +

+
+ {PROVIDER_TRAILER_KINDS.map((kind) => { + const checked = form.trailerKinds.includes(kind); + return ( + + ); + })} +
+
{form.contentLevels.length > 0 && (
diff --git a/web/src/components/admin/libraries/useLibraryForm.ts b/web/src/components/admin/libraries/useLibraryForm.ts index c30ee7fe..ccafaec4 100644 --- a/web/src/components/admin/libraries/useLibraryForm.ts +++ b/web/src/components/admin/libraries/useLibraryForm.ts @@ -8,6 +8,7 @@ import { useUpdateLibrary, } from "@/hooks/queries/admin/libraries"; import { useAdminPlugins } from "@/hooks/queries/admin/plugins"; +import { PROVIDER_TRAILER_KINDS } from "@/lib/extraKinds"; export type LevelChainItem = { plugin_installation_id: number; @@ -158,6 +159,9 @@ export function useLibraryForm({ const [introDetectionEnabled, setIntroDetectionEnabled] = useState( library?.intro_detection_enabled ?? false, ); + const [trailerKinds, setTrailerKinds] = useState( + library?.trailer_kinds ?? [...PROVIDER_TRAILER_KINDS], + ); const [levelChains, setLevelChains] = useState>({}); const [chainDirty, setChainDirty] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false); @@ -251,6 +255,12 @@ export function useLibraryForm({ } } + function toggleTrailerKind(kind: string) { + setTrailerKinds((current) => + current.includes(kind) ? current.filter((k) => k !== kind) : [...current, kind], + ); + } + function reorderLevel(level: string, items: LevelChainItem[]) { setLevelChains({ ...activeLevelChains, [level]: items }); setChainDirty(true); @@ -290,6 +300,7 @@ export function useLibraryForm({ auto_translate_metadata: autoTranslateMetadata, chapter_thumbnails_enabled: chapterThumbnailsEnabled, intro_detection_enabled: introDetectionEnabled, + trailer_kinds: trailerKinds, }; if (library) { @@ -353,6 +364,8 @@ export function useLibraryForm({ setChapterThumbnailsEnabled, introDetectionEnabled, setIntroDetectionEnabled, + trailerKinds, + toggleTrailerKind, contentLevels: contentLevelsForType(type), activeLevelChains, reorderLevel, diff --git a/web/src/lib/extraKinds.ts b/web/src/lib/extraKinds.ts new file mode 100644 index 00000000..20169e47 --- /dev/null +++ b/web/src/lib/extraKinds.ts @@ -0,0 +1,56 @@ +/** + * Shared kind vocabulary for remote provider videos (ItemVideo) and local + * extras files (ItemExtra). Keep in sync with the server's kind list. + */ +export const EXTRA_KINDS = [ + "trailer", + "teaser", + "featurette", + "clip", + "behind_the_scenes", + "bloopers", + "deleted_scene", + "other", +] as const; + +export type ExtraKind = (typeof EXTRA_KINDS)[number]; + +/** Singular label, used for individual video/extra cards. */ +const EXTRA_KIND_LABELS: Record = { + trailer: "Trailer", + teaser: "Teaser", + featurette: "Featurette", + clip: "Clip", + behind_the_scenes: "Behind the Scenes", + bloopers: "Bloopers", + deleted_scene: "Deleted Scene", + other: "Extra", +}; + +/** Plural group label, used for section headings when grouping by kind. */ +const EXTRA_KIND_GROUP_LABELS: Record = { + trailer: "Trailers", + teaser: "Teasers", + featurette: "Featurettes", + clip: "Clips", + behind_the_scenes: "Behind the Scenes", + bloopers: "Bloopers", + deleted_scene: "Deleted Scenes", + other: "Other", +}; + +/** + * Kinds providers can return, offered in the library allow-list picker. + * "deleted_scene" is local-only — providers never emit it — so it is omitted. + */ +export const PROVIDER_TRAILER_KINDS: ExtraKind[] = EXTRA_KINDS.filter( + (kind) => kind !== "deleted_scene", +); + +export function extraKindLabel(kind: string): string { + return EXTRA_KIND_LABELS[kind] ?? "Extra"; +} + +export function extraKindGroupLabel(kind: string): string { + return EXTRA_KIND_GROUP_LABELS[kind] ?? "Other"; +} diff --git a/web/src/pages/ItemDetail/MovieContent.tsx b/web/src/pages/ItemDetail/MovieContent.tsx index 9188af5f..34d9020b 100644 --- a/web/src/pages/ItemDetail/MovieContent.tsx +++ b/web/src/pages/ItemDetail/MovieContent.tsx @@ -23,6 +23,8 @@ import RecommendationGrid from "@/components/RecommendationGrid"; import DetailHero from "./DetailHero"; import { useOnViewTranslation } from "@/hooks/useOnViewTranslation"; import MetadataBadges from "./components/MetadataBadges"; +import TrailersSection from "./components/TrailersSection"; +import ExtrasSection from "./components/ExtrasSection"; import QualityBadges from "./components/QualityBadges"; import ScoreRow from "./components/ScoreRow"; import HeroCrewLine from "./components/HeroCrewLine"; @@ -309,6 +311,10 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
{canCurateMetadata && } + {item.videos && item.videos.length > 0 && } + + {item.extras && item.extras.length > 0 && } + {item.cast && item.cast.length > 0 && (

Cast

diff --git a/web/src/pages/ItemDetail/SeriesContent.tsx b/web/src/pages/ItemDetail/SeriesContent.tsx index 41cd8309..87bd1991 100644 --- a/web/src/pages/ItemDetail/SeriesContent.tsx +++ b/web/src/pages/ItemDetail/SeriesContent.tsx @@ -24,6 +24,8 @@ import { useOnViewTranslation } from "@/hooks/useOnViewTranslation"; import SeasonCarousel from "./SeasonCarousel"; import SeasonEpisodeGrid from "./components/SeasonEpisodeGrid"; import MetadataBadges from "./components/MetadataBadges"; +import TrailersSection from "./components/TrailersSection"; +import ExtrasSection from "./components/ExtrasSection"; import ScoreRow from "./components/ScoreRow"; import HeroCrewLine from "./components/HeroCrewLine"; import ActionBar from "./components/ActionBar"; @@ -212,6 +214,10 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se ) : ( seasons.length > 0 && )} + {item.videos && item.videos.length > 0 && } + + {item.extras && item.extras.length > 0 && } + {item.cast && item.cast.length > 0 && (

Cast

diff --git a/web/src/pages/ItemDetail/components/ExtrasSection.tsx b/web/src/pages/ItemDetail/components/ExtrasSection.tsx new file mode 100644 index 00000000..ffb02b1d --- /dev/null +++ b/web/src/pages/ItemDetail/components/ExtrasSection.tsx @@ -0,0 +1,91 @@ +import { useMemo } from "react"; +import { useLocation } from "react-router"; +import { Play } from "lucide-react"; +import type { ItemExtra } from "@/api/types"; +import { useWatchPlaybackController } from "@/playback/watchPlaybackContext"; +import { extraKindGroupLabel } from "@/lib/extraKinds"; +import { formatClock } from "@/lib/markers"; + +interface ExtrasSectionProps { + extras: ItemExtra[]; +} + +/** + * Local extras files grouped by kind. Each card starts playback through the + * same watch flow as the main Play button — an extra's content_id is a + * first-class watch target. + */ +export default function ExtrasSection({ extras }: ExtrasSectionProps) { + const location = useLocation(); + const playbackController = useWatchPlaybackController(); + const currentHref = `${location.pathname}${location.search}`; + + // Server order is already kind-sorted; group while preserving that order. + const groups = useMemo(() => { + const byKind = new Map(); + for (const extra of extras) { + const group = byKind.get(extra.kind); + if (group) { + group.push(extra); + } else { + byKind.set(extra.kind, [extra]); + } + } + return [...byKind.entries()]; + }, [extras]); + + if (groups.length === 0) return null; + + const playExtra = (extra: ItemExtra) => { + playbackController.startPlayback({ + contentId: extra.content_id, + returnHref: currentHref, + }); + }; + + return ( +
+

Extras

+
+ {groups.map(([kind, groupExtras]) => ( +
+

+ {extraKindGroupLabel(kind)} +

+
    + {groupExtras.map((extra) => ( +
  • + playExtra(extra)} /> +
  • + ))} +
+
+ ))} +
+
+ ); +} + +function ExtraCard({ extra, onPlay }: { extra: ItemExtra; onPlay: () => void }) { + const title = extra.title || extraKindGroupLabel(extra.kind); + + return ( + + ); +} diff --git a/web/src/pages/ItemDetail/components/TrailerModal.tsx b/web/src/pages/ItemDetail/components/TrailerModal.tsx new file mode 100644 index 00000000..976cc4d1 --- /dev/null +++ b/web/src/pages/ItemDetail/components/TrailerModal.tsx @@ -0,0 +1,34 @@ +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import type { ItemVideo } from "@/api/types"; +import { extraKindLabel } from "@/lib/extraKinds"; + +interface TrailerModalProps { + video: ItemVideo | null; + onOpenChange: (open: boolean) => void; +} + +/** + * Plays a remote provider video (YouTube only for now) in a privacy-enhanced + * embed. Rendered open whenever `video` is set; closes via the dialog + * primitive's backdrop/Escape/close-button behavior. + */ +export default function TrailerModal({ video, onOpenChange }: TrailerModalProps) { + const title = video?.name || (video ? extraKindLabel(video.kind) : ""); + + return ( + + + {title} + Trailer video player + {video && ( +