mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(portals): let an exact title keep a number that is part of its name
Both match tiers weighed the same year, taken from a trailing four-digit tail or a bracketed tag. On the exact tier that rejects the very copy it was meant to confirm: reaching it means both titles are the SAME string, so the trailing digits belong to both, and comparing them against a release year out of metadata makes "Blade Runner 2049" disagree with its own stated 2017 — the genuine alternative disappears at the moment enrichment lands, which is when the user has most reason to expect it. The exact tier now reads the bracketed form only. Brackets are never part of a name, so "Dune (1984)" is still rejected against 2021. The base tier is unchanged: it has just stripped a trailing year, and that year is the only thing separating "Dune 1984" from "Dune 2021". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
257a53d9e7
commit
354df3d851
4 files changed
+55
-3
No files matched your search
@@ -897,7 +897,7 @@ 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 CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The chip counts alternative **streams**; the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal.
|
||||
- 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; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches an ASCII token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an *exact* match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row. A non-ASCII token cannot be folded by `LOWER()` (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so `caseInsensitiveGlobPattern` folds the case in JS and emits one `[lowerUpper]` class per character — returning `null`, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (`ß`→`SS`). The movie's own year comes from `releaseTagYear` (bracketed or trailing only), never `extractYear`: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. 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 scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches an ASCII token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an *exact* match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded by `LOWER()` (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so `caseInsensitiveGlobPattern` folds the case in JS and emits one `[lowerUpper]` class per character — returning `null`, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (`ß`→`SS`). The movie's own year comes from `releaseTagYear` (bracketed or trailing only), never `extractYear`: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. 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. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password.
|
||||
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — three key sets (`pinKeysFor`): `lookup` passes every alias most-trusted-first, `write` holds only keys naming exactly one film, and `loaded` records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. A write stores the decision under **every** key in `write` (`setVodSourcePin(db, pin, retireKeys, aliasKeys)`: one upsert per key plus the leftover retirement, in a single transaction), because a movie's identity grows — recorded only under the enriched `tmdb:` key, a pin is invisible to the next reopen, which starts out with just a title and a year, and stays invisible for good if enrichment is off or never answers. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. The primary button says nothing at all until that row is in, and "is it in" is answered by comparing the loaded pin **id** rather than mere presence, or re-pinning would leave the button wearing the previous copy's timecode. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
|
||||
- Claims in the present tense (the "Playing from" caption and the source row's `Playing` badge) are gated on `VodDetailsRouteComponent.playbackLive`, never on `isActive` — discovery marks a source active before anything plays and it stays active after the player closes. Inline that means a `timeupdate` has arrived (`inlinePlayback()` is only the request to play); external it means the session is past `launching`. A merely selected row reads `Current`.
|
||||
|
||||
@@ -86,6 +86,36 @@ describe('title-sources.operations — confirmation and scoping', () => {
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps an identical title whose tail is part of its name', async () => {
|
||||
const { db } = createDbMock([
|
||||
{ ...duneRow, title: 'Blade Runner 2049' },
|
||||
]);
|
||||
|
||||
// Enrichment supplies the real release year, 2017, while the title
|
||||
// ends in a four-digit number that is part of the NAME. Reading it
|
||||
// as a release tag makes the years "disagree" and the genuine copy
|
||||
// disappears the moment metadata lands — the one point at which the
|
||||
// user has most reason to expect it.
|
||||
const matches = await findTitleSources(db, {
|
||||
title: 'Blade Runner 2049',
|
||||
year: 2017,
|
||||
});
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].matchConfidence).toBe('exact');
|
||||
});
|
||||
|
||||
it('still rejects a bracketed remake of an otherwise identical title', async () => {
|
||||
const { db } = createDbMock([{ ...duneRow, title: 'Dune (1984)' }]);
|
||||
|
||||
// The relaxation above must not reach a BRACKETED year: brackets
|
||||
// are unambiguously a tag, never part of the name, so a stated
|
||||
// disagreement still means a different film.
|
||||
await expect(
|
||||
findTitleSources(db, { title: 'Dune', year: 2021 })
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a base-tier match when one side has no known year', async () => {
|
||||
const { db } = createDbMock([duneRow]);
|
||||
|
||||
|
||||
@@ -301,17 +301,29 @@ export async function findTitleSources(
|
||||
}
|
||||
|
||||
const rowKeys = normalizeTitleKeys(row.title);
|
||||
const rowYear = rowKeys.trailingYear ?? bracketedYear(row.title);
|
||||
const rowBracketedYear = bracketedYear(row.title);
|
||||
const rowYear = rowKeys.trailingYear ?? rowBracketedYear;
|
||||
// Two films share a title far too often for this to be optional, and
|
||||
// it applies to BOTH tiers: "Dune (1984)" normalizes to exactly the
|
||||
// same string as "Dune", so an exact match says nothing about which
|
||||
// film it is. When each side states a year and they disagree, it is
|
||||
// not the same movie. An unknown year still never blocks.
|
||||
const yearsAgree = titleYearsCompatible(rowYear, wantedYear);
|
||||
// The exact tier reads only the BRACKETED year, though. Reaching it
|
||||
// means both titles are the same string, so any trailing four digits
|
||||
// belong to both — and comparing a number that is part of the NAME
|
||||
// against a release year from metadata rejects the very copy it was
|
||||
// meant to confirm: "Blade Runner 2049" against a stated 2017 vanishes
|
||||
// the moment enrichment lands. Brackets are never part of a name, so
|
||||
// that form still decides.
|
||||
const exactYearsAgree = titleYearsCompatible(
|
||||
rowBracketedYear,
|
||||
wantedYear
|
||||
);
|
||||
const exactMatch =
|
||||
rowKeys.exact !== '' &&
|
||||
rowKeys.exact === wanted.exact &&
|
||||
yearsAgree;
|
||||
exactYearsAgree;
|
||||
const baseMatch =
|
||||
!exactMatch && rowKeys.base === wanted.base && yearsAgree;
|
||||
|
||||
|
||||
@@ -285,6 +285,16 @@ year, ranked *above* every fuzzy match. Discovery reads a bracketed year out of
|
||||
the raw title first, and when both sides state a year and they disagree, it is
|
||||
not the same movie. An unknown year still never blocks.
|
||||
|
||||
The two tiers read different years, though. The **base** tier accepts either
|
||||
form, bracketed or trailing, because it has just stripped a trailing year and
|
||||
that year is the only thing standing between "Dune 1984" and "Dune 2021". The
|
||||
**exact** tier reads the bracketed form ONLY: reaching it means both titles are
|
||||
the same string, so a trailing four digits belong to both, and weighing a number
|
||||
that is part of the NAME against a release year out of metadata rejects the very
|
||||
copy it was meant to confirm — "Blade Runner 2049" against a stated 2017
|
||||
disappears the moment enrichment lands. Brackets are never part of a name, so
|
||||
"Dune (1984)" is still rejected against 2021 on the exact tier.
|
||||
|
||||
Which is why the movie's OWN year is read with `releaseTagYear`, not
|
||||
`extractYear`. The latter takes a year from anywhere in the title, which is the
|
||||
right answer where a year is only a search hint that scoring will confirm — and
|
||||
|
||||
Reference in new issue
Block a user