mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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:
1 parent
89beb2c4a5
commit
dfdf1d69f5
11 files changed
+260
-41
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user