First working POC
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to refresh downloads (avoids `this` binding issues)
|
||||
async function refreshDownloads(userId: string, statusFilter?: string[]): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 invoke<number>('download_item', {
|
||||
itemId,
|
||||
userId,
|
||||
filePath,
|
||||
mimeType,
|
||||
priority,
|
||||
itemName,
|
||||
artistName,
|
||||
albumName
|
||||
});
|
||||
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 invoke<number>('download_video', {
|
||||
itemId,
|
||||
userId,
|
||||
filePath,
|
||||
mimeType,
|
||||
priority,
|
||||
itemName,
|
||||
qualityPreset,
|
||||
seriesName,
|
||||
seasonName,
|
||||
episodeNumber,
|
||||
seasonNumber
|
||||
});
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user