fix(xtream): drop a panel clock that no longer belongs to the source

Review follow-ups (Codex + Greptile):

- A metadata update or DB_UPDATE_PLAYLIST that points the source at another
  server drops the persisted `serverTimezone` (payload-only) until the next
  account-info check, so Favorites / Recent cannot keep rendering the OLD
  panel's clock; an update that supplies a clock keeps it.
- A late account-info answer is persisted only onto a row that still points
  at the panel it came from — an edit that moved the source during the
  request keeps the clock the edit flow dropped.
- The PWA data source and the route-session converter carry the persisted
  timezone into the store playlist, so a later response without a usable
  clock has a previous value to preserve.
- Mirror the catch-up timezone contract into AGENTS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 23:25:41 +02:00
1 parent 89beb2c4a5
commit dfdf1d69f5
11 files changed
+260 -41

No files matched your search

+16
View File
@@ -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:
+2 -1
View File
@@ -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").
@@ -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([
{
@@ -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<Record<string, unknown> | null>(
row.payload,
null
);
if (!payload || typeof payload !== 'object' || !payload.serverTimezone) {
return {};
}
const rest: Record<string, unknown> = { ...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. */
@@ -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,
@@ -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
@@ -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;
@@ -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<void> => {
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
);
}
@@ -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));
@@ -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',
+8 -1
View File
@@ -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,