diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ba57205c..f202d2cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,7 +35,7 @@ symphonia = { version = "0.5", default-features = false, features = ["flac", "mp reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] } futures-util = "0.3" md5 = "0.7" -tokio = { version = "1", features = ["rt", "time"] } +tokio = { version = "1", features = ["rt", "time", "sync"] } biquad = "0.4" ringbuf = "0.3" tauri-plugin-window-state = "2.4.1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 07828986..a6d92891 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ mod audio; mod discord; use std::collections::HashMap; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use std::sync::atomic::Ordering; use tauri::{ @@ -18,6 +18,13 @@ use tauri::{ /// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode). type ShortcutMap = Mutex>; +/// Maximum number of offline track downloads that can run concurrently. +/// The frontend queues more tasks than this; Rust is the real throttle. +const MAX_DL_CONCURRENCY: usize = 4; + +/// Shared semaphore that caps simultaneous `download_track_offline` executions. +type DownloadSemaphore = Arc; + /// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed. /// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms. type TrayState = Mutex>; @@ -399,8 +406,32 @@ fn mpris_set_playback( .map_err(|e| format!("MPRIS set_playback failed: {e:?}")) } +/// Returns true if `path` is an accessible directory (used for pre-flight checks in the frontend). +#[tauri::command] +fn check_dir_accessible(path: String) -> bool { + std::path::Path::new(&path).is_dir() +} + // ─── Offline Track Cache ────────────────────────────────────────────────────── +/// Streams an HTTP response body directly to `dest_path` in small chunks. +/// Never buffers the full file in memory — keeps RAM flat regardless of file size. +async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path) -> Result<(), String> { + use futures_util::StreamExt; + use tokio::io::AsyncWriteExt; + + let mut file = tokio::fs::File::create(dest_path) + .await + .map_err(|e| e.to_string())?; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| e.to_string())?; + file.write_all(&chunk).await.map_err(|e| e.to_string())?; + } + file.flush().await.map_err(|e| e.to_string())?; + Ok(()) +} + /// Downloads a single track to the app's offline cache directory. /// Returns the absolute file path so TypeScript can store it and later /// construct a `psysonic-local://` URL for the audio engine. @@ -411,6 +442,7 @@ async fn download_track_offline( url: String, suffix: String, custom_dir: Option, + dl_sem: tauri::State<'_, DownloadSemaphore>, app: tauri::AppHandle, ) -> Result { // Determine base cache directory. @@ -436,11 +468,15 @@ async fn download_track_offline( let file_path = cache_dir.join(format!("{}.{}", track_id, suffix)); let path_str = file_path.to_string_lossy().to_string(); - // Already cached — skip re-download. + // Already cached — skip re-download (no semaphore needed). if file_path.exists() { return Ok(path_str); } + // Acquire a download slot. The permit is held for the duration of the HTTP transfer + // and released automatically when this function returns (success or error). + let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?; + let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(120)) .build() @@ -450,8 +486,14 @@ async fn download_track_offline( if !response.status().is_success() { return Err(format!("HTTP {}", response.status().as_u16())); } - let bytes = response.bytes().await.map_err(|e| e.to_string())?; - tokio::fs::write(&file_path, &bytes) + + // Stream directly to a .part file; rename on success to avoid partial files. + let part_path = file_path.with_extension(format!("{suffix}.part")); + if let Err(e) = stream_to_file(response, &part_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + return Err(e); + } + tokio::fs::rename(&part_path, &file_path) .await .map_err(|e| e.to_string())?; @@ -569,6 +611,96 @@ fn resolve_hot_cache_root( } } +/// Progress payload emitted to the frontend during a ZIP download. +/// `total` is `None` when the server doesn't send a `Content-Length` header +/// (Navidrome on-the-fly ZIPs). +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ZipProgress { + id: String, + bytes: u64, + total: Option, +} + +/// Downloads a server-generated ZIP (album/playlist) directly to disk via streaming. +/// Emits `download:zip:progress` events every 500 ms so the frontend can show +/// live MB-counter without holding any binary data in the WebView process. +/// Returns the final destination path on success. +#[tauri::command] +async fn download_zip( + id: String, + url: String, + dest_path: String, + app: tauri::AppHandle, +) -> Result { + use futures_util::StreamExt; + use std::time::{Duration, Instant}; + use tokio::io::AsyncWriteExt; + + const EMIT_INTERVAL: Duration = Duration::from_millis(500); + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(7200)) // up to 2 h for large on-the-fly ZIPs + .build() + .map_err(|e| e.to_string())?; + + let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + + let total = response.content_length(); // None for Navidrome on-the-fly ZIPs + let part_path = format!("{dest_path}.part"); + + // Stream to .part file; rename on success, delete on error. + let result: Result = async { + let mut file = tokio::fs::File::create(&part_path) + .await + .map_err(|e| e.to_string())?; + + let mut bytes_done: u64 = 0; + let mut stream = response.bytes_stream(); + let mut last_emit = Instant::now(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| e.to_string())?; + file.write_all(&chunk).await.map_err(|e| e.to_string())?; + bytes_done += chunk.len() as u64; + + if last_emit.elapsed() >= EMIT_INTERVAL { + let _ = app.emit("download:zip:progress", ZipProgress { + id: id.clone(), + bytes: bytes_done, + total, + }); + last_emit = Instant::now(); + } + } + file.flush().await.map_err(|e| e.to_string())?; + Ok(bytes_done) + }.await; + + match result { + Err(e) => { + let _ = tokio::fs::remove_file(&part_path).await; + Err(e) + } + Ok(bytes_done) => { + // Final emission so the frontend sees 100 % (or final MB count). + let _ = app.emit("download:zip:progress", ZipProgress { + id: id.clone(), + bytes: bytes_done, + total: Some(bytes_done), + }); + tokio::fs::rename(&part_path, &dest_path) + .await + .map_err(|e| e.to_string())?; + Ok(dest_path) + } + } +} + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct HotCacheDownloadResult { @@ -618,12 +750,21 @@ async fn download_track_hot_cache( if !response.status().is_success() { return Err(format!("HTTP {}", response.status().as_u16())); } - let bytes = response.bytes().await.map_err(|e| e.to_string())?; - tokio::fs::write(&file_path, &bytes) + + // Stream directly to a .part file; rename on success to avoid partial files. + let part_path = file_path.with_extension(format!("{suffix}.part")); + if let Err(e) = stream_to_file(response, &part_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + return Err(e); + } + tokio::fs::rename(&part_path, &file_path) .await .map_err(|e| e.to_string())?; - let size = bytes.len() as u64; + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); Ok(HotCacheDownloadResult { path: path_str, size, @@ -870,6 +1011,7 @@ pub fn run() { .manage(audio_engine) .manage(ShortcutMap::default()) .manage(discord::DiscordState::new()) + .manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore) .manage(TrayState::default()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) @@ -1062,6 +1204,8 @@ pub fn run() { delete_hot_cache_track, purge_hot_cache, toggle_tray_icon, + check_dir_accessible, + download_zip, ]) .run(tauri::generate_context!()) .expect("error while running Psysonic"); diff --git a/src/App.tsx b/src/App.tsx index 9464d241..388d6a76 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -67,6 +67,8 @@ import { useFontStore } from './store/fontStore'; import { useEqStore } from './store/eqStore'; import { useKeybindingsStore } from './store/keybindingsStore'; import { useGlobalShortcutsStore } from './store/globalShortcutsStore'; +import { useZipDownloadStore } from './store/zipDownloadStore'; +import ZipDownloadOverlay from './components/ZipDownloadOverlay'; function RequireAuth({ children }: { children: React.ReactNode }) { const { isLoggedIn, servers, activeServerId } = useAuthStore(); @@ -397,6 +399,15 @@ function TauriEventBridge() { const next = usePlayerStore(s => s.next); const previous = usePlayerStore(s => s.previous); + // ZIP download progress events from Rust + useEffect(() => { + let unlisten: (() => void) | undefined; + listen<{ id: string; bytes: number; total: number | null }>('download:zip:progress', e => { + useZipDownloadStore.getState().updateProgress(e.payload.id, e.payload.bytes, e.payload.total); + }).then(u => { unlisten = u; }); + return () => { unlisten?.(); }; + }, []); + // Sync tray-icon visibility with the user's stored setting. // Runs once on mount (initial sync) and again whenever the setting changes. const showTrayIcon = useAuthStore(s => s.showTrayIcon); @@ -609,6 +620,7 @@ export default function App() { /> {exportPickerOpen && setExportPickerOpen(false)} />} + ); } diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 4518932b..58380c8d 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -10,8 +10,9 @@ import { useAuthStore } from '../store/authStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { usePlaylistStore } from '../store/playlistStore'; import { open } from '@tauri-apps/plugin-shell'; -import { writeFile } from '@tauri-apps/plugin-fs'; import { join } from '@tauri-apps/api/path'; +import { invoke } from '@tauri-apps/api/core'; +import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useTranslation } from 'react-i18next'; function sanitizeFilename(name: string): string { @@ -279,19 +280,22 @@ export default function ContextMenu() { }; const downloadAlbum = async (albumName: string, albumId: string) => { - try { - const folder = auth.downloadFolder || await requestDownloadFolder(); - if (!folder) return; + const folder = auth.downloadFolder || await requestDownloadFolder(); + if (!folder) return; - const url = buildDownloadUrl(albumId); - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const blob = await response.blob(); - const buffer = await blob.arrayBuffer(); - const path = await join(folder, `${sanitizeFilename(albumName)}.zip`); - await writeFile(path, new Uint8Array(buffer)); + const filename = `${sanitizeFilename(albumName)}.zip`; + const destPath = await join(folder, filename); + const url = buildDownloadUrl(albumId); + const id = crypto.randomUUID(); + + const { start, complete, fail } = useZipDownloadStore.getState(); + start(id, filename); + try { + await invoke('download_zip', { id, url, destPath }); + complete(id); } catch (e) { - console.error('Download failed:', e); + fail(id); + console.error('ZIP download failed:', e); } }; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 3eb75150..05100a73 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import React, { useState, useRef, useLayoutEffect, useEffect, useCallback } from import { createPortal } from 'react-dom'; import { usePlayerStore } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; +import { useOfflineJobStore } from '../store/offlineJobStore'; import { useAuthStore } from '../store/authStore'; import { useSidebarStore } from '../store/sidebarStore'; import { NavLink } from 'react-router-dom'; @@ -9,7 +10,7 @@ import { useTranslation } from 'react-i18next'; import { Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast, - ChevronDown, Check, Music2, TrendingUp, FolderOpen, + ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, } from 'lucide-react'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; @@ -44,7 +45,8 @@ export default function Sidebar({ const { t } = useTranslation(); const isPlaying = usePlayerStore(s => s.isPlaying); const currentTrack = usePlayerStore(s => s.currentTrack); - const offlineJobs = useOfflineStore(s => s.jobs); + const offlineJobs = useOfflineJobStore(s => s.jobs); + const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads); const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading'); const offlineAlbums = useOfflineStore(s => s.albums); const serverId = useAuthStore(s => s.activeServerId ?? ''); @@ -288,6 +290,15 @@ export default function Sidebar({ {!isCollapsed && ( {t('sidebar.downloadingTracks', { n: activeJobs.length })} )} + )} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 03d29e30..c4cc641b 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -3,9 +3,8 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { X, Minus, Square } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; -const win = getCurrentWindow(); - export default function TitleBar() { + const win = getCurrentWindow(); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); diff --git a/src/components/ZipDownloadOverlay.tsx b/src/components/ZipDownloadOverlay.tsx new file mode 100644 index 00000000..1194ba7d --- /dev/null +++ b/src/components/ZipDownloadOverlay.tsx @@ -0,0 +1,78 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { HardDriveDownload, Check, X } from 'lucide-react'; +import { useZipDownloadStore } from '../store/zipDownloadStore'; + +function formatMB(bytes: number): string { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function ZipDownloadItem({ id }: { id: string }) { + const dismiss = useZipDownloadStore(s => s.dismiss); + const item = useZipDownloadStore(s => s.downloads.find(d => d.id === id)); + + // Auto-dismiss 3 s after completion or error. + useEffect(() => { + if (!item?.done && !item?.error) return; + const timer = setTimeout(() => dismiss(id), 3000); + return () => clearTimeout(timer); + }, [item?.done, item?.error, id, dismiss]); + + if (!item) return null; + + const pct = item.total && item.total > 0 + ? Math.min(100, (item.bytes / item.total) * 100) + : null; + + const isIndeterminate = !item.done && !item.error && (item.total === null || item.total === 0); + + return ( +
+
+ {item.done + ? + : item.error + ? + : + } + {item.filename} + {(item.done || item.error) && ( + + )} +
+ + {!item.done && !item.error && ( + <> +
+ {formatMB(item.bytes)} + {item.total !== null && item.total > 0 && ( + <> / {formatMB(item.total)}  ({pct!.toFixed(0)}%) + )} +
+
+ {!isIndeterminate && pct !== null && ( +
+ )} +
+ + )} +
+ ); +} + +export default function ZipDownloadOverlay() { + // Subscribe to the array reference directly — never derive a new array in the selector + // (selector returning new array on every call causes an infinite re-render loop). + const downloads = useZipDownloadStore(s => s.downloads); + if (downloads.length === 0) return null; + + return createPortal( +
+ {downloads.map(d => )} +
, + document.body, + ); +} diff --git a/src/locales/de.ts b/src/locales/de.ts index 0fa09110..9b992203 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -16,6 +16,7 @@ export const deTranslation = { expand: 'Sidebar einblenden', collapse: 'Sidebar ausblenden', downloadingTracks: '{{n}} Tracks werden gecacht…', + cancelDownload: 'Download abbrechen', offlineLibrary: 'Offline-Bibliothek', genres: 'Genres', playlists: 'Playlists', @@ -823,6 +824,7 @@ export const deTranslation = { addSong: 'Zur Playlist hinzufügen', cacheOffline: 'Playlist offline speichern', offlineCached: 'Playlist gecacht', + removeOffline: 'Aus Offline-Cache entfernen', offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)', publicLabel: 'Öffentlich', privateLabel: 'Privat', diff --git a/src/locales/en.ts b/src/locales/en.ts index 200db414..57686477 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -17,6 +17,7 @@ export const enTranslation = { collapse: 'Collapse Sidebar', downloadingTracks: 'Caching {{n}} tracks…', + cancelDownload: 'Cancel download', offlineLibrary: 'Offline Library', genres: 'Genres', playlists: 'Playlists', @@ -824,6 +825,7 @@ export const enTranslation = { addSong: 'Add to playlist', cacheOffline: 'Cache playlist offline', offlineCached: 'Playlist cached', + removeOffline: 'Remove from offline cache', offlineDownloading: 'Caching… ({{done}}/{{total}} albums)', publicLabel: 'Public', privateLabel: 'Private', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 40295cda..fa105b93 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -16,6 +16,7 @@ export const frTranslation = { expand: 'Développer la barre latérale', collapse: 'Réduire la barre latérale', downloadingTracks: '{{n}} pistes en cache…', + cancelDownload: 'Annuler le téléchargement', offlineLibrary: 'Bibliothèque hors ligne', genres: 'Genres', playlists: 'Playlists', @@ -821,6 +822,7 @@ export const frTranslation = { addSong: 'Ajouter à la playlist', cacheOffline: 'Mettre la playlist hors ligne', offlineCached: 'Playlist en cache', + removeOffline: 'Retirer du cache hors ligne', offlineDownloading: 'En cache… ({{done}}/{{total}} albums)', publicLabel: 'Publique', privateLabel: 'Privée', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 787749d3..7349abac 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -16,6 +16,7 @@ export const nbTranslation = { expand: 'Utvid sidefelt', collapse: 'Skjul sidefelt', downloadingTracks: 'Bufre {{n}} spor…', + cancelDownload: 'Avbryt nedlasting', offlineLibrary: 'Frakoblet bibliotek', genres: 'Sjangere', playlists: 'Spillelister', @@ -820,6 +821,7 @@ export const nbTranslation = { addSong: 'Legg til i spilleliste', cacheOffline: 'Bufre spilleliste offline', offlineCached: 'Spilleliste bufret', + removeOffline: 'Fjern fra offline-buffer', offlineDownloading: 'Bufre… ({{done}}/{{total}} album)', publicLabel: 'Offentlig', privateLabel: 'Privat', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 504ab510..938e50ef 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -16,6 +16,7 @@ export const nlTranslation = { expand: 'Zijbalk uitklappen', collapse: 'Zijbalk inklappen', downloadingTracks: '{{n}} nummers worden gecached…', + cancelDownload: 'Download annuleren', offlineLibrary: 'Offline bibliotheek', genres: 'Genres', playlists: 'Playlists', @@ -821,6 +822,7 @@ export const nlTranslation = { addSong: 'Toevoegen aan playlist', cacheOffline: 'Playlist offline opslaan', offlineCached: 'Playlist gecached', + removeOffline: 'Verwijder uit offline cache', offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)', publicLabel: 'Openbaar', privateLabel: 'Privé', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index e5d37b0e..db892089 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -17,6 +17,7 @@ export const ruTranslation = { expand: 'Развернуть боковую панель', collapse: 'Свернуть боковую панель', downloadingTracks: 'Кэширование {{n}} треков…', + cancelDownload: 'Отменить загрузку', offlineLibrary: 'Офлайн-библиотека', genres: 'Жанры', playlists: 'Плейлисты', @@ -878,6 +879,7 @@ export const ruTranslation = { addSong: 'В плейлист', cacheOffline: 'Сохранить плейлист офлайн', offlineCached: 'Плейлист сохранён', + removeOffline: 'Удалить из офлайн-кэша', offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)', publicLabel: 'Публичный', privateLabel: 'Личный', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index ba16101d..43e7abf8 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -16,6 +16,7 @@ export const zhTranslation = { expand: '展开侧边栏', collapse: '收起侧边栏', downloadingTracks: '正在缓存 {{n}} 首歌曲…', + cancelDownload: '取消下载', offlineLibrary: '离线音乐库', genres: '流派', playlists: '播放列表', @@ -817,6 +818,7 @@ export const zhTranslation = { addSong: '添加到播放列表', cacheOffline: '离线缓存播放列表', offlineCached: '播放列表已缓存', + removeOffline: '从离线缓存中移除', offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)', publicLabel: '公开', privateLabel: '私有', diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index f28355b7..4b50979b 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -6,8 +6,9 @@ import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { useOfflineStore } from '../store/offlineStore'; -import { writeFile } from '@tauri-apps/plugin-fs'; +import { useOfflineJobStore } from '../store/offlineJobStore'; import { join } from '@tauri-apps/api/path'; +import { useZipDownloadStore } from '../store/zipDownloadStore'; import AlbumCard from '../components/AlbumCard'; import AlbumHeader from '../components/AlbumHeader'; import AlbumTrackList from '../components/AlbumTrackList'; @@ -44,15 +45,12 @@ export default function AlbumDetail() { const [bio, setBio] = useState(null); const [bioOpen, setBioOpen] = useState(false); const [loading, setLoading] = useState(true); - const [downloadProgress, setDownloadProgress] = useState(null); const [isStarred, setIsStarred] = useState(false); const [starredSongs, setStarredSongs] = useState>(new Set()); const [offlineStorageFull, setOfflineStorageFull] = useState(false); - const { downloadAlbum, deleteAlbum } = useOfflineStore(); - const offlineTracks = useOfflineStore(s => s.tracks); - const offlineAlbums = useOfflineStore(s => s.albums); - const offlineJobs = useOfflineStore(s => s.jobs); + const downloadAlbum = useOfflineStore(s => s.downloadAlbum); + const deleteAlbum = useOfflineStore(s => s.deleteAlbum); const serverId = auth.activeServerId ?? ''; const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer); const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); @@ -60,22 +58,32 @@ export default function AlbumDetail() { const [albumEntityRating, setAlbumEntityRating] = useState(0); - const offlineStatus: 'none' | 'downloading' | 'cached' = (() => { - if (!album) return 'none'; - const meta = offlineAlbums[`${serverId}:${album.album.id}`]; - const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!offlineTracks[`${serverId}:${tid}`]); - if (isDownloaded) return 'cached'; - const isDownloading = offlineJobs.some(j => j.albumId === album.album.id && (j.status === 'queued' || j.status === 'downloading')); - return isDownloading ? 'downloading' : 'none'; - })(); + // Derive a stable albumId for the selectors below (empty string when not yet loaded). + const albumId = album?.album.id ?? ''; - const offlineProgress = (() => { - if (!album) return null; - const albumJobs = offlineJobs.filter(j => j.albumId === album.album.id); - if (albumJobs.length === 0) return null; - const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length; - return { done, total: albumJobs.length }; - })(); + // Selectors return primitives so Zustand only triggers a re-render when the VALUE + // actually changes — not on every `jobs` array mutation during batch downloads. + const offlineStatus = useOfflineStore((s): 'none' | 'downloading' | 'cached' => { + if (!albumId) return 'none'; + const meta = s.albums[`${serverId}:${albumId}`]; + const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]); + return isDownloaded ? 'cached' : 'none'; + }); + const isOfflineDownloading = useOfflineJobStore(s => + !!albumId && s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')) + ); + const offlineProgressDone = useOfflineJobStore(s => { + if (!albumId) return 0; + return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length; + }); + const offlineProgressTotal = useOfflineJobStore(s => { + if (!albumId) return 0; + return s.jobs.filter(j => j.albumId === albumId).length; + }); + const resolvedOfflineStatus = isOfflineDownloading ? 'downloading' : offlineStatus; + const offlineProgress = offlineProgressTotal > 0 + ? { done: offlineProgressDone, total: offlineProgressTotal } + : null; useEffect(() => { if (!id) return; @@ -181,45 +189,22 @@ const handleEnqueueAll = () => { if (!album) return; const { name, id: albumId } = album.album; - // Ask for folder before starting download if not already set const folder = auth.downloadFolder || await requestDownloadFolder(); if (!folder) return; - setDownloadProgress(0); + const filename = `${sanitizeFilename(name)}.zip`; + const destPath = await join(folder, filename); + const url = buildDownloadUrl(albumId); + const downloadId = crypto.randomUUID(); + + const { start, complete, fail } = useZipDownloadStore.getState(); + start(downloadId, filename); try { - const url = buildDownloadUrl(albumId); - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const contentLength = response.headers.get('Content-Length'); - const total = contentLength ? parseInt(contentLength, 10) : 0; - const chunks: Uint8Array[] = []; - - if (total && response.body) { - const reader = response.body.getReader(); - let received = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - received += value.length; - setDownloadProgress(Math.round((received / total) * 100)); - } - } else { - const buffer = await response.arrayBuffer() as ArrayBuffer; - chunks.push(new Uint8Array(buffer)); - setDownloadProgress(100); - } - - const blob = new Blob(chunks); - const buffer = await blob.arrayBuffer(); - const path = await join(folder, `${sanitizeFilename(name)}.zip`); - await writeFile(path, new Uint8Array(buffer)); + await invoke('download_zip', { id: downloadId, url, destPath }); + complete(downloadId); } catch (e) { - console.error('Download failed:', e); - setDownloadProgress(null); - } finally { - setTimeout(() => setDownloadProgress(null), 60000); + fail(downloadId); + console.error('ZIP download failed:', e); } }; @@ -282,6 +267,12 @@ const handleEnqueueAll = () => { const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]); const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey); + // Must be before early returns — hooks must be called unconditionally. + const mergedStarredSongs = useMemo(() => new Set([ + ...[...starredSongs].filter(id => starredOverrides[id] !== false), + ...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k), + ]), [starredSongs, starredOverrides]); + if (loading) return
; if (!album) return
{t('albumDetail.notFound')}
; @@ -297,7 +288,7 @@ const handleEnqueueAll = () => { coverKey={coverKey} resolvedCoverUrl={resolvedCoverUrl} isStarred={isStarred} - downloadProgress={downloadProgress} + downloadProgress={null} bio={bio} bioOpen={bioOpen} onToggleStar={toggleStar} @@ -306,7 +297,7 @@ const handleEnqueueAll = () => { onEnqueueAll={handleEnqueueAll} onBio={handleBio} onCloseBio={() => setBioOpen(false)} - offlineStatus={offlineStatus} + offlineStatus={resolvedOfflineStatus} offlineProgress={offlineProgress} onCacheOffline={handleCacheOffline} onRemoveOffline={handleRemoveOffline} @@ -334,10 +325,7 @@ const handleEnqueueAll = () => { isPlaying={isPlaying} ratings={ratings} userRatingOverrides={userRatingOverrides} - starredSongs={new Set([ - ...[...starredSongs].filter(id => starredOverrides[id] !== false), - ...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k), - ])} + starredSongs={mergedStarredSongs} onPlaySong={handlePlaySong} onRate={handleRate} onToggleSongStar={toggleSongStar} diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index d04afb0d..298ee000 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -6,9 +6,10 @@ import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { useOfflineStore } from '../store/offlineStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; -import { writeFile } from '@tauri-apps/plugin-fs'; +import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; import { showToast } from '../utils/toast'; +import { useZipDownloadStore } from '../store/zipDownloadStore'; import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react'; type SortType = 'alphabeticalByName' | 'alphabeticalByArtist'; @@ -31,7 +32,7 @@ export default function Albums() { const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const auth = useAuthStore(); const serverId = useAuthStore(s => s.activeServerId ?? ''); - const { downloadAlbum } = useOfflineStore(); + const downloadAlbum = useOfflineStore(s => s.downloadAlbum); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const [albums, setAlbums] = useState([]); @@ -72,26 +73,23 @@ export default function Albums() { if (selectedAlbums.length === 0) return; const folder = auth.downloadFolder || await requestDownloadFolder(); if (!folder) return; - - let done = 0; + const { start, complete, fail } = useZipDownloadStore.getState(); + clearSelection(); for (const album of selectedAlbums) { - showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info'); + const downloadId = crypto.randomUUID(); + const filename = `${sanitizeFilename(album.name)}.zip`; + const destPath = await join(folder, filename); + const url = buildDownloadUrl(album.id); + start(downloadId, filename); try { - const url = buildDownloadUrl(album.id); - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const blob = await response.blob(); - const buffer = await blob.arrayBuffer(); - const path = await join(folder, `${sanitizeFilename(album.name)}.zip`); - await writeFile(path, new Uint8Array(buffer)); - done++; + await invoke('download_zip', { id: downloadId, url, destPath }); + complete(downloadId); } catch (e) { + fail(downloadId); console.error('ZIP download failed for', album.name, e); showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error'); } } - showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info'); - clearSelection(); }; const handleAddOffline = async () => { diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 0a5f4088..7c08ba0a 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -8,6 +8,7 @@ import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveD import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; +import { useOfflineJobStore } from '../store/offlineJobStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm'; @@ -69,7 +70,8 @@ export default function ArtistDetail() { const openContextMenu = usePlayerStore(state => state.openContextMenu); const currentTrack = usePlayerStore(state => state.currentTrack); const isPlaying = usePlayerStore(state => state.isPlaying); - const { downloadArtist, bulkProgress } = useOfflineStore(); + const downloadArtist = useOfflineStore(s => s.downloadArtist); + const bulkProgress = useOfflineJobStore(s => s.bulkProgress); const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer); diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index 44ce8dde..95e3694d 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -7,9 +7,10 @@ import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { useOfflineStore } from '../store/offlineStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; -import { writeFile } from '@tauri-apps/plugin-fs'; +import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; import { showToast } from '../utils/toast'; +import { useZipDownloadStore } from '../store/zipDownloadStore'; const PAGE_SIZE = 30; @@ -29,7 +30,7 @@ export default function NewReleases() { const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const auth = useAuthStore(); const serverId = useAuthStore(s => s.activeServerId ?? ''); - const { downloadAlbum } = useOfflineStore(); + const downloadAlbum = useOfflineStore(s => s.downloadAlbum); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const [albums, setAlbums] = useState([]); @@ -54,21 +55,23 @@ export default function NewReleases() { if (selectedAlbums.length === 0) return; const folder = auth.downloadFolder || await requestDownloadFolder(); if (!folder) return; - let done = 0; + const { start, complete, fail } = useZipDownloadStore.getState(); + clearSelection(); for (const album of selectedAlbums) { - showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info'); + const downloadId = crypto.randomUUID(); + const filename = `${sanitizeFilename(album.name)}.zip`; + const destPath = await join(folder, filename); + const url = buildDownloadUrl(album.id); + start(downloadId, filename); try { - const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); }); - const path = await join(folder, `${sanitizeFilename(album.name)}.zip`); - await writeFile(path, new Uint8Array(await blob.arrayBuffer())); - done++; + await invoke('download_zip', { id: downloadId, url, destPath }); + complete(downloadId); } catch (e) { + fail(downloadId); console.error('ZIP download failed for', album.name, e); showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error'); } } - showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info'); - clearSelection(); }; const handleAddOffline = async () => { diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 43148f69..a0adbf0d 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -12,10 +12,12 @@ import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { usePlaylistStore } from '../store/playlistStore'; import { useOfflineStore } from '../store/offlineStore'; +import { useOfflineJobStore } from '../store/offlineJobStore'; import { useAuthStore } from '../store/authStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; -import { writeFile } from '@tauri-apps/plugin-fs'; +import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; +import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useDragDrop } from '../contexts/DragDropContext'; import CachedImage, { useCachedUrl } from '../components/CachedImage'; import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic'; @@ -83,8 +85,24 @@ export default function PlaylistDetail() { ); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const { startDrag, isDragging } = useDragDrop(); - const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore(); + const downloadPlaylist = useOfflineStore(s => s.downloadPlaylist); + const deleteAlbum = useOfflineStore(s => s.deleteAlbum); const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; + const isDownloading = useOfflineJobStore(s => + !!id && s.jobs.some(j => j.albumId === id && (j.status === 'queued' || j.status === 'downloading')) + ); + const isCached = useOfflineStore(s => { + if (!id) return false; + const meta = s.albums[`${activeServerId}:${id}`]; + if (!meta || meta.trackIds.length === 0) return false; + return meta.trackIds.every(tid => !!s.tracks[`${activeServerId}:${tid}`]); + }); + const offlineProgressDone = useOfflineJobStore(s => { + if (!id) return 0; + return s.jobs.filter(j => j.albumId === id && (j.status === 'done' || j.status === 'error')).length; + }); + const offlineProgressTotal = useOfflineJobStore(s => (!id ? 0 : s.jobs.filter(j => j.albumId === id).length)); + const offlineProgress = offlineProgressTotal > 0 ? { done: offlineProgressDone, total: offlineProgressTotal } : null; const downloadFolder = useAuthStore(s => s.downloadFolder); const setDownloadFolder = useAuthStore(s => s.setDownloadFolder); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); @@ -100,7 +118,9 @@ export default function PlaylistDetail() { const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - const [downloadProgress, setDownloadProgress] = useState(null); + const zipDownloads = useZipDownloadStore(s => s.downloads); + const [zipDownloadId, setZipDownloadId] = useState(null); + const activeZip = zipDownloadId ? zipDownloads.find(d => d.id === zipDownloadId) : undefined; // ── Bulk select ─────────────────────────────────────────────────── const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -299,38 +319,21 @@ export default function PlaylistDetail() { if (!playlist || !id) return; const folder = downloadFolder || await requestDownloadFolder(); if (!folder) return; - setDownloadProgress(0); + + const filename = `${sanitizeFilename(playlist.name)}.zip`; + const destPath = await join(folder, filename); + const url = buildDownloadUrl(id); + const downloadId = crypto.randomUUID(); + + const { start, complete, fail } = useZipDownloadStore.getState(); + start(downloadId, filename); + setZipDownloadId(downloadId); try { - const url = buildDownloadUrl(id); - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const contentLength = response.headers.get('Content-Length'); - const total = contentLength ? parseInt(contentLength, 10) : 0; - const chunks: Uint8Array[] = []; - if (total && response.body) { - const reader = response.body.getReader(); - let received = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - received += value.length; - setDownloadProgress(Math.round((received / total) * 100)); - } - } else { - const buffer = await response.arrayBuffer() as ArrayBuffer; - chunks.push(new Uint8Array(buffer)); - setDownloadProgress(100); - } - const blob = new Blob(chunks); - const buffer = await blob.arrayBuffer(); - const path = await join(folder, `${sanitizeFilename(playlist.name)}.zip`); - await writeFile(path, new Uint8Array(buffer)); + await invoke('download_zip', { id: downloadId, url, destPath }); + complete(downloadId); } catch (e) { - console.error('Download failed:', e); - setDownloadProgress(null); - } finally { - setTimeout(() => setDownloadProgress(null), 60000); + fail(downloadId); + console.error('ZIP download failed:', e); } }; @@ -587,33 +590,34 @@ export default function PlaylistDetail() { > {t('playlists.addSongs')} - {songs.length > 0 && id && (() => { - const isDownloading = isAlbumDownloading(id); - const isCached = isAlbumDownloaded(id, activeServerId); - const progress = isDownloading ? getAlbumProgress(id) : null; - return ( - - ); - })()} + {songs.length > 0 && id && ( + + )} {songs.length > 0 && ( - downloadProgress !== null ? ( + activeZip && !activeZip.done && !activeZip.error ? (
-
+
- {downloadProgress}% + {activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%
) : (