//! The `/orphans` provider: a store garbage-collection view. //! //! `/orphans` surfaces content-store entries that no mounted local file //! provider references any more (see `architecture/orphans.md`). It is a //! read-mostly management subtree: each orphan is presented as an editable, //! deletable, queueable child node, so every client's existing `e`/`d`/queue //! gestures work unchanged (no proto surface is added). //! //! The heavy lifting lives on [`CrabidyStore`] (it owns the store root and the //! derived index); this provider only supplies the reference roots to walk and //! translates between library paths and store names. use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; use crabidy_core::{ proto::crabidy::{LibraryNode, LibraryNodeChild, Track}, ProviderClient, ProviderError, }; use tracing::warn; use crate::crabidy_store::{CrabidyStore, StoreError}; /// The single library segment this provider owns. pub const ORPHANS_PROVIDER_ROOT: &str = "/orphans"; /// Maps a [`StoreError`] to the provider-boundary error: a missing entry or a /// malformed name is a bad path, a taken/invalid rename target is bad input, /// everything else is internal. fn to_provider_error(err: StoreError) -> ProviderError { match err { StoreError::NotFound(_) => ProviderError::MalformedPath, StoreError::NameTaken(_) | StoreError::InvalidName(_) => ProviderError::InvalidInput, other => { warn!("orphans store error: {other}"); ProviderError::InternalError } } } /// The `/orphans` provider. /// /// Holds the store (for enumeration and mutation) and the disk roots of every /// mounted local file provider (for the reference scan). Constructed in /// [`crate::provider::ProviderOrchestrator::init`] only when the store is /// present. #[derive(Debug)] pub struct OrphansProvider { /// The content store: enumerates orphans and performs rename/delete. store: Arc, /// Disk roots to walk for `Playable::Store` references — the `/crabidy` /// toml tree and (when enabled) the `/fs` root. "Referenced" is defined as /// reachable through one of these (architecture/orphans.md). ref_roots: Vec, } impl OrphansProvider { /// Builds the provider over `store`, scanning `ref_roots` for references. pub fn new(store: Arc, ref_roots: Vec) -> Self { Self { store, ref_roots } } /// The bare store name addressed by an `/orphans/` path (its single /// decoded segment); `None` for the bare root, a deeper path, or a segment /// that is not a legal bare name (empty, `.`, `..`, or containing a /// separator) — which keeps a crafted path from escaping the store root. fn entry_name(&self, path: &str) -> Option { let rest = path .strip_prefix(ORPHANS_PROVIDER_ROOT)? .strip_prefix('/')?; if rest.is_empty() || rest.contains('/') { return None; } let name = crabidy_core::decode_segment(rest); if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) { return None; } Some(name) } /// The library path of the orphan named `name`. fn entry_path(name: &str) -> String { crabidy_core::join_path(ORPHANS_PROVIDER_ROOT, &crabidy_core::encode_segment(name)) } /// The `/orphans` root node, its children recomputed from the current /// orphan set. async fn root_node(&self) -> Result { let orphans = self .store .list_orphans(&self.ref_roots) .await .map_err(to_provider_error)?; let children = orphans .into_iter() .map(|orphan| { let mut child = LibraryNodeChild::new(Self::entry_path(&orphan.name), orphan.name, true); // Reuse the existing rename/delete/queue gestures: an orphan is // an editable, deletable, queueable, already-captured child. child.is_editable = true; child.is_deletable = true; child.is_downloadable = false; child.is_captured = true; child }) .collect(); Ok(LibraryNode { path: ORPHANS_PROVIDER_ROOT.to_string(), title: "orphans".to_string(), children, parent: Some(crabidy_core::ROOT_PATH.to_string()), tracks: Vec::new(), is_queable: true, is_creatable: false, is_downloadable: false, tracks_deletable: false, is_captured: false, }) } /// A single orphan's node: childless, queueable, carrying its one track. async fn entry_node(&self, path: &str, name: &str) -> Result { let track = self .store .orphan_track(name, path) .await .map_err(to_provider_error)?; Ok(LibraryNode { path: path.to_string(), title: name.to_string(), children: Vec::new(), parent: Some(ORPHANS_PROVIDER_ROOT.to_string()), tracks: vec![track], is_queable: true, is_creatable: false, is_downloadable: false, tracks_deletable: false, is_captured: true, }) } } #[async_trait] impl ProviderClient for OrphansProvider { /// Not constructed from a config string — built by the orchestrator with a /// store handle. Present only to satisfy the trait; never called. async fn init(_raw_toml_settings: &str) -> Result { Err(ProviderError::NotSupported) } fn settings(&self) -> String { String::new() } /// Always false: orphans are addressed as nodes. The one track per orphan /// is reached by resolving the node (the default `resolve_tracks_into` /// walk), not by a track path. fn is_track_path(&self, _path: &str) -> bool { false } /// Resolves an orphan's audio to its store file path (a local file, exactly /// like a resolved `Playable::Store`), so the walked-out track still plays. async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError> { let name = self .entry_name(track_path) .ok_or(ProviderError::MalformedPath)?; let url = self .store .orphan_url(&name) .await .map_err(to_provider_error)?; Ok(vec![url]) } /// Track metadata for a single orphan (from the sidecar's first provider /// entry); `is_captured` is true. async fn get_metadata_for_track(&self, track_path: &str) -> Result { let name = self .entry_name(track_path) .ok_or(ProviderError::MalformedPath)?; self.store .orphan_track(&name, track_path) .await .map_err(to_provider_error) } /// A minimal `/orphans` root; children are discovered via /// [`Self::get_lib_node`] (listing needs async store access). fn get_lib_root(&self) -> LibraryNode { LibraryNode { path: ORPHANS_PROVIDER_ROOT.to_string(), title: "orphans".to_string(), children: Vec::new(), parent: Some(crabidy_core::ROOT_PATH.to_string()), tracks: Vec::new(), is_queable: true, is_creatable: false, is_downloadable: false, tracks_deletable: false, is_captured: false, } } /// The `/orphans` root lists one child per current orphan; an /// `/orphans/` path returns that orphan's single-track node. /// Recomputes the orphan set on every call (no cache). async fn get_lib_node(&self, path: &str) -> Result { if path == ORPHANS_PROVIDER_ROOT { return self.root_node().await; } let name = self.entry_name(path).ok_or(ProviderError::MalformedPath)?; self.entry_node(path, &name).await } /// The root is not creatable. async fn create_lib_node( &self, _parent_path: &str, _title: &str, ) -> Result { Err(ProviderError::NotSupported) } /// Renames an orphan's store files (audio + sidecar), keeping the index in /// sync; returns the node at its new `/orphans/` path. async fn rename_lib_node( &self, path: &str, new_title: &str, ) -> Result { let old = self.entry_name(path).ok_or(ProviderError::NotSupported)?; self.store .rename_orphan(&old, new_title) .await .map_err(to_provider_error)?; let new_path = Self::entry_path(new_title.trim()); let name = self .entry_name(&new_path) .ok_or(ProviderError::InvalidInput)?; self.entry_node(&new_path, &name).await } /// Deletes an orphan's audio file and sidecar; returns the refreshed /// `/orphans` root. Idempotent. async fn delete_lib_node(&self, path: &str) -> Result { let name = self.entry_name(path).ok_or(ProviderError::NotSupported)?; self.store .delete_orphan(&name) .await .map_err(to_provider_error)?; self.root_node().await } } #[cfg(test)] mod tests { use super::*; use crate::crabidy_store::{ProviderEntry, StoreSidecar}; use std::path::Path; use tempfile::TempDir; async fn open_store() -> (Arc, TempDir) { let dir = TempDir::new().expect("tempdir"); let store = CrabidyStore::open(dir.path().join("state"), dir.path().join("store")) .await .expect("open store"); (Arc::new(store), dir) } /// Writes a store entry (audio + sidecar) directly, bypassing capture. async fn write_entry(store: &CrabidyStore, name: &str, hash: &str, id: &str, title: &str) { let root = store.store_dir(); tokio::fs::write(root.join(name), b"AUDIO") .await .expect("audio"); let sidecar = StoreSidecar { hash: hash.to_string(), providers: vec![ProviderEntry { provider: "tidal".to_string(), id: id.to_string(), title: title.to_string(), artist: "artist".to_string(), duration: Some(123), album: None, aliases: Vec::new(), }], }; let text = toml::to_string_pretty(&sidecar).expect("ser"); tokio::fs::write(root.join(format!("{name}.cbd-store.toml")), text) .await .expect("sidecar"); } /// Writes a `.cbd-track.toml` under `dir` that references store `name`. async fn write_store_reference(dir: &Path, file: &str, name: &str) { tokio::fs::create_dir_all(dir).await.expect("mkdir"); let toml = format!("title = \"ref\"\n\n[playable]\nstore = \"{name}\"\n"); tokio::fs::write(dir.join(file), toml) .await .expect("ref toml"); } #[tokio::test] async fn orphans_are_store_entries_no_reference_reaches() { let (store, dir) = open_store().await; write_entry(&store, "a.flac", "blake3:a", "1", "A").await; write_entry(&store, "b.flac", "blake3:b", "2", "B").await; // A reference under a scanned root rescues b.flac only. let ref_root = dir.path().join("tree"); write_store_reference(&ref_root, "0001 b.cbd-track.toml", "b.flac").await; let provider = OrphansProvider::new(store, vec![ref_root]); let root = provider.get_lib_node("/orphans").await.expect("root"); let names: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect(); assert_eq!(names, vec!["a.flac"], "only the unreferenced entry orphans"); let child = &root.children[0]; assert!(child.is_editable && child.is_deletable && child.is_queable && child.is_captured); assert!(!child.is_downloadable); } #[tokio::test] async fn a_leaf_carries_one_captured_track_that_resolves_to_store_audio() { let (store, _dir) = open_store().await; write_entry(&store, "song.flac", "blake3:s", "9", "Song").await; let provider = OrphansProvider::new(store.clone(), vec![]); let path = OrphansProvider::entry_path("song.flac"); let node = provider.get_lib_node(&path).await.expect("leaf"); assert!(node.children.is_empty()); assert_eq!(node.tracks.len(), 1); assert!(node.tracks[0].is_captured); assert_eq!(node.tracks[0].title, "Song"); let urls = provider.get_urls_for_track(&path).await.expect("urls"); let expected = store.store_dir().join("song.flac"); assert_eq!(urls, vec![expected.to_str().unwrap().to_string()]); } #[tokio::test] async fn rename_moves_both_files_and_delete_removes_them() { let (store, _dir) = open_store().await; write_entry(&store, "old.flac", "blake3:x", "7", "Old").await; let provider = OrphansProvider::new(store.clone(), vec![]); let path = OrphansProvider::entry_path("old.flac"); let renamed = provider .rename_lib_node(&path, "new.flac") .await .expect("rename"); assert_eq!(renamed.title, "new.flac"); assert!(store.store_dir().join("new.flac").is_file()); assert!(!store.store_dir().join("old.flac").exists()); assert!(store.store_dir().join("new.flac.cbd-store.toml").is_file()); assert!(!store.store_dir().join("old.flac.cbd-store.toml").exists()); let new_path = OrphansProvider::entry_path("new.flac"); provider.delete_lib_node(&new_path).await.expect("delete"); assert!(!store.store_dir().join("new.flac").exists()); assert!(!store.store_dir().join("new.flac.cbd-store.toml").exists()); } #[tokio::test] async fn unknown_and_traversal_paths_are_malformed() { let (store, _dir) = open_store().await; let provider = OrphansProvider::new(store, vec![]); // A never-created entry. assert!(matches!( provider.get_lib_node("/orphans/ghost.flac").await, Err(ProviderError::MalformedPath) )); // A traversal attempt decodes to a rejected segment. assert!(provider.entry_name("/orphans/..%2Fetc").is_none()); assert!(provider.entry_name("/orphans/a/b").is_none()); assert!(provider.entry_name("/orphans").is_none()); } }