//! YouTube media provider, backed by a `yt-dlp` subprocess //! (see `architecture/youtube-provider.md`). //! //! Mounted at [`PROVIDER_ROOT`]. Search works without login: creatable //! search-term nodes exactly like `/tidal/search` (in-memory terms, //! renamable/deletable, results listed as tracks). With a cookies file //! configured ("logged in"), the user's playlists appear under //! `/youtube/playlists`. Every node that serves tracks is downloadable — //! `W` captures work out of the box. use std::path::PathBuf; use std::time::Duration; use async_trait::async_trait; use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild, Track}; use crabidy_core::{ProviderClient, ProviderError}; use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; pub mod engine; use engine::Engine; /// First path segment owned by this provider. pub const PROVIDER_ROOT: &str = "/youtube"; /// Default number of search results per term (`ytsearchN:`). pub const DEFAULT_SEARCH_RESULTS: usize = 20; /// Default per-subprocess-call timeout in seconds. pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 60; /// Provider settings, persisted as `ytdy.toml` next to the other crabidy /// config files. The cookies file is user-provided and its **contents are /// a secret**: only the path may ever be logged. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct Settings { /// The yt-dlp binary; a bare name resolves via PATH. Default /// `yt-dlp`. pub binary: Option, /// Netscape cookies file for logged-in features (playlists, /// age-gated streams). Absent = logged out; search still works. pub cookies: Option, /// Results per search term. Default [`DEFAULT_SEARCH_RESULTS`]. pub search_results: Option, /// Per-call timeout in seconds. Default /// [`DEFAULT_CALL_TIMEOUT_SECS`]; tests shrink it. pub call_timeout_secs: Option, } /// A parsed `/youtube/...` path. #[derive(Debug, PartialEq, Eq)] enum YtPath<'a> { Root, Search, /// Percent-encoded search term segment. SearchTerm(&'a str), SearchTrack { term: &'a str, video: &'a str, }, Playlists, Playlist(&'a str), PlaylistTrack { playlist: &'a str, video: &'a str, }, } /// Splits a `/youtube/...` path into its recognized shape. /// Unknown shapes are [`ProviderError::MalformedPath`]. fn parse_path(path: &str) -> Result, ProviderError> { if path == PROVIDER_ROOT { return Ok(YtPath::Root); } let rest = path .strip_prefix("/youtube/") .ok_or(ProviderError::MalformedPath)?; let segments: Vec<&str> = rest.split('/').collect(); if segments.iter().any(|segment| segment.is_empty()) { return Err(ProviderError::MalformedPath); } match segments.as_slice() { ["search"] => Ok(YtPath::Search), ["search", term] => Ok(YtPath::SearchTerm(term)), ["search", term, video] => Ok(YtPath::SearchTrack { term, video }), ["playlists"] => Ok(YtPath::Playlists), ["playlists", playlist] => Ok(YtPath::Playlist(playlist)), ["playlists", playlist, video] => Ok(YtPath::PlaylistTrack { playlist, video }), _ => Err(ProviderError::MalformedPath), } } /// Maps an engine failure to the trait-level error, logging the typed /// cause (paths and statuses only — never URLs or cookie contents). fn engine_err(context: &str, err: engine::EngineError) -> ProviderError { warn!(context, "yt-dlp call failed: {err}"); ProviderError::FetchError } /// The canonical watch URL for a video id. fn video_url(video_id: &str) -> String { format!("https://www.youtube.com/watch?v={video_id}") } /// Builds the wire track for one listing entry under `node_path`. /// Missing metadata degrades to empty fields, never an error; float /// durations truncate to whole seconds. fn entry_to_track(entry: &engine::Entry, node_path: &str) -> Track { Track { path: crabidy_core::join_path(node_path, &entry.id), artist: entry.artist().unwrap_or_default().to_string(), title: entry.title.clone().unwrap_or_default(), duration: entry.duration.map(|secs| secs.max(0.0) as u32), album: None, } } /// The YouTube provider client. #[derive(Debug)] pub struct Client { engine: Engine, settings: Settings, /// Cookies were configured and readable at init — gates the /// playlists subtree. logged_in: bool, /// Search terms created under `/youtube/search`, in creation order, /// deduplicated. In-memory only, like tidal's. Never held across /// awaits. search_terms: std::sync::RwLock>, } impl Client { /// The search-term node: top `search_results` results as tracks /// (`ytsearchN:` flat listing). Queueable and downloadable — /// results are homogeneous tracks. async fn search_term_node( &self, path: &str, term: &str, parent: String, ) -> Result { let count = self .settings .search_results .unwrap_or(DEFAULT_SEARCH_RESULTS); let listing = self .engine .flat_listing(&format!("ytsearch{count}:{term}")) .await .map_err(|err| engine_err("search", err))?; Ok(LibraryNode { path: path.to_string(), title: term.to_string(), parent: Some(parent), tracks: listing .entries .iter() .map(|entry| entry_to_track(entry, path)) .collect(), children: Vec::new(), is_queable: true, is_creatable: false, is_downloadable: false, }) } /// The playlists listing (`feed/playlists`, cookies required): /// one queueable, downloadable child per playlist. async fn playlists_node( &self, path: &str, parent: String, ) -> Result { let listing = self .engine .flat_listing("https://www.youtube.com/feed/playlists") .await .map_err(|err| engine_err("playlists feed", err))?; Ok(LibraryNode { path: path.to_string(), title: "playlists".to_string(), parent: Some(parent), tracks: Vec::new(), children: listing .entries .iter() .map(|entry| { LibraryNodeChild::new( crabidy_core::join_path(path, &entry.id), entry.title.clone().unwrap_or_else(|| entry.id.clone()), true, ) }) .collect(), is_queable: false, is_creatable: false, is_downloadable: false, }) } /// One playlist's entries as tracks. async fn playlist_node( &self, path: &str, playlist_id: &str, parent: String, ) -> Result { let listing = self .engine .flat_listing(&format!( "https://www.youtube.com/playlist?list={playlist_id}" )) .await .map_err(|err| engine_err("playlist", err))?; Ok(LibraryNode { path: path.to_string(), title: listing .title .clone() .unwrap_or_else(|| playlist_id.to_string()), parent: Some(parent), tracks: listing .entries .iter() .map(|entry| entry_to_track(entry, path)) .collect(), children: Vec::new(), is_queable: true, is_creatable: false, is_downloadable: false, }) } fn search_terms_snapshot(&self) -> Vec { self.search_terms .read() .map(|terms| terms.clone()) .unwrap_or_default() } fn register_search_term(&self, term: &str) { if let Ok(mut terms) = self.search_terms.write() { if !terms.iter().any(|existing| existing == term) { terms.push(term.to_string()); } } } /// Removes a term; `true` when it existed. fn remove_search_term(&self, term: &str) -> bool { match self.search_terms.write() { Ok(mut terms) => { let before = terms.len(); terms.retain(|existing| existing != term); terms.len() != before } Err(_) => false, } } } #[async_trait] impl ProviderClient for Client { /// Builds the engine from settings and probes `--version`; a missing /// or broken binary fails init (the orchestrator disables the /// provider non-fatally). A configured but unreadable cookies file /// degrades to logged-out with a warning, never an error. async fn init(raw_toml_settings: &str) -> Result { let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| { warn!("could not parse toml settings, using defaults"); Settings::default() }); let binary = settings .binary .clone() .unwrap_or_else(|| PathBuf::from("yt-dlp")); let timeout = Duration::from_secs( settings .call_timeout_secs .unwrap_or(DEFAULT_CALL_TIMEOUT_SECS), ); // "Logged in" is exactly "the configured cookies file is // readable"; anything less degrades to logged out, never to a // failed init. Only the *path* is ever logged. let logged_in = match &settings.cookies { Some(path) => match std::fs::metadata(path) { Ok(meta) if meta.is_file() => true, _ => { warn!( cookies = %path.display(), "cookies file not readable; running logged out" ); false } }, None => false, }; let cookies = logged_in.then(|| settings.cookies.clone()).flatten(); let engine = Engine::new(binary, cookies, timeout); let version = engine.probe().await.map_err(|err| { warn!("yt-dlp probe failed: {err}"); ProviderError::Config(err.to_string()) })?; debug!(version, logged_in, "yt-dlp ready"); Ok(Self { engine, settings, logged_in, search_terms: std::sync::RwLock::new(Vec::new()), }) } fn settings(&self) -> String { toml::to_string_pretty(&self.settings).unwrap_or_default() } fn is_track_path(&self, path: &str) -> bool { matches!( parse_path(path), Ok(YtPath::SearchTrack { .. } | YtPath::PlaylistTrack { .. }) ) } /// `-f bestaudio/best -g` on the track's video id. async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError> { let video = match parse_path(track_path)? { YtPath::SearchTrack { video, .. } | YtPath::PlaylistTrack { video, .. } => video, _ => return Err(ProviderError::MalformedPath), }; let urls = self .engine .stream_urls(&video_url(video)) .await .map_err(|err| engine_err("stream urls", err))?; if urls.is_empty() { warn!(path = track_path, "yt-dlp returned no stream url"); return Err(ProviderError::FetchError); } Ok(urls) } /// Single-video `-J` metadata. async fn get_metadata_for_track(&self, track_path: &str) -> Result { let video = match parse_path(track_path)? { YtPath::SearchTrack { video, .. } | YtPath::PlaylistTrack { video, .. } => video, _ => return Err(ProviderError::MalformedPath), }; let entry = self .engine .video_entry(&video_url(video)) .await .map_err(|err| engine_err("video metadata", err))?; let parent = crabidy_core::parent_path(track_path).unwrap_or(PROVIDER_ROOT); let mut track = entry_to_track(&entry, parent); // The entry's id names the video; the caller's path is canonical. track.path = track_path.to_string(); Ok(track) } /// `search` always; `playlists` only when logged in. fn get_lib_root(&self) -> LibraryNode { let mut children = vec![LibraryNodeChild { is_creatable: true, ..LibraryNodeChild::new( format!("{PROVIDER_ROOT}/search"), "search".to_string(), false, ) }]; if self.logged_in { children.push(LibraryNodeChild::new( format!("{PROVIDER_ROOT}/playlists"), "playlists".to_string(), false, )); } LibraryNode { path: PROVIDER_ROOT.to_string(), title: "youtube".to_string(), parent: Some(crabidy_core::ROOT_PATH.to_string()), tracks: Vec::new(), children, is_queable: false, is_creatable: false, is_downloadable: false, } } async fn get_lib_node(&self, path: &str) -> Result { let parent = crabidy_core::parent_path(path) .unwrap_or(crabidy_core::ROOT_PATH) .to_string(); let node = match parse_path(path)? { YtPath::Root => self.get_lib_root(), YtPath::Search => LibraryNode { path: path.to_string(), title: "search".to_string(), parent: Some(parent), tracks: Vec::new(), children: self .search_terms_snapshot() .iter() .map(|term| { // Term nodes are the modifiable nodes: renamable // (`e`) and deletable (`d`), like tidal's. LibraryNodeChild { is_editable: true, is_deletable: true, ..LibraryNodeChild::new( crabidy_core::join_path(path, &crabidy_core::encode_segment(term)), term.clone(), true, ) } }) .collect(), is_queable: false, is_creatable: true, is_downloadable: false, }, YtPath::SearchTerm(encoded) => { let term = crabidy_core::decode_segment(encoded); // Unknown terms (stale client cache, server restart) are // recreated implicitly instead of erroring. self.register_search_term(&term); self.search_term_node(path, &term, parent).await? } YtPath::Playlists => { if !self.logged_in { warn!(path, "playlists need a configured cookies file"); return Err(ProviderError::MalformedPath); } self.playlists_node(path, parent).await? } YtPath::Playlist(playlist_id) => { if !self.logged_in { warn!(path, "playlists need a configured cookies file"); return Err(ProviderError::MalformedPath); } self.playlist_node(path, playlist_id, parent).await? } YtPath::SearchTrack { .. } | YtPath::PlaylistTrack { .. } => { warn!(path, "get_lib_node called with a track path"); return Err(ProviderError::MalformedPath); } }; // The central download blessing (architecture/youtube-provider.md // D4, same rule as tidal): every node serving playable content // allows `W`; children mirror their queueability. let mut node = node; node.is_downloadable = node.is_queable || !node.tracks.is_empty(); for child in &mut node.children { child.is_downloadable = child.is_queable; } Ok(node) } /// Only `/youtube/search` is creatable: registers the term and /// returns its node (implicit recreation on stale paths, like /// tidal). async fn create_lib_node( &self, parent_path: &str, title: &str, ) -> Result { let term = title.trim(); if term.is_empty() { return Err(ProviderError::InvalidInput); } if parse_path(parent_path)? != YtPath::Search { warn!(parent_path, "node creation not supported here"); return Err(ProviderError::NotSupported); } self.register_search_term(term); let term_path = crabidy_core::join_path(parent_path, &crabidy_core::encode_segment(term)); self.get_lib_node(&term_path).await } /// Renaming a search term re-runs the search under the new term. async fn rename_lib_node( &self, path: &str, new_title: &str, ) -> Result { let YtPath::SearchTerm(encoded) = parse_path(path)? else { warn!(path, "only search terms are renamable"); return Err(ProviderError::NotSupported); }; let new_term = new_title.trim(); if new_term.is_empty() { return Err(ProviderError::InvalidInput); } let old_term = crabidy_core::decode_segment(encoded); // Replace in place; renaming onto an existing term merges (the // duplicate disappears), like tidal's terms. self.remove_search_term(&old_term); self.register_search_term(new_term); let new_path = crabidy_core::join_path("/youtube/search", &crabidy_core::encode_segment(new_term)); self.get_lib_node(&new_path).await } /// Deleting a search term is idempotent and returns the refreshed /// search node. async fn delete_lib_node(&self, path: &str) -> Result { let YtPath::SearchTerm(encoded) = parse_path(path)? else { warn!(path, "only search terms are deletable"); return Err(ProviderError::NotSupported); }; let term = crabidy_core::decode_segment(encoded); self.remove_search_term(&term); self.get_lib_node("/youtube/search").await } } #[cfg(test)] mod tests { use super::*; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; use tempfile::TempDir; /// A fake yt-dlp: a shell script dispatching on its argv. Every test /// engine call goes through it — no network, no real binary. fn fake_binary(dir: &Path, body: &str) -> PathBuf { let path = dir.join("fake-yt-dlp"); fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake binary"); let mut perms = fs::metadata(&path).expect("metadata").permissions(); perms.set_mode(0o755); fs::set_permissions(&path, perms).expect("chmod"); path } /// The standard fake: search terms `lofi` (2 results), one playlist /// feed, one playlist, and one resolvable video. const FAKE: &str = r#" case "$*" in *--version*) echo "2026.06.09"; exit 0 ;; *ytsearch2:lofi\ beats*) printf '%s' '{"entries":[{"id":"vidA","title":"Beats","uploader":"Chan"}]}'; exit 0 ;; *ytsearch2:lofi*) printf '%s' '{"entries":[{"id":"vid1","title":"Track One","uploader":"Chan","duration":63.4},{"id":"vid2","title":"Track Two","channel":"Chan Two"}]}'; exit 0 ;; *-g*watch?v=vid1*|*watch?v=vid1*-g*) printf '%s\n%s\n' "https://example.test/a.webm" "https://example.test/b.webm"; exit 0 ;; *watch?v=vid1*) printf '%s' '{"id":"vid1","title":"Track One","uploader":"Chan","duration":63.9}'; exit 0 ;; *feed/playlists*) printf '%s' '{"entries":[{"id":"PL1","title":"Road Mix"}]}'; exit 0 ;; *list=PL1*) printf '%s' '{"title":"Road Mix","entries":[{"id":"vid9","title":"Nine","uploader":"Chan","duration":10}]}'; exit 0 ;; *) echo "unmatched: $*" >&2; exit 1 ;; esac"#; async fn client_with(dir: &Path, body: &str, cookies: Option<&Path>) -> Client { let binary = fake_binary(dir, body); let settings = Settings { binary: Some(binary), cookies: cookies.map(Path::to_path_buf), search_results: Some(2), // Generous: under full-workspace parallel test load, process // spawn latency has flaked a 5 s budget. call_timeout_secs: Some(30), }; let toml = toml::to_string(&settings).expect("settings toml"); Client::init(&toml).await.expect("init") } async fn client(dir: &Path) -> Client { client_with(dir, FAKE, None).await } #[tokio::test] async fn init_probes_the_binary() { let dir = TempDir::new().expect("tempdir"); // A working probe succeeds… let _ = client(dir.path()).await; // …a missing binary fails init (the orchestrator treats that as // "provider disabled", never as a server error). let settings = Settings { binary: Some(dir.path().join("no-such-binary")), ..Settings::default() }; let toml = toml::to_string(&settings).expect("settings toml"); assert!(Client::init(&toml).await.is_err()); // …and so does one that exits non-zero on --version. let broken = TempDir::new().expect("tempdir"); fake_binary(broken.path(), "exit 3"); let settings = Settings { binary: Some(broken.path().join("fake-yt-dlp")), ..Settings::default() }; let toml = toml::to_string(&settings).expect("settings toml"); assert!(Client::init(&toml).await.is_err()); } #[tokio::test] async fn root_lists_playlists_only_when_logged_in() { let dir = TempDir::new().expect("tempdir"); let anon = client(dir.path()).await; let root = anon.get_lib_root(); let titles: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect(); assert_eq!(titles, vec!["search"]); assert!(root.children[0].is_creatable, "search is creatable"); let cookies = dir.path().join("cookies.txt"); fs::write(&cookies, "# Netscape HTTP Cookie File\n").expect("cookies"); let dir2 = TempDir::new().expect("tempdir"); let logged_in = client_with(dir2.path(), FAKE, Some(&cookies)).await; let root = logged_in.get_lib_root(); let titles: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect(); assert_eq!(titles, vec!["search", "playlists"]); // A configured but unreadable cookies file degrades to logged // out instead of failing init. let dir3 = TempDir::new().expect("tempdir"); let missing = dir3.path().join("gone.txt"); let degraded = client_with(dir3.path(), FAKE, Some(&missing)).await; assert_eq!(degraded.get_lib_root().children.len(), 1); } #[tokio::test] async fn search_terms_are_created_listed_and_searched() { let dir = TempDir::new().expect("tempdir"); let client = client(dir.path()).await; let node = client .create_lib_node("/youtube/search", "lofi") .await .expect("create term"); assert_eq!(node.path, "/youtube/search/lofi"); assert!(node.is_queable, "pure track results are queueable"); assert!(node.is_downloadable, "search results are downloadable"); assert_eq!(node.tracks.len(), 2); let one = &node.tracks[0]; assert_eq!(one.path, "/youtube/search/lofi/vid1"); assert_eq!(one.title, "Track One"); assert_eq!(one.artist, "Chan"); assert_eq!(one.duration, Some(63)); // `channel` is an accepted alias for the artist. assert_eq!(node.tracks[1].artist, "Chan Two"); // The search node lists the term as an editable/deletable child. let search = client .get_lib_node("/youtube/search") .await .expect("search"); assert!(search.is_creatable); assert_eq!(search.children.len(), 1); let child = &search.children[0]; assert_eq!(child.title, "lofi"); assert!(child.is_editable && child.is_deletable); // Encoded terms round trip; unknown terms recreate implicitly. let node = client .get_lib_node("/youtube/search/lofi%20beats") .await .expect("implicit term"); assert_eq!(node.tracks.len(), 1); assert_eq!(node.tracks[0].path, "/youtube/search/lofi%20beats/vidA"); assert_eq!(client.search_terms_snapshot().len(), 2); } #[tokio::test] async fn search_terms_rename_and_delete() { let dir = TempDir::new().expect("tempdir"); let client = client(dir.path()).await; let _ = client .create_lib_node("/youtube/search", "lofi beats") .await .expect("create term"); let renamed = client .rename_lib_node("/youtube/search/lofi%20beats", "lofi") .await .expect("rename re-searches"); assert_eq!(renamed.path, "/youtube/search/lofi"); assert_eq!(renamed.tracks.len(), 2); assert_eq!(client.search_terms_snapshot(), vec!["lofi".to_string()]); let search = client .delete_lib_node("/youtube/search/lofi") .await .expect("delete"); assert!(search.children.is_empty()); // Idempotent: deleting again still returns the search node. let again = client .delete_lib_node("/youtube/search/lofi") .await .expect("idempotent delete"); assert!(again.children.is_empty()); // Only /youtube/search children are mutable. assert!(client.rename_lib_node("/youtube", "nope").await.is_err()); assert!(client.delete_lib_node("/youtube/playlists").await.is_err()); } #[tokio::test] async fn playlists_list_and_resolve_when_logged_in() { let dir = TempDir::new().expect("tempdir"); let cookies = dir.path().join("cookies.txt"); fs::write(&cookies, "# cookies\n").expect("cookies"); let client = client_with(dir.path(), FAKE, Some(&cookies)).await; let playlists = client .get_lib_node("/youtube/playlists") .await .expect("playlists feed"); assert_eq!(playlists.children.len(), 1); let pl = &playlists.children[0]; assert_eq!(pl.path, "/youtube/playlists/PL1"); assert_eq!(pl.title, "Road Mix"); assert!(pl.is_queable && pl.is_downloadable); let node = client.get_lib_node(&pl.path).await.expect("playlist"); assert_eq!(node.title, "Road Mix"); assert!(node.is_queable && node.is_downloadable); assert_eq!(node.tracks.len(), 1); assert_eq!(node.tracks[0].path, "/youtube/playlists/PL1/vid9"); // Logged out, the playlists subtree is a malformed path. let dir2 = TempDir::new().expect("tempdir"); let anon = client_with(dir2.path(), FAKE, None).await; assert!(anon.get_lib_node("/youtube/playlists").await.is_err()); } #[tokio::test] async fn tracks_resolve_streams_and_metadata() { let dir = TempDir::new().expect("tempdir"); let client = client(dir.path()).await; assert!(client.is_track_path("/youtube/search/lofi/vid1")); assert!(client.is_track_path("/youtube/playlists/PL1/vid9")); assert!(!client.is_track_path("/youtube/search/lofi")); let urls = client .get_urls_for_track("/youtube/search/lofi/vid1") .await .expect("stream urls"); assert_eq!( urls, vec![ "https://example.test/a.webm".to_string(), "https://example.test/b.webm".into() ] ); let track = client .get_metadata_for_track("/youtube/playlists/PL1/vid1") .await .expect("metadata"); assert_eq!(track.title, "Track One"); assert_eq!(track.artist, "Chan"); assert_eq!(track.duration, Some(63)); assert_eq!(track.path, "/youtube/playlists/PL1/vid1"); } #[tokio::test] async fn engine_failures_are_typed_never_panics() { let dir = TempDir::new().expect("tempdir"); // Non-zero exit on everything but the probe. let body = r#" case "$*" in *--version*) echo 1.0; exit 0 ;; *) echo boom >&2; exit 1 ;; esac"#; let client = client_with(dir.path(), body, None).await; assert!(client.get_lib_node("/youtube/search/lofi").await.is_err()); assert!(client .get_urls_for_track("/youtube/search/lofi/vid1") .await .is_err()); // Malformed JSON is a typed error, not a panic. let dir2 = TempDir::new().expect("tempdir"); let body = r#" case "$*" in *--version*) echo 1.0; exit 0 ;; *) printf 'not json'; exit 0 ;; esac"#; let client = client_with(dir2.path(), body, None).await; assert!(client.get_lib_node("/youtube/search/lofi").await.is_err()); // A hung binary hits the per-call timeout. let dir3 = TempDir::new().expect("tempdir"); let body = r#" case "$*" in *--version*) echo 1.0; exit 0 ;; *) sleep 30 ;; esac"#; let binary = fake_binary(dir3.path(), body); let settings = Settings { binary: Some(binary), call_timeout_secs: Some(1), search_results: Some(2), ..Settings::default() }; let toml = toml::to_string(&settings).expect("settings toml"); let client = Client::init(&toml).await.expect("init"); let started = std::time::Instant::now(); assert!(client.get_lib_node("/youtube/search/lofi").await.is_err()); assert!( started.elapsed() < Duration::from_secs(10), "timed out late" ); } #[tokio::test] async fn foreign_and_malformed_paths_are_rejected() { let dir = TempDir::new().expect("tempdir"); let client = client(dir.path()).await; for path in ["/tidal/artists", "/youtube/nope", "/youtube/search/a/b/c"] { assert!(client.get_lib_node(path).await.is_err(), "{path}"); } assert!(client.create_lib_node("/youtube", "term").await.is_err()); } }