feat(downloads): redesign download manager (#1313)

* docs(downloads): specify manager MVP redesign

* docs(downloads): plan manager MVP implementation

* docs(downloads): tighten manager validation plan

* fix(downloads): keep renderer download state global

* fix(downloads): make active count accessible

* feat(downloads): derive queue and library view model

* test(downloads): close view model coverage gaps

* fix(downloads): stabilize malformed view model data

* refactor(downloads): isolate library navigation

* fix(downloads): report library navigation failures

* feat(downloads): add ready-to-watch library

* feat(downloads): add active download queue

* feat(downloads): finish manager MVP

* docs(downloads): clarify detail-first offline behavior

* docs(downloads): plan detail navigation follow-up

* fix(downloads): open completed movies in details

* test(downloads): cover pending series navigation

* fix(downloads): honor the global cover size

* fix(downloads): prefer local playback in shared details

* fix(downloads): preserve external launch priority

* fix(downloads): prefer local playback in Xtream details

* test(downloads): cover offline detail journey

* docs(downloads): document offline detail behavior

* docs(downloads): format detail navigation plan

* fix(downloads): open Stalker items in provider details

* docs(downloads): clarify Stalker navigation fallback

* fix(xtream): isolate reused detail identities

* fix(xtream): ignore stale VOD positions

* fix(downloads): keep offline Xtream playback available

* docs(downloads): clarify provider playback availability

* docs(downloads): design missing-file recovery

* docs(downloads): plan missing-file recovery

* feat(downloads): derive completed file availability

* feat(downloads): recover missing completed files

* feat(downloads): refresh missing local files

* feat(downloads): separate missing files from ready media

* feat(downloads): surface missing files for recovery

* refactor(downloads): simplify ready cards

* test(downloads): cover missing-file and series journeys

* feat(downloads): finish missing-file recovery

* docs(downloads): design offline detail views

* docs(downloads): plan offline detail views

* feat(downloads): persist offline metadata snapshots

* fix(downloads): complete metadata snapshot bridge contract

* feat(downloads): manage offline metadata snapshots

* fix(downloads): harden metadata snapshot updates

* fix(downloads): restrict snapshot artwork

* fix(downloads): guard restart artwork URL

* fix(downloads): refine artwork URL checks

* feat(downloads): expose offline metadata updates

* fix(downloads): keep metadata service change focused

* fix(downloads): preserve metadata error conventions

* feat(downloads): derive offline detail content

* fix(downloads): preserve unknown episode coordinates

* feat(downloads): add focused offline detail routes

* fix(downloads): ignore fragments in shell route state

* fix(downloads): normalize fragments before queries

* feat(downloads): open ready cards in offline details

* fix(downloads): use native disabled card styles

* feat(downloads): enrich offline detail metadata

* fix(downloads): harden offline metadata resolution

* fix(downloads): preserve stalker provider titles

* fix(downloads): distinguish stalker metadata seeds

* fix(downloads): stabilize offline metadata refresh

* fix(downloads): throttle sparse metadata refreshes

* fix(downloads): type metadata language settings

* feat(downloads): render offline movie and series details

* fix(downloads): harden offline detail interactions

* fix(downloads): close offline detail edge cases

* feat(downloads): hand off to provider-only details

* fix(downloads): preserve stalker provider handoff

* feat(downloads): capture metadata at download time

* fix(downloads): preserve snapshot source semantics

* fix(downloads): preserve episode snapshot identity

* docs(downloads): document offline details flow

* docs(downloads): clarify stalker provider fallback

* test(downloads): cover offline detail journeys

* test(downloads): stabilize offline detail selectors

* style(downloads): format changed files

* docs(downloads): clean design spec formatting

* fix(downloads): preserve offline library ownership

* test(downloads): fix Windows workspace navigation

* test(database): preserve Electron tsconfig resolution

* perf(downloads): avoid blocking file availability probes
This commit is contained in:
4gray authored and GitHub committed 2026-08-01 18:09:31 +02:00
1 parent 46c58f4f57
commit 760099358b
188 files changed
+31245 -2281

No files matched your search

@@ -0,0 +1,29 @@
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
interface DownloadMetadataHost {
isAvailable(): boolean;
loadDownloads(): Promise<void>;
}
export async function updateDownloadMetadata(
host: DownloadMetadataHost,
downloadId: number,
metadataSnapshot: DownloadMetadataSnapshot
): Promise<{ success: boolean; error?: string }> {
if (!host.isAvailable()) return { success: false };
try {
const result = await window.electron.downloadsUpdateMetadata(
downloadId,
metadataSnapshot
);
if (result.success) await host.loadDownloads();
return result;
} catch (error) {
console.error('[DownloadsService] Metadata update error:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
+36
View File
@@ -0,0 +1,36 @@
import type {
DownloadMetadataSnapshot,
ElectronBridgeDownloadStartPayload,
ElectronDownloadFileAvailability,
} from '@iptvnator/shared/interfaces';
export type DownloadStatus =
'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'canceled';
export type DownloadStartInput = Omit<
ElectronBridgeDownloadStartPayload,
'downloadFolder'
>;
export interface DownloadItem {
id: number;
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
title: string;
url: string;
fileName?: string;
filePath?: string;
fileAvailability?: ElectronDownloadFileAvailability;
posterUrl?: string;
metadataSnapshot?: DownloadMetadataSnapshot;
status: DownloadStatus;
bytesDownloaded?: number;
totalBytes?: number;
errorMessage?: string;
createdAt?: string;
updatedAt?: string;
}
+298 -24
View File
@@ -7,10 +7,11 @@ import {
runInInjectionContext,
signal,
} from '@angular/core';
import {
DownloadItem,
DownloadsService,
} from './downloads.service';
import type {
DownloadMetadataSnapshot,
ElectronBridgeDownloadStartPayload,
} from '@iptvnator/shared/interfaces';
import { DownloadItem, DownloadsService } from './downloads.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
type TestDownloadsService = {
@@ -19,11 +20,15 @@ type TestDownloadsService = {
isAvailable: () => boolean;
isLoadingDownloads: Signal<boolean>;
hasLoadedDownloads: Signal<boolean>;
getDownload: DownloadsService['getDownload'];
loadDownloads: DownloadsService['loadDownloads'];
loadDownloadFolder: DownloadsService['loadDownloadFolder'];
pauseDownload: DownloadsService['pauseDownload'];
redownloadMissing: DownloadsService['redownloadMissing'];
resumeDownload: DownloadsService['resumeDownload'];
selectFolder: DownloadsService['selectFolder'];
startDownload: DownloadsService['startDownload'];
updateMetadata: DownloadsService['updateMetadata'];
_isLoadingDownloads: WritableSignal<boolean>;
_hasLoadedDownloads: WritableSignal<boolean>;
loadDownloadsRequestId: number;
@@ -31,13 +36,33 @@ type TestDownloadsService = {
type DownloadsElectronStub = {
downloadsGetDefaultFolder?: jest.Mock<Promise<string>, []>;
downloadsStart?: jest.Mock<
Promise<{ success: boolean; id?: number; error?: string }>,
[ElectronBridgeDownloadStartPayload]
>;
downloadsUpdateMetadata?: jest.Mock<
Promise<{ success: boolean; error?: string }>,
[number, DownloadMetadataSnapshot]
>;
downloadsPause?: jest.Mock<Promise<{ success: boolean }>, [number]>;
downloadsRedownloadMissing?: jest.Mock<
Promise<{ success: boolean; recovered?: boolean }>,
[number]
>;
downloadsResume?: jest.Mock<
Promise<{ success: boolean }>,
[number, string]
>;
downloadsSelectFolder?: jest.Mock<Promise<string | null>, []>;
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, [string?]>;
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, []>;
};
const metadataSnapshot: DownloadMetadataSnapshot = {
version: 1,
language: 'en',
mediaKind: 'movie',
title: 'Offline Movie',
plot: 'Stored for offline details.',
};
describe('DownloadsService', () => {
@@ -51,7 +76,10 @@ describe('DownloadsService', () => {
jest.restoreAllMocks();
});
function createDownload(id: number, playlistId = 'playlist-1'): DownloadItem {
function createDownload(
id: number,
playlistId = 'playlist-1'
): DownloadItem {
return {
id,
playlistId,
@@ -74,6 +102,18 @@ describe('DownloadsService', () => {
return { promise, resolve, reject };
}
function createMetadataUpdateMock(result: {
success: boolean;
error?: string;
}) {
return jest
.fn<
Promise<{ success: boolean; error?: string }>,
[number, DownloadMetadataSnapshot]
>()
.mockResolvedValue(result);
}
function createService(initialDownloads: DownloadItem[] = []) {
const downloads = signal(initialDownloads);
const isLoadingDownloads = signal(false);
@@ -97,6 +137,15 @@ describe('DownloadsService', () => {
return service;
}
it('exposes a download metadata snapshot on list items', () => {
const item: DownloadItem = {
...createDownload(42),
metadataSnapshot,
};
expect(item.metadataSnapshot).toBe(metadataSnapshot);
});
it('reports availability through the runtime capability', () => {
const injector = createEnvironmentInjector(
[
@@ -121,7 +170,7 @@ describe('DownloadsService', () => {
}
});
it('tracks loading and loaded state around a successful download list request', async () => {
it('loads the global download list without a playlist filter and tracks request state', async () => {
const item = createDownload(1);
const pending = createDeferred<DownloadItem[]>();
const electron = {
@@ -130,11 +179,11 @@ describe('DownloadsService', () => {
testWindow.electron = electron;
const service = createService();
const request = service.loadDownloads('playlist-1');
const request = service.loadDownloads();
expect(service.isLoadingDownloads()).toBe(true);
expect(service.hasLoadedDownloads()).toBe(false);
expect(electron.downloadsGetList).toHaveBeenCalledWith('playlist-1');
expect(electron.downloadsGetList).toHaveBeenCalledWith();
pending.resolve([item]);
await request;
@@ -157,6 +206,45 @@ describe('DownloadsService', () => {
expect(electron.downloadsGetDefaultFolder).toHaveBeenCalledTimes(1);
});
it('forwards a metadata snapshot unchanged when starting a download', async () => {
const electron = {
downloadsGetDefaultFolder: jest.fn(async () => '/downloads'),
downloadsGetList: jest.fn(async () => []),
downloadsStart: jest
.fn<
Promise<{ id: number; success: boolean }>,
[ElectronBridgeDownloadStartPayload]
>()
.mockResolvedValue({ id: 42, success: true }),
};
testWindow.electron = electron;
const service = createService();
await expect(
service.startDownload({
playlistId: 'playlist-1',
xtreamId: 7,
contentType: 'vod',
title: 'Offline Movie',
url: 'https://example.com/movie.mp4',
metadataSnapshot,
})
).resolves.toEqual({ id: 42, success: true });
expect(electron.downloadsStart).toHaveBeenCalledWith({
playlistId: 'playlist-1',
xtreamId: 7,
contentType: 'vod',
title: 'Offline Movie',
url: 'https://example.com/movie.mp4',
metadataSnapshot,
downloadFolder: '/downloads',
});
expect(electron.downloadsStart.mock.calls[0][0].metadataSnapshot).toBe(
metadataSnapshot
);
});
it('stores a selected download folder returned by the main process', async () => {
const electron = {
downloadsSelectFolder: jest.fn(async () => '/selected'),
@@ -203,9 +291,177 @@ describe('DownloadsService', () => {
await expect(service.resumeDownload(42)).resolves.toEqual({
success: true,
});
expect(electron.downloadsResume).toHaveBeenCalledWith(
expect(electron.downloadsResume).toHaveBeenCalledWith(42, '/downloads');
});
it('re-downloads a missing completed file by managed id', async () => {
const electron = {
downloadsGetList: jest.fn(async () => []),
downloadsRedownloadMissing: jest.fn(async (downloadId: number) => ({
success: downloadId === 42,
})),
};
testWindow.electron = electron;
const service = createService() as unknown as DownloadsService;
await expect(service.redownloadMissing(42)).resolves.toEqual({
success: true,
});
expect(electron.downloadsRedownloadMissing).toHaveBeenCalledWith(42);
});
it('gets a download from the current signal by managed id', () => {
const matching = {
...createDownload(42),
metadataSnapshot,
};
const service = createService([createDownload(7), matching]);
expect(service.getDownload(42)).toBe(matching);
expect(service.getDownload(99)).toBeUndefined();
});
it('updates metadata by managed id and reloads the authoritative list on success', async () => {
const refreshed = {
...createDownload(42),
metadataSnapshot,
};
const electron = {
downloadsGetList: jest.fn(async () => [refreshed]),
downloadsUpdateMetadata: createMetadataUpdateMock({
success: true,
}),
};
testWindow.electron = electron;
const service = createService([createDownload(42)]);
await expect(
service.updateMetadata(42, metadataSnapshot)
).resolves.toEqual({ success: true });
expect(electron.downloadsUpdateMetadata).toHaveBeenCalledWith(
42,
'/downloads'
metadataSnapshot
);
expect(electron.downloadsGetList).toHaveBeenCalledTimes(1);
expect(service.downloads()).toEqual([refreshed]);
});
it('returns a metadata update bridge failure without reloading', async () => {
const electron = {
downloadsGetList: jest.fn(async () => []),
downloadsUpdateMetadata: createMetadataUpdateMock({
error: 'Metadata rejected',
success: false,
}),
};
testWindow.electron = electron;
const service = createService();
await expect(
service.updateMetadata(42, metadataSnapshot)
).resolves.toEqual({
error: 'Metadata rejected',
success: false,
});
expect(electron.downloadsGetList).not.toHaveBeenCalled();
});
it('does not touch the bridge when metadata updates are unavailable', async () => {
const electron = {
downloadsGetList: jest.fn(async () => []),
downloadsUpdateMetadata: createMetadataUpdateMock({
success: true,
}),
};
testWindow.electron = electron;
const service = createService();
service.isAvailable = () => false;
await expect(
service.updateMetadata(42, metadataSnapshot)
).resolves.toEqual({ success: false });
expect(electron.downloadsUpdateMetadata).not.toHaveBeenCalled();
expect(electron.downloadsGetList).not.toHaveBeenCalled();
});
it('returns a safe failure when the metadata update bridge throws', async () => {
const error = new Error('metadata update failed');
jest.spyOn(console, 'error').mockImplementation(() => undefined);
const electron = {
downloadsGetList: jest.fn(async () => []),
downloadsUpdateMetadata: jest
.fn<
Promise<{ success: boolean; error?: string }>,
[number, DownloadMetadataSnapshot]
>()
.mockRejectedValue(error),
};
testWindow.electron = electron;
const service = createService();
await expect(
service.updateMetadata(42, metadataSnapshot)
).resolves.toEqual({
error: 'metadata update failed',
success: false,
});
expect(electron.downloadsGetList).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
'[DownloadsService] Metadata update error:',
error
);
});
it('stringifies a non-Error metadata update failure', async () => {
const error = { message: 404 };
jest.spyOn(console, 'error').mockImplementation(() => undefined);
const electron = {
downloadsGetList: jest.fn(async () => []),
downloadsUpdateMetadata: jest
.fn<
Promise<{ success: boolean; error?: string }>,
[number, DownloadMetadataSnapshot]
>()
.mockRejectedValue(error),
};
testWindow.electron = electron;
const service = createService();
await expect(
service.updateMetadata(42, metadataSnapshot)
).resolves.toEqual({
error: '[object Object]',
success: false,
});
expect(console.error).toHaveBeenCalledWith(
'[DownloadsService] Metadata update error:',
error
);
});
it('returns a safe failure when the metadata reload throws', async () => {
const error = new Error('metadata reload failed');
jest.spyOn(console, 'error').mockImplementation(() => undefined);
const electron = {
downloadsGetList: jest.fn(async () => []),
downloadsUpdateMetadata: createMetadataUpdateMock({
success: true,
}),
};
testWindow.electron = electron;
const service = createService();
service.loadDownloads = jest.fn().mockRejectedValue(error);
await expect(
service.updateMetadata(42, metadataSnapshot)
).resolves.toEqual({
error: 'metadata reload failed',
success: false,
});
expect(console.error).toHaveBeenCalledWith(
'[DownloadsService] Metadata update error:',
error
);
});
@@ -245,8 +501,8 @@ describe('DownloadsService', () => {
testWindow.electron = electron;
const service = createService();
const firstRequest = service.loadDownloads('playlist-old');
const secondRequest = service.loadDownloads('playlist-new');
const firstRequest = service.loadDownloads();
const secondRequest = service.loadDownloads();
expect(service.isLoadingDownloads()).toBe(true);
@@ -262,14 +518,8 @@ describe('DownloadsService', () => {
expect(service.downloads()).toEqual([latestItem]);
expect(service.isLoadingDownloads()).toBe(false);
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(
1,
'playlist-old'
);
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(
2,
'playlist-new'
);
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(1);
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(2);
});
it('reports paused content and resumes it by content identity', async () => {
@@ -281,9 +531,7 @@ describe('DownloadsService', () => {
pausedItem,
createDownload(8),
]) as unknown as DownloadsService;
const resumeDownload = jest
.fn()
.mockResolvedValue({ success: true });
const resumeDownload = jest.fn().mockResolvedValue({ success: true });
(service as unknown as { resumeDownload: jest.Mock }).resumeDownload =
resumeDownload;
@@ -296,6 +544,32 @@ describe('DownloadsService', () => {
expect(resumeDownload).toHaveBeenCalledWith(7);
});
it('only exposes completed files that are currently available', () => {
const available = {
...createDownload(20),
fileAvailability: 'available' as const,
filePath: '/downloads/available.mp4',
};
const missing = {
...createDownload(21),
fileAvailability: 'missing' as const,
filePath: '/downloads/missing.mp4',
};
const service = createService([
available,
missing,
]) as unknown as DownloadsService;
expect(service.isDownloaded(20, 'playlist-1', 'vod')).toBe(true);
expect(service.getDownloadedFilePath(20, 'playlist-1', 'vod')).toBe(
'/downloads/available.mp4'
);
expect(service.isDownloaded(21, 'playlist-1', 'vod')).toBe(false);
expect(
service.getDownloadedFilePath(21, 'playlist-1', 'vod')
).toBeUndefined();
});
it('rejects resume-by-content when the download is not paused', async () => {
const service = createService([
createDownload(8),
+88 -72
View File
@@ -1,34 +1,11 @@
import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core';
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import { updateDownloadMetadata } from './downloads-metadata-update';
import type { DownloadItem, DownloadStartInput } from './downloads.models';
import { formatDownloadBytes } from './downloads.utils';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
export type DownloadStatus =
| 'queued'
| 'downloading'
| 'paused'
| 'completed'
| 'failed'
| 'canceled';
export interface DownloadItem {
id: number;
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
title: string;
url: string;
fileName?: string;
filePath?: string;
posterUrl?: string;
status: DownloadStatus;
bytesDownloaded?: number;
totalBytes?: number;
errorMessage?: string;
createdAt?: string;
updatedAt?: string;
}
export type { DownloadItem, DownloadStatus } from './downloads.models';
@Injectable({ providedIn: 'root' })
export class DownloadsService implements OnDestroy {
@@ -106,14 +83,14 @@ export class DownloadsService implements OnDestroy {
/**
* Load downloads from the backend
*/
async loadDownloads(playlistId?: string): Promise<void> {
async loadDownloads(): Promise<void> {
if (!this.isAvailable()) return;
const requestId = ++this.loadDownloadsRequestId;
this._isLoadingDownloads.set(true);
try {
const list = await window.electron.downloadsGetList(playlistId);
const list = await window.electron.downloadsGetList();
if (requestId === this.loadDownloadsRequestId) {
this.downloads.set(list);
this._hasLoadedDownloads.set(true);
@@ -156,24 +133,9 @@ export class DownloadsService implements OnDestroy {
/**
* Start a new download
*/
async startDownload(data: {
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
title: string;
url: string;
posterUrl?: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
// Playlist info for auto-creation if needed (Stalker playlists)
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
}): Promise<{ success: boolean; id?: number; error?: string }> {
async startDownload(
data: DownloadStartInput
): Promise<{ success: boolean; id?: number; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
@@ -286,9 +248,30 @@ export class DownloadsService implements OnDestroy {
try {
return await window.electron.downloadsRetry(downloadId, folder);
} catch (error) {
console.error('[DownloadsService] Error retrying download:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Re-download a completed item whose finalized file is unavailable.
*/
async redownloadMissing(
downloadId: number
): Promise<{ success: boolean; recovered?: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsRedownloadMissing(downloadId);
} catch (error) {
console.error(
'[DownloadsService] Error retrying download:',
'[DownloadsService] Error re-downloading missing file:',
error
);
return {
@@ -311,10 +294,7 @@ export class DownloadsService implements OnDestroy {
try {
return await window.electron.downloadsRemove(downloadId);
} catch (error) {
console.error(
'[DownloadsService] Error removing download:',
error
);
console.error('[DownloadsService] Error removing download:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
@@ -322,6 +302,14 @@ export class DownloadsService implements OnDestroy {
}
}
/** Update the offline metadata snapshot for a managed download. */
async updateMetadata(
id: number,
snapshot: DownloadMetadataSnapshot
): Promise<{ success: boolean; error?: string }> {
return updateDownloadMetadata(this, id, snapshot);
}
/**
* Play a downloaded file
*/
@@ -379,10 +367,7 @@ export class DownloadsService implements OnDestroy {
}
return folder;
} catch (error) {
console.error(
'[DownloadsService] Error selecting folder:',
error
);
console.error('[DownloadsService] Error selecting folder:', error);
return null;
}
}
@@ -390,9 +375,7 @@ export class DownloadsService implements OnDestroy {
/**
* Clear completed/failed downloads
*/
async clearCompleted(
playlistId?: string
): Promise<{ success: boolean }> {
async clearCompleted(playlistId?: string): Promise<{ success: boolean }> {
if (!this.isAvailable()) {
return { success: false };
}
@@ -420,6 +403,13 @@ export class DownloadsService implements OnDestroy {
);
}
/**
* Get a download item by managed id
*/
getDownload(downloadId: number): DownloadItem | undefined {
return this.downloads().find(({ id }) => id === downloadId);
}
/**
* Get download item by xtreamId and playlistId
*/
@@ -444,8 +434,12 @@ export class DownloadsService implements OnDestroy {
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'completed' && !!download.filePath;
const download = this.getDownloadByContent(
xtreamId,
playlistId,
contentType
);
return this.hasAvailableCompletedFile(download);
}
/**
@@ -456,7 +450,11 @@ export class DownloadsService implements OnDestroy {
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
const download = this.getDownloadByContent(
xtreamId,
playlistId,
contentType
);
return (
download?.status === 'downloading' ||
download?.status === 'queued' ||
@@ -472,7 +470,11 @@ export class DownloadsService implements OnDestroy {
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
const download = this.getDownloadByContent(
xtreamId,
playlistId,
contentType
);
return download?.status === 'paused';
}
@@ -484,7 +486,11 @@ export class DownloadsService implements OnDestroy {
playlistId: string,
contentType: 'vod' | 'episode'
): Promise<{ success: boolean; error?: string }> {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
const download = this.getDownloadByContent(
xtreamId,
playlistId,
contentType
);
if (!download || download.status !== 'paused') {
return { success: false, error: 'No paused download found' };
}
@@ -499,21 +505,31 @@ export class DownloadsService implements OnDestroy {
playlistId: string,
contentType: 'vod' | 'episode'
): string | undefined {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
if (download?.status === 'completed') {
const download = this.getDownloadByContent(
xtreamId,
playlistId,
contentType
);
if (this.hasAvailableCompletedFile(download)) {
return download.filePath;
}
return undefined;
}
private hasAvailableCompletedFile(
item: DownloadItem | undefined
): item is DownloadItem & { filePath: string } {
return (
item?.status === 'completed' &&
!!item.filePath &&
item.fileAvailability !== 'missing'
);
}
/**
* Format bytes to human readable string
*/
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
return formatDownloadBytes(bytes);
}
}
+11
View File
@@ -0,0 +1,11 @@
export function formatDownloadBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const kilobyte = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const index = Math.floor(Math.log(bytes) / Math.log(kilobyte));
return (
parseFloat((bytes / Math.pow(kilobyte, index)).toFixed(1)) +
' ' +
sizes[index]
);
}
@@ -100,6 +100,7 @@ describe('RuntimeCapabilitiesService', () => {
downloadsPause: jest.fn(),
downloadsResume: jest.fn(),
downloadsRetry: jest.fn(),
downloadsRedownloadMissing: jest.fn(),
downloadsRemove: jest.fn(),
downloadsGetList: jest.fn(),
downloadsGet: jest.fn(),
@@ -382,6 +383,13 @@ describe('RuntimeCapabilitiesService', () => {
onDownloadsUpdate: jest.fn(),
};
expect(service.supportsDownloads).toBe(false);
testWindow.electron = {
...testWindow.electron,
downloadsRedownloadMissing: jest.fn(),
};
expect(service.supportsDownloads).toBe(true);
});
@@ -186,6 +186,7 @@ export class RuntimeCapabilitiesService {
'downloadsPause',
'downloadsResume',
'downloadsRetry',
'downloadsRedownloadMissing',
'downloadsRemove',
'downloadsGetList',
'downloadsGet',
@@ -278,8 +279,7 @@ export class RuntimeCapabilitiesService {
private hasElectronMethod(methodName: string): boolean {
const bridge = this.electronBridge as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
return typeof bridge?.[methodName] === 'function';
}