jellytau/src/lib/stores/downloads.ts
Duncan Tourolle ea342d76e3 Fix download + casting breakages from the specta migration
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).
2026-06-20 19:27:29 +02:00

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);
}