mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user