mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
* 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
445 lines
13 KiB
TypeScript
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];
|
|
}
|
|
}
|