mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
feat(downloads): save completed Xtream archive programmes as TS
This commit is contained in:
1 parent
dcaeb26849
commit
316243f45d
74 files changed
+1678
-269
No files matched your search
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "التحكم عن بعد",
|
||||
|
||||
@@ -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": "التحكم عن بعد",
|
||||
|
||||
@@ -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": "Аддаленае кіраванне",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Τηλεχειριστήριο",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "リモートコントロール",
|
||||
|
||||
@@ -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": "원격 제어",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Дистанционное управление",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "远程控制",
|
||||
|
||||
@@ -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": "遠端控制",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+5
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+15
@@ -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',
|
||||
};
|
||||
+2
@@ -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()"
|
||||
|
||||
+48
-14
@@ -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(() => {
|
||||
|
||||
@@ -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')",
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
}
|
||||
@@ -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'`),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user