diff --git a/CLAUDE.md b/CLAUDE.md index 02f338a5a..2deeaa761 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -823,10 +823,10 @@ engine` (restart required) or - Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 460px anchored `MatMenu` popover (`libs/ui/components/src/lib/vod-sources/`), reused unchanged on the playback-error screen. - Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing. - **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height. -- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials. +- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a bounded LIKE scan, since the trigram tokenizer cannot index them at all. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials. - Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. -- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}`, `vod_source_pins` table); lookups pass every alias most-trusted-first so a late TMDB id does not orphan a title-keyed pin. -- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally. The switch is never silent: the toast names the new playlist, offers Undo, and warns "dub may differ" only when both sides state an audio track as fact. +- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}`, `vod_source_pins` table); lookups pass every alias most-trusted-first so a late TMDB id does not orphan a title-keyed pin. A pin is not decoration: the primary Play action starts from the pinned source, and it outranks everything else in failover ranking. The movie-identity key covers title, year and tmdbId, so TMDB enrichment re-triggers discovery and rebuilds the pin keys. +- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally, and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist, offers Undo, and warns "dub may differ" only when both sides state an audio track as fact. - HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup). No ffprobe — the binary is not bundled. - See `docs/architecture/vod-multi-source.md` diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 2e1e23ece..0c19643b0 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -39,7 +39,7 @@ Every metadata value carries **where it came from**: |---|---|---| | `api` | `get_vod_info` — container, codec, audio, dimensions | plain tag | | `parsed` | regex over the title/filename | tag prefixed `~`, warn colour | -| `probe` | HEAD → reachable + latency | `ok` / `fail` status tag | +| `probe` | HEAD → reachable + latency (retried as a ranged GET when the server answers 405/501, since plenty serve media over GET while refusing HEAD) | `ok` / `fail` status tag | | *absent* | — | **no tag at all** + a `check` chip | Three rules follow, and each is enforced in code rather than by convention: @@ -110,6 +110,16 @@ landed is stored under its title key and prefers a `tmdb:` key afterwards; reading both means the id arriving later does not orphan the pin, and unpinning clears every alias so a stale row cannot resurrect it. +## Short titles + +The trigram tokenizer cannot index tokens under three characters, so a title +like "Up", "It" or "Us" produces an empty `MATCH` expression. Discovery falls +back to a bounded `LIKE` scan for exactly those titles rather than returning +nothing — the two-tier normalized confirmation still runs afterwards, so the +looser query does not admit "Upgrade" for "Up". The scan is slower and cannot +use the title index, which is acceptable because it is only reachable for the +handful of titles FTS structurally cannot serve. + ## Why resolution is lazy The `content` table stores no `container_extension`, and @@ -161,7 +171,18 @@ an N-source movie fails over at most N−1 times and then shows the honest error screen. Returning to an earlier source by hand does not clear the set. A source that cannot even be resolved is marked tried without becoming active, -so it is not re-picked. +and failover **continues to the next candidate** rather than giving up — +production calls `failover()` only once, on the original failure, so stopping at +a dead top-ranked source would strand every healthy one below it. `switchTo` +therefore reports which of two things happened: `unresolvable` (marked tried, +keep going) or `superseded` (something newer owns the screen, stop). Without +that distinction the loop would spin on a superseded target forever, because +only the first outcome marks the candidate tried. + +**A pin is not decoration.** The primary Play action starts from the pinned +source when one is set, and the pin outranks every other signal in the ranking +above. Loading a pin that only decorated its row would mean "make this the main +source" survived a restart as an icon and nothing else. The switch is **always announced** — another source can carry a different dub or cut. The toast offers Undo, and adds a dub warning when