Files
silo-server/internal/scanner/root_observation_test.go
e140bd9424 feat(metadata,scanner): trailers and extras for movies and series (#322)
* 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>
2026-07-06 18:52:44 -04:00

152 lines
5.4 KiB
Go

package scanner
import "testing"
func TestObserveRoot_ReportedMovieFolderStaysMovie(t *testing.T) {
observation, ok := ObserveRoot(
"/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv",
"mixed",
)
if !ok {
t.Fatal("expected observation")
}
if observation.RootPath != "/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}" {
t.Fatalf("RootPath = %q, want movie folder root", observation.RootPath)
}
if !observation.HasFolderIDs {
t.Fatal("expected folder ids to be detected")
}
if observation.Reason != RootObservationReasonMatchable {
t.Fatalf("Reason = %q, want %q", observation.Reason, RootObservationReasonMatchable)
}
}
func TestObserveRoot_FlatTVFolderStaysSeries(t *testing.T) {
observation, ok := ObserveRoot("/mixed/Show Name/Show Name S01E03.mkv", "mixed")
if !ok {
t.Fatal("expected observation")
}
if observation.RootPath != "/mixed/Show Name" {
t.Fatalf("RootPath = %q, want %q", observation.RootPath, "/mixed/Show Name")
}
}
func TestObserveRoot_IDTaggedMovieFolderBeatsDivergentReleaseFilename(t *testing.T) {
observation, ok := ObserveRoot(
"/movies/The Expendables 4 {imdb-tt3291150} {tmdb-299054}/Expend4bles (2023) [Remux-1080p 8-bit AVC TrueHD Atmos 7.1]-CiNEPHiLES.mkv",
"movies",
)
if !ok {
t.Fatal("expected observation")
}
if observation.RootPath != "/movies/The Expendables 4 {imdb-tt3291150} {tmdb-299054}" {
t.Fatalf("RootPath = %q, want movie folder root", observation.RootPath)
}
if !observation.HasFolderIDs {
t.Fatal("expected folder ids to be detected")
}
if observation.Reason != RootObservationReasonMatchable {
t.Fatalf("Reason = %q, want %q", observation.Reason, RootObservationReasonMatchable)
}
}
func TestInferRootAssignments_CollapsesWrapperFolderToTaggedParent(t *testing.T) {
result := inferRootAssignments([]string{
"/movies/The Bay (2019) {tvdbid-368807}/The Bay (2019)/The Bay (2019) S01E01.mkv",
}, "mixed", 7, nil)
assignment := result.Assignments["/movies/The Bay (2019) {tvdbid-368807}/The Bay (2019)/The Bay (2019) S01E01.mkv"]
if got, want := assignment.RootPath, "/movies/The Bay (2019) {tvdbid-368807}"; got != want {
t.Fatalf("RootPath = %q, want %q", got, want)
}
if !assignment.WrapperCollapsed {
t.Fatal("expected wrapper collapse to be recorded")
}
}
func TestCollectScannedRoots_ProviderTaggedParentBeatsSyntheticChild(t *testing.T) {
roots := collectScannedRoots([]string{
"/movies/Bagman {tmdb-814889}/Bagman.2024.2160p.WEB-DL.mkv",
}, "movies", 12, nil)
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
if got, want := roots[0].RootPath, "/movies/Bagman {tmdb-814889}"; got != want {
t.Fatalf("RootPath = %q, want %q", got, want)
}
}
func TestCollectScannedRoots_UFCEventWithoutFolderIDsStillResolves(t *testing.T) {
roots := collectScannedRoots([]string{
"/events/UFC 300/UFC.300.2024.1080p.WEB-DL.mkv",
}, "mixed", 14, nil)
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
if got := roots[0].State; got != "resolved" {
t.Fatalf("State = %q, want resolved", got)
}
}
func TestCollectScannedRoots_AltCutReleaseNameUsesMovieFolderRoot(t *testing.T) {
roots := collectScannedRoots([]string{
"/movies/alt-cuts/1080p/Borderland (2007)/Borderland.2007.Unrated.Directors.Cut.BluRay.1080p.DTS-HD.MA.5.1.AVC.REMUX-FraMeSToR.mkv",
}, "movies", 21, nil)
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
if got, want := roots[0].RootPath, "/movies/alt-cuts/1080p/Borderland (2007)"; got != want {
t.Fatalf("RootPath = %q, want %q", got, want)
}
if got, want := roots[0].Title, "Borderland"; got != want {
t.Fatalf("Title = %q, want %q", got, want)
}
if got, want := roots[0].Year, 2007; got != want {
t.Fatalf("Year = %d, want %d", got, want)
}
if got := roots[0].State; got != "resolved" {
t.Fatalf("State = %q, want resolved", got)
}
}
func TestCollectScannedRoots_ContradictorySingleMovieFileBecomesAmbiguous(t *testing.T) {
roots := collectScannedRoots([]string{
"/movies/Puppet Master (1989)/Transformers.Armada.2002.1080p.BluRay.mkv",
}, "movies", 22, nil)
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
if got, want := roots[0].RootPath, "/movies/Puppet Master (1989)"; got != want {
t.Fatalf("RootPath = %q, want %q", got, want)
}
if got := roots[0].State; got != "ambiguous" {
t.Fatalf("State = %q, want ambiguous", got)
}
}
func TestShouldSkipMovieSupplementalDir(t *testing.T) {
// 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")
}
}
func TestShouldSkipMovieSupplementalFile(t *testing.T) {
if !shouldSkipMovieSupplementalFile("/movies/Movie (2000)/Sample.mkv") {
t.Fatal("expected Sample.mkv to be skipped")
}
if shouldSkipMovieSupplementalFile("/movies/Sample (2011)/Sample.2011.1080p.BluRay.mkv") {
t.Fatal("did not expect a real movie named Sample to be skipped")
}
}