fix(playlist): keep cleared optional fields cleared on desktop

Clearing an optional playlist field (User-Agent, Referrer, Origin,
credentials, EPG URL lists, file path) did not persist in Electron: the
codec maps an empty value to `undefined`, `ON CONFLICT DO UPDATE` skips
undefined keys, and `parseAppPlaylist` prefers the stale column over the
updated payload, so the old value came back on the next read.

`playlistConflictUpdate` now sets every optional column the document
leaves empty to NULL, so both desktop upserts (`DB_UPSERT_APP_PLAYLIST`
and `DB_UPSERT_APP_PLAYLISTS`) replace the stored row. The cleared-columns
list moves from the web backend's playlist repository into the shared
conflict module, and the server now uses the shared version.
`date_created`, `name`, `type`, `autoRefresh`, `payload` and `last_usage`
are never cleared.

Every caller sends a whole document: `PlaylistsService` reads, patches
and writes the full row; refresh merges spread the current row first;
backup restore merges the existing playlist and clears fields on
purpose. The legacy migration and stub-row inserts do not use this
update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 07:37:23 +02:00
1 parent b88a1c81e5
commit 4d20ed2e5f
6 files changed
+244 -60

No files matched your search

@@ -0,0 +1,80 @@
import { SQL } from 'drizzle-orm';
import { buildPlaylistRow } from './app-playlist-codec';
import { playlistConflictUpdate } from './app-playlist-conflict';
import type { NewPlaylist } from './schema';
function rowOf(document: Record<string, unknown>): NewPlaylist {
const row = buildPlaylistRow(document);
if (!row) {
throw new Error('Expected the document to produce a row');
}
return row;
}
/** Every optional column of a document that only has an id, title and URL. */
const CLEARED_COLUMNS = {
serverUrl: null,
username: null,
password: null,
lastUpdated: null,
userAgent: null,
origin: null,
referrer: null,
filePath: null,
epgUrls: null,
detectedEpgUrls: null,
manualEpgUrls: null,
disabledEpgUrls: null,
macAddress: null,
portalUrl: null,
count: null,
importDate: null,
updateDate: null,
position: null,
favorites: null,
recentlyViewed: null,
};
describe('playlistConflictUpdate', () => {
const document = {
_id: 'm3u-1',
title: 'Family',
url: 'https://example.test/list.m3u',
userAgent: '',
referrer: ' ',
};
it('sets every optional column the document leaves empty to NULL', () => {
const row = rowOf(document);
expect(playlistConflictUpdate(row, document)).toMatchObject({
...CLEARED_COLUMNS,
url: 'https://example.test/list.m3u',
});
});
it('never clears the creation date or the columns every row carries', () => {
const row = rowOf(document);
const update = playlistConflictUpdate(row, document);
expect(update).not.toHaveProperty('dateCreated');
expect(update).toMatchObject({
id: 'm3u-1',
name: 'Family',
type: 'm3u-url',
autoRefresh: false,
lastUsage: row.lastUsage,
});
// A clockless document keeps a stored panel clock through SQL.
expect(update.payload).toBeInstanceOf(SQL);
});
it('clears the same columns when the document carries its own clock', () => {
const withClock = { ...document, serverTimezone: 'UTC' };
const row = rowOf(withClock);
const update = playlistConflictUpdate(row, withClock);
expect(update).toMatchObject(CLEARED_COLUMNS);
expect(update.payload).toBe(row.payload);
});
});
@@ -14,22 +14,70 @@ export type PlaylistConflictUpdate = {
const stored = schema.playlists;
/**
* The `DO UPDATE` half of an app-playlist upsert. A full upsert is built
* from a snapshot the caller read earlier; when that snapshot carries no
* panel clock while the stored row does — `setPlaylistServerTimezone`
* landed in between — the stored clock is carried over as long as the row
* still points at the same panel, so a favorites, recent-items or metadata
* write cannot hand a clockless payload back to the past. A snapshot that
* carries its own clock, or moves the source, wins as is. Nested CASE, not
* AND: SQLite may reorder AND terms, and the json_* readers raise on a
* malformed payload unless json_valid ran first.
* Columns a whole-document upsert must rewrite even when the document no
* longer has a value: the codec maps empty values to `undefined`, which an
* `ON CONFLICT DO UPDATE` skips, so a cleared field would keep its old
* column value and `parseAppPlaylist` would bring it back. `dateCreated`
* belongs to the first insert, and `name`, `type`, `autoRefresh`,
* `payload` and `lastUsage` always carry a value.
*/
const REPLACEABLE_COLUMNS = [
'serverUrl',
'username',
'password',
'lastUpdated',
'userAgent',
'origin',
'referrer',
'filePath',
'epgUrls',
'detectedEpgUrls',
'manualEpgUrls',
'disabledEpgUrls',
'macAddress',
'url',
'portalUrl',
'count',
'importDate',
'updateDate',
'position',
'favorites',
'recentlyViewed',
] as const satisfies readonly (keyof schema.NewPlaylist)[];
function withClearedColumns(row: schema.NewPlaylist): schema.NewPlaylist {
const cleared: Partial<Record<(typeof REPLACEABLE_COLUMNS)[number], null>> =
{};
for (const column of REPLACEABLE_COLUMNS) {
if (row[column] === undefined) {
cleared[column] = null;
}
}
return { ...row, ...cleared };
}
/**
* The `DO UPDATE` half of an app-playlist upsert. The document replaces
* the stored one, so every optional column it leaves empty is set to NULL
* rather than skipped (see REPLACEABLE_COLUMNS).
*
* A full upsert is built from a snapshot the caller read earlier; when
* that snapshot carries no panel clock while the stored row does —
* `setPlaylistServerTimezone` landed in between — the stored clock is
* carried over as long as the row still points at the same panel, so a
* favorites, recent-items or metadata write cannot hand a clockless
* payload back to the past. A snapshot that carries its own clock, or
* moves the source, wins as is. Nested CASE, not AND: SQLite may reorder
* AND terms, and the json_* readers raise on a malformed payload unless
* json_valid ran first.
*/
export function playlistConflictUpdate(
row: schema.NewPlaylist,
playlist: Record<string, unknown>
): PlaylistConflictUpdate {
const replacement = withClearedColumns(row);
if (readString(playlist['serverTimezone'])) {
return row;
return replacement;
}
const excludedPayload = sql.raw(`excluded.${stored.payload.name}`);
const payload = sql`CASE
@@ -48,7 +96,7 @@ export function playlistConflictUpdate(
END
ELSE ${excludedPayload}
END`;
return { ...row, payload };
return { ...replacement, payload };
}
/**