feat(downloads): save completed Xtream archive programmes as TS

This commit is contained in:
4gray committed 2026-09-07 21:32:58 +02:00
1 parent dcaeb26849
commit 316243f45d
74 files changed
+1678 -269

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: feature
area: downloads
---
Download completed Xtream catch-up programmes as TS files from Live TV programme details. Find them in Downloads with the channel and broadcast date and play them offline. Interrupted archive downloads restart from the beginning.
+12
View File
@@ -1027,3 +1027,15 @@ Xtream/M3U archives, including Favorites/Recent. `EpgArchiveCopyService` owns
clipboard feedback; hosts resolve URLs without mutating playback. Stalker and
the currently non-catch-up M3U guide expose no action. See
`docs/architecture/m3u-playlist-module.md` (Copy archive URL).
## Xtream Archive Downloads
Desktop Xtream Live TV programme details can enqueue completed catch-up as
`contentType: catchup`. The queue uses the existing timeshift resolver, original
timestamps and playback headers. `programme_start` plus playlist/channel provides
identity; JSON `catchup` metadata retains channel, broadcast window and known
expiry. `download-schema.ts` owns the transactional CHECK/index migration.
Archive transfers validate TS framing, restart from byte zero after interruption
and check expiry again at transfer start. Completed cards play locally and never
route to VOD details. Contract and EOF/duration limits:
`docs/architecture/download-manager.md` (Xtream archive downloads).
+12
View File
@@ -1838,3 +1838,15 @@ Xtream/M3U archives, including Favorites/Recent. `EpgArchiveCopyService` owns
clipboard feedback; hosts resolve URLs without mutating playback. Stalker and
the currently non-catch-up M3U guide expose no action. See
`docs/architecture/m3u-playlist-module.md` (Copy archive URL).
## Xtream Archive Downloads
Desktop Xtream Live TV programme details can enqueue completed catch-up as
`contentType: catchup`. The queue uses the existing timeshift resolver, original
timestamps and playback headers. `programme_start` plus playlist/channel provides
identity; JSON `catchup` metadata retains channel, broadcast window and known
expiry. `download-schema.ts` owns the transactional CHECK/index migration.
Archive transfers validate TS framing, restart from byte zero after interruption
and check expiry again at transfer start. Completed cards play locally and never
route to VOD details. Contract and EOF/duration limits:
`docs/architecture/download-manager.md` (Xtream archive downloads).
@@ -1,3 +1,9 @@
import {
getDownloadPlayPaths,
installDownloadPlayCapture,
} from './downloads.e2e-support';
import { mkdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
@@ -308,3 +314,132 @@ test('@epg @xtream @electron copies the archive URL without starting archive pla
await closeElectronApp(app);
}
});
test('@downloads @epg @xtream @electron downloads a completed archive into the local library', async ({
dataDir,
request,
}) => {
test.setTimeout(120000);
const credentials = { username: 'epg', password: 'epg' };
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamEpgFixture(request, credentials);
let app = await launchElectronApp(dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
try {
const folder = join(dataDir, 'archive-downloads');
mkdirSync(folder, { recursive: true });
await app.electronApp.evaluate(({ dialog }, target) => {
dialog.showOpenDialog = async () =>
({ canceled: false, filePaths: [target] }) as Awaited<
ReturnType<typeof dialog.showOpenDialog>
>;
}, folder);
await app.mainWindow.evaluate(async () => {
await window.electron.downloadsSelectFolder();
});
await addXtreamPortal(app.mainWindow, {
name: 'Archive downloads',
...credentials,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openTimezoneNewsInLiveTv(app.mainWindow, fixture.categoryName);
const captured = captureTimeshiftRequests(app.mainWindow);
const block = app.mainWindow
.locator('app-epg-timeline .epg-timeline__block')
.filter({ hasText: PAST_PROGRAM })
.first();
await expect(block).toBeVisible({ timeout: 20000 });
await block.locator('.epg-timeline__info').click();
await app.mainWindow
.getByRole('dialog')
.getByRole('button', {
name: 'Download programme (TS)',
exact: true,
})
.click();
await expect(
app.mainWindow.getByText('Programme added to Downloads', {
exact: true,
})
).toBeVisible();
await expect
.poll(
async () => {
const rows = await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
);
return rows.find((row) => row.contentType === 'catchup')
?.status;
},
{ timeout: 30000 }
)
.toBe('completed');
const rows = await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
);
const row = rows.find((entry) => entry.contentType === 'catchup');
expect(row?.catchup?.channelName).toBe(CHANNEL);
expect(row?.title).toBe(PAST_PROGRAM);
expect(row?.filePath).toMatch(/archive-downloads.*\.ts$/);
if (!row?.filePath) throw new Error('No archive file');
expect(readFileSync(row.filePath)).toEqual(
readFileSync('apps/xtream-mock-server/src/fixtures/live.mpegts')
);
expect(captured).toEqual([]);
// Repeated clicks on the same programme reuse its identity.
await block.locator('.epg-timeline__info').click();
await app.mainWindow
.getByRole('dialog')
.getByRole('button', {
name: 'Download programme (TS)',
exact: true,
})
.click();
await expect(
app.mainWindow.getByText(
'This programme is already in Downloads.',
{ exact: true }
)
).toBeVisible();
expect(
await app.mainWindow.evaluate(
async () => (await window.electron.downloadsGetList()).length
)
).toBe(1);
await app.mainWindow
.getByRole('button', { name: 'Open downloads', exact: true })
.click();
const card = app.mainWindow.getByTestId(
`download-library-catchup-${row.id}`
);
await expect(card).toBeVisible();
await expect(card).toContainText('TV programme');
await expect(card).toContainText(CHANNEL);
await app.mainWindow.screenshot({
path: test.info().outputPath('archive-download-library.png'),
});
await installDownloadPlayCapture(app);
await card
.getByRole('button', { name: `Play: ${PAST_PROGRAM}`, exact: true })
.click();
await expect
.poll(() => getDownloadPlayPaths(app))
.toEqual([row.filePath]);
await expect(app.mainWindow).toHaveURL(
/\/workspace\/downloads(?:\?.*)?$/
);
// Library remains usable after a restart, including archive metadata.
app = await restartElectronApp(app, dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
await app.mainWindow
.getByRole('button', { name: 'Open downloads', exact: true })
.click();
await expect(
app.mainWindow.getByTestId(`download-library-catchup-${row.id}`)
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
@@ -0,0 +1,154 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough, Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import {
createTsValidator,
transferCatchupToPartialFile,
} from './download-catchup-transfer';
import { requestWithValidatedRedirects } from '../../util/validated-axios';
import type { DownloadsDatabase, DownloadTask } from './download-task';
import { getCompletedPartialProgress } from './download-finalize';
jest.mock('../../util/validated-axios', () => ({
requestWithValidatedRedirects: jest.fn(),
}));
jest.mock('./download-transfer-persistence', () => ({
persistProgress: jest.fn().mockResolvedValue(undefined),
persistTransferStart: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('./download-broadcast', () => ({
broadcastDownloadUpdate: jest.fn(),
}));
const packets = Buffer.alloc(188 * 10, 0xff);
for (let i = 0; i < packets.length; i += 188) packets[i] = 0x47;
const metadata = {
channelName: 'News',
startTimestamp: Math.floor(Date.now() / 1000) - 7200,
stopTimestamp: Math.floor(Date.now() / 1000) - 3600,
};
const sink = () =>
new Writable({
write(_chunk, _encoding, callback) {
callback();
},
});
describe('TS archive transfer', () => {
it('accepts TS packet boundaries split across arbitrary chunks', async () => {
await pipeline(
Readable.from([packets.subarray(0, 199), packets.subarray(199)]),
createTsValidator(),
sink()
);
});
it.each([
Buffer.from('#EXTM3U\n#EXTINF:10,\na.ts'),
Buffer.from('<html>expired</html>'),
packets.subarray(0, packets.length - 1),
Buffer.alloc(0),
])(
'rejects playlists, error bodies and truncated packets',
async (body) => {
await expect(
pipeline(Readable.from([body]), createTsValidator(), sink())
).rejects.toThrow();
}
);
it('does not consider a byte-complete retained archive safe to finalize', () => {
expect(
getCompletedPartialProgress({
id: 1,
url: 'https://host/1.ts',
fileName: 'a.ts',
directory: '/tmp',
filePath: '/tmp/a.ts',
totalBytes: 1880,
catchup: metadata,
})
).toBeNull();
});
it('overwrites retained bytes and requests a fresh response without Range', async () => {
const dir = await mkdtemp(join(tmpdir(), 'archive-transfer-'));
const path = join(dir, 'show.ts');
const task: DownloadTask = {
id: 1,
url: 'https://host/archive.ts',
fileName: 'show.ts',
directory: dir,
catchup: metadata,
totalBytes: 999,
resumeValidator: 'old',
headers: { 'User-Agent': 'custom' },
};
try {
await writeFile(path + '.part', 'old data');
jest.mocked(requestWithValidatedRedirects).mockResolvedValue({
status: 200,
headers: {
'content-length': String(packets.length),
'content-type': 'video/mp2t',
},
data: Readable.from([packets]),
} as never);
const progress = await transferCatchupToPartialFile(
{} as DownloadsDatabase,
task,
{ path, partialPath: path + '.part', filename: 'show.ts' }
);
expect(progress).toEqual({
bytesDownloaded: packets.length,
totalBytes: packets.length,
});
expect(await readFile(path + '.part')).toEqual(packets);
expect(requestWithValidatedRedirects).toHaveBeenLastCalledWith(
task.url,
expect.objectContaining({
headers: {
'User-Agent': 'custom',
'Accept-Encoding': 'identity',
},
}),
expect.anything()
);
expect(task.resumeValidator).toBeNull();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('aborts a stalled response and never reports completion', async () => {
const dir = await mkdtemp(join(tmpdir(), 'archive-abort-'));
const task: DownloadTask = {
id: 2,
url: 'https://host/archive.ts',
fileName: 'show.ts',
directory: dir,
catchup: metadata,
};
const body = new PassThrough();
jest.mocked(requestWithValidatedRedirects).mockResolvedValue({
status: 200,
headers: {},
data: body,
} as never);
try {
const transfer = transferCatchupToPartialFile(
{} as DownloadsDatabase,
task,
{
path: join(dir, 'show.ts'),
partialPath: join(dir, 'show.ts.part'),
filename: 'show.ts',
}
);
const rejected = expect(transfer).rejects.toThrow('interrupted');
await new Promise((resolve) => setImmediate(resolve));
task.abortController?.abort();
await rejected;
expect(body.destroyed).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,137 @@
import { createWriteStream } from 'node:fs';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { requestWithValidatedRedirects } from '../../util/validated-axios';
import { validateCatchupDownload } from './download-catchup';
import type { ReservedPartialDownloadFile } from './download-file-path';
import type {
DownloadsDatabase,
DownloadTask,
TransferProgress,
} from './download-task';
import {
persistProgress,
persistTransferStart,
} from './download-transfer-persistence';
/** Check every 188-byte MPEG-TS packet without retaining the media in memory. */
export function createTsValidator(): Transform {
let bytes = 0;
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
for (
let offset = (188 - (bytes % 188)) % 188;
offset < chunk.length;
offset += 188
) {
if (chunk[offset] !== 0x47) {
callback(
new Error(
'The provider did not return a valid TS archive'
)
);
return;
}
}
bytes += chunk.length;
callback(null, chunk);
},
flush(callback) {
callback(
bytes < 188 * 3 || bytes % 188 !== 0
? new Error('The archive stream is empty or incomplete')
: undefined
);
},
});
}
export async function transferCatchupToPartialFile(
db: DownloadsDatabase,
task: DownloadTask,
reservation: ReservedPartialDownloadFile
): Promise<TransferProgress> {
const metadata = validateCatchupDownload(task.catchup, task.url);
const controller = new AbortController();
task.abortController = controller;
if (task.cancelRequested || task.pauseRequested) controller.abort();
// An archive endpoint must terminate. Bound both stalled and endless streams.
const timeoutMs = Math.min(
24 * 3600_000,
(metadata.stopTimestamp - metadata.startTimestamp) * 2000 + 600_000
);
const timer = setTimeout(() => controller.abort(), timeoutMs);
let readable: Readable | undefined;
let pendingProgress = Promise.resolve();
try {
const response = await requestWithValidatedRedirects<Readable>(
task.url,
{
headers: { ...task.headers, 'Accept-Encoding': 'identity' },
method: 'GET',
responseType: 'stream',
decompress: false,
signal: controller.signal,
timeout: 30_000,
validateStatus: (status) => status === 200,
},
{ allowPrivateNetworks: true }
);
readable = response.data;
readable.on('error', () => undefined);
const contentType = String(response.headers['content-type'] ?? '');
if (
/mpegurl|html|json|xml|text\/plain/i.test(contentType) ||
(response.headers['content-encoding'] &&
response.headers['content-encoding'] !== 'identity')
) {
throw new Error('The provider did not return a TS archive');
}
const length = Number(response.headers['content-length']);
const totalBytes =
Number.isSafeInteger(length) && length > 0 ? length : null;
task.totalBytes = totalBytes;
task.resumeValidator = null;
await persistTransferStart(db, task, 0, totalBytes);
let bytesDownloaded = 0;
let lastProgress = Date.now();
const progress = new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytesDownloaded += chunk.length;
if (Date.now() - lastProgress >= 500) {
lastProgress = Date.now();
const snapshot = { bytesDownloaded, totalBytes };
pendingProgress = pendingProgress.then(() =>
persistProgress(db, task, snapshot)
);
// Apply backpressure while persisting, so no updates outlive finalization.
pendingProgress.then(
() => callback(null, chunk),
(error: Error) => callback(error)
);
} else callback(null, chunk);
},
});
await pipeline(
readable,
createTsValidator(),
progress,
createWriteStream(reservation.partialPath, { flags: 'w' }),
{ signal: controller.signal }
);
if (totalBytes !== null && bytesDownloaded !== totalBytes) {
throw new Error('The archive stream ended before it was complete');
}
return { bytesDownloaded, totalBytes: totalBytes ?? bytesDownloaded };
} catch {
// Network errors can embed credential-bearing request URLs.
throw new Error(
'Archive download failed: the stream was interrupted, expired, or is not a complete TS response. Retry starts from the beginning.'
);
} finally {
clearTimeout(timer);
readable?.on('error', () => undefined);
readable?.destroy();
await pendingProgress.catch(() => undefined);
}
}
@@ -0,0 +1,37 @@
import { validateCatchupDownload } from './download-catchup';
describe('catch-up download validation', () => {
const now = 1788800000;
const metadata = {
channelName: 'News',
startTimestamp: now - 7200,
stopTimestamp: now - 3600,
expiresAt: now + 86400,
};
it('accepts a completed programme inside its archive window', () => {
expect(
validateCatchupDownload(
metadata,
'https://host/timeshift/u/p/60/date/1.ts',
now
)
).toEqual(metadata);
});
it.each([
{ ...metadata, stopTimestamp: now + 1 },
{ ...metadata, stopTimestamp: metadata.startTimestamp },
{ ...metadata, startTimestamp: NaN },
{ ...metadata, expiresAt: now },
null,
])('refuses incomplete, invalid or expired programmes', (data) => {
expect(() =>
validateCatchupDownload(data, 'https://host/1.ts', now)
).toThrow();
});
it.each([
'https://host/archive.m3u8',
'https://host/timeshift.php?extension=m3u8',
])('refuses HLS URLs', (url) => {
expect(() => validateCatchupDownload(metadata, url, now)).toThrow(/TS/);
});
});
@@ -0,0 +1,49 @@
import type { CatchupDownloadMetadata } from '@iptvnator/shared/interfaces';
export function validateCatchupDownload(
value: CatchupDownloadMetadata | null | undefined,
url: string,
now = Math.floor(Date.now() / 1000)
): CatchupDownloadMetadata {
if (
!value ||
typeof value.channelName !== 'string' ||
!Number.isSafeInteger(value.startTimestamp) ||
value.startTimestamp <= 0 ||
!Number.isSafeInteger(value.stopTimestamp) ||
value.stopTimestamp <= value.startTimestamp ||
value.stopTimestamp > now
) {
throw new Error('Only completed archive programmes can be downloaded');
}
if (
value.expiresAt !== undefined &&
(!Number.isSafeInteger(value.expiresAt) || value.expiresAt <= now)
) {
throw new Error('This programme is no longer available in the archive');
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error('Invalid archive URL');
}
if (
!['http:', 'https:'].includes(parsed.protocol) ||
/\.m3u8$/i.test(parsed.pathname) ||
[...parsed.searchParams.values()].some((v) => /^m3u8$/i.test(v))
) {
throw new Error('Archive downloads currently require a TS stream');
}
return value;
}
export function catchupForDownload(item: {
contentType: string;
catchup?: CatchupDownloadMetadata | null;
url: string;
}): CatchupDownloadMetadata | undefined {
return item.contentType === 'catchup'
? validateCatchupDownload(item.catchup, item.url)
: undefined;
}
@@ -263,6 +263,7 @@ export async function getExistingCompletedFileProgress(
export function getCompletedPartialProgress(
task: DownloadTask
): CompletedPartialProgress | null {
if (task.catchup) return null;
if (
!task.filePath ||
task.totalBytes === null ||
@@ -1,3 +1,4 @@
import { catchupForDownload } from './download-catchup';
import { and, eq, sql } from 'drizzle-orm';
import { accessSync, constants } from 'node:fs';
import { basename, dirname } from 'node:path';
@@ -60,6 +61,7 @@ export async function redownloadMissingRequest(
return { error: 'Download folder is unavailable', success: false };
}
const catchup = catchupForDownload(item);
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
const headers = await resolveStoredDownloadHeaders(db, item);
@@ -100,6 +102,7 @@ export async function redownloadMissingRequest(
}
enqueueDownload({
catchup,
directory: dirname(item.filePath),
fileName: basename(item.filePath),
filePath: item.filePath,
@@ -18,6 +18,8 @@ const episodeRequest = {
function createDownloadRow(overrides: Partial<DownloadRow> = {}): DownloadRow {
return {
catchup: null,
programmeStart: 0,
bytesDownloaded: 0,
contentType: 'episode',
createdAt: '2026-08-02 10:00:00',
@@ -1,5 +1,8 @@
import { and, eq, isNull, or } from 'drizzle-orm';
import type { ElectronBridgeEpisodeIdentityScope } from '@iptvnator/shared/interfaces';
import type {
CatchupDownloadMetadata,
ElectronBridgeEpisodeIdentityScope,
} from '@iptvnator/shared/interfaces';
import * as schema from '../../database/schema';
import type { DownloadsDatabase } from './download-task';
@@ -29,6 +32,7 @@ export type ExistingDownloadIdentityResolution =
DownloadIdentityNone | DownloadIdentityConflict | DownloadIdentityMatch;
export interface DownloadIdentityRequest {
catchup?: CatchupDownloadMetadata | null;
contentType: DownloadRow['contentType'];
episodeNumber?: number;
episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope;
@@ -92,7 +96,13 @@ export async function resolveExistingDownloadIdentity(
and(
eq(schema.downloads.playlistId, request.playlistId),
eq(schema.downloads.contentType, request.contentType),
eq(schema.downloads.xtreamId, request.xtreamId)
eq(schema.downloads.xtreamId, request.xtreamId),
request.contentType === 'catchup'
? eq(
schema.downloads.programmeStart,
request.catchup?.startTimestamp ?? 0
)
: undefined
)
)
.limit(1);
@@ -0,0 +1,75 @@
import type {
CatchupDownloadMetadata,
DownloadContentType,
DownloadMetadataSnapshot,
ElectronBridgeEpisodeIdentityScope,
} from '@iptvnator/shared/interfaces';
import { extname } from 'node:path';
export interface StartDownloadRequest {
playlistId: string;
xtreamId: number;
contentType: DownloadContentType;
catchup?: CatchupDownloadMetadata | null;
title: string;
url: string;
posterUrl?: string;
metadataSnapshot?: DownloadMetadataSnapshot;
downloadFolder: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope;
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
}
export function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
}
function getExtensionFromUrl(url: string): string {
try {
// Sanitize too: URL pathnames may legally contain characters like ':'
// that would create NTFS alternate data streams on Windows.
const extension = sanitizeFilename(extname(new URL(url).pathname));
return extension.startsWith('.') ? extension : '.mp4';
} catch {
return '.mp4';
}
}
export function createFileName(title: string, url: string): string {
return sanitizeFilename(title) + getExtensionFromUrl(url);
}
export function createHeaders(
headers: StartDownloadRequest['headers']
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
const result: Record<string, string> = {};
if (headers.userAgent) {
result['User-Agent'] = headers.userAgent;
}
if (headers.origin) {
result.Origin = headers.origin;
}
if (headers.referer) {
result.Referer = headers.referer;
}
return Object.keys(result).length > 0 ? result : undefined;
}
export function serializeHeaders(
headers: Record<string, string> | undefined
): string | null {
return headers ? JSON.stringify(headers) : null;
}
@@ -86,6 +86,8 @@ async function setupStartMetadataRequest(
function createStartDownloadRow(overrides: Partial<Download> = {}): Download {
return {
catchup: null,
programmeStart: 0,
bytesDownloaded: 0,
contentType: 'episode',
createdAt: '2026-08-02 10:00:00',
@@ -1,11 +1,16 @@
import type {
DownloadMetadataSnapshot,
ElectronBridgeEpisodeIdentityScope,
ElectronBridgeDownloadStartResult,
} from '@iptvnator/shared/interfaces';
import {
sanitizeFilename,
createFileName,
createHeaders,
serializeHeaders,
type StartDownloadRequest,
} from './download-request-options';
export type { StartDownloadRequest } from './download-request-options';
import { catchupForDownload } from './download-catchup';
import type { ElectronBridgeDownloadStartResult } from '@iptvnator/shared/interfaces';
import { ELECTRON_BRIDGE_DOWNLOAD_START_REASONS } from '@iptvnator/shared/interfaces';
import { and, eq, sql } from 'drizzle-orm';
import { basename, dirname, extname } from 'node:path';
import { basename, dirname } from 'node:path';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { assertRemoteUrlAllowed } from '../url-safety';
@@ -22,77 +27,11 @@ import {
} from './download-metadata-snapshot';
import { enqueueDownload } from './download-runtime';
export interface StartDownloadRequest {
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
title: string;
url: string;
posterUrl?: string;
metadataSnapshot?: DownloadMetadataSnapshot;
downloadFolder: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope;
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
}
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
}
function getExtensionFromUrl(url: string): string {
try {
// Sanitize too: URL pathnames may legally contain characters like ':'
// that would create NTFS alternate data streams on Windows.
const extension = sanitizeFilename(extname(new URL(url).pathname));
return extension.startsWith('.') ? extension : '.mp4';
} catch {
return '.mp4';
}
}
function createFileName(title: string, url: string): string {
return sanitizeFilename(title) + getExtensionFromUrl(url);
}
function createHeaders(
headers: StartDownloadRequest['headers']
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
const result: Record<string, string> = {};
if (headers.userAgent) {
result['User-Agent'] = headers.userAgent;
}
if (headers.origin) {
result.Origin = headers.origin;
}
if (headers.referer) {
result.Referer = headers.referer;
}
return Object.keys(result).length > 0 ? result : undefined;
}
function serializeHeaders(
headers: Record<string, string> | undefined
): string | null {
return headers ? JSON.stringify(headers) : null;
}
export async function startDownloadRequest(
data: StartDownloadRequest,
authorizer: DownloadDirectoryAuthorizer
): Promise<ElectronBridgeDownloadStartResult> {
const catchup = catchupForDownload(data);
const encodedMetadataSnapshot =
data.metadataSnapshot === undefined
? undefined
@@ -128,7 +67,9 @@ export async function startDownloadRequest(
success: false,
};
}
const fileName = createFileName(data.title, data.url);
const fileName = catchup
? sanitizeFilename(data.title) + '.ts'
: createFileName(data.title, data.url);
const headers = createHeaders(data.headers);
if (identity.kind === 'match') {
@@ -147,7 +88,11 @@ export async function startDownloadRequest(
data.url
);
}
if (item.contentType === 'episode' && item.status === 'completed') {
if (
(item.contentType === 'episode' ||
item.contentType === 'catchup') &&
item.status === 'completed'
) {
const completedFileAvailability =
await getDownloadFileAvailabilityWithTimeoutAsync(item);
if (completedFileAvailability === 'unknown') {
@@ -198,6 +143,8 @@ export async function startDownloadRequest(
await db
.update(schema.downloads)
.set({
catchup,
programmeStart: catchup?.startTimestamp ?? 0,
bytesDownloaded: 0,
errorMessage: null,
fileName,
@@ -220,6 +167,7 @@ export async function startDownloadRequest(
})
.where(eq(schema.downloads.id, item.id));
enqueueDownload({
catchup,
directory,
fileName,
headers,
@@ -256,6 +204,8 @@ export async function startDownloadRequest(
const result = await db.insert(schema.downloads).values({
contentType: data.contentType,
catchup,
programmeStart: catchup?.startTimestamp ?? 0,
episodeNumber: data.episodeNumber,
episodeIdentityScope: data.episodeIdentityScope,
fileName,
@@ -272,6 +222,7 @@ export async function startDownloadRequest(
});
const insertedId = Number(result.lastInsertRowid);
enqueueDownload({
catchup,
directory,
fileName,
headers,
@@ -299,6 +250,7 @@ export async function retryDownloadRequest(
}
const item = existing[0];
const catchup = catchupForDownload(item);
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
if (!['failed', 'canceled'].includes(item.status)) {
return {
@@ -317,7 +269,9 @@ export async function retryDownloadRequest(
: await authorizer.requireAuthorized(downloadFolder);
const fileName = retainedFilePath
? basename(retainedFilePath)
: createFileName(item.title, item.url);
: catchup
? sanitizeFilename(item.title) + '.ts'
: createFileName(item.title, item.url);
const headers = await resolveStoredDownloadHeaders(db, item);
const queuedUpdate = retainedFilePath
? {
@@ -341,6 +295,7 @@ export async function retryDownloadRequest(
.set(queuedUpdate)
.where(eq(schema.downloads.id, downloadId));
enqueueDownload({
catchup,
directory,
fileName,
filePath: retainedFilePath,
@@ -371,6 +326,7 @@ export async function resumeDownloadRequest(
}
const item = existing[0];
const catchup = catchupForDownload(item);
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
if (item.status !== 'paused') {
return {
@@ -386,7 +342,9 @@ export async function resumeDownloadRequest(
: await authorizer.requireAuthorized(downloadFolder);
const fileName = item.filePath
? basename(item.filePath)
: createFileName(item.title, item.url);
: catchup
? sanitizeFilename(item.title) + '.ts'
: createFileName(item.title, item.url);
const headers = await resolveStoredDownloadHeaders(db, item);
// Claim the row atomically: a concurrent resume for the same id loses
@@ -413,6 +371,7 @@ export async function resumeDownloadRequest(
}
enqueueDownload({
catchup,
directory,
fileName,
filePath: item.filePath,
@@ -1,3 +1,4 @@
import { transferCatchupToPartialFile } from './download-catchup-transfer';
import { eq, sql } from 'drizzle-orm';
import { existsSync } from 'node:fs';
import { rename } from 'node:fs/promises';
@@ -239,7 +240,9 @@ async function startDownload(task: DownloadTask): Promise<void> {
return;
}
const progress = await transferWithReconnects(db, task, reservation);
const progress = await (task.catchup
? transferCatchupToPartialFile(db, task, reservation)
: transferWithReconnects(db, task, reservation));
if (task.cancelRequested) {
await persistCancellation(db, task);
return;
@@ -1,3 +1,4 @@
import type { CatchupDownloadMetadata } from '@iptvnator/shared/interfaces';
import type { getDatabase } from '../../database/connection';
export type DownloadsDatabase = Awaited<ReturnType<typeof getDatabase>>;
@@ -12,6 +13,7 @@ export interface CompletedPartialProgress extends TransferProgress {
}
export interface DownloadTask {
catchup?: CatchupDownloadMetadata;
id: number;
url: string;
fileName: string;
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "نسخ رابط الأرشيف",
"ARCHIVE_URL_COPIED": "تم نسخ رابط الأرشيف",
"ARCHIVE_URL_FAILED": "تعذّر نسخ رابط الأرشيف",
"ARCHIVE_URL_NOTICE": "قد يحتوي الرابط على بيانات الدخول. قد تحتاج المشغلات الخارجية أيضًا إلى ترويسات HTTP الخاصة بالمصدر."
"ARCHIVE_URL_NOTICE": "قد يحتوي الرابط على بيانات الدخول. قد تحتاج المشغلات الخارجية أيضًا إلى ترويسات HTTP الخاصة بالمصدر.",
"DOWNLOAD_ARCHIVE": "تنزيل البرنامج (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "أُضيف البرنامج إلى التنزيلات",
"ARCHIVE_DOWNLOAD_FAILED": "تعذر تنزيل البرنامج. قد لا يكون متاحًا بعد الآن.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "تنزيلات الأرشيف تدعم حاليًا تدفقات TS فقط.",
"ARCHIVE_DOWNLOAD_EXISTS": "هذا البرنامج موجود بالفعل في التنزيلات."
},
"FETCH_SUCCESS": "تم جلب الدليل بنجاح.",
"IMPORT_PROGRESS": "استيراد EPG",
@@ -1375,9 +1380,12 @@
"RETRY": "إعادة محاولة {{title}}",
"MORE_ACTIONS": "إجراءات أخرى لـ {{title}}",
"COPY_URL": "نسخ رابط تنزيل {{title}}",
"DOWNLOAD_AGAIN": "تنزيل {{title}} مرة أخرى"
"DOWNLOAD_AGAIN": "تنزيل {{title}} مرة أخرى",
"RESTART_ARCHIVE": "تنزيل {{title}} من البداية"
},
"DOWNLOAD_AGAIN": "تنزيل مرة أخرى"
"DOWNLOAD_AGAIN": "تنزيل مرة أخرى",
"CATCHUP": "برنامج تلفزيوني",
"ARCHIVE_RESTART_NOTE": "بعد الانقطاع، يبدأ تنزيل الأرشيف من البداية."
},
"REMOTE_CONTROL": {
"TITLE": "التحكم عن بعد",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "نسخ رابط الأرشيف",
"ARCHIVE_URL_COPIED": "تنسخ رابط الأرشيف",
"ARCHIVE_URL_FAILED": "ما قدرناش ننسخو رابط الأرشيف",
"ARCHIVE_URL_NOTICE": "الرابط يقدر يكون فيه معلومات الدخول. المشغلات الخارجية تقدر تحتاج حتى ترويسات HTTP ديال المصدر."
"ARCHIVE_URL_NOTICE": "الرابط يقدر يكون فيه معلومات الدخول. المشغلات الخارجية تقدر تحتاج حتى ترويسات HTTP ديال المصدر.",
"DOWNLOAD_ARCHIVE": "حمّل البرنامج (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "تزاد البرنامج للتنزيلات",
"ARCHIVE_DOWNLOAD_FAILED": "ما قدرناش نحمّلو البرنامج. يمكن ما بقاش متوفر.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "تنزيلات الأرشيف كتدعم دابا غير تدفقات TS.",
"ARCHIVE_DOWNLOAD_EXISTS": "هاد البرنامج راه كاين فالتنزيلات."
},
"FETCH_SUCCESS": "EPG تجلب بنجاح",
"IMPORT_PROGRESS": "استيراد EPG",
@@ -1375,9 +1380,12 @@
"RETRY": "عاود جرّب {{title}}",
"MORE_ACTIONS": "إجراءات أخرى لـ {{title}}",
"COPY_URL": "نسخ رابط تحميل {{title}}",
"DOWNLOAD_AGAIN": "عاود حمّل {{title}}"
"DOWNLOAD_AGAIN": "عاود حمّل {{title}}",
"RESTART_ARCHIVE": "حمّل {{title}} من الأول"
},
"DOWNLOAD_AGAIN": "عاود حمّل"
"DOWNLOAD_AGAIN": "عاود حمّل",
"CATCHUP": "برنامج تلفزيوني",
"ARCHIVE_RESTART_NOTE": "من بعد الانقطاع، تنزيل الأرشيف كيبدا من الأول."
},
"REMOTE_CONTROL": {
"TITLE": "التحكم عن بعد",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Капіяваць спасылку на архіў",
"ARCHIVE_URL_COPIED": "Спасылка на архіў скапіявана",
"ARCHIVE_URL_FAILED": "Не ўдалося скапіяваць спасылку на архіў",
"ARCHIVE_URL_NOTICE": "Спасылка можа змяшчаць даныя ўліковага запісу. Знешняму прайгравальніку таксама могуць спатрэбіцца HTTP-загалоўкі крыніцы."
"ARCHIVE_URL_NOTICE": "Спасылка можа змяшчаць даныя ўліковага запісу. Знешняму прайгравальніку таксама могуць спатрэбіцца HTTP-загалоўкі крыніцы.",
"DOWNLOAD_ARCHIVE": "Спампаваць перадачу (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Перадача дададзена ў спампоўкі",
"ARCHIVE_DOWNLOAD_FAILED": "Не атрымалася спампаваць перадачу. Магчыма, яна ўжо недаступная.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Спампоўка архіва пакуль падтрымлівае толькі TS-патокі.",
"ARCHIVE_DOWNLOAD_EXISTS": "Гэтая перадача ўжо ёсць у спампоўках."
},
"FETCH_SUCCESS": "EPG быў паспяхова загружаны",
"IMPORT_PROGRESS": "Імпарт EPG",
@@ -1375,9 +1380,12 @@
"RETRY": "Паўтарыць {{title}}",
"MORE_ACTIONS": "Дадатковыя дзеянні для {{title}}",
"COPY_URL": "Скапіяваць URL спампоўкі для {{title}}",
"DOWNLOAD_AGAIN": "Спампаваць {{title}} яшчэ раз"
"DOWNLOAD_AGAIN": "Спампаваць {{title}} яшчэ раз",
"RESTART_ARCHIVE": "Спампаваць {{title}} нанова"
},
"DOWNLOAD_AGAIN": "Спампаваць яшчэ раз"
"DOWNLOAD_AGAIN": "Спампаваць яшчэ раз",
"CATCHUP": "Тэлеперадача",
"ARCHIVE_RESTART_NOTE": "Пасля перапынення спампоўка перадачы пачынаецца нанова."
},
"REMOTE_CONTROL": {
"TITLE": "Аддаленае кіраванне",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Archiv-URL kopieren",
"ARCHIVE_URL_COPIED": "Archiv-URL kopiert",
"ARCHIVE_URL_FAILED": "Archiv-URL konnte nicht kopiert werden",
"ARCHIVE_URL_NOTICE": "Die URL kann Zugangsdaten enthalten. Externe Player benötigen möglicherweise auch die HTTP-Header der Quelle."
"ARCHIVE_URL_NOTICE": "Die URL kann Zugangsdaten enthalten. Externe Player benötigen möglicherweise auch die HTTP-Header der Quelle.",
"DOWNLOAD_ARCHIVE": "Sendung herunterladen (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Sendung zu Downloads hinzugefügt",
"ARCHIVE_DOWNLOAD_FAILED": "Die Sendung konnte nicht heruntergeladen werden. Sie ist möglicherweise nicht mehr verfügbar.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Archiv-Downloads unterstützen derzeit nur TS-Streams.",
"ARCHIVE_DOWNLOAD_EXISTS": "Diese Sendung ist bereits in Downloads."
},
"FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen",
"IMPORT_PROGRESS": "EPG-Import",
@@ -1375,9 +1380,12 @@
"RETRY": "{{title}} erneut versuchen",
"MORE_ACTIONS": "Weitere Aktionen für {{title}}",
"COPY_URL": "Download-URL für {{title}} kopieren",
"DOWNLOAD_AGAIN": "{{title}} erneut herunterladen"
"DOWNLOAD_AGAIN": "{{title}} erneut herunterladen",
"RESTART_ARCHIVE": "{{title}} von vorn herunterladen"
},
"DOWNLOAD_AGAIN": "Erneut herunterladen"
"DOWNLOAD_AGAIN": "Erneut herunterladen",
"CATCHUP": "TV-Sendung",
"ARCHIVE_RESTART_NOTE": "Nach einer Unterbrechung beginnt der Archiv-Download von vorn."
},
"REMOTE_CONTROL": {
"TITLE": "Fernbedienung",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Αντιγραφή URL αρχείου",
"ARCHIVE_URL_COPIED": "Το URL αρχείου αντιγράφηκε",
"ARCHIVE_URL_FAILED": "Αδυναμία αντιγραφής του URL αρχείου",
"ARCHIVE_URL_NOTICE": "Το URL μπορεί να περιέχει στοιχεία σύνδεσης. Τα εξωτερικά προγράμματα αναπαραγωγής μπορεί να χρειάζονται και τις κεφαλίδες HTTP της πηγής."
"ARCHIVE_URL_NOTICE": "Το URL μπορεί να περιέχει στοιχεία σύνδεσης. Τα εξωτερικά προγράμματα αναπαραγωγής μπορεί να χρειάζονται και τις κεφαλίδες HTTP της πηγής.",
"DOWNLOAD_ARCHIVE": "Λήψη εκπομπής (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Η εκπομπή προστέθηκε στις λήψεις",
"ARCHIVE_DOWNLOAD_FAILED": "Η λήψη απέτυχε. Η εκπομπή ίσως δεν είναι πλέον διαθέσιμη.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Οι λήψεις από το αρχείο υποστηρίζουν προς το παρόν μόνο ροές TS.",
"ARCHIVE_DOWNLOAD_EXISTS": "Αυτή η εκπομπή υπάρχει ήδη στις λήψεις."
},
"FETCH_SUCCESS": "Το EPG ανακτήθηκε με επιτυχία",
"IMPORT_PROGRESS": "Εισαγωγή Ηλεκτρονικού Οδηγού Προγράμματος (EPG)",
@@ -1375,9 +1380,12 @@
"RETRY": "Επανάληψη {{title}}",
"MORE_ACTIONS": "Περισσότερες ενέργειες για {{title}}",
"COPY_URL": "Αντιγραφή URL λήψης για {{title}}",
"DOWNLOAD_AGAIN": "Λήψη του {{title}} ξανά"
"DOWNLOAD_AGAIN": "Λήψη του {{title}} ξανά",
"RESTART_ARCHIVE": "Λήψη του {{title}} από την αρχή"
},
"DOWNLOAD_AGAIN": "Λήψη ξανά"
"DOWNLOAD_AGAIN": "Λήψη ξανά",
"CATCHUP": "Τηλεοπτική εκπομπή",
"ARCHIVE_RESTART_NOTE": "Μετά από διακοπή, η λήψη από το αρχείο ξεκινά από την αρχή."
},
"REMOTE_CONTROL": {
"TITLE": "Τηλεχειριστήριο",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Copy archive URL",
"ARCHIVE_URL_COPIED": "Archive URL copied",
"ARCHIVE_URL_FAILED": "Could not copy the archive URL",
"ARCHIVE_URL_NOTICE": "The URL may contain account credentials. External players may also need your source’s HTTP headers."
"ARCHIVE_URL_NOTICE": "The URL may contain account credentials. External players may also need your source’s HTTP headers.",
"DOWNLOAD_ARCHIVE": "Download programme (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Programme added to Downloads",
"ARCHIVE_DOWNLOAD_FAILED": "Could not download this programme. It may no longer be available.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Archive downloads currently support TS streams only.",
"ARCHIVE_DOWNLOAD_EXISTS": "This programme is already in Downloads."
},
"FETCH_SUCCESS": "EPG was fetched successfully",
"IMPORT_PROGRESS": "EPG Import",
@@ -1375,9 +1380,12 @@
"RETRY": "Retry {{title}}",
"MORE_ACTIONS": "More actions for {{title}}",
"COPY_URL": "Copy download URL for {{title}}",
"DOWNLOAD_AGAIN": "Download {{title}} again"
"DOWNLOAD_AGAIN": "Download {{title}} again",
"RESTART_ARCHIVE": "Restart {{title}} from the beginning"
},
"DOWNLOAD_AGAIN": "Download again"
"DOWNLOAD_AGAIN": "Download again",
"CATCHUP": "TV programme",
"ARCHIVE_RESTART_NOTE": "Archive downloads restart from the beginning after an interruption."
},
"REMOTE_CONTROL": {
"TITLE": "Remote control",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Copiar URL del archivo",
"ARCHIVE_URL_COPIED": "URL del archivo copiada",
"ARCHIVE_URL_FAILED": "No se pudo copiar la URL del archivo",
"ARCHIVE_URL_NOTICE": "La URL puede contener credenciales. Los reproductores externos también pueden necesitar las cabeceras HTTP de la fuente."
"ARCHIVE_URL_NOTICE": "La URL puede contener credenciales. Los reproductores externos también pueden necesitar las cabeceras HTTP de la fuente.",
"DOWNLOAD_ARCHIVE": "Descargar programa (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Programa añadido a Descargas",
"ARCHIVE_DOWNLOAD_FAILED": "No se pudo descargar el programa. Puede que ya no esté disponible.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Las descargas del archivo solo admiten flujos TS por ahora.",
"ARCHIVE_DOWNLOAD_EXISTS": "Este programa ya está en Descargas."
},
"FETCH_SUCCESS": "EPG obtenida correctamente",
"IMPORT_PROGRESS": "Importación de EPG",
@@ -1375,9 +1380,12 @@
"RETRY": "Reintentar {{title}}",
"MORE_ACTIONS": "Más acciones para {{title}}",
"COPY_URL": "Copiar la URL de descarga de {{title}}",
"DOWNLOAD_AGAIN": "Volver a descargar {{title}}"
"DOWNLOAD_AGAIN": "Volver a descargar {{title}}",
"RESTART_ARCHIVE": "Descargar {{title}} desde el principio"
},
"DOWNLOAD_AGAIN": "Volver a descargar"
"DOWNLOAD_AGAIN": "Volver a descargar",
"CATCHUP": "Programa de TV",
"ARCHIVE_RESTART_NOTE": "Tras una interrupción, la descarga del archivo empieza desde el principio."
},
"REMOTE_CONTROL": {
"TITLE": "Control remoto",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Copier le lien de rattrapage",
"ARCHIVE_URL_COPIED": "Lien de rattrapage copié",
"ARCHIVE_URL_FAILED": "Impossible de copier le lien de rattrapage",
"ARCHIVE_URL_NOTICE": "Le lien peut contenir des identifiants. Les lecteurs externes peuvent aussi nécessiter les en-têtes HTTP de la source."
"ARCHIVE_URL_NOTICE": "Le lien peut contenir des identifiants. Les lecteurs externes peuvent aussi nécessiter les en-têtes HTTP de la source.",
"DOWNLOAD_ARCHIVE": "Télécharger l’émission (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Émission ajoutée aux téléchargements",
"ARCHIVE_DOWNLOAD_FAILED": "Impossible de télécharger cette émission. Elle n’est peut-être plus disponible.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Seuls les flux TS sont pris en charge pour le téléchargement des archives.",
"ARCHIVE_DOWNLOAD_EXISTS": "Cette émission figure déjà dans les téléchargements."
},
"FETCH_SUCCESS": "L'EPG a été récupéré avec succès",
"IMPORT_PROGRESS": "Importation EPG",
@@ -1375,9 +1380,12 @@
"RETRY": "Réessayer {{title}}",
"MORE_ACTIONS": "Plus d’actions pour {{title}}",
"COPY_URL": "Copier l’URL de téléchargement de {{title}}",
"DOWNLOAD_AGAIN": "Télécharger à nouveau {{title}}"
"DOWNLOAD_AGAIN": "Télécharger à nouveau {{title}}",
"RESTART_ARCHIVE": "Retélécharger {{title}} depuis le début"
},
"DOWNLOAD_AGAIN": "Télécharger à nouveau"
"DOWNLOAD_AGAIN": "Télécharger à nouveau",
"CATCHUP": "Émission TV",
"ARCHIVE_RESTART_NOTE": "Après une interruption, le téléchargement de l’archive reprend depuis le début."
},
"REMOTE_CONTROL": {
"TITLE": "Télécommande",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Archív URL másolása",
"ARCHIVE_URL_COPIED": "Archív URL másolva",
"ARCHIVE_URL_FAILED": "Az archív URL másolása sikertelen",
"ARCHIVE_URL_NOTICE": "Az URL bejelentkezési adatokat tartalmazhat. A külső lejátszóknak a forrás HTTP-fejléceire is szükségük lehet."
"ARCHIVE_URL_NOTICE": "Az URL bejelentkezési adatokat tartalmazhat. A külső lejátszóknak a forrás HTTP-fejléceire is szükségük lehet.",
"DOWNLOAD_ARCHIVE": "Műsor letöltése (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Műsor hozzáadva a letöltésekhez",
"ARCHIVE_DOWNLOAD_FAILED": "A műsor nem tölthető le. Lehet, hogy már nem érhető el.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Az archívum letöltése jelenleg csak TS adatfolyamokkal működik.",
"ARCHIVE_DOWNLOAD_EXISTS": "Ez a műsor már szerepel a letöltések között."
},
"FETCH_SUCCESS": "Az EPG lekérése sikerült",
"IMPORT_PROGRESS": "EPG importálása",
@@ -1375,9 +1380,12 @@
"RETRY": "{{title}} újrapróbálása",
"MORE_ACTIONS": "További műveletek: {{title}}",
"COPY_URL": "{{title}} letöltési URL-jének másolása",
"DOWNLOAD_AGAIN": "{{title}} letöltése újra"
"DOWNLOAD_AGAIN": "{{title}} letöltése újra",
"RESTART_ARCHIVE": "{{title}} letöltése elölről"
},
"DOWNLOAD_AGAIN": "Letöltés újra"
"DOWNLOAD_AGAIN": "Letöltés újra",
"CATCHUP": "TV-műsor",
"ARCHIVE_RESTART_NOTE": "Megszakítás után az archív műsor letöltése elölről indul."
},
"REMOTE_CONTROL": {
"TITLE": "Távvezérlés",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Copia URL archivio",
"ARCHIVE_URL_COPIED": "URL archivio copiato",
"ARCHIVE_URL_FAILED": "Impossibile copiare URL archivio",
"ARCHIVE_URL_NOTICE": "L’URL può contenere credenziali. I lettori esterni potrebbero richiedere anche le intestazioni HTTP della sorgente."
"ARCHIVE_URL_NOTICE": "L’URL può contenere credenziali. I lettori esterni potrebbero richiedere anche le intestazioni HTTP della sorgente.",
"DOWNLOAD_ARCHIVE": "Scarica programma (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Programma aggiunto ai download",
"ARCHIVE_DOWNLOAD_FAILED": "Impossibile scaricare il programma. Potrebbe non essere più disponibile.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "I download dall’archivio supportano solo flussi TS.",
"ARCHIVE_DOWNLOAD_EXISTS": "Questo programma è già nei download."
},
"FETCH_SUCCESS": "EPG scaricato correttamente",
"IMPORT_PROGRESS": "Importazione EPG",
@@ -1375,9 +1380,12 @@
"RETRY": "Riprova {{title}}",
"MORE_ACTIONS": "Altre azioni per {{title}}",
"COPY_URL": "Copia URL di download per {{title}}",
"DOWNLOAD_AGAIN": "Scarica di nuovo {{title}}"
"DOWNLOAD_AGAIN": "Scarica di nuovo {{title}}",
"RESTART_ARCHIVE": "Scarica {{title}} dall’inizio"
},
"DOWNLOAD_AGAIN": "Scarica di nuovo"
"DOWNLOAD_AGAIN": "Scarica di nuovo",
"CATCHUP": "Programma TV",
"ARCHIVE_RESTART_NOTE": "Dopo un’interruzione, il download dall’archivio ricomincia dall’inizio."
},
"REMOTE_CONTROL": {
"TITLE": "Controllo remoto",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "アーカイブURLをコピー",
"ARCHIVE_URL_COPIED": "アーカイブURLをコピーしました",
"ARCHIVE_URL_FAILED": "アーカイブURLをコピーできませんでした",
"ARCHIVE_URL_NOTICE": "URLにはアカウントの認証情報が含まれる場合があります。外部プレーヤーではソースのHTTPヘッダーも必要になることがあります。"
"ARCHIVE_URL_NOTICE": "URLにはアカウントの認証情報が含まれる場合があります。外部プレーヤーではソースのHTTPヘッダーも必要になることがあります。",
"DOWNLOAD_ARCHIVE": "番組をダウンロード (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "番組をダウンロードに追加しました",
"ARCHIVE_DOWNLOAD_FAILED": "番組をダウンロードできませんでした。配信が終了している可能性があります。",
"ARCHIVE_DOWNLOAD_TS_ONLY": "アーカイブのダウンロードは現在TSストリームのみ対応しています。",
"ARCHIVE_DOWNLOAD_EXISTS": "この番組はすでにダウンロードにあります。"
},
"FETCH_SUCCESS": "EPGの取得に成功しました",
"IMPORT_PROGRESS": "EPGインポート",
@@ -1375,9 +1380,12 @@
"RETRY": "{{title}} を再試行",
"MORE_ACTIONS": "{{title}} のその他の操作",
"COPY_URL": "{{title}} のダウンロード URL をコピー",
"DOWNLOAD_AGAIN": "{{title}} を再ダウンロード"
"DOWNLOAD_AGAIN": "{{title}} を再ダウンロード",
"RESTART_ARCHIVE": "{{title}}を最初からダウンロード"
},
"DOWNLOAD_AGAIN": "再ダウンロード"
"DOWNLOAD_AGAIN": "再ダウンロード",
"CATCHUP": "テレビ番組",
"ARCHIVE_RESTART_NOTE": "中断後はアーカイブのダウンロードを最初からやり直します。"
},
"REMOTE_CONTROL": {
"TITLE": "リモートコントロール",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "다시보기 URL 복사",
"ARCHIVE_URL_COPIED": "다시보기 URL이 복사되었습니다",
"ARCHIVE_URL_FAILED": "다시보기 URL을 복사하지 못했습니다",
"ARCHIVE_URL_NOTICE": "URL에 계정 인증 정보가 포함될 수 있습니다. 외부 플레이어에는 소스의 HTTP 헤더도 필요할 수 있습니다."
"ARCHIVE_URL_NOTICE": "URL에 계정 인증 정보가 포함될 수 있습니다. 외부 플레이어에는 소스의 HTTP 헤더도 필요할 수 있습니다.",
"DOWNLOAD_ARCHIVE": "프로그램 다운로드 (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "프로그램을 다운로드에 추가했습니다",
"ARCHIVE_DOWNLOAD_FAILED": "프로그램을 다운로드할 수 없습니다. 더 이상 제공되지 않을 수 있습니다.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "보관 프로그램 다운로드는 현재 TS 스트림만 지원합니다.",
"ARCHIVE_DOWNLOAD_EXISTS": "이 프로그램은 이미 다운로드 목록에 있습니다."
},
"FETCH_SUCCESS": "EPG를 성공적으로 가져왔습니다",
"IMPORT_PROGRESS": "EPG 가져오기",
@@ -1375,9 +1380,12 @@
"RETRY": "{{title}} 다시 시도",
"MORE_ACTIONS": "{{title}} 추가 작업",
"COPY_URL": "{{title}} 다운로드 URL 복사",
"DOWNLOAD_AGAIN": "{{title}} 다시 다운로드"
"DOWNLOAD_AGAIN": "{{title}} 다시 다운로드",
"RESTART_ARCHIVE": "{{title}} 처음부터 다운로드"
},
"DOWNLOAD_AGAIN": "다시 다운로드"
"DOWNLOAD_AGAIN": "다시 다운로드",
"CATCHUP": "TV 프로그램",
"ARCHIVE_RESTART_NOTE": "중단 후에는 보관 프로그램 다운로드를 처음부터 다시 시작합니다."
},
"REMOTE_CONTROL": {
"TITLE": "원격 제어",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Archief-URL kopiëren",
"ARCHIVE_URL_COPIED": "Archief-URL gekopieerd",
"ARCHIVE_URL_FAILED": "Kan archief-URL niet kopiëren",
"ARCHIVE_URL_NOTICE": "De URL kan inloggegevens bevatten. Externe spelers hebben mogelijk ook de HTTP-headers van de bron nodig."
"ARCHIVE_URL_NOTICE": "De URL kan inloggegevens bevatten. Externe spelers hebben mogelijk ook de HTTP-headers van de bron nodig.",
"DOWNLOAD_ARCHIVE": "Programma downloaden (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Programma toegevoegd aan Downloads",
"ARCHIVE_DOWNLOAD_FAILED": "Het programma kon niet worden gedownload. Het is mogelijk niet meer beschikbaar.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Archiefdownloads ondersteunen momenteel alleen TS-streams.",
"ARCHIVE_DOWNLOAD_EXISTS": "Dit programma staat al in Downloads."
},
"FETCH_SUCCESS": "TV-Gids bijgewerkt",
"IMPORT_PROGRESS": "EPG-import",
@@ -1375,9 +1380,12 @@
"RETRY": "{{title}} opnieuw proberen",
"MORE_ACTIONS": "Meer acties voor {{title}}",
"COPY_URL": "Download-URL voor {{title}} kopiëren",
"DOWNLOAD_AGAIN": "{{title}} opnieuw downloaden"
"DOWNLOAD_AGAIN": "{{title}} opnieuw downloaden",
"RESTART_ARCHIVE": "{{title}} vanaf het begin downloaden"
},
"DOWNLOAD_AGAIN": "Opnieuw downloaden"
"DOWNLOAD_AGAIN": "Opnieuw downloaden",
"CATCHUP": "TV-programma",
"ARCHIVE_RESTART_NOTE": "Na een onderbreking begint de archiefdownload opnieuw."
},
"REMOTE_CONTROL": {
"TITLE": "Afstandsbediening",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Kopiuj adres archiwum",
"ARCHIVE_URL_COPIED": "Skopiowano adres archiwum",
"ARCHIVE_URL_FAILED": "Nie udało się skopiować adresu archiwum",
"ARCHIVE_URL_NOTICE": "Adres może zawierać dane logowania. Odtwarzacze zewnętrzne mogą też wymagać nagłówków HTTP źródła."
"ARCHIVE_URL_NOTICE": "Adres może zawierać dane logowania. Odtwarzacze zewnętrzne mogą też wymagać nagłówków HTTP źródła.",
"DOWNLOAD_ARCHIVE": "Pobierz program (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Program dodany do pobierania",
"ARCHIVE_DOWNLOAD_FAILED": "Nie udało się pobrać programu. Może już być niedostępny.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Pobieranie z archiwum obsługuje obecnie tylko strumienie TS.",
"ARCHIVE_DOWNLOAD_EXISTS": "Ten program jest już na liście pobierania."
},
"FETCH_SUCCESS": "EPG zostało pomyślnie pobrane",
"IMPORT_PROGRESS": "Import EPG",
@@ -1375,9 +1380,12 @@
"RETRY": "Ponów {{title}}",
"MORE_ACTIONS": "Więcej akcji dla {{title}}",
"COPY_URL": "Kopiuj URL pobierania dla {{title}}",
"DOWNLOAD_AGAIN": "Pobierz {{title}} ponownie"
"DOWNLOAD_AGAIN": "Pobierz {{title}} ponownie",
"RESTART_ARCHIVE": "Pobierz {{title}} od początku"
},
"DOWNLOAD_AGAIN": "Pobierz ponownie"
"DOWNLOAD_AGAIN": "Pobierz ponownie",
"CATCHUP": "Program TV",
"ARCHIVE_RESTART_NOTE": "Po przerwaniu pobieranie z archiwum zaczyna się od początku."
},
"REMOTE_CONTROL": {
"TITLE": "Zdalne sterowanie",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Copiar URL do arquivo",
"ARCHIVE_URL_COPIED": "URL do arquivo copiada",
"ARCHIVE_URL_FAILED": "Não foi possível copiar a URL do arquivo",
"ARCHIVE_URL_NOTICE": "A URL pode conter credenciais. Os leitores externos também podem precisar dos cabeçalhos HTTP da fonte."
"ARCHIVE_URL_NOTICE": "A URL pode conter credenciais. Os leitores externos também podem precisar dos cabeçalhos HTTP da fonte.",
"DOWNLOAD_ARCHIVE": "Baixar programa (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Programa adicionado aos downloads",
"ARCHIVE_DOWNLOAD_FAILED": "Não foi possível baixar o programa. Ele pode não estar mais disponível.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Os downloads do arquivo aceitam apenas fluxos TS por enquanto.",
"ARCHIVE_DOWNLOAD_EXISTS": "Este programa já está nos downloads."
},
"FETCH_SUCCESS": "EPG foi buscado com sucesso",
"IMPORT_PROGRESS": "Importação de EPG",
@@ -1375,9 +1380,12 @@
"RETRY": "Tentar {{title}} novamente",
"MORE_ACTIONS": "Mais ações para {{title}}",
"COPY_URL": "Copiar URL de download de {{title}}",
"DOWNLOAD_AGAIN": "Baixar {{title}} novamente"
"DOWNLOAD_AGAIN": "Baixar {{title}} novamente",
"RESTART_ARCHIVE": "Baixar {{title}} do início"
},
"DOWNLOAD_AGAIN": "Baixar novamente"
"DOWNLOAD_AGAIN": "Baixar novamente",
"CATCHUP": "Programa de TV",
"ARCHIVE_RESTART_NOTE": "Após uma interrupção, o download do arquivo começa do início."
},
"REMOTE_CONTROL": {
"TITLE": "Controle remoto",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Копировать ссылку на архив",
"ARCHIVE_URL_COPIED": "Ссылка на архив скопирована",
"ARCHIVE_URL_FAILED": "Не удалось скопировать ссылку на архив",
"ARCHIVE_URL_NOTICE": "Ссылка может содержать данные аккаунта. Внешнему плееру также могут понадобиться HTTP-заголовки источника."
"ARCHIVE_URL_NOTICE": "Ссылка может содержать данные аккаунта. Внешнему плееру также могут понадобиться HTTP-заголовки источника.",
"DOWNLOAD_ARCHIVE": "Скачать передачу (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Передача добавлена в загрузки",
"ARCHIVE_DOWNLOAD_FAILED": "Не удалось скачать передачу. Возможно, она уже недоступна в архиве.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Скачивание архива пока поддерживает только TS-потоки.",
"ARCHIVE_DOWNLOAD_EXISTS": "Эта передача уже есть в загрузках."
},
"FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена",
"IMPORT_PROGRESS": "Импорт EPG",
@@ -1375,9 +1380,12 @@
"RETRY": "Повторить {{title}}",
"MORE_ACTIONS": "Дополнительные действия для {{title}}",
"COPY_URL": "Скопировать URL загрузки для {{title}}",
"DOWNLOAD_AGAIN": "Скачать {{title}} снова"
"DOWNLOAD_AGAIN": "Скачать {{title}} снова",
"RESTART_ARCHIVE": "Скачать {{title}} заново"
},
"DOWNLOAD_AGAIN": "Скачать снова"
"DOWNLOAD_AGAIN": "Скачать снова",
"CATCHUP": "Передача",
"ARCHIVE_RESTART_NOTE": "После прерывания скачивание передачи начинается заново."
},
"REMOTE_CONTROL": {
"TITLE": "Дистанционное управление",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "Arşiv URL’sini kopyala",
"ARCHIVE_URL_COPIED": "Arşiv URL’si kopyalandı",
"ARCHIVE_URL_FAILED": "Arşiv URL’si kopyalanamadı",
"ARCHIVE_URL_NOTICE": "URL hesap bilgileri içerebilir. Harici oynatıcılar kaynağın HTTP başlıklarına da ihtiyaç duyabilir."
"ARCHIVE_URL_NOTICE": "URL hesap bilgileri içerebilir. Harici oynatıcılar kaynağın HTTP başlıklarına da ihtiyaç duyabilir.",
"DOWNLOAD_ARCHIVE": "Programı indir (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Program indirmelere eklendi",
"ARCHIVE_DOWNLOAD_FAILED": "Program indirilemedi. Artık mevcut olmayabilir.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Arşiv indirmeleri şu anda yalnızca TS akışlarını destekler.",
"ARCHIVE_DOWNLOAD_EXISTS": "Bu program zaten indirmelerde."
},
"FETCH_SUCCESS": "EPG verileri başarıyla alındı",
"IMPORT_PROGRESS": "EPG İçe Aktarımı",
@@ -1375,9 +1380,12 @@
"RETRY": "{{title}} için yeniden dene",
"MORE_ACTIONS": "{{title}} için daha fazla eylem",
"COPY_URL": "{{title}} için indirme URL’sini kopyala",
"DOWNLOAD_AGAIN": "{{title}} öğesini yeniden indir"
"DOWNLOAD_AGAIN": "{{title}} öğesini yeniden indir",
"RESTART_ARCHIVE": "{{title}} programını baştan indir"
},
"DOWNLOAD_AGAIN": "Yeniden indir"
"DOWNLOAD_AGAIN": "Yeniden indir",
"CATCHUP": "TV programı",
"ARCHIVE_RESTART_NOTE": "Kesintiden sonra arşiv indirmesi baştan başlar."
},
"REMOTE_CONTROL": {
"TITLE": "Uzaktan kontrol",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "复制回看链接",
"ARCHIVE_URL_COPIED": "已复制回看链接",
"ARCHIVE_URL_FAILED": "无法复制回看链接",
"ARCHIVE_URL_NOTICE": "链接可能包含账户凭据。外部播放器可能还需要此来源的 HTTP 请求头。"
"ARCHIVE_URL_NOTICE": "链接可能包含账户凭据。外部播放器可能还需要此来源的 HTTP 请求头。",
"DOWNLOAD_ARCHIVE": "下载节目 (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "节目已加入下载列表",
"ARCHIVE_DOWNLOAD_FAILED": "无法下载此节目,它可能已不可用。",
"ARCHIVE_DOWNLOAD_TS_ONLY": "回看下载目前仅支持 TS 流。",
"ARCHIVE_DOWNLOAD_EXISTS": "此节目已在下载列表中。"
},
"FETCH_SUCCESS": "EPG 获取成功",
"IMPORT_PROGRESS": "EPG 导入",
@@ -1375,9 +1380,12 @@
"RETRY": "重试 {{title}}",
"MORE_ACTIONS": "{{title}} 的更多操作",
"COPY_URL": "复制 {{title}} 的下载 URL",
"DOWNLOAD_AGAIN": "再次下载 {{title}}"
"DOWNLOAD_AGAIN": "再次下载 {{title}}",
"RESTART_ARCHIVE": "从头下载 {{title}}"
},
"DOWNLOAD_AGAIN": "再次下载"
"DOWNLOAD_AGAIN": "再次下载",
"CATCHUP": "电视节目",
"ARCHIVE_RESTART_NOTE": "中断后,回看节目的下载将从头开始。"
},
"REMOTE_CONTROL": {
"TITLE": "远程控制",
+11 -3
View File
@@ -796,7 +796,12 @@
"COPY_ARCHIVE_URL": "複製回看連結",
"ARCHIVE_URL_COPIED": "已複製回看連結",
"ARCHIVE_URL_FAILED": "無法複製回看連結",
"ARCHIVE_URL_NOTICE": "連結可能包含帳戶憑證。外部播放器可能還需要此來源的 HTTP 標頭。"
"ARCHIVE_URL_NOTICE": "連結可能包含帳戶憑證。外部播放器可能還需要此來源的 HTTP 標頭。",
"DOWNLOAD_ARCHIVE": "下載節目 (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "節目已加入下載清單",
"ARCHIVE_DOWNLOAD_FAILED": "無法下載此節目,它可能已無法使用。",
"ARCHIVE_DOWNLOAD_TS_ONLY": "回看下載目前僅支援 TS 串流。",
"ARCHIVE_DOWNLOAD_EXISTS": "此節目已在下載清單中。"
},
"FETCH_SUCCESS": "EPG 已成功擷取",
"IMPORT_PROGRESS": "EPG 匯入",
@@ -1375,9 +1380,12 @@
"RETRY": "重試 {{title}}",
"MORE_ACTIONS": "{{title}} 的更多操作",
"COPY_URL": "複製 {{title}} 的下載 URL",
"DOWNLOAD_AGAIN": "再次下載 {{title}}"
"DOWNLOAD_AGAIN": "再次下載 {{title}}",
"RESTART_ARCHIVE": "從頭下載 {{title}}"
},
"DOWNLOAD_AGAIN": "再次下載"
"DOWNLOAD_AGAIN": "再次下載",
"CATCHUP": "電視節目",
"ARCHIVE_RESTART_NOTE": "中斷後,回看節目的下載將從頭開始。"
},
"REMOTE_CONTROL": {
"TITLE": "遠端控制",
+16 -1
View File
@@ -309,7 +309,22 @@ function installStreamRoutes(
app.get('/series/:username/:password/:streamId.:ext', seriesResponse);
app.all(
'/timeshift/:username/:password/:duration/:start/:streamId.ts',
streamResponse
(request, response) => {
if (request.params['username'] === 'epg') {
response
.type('video/mp2t')
.send(
readFileSync(
join(
process.cwd(),
'apps/xtream-mock-server/src/fixtures/live.mpegts'
)
)
);
return;
}
streamResponse(request, response);
}
);
app.all('/streaming/timeshift.php', streamResponse);
}
+50 -5
View File
@@ -7,6 +7,51 @@ views. Backend work is handled in the Electron process while the Angular
renderer exposes the global `/workspace/downloads` page, source-scoped route
variants, contextual buttons, and theme-aware styling.
## Xtream archive downloads
The desktop Xtream Live TV programme dialog offers **Download programme (TS)**
for completed, available catch-up programmes in both timeline and list views.
The host captures the playlist, channel and original programme timestamps before
resolving the existing timeshift URL (including the panel timezone). A channel
change while the dialog is open invalidates its action. Downloading does not
select the programme or change playback. Collection views retain archive URL
copying; archive downloads are initially exposed through Xtream Live TV only.
M3U, Stalker and PWA archive downloads, HLS assembly and recording future broadcasts
are outside this feature.
`EpgArchiveDownloadService` submits the resolved URL and the playlist's playback
header allowlist to the existing desktop queue. HLS URLs are refused explicitly;
the backend also checks the response type and every 188-byte MPEG-TS packet.
No transcoder or media helper is required. The file always uses `.ts`.
The `catchup` download type stores `programme_start` and JSON metadata containing
channel name, start/stop Unix seconds and the known archive expiry. Its unique
identity is `(playlist_id, xtream_id, programme_start)`; movies and episodes keep
their existing identity. `download-schema.ts` widens the SQLite CHECK in a
transaction after older additive migrations, preserving row IDs, files, headers,
resume state and metadata. It also replaces the former global unique index with
separate catch-up and non-catch-up indexes.
Only ended programmes can be enqueued. Known expiry is checked on enqueue,
retry, resume, missing-file recovery and when the queued transfer starts.
Pause keeps its owned partial file, but **resume/retry restarts from byte zero**,
without Range, If-Range, or automatic reconnect append. The queue explains this.
A retained archive cannot use the VOD byte-count completion shortcut. Transfers
have a 30-second idle timeout and a total deadline of twice programme duration
plus ten minutes, capped at 24 hours. Failure never promotes the partial to the
library, and errors omit credential-bearing URLs.
Completion means clean HTTP EOF, matching Content-Length when supplied, and
valid nonempty TS packet framing. **It does not verify media duration or the
provider's programme boundaries.** A provider can return a shorter valid clip
with clean EOF; this version cannot identify that without demuxing duration.
Unknown-length responses show indeterminate progress until EOF.
Completed archive cards appear under All with programme title, channel and
broadcast date. Clicking a card plays the local file through the download action;
it does not navigate to a movie or series detail route. Files and metadata remain
available after restart and after the source archive expires.
## Backend responsibilities
- **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)**
@@ -294,12 +339,12 @@ display snapshot via `playlistDisplayLabel`).
- **Lifecycle tracking** is owned by `EmbeddedMpvRecordingTracker`
(`apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.ts`):
explicit start/stop hooks in `EmbeddedMpvNativeService` plus a
session-snapshot observer. The stop hook is a *request*, not an outcome —
session-snapshot observer. The stop hook is a _request_, not an outcome —
`addon.stopRecording()` only dispatches (async mpv property set, or a
command written to the frame-copy helper), so finalization always waits for
the snapshot reporting the recording inactive; statting or unlinking
earlier would report a short recording as failed and could delete bytes mpv
is still flushing. macOS native-view clears `recordingActive` *before*
is still flushing. macOS native-view clears `recordingActive` _before_
dispatching the async property set and restores it if that request is
rejected, so an inactive snapshot must additionally survive a 1.5 s settle
window (three poll cycles) before it counts as an acknowledgement; a revived
@@ -355,7 +400,7 @@ display snapshot via `playlistDisplayLabel`).
`RecordingStartMetadata` (channel name/logo, playlist id + display-label
snapshot, source type, EPG key, current program) that flows
`WebPlayerViewComponent → EmbeddedMpvPlayerComponent →
EmbeddedMpvControlsAdapter → EmbeddedMpvRecordingStartOptions.metadata`.
EmbeddedMpvControlsAdapter → EmbeddedMpvRecordingStartOptions.metadata`.
`EmbeddedMpvPlayerComponent` watches the session snapshot for the
active→inactive recording edge and emits `recordingStopped` — one owner for
every trigger, including a Stop clicked in the download manager, which
@@ -377,14 +422,14 @@ display snapshot via `playlistDisplayLabel`).
target path — that is how a recording spanning a program boundary lists
every covered show. The handler is deliberately independent of finalization: it
looks the row up in **any** status (the newest for that path — `openSync
('wx')` keeps a reserved path exclusive while its recording owns it) and
('wx')` keeps a reserved path exclusive while its recording owns it) and
`finalize()` never touches `programs_json`, so the two writes commit in
either order. The only wait is the tracker's queue drain, which guarantees
the row's INSERT exists — no deadline, and therefore no way for a one-shot
enrichment to be dropped by a clock. A recording stopped while no player is mounted on that
channel keeps its start snapshot.
- **IPC surface** (`recordings.events.ts`): `RECORDINGS_GET_LIST/GET/STOP/
REMOVE/UPDATE_PROGRAMS/REVEAL_FILE/PLAY_FILE` plus the dedicated
REMOVE/UPDATE_PROGRAMS/REVEAL_FILE/PLAY_FILE` plus the dedicated
`RECORDINGS_UPDATE_EVENT` bare ping (not shared with downloads, so
recording transitions do not force availability-probed download refetches).
Active rows are decorated with a live `fs.stat` size — `file_size_bytes` is
@@ -316,3 +316,11 @@ or without, the account-info check. The same timezone lets
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`.
### Downloading completed catch-up programmes
Desktop Xtream Live TV exposes a TS-only download action in programme details.
It reuses the canonical timeshift resolver and original timestamps, preserves
playback headers and does not change playback. See
[Download Manager](download-manager.md#xtream-archive-downloads) for identity,
restart, expiry and transport-completion limits.
@@ -32,6 +32,7 @@ export class DownloadLibraryNavigationService {
availablePlaylistIds: ReadonlySet<string>
): boolean {
return (
item.contentType !== 'catchup' &&
availablePlaylistIds.has(item.playlistId) &&
this.targetId(item) !== null
);
@@ -47,6 +48,7 @@ export class DownloadLibraryNavigationService {
async resolveProviderTarget(
item: DownloadItem
): Promise<WorkspaceNavigationTarget | null> {
if (item.contentType === 'catchup') return null;
const targetId = this.targetId(item);
if (targetId === null) {
return null;
@@ -198,7 +200,7 @@ export class DownloadLibraryNavigationService {
const matched = findMatchingStalkerDownloadRecent(
recent,
targetId,
item.contentType
item.contentType === 'episode' ? 'episode' : 'vod'
);
if (matched) {
@@ -29,8 +29,10 @@
"
[disabled]="isPending(representativeOf(entity))"
[attr.aria-label]="
'DOWNLOADS.OPEN_ARTWORK'
| translate: { title: entityTitle(entity) }
(entity.kind === 'catchup'
? 'DOWNLOADS.PLAY'
: 'DOWNLOADS.OPEN_ARTWORK'
) | translate: { title: entityTitle(entity) }
"
(click)="openDetails(representativeOf(entity))"
>
@@ -199,7 +201,10 @@
"
[disabled]="isPending(representativeOf(entity))"
[attr.aria-label]="
('DOWNLOADS.OPEN_DETAILS' | translate) +
((entity.kind === 'catchup'
? 'DOWNLOADS.PLAY'
: 'DOWNLOADS.OPEN_DETAILS'
) | translate) +
': ' +
entityTitle(entity)
"
@@ -260,6 +265,18 @@
}
</div>
}
@if (
entity.kind === 'catchup' && entity.item.catchup;
as archive
) {
<div class="download-library__facts">
{{ archive.channelName }} ·
{{
archive.startTimestamp * 1000
| date: 'medium'
}}
</div>
}
@if (entity.kind === 'episode' && entity.episodeLabel) {
<span class="download-library__episode-label">
{{ entity.episodeLabel }}
@@ -135,6 +135,38 @@ describe('DownloadLibraryComponent', () => {
let fixture: ComponentFixture<DownloadLibraryComponent>;
let component: DownloadLibraryComponent;
it('plays an archive card locally instead of opening provider details', async () => {
const archive = item({
contentType: 'catchup',
catchup: {
channelName: 'News TV',
startTimestamp: 1788800000,
stopTimestamp: 1788803600,
},
});
fixture.componentRef.setInput('entities', [
{
kind: 'catchup',
key: 'catchup:1',
item: archive,
newestTimestamp: 0,
sourceName: 'Source',
trackedBytes: 0,
},
]);
await fixture.whenStable();
const actions = jest.fn(),
open = jest.fn();
component.itemAction.subscribe(actions);
component.openRequested.subscribe(open);
fixture.nativeElement
.querySelector('.download-library__title-button')
.click();
expect(actions).toHaveBeenCalledWith({ type: 'play', item: archive });
expect(open).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('News TV');
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
@@ -1,3 +1,4 @@
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@@ -25,6 +26,7 @@ import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header
selector: 'app-download-library',
standalone: true,
imports: [
DatePipe,
MatButtonModule,
MatIconModule,
MatMenuModule,
@@ -46,6 +48,8 @@ export class DownloadLibraryComponent {
protected libraryTestId(entity: DownloadLibraryEntity): string {
switch (entity.kind) {
case 'catchup':
return `download-library-catchup-${entity.item.id}`;
case 'movie':
return `download-library-movie-${entity.item.id}`;
case 'episode':
@@ -65,6 +69,8 @@ export class DownloadLibraryComponent {
protected typeKey(entity: DownloadLibraryEntity): string {
switch (entity.kind) {
case 'catchup':
return 'DOWNLOADS.CATCHUP';
case 'movie':
return 'DOWNLOADS.MOVIE';
case 'episode':
@@ -78,6 +84,7 @@ export class DownloadLibraryComponent {
switch (entity.kind) {
case 'movie':
return 'movie';
case 'catchup':
case 'episode':
return 'live_tv';
case 'series':
@@ -129,7 +136,9 @@ export class DownloadLibraryComponent {
protected openDetails(item: DownloadItem): void {
if (!this.isPending(item)) {
this.openRequested.emit(item);
if (item.contentType === 'catchup')
this.itemAction.emit({ type: 'play', item });
else this.openRequested.emit(item);
}
}
@@ -21,6 +21,10 @@ export interface DownloadMovieCardViewModel extends DownloadItemLibraryEntityBas
readonly kind: 'movie';
}
export interface DownloadCatchupCardViewModel extends DownloadItemLibraryEntityBase {
readonly kind: 'catchup';
}
export interface DownloadEpisodeCardViewModel extends DownloadItemLibraryEntityBase {
readonly kind: 'episode';
readonly episodeLabel: string;
@@ -39,6 +43,7 @@ export interface DownloadSeriesCardViewModel extends DownloadLibraryEntityBase {
export type DownloadLibraryEntity =
| DownloadMovieCardViewModel
| DownloadCatchupCardViewModel
| DownloadEpisodeCardViewModel
| DownloadSeriesCardViewModel;
@@ -216,10 +221,10 @@ export function buildDownloadLibrary(
for (const row of rows) {
const { item } = row;
if (item.contentType === 'vod') {
if (item.contentType === 'vod' || item.contentType === 'catchup') {
entities.push({
kind: 'movie',
key: `movie:${item.id}`,
kind: item.contentType === 'catchup' ? 'catchup' : 'movie',
key: `${item.contentType === 'catchup' ? 'catchup' : 'movie'}:${item.id}`,
item,
newestTimestamp: normalizedDownloadTimestamp(item.createdAt),
sourceName: row.sourceName,
@@ -3,6 +3,7 @@ import type { Playlist } from '@iptvnator/shared/interfaces';
import {
buildDownloadManagerViewModel,
normalizeDownloadFilter,
type DownloadCatchupCardViewModel,
type DownloadEpisodeCardViewModel,
type DownloadLibraryEntity,
type DownloadManagerViewModel,
@@ -13,6 +14,7 @@ import {
type PlaylistSummary = Pick<Playlist, '_id' | 'title'>;
type ExpectedDownloadLibraryEntity =
| DownloadMovieCardViewModel
| DownloadCatchupCardViewModel
| DownloadEpisodeCardViewModel
| DownloadSeriesCardViewModel;
@@ -1092,3 +1094,18 @@ describe('buildDownloadManagerViewModel', () => {
}
});
});
it('keeps separate archive programmes out of movie and series counts', () => {
const items = [
download(1, { contentType: 'catchup', xtreamId: 100 }),
download(2, { contentType: 'catchup', xtreamId: 100 }),
];
const model = build(items);
expect(model.library.map(({ kind }) => kind)).toEqual([
'catchup',
'catchup',
]);
expect(model.counts).toMatchObject({ all: 2, movie: 0, series: 0 });
expect(build(items, { filter: 'movie' }).library).toEqual([]);
expect(build(items, { filter: 'series' }).library).toEqual([]);
});
@@ -11,6 +11,7 @@ import {
} from './download-library.viewmodel';
export type {
DownloadCatchupCardViewModel,
DownloadEpisodeCardViewModel,
DownloadLibraryEntity,
DownloadMovieCardViewModel,
@@ -18,11 +19,7 @@ export type {
} from './download-library.viewmodel';
export type DownloadFilterId =
| 'all'
| 'movie'
| 'series'
| 'in-progress'
| 'recording';
'all' | 'movie' | 'series' | 'in-progress' | 'recording';
export type DownloadAttentionReason = 'file-missing' | 'transfer';
@@ -231,7 +228,9 @@ export function buildDownloadManagerViewModel({
series:
countContentType(scopedActive, 'episode') +
countContentType(scopedAttention, 'episode') +
scopedLibrary.filter(({ kind }) => kind !== 'movie').length,
scopedLibrary.filter(
({ kind }) => kind === 'series' || kind === 'episode'
).length,
inProgress: scopedActive.length,
},
activeCount: scopedItems.filter(
@@ -33,7 +33,7 @@
'DOWNLOADS.ARIA.OPEN'
| translate: { title: row.item.title }
"
[disabled]="isPending(row.item.id)"
[disabled]="isPending(row.item.id) || row.item.contentType === 'catchup'"
(click)="requestOpen(row.item)"
>
@if (hasPoster(row.item)) {
@@ -55,9 +55,9 @@
>
<mat-icon aria-hidden="true">
{{
row.item.contentType === 'episode'
? 'tv'
: 'movie'
row.item.contentType === 'vod'
? 'movie'
: 'tv'
}}
</mat-icon>
</span>
@@ -74,7 +74,7 @@
'DOWNLOADS.ARIA.OPEN'
| translate: { title: row.item.title }
"
[disabled]="isPending(row.item.id)"
[disabled]="isPending(row.item.id) || row.item.contentType === 'catchup'"
(click)="requestOpen(row.item)"
>
{{ row.item.title }}
@@ -86,6 +86,13 @@
}
</div>
@if (row.item.contentType === 'catchup') {
<p class="download-queue__episode">
{{
'DOWNLOADS.ARCHIVE_RESTART_NOTE' | translate
}}
</p>
}
<div class="download-queue__metadata">
<span
class="download-queue__status"
@@ -178,12 +185,18 @@
data-test-action="resume"
[disabled]="isPending(row.item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.RESUME'
(row.item.contentType === 'catchup'
? 'DOWNLOADS.ARIA.RESTART_ARCHIVE'
: 'DOWNLOADS.ARIA.RESUME'
)
| translate
: { title: row.item.title }
"
[matTooltip]="
'DOWNLOADS.ARIA.RESUME'
(row.item.contentType === 'catchup'
? 'DOWNLOADS.ARIA.RESTART_ARCHIVE'
: 'DOWNLOADS.ARIA.RESUME'
)
| translate
: { title: row.item.title }
"
@@ -114,6 +114,7 @@ export class DownloadQueueComponent {
}
requestOpen(item: DownloadItem): void {
if (item.contentType === 'catchup') return;
if (!this.isPending(item.id)) {
this.openRequested.emit(item);
}
@@ -328,6 +328,10 @@ export class DownloadsComponent {
}
openOfflineDetail(item: DownloadItem): void {
if (item.contentType === 'catchup') {
void this.actions.run({ type: 'play', item });
return;
}
if (this.pendingIds().has(item.id)) {
return;
}
@@ -338,6 +342,7 @@ export class DownloadsComponent {
}
async openInLibrary(item: DownloadItem): Promise<void> {
if (item.contentType === 'catchup') return;
if (this.pendingIds().has(item.id)) {
return;
}
@@ -374,7 +374,11 @@ export function buildDownloadOfflineDetail({
}
const representative = downloads.find(({ id }) => id === downloadId);
if (!representative || !isLocallyAvailable(representative)) {
if (
!representative ||
representative.contentType === 'catchup' ||
!isLocallyAvailable(representative)
) {
return undefined;
}
@@ -238,7 +238,7 @@ export class DownloadOfflineMetadataService {
provider: findMatchingStalkerDownloadRecent(
recent,
targetId(detail),
item.contentType
item.contentType === 'episode' ? 'episode' : 'vod'
),
};
} catch {
@@ -1,7 +1,7 @@
import type {
ElectronBridgeDownloadStatus,
ElectronBridgeEpisodeIdentityScope,
ElectronBridgePlaybackContentType,
DownloadContentType,
ElectronDownloadFileAvailability,
} from '@iptvnator/shared/interfaces';
@@ -18,7 +18,7 @@ export interface EpisodeDownloadIdentity {
export interface EpisodeDownloadRecord {
readonly id: number;
readonly playlistId: string;
readonly contentType: ElectronBridgePlaybackContentType;
readonly contentType: DownloadContentType;
readonly xtreamId: number;
readonly seriesXtreamId?: number | null;
readonly seasonNumber?: number | null;
@@ -73,6 +73,7 @@ export class StubEpgTimelineComponent {
readonly channelLogo = input('');
readonly sourceLabel = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDownloadAvailable = input(false);
readonly archiveContextKey = input<string | null>(null);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
@@ -94,3 +95,17 @@ export class StubEpgTimelineComponent {
standalone: true,
})
export class StubResizableDirective {}
export const sampleChannel = {
xtream_id: 101,
name: 'Channel 101',
stream_icon: 'channel-101.png',
tv_archive: 1,
tv_archive_duration: 3,
};
export const playlist = {
id: 'playlist-1',
serverUrl: 'http://demo.example',
username: 'demo',
password: 'secret',
};
@@ -230,6 +230,7 @@
[channelLogo]="activePlayback()?.thumbnail ?? ''"
[sourceLabel]="'Xtream'"
[archiveContextKey]="archiveContextKey()"
[archiveDownloadAvailable]="archiveDownloadsAvailable()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archiveDays]="controlledArchiveDays()"
[activeProgram]="activeCatchupProgram()"
@@ -252,6 +253,7 @@
[channelLogo]="activePlayback()?.thumbnail ?? ''"
[sourceLabel]="'Xtream'"
[archiveContextKey]="archiveContextKey()"
[archiveDownloadAvailable]="archiveDownloadsAvailable()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archiveDays]="controlledArchiveDays()"
[activeProgram]="activeCatchupProgram()"
@@ -24,6 +24,7 @@ import {
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
EpgArchiveDownloadService,
EpgArchiveCopyService,
EpgListViewComponent,
EpgTimelineComponent,
@@ -44,6 +45,8 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import {
sampleChannel,
playlist,
StubEpgTimelineComponent,
StubGridListComponent,
StubPortalChannelsListComponent,
@@ -61,20 +64,6 @@ describe('LiveStreamLayoutComponent', () => {
>;
const fixedNow = new Date('2026-04-05T12:00:00.000Z');
const sampleChannel = {
xtream_id: 101,
name: 'Channel 101',
stream_icon: 'channel-101.png',
tv_archive: 1,
tv_archive_duration: 3,
};
const playlist = {
id: 'playlist-1',
serverUrl: 'http://demo.example',
username: 'demo',
password: 'secret',
};
const categories = signal([{ category_id: 1, category_name: 'News' }]);
const categoryItemCounts = signal(new Map<number, number>([[1, 1]]));
const epgItems = signal<EpgItem[]>([]);
@@ -186,6 +175,14 @@ describe('LiveStreamLayoutComponent', () => {
await TestBed.configureTestingModule({
imports: [LiveStreamLayoutComponent, NoopAnimationsModule],
providers: [
{
provide: EpgArchiveDownloadService,
useValue: {
start: jest.fn(async (_input, resolve) => {
await resolve();
}),
},
},
{
provide: EpgArchiveCopyService,
useValue: {
@@ -226,6 +223,7 @@ describe('LiveStreamLayoutComponent', () => {
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsDownloads: true,
get supportsEpg() {
return Boolean(window.electron);
},
@@ -785,6 +783,42 @@ describe('LiveStreamLayoutComponent', () => {
);
});
it('queues a completed programme with its channel identity and headers without changing playback', async () => {
const before = component.activePlayback();
const start = Math.floor(Date.now() / 1000) - 7200;
await component.onProgramActivated({
type: 'download-catchup',
program: {
start: new Date(start * 1000).toISOString(),
stop: new Date((start + 3600) * 1000).toISOString(),
startTimestamp: start,
stopTimestamp: start + 3600,
channel: 'channel-101',
title: 'Archive',
desc: null,
category: null,
},
});
expect(
TestBed.inject(EpgArchiveDownloadService).start
).toHaveBeenCalledWith(
expect.objectContaining({
playlistId: playlist.id,
xtreamId: 101,
title: 'Archive',
catchup: expect.objectContaining({
startTimestamp: start,
stopTimestamp: start + 3600,
}),
headers: expect.objectContaining({
userAgent: expect.any(String),
}),
}),
expect.any(Function)
);
expect(component.activePlayback()).toEqual(before);
});
it('copies the programme URL without changing playback or channel selection', async () => {
const before = component.activePlayback();
xtreamStore.openPlayer.mockClear();
@@ -1,3 +1,5 @@
import { EpgArchiveDownloadService } from '@iptvnator/ui/epg';
import { XTREAM_CLIENT_USER_AGENT } from '@iptvnator/shared/interfaces';
import { NgTemplateOutlet } from '@angular/common';
import {
ChangeDetectionStrategy,
@@ -153,6 +155,10 @@ export class LiveStreamLayoutComponent
this.selectedLiveItem()?.xtream_id,
])
);
private readonly archiveDownloads = inject(EpgArchiveDownloadService);
readonly archiveDownloadsAvailable = computed(
() => this.runtime.supportsDownloads && this.archivePlaybackAvailable()
);
private readonly archiveCopy = inject(EpgArchiveCopyService);
private readonly xtreamUrlService = inject(XtreamUrlService);
private readonly runtime = inject(RuntimeCapabilitiesService);
@@ -619,6 +625,66 @@ export class LiveStreamLayoutComponent
return;
}
if (event.type === 'download-catchup') {
const playlist = this.xtreamStore.currentPlaylist();
const start = this.getProgramTimestampSeconds(
event.program.start,
event.program.startTimestamp
);
const stop = this.getProgramTimestampSeconds(
event.program.stop,
event.program.stopTimestamp
);
if (
!this.archiveDownloadsAvailable() ||
!playlist ||
!start ||
!stop ||
stop > Date.now() / 1000 ||
stop <= start
)
return;
const days = Number(selectedItem.tv_archive_duration ?? 0);
await this.archiveDownloads.start(
{
playlistId: playlist.id,
xtreamId: selectedItem.xtream_id,
playlistType: 'xtream',
serverUrl: playlist.serverUrl,
title: event.program.title,
posterUrl:
selectedItem.poster_url ??
selectedItem.stream_icon ??
undefined,
catchup: {
channelName:
selectedItem.title ?? selectedItem.name ?? '',
startTimestamp: start,
stopTimestamp: stop,
...(days > 0
? { expiresAt: Math.floor(start + days * 86400) }
: {}),
},
headers: {
userAgent:
playlist.userAgent?.trim() ||
XTREAM_CLIENT_USER_AGENT,
referer: playlist.referrer,
origin: playlist.origin,
},
},
() =>
this.xtreamUrlService.resolveCatchupUrl(
playlist.id,
playlist,
selectedItem.xtream_id,
start,
stop,
playlist.serverTimezone
)
);
return;
}
if (event.type === 'copy-catchup-url') {
const playlist = this.xtreamStore.currentPlaylist();
await this.archiveCopy.copy(() => {
+4 -1
View File
@@ -1,4 +1,6 @@
import type {
CatchupDownloadMetadata,
DownloadContentType,
DownloadMetadataSnapshot,
ElectronBridgeDownloadStatus,
ElectronBridgeDownloadStartPayload,
@@ -16,7 +18,8 @@ export interface DownloadItem {
id: number;
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
contentType: DownloadContentType;
catchup?: CatchupDownloadMetadata | null;
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
@@ -56,7 +56,7 @@ const completedMigrationStateRule: HandlerRule = [
/** The live title index, already carrying the folding tokenizer. */
const foldedIndexRule: HandlerRule = [
'SELECT sql FROM sqlite_master',
"name = 'content_title_fts'",
{
get: () => ({
sql: "CREATE VIRTUAL TABLE content_title_fts USING fts5(title, tokenize='trigram remove_diacritics 1')",
+6 -29
View File
@@ -1,3 +1,8 @@
import {
DOWNLOADS_TABLE_SQL,
DOWNLOADS_INDEX_STATEMENTS,
ensureDownloadsCatchupSchema,
} from './download-schema';
/**
* Database connection and initialization for IPTVnator
* Uses Drizzle ORM with better-sqlite3
@@ -90,35 +95,6 @@ const TMDB_METADATA_TABLE_SQL = `CREATE TABLE IF NOT EXISTS tmdb_metadata (
fetched_at TEXT DEFAULT (datetime('now'))
)`;
const TMDB_METADATA_INDEX_SQL = `CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)`;
const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
episode_identity_scope TEXT,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
file_path TEXT,
poster_url TEXT,
request_headers TEXT,
resume_validator TEXT,
metadata_snapshot TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')),
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
error_message TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`;
const DOWNLOADS_INDEX_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
];
// No unique index: re-recording the same channel is a normal workflow, and
// playlist_id carries no FK so recordings survive source deletion.
const RECORDINGS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS recordings (
@@ -1165,6 +1141,7 @@ function runMigrations(sqliteDb: Database.Database): void {
cleanupLegacyTmdbSearchCache(sqliteDb);
ensureDownloadsPauseResumeSchema(sqliteDb);
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
ensureDownloadsCatchupSchema(sqliteDb);
// The tokenizer upgrade recreates and rebuilds the index itself, so the
// plain rebuild below would only repeat work it just did.
if (!upgradeContentTitleFtsTokenizer(sqliteDb)) {
@@ -0,0 +1,59 @@
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
it('migrates existing downloads without losing files and separates programme identities', () => {
const electron = createRequire(__filename)('electron') as string;
const moduleUrl = pathToFileURL(
resolve(__dirname, 'download-schema.ts')
).href;
const result = execFileSync(
electron,
[
'--import',
'tsx',
'--eval',
`
const { default: Database } = await import('better-sqlite3');
const { DOWNLOADS_TABLE_SQL, ensureDownloadsCatchupSchema } = await import(${JSON.stringify(moduleUrl)});
const db = new Database(':memory:');
db.exec(DOWNLOADS_TABLE_SQL.replace(", 'catchup'", '').replace('programme_start INTEGER NOT NULL DEFAULT 0,', '').replace('catchup TEXT,', ''));
db.exec("CREATE UNIQUE INDEX downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)");
db.prepare("INSERT INTO downloads (id,playlist_id,xtream_id,content_type,title,url,status,file_path,bytes_downloaded,resume_validator,request_headers) VALUES (42,'p',1,'vod','Movie','https://host/movie','paused','/safe/movie.mp4',123,'etag','headers')").run();
ensureDownloadsCatchupSchema(db);
ensureDownloadsCatchupSchema(db);
const insert = db.prepare("INSERT INTO downloads (playlist_id,xtream_id,content_type,programme_start,title,url) VALUES ('p',1,?,?, 'Show','https://host/archive')");
insert.run('catchup', 1000); insert.run('catchup', 2000);
let duplicateArchive = false, duplicateMovie = false;
try { insert.run('catchup', 1000); } catch { duplicateArchive = true; }
try { insert.run('vod', 0); } catch { duplicateMovie = true; }
process.stdout.write(JSON.stringify({row:db.prepare('SELECT * FROM downloads WHERE id=42').get(), count:db.prepare('SELECT count(*) AS n FROM downloads').get().n, duplicateArchive,duplicateMovie}));
`,
],
{
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
TSX_TSCONFIG_PATH: resolve(process.cwd(), 'tsconfig.base.json'),
},
}
);
expect(JSON.parse(result)).toMatchObject({
count: 3,
duplicateArchive: true,
duplicateMovie: true,
row: {
id: 42,
status: 'paused',
file_path: '/safe/movie.mp4',
bytes_downloaded: 123,
resume_validator: 'etag',
request_headers: 'headers',
programme_start: 0,
catchup: null,
},
});
});
@@ -0,0 +1,87 @@
import type Database from 'better-sqlite3';
export const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode', 'catchup')),
programme_start INTEGER NOT NULL DEFAULT 0,
catchup TEXT,
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
episode_identity_scope TEXT,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
file_path TEXT,
poster_url TEXT,
request_headers TEXT,
resume_validator TEXT,
metadata_snapshot TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')),
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
error_message TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`;
export const DOWNLOADS_INDEX_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type) WHERE content_type != 'catchup'`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
];
const CATCHUP_INDEX = `CREATE UNIQUE INDEX IF NOT EXISTS downloads_catchup_unique
ON downloads(xtream_id, playlist_id, programme_start) WHERE content_type = 'catchup'`;
/** Widen the CHECK transactionally; existing ids, files and resume state survive. */
export function ensureDownloadsCatchupSchema(db: Database.Database): void {
const row = db
.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'"
)
.get() as { sql: string } | undefined;
if (!row) return;
if (row.sql.includes("'catchup'")) {
db.exec(CATCHUP_INDEX);
return;
}
const columns = [
'id',
'playlist_id',
'xtream_id',
'content_type',
'series_xtream_id',
'season_number',
'episode_number',
'episode_identity_scope',
'title',
'url',
'file_name',
'file_path',
'poster_url',
'request_headers',
'resume_validator',
'metadata_snapshot',
'status',
'bytes_downloaded',
'total_bytes',
'error_message',
'created_at',
'updated_at',
];
db.transaction(() => {
db.exec('DROP INDEX IF EXISTS downloads_xtream_playlist_unique');
db.exec('DROP INDEX IF EXISTS downloads_playlist_idx');
db.exec('DROP INDEX IF EXISTS downloads_status_idx');
db.exec('ALTER TABLE downloads RENAME TO downloads_catchup_legacy');
db.exec(DOWNLOADS_TABLE_SQL);
db.exec(
`INSERT INTO downloads (${columns.join(',')}) SELECT ${columns.join(',')} FROM downloads_catchup_legacy`
);
db.exec('DROP TABLE downloads_catchup_legacy');
for (const statement of DOWNLOADS_INDEX_STATEMENTS) db.exec(statement);
db.exec(CATCHUP_INDEX);
})();
}
+12 -4
View File
@@ -1,3 +1,4 @@
import type { CatchupDownloadMetadata } from '@iptvnator/shared/interfaces';
/**
* Drizzle ORM schema for IPTVnator database
* This schema defines the structure for Xtream Codes API data storage
@@ -344,8 +345,12 @@ export const downloads = sqliteTable(
// Content identifiers
xtreamId: integer('xtream_id').notNull(),
contentType: text('content_type', {
enum: ['vod', 'episode'],
enum: ['vod', 'episode', 'catchup'],
}).notNull(),
programmeStart: integer('programme_start').notNull().default(0),
catchup: text('catchup', {
mode: 'json',
}).$type<CatchupDownloadMetadata>(),
// For episodes: store series info
seriesXtreamId: integer('series_xtream_id'),
seasonNumber: integer('season_number'),
@@ -383,9 +388,12 @@ export const downloads = sqliteTable(
(table) => ({
playlistIdx: index('downloads_playlist_idx').on(table.playlistId),
statusIdx: index('downloads_status_idx').on(table.status),
xtreamPlaylistUnique: uniqueIndex(
'downloads_xtream_playlist_unique'
).on(table.xtreamId, table.playlistId, table.contentType),
xtreamPlaylistUnique: uniqueIndex('downloads_xtream_playlist_unique')
.on(table.xtreamId, table.playlistId, table.contentType)
.where(sql`${table.contentType} != 'catchup'`),
catchupUnique: uniqueIndex('downloads_catchup_unique')
.on(table.xtreamId, table.playlistId, table.programmeStart)
.where(sql`${table.contentType} = 'catchup'`),
})
);
+2
View File
@@ -94,3 +94,5 @@ export * from './lib/stalker-vod-details.interface';
// Unified VOD details (discriminated union + adapters)
export * from './lib/vod-details-adapters';
export * from './lib/vod-details-item.interface';
export * from './lib/catchup-download.interface';
@@ -0,0 +1,10 @@
/** Provider-clock instants in seconds; independent of the viewer's EPG offset. */
export interface CatchupDownloadMetadata {
channelName: string;
startTimestamp: number;
stopTimestamp: number;
/** Last known archive availability; absent when the provider gives no window. */
expiresAt?: number;
}
export type DownloadContentType = 'vod' | 'episode' | 'catchup';
@@ -1,3 +1,7 @@
import type {
CatchupDownloadMetadata,
DownloadContentType,
} from './catchup-download.interface';
import {
EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions,
@@ -585,7 +589,8 @@ export interface ElectronBridgeDownloadHeaders {
export interface ElectronBridgeDownloadStartPayload {
playlistId: string;
xtreamId: number;
contentType: ElectronBridgePlaybackContentType;
contentType: DownloadContentType;
catchup?: CatchupDownloadMetadata | null;
title: string;
url: string;
posterUrl?: string;
@@ -616,7 +621,8 @@ export interface ElectronDownloadItem {
id: number;
playlistId: string;
xtreamId: number;
contentType: ElectronBridgePlaybackContentType;
contentType: DownloadContentType;
catchup?: CatchupDownloadMetadata | null;
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
+2
View File
@@ -30,3 +30,5 @@ export * from './lib/epg-guide/epg-guide.component';
export * from './lib/epg-guide/epg-guide-now-playing.component';
export * from './lib/epg-archive-copy.service';
export * from './lib/epg-archive-download.service';
@@ -0,0 +1,87 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { DownloadsService } from '@iptvnator/services';
import { TranslateService } from '@ngx-translate/core';
import { EpgArchiveDownloadService } from './epg-archive-download.service';
describe('archive download feedback', () => {
const startDownload = jest.fn();
const isAvailable = jest.fn();
const open = jest.fn();
const input = {
playlistId: 'p',
xtreamId: 1,
title: 'News',
catchup: {
channelName: 'TV',
startTimestamp: 1000,
stopTimestamp: 2000,
},
};
let service: EpgArchiveDownloadService;
beforeEach(() => {
startDownload.mockReset().mockResolvedValue({ success: true });
isAvailable.mockReset().mockReturnValue(true);
open.mockReset();
TestBed.configureTestingModule({
providers: [
{
provide: DownloadsService,
useValue: { startDownload, isAvailable },
},
{ provide: MatSnackBar, useValue: { open } },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
],
});
service = TestBed.inject(EpgArchiveDownloadService);
});
it('passes the captured programme and resolved URL to the download manager', async () => {
await service.start(input, async () => 'https://host/archive.ts');
expect(startDownload).toHaveBeenCalledWith({
...input,
url: 'https://host/archive.ts',
contentType: 'catchup',
});
expect(open).toHaveBeenCalledWith(
'EPG.PROGRAM_DIALOG.ARCHIVE_DOWNLOAD_QUEUED',
undefined,
{ duration: 5000 }
);
});
it('does not offer browser downloads or enqueue HLS', async () => {
await service.start(input, async () => 'https://host/archive.m3u8');
expect(startDownload).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledWith(
'EPG.PROGRAM_DIALOG.ARCHIVE_DOWNLOAD_TS_ONLY',
undefined,
{ duration: 5000 }
);
isAvailable.mockReturnValue(false);
const resolve = jest.fn();
await service.start(input, resolve);
expect(resolve).not.toHaveBeenCalled();
});
it('coalesces pending clicks and hides provider credentials on failure', async () => {
let reject!: (error: Error) => void;
const first = service.start(
input,
() =>
new Promise((_resolve, fail) => {
reject = fail;
})
);
const second = jest.fn();
await service.start(input, second);
expect(second).not.toHaveBeenCalled();
reject(new Error('https://secret@host'));
await first;
expect(open).toHaveBeenCalledWith(
'EPG.PROGRAM_DIALOG.ARCHIVE_DOWNLOAD_FAILED',
undefined,
{ duration: 5000 }
);
});
});
@@ -0,0 +1,54 @@
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { DownloadsService, type DownloadStartInput } from '@iptvnator/services';
import { TranslateService } from '@ngx-translate/core';
@Injectable({ providedIn: 'root' })
export class EpgArchiveDownloadService {
private readonly downloads = inject(DownloadsService);
private readonly snackbar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private pending = false;
async start(
input: Omit<DownloadStartInput, 'url' | 'contentType'>,
resolve: () => Promise<string>
): Promise<void> {
if (this.pending || !this.downloads.isAvailable()) return;
this.pending = true;
let message = 'ARCHIVE_DOWNLOAD_FAILED';
try {
const url = await resolve();
const parsed = new URL(url);
let hls = false;
parsed.searchParams.forEach((value) => {
if (/^m3u8$/i.test(value)) hls = true;
});
if (/\.m3u8$/i.test(parsed.pathname) || hls) {
message = 'ARCHIVE_DOWNLOAD_TS_ONLY';
} else {
const result = await this.downloads.startDownload({
...input,
url,
contentType: 'catchup',
});
if (result.success) message = 'ARCHIVE_DOWNLOAD_QUEUED';
else if (
result.reason === 'already-in-progress' ||
result.reason === 'already-downloaded'
) {
message = 'ARCHIVE_DOWNLOAD_EXISTS';
}
}
} catch {
// Neither URLs nor provider error messages are suitable for a toast.
} finally {
this.pending = false;
}
this.snackbar.open(
this.translate.instant(`EPG.PROGRAM_DIALOG.${message}`),
undefined,
{ duration: 5000 }
);
}
}
@@ -111,6 +111,17 @@
{{ 'EPG.PROGRAM_DIALOG.COPY_ARCHIVE_URL' | translate }}
</button>
}
@if (dialogData.archiveDownloadAvailable) {
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--ghost"
data-testid="download-catchup"
[mat-dialog-close]="'download-catchup'"
>
<mat-icon>download</mat-icon>
{{ 'EPG.PROGRAM_DIALOG.DOWNLOAD_ARCHIVE' | translate }}
</button>
}
@if (archiveUnavailableNote) {
<span class="epg-dialog__note">{{
'EPG.TIMELINE.ARCHIVE_UNAVAILABLE_NOTE' | translate
@@ -11,11 +11,13 @@ import { EpgProgram } from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { getProgramTimeMs } from '../epg-program.utils';
export type EpgItemDialogAction = 'live' | 'timeshift' | 'copy-catchup-url';
export type EpgItemDialogAction =
'live' | 'timeshift' | 'copy-catchup-url' | 'download-catchup';
export type EpgItemDialogData = EpgProgram & {
/** Host can resolve an archive URL without starting playback. */
archiveUrlAvailable?: boolean;
archiveDownloadAvailable?: boolean;
channelName?: string | null;
channel_name?: string | null;
display_name?: string | null;
@@ -1,3 +1,4 @@
import { isAllowedArchiveAction } from '../epg-program-activation-event';
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
@@ -83,6 +84,7 @@ export class EpgListViewComponent {
readonly sourceLabel = input('Programme guide');
readonly archivePlaybackAvailable = input(false);
/** Source/channel identity captured by programme details, independent of playback. */
readonly archiveDownloadAvailable = input(false);
readonly archiveContextKey = input<string | null>(null);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
@@ -291,6 +293,10 @@ export class EpgListViewComponent {
channelName: this.channelName(),
channelLogo: this.channelLogo(),
archiveUrlAvailable: row.canCatchUp,
archiveDownloadAvailable:
this.archiveDownloadAvailable() &&
row.when === 'past' &&
row.canCatchUp,
primaryAction: epgDialogActionFor(row.when, row.canCatchUp),
archiveUnavailableNote:
row.when === 'past' && !this.archivePlaybackAvailable(),
@@ -300,7 +306,13 @@ export class EpgListViewComponent {
if (archiveContextKey !== this.archiveContextKey()) return;
if (result === 'live') {
this.returnToLive.emit();
} else if (result === 'copy-catchup-url' && row.canCatchUp) {
} else if (
isAllowedArchiveAction(
result,
row.canCatchUp,
this.archiveDownloadAvailable() && row.when === 'past'
)
) {
this.programActivated.emit({
program: row.program,
type: result,
@@ -2,5 +2,18 @@ import type { EpgProgram } from '@iptvnator/shared/interfaces';
export interface EpgProgramActivationEvent {
program: EpgProgram;
type: 'live' | 'timeshift' | 'copy-catchup-url';
type: 'live' | 'timeshift' | 'copy-catchup-url' | 'download-catchup';
}
/** Keep archive actions independent from selection and playback actions. */
export function isAllowedArchiveAction(
action: EpgProgramActivationEvent['type'] | undefined,
canCatchUp: boolean,
canDownload: boolean
): action is 'copy-catchup-url' | 'download-catchup' {
return (
canCatchUp &&
(action === 'copy-catchup-url' ||
(action === 'download-catchup' && canDownload))
);
}
@@ -152,6 +152,7 @@ describe('EpgTimelineComponent', () => {
setInputs({
programs: [programAt(-180, 60, 'Earlier')],
archivePlaybackAvailable: true,
archiveDownloadAvailable: true,
archiveDays: 7,
});
const past = component.blocks().find((b) => b.when === 'past');
@@ -166,6 +167,7 @@ describe('EpgTimelineComponent', () => {
setInputs({
programs: [programAt(-180, 60, 'Earlier')],
archivePlaybackAvailable: true,
archiveDownloadAvailable: true,
archiveDays: 7,
});
const past = component.blocks().find((b) => b.when === 'past');
@@ -188,56 +190,84 @@ describe('EpgTimelineComponent', () => {
expect(returned).toBe(true);
});
it('forwards archive copy separately from playback and keeps selection', () => {
const past = programAt(-180, 60, 'Earlier');
setInputs({
programs: [past],
selectedDate: localDateKey(past.start),
archivePlaybackAvailable: true,
archiveDays: 7,
});
jest.spyOn(TestBed.inject(MatDialog), 'open').mockReturnValue({
afterClosed: () => of('copy-catchup-url'),
} as never);
const events: string[] = [];
component.programActivated.subscribe((event) =>
events.push(event.type)
);
const selection = component.selectedKey();
component.openDetails(
component
.blocks()
.find((block) => block.when === 'past') as TimelineBlock
);
expect(events).toEqual(['copy-catchup-url']);
expect(component.selectedKey()).toBe(selection);
});
it.each(['copy-catchup-url', 'download-catchup'])(
'forwards %s separately from playback and keeps selection',
(action) => {
const past = programAt(-180, 60, 'Earlier');
setInputs({
programs: [past],
selectedDate: localDateKey(past.start),
archivePlaybackAvailable: true,
archiveDownloadAvailable: true,
archiveDays: 7,
});
jest.spyOn(TestBed.inject(MatDialog), 'open').mockReturnValue({
afterClosed: () => of(action),
} as never);
const events: string[] = [];
component.programActivated.subscribe((event) =>
events.push(event.type)
);
const selection = component.selectedKey();
component.openDetails(
component
.blocks()
.find((block) => block.when === 'past') as TimelineBlock
);
expect(events).toEqual([action]);
expect(component.selectedKey()).toBe(selection);
}
);
it('does not copy a programme from a dialog belonging to the previous channel', () => {
const past = programAt(-180, 60, 'Earlier');
it.each(['copy-catchup-url', 'download-catchup'])(
'does not dispatch %s from the previous channel dialog',
(action) => {
const past = programAt(-180, 60, 'Earlier');
setInputs({
programs: [past],
selectedDate: localDateKey(past.start),
archivePlaybackAvailable: true,
archiveDownloadAvailable: true,
archiveDays: 7,
archiveContextKey: 'source:channel-a',
});
const result = new BehaviorSubject<string | undefined>(undefined);
jest.spyOn(TestBed.inject(MatDialog), 'open').mockReturnValue({
afterClosed: () => result,
} as never);
const events: string[] = [];
component.programActivated.subscribe((event) =>
events.push(event.type)
);
component.openDetails(
component
.blocks()
.find((block) => block.when === 'past') as TimelineBlock
);
setInputs({ archiveContextKey: 'source:channel-b' });
result.next(action);
expect(events).toEqual([]);
}
);
it('never dispatches a download for the current programme', () => {
setInputs({
programs: [past],
selectedDate: localDateKey(past.start),
programs: [programAt(-10, 60, 'Current')],
archivePlaybackAvailable: true,
archiveDownloadAvailable: true,
archiveDays: 7,
archiveContextKey: 'source:channel-a',
});
const result = new BehaviorSubject<string | undefined>(undefined);
jest.spyOn(TestBed.inject(MatDialog), 'open').mockReturnValue({
afterClosed: () => result,
afterClosed: () => of('download-catchup'),
} as never);
const events: string[] = [];
component.programActivated.subscribe((event) =>
events.push(event.type)
);
const events = jest.fn();
component.programActivated.subscribe(events);
component.openDetails(
component
.blocks()
.find((block) => block.when === 'past') as TimelineBlock
.find((block) => block.when === 'now') as TimelineBlock
);
setInputs({ archiveContextKey: 'source:channel-b' });
result.next('copy-catchup-url');
expect(events).toEqual([]);
expect(events).not.toHaveBeenCalled();
});
it('emits the centred day when stepping forward', () => {
@@ -1,3 +1,4 @@
import { isAllowedArchiveAction } from '../epg-program-activation-event';
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
@@ -92,6 +93,7 @@ export class EpgTimelineComponent {
readonly sourceLabel = input('Timeline');
readonly archivePlaybackAvailable = input(false);
/** Source/channel identity captured by programme details, independent of playback. */
readonly archiveDownloadAvailable = input(false);
readonly archiveContextKey = input<string | null>(null);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
@@ -382,14 +384,19 @@ export class EpgTimelineComponent {
}
openDetails(block: TimelineBlock): void {
const canCatchUp = this.canCatchUp(block);
const archiveContextKey = this.archiveContextKey();
this.programmeDialog
.open({
...block.program,
channelName: this.channelName(),
channelLogo: this.channelLogo(),
archiveUrlAvailable: this.canCatchUp(block),
primaryAction: this.dialogActionFor(block),
archiveUrlAvailable: canCatchUp,
archiveDownloadAvailable:
this.archiveDownloadAvailable() &&
block.when === 'past' &&
canCatchUp,
primaryAction: epgDialogActionFor(block.when, canCatchUp),
archiveUnavailableNote:
block.when === 'past' && !this.archivePlaybackAvailable(),
})
@@ -399,8 +406,11 @@ export class EpgTimelineComponent {
if (result === 'live') {
this.returnToLive.emit();
} else if (
result === 'copy-catchup-url' &&
this.canCatchUp(block)
isAllowedArchiveAction(
result,
this.canCatchUp(block),
this.archiveDownloadAvailable() && block.when === 'past'
)
) {
this.programActivated.emit({
program: block.program,
@@ -451,8 +461,4 @@ export class EpgTimelineComponent {
this.viewDayKey.set(dayKey);
this.selectedDateChange.emit(dayKey);
}
private dialogActionFor(block: TimelineBlock): EpgItemDialogAction | null {
return epgDialogActionFor(block.when, this.canCatchUp(block));
}
}