Merge pull request #743 from 4gray/feat/download-manager-ui

feat: add download manager feature
This commit is contained in:
4gray authored and GitHub committed 2026-01-27 13:25:49 +01:00
commit 25e8c78df1
41 files changed
+3373 -180

No files matched your search

@@ -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);
},
});
@@ -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';
@@ -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';
@@ -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<string, string>;
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<typeof download>[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);
}
}
+10
View File
@@ -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();
@@ -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<DownloadItem[]>([]);
/** Whether the download feature is available (Electron only) */
readonly isAvailable = computed(() => !!window.electron?.downloadsGetList);
/** Whether there are any downloads */
readonly hasDownloads = computed(() => this.downloads().length > 0);
/** Active downloads count */
readonly activeCount = computed(
() =>
this.downloads().filter(
(d) => d.status === 'queued' || d.status === 'downloading'
).length
);
/** Completed downloads count */
readonly completedCount = computed(
() => this.downloads().filter((d) => d.status === 'completed').length
);
/** Failed downloads count */
readonly failedCount = computed(
() =>
this.downloads().filter(
(d) => d.status === 'failed' || d.status === 'canceled'
).length
);
/** Current download folder */
readonly downloadFolder = signal<string>('');
constructor() {
this.init();
}
private async init() {
if (!this.isAvailable()) {
return;
}
// Load initial download list
await this.loadDownloads();
// Load download folder
await this.loadDownloadFolder();
// Subscribe to download updates
this.unsubscribe = window.electron.onDownloadsUpdate(() => {
this.loadDownloads();
});
}
ngOnDestroy() {
this.unsubscribe?.();
}
/**
* Load downloads from the backend
*/
async loadDownloads(playlistId?: string): Promise<void> {
if (!this.isAvailable()) return;
try {
const list = await window.electron.downloadsGetList(playlistId);
this.downloads.set(list);
} catch (error) {
console.error('[DownloadsService] Error loading downloads:', error);
}
}
/**
* Load or resolve download folder
*/
async loadDownloadFolder(): Promise<string> {
if (!this.isAvailable()) return '';
// First check settings
const storedFolder = this.settingsStore.getDownloadFolder?.();
if (storedFolder) {
this.downloadFolder.set(storedFolder);
return storedFolder;
}
// Fall back to default
try {
const defaultFolder =
await window.electron.downloadsGetDefaultFolder();
this.downloadFolder.set(defaultFolder);
return defaultFolder;
} catch (error) {
console.error(
'[DownloadsService] Error getting default folder:',
error
);
return '';
}
}
/**
* Start a new download
*/
async startDownload(data: {
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
title: string;
url: string;
posterUrl?: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
// Playlist info for auto-creation if needed (Stalker playlists)
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
}): Promise<{ success: boolean; id?: number; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
const folder = await this.loadDownloadFolder();
if (!folder) {
return { success: false, error: 'No download folder configured' };
}
try {
const result = await window.electron.downloadsStart({
...data,
downloadFolder: folder,
});
return result;
} catch (error) {
console.error('[DownloadsService] Error starting download:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Cancel a download
*/
async cancelDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsCancel(downloadId);
} catch (error) {
console.error(
'[DownloadsService] Error canceling download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Retry a failed download
*/
async retryDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
const folder = await this.loadDownloadFolder();
if (!folder) {
return { success: false, error: 'No download folder configured' };
}
try {
return await window.electron.downloadsRetry(downloadId, folder);
} catch (error) {
console.error(
'[DownloadsService] Error retrying download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Remove a download from the list
*/
async removeDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsRemove(downloadId);
} catch (error) {
console.error(
'[DownloadsService] Error removing download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Play a downloaded file
*/
async playDownload(
filePath: string
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsPlayFile(filePath);
} catch (error) {
console.error('[DownloadsService] Error playing file:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Reveal file in system file manager
*/
async revealFile(
filePath: string
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsRevealFile(filePath);
} catch (error) {
console.error('[DownloadsService] Error revealing file:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Select a new download folder
*/
async selectFolder(): Promise<string | null> {
if (!this.isAvailable()) {
return null;
}
try {
const folder = await window.electron.downloadsSelectFolder();
if (folder) {
this.downloadFolder.set(folder);
// Save to settings
await this.settingsStore.updateSettings({ downloadFolder: folder });
}
return folder;
} catch (error) {
console.error(
'[DownloadsService] Error selecting folder:',
error
);
return null;
}
}
/**
* Clear completed/failed downloads
*/
async clearCompleted(
playlistId?: string
): Promise<{ success: boolean }> {
if (!this.isAvailable()) {
return { success: false };
}
try {
return await window.electron.downloadsClearCompleted(playlistId);
} catch (error) {
console.error(
'[DownloadsService] Error clearing completed:',
error
);
return { success: false };
}
}
/**
* Get download progress as percentage
*/
getProgressPercent(item: DownloadItem): number {
if (!item.totalBytes || item.totalBytes === 0) {
return 0;
}
return Math.round(
((item.bytesDownloaded || 0) / item.totalBytes) * 100
);
}
/**
* Get download item by xtreamId and playlistId
*/
getDownloadByContent(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): DownloadItem | undefined {
return this.downloads().find(
(d) =>
d.xtreamId === xtreamId &&
d.playlistId === playlistId &&
d.contentType === contentType
);
}
/**
* Check if content is downloaded (completed status)
*/
isDownloaded(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'completed' && !!download.filePath;
}
/**
* Check if content is currently downloading or queued
*/
isDownloading(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'downloading' || download?.status === 'queued';
}
/**
* Get the file path for a downloaded content item
*/
getDownloadedFilePath(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): string | undefined {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
if (download?.status === 'completed') {
return download.filePath;
}
return undefined;
}
/**
* Format bytes to human readable string
*/
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
}
@@ -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();
},
@@ -17,50 +17,41 @@
}
</div>
@if (isItvCategorySelected) {
<mat-nav-list style="overflow: auto; flex: 1">
@for (item of itvChannels(); track $index) {
<mat-list-item (click)="createLinkToPlayItv(item)">
<img
matListItemAvatar
class="stream-icon"
[src]="item.logo"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt="logo"
/>
{{ item.name }}
<button
mat-icon-button
matListItemMeta
(click)="toggleFavorite(item); $event.stopPropagation()"
>
<mat-icon>{{
favorites.get(item.id) ? 'star' : 'star_outline'
}}</mat-icon>
</button>
</mat-list-item>
}
</mat-nav-list>
<div #itvScrollContainer style="overflow: auto; flex: 1">
<mat-nav-list>
@for (item of itvChannels(); track $index) {
<mat-list-item (click)="createLinkToPlayItv(item)">
<img
matListItemAvatar
class="stream-icon"
[src]="item.logo"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt="logo"
/>
{{ item.name }}
<button
mat-icon-button
matListItemMeta
(click)="toggleFavorite(item); $event.stopPropagation()"
>
<mat-icon>{{
favorites.get(item.id) ? 'star' : 'star_outline'
}}</mat-icon>
</button>
</mat-list-item>
}
</mat-nav-list>
@if (hasMoreItems() && !isContentLoading()) {
<mat-divider />
<div>
<button
mat-button
(click)="loadMore()"
[disabled]="isLoadingMore()"
style="width: 100%"
>
@if (isLoadingMore()) {
Loading page {{ pageIndex() + 1 }}...
} @else {
Load More (Page {{ pageIndex() + 1 }})
}
</button>
</div>
}
<!-- Loading indicator for infinite scroll -->
@if (isLoadingMore()) {
<div style="display: flex; justify-content: center; padding: 16px">
<mat-spinner diameter="24" />
</div>
}
</div>
} @else {
@if (isCategoryLoading()) {
<ngx-skeleton-loader
@@ -1,8 +1,18 @@
import { Component, effect, inject, signal } from '@angular/core';
import {
Component,
effect,
inject,
signal,
viewChild,
ElementRef,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
@@ -28,6 +38,7 @@ import { StalkerStore } from './stalker.store';
MatIconButton,
MatListModule,
MatPaginatorModule,
MatProgressSpinnerModule,
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
PlaylistSwitcherComponent,
@@ -35,7 +46,7 @@ import { StalkerStore } from './stalker.store';
TranslatePipe,
],
})
export class StalkerMainContainerComponent {
export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
readonly stalkerStore = inject(StalkerStore);
currentLayout:
@@ -54,6 +65,10 @@ export class StalkerMainContainerComponent {
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
readonly itvChannels = this.stalkerStore.itvChannels;
/** Scrollable container for ITV channels */
readonly itvScrollContainer = viewChild<ElementRef>('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() {
@@ -63,6 +63,7 @@
<app-season-container
[seasons]="mappedSeasons()"
[seriesId]="serial.id"
[seriesTitle]="serial.info?.name"
[isLoading]="
isVodSeriesSeasonsLoading() ||
(seasonContainer?.selectedSeason &&
@@ -87,6 +87,13 @@ export const stalkerRoutes: Route[] = [
(c) => c.StalkerSearchComponent
),
},
{
path: 'downloads',
loadComponent: () =>
import(
'../xtream-tauri/downloads/downloads.component'
).then((c) => c.DownloadsComponent),
},
],
},
];
+36 -6
View File
@@ -115,7 +115,8 @@
}
}
.favorite-btn {
.favorite-btn,
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
@@ -133,6 +134,14 @@
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
@@ -140,11 +149,31 @@
background: rgba(229, 9, 20, 0.6);
}
}
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
.download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
}
@@ -307,7 +336,8 @@
}
.play-btn,
.favorite-btn {
.favorite-btn,
.download-btn {
flex: 1;
justify-content: center;
padding: 10px 16px;
@@ -0,0 +1,214 @@
<div class="downloads">
<header class="downloads__header">
<div class="downloads__header-left">
<button mat-icon-button (click)="goBack()" class="back-btn">
<mat-icon>arrow_back</mat-icon>
</button>
<h1>{{ 'DOWNLOADS.TITLE' | translate }}</h1>
@if (activeCount() > 0) {
<span class="downloads__badge">{{ activeCount() }}</span>
}
</div>
<div class="downloads__header-actions">
@if (hasDownloads()) {
<button
mat-stroked-button
(click)="clearCompleted()"
[matTooltip]="'DOWNLOADS.CLEAR_COMPLETED' | translate"
>
<mat-icon>delete_sweep</mat-icon>
{{ 'DOWNLOADS.CLEAR_COMPLETED' | translate }}
</button>
}
</div>
</header>
<div class="downloads__folder">
<mat-icon>folder</mat-icon>
<span class="downloads__folder-path">{{ downloadFolder() || ('DOWNLOADS.NO_FOLDER' | translate) }}</span>
<button mat-stroked-button (click)="changeFolder()">
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
</button>
</div>
@if (!isAvailable()) {
<div class="downloads__empty">
<mat-icon>desktop_access_disabled</mat-icon>
<p>{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}</p>
<span>{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}</span>
</div>
} @else if (!hasDownloads()) {
<div class="downloads__empty">
<mat-icon>download</mat-icon>
<p>{{ 'DOWNLOADS.EMPTY' | translate }}</p>
<span>{{ 'DOWNLOADS.EMPTY_HINT' | translate }}</span>
</div>
} @else {
<div class="downloads__list-wrapper">
<div class="downloads__list">
@for (item of filteredDownloads(); track item.id) {
<div
class="downloads__item"
[class]="getStatusColor(item.status)"
>
<div class="downloads__item-poster">
@if (item.posterUrl) {
<img
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<mat-icon>movie</mat-icon>
}
</div>
<div class="downloads__item-info">
<div class="downloads__item-title">
{{ item.title }}
@if (item.contentType === 'episode') {
<span class="downloads__episode-badge">
{{ formatEpisodeLabel(item) }}
</span>
}
</div>
<div class="downloads__item-meta">
<span
class="downloads__item-status"
[class]="getStatusColor(item.status)"
>
<mat-icon>{{
getStatusIcon(item.status)
}}</mat-icon>
{{ 'DOWNLOADS.STATUS.' + item.status.toUpperCase() | translate }}
</span>
@if (
item.status === 'downloading' ||
item.status === 'completed'
) {
<span class="downloads__item-size">
{{ formatBytes(item.bytesDownloaded) }}
@if (item.totalBytes) {
/ {{ formatBytes(item.totalBytes) }}
}
</span>
}
@if (item.errorMessage) {
<span class="downloads__item-error">
{{ item.errorMessage }}
</span>
}
</div>
@if (item.status === 'downloading') {
<mat-progress-bar
mode="determinate"
[value]="getProgress(item)"
></mat-progress-bar>
}
</div>
<div class="downloads__item-actions">
@switch (item.status) {
@case ('queued') {
<button
mat-icon-button
(click)="cancel(item)"
[matTooltip]="
'DOWNLOADS.CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@case ('downloading') {
<button
mat-icon-button
(click)="cancel(item)"
[matTooltip]="
'DOWNLOADS.CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@case ('completed') {
<button
mat-icon-button
(click)="play(item)"
[matTooltip]="
'DOWNLOADS.PLAY' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
<button
mat-icon-button
(click)="reveal(item)"
[matTooltip]="
'DOWNLOADS.REVEAL' | translate
"
>
<mat-icon>folder_open</mat-icon>
</button>
<button
mat-icon-button
(click)="remove(item)"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
@case ('failed') {
<button
mat-icon-button
(click)="retry(item)"
[matTooltip]="
'DOWNLOADS.RETRY' | translate
"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
(click)="remove(item)"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
@case ('canceled') {
<button
mat-icon-button
(click)="retry(item)"
[matTooltip]="
'DOWNLOADS.RETRY' | translate
"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
(click)="remove(item)"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
}
</div>
</div>
}
</div>
</div>
}
</div>
@@ -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);
}
}
}
}
@@ -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}`;
}
}
@@ -103,6 +103,17 @@
>
<mat-icon matListItemIcon>favorite</mat-icon>
</a>
@if (isElectron) {
<a
mat-list-item
routerLink="./downloads"
routerLinkActive="active"
[matTooltip]="'DOWNLOADS.TITLE' | translate"
matTooltipPosition="right"
>
<mat-icon matListItemIcon>download</mat-icon>
</a>
}
</mat-nav-list>
</div>
@@ -49,6 +49,9 @@ export class NavigationComponent {
readonly navigationItems = input<NavigationItem[]>();
/** Check if running in Electron (downloads only available in desktop) */
readonly isElectron = !!window.electron;
getStatusColor(): string {
if (this.isStalkerPlaylist()) return 'status-active';
@@ -89,6 +89,36 @@
[isWatched]="isEpisodeWatched(episode)"
/>
<div class="episode-card__actions">
@if (isElectron()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="download-btn download-btn--completed"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="download-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
>
<mat-icon>download</mat-icon>
</button>
}
}
<button
mat-icon-button
class="toggle-watched-btn"
@@ -312,7 +312,10 @@
z-index: 2;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
gap: 4px;
.download-btn,
.toggle-watched-btn {
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.5);
@@ -331,6 +334,23 @@
height: 20px;
}
}
.download-btn:hover {
background: rgba(25, 118, 210, 0.8);
}
.download-btn--completed {
color: #4caf50 !important;
&:hover {
background: rgba(76, 175, 80, 0.8);
}
}
.download-btn--downloading {
color: rgba(255, 255, 255, 0.5) !important;
cursor: not-allowed;
}
}
&:hover {
@@ -7,8 +7,10 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces';
import { XtreamStore } from '../stores/xtream.store';
import { StalkerStore } from '../../stalker/stalker.store';
import { ProgressCapsuleComponent } from '../shared/progress-capsule/progress-capsule.component';
import { WatchedBadgeComponent } from '../shared/watched-badge/watched-badge.component';
import { DownloadsService } from '../../services/downloads.service';
@Component({
selector: 'app-season-container',
@@ -27,11 +29,75 @@ import { WatchedBadgeComponent } from '../shared/watched-badge/watched-badge.com
})
export class SeasonContainerComponent {
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly downloadsService = inject(DownloadsService);
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
readonly seriesId = input.required<number>(); // Add seriesId input
readonly seriesId = input.required<number>();
readonly seriesTitle = input<string>('');
readonly isLoading = input<boolean>(false);
readonly isElectron = this.downloadsService.isAvailable;
/**
* Detect if episode is from Stalker portal based on custom_sid marker
*/
private isStalkerEpisode(episode: XtreamSerieEpisode): boolean {
return (
(episode as any).custom_sid === 'vod-series' ||
(episode as any).custom_sid === 'regular-series'
);
}
/**
* Get the appropriate playlist ID based on mode
* Note: XtreamStore uses 'id', StalkerStore uses '_id'
*/
private getPlaylistId(episode: XtreamSerieEpisode): string | undefined {
if (this.isStalkerEpisode(episode)) {
return this.stalkerStore.currentPlaylist()?._id;
}
return this.xtreamStore.currentPlaylist()?.id;
}
/**
* Get a stable numeric ID for an episode (used for downloads tracking)
* For regular-series, episode.id contains cmd string, so we extract ID from it
* For vod-series and xtream, episode.id is already numeric
*/
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
const customSid = (episode as any).custom_sid;
if (customSid === 'regular-series') {
// For regular-series, episode.id is cmd like "/media/file_123.mpg"
// Extract the numeric part if possible
const cmd = episode.id as unknown as string;
const match = cmd?.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
}
// Fallback: hash the cmd string to get a consistent number
return this.hashString(cmd || '');
}
// For vod-series and xtream, use the numeric id directly
const numId = Number(episode.id);
return isNaN(numId) ? this.hashString(String(episode.id)) : numId;
}
/**
* Simple string hash function to generate consistent numeric IDs
*/
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
@Output() episodeClicked = new EventEmitter<any>();
@Output() seasonSelected = new EventEmitter<string>();
@@ -136,4 +202,163 @@ export class SeasonContainerComponent {
const percent = (watched / episodes.length) * 100;
return `${percent}, 100`;
}
async downloadEpisode(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
// Handle Stalker mode
if (this.isStalkerEpisode(episode)) {
await this.downloadStalkerEpisode(episode);
return;
}
// Xtream mode
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) return;
// Construct the episode stream URL
const serverUrl = playlist.serverUrl?.replace(/\/$/, '') || '';
const username = playlist.username || '';
const password = playlist.password || '';
const extension = episode.container_extension || 'mp4';
const url = `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`;
// Get episode info for poster
const episodeInfo = this.getEpisodeInfo(episode);
const posterUrl = episodeInfo?.movie_image;
// Build episode title
const seriesName = this.seriesTitle() || 'Series';
const seasonNum = episode.season || Number(this.selectedSeason) || 1;
const episodeNum = episode.episode_num || 1;
const episodeTitle = `${seriesName} - S${String(seasonNum).padStart(2, '0')}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: Number(episode.id),
contentType: 'episode',
title: episodeTitle,
url,
posterUrl,
seriesXtreamId: this.seriesId(),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
});
}
/**
* Download episode from Stalker portal
*/
private async downloadStalkerEpisode(episode: XtreamSerieEpisode) {
const playlist = this.stalkerStore.currentPlaylist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) return;
const customSid = (episode as any).custom_sid;
let cmd: string;
if (customSid === 'vod-series') {
// VOD series episode - construct the cmd using episode id
cmd = `/media/file_${episode.id}.mpg`;
} else {
// Regular series - episode.id contains the cmd
cmd = episode.id as unknown as string;
}
// Resolve the actual stream URL via Stalker API
// The cmd is a reference that needs to be resolved to get the tokenized URL
let url: string;
try {
url = await this.stalkerStore.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmd,
episode.episode_num // series param for episode number
);
if (!url) {
console.error('[SeasonContainer] Failed to resolve Stalker stream URL');
return;
}
} catch (error) {
console.error('[SeasonContainer] Error resolving Stalker stream URL:', error);
return;
}
// Get episode info for poster
const episodeInfo = this.getEpisodeInfo(episode);
const posterUrl = episodeInfo?.movie_image;
// Build episode title
const selectedItem = this.stalkerStore.selectedItem();
const seriesName = selectedItem?.info?.name || this.seriesTitle() || 'Series';
const seasonNum = Number(this.selectedSeason) || 1;
const episodeNum = episode.episode_num || 1;
const episodeTitle = `${seriesName} - S${String(seasonNum).padStart(2, '0')}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
await this.downloadsService.startDownload({
playlistId: playlist._id,
xtreamId: this.getEpisodeDownloadId(episode),
contentType: 'episode',
title: episodeTitle,
url,
posterUrl,
seriesXtreamId: this.seriesId(),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
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,
});
}
/** Check if episode is already downloaded */
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
const playlistId = this.getPlaylistId(episode);
if (!playlistId) return false;
// Access downloads signal to make this reactive
this.downloadsService.downloads();
return this.downloadsService.isDownloaded(
this.getEpisodeDownloadId(episode),
playlistId,
'episode'
);
}
/** Check if episode is currently downloading */
isEpisodeDownloading(episode: XtreamSerieEpisode): boolean {
const playlistId = this.getPlaylistId(episode);
if (!playlistId) return false;
// Access downloads signal to make this reactive
this.downloadsService.downloads();
return this.downloadsService.isDownloading(
this.getEpisodeDownloadId(episode),
playlistId,
'episode'
);
}
/** Play episode from local file */
async playFromLocal(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
const playlistId = this.getPlaylistId(episode);
if (!playlistId) return;
const filePath = this.downloadsService.getDownloadedFilePath(
this.getEpisodeDownloadId(episode),
playlistId,
'episode'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
}
@@ -70,6 +70,7 @@
<app-season-container
[seasons]="item.episodes"
[seriesId]="item.series_id"
[seriesTitle]="item.info?.name"
(episodeClicked)="playEpisode($event)"
/>
</app-content-hero>
@@ -96,6 +96,24 @@
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@if (isElectron()) {
@if (isDownloaded()) {
<button class="download-btn download-btn--completed" (click)="playFromLocal()">
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isDownloading()) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button class="download-btn" (click)="downloadVod(item)">
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
}
</ng-container>
<!-- YouTube Trailer -->
@@ -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);
}
}
}
@@ -117,6 +117,13 @@ export const xtreamRoutes: Route[] = [
(c) => c.RecentlyAddedComponent
),
},
{
path: 'downloads',
loadComponent: () =>
import('./downloads/downloads.component').then(
(c) => c.DownloadsComponent
),
},
],
},
];
@@ -73,6 +73,24 @@
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@if (isElectron) {
@if (isDownloaded) {
<button class="download-btn download-btn--completed" (click)="playFromLocal()">
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isDownloading) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button class="download-btn" (click)="downloadVod()">
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
}
</ng-container>
<!-- YouTube Trailer -->
@@ -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);
}
}
}
+24
View File
@@ -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",
+24
View File
@@ -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",
+26 -1
View File
@@ -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"
}
}
}
+24
View File
@@ -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",
+319 -119
View File
@@ -3,127 +3,327 @@ declare const nodeModule: NodeModule;
interface NodeModule {
id: string;
}
interface Window {
process: any;
require: any;
electron: {
// Core
getAppVersion: () => Promise<string>;
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<any>;
writeFile: (filePath: string, content: string) => Promise<void>;
getLocalIpAddresses: () => Promise<string[]>;
// Playlist
fetchPlaylistByUrl: (url: string, title?: string) => Promise<any>;
updatePlaylistFromFilePath: (filePath: string, title: string) => Promise<any>;
openPlaylistFromFile: () => Promise<any>;
autoUpdatePlaylists: (playlists: any[]) => Promise<any[]>;
dbDeleteAllPlaylists: () => Promise<void>;
// Player
setUserAgent: (userAgent: string, referer?: string) => Promise<void>;
openInMpv: (url: string, title: string, userAgent: string, referer?: string, origin?: string, contentInfo?: any, startTime?: number) => Promise<void>;
openInVlc: (url: string, title: string, userAgent: string, referer?: string, origin?: string, contentInfo?: any, startTime?: number) => Promise<void>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
// EPG
fetchEpg: (urls: string[]) => Promise<{ success: boolean; message?: string; skipped?: string[] }>;
getChannelPrograms: (channelId: string) => Promise<any>;
getEpgChannels: () => Promise<any>;
getEpgChannelsByRange: (skip: number, limit: number) => Promise<any>;
forceFetchEpg: (url: string) => Promise<void>;
clearEpgData: () => Promise<void>;
checkEpgFreshness: (urls: string[], maxAgeHours?: number) => Promise<any>;
searchEpgPrograms: (searchTerm: string, limit?: number) => Promise<any>;
// Settings & Requests
updateSettings: (settings: any) => Promise<void>;
getAiSettings: () => Promise<any>;
stalkerRequest: (payload: any) => Promise<any>;
xtreamRequest: (payload: any) => Promise<any>;
// Database - Playlists & Content
dbCreatePlaylist: (playlist: any) => Promise<void>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
dbUpdatePlaylist: (playlistId: string, updates: any) => Promise<void>;
dbDeletePlaylist: (playlistId: string) => Promise<void>;
dbDeleteXtreamContent: (playlistId: string) => Promise<any>;
dbRestoreXtreamUserData: (playlistId: string, favoritedXtreamIds: number[], recentlyViewedXtreamIds: any[]) => Promise<void>;
// Database - Categories & Content
dbHasCategories: (playlistId: string, type: string) => Promise<boolean>;
dbGetCategories: (playlistId: string, type: string) => Promise<any[]>;
dbSaveCategories: (playlistId: string, categories: any[], type: string, hiddenCategoryXtreamIds?: number[]) => Promise<void>;
dbGetAllCategories: (playlistId: string, type: string) => Promise<any[]>;
dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) => Promise<void>;
dbHasContent: (playlistId: string, type: string) => Promise<boolean>;
dbGetContent: (playlistId: string, type: string) => Promise<any[]>;
dbSaveContent: (playlistId: string, streams: any[], type: string) => Promise<any>;
dbSearchContent: (playlistId: string, searchTerm: string, types: string[]) => Promise<any[]>;
dbGlobalSearch: (searchTerm: string, types: string[]) => Promise<any[]>;
dbGetContentByXtreamId: (xtreamId: number, playlistId: string) => Promise<any>;
// Database - Favorites & Recent
dbGetRecentlyViewed: () => Promise<any[]>;
dbClearRecentlyViewed: () => Promise<void>;
dbAddFavorite: (contentId: number, playlistId: string) => Promise<void>;
dbRemoveFavorite: (contentId: number, playlistId: string) => Promise<void>;
dbIsFavorite: (contentId: number, playlistId: string) => Promise<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
dbAddRecentItem: (contentId: number, playlistId: string) => Promise<void>;
dbClearPlaylistRecentItems: (playlistId: string) => Promise<void>;
dbRemoveRecentItem: (contentId: number, playlistId: string) => Promise<void>;
// Database - Playback Positions
dbSavePlaybackPosition: (playlistId: string, data: any) => Promise<void>;
dbGetPlaybackPosition: (playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode') => Promise<any>;
dbGetSeriesPlaybackPositions: (playlistId: string, seriesXtreamId: number) => Promise<any[]>;
dbGetRecentPlaybackPositions: (playlistId: string, limit?: number) => Promise<any[]>;
dbGetAllPlaybackPositions: (playlistId: string) => Promise<any[]>;
dbClearPlaybackPosition: (playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode') => Promise<void>;
};
}
/* export interface ParsedPlaylist {
header: {
attrs: {
'x-tvg-url': string;
declare global {
interface Window {
electron: {
getAppVersion: () => Promise<string>;
platform: string;
fetchPlaylistByUrl: (
url: string,
title?: string
) => Promise<any>;
updatePlaylistFromFilePath: (
filePath: string,
title: string
) => Promise<any>;
openPlaylistFromFile: () => Promise<any>;
saveFileDialog: (
defaultPath: string,
filters?: { name: string; extensions: string[] }[]
) => Promise<string | null>;
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<any[]>;
fetchEpg: (
urls: string[]
) => Promise<{ success: boolean; message?: string; skipped?: string[] }>;
getChannelPrograms: (channelId: string) => Promise<any>;
getEpgChannels: () => Promise<any>;
getEpgChannelsByRange: (
skip: number,
limit: number
) => Promise<any>;
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<any[]>;
updateSettings: (settings: any) => Promise<void>;
getAiSettings: () => Promise<{
aiProvider: string;
aiModelName: string;
aiApiKey: string;
}>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
stalkerRequest: (payload: {
url: string;
macAddress: string;
params: Record<string, string>;
token?: string;
}) => Promise<any>;
xtreamRequest: (payload: {
url: string;
params: Record<string, string>;
}) => Promise<{ payload: any; action: string }>;
// Database operations
dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
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<boolean>;
dbGetCategories: (
playlistId: string,
type: string
) => Promise<any[]>;
dbSaveCategories: (
playlistId: string,
categories: any[],
type: string,
hiddenCategoryXtreamIds?: number[]
) => Promise<{ success: boolean }>;
dbGetAllCategories: (
playlistId: string,
type: string
) => Promise<any[]>;
dbUpdateCategoryVisibility: (
categoryIds: number[],
hidden: boolean
) => Promise<{ success: boolean }>;
dbHasContent: (
playlistId: string,
type: string
) => Promise<boolean>;
dbGetContent: (playlistId: string, type: string) => Promise<any[]>;
dbSaveContent: (
playlistId: string,
streams: any[],
type: string
) => Promise<{ success: boolean; count: number }>;
dbSearchContent: (
playlistId: string,
searchTerm: string,
types: string[]
) => Promise<any[]>;
dbGlobalSearch: (
searchTerm: string,
types: string[]
) => Promise<any[]>;
dbGetRecentlyViewed: () => Promise<any[]>;
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<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
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<any | null>;
// 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<string[]>;
// 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<any | null>;
dbGetSeriesPlaybackPositions: (
playlistId: string,
seriesXtreamId: number
) => Promise<any[]>;
dbGetRecentPlaybackPositions: (
playlistId: string,
limit?: number
) => Promise<any[]>;
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<any[]>;
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<DownloadItem[]>;
downloadsGet: (downloadId: number) => Promise<DownloadItem | null>;
downloadsGetDefaultFolder: () => Promise<string>;
downloadsSelectFolder: () => Promise<string | null>;
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;
}
}
*/
+41
View File
@@ -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.
Vendored
+103 -2
View File
@@ -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<Playlist[]>;
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<any | null>;
dbGetSeriesPlaybackPositions: (
playlistId: string,
seriesXtreamId: number
) => Promise<any[]>;
dbGetRecentPlaybackPositions: (
playlistId: string,
limit?: number
) => Promise<any[]>;
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<any[]>;
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<DownloadItem[]>;
downloadsGet: (downloadId: number) => Promise<DownloadItem | null>;
downloadsGetDefaultFolder: () => Promise<string>;
downloadsSelectFolder: () => Promise<string | null>;
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
@@ -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)`,
];
/**
+48
View File
@@ -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;
@@ -9,6 +9,8 @@ export type PlaylistMeta = Pick<
| 'url'
| 'importDate'
| 'userAgent'
| 'referrer'
| 'origin'
| 'filePath'
| 'updateDate'
| 'updateState'
@@ -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;
}
-1
View File
@@ -20,7 +20,6 @@
"{workspaceRoot}/.github/workflows/ci.yml"
]
},
"nxCloudId": "68be99a8c4fb535f60d0d8e8",
"targetDefaults": {
"@angular/build:application": {
"cache": true,
+140 -1
View File
@@ -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",
+2 -1
View File
@@ -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"
}
}
}
+75
View File
@@ -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):