* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * chore(deps): bump silo-plugin-sdk to v0.9.0 for VideoRecord Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
83 lines
2.9 KiB
Go
83 lines
2.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|