mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -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
@@ -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': {
|
||||
|
||||
Reference in new issue
Block a user