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:
genrichh93-ui authored and GitHub committed 2026-07-24 18:40:30 +02:00
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();
});
});
+81 -2
View File
@@ -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;
+136 -24
View File
@@ -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);
+10 -1
View File
@@ -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')
),
};
}
+10
View File
@@ -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 {