mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(portals): keep a pin readable under every identity of its film
Two defects in the pin/position subsystem, both reported in review. A pin was stored under the movie's most-trusted key alone and its other keys retired. But a movie's identity GROWS: the film keyed `tmdb:438631` today was `title:dune:2021` before enrichment, and reopening it cold asks for the poorer key first. The preference was therefore ignored until enrichment landed — and permanently when enrichment is off or never answers. The decision is now written under every key in `write` (never the yearless form, which every remake shares), one upsert per key plus the leftover retirement in the same transaction. `setVodSourcePin` also reports failure for a pin with no usable key instead of claiming a write it never made. The primary button asked whether the pinned copy's position had loaded by testing presence rather than identity, so re-pinning left it wearing the previous copy's timecode until the new lookup returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e0ebbeafed
commit
45fca81504
16 files changed
+326
-78
No files matched your search
@@ -899,7 +899,7 @@ engine` (restart required) or
|
||||
- **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 the 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. 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 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. 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.
|
||||
- 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`.
|
||||
- Pins are included in playlist backup as the optional `sourcePins` collection, carried under the playlist they point at; `matchKey` survives untouched and only the playlist id is remapped on restore (older archives simply lack the field).
|
||||
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (`reportsPlaybackFailures()`); it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally — but SELECTION is not an attempt: `setActiveSource` only selects, `markPlaying` spends the turn, and `runFailover` retires whatever is on screen before picking, so discovery selecting the route row (or a pin selecting an alternative) before anything plays cannot burn a healthy fallback; 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 (through `playlistDisplayLabel`, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state an audio track as fact.
|
||||
|
||||
@@ -426,11 +426,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
|
||||
},
|
||||
{
|
||||
method: 'dbSetVodSourcePin',
|
||||
args: [vodSourcePin, ['title:dune:']],
|
||||
args: [vodSourcePin, ['title:dune:'], ['title:dune:2021']],
|
||||
channel: 'DB_SET_VOD_SOURCE_PIN',
|
||||
// The aliases to retire ride along, so the write and the retirement
|
||||
// are one transaction rather than two calls that can half-apply.
|
||||
forwardedArgs: [vodSourcePin, ['title:dune:']],
|
||||
// Both key lists ride along, so the write, the extra keys it is also
|
||||
// stored under, and the retirement are one transaction rather than
|
||||
// separate calls that can half-apply.
|
||||
forwardedArgs: [vodSourcePin, ['title:dune:'], ['title:dune:2021']],
|
||||
},
|
||||
{
|
||||
method: 'dbClearVodSourcePin',
|
||||
|
||||
@@ -905,8 +905,17 @@ const electronApi: ElectronBridgeApi = {
|
||||
ipcRenderer.invoke('DB_LIST_VOD_SOURCE_PINS', playlistId),
|
||||
dbClearVodSourcePinsForPlaylist: (playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', playlistId),
|
||||
dbSetVodSourcePin: (pin: VodSourcePin, retireKeys?: string[]) =>
|
||||
ipcRenderer.invoke('DB_SET_VOD_SOURCE_PIN', pin, retireKeys ?? []),
|
||||
dbSetVodSourcePin: (
|
||||
pin: VodSourcePin,
|
||||
retireKeys?: string[],
|
||||
aliasKeys?: string[]
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SET_VOD_SOURCE_PIN',
|
||||
pin,
|
||||
retireKeys ?? [],
|
||||
aliasKeys ?? []
|
||||
),
|
||||
dbClearVodSourcePin: (matchKeys: string[]) =>
|
||||
ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PIN', matchKeys),
|
||||
// Playback Positions
|
||||
|
||||
@@ -193,6 +193,58 @@ describe('vod-source-pin.operations', () => {
|
||||
expect(deleteRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores the pin under every alias it was given', async () => {
|
||||
const { db, insertValues, insertRun, transaction } =
|
||||
createUpsertDbMock();
|
||||
|
||||
await setVodSourcePin(db, pin, [], ['title:the matrix:1999']);
|
||||
|
||||
// A movie's identity grows: the film keyed `tmdb:603` today was
|
||||
// `title:the matrix:1999` before enrichment, and reopening it
|
||||
// cold asks for the poorer key first. One row would answer
|
||||
// nothing until enrichment landed — if it ever did.
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(insertRun).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
insertValues.mock.calls.map(([row]) => row.matchKey)
|
||||
).toEqual(['tmdb:603', 'title:the matrix:1999']);
|
||||
});
|
||||
|
||||
it('writes each key once, however often it is passed', async () => {
|
||||
const { db, insertRun } = createUpsertDbMock();
|
||||
|
||||
await setVodSourcePin(db, pin, [], ['tmdb:603', 'tmdb:603']);
|
||||
|
||||
expect(insertRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never retires an alias it just wrote', async () => {
|
||||
const { db, deleteRun } = createUpsertDbMock();
|
||||
|
||||
// The same key can legitimately appear in both lists — it was an
|
||||
// alias of the PREVIOUS pin and is a write key of this one. Taking
|
||||
// the retirement literally would delete the row just written.
|
||||
await setVodSourcePin(
|
||||
db,
|
||||
pin,
|
||||
['title:the matrix:1999'],
|
||||
['title:the matrix:1999']
|
||||
);
|
||||
|
||||
expect(deleteRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a pin with no usable key rather than reporting success', async () => {
|
||||
const { db, insertRun, transaction } = createUpsertDbMock();
|
||||
|
||||
await expect(
|
||||
setVodSourcePin(db, { ...pin, matchKey: '' })
|
||||
).resolves.toEqual({ success: false });
|
||||
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
expect(insertRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('touches nothing else when there is no alias to retire', async () => {
|
||||
const { db, deleteRun } = createUpsertDbMock();
|
||||
|
||||
|
||||
@@ -72,7 +72,15 @@ export async function listVodSourcePinsForPlaylist(
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the pin and retire its old aliases as ONE unit.
|
||||
* Store the pin under every key that names this film, and retire its old
|
||||
* aliases, as ONE unit.
|
||||
*
|
||||
* `aliasKeys` exist because a movie's identity GROWS: the same film is
|
||||
* `title:dune:2021` before TMDB enrichment lands and `tmdb:438631` after, and
|
||||
* a decision recorded only under the richer key is invisible to the next
|
||||
* reopen that has not been enriched yet. Callers pass every key that names
|
||||
* exactly this one film — never the ambiguous yearless form, which every
|
||||
* remake shares.
|
||||
*
|
||||
* Split across two calls there is no honest answer to a half-failure: report
|
||||
* success and a surviving alias silently wins the next lookup; report failure
|
||||
@@ -82,40 +90,59 @@ export async function listVodSourcePinsForPlaylist(
|
||||
export async function setVodSourcePin(
|
||||
db: AppDatabase,
|
||||
pin: VodSourcePin,
|
||||
retireKeys: string[] = []
|
||||
retireKeys: string[] = [],
|
||||
aliasKeys: string[] = []
|
||||
): Promise<{ success: boolean }> {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const write = [
|
||||
...new Set(
|
||||
[pin.matchKey, ...(aliasKeys ?? [])].filter(
|
||||
(key) => typeof key === 'string' && key !== ''
|
||||
)
|
||||
),
|
||||
].slice(0, MAX_KEYS_PER_LOOKUP);
|
||||
|
||||
// Nothing identifying to store it under. Reporting success here would
|
||||
// promise a preference that was never written.
|
||||
if (write.length === 0) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const retire = (retireKeys ?? [])
|
||||
.filter(
|
||||
(key) =>
|
||||
typeof key === 'string' && key !== '' && key !== pin.matchKey
|
||||
typeof key === 'string' && key !== '' && !write.includes(key)
|
||||
)
|
||||
.slice(0, MAX_KEYS_PER_LOOKUP);
|
||||
|
||||
const insert = db
|
||||
.insert(schema.vodSourcePins)
|
||||
.values({
|
||||
matchKey: pin.matchKey,
|
||||
playlistId: pin.playlistId,
|
||||
contentId: pin.contentId,
|
||||
portalType: pin.portalType,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.vodSourcePins.matchKey,
|
||||
set: {
|
||||
const inserts = write.map((matchKey) =>
|
||||
db
|
||||
.insert(schema.vodSourcePins)
|
||||
.values({
|
||||
matchKey,
|
||||
playlistId: pin.playlistId,
|
||||
contentId: pin.contentId,
|
||||
portalType: pin.portalType,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.vodSourcePins.matchKey,
|
||||
set: {
|
||||
playlistId: pin.playlistId,
|
||||
contentId: pin.contentId,
|
||||
portalType: pin.portalType,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await db.transaction(() => {
|
||||
// `.run()`, not `.execute()`: on better-sqlite3 the latter defers the
|
||||
// write to a promise that never settles inside this synchronous
|
||||
// callback, so the statement would be a silent no-op.
|
||||
insert.run();
|
||||
for (const insert of inserts) {
|
||||
insert.run();
|
||||
}
|
||||
|
||||
if (retire.length > 0) {
|
||||
db.delete(schema.vodSourcePins)
|
||||
|
||||
@@ -27,7 +27,15 @@ handleWorkerRequest(
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_SET_VOD_SOURCE_PIN',
|
||||
(pin: VodSourcePin, retireKeys: string[] = []) => ({ pin, retireKeys })
|
||||
(
|
||||
pin: VodSourcePin,
|
||||
retireKeys: string[] = [],
|
||||
aliasKeys: string[] = []
|
||||
) => ({
|
||||
pin,
|
||||
retireKeys,
|
||||
aliasKeys,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest('DB_CLEAR_VOD_SOURCE_PIN', (matchKeys: string[]) => ({
|
||||
|
||||
@@ -372,8 +372,12 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
|
||||
},
|
||||
{
|
||||
operation: 'DB_SET_VOD_SOURCE_PIN',
|
||||
args: [vodSourcePin, ['title:dune:']],
|
||||
payload: { pin: vodSourcePin, retireKeys: ['title:dune:'] },
|
||||
args: [vodSourcePin, ['title:dune:'], ['title:dune:2021']],
|
||||
payload: {
|
||||
pin: vodSourcePin,
|
||||
retireKeys: ['title:dune:'],
|
||||
aliasKeys: ['title:dune:2021'],
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: 'DB_CLEAR_VOD_SOURCE_PIN',
|
||||
|
||||
@@ -820,8 +820,14 @@ async function executeRequest(
|
||||
const payload = message.payload as {
|
||||
pin: VodSourcePin;
|
||||
retireKeys?: string[];
|
||||
aliasKeys?: string[];
|
||||
};
|
||||
return setVodSourcePin(db, payload.pin, payload.retireKeys ?? []);
|
||||
return setVodSourcePin(
|
||||
db,
|
||||
payload.pin,
|
||||
payload.retireKeys ?? [],
|
||||
payload.aliasKeys ?? []
|
||||
);
|
||||
}
|
||||
|
||||
case 'DB_CLEAR_VOD_SOURCE_PIN': {
|
||||
|
||||
@@ -151,16 +151,26 @@ Three key sets, because reading, writing and deleting are different questions
|
||||
| `write` | `tmdb:` + `title:{base}:{year}` | keys that name exactly ONE film |
|
||||
| `loaded` | the key the pin on screen came from | the only ambiguous row this session may retire |
|
||||
|
||||
A write stores the canonical key and clears the stale ones — both halves
|
||||
matter, and each rules out the other's shortcut:
|
||||
A write stores the decision under **every** key in `write`, and clears whatever
|
||||
stale row is left over. Each half rules out the other's shortcut:
|
||||
|
||||
- Writing only the top key would leave the lower-trust aliases pointing at
|
||||
whatever was pinned before, and a reopen that reads one of those (enrichment
|
||||
has not landed, or its request failed) starts the source the user replaced.
|
||||
- Writing the decision *into* every alias is not the fix either: the yearless
|
||||
form is shared by every remake, so a known-year pin stored there would answer
|
||||
for a different film — pin Dune (2021), open Dune (1984) before its year
|
||||
arrives, and it starts the 2021 source.
|
||||
- Writing only the top key leaves the movie unfindable under its own poorer
|
||||
identity. A pin set while `tmdb:438631` was known is invisible to the next
|
||||
reopen, which starts out with nothing but a title and a year and asks for
|
||||
`title:dune:2021` — so the preference is ignored until enrichment lands, and
|
||||
permanently if enrichment is off or never answers. Storing it under both keys
|
||||
makes it readable at every stage of the same film's identity, and overwriting
|
||||
the poorer key is also what stops it from still naming the source the user
|
||||
just replaced.
|
||||
- Spreading it across *every* alias is not the fix either: the yearless form is
|
||||
shared by every remake, so a known-year pin stored there would answer for a
|
||||
different film — pin Dune (2021), open Dune (1984) before its year arrives,
|
||||
and it starts the 2021 source. That form is deliberately absent from `write`
|
||||
for exactly this reason; it stays readable and unwritten.
|
||||
|
||||
The renderer passes `write[0]` as the pin's own `matchKey` and the rest as
|
||||
`aliasKeys`; `setVodSourcePin` upserts one row per key and retires the leftovers
|
||||
inside the same transaction, so no key list can half-apply.
|
||||
|
||||
A write stores the new key **before** retiring the old rows. The other order
|
||||
destroys the stored preference and can then fail to replace it, leaving nothing
|
||||
@@ -551,12 +561,13 @@ had the losing attempt conclude "no usable pin" and start the route stream over
|
||||
the playback the winning one had just begun. Same distinction as `runFailover`'s,
|
||||
for the same reason.
|
||||
|
||||
A pin is written and its old aliases retired in ONE transaction
|
||||
(`setVodSourcePin(db, pin, retireKeys)`). Split in two there is no honest
|
||||
outcome for a half-failure: a surviving alias is read before the canonical key
|
||||
on the next open, so reporting success starts the source the user just
|
||||
replaced — while reporting failure leaves the canonical row durable and the UI
|
||||
showing a pin that is no longer the stored one.
|
||||
A pin is written under every key naming its film, and its stale aliases retired,
|
||||
in ONE transaction (`setVodSourcePin(db, pin, retireKeys, aliasKeys)`). Split in
|
||||
two there is no honest outcome for a half-failure: a surviving alias is read
|
||||
before the canonical key on the next open, so reporting success starts the
|
||||
source the user just replaced — while reporting failure leaves the canonical row
|
||||
durable and the UI showing a pin that is no longer the stored one. A call with
|
||||
no usable key reports failure rather than claiming a preference it never wrote.
|
||||
|
||||
Pins ride along with playlist backup, under the playlist they point at, as the
|
||||
optional `sourcePins` collection. See
|
||||
|
||||
+74
-10
@@ -18,6 +18,7 @@ import {
|
||||
ALT_TWO,
|
||||
CURRENT_A_ID,
|
||||
MOVIE_A,
|
||||
MOVIE_B,
|
||||
PROBE_OK,
|
||||
createDeferred,
|
||||
resolveWith,
|
||||
@@ -129,9 +130,9 @@ describe('VodMultiSourceHostService — pin persistence', () => {
|
||||
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
|
||||
});
|
||||
|
||||
it('retires its own aliases but never another remake’s', async () => {
|
||||
it('stores itself under its own keys, never under a remake’s', async () => {
|
||||
// With a TMDB id there are two keys naming this film and one shared
|
||||
// with every remake, so the retire set is worth asserting on.
|
||||
// with every remake, so both lists are worth asserting on.
|
||||
await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 });
|
||||
expect(pins.get.mock.calls[0][0]).toEqual([
|
||||
'tmdb:603',
|
||||
@@ -143,20 +144,79 @@ describe('VodMultiSourceHostService — pin persistence', () => {
|
||||
|
||||
expect(pins.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ matchKey: 'tmdb:603' }),
|
||||
expect.any(Array),
|
||||
expect.any(Array)
|
||||
);
|
||||
// The aliases ride along with the write, so the two cannot half-apply.
|
||||
const [, retired] = pins.set.mock.calls[0] as [unknown, string[]];
|
||||
// This film's other key goes, so a reopen before enrichment cannot
|
||||
// read a row still pointing at the source just replaced.
|
||||
expect(retired).toContain('title:the matrix:1999');
|
||||
// Both lists ride along with the write, so none of it can half-apply.
|
||||
const [, retired, aliases] = pins.set.mock.calls[0] as [
|
||||
unknown,
|
||||
string[],
|
||||
string[],
|
||||
];
|
||||
// This film's pre-enrichment key takes the SAME decision instead of
|
||||
// being deleted: a reopen that has only a title and a year still finds
|
||||
// the pin, and finds this source rather than the one just replaced.
|
||||
expect(aliases).toContain('title:the matrix:1999');
|
||||
expect(retired).not.toContain('title:the matrix:1999');
|
||||
// The yearless form is shared by every remake: a Dune (2021) pin must
|
||||
// not delete — or answer for — a row that may be Dune (1984)'s.
|
||||
// not answer for — or delete — a row that may be Dune (1984)'s.
|
||||
expect(aliases).not.toContain('title:the matrix:');
|
||||
expect(retired).not.toContain('title:the matrix:');
|
||||
// And never the row just written.
|
||||
expect(retired).not.toContain('tmdb:603');
|
||||
});
|
||||
|
||||
it('survives a reopen that has not been enriched yet', async () => {
|
||||
// The whole point of the pin is that it outlives the page. Enrichment
|
||||
// is asynchronous and optional, so the movie that owned a `tmdb:` key
|
||||
// when the user pinned it opens again as nothing but a title and a
|
||||
// year — and asks for the pin under THAT identity.
|
||||
const rows = new Map<string, unknown>();
|
||||
pins.set.mockImplementation(
|
||||
async (
|
||||
pin: { matchKey: string },
|
||||
retire: string[] = [],
|
||||
aliases: string[] = []
|
||||
) => {
|
||||
for (const key of [pin.matchKey, ...aliases]) {
|
||||
rows.set(key, { ...pin, matchKey: key });
|
||||
}
|
||||
for (const key of retire) {
|
||||
rows.delete(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
pins.get.mockImplementation(async (keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
if (rows.has(key)) {
|
||||
return rows.get(key);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await loadMovie([ALT_TWO, ALT_THREE], { ...MOVIE_A, tmdbId: 603 });
|
||||
await service.togglePin(ALT_THREE.id);
|
||||
expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true);
|
||||
|
||||
// Away to another film and back. That is what makes this a REOPEN:
|
||||
// the session key is (playlist, content), so a different movie is what
|
||||
// drops the in-memory controller and forces the pin to be re-read from
|
||||
// storage — the same thing reopening the page does.
|
||||
await loadMovie([], MOVIE_B);
|
||||
await loadMovie([ALT_TWO, ALT_THREE], MOVIE_A);
|
||||
|
||||
// MOVIE_A carries no TMDB id, so this lookup asks only for the title
|
||||
// forms. Stored under the enriched key alone the preference would be
|
||||
// invisible here, and Play would quietly start the route source.
|
||||
expect(pins.get).toHaveBeenLastCalledWith([
|
||||
'title:the matrix:1999',
|
||||
'title:the matrix:',
|
||||
]);
|
||||
expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the stored pin when the replacement write fails', async () => {
|
||||
await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 });
|
||||
pins.set.mockResolvedValue(false);
|
||||
@@ -208,8 +268,12 @@ describe('VodMultiSourceHostService — pin persistence', () => {
|
||||
// half-outcome had an honest answer, so there is only one call now.
|
||||
expect(pins.set).toHaveBeenCalledTimes(1);
|
||||
expect(pins.clear).not.toHaveBeenCalled();
|
||||
const [, retired] = pins.set.mock.calls[0] as [unknown, string[]];
|
||||
expect(retired).toContain('title:the matrix:1999');
|
||||
const [, , aliases] = pins.set.mock.calls[0] as [
|
||||
unknown,
|
||||
string[],
|
||||
string[],
|
||||
];
|
||||
expect(aliases).toContain('title:the matrix:1999');
|
||||
});
|
||||
|
||||
it('keeps the pin when clearing it fails', async () => {
|
||||
|
||||
@@ -322,8 +322,10 @@ describe('VodMultiSourceHostService — pinning', () => {
|
||||
|
||||
await service.togglePin(ALT_TWO.id);
|
||||
|
||||
// The aliases to retire travel with the write, in one transaction.
|
||||
expect(pins.set).toHaveBeenCalledWith(pin, []);
|
||||
// Both key lists travel with the write, in one transaction. This film
|
||||
// has no TMDB id, so its title key IS the canonical one and there is
|
||||
// nothing further to store it under or retire.
|
||||
expect(pins.set).toHaveBeenCalledWith(pin, [], []);
|
||||
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
|
||||
|
||||
await service.togglePin(ALT_TWO.id);
|
||||
|
||||
@@ -68,28 +68,32 @@ export async function readPin(
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the pin for `candidate` under the movie's most-trusted key, having
|
||||
* first cleared every alias it could otherwise be found under.
|
||||
* Persist the pin for `candidate` under EVERY key that names exactly this
|
||||
* film, having first cleared any remaining alias it could otherwise be found
|
||||
* under.
|
||||
*
|
||||
* Both halves are load-bearing, and each rules out the other's obvious
|
||||
* shortcut:
|
||||
* Storing it once, under the most-trusted key alone, is not enough: a movie's
|
||||
* identity grows. Pin Dune while `tmdb:438631` is known and the decision goes
|
||||
* only there — then reopen it, and the page starts out with nothing but a
|
||||
* title and a year, so its lookup asks for `title:dune:2021` and finds
|
||||
* nothing. The pin is ignored until enrichment lands, and if enrichment is off
|
||||
* or never answers, permanently. Writing both keys makes the preference
|
||||
* readable at every stage of the same film's identity.
|
||||
*
|
||||
* - Writing ONLY the top key leaves the lower-trust aliases pointing at
|
||||
* whatever was pinned before, and a reopen that reads one of those —
|
||||
* because enrichment has not landed yet — starts the source the user just
|
||||
* replaced. Hence the clear.
|
||||
* - Writing the decision INTO every alias is not the fix either: the yearless
|
||||
* `title:{base}:` form is shared by every remake, so a known-year pin stored
|
||||
* there would answer for a different film — pin Dune (2021), open Dune
|
||||
* (1984) before its year arrives, and it would start the 2021 source. That
|
||||
* alias stays readable, for genuinely pre-enrichment pins, and unwritten.
|
||||
* `keys.write` is exactly the right set to spread across, because it holds
|
||||
* only keys that name ONE film. The yearless `title:{base}:` form is
|
||||
* deliberately not among them — every remake shares it, so a known-year pin
|
||||
* stored there would answer for a different film: pin Dune (2021), open Dune
|
||||
* (1984) before its year arrives, and it would start the 2021 source. That
|
||||
* alias stays readable, for genuinely pre-enrichment pins, and unwritten.
|
||||
*/
|
||||
export async function writePin(
|
||||
pins: Pick<VodSourcePinService, 'set' | 'clear'>,
|
||||
keys: PinKeySets,
|
||||
candidate: VodSourceCandidate
|
||||
): Promise<boolean> {
|
||||
const matchKey = keys.write[0] ?? buildVodSourceMatchKey(candidate);
|
||||
const [primary, ...aliases] = keys.write;
|
||||
const matchKey = primary ?? buildVodSourceMatchKey(candidate);
|
||||
if (!matchKey) {
|
||||
return false;
|
||||
}
|
||||
@@ -100,6 +104,7 @@ export async function writePin(
|
||||
// source the user just replaced — while reporting failure leaves the
|
||||
// canonical row durable and the UI showing a pin that is no longer the
|
||||
// stored one.
|
||||
const written = new Set([matchKey, ...aliases]);
|
||||
return pins.set(
|
||||
{
|
||||
matchKey,
|
||||
@@ -107,7 +112,10 @@ export async function writePin(
|
||||
contentId: candidate.contentId,
|
||||
portalType: candidate.portalType,
|
||||
},
|
||||
retirablePinKeys(keys).filter((key) => key !== matchKey)
|
||||
// Whatever is left is the ambiguous row this session actually read —
|
||||
// the only one an unpin may take, and one this write must not keep.
|
||||
retirablePinKeys(keys).filter((key) => !written.has(key)),
|
||||
aliases
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,43 @@ describe('createPrimaryActionPosition', () => {
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops describing the old pin the moment a new one is chosen', async () => {
|
||||
const load = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(position(4200))
|
||||
.mockImplementationOnce(
|
||||
() => new Promise<PlaybackPositionData | null>(() => undefined)
|
||||
);
|
||||
|
||||
const { api, sourcesSignal } = setup(
|
||||
[source(), ALT],
|
||||
position(2538),
|
||||
load
|
||||
);
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
TestBed.tick();
|
||||
expect(api.position()?.positionSeconds).toBe(4200);
|
||||
|
||||
sourcesSignal.set([
|
||||
source(),
|
||||
source({
|
||||
id: 'playlist-3:xtream:77',
|
||||
playlistId: 'playlist-3',
|
||||
contentId: 77,
|
||||
isPinned: true,
|
||||
isActive: false,
|
||||
}),
|
||||
]);
|
||||
TestBed.tick();
|
||||
|
||||
// A row IS loaded — the one belonging to the copy that was pinned a
|
||||
// moment ago. Treating "loaded something" as "loaded this" makes the
|
||||
// button offer the old copy's timecode for the new one.
|
||||
expect(api.position()).toBeNull();
|
||||
expect(api.hasPosition()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a lookup the pin outran', async () => {
|
||||
let resolveFirst: (value: PlaybackPositionData | null) => void = () => {
|
||||
/* replaced below */
|
||||
|
||||
@@ -113,7 +113,11 @@ export function createPrimaryActionPosition(
|
||||
// button is not going to start — and a click across this window looks
|
||||
// the pinned position up separately and begins somewhere else. Say
|
||||
// nothing until the answer is in; empty beats wrong.
|
||||
if (!pinnedLoadedFor()) {
|
||||
//
|
||||
// Compared by identity rather than mere presence: re-pinning leaves
|
||||
// the PREVIOUS copy's row loaded, and "something was loaded once" would
|
||||
// let the button wear that copy's timecode until the new lookup lands.
|
||||
if (pinnedLoadedFor() !== pinned.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,10 +87,19 @@ export class VodSourcePinService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the pin, retiring `retireKeys` in the same transaction — a
|
||||
* half-applied change has no honest outcome to report.
|
||||
* Store the pin, also under `aliasKeys`, retiring `retireKeys` — all in
|
||||
* the same transaction, because a half-applied change has no honest
|
||||
* outcome to report.
|
||||
*
|
||||
* @param aliasKeys further keys naming exactly this film. A movie's
|
||||
* identity grows as enrichment lands, and a pin recorded only under the
|
||||
* richest key is invisible to the next reopen that has not been enriched.
|
||||
*/
|
||||
async set(pin: VodSourcePin, retireKeys: string[] = []): Promise<boolean> {
|
||||
async set(
|
||||
pin: VodSourcePin,
|
||||
retireKeys: string[] = [],
|
||||
aliasKeys: string[] = []
|
||||
): Promise<boolean> {
|
||||
if (!this.isAvailable) {
|
||||
return false;
|
||||
}
|
||||
@@ -98,7 +107,8 @@ export class VodSourcePinService {
|
||||
try {
|
||||
const result = await window.electron.dbSetVodSourcePin(
|
||||
pin,
|
||||
retireKeys
|
||||
retireKeys,
|
||||
aliasKeys
|
||||
);
|
||||
return result?.success === true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -913,10 +913,15 @@ export interface ElectronBridgeApi {
|
||||
dbClearVodSourcePinsForPlaylist: (
|
||||
playlistId: string
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
/** `retireKeys` are removed in the SAME transaction as the write. */
|
||||
/**
|
||||
* `aliasKeys` receive the same pin, and `retireKeys` are removed, all in
|
||||
* the SAME transaction as the write. Aliases keep a pin readable under the
|
||||
* poorer key forms the movie had before enrichment.
|
||||
*/
|
||||
dbSetVodSourcePin: (
|
||||
pin: VodSourcePin,
|
||||
retireKeys?: string[]
|
||||
retireKeys?: string[],
|
||||
aliasKeys?: string[]
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
dbClearVodSourcePin: (
|
||||
matchKeys: string[]
|
||||
|
||||
Reference in new issue
Block a user