// yt - A fully featured command line YouTube client // // Copyright (C) 2025 Benedikt Peetz // SPDX-License-Identifier: GPL-3.0-or-later // // This file is part of Yt. // // You should have received a copy of the License along with this program. // If not, see . //! The `yt_dlp` interface is completely contained in the [`YoutubeDL`] structure. use std::path::PathBuf; use log::info; use pyo3::{ Bound, Py, PyAny, Python, intern, types::{PyAnyMethods, PyDict, PyIterator, PyList}, }; use url::Url; use crate::{ info_json::{InfoJson, json_dumps, json_loads}, python_error::{IntoPythonError, PythonError}, }; pub mod info_json; pub mod options; pub mod post_processors; pub mod progress_hook; pub mod python_error; #[macro_export] macro_rules! json_get { ($value:expr, $name:literal, $into:ident) => {{ match $value.get($name) { Some(val) => $crate::json_cast!(@log_key $name, val, $into), None => panic!( concat!( "Expected '", $name, "' to be a key for the '", stringify!($value), "' object: {:#?}" ), $value ), } }}; } #[macro_export] macro_rules! json_cast { ($value:expr, $into:ident) => {{ json_cast!(@log_key "", $value, $into) }}; (@log_key $name:literal, $value:expr, $into:ident) => {{ match $value.$into() { Some(result) => result, None => panic!( concat!( "Expected to be able to cast '", $name, "' value ({:#?}) ", stringify!($into) ), $value ), } }}; } macro_rules! py_kw_args { ($py:expr => $($kw_arg_name:ident = $kw_arg_val:expr),*) => {{ use $crate::python_error::IntoPythonError; let dict = PyDict::new($py); $( dict.set_item(stringify!($kw_arg_name), $kw_arg_val).wrap_exc($py)?; )* Some(dict) } .as_ref()}; } pub(crate) use py_kw_args; /// The core of the `yt_dlp` interface. #[derive(Debug)] pub struct YoutubeDL { inner: Py, options: serde_json::Map, } impl YoutubeDL { /// Fetch the underlying `yt_dlp` and `python` version. /// /// # Errors /// If python attribute access fails. pub fn version(&self) -> Result<(String, String), PythonError> { Python::with_gil(|py| { let yt_dlp = py .import(intern!(py, "yt_dlp")) .wrap_exc(py)? .getattr(intern!(py, "version")) .wrap_exc(py)? .getattr(intern!(py, "__version__")) .wrap_exc(py)? .extract() .wrap_exc(py)?; let python = py.version(); Ok((yt_dlp, python.to_owned())) }) } /// Download a given list of URLs. /// Returns the paths they were downloaded to. /// /// # Errors /// If one of the downloads error. pub fn download(&self, urls: &[Url]) -> Result, extract_info::Error> { let mut out_paths = Vec::with_capacity(urls.len()); for url in urls { info!("Started downloading url: '{url}'"); let info_json = self.extract_info(url, true, true)?; // Try to work around yt-dlp type weirdness let result_string = if let Some(filename) = info_json.get("filename") { PathBuf::from(json_cast!(filename, as_str)) } else { PathBuf::from(json_get!( json_cast!( json_get!(info_json, "requested_downloads", as_array)[0], as_object ), "filename", as_str )) }; out_paths.push(result_string); info!("Finished downloading url"); } Ok(out_paths) } /// `extract_info(self, url, download=True, ie_key=None, extra_info=None, process=True, force_generic_extractor=False)` /// /// Extract and return the information dictionary of the URL /// /// Arguments: /// - `url` URL to extract /// /// Keyword arguments: /// :`download` Whether to download videos /// :`process` Whether to resolve all unresolved references (URLs, playlist items). /// Must be True for download to work /// /// # Panics /// If expectations about python fail to hold. /// /// # Errors /// If python operations fail. pub fn extract_info( &self, url: &Url, download: bool, process: bool, ) -> Result { Python::with_gil(|py| { let inner = self .inner .bind(py) .getattr(intern!(py, "extract_info")) .wrap_exc(py)?; let result = inner .call( (url.to_string(),), py_kw_args!(py => download = download, process = process), ) .wrap_exc(py)? .downcast_into::() .expect("This is a dict"); // Resolve the generator object if let Ok(generator) = result.get_item(intern!(py, "entries")) { if generator.is_instance_of::() { // already resolved. Do nothing } else if let Ok(generator) = generator.downcast::() { // A python generator object. let max_backlog = self.options.get("playlistend").map_or(10000, |value| { usize::try_from(json_cast!(value, as_u64)).expect("Should work") }); let mut out = vec![]; for output in generator { out.push(output.wrap_exc(py)?); if out.len() == max_backlog { break; } } result.set_item(intern!(py, "entries"), out).wrap_exc(py)?; } else { // Probably some sort of paged list (`OnDemand` or otherwise) let max_backlog = self.options.get("playlistend").map_or(10000, |value| { usize::try_from(json_cast!(value, as_u64)).expect("Should work") }); let next = generator.getattr(intern!(py, "getslice")).wrap_exc(py)?; let output = next .call((), py_kw_args!(py => start = 0, end = max_backlog)) .wrap_exc(py)?; result .set_item(intern!(py, "entries"), output) .wrap_exc(py)?; } } let result = self.prepare_info_json(&result, py)?; Ok(result) }) } /// Take the (potentially modified) result of the information extractor (i.e., /// [`Self::extract_info`] with `process` and `download` set to false) /// and resolve all unresolved references (URLs, /// playlist items). /// /// It will also download the videos if 'download' is true. /// Returns the resolved `ie_result`. /// /// # Panics /// If expectations about python fail to hold. /// /// # Errors /// If python operations fail. pub fn process_ie_result( &self, ie_result: InfoJson, download: bool, ) -> Result { Python::with_gil(|py| { let inner = self .inner .bind(py) .getattr(intern!(py, "process_ie_result")) .wrap_exc(py)?; let result = inner .call( (json_loads(ie_result, py),), py_kw_args!(py => download = download), ) .wrap_exc(py)? .downcast_into::() .expect("This is a dict"); let result = self.prepare_info_json(&result, py)?; Ok(result) }) } fn prepare_info_json<'py>( &self, info: &Bound<'py, PyDict>, py: Python<'py>, ) -> Result { let sanitize = self.inner.bind(py).getattr(intern!(py, "sanitize_info")).wrap_exc(py)?; let value = sanitize.call((info,), None).wrap_exc(py)?; let result = value.downcast::().expect("This should stay a dict"); Ok(json_dumps(result)) } } #[allow(missing_docs)] pub mod process_ie_result { use crate::{prepare, python_error::PythonError}; #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] Python(#[from] PythonError), #[error("Failed to prepare the info json")] InfoJsonPrepare(#[from] prepare::Error), } } #[allow(missing_docs)] pub mod extract_info { use crate::{prepare, python_error::PythonError}; #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] Python(#[from] PythonError), #[error("Failed to prepare the info json")] InfoJsonPrepare(#[from] prepare::Error), } } #[allow(missing_docs)] pub mod prepare { use crate::python_error::PythonError; #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] Python(#[from] PythonError), } }