379 lines
14 KiB
Rust
379 lines
14 KiB
Rust
//! 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<CrabidyStore>,
|
|
/// 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<PathBuf>,
|
|
}
|
|
|
|
impl OrphansProvider {
|
|
/// Builds the provider over `store`, scanning `ref_roots` for references.
|
|
pub fn new(store: Arc<CrabidyStore>, ref_roots: Vec<PathBuf>) -> Self {
|
|
Self { store, ref_roots }
|
|
}
|
|
|
|
/// The bare store name addressed by an `/orphans/<seg>` 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<String> {
|
|
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<LibraryNode, ProviderError> {
|
|
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<LibraryNode, ProviderError> {
|
|
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<Self, ProviderError> {
|
|
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<Vec<String>, 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<Track, ProviderError> {
|
|
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/<seg>` 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<LibraryNode, ProviderError> {
|
|
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<LibraryNode, ProviderError> {
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
|
|
/// Renames an orphan's store files (audio + sidecar), keeping the index in
|
|
/// sync; returns the node at its new `/orphans/<encode(new)>` path.
|
|
async fn rename_lib_node(
|
|
&self,
|
|
path: &str,
|
|
new_title: &str,
|
|
) -> Result<LibraryNode, ProviderError> {
|
|
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<LibraryNode, ProviderError> {
|
|
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<CrabidyStore>, 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());
|
|
}
|
|
}
|