diff --git a/AGENTS.md b/AGENTS.md index f4b87bd72..3c33dbe1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -323,6 +323,22 @@ MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without a terminal diagnostic also need manual TS. Contract and full support matrix: `docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure). +## Xtream Catch-Up Server Timezone + +The `{Y-m-d:H-M}` segment of a timeshift URL is read by the panel in ITS +timezone (`server_info.timezone`), never the viewer's (issue #1562). +`withPortal.checkPortalStatus()` normalizes it with +`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 +`start`/`end` strings. Contract: +`docs/architecture/xtream-portal-compatibility.md` ("Start time is the +panel's clock, not the viewer's"). + ## Radio / Audio Player M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected: diff --git a/CLAUDE.md b/CLAUDE.md index 45cda7e21..d2b905021 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -815,7 +815,8 @@ 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. The same value converts timestamp-less EPG +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"). diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts index 1b6b0d369..72c8586fc 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts @@ -3,6 +3,7 @@ import { getAppPlaylistMetas, getPlaylist, parseAppPlaylist, + updatePlaylist, } from './playlist.operations'; import type { AppDatabase } from '../database.types'; @@ -94,6 +95,58 @@ describe('playlist.operations', () => { ).resolves.toBeNull(); }); + it('drops the persisted server timezone from the payload when DB_UPDATE_PLAYLIST moves the source to another server', async () => { + const payload = { + _id: 'playlist-1', + title: 'Xtream Source', + serverTimezone: 'Europe/London', + favorites: ['1'], + }; + const read = createPlaylistFavoriteChannelsDbMock({ + serverUrl: 'http://old.example:8080', + payload: JSON.stringify(payload), + }); + const where = jest.fn().mockResolvedValue(undefined); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + const db = { + select: read.select, + update, + } as unknown as AppDatabase; + + await expect( + updatePlaylist(db, 'playlist-1', { + name: 'Moved', + serverUrl: 'http://new.example:8080', + }) + ).resolves.toEqual({ success: true }); + expect(set).toHaveBeenCalledWith({ + name: 'Moved', + serverUrl: 'http://new.example:8080', + payload: JSON.stringify({ + _id: 'playlist-1', + title: 'Xtream Source', + favorites: ['1'], + }), + }); + + set.mockClear(); + await updatePlaylist(db, 'playlist-1', { + name: 'Renamed', + serverUrl: 'http://old.example:8080', + }); + expect(set).toHaveBeenCalledWith({ + name: 'Renamed', + serverUrl: 'http://old.example:8080', + }); + + set.mockClear(); + read.select.mockClear(); + await updatePlaylist(db, 'playlist-1', { name: 'Only renamed' }); + expect(read.select).not.toHaveBeenCalled(); + expect(set).toHaveBeenCalledWith({ name: 'Only renamed' }); + }); + it('loads app playlist metadata without selecting the large payload column', async () => { const from = jest.fn().mockResolvedValue([ { diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 373b3e9d5..00080b10a 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -600,12 +600,55 @@ export async function updatePlaylist( ): Promise<{ success: boolean }> { await db .update(schema.playlists) - .set(updates) + .set({ + ...updates, + ...(await serverTimezoneInvalidation(db, playlistId, updates)), + }) .where(eq(schema.playlists.id, playlistId)); return { success: true }; } +/** + * The persisted panel clock (`serverTimezone`, payload-only) belongs to the + * panel it was learned from: pointing the row at another server drops it, + * so Favorites / Recent catch-up cannot keep rendering the OLD panel's + * clock until the next account-info check (issue #1562). Returns the + * payload column to write, or nothing when the server is unchanged or the + * payload carries no clock. + */ +async function serverTimezoneInvalidation( + db: AppDatabase, + playlistId: string, + updates: { serverUrl?: string } +): Promise<{ payload?: string }> { + if (updates.serverUrl === undefined) { + return {}; + } + const rows = await db + .select({ + serverUrl: schema.playlists.serverUrl, + payload: schema.playlists.payload, + }) + .from(schema.playlists) + .where(eq(schema.playlists.id, playlistId)) + .limit(1); + const row = rows[0]; + if (!row || row.serverUrl === updates.serverUrl) { + return {}; + } + const payload = parseJsonValue | null>( + row.payload, + null + ); + if (!payload || typeof payload !== 'object' || !payload.serverTimezone) { + return {}; + } + const rest: Record = { ...payload }; + delete rest.serverTimezone; + return { payload: JSON.stringify(rest) }; +} + interface PlaylistDeletionCollection { readonly categoryIds: number[]; /** Content rows per category, the unit the delete is batched by. */ diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index 2ee488a7c..ad4103d74 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -110,6 +110,30 @@ describe('PwaXtreamDataSource', () => { ); }); + it('carries the persisted panel timezone from the IndexedDB row into the store playlist (issue #1562)', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-1', + title: 'Xtream', + importDate: '2026-04-01T00:00:00.000Z', + lastUsage: '2026-04-01T00:00:00.000Z', + count: 0, + autoRefresh: false, + serverUrl: 'http://panel.example:8080', + username: 'user', + password: 'pass', + serverTimezone: 'UTC+03:00', + }) + ); + + await expect(dataSource.getPlaylist('playlist-1')).resolves.toEqual( + expect.objectContaining({ + id: 'playlist-1', + serverTimezone: 'UTC+03:00', + }) + ); + }); + it('uses current playlist metadata before stale PWA storage when fetching playlist details', async () => { await dataSource.createPlaylist({ id: 'playlist-1', @@ -656,12 +680,8 @@ describe('PwaXtreamDataSource', () => { expect(setItemSpy).toHaveBeenCalledTimes(1); setItemSpy.mockRestore(); - const stored = JSON.parse( - localStorage.getItem(storageKey) || '{}' - ); - const playlistRows = stored[ - 'playlist-1' - ] as PlaybackPositionData[]; + const stored = JSON.parse(localStorage.getItem(storageKey) || '{}'); + const playlistRows = stored['playlist-1'] as PlaybackPositionData[]; expect(playlistRows).toHaveLength(2); const replaced = playlistRows.find( @@ -721,9 +741,7 @@ describe('PwaXtreamDataSource', () => { expect(setItemSpy).toHaveBeenCalledTimes(1); setItemSpy.mockRestore(); - const stored = JSON.parse( - localStorage.getItem(storageKey) || '{}' - ); + const stored = JSON.parse(localStorage.getItem(storageKey) || '{}'); expect(stored['playlist-1']).toEqual([ expect.objectContaining({ contentXtreamId: 202, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 67a5f2f4c..a1f0b65db 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -212,6 +212,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { userAgent: playlist.userAgent, referrer: playlist.referrer, origin: playlist.origin, + serverTimezone: playlist.serverTimezone, }; } @@ -388,9 +389,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const cachedContent = this.contentCache.get(cacheKey); if (cachedContent) { return cachedContent as - | XtreamLiveStream[] - | XtreamVodStream[] - | XtreamSerieItem[]; + XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]; } // Fetch from API diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts index 58d34e90d..390225da9 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts @@ -230,6 +230,32 @@ describe('withPortal', () => { expect(storedPlaylist.serverTimezone).toBe('Europe/London'); }); + 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; + }) + ); + + 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 () => { const other: XtreamPlaylistData = { ...PLAYLIST, id: 'playlist-2' }; let fail!: (reason: unknown) => void; diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index 497885ed7..6dfc8c92b 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -62,12 +62,16 @@ 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 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 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. */ const rememberServerTimezone = async ( playlistId: string, + credentials: XtreamCredentials, serverTimezone: string ): Promise => { try { @@ -75,6 +79,9 @@ export function withPortal() { playlistsService.transformPlaylistMeta( playlistId, (playlist) => + playlist.serverUrl !== credentials.serverUrl || + playlist.username !== credentials.username || + playlist.password !== credentials.password || playlist.serverTimezone === serverTimezone ? null : { ...playlist, serverTimezone } @@ -192,6 +199,7 @@ export function withPortal() { ) { await rememberServerTimezone( playlist.id, + credentials, serverTimezone ); } diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index 63247e585..274496307 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -53,6 +53,9 @@ function toXtreamPlaylistData( ...(userAgent ? { userAgent } : {}), ...(referrer ? { referrer } : {}), ...(origin ? { origin } : {}), + ...(playlist.serverTimezone + ? { serverTimezone: playlist.serverTimezone } + : {}), }; } @@ -117,9 +120,7 @@ function getXtreamRouteTarget(url: string): { playlistId: string | null; section: PortalRailSection | null; } { - const match = url.match( - /^\/workspace\/xtreams\/([^/?]+)(?:\/([^/?]+))?/ - ); + const match = url.match(/^\/workspace\/xtreams\/([^/?]+)(?:\/([^/?]+))?/); return { playlistId: match?.[1] ?? null, @@ -144,7 +145,9 @@ function hasPlaylistConnectionChanges( const currentReferrer = normalizeOptionalConnectionValue( currentPlaylist.referrer ); - const nextReferrer = normalizeOptionalConnectionValue(nextPlaylist.referrer); + const nextReferrer = normalizeOptionalConnectionValue( + nextPlaylist.referrer + ); const currentOrigin = normalizeOptionalConnectionValue( currentPlaylist.origin ); @@ -171,11 +174,11 @@ function shouldBootstrapXtreamPlaylist( return Boolean( playlistId && - routePlaylist && - (storePlaylistId !== playlistId || - currentPlaylist?.id !== playlistId || - currentPlaylistUpdateDate !== routePlaylistUpdateDate || - hasPlaylistConnectionChanges(currentPlaylist, routePlaylist)) + routePlaylist && + (storePlaylistId !== playlistId || + currentPlaylist?.id !== playlistId || + currentPlaylistUpdateDate !== routePlaylistUpdateDate || + hasPlaylistConnectionChanges(currentPlaylist, routePlaylist)) ); } @@ -183,11 +186,7 @@ function getXtreamRouteCategoryId( url: string, section: PortalRailSection | null ): number | null { - if ( - section !== 'live' && - section !== 'vod' && - section !== 'series' - ) { + if (section !== 'live' && section !== 'vod' && section !== 'series') { return null; } @@ -330,10 +329,9 @@ export class XtreamWorkspaceRouteSession { ? this.xtreamStore.isCachedContentScopeReady(cacheScope) || (await this.xtreamStore.hasUsableOfflineCache(cacheScope)) : false; - const nextBlockReason = - canUseCachedContent - ? null - : toContentInitBlockReason(portalStatus); + const nextBlockReason = canUseCachedContent + ? null + : toContentInitBlockReason(portalStatus); const currentBlockReason = this.xtreamStore.contentInitBlockReason(); @@ -349,11 +347,7 @@ export class XtreamWorkspaceRouteSession { section = this.syncRouteState(routeSection); } - if ( - portalStatus !== 'active' && - !canUseCachedContent && - cacheScope - ) { + if (portalStatus !== 'active' && !canUseCachedContent && cacheScope) { canUseCachedContent = this.xtreamStore.isCachedContentScopeReady(cacheScope) || (await this.xtreamStore.hasUsableOfflineCache(cacheScope)); diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 4c424f473..d57b510cc 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -652,6 +652,60 @@ describe('PlaylistsService', () => { ); }); + it('drops the learned panel timezone when a metadata update points the source at another server (issue #1562)', async () => { + const existingPlaylist = { + _id: 'xtream-1', + title: 'Portal', + serverUrl: 'https://old.example.com', + username: 'user', + password: 'pass', + serverTimezone: 'Europe/London', + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + const service = createService(dbService); + + await firstValueFrom( + service.updatePlaylistMeta({ + _id: 'xtream-1', + title: 'Portal', + serverUrl: 'https://new.example.com', + } as PlaylistMeta) + ); + const [, moved] = dbService.update.mock.calls[0]; + expect(moved.serverUrl).toBe('https://new.example.com'); + expect(moved.serverTimezone).toBeUndefined(); + + dbService.update.mockClear(); + await firstValueFrom( + service.updatePlaylistMeta({ + _id: 'xtream-1', + title: 'Renamed portal', + serverUrl: 'https://old.example.com', + } as PlaylistMeta) + ); + const [, renamed] = dbService.update.mock.calls[0]; + expect(renamed.serverTimezone).toBe('Europe/London'); + + dbService.update.mockClear(); + await firstValueFrom( + service.updatePlaylistMeta({ + _id: 'xtream-1', + title: 'Portal', + serverUrl: 'https://new.example.com', + serverTimezone: 'UTC+03:00', + } as PlaylistMeta) + ); + const [, relearned] = dbService.update.mock.calls[0]; + expect(relearned.serverTimezone).toBe('UTC+03:00'); + }); + it('aborts a guarded metadata update in one readwrite transaction when the row no longer matches', async () => { const replacementPlaylist = { _id: 'stalker-replaced', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 23a7eaeaf..07d05908e 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -755,9 +755,16 @@ export class PlaylistsService { ...(updatedPlaylist.portalUrl != null ? { portalUrl: updatedPlaylist.portalUrl } : {}), + // A learned panel clock belongs to the panel it was learned + // from: an update that points the source at another server + // drops it until the next account-info check, unless the + // update itself supplies one (issue #1562). ...(updatedPlaylist.serverTimezone != null ? { serverTimezone: updatedPlaylist.serverTimezone } - : {}), + : updatedPlaylist.serverUrl != null && + updatedPlaylist.serverUrl !== playlist.serverUrl + ? { serverTimezone: undefined } + : {}), ...(updatedPlaylist.isFullStalkerPortal !== undefined ? { isFullStalkerPortal: updatedPlaylist.isFullStalkerPortal,