Commit Graph
14 Commits
Author SHA1 Message Date
383973ec22 feat(metadata): improve match accuracy and localized titles (#461)
* feat(metadata): improve match accuracy and localized titles

* fix(metadata): address matching review findings

* test(catalog): align empty alias snapshot scope

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-24 12:02:52 -04:00
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
0fb5afe479 feat(matching): split wrongly merged versions with watch-state reattribution; anchor group keys on provider tags (#319)
* feat(matching): split wrongly merged versions, reattribute watch state, anchor group keys on provider tags

Wrong merges (two titles normalizing to the same title+year key) stacked
different films as fake "versions" of one item with no in-app repair, and
explicit {tmdb-…}/[imdb-…] folder tags could not prevent it because the
content-group key ignored provider IDs entirely. Merges also silently
orphaned all per-user watch state.

- Anchor group keys on structured provider tags: same tag always groups,
  different tags can never merge; untagged files keep title+year keys.
- media_identity_overrides: path-scoped (root/file) forced identities applied
  during group inference, so admin splits survive rescans.
- internal/catalog/reattribute: shared user-state mover — exact moves for
  file-linked rows, evidence-based user_watch_history classification via the
  playback session log, newest-wins progress conflicts; wired into
  rebindItemToExistingItem to stop merge orphaning (with S/E episode mapping).
- POST /admin/items/{id}/split (dry-run = full transaction + rollback, so
  previews are exact), POST /admin/items/{id}/merge, GET /admin/items/{id}/files.
- Web admin: Split Versions dialog (files by folder → candidate search →
  preview → split), Resolve link from ambiguous-roots diagnostics.

Part of #318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reattribute): classify history before moving session log; cover managed downloads and series-scoped preferences

Review findings on #319, all reproduced against a migrated scratch database:

- moveFileSubset re-pointed playback_history_admin before the history
  evidence query ran, erasing exactly the evidence proving a profile's plays
  were all on moved files — their history stayed behind as ambiguous.
  History classification now runs first; the pre-fix code demonstrably fails
  TestRun_HistoryEvidenceClassification.
- Managed offline downloads (downloads.content_id/episode_id) were not
  remapped on split or merge, stranding rows on the old id. Now moved per
  file on splits and swept per id pair on merges/episode re-anchoring.
- Series merges left user_audio_preferences, user_subtitle_preferences,
  user_series_playback_preferences (series_id-keyed) and the denormalized
  user_home_item_dismissals.series_id behind. All four now move, mirroring
  the provider-merge remap.

All five reattribute DB tests now verified green against PostgreSQL, with
new coverage for managed downloads, subtitle preferences, and dismissal
series ids.

Part of #318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:23:32 -04:00
6f716d3404 fix(scanner): skip misplaced TV episodes in movie libraries (#90)
* fix(scanner): skip misplaced TV episodes in movie libraries

A movie-type library containing a TV show laid out as
"Show/Season NN/SxxExx.mkv" (e.g. a fan "supercuts" pack) created one
bogus movie item per episode, all titled after the season folder
("Season 01"). They surfaced on Recently Added and never matched.

Add naming.IsMisplacedSeriesFile, which detects a file with an SxxExx
name inside an explicit "Season NN"/"Specials" directory, and guard
createOrFindSkeleton so such files in a strict movie library are recorded
as a skipped root (reason series_in_movie_library) instead of becoming
items. The movie match-queue worker now dequeues files the skeleton step
deliberately skips instead of erroring on the empty content id.

The guard fires on the structural signal alone, regardless of any parsed
provider id, because a "Season NN" folder otherwise yields a bogus tmdb
id (the season number). Movies whose release filename merely contains an
SxxExx substring but live in a proper "Title (Year)/" folder are
unaffected (covered by the new test).

* fix(metadata): durably exclude misplaced-series files from movie queue

Address review findings on the misplaced-TV skip:

- The skipped file's content_id is never set, so every library sync
  re-enqueued it just for the worker to skip and dequeue it again.
  Exclude files beneath a series_in_movie_library skipped root in the
  movie match queue predicates; deleting the skipped root row makes the
  files eligible again. The worker drain remains to flush rows claimed
  before the root was recorded.
- Extract the eligibility predicate (previously duplicated verbatim in
  eight queries) into movieQueueFileEligibleCond.
- Derive skipped-root file_count from media_files under the root via a
  new UpsertObservedFile instead of hardcoding 1: a 34-episode pack now
  reports 34 instead of each per-file upsert overwriting the count.
  This also fixes the pre-existing missing_folder_ids undercount.
- Fold the two near-identical Upsert+log blocks in createOrFindSkeleton
  into a recordSkippedRoot helper, normalize libraryType once, and name
  the reason strings as constants.
- Drop a no-op filepath.Base on already-split path segments in
  IsMisplacedSeriesFile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:18:41 -04:00
0d0d7bab1e fix(naming): let trailing bare IMDb ids anchor group identity (#113)
* fix(naming): let trailing bare IMDb ids anchor group identity

hasStructuredIDAnchor only consulted ParseStructuredFolderIDs, so folders
tagged with an unbracketed trailing id ("Eggs Run (2021) tt8049994") still
went ambiguous on title conflicts. ParseFolderIDs now returns only explicit
evidence (structured tags plus unambiguous tt-prefixed ids), so use it for
the anchor check. Covers the last 3 ambiguous-with-IDs groups on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(naming): persist file-stem trailing IMDb ids into group identity

InferGroupIdentity anchored on file-level trailing bare IMDb ids via
hasStructuredIDAnchor but extracted file-stem IDs with
ParseStructuredFolderIDs, so the id that justified resolving a title
conflict never reached GroupIdentity/ScannedMediaGroup and downstream
matching saw a resolved group without it. Use ParseFolderIDs for the
file-stem extraction to mirror the anchor check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 17:12:05 -04:00
75b476d124 fix(naming,metadata): anchor identity on provider IDs, drop bare-numeric IDs, back off match queue retries (#112)
* fix(naming,metadata): anchor identity on provider IDs, drop bare-numeric IDs, back off match queue retries

Three scanner/matching fixes validated against dev data:

- Group identity: explicit structured provider tags ({tmdb-...},
  [tvdbid-...]) now anchor a group's identity, so folder/file title
  conflicts (renamed releases in Radarr-tagged folders) no longer mark
  groups ambiguous and silently exclude them from matching. 3,049 of
  3,121 ambiguous groups on dev carried explicit tags.

- ParseFolderIDs: remove bare trailing numeric ID parsing entirely,
  mirroring Jellyfin's path-attribute model (bracketed key tags plus
  unambiguous tt-prefixed IMDb ids only). Titles ending in numbers
  ("District 9", "Beverly Hills 90210", "Season 01") were misparsed as
  trusted IDs, which suppresses title search and silently mismatches.
  The folderType parameter existed only to type bare numerics, so it
  is gone too.

- Match queues: replace the constant 15s/30s retry delay with shared
  exponential backoff capped at 24h. Terminal failures ("no metadata
  found from any provider") had rows at 15k+ attempts hot-looping
  every 15s on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(naming): merge trailing bare IMDb id with structured folder tags

ParseFolderIDs returned early on any structured tag, so a folder like
"Show [tvdbid-81189] tt1375666" lost the trailing IMDb id. Parse both and
merge, with an explicit structured imdb tag still taking precedence over a
trailing bare id. Matches Jellyfin, which resolves each provider key
independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:38:20 -04:00
Quick 3f49271f63 fix(metadata): tighten matcher and bare IMDb parsing 2026-05-28 13:09:56 -04:00
Silo Server DeveloperandClaude Opus 4.7 5d6be3ad4b chore(lint): gofmt single-space alignment in root_inference.go var block
When inferProviderTagRe was broadened to handle unsubstituted Sonarr token
placeholders ({TvdbId}/{imdb-}), the regex grew long enough that gofmt prefers
single-space rather than column-aligned spacing across the var block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:06 +02:00
Silo Server Developer 17d156a7ca fix(naming): parse bare bracketed IMDb IDs ([tt10011226]/{tt...})
Folders tagged with a bare IMDb id in brackets (Plex/Kodi style, e.g.
'17 Blocks (2021) [tt10011226]') had the id silently dropped — folderIDPattern
needs an 'imdb-' prefix and trailingImdbIDPattern needs an un-bracketed trailing
tt-id. Recognize bracketed bare tt-ids so these items get the trusted-ID match
path instead of falling to title+year scoring.
2026-05-28 17:58:06 +02:00
Silo Server DeveloperandClaude Opus 4.7 7922224839 test(naming): document bare-id trade-off; cover CJK title + movies numeric
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:45:38 +02:00
Silo Server DeveloperandClaude Opus 4.7 534f7bb901 fix(naming): numeric-only titles are not bare provider IDs
'86' / '22 7' were parsed as trailing tvdb ids, tripping the trusted-ID gate
so the correct title match was rejected. Require a letter in the name before
treating a trailing number as a bare id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:45:38 +02:00
Silo Server Developer f2b6eff29d fix(naming): strip unsubstituted Sonarr tokens ({TvdbId}/{imdb-}) from titles
These tokens survived the provider-tag regex ([\w]+ doesn't match braces),
polluting parsed titles (e.g. 'A Girl & Her Guard Dog [tvdb-{TvdbId}]') so
they could not score-match. Broaden the regex to drop {...} and empty tokens.
2026-05-28 17:45:38 +02:00
Quick bba3177fc9 fix(metadata): break duplicate provider candidate ties
- Score candidate metadata completeness and auto-match the richer duplicate when title/year/type tie
- Enrich near-duplicate candidates via the provider chain before initial match selection
- Seed both movie and series match queues for mixed-type libraries and wait for TV queue settle
- Add taskmanager worker test coverage and a plan doc for the tie-breaker work
2026-05-26 20:07:43 -04:00
Silo Server Migration c085b12fd1 Initial Silo migration 2026-05-22 23:26:56 -04:00