Files
iptvnator/libs/services/src/lib/downloads.service.ts
T
Salem 2c032cd3c8 fix(security): complete Electron hardening and review follow-ups
* fix(security): harden Electron IPC against MITM, SSRF, path and injection risks

S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts.
S2: write-file IPC restricted to save-dialog-authorized paths.
S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests).
S4: EPG titles rendered via interpolation, not [innerHTML].
S5: downloads reveal/play limited to recorded download paths.
S6: Stalker cmd encoded (slash-preserving) to block query injection.
EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed.

* perf(player): lazy-load web video players via @defer

Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js,
artplayer and mpegts.js split into a deferred chunk loaded on first playback
instead of eagerly on the player route. Embedded MPV (native) stays eager.
Spec uses DeferBlockBehavior.Playthrough.

* fix(player): remove leaked HTML video listeners on destroy

volumechange used a mismatched removeEventListener reference, while
loadedmetadata and timeupdate were never removed at all. Bind all three to
stable handler fields used for both add and remove, and add a teardown
regression test asserting each listener is detached on destroy.

* refactor(dashboard): extract pure navigation helpers from DashboardDataService

Move the 8 stateless link/navigation-state/type-kind helpers into a new
dashboard-navigation.util.ts so the routing logic is independently testable and
the 1260-line god-service shrinks. DashboardDataService keeps the public methods
as thin delegators (facade) so the public API and the single consumer
(workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService
decomposition; verified by the existing service spec (33/33) and the app typecheck.

* fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder)

- url-safety: broaden IPv6 link-local detection to the full fe80::/10 range
  (fe80:: through febf::), not just the fe80:: prefix (+ regression tests).
- playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save
  dialog opened without a following write cannot accumulate entries until restart.
- web-player-view: add a @placeholder to each @defer (on immediate) player block
  to avoid the one-frame blank/layout-shift before the chunk resolves.

* fix(security): close Electron network and download gaps

* test(downloads): cover cancellation and restart cleanup

* fix(downloads): address Greptile review gaps

* test(security): reproduce remaining Greptile findings

* fix(security): close remaining Greptile findings

* test(downloads): reproduce early database queue stall

* fix(downloads): release queue after setup failures

* test(downloads): reproduce completion queue stall

* fix(downloads): release queue after completion failures
2026-06-12 15:24:29 +02:00

445 lines
13 KiB
TypeScript

import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import { SettingsStore } from './settings-store.service';
export type DownloadStatus =
| 'queued'
| 'downloading'
| '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;
}
@Injectable({ providedIn: 'root' })
export class DownloadsService implements OnDestroy {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private unsubscribe?: () => void;
private loadDownloadsRequestId = 0;
private readonly _isLoadingDownloads = signal(false);
private readonly _hasLoadedDownloads = signal(false);
/** Signal for the list of downloads */
readonly downloads = signal<DownloadItem[]>([]);
/** Whether the download list is currently being loaded */
readonly isLoadingDownloads = this._isLoadingDownloads.asReadonly();
/** Whether the first download list request has completed */
readonly hasLoadedDownloads = this._hasLoadedDownloads.asReadonly();
/** Whether the download feature is available (Electron only) */
readonly isAvailable = computed(() => this.runtime.supportsDownloads);
/** Whether there are any downloads */
readonly hasDownloads = computed(() => this.downloads().length > 0);
/** Active downloads count */
readonly activeCount = computed(
() =>
this.downloads().filter(
(d) => d.status === 'queued' || d.status === 'downloading'
).length
);
/** Completed downloads count */
readonly completedCount = computed(
() => this.downloads().filter((d) => d.status === 'completed').length
);
/** Failed downloads count */
readonly failedCount = computed(
() =>
this.downloads().filter(
(d) => d.status === 'failed' || d.status === 'canceled'
).length
);
/** Current download folder */
readonly downloadFolder = signal<string>('');
constructor() {
this.init();
}
private async init() {
if (!this.isAvailable()) {
return;
}
// Load initial download list
await this.loadDownloads();
// Load download folder
await this.loadDownloadFolder();
// Subscribe to download updates
this.unsubscribe = window.electron.onDownloadsUpdate(() => {
this.loadDownloads();
});
}
ngOnDestroy() {
this.unsubscribe?.();
}
/**
* Load downloads from the backend
*/
async loadDownloads(playlistId?: string): Promise<void> {
if (!this.isAvailable()) return;
const requestId = ++this.loadDownloadsRequestId;
this._isLoadingDownloads.set(true);
try {
const list = await window.electron.downloadsGetList(playlistId);
if (requestId === this.loadDownloadsRequestId) {
this.downloads.set(list);
this._hasLoadedDownloads.set(true);
}
} catch (error) {
console.error('[DownloadsService] Error loading downloads:', error);
if (requestId === this.loadDownloadsRequestId) {
this._hasLoadedDownloads.set(true);
}
} finally {
if (requestId === this.loadDownloadsRequestId) {
this._isLoadingDownloads.set(false);
}
}
}
/**
* Load or resolve download folder
*/
async loadDownloadFolder(): Promise<string> {
if (!this.isAvailable()) return '';
// The main process owns folder authorization. It returns either the OS
// default or a custom folder previously selected through a native
// dialog, rather than trusting renderer-managed settings.
try {
const defaultFolder =
await window.electron.downloadsGetDefaultFolder();
this.downloadFolder.set(defaultFolder);
return defaultFolder;
} catch (error) {
console.error(
'[DownloadsService] Error getting default folder:',
error
);
return '';
}
}
/**
* 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 }> {
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 {
const result = await window.electron.downloadsStart({
...data,
downloadFolder: folder,
});
return result;
} catch (error) {
console.error('[DownloadsService] Error starting download:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Cancel a download
*/
async cancelDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsCancel(downloadId);
} catch (error) {
console.error(
'[DownloadsService] Error canceling download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Retry a failed download
*/
async retryDownload(
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.downloadsRetry(downloadId, folder);
} catch (error) {
console.error(
'[DownloadsService] Error retrying download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Remove a download from the list
*/
async removeDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsRemove(downloadId);
} catch (error) {
console.error(
'[DownloadsService] Error removing download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Play a downloaded file
*/
async playDownload(
filePath: string
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsPlayFile(filePath);
} catch (error) {
console.error('[DownloadsService] Error playing file:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Reveal file in system file manager
*/
async revealFile(
filePath: string
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsRevealFile(filePath);
} catch (error) {
console.error('[DownloadsService] Error revealing file:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Select a new download folder
*/
async selectFolder(): Promise<string | null> {
if (!this.isAvailable()) {
return null;
}
try {
const folder = await window.electron.downloadsSelectFolder();
if (folder) {
this.downloadFolder.set(folder);
// Save to settings
await this.settingsStore.updateSettings({ downloadFolder: folder });
}
return folder;
} catch (error) {
console.error(
'[DownloadsService] Error selecting folder:',
error
);
return null;
}
}
/**
* Clear completed/failed downloads
*/
async clearCompleted(
playlistId?: string
): Promise<{ success: boolean }> {
if (!this.isAvailable()) {
return { success: false };
}
try {
return await window.electron.downloadsClearCompleted(playlistId);
} catch (error) {
console.error(
'[DownloadsService] Error clearing completed:',
error
);
return { success: false };
}
}
/**
* Get download progress as percentage
*/
getProgressPercent(item: DownloadItem): number {
if (!item.totalBytes || item.totalBytes === 0) {
return 0;
}
return Math.round(
((item.bytesDownloaded || 0) / item.totalBytes) * 100
);
}
/**
* Get download item by xtreamId and playlistId
*/
getDownloadByContent(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): DownloadItem | undefined {
return this.downloads().find(
(d) =>
d.xtreamId === xtreamId &&
d.playlistId === playlistId &&
d.contentType === contentType
);
}
/**
* Check if content is downloaded (completed status)
*/
isDownloaded(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'completed' && !!download.filePath;
}
/**
* Check if content is currently downloading or queued
*/
isDownloading(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'downloading' || download?.status === 'queued';
}
/**
* Get the file path for a downloaded content item
*/
getDownloadedFilePath(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): string | undefined {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
if (download?.status === 'completed') {
return download.filePath;
}
return undefined;
}
/**
* 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];
}
}