425 lines
12 KiB
TypeScript
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];
|
|
}
|
|
}
|