mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(xtream): add catch-up playback to favorites and recent tabs (#1166)
Enables Xtream catch-up/timeshift from the Favorites and Recent surfaces (per-playlist and global), not just Live TV, and adds start-over replay of the currently-airing programme. Carries tv_archive/tv_archive_duration through the favorites and recently-viewed DB projections and maps them onto UnifiedCollectionItem; tv_archive_duration is interpreted as days, matching live-stream-layout.controlledArchiveDays. Closes #1138. Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
c266eaa680
commit
2f8aee72df
25 files changed
+459
-48
No files matched your search
@@ -25,9 +25,12 @@ jest.mock('drizzle-orm', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { createDbMock } from './operations.test-helpers';
|
||||
import {
|
||||
getAllGlobalFavorites,
|
||||
getFavorites,
|
||||
getGlobalFavorites,
|
||||
reorderGlobalFavorites,
|
||||
} from './favorites.operations';
|
||||
@@ -93,6 +96,38 @@ describe('favorites.operations', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression for issue #1138: archive metadata must survive every
|
||||
// favorites projection so catch-up stays available outside Live TV.
|
||||
it('selects archive metadata for playlist favorites', async () => {
|
||||
const { db, select } = createGlobalFavoritesDbMock([]);
|
||||
|
||||
await getFavorites(db, 'playlist-1');
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('selects archive metadata for global favorites', async () => {
|
||||
const { db, select } = createGlobalFavoritesDbMock([]);
|
||||
|
||||
await getGlobalFavorites(db);
|
||||
await getAllGlobalFavorites(db);
|
||||
|
||||
expect(select).toHaveBeenCalledTimes(2);
|
||||
for (const [projection] of select.mock.calls) {
|
||||
expect(projection).toEqual(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe('reorderGlobalFavorites', () => {
|
||||
it('short-circuits without touching the db when there are no updates', async () => {
|
||||
const { db, update, transaction } = createDbMock();
|
||||
|
||||
@@ -73,6 +73,8 @@ export async function getFavorites(db: AppDatabase, playlistId: string) {
|
||||
poster_url: schema.content.posterUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
added_at: schema.favorites.addedAt,
|
||||
position: schema.favorites.position,
|
||||
})
|
||||
@@ -117,6 +119,8 @@ function selectGlobalFavoriteRows(
|
||||
: {}),
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
playlist_id: schema.playlists.id,
|
||||
playlist_name: schema.playlists.name,
|
||||
added_at: schema.favorites.addedAt,
|
||||
|
||||
+16
-2
@@ -35,7 +35,7 @@ describe('recently-viewed.operations', () => {
|
||||
{ id: 2, title: 'Newest', viewed_at: '2026-07-02 10:00:00' },
|
||||
{ id: 1, title: 'Older', viewed_at: '2026-07-01 10:00:00' },
|
||||
];
|
||||
const { db, queries } = createDbMock([rows]);
|
||||
const { db, queries, select } = createDbMock([rows]);
|
||||
|
||||
await expect(getRecentlyViewed(db)).resolves.toEqual(rows);
|
||||
|
||||
@@ -47,11 +47,19 @@ describe('recently-viewed.operations', () => {
|
||||
);
|
||||
expect(queries[0].limit).toHaveBeenCalledWith(100);
|
||||
expect(queries[0].innerJoin).toHaveBeenCalledTimes(3);
|
||||
// Regression for issue #1138: archive metadata must survive the
|
||||
// recent projections so catch-up stays available outside Live TV.
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes playlist history to the playlist, newest-first with a 100 cap', async () => {
|
||||
const rows = [{ id: 5, title: 'Recent Movie' }];
|
||||
const { db, queries } = createDbMock([rows]);
|
||||
const { db, queries, select } = createDbMock([rows]);
|
||||
|
||||
await expect(getRecentItems(db, 'playlist-1')).resolves.toEqual(
|
||||
rows
|
||||
@@ -65,6 +73,12 @@ describe('recently-viewed.operations', () => {
|
||||
schema.recentlyViewed.viewedAt
|
||||
);
|
||||
expect(queries[0].limit).toHaveBeenCalledWith(100);
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export async function getRecentlyViewed(db: AppDatabase) {
|
||||
backdrop_url: schema.content.backdropUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
playlist_id: schema.categories.playlistId,
|
||||
playlist_name: schema.playlists.name,
|
||||
viewed_at: schema.recentlyViewed.viewedAt,
|
||||
@@ -58,6 +60,8 @@ export async function getRecentItems(
|
||||
backdrop_url: schema.content.backdropUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
viewed_at: schema.recentlyViewed.viewedAt,
|
||||
})
|
||||
.from(schema.recentlyViewed)
|
||||
|
||||
@@ -43,7 +43,7 @@ function createEpgDatabaseMock() {
|
||||
}
|
||||
|
||||
describe('EpgDatabase', () => {
|
||||
it('refreshes a source without cascading shared channel programs from other sources', () => {
|
||||
it('refreshes a source selectively, keeping the recent past-programme archive', () => {
|
||||
const sourceUrl = 'https://example.com/playlist-guide.xml';
|
||||
const { Database, database, statements } = createEpgDatabaseMock();
|
||||
const epgDb = new EpgDatabase(Database);
|
||||
@@ -62,8 +62,11 @@ describe('EpgDatabase', () => {
|
||||
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
|
||||
normalizeSql(sql)
|
||||
);
|
||||
const deleteProgramsSql =
|
||||
'DELETE FROM epg_programs WHERE source_url = ?';
|
||||
const deleteTodayAndFutureSql = normalizeSql(`
|
||||
DELETE FROM epg_programs
|
||||
WHERE source_url = ?
|
||||
AND (start >= date('now') OR start < date('now', '-7 days'))
|
||||
`);
|
||||
const deleteOrphanChannelsSql = normalizeSql(`
|
||||
DELETE FROM epg_channels
|
||||
WHERE source_url = ?
|
||||
@@ -74,12 +77,18 @@ describe('EpgDatabase', () => {
|
||||
)
|
||||
`);
|
||||
|
||||
expect(preparedSql).toContain(deleteProgramsSql);
|
||||
expect(preparedSql).toContain(deleteTodayAndFutureSql);
|
||||
expect(preparedSql).toContain(deleteOrphanChannelsSql);
|
||||
// A refresh must not wipe the whole source: the last 7 days of
|
||||
// programmes stay behind so catch-up remains browsable (#1138),
|
||||
// and other sources' shared channels must not cascade away.
|
||||
expect(preparedSql).not.toContain(
|
||||
'DELETE FROM epg_programs WHERE source_url = ?'
|
||||
);
|
||||
expect(preparedSql).not.toContain(
|
||||
'DELETE FROM epg_channels WHERE source_url = ?'
|
||||
);
|
||||
expect(statements.get(deleteProgramsSql)).toHaveBeenCalledWith(
|
||||
expect(statements.get(deleteTodayAndFutureSql)).toHaveBeenCalledWith(
|
||||
sourceUrl
|
||||
);
|
||||
expect(statements.get(deleteOrphanChannelsSql)).toHaveBeenCalledWith(
|
||||
|
||||
@@ -11,8 +11,8 @@ export class EpgDatabase {
|
||||
private readonly knownChannelIds = new Set<string>();
|
||||
private readonly insertChannelStmt: BetterSqlite3.Statement;
|
||||
private readonly insertProgramStmt: BetterSqlite3.Statement;
|
||||
private readonly deleteProgramsForSourceStmt: BetterSqlite3.Statement;
|
||||
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
|
||||
private readonly deleteTodayAndFutureStmt: BetterSqlite3.Statement;
|
||||
|
||||
constructor(Database: typeof BetterSqlite3) {
|
||||
this.db = new Database(getIptvnatorDatabasePath());
|
||||
@@ -35,10 +35,6 @@ export class EpgDatabase {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.deleteProgramsForSourceStmt = this.db.prepare(`
|
||||
DELETE FROM epg_programs WHERE source_url = ?
|
||||
`);
|
||||
|
||||
this.deleteOrphanChannelsForSourceStmt = this.db.prepare(`
|
||||
DELETE FROM epg_channels
|
||||
WHERE source_url = ?
|
||||
@@ -48,21 +44,28 @@ export class EpgDatabase {
|
||||
WHERE epg_programs.channel_id = epg_channels.id
|
||||
)
|
||||
`);
|
||||
|
||||
this.deleteTodayAndFutureStmt = this.db.prepare(`
|
||||
DELETE FROM epg_programs
|
||||
WHERE source_url = ?
|
||||
AND (start >= date('now') OR start < date('now', '-7 days'))
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a batch of channels. When `clearFirst` is true, the existing rows
|
||||
* for `sourceUrl` are deleted inside the same transaction as the insert so
|
||||
* old data is preserved if the fetch/parse never produces any channels.
|
||||
* Insert a batch of channels. When `clearTodayAndFuture` is true, the
|
||||
* selective delete (today+future and older-than-7-days) runs inside the
|
||||
* same transaction so a parse failure after the delete atomically rolls
|
||||
* back both — no gap left in the schedule.
|
||||
*/
|
||||
insertChannels(
|
||||
channels: ParsedChannel[],
|
||||
sourceUrl: string,
|
||||
clearFirst = false
|
||||
clearTodayAndFuture = false
|
||||
): void {
|
||||
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
|
||||
if (clearFirst) {
|
||||
this.deleteProgramsForSourceStmt.run(sourceUrl);
|
||||
if (clearTodayAndFuture) {
|
||||
this.deleteTodayAndFutureStmt.run(sourceUrl);
|
||||
this.deleteOrphanChannelsForSourceStmt.run(sourceUrl);
|
||||
this.knownChannelIds.clear();
|
||||
}
|
||||
|
||||
@@ -363,7 +363,8 @@ activation, and the details dialog behave identically to the timeline.
|
||||
no-EPG-anywhere states and while loading. Return-to-live is a playback control
|
||||
(`!isLivePlayback()`) and is independent of EPG state.
|
||||
- **State-driven affordances.** Blocks are coloured past / now / future, with a
|
||||
red "now" playhead. Catch-up "Watch" appears on past blocks only when
|
||||
red "now" playhead. Catch-up "Watch" appears on past blocks — and as a
|
||||
start-over replay button on the currently-airing block — only when
|
||||
`archivePlaybackAvailable` (Xtream `tv_archive`, M3U `catchup-*`); Stalker is
|
||||
schedule-only (dimmed past + a notice, no false buttons). The "i" button opens
|
||||
the shared `app-epg-item-description` dialog with a state-aware action.
|
||||
|
||||
@@ -111,3 +111,12 @@ playable. MPEG-TS is preferred before HLS when the provider allows it because
|
||||
some portals return a valid HLS manifest while the first media segment fails in
|
||||
Chromium/video.js. PWA fallback keeps the REST MPEG-TS URL when no Electron
|
||||
probe API is available.
|
||||
|
||||
Catch-up is offered from the Xtream Live TV tab and from the unified
|
||||
collection surfaces (per-playlist and global Favorites and Recent). The
|
||||
`tv_archive` / `tv_archive_duration` columns are carried through the
|
||||
favorites and recently-viewed DB projections and mapped onto
|
||||
`UnifiedCollectionItem.tvArchive` / `tvArchiveDuration` so the shared live
|
||||
tab can gate the timeline's archive window. `tv_archive_duration` is
|
||||
interpreted as **days** everywhere, matching
|
||||
`live-stream-layout.controlledArchiveDays` (issue #1138).
|
||||
@@ -443,7 +443,14 @@ export class StreamResolverService {
|
||||
return this.fetchStalkerShortEpg(playlist, channelId, size);
|
||||
}
|
||||
|
||||
private async getXtreamCredentials(playlistId: string) {
|
||||
private async getXtreamCredentials(
|
||||
playlistId: string
|
||||
): Promise<{
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
serverTimezone?: string;
|
||||
} | null> {
|
||||
const playlist =
|
||||
(await this.getElectronPlaylist(playlistId)) ??
|
||||
((await firstValueFrom(
|
||||
@@ -458,9 +465,37 @@ export class StreamResolverService {
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
serverTimezone: playlist.serverTimezone,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Xtream archive/catch-up URL for a given programme.
|
||||
* Returns the timeshift playback URL, or null when credentials are
|
||||
* missing or the provider doesn't support it.
|
||||
*/
|
||||
async resolveXtreamCatchupUrl(
|
||||
item: UnifiedCollectionItem,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
): Promise<string | null> {
|
||||
const creds = await this.getXtreamCredentials(item.playlistId);
|
||||
if (!creds || !item.xtreamId) return null;
|
||||
|
||||
try {
|
||||
return await this.xtreamUrl.resolveCatchupUrl(
|
||||
item.playlistId,
|
||||
creds,
|
||||
item.xtreamId,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
creds.serverTimezone
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadM3uEpg(
|
||||
item: UnifiedCollectionItem,
|
||||
epgMap: Map<string, EpgProgram | null>,
|
||||
|
||||
+6
@@ -209,6 +209,8 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
title: 'Live One',
|
||||
type: 'live',
|
||||
poster_url: 'live.png',
|
||||
tv_archive: 1,
|
||||
tv_archive_duration: 7,
|
||||
added_at: '2026-03-26T09:00:00.000Z',
|
||||
position: 0,
|
||||
},
|
||||
@@ -247,6 +249,10 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
contentType: 'live',
|
||||
logo: 'live.png',
|
||||
posterUrl: null,
|
||||
// Regression for issue #1138: archive metadata must
|
||||
// survive the global favorites mapping for catch-up.
|
||||
tvArchive: 1,
|
||||
tvArchiveDuration: 7,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'Movie One',
|
||||
|
||||
@@ -609,6 +609,8 @@ export class UnifiedFavoritesDataService {
|
||||
categoryId: row.category_id,
|
||||
tvgId: ct === 'live' ? String(row.xtream_id) : undefined,
|
||||
rating: row.rating ?? undefined,
|
||||
tvArchive: row.tv_archive ?? null,
|
||||
tvArchiveDuration: row.tv_archive_duration ?? null,
|
||||
addedAt:
|
||||
normalizeStalkerDate(row.added_at) || new Date(0).toISOString(),
|
||||
position: row.position ?? 0,
|
||||
@@ -635,6 +637,8 @@ export class UnifiedFavoritesDataService {
|
||||
categoryId: item.category_id,
|
||||
tvgId: ct === 'live' ? String(item.xtream_id) : undefined,
|
||||
rating: item.rating ?? undefined,
|
||||
tvArchive: item.tv_archive ?? null,
|
||||
tvArchiveDuration: item.tv_archive_duration ?? null,
|
||||
addedAt:
|
||||
normalizeStalkerDate(item.added_at ?? item.added) ||
|
||||
new Date(0).toISOString(),
|
||||
|
||||
+33
-1
@@ -355,6 +355,8 @@ describe('UnifiedRecentDataService', () => {
|
||||
poster_url: 'https://example.com/live.png',
|
||||
xtream_id: 290,
|
||||
type: 'live',
|
||||
tv_archive: 1,
|
||||
tv_archive_duration: 3,
|
||||
viewed_at: '2026-04-21T20:42:27.000Z',
|
||||
},
|
||||
{
|
||||
@@ -380,6 +382,10 @@ describe('UnifiedRecentDataService', () => {
|
||||
uid: 'xtream::xtream-1::live:290',
|
||||
contentType: 'live',
|
||||
contentId: 3867578,
|
||||
// Regression for issue #1138: archive metadata must
|
||||
// survive the recent mapping for catch-up.
|
||||
tvArchive: 1,
|
||||
tvArchiveDuration: 3,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
uid: 'xtream::xtream-1::series:290',
|
||||
@@ -697,10 +703,25 @@ describe('UnifiedRecentDataService', () => {
|
||||
playlist_name: 'Xtream One',
|
||||
viewed_at: '2026-04-21 22:49:02',
|
||||
},
|
||||
{
|
||||
id: 3867578,
|
||||
category_id: 11,
|
||||
title: 'Live With Archive',
|
||||
poster_url: 'https://example.com/live.png',
|
||||
xtream_id: 290,
|
||||
type: 'live',
|
||||
tv_archive: 1,
|
||||
tv_archive_duration: 3,
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream One',
|
||||
viewed_at: '2026-04-21 20:42:27',
|
||||
},
|
||||
]);
|
||||
|
||||
const items = await service.getRecentItems('all');
|
||||
const xtreamItem = items.find((item) => item.sourceType === 'xtream');
|
||||
const xtreamItem = items.find(
|
||||
(item) => item.name === 'Unter Nachbarn - 2011'
|
||||
);
|
||||
|
||||
expect(xtreamItem).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -708,5 +729,16 @@ describe('UnifiedRecentDataService', () => {
|
||||
viewedAt: '2026-04-21T22:49:02.000Z',
|
||||
})
|
||||
);
|
||||
// Regression for issue #1138: archive metadata must survive the
|
||||
// global recent mapping for catch-up.
|
||||
expect(items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'Live With Archive',
|
||||
tvArchive: 1,
|
||||
tvArchiveDuration: 3,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -389,6 +389,8 @@ export class UnifiedRecentDataService {
|
||||
xtreamId: row.xtream_id,
|
||||
categoryId: row.category_id,
|
||||
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
|
||||
tvArchive: row.tv_archive ?? null,
|
||||
tvArchiveDuration: row.tv_archive_duration ?? null,
|
||||
contentId: row.id,
|
||||
viewedAt: normalizeStalkerDate(row.viewed_at),
|
||||
}));
|
||||
@@ -428,6 +430,8 @@ export class UnifiedRecentDataService {
|
||||
xtreamId: row.xtream_id,
|
||||
categoryId: row.category_id,
|
||||
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
|
||||
tvArchive: row.tv_archive ?? null,
|
||||
tvArchiveDuration: row.tv_archive_duration ?? null,
|
||||
contentId: row.id,
|
||||
viewedAt: normalizeStalkerDate(row.viewed_at),
|
||||
}));
|
||||
@@ -485,6 +489,8 @@ export class UnifiedRecentDataService {
|
||||
xtreamId: item.xtream_id,
|
||||
categoryId: item.category_id,
|
||||
tvgId: contentType === 'live' ? String(item.xtream_id) : undefined,
|
||||
tvArchive: item.tv_archive ?? null,
|
||||
tvArchiveDuration: item.tv_archive_duration ?? null,
|
||||
contentId: item.id,
|
||||
viewedAt: normalizeStalkerDate(item.viewed_at),
|
||||
};
|
||||
|
||||
+16
@@ -94,6 +94,22 @@ function findCurrentPortalProgram(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert program start/stop to epoch seconds, preferring the
|
||||
* numeric timestamp (which is provider-native) over the ISO string.
|
||||
*/
|
||||
export function toEpochSeconds(
|
||||
timestamp: number | null | undefined,
|
||||
fallbackIso: string
|
||||
): number | null {
|
||||
if (timestamp != null && Number.isFinite(timestamp)) {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
const ms = Date.parse(fallbackIso);
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
|
||||
}
|
||||
|
||||
function getProgramTimeMs(
|
||||
rawDate: string | null | undefined,
|
||||
rawTimestamp?: number | string | null
|
||||
|
||||
+104
@@ -140,6 +140,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
let streamResolver: {
|
||||
resolveLiveDetail: jest.Mock;
|
||||
resolveM3uPlaybackDetail: jest.Mock;
|
||||
resolveXtreamCatchupUrl: jest.Mock;
|
||||
loadM3uProgramsForItem: jest.Mock;
|
||||
loadEpgForItems: jest.Mock;
|
||||
};
|
||||
@@ -164,6 +165,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
streamResolver = {
|
||||
resolveLiveDetail: jest.fn(),
|
||||
resolveM3uPlaybackDetail: jest.fn(),
|
||||
resolveXtreamCatchupUrl: jest.fn().mockResolvedValue(null),
|
||||
loadM3uProgramsForItem: jest.fn().mockResolvedValue([]),
|
||||
loadEpgForItems: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
@@ -1065,6 +1067,108 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Xtream (portal) catch-up (timeshift) playback', () => {
|
||||
const selectXtreamArchiveChannel = async (
|
||||
archive: Partial<UnifiedCollectionItem> = {
|
||||
tvArchive: 1,
|
||||
tvArchiveDuration: 5,
|
||||
}
|
||||
) => {
|
||||
const item = { ...buildLiveItem('xtream'), ...archive };
|
||||
streamResolver.resolveLiveDetail.mockResolvedValue({
|
||||
epgMode: 'portal',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
title: 'Xtream Live',
|
||||
},
|
||||
epgItems: [buildEpgItem('Xtream Show')],
|
||||
});
|
||||
recentData.recordLivePlayback.mockResolvedValue(item);
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
return item;
|
||||
};
|
||||
|
||||
const timeshiftEvent = (): EpgProgramActivationEvent => ({
|
||||
program: buildProgram('Xtream Show'),
|
||||
type: 'timeshift',
|
||||
});
|
||||
|
||||
const queryTimeline = () =>
|
||||
fixture.debugElement.query(By.directive(StubEpgTimelineComponent))
|
||||
.componentInstance as StubEpgTimelineComponent;
|
||||
|
||||
it('exposes the provider archive window to the timeline in days', async () => {
|
||||
// Regression: tv_archive_duration is days (matching
|
||||
// live-stream-layout.controlledArchiveDays), not hours — a
|
||||
// 5-day window must not collapse to ceil(5 / 24) = 1 day.
|
||||
await selectXtreamArchiveChannel({
|
||||
tvArchive: 1,
|
||||
tvArchiveDuration: 5,
|
||||
});
|
||||
|
||||
const timeline = queryTimeline();
|
||||
expect(timeline.archivePlaybackAvailable()).toBe(true);
|
||||
expect(timeline.archiveDays()).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps the archive gate closed when the provider has no archive', async () => {
|
||||
await selectXtreamArchiveChannel({
|
||||
tvArchive: 0,
|
||||
tvArchiveDuration: 0,
|
||||
});
|
||||
|
||||
const timeline = queryTimeline();
|
||||
expect(timeline.archivePlaybackAvailable()).toBe(false);
|
||||
expect(timeline.archiveDays()).toBe(0);
|
||||
});
|
||||
|
||||
it('resolves the catch-up stream with epoch seconds and switches inline playback', async () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
streamResolver.resolveXtreamCatchupUrl.mockResolvedValue(
|
||||
'https://example.com/timeshift.m3u8'
|
||||
);
|
||||
const item = await selectXtreamArchiveChannel();
|
||||
|
||||
component.onTimelineProgramActivated(timeshiftEvent());
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const program = timeshiftEvent().program;
|
||||
expect(streamResolver.resolveXtreamCatchupUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ xtreamId: item.xtreamId }),
|
||||
Math.floor(Date.parse(program.start) / 1000),
|
||||
Math.floor(Date.parse(program.stop) / 1000)
|
||||
);
|
||||
expect(component.inlinePlayback()?.streamUrl).toBe(
|
||||
'https://example.com/timeshift.m3u8'
|
||||
);
|
||||
expect(component.inlinePlayback()?.isLive).toBe(false);
|
||||
expect(component.activeTimeshiftProgram()?.title).toBe(
|
||||
'Xtream Show'
|
||||
);
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows feedback instead of failing silently when the provider rejects catch-up', async () => {
|
||||
streamResolver.resolveXtreamCatchupUrl.mockResolvedValue(null);
|
||||
await selectXtreamArchiveChannel();
|
||||
|
||||
component.onTimelineProgramActivated(timeshiftEvent());
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.activeTimeshift()).toBeNull();
|
||||
expect(snackBar.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeline channel name', () => {
|
||||
it('strips the country prefix when the setting is enabled', () => {
|
||||
stripCountryPrefix.set(true);
|
||||
|
||||
+113
-16
@@ -48,6 +48,7 @@ import {
|
||||
import {
|
||||
getLiveEpgPanelSummary,
|
||||
toEpgProgram,
|
||||
toEpochSeconds,
|
||||
toLiveEpgPanelSummary,
|
||||
} from './unified-live-epg-summary.util';
|
||||
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
|
||||
@@ -114,6 +115,10 @@ export class UnifiedLiveTabComponent {
|
||||
|
||||
readonly activeDetail = signal<ResolvedLiveCollectionDetail | null>(null);
|
||||
readonly activeUid = signal<string | null>(null);
|
||||
/** Full collection item for the active selection — used to read
|
||||
* portal archive fields (tvArchive/tvArchiveDuration) and to
|
||||
* supply credentials for Xtream catch-up URL resolution. */
|
||||
readonly activeItem = signal<UnifiedCollectionItem | null>(null);
|
||||
readonly isSelecting = signal(false);
|
||||
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
|
||||
readonly progressTick = signal(0);
|
||||
@@ -197,22 +202,41 @@ export class UnifiedLiveTabComponent {
|
||||
this.activeDetail()?.playback?.thumbnail ??
|
||||
''
|
||||
);
|
||||
readonly timelineArchiveAvailable = computed(
|
||||
() =>
|
||||
this.isM3uSelection() && this.currentM3uArchivePlaybackAvailable()
|
||||
);
|
||||
readonly timelineArchiveAvailable = computed(() => {
|
||||
if (this.isM3uSelection()) {
|
||||
return this.currentM3uArchivePlaybackAvailable();
|
||||
}
|
||||
|
||||
// Portal archive: tvArchive === 1 means the provider has
|
||||
// timeshift / archive enabled for this channel.
|
||||
const item = this.activeItem();
|
||||
return (
|
||||
Number(item?.tvArchive ?? 0) === 1 &&
|
||||
Number(item?.tvArchiveDuration ?? 0) > 0
|
||||
);
|
||||
});
|
||||
/**
|
||||
* Catch-up window (days) for the active M3U channel, so the timeline can
|
||||
* Catch-up window (days) for the active channel, so the timeline can
|
||||
* gate "Watch" to programmes inside it. Without this the timeline defaults
|
||||
* `archiveDays` to 0 (treated as unlimited) and offers catch-up on
|
||||
* programmes older than the channel's real `catchup-days`/`timeshift`/
|
||||
* `tvg-rec` window. 0 for portal selections (archive is M3U-only here).
|
||||
* programmes older than the real archive window.
|
||||
*
|
||||
* M3U: reads catchup-days / timeshift / tvg-rec from the channel attrs.
|
||||
* Portal: tvArchiveDuration is already in days — pass it through,
|
||||
* matching `live-stream-layout.controlledArchiveDays`.
|
||||
*/
|
||||
readonly timelineArchiveDays = computed(() =>
|
||||
this.timelineArchiveAvailable()
|
||||
? getM3uArchiveDays(this.currentM3uChannel())
|
||||
: 0
|
||||
);
|
||||
readonly timelineArchiveDays = computed(() => {
|
||||
if (!this.timelineArchiveAvailable()) return 0;
|
||||
|
||||
if (this.isM3uSelection()) {
|
||||
return getM3uArchiveDays(this.currentM3uChannel());
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
Number(this.activeItem()?.tvArchiveDuration ?? 0) || 0
|
||||
);
|
||||
});
|
||||
readonly activeRadioChannel = computed(() => {
|
||||
const channel = this.activeDetail()?.channel ?? null;
|
||||
return channel?.radio === 'true' ? channel : null;
|
||||
@@ -389,9 +413,20 @@ export class UnifiedLiveTabComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isM3uSelection()) {
|
||||
this.activateM3uCatchup(event.program);
|
||||
} else {
|
||||
void this.activatePortalCatchup(event.program);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M3U catch-up: resolve via the M3U timeshift URL resolver.
|
||||
*/
|
||||
private activateM3uCatchup(program: EpgProgram): void {
|
||||
const playbackUrl = resolveM3uCatchupUrl(
|
||||
this.currentM3uChannel(),
|
||||
event.program
|
||||
program
|
||||
);
|
||||
if (!playbackUrl) {
|
||||
this.snackBar.open(
|
||||
@@ -402,10 +437,70 @@ export class UnifiedLiveTabComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeTimeshift.set({ url: playbackUrl, program: event.program });
|
||||
this.activeTimeshift.set({ url: playbackUrl, program });
|
||||
|
||||
const playback = this.activeDetail()?.playback;
|
||||
if (!this.shouldUseInlinePlayer() && playback) {
|
||||
void this.portalPlayer.openResolvedPlayback({
|
||||
...playback,
|
||||
streamUrl: playbackUrl,
|
||||
isLive: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal (Xtream) catch-up: compute start/stop as epoch seconds and
|
||||
* resolve via the provider's timeshift endpoint.
|
||||
*/
|
||||
private async activatePortalCatchup(program: EpgProgram): Promise<void> {
|
||||
const requestId = this.selectionRequestId;
|
||||
const item = this.activeItem();
|
||||
if (!item?.xtreamId) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
|
||||
undefined,
|
||||
{ duration: 4000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const startEpoch = toEpochSeconds(
|
||||
program.startTimestamp,
|
||||
program.start
|
||||
);
|
||||
const stopEpoch = toEpochSeconds(
|
||||
program.stopTimestamp,
|
||||
program.stop
|
||||
);
|
||||
if (startEpoch == null || stopEpoch == null) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
|
||||
undefined,
|
||||
{ duration: 4000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const playbackUrl = await this.streamResolver.resolveXtreamCatchupUrl(
|
||||
item,
|
||||
startEpoch,
|
||||
stopEpoch
|
||||
);
|
||||
if (requestId !== this.selectionRequestId) {
|
||||
return; // switched channel — discard silently
|
||||
}
|
||||
if (!playbackUrl) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
|
||||
undefined,
|
||||
{ duration: 4000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeTimeshift.set({ url: playbackUrl, program });
|
||||
|
||||
// No inline player (external MPV/VLC configured) → hand the archive
|
||||
// stream to the external player, mirroring the live-selection flow.
|
||||
const playback = this.activeDetail()?.playback;
|
||||
if (!this.shouldUseInlinePlayer() && playback) {
|
||||
void this.portalPlayer.openResolvedPlayback({
|
||||
@@ -441,6 +536,7 @@ export class UnifiedLiveTabComponent {
|
||||
this.isSelecting.set(false);
|
||||
this.activeDetail.set(null);
|
||||
this.activeUid.set(null);
|
||||
this.activeItem.set(null);
|
||||
this.activeTimeshift.set(null);
|
||||
}
|
||||
|
||||
@@ -472,6 +568,7 @@ export class UnifiedLiveTabComponent {
|
||||
|
||||
const requestId = ++this.selectionRequestId;
|
||||
this.activeUid.set(item.uid);
|
||||
this.activeItem.set(item);
|
||||
this.activeDetail.set(null);
|
||||
this.activeTimeshift.set(null);
|
||||
this.isSelecting.set(true);
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface XtreamFavoriteRow {
|
||||
readonly type: string;
|
||||
readonly poster_url?: string | null;
|
||||
readonly rating?: string | null;
|
||||
readonly tv_archive?: number | null;
|
||||
readonly tv_archive_duration?: number | null;
|
||||
readonly added_at?: string | null;
|
||||
readonly position?: number | null;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ export interface UnifiedCollectionItem {
|
||||
/** Xtream DB content id — used for reorder / remove IPC */
|
||||
contentId?: number;
|
||||
|
||||
/** Whether the Xtream provider has timeshift / archive enabled for this stream */
|
||||
tvArchive?: number | null;
|
||||
/** Timeshift / archive window in days — passed through as-is, matching
|
||||
* `live-stream-layout.controlledArchiveDays`. */
|
||||
tvArchiveDuration?: number | null;
|
||||
|
||||
/** Stalker cmd for stream resolution */
|
||||
stalkerId?: string | number;
|
||||
stalkerCmd?: string;
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface Playlist {
|
||||
recentlyViewed?: PlaylistRecentlyViewedItem[];
|
||||
/** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */
|
||||
isFullStalkerPortal?: boolean;
|
||||
/** Xtream server timezone string for catch-up URL construction */
|
||||
serverTimezone?: string;
|
||||
/** Session token for full stalker portal authentication - persisted for session */
|
||||
stalkerToken?: string;
|
||||
/** Serial number for stalker portal - generated once and stored for consistency */
|
||||
|
||||
@@ -64,10 +64,19 @@ describe('epg-archive.util', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies when the programme is not in the past', () => {
|
||||
it('allows start-over on the currently-airing programme', () => {
|
||||
expect(
|
||||
canCatchUpProgramme('now', inWindowStart, true, 7, NOW)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies start-over when archive playback is unavailable', () => {
|
||||
expect(
|
||||
canCatchUpProgramme('now', inWindowStart, false, 7, NOW)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies future programmes', () => {
|
||||
expect(
|
||||
canCatchUpProgramme('future', NOW + 60_000, true, 7, NOW)
|
||||
).toBe(false);
|
||||
@@ -91,8 +100,8 @@ describe('epg-archive.util', () => {
|
||||
expect(epgDialogActionFor('now', false)).toBe('live');
|
||||
});
|
||||
|
||||
it('prefers live over timeshift for an on-air programme', () => {
|
||||
expect(epgDialogActionFor('now', true)).toBe('live');
|
||||
it('prefers start-over timeshift for a catch-up-able on-air programme', () => {
|
||||
expect(epgDialogActionFor('now', true)).toBe('timeshift');
|
||||
});
|
||||
|
||||
it('maps a catch-up-able past programme to timeshift', () => {
|
||||
|
||||
@@ -21,7 +21,8 @@ export function isWithinArchiveWindow(
|
||||
return startMs >= nowMs - archiveDays * DAY_MS;
|
||||
}
|
||||
|
||||
/** Whether a past programme is playable from the catch-up archive. */
|
||||
/** Whether a programme is playable from the catch-up archive —
|
||||
* both past programmes and the currently-airing programme (start-over). */
|
||||
export function canCatchUpProgramme(
|
||||
when: TimelineWhen,
|
||||
startMs: number,
|
||||
@@ -31,7 +32,7 @@ export function canCatchUpProgramme(
|
||||
): boolean {
|
||||
return (
|
||||
archivePlaybackAvailable &&
|
||||
when === 'past' &&
|
||||
(when === 'past' || when === 'now') &&
|
||||
isWithinArchiveWindow(startMs, archiveDays, nowMs)
|
||||
);
|
||||
}
|
||||
@@ -41,7 +42,7 @@ export function epgDialogActionFor(
|
||||
when: TimelineWhen,
|
||||
canCatchUp: boolean
|
||||
): EpgItemDialogAction | null {
|
||||
if (when === 'now') {
|
||||
if (when === 'now' && !canCatchUp) {
|
||||
return 'live';
|
||||
}
|
||||
if (canCatchUp) {
|
||||
|
||||
@@ -109,7 +109,7 @@ function toRenderBlock(
|
||||
nowFillPercent: nowFillFor(block, nowMs),
|
||||
canCatchUp:
|
||||
archivePlaybackAvailable &&
|
||||
block.when === 'past' &&
|
||||
(block.when === 'past' || block.when === 'now') &&
|
||||
block.startMs >= archiveWindowStartMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +80,15 @@
|
||||
<span class="epg-timeline__tag is-now">{{
|
||||
'EPG.TIMELINE.ON_NOW' | translate
|
||||
}}</span>
|
||||
@if (item.canCatchUp) {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-timeline__watch"
|
||||
(click)="watchClick.emit(item.block); $event.stopPropagation()"
|
||||
>
|
||||
<mat-icon>replay</mat-icon>
|
||||
</button>
|
||||
}
|
||||
} @else if (isPlaying(item)) {
|
||||
<span class="epg-timeline__tag is-arch">{{
|
||||
'EPG.TIMELINE.FROM_ARCHIVE' | translate
|
||||
|
||||
@@ -167,6 +167,9 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.epg-timeline__block-time {
|
||||
|
||||
@@ -316,15 +316,15 @@ export class EpgTimelineComponent {
|
||||
|
||||
onBlockClick(block: TimelineBlock): void {
|
||||
this.selectedKey.set(block.key);
|
||||
if (block.when === 'now') {
|
||||
this.returnToLive.emit();
|
||||
return;
|
||||
}
|
||||
if (this.canCatchUp(block)) {
|
||||
this.programActivated.emit({
|
||||
program: block.program,
|
||||
type: 'timeshift',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (block.when === 'now') {
|
||||
this.returnToLive.emit();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user