Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
679 lines
18 KiB
TypeScript
679 lines
18 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);
|
|
}
|