diff --git a/src-tauri/src/commands_v2/ephemeral.rs b/src-tauri/src/commands_v2/ephemeral.rs new file mode 100644 index 000000000..170ab20c9 --- /dev/null +++ b/src-tauri/src/commands_v2/ephemeral.rs @@ -0,0 +1,46 @@ +//! Tauri commands for the ephemeral library. +//! +//! Three commands surface the in-memory ephemeral state to the frontend: +//! * `v2_ephemeral_open_folder` — scan + extract metadata for every +//! audio file under a path, replace any prior session, return the +//! fresh track list. +//! * `v2_ephemeral_clear` — drop the current session. +//! * `v2_ephemeral_get_track` — fetch a single ephemeral track by its +//! synthetic id (used by the player when it sees a negative id and +//! needs to recover the metadata for queue display, etc.). The hot +//! playback path resolves ids inline via `EphemeralLibraryState::get_track`, +//! not through this command. + +use std::path::Path; +use tauri::State; + +use crate::ephemeral_library::{EphemeralFolderResult, EphemeralLibraryState}; +use qbz_library::LocalTrack; + +#[tauri::command] +pub async fn v2_ephemeral_open_folder( + path: String, + state: State<'_, EphemeralLibraryState>, +) -> Result { + log::info!("Command: v2_ephemeral_open_folder {}", path); + state + .open_folder(Path::new(&path)) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn v2_ephemeral_clear( + state: State<'_, EphemeralLibraryState>, +) -> Result<(), String> { + log::info!("Command: v2_ephemeral_clear"); + state.clear(); + Ok(()) +} + +#[tauri::command] +pub async fn v2_ephemeral_get_track( + track_id: i64, + state: State<'_, EphemeralLibraryState>, +) -> Result, String> { + Ok(state.get_track(track_id)) +} diff --git a/src-tauri/src/commands_v2/library.rs b/src-tauri/src/commands_v2/library.rs index ea1f54e0d..51827a8d4 100644 --- a/src-tauri/src/commands_v2/library.rs +++ b/src-tauri/src/commands_v2/library.rs @@ -826,6 +826,7 @@ pub async fn v2_library_get_tracks_by_ids( pub async fn v2_library_play_track( track_id: i64, library_state: State<'_, LibraryState>, + ephemeral_state: State<'_, crate::ephemeral_library::EphemeralLibraryState>, bridge: State<'_, CoreBridgeState>, offline_cache: State<'_, crate::offline_cache::OfflineCacheState>, app_state: State<'_, AppState>, @@ -838,6 +839,29 @@ pub async fn v2_library_play_track( .await .map_err(|e| e.to_string())?; + // Negative ids belong to the ephemeral library (an ad-hoc folder the + // user opened without persisting it to local_tracks). Resolve to the + // in-memory cache and skip every DB-bound branch below — ephemeral + // tracks are never qobuz_download, never offline-cached, never have a + // CUE pointer. Just read the file and hand it to the player. + if track_id < 0 { + let track = ephemeral_state + .get_track(track_id) + .ok_or_else(|| format!("Ephemeral track {} not found (session may have been cleared)", track_id))?; + let file_path = std::path::Path::new(&track.file_path); + if !file_path.exists() { + return Err(format!("Ephemeral file not found: {}", track.file_path)); + } + let audio_data = + std::fs::read(file_path).map_err(|e| format!("Failed to read ephemeral file: {}", e))?; + let bridge = bridge.get().await; + bridge + .player() + .play_data(audio_data, track_id as u64) + .map_err(|e| format!("Failed to play ephemeral track: {}", e))?; + return Ok(()); + } + let track = { let guard = library_state.db.lock().await; let db = guard.as_ref().ok_or("No active session - please log in")?; diff --git a/src-tauri/src/commands_v2/mod.rs b/src-tauri/src/commands_v2/mod.rs index 8159ee94f..675edb51d 100644 --- a/src-tauri/src/commands_v2/mod.rs +++ b/src-tauri/src/commands_v2/mod.rs @@ -83,3 +83,6 @@ pub use mixtapes::*; pub mod offline_cache; pub use offline_cache::*; + +mod ephemeral; +pub use ephemeral::*; diff --git a/src-tauri/src/ephemeral_library/mod.rs b/src-tauri/src/ephemeral_library/mod.rs new file mode 100644 index 000000000..e411b647f --- /dev/null +++ b/src-tauri/src/ephemeral_library/mod.rs @@ -0,0 +1,170 @@ +//! In-memory ephemeral library for ad-hoc folder playback. +//! +//! The user can point QBZ at a folder that lives outside their library +//! (a downloaded album they haven't decided to keep, an external drive, +//! etc.), browse it, and play tracks from it without anything landing +//! in `local_tracks`. The ephemeral session lives only in memory: a +//! `HashMap` keyed by *synthetic negative ids*. Negative +//! ids are how the rest of the playback pipeline distinguishes ephemeral +//! tracks from DB-resolvable ones — DB ids are always positive +//! (autoincrement), so a negative `track_id` arriving at +//! `v2_library_play_track` is unambiguously an ephemeral track and gets +//! routed here instead of the DB. +//! +//! Only one folder is held at a time; opening a new folder replaces the +//! previous session. The state vanishes on app exit by virtue of being +//! in-memory — nothing persists, no migration, no cleanup logic needed. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Mutex; + +use qbz_library::{LibraryError, LibraryScanner, LocalTrack, MetadataExtractor}; +use serde::Serialize; + +#[derive(Debug, Serialize, Clone)] +pub struct EphemeralFolderResult { + pub folder_path: String, + pub tracks: Vec, + pub skipped_files: usize, +} + +#[derive(Debug)] +pub enum EphemeralError { + Lock, + Library(String), + Io(String), +} + +impl std::fmt::Display for EphemeralError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Lock => write!(f, "ephemeral library state lock poisoned"), + Self::Library(msg) => write!(f, "{}", msg), + Self::Io(msg) => write!(f, "{}", msg), + } + } +} + +impl From for EphemeralError { + fn from(e: LibraryError) -> Self { + EphemeralError::Library(e.to_string()) + } +} + +struct EphemeralLibraryInner { + tracks: HashMap, + next_id: i64, + current_folder_path: Option, +} + +impl EphemeralLibraryInner { + fn new() -> Self { + Self { + tracks: HashMap::new(), + next_id: -1, + current_folder_path: None, + } + } + + fn reset(&mut self) { + self.tracks.clear(); + self.next_id = -1; + self.current_folder_path = None; + } +} + +pub struct EphemeralLibraryState { + inner: Mutex, +} + +impl EphemeralLibraryState { + pub fn new() -> Self { + Self { + inner: Mutex::new(EphemeralLibraryInner::new()), + } + } + + /// Scan a folder, extract metadata for every supported audio file + /// found, assign synthetic negative ids and stash the result. The + /// previous ephemeral session, if any, is dropped. + pub fn open_folder(&self, path: &Path) -> Result { + if !path.exists() { + return Err(EphemeralError::Io(format!( + "Folder does not exist: {}", + path.display() + ))); + } + if !path.is_dir() { + return Err(EphemeralError::Io(format!( + "Not a directory: {}", + path.display() + ))); + } + + let scanner = LibraryScanner::new(); + let scan = scanner.scan_directory(path)?; + + let mut tracks_out: Vec = Vec::with_capacity(scan.audio_files.len()); + let mut skipped_files: usize = 0; + + let mut inner = self.inner.lock().map_err(|_| EphemeralError::Lock)?; + inner.reset(); + + for audio_file in &scan.audio_files { + match MetadataExtractor::extract(audio_file) { + Ok(mut track) => { + track.id = inner.next_id; + inner.next_id -= 1; + track.source = Some("ephemeral".to_string()); + inner.tracks.insert(track.id, track.clone()); + tracks_out.push(track); + } + Err(e) => { + log::warn!( + "[ephemeral] failed to extract metadata from {}: {}", + audio_file.display(), + e + ); + skipped_files += 1; + } + } + } + + let folder_path = path.display().to_string(); + inner.current_folder_path = Some(folder_path.clone()); + + log::info!( + "[ephemeral] opened {} ({} tracks, {} skipped)", + folder_path, + tracks_out.len(), + skipped_files + ); + + Ok(EphemeralFolderResult { + folder_path, + tracks: tracks_out, + skipped_files, + }) + } + + pub fn clear(&self) { + if let Ok(mut inner) = self.inner.lock() { + inner.reset(); + } + } + + /// Resolve a synthetic negative id to the cached `LocalTrack`. Returns + /// `None` if the id is unknown (stale queue entry from a previous + /// session, race against `clear`, etc.). + pub fn get_track(&self, id: i64) -> Option { + let inner = self.inner.lock().ok()?; + inner.tracks.get(&id).cloned() + } +} + +impl Default for EphemeralLibraryState { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 25db81d03..5b40d1322 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,6 +39,7 @@ pub mod config; pub mod credentials; pub mod discogs; pub mod discord_rpc; +pub mod ephemeral_library; pub mod flatpak; #[cfg(target_os = "linux")] pub mod idle_inhibit; @@ -1468,6 +1469,7 @@ pub fn run(qconnect_cli_override: Option) { } }) .manage(library_state) + .manage(ephemeral_library::EphemeralLibraryState::new()) .manage(cast_state) .manage(dlna_state) .manage(offline_cache_state) @@ -1674,6 +1676,9 @@ pub fn run(qconnect_cli_override: Option) { commands_v2::v2_library_get_scan_progress, commands_v2::v2_library_get_tracks_by_ids, commands_v2::v2_library_play_track, + commands_v2::v2_ephemeral_open_folder, + commands_v2::v2_ephemeral_clear, + commands_v2::v2_ephemeral_get_track, commands_v2::v2_playlist_set_sort, commands_v2::v2_playlist_set_artwork, commands_v2::v2_playlist_get_all_settings, diff --git a/src/lib/components/views/LocalLibraryView.svelte b/src/lib/components/views/LocalLibraryView.svelte index 039a26d76..5b9f93ab3 100644 --- a/src/lib/components/views/LocalLibraryView.svelte +++ b/src/lib/components/views/LocalLibraryView.svelte @@ -7,7 +7,7 @@ import { onMount, onDestroy, tick, untrack } from 'svelte'; import { fade } from 'svelte/transition'; import { - HardDrive, Music, Disc3, MicVocal, FolderPlus, Trash2, RefreshCw, + HardDrive, Music, Disc3, MicVocal, FolderPlus, FolderOpen, Trash2, RefreshCw, Settings, ArrowLeft, X, Play, CircleAlert, ImageDown, Upload, Search, LayoutGrid, List, ListOrdered, PenLine, Network, Power, PowerOff, ChevronLeft, ChevronRight, Shuffle, SlidersHorizontal, ArrowUpDown, ChevronDown, Check, SquareCheckBig, CassetteTape, ChevronsDownUp } from 'lucide-svelte'; @@ -513,6 +513,12 @@ // line ~4563, hiding the tree rail. Tree mode keeps the navigation // visible by loading album tracks into a separate state. function handleFolderTreeSelect(path: string) { + // Navigating to a tree folder ends the ephemeral session — the user + // is moving back into the library proper, so the in-memory cache and + // the right-pane override both get dropped. + if (ephemeralFolder) { + void closeEphemeralFolder(); + } selectedFolderPath = path; } @@ -563,6 +569,125 @@ await handleTrackPlay(track); } + // Open an ad-hoc folder via the OS file picker. The backend scans the + // folder, extracts metadata for every supported audio file and stashes + // the result in EphemeralLibraryState (in-memory, negative ids). The + // tree itself is untouched — the right pane shows the result instead + // of the regular folder/album views. Replaces any prior ephemeral + // session. + async function openEphemeralFolder(): Promise { + if (openingEphemeralFolder) return; + try { + const selected = await open({ + directory: true, + multiple: false, + title: $t('library.ephemeralFolder.dialogTitle') + }); + if (typeof selected !== 'string' || selected.length === 0) return; + + openingEphemeralFolder = true; + // Clear the existing folder/album selection BEFORE assigning the + // ephemeral result. The $effect that closes ephemeral on + // selection-change reacts to either field becoming non-null while + // ephemeral is set; doing the reset first means we never enter the + // racy intermediate state where both ephemeral and a real + // selection are non-null at the same time. + selectedFolderPath = null; + selectedAlbum = null; + const result = await invoke('v2_ephemeral_open_folder', { + path: selected + }); + ephemeralFolder = result; + if (result.tracks.length === 0) { + showToast($t('library.ephemeralFolder.empty'), 'info'); + } else if (result.skippedFiles > 0) { + showToast( + $t('library.ephemeralFolder.openedWithSkips', { + values: { count: result.tracks.length, skipped: result.skippedFiles } + }), + 'success' + ); + } else { + showToast( + $t('library.ephemeralFolder.opened', { values: { count: result.tracks.length } }), + 'success' + ); + } + } catch (err) { + console.error('[LocalLibrary] Failed to open ephemeral folder:', err); + showToast( + $t('library.ephemeralFolder.openFailed', { values: { error: String(err) } }), + 'error' + ); + } finally { + openingEphemeralFolder = false; + } + } + + async function closeEphemeralFolder(): Promise { + ephemeralFolder = null; + try { + await invoke('v2_ephemeral_clear'); + } catch (err) { + console.warn('[LocalLibrary] Failed to clear ephemeral state:', err); + } + } + + async function playEphemeralTrack(track: LocalTrack): Promise { + if (!ephemeralFolder) return; + try { + const startIndex = ephemeralFolder.tracks.findIndex((item) => item.id === track.id); + await setPlaybackContext( + 'local_library', + 'ephemeral', + $t('library.ephemeralFolder.contextLabel'), + 'local', + ephemeralFolder.tracks.map((item) => item.id), + Math.max(0, startIndex) + ); + await setQueueForLocalTracks(ephemeralFolder.tracks, Math.max(0, startIndex)); + await invoke('v2_library_play_track', { trackId: track.id }); + } catch (err) { + const message = String(err); + console.error('[LocalLibrary] Failed to play ephemeral track:', err); + // Backend returns "Ephemeral file not found" / "Ephemeral track not found" + // when the folder or files have been moved/deleted/unmounted while the + // session was open. Treat that as the "directory or files no longer + // exist" cleanup trigger from the spec — drop the whole session so + // the user gets a clean slate instead of a half-broken pane. + if (message.includes('Ephemeral file not found') + || message.includes('Ephemeral track not found')) { + await closeEphemeralFolder(); + showToast($t('library.ephemeralFolder.gone'), 'info'); + return; + } + showToast( + $t('library.ephemeralFolder.playFailed', { values: { error: message } }), + 'error' + ); + } + } + + // Spec rule: an ephemeral session "survives until a different album is + // loaded, the Clear/Close button is pressed, or the directory/files are + // gone". This effect handles the first trigger — anything that points + // the right pane at a real album or tree folder ends the session. The + // Close button handles the second trigger directly via closeEphemeralFolder; + // the playEphemeralTrack error path handles the third. + $effect(() => { + if (!ephemeralFolder) return; + if (selectedAlbum != null || selectedFolderPath != null) { + untrack(() => { + void closeEphemeralFolder(); + }); + } + }); + + async function playAllEphemeral(): Promise { + if (!ephemeralFolder || ephemeralFolder.tracks.length === 0) return; + await playEphemeralTrack(ephemeralFolder.tracks[0]); + } + function handleLibraryPreferencesSaved(prefs: LibraryPreferences) { libraryPreferences = sanitizeLibraryPreferences(prefs); // Keep the shared store in sync so the title-bar / sidebar dropdowns @@ -878,6 +1003,25 @@ // Path of the folder currently selected in the tree; drives the // right-pane routing (album-detail vs FolderDetail vs empty-state). let selectedFolderPath = $state(null); + + // Ephemeral folder session: when set, the right pane shows the contents + // of an ad-hoc folder the user opened via the "Open Folder" button. The + // tracks here have synthetic negative ids that route to the in-memory + // EphemeralLibraryState on the backend (see v2_library_play_track). The + // session vanishes on app exit (no DB persistence). + type EphemeralFolderState = { + folderPath: string; + tracks: LocalTrack[]; + skippedFiles: number; + }; + let ephemeralFolder = $state(null); + let openingEphemeralFolder = $state(false); + + const ephemeralFolderName = $derived.by(() => { + if (!ephemeralFolder) return ''; + const segments = ephemeralFolder.folderPath.split('/').filter(Boolean); + return segments.length > 0 ? segments[segments.length - 1] : ephemeralFolder.folderPath; + }); // Set of paths the user has expanded in the tree. Each top-level // shares this set so siblings stay in sync. let treeExpandedPaths = $state(new SvelteSet()); @@ -901,8 +1045,8 @@ // forcing the rail wider). Bounds: 200px floor; 40% of the live content // area as ceiling. Persisted on drag-end via // v2_set_library_folders_tree_sidebar_width. - const FOLDER_TREE_SIDEBAR_DEFAULT_WIDTH = 302; - const FOLDER_TREE_SIDEBAR_MIN_WIDTH = 200; + const FOLDER_TREE_SIDEBAR_DEFAULT_WIDTH = 290; + const FOLDER_TREE_SIDEBAR_MIN_WIDTH = 290; let folderTreeSidebarWidth = $state(FOLDER_TREE_SIDEBAR_DEFAULT_WIDTH); let isResizingTreeSidebar = $state(false); let folderTreeContentAreaWidth = $state(0); @@ -5476,6 +5620,16 @@ > +
- {#if selectedAlbumForTree} + {#if ephemeralFolder} + +
+
+
+ +
+
{ephemeralFolderName}
+
+ {$t('library.ephemeralFolder.subtitle', { + values: { + count: ephemeralFolder.tracks.length, + path: ephemeralFolder.folderPath + } + })} +
+
+
+
+ + +
+
+ {#if ephemeralFolder.tracks.length === 0} +
+ {$t('library.ephemeralFolder.empty')} +
+ {:else} +
+ {#each ephemeralFolder.tracks as track (track.id)} + + {/each} +
+ {/if} +
+ {:else if selectedAlbumForTree}