Files
KiraTV---App-Linux/libs/services/src/lib/downloads.service.ts
T
2026-05-03 23:43:09 +02:00

425 lines
12 KiB
TypeScript

import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core';
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 settingsStore = inject(SettingsStore);
private unsubscribe?: () => void;
/** Signal for the list of downloads */
readonly downloads = signal<DownloadItem[]>([]);
/** Whether the download feature is available (Electron only) */
readonly isAvailable = computed(() => !!window.electron?.downloadsGetList);
/** 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;
try {
const list = await window.electron.downloadsGetList(playlistId);
this.downloads.set(list);
} catch (error) {
console.error('[DownloadsService] Error loading downloads:', error);
}
}
/**
* Load or resolve download folder
*/
async loadDownloadFolder(): Promise<string> {
if (!this.isAvailable()) return '';
// First check settings
const storedFolder = this.settingsStore.getDownloadFolder?.();
if (storedFolder) {
this.downloadFolder.set(storedFolder);
return storedFolder;
}
// Fall back to default
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];
}
}