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:
4grayandClaude Opus 5 committed 2026-07-29 19:32:14 +02:00
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': {