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:
vicrodh committed 2026-05-09 02:13:44 -06:00
1 parent 4454619371
commit aa015829aa
11 files changed
+752 -9

No files matched your search

+46
View File
@@ -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))
}
+24
View File
@@ -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")?;
+3
View File
@@ -83,3 +83,6 @@ pub use mixtapes::*;
pub mod offline_cache;
pub use offline_cache::*;
mod ephemeral;
pub use ephemeral::*;
+170
View File
@@ -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()
}
}
+5
View File
@@ -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,