mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(downloads): pause and resume support for the download manager (#1147)
Adds a paused state to the Electron download manager with a full partial-file lifecycle: - Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable. - Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed. - Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update. - Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids. - Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only. - UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales. - Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly. Co-authored-by: genrichh93-ui <genrichh93@users.noreply.github.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
0273ded8e2
commit
188f5c4b56
63 files changed
+4986
-683
No files matched your search
@@ -228,6 +228,7 @@
|
||||
|
||||
@if (
|
||||
item.status === 'downloading' ||
|
||||
item.status === 'paused' ||
|
||||
item.status === 'completed'
|
||||
) {
|
||||
<span class="downloads__item-size">
|
||||
@@ -253,6 +254,17 @@
|
||||
</div>
|
||||
|
||||
@if (item.status === 'downloading') {
|
||||
<mat-progress-bar
|
||||
[mode]="
|
||||
item.totalBytes
|
||||
? 'determinate'
|
||||
: 'indeterminate'
|
||||
"
|
||||
[value]="getProgress(item)"
|
||||
></mat-progress-bar>
|
||||
} @else if (
|
||||
item.status === 'paused' && item.totalBytes
|
||||
) {
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="getProgress(item)"
|
||||
@@ -274,6 +286,18 @@
|
||||
</button>
|
||||
@switch (item.status) {
|
||||
@case ('queued') {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
pause(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.PAUSE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>pause</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
@@ -288,6 +312,18 @@
|
||||
</button>
|
||||
}
|
||||
@case ('downloading') {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
pause(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.PAUSE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>pause</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
@@ -301,6 +337,46 @@
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('paused') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
resume(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RESUME' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
cancel(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
remove(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REMOVE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('completed') {
|
||||
<button
|
||||
mat-icon-button
|
||||
|
||||
@@ -442,6 +442,11 @@
|
||||
&.status-queued {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
&.status-paused {
|
||||
color: #f59e0b;
|
||||
background: color-mix(in srgb, #f59e0b 14%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&-size {
|
||||
|
||||
@@ -212,6 +212,8 @@ export class DownloadsComponent {
|
||||
return 'schedule';
|
||||
case 'downloading':
|
||||
return 'downloading';
|
||||
case 'paused':
|
||||
return 'pause_circle';
|
||||
case 'completed':
|
||||
return 'check_circle';
|
||||
case 'failed':
|
||||
@@ -229,6 +231,8 @@ export class DownloadsComponent {
|
||||
return 'status-queued';
|
||||
case 'downloading':
|
||||
return 'status-downloading';
|
||||
case 'paused':
|
||||
return 'status-paused';
|
||||
case 'completed':
|
||||
return 'status-completed';
|
||||
case 'failed':
|
||||
@@ -257,15 +261,38 @@ export class DownloadsComponent {
|
||||
}
|
||||
|
||||
async cancel(item: DownloadItem) {
|
||||
await this.downloadsService.cancelDownload(item.id);
|
||||
const result = await this.downloadsService.cancelDownload(item.id);
|
||||
if (!result.success) {
|
||||
this.showActionError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async pause(item: DownloadItem) {
|
||||
const result = await this.downloadsService.pauseDownload(item.id);
|
||||
if (!result.success) {
|
||||
this.showActionError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async resume(item: DownloadItem) {
|
||||
const result = await this.downloadsService.resumeDownload(item.id);
|
||||
if (!result.success) {
|
||||
this.showActionError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async retry(item: DownloadItem) {
|
||||
await this.downloadsService.retryDownload(item.id);
|
||||
const result = await this.downloadsService.retryDownload(item.id);
|
||||
if (!result.success) {
|
||||
this.showActionError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(item: DownloadItem) {
|
||||
await this.downloadsService.removeDownload(item.id);
|
||||
const result = await this.downloadsService.removeDownload(item.id);
|
||||
if (!result.success) {
|
||||
this.showActionError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async play(item: DownloadItem) {
|
||||
@@ -571,6 +598,17 @@ export class DownloadsComponent {
|
||||
return `${item.id}:${item.posterUrl ?? ''}`;
|
||||
}
|
||||
|
||||
private showActionError(error?: string): void {
|
||||
const message = error
|
||||
? `${this.translate.instant('DOWNLOADS.ACTION_FAILED')}: ${error}`
|
||||
: this.translate.instant('DOWNLOADS.ACTION_FAILED');
|
||||
|
||||
this.snackBar.open(message, undefined, {
|
||||
duration: 4000,
|
||||
horizontalPosition: 'start',
|
||||
});
|
||||
}
|
||||
|
||||
private handleFileActionError(error?: string): void {
|
||||
const message =
|
||||
error === 'File not found'
|
||||
|
||||
@@ -213,6 +213,14 @@
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
|
||||
</button>
|
||||
} @else if (isPausedDownload()) {
|
||||
<button
|
||||
class="download-btn download-btn--paused"
|
||||
(click)="resumePausedDownload()"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'DOWNLOADS.RESUME' | translate }}
|
||||
</button>
|
||||
} @else if (isDownloading()) {
|
||||
<button
|
||||
class="download-btn download-btn--downloading"
|
||||
|
||||
@@ -219,6 +219,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
return this.downloadsService.isDownloading(vodId, playlistId, 'vod');
|
||||
});
|
||||
|
||||
readonly isPausedDownload = computed(() => {
|
||||
const vodId = this.selectedVodId();
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
if (!playlistId) return false;
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isPaused(vodId, playlistId, 'vod');
|
||||
});
|
||||
|
||||
readonly trailerEmbedUrl = computed(() =>
|
||||
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
|
||||
);
|
||||
@@ -431,6 +439,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
this.playback.handleExternalFallbackRequest(request);
|
||||
}
|
||||
|
||||
async resumePausedDownload(): Promise<void> {
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
await this.downloadsService.resumeDownloadByContent(
|
||||
this.selectedVodId(),
|
||||
playlistId,
|
||||
'vod'
|
||||
);
|
||||
}
|
||||
|
||||
async downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
|
||||
if (!vodItem) {
|
||||
return;
|
||||
|
||||
@@ -21,6 +21,8 @@ type TestDownloadsService = {
|
||||
hasLoadedDownloads: Signal<boolean>;
|
||||
loadDownloads: DownloadsService['loadDownloads'];
|
||||
loadDownloadFolder: DownloadsService['loadDownloadFolder'];
|
||||
pauseDownload: DownloadsService['pauseDownload'];
|
||||
resumeDownload: DownloadsService['resumeDownload'];
|
||||
selectFolder: DownloadsService['selectFolder'];
|
||||
_isLoadingDownloads: WritableSignal<boolean>;
|
||||
_hasLoadedDownloads: WritableSignal<boolean>;
|
||||
@@ -29,6 +31,11 @@ type TestDownloadsService = {
|
||||
|
||||
type DownloadsElectronStub = {
|
||||
downloadsGetDefaultFolder?: jest.Mock<Promise<string>, []>;
|
||||
downloadsPause?: jest.Mock<Promise<{ success: boolean }>, [number]>;
|
||||
downloadsResume?: jest.Mock<
|
||||
Promise<{ success: boolean }>,
|
||||
[number, string]
|
||||
>;
|
||||
downloadsSelectFolder?: jest.Mock<Promise<string | null>, []>;
|
||||
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, [string?]>;
|
||||
};
|
||||
@@ -163,6 +170,45 @@ describe('DownloadsService', () => {
|
||||
expect(electron.downloadsSelectFolder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forwards pause requests to the main process', async () => {
|
||||
const electron = {
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsPause: jest.fn(async (downloadId: number) => ({
|
||||
success: downloadId === 42,
|
||||
})),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
await expect(service.pauseDownload(42)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(electron.downloadsPause).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('resolves the authorized folder before resuming a paused download', async () => {
|
||||
const electron = {
|
||||
downloadsGetDefaultFolder: jest.fn(async () => '/downloads'),
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsResume: jest.fn(
|
||||
async (downloadId: number, downloadFolder: string) => ({
|
||||
success:
|
||||
downloadId === 42 && downloadFolder === '/downloads',
|
||||
})
|
||||
),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
await expect(service.resumeDownload(42)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(electron.downloadsResume).toHaveBeenCalledWith(
|
||||
42,
|
||||
'/downloads'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks downloads as loaded after a failed request while preserving existing data', async () => {
|
||||
const existing = createDownload(1);
|
||||
const error = new Error('download query failed');
|
||||
@@ -225,4 +271,45 @@ describe('DownloadsService', () => {
|
||||
'playlist-new'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports paused content and resumes it by content identity', async () => {
|
||||
const pausedItem = {
|
||||
...createDownload(7),
|
||||
status: 'paused' as DownloadItem['status'],
|
||||
};
|
||||
const service = createService([
|
||||
pausedItem,
|
||||
createDownload(8),
|
||||
]) as unknown as DownloadsService;
|
||||
const resumeDownload = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true });
|
||||
(service as unknown as { resumeDownload: jest.Mock }).resumeDownload =
|
||||
resumeDownload;
|
||||
|
||||
expect(service.isPaused(7, 'playlist-1', 'vod')).toBe(true);
|
||||
expect(service.isPaused(8, 'playlist-1', 'vod')).toBe(false);
|
||||
|
||||
await expect(
|
||||
service.resumeDownloadByContent(7, 'playlist-1', 'vod')
|
||||
).resolves.toEqual({ success: true });
|
||||
expect(resumeDownload).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('rejects resume-by-content when the download is not paused', async () => {
|
||||
const service = createService([
|
||||
createDownload(8),
|
||||
]) as unknown as DownloadsService;
|
||||
const resumeDownload = jest.fn();
|
||||
(service as unknown as { resumeDownload: jest.Mock }).resumeDownload =
|
||||
resumeDownload;
|
||||
|
||||
await expect(
|
||||
service.resumeDownloadByContent(8, 'playlist-1', 'vod')
|
||||
).resolves.toEqual({
|
||||
error: 'No paused download found',
|
||||
success: false,
|
||||
});
|
||||
expect(resumeDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
export type DownloadStatus =
|
||||
| 'queued'
|
||||
| 'downloading'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'canceled';
|
||||
@@ -221,6 +222,53 @@ export class DownloadsService implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a queued or active download
|
||||
*/
|
||||
async pauseDownload(
|
||||
downloadId: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.isAvailable()) {
|
||||
return { success: false, error: 'Downloads not available' };
|
||||
}
|
||||
|
||||
try {
|
||||
return await window.electron.downloadsPause(downloadId);
|
||||
} catch (error) {
|
||||
console.error('[DownloadsService] Error pausing download:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused download
|
||||
*/
|
||||
async resumeDownload(
|
||||
downloadId: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.isAvailable()) {
|
||||
return { success: false, error: 'Downloads not available' };
|
||||
}
|
||||
|
||||
const folder = await this.loadDownloadFolder();
|
||||
if (!folder) {
|
||||
return { success: false, error: 'No download folder configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
return await window.electron.downloadsResume(downloadId, folder);
|
||||
} catch (error) {
|
||||
console.error('[DownloadsService] Error resuming download:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed download
|
||||
*/
|
||||
@@ -401,7 +449,7 @@ export class DownloadsService implements OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is currently downloading or queued
|
||||
* Check if content is currently downloading, queued, or paused
|
||||
*/
|
||||
isDownloading(
|
||||
xtreamId: number,
|
||||
@@ -409,7 +457,38 @@ export class DownloadsService implements OnDestroy {
|
||||
contentType: 'vod' | 'episode'
|
||||
): boolean {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
return download?.status === 'downloading' || download?.status === 'queued';
|
||||
return (
|
||||
download?.status === 'downloading' ||
|
||||
download?.status === 'queued' ||
|
||||
download?.status === 'paused'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content has a paused download
|
||||
*/
|
||||
isPaused(
|
||||
xtreamId: number,
|
||||
playlistId: string,
|
||||
contentType: 'vod' | 'episode'
|
||||
): boolean {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
return download?.status === 'paused';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the paused download that belongs to a content item
|
||||
*/
|
||||
async resumeDownloadByContent(
|
||||
xtreamId: number,
|
||||
playlistId: string,
|
||||
contentType: 'vod' | 'episode'
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
if (!download || download.status !== 'paused') {
|
||||
return { success: false, error: 'No paused download found' };
|
||||
}
|
||||
return this.resumeDownload(download.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,6 +97,8 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
dbRestoreXtreamUserData: jest.fn(),
|
||||
downloadsStart: jest.fn(),
|
||||
downloadsCancel: jest.fn(),
|
||||
downloadsPause: jest.fn(),
|
||||
downloadsResume: jest.fn(),
|
||||
downloadsRetry: jest.fn(),
|
||||
downloadsRemove: jest.fn(),
|
||||
downloadsGetList: jest.fn(),
|
||||
@@ -366,6 +368,8 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
testWindow.electron = {
|
||||
downloadsStart: jest.fn(),
|
||||
downloadsCancel: jest.fn(),
|
||||
downloadsPause: jest.fn(),
|
||||
downloadsResume: jest.fn(),
|
||||
downloadsRetry: jest.fn(),
|
||||
downloadsRemove: jest.fn(),
|
||||
downloadsGetList: jest.fn(),
|
||||
|
||||
@@ -183,6 +183,8 @@ export class RuntimeCapabilitiesService {
|
||||
return [
|
||||
'downloadsStart',
|
||||
'downloadsCancel',
|
||||
'downloadsPause',
|
||||
'downloadsResume',
|
||||
'downloadsRetry',
|
||||
'downloadsRemove',
|
||||
'downloadsGetList',
|
||||
|
||||
@@ -25,6 +25,39 @@ describe('database schema statements', () => {
|
||||
normalizeXtreamContentAddedEpochs,
|
||||
ensureContentTitleFts,
|
||||
} = __databaseConnectionTestHooks;
|
||||
const ensureDownloadsPauseResumeSchema = (
|
||||
__databaseConnectionTestHooks as unknown as {
|
||||
ensureDownloadsPauseResumeSchema: (sqlite: {
|
||||
prepare: (statement: string) => {
|
||||
get?: () => unknown;
|
||||
run?: (...args: unknown[]) => unknown;
|
||||
};
|
||||
transaction: (callback: () => void) => () => void;
|
||||
}) => void;
|
||||
}
|
||||
).ensureDownloadsPauseResumeSchema;
|
||||
|
||||
function createRebuildSqlite(legacyTableSql: string | undefined) {
|
||||
const statements: string[] = [];
|
||||
const transaction = jest.fn((callback: () => void) => callback);
|
||||
const prepare = jest.fn((statement: string) => {
|
||||
if (statement.includes('FROM sqlite_master')) {
|
||||
return {
|
||||
get: () =>
|
||||
legacyTableSql === undefined
|
||||
? undefined
|
||||
: { sql: legacyTableSql },
|
||||
};
|
||||
}
|
||||
return {
|
||||
run: () => {
|
||||
statements.push(compactSql(statement));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { prepare, statements, transaction };
|
||||
}
|
||||
|
||||
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
|
||||
const schemaSql = createTableStatements.map(compactSql).join('\n');
|
||||
@@ -45,6 +78,9 @@ describe('database schema statements', () => {
|
||||
'CREATE TABLE IF NOT EXISTS playback_positions'
|
||||
);
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
|
||||
expect(schemaSql).toContain('request_headers TEXT');
|
||||
expect(schemaSql).toContain('resume_validator TEXT');
|
||||
expect(schemaSql).toContain("'paused'");
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
|
||||
);
|
||||
@@ -383,6 +419,80 @@ describe('database schema statements', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rebuilds a legacy downloads table without paused status or stored headers', () => {
|
||||
const sqlite = createRebuildSqlite(
|
||||
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
|
||||
);
|
||||
|
||||
ensureDownloadsPauseResumeSchema(sqlite);
|
||||
|
||||
expect(sqlite.transaction).toHaveBeenCalledTimes(1);
|
||||
const { statements } = sqlite;
|
||||
const renameIndex = statements.findIndex((statement) =>
|
||||
statement.startsWith('ALTER TABLE downloads RENAME TO')
|
||||
);
|
||||
const createIndex = statements.findIndex((statement) =>
|
||||
statement.startsWith('CREATE TABLE IF NOT EXISTS downloads')
|
||||
);
|
||||
const copyIndex = statements.findIndex((statement) =>
|
||||
statement.startsWith('INSERT INTO downloads')
|
||||
);
|
||||
const dropLegacyIndex = statements.findIndex((statement) =>
|
||||
statement.startsWith('DROP TABLE downloads_pause_resume_legacy')
|
||||
);
|
||||
|
||||
expect(renameIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(createIndex).toBeGreaterThan(renameIndex);
|
||||
expect(copyIndex).toBeGreaterThan(createIndex);
|
||||
expect(dropLegacyIndex).toBeGreaterThan(copyIndex);
|
||||
// Indexes on the old table are dropped before the rename and
|
||||
// recreated after the copy.
|
||||
expect(
|
||||
statements.filter((statement) =>
|
||||
statement.startsWith('DROP INDEX IF EXISTS')
|
||||
).length
|
||||
).toBe(3);
|
||||
expect(
|
||||
statements
|
||||
.slice(dropLegacyIndex + 1)
|
||||
.filter((statement) => statement.includes('CREATE')).length
|
||||
).toBe(3);
|
||||
// The rebuilt table carries the new contract; legacy rows get NULL
|
||||
// stored headers, and the copy never references resume_validator so
|
||||
// it defaults to NULL.
|
||||
expect(statements[createIndex]).toContain(`'paused'`);
|
||||
expect(statements[createIndex]).toContain('request_headers TEXT');
|
||||
expect(statements[createIndex]).toContain('resume_validator TEXT');
|
||||
expect(statements[copyIndex]).toContain('NULL AS request_headers');
|
||||
expect(statements[copyIndex]).not.toContain('resume_validator');
|
||||
});
|
||||
|
||||
it('copies stored headers when only the paused status is missing', () => {
|
||||
const sqlite = createRebuildSqlite(
|
||||
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
|
||||
);
|
||||
|
||||
ensureDownloadsPauseResumeSchema(sqlite);
|
||||
|
||||
const copy = sqlite.statements.find((statement) =>
|
||||
statement.startsWith('INSERT INTO downloads')
|
||||
);
|
||||
expect(copy).toBeDefined();
|
||||
expect(copy).not.toContain('NULL AS request_headers');
|
||||
expect(copy).toContain('request_headers');
|
||||
});
|
||||
|
||||
it('skips the downloads rebuild when the table already has the paused contract', () => {
|
||||
const sqlite = createRebuildSqlite(
|
||||
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, resume_validator TEXT, status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')))`
|
||||
);
|
||||
|
||||
ensureDownloadsPauseResumeSchema(sqlite);
|
||||
|
||||
expect(sqlite.transaction).not.toHaveBeenCalled();
|
||||
expect(sqlite.statements).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('backfills migrated EPG program source URLs in bounded batches', () => {
|
||||
const { backfillEpgProgramSourceUrls } =
|
||||
__databaseConnectionTestHooks;
|
||||
|
||||
@@ -85,6 +85,34 @@ 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,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
file_path TEXT,
|
||||
poster_url TEXT,
|
||||
request_headers TEXT,
|
||||
resume_validator 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')),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
)`;
|
||||
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)`,
|
||||
];
|
||||
|
||||
const CREATE_TABLE_STATEMENTS = [
|
||||
`CREATE TABLE IF NOT EXISTS playlists (
|
||||
@@ -293,30 +321,8 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
`CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS playback_positions_playlist_updated_idx ON playback_positions(playlist_id, updated_at DESC)`,
|
||||
// Downloads table
|
||||
`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,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
file_path TEXT,
|
||||
poster_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', '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')),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
)`,
|
||||
`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)`,
|
||||
DOWNLOADS_TABLE_SQL,
|
||||
...DOWNLOADS_INDEX_STATEMENTS,
|
||||
// TMDB metadata cache (details payloads + search match resolutions)
|
||||
TMDB_METADATA_TABLE_SQL,
|
||||
TMDB_METADATA_INDEX_SQL,
|
||||
@@ -354,6 +360,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
|
||||
`ALTER TABLE content ADD COLUMN backdrop_url TEXT`,
|
||||
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
|
||||
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
|
||||
// Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume
|
||||
`ALTER TABLE downloads ADD COLUMN resume_validator TEXT`,
|
||||
];
|
||||
|
||||
const INDEX_MIGRATION_STATEMENTS = [
|
||||
@@ -372,6 +380,7 @@ export const __databaseConnectionTestHooks = {
|
||||
createTableStatements: CREATE_TABLE_STATEMENTS,
|
||||
columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS,
|
||||
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
|
||||
ensureDownloadsPauseResumeSchema,
|
||||
normalizeXtreamContentAddedEpochs,
|
||||
ensureContentTitleFts,
|
||||
backfillEpgProgramSourceUrls,
|
||||
@@ -820,12 +829,115 @@ function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing downloads tables had a status CHECK without 'paused'. SQLite cannot
|
||||
* alter CHECK constraints in place, so rebuild only when the table SQL still
|
||||
* reflects the old contract or lacks request_headers.
|
||||
*/
|
||||
function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
|
||||
try {
|
||||
const row = sqliteDb
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'`
|
||||
)
|
||||
.get() as { sql?: string } | undefined;
|
||||
if (!row?.sql) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPausedStatus = row.sql.includes(`'paused'`);
|
||||
const hasRequestHeaders = row.sql.includes('request_headers');
|
||||
if (hasPausedStatus && hasRequestHeaders) {
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyHeadersSelect = hasRequestHeaders
|
||||
? 'request_headers'
|
||||
: 'NULL AS request_headers';
|
||||
const rebuild = sqliteDb.transaction(() => {
|
||||
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
|
||||
const match = statement.match(
|
||||
/^CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+([^\s]+)\s+/i
|
||||
);
|
||||
if (match?.[1]) {
|
||||
sqliteDb.prepare(`DROP INDEX IF EXISTS ${match[1]}`).run();
|
||||
}
|
||||
}
|
||||
|
||||
sqliteDb
|
||||
.prepare(
|
||||
`ALTER TABLE downloads RENAME TO downloads_pause_resume_legacy`
|
||||
)
|
||||
.run();
|
||||
sqliteDb.prepare(DOWNLOADS_TABLE_SQL).run();
|
||||
sqliteDb
|
||||
.prepare(
|
||||
`INSERT INTO downloads (
|
||||
id,
|
||||
playlist_id,
|
||||
xtream_id,
|
||||
content_type,
|
||||
series_xtream_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
title,
|
||||
url,
|
||||
file_name,
|
||||
file_path,
|
||||
poster_url,
|
||||
request_headers,
|
||||
status,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
error_message,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
playlist_id,
|
||||
xtream_id,
|
||||
content_type,
|
||||
series_xtream_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
title,
|
||||
url,
|
||||
file_name,
|
||||
file_path,
|
||||
poster_url,
|
||||
${legacyHeadersSelect},
|
||||
status,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
error_message,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM downloads_pause_resume_legacy`
|
||||
)
|
||||
.run();
|
||||
sqliteDb
|
||||
.prepare(`DROP TABLE downloads_pause_resume_legacy`)
|
||||
.run();
|
||||
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
|
||||
sqliteDb.prepare(statement).run();
|
||||
}
|
||||
});
|
||||
|
||||
rebuild();
|
||||
console.log('[DB] Rebuilt downloads table with pause/resume schema');
|
||||
} catch (error) {
|
||||
console.warn('[DB] downloads pause/resume migration failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run migrations that may fail if already applied
|
||||
*/
|
||||
function runMigrations(sqliteDb: Database.Database): void {
|
||||
widenTmdbMetadataMediaTypeCheck(sqliteDb);
|
||||
cleanupLegacyTmdbSearchCache(sqliteDb);
|
||||
ensureDownloadsPauseResumeSchema(sqliteDb);
|
||||
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
|
||||
ensureContentTitleFts(sqliteDb);
|
||||
deduplicateXtreamCache(sqliteDb);
|
||||
|
||||
@@ -332,9 +332,18 @@ export const downloads = sqliteTable(
|
||||
fileName: text('file_name'),
|
||||
filePath: text('file_path'),
|
||||
posterUrl: text('poster_url'),
|
||||
requestHeaders: text('request_headers'),
|
||||
resumeValidator: text('resume_validator'),
|
||||
// Download progress
|
||||
status: text('status', {
|
||||
enum: ['queued', 'downloading', 'completed', 'failed', 'canceled'],
|
||||
enum: [
|
||||
'queued',
|
||||
'downloading',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled',
|
||||
],
|
||||
})
|
||||
.notNull()
|
||||
.default('queued'),
|
||||
|
||||
@@ -119,6 +119,7 @@ export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = {
|
||||
Completed: 'completed',
|
||||
Downloading: 'downloading',
|
||||
Failed: 'failed',
|
||||
Paused: 'paused',
|
||||
Queued: 'queued',
|
||||
} as const;
|
||||
|
||||
@@ -953,6 +954,11 @@ export interface ElectronBridgeApi {
|
||||
data: ElectronBridgeDownloadStartPayload
|
||||
) => Promise<ElectronBridgeDownloadStartResult>;
|
||||
downloadsCancel: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
|
||||
downloadsPause: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
|
||||
downloadsResume: (
|
||||
downloadId: number,
|
||||
downloadFolder: string
|
||||
) => Promise<ElectronBridgeErrorResult>;
|
||||
downloadsRetry: (
|
||||
downloadId: number,
|
||||
downloadFolder: string
|
||||
|
||||
@@ -173,6 +173,25 @@
|
||||
>
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
</button>
|
||||
} @else if (
|
||||
isEpisodePaused(episode)
|
||||
) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="download-btn download-btn--paused"
|
||||
(click)="
|
||||
resumeEpisodeDownload(
|
||||
$event,
|
||||
episode
|
||||
)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RESUME'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
} @else if (
|
||||
isEpisodeDownloading(episode)
|
||||
) {
|
||||
@@ -413,6 +432,22 @@
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</button>
|
||||
} @else if (isEpisodePaused(episode)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-btn"
|
||||
(click)="
|
||||
resumeEpisodeDownload(
|
||||
$event,
|
||||
episode
|
||||
)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RESUME' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
} @else if (isEpisodeDownloading(episode)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
|
||||
@@ -394,6 +394,14 @@ app-season-tabs {
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.download-btn--paused {
|
||||
color: #fbbf24 !important;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, #fbbf24 20%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -414,6 +414,35 @@ export class SeasonContainerComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
isEpisodePaused(episode: XtreamSerieEpisode): boolean {
|
||||
if (!this.playlistId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isPaused(
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
}
|
||||
|
||||
async resumeEpisodeDownload(
|
||||
event: Event,
|
||||
episode: XtreamSerieEpisode
|
||||
): Promise<void> {
|
||||
event.stopPropagation();
|
||||
if (!this.playlistId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.downloadsService.resumeDownloadByContent(
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
}
|
||||
|
||||
async playFromLocal(event: Event, episode: XtreamSerieEpisode) {
|
||||
event.stopPropagation();
|
||||
if (!this.playlistId()) {
|
||||
|
||||
@@ -194,6 +194,14 @@
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
|
||||
</button>
|
||||
} @else if (isPausedDownload()) {
|
||||
<button
|
||||
class="download-btn download-btn--paused"
|
||||
(click)="resumePausedDownload()"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'DOWNLOADS.RESUME' | translate }}
|
||||
</button>
|
||||
} @else if (isDownloading()) {
|
||||
<button class="download-btn download-btn--downloading" disabled>
|
||||
<mat-icon>downloading</mat-icon>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@iptvnator/services';
|
||||
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
|
||||
import { createVodDownloadState } from './vod-download-state.util';
|
||||
|
||||
/**
|
||||
* Unified VOD details component for both Xtream and Stalker portals.
|
||||
@@ -189,23 +190,13 @@ export class VodDetailsComponent {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
/** Whether VOD is already downloaded */
|
||||
readonly isDownloaded = computed(() => {
|
||||
const item = this.item();
|
||||
// Access signal to create reactive dependency
|
||||
this.downloadsService.downloads();
|
||||
const vodId = getVodNumericId(item);
|
||||
return this.downloadsService.isDownloaded(vodId, item.playlistId, 'vod');
|
||||
});
|
||||
|
||||
/** Whether VOD is currently downloading */
|
||||
readonly isDownloading = computed(() => {
|
||||
const item = this.item();
|
||||
// Access signal to create reactive dependency
|
||||
this.downloadsService.downloads();
|
||||
const vodId = getVodNumericId(item);
|
||||
return this.downloadsService.isDownloading(vodId, item.playlistId, 'vod');
|
||||
});
|
||||
private readonly downloadState = createVodDownloadState(
|
||||
this.downloadsService,
|
||||
this.item
|
||||
);
|
||||
readonly isDownloaded = this.downloadState.isDownloaded;
|
||||
readonly isDownloading = this.downloadState.isDownloading;
|
||||
readonly isPausedDownload = this.downloadState.isPausedDownload;
|
||||
|
||||
readonly matchedExternalPlayback = computed(() => {
|
||||
const session = this.externalPlayback();
|
||||
@@ -343,6 +334,16 @@ export class VodDetailsComponent {
|
||||
this.downloadRequested.emit(this.item());
|
||||
}
|
||||
|
||||
/** Resume the paused download of this VOD */
|
||||
async resumePausedDownload(): Promise<void> {
|
||||
const item = this.item();
|
||||
await this.downloadsService.resumeDownloadByContent(
|
||||
getVodNumericId(item),
|
||||
item.playlistId,
|
||||
'vod'
|
||||
);
|
||||
}
|
||||
|
||||
onInlineTimeUpdate(event: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { computed, Signal } from '@angular/core';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { getVodNumericId, VodDetailsItem } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Download-state signals for a VOD detail view. Reading
|
||||
* `downloadsService.downloads()` inside each computed creates the reactive
|
||||
* dependency on the download list.
|
||||
*/
|
||||
export function createVodDownloadState(
|
||||
downloadsService: DownloadsService,
|
||||
item: Signal<VodDetailsItem>
|
||||
) {
|
||||
const query = (
|
||||
check: (vodId: number, playlistId: string) => boolean
|
||||
): Signal<boolean> =>
|
||||
computed(() => {
|
||||
const currentItem = item();
|
||||
downloadsService.downloads();
|
||||
return check(getVodNumericId(currentItem), currentItem.playlistId);
|
||||
});
|
||||
|
||||
return {
|
||||
isDownloaded: query((vodId, playlistId) =>
|
||||
downloadsService.isDownloaded(vodId, playlistId, 'vod')
|
||||
),
|
||||
isDownloading: query((vodId, playlistId) =>
|
||||
downloadsService.isDownloading(vodId, playlistId, 'vod')
|
||||
),
|
||||
isPausedDownload: query((vodId, playlistId) =>
|
||||
downloadsService.isPaused(vodId, playlistId, 'vod')
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -95,6 +95,16 @@
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
&--paused {
|
||||
background: rgba(251, 191, 36, 0.14);
|
||||
border-color: rgba(251, 191, 36, 0.4);
|
||||
color: #fbbf24;
|
||||
|
||||
&:hover {
|
||||
background: rgba(251, 191, 36, 0.22);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin mobile-action-buttons {
|
||||
|
||||
Reference in new issue
Block a user