Casting: restore camelCase serialization on SessionInfo/NowPlayingItem/PlayState (container rename_all = "camelCase" + per-field PascalCase serde aliases so Jellyfin PascalCase still deserializes). Bindings and the existing frontend are camelCase again — casting works with no frontend session changes. Downloads: migrate DownloadButton.svelte and downloads.ts to the typed commands.downloadItemAndStart / downloadItem / downloadVideo wrappers (request objects), matching the bundled backend signatures. Tooling: - Enable tauri-specta ErrorHandlingMode::Throw so commands.* return Promise<T> and throw (drop-in for invoke()). - Disambiguate specta name collisions: player MediaItem/MediaSource -> PlayerMediaItem/PlayerMediaSource, auth ServerInfo -> AuthServerInfo. - Regenerate bindings.ts; svelte-check passes (0 errors).
636 lines
16 KiB
TypeScript
636 lines
16 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 { invoke } from '@tauri-apps/api/core';
|
|
import { commands } from '$lib/api/bindings';
|
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
|
|
|
// 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';
|
|
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;
|
|
}
|
|
|
|
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) {
|
|
console.debug('🔄 Refresh already in progress, queuing request for user:', userId);
|
|
pendingRefreshRequest = { userId, statusFilter };
|
|
return;
|
|
}
|
|
|
|
refreshInProgress = true;
|
|
|
|
try {
|
|
console.log('🔄 Refreshing downloads for user:', userId);
|
|
const response = await invoke<{ downloads: DownloadInfo[]; stats: DownloadStats }>(
|
|
'get_downloads',
|
|
{
|
|
userId,
|
|
statusFilter
|
|
}
|
|
);
|
|
console.log(' Got', response.downloads.length, 'downloads from backend');
|
|
console.log(' 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) {
|
|
console.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 {
|
|
console.log('📥 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
|
|
});
|
|
console.log(' Got download ID from backend:', downloadId);
|
|
|
|
// Fetch download info and add to store
|
|
console.log(' Refreshing downloads...');
|
|
await refreshDownloads(userId);
|
|
console.log(' Refresh complete. Store state:', get({ subscribe }));
|
|
|
|
return downloadId;
|
|
} catch (error) {
|
|
console.error('Failed to queue download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Queue an entire album for download
|
|
*/
|
|
async downloadAlbum(albumId: string, userId: string, basePath: string): Promise<number[]> {
|
|
try {
|
|
console.log('📥 downloadAlbum called:', { albumId, userId, basePath });
|
|
const downloadIds = await invoke<number[]>('download_album', {
|
|
albumId,
|
|
userId,
|
|
basePath
|
|
});
|
|
console.log(' Got download IDs from backend:', downloadIds);
|
|
|
|
// Refresh downloads
|
|
await refreshDownloads(userId);
|
|
|
|
return downloadIds;
|
|
} catch (error) {
|
|
console.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 {
|
|
console.log('🎬 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
|
|
});
|
|
console.log(' Got download ID from backend:', downloadId);
|
|
|
|
// Refresh downloads
|
|
await refreshDownloads(userId);
|
|
|
|
return downloadId;
|
|
} catch (error) {
|
|
console.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 {
|
|
console.log('📺 downloadSeries called:', {
|
|
seriesId,
|
|
seriesName,
|
|
userId,
|
|
basePath,
|
|
qualityPreset
|
|
});
|
|
const downloadIds = await invoke<number[]>('download_series', {
|
|
seriesId,
|
|
seriesName,
|
|
userId,
|
|
basePath,
|
|
qualityPreset
|
|
});
|
|
console.log(' Queued', downloadIds.length, 'episodes for download');
|
|
|
|
// Refresh downloads
|
|
await refreshDownloads(userId);
|
|
|
|
return downloadIds;
|
|
} catch (error) {
|
|
console.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 {
|
|
console.log('📺 downloadSeason called:', {
|
|
seasonId,
|
|
seriesName,
|
|
seasonName,
|
|
seasonNumber,
|
|
qualityPreset
|
|
});
|
|
const downloadIds = await invoke<number[]>('download_season', {
|
|
seasonId,
|
|
seriesName,
|
|
seasonName,
|
|
seasonNumber,
|
|
userId,
|
|
basePath,
|
|
qualityPreset
|
|
});
|
|
console.log(' Queued', downloadIds.length, 'episodes for download');
|
|
|
|
// Refresh downloads
|
|
await refreshDownloads(userId);
|
|
|
|
return downloadIds;
|
|
} catch (error) {
|
|
console.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 invoke('pin_item', { itemId });
|
|
} catch (error) {
|
|
console.error('Failed to pin item:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Unpin an item's metadata
|
|
*/
|
|
async unpinItem(itemId: string): Promise<void> {
|
|
try {
|
|
await invoke('unpin_item', { itemId });
|
|
} catch (error) {
|
|
console.error('Failed to unpin item:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Check if an item is pinned
|
|
*/
|
|
async isItemPinned(itemId: string): Promise<boolean> {
|
|
try {
|
|
return await invoke<boolean>('is_item_pinned', { itemId });
|
|
} catch (error) {
|
|
console.error('Failed to check pin status:', error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Pause a download
|
|
*/
|
|
async pause(downloadId: number): Promise<void> {
|
|
try {
|
|
await invoke('pause_download', { downloadId });
|
|
} catch (error) {
|
|
console.error('Failed to pause download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Resume a paused download
|
|
*/
|
|
async resume(downloadId: number): Promise<void> {
|
|
try {
|
|
await invoke('resume_download', { downloadId });
|
|
} catch (error) {
|
|
console.error('Failed to resume download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Cancel a download
|
|
*/
|
|
async cancel(downloadId: number): Promise<void> {
|
|
try {
|
|
await invoke('cancel_download', { downloadId });
|
|
} catch (error) {
|
|
console.error('Failed to cancel download:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Delete a completed download
|
|
*/
|
|
async delete(downloadId: number): Promise<void> {
|
|
try {
|
|
await invoke('delete_download', { downloadId });
|
|
update((state) => {
|
|
const { [downloadId]: removed, ...remaining } = state.downloads;
|
|
return { ...state, downloads: remaining };
|
|
});
|
|
} catch (error) {
|
|
console.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) {
|
|
console.log(' Download not in store:', downloadId);
|
|
return state;
|
|
}
|
|
|
|
const updatedDownload = { ...download, ...updates };
|
|
const newDownloads = { ...state.downloads, [downloadId]: updatedDownload };
|
|
|
|
console.log(' 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) {
|
|
console.warn('Download events already initialized');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log('🎧 Setting up download event listener...');
|
|
unlistenFn = await listen<DownloadEvent>('download-event', (event) => {
|
|
const payload = event.payload;
|
|
console.log('📬 Received download event:', payload.type, 'for download:', payload.downloadId);
|
|
console.log(' Full event payload:', JSON.stringify(payload));
|
|
|
|
// Update the store based on event type
|
|
downloads.subscribe((state) => {
|
|
const download = state.downloads[payload.downloadId];
|
|
console.log(' Current download state:', download ? download.status : 'NOT IN STORE');
|
|
})(); // Immediately unsubscribe after reading
|
|
|
|
handleDownloadEvent(payload);
|
|
});
|
|
|
|
isEventsInitialized = true;
|
|
console.log('✅ Download event listener registered successfully');
|
|
} catch (err) {
|
|
console.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
|
|
invoke('mark_download_completed', {
|
|
downloadId: payload.downloadId,
|
|
bytesDownloaded: payload.totalBytes || download.fileSize || download.bytesDownloaded,
|
|
filePath: payload.filePath || download.filePath
|
|
}).catch((err) => console.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
|
|
invoke('mark_download_failed', {
|
|
downloadId: payload.downloadId,
|
|
errorMessage: payload.error || 'Unknown error'
|
|
}).catch((err) => console.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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to update a download in the store.
|
|
*/
|
|
function updateDownloadInStore(downloadId: number, updates: Partial<DownloadInfo>): void {
|
|
console.log(' updateDownloadInStore:', downloadId, updates);
|
|
downloads.updateDownload(downloadId, updates);
|
|
}
|
|
|
|
/**
|
|
* Helper to remove a download from the store.
|
|
*/
|
|
function removeDownloadFromStore(downloadId: number): void {
|
|
console.log(' removeDownloadFromStore:', downloadId);
|
|
downloads.removeDownload(downloadId);
|
|
}
|