807 lines
30 KiB
Rust
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());
|
|
}
|
|
}
|