TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself.
671 lines
17 KiB
TypeScript
671 lines
17 KiB
TypeScript
// Download manager state store
|
|
// TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017
|
|
import { writable, derived, get } from 'svelte/store';
|
|
import { commands } from '$lib/api/bindings';
|
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
|
import { createLogger } from '$lib/utils/logger';
|
|
|
|
const log = createLogger('Downloads');
|
|
|
|
// Event listener state
|
|
let unlistenFn: UnlistenFn | null = null;
|
|
let isEventsInitialized = false;
|
|
|
|
export interface DownloadInfo {
|
|
id: number;
|
|
itemId: string;
|
|
userId: string;
|
|
filePath: string;
|
|
fileSize?: number;
|
|
mimeType?: string;
|
|
status: 'pending' | 'downloading' | 'completed' | 'failed' | 'paused';
|
|
progress: number;
|
|
bytesDownloaded: number;
|
|
queuedAt: string;
|
|
startedAt?: string;
|
|
completedAt?: string;
|
|
errorMessage?: string;
|
|
retryCount: number;
|
|
priority: number;
|
|
// Item metadata for display (audio)
|
|
itemName?: string;
|
|
artistName?: string;
|
|
albumName?: string;
|
|
// Video-specific metadata
|
|
seriesName?: string;
|
|
seasonName?: string;
|
|
episodeNumber?: number;
|
|
seasonNumber?: number;
|
|
qualityPreset?: string;
|
|
mediaType: 'audio' | 'video';
|
|
// Download source tracking
|
|
downloadSource: 'user' | 'auto';
|
|
}
|
|
|
|
export interface DownloadEvent {
|
|
type:
|
|
| 'queued'
|
|
| 'started'
|
|
| 'progress'
|
|
| 'completed'
|
|
| 'failed'
|
|
| 'paused'
|
|
| 'cancelled'
|
|
| 'waitingForNetwork';
|
|
/** Absent on 'waitingForNetwork', which is queue-wide rather than per-download. */
|
|
downloadId: number;
|
|
itemId: string;
|
|
bytesDownloaded?: number;
|
|
totalBytes?: number;
|
|
progress?: number;
|
|
filePath?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export interface DownloadStats {
|
|
total: number;
|
|
activeCount: number;
|
|
queuedCount: number;
|
|
completedCount: number;
|
|
failedCount: number;
|
|
pausedCount: number;
|
|
}
|
|
|
|
interface DownloadsState {
|
|
downloads: Record<number, DownloadInfo>;
|
|
stats: DownloadStats;
|
|
}
|
|
|
|
/**
|
|
* True when the download queue is held because "WiFi Only" is enabled and the
|
|
* device is on a metered/cellular network. Pending rows stay pending; the queue
|
|
* resumes automatically when an acceptable network appears.
|
|
*
|
|
* TRACES: UR-053 | DR-074
|
|
*/
|
|
export const waitingForNetwork = writable(false);
|
|
|
|
function createDownloadsStore() {
|
|
const { subscribe, update, set } = writable<DownloadsState>({
|
|
downloads: {},
|
|
stats: {
|
|
total: 0,
|
|
activeCount: 0,
|
|
queuedCount: 0,
|
|
completedCount: 0,
|
|
failedCount: 0,
|
|
pausedCount: 0
|
|
}
|
|
});
|
|
|
|
// Prevent concurrent refresh calls (race condition protection)
|
|
let refreshInProgress = false;
|
|
let pendingRefreshRequest: { userId: string; statusFilter?: string[] } | null = null;
|
|
|
|
// Helper function to refresh downloads (avoids `this` binding issues)
|
|
async function refreshDownloads(userId: string, statusFilter?: string[]): Promise<void> {
|
|
// If a refresh is already in progress, queue this request instead
|
|
if (refreshInProgress) {
|
|
log.debug('🔄 Refresh already in progress, queuing request for user:', userId);
|
|
pendingRefreshRequest = { userId, statusFilter };
|
|
return;
|
|
}
|
|
|
|
refreshInProgress = true;
|
|
|
|
try {
|
|
log.debug('🔄 Refreshing downloads for user:', userId);
|
|
const response = (await commands.getDownloads(
|
|
userId,
|
|
statusFilter ?? null
|
|
)) as unknown as { downloads: DownloadInfo[]; stats: DownloadStats };
|
|
log.debug(' Got', response.downloads.length, 'downloads from backend');
|
|
log.debug(' Stats:', response.stats);
|
|
|
|
update((state) => {
|
|
const downloadsMap: Record<number, DownloadInfo> = {};
|
|
|
|
for (const download of response.downloads) {
|
|
downloadsMap[download.id] = download;
|
|
}
|
|
|
|
// No count calculation - use pre-computed stats from Rust!
|
|
return {
|
|
downloads: downloadsMap,
|
|
stats: response.stats
|
|
};
|
|
});
|
|
} catch (error) {
|
|
log.error('Failed to refresh downloads:', error);
|
|
throw error;
|
|
} finally {
|
|
refreshInProgress = false;
|
|
|
|
// Process queued request if any
|
|
if (pendingRefreshRequest) {
|
|
const { userId: queuedUserId, statusFilter: queuedFilter } = pendingRefreshRequest;
|
|
pendingRefreshRequest = null;
|
|
await refreshDownloads(queuedUserId, queuedFilter);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
subscribe,
|
|
|
|
/**
|
|
* Queue a single item for download
|
|
*/
|
|
async downloadItem(
|
|
itemId: string,
|
|
userId: string,
|
|
filePath: string,
|
|
mimeType?: string,
|
|
priority?: number,
|
|
itemName?: string,
|
|
artistName?: string,
|
|
albumName?: string
|
|
): Promise<number> {
|
|
try {
|
|
log.debug('📥 downloadItem called:', { itemId, userId, filePath, itemName, artistName, albumName });
|
|
const downloadId = await commands.downloadItem({
|
|
itemId,
|
|
userId,
|
|
filePath,
|
|
mimeType: mimeType ?? null,
|
|
priority: priority ?? null,
|
|
itemName: itemName ?? null,
|
|
artistName: artistName ?? null,
|
|
albumName: albumName ?? null,
|
|
expectedSize: null
|
|
});
|
|
log.debug(' Got download ID from backend:', downloadId);
|
|
|
|
// Fetch download info and add to store
|
|
log.debug(' Refreshing downloads...');
|
|
await refreshDownloads(userId);
|
|
log.debug(' Refresh complete. Store state:', get({ subscribe }));
|
|
|
|
return downloadId;
|
|
} catch (error) {
|
|
log.error('Failed to queue download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Queue an entire album for download.
|
|
*
|
|
* The backend does all of it — listing the album's tracks, queueing them,
|
|
* resolving each stream URL and starting the queue. It returns the queued
|
|
* row ids for reporting only; nothing here pairs them back to tracks.
|
|
*
|
|
* TRACES: UR-018, UR-055 | DR-173
|
|
*/
|
|
async downloadAlbum(
|
|
handle: string,
|
|
albumId: string,
|
|
userId: string,
|
|
basePath: string
|
|
): Promise<number[]> {
|
|
try {
|
|
log.debug('📥 downloadAlbum called:', { albumId, userId, basePath });
|
|
const downloadIds = await commands.downloadAlbum(handle, albumId, userId, basePath);
|
|
log.debug(' Got download IDs from backend:', downloadIds);
|
|
|
|
// Refresh downloads
|
|
await refreshDownloads(userId);
|
|
|
|
return downloadIds;
|
|
} catch (error) {
|
|
log.error('Failed to queue album download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Queue a video item (movie/episode) for download with quality preset
|
|
*/
|
|
async downloadVideo(
|
|
itemId: string,
|
|
userId: string,
|
|
filePath: string,
|
|
mimeType?: string,
|
|
priority?: number,
|
|
itemName?: string,
|
|
qualityPreset?: string,
|
|
seriesName?: string,
|
|
seasonName?: string,
|
|
episodeNumber?: number,
|
|
seasonNumber?: number
|
|
): Promise<number> {
|
|
try {
|
|
log.debug('🎬 downloadVideo called:', {
|
|
itemId,
|
|
userId,
|
|
filePath,
|
|
itemName,
|
|
qualityPreset,
|
|
seriesName
|
|
});
|
|
const downloadId = await commands.downloadVideo({
|
|
itemId,
|
|
userId,
|
|
filePath,
|
|
mimeType: mimeType ?? null,
|
|
priority: priority ?? null,
|
|
itemName: itemName ?? null,
|
|
qualityPreset: qualityPreset ?? null,
|
|
seriesName: seriesName ?? null,
|
|
seasonName: seasonName ?? null,
|
|
episodeNumber: episodeNumber ?? null,
|
|
seasonNumber: seasonNumber ?? null
|
|
});
|
|
log.debug(' Got download ID from backend:', downloadId);
|
|
|
|
// Refresh downloads
|
|
await refreshDownloads(userId);
|
|
|
|
return downloadId;
|
|
} catch (error) {
|
|
log.error('Failed to queue video download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Queue all episodes of a series for download
|
|
*/
|
|
async downloadSeries(
|
|
seriesId: string,
|
|
seriesName: string,
|
|
userId: string,
|
|
basePath: string,
|
|
qualityPreset?: string
|
|
): Promise<number[]> {
|
|
try {
|
|
log.debug('📺 downloadSeries called:', {
|
|
seriesId,
|
|
seriesName,
|
|
userId,
|
|
basePath,
|
|
qualityPreset
|
|
});
|
|
const downloadIds = await commands.downloadSeries(
|
|
seriesId,
|
|
seriesName,
|
|
userId,
|
|
basePath,
|
|
qualityPreset ?? null
|
|
);
|
|
log.debug(' Queued', downloadIds.length, 'episodes for download');
|
|
|
|
// Refresh downloads
|
|
await refreshDownloads(userId);
|
|
|
|
return downloadIds;
|
|
} catch (error) {
|
|
log.error('Failed to queue series download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Queue all episodes of a season for download
|
|
*/
|
|
async downloadSeason(
|
|
seasonId: string,
|
|
seriesName: string,
|
|
seasonName: string,
|
|
seasonNumber: number,
|
|
userId: string,
|
|
basePath: string,
|
|
qualityPreset?: string
|
|
): Promise<number[]> {
|
|
try {
|
|
log.debug('📺 downloadSeason called:', {
|
|
seasonId,
|
|
seriesName,
|
|
seasonName,
|
|
seasonNumber,
|
|
qualityPreset
|
|
});
|
|
const downloadIds = await commands.downloadSeason(
|
|
seasonId,
|
|
seriesName,
|
|
seasonName,
|
|
seasonNumber,
|
|
userId,
|
|
basePath,
|
|
qualityPreset ?? null
|
|
);
|
|
log.debug(' Queued', downloadIds.length, 'episodes for download');
|
|
|
|
// Refresh downloads
|
|
await refreshDownloads(userId);
|
|
|
|
return downloadIds;
|
|
} catch (error) {
|
|
log.error('Failed to queue season download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Pin an item's metadata (protects from cache clear)
|
|
*/
|
|
async pinItem(itemId: string): Promise<void> {
|
|
try {
|
|
await commands.pinItem(itemId);
|
|
} catch (error) {
|
|
log.error('Failed to pin item:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Unpin an item's metadata
|
|
*/
|
|
async unpinItem(itemId: string): Promise<void> {
|
|
try {
|
|
await commands.unpinItem(itemId);
|
|
} catch (error) {
|
|
log.error('Failed to unpin item:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Check if an item is pinned
|
|
*/
|
|
async isItemPinned(itemId: string): Promise<boolean> {
|
|
try {
|
|
return await commands.isItemPinned(itemId);
|
|
} catch (error) {
|
|
log.error('Failed to check pin status:', error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Pause a download
|
|
*/
|
|
async pause(downloadId: number): Promise<void> {
|
|
try {
|
|
await commands.pauseDownload(downloadId);
|
|
} catch (error) {
|
|
log.error('Failed to pause download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Resume a paused download
|
|
*/
|
|
async resume(downloadId: number): Promise<void> {
|
|
try {
|
|
await commands.resumeDownload(downloadId);
|
|
} catch (error) {
|
|
log.error('Failed to resume download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Cancel a download
|
|
*/
|
|
async cancel(downloadId: number): Promise<void> {
|
|
try {
|
|
await commands.cancelDownload(downloadId);
|
|
} catch (error) {
|
|
log.error('Failed to cancel download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Delete a completed download
|
|
*/
|
|
async delete(downloadId: number): Promise<void> {
|
|
try {
|
|
await commands.deleteDownload(downloadId);
|
|
update((state) => {
|
|
const { [downloadId]: removed, ...remaining } = state.downloads;
|
|
return { ...state, downloads: remaining };
|
|
});
|
|
} catch (error) {
|
|
log.error('Failed to delete download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Refresh downloads list from backend
|
|
*/
|
|
refresh: refreshDownloads,
|
|
|
|
/**
|
|
* Update a specific download in the store (for event handling)
|
|
*/
|
|
updateDownload(downloadId: number, updates: Partial<DownloadInfo>): void {
|
|
update((state) => {
|
|
const download = state.downloads[downloadId];
|
|
if (!download) {
|
|
log.debug(' Download not in store:', downloadId);
|
|
return state;
|
|
}
|
|
|
|
const updatedDownload = { ...download, ...updates };
|
|
const newDownloads = { ...state.downloads, [downloadId]: updatedDownload };
|
|
|
|
log.debug(' Store updated for download', downloadId, ':', updates);
|
|
// No count calculation - stats remain as-is until next refresh
|
|
return {
|
|
downloads: newDownloads,
|
|
stats: state.stats
|
|
};
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Remove a download from the store
|
|
*/
|
|
removeDownload(downloadId: number): void {
|
|
update((state) => {
|
|
const { [downloadId]: removed, ...remaining } = state.downloads;
|
|
if (!removed) return state;
|
|
|
|
// No count calculation - stats remain as-is until next refresh
|
|
return {
|
|
downloads: remaining,
|
|
stats: state.stats
|
|
};
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
export const downloads = createDownloadsStore();
|
|
|
|
// Derived stores
|
|
export const activeDownloads = derived(downloads, ($d) =>
|
|
Object.values($d.downloads).filter((d) => d.status === 'downloading')
|
|
);
|
|
|
|
export const completedDownloads = derived(downloads, ($d) =>
|
|
Object.values($d.downloads).filter((d) => d.status === 'completed')
|
|
);
|
|
|
|
export const pendingDownloads = derived(downloads, ($d) =>
|
|
Object.values($d.downloads).filter((d) => d.status === 'pending')
|
|
);
|
|
|
|
export const failedDownloads = derived(downloads, ($d) =>
|
|
Object.values($d.downloads).filter((d) => d.status === 'failed')
|
|
);
|
|
|
|
export const videoDownloads = derived(downloads, ($d) =>
|
|
Object.values($d.downloads).filter((d) => d.mediaType === 'video')
|
|
);
|
|
|
|
export const audioDownloads = derived(downloads, ($d) =>
|
|
Object.values($d.downloads).filter((d) => d.mediaType === 'audio' || !d.mediaType)
|
|
);
|
|
|
|
/**
|
|
* Initialize download event listeners.
|
|
* Should be called once when the app starts (e.g., in +layout.svelte).
|
|
*/
|
|
export async function initDownloadEvents(): Promise<void> {
|
|
if (isEventsInitialized) {
|
|
log.warn('Download events already initialized');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
log.debug('🎧 Setting up download event listener...');
|
|
unlistenFn = await listen<DownloadEvent>('download-event', (event) => {
|
|
const payload = event.payload;
|
|
log.debug('📬 Received download event:', payload.type, 'for download:', payload.downloadId);
|
|
log.debug(' Full event payload:', JSON.stringify(payload));
|
|
|
|
// Update the store based on event type
|
|
downloads.subscribe((state) => {
|
|
const download = state.downloads[payload.downloadId];
|
|
log.debug(' Current download state:', download ? download.status : 'NOT IN STORE');
|
|
})(); // Immediately unsubscribe after reading
|
|
|
|
handleDownloadEvent(payload);
|
|
});
|
|
|
|
isEventsInitialized = true;
|
|
log.debug('✅ Download event listener registered successfully');
|
|
} catch (err) {
|
|
log.error('❌ Failed to register download event listener:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up download event listeners.
|
|
* Should be called when the app is destroyed.
|
|
*/
|
|
export function cleanupDownloadEvents(): void {
|
|
if (unlistenFn) {
|
|
unlistenFn();
|
|
unlistenFn = null;
|
|
}
|
|
isEventsInitialized = false;
|
|
}
|
|
|
|
/**
|
|
* Check if the download event listener is initialized.
|
|
*/
|
|
export function isDownloadEventsInitialized(): boolean {
|
|
return isEventsInitialized;
|
|
}
|
|
|
|
/**
|
|
* Handle a download event and update the store.
|
|
*/
|
|
function handleDownloadEvent(payload: DownloadEvent): void {
|
|
const currentState = get(downloads);
|
|
const download = currentState.downloads[payload.downloadId];
|
|
|
|
switch (payload.type) {
|
|
case 'queued':
|
|
// Just increment queue count - the download will be fetched on refresh
|
|
break;
|
|
|
|
case 'started':
|
|
if (download) {
|
|
updateDownloadInStore(payload.downloadId, {
|
|
status: 'downloading',
|
|
startedAt: new Date().toISOString()
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'progress':
|
|
if (download && payload.progress !== undefined) {
|
|
updateDownloadInStore(payload.downloadId, {
|
|
progress: payload.progress,
|
|
bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded,
|
|
fileSize: payload.totalBytes || download.fileSize
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'completed':
|
|
if (download) {
|
|
// Persist to database
|
|
commands.markDownloadCompleted(
|
|
payload.downloadId,
|
|
payload.totalBytes || download.fileSize || download.bytesDownloaded,
|
|
payload.filePath || download.filePath
|
|
).catch((err) => log.error('Failed to persist download completion:', err));
|
|
|
|
updateDownloadInStore(payload.downloadId, {
|
|
status: 'completed',
|
|
progress: 1.0,
|
|
completedAt: new Date().toISOString(),
|
|
filePath: payload.filePath || download.filePath
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'failed':
|
|
if (download) {
|
|
// Persist to database
|
|
commands.markDownloadFailed(
|
|
payload.downloadId,
|
|
payload.error || 'Unknown error'
|
|
).catch((err) => log.error('Failed to persist download failure:', err));
|
|
|
|
updateDownloadInStore(payload.downloadId, {
|
|
status: 'failed',
|
|
errorMessage: payload.error
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'paused':
|
|
if (download) {
|
|
updateDownloadInStore(payload.downloadId, {
|
|
status: 'paused'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'cancelled':
|
|
removeDownloadFromStore(payload.downloadId);
|
|
break;
|
|
|
|
case 'waitingForNetwork':
|
|
// Queue-wide, not tied to one download: the pump refused to start
|
|
// anything because WiFi-only is on and we're on a metered network.
|
|
waitingForNetwork.set(true);
|
|
break;
|
|
}
|
|
|
|
// Any per-download progress proves the gate isn't holding us any more.
|
|
if (payload.type === 'started' || payload.type === 'progress') {
|
|
waitingForNetwork.set(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to update a download in the store.
|
|
*/
|
|
function updateDownloadInStore(downloadId: number, updates: Partial<DownloadInfo>): void {
|
|
log.debug(' updateDownloadInStore:', downloadId, updates);
|
|
downloads.updateDownload(downloadId, updates);
|
|
}
|
|
|
|
/**
|
|
* Helper to remove a download from the store.
|
|
*/
|
|
function removeDownloadFromStore(downloadId: number): void {
|
|
log.debug(' removeDownloadFromStore:', downloadId);
|
|
downloads.removeDownload(downloadId);
|
|
}
|