mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
fix(xtream): persist the panel clock with one conditional UPDATE
Review follow-up (Codex): `transformPlaylistMeta` reads the row and then upserts it whole, while the Xtream edit dialog saves through `DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue and the database worker interleaves requests — an edit landing between that read and the upsert was silently undone. Persistence now goes through `IXtreamDataSource.rememberServerTimezone`: - Electron: new `DB_SET_PLAYLIST_SERVER_TIMEZONE` worker op — one UPDATE that `json_set`s the payload only while the row still points at the request's connection and does not already carry the value; a malformed payload is never rewritten (CASE, not AND, so json_extract cannot run before json_valid). Wired through the worker types, main handler, preload, bridge interface, both IPC contract tables and `DatabaseService.setXtreamPlaylistServerTimezone`. - PWA: `transformPlaylistMeta`, whose read and write share one IndexedDB readwrite cursor transaction, plus the localStorage copy. The store no longer injects `PlaylistsService`; it offers the resolved clock to the data source and keeps only its in-memory guards. Real-SQLite coverage for the op (fresh / same / moved / NULL / malformed / missing rows), delegation specs for both data sources, docs updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
5953e60d9a
commit
212d6ed785
21 files changed
+467
-170
No files matched your search
@@ -331,10 +331,14 @@ timezone (`server_info.timezone`), never the viewer's (issue #1562).
|
||||
`resolveXtreamServerTimezone()` (`libs/shared/interfaces`, an ICU-resolvable
|
||||
name, else a `UTC±HH:MM` derived from the `time_now`/`timestamp_now` clock
|
||||
pair) and persists it on the playlist row through
|
||||
`PlaylistsService.transformPlaylistMeta`, because the Favorites / Recent
|
||||
resolver reads the STORED row, not the store; `DB_GET_PLAYLIST` projects it
|
||||
back from the row payload, and a server URL change drops it until the next
|
||||
account-info check. The same value converts timestamp-less EPG
|
||||
`IXtreamDataSource.rememberServerTimezone` — Electron: one conditional
|
||||
`json_set` UPDATE (`DB_SET_PLAYLIST_SERVER_TIMEZONE`) guarded by the row's
|
||||
current connection; PWA: `PlaylistsService.transformPlaylistMeta` — because
|
||||
the Favorites / Recent resolver reads the STORED row, not the store, and the
|
||||
worker interleaves requests, so no read may precede the write.
|
||||
`DB_GET_PLAYLIST` projects it back from the row payload, and a server URL
|
||||
change drops it until the next account-info check. The same value converts
|
||||
timestamp-less EPG
|
||||
`start`/`end` strings. Contract:
|
||||
`docs/architecture/xtream-portal-compatibility.md` ("Start time is the
|
||||
panel's clock, not the viewer's").
|
||||
|
||||
@@ -813,10 +813,14 @@ timezone (`server_info.timezone`), never the viewer's (issue #1562).
|
||||
`resolveXtreamServerTimezone()` (`libs/shared/interfaces`, an ICU-resolvable
|
||||
name, else a `UTC±HH:MM` derived from the `time_now`/`timestamp_now` clock
|
||||
pair) and persists it on the playlist row through
|
||||
`PlaylistsService.transformPlaylistMeta`, because the Favorites / Recent
|
||||
resolver reads the STORED row, not the store; `DB_GET_PLAYLIST` projects it
|
||||
back from the row payload, and a server URL change drops it until the next
|
||||
account-info check. The same value converts timestamp-less EPG
|
||||
`IXtreamDataSource.rememberServerTimezone` — Electron: one conditional
|
||||
`json_set` UPDATE (`DB_SET_PLAYLIST_SERVER_TIMEZONE`) guarded by the row's
|
||||
current connection; PWA: `PlaylistsService.transformPlaylistMeta` — because
|
||||
the Favorites / Recent resolver reads the STORED row, not the store, and the
|
||||
worker interleaves requests, so no read may precede the write.
|
||||
`DB_GET_PLAYLIST` projects it back from the row payload, and a server URL
|
||||
change drops it until the next account-info check. The same value converts
|
||||
timestamp-less EPG
|
||||
`start`/`end` strings. Contract:
|
||||
`docs/architecture/xtream-portal-compatibility.md` ("Start time is the
|
||||
panel's clock, not the viewer's").
|
||||
|
||||
@@ -130,6 +130,20 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
|
||||
channel: 'DB_UPDATE_PLAYLIST',
|
||||
forwardedArgs: [playlistId, playlistUpdates],
|
||||
},
|
||||
{
|
||||
method: 'dbSetPlaylistServerTimezone',
|
||||
args: [
|
||||
playlistId,
|
||||
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
|
||||
'Europe/London',
|
||||
],
|
||||
channel: 'DB_SET_PLAYLIST_SERVER_TIMEZONE',
|
||||
forwardedArgs: [
|
||||
playlistId,
|
||||
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
|
||||
'Europe/London',
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'dbDeletePlaylist',
|
||||
args: [playlistId, operationId],
|
||||
|
||||
@@ -749,6 +749,17 @@ const electronApi: ElectronBridgeApi = {
|
||||
ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist),
|
||||
dbGetPlaylist: (playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId),
|
||||
dbSetPlaylistServerTimezone: (
|
||||
playlistId: string,
|
||||
connection: { serverUrl: string; username: string; password: string },
|
||||
serverTimezone: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SET_PLAYLIST_SERVER_TIMEZONE',
|
||||
playlistId,
|
||||
connection,
|
||||
serverTimezone
|
||||
),
|
||||
dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) =>
|
||||
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist),
|
||||
dbMigrateAppPlaylists: (playlists: Playlist[]) =>
|
||||
|
||||
@@ -43,7 +43,7 @@ function runPlaylistUpdateScenario(scenario: string): unknown {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const { drizzle } = await import('drizzle-orm/better-sqlite3');
|
||||
const schema = await import('@iptvnator/shared/database/schema');
|
||||
const { updatePlaylist } = await import(${JSON.stringify(operationsUrl)});
|
||||
const { updatePlaylist, setPlaylistServerTimezone } = await import(${JSON.stringify(operationsUrl)});
|
||||
const { __databaseConnectionTestHooks } = await import(${JSON.stringify(connectionUrl)});
|
||||
const sqlite = new Database(':memory:');
|
||||
const statements = [
|
||||
@@ -221,6 +221,58 @@ describe('playlist.operations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('records the learned panel timezone with one conditional UPDATE guarded by the row connection (issue #1562)', () => {
|
||||
const result = runPlaylistUpdateScenario(`
|
||||
const conn = { serverUrl: 'http://panel.example', username: 'u', password: 'p' };
|
||||
const seed = (id, serverUrl, payload) => sqlite.prepare(
|
||||
'INSERT INTO playlists (id, name, serverUrl, username, password, type, payload) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, id, serverUrl, 'u', 'p', 'xtream', payload);
|
||||
seed('fresh', conn.serverUrl, JSON.stringify({ _id: 'fresh', favorites: ['1'] }));
|
||||
seed('same', conn.serverUrl, JSON.stringify({ _id: 'same', serverTimezone: 'Europe/London' }));
|
||||
seed('moved', 'http://other.example', JSON.stringify({ _id: 'moved' }));
|
||||
seed('legacy', conn.serverUrl, null);
|
||||
seed('broken', conn.serverUrl, 'not json');
|
||||
const results = {
|
||||
fresh: await setPlaylistServerTimezone(db, 'fresh', conn, 'Europe/London'),
|
||||
same: await setPlaylistServerTimezone(db, 'same', conn, 'Europe/London'),
|
||||
moved: await setPlaylistServerTimezone(db, 'moved', conn, 'Europe/London'),
|
||||
legacy: await setPlaylistServerTimezone(db, 'legacy', conn, 'UTC+03:00'),
|
||||
broken: await setPlaylistServerTimezone(db, 'broken', conn, 'Europe/London'),
|
||||
missing: await setPlaylistServerTimezone(db, 'missing', conn, 'Europe/London'),
|
||||
};
|
||||
const rows = Object.fromEntries(
|
||||
sqlite.prepare('SELECT id, payload FROM playlists').all().map((row) => [row.id, row.payload])
|
||||
);
|
||||
process.stdout.write(JSON.stringify({ results, rows }));
|
||||
`) as {
|
||||
results: Record<string, { updated: boolean }>;
|
||||
rows: Record<string, string | null>;
|
||||
};
|
||||
|
||||
expect(result.results).toEqual({
|
||||
fresh: { updated: true },
|
||||
same: { updated: false },
|
||||
moved: { updated: false },
|
||||
legacy: { updated: true },
|
||||
broken: { updated: false },
|
||||
missing: { updated: false },
|
||||
});
|
||||
expect(result.rows).toEqual({
|
||||
fresh: JSON.stringify({
|
||||
_id: 'fresh',
|
||||
favorites: ['1'],
|
||||
serverTimezone: 'Europe/London',
|
||||
}),
|
||||
same: JSON.stringify({
|
||||
_id: 'same',
|
||||
serverTimezone: 'Europe/London',
|
||||
}),
|
||||
moved: JSON.stringify({ _id: 'moved' }),
|
||||
legacy: JSON.stringify({ serverTimezone: 'UTC+03:00' }),
|
||||
broken: 'not json',
|
||||
});
|
||||
});
|
||||
|
||||
it('loads app playlist metadata without selecting the large payload column', async () => {
|
||||
const from = jest.fn().mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -629,6 +629,55 @@ function serverTimezoneInvalidation(nextServerUrl: string) {
|
||||
END`;
|
||||
}
|
||||
|
||||
export interface PlaylistConnectionIdentity {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the panel clock a successful account-info check learned
|
||||
* (`serverTimezone`, payload-only) as ONE conditional UPDATE: the row must
|
||||
* still point at the panel the answer came from (`DB_UPDATE_PLAYLIST` may
|
||||
* have moved it meanwhile), a payload already carrying the value is left
|
||||
* untouched, and a malformed payload is never rewritten. No read precedes
|
||||
* the write, so it can neither hand a concurrent upsert's newer payload
|
||||
* back to the past nor undo an edit that landed in between (issue #1562).
|
||||
*/
|
||||
export async function setPlaylistServerTimezone(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
connection: PlaylistConnectionIdentity,
|
||||
serverTimezone: string
|
||||
): Promise<{ updated: boolean }> {
|
||||
const result = await db
|
||||
.update(schema.playlists)
|
||||
.set({
|
||||
payload: sql`CASE
|
||||
WHEN ${schema.playlists.payload} IS NULL
|
||||
THEN json_object('serverTimezone', ${serverTimezone})
|
||||
ELSE json_set(${schema.playlists.payload}, '$.serverTimezone', ${serverTimezone})
|
||||
END`,
|
||||
})
|
||||
.where(
|
||||
// CASE, not AND: SQLite may reorder AND terms, and json_extract
|
||||
// raises on a malformed payload unless json_valid ran first.
|
||||
sql`${schema.playlists.id} = ${playlistId}
|
||||
AND ${schema.playlists.serverUrl} IS ${connection.serverUrl}
|
||||
AND ${schema.playlists.username} IS ${connection.username}
|
||||
AND ${schema.playlists.password} IS ${connection.password}
|
||||
AND CASE
|
||||
WHEN ${schema.playlists.payload} IS NULL THEN 1
|
||||
WHEN json_valid(${schema.playlists.payload})
|
||||
THEN json_extract(${schema.playlists.payload}, '$.serverTimezone') IS NOT ${serverTimezone}
|
||||
ELSE 0
|
||||
END`
|
||||
)
|
||||
.run();
|
||||
|
||||
return { updated: result.changes > 0 };
|
||||
}
|
||||
|
||||
interface PlaylistDeletionCollection {
|
||||
readonly categoryIds: number[];
|
||||
/** Content rows per category, the unit the delete is batched by. */
|
||||
|
||||
@@ -16,7 +16,10 @@ handleWorkerRequest(
|
||||
'DB_MIGRATE_APP_PLAYLISTS',
|
||||
(playlists: Record<string, unknown>[]) => ({ playlists })
|
||||
);
|
||||
handleWorkerRequest('DB_CREATE_PLAYLIST', (playlist: Record<string, unknown>) => playlist);
|
||||
handleWorkerRequest(
|
||||
'DB_CREATE_PLAYLIST',
|
||||
(playlist: Record<string, unknown>) => playlist
|
||||
);
|
||||
handleWorkerRequest(
|
||||
'DB_UPSERT_APP_PLAYLIST',
|
||||
(playlist: Record<string, unknown>) => playlist
|
||||
@@ -27,11 +30,26 @@ handleWorkerRequest(
|
||||
);
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLISTS', () => ({}));
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLIST_METAS', () => ({}));
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({ playlistId }));
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', (playlistId: string) => ({
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({
|
||||
playlistId,
|
||||
}));
|
||||
handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({ playlistId }));
|
||||
handleWorkerRequest(
|
||||
'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS',
|
||||
(playlistId: string) => ({
|
||||
playlistId,
|
||||
})
|
||||
);
|
||||
handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({
|
||||
playlistId,
|
||||
}));
|
||||
handleWorkerRequest(
|
||||
'DB_SET_PLAYLIST_SERVER_TIMEZONE',
|
||||
(
|
||||
playlistId: string,
|
||||
connection: { serverUrl: string; username: string; password: string },
|
||||
serverTimezone: string
|
||||
) => ({ playlistId, connection, serverTimezone })
|
||||
);
|
||||
handleWorkerRequest(
|
||||
'DB_UPDATE_PLAYLIST',
|
||||
(
|
||||
@@ -56,20 +74,12 @@ handleWorkerRequest('DB_SET_APP_STATE', (key: string, value: string) => ({
|
||||
|
||||
ipcMain.handle(
|
||||
'DB_DELETE_PLAYLIST',
|
||||
async (
|
||||
event,
|
||||
playlistId: string,
|
||||
operationId?: string
|
||||
) => {
|
||||
async (event, playlistId: string, operationId?: string) => {
|
||||
try {
|
||||
return await requestWorkerWithEvents(
|
||||
event,
|
||||
'DB_DELETE_PLAYLIST',
|
||||
{
|
||||
playlistId,
|
||||
operationId,
|
||||
}
|
||||
);
|
||||
return await requestWorkerWithEvents(event, 'DB_DELETE_PLAYLIST', {
|
||||
playlistId,
|
||||
operationId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error handling DB_DELETE_PLAYLIST:', error);
|
||||
throw error;
|
||||
|
||||
@@ -41,7 +41,9 @@ const playbackBatchItems = [
|
||||
durationSeconds: 1200,
|
||||
},
|
||||
];
|
||||
const playbackClearBatchItems = [{ contentXtreamId: 42, contentType: 'episode' }];
|
||||
const playbackClearBatchItems = [
|
||||
{ contentXtreamId: 42, contentType: 'episode' },
|
||||
];
|
||||
const tmdbCacheEntry = {
|
||||
mediaType: 'movie',
|
||||
lookupKey: 'id:603',
|
||||
@@ -113,6 +115,23 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
|
||||
args: [playlistId, playlistUpdates],
|
||||
payload: { playlistId, updates: playlistUpdates },
|
||||
},
|
||||
{
|
||||
operation: 'DB_SET_PLAYLIST_SERVER_TIMEZONE',
|
||||
args: [
|
||||
playlistId,
|
||||
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
|
||||
'Europe/London',
|
||||
],
|
||||
payload: {
|
||||
playlistId,
|
||||
connection: {
|
||||
serverUrl: 'http://panel.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
},
|
||||
serverTimezone: 'Europe/London',
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: 'DB_GET_APP_STATE',
|
||||
args: ['workspace:last-route'],
|
||||
|
||||
@@ -25,6 +25,7 @@ export const DB_WORKER_OPERATIONS = [
|
||||
'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS',
|
||||
'DB_GET_PLAYLIST',
|
||||
'DB_UPDATE_PLAYLIST',
|
||||
'DB_SET_PLAYLIST_SERVER_TIMEZONE',
|
||||
'DB_DELETE_PLAYLIST',
|
||||
'DB_GET_APP_STATE',
|
||||
'DB_SET_APP_STATE',
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
getAppState,
|
||||
getPlaylist,
|
||||
setAppState,
|
||||
setPlaylistServerTimezone,
|
||||
type AppPlaylistGetPhaseCapture,
|
||||
type AppPlaylistUpsertPhaseCapture,
|
||||
updatePlaylist,
|
||||
@@ -735,6 +736,24 @@ async function executeRequest(
|
||||
return getPlaylist(db, payload.playlistId);
|
||||
}
|
||||
|
||||
case 'DB_SET_PLAYLIST_SERVER_TIMEZONE': {
|
||||
const payload = message.payload as {
|
||||
playlistId: string;
|
||||
connection: {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
serverTimezone: string;
|
||||
};
|
||||
return setPlaylistServerTimezone(
|
||||
db,
|
||||
payload.playlistId,
|
||||
payload.connection,
|
||||
payload.serverTimezone
|
||||
);
|
||||
}
|
||||
|
||||
case 'DB_UPDATE_PLAYLIST': {
|
||||
const payload = message.payload as {
|
||||
playlistId: string;
|
||||
|
||||
@@ -277,16 +277,32 @@ timezone is learned by `withPortal.checkPortalStatus()` and normalized by
|
||||
- with neither, nothing is stored and the URL falls back to the viewer's
|
||||
clock, the only remaining guess.
|
||||
|
||||
The value is persisted on the playlist row (`Playlist.serverTimezone`, via
|
||||
`PlaylistsService.transformPlaylistMeta` — a no-op write when the row already
|
||||
carries it) because the two catch-up entry points read different sources:
|
||||
the Live TV layout uses the store's `currentPlaylist`, while the Favorites /
|
||||
Recent resolver (`StreamResolverService.resolveXtreamCatchupUrl`) reads the
|
||||
stored row through `dbGetAppPlaylist` / IndexedDB. Electron's
|
||||
`DB_GET_PLAYLIST` projects the persisted value from the row payload so the
|
||||
store is seeded with it before, or without, the account-info check. The
|
||||
same timezone lets `XtreamApiService` read timestamp-less EPG `start`/`end`
|
||||
strings in the clock the panel wrote them in
|
||||
(`parseXtreamServerLocalDateTime`); `start_timestamp` still wins whenever it
|
||||
is present. Formatting uses `hourCycle: 'h23'`, so server midnight renders
|
||||
as `00`, never `24`.
|
||||
The value is persisted on the playlist row (`Playlist.serverTimezone`)
|
||||
because the two catch-up entry points read different sources: the Live TV
|
||||
layout uses the store's `currentPlaylist`, while the Favorites / Recent
|
||||
resolver (`StreamResolverService.resolveXtreamCatchupUrl`) reads the stored
|
||||
row through `dbGetAppPlaylist` / IndexedDB. The write goes through
|
||||
`IXtreamDataSource.rememberServerTimezone` and is atomic against the row's
|
||||
CURRENT connection in both runtimes — Electron: one conditional UPDATE
|
||||
(`DB_SET_PLAYLIST_SERVER_TIMEZONE` → `setPlaylistServerTimezone`,
|
||||
`json_set(payload, '$.serverTimezone', …)` only while `serverUrl`/`username`/
|
||||
`password` still match the request and the payload does not already carry
|
||||
the value; a malformed payload is never rewritten); PWA:
|
||||
`PlaylistsService.transformPlaylistMeta`, whose read and write share one
|
||||
IndexedDB readwrite cursor transaction. No read precedes the write, because
|
||||
the database worker interleaves requests and the Xtream edit dialog saves
|
||||
through `DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue: a
|
||||
read-modify-write could hand a concurrent upsert's newer payload back to the
|
||||
past or undo an edit that landed in between. The store offers the resolved
|
||||
value on every check (a transient write failure is retried by the next one),
|
||||
patches its own state only while the selected playlist is still the panel the
|
||||
answer came from (`answersFor`: id + connection), and returns the store's
|
||||
verdict about the current selection when it is not. An update that moves
|
||||
`serverUrl` (`mergePlaylistMeta`, `DB_UPDATE_PLAYLIST`) drops the clock until
|
||||
the next account-info check. Electron's `DB_GET_PLAYLIST` projects the
|
||||
persisted value from the row payload so the store is seeded with it before,
|
||||
or without, the account-info check. The same timezone lets
|
||||
`XtreamApiService` read timestamp-less EPG `start`/`end` strings in the
|
||||
clock the panel wrote them in (`parseXtreamServerLocalDateTime`);
|
||||
`start_timestamp` still wins whenever it is present. Formatting uses
|
||||
`hourCycle: 'h23'`, so server midnight renders as `00`, never `24`.
|
||||
+25
@@ -70,6 +70,31 @@ describe('ElectronXtreamDataSource (delegation)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the panel timezone through the conditional worker UPDATE with the request connection (issue #1562)', async () => {
|
||||
await harness.dataSource.rememberServerTimezone(
|
||||
playlistId,
|
||||
{
|
||||
serverUrl: 'http://portal.example',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
allowedOutputFormats: ['ts'],
|
||||
},
|
||||
'Europe/London'
|
||||
);
|
||||
|
||||
expect(
|
||||
harness.dbService.setXtreamPlaylistServerTimezone
|
||||
).toHaveBeenCalledWith(
|
||||
playlistId,
|
||||
{
|
||||
serverUrl: 'http://portal.example',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
},
|
||||
'Europe/London'
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes playlists via the DB and propagates failures', async () => {
|
||||
await harness.dataSource.deletePlaylist(playlistId);
|
||||
expect(harness.dbService.deletePlaylist).toHaveBeenCalledWith(
|
||||
|
||||
+1
@@ -48,6 +48,7 @@ export function createDbServiceMock() {
|
||||
clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined),
|
||||
getContentByXtreamId: jest.fn().mockResolvedValue(null),
|
||||
setContentMetadataIfMissing: jest.fn().mockResolvedValue(undefined),
|
||||
setXtreamPlaylistServerTimezone: jest.fn().mockResolvedValue(true),
|
||||
deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({
|
||||
hiddenCategories: [],
|
||||
favorites: [],
|
||||
|
||||
@@ -107,6 +107,25 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
});
|
||||
}
|
||||
|
||||
async rememberServerTimezone(
|
||||
playlistId: string,
|
||||
credentials: XtreamCredentials,
|
||||
serverTimezone: string
|
||||
): Promise<void> {
|
||||
// One conditional UPDATE in the worker (`DB_SET_PLAYLIST_SERVER_TIMEZONE`):
|
||||
// the row-level connection match and the no-op-when-equal check
|
||||
// happen inside the statement, never as a read here.
|
||||
await this.dbService.setXtreamPlaylistServerTimezone(
|
||||
playlistId,
|
||||
{
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
},
|
||||
serverTimezone
|
||||
);
|
||||
}
|
||||
|
||||
async deletePlaylist(playlistId: string): Promise<void> {
|
||||
await this.dbService.deletePlaylist(playlistId);
|
||||
}
|
||||
@@ -336,9 +355,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
await this.dbService.saveXtreamContent(
|
||||
playlistId,
|
||||
remoteData as
|
||||
| XtreamLiveStream[]
|
||||
| XtreamVodStream[]
|
||||
| XtreamSerieItem[],
|
||||
XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[],
|
||||
type,
|
||||
onProgress,
|
||||
options
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('PwaXtreamDataSource', () => {
|
||||
};
|
||||
let playlistsService: {
|
||||
getPlaylistById: jest.Mock;
|
||||
transformPlaylistMeta: jest.Mock;
|
||||
};
|
||||
|
||||
const credentials: XtreamCredentials = {
|
||||
@@ -31,6 +32,7 @@ describe('PwaXtreamDataSource', () => {
|
||||
};
|
||||
playlistsService = {
|
||||
getPlaylistById: jest.fn(() => of(undefined)),
|
||||
transformPlaylistMeta: jest.fn(() => of(null)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -110,6 +112,49 @@ describe('PwaXtreamDataSource', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('persists the panel timezone through one IndexedDB transform guarded by the request connection (issue #1562)', async () => {
|
||||
const credentials = {
|
||||
serverUrl: 'http://panel.example:8080',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
};
|
||||
const row = {
|
||||
_id: 'playlist-1',
|
||||
title: 'Xtream',
|
||||
...credentials,
|
||||
};
|
||||
await dataSource.createPlaylist({
|
||||
id: 'playlist-1',
|
||||
name: 'Xtream',
|
||||
type: 'xtream',
|
||||
...credentials,
|
||||
});
|
||||
playlistsService.transformPlaylistMeta.mockImplementation(
|
||||
(_id: string, transform: (current: unknown) => unknown) =>
|
||||
of(transform(row))
|
||||
);
|
||||
|
||||
await dataSource.rememberServerTimezone(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'UTC+03:00'
|
||||
);
|
||||
|
||||
const [, transform] = playlistsService.transformPlaylistMeta.mock
|
||||
.calls[0] as [string, (current: unknown) => unknown];
|
||||
// The row still points at the panel → written, with the clock.
|
||||
expect(transform(row)).toEqual({ ...row, serverTimezone: 'UTC+03:00' });
|
||||
// Already carrying it, or moved to another panel → no write.
|
||||
expect(transform({ ...row, serverTimezone: 'UTC+03:00' })).toBeNull();
|
||||
expect(
|
||||
transform({ ...row, serverUrl: 'http://moved.example:8080' })
|
||||
).toBeNull();
|
||||
// The localStorage copy follows the successful write.
|
||||
await expect(dataSource.getPlaylist('playlist-1')).resolves.toEqual(
|
||||
expect.objectContaining({ serverTimezone: 'UTC+03:00' })
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the persisted panel timezone from the IndexedDB row into the store playlist (issue #1562)', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
|
||||
@@ -128,6 +128,34 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
async rememberServerTimezone(
|
||||
playlistId: string,
|
||||
credentials: XtreamCredentials,
|
||||
serverTimezone: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// `transformPlaylistMeta` runs the read and the write inside
|
||||
// one IndexedDB readwrite cursor transaction, so the
|
||||
// connection check below cannot be overtaken by an edit.
|
||||
const playlistsService = this.injector.get(PlaylistsService);
|
||||
const written = await firstValueFrom(
|
||||
playlistsService.transformPlaylistMeta(playlistId, (row) =>
|
||||
row.serverUrl !== credentials.serverUrl ||
|
||||
row.username !== credentials.username ||
|
||||
row.password !== credentials.password ||
|
||||
row.serverTimezone === serverTimezone
|
||||
? null
|
||||
: { ...row, serverTimezone }
|
||||
)
|
||||
);
|
||||
if (written) {
|
||||
await this.updatePlaylist(playlistId, { serverTimezone });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to persist the portal timezone', error);
|
||||
}
|
||||
}
|
||||
|
||||
async deletePlaylist(playlistId: string): Promise<void> {
|
||||
const playlists = this.getPlaylistsFromStorage();
|
||||
const filtered = playlists.filter((p) => p.id !== playlistId);
|
||||
|
||||
@@ -164,6 +164,23 @@ export interface IXtreamDataSource {
|
||||
updates: Partial<XtreamPlaylistData>
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Persist the panel clock a successful account-info check learned
|
||||
* (`resolveXtreamServerTimezone`) onto the stored playlist row, which
|
||||
* the Favorites / Recent catch-up resolver reads instead of the store
|
||||
* (issue #1562). Each runtime applies it atomically against the row's
|
||||
* current connection: the write lands only while the row still points
|
||||
* at `credentials`, so an edit that moved the source meanwhile keeps
|
||||
* the clock the edit flow dropped, and a row already carrying the value
|
||||
* is left untouched. Never rejects — a failed write is retried by the
|
||||
* next check.
|
||||
*/
|
||||
rememberServerTimezone(
|
||||
playlistId: string,
|
||||
credentials: XtreamCredentials,
|
||||
serverTimezone: string
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a playlist and all its data
|
||||
*/
|
||||
|
||||
+44
-99
@@ -1,8 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { signalStore } from '@ngrx/signals';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamPlaylistData,
|
||||
@@ -25,6 +22,12 @@ const PLAYLIST: XtreamPlaylistData = {
|
||||
username: 'user',
|
||||
};
|
||||
|
||||
const PLAYLIST_CONNECTION = {
|
||||
serverUrl: PLAYLIST.serverUrl,
|
||||
username: PLAYLIST.username,
|
||||
password: PLAYLIST.password,
|
||||
};
|
||||
|
||||
// 2026-09-06 19:30:00 UTC
|
||||
const SERVER_EPOCH = 1_788_723_000;
|
||||
|
||||
@@ -35,34 +38,13 @@ describe('withPortal', () => {
|
||||
let apiService: {
|
||||
getAccountInfo: jest.Mock;
|
||||
};
|
||||
let storedPlaylist: Playlist;
|
||||
let transformPlaylistMeta: jest.Mock;
|
||||
let rememberServerTimezone: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = {
|
||||
getAccountInfo: jest.fn(),
|
||||
};
|
||||
storedPlaylist = {
|
||||
_id: PLAYLIST.id,
|
||||
title: PLAYLIST.name,
|
||||
serverUrl: PLAYLIST.serverUrl,
|
||||
username: PLAYLIST.username,
|
||||
password: PLAYLIST.password,
|
||||
} as Playlist;
|
||||
// Mirrors the real service: the transform receives the stored row
|
||||
// and `null` means "nothing to write".
|
||||
transformPlaylistMeta = jest.fn(
|
||||
(
|
||||
_playlistId: string,
|
||||
transform: (current: Playlist) => Playlist | null
|
||||
) => {
|
||||
const next = transform(storedPlaylist);
|
||||
if (next) {
|
||||
storedPlaylist = next;
|
||||
}
|
||||
return of(next);
|
||||
}
|
||||
);
|
||||
rememberServerTimezone = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -75,12 +57,9 @@ describe('withPortal', () => {
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
useValue: {
|
||||
getPlaylist: jest.fn(),
|
||||
rememberServerTimezone,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: { transformPlaylistMeta },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -146,7 +125,7 @@ describe('withPortal', () => {
|
||||
});
|
||||
|
||||
describe('server timezone (issue #1562)', () => {
|
||||
it('keeps the learned IANA timezone in the store AND on the stored row', async () => {
|
||||
it('keeps the learned IANA timezone in the store AND hands it to the data source for the stored row', async () => {
|
||||
respondWith({ timezone: 'Europe/London' });
|
||||
|
||||
await store.checkPortalStatus();
|
||||
@@ -154,11 +133,11 @@ describe('withPortal', () => {
|
||||
expect(store.currentPlaylist()?.serverTimezone).toBe(
|
||||
'Europe/London'
|
||||
);
|
||||
expect(transformPlaylistMeta).toHaveBeenCalledWith(
|
||||
expect(rememberServerTimezone).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
expect.any(Function)
|
||||
PLAYLIST_CONNECTION,
|
||||
'Europe/London'
|
||||
);
|
||||
expect(storedPlaylist.serverTimezone).toBe('Europe/London');
|
||||
});
|
||||
|
||||
it('derives a fixed offset from the panel clock when the timezone name is unusable', async () => {
|
||||
@@ -171,39 +150,26 @@ describe('withPortal', () => {
|
||||
await store.checkPortalStatus();
|
||||
|
||||
expect(store.currentPlaylist()?.serverTimezone).toBe('UTC+03:00');
|
||||
expect(storedPlaylist.serverTimezone).toBe('UTC+03:00');
|
||||
expect(rememberServerTimezone).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
PLAYLIST_CONNECTION,
|
||||
'UTC+03:00'
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the value to storage every time but writes nothing when the row already carries it', async () => {
|
||||
storedPlaylist = { ...storedPlaylist, serverTimezone: 'UTC' };
|
||||
it('offers the value to the data source even when the store already carries it', async () => {
|
||||
// The store cannot know whether the ROW has it (a transient
|
||||
// write failure leaves them apart); the data source decides.
|
||||
store.setCurrentPlaylist({ ...PLAYLIST, serverTimezone: 'UTC' });
|
||||
respondWith({ timezone: 'UTC' });
|
||||
|
||||
await store.checkPortalStatus();
|
||||
|
||||
expect(transformPlaylistMeta).toHaveBeenCalledTimes(1);
|
||||
const [, transform] = transformPlaylistMeta.mock.calls[0];
|
||||
expect(transform(storedPlaylist)).toBeNull();
|
||||
});
|
||||
|
||||
it('retries the write on the next check after a transient storage failure', async () => {
|
||||
transformPlaylistMeta.mockImplementationOnce(() => {
|
||||
throw new Error('storage unavailable');
|
||||
});
|
||||
respondWith({ timezone: 'Europe/London' });
|
||||
|
||||
await store.checkPortalStatus();
|
||||
expect(store.currentPlaylist()?.serverTimezone).toBe(
|
||||
'Europe/London'
|
||||
expect(rememberServerTimezone).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
PLAYLIST_CONNECTION,
|
||||
'UTC'
|
||||
);
|
||||
expect(storedPlaylist.serverTimezone).toBeUndefined();
|
||||
|
||||
// The store already carries the clock, so only the row-level
|
||||
// check may decide that the row still lacks it.
|
||||
await store.checkPortalStatus();
|
||||
|
||||
expect(transformPlaylistMeta).toHaveBeenCalledTimes(2);
|
||||
expect(storedPlaylist.serverTimezone).toBe('Europe/London');
|
||||
});
|
||||
|
||||
it('keeps the previously known timezone when the response carries no usable clock', async () => {
|
||||
@@ -218,10 +184,10 @@ describe('withPortal', () => {
|
||||
expect(store.currentPlaylist()?.serverTimezone).toBe(
|
||||
'Europe/London'
|
||||
);
|
||||
expect(transformPlaylistMeta).not.toHaveBeenCalled();
|
||||
expect(rememberServerTimezone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists a late answer under the playlist that asked, never onto the one selected meanwhile', async () => {
|
||||
it('hands a late answer to the data source under the playlist and connection that asked, never the one selected meanwhile', async () => {
|
||||
const other: XtreamPlaylistData = {
|
||||
...PLAYLIST,
|
||||
id: 'playlist-2',
|
||||
@@ -247,11 +213,11 @@ describe('withPortal', () => {
|
||||
await expect(pending).resolves.toBe('unavailable');
|
||||
expect(store.currentPlaylist()).toEqual(other);
|
||||
expect(store.portalStatus()).toBe('unavailable');
|
||||
expect(transformPlaylistMeta).toHaveBeenCalledWith(
|
||||
expect(rememberServerTimezone).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
expect.any(Function)
|
||||
PLAYLIST_CONNECTION,
|
||||
'Europe/London'
|
||||
);
|
||||
expect(storedPlaylist.serverTimezone).toBe('Europe/London');
|
||||
});
|
||||
|
||||
it('does not patch the store with a late answer when the playlist was edited in place meanwhile', async () => {
|
||||
@@ -269,11 +235,6 @@ describe('withPortal', () => {
|
||||
serverUrl: 'https://moved.example.com',
|
||||
};
|
||||
store.setCurrentPlaylist(moved);
|
||||
storedPlaylist = {
|
||||
...storedPlaylist,
|
||||
serverUrl: moved.serverUrl,
|
||||
serverTimezone: undefined,
|
||||
};
|
||||
answer({
|
||||
user_info: { auth: 1, exp_date: '0', status: 'Active' },
|
||||
server_info: { timezone: 'Europe/London' },
|
||||
@@ -282,33 +243,13 @@ describe('withPortal', () => {
|
||||
await expect(pending).resolves.toBe('unavailable');
|
||||
expect(store.currentPlaylist()).toEqual(moved);
|
||||
expect(store.portalStatus()).toBe('unavailable');
|
||||
expect(storedPlaylist.serverTimezone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not persist a late answer onto a row whose connection was edited meanwhile', async () => {
|
||||
let answer!: (value: unknown) => void;
|
||||
apiService.getAccountInfo.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
})
|
||||
// The row-level guard belongs to the data source, which only
|
||||
// ever sees the ORIGINAL connection the answer came from.
|
||||
expect(rememberServerTimezone).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
PLAYLIST_CONNECTION,
|
||||
'Europe/London'
|
||||
);
|
||||
|
||||
const pending = store.checkPortalStatus();
|
||||
// The edit flow moved the source (and dropped the old clock)
|
||||
// while the old panel's answer was still on the wire.
|
||||
storedPlaylist = {
|
||||
...storedPlaylist,
|
||||
serverUrl: 'https://moved.example.com',
|
||||
serverTimezone: undefined,
|
||||
};
|
||||
answer({
|
||||
user_info: { auth: 1, exp_date: '0', status: 'Active' },
|
||||
server_info: { timezone: 'Europe/London' },
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toBe('active');
|
||||
expect(transformPlaylistMeta).toHaveBeenCalledTimes(1);
|
||||
expect(storedPlaylist.serverTimezone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not mark a playlist selected meanwhile unavailable for the earlier playlist’s failure', async () => {
|
||||
@@ -333,16 +274,20 @@ describe('withPortal', () => {
|
||||
expect(store.portalStatus()).toBe('active');
|
||||
});
|
||||
|
||||
it('still reports the portal status when persisting the timezone fails', async () => {
|
||||
transformPlaylistMeta.mockImplementation(() => {
|
||||
throw new Error('storage unavailable');
|
||||
});
|
||||
it('still reports the portal status and retries on the next check when the data source rejects', async () => {
|
||||
rememberServerTimezone.mockRejectedValueOnce(
|
||||
new Error('storage unavailable')
|
||||
);
|
||||
respondWith({ timezone: 'Europe/London' });
|
||||
|
||||
await expect(store.checkPortalStatus()).resolves.toBe('active');
|
||||
expect(store.currentPlaylist()?.serverTimezone).toBe(
|
||||
'Europe/London'
|
||||
);
|
||||
|
||||
await store.checkPortalStatus();
|
||||
|
||||
expect(rememberServerTimezone).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
} from '../../services/xtream-api.service';
|
||||
import { PortalStatusType } from '../../xtream-state';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
resolveXtreamPortalStatus,
|
||||
resolveXtreamServerTimezone,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Portal state for managing playlist and portal status
|
||||
@@ -56,7 +54,6 @@ export function withPortal() {
|
||||
withMethods((store) => {
|
||||
const apiService = inject(XtreamApiService);
|
||||
const dataSource = inject(XTREAM_DATA_SOURCE);
|
||||
const playlistsService = inject(PlaylistsService);
|
||||
|
||||
/**
|
||||
* Whether a playlist (the store's current one, or the stored
|
||||
@@ -82,12 +79,10 @@ export function withPortal() {
|
||||
* The Favorites / Recent catch-up resolver reads the STORED
|
||||
* playlist row, not this store, so a timezone learned here has
|
||||
* to reach storage or that path keeps rendering programme
|
||||
* start times in the viewer's clock (issue #1562). The write
|
||||
* lands only on a row that still points at the panel the answer
|
||||
* came from — an edit that moved the source during the request
|
||||
* keeps the clock the edit flow dropped — is skipped when the
|
||||
* row already carries the value, and never fails the status
|
||||
* check that learned it.
|
||||
* start times in the viewer's clock (issue #1562). The data
|
||||
* source applies it atomically against the row's current
|
||||
* connection (see `IXtreamDataSource.rememberServerTimezone`);
|
||||
* a failed write never fails the status check that learned it.
|
||||
*/
|
||||
const rememberServerTimezone = async (
|
||||
playlistId: string,
|
||||
@@ -95,15 +90,10 @@ export function withPortal() {
|
||||
serverTimezone: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await firstValueFrom(
|
||||
playlistsService.transformPlaylistMeta(
|
||||
playlistId,
|
||||
(playlist) =>
|
||||
!answersFor(playlist, credentials) ||
|
||||
playlist.serverTimezone === serverTimezone
|
||||
? null
|
||||
: { ...playlist, serverTimezone }
|
||||
)
|
||||
await dataSource.rememberServerTimezone(
|
||||
playlistId,
|
||||
credentials,
|
||||
serverTimezone
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
||||
@@ -122,11 +122,7 @@ export interface DbOperationOptions {
|
||||
}
|
||||
|
||||
export type XtreamImportStatus =
|
||||
| 'idle'
|
||||
| 'importing'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
| 'failed';
|
||||
'idle' | 'importing' | 'completed' | 'cancelled' | 'failed';
|
||||
|
||||
export function isDbAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
@@ -135,11 +131,7 @@ export function isDbAbortError(error: unknown): boolean {
|
||||
export type GlobalRecentlyAddedKind = 'all' | 'vod' | 'series';
|
||||
|
||||
export type GlobalRecentlyAddedPlaylistType =
|
||||
| 'xtream'
|
||||
| 'stalker'
|
||||
| 'm3u-file'
|
||||
| 'm3u-text'
|
||||
| 'm3u-url';
|
||||
'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
|
||||
|
||||
export interface GlobalRecentlyAddedItem extends XtreamContent {
|
||||
playlist_id: string;
|
||||
@@ -381,6 +373,34 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the panel clock a successful account-info check learned, as
|
||||
* one conditional UPDATE on the row payload (issue #1562). A no-op when
|
||||
* the row no longer points at the given connection or already carries
|
||||
* the value; never reads before writing, so it cannot undo a concurrent
|
||||
* edit or upsert. Resolves `true` when the row now carries the clock.
|
||||
*/
|
||||
async setXtreamPlaylistServerTimezone(
|
||||
playlistId: string,
|
||||
connection: { serverUrl: string; username: string; password: string },
|
||||
serverTimezone: string
|
||||
): Promise<boolean> {
|
||||
if (!window.electron?.dbSetPlaylistServerTimezone) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await window.electron.dbSetPlaylistServerTimezone(
|
||||
playlistId,
|
||||
connection,
|
||||
serverTimezone
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error persisting the portal timezone:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if categories exist
|
||||
*/
|
||||
|
||||
@@ -896,6 +896,16 @@ export interface ElectronBridgeApi {
|
||||
dbGetPlaylist: (
|
||||
playlistId: string
|
||||
) => Promise<ElectronBridgePlaylistRow | null>;
|
||||
/**
|
||||
* Records the panel clock learned from account info as one conditional
|
||||
* UPDATE on the row payload; a no-op unless the row still points at the
|
||||
* given connection (issue #1562).
|
||||
*/
|
||||
dbSetPlaylistServerTimezone: (
|
||||
playlistId: string,
|
||||
connection: { serverUrl: string; username: string; password: string },
|
||||
serverTimezone: string
|
||||
) => Promise<{ updated: boolean }>;
|
||||
dbUpsertAppPlaylist: (
|
||||
playlist: Playlist,
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user