* 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>
154 lines
6.2 KiB
Go
154 lines
6.2 KiB
Go
package server
|
|
|
|
import (
|
|
"io/fs"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/branding"
|
|
)
|
|
|
|
// WebDistFS holds the embedded frontend build output.
|
|
// When nil, FrontendHandler returns a placeholder response.
|
|
var WebDistFS fs.FS
|
|
|
|
// Branding supplies white-label customization (server name, favicon, manifest)
|
|
// to the SPA shell. When nil, the frontend is served exactly as built.
|
|
var Branding *branding.Service
|
|
|
|
// frontendContentSecurityPolicy is served with every SPA HTML response.
|
|
//
|
|
// SECURITY: this policy is the primary mitigation for malicious ebook content.
|
|
// The in-app reader (foliate-js) renders book chapters in same-origin blob:
|
|
// iframes with allow-scripts (required to work around a WebKit bug), and
|
|
// blob:/srcdoc documents inherit the embedding document's CSP. With
|
|
// script-src 'self', scripts embedded in an EPUB (blob:/inline/data: sources)
|
|
// cannot execute, so a hostile book cannot read localStorage tokens or call
|
|
// the API. Do not add 'unsafe-inline', 'unsafe-eval', blob:, or data: to
|
|
// script-src without revisiting that threat model.
|
|
//
|
|
// Allowances beyond 'self' exist for concrete app needs:
|
|
// - script-src 'wasm-unsafe-eval': JASSUB (libass) subtitle rendering and
|
|
// node-unrar-js CBR extraction compile WebAssembly.
|
|
// - style-src blob: and 'unsafe-inline': foliate-js loads EPUB stylesheets
|
|
// via blob: URLs; the app uses inline style attributes. Google Fonts CSS
|
|
// is linked from index.html.
|
|
// - img-src/media-src http(s): artwork can come from TMDB/TVDB/S3 public
|
|
// URLs, and stream URLs may point at standalone proxy/transcode workers
|
|
// on another origin (proxy public_url, plain http on LANs).
|
|
// - connect-src http(s)/ws(s): realtime session hub WebSockets, browser-side
|
|
// 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; " +
|
|
"img-src 'self' blob: data: http: https:; " +
|
|
"font-src 'self' blob: data: https://fonts.gstatic.com; " +
|
|
"media-src 'self' blob: http: https:; " +
|
|
"connect-src 'self' ws: wss: http: https:; " +
|
|
"worker-src 'self' blob:; " +
|
|
"frame-src 'self' blob: https://www.youtube-nocookie.com; " +
|
|
"object-src 'none'; " +
|
|
"base-uri 'self'; " +
|
|
"form-action 'self'"
|
|
|
|
// FrontendHandler returns an http.Handler that serves the embedded SPA.
|
|
// It serves static files from WebDistFS and falls back to index.html for
|
|
// SPA routing (any path that doesn't match a file).
|
|
func FrontendHandler() http.Handler {
|
|
if WebDistFS == nil {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("Frontend not built. Run: cd web && bun run build"))
|
|
})
|
|
}
|
|
|
|
fileServer := http.FileServer(http.FS(WebDistFS))
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
path := r.URL.Path
|
|
|
|
// Dynamic branding endpoints must be handled before the static file
|
|
// server, which would otherwise serve the bundled defaults shadowing
|
|
// them. Both fall through to the static asset when no override applies.
|
|
if Branding != nil {
|
|
switch path {
|
|
case "/site.webmanifest":
|
|
serveDynamicManifest(w, r)
|
|
return
|
|
case "/favicon.ico":
|
|
if serveCustomFavicon(w, r) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to serve the file directly. index.html is excluded so the SPA
|
|
// HTML always goes through the fallback below and carries the CSP.
|
|
if path != "/" && path != "/index.html" && !strings.HasSuffix(path, "/") {
|
|
if f, err := WebDistFS.Open(strings.TrimPrefix(path, "/")); err == nil {
|
|
f.Close()
|
|
fileServer.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
// SPA fallback: serve index.html
|
|
indexBytes, err := fs.ReadFile(WebDistFS, "index.html")
|
|
if err != nil {
|
|
http.Error(w, "index.html not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if Branding != nil {
|
|
indexBytes = branding.RenderIndexHTML(indexBytes, Branding.Load(r.Context()))
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Content-Security-Policy", frontendContentSecurityPolicy)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(indexBytes)
|
|
})
|
|
}
|
|
|
|
// serveDynamicManifest writes the branding-aware web app manifest.
|
|
func serveDynamicManifest(w http.ResponseWriter, r *http.Request) {
|
|
body := branding.RenderManifest(Branding.Load(r.Context()))
|
|
w.Header().Set("Content-Type", "application/manifest+json")
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
// serveCustomFavicon serves the admin-uploaded favicon at /favicon.ico when one
|
|
// is configured, so direct requests (browsers, crawlers) get the branded icon.
|
|
// Returns false when there is no custom favicon, letting the caller fall through
|
|
// to the bundled static file.
|
|
func serveCustomFavicon(w http.ResponseWriter, r *http.Request) bool {
|
|
data, contentType, ref, err := Branding.GetAsset(r.Context(), branding.KindFavicon)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// X-Content-Type-Options is already set on the response by the caller. The
|
|
// favicon may be an admin-uploaded SVG; harden it against script execution
|
|
// on direct navigation (stored-XSS defense), matching the API asset route.
|
|
etag := `"` + ref + `"`
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Content-Security-Policy", branding.AssetContentSecurityPolicy)
|
|
w.Header().Set("ETag", etag)
|
|
// Stable path (no content hash in the URL), so revalidate rather than cache
|
|
// long-lived; the ETag lets browsers skip the body when unchanged.
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
if r.Header.Get("If-None-Match") == etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return true
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(data)
|
|
return true
|
|
}
|