diff --git a/.changes/downloads-xtream-archive.md b/.changes/downloads-xtream-archive.md new file mode 100644 index 000000000..3d0c4b0c2 --- /dev/null +++ b/.changes/downloads-xtream-archive.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 8cad45309..31c9c9604 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/CLAUDE.md b/CLAUDE.md index 2777fd214..c716afcb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts index 98023d02b..e7ca4580c 100644 --- a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts @@ -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 + >; + }, 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); + } +}); diff --git a/apps/electron-backend/src/app/events/database/download-catchup-transfer.spec.ts b/apps/electron-backend/src/app/events/database/download-catchup-transfer.spec.ts new file mode 100644 index 000000000..dd991df5f --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-catchup-transfer.spec.ts @@ -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('expired'), + 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 }); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-catchup-transfer.ts b/apps/electron-backend/src/app/events/database/download-catchup-transfer.ts new file mode 100644 index 000000000..8d377f36a --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-catchup-transfer.ts @@ -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 { + 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( + 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); + } +} diff --git a/apps/electron-backend/src/app/events/database/download-catchup.spec.ts b/apps/electron-backend/src/app/events/database/download-catchup.spec.ts new file mode 100644 index 000000000..38b5f4cfa --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-catchup.spec.ts @@ -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/); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-catchup.ts b/apps/electron-backend/src/app/events/database/download-catchup.ts new file mode 100644 index 000000000..902a1388f --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-catchup.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; +} diff --git a/apps/electron-backend/src/app/events/database/download-finalize.ts b/apps/electron-backend/src/app/events/database/download-finalize.ts index 752831951..f7bb47b72 100644 --- a/apps/electron-backend/src/app/events/database/download-finalize.ts +++ b/apps/electron-backend/src/app/events/database/download-finalize.ts @@ -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 || diff --git a/apps/electron-backend/src/app/events/database/download-redownload.ts b/apps/electron-backend/src/app/events/database/download-redownload.ts index fd9aa8d44..d99393ca5 100644 --- a/apps/electron-backend/src/app/events/database/download-redownload.ts +++ b/apps/electron-backend/src/app/events/database/download-redownload.ts @@ -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, diff --git a/apps/electron-backend/src/app/events/database/download-request-identity.spec.ts b/apps/electron-backend/src/app/events/database/download-request-identity.spec.ts index 044d3f4cf..15e510fbf 100644 --- a/apps/electron-backend/src/app/events/database/download-request-identity.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-request-identity.spec.ts @@ -18,6 +18,8 @@ const episodeRequest = { function createDownloadRow(overrides: Partial = {}): DownloadRow { return { + catchup: null, + programmeStart: 0, bytesDownloaded: 0, contentType: 'episode', createdAt: '2026-08-02 10:00:00', diff --git a/apps/electron-backend/src/app/events/database/download-request-identity.ts b/apps/electron-backend/src/app/events/database/download-request-identity.ts index d78c43926..c71e1144a 100644 --- a/apps/electron-backend/src/app/events/database/download-request-identity.ts +++ b/apps/electron-backend/src/app/events/database/download-request-identity.ts @@ -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); diff --git a/apps/electron-backend/src/app/events/database/download-request-options.ts b/apps/electron-backend/src/app/events/database/download-request-options.ts new file mode 100644 index 000000000..1b41874fa --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-request-options.ts @@ -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 | undefined { + if (!headers) { + return undefined; + } + + const result: Record = {}; + 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 | undefined +): string | null { + return headers ? JSON.stringify(headers) : null; +} diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts index 6a24e7481..a286b04ec 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -86,6 +86,8 @@ async function setupStartMetadataRequest( function createStartDownloadRow(overrides: Partial = {}): Download { return { + catchup: null, + programmeStart: 0, bytesDownloaded: 0, contentType: 'episode', createdAt: '2026-08-02 10:00:00', diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index 0ac39c23d..00b36d3e9 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -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 | undefined { - if (!headers) { - return undefined; - } - - const result: Record = {}; - 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 | undefined -): string | null { - return headers ? JSON.stringify(headers) : null; -} - export async function startDownloadRequest( data: StartDownloadRequest, authorizer: DownloadDirectoryAuthorizer ): Promise { + 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, diff --git a/apps/electron-backend/src/app/events/database/download-runtime.ts b/apps/electron-backend/src/app/events/database/download-runtime.ts index aca41f5c2..7647eb604 100644 --- a/apps/electron-backend/src/app/events/database/download-runtime.ts +++ b/apps/electron-backend/src/app/events/database/download-runtime.ts @@ -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 { 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; diff --git a/apps/electron-backend/src/app/events/database/download-task.ts b/apps/electron-backend/src/app/events/database/download-task.ts index 685950e30..2ead8c109 100644 --- a/apps/electron-backend/src/app/events/database/download-task.ts +++ b/apps/electron-backend/src/app/events/database/download-task.ts @@ -1,3 +1,4 @@ +import type { CatchupDownloadMetadata } from '@iptvnator/shared/interfaces'; import type { getDatabase } from '../../database/connection'; export type DownloadsDatabase = Awaited>; @@ -12,6 +13,7 @@ export interface CompletedPartialProgress extends TransferProgress { } export interface DownloadTask { + catchup?: CatchupDownloadMetadata; id: number; url: string; fileName: string; diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 2f6eb2e63..5330a9ad2 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "التحكم عن بعد", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index ad2480487..09c116d35 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "التحكم عن بعد", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e9fd587e7..af5c0cf2f 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Аддаленае кіраванне", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 86869f336..0c4e20335 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 026b4c6fa..cc2999dba 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Τηλεχειριστήριο", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 86d47c533..229a9c985 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 374d05eee..95aab4e56 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 2f1cc4bd4..d82326c83 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 87c8f9191..9752ab0a8 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0cb3d7fec..e0d74255e 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index f64649f59..c5288e5c2 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "リモートコントロール", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 2135a99f8..6f22cadd2 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "원격 제어", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 48a4ef49a..fc63d3a13 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index d45fb60a6..c8c9261bf 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 0ddb23757..5ee835fa0 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 9cb942f33..5197fecaa 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Дистанционное управление", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 43097a844..46e1ed2ed 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 0fb51920f..b161b6d6e 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "远程控制", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 50404a903..8e36dbb66 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "遠端控制", diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index cb001deba..1bdee5a51 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -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); } diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 112bd9bcb..fa6875118 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -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 diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 6e89fb883..6657e7ded 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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. diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts index aaed330a4..79397794d 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts @@ -32,6 +32,7 @@ export class DownloadLibraryNavigationService { availablePlaylistIds: ReadonlySet ): 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 { + 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) { diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.html b/libs/portal/downloads/feature/src/lib/download-library.component.html index aab3ba201..c2a3fba88 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.html +++ b/libs/portal/downloads/feature/src/lib/download-library.component.html @@ -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 @@ } } + @if ( + entity.kind === 'catchup' && entity.item.catchup; + as archive + ) { +
+ {{ archive.channelName }} · + {{ + archive.startTimestamp * 1000 + | date: 'medium' + }} +
+ } @if (entity.kind === 'episode' && entity.episodeLabel) { {{ entity.episodeLabel }} diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts index 01f9fbd2f..022d2f9b6 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts @@ -135,6 +135,38 @@ describe('DownloadLibraryComponent', () => { let fixture: ComponentFixture; 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: [ diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.ts b/libs/portal/downloads/feature/src/lib/download-library.component.ts index fa1ae0c97..4825da6c0 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.component.ts @@ -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); } } diff --git a/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts index f21b50352..3c7b484f4 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts @@ -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, diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts index bd8c70a55..35806d00c 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts @@ -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; 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([]); +}); diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts index 8309b6f83..5682b30ba 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts @@ -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( diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.html b/libs/portal/downloads/feature/src/lib/download-queue.component.html index 4e28d176b..c6a1b3f3e 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.html +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.html @@ -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 @@ > @@ -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 @@ } + @if (row.item.contentType === 'catchup') { +

+ {{ + 'DOWNLOADS.ARCHIVE_RESTART_NOTE' | translate + }} +

+ }