crabidy/ytdy/src/lib.rs

807 lines
30 KiB
Rust

//! 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<PathBuf>,
/// Netscape cookies file for logged-in features (playlists,
/// age-gated streams). Absent = logged out; search still works.
pub cookies: Option<PathBuf>,
/// Results per search term. Default [`DEFAULT_SEARCH_RESULTS`].
pub search_results: Option<usize>,
/// Per-call timeout in seconds. Default
/// [`DEFAULT_CALL_TIMEOUT_SECS`]; tests shrink it.
pub call_timeout_secs: Option<u64>,
}
/// 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<YtPath<'_>, 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<Vec<String>>,
}
impl Client {
/// The search-term node: top `search_results` results as tracks
/// (`ytsearchN:<term>` flat listing). Queueable and downloadable —
/// results are homogeneous tracks.
async fn search_term_node(
&self,
path: &str,
term: &str,
parent: String,
) -> Result<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<String> {
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<Self, ProviderError> {
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<Vec<String>, 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<Track, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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());
}
}