Files
silo-server/internal/metadata/types.go
T
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

333 lines
10 KiB
Go

package metadata
import (
"context"
"github.com/Silo-Server/silo-server/internal/models"
)
// RefreshMode determines how the pipeline processes an item.
type RefreshMode int
const (
ModeInitialMatch RefreshMode = iota // New file discovered by scanner
ModeScheduledRefresh // Background periodic refresh
ModeManualRefresh // User-triggered refresh
ModeIdentify // User manually identified item
)
// MergeMode determines how provider results merge into existing items.
type MergeMode int
const (
MergeFillEmpty MergeMode = iota // Only fill zero/empty fields
MergeReplaceUnlocked // Replace all unlocked fields
)
// MetadataField identifies a lockable metadata field.
type MetadataField int
const (
FieldName MetadataField = iota
FieldOverview
FieldGenres
FieldStudios
FieldCast
FieldCrew
FieldRating
FieldRuntime
FieldTags
FieldContentRating
FieldImages
FieldAirSchedule
FieldVideos
)
// RefreshPriority controls queue ordering.
type RefreshPriority int
const (
PriorityHigh RefreshPriority = 0 // Manual refresh, identify
PriorityNormal RefreshPriority = 1 // Newly matched items
PriorityLow RefreshPriority = 2 // Scheduled background refresh
)
// ProcessRequest is the unified input to MetadataService.Process().
type ProcessRequest struct {
ContentID string // Existing item (refresh/identify) or empty (new match)
Hints *MatchHints // From scanner (initial match only)
ProviderIDs map[string]string // From user selection (identify only)
FolderID string // Which library folder
Language string // ISO 639-1 metadata language (resolved from folder)
Mode RefreshMode
// AdoptLanguage makes Language the item's new canonical metadata
// language: the base row is rewritten in Language and
// default_metadata_language is restamped, instead of routing a
// non-canonical language to the localization tables. Set by
// folder-scoped manual refreshes when the library's configured
// language differs from the item's stamp, so changing a library's
// metadata language actually re-fetches titles/overviews.
AdoptLanguage bool
}
// ProcessResult is the output of MetadataService.Process().
type ProcessResult struct {
ContentID string
IsNew bool // True if this was an initial match (new item created)
Updated bool // True if any fields were changed
}
// MatchHints carries scanner-extracted data into the pipeline.
// Adapted from matcher.MatchHints with FilePath added for local providers.
type MatchHints struct {
ContentID string
FileHash string
TmdbID string
TvdbID string
ImdbID string
Title string
Year int
Type string // "movie" or "series"
SeasonNum int
EpisodeNum int
HintSource string // "xattr", "nfo", "folder", "filename"
FilePath string // Absolute path to media file (legacy local-provider field)
RepresentativeFilePath string
ObservedRootPath string
AllGroupFilePaths []string
PrimarySidecarSearchPaths []string
}
// SearchQuery is passed to SearchProvider.Search().
type SearchQuery struct {
Title string
Author string // optional creator hint (e.g. ebook author) folded into the search query
Year int
ContentType string // media_items.type value
ProviderIDs map[string]string // Accumulated IDs from prior providers
Language string // ISO 639-1 code from library preference
FilePath string
RepresentativeFilePath string
ObservedRootPath string
AllGroupFilePaths []string
PrimarySidecarSearchPaths []string
}
// SearchResult is returned from SearchProvider.Search().
type SearchResult struct {
Name string
Year int
ProviderIDs map[string]string
ImageURL string
Overview string
Provider string // Slug of the provider that returned this
}
// MetadataRequest is passed to MetadataProvider.GetMetadata().
type MetadataRequest struct {
ProviderIDs map[string]string
ContentType string
Language string
FilePath string // Populated for local providers, empty for remote
RepresentativeFilePath string
ObservedRootPath string
AllGroupFilePaths []string
PrimarySidecarSearchPaths []string
GroupTitle string
GroupYear int
}
// PersonDetailRequest is passed to person detail lookups.
type PersonDetailRequest struct {
ProviderIDs map[string]string
Language string
}
// PersonDetailResult carries person-level metadata from a provider.
type PersonDetailResult struct {
Name string
SortName string
Bio string
BirthDate string
DeathDate string
Birthplace string
Homepage string
PhotoPath string
PhotoSourcePath string
PhotoThumbhash string
ProviderIDs map[string]string
}
// MetadataResult carries structured metadata from a single provider.
type MetadataResult struct {
HasMetadata bool
ProviderIDs map[string]string
Title string
OriginalTitle string
SortTitle string
Overview string
Tagline string
Year int
Runtime int
Genres []string
Studios []string
Networks []string
Countries []string
Keywords []string
OriginalLanguage string
ContentRating string
Ratings Ratings
People []models.ItemPerson
// Images (S3 paths or URLs).
PosterPath string
PosterThumbhash string
BackdropPath string
BackdropThumbhash string
LogoPath string
// Series-specific
ReleaseDate string
SeasonCount int
FirstAirDate string
LastAirDate string
AirTime string
AirTimezone string
// 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.
type Ratings struct {
IMDB float64
TMDB float64
RTCritic float64
RTAudience float64
}
// ImageRequest is passed to ImageProvider.GetImages().
type ImageRequest struct {
ProviderIDs map[string]string
ContentType string
Language string
}
// RemoteImage describes an available image from a provider.
type RemoteImage struct {
ProviderID string // Slug of the provider that returned this image
URL string
Type ImageType
Language string
Width int
Height int
Rating float64 // Vote average for ordering
}
// ImageType classifies image purpose.
type ImageType int
const (
ImagePoster ImageType = iota
ImageBackdrop
ImageLogo
ImageStill // Episode stills
ImageProfile
)
// CacheImageRequest describes an image to be cached. For season posters
// and episode stills, ContentID is the parent series's ID and the
// SeasonNumber / EpisodeNumber fields scope the S3 key so siblings do not
// collide. Both pointers are nil for item-level images.
type CacheImageRequest struct {
SourceURL string
ProviderID string
ContentType string // "movies" or "series"
ContentID string
ImageType ImageType
SeasonNumber *int
EpisodeNumber *int
Language string
}
// CacheImageResult is returned by ImageCacher on success.
type CacheImageResult struct {
BasePath string // S3 key prefix
Thumbhash string // base64-encoded
Ext string // file extension including dot (e.g. ".jpg", ".png")
UploadedVariants int
ExistingVariants int
}
// ImageCacher caches a remote image to object storage.
type ImageCacher interface {
CacheImage(ctx context.Context, req CacheImageRequest) (*CacheImageResult, error)
}
type ImageCacheJobEnqueuer interface {
Enqueue(ctx context.Context, in EnqueueImageCacheJobInput) error
EnqueueBatch(ctx context.Context, inputs []EnqueueImageCacheJobInput) (int, error)
}
// SeasonsRequest is passed to EpisodeProvider.GetSeasons().
type SeasonsRequest struct {
ProviderIDs map[string]string
ContentType string
Language string
}
// EpisodesRequest is passed to EpisodeProvider.GetEpisodes().
type EpisodesRequest struct {
ProviderIDs map[string]string
SeasonNumber int
Language string
}
// SeasonResult carries season data from a provider.
type SeasonResult struct {
ContentID string
SeasonNumber int
Title string
Overview string
AirDate string
PosterPath string
PosterSourcePath string
PosterThumbhash string
Episodes []EpisodeResult
}
// EpisodeResult carries episode data from a provider.
type EpisodeResult struct {
ContentID string
ProviderIDs map[string]string
SeasonNumber int
EpisodeNumber int
Title string
Overview string
AirDate string
Runtime int
Ratings Ratings
StillPath string
StillSourcePath string
StillThumbhash string
}