* 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>
151 lines
5.0 KiB
Go
151 lines
5.0 KiB
Go
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)
|
|
})
|
|
}
|