feat(local-library): ephemeral folder playback (no DB persistence)
Adds an "Open Folder" button in the LocalLibrary > Folders tree toolbar that lets the user point QBZ at any folder on disk and play it without adding anything to local_tracks. Lets the app act as an ad-hoc media player for downloaded albums, external drives, etc. Backend: - New ephemeral_library module: in-memory HashMap<i64, LocalTrack> keyed by synthetic negative ids. Reuses LibraryScanner and MetadataExtractor to walk the folder and build LocalTrack records. - Three V2 commands: v2_ephemeral_open_folder (scan + populate, replaces any prior session), v2_ephemeral_clear, v2_ephemeral_get_track. - v2_library_play_track gets a small branch at the top: track_id < 0 resolves through the ephemeral state and goes straight to the regular local-file playback path (read file, hand to player). DB-backed paths (Qobuz cache, CUE start positions) are skipped — ephemeral tracks can't be any of those. Frontend: - "Open Folder" button (FolderOpen icon) next to the collapse-all button in the tree toolbar. Click opens the OS file picker, the result lands in a new ephemeralFolder $state. - Right pane gets a new branch above the existing album/folder views: when ephemeralFolder is set, render a dedicated pane with header (folder name, path, track count, Play All, Close) and a flat track list with play-on-click rows. - Lifecycle per spec: a session survives until (1) a different album or tree folder is selected — handled via $effect that watches selectedAlbum and selectedFolderPath, (2) the user clicks the Close button, or (3) the backend reports the file/directory is gone (caught in the play handler and folded into the same close path). - Translations for en, es, de, fr, pt. Notes: - The audio_cache key is `track_id as u64`, which for negative ids wraps to a large u64 value — unique and safe (DB ids are positive). - Edit-metadata gating for ephemeral tracks is deferred: there is no edit trigger from the new pane yet, so the sidecar/direct toggle behaviour is unchanged. Will gate when an edit path lands. cc
This commit is contained in:
11 files changed
+752
-9
No files matched your search
@@ -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<EphemeralFolderResult, String> {
|
||||
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<Option<LocalTrack>, String> {
|
||||
Ok(state.get_track(track_id))
|
||||
}
|
||||
@@ -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")?;
|
||||
|
||||
@@ -83,3 +83,6 @@ pub use mixtapes::*;
|
||||
|
||||
pub mod offline_cache;
|
||||
pub use offline_cache::*;
|
||||
|
||||
mod ephemeral;
|
||||
pub use ephemeral::*;
|
||||
@@ -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<i64, LocalTrack>` 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<LocalTrack>,
|
||||
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<LibraryError> for EphemeralError {
|
||||
fn from(e: LibraryError) -> Self {
|
||||
EphemeralError::Library(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
struct EphemeralLibraryInner {
|
||||
tracks: HashMap<i64, LocalTrack>,
|
||||
next_id: i64,
|
||||
current_folder_path: Option<String>,
|
||||
}
|
||||
|
||||
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<EphemeralLibraryInner>,
|
||||
}
|
||||
|
||||
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<EphemeralFolderResult, EphemeralError> {
|
||||
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<LocalTrack> = 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<LocalTrack> {
|
||||
let inner = self.inner.lock().ok()?;
|
||||
inner.tracks.get(&id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EphemeralLibraryState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -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<bool>) {
|
||||
}
|
||||
})
|
||||
.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<bool>) {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user