use crate::input::{ metadata::ytdl::Output, AudioStream, AudioStreamError, AuxMetadata, Compose, HttpRequest, Input, }; use async_trait::async_trait; use reqwest::{ header::{HeaderMap, HeaderName, HeaderValue}, Client, }; use std::error::Error; use symphonia_core::io::MediaSource; use tokio::process::Command; const YOUTUBE_DL_COMMAND: &str = "yt-dlp"; /// A lazily instantiated call to download a file, finding its URL via youtube-dl. /// /// By default, this uses yt-dlp and is backed by an [`HttpRequest`]. This handler /// attempts to find the best audio-only source (typically `WebM`, enabling low-cost /// Opus frame passthrough). /// /// [`HttpRequest`]: super::HttpRequest #[derive(Clone, Debug)] pub struct YoutubeDl { program: &'static str, client: Client, metadata: Option, url: String, } impl YoutubeDl { /// Creates a lazy request to select an audio stream from `url`, using "yt-dlp". /// /// This requires a reqwest client: ideally, one should be created and shared between /// all requests. #[must_use] pub fn new(client: Client, url: String) -> Self { Self::new_ytdl_like(YOUTUBE_DL_COMMAND, client, url) } /// Creates a lazy request to select an audio stream from `url` as in [`new`], using `program`. /// /// [`new`]: Self::new #[must_use] pub fn new_ytdl_like(program: &'static str, client: Client, url: String) -> Self { Self { program, client, metadata: None, url, } } async fn query(&mut self) -> Result { let ytdl_args = ["-j", &self.url, "-f", "ba[abr>0][vcodec=none]/best"]; let mut output = Command::new(self.program) .args(ytdl_args) .output() .await .map_err(|e| AudioStreamError::Fail(Box::new(e)))?; // NOTE: must be mut for simd-json. #[allow(clippy::unnecessary_mut_passed)] let stdout: Output = crate::json::from_slice(&mut output.stdout[..]) .map_err(|e| AudioStreamError::Fail(Box::new(e)))?; self.metadata = Some(stdout.as_aux_metadata()); Ok(stdout) } } impl From for Input { fn from(val: YoutubeDl) -> Self { Input::Lazy(Box::new(val)) } } #[async_trait] impl Compose for YoutubeDl { fn create(&mut self) -> Result>, AudioStreamError> { Err(AudioStreamError::Unsupported) } async fn create_async( &mut self, ) -> Result>, AudioStreamError> { let stdout = self.query().await?; let mut headers = HeaderMap::default(); if let Some(map) = stdout.http_headers { headers.extend(map.iter().filter_map(|(k, v)| { Some(( HeaderName::from_bytes(k.as_bytes()).ok()?, HeaderValue::from_str(v).ok()?, )) })); } let mut req = HttpRequest { client: self.client.clone(), request: stdout.url, headers, content_length: stdout.filesize, }; req.create_async().await } fn should_create_async(&self) -> bool { true } async fn aux_metadata(&mut self) -> Result { if let Some(meta) = self.metadata.as_ref() { return Ok(meta.clone()); } self.query().await?; self.metadata.clone().ok_or_else(|| { let msg: Box = "Failed to instansiate any metadata... Should be unreachable.".into(); AudioStreamError::Fail(msg) }) } } #[cfg(test)] mod tests { use reqwest::Client; use super::*; use crate::{constants::test_data::YTDL_TARGET, input::input_tests::*}; #[tokio::test] #[ntest::timeout(20_000)] async fn ytdl_track_plays() { track_plays_mixed(|| YoutubeDl::new(Client::new(), YTDL_TARGET.into())).await; } #[tokio::test] #[ntest::timeout(20_000)] async fn ytdl_forward_seek_correct() { forward_seek_correct(|| YoutubeDl::new(Client::new(), YTDL_TARGET.into())).await; } #[tokio::test] #[ntest::timeout(20_000)] async fn ytdl_backward_seek_correct() { backward_seek_correct(|| YoutubeDl::new(Client::new(), YTDL_TARGET.into())).await; } }