mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
Merge pull request #743 from 4gray/feat/download-manager-ui
feat: add download manager feature
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Vendored
+319
-119
@@ -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;
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -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
@@ -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)`,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,7 +20,6 @@
|
||||
"{workspaceRoot}/.github/workflows/ci.yml"
|
||||
]
|
||||
},
|
||||
"nxCloudId": "68be99a8c4fb535f60d0d8e8",
|
||||
"targetDefaults": {
|
||||
"@angular/build:application": {
|
||||
"cache": true,
|
||||
|
||||
Generated
+140
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+75
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user