diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index bf8e461dd..117ace3dd 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -254,4 +254,48 @@ contextBridge.exposeInMainWorld('electron', { contentType ), getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'), + // Downloads + downloadsStart: (data: { + playlistId: string; + xtreamId: number; + contentType: 'vod' | 'episode'; + title: string; + url: string; + posterUrl?: string; + downloadFolder: string; + headers?: { userAgent?: string; referer?: string; origin?: string }; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + // Playlist info for auto-creation if needed + playlistName?: string; + playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; + serverUrl?: string; + portalUrl?: string; + macAddress?: string; + }) => ipcRenderer.invoke('DOWNLOADS_START', data), + downloadsCancel: (downloadId: number) => + ipcRenderer.invoke('DOWNLOADS_CANCEL', downloadId), + downloadsRetry: (downloadId: number, downloadFolder: string) => + ipcRenderer.invoke('DOWNLOADS_RETRY', downloadId, downloadFolder), + downloadsRemove: (downloadId: number) => + ipcRenderer.invoke('DOWNLOADS_REMOVE', downloadId), + downloadsGetList: (playlistId?: string) => + ipcRenderer.invoke('DOWNLOADS_GET_LIST', playlistId), + downloadsGet: (downloadId: number) => + ipcRenderer.invoke('DOWNLOADS_GET', downloadId), + downloadsGetDefaultFolder: () => + ipcRenderer.invoke('DOWNLOADS_GET_DEFAULT_FOLDER'), + downloadsSelectFolder: () => ipcRenderer.invoke('DOWNLOADS_SELECT_FOLDER'), + downloadsRevealFile: (filePath: string) => + ipcRenderer.invoke('DOWNLOADS_REVEAL_FILE', filePath), + downloadsPlayFile: (filePath: string) => + ipcRenderer.invoke('DOWNLOADS_PLAY_FILE', filePath), + downloadsClearCompleted: (playlistId?: string) => + ipcRenderer.invoke('DOWNLOADS_CLEAR_COMPLETED', playlistId), + onDownloadsUpdate: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on('DOWNLOADS_UPDATE_EVENT', handler); + return () => ipcRenderer.off('DOWNLOADS_UPDATE_EVENT', handler); + }, }); diff --git a/apps/electron-backend/src/app/database/schema.ts b/apps/electron-backend/src/app/database/schema.ts index 5a23e84e0..52c188f5c 100644 --- a/apps/electron-backend/src/app/database/schema.ts +++ b/apps/electron-backend/src/app/database/schema.ts @@ -13,6 +13,7 @@ export { epgChannels, epgPrograms, playbackPositions, + downloads, // Types type Playlist, type NewPlaylist, @@ -30,5 +31,7 @@ export { type NewEpgProgramDb, type PlaybackPosition, type NewPlaybackPosition, + type Download, + type NewDownload, } from 'database'; diff --git a/apps/electron-backend/src/app/events/database.events.ts b/apps/electron-backend/src/app/events/database.events.ts index a9dd86be4..4147839cb 100644 --- a/apps/electron-backend/src/app/events/database.events.ts +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -7,6 +7,7 @@ import { ipcMain } from 'electron'; import './database/category.events'; import './database/content.events'; +import './database/downloads.events'; import './database/epg-db.events'; import './database/favorites.events'; import './database/playback-position.events'; diff --git a/apps/electron-backend/src/app/events/database/downloads.events.ts b/apps/electron-backend/src/app/events/database/downloads.events.ts new file mode 100644 index 000000000..c451f6bfd --- /dev/null +++ b/apps/electron-backend/src/app/events/database/downloads.events.ts @@ -0,0 +1,649 @@ +/** + * Downloads IPC event handlers + * Uses electron-dl for download management with queue control and progress tracking + */ + +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'; +import { download, File as ElectronDlFile } from 'electron-dl'; +import { existsSync, unlinkSync } from 'fs'; +import { basename, extname, join } from 'path'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; + +type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'failed' | 'canceled'; + +interface DownloadTask { + id: number; + url: string; + fileName: string; + directory: string; + headers?: Record; + downloadItem?: ElectronDlFile; +} + +// Download queue management +const downloadQueue: DownloadTask[] = []; +let activeDownload: DownloadTask | null = null; +let mainWindow: BrowserWindow | null = null; + +/** + * Set the main window reference for sending updates + */ +export function setMainWindow(win: BrowserWindow) { + mainWindow = win; +} + +/** + * Broadcast download updates to renderer + */ +function broadcastUpdate() { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT'); + } +} + +/** + * Get file extension from URL + */ +function getExtensionFromUrl(url: string): string { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const ext = extname(pathname); + return ext || '.mp4'; + } catch { + return '.mp4'; + } +} + +/** + * Sanitize filename for filesystem + */ +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*]/g, '_').trim(); +} + +/** + * Process the download queue - starts next download if none active + */ +async function processQueue() { + if (activeDownload || downloadQueue.length === 0) { + return; + } + + const task = downloadQueue.shift(); + if (!task) return; + + activeDownload = task; + await startDownload(task); +} + +/** + * Start a download using electron-dl + */ +async function startDownload(task: DownloadTask) { + const db = await getDatabase(); + + // Update status to downloading + await db + .update(schema.downloads) + .set({ + status: 'downloading', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + + broadcastUpdate(); + + if (!mainWindow || mainWindow.isDestroyed()) { + console.error('[Downloads] No main window available'); + await db + .update(schema.downloads) + .set({ + status: 'failed', + errorMessage: 'No window available for download', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + activeDownload = null; + broadcastUpdate(); + processQueue(); + return; + } + + let lastProgressUpdate = 0; + const PROGRESS_THROTTLE_MS = 500; + + try { + const downloadOptions: Parameters[2] = { + directory: task.directory, + filename: task.fileName, + overwrite: true, + onStarted: (item) => { + console.log(`[Downloads] Started: ${task.fileName}`); + task.downloadItem = item as unknown as ElectronDlFile; + }, + onProgress: async (progress) => { + const now = Date.now(); + if (now - lastProgressUpdate < PROGRESS_THROTTLE_MS) { + return; + } + lastProgressUpdate = now; + + await db + .update(schema.downloads) + .set({ + bytesDownloaded: progress.transferredBytes, + totalBytes: progress.totalBytes, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + + broadcastUpdate(); + }, + onCompleted: async (file) => { + console.log(`[Downloads] Completed: ${task.fileName}`); + await db + .update(schema.downloads) + .set({ + status: 'completed', + filePath: file.path, + fileName: file.filename, + bytesDownloaded: file.fileSize, + totalBytes: file.fileSize, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + + activeDownload = null; + broadcastUpdate(); + processQueue(); + }, + onCancel: async () => { + console.log(`[Downloads] Canceled: ${task.fileName}`); + // Delete partial file if it exists + const partialPath = join(task.directory, task.fileName); + if (existsSync(partialPath)) { + try { + unlinkSync(partialPath); + } catch (e) { + console.error('[Downloads] Failed to delete partial file:', e); + } + } + + await db + .update(schema.downloads) + .set({ + status: 'canceled', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + + activeDownload = null; + broadcastUpdate(); + processQueue(); + }, + }; + + // Add headers if provided (user-agent, referer, origin) + if (task.headers) { + (downloadOptions as any).headers = task.headers; + } + + await download(mainWindow, task.url, downloadOptions); + } catch (error) { + console.error(`[Downloads] Error downloading ${task.fileName}:`, error); + + // Delete partial file if it exists + const partialPath = join(task.directory, task.fileName); + if (existsSync(partialPath)) { + try { + unlinkSync(partialPath); + } catch (e) { + console.error('[Downloads] Failed to delete partial file:', e); + } + } + + await db + .update(schema.downloads) + .set({ + status: 'failed', + errorMessage: error instanceof Error ? error.message : String(error), + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + + activeDownload = null; + broadcastUpdate(); + processQueue(); + } +} + +/** + * Enqueue a new download + */ +ipcMain.handle( + 'DOWNLOADS_START', + async ( + _event, + data: { + playlistId: string; + xtreamId: number; + contentType: 'vod' | 'episode'; + title: string; + url: string; + posterUrl?: string; + downloadFolder: string; + headers?: { userAgent?: string; referer?: string; origin?: string }; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + // Playlist info for auto-creation if needed + playlistName?: string; + playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; + serverUrl?: string; + portalUrl?: string; + macAddress?: string; + } + ) => { + try { + console.log('[Downloads] Enqueue download:', data.title); + const db = await getDatabase(); + + // Ensure playlist exists in database (required for foreign key constraint) + if (data.playlistId) { + const existingPlaylist = await db + .select() + .from(schema.playlists) + .where(eq(schema.playlists.id, data.playlistId)) + .limit(1); + + if (existingPlaylist.length === 0) { + // Create playlist entry for downloads to work + console.log('[Downloads] Creating playlist entry for:', data.playlistId); + await db.insert(schema.playlists).values({ + id: data.playlistId, + name: data.playlistName || 'Unknown Playlist', + type: data.playlistType || 'stalker', + serverUrl: data.serverUrl, + macAddress: data.macAddress, + url: data.portalUrl, + }); + } + } else { + throw new Error('playlistId is required for downloads'); + } + + // Check if already exists + const existing = await db + .select() + .from(schema.downloads) + .where( + and( + eq(schema.downloads.playlistId, data.playlistId), + eq(schema.downloads.xtreamId, data.xtreamId), + eq(schema.downloads.contentType, data.contentType) + ) + ) + .limit(1); + + if (existing.length > 0) { + const item = existing[0]; + // If completed or failed/canceled, allow retry by updating + if (['completed', 'failed', 'canceled'].includes(item.status)) { + await db + .update(schema.downloads) + .set({ + status: 'queued', + url: data.url, + bytesDownloaded: 0, + totalBytes: null, + errorMessage: null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, item.id)); + + const ext = getExtensionFromUrl(data.url); + const fileName = sanitizeFilename(data.title) + ext; + + downloadQueue.push({ + id: item.id, + url: data.url, + fileName, + directory: data.downloadFolder, + headers: data.headers + ? { + 'User-Agent': data.headers.userAgent || '', + Referer: data.headers.referer || '', + Origin: data.headers.origin || '', + } + : undefined, + }); + + broadcastUpdate(); + processQueue(); + return { success: true, id: item.id }; + } + + // Already queued or downloading + return { success: false, error: 'Download already in progress', id: item.id }; + } + + // Create new download entry + const ext = getExtensionFromUrl(data.url); + const fileName = sanitizeFilename(data.title) + ext; + + const result = await db.insert(schema.downloads).values({ + playlistId: data.playlistId, + xtreamId: data.xtreamId, + contentType: data.contentType, + title: data.title, + url: data.url, + posterUrl: data.posterUrl, + fileName, + status: 'queued', + seriesXtreamId: data.seriesXtreamId, + seasonNumber: data.seasonNumber, + episodeNumber: data.episodeNumber, + }); + + const insertedId = Number(result.lastInsertRowid); + + downloadQueue.push({ + id: insertedId, + url: data.url, + fileName, + directory: data.downloadFolder, + headers: data.headers + ? { + 'User-Agent': data.headers.userAgent || '', + Referer: data.headers.referer || '', + Origin: data.headers.origin || '', + } + : undefined, + }); + + broadcastUpdate(); + processQueue(); + + return { success: true, id: insertedId }; + } catch (error) { + console.error('[Downloads] Error enqueuing download:', error); + throw error; + } + } +); + +/** + * Cancel a download + */ +ipcMain.handle('DOWNLOADS_CANCEL', async (_event, downloadId: number) => { + try { + console.log('[Downloads] Cancel download:', downloadId); + const db = await getDatabase(); + + // Check if it's the active download + if (activeDownload && activeDownload.id === downloadId) { + if (activeDownload.downloadItem) { + (activeDownload.downloadItem as any).cancel?.(); + } + return { success: true }; + } + + // Remove from queue + const queueIndex = downloadQueue.findIndex((t) => t.id === downloadId); + if (queueIndex !== -1) { + downloadQueue.splice(queueIndex, 1); + await db + .update(schema.downloads) + .set({ + status: 'canceled', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, downloadId)); + + broadcastUpdate(); + return { success: true }; + } + + return { success: false, error: 'Download not found in queue' }; + } catch (error) { + console.error('[Downloads] Error canceling download:', error); + throw error; + } +}); + +/** + * Retry a failed/canceled download + */ +ipcMain.handle( + 'DOWNLOADS_RETRY', + async (_event, downloadId: number, downloadFolder: string) => { + try { + console.log('[Downloads] Retry download:', downloadId); + const db = await getDatabase(); + + const existing = await db + .select() + .from(schema.downloads) + .where(eq(schema.downloads.id, downloadId)) + .limit(1); + + if (existing.length === 0) { + return { success: false, error: 'Download not found' }; + } + + const item = existing[0]; + if (!['failed', 'canceled'].includes(item.status)) { + return { success: false, error: 'Can only retry failed or canceled downloads' }; + } + + await db + .update(schema.downloads) + .set({ + status: 'queued', + bytesDownloaded: 0, + totalBytes: null, + errorMessage: null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, downloadId)); + + const ext = getExtensionFromUrl(item.url); + const fileName = sanitizeFilename(item.title) + ext; + + downloadQueue.push({ + id: item.id, + url: item.url, + fileName, + directory: downloadFolder, + }); + + broadcastUpdate(); + processQueue(); + + return { success: true }; + } catch (error) { + console.error('[Downloads] Error retrying download:', error); + throw error; + } + } +); + +/** + * Remove a download from the list + */ +ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => { + try { + console.log('[Downloads] Remove download:', downloadId); + const db = await getDatabase(); + + // Cancel if active + if (activeDownload && activeDownload.id === downloadId) { + if (activeDownload.downloadItem) { + (activeDownload.downloadItem as any).cancel?.(); + } + } + + // Remove from queue + const queueIndex = downloadQueue.findIndex((t) => t.id === downloadId); + if (queueIndex !== -1) { + downloadQueue.splice(queueIndex, 1); + } + + // Delete from database + await db.delete(schema.downloads).where(eq(schema.downloads.id, downloadId)); + + broadcastUpdate(); + return { success: true }; + } catch (error) { + console.error('[Downloads] Error removing download:', error); + throw error; + } +}); + +/** + * Get all downloads for a playlist + */ +ipcMain.handle('DOWNLOADS_GET_LIST', async (_event, playlistId?: string) => { + try { + const db = await getDatabase(); + + if (playlistId) { + const result = await db + .select() + .from(schema.downloads) + .where(eq(schema.downloads.playlistId, playlistId)) + .orderBy(schema.downloads.createdAt); + return result; + } + + const result = await db + .select() + .from(schema.downloads) + .orderBy(schema.downloads.createdAt); + return result; + } catch (error) { + console.error('[Downloads] Error getting download list:', error); + throw error; + } +}); + +/** + * Get download by ID + */ +ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => { + try { + const db = await getDatabase(); + const result = await db + .select() + .from(schema.downloads) + .where(eq(schema.downloads.id, downloadId)) + .limit(1); + return result[0] || null; + } catch (error) { + console.error('[Downloads] Error getting download:', error); + throw error; + } +}); + +/** + * Get default download folder + */ +ipcMain.handle('DOWNLOADS_GET_DEFAULT_FOLDER', async () => { + return app.getPath('downloads'); +}); + +/** + * Select download folder via dialog + */ +ipcMain.handle('DOWNLOADS_SELECT_FOLDER', async () => { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory', 'createDirectory'], + title: 'Select Download Folder', + defaultPath: app.getPath('downloads'), + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + return result.filePaths[0]; +}); + +/** + * Reveal file in system file manager + */ +ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => { + if (existsSync(filePath)) { + shell.showItemInFolder(filePath); + return { success: true }; + } + return { success: false, error: 'File not found' }; +}); + +/** + * Play downloaded file + */ +ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => { + if (existsSync(filePath)) { + await shell.openPath(filePath); + return { success: true }; + } + return { success: false, error: 'File not found' }; +}); + +/** + * Clear all completed downloads + */ +ipcMain.handle('DOWNLOADS_CLEAR_COMPLETED', async (_event, playlistId?: string) => { + try { + const db = await getDatabase(); + + if (playlistId) { + await db + .delete(schema.downloads) + .where( + and( + eq(schema.downloads.playlistId, playlistId), + inArray(schema.downloads.status, ['completed', 'failed', 'canceled']) + ) + ); + } else { + await db + .delete(schema.downloads) + .where( + inArray(schema.downloads.status, ['completed', 'failed', 'canceled']) + ); + } + + broadcastUpdate(); + return { success: true }; + } catch (error) { + console.error('[Downloads] Error clearing completed:', error); + throw error; + } +}); + +/** + * Reset stale downloads on startup (downloading -> failed) + */ +export async function resetStaleDownloads() { + try { + const db = await getDatabase(); + await db + .update(schema.downloads) + .set({ + status: 'failed', + errorMessage: 'Download interrupted by application restart', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where( + inArray(schema.downloads.status, ['queued', 'downloading']) + ); + console.log('[Downloads] Reset stale downloads'); + } catch (error) { + console.error('[Downloads] Error resetting stale downloads:', error); + } +} diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 69d6f8e9b..36f07eb42 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -3,6 +3,10 @@ import fixPath from 'fix-path'; import App from './app/app'; import { initDatabase } from './app/database/connection'; import DatabaseEvents from './app/events/database.events'; +import { + resetStaleDownloads, + setMainWindow as setDownloadsMainWindow, +} from './app/events/database/downloads.events'; import ElectronEvents from './app/events/electron.events'; import EpgEvents from './app/events/epg.events'; import PlayerEvents from './app/events/player.events'; @@ -43,6 +47,12 @@ export default class Main { EpgEvents.bootstrapEpgEvents(); RemoteControlEvents.bootstrapRemoteControlEvents(); + // Set main window for downloads and reset stale downloads + if (App.mainWindow) { + setDownloadsMainWindow(App.mainWindow); + } + await resetStaleDownloads(); + // initialize auto updater service if (!App.isDevelopmentMode()) { // UpdateEvents.initAutoUpdateService(); diff --git a/apps/web/src/app/services/downloads.service.ts b/apps/web/src/app/services/downloads.service.ts new file mode 100644 index 000000000..992c3a45d --- /dev/null +++ b/apps/web/src/app/services/downloads.service.ts @@ -0,0 +1,424 @@ +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([]); + + /** 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(''); + + 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 { + 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 { + 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 { + 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]; + } +} diff --git a/apps/web/src/app/services/settings-store.service.ts b/apps/web/src/app/services/settings-store.service.ts index 4aed192ab..4a4172e57 100644 --- a/apps/web/src/app/services/settings-store.service.ts +++ b/apps/web/src/app/services/settings-store.service.ts @@ -79,9 +79,14 @@ export const SettingsStore = signalStore( remoteControl: store.remoteControl(), remoteControlPort: store.remoteControlPort(), epgUrl: store.epgUrl(), + downloadFolder: store.downloadFolder?.(), }; }, + getDownloadFolder() { + return store.downloadFolder?.(); + }, + getPlayer() { return store.player(); }, diff --git a/apps/web/src/app/stalker/stalker-main-container.component.html b/apps/web/src/app/stalker/stalker-main-container.component.html index 865406fd8..c1309723a 100644 --- a/apps/web/src/app/stalker/stalker-main-container.component.html +++ b/apps/web/src/app/stalker/stalker-main-container.component.html @@ -17,50 +17,41 @@ } @if (isItvCategorySelected) { - - @for (item of itvChannels(); track $index) { - - logo - {{ item.name }} - - - } - +
+ + @for (item of itvChannels(); track $index) { + + logo + {{ item.name }} + + + } + - @if (hasMoreItems() && !isContentLoading()) { - -
- -
- } + + @if (isLoadingMore()) { +
+ +
+ } +
} @else { @if (isCategoryLoading()) { ('itvScrollContainer'); + private scrollListener: (() => void) | null = null; + readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName; readonly currentPlaylist = this.stalkerStore.currentPlaylist; @@ -158,10 +173,22 @@ export class StalkerMainContainerComponent { //this.hasMoreItems.set(false); }); - // reset loading state when content resource changes + // reset loading state when content resource changes and check if we need more effect(() => { if (this.contentItems() !== undefined) { this.isLoadingMore.set(false); + // Check if we need to load more to fill the viewport + this.checkIfNeedsMoreContent(); + } + }); + + // Also check when ITV channels are loaded + effect(() => { + const channels = this.itvChannels(); + if (channels.length > 0) { + this.isLoadingMore.set(false); + // Small delay to allow DOM to update before checking + setTimeout(() => this.checkIfNeedsMoreContent(), 100); } }); @@ -172,6 +199,66 @@ export class StalkerMainContainerComponent { this.favorites.set(fav.id, true); }); }); + + // Setup scroll listener when container becomes available + effect(() => { + const scrollContainer = this.itvScrollContainer(); + if (scrollContainer) { + this.setupScrollListener(); + } + }); + } + + ngAfterViewInit() { + this.setupScrollListener(); + } + + ngOnDestroy() { + this.removeScrollListener(); + } + + private setupScrollListener() { + this.removeScrollListener(); + + const container = this.itvScrollContainer()?.nativeElement; + if (!container) return; + + const onScroll = () => { + if (this.isLoadingMore() || !this.hasMoreItems()) return; + + const { scrollTop, scrollHeight, clientHeight } = container; + const scrollThreshold = 150; // pixels from bottom to trigger load + + if (scrollHeight - scrollTop - clientHeight <= scrollThreshold) { + this.loadMore(); + } + }; + + container.addEventListener('scroll', onScroll, { passive: true }); + this.scrollListener = () => container.removeEventListener('scroll', onScroll); + } + + /** + * Check if the container needs more content to become scrollable. + * If content doesn't fill the viewport and there are more items, load more. + */ + private checkIfNeedsMoreContent() { + const container = this.itvScrollContainer()?.nativeElement; + if (!container) return; + if (this.isLoadingMore() || !this.hasMoreItems()) return; + + const { scrollHeight, clientHeight } = container; + // If content doesn't fill the container (no scrollbar), load more + if (scrollHeight <= clientHeight) { + this.loadMore(); + } + } + + private removeScrollListener() { + if (this.scrollListener) { + this.scrollListener(); + this.scrollListener = null; + } } handshake() { diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html index f97bb9852..d490f8b55 100644 --- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html +++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html @@ -63,6 +63,7 @@ +
+
+ +

{{ 'DOWNLOADS.TITLE' | translate }}

+ @if (activeCount() > 0) { + {{ activeCount() }} + } +
+
+ @if (hasDownloads()) { + + } +
+
+ +
+ folder + {{ downloadFolder() || ('DOWNLOADS.NO_FOLDER' | translate) }} + +
+ + @if (!isAvailable()) { +
+ desktop_access_disabled +

{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}

+ {{ 'DOWNLOADS.DESKTOP_ONLY' | translate }} +
+ } @else if (!hasDownloads()) { +
+ download +

{{ 'DOWNLOADS.EMPTY' | translate }}

+ {{ 'DOWNLOADS.EMPTY_HINT' | translate }} +
+ } @else { +
+
+ @for (item of filteredDownloads(); track item.id) { +
+
+ @if (item.posterUrl) { + + } @else { + movie + } +
+ +
+
+ {{ item.title }} + @if (item.contentType === 'episode') { + + {{ formatEpisodeLabel(item) }} + + } +
+ +
+ + {{ + getStatusIcon(item.status) + }} + {{ 'DOWNLOADS.STATUS.' + item.status.toUpperCase() | translate }} + + + @if ( + item.status === 'downloading' || + item.status === 'completed' + ) { + + {{ formatBytes(item.bytesDownloaded) }} + @if (item.totalBytes) { + / {{ formatBytes(item.totalBytes) }} + } + + } + + @if (item.errorMessage) { + + {{ item.errorMessage }} + + } +
+ + @if (item.status === 'downloading') { + + } +
+ +
+ @switch (item.status) { + @case ('queued') { + + } + @case ('downloading') { + + } + @case ('completed') { + + + + } + @case ('failed') { + + + } + @case ('canceled') { + + + } + } +
+
+ } +
+
+ } + diff --git a/apps/web/src/app/xtream-tauri/downloads/downloads.component.scss b/apps/web/src/app/xtream-tauri/downloads/downloads.component.scss new file mode 100644 index 000000000..7d4ce8fa4 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/downloads/downloads.component.scss @@ -0,0 +1,298 @@ +.downloads { + display: flex; + flex-direction: column; + height: 100%; + padding: 1rem; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.12)); + + &-left { + display: flex; + align-items: center; + gap: 0.75rem; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 500; + } + + .back-btn { + margin-right: 0.5rem; + } + } + + &-actions { + display: flex; + gap: 0.5rem; + } + } + + &__badge { + background: var(--primary-color, #3f51b5); + color: white; + padding: 0.125rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + } + + &__folder { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + background: var(--surface-color, #f5f5f5); + border-radius: 8px; + + mat-icon { + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); + } + + &-path { + flex: 1; + font-family: monospace; + font-size: 0.875rem; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); + + mat-icon { + font-size: 4rem; + width: 4rem; + height: 4rem; + margin-bottom: 1rem; + opacity: 0.5; + } + + p { + margin: 0 0 0.5rem; + font-size: 1.25rem; + font-weight: 500; + } + + span { + font-size: 0.875rem; + } + } + + &__list-wrapper { + flex: 1; + overflow-y: auto; + margin: 0 -1rem; + padding: 0 1rem; + } + + &__list { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + &__item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--card-background, #fff); + border-radius: 12px; + border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08)); + transition: box-shadow 0.2s ease, transform 0.2s ease; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } + + &.status-downloading { + border-left: 4px solid #2196f3; + } + + &.status-completed { + border-left: 4px solid #4caf50; + } + + &.status-failed { + border-left: 4px solid #f44336; + } + + &.status-queued { + border-left: 4px solid #9e9e9e; + } + + &-poster { + width: 60px; + height: 85px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + background: var(--surface-color, #f5f5f5); + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + mat-icon { + font-size: 2rem; + width: 2rem; + height: 2rem; + color: var(--text-secondary, rgba(0, 0, 0, 0.38)); + } + } + + &-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + &-title { + font-size: 1rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; + gap: 0.5rem; + } + + &-meta { + display: flex; + align-items: center; + gap: 1rem; + font-size: 0.8125rem; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); + } + + &-status { + display: flex; + align-items: center; + gap: 0.25rem; + + mat-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + } + + &.status-downloading { + color: #2196f3; + } + + &.status-completed { + color: #4caf50; + } + + &.status-failed { + color: #f44336; + } + + &.status-queued { + color: #9e9e9e; + } + } + + &-size { + font-family: monospace; + } + + &-error { + color: #f44336; + font-size: 0.75rem; + } + + &-actions { + display: flex; + gap: 0.25rem; + flex-shrink: 0; + } + + mat-progress-bar { + margin-top: 0.5rem; + border-radius: 4px; + } + } + + &__episode-badge { + background: var(--primary-color, #3f51b5); + color: white; + padding: 0.125rem 0.375rem; + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + } +} + +// Dark theme support +:host-context(body.dark-theme) { + .downloads { + &__header { + border-bottom-color: rgba(255, 255, 255, 0.12); + } + + &__folder { + background: rgba(255, 255, 255, 0.05); + + mat-icon { + color: rgba(255, 255, 255, 0.6); + } + + &-path { + color: rgba(255, 255, 255, 0.6); + } + } + + &__empty { + color: rgba(255, 255, 255, 0.6); + } + + &__item { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.08); + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + } + + &-poster { + background: rgba(255, 255, 255, 0.08); + + mat-icon { + color: rgba(255, 255, 255, 0.38); + } + } + + &-meta { + color: rgba(255, 255, 255, 0.6); + } + } + } +} diff --git a/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts b/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts new file mode 100644 index 000000000..091b1d82d --- /dev/null +++ b/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts @@ -0,0 +1,140 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + DownloadItem, + DownloadsService, +} from '../../services/downloads.service'; + +@Component({ + selector: 'app-downloads', + templateUrl: './downloads.component.html', + styleUrls: ['./downloads.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatButtonModule, + MatIconModule, + MatProgressBarModule, + MatTooltipModule, + TranslatePipe, + ], +}) +export class DownloadsComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + readonly downloadsService = inject(DownloadsService); + + readonly downloads = this.downloadsService.downloads; + readonly downloadFolder = this.downloadsService.downloadFolder; + readonly isAvailable = this.downloadsService.isAvailable; + readonly hasDownloads = this.downloadsService.hasDownloads; + readonly activeCount = this.downloadsService.activeCount; + + /** Filter downloads for current playlist */ + readonly filteredDownloads = computed(() => { + const playlistId = this.route.snapshot.params['id']; + if (!playlistId) return this.downloads(); + return this.downloads().filter((d) => d.playlistId === playlistId); + }); + + ngOnInit() { + const playlistId = this.route.snapshot.params['id']; + this.downloadsService.loadDownloads(playlistId); + } + + getProgress(item: DownloadItem): number { + return this.downloadsService.getProgressPercent(item); + } + + formatBytes(bytes: number | undefined): string { + if (!bytes) return '0 B'; + return this.downloadsService.formatBytes(bytes); + } + + getStatusIcon(status: string): string { + switch (status) { + case 'queued': + return 'schedule'; + case 'downloading': + return 'downloading'; + case 'completed': + return 'check_circle'; + case 'failed': + return 'error'; + case 'canceled': + return 'cancel'; + default: + return 'help'; + } + } + + getStatusColor(status: string): string { + switch (status) { + case 'queued': + return 'status-queued'; + case 'downloading': + return 'status-downloading'; + case 'completed': + return 'status-completed'; + case 'failed': + case 'canceled': + return 'status-failed'; + default: + return ''; + } + } + + async cancel(item: DownloadItem) { + await this.downloadsService.cancelDownload(item.id); + } + + async retry(item: DownloadItem) { + await this.downloadsService.retryDownload(item.id); + } + + async remove(item: DownloadItem) { + await this.downloadsService.removeDownload(item.id); + } + + async play(item: DownloadItem) { + if (item.filePath) { + await this.downloadsService.playDownload(item.filePath); + } + } + + async reveal(item: DownloadItem) { + if (item.filePath) { + await this.downloadsService.revealFile(item.filePath); + } + } + + async changeFolder() { + await this.downloadsService.selectFolder(); + } + + async clearCompleted() { + const playlistId = this.route.snapshot.params['id']; + await this.downloadsService.clearCompleted(playlistId); + } + + goBack() { + this.router.navigate(['..'], { relativeTo: this.route }); + } + + formatEpisodeLabel(item: DownloadItem): string { + if (item.contentType !== 'episode') return ''; + const s = item.seasonNumber?.toString().padStart(2, '0') || '00'; + const e = item.episodeNumber?.toString().padStart(2, '0') || '00'; + return `S${s}E${e}`; + } +} diff --git a/apps/web/src/app/xtream-tauri/navigation/navigation.component.html b/apps/web/src/app/xtream-tauri/navigation/navigation.component.html index a27f7f634..2e57eb97e 100644 --- a/apps/web/src/app/xtream-tauri/navigation/navigation.component.html +++ b/apps/web/src/app/xtream-tauri/navigation/navigation.component.html @@ -103,6 +103,17 @@ > favorite + @if (isElectron) { + + download + + } diff --git a/apps/web/src/app/xtream-tauri/navigation/navigation.component.ts b/apps/web/src/app/xtream-tauri/navigation/navigation.component.ts index 193cf672b..43047b86e 100644 --- a/apps/web/src/app/xtream-tauri/navigation/navigation.component.ts +++ b/apps/web/src/app/xtream-tauri/navigation/navigation.component.ts @@ -49,6 +49,9 @@ export class NavigationComponent { readonly navigationItems = input(); + /** Check if running in Electron (downloads only available in desktop) */ + readonly isElectron = !!window.electron; + getStatusColor(): string { if (this.isStalkerPlaylist()) return 'status-active'; diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.html b/apps/web/src/app/xtream-tauri/season-container/season-container.component.html index d49e2730e..9fd303649 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.html +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.html @@ -89,6 +89,36 @@ [isWatched]="isEpisodeWatched(episode)" />
+ @if (isElectron()) { + @if (isEpisodeDownloaded(episode)) { + + } @else if (isEpisodeDownloading(episode)) { + + } @else { + + } + } } + @if (isElectron()) { + @if (isDownloaded()) { + + } @else if (isDownloading()) { + + } @else { + + } + } diff --git a/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.ts b/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.ts index 1389a80cf..bd54924af 100644 --- a/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.ts +++ b/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.ts @@ -6,6 +6,7 @@ import { ActivatedRoute } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { ContentHeroComponent } from 'components'; import { XtreamVodDetails } from 'shared-interfaces'; +import { DownloadsService } from '../../services/downloads.service'; import { SettingsStore } from '../../services/settings-store.service'; import { XtreamStore } from '../stores/xtream.store'; import { SafePipe } from './safe.pipe'; @@ -27,8 +28,10 @@ export class VodDetailsComponent implements OnInit, OnDestroy { private settingsStore = inject(SettingsStore); private route = inject(ActivatedRoute); private readonly xtreamStore = inject(XtreamStore); + private readonly downloadsService = inject(DownloadsService); readonly theme = this.settingsStore.theme; + readonly isElectron = this.downloadsService.isAvailable; readonly isFavorite = this.xtreamStore.isFavorite; readonly selectedItem = this.xtreamStore.selectedItem; @@ -42,6 +45,26 @@ export class VodDetailsComponent implements OnInit, OnDestroy { return inProgress; }); + /** Check if VOD is already downloaded */ + readonly isDownloaded = computed(() => { + const vodId = Number(this.route.snapshot.params.vodId); + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) return false; + // Access downloads signal to make this reactive + this.downloadsService.downloads(); + return this.downloadsService.isDownloaded(vodId, playlistId, 'vod'); + }); + + /** Check if VOD is currently downloading */ + readonly isDownloading = computed(() => { + const vodId = Number(this.route.snapshot.params.vodId); + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) return false; + // Access downloads signal to make this reactive + this.downloadsService.downloads(); + return this.downloadsService.isDownloading(vodId, playlistId, 'vod'); + }); + ngOnInit(): void { const { categoryId, vodId } = this.route.snapshot.params; this.xtreamStore.fetchVodDetailsWithMetadata({ vodId, categoryId }); @@ -140,4 +163,44 @@ export class VodDetailsComponent implements OnInit, OnDestroy { goBack() { this.location.back(); } + + async downloadVod(vodItem: XtreamVodDetails) { + const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); + const routeVodId = this.route.snapshot.params.vodId; + const id = routeVodId + ? Number(routeVodId) + : Number(vodItem.movie_data?.stream_id || (vodItem as any).stream_id); + + const playlist = this.xtreamStore.currentPlaylist(); + + await this.downloadsService.startDownload({ + playlistId: playlist.id, + xtreamId: id, + contentType: 'vod', + title: vodItem.info?.name || vodItem?.movie_data?.name || 'Unknown', + url: streamUrl, + posterUrl: vodItem.info?.movie_image, + headers: { + userAgent: playlist.userAgent, + referer: playlist.referrer, + origin: playlist.origin, + }, + }); + } + + async playFromLocal() { + const vodId = Number(this.route.snapshot.params.vodId); + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) return; + + const filePath = this.downloadsService.getDownloadedFilePath( + vodId, + playlistId, + 'vod' + ); + + if (filePath) { + await this.downloadsService.playDownload(filePath); + } + } } diff --git a/apps/web/src/app/xtream-tauri/xtream.routes.ts b/apps/web/src/app/xtream-tauri/xtream.routes.ts index df8db0228..c337c358b 100644 --- a/apps/web/src/app/xtream-tauri/xtream.routes.ts +++ b/apps/web/src/app/xtream-tauri/xtream.routes.ts @@ -117,6 +117,13 @@ export const xtreamRoutes: Route[] = [ (c) => c.RecentlyAddedComponent ), }, + { + path: 'downloads', + loadComponent: () => + import('./downloads/downloads.component').then( + (c) => c.DownloadsComponent + ), + }, ], }, ]; diff --git a/apps/web/src/app/xtream/vod-details/vod-details.component.html b/apps/web/src/app/xtream/vod-details/vod-details.component.html index ca9c900be..6828b3410 100644 --- a/apps/web/src/app/xtream/vod-details/vod-details.component.html +++ b/apps/web/src/app/xtream/vod-details/vod-details.component.html @@ -73,6 +73,24 @@ {{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }} } + @if (isElectron) { + @if (isDownloaded) { + + } @else if (isDownloading) { + + } @else { + + } + } diff --git a/apps/web/src/app/xtream/vod-details/vod-details.component.ts b/apps/web/src/app/xtream/vod-details/vod-details.component.ts index c46ba1b2d..a04fc7d10 100644 --- a/apps/web/src/app/xtream/vod-details/vod-details.component.ts +++ b/apps/web/src/app/xtream/vod-details/vod-details.component.ts @@ -16,6 +16,7 @@ import { PlaylistsService } from 'services'; import { XtreamVodDetails } from 'shared-interfaces'; import { StalkerStore } from '../../stalker/stalker.store'; import { SafePipe } from '../../xtream-tauri/vod-details/safe.pipe'; +import { DownloadsService } from '../../services/downloads.service'; @Component({ selector: 'app-vod-details', @@ -42,13 +43,38 @@ export class VodDetailsComponent implements OnInit { private playlistService = inject(PlaylistsService); private route = inject(ActivatedRoute); private stalkerStore = inject(StalkerStore); + private readonly downloadsService = inject(DownloadsService); private portalId = this.route.snapshot.paramMap.get('id') ?? this.route.parent.snapshot.params.id; - /** Check if this is a Stalker item (has cmd property) */ + /** + * Check if this is a Stalker item + * Uses both cmd property check and stalkerStore playlist to be more reliable + */ private get isStalker(): boolean { - return !!(this.item as any)?.cmd; + const hasCmd = !!(this.item as any)?.cmd; + const hasStalkerPlaylist = !!( + this.stalkerStore.currentPlaylist()?.portalUrl && + this.stalkerStore.currentPlaylist()?.macAddress + ); + return hasCmd || hasStalkerPlaylist; + } + + /** + * Get the correct playlist ID based on mode + * Stalker uses _id, Xtream uses portalId from route + */ + private getPlaylistIdForDownloads(): string | null { + if (this.isStalker) { + return this.stalkerStore.currentPlaylist()?._id || null; + } + return this.portalId; + } + + /** Whether downloads are available (Electron only) */ + get isElectron(): boolean { + return this.downloadsService.isAvailable(); } isFavorite = false; @@ -124,4 +150,141 @@ export class VodDetailsComponent implements OnInit { this.location.back(); } } + + /** Check if VOD is already downloaded */ + get isDownloaded(): boolean { + const vodId = this.getVodId(); + const playlistId = this.getPlaylistIdForDownloads(); + if (!vodId || !playlistId) return false; + // Access downloads signal to make this reactive + this.downloadsService.downloads(); + return this.downloadsService.isDownloaded(vodId, playlistId, 'vod'); + } + + /** Check if VOD is currently downloading */ + get isDownloading(): boolean { + const vodId = this.getVodId(); + const playlistId = this.getPlaylistIdForDownloads(); + if (!vodId || !playlistId) return false; + // Access downloads signal to make this reactive + this.downloadsService.downloads(); + return this.downloadsService.isDownloading(vodId, playlistId, 'vod'); + } + + /** Get VOD ID from item */ + private getVodId(): number | null { + if (this.isStalker) { + return Number((this.item as any)?.id) || null; + } + return Number(this.item?.movie_data?.stream_id) || null; + } + + /** Get stream URL for download */ + private getStreamUrl(): string | null { + if (this.isStalker) { + // For Stalker, the stream URL is in the cmd property + return (this.item as any)?.cmd || null; + } + // For Xtream, construct the URL from playlist info + const playlist = this.stalkerStore.currentPlaylist(); + if (!playlist) return null; + + const serverUrl = playlist.serverUrl?.replace(/\/$/, '') || ''; + const username = playlist.username || ''; + const password = playlist.password || ''; + const streamId = this.item?.movie_data?.stream_id; + const extension = this.item?.movie_data?.container_extension || 'mp4'; + + if (!streamId) return null; + return `${serverUrl}/movie/${username}/${password}/${streamId}.${extension}`; + } + + /** Download VOD */ + async downloadVod() { + const vodId = this.getVodId(); + if (!vodId) return; + + const playlistId = this.getPlaylistIdForDownloads(); + if (!playlistId) { + console.error('[VodDetails] No playlist ID available'); + return; + } + + let url: string; + + if (this.isStalker) { + // For Stalker, resolve the actual stream URL via the API + const playlist = this.stalkerStore.currentPlaylist(); + const cmd = (this.item as any)?.cmd; + if (!cmd || !playlist?.portalUrl || !playlist?.macAddress) { + console.error('[VodDetails] Missing Stalker data: cmd, portalUrl, or macAddress'); + return; + } + + try { + url = await this.stalkerStore.fetchLinkToPlay( + playlist.portalUrl, + playlist.macAddress, + cmd + ); + if (!url) { + console.error('[VodDetails] Failed to resolve Stalker stream URL'); + return; + } + } catch (error) { + console.error('[VodDetails] Error resolving Stalker stream URL:', error); + return; + } + + await this.downloadsService.startDownload({ + playlistId, + xtreamId: vodId, + contentType: 'vod', + title: this.item?.info?.name || 'Unknown', + url, + posterUrl: this.item?.info?.movie_image, + headers: { + userAgent: playlist?.userAgent, + referer: playlist?.referrer, + origin: playlist?.origin, + }, + // Playlist info for auto-creation (Stalker playlists) + playlistName: playlist?.title || 'Stalker Portal', + playlistType: 'stalker', + portalUrl: playlist?.portalUrl, + macAddress: playlist?.macAddress, + }); + } else { + // For Xtream, construct the URL from playlist info + const streamUrl = this.getStreamUrl(); + if (!streamUrl) return; + url = streamUrl; + + await this.downloadsService.startDownload({ + playlistId, + xtreamId: vodId, + contentType: 'vod', + title: this.item?.info?.name || 'Unknown', + url, + posterUrl: this.item?.info?.movie_image, + }); + } + } + + /** Play from local file */ + async playFromLocal() { + const vodId = this.getVodId(); + const playlistId = this.getPlaylistIdForDownloads(); + if (!vodId || !playlistId) return; + + const filePath = this.downloadsService.getDownloadedFilePath( + vodId, + playlistId, + 'vod' + ); + + if (filePath) { + await this.downloadsService.playDownload(filePath); + } + } } diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 250b6817b..96b37273d 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -394,6 +394,30 @@ "REFRESH": "Aktualisieren", "UPDATE_AVAILABLE": "Update verfügbar", "INFORMATION": "Information", + "DOWNLOADS": { + "TITLE": "Downloads", + "DOWNLOAD": "Herunterladen", + "PLAY_LOCAL": "Lokal abspielen", + "EMPTY": "Noch keine Downloads", + "EMPTY_HINT": "Filme und Episoden zum Offline-Ansehen herunterladen", + "NOT_AVAILABLE": "Downloads nicht verfügbar", + "DESKTOP_ONLY": "Diese Funktion ist nur in der Desktop-App verfügbar", + "NO_FOLDER": "Kein Download-Ordner ausgewählt", + "CHANGE_FOLDER": "Ordner ändern", + "CLEAR_COMPLETED": "Abgeschlossene löschen", + "CANCEL": "Abbrechen", + "RETRY": "Wiederholen", + "REMOVE": "Entfernen", + "PLAY": "Abspielen", + "REVEAL": "Im Ordner anzeigen", + "STATUS": { + "QUEUED": "In Warteschlange", + "DOWNLOADING": "Wird heruntergeladen", + "COMPLETED": "Abgeschlossen", + "FAILED": "Fehlgeschlagen", + "CANCELED": "Abgebrochen" + } + }, "REMOTE_CONTROL": { "TITLE": "Fernbedienung", "UP_CHANNEL": "Sender aufwärts", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b6af4a854..6b7f1ac5d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -446,6 +446,30 @@ "NO_EPISODES": "No episodes available" }, "INFORMATION": "Information", + "DOWNLOADS": { + "TITLE": "Downloads", + "DOWNLOAD": "Download", + "PLAY_LOCAL": "Play Local", + "EMPTY": "No downloads yet", + "EMPTY_HINT": "Download movies and episodes to watch offline", + "NOT_AVAILABLE": "Downloads not available", + "DESKTOP_ONLY": "This feature is only available in the desktop app", + "NO_FOLDER": "No download folder selected", + "CHANGE_FOLDER": "Change Folder", + "CLEAR_COMPLETED": "Clear Completed", + "CANCEL": "Cancel", + "RETRY": "Retry", + "REMOVE": "Remove", + "PLAY": "Play", + "REVEAL": "Show in Folder", + "STATUS": { + "QUEUED": "Queued", + "DOWNLOADING": "Downloading", + "COMPLETED": "Completed", + "FAILED": "Failed", + "CANCELED": "Canceled" + } + }, "REMOTE_CONTROL": { "TITLE": "Remote control", "SUBTITLE": "Control your IPTV player remotely", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index cb9a194ac..c0e2ad7da 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -281,5 +281,30 @@ "BUY_ME_A_COFFEE_TOOLTIP": "Buy me a coffee" }, "REFRESH": "Actualizar", - "UPDATE_AVAILABLE": "Actualización disponible" + "UPDATE_AVAILABLE": "Actualización disponible", + "INFORMATION": "Información", + "DOWNLOADS": { + "TITLE": "Descargas", + "DOWNLOAD": "Descargar", + "PLAY_LOCAL": "Reproducir local", + "EMPTY": "Sin descargas aún", + "EMPTY_HINT": "Descarga películas y episodios para ver sin conexión", + "NOT_AVAILABLE": "Descargas no disponibles", + "DESKTOP_ONLY": "Esta función solo está disponible en la aplicación de escritorio", + "NO_FOLDER": "No se ha seleccionado carpeta de descarga", + "CHANGE_FOLDER": "Cambiar carpeta", + "CLEAR_COMPLETED": "Limpiar completados", + "CANCEL": "Cancelar", + "RETRY": "Reintentar", + "REMOVE": "Eliminar", + "PLAY": "Reproducir", + "REVEAL": "Mostrar en carpeta", + "STATUS": { + "QUEUED": "En cola", + "DOWNLOADING": "Descargando", + "COMPLETED": "Completado", + "FAILED": "Fallido", + "CANCELED": "Cancelado" + } + } } diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 96e743728..0ec656b2f 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -350,6 +350,30 @@ "SELECT_CATEGORY": "Sélectionner une catégorie" }, "INFORMATION": "Information", + "DOWNLOADS": { + "TITLE": "Téléchargements", + "DOWNLOAD": "Télécharger", + "PLAY_LOCAL": "Lire local", + "EMPTY": "Aucun téléchargement", + "EMPTY_HINT": "Téléchargez des films et épisodes pour les regarder hors ligne", + "NOT_AVAILABLE": "Téléchargements non disponibles", + "DESKTOP_ONLY": "Cette fonctionnalité n'est disponible que dans l'application de bureau", + "NO_FOLDER": "Aucun dossier de téléchargement sélectionné", + "CHANGE_FOLDER": "Changer de dossier", + "CLEAR_COMPLETED": "Effacer terminés", + "CANCEL": "Annuler", + "RETRY": "Réessayer", + "REMOVE": "Supprimer", + "PLAY": "Lire", + "REVEAL": "Afficher dans le dossier", + "STATUS": { + "QUEUED": "En attente", + "DOWNLOADING": "En cours", + "COMPLETED": "Terminé", + "FAILED": "Échec", + "CANCELED": "Annulé" + } + }, "REMOTE_CONTROL": { "TITLE": "Télécommande", "UP_CHANNEL": "Chaîne précédente", diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 6a36da2ce..aa5b62150 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -3,127 +3,327 @@ declare const nodeModule: NodeModule; interface NodeModule { id: string; } -interface Window { - process: any; - require: any; - electron: { - // Core - getAppVersion: () => Promise; - platform: string; - - // Listeners - onChannelChange: (callback: (data: { direction: 'up' | 'down' }) => void) => void; - onPlayerError: (callback: (data: { player: string; error: string; originalError: string }) => void) => void; - onEpgProgress: (callback: (data: { url: string; status: 'loading' | 'complete' | 'error'; stats?: { totalChannels: number; totalPrograms: number }; error?: string }) => void) => void; - onDbSaveContentProgress: (callback: (count: number) => void) => void; - removeDbSaveContentProgress: () => void; - onPlaybackPositionUpdate: (callback: (data: any) => void) => () => void; - // Files & System - saveFileDialog: (defaultPath: string, filters?: { name: string; extensions: string[] }[]) => Promise; - writeFile: (filePath: string, content: string) => Promise; - getLocalIpAddresses: () => Promise; - - // Playlist - fetchPlaylistByUrl: (url: string, title?: string) => Promise; - updatePlaylistFromFilePath: (filePath: string, title: string) => Promise; - openPlaylistFromFile: () => Promise; - autoUpdatePlaylists: (playlists: any[]) => Promise; - dbDeleteAllPlaylists: () => Promise; - - // Player - setUserAgent: (userAgent: string, referer?: string) => Promise; - openInMpv: (url: string, title: string, userAgent: string, referer?: string, origin?: string, contentInfo?: any, startTime?: number) => Promise; - openInVlc: (url: string, title: string, userAgent: string, referer?: string, origin?: string, contentInfo?: any, startTime?: number) => Promise; - setMpvPlayerPath: (mpvPlayerPath: string) => Promise; - setVlcPlayerPath: (vlcPlayerPath: string) => Promise; - - // EPG - fetchEpg: (urls: string[]) => Promise<{ success: boolean; message?: string; skipped?: string[] }>; - getChannelPrograms: (channelId: string) => Promise; - getEpgChannels: () => Promise; - getEpgChannelsByRange: (skip: number, limit: number) => Promise; - forceFetchEpg: (url: string) => Promise; - clearEpgData: () => Promise; - checkEpgFreshness: (urls: string[], maxAgeHours?: number) => Promise; - searchEpgPrograms: (searchTerm: string, limit?: number) => Promise; - - // Settings & Requests - updateSettings: (settings: any) => Promise; - getAiSettings: () => Promise; - stalkerRequest: (payload: any) => Promise; - xtreamRequest: (payload: any) => Promise; - - // Database - Playlists & Content - dbCreatePlaylist: (playlist: any) => Promise; - dbGetPlaylist: (playlistId: string) => Promise; - dbUpdatePlaylist: (playlistId: string, updates: any) => Promise; - dbDeletePlaylist: (playlistId: string) => Promise; - dbDeleteXtreamContent: (playlistId: string) => Promise; - dbRestoreXtreamUserData: (playlistId: string, favoritedXtreamIds: number[], recentlyViewedXtreamIds: any[]) => Promise; - - // Database - Categories & Content - dbHasCategories: (playlistId: string, type: string) => Promise; - dbGetCategories: (playlistId: string, type: string) => Promise; - dbSaveCategories: (playlistId: string, categories: any[], type: string, hiddenCategoryXtreamIds?: number[]) => Promise; - dbGetAllCategories: (playlistId: string, type: string) => Promise; - dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) => Promise; - dbHasContent: (playlistId: string, type: string) => Promise; - dbGetContent: (playlistId: string, type: string) => Promise; - dbSaveContent: (playlistId: string, streams: any[], type: string) => Promise; - dbSearchContent: (playlistId: string, searchTerm: string, types: string[]) => Promise; - dbGlobalSearch: (searchTerm: string, types: string[]) => Promise; - dbGetContentByXtreamId: (xtreamId: number, playlistId: string) => Promise; - - // Database - Favorites & Recent - dbGetRecentlyViewed: () => Promise; - dbClearRecentlyViewed: () => Promise; - dbAddFavorite: (contentId: number, playlistId: string) => Promise; - dbRemoveFavorite: (contentId: number, playlistId: string) => Promise; - dbIsFavorite: (contentId: number, playlistId: string) => Promise; - dbGetFavorites: (playlistId: string) => Promise; - dbGetRecentItems: (playlistId: string) => Promise; - dbAddRecentItem: (contentId: number, playlistId: string) => Promise; - dbClearPlaylistRecentItems: (playlistId: string) => Promise; - dbRemoveRecentItem: (contentId: number, playlistId: string) => Promise; - - // Database - Playback Positions - dbSavePlaybackPosition: (playlistId: string, data: any) => Promise; - dbGetPlaybackPosition: (playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode') => Promise; - dbGetSeriesPlaybackPositions: (playlistId: string, seriesXtreamId: number) => Promise; - dbGetRecentPlaybackPositions: (playlistId: string, limit?: number) => Promise; - dbGetAllPlaybackPositions: (playlistId: string) => Promise; - dbClearPlaybackPosition: (playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode') => Promise; - }; -} - -/* export interface ParsedPlaylist { - header: { - attrs: { - 'x-tvg-url': string; +declare global { + interface Window { + electron: { + getAppVersion: () => Promise; + platform: string; + fetchPlaylistByUrl: ( + url: string, + title?: string + ) => Promise; + updatePlaylistFromFilePath: ( + filePath: string, + title: string + ) => Promise; + openPlaylistFromFile: () => Promise; + saveFileDialog: ( + defaultPath: string, + filters?: { name: string; extensions: string[] }[] + ) => Promise; + writeFile: ( + filePath: string, + content: string + ) => Promise<{ success: boolean }>; + setUserAgent: (userAgent: string, referer?: string) => void; + openInMpv: ( + url: string, + title: string, + userAgent: string, + referer?: string, + origin?: string, + contentInfo?: any, + startTime?: number + ) => void; + openInVlc: ( + url: string, + title: string, + userAgent: string, + referer?: string, + origin?: string, + contentInfo?: any, + startTime?: number + ) => void; + autoUpdatePlaylists: (playlists: any[]) => Promise; + fetchEpg: ( + urls: string[] + ) => Promise<{ success: boolean; message?: string; skipped?: string[] }>; + getChannelPrograms: (channelId: string) => Promise; + getEpgChannels: () => Promise; + getEpgChannelsByRange: ( + skip: number, + limit: number + ) => Promise; + forceFetchEpg: ( + url: string + ) => Promise<{ success: boolean; message?: string }>; + clearEpgData: () => Promise<{ success: boolean }>; + checkEpgFreshness: ( + urls: string[], + maxAgeHours?: number + ) => Promise<{ staleUrls: string[]; freshUrls: string[] }>; + searchEpgPrograms: ( + searchTerm: string, + limit?: number + ) => Promise; + updateSettings: (settings: any) => Promise; + getAiSettings: () => Promise<{ + aiProvider: string; + aiModelName: string; + aiApiKey: string; + }>; + setMpvPlayerPath: (mpvPlayerPath: string) => Promise; + setVlcPlayerPath: (vlcPlayerPath: string) => Promise; + stalkerRequest: (payload: { + url: string; + macAddress: string; + params: Record; + token?: string; + }) => Promise; + xtreamRequest: (payload: { + url: string; + params: Record; + }) => Promise<{ payload: any; action: string }>; + // Database operations + dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>; + dbGetPlaylist: (playlistId: string) => Promise; + dbUpdatePlaylist: ( + playlistId: string, + updates: any + ) => Promise<{ success: boolean }>; + dbDeletePlaylist: ( + playlistId: string + ) => Promise<{ success: boolean }>; + dbDeleteXtreamContent: (playlistId: string) => Promise<{ + success: boolean; + favoritedXtreamIds: number[]; + recentlyViewedXtreamIds: { + xtreamId: number; + viewedAt: string; + }[]; + hiddenCategories: { + xtreamId: number; + type: string; + }[]; + }>; + dbRestoreXtreamUserData: ( + playlistId: string, + favoritedXtreamIds: number[], + recentlyViewedXtreamIds: { + xtreamId: number; + viewedAt: string; + }[] + ) => Promise<{ success: boolean }>; + dbHasCategories: ( + playlistId: string, + type: string + ) => Promise; + dbGetCategories: ( + playlistId: string, + type: string + ) => Promise; + dbSaveCategories: ( + playlistId: string, + categories: any[], + type: string, + hiddenCategoryXtreamIds?: number[] + ) => Promise<{ success: boolean }>; + dbGetAllCategories: ( + playlistId: string, + type: string + ) => Promise; + dbUpdateCategoryVisibility: ( + categoryIds: number[], + hidden: boolean + ) => Promise<{ success: boolean }>; + dbHasContent: ( + playlistId: string, + type: string + ) => Promise; + dbGetContent: (playlistId: string, type: string) => Promise; + dbSaveContent: ( + playlistId: string, + streams: any[], + type: string + ) => Promise<{ success: boolean; count: number }>; + dbSearchContent: ( + playlistId: string, + searchTerm: string, + types: string[] + ) => Promise; + dbGlobalSearch: ( + searchTerm: string, + types: string[] + ) => Promise; + dbGetRecentlyViewed: () => Promise; + dbClearRecentlyViewed: () => Promise<{ success: boolean }>; + // Favorites + dbAddFavorite: ( + contentId: number, + playlistId: string + ) => Promise<{ success: boolean }>; + dbRemoveFavorite: ( + contentId: number, + playlistId: string + ) => Promise<{ success: boolean }>; + dbIsFavorite: ( + contentId: number, + playlistId: string + ) => Promise; + dbGetFavorites: (playlistId: string) => Promise; + // Recently viewed (playlist-specific) + dbGetRecentItems: (playlistId: string) => Promise; + dbAddRecentItem: ( + contentId: number, + playlistId: string + ) => Promise<{ success: boolean }>; + dbClearPlaylistRecentItems: ( + playlistId: string + ) => Promise<{ success: boolean }>; + dbRemoveRecentItem: ( + contentId: number, + playlistId: string + ) => Promise<{ success: boolean }>; + dbGetContentByXtreamId: ( + xtreamId: number, + playlistId: string + ) => Promise; + // Remote control + onChannelChange?: ( + callback: (data: { direction: 'up' | 'down' }) => void + ) => void; + // Player error notifications + onPlayerError?: ( + callback: (data: { + player: string; + error: string; + originalError: string; + }) => void + ) => void; + getLocalIpAddresses: () => Promise; + // EPG progress listener + onEpgProgress?: ( + callback: (data: { + url: string; + status: 'loading' | 'complete' | 'error'; + stats?: { totalChannels: number; totalPrograms: number }; + error?: string; + }) => void + ) => void; + // DB save content progress listener + onDbSaveContentProgress: (callback: (count: number) => void) => void; + removeDbSaveContentProgress: () => void; + dbDeleteAllPlaylists: () => Promise<{ success: boolean }>; + // Playback positions + dbSavePlaybackPosition: ( + playlistId: string, + data: { + contentXtreamId: number; + contentType: 'vod' | 'episode'; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + position: number; + duration: number; + title: string; + posterUrl?: string; + } + ) => Promise<{ success: boolean }>; + dbGetPlaybackPosition: ( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ) => Promise; + dbGetSeriesPlaybackPositions: ( + playlistId: string, + seriesXtreamId: number + ) => Promise; + dbGetRecentPlaybackPositions: ( + playlistId: string, + limit?: number + ) => Promise; + dbGetAllPlaybackPositions: ( + playlistId: string + ) => Promise; + dbClearPlaybackPosition: ( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ) => Promise<{ success: boolean }>; + onPlaybackPositionUpdate: (callback: (data: any) => void) => () => void; + // Downloads + downloadsStart: (data: { + playlistId: string; + xtreamId: number; + contentType: 'vod' | 'episode'; + title: string; + url: string; + posterUrl?: string; + downloadFolder: string; + headers?: { + userAgent?: string; + referer?: string; + origin?: string; + }; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + // Playlist info for auto-creation if needed + playlistName?: string; + playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; + serverUrl?: string; + portalUrl?: string; + macAddress?: string; + }) => Promise<{ success: boolean; id?: number; error?: string }>; + downloadsCancel: ( + downloadId: number + ) => Promise<{ success: boolean; error?: string }>; + downloadsRetry: ( + downloadId: number, + downloadFolder: string + ) => Promise<{ success: boolean; error?: string }>; + downloadsRemove: ( + downloadId: number + ) => Promise<{ success: boolean; error?: string }>; + downloadsGetList: (playlistId?: string) => Promise; + downloadsGet: (downloadId: number) => Promise; + downloadsGetDefaultFolder: () => Promise; + downloadsSelectFolder: () => Promise; + downloadsRevealFile: ( + filePath: string + ) => Promise<{ success: boolean; error?: string }>; + downloadsPlayFile: ( + filePath: string + ) => Promise<{ success: boolean; error?: string }>; + downloadsClearCompleted: ( + playlistId?: string + ) => Promise<{ success: boolean }>; + onDownloadsUpdate: (callback: () => void) => () => void; }; - raw: string; - }; - items: ParsedPlaylistItem[]; -} + process: NodeJS.Process; + require: NodeRequire; + } -export interface ParsedPlaylistItem { - name: string; - tvg: { - id: string; - name: string; - url: string; - logo: string; - rec: string; - }; - group: { + /** Download item from the database */ + interface DownloadItem { + id: number; + playlistId: string; + xtreamId: number; + contentType: 'vod' | 'episode'; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; title: string; - }; - http: { - referrer: string; - 'user-agent': string; - }; - url: string; - raw: string; + url: string; + fileName?: string; + filePath?: string; + posterUrl?: string; + status: 'queued' | 'downloading' | 'completed' | 'failed' | 'canceled'; + bytesDownloaded?: number; + totalBytes?: number; + errorMessage?: string; + createdAt?: string; + updatedAt?: string; + } } - */ diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md new file mode 100644 index 000000000..a793ef483 --- /dev/null +++ b/docs/architecture/download-manager.md @@ -0,0 +1,41 @@ +# Download Manager Architecture + +The download manager is a desktop-only feature that layers a curated queue, progress tracking, storage configuration, and playback controls on top of the existing Xtream (xtream-tauri folder) + Stalker viewers. Backend work is handled in the Electron process while the Angular renderer surface exposes a dedicated `/downloads` route, contextual buttons, and theme-aware styling. + +## Backend responsibilities + +- **Queue control (apps/electron-backend/src/app/events/downloads.events.ts)** + `DownloadTask` mirrors the shared `DownloadItem` table plus transient cancel/progress helpers. `enqueueDownload()` resolves a unique file path, persists a `queued` row in `downloads`, pushes the task onto `downloadQueue`, and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. +- **electron-dl integration** + `startDownload()` now calls `electron-dl`’s `download()` helper. Headers (user agent, referer, origin) are attached, and the `onStarted`, `onProgress`, `onCompleted`, and `onCancel` callbacks translate the helper’s payload into Drizzle updates. The handler throttles progress broadcast, saves `filePath`/`fileName` from `electron-dl`, and marks failures/cancellations cleanly. Errors and cancellations delete partial files. +- **IPC surface** + The backend exposes `DOWNLOADS_*` handlers for list retrieval, start/cancel/retry/remove operations, folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the renderer listens to in order to refresh its signal store. + +## Renderer architecture + +- **Downloads service** (`apps/web/src/app/services/downloads.service.ts`) + Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download the service resolves a download folder (stored in `SettingsStore` or fetched via `downloadsGetDefaultFolder`) and calls `downloadsStart`. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path. +- **Downloads view** (`apps/web/src/app/xtream-tauri/downloads`) + A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` now wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives a bold two-tone aesthetic inspired by the frontend-design mandate—gradient cards, floating avatars, and theme-aware variables triggered via `body.dark-theme`. + Failed/canceled cards now show retry/delete controls, queued/downloading cards show a cancel icon, and completed cards render inline play/open buttons with `mat-icon` cues. The header also shows the resolved download folder and a `CHANGE FOLDER` action. +- **Theme fixes** + To keep typography legible in both modes, `app-search-result-item` now inherits color from `:host-context(body.dark-theme)` and `:host-context(body:not(.dark-theme))`, ensuring dense light-theme grids no longer show white text on white backgrounds. + +## Global API surface + +- **Preload + types** + `apps/electron-backend/src/app/api/main.preload.ts` wires every download IPC command plus the `onDownloadsUpdate` listener to `window.electron`. `global.d.ts` now mirrors those methods, adds playback-position helpers, and exposes `onPlaybackPositionUpdate` / `removePlaybackPositionListener` so Angular can type-check the new APIs. This keeps the renderer typing in sync with the backend implementation. + +## Routing and navigation + +- `/downloads` is available under both portal flavors: the Xtream routes already load `DownloadsComponent`, and the Stalker routes now import the same component so the sidebar link can target `/stalker/:id/downloads` without returning to the startup screen. +- The navigation component already points `routerLink="./downloads"` inside the shared nav pane, so both portals reuse the same download page. + +## Queuing, persistence, and UX notes + +- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, and Xtream identifiers. Stale downloads reset to `failed` on startup. +- Queue cancellation removes the task or calls `downloadItem.cancel()` if the item is active; retries reuse the same database entry, preventing duplicate rows. +- Folder selection first checks stored preferences, falls back to the OS default downloads path, and finally prompts the user to pick a folder. The downloads service persists the chosen path via `SettingsStore`. +- The new UI leverages CSS variables for theme-specific backgrounds/borders, ensures `.downloads__list` can scroll inside its panel, and brings consistent badge/typography treatments to each card. + +Keeping the backend queue, IPC handlers, shared schema, and renderer signals synchronized minimizes drift between platform rules and the UI. Future work might cover download list filters, cancel-all actions, or integration with upcoming playback analytics. diff --git a/global.d.ts b/global.d.ts index 793ef6ce8..69fbef54f 100644 --- a/global.d.ts +++ b/global.d.ts @@ -35,14 +35,18 @@ declare global { title: string, userAgent: string, referer?: string, - origin?: string + origin?: string, + contentInfo?: any, + startTime?: number ) => void; openInVlc: ( url: string, title: string, userAgent: string, referer?: string, - origin?: string + origin?: string, + contentInfo?: any, + startTime?: number ) => void; autoUpdatePlaylists: (playlists: Playlist[]) => Promise; fetchEpg: ( @@ -214,10 +218,107 @@ declare global { onDbSaveContentProgress: (callback: (count: number) => void) => void; removeDbSaveContentProgress: () => void; dbDeleteAllPlaylists: () => Promise<{ success: boolean }>; + // Playback positions + dbSavePlaybackPosition: ( + playlistId: string, + data: any + ) => Promise<{ success: boolean }>; + dbGetPlaybackPosition: ( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ) => Promise; + dbGetSeriesPlaybackPositions: ( + playlistId: string, + seriesXtreamId: number + ) => Promise; + dbGetRecentPlaybackPositions: ( + playlistId: string, + limit?: number + ) => Promise; + dbGetAllPlaybackPositions: ( + playlistId: string + ) => Promise; + dbClearPlaybackPosition: ( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ) => Promise<{ success: boolean }>; + onPlaybackPositionUpdate: (callback: (data: any) => void) => () => void; + // Downloads + downloadsStart: (data: { + playlistId: string; + xtreamId: number; + contentType: 'vod' | 'episode'; + title: string; + url: string; + posterUrl?: string; + downloadFolder: string; + headers?: { + userAgent?: string; + referer?: string; + origin?: string; + }; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + // Playlist info for auto-creation if needed + playlistName?: string; + playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; + serverUrl?: string; + portalUrl?: string; + macAddress?: string; + }) => Promise<{ success: boolean; id?: number; error?: string }>; + downloadsCancel: ( + downloadId: number + ) => Promise<{ success: boolean; error?: string }>; + downloadsRetry: ( + downloadId: number, + downloadFolder: string + ) => Promise<{ success: boolean; error?: string }>; + downloadsRemove: ( + downloadId: number + ) => Promise<{ success: boolean; error?: string }>; + downloadsGetList: (playlistId?: string) => Promise; + downloadsGet: (downloadId: number) => Promise; + downloadsGetDefaultFolder: () => Promise; + downloadsSelectFolder: () => Promise; + downloadsRevealFile: ( + filePath: string + ) => Promise<{ success: boolean; error?: string }>; + downloadsPlayFile: ( + filePath: string + ) => Promise<{ success: boolean; error?: string }>; + downloadsClearCompleted: ( + playlistId?: string + ) => Promise<{ success: boolean }>; + onDownloadsUpdate: (callback: () => void) => () => void; }; process: NodeJS.Process; require: NodeRequire; } + + /** Download item from the database */ + 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: 'queued' | 'downloading' | 'completed' | 'failed' | 'canceled'; + bytesDownloaded?: number; + totalBytes?: number; + errorMessage?: string; + createdAt?: string; + updatedAt?: string; + } } // SystemJS module definition diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index d72d3faf5..86c1f16e0 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -175,6 +175,31 @@ const CREATE_TABLE_STATEMENTS = [ `CREATE INDEX IF NOT EXISTS playback_positions_playlist_idx ON playback_positions(playlist_id)`, `CREATE INDEX IF NOT EXISTS playback_positions_series_idx ON playback_positions(series_xtream_id)`, `CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at)`, + // Downloads table + `CREATE TABLE IF NOT EXISTS downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id TEXT NOT NULL, + xtream_id INTEGER NOT NULL, + content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')), + series_xtream_id INTEGER, + season_number INTEGER, + episode_number INTEGER, + title TEXT NOT NULL, + url TEXT NOT NULL, + file_name TEXT, + file_path TEXT, + poster_url TEXT, + status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')), + bytes_downloaded INTEGER DEFAULT 0, + total_bytes INTEGER, + error_message TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`, + `CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`, + `CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`, ]; /** diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 90aea53e3..3581226f4 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -235,3 +235,51 @@ export type NewEpgProgramDb = typeof epgPrograms.$inferInsert; export type PlaybackPosition = typeof playbackPositions.$inferSelect; export type NewPlaybackPosition = typeof playbackPositions.$inferInsert; + +// Downloads table +export const downloads = sqliteTable( + 'downloads', + { + id: integer('id').primaryKey({ autoIncrement: true }), + playlistId: text('playlist_id') + .notNull() + .references(() => playlists.id, { onDelete: 'cascade' }), + // Content identifiers + xtreamId: integer('xtream_id').notNull(), + contentType: text('content_type', { + enum: ['vod', 'episode'], + }).notNull(), + // For episodes: store series info + seriesXtreamId: integer('series_xtream_id'), + seasonNumber: integer('season_number'), + episodeNumber: integer('episode_number'), + // Download metadata + title: text('title').notNull(), + url: text('url').notNull(), + fileName: text('file_name'), + filePath: text('file_path'), + posterUrl: text('poster_url'), + // Download progress + status: text('status', { + enum: ['queued', 'downloading', 'completed', 'failed', 'canceled'], + }) + .notNull() + .default('queued'), + bytesDownloaded: integer('bytes_downloaded').default(0), + totalBytes: integer('total_bytes'), + errorMessage: text('error_message'), + // Timestamps + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + playlistIdx: index('downloads_playlist_idx').on(table.playlistId), + statusIdx: index('downloads_status_idx').on(table.status), + xtreamPlaylistUnique: uniqueIndex( + 'downloads_xtream_playlist_unique' + ).on(table.xtreamId, table.playlistId, table.contentType), + }) +); + +export type Download = typeof downloads.$inferSelect; +export type NewDownload = typeof downloads.$inferInsert; diff --git a/libs/shared/interfaces/src/lib/playlist-meta.type.ts b/libs/shared/interfaces/src/lib/playlist-meta.type.ts index 455dbe9b6..9158b28d3 100644 --- a/libs/shared/interfaces/src/lib/playlist-meta.type.ts +++ b/libs/shared/interfaces/src/lib/playlist-meta.type.ts @@ -9,6 +9,8 @@ export type PlaylistMeta = Pick< | 'url' | 'importDate' | 'userAgent' + | 'referrer' + | 'origin' | 'filePath' | 'updateDate' | 'updateState' diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 2425db3a5..ff4d99175 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -28,4 +28,6 @@ export interface Settings { vlcPlayerPath: string; remoteControl: boolean; remoteControlPort: number; + /** Custom download folder path (uses system Downloads folder if not set) */ + downloadFolder?: string; } diff --git a/nx.json b/nx.json index 799cb8f8d..fcd876627 100644 --- a/nx.json +++ b/nx.json @@ -20,7 +20,6 @@ "{workspaceRoot}/.github/workflows/ci.yml" ] }, - "nxCloudId": "68be99a8c4fb535f60d0d8e8", "targetDefaults": { "@angular/build:application": { "cache": true, diff --git a/package-lock.json b/package-lock.json index e7b87f0b2..feb1047e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "drizzle-orm": "0.44.6", "electron-builder-sandbox-fix": "1.1.1", "electron-conf": "1.3.0", + "electron-dl": "4.0.0", "fix-path": "5.0.0", "hls.js": "1.6.13", "iptv-playlist-parser": "github:4gray/iptv-playlist-parser", @@ -18635,6 +18636,23 @@ "electron": ">=15.0.0" } }, + "node_modules/electron-dl": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/electron-dl/-/electron-dl-4.0.0.tgz", + "integrity": "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong==", + "license": "MIT", + "dependencies": { + "ext-name": "^5.0.0", + "pupa": "^3.1.0", + "unused-filename": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/electron-playwright-helpers": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/electron-playwright-helpers/-/electron-playwright-helpers-1.8.2.tgz", @@ -19259,6 +19277,18 @@ "node": ">=6" } }, + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -19926,6 +19956,31 @@ "express": ">= 4.11" } }, + "node_modules/ext-list": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", + "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.28.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ext-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", + "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", + "license": "MIT", + "dependencies": { + "ext-list": "^2.0.0", + "sort-keys-length": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -25451,7 +25506,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -33033,6 +33087,21 @@ "node": ">=6" } }, + "node_modules/pupa": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", + "license": "MIT", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -35762,6 +35831,39 @@ "node": ">= 6.0.0" } }, + "node_modules/sort-keys": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", + "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-keys-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", + "integrity": "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==", + "license": "MIT", + "dependencies": { + "sort-keys": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-keys/node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sorted-array-functions": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", @@ -38138,6 +38240,43 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/unused-filename": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/unused-filename/-/unused-filename-4.0.1.tgz", + "integrity": "sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unused-filename/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unused-filename/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/upath": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", diff --git a/package.json b/package.json index 1db91ec6d..edf73638b 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "drizzle-orm": "0.44.6", "electron-builder-sandbox-fix": "1.1.1", "electron-conf": "1.3.0", + "electron-dl": "4.0.0", "fix-path": "5.0.0", "hls.js": "1.6.13", "iptv-playlist-parser": "github:4gray/iptv-playlist-parser", @@ -161,4 +162,4 @@ "typescript": "5.9.3", "typescript-eslint": "^8.46.2" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 983f66672..1c51cb429 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: electron-conf: specifier: 1.3.0 version: 1.3.0(electron@39.2.7) + electron-dl: + specifier: 4.0.0 + version: 4.0.0 fix-path: specifier: 5.0.0 version: 5.0.0 @@ -5499,6 +5502,10 @@ packages: peerDependencies: electron: '>=15.0.0' + electron-dl@4.0.0: + resolution: {integrity: sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong==} + engines: {node: '>=18'} + electron-playwright-helpers@1.8.2: resolution: {integrity: sha512-sM9fDSEFDOptKDa8oiNphiXdR1KR1uFe8yHnlrnvyQE1ScnHizca/SoYvUdtn+5IuVNTkT8MesKWJ6seS4/zuA==} @@ -5655,6 +5662,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-goat@4.0.0: + resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} + engines: {node: '>=12'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -5850,6 +5861,14 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + ext-list@2.2.2: + resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} + engines: {node: '>=0.10.0'} + + ext-name@5.0.0: + resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==} + engines: {node: '>=4'} + extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} @@ -6715,6 +6734,10 @@ packages: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} engines: {node: '>=8'} + is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + is-plain-obj@3.0.0: resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} engines: {node: '>=10'} @@ -8617,6 +8640,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pupa@3.3.0: + resolution: {integrity: sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==} + engines: {node: '>=12.20'} + pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} @@ -9255,6 +9282,14 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sort-keys-length@1.0.1: + resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==} + engines: {node: '>=0.10.0'} + + sort-keys@1.1.2: + resolution: {integrity: sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==} + engines: {node: '>=0.10.0'} + sorted-array-functions@1.3.0: resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==} @@ -9882,6 +9917,10 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + unused-filename@4.0.1: + resolution: {integrity: sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + upath@2.0.1: resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==} engines: {node: '>=4'} @@ -16341,6 +16380,12 @@ snapshots: ajv: 8.17.1 electron: 39.2.7 + electron-dl@4.0.0: + dependencies: + ext-name: 5.0.0 + pupa: 3.3.0 + unused-filename: 4.0.1 + electron-playwright-helpers@1.8.2: dependencies: '@electron/asar': 3.4.1 @@ -16657,6 +16702,8 @@ snapshots: escalade@3.2.0: {} + escape-goat@4.0.0: {} + escape-html@1.0.3: {} escape-string-regexp@1.0.5: {} @@ -16951,6 +16998,15 @@ snapshots: transitivePeerDependencies: - supports-color + ext-list@2.2.2: + dependencies: + mime-db: 1.54.0 + + ext-name@5.0.0: + dependencies: + ext-list: 2.2.2 + sort-keys-length: 1.0.1 + extract-zip@2.0.1: dependencies: debug: 4.4.3 @@ -17896,6 +17952,8 @@ snapshots: is-obj@2.0.0: {} + is-plain-obj@1.1.0: {} + is-plain-obj@3.0.0: {} is-plain-obj@4.1.0: {} @@ -20036,6 +20094,10 @@ snapshots: punycode@2.3.1: {} + pupa@3.3.0: + dependencies: + escape-goat: 4.0.0 + pure-rand@7.0.1: {} qrcode@1.5.4: @@ -20798,6 +20860,14 @@ snapshots: ip-address: 10.1.0 smart-buffer: 4.2.0 + sort-keys-length@1.0.1: + dependencies: + sort-keys: 1.1.2 + + sort-keys@1.1.2: + dependencies: + is-plain-obj: 1.1.0 + sorted-array-functions@1.3.0: {} source-map-js@1.2.1: {} @@ -21482,6 +21552,11 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + unused-filename@4.0.1: + dependencies: + escape-string-regexp: 5.0.0 + path-exists: 5.0.0 + upath@2.0.1: {} update-browserslist-db@1.2.3(browserslist@4.28.1):