crabidy/crabidy-server/src/orphans.rs

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());
}
}