diff options
Diffstat (limited to 'crates/yt_dlp')
-rw-r--r-- | crates/yt_dlp/Cargo.toml | 1 | ||||
-rw-r--r-- | crates/yt_dlp/src/duration.rs | 7 | ||||
-rw-r--r-- | crates/yt_dlp/src/lib.rs | 67 | ||||
-rw-r--r-- | crates/yt_dlp/src/logging.rs | 16 | ||||
-rw-r--r-- | crates/yt_dlp/src/main.rs | 96 | ||||
-rw-r--r-- | crates/yt_dlp/src/tests.rs | 85 | ||||
-rw-r--r-- | crates/yt_dlp/src/wrapper/info_json.rs | 25 |
7 files changed, 165 insertions, 132 deletions
diff --git a/crates/yt_dlp/Cargo.toml b/crates/yt_dlp/Cargo.toml index 0f1d248..03196e9 100644 --- a/crates/yt_dlp/Cargo.toml +++ b/crates/yt_dlp/Cargo.toml @@ -30,6 +30,7 @@ serde_json.workspace = true url.workspace = true [dev-dependencies] +tokio.workspace = true [lints] workspace = true diff --git a/crates/yt_dlp/src/duration.rs b/crates/yt_dlp/src/duration.rs index cd7454b..f91892d 100644 --- a/crates/yt_dlp/src/duration.rs +++ b/crates/yt_dlp/src/duration.rs @@ -9,6 +9,8 @@ // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. // TODO: This file should be de-duplicated with the same file in the 'yt' crate <2024-06-25> + +#[derive(Debug, Clone, Copy)] pub struct Duration { time: u32, } @@ -26,6 +28,11 @@ impl From<&str> for Duration { impl From<Option<f64>> for Duration { fn from(value: Option<f64>) -> Self { Self { + #[allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss + )] time: value.unwrap_or(0.0).ceil() as u32, } } diff --git a/crates/yt_dlp/src/lib.rs b/crates/yt_dlp/src/lib.rs index f958895..4e35cb0 100644 --- a/crates/yt_dlp/src/lib.rs +++ b/crates/yt_dlp/src/lib.rs @@ -8,6 +8,10 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. +// The pyo3 `pyfunction` proc-macros call unsafe functions internally, which trigger this lint. +#![allow(unsafe_op_in_unsafe_fn)] +#![allow(clippy::missing_errors_doc)] + use std::env; use std::{fs::File, io::Write}; @@ -31,14 +35,20 @@ pub mod duration; pub mod logging; pub mod wrapper; +#[cfg(test)] +mod tests; + /// Synchronisation helper, to ensure that we don't setup the logger multiple times static SYNC_OBJ: Once = Once::new(); /// Add a logger to the yt-dlp options. /// If you have an logger set (i.e. for rust), than this will log to rust +/// +/// # Panics +/// This should never panic. pub fn add_logger_and_sig_handler<'a>( opts: Bound<'a, PyDict>, - py: Python, + py: Python<'_>, ) -> PyResult<Bound<'a, PyDict>> { setup_logging(py, "yt_dlp")?; @@ -52,10 +62,9 @@ pub fn add_logger_and_sig_handler<'a>( // This allows the user to actually stop the application with Ctrl+C. // This is here because it can only be run in the main thread and this was here already. py.run_bound( - r#" + "\ import signal -signal.signal(signal.SIGINT, signal.SIG_DFL) - "#, +signal.signal(signal.SIGINT, signal.SIG_DFL)", None, None, ) @@ -82,14 +91,22 @@ signal.signal(signal.SIGINT, signal.SIG_DFL) } #[pyfunction] -pub fn progress_hook(py: Python, input: Bound<'_, PyDict>) -> PyResult<()> { +#[allow(clippy::too_many_lines)] +#[allow(clippy::missing_panics_doc)] +#[allow(clippy::items_after_statements)] +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss +)] +pub fn progress_hook(py: Python<'_>, input: &Bound<'_, PyDict>) -> PyResult<()> { // Only add the handler, if the log-level is higher than Debug (this avoids covering debug // messages). if log_enabled!(Level::Debug) { return Ok(()); } - let input: serde_json::Map<String, Value> = serde_json::from_str(&json_dumps( + let input: Map<String, Value> = serde_json::from_str(&json_dumps( py, input .downcast::<PyAny>() @@ -164,8 +181,9 @@ pub fn progress_hook(py: Python, input: Bound<'_, PyDict>) -> PyResult<()> { } fn format_speed(speed: f64) -> String { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let bytes = Bytes::new(speed.floor() as u64); - format!("{}/s", bytes) + format!("{bytes}/s") } let get_title = |add_extension: bool| -> String { @@ -187,7 +205,7 @@ pub fn progress_hook(py: Python, input: Bound<'_, PyDict>) -> PyResult<()> { default_get! { as_str, "<No title>", "info_dict", "title"}.to_owned() } } - other => panic!("The extension '{}' is not yet implemented", other), + other => panic!("The extension '{other}' is not yet implemented"), } }; @@ -242,18 +260,18 @@ pub fn progress_hook(py: Python, input: Bound<'_, PyDict>) -> PyResult<()> { ); } "finished" => { - println!("Finished downloading: '{}'", c!("34;1", get_title(false))) + println!("Finished downloading: '{}'", c!("34;1", get_title(false))); } "error" => { panic!("Error whilst downloading: {}", get_title(true)) } - other => panic!("{} is not a valid state!", other), + other => panic!("{other} is not a valid state!"), }; Ok(()) } -pub fn add_hooks<'a>(opts: Bound<'a, PyDict>, py: Python) -> PyResult<Bound<'a, PyDict>> { +pub fn add_hooks<'a>(opts: Bound<'a, PyDict>, py: Python<'_>) -> PyResult<Bound<'a, PyDict>> { if let Some(hooks) = opts.get_item("progress_hooks")? { let hooks = hooks.downcast::<PyList>()?; hooks.append(wrap_pyfunction_bound!(progress_hook, py)?)?; @@ -280,10 +298,12 @@ pub fn add_hooks<'a>(opts: Bound<'a, PyDict>, py: Python) -> PyResult<Bound<'a, /// @param download Whether to download videos /// @param process Whether to resolve all unresolved references (URLs, playlist items). /// Must be True for download to work -/// @param ie_key Use only the extractor with this key +/// @param `ie_key` Use only the extractor with this key /// -/// @param extra_info Dictionary containing the extra values to add to the info (For internal use only) -/// @force_generic_extractor Force using the generic extractor (Deprecated; use ie_key='Generic') +/// @param `extra_info` Dictionary containing the extra values to add to the info (For internal use only) +/// @`force_generic_extractor` Force using the generic extractor (Deprecated; use `ie_key`='Generic') +#[allow(clippy::unused_async)] +#[allow(clippy::missing_panics_doc)] pub async fn extract_info( yt_dlp_opts: &Map<String, Value>, url: &Url, @@ -311,8 +331,8 @@ pub async fn extract_info( if let Ok(confirm) = env::var("YT_STORE_INFO_JSON") { if confirm == "yes" { - let mut file = File::create("output.info.json").unwrap(); - write!(file, "{}", result_str).unwrap(); + let mut file = File::create("output.info.json")?; + write!(file, "{result_str}").unwrap(); } } @@ -321,7 +341,9 @@ pub async fn extract_info( }) } -pub fn unsmuggle_url(smug_url: Url) -> PyResult<Url> { +/// # Panics +/// Only if python fails to return a valid URL. +pub fn unsmuggle_url(smug_url: &Url) -> PyResult<Url> { Python::with_gil(|py| { let utils = get_yt_dlp_utils(py)?; let url = utils @@ -341,6 +363,9 @@ pub fn unsmuggle_url(smug_url: Url) -> PyResult<Url> { /// Download a given list of URLs. /// Returns the paths they were downloaded to. +/// +/// # Panics +/// Only if `yt_dlp` changes their `info_json` schema. pub async fn download( urls: &[Url], download_options: &Map<String, Value>, @@ -357,7 +382,7 @@ pub async fn download( } else { info_json.requested_downloads.expect("This must exist")[0] .filename - .to_owned() + .clone() }; out_paths.push(result_string); @@ -378,7 +403,7 @@ fn json_map_to_py_dict<'a>( Ok(python_dict) } -fn json_dumps(py: Python, input: Bound<PyAny>) -> PyResult<String> { +fn json_dumps(py: Python<'_>, input: Bound<'_, PyAny>) -> PyResult<String> { // json.dumps(yt_dlp.sanitize_info(input)) let yt_dlp = get_yt_dlp(py, PyDict::new_bound(py))?; @@ -394,13 +419,13 @@ fn json_dumps(py: Python, input: Bound<PyAny>) -> PyResult<String> { Ok(output_str) } -fn json_loads_str<T: Serialize>(py: Python, input: T) -> PyResult<Bound<PyDict>> { +fn json_loads_str<T: Serialize>(py: Python<'_>, input: T) -> PyResult<Bound<'_, PyDict>> { let string = serde_json::to_string(&input).expect("Correct json must be pased"); json_loads(py, string) } -fn json_loads(py: Python, input: String) -> PyResult<Bound<PyDict>> { +fn json_loads(py: Python<'_>, input: String) -> PyResult<Bound<'_, PyDict>> { // json.loads(input) let json = PyModule::import_bound(py, "json")?; diff --git a/crates/yt_dlp/src/logging.rs b/crates/yt_dlp/src/logging.rs index 4039da4..385255d 100644 --- a/crates/yt_dlp/src/logging.rs +++ b/crates/yt_dlp/src/logging.rs @@ -12,6 +12,9 @@ // It is licensed under the Apache 2.0 License, copyright up to 2024 by Dylan Storey // It was modified by Benedikt Peetz 2024 +// The pyo3 `pyfunction` proc-macros call unsafe functions internally, which trigger this lint. +#![allow(unsafe_op_in_unsafe_fn)] + use log::{logger, Level, MetadataBuilder, Record}; use pyo3::{ prelude::{PyAnyMethods, PyListMethods, PyModuleMethods}, @@ -19,6 +22,7 @@ use pyo3::{ }; /// Consume a Python `logging.LogRecord` and emit a Rust `Log` instead. +#[allow(clippy::needless_pass_by_value)] #[pyfunction] fn host_log(record: Bound<'_, PyAny>, rust_target: &str) -> PyResult<()> { let level = record.getattr("levelno")?; @@ -37,7 +41,7 @@ fn host_log(record: Bound<'_, PyAny>, rust_target: &str) -> PyResult<()> { } else { // Libraries (ex: tracing_subscriber::filter::Directive) expect rust-style targets like foo::bar, // and may not deal well with "." as a module separator: - let logger_name = logger_name.replace(".", "::"); + let logger_name = logger_name.replace('.', "::"); Some(format!("{rust_target}::{logger_name}")) }; @@ -84,10 +88,11 @@ fn host_log(record: Bound<'_, PyAny>, rust_target: &str) -> PyResult<()> { Ok(()) } -/// Registers the host_log function in rust as the event handler for Python's logging logger +/// Registers the `host_log` function in rust as the event handler for Python's logging logger /// This function needs to be called from within a pyo3 context as early as possible to ensure logging messages /// arrive to the rust consumer. -pub fn setup_logging(py: Python, target: &str) -> PyResult<()> { +#[allow(clippy::module_name_repetitions)] +pub fn setup_logging(py: Python<'_>, target: &str) -> PyResult<()> { let logging = py.import_bound("logging")?; logging.setattr("host_log", wrap_pyfunction!(host_log, &logging)?)?; @@ -100,15 +105,14 @@ class HostHandler(Handler): super().__init__(level=level) def emit(self, record): - host_log(record,"{}") + host_log(record,"{target}") oldBasicConfig = basicConfig def basicConfig(*pargs, **kwargs): if "handlers" not in kwargs: kwargs["handlers"] = [HostHandler()] return oldBasicConfig(*pargs, **kwargs) -"#, - target +"# ) .as_str(), Some(&logging.dict()), diff --git a/crates/yt_dlp/src/main.rs b/crates/yt_dlp/src/main.rs deleted file mode 100644 index c40ddc3..0000000 --- a/crates/yt_dlp/src/main.rs +++ /dev/null @@ -1,96 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{env::args, fs}; - -use yt_dlp::wrapper::info_json::InfoJson; - -#[cfg(test)] -mod test { - use url::Url; - use yt_dlp::wrapper::yt_dlp_options::{ExtractFlat, YtDlpOptions}; - - const YT_OPTS: YtDlpOptions = YtDlpOptions { - playliststart: 1, - playlistend: 10, - noplaylist: false, - extract_flat: ExtractFlat::InPlaylist, - }; - - #[test] - fn test_extract_info_video() { - let info = yt_dlp::extract_info( - YT_OPTS, - &Url::parse("https://www.youtube.com/watch?v=dbjPnXaacAU").expect("Is valid."), - false, - false, - false, - ) - .map_err(|err| format!("Encountered error: '{}'", err)) - .unwrap(); - - println!("{:#?}", info); - } - - #[test] - fn test_extract_info_url() { - let err = yt_dlp::extract_info( - YT_OPTS, - &Url::parse("https://google.com").expect("Is valid."), - false, - false, - false, - ) - .map_err(|err| format!("Encountered error: '{}'", err)) - .unwrap(); - - println!("{:#?}", err); - } - - #[test] - fn test_extract_info_playlist() { - let err = yt_dlp::extract_info( - YT_OPTS, - &Url::parse("https://www.youtube.com/@TheGarriFrischer/videos").expect("Is valid."), - false, - false, - true, - ) - .map_err(|err| format!("Encountered error: '{}'", err)) - .unwrap(); - - println!("{:#?}", err); - } - #[test] - fn test_extract_info_playlist_full() { - let err = yt_dlp::extract_info( - YT_OPTS, - &Url::parse("https://www.youtube.com/@NixOS-Foundation/videos").expect("Is valid."), - false, - false, - true, - ) - .map_err(|err| format!("Encountered error: '{}'", err)) - .unwrap(); - - println!("{:#?}", err); - } -} - -fn main() { - let input_file: &str = &args().take(2).collect::<Vec<String>>()[1]; - - let input = fs::read_to_string(input_file).unwrap(); - - let output: InfoJson = - serde_json::from_str(&input).expect("Python should be able to produce correct json"); - - println!("{:#?}", output); -} diff --git a/crates/yt_dlp/src/tests.rs b/crates/yt_dlp/src/tests.rs new file mode 100644 index 0000000..08e392f --- /dev/null +++ b/crates/yt_dlp/src/tests.rs @@ -0,0 +1,85 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::sync::LazyLock; + +use serde_json::{json, Value}; +use url::Url; + +static YT_OPTS: LazyLock<serde_json::Map<String, Value>> = LazyLock::new(|| { + match json!({ + "playliststart": 1, + "playlistend": 10, + "noplaylist": false, + "extract_flat": false, + }) { + Value::Object(obj) => obj, + _ => unreachable!("This json is hardcoded"), + } +}); + +#[tokio::test] +async fn test_extract_info_video() { + let info = crate::extract_info( + &YT_OPTS, + &Url::parse("https://www.youtube.com/watch?v=dbjPnXaacAU").expect("Is valid."), + false, + false, + ) + .await + .map_err(|err| format!("Encountered error: '{}'", err)) + .unwrap(); + + println!("{:#?}", info); +} + +#[tokio::test] +async fn test_extract_info_url() { + let err = crate::extract_info( + &YT_OPTS, + &Url::parse("https://google.com").expect("Is valid."), + false, + false, + ) + .await + .map_err(|err| format!("Encountered error: '{}'", err)) + .unwrap(); + + println!("{:#?}", err); +} + +#[tokio::test] +async fn test_extract_info_playlist() { + let err = crate::extract_info( + &YT_OPTS, + &Url::parse("https://www.youtube.com/@TheGarriFrischer/videos").expect("Is valid."), + false, + true, + ) + .await + .map_err(|err| format!("Encountered error: '{}'", err)) + .unwrap(); + + println!("{:#?}", err); +} +#[tokio::test] +async fn test_extract_info_playlist_full() { + let err = crate::extract_info( + &YT_OPTS, + &Url::parse("https://www.youtube.com/@NixOS-Foundation/videos").expect("Is valid."), + false, + true, + ) + .await + .map_err(|err| format!("Encountered error: '{}'", err)) + .unwrap(); + + println!("{:#?}", err); +} diff --git a/crates/yt_dlp/src/wrapper/info_json.rs b/crates/yt_dlp/src/wrapper/info_json.rs index 50a026d..f113fe5 100644 --- a/crates/yt_dlp/src/wrapper/info_json.rs +++ b/crates/yt_dlp/src/wrapper/info_json.rs @@ -8,6 +8,9 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. +// `yt_dlp` named them like this. +#![allow(clippy::pub_underscore_fields)] + use std::{collections::HashMap, path::PathBuf}; use pyo3::{types::PyDict, Bound, PyResult, Python}; @@ -146,6 +149,7 @@ pub struct InfoJson { #[derive(Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] +#[allow(missing_copy_implementations)] pub struct FilesToMove {} #[derive(Debug, Deserialize, Serialize, PartialEq)] @@ -197,8 +201,7 @@ pub struct Subtitle { pub url: Url, } -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Clone, Copy)] pub enum SubtitleExt { #[serde(alias = "vtt")] Vtt, @@ -266,7 +269,7 @@ where Ok(None) } -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq)] +#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Clone, Copy)] #[serde(deny_unknown_fields)] pub enum SponsorblockChapterType { #[serde(alias = "skip")] @@ -278,7 +281,7 @@ pub enum SponsorblockChapterType { #[serde(alias = "poi")] Poi, } -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq)] +#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Clone, Copy)] #[serde(deny_unknown_fields)] pub enum SponsorblockChapterCategory { #[serde(alias = "filler")] @@ -314,13 +317,14 @@ pub enum SponsorblockChapterCategory { #[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd)] #[serde(deny_unknown_fields)] +#[allow(missing_copy_implementations)] pub struct HeatMapEntry { pub start_time: f64, pub end_time: f64, pub value: f64, } -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq)] +#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Clone, Copy)] #[serde(deny_unknown_fields)] pub enum Extractor { #[serde(alias = "generic")] @@ -337,7 +341,7 @@ pub enum Extractor { YouTubeTab, } -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq)] +#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Clone, Copy)] #[serde(deny_unknown_fields)] pub enum ExtractorKey { #[serde(alias = "Generic")] @@ -354,7 +358,7 @@ pub enum ExtractorKey { YouTubeTab, } -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq)] +#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Clone, Copy)] #[serde(deny_unknown_fields)] pub enum InfoType { #[serde(alias = "playlist")] @@ -385,6 +389,7 @@ pub enum Parent { } impl Parent { + #[must_use] pub fn id(&self) -> Option<&str> { if let Self::Id(id) = self { Some(id) @@ -421,6 +426,7 @@ impl From<String> for Id { #[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, PartialOrd, Ord)] #[serde(deny_unknown_fields)] +#[allow(clippy::struct_excessive_bools)] pub struct Comment { pub id: Id, pub text: String, @@ -522,6 +528,7 @@ pub struct Format { #[derive(Debug, Deserialize, Serialize, Eq, PartialEq, PartialOrd, Ord)] #[serde(deny_unknown_fields)] +#[allow(missing_copy_implementations)] pub struct DownloaderOptions { http_chunk_size: u64, } @@ -554,8 +561,8 @@ pub struct Fragment { } impl InfoJson { - pub fn to_py_dict(self, py: Python) -> PyResult<Bound<PyDict>> { - let output: Bound<PyDict> = json_loads_str(py, self)?; + pub fn to_py_dict(self, py: Python<'_>) -> PyResult<Bound<'_, PyDict>> { + let output: Bound<'_, PyDict> = json_loads_str(py, self)?; Ok(output) } } |