diff options
Diffstat (limited to '')
45 files changed, 902 insertions, 421 deletions
diff --git a/crates/libmpv2/libmpv2-sys/update.sh b/crates/libmpv2/libmpv2-sys/update.sh deleted file mode 100755 index c1a0215..0000000 --- a/crates/libmpv2/libmpv2-sys/update.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env sh - -# 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>. - -cd "$(dirname "$0")" || exit 1 -[ "$1" = "upgrade" ] && cargo upgrade --incompatible -cargo update diff --git a/crates/libmpv2/update.sh b/crates/libmpv2/update.sh deleted file mode 100755 index e1669a9..0000000 --- a/crates/libmpv2/update.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env sh - -# 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>. - -"$(dirname "$0")/libmpv2-sys/update.sh" "$@" diff --git a/crates/yt/Cargo.toml b/crates/yt/Cargo.toml index 6184eb7..91d9204 100644 --- a/crates/yt/Cargo.toml +++ b/crates/yt/Cargo.toml @@ -24,33 +24,32 @@ rust-version.workspace = true publish = false [dependencies] -anyhow = "1.0.100" -blake3 = { version = "1.8.2", features = ["serde"] } -chrono = { version = "0.4.42", features = ["now"] } +anyhow = "1.0.102" +blake3 = { version = "1.8.5", features = ["serde"] } +chrono = { version = "0.4.45", features = ["now"] } chrono-humanize = "0.2.3" -clap = { version = "4.5.53", features = ["derive"] } -clap_complete = { version = "4.5.61", features = ["unstable-dynamic"] } +clap = { version = "4.6.1", features = ["derive"] } +clap_complete = { version = "4.6.5", features = ["unstable-dynamic"] } colors.workspace = true -futures = "0.3.31" +futures = "0.3.32" libmpv2.workspace = true log.workspace = true notify = { version = "8.2.0", default-features = false } -regex = "1.12.2" serde.workspace = true serde_json.workspace = true -shlex = "1.3.0" +shlex = "2.0.1" sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] } stderrlog = "0.6.0" -tempfile = "3.23.0" +tempfile = "3.27.0" termsize.workspace = true -tokio-util = { version = "0.7.17", features = ["rt"] } +tokio-util = { version = "0.7.18", features = ["rt"] } tokio.workspace = true -toml = "0.9.8" +toml = "1.1.2" url.workspace = true uu_fmt.workspace = true xdg = "3.0.0" yt_dlp.workspace = true -reqwest = "0.12.24" +reqwest = "0.13.4" [[bin]] name = "yt" diff --git a/crates/yt/src/ansi_escape_codes.rs b/crates/yt/src/ansi_escape_codes.rs index 28a8370..0348d89 100644 --- a/crates/yt/src/ansi_escape_codes.rs +++ b/crates/yt/src/ansi_escape_codes.rs @@ -8,22 +8,24 @@ // 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::io::Write; + // see: https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands const CSI: &str = "\x1b["; -pub(crate) fn erase_from_cursor_to_bottom() { - print!("{CSI}0J"); +pub(crate) fn erase_from_cursor_to_bottom<W: Write>(mut stream: W) { + write!(stream, "{CSI}0J").expect("the stream to accept writes"); } -pub(crate) fn cursor_up(number: usize) { +pub(crate) fn cursor_up<W: Write>(mut stream: W, number: usize) { // HACK(@bpeetz): The default is `1` and running this command with a // number of `0` results in it using the default (i.e., `1`) <2025-03-25> if number != 0 { - print!("{CSI}{number}A"); + write!(stream, "{CSI}{number}A").expect("the stream to accept writes"); } } -pub(crate) fn clear_whole_line() { - eprint!("{CSI}2K"); +pub(crate) fn clear_whole_line<W: Write>(mut stream: W) { + write!(stream, "{CSI}2K").expect("the stream to accept writes"); } -pub(crate) fn move_to_col(x: usize) { - eprint!("{CSI}{x}G"); +pub(crate) fn move_to_col<W: Write>(mut stream: W, x: usize) { + write!(stream, "{CSI}{x}G").expect("the stream to accept writes"); } diff --git a/crates/yt/src/cli.rs b/crates/yt/src/cli.rs index 9a24403..dbdcdd1 100644 --- a/crates/yt/src/cli.rs +++ b/crates/yt/src/cli.rs @@ -41,6 +41,11 @@ pub(crate) struct CliArgs { #[arg(long, short)] pub(crate) db_path: Option<PathBuf>, + /// Set the cookie file to be passed to `yt_dlp`. This overrides the default and the value from + /// the config file. + #[arg(long, short = 'P')] + pub(crate) cookies: Option<PathBuf>, + /// Set the path to the config.toml. /// This overrides the default. #[arg(long, short)] diff --git a/crates/yt/src/commands/download/implm/download/download_options.rs b/crates/yt/src/commands/download/implm/download/download_options.rs index 15fed7e..4174266 100644 --- a/crates/yt/src/commands/download/implm/download/download_options.rs +++ b/crates/yt/src/commands/download/implm/download/download_options.rs @@ -15,14 +15,15 @@ use yt_dlp::{YoutubeDL, options::YoutubeDLOptions}; use crate::app::App; -use super::progress_hook::wrapped_progress_hook; +use super::hooks::{wrapped_progress_hook, wrapped_post_processor_hook}; pub(crate) fn download_opts( app: &App, - subtitle_langs: Option<&String>, + subtitle_langs: Option<&str>, ) -> anyhow::Result<YoutubeDL> { - YoutubeDLOptions::new() + let base = YoutubeDLOptions::new() .with_progress_hook(wrapped_progress_hook) + .with_post_processor_hook(wrapped_post_processor_hook) .set("extract_flat", "in_playlist") .set( "extractor_args", @@ -35,7 +36,6 @@ pub(crate) fn download_opts( } }, ) - //.set("cookiesfrombrowser", json! {("firefox", "me.google", None::<String>, "youtube_dlp")}) .set("prefer_free_formats", true) .set("ffmpeg_location", env!("FFMPEG_LOCATION")) .set("format", "bestvideo[height<=?1080]+bestaudio/best") @@ -110,12 +110,18 @@ pub(crate) fn download_opts( "subtitleslangs", Value::Array( subtitle_langs - .map_or("", String::as_str) + .unwrap_or("") .split(',') .map(|val| Value::String(val.to_owned())) .collect::<Vec<_>>(), ), - ) - .build() - .context("Failed to instanciate download yt_dlp") + ); + + if let Some(cookies) = &app.config.global.cookies { + base.set("cookies", cookies.to_str()) + } else { + base + } + .build() + .context("Failed to instanciate download yt_dlp") } diff --git a/crates/yt/src/commands/download/implm/download/progress_hook.rs b/crates/yt/src/commands/download/implm/download/hooks.rs index a414d4a..de52b98 100644 --- a/crates/yt/src/commands/download/implm/download/progress_hook.rs +++ b/crates/yt/src/commands/download/implm/download/hooks.rs @@ -12,15 +12,21 @@ use std::{ collections::HashSet, io::{Write, stderr}, process, - sync::{Mutex, atomic::Ordering}, + sync::{ + Mutex, OnceLock, + atomic::{AtomicUsize, Ordering}, + mpsc::{self, Sender}, + }, + thread, + time::Duration, }; use colors::{Colorize, IntoCanvas}; use log::{Level, log_enabled}; -use yt_dlp::{json_cast, json_get, wrap_progress_hook}; +use yt_dlp::{json_cast, json_get, json_try_get, wrap_post_processor_hook, wrap_progress_hook}; use crate::{ - ansi_escape_codes::{clear_whole_line, move_to_col}, + ansi_escape_codes::{clear_whole_line, cursor_up, erase_from_cursor_to_bottom, move_to_col}, config::SHOULD_DISPLAY_COLOR, select::duration::MaybeDuration, shared::bytes::Bytes, @@ -50,10 +56,31 @@ fn format_speed(speed: f64) -> String { let bytes = Bytes::new(speed.floor() as u64); format!("{bytes}/s") } +fn get_title(info_dict: &serde_json::Map<String, serde_json::Value>) -> String { + if let Some(ext) = json_try_get!(info_dict, "ext", as_str) { + match ext { + "vtt" => { + format!( + "Subtitles ({})", + json_get_default!(info_dict, "name", as_str, "<No Subtitle Language>") + ) + } + "webm" | "mp4" | "mp3" | "m4a" => { + json_get_default!(info_dict, "title", as_str, "<No title>").to_owned() + } + other => panic!("The extension '{other}' is not yet implemented"), + } + } else { + json_get_default!(info_dict, "title", as_str, "<No title>").to_owned() + } +} /// # Panics /// If expectations fail. -#[allow(clippy::needless_pass_by_value)] +#[expect( + clippy::needless_pass_by_value, + reason = "We need to conform to the expected function signature" +)] pub(crate) fn progress_hook( input: serde_json::Map<String, serde_json::Value>, ) -> Result<(), std::io::Error> { @@ -65,21 +92,6 @@ pub(crate) fn progress_hook( let info_dict = json_get!(input, "info_dict", as_object); - let get_title = || -> String { - match json_get!(info_dict, "ext", as_str) { - "vtt" => { - format!( - "Subtitles ({})", - json_get_default!(info_dict, "name", as_str, "<No Subtitle Language>") - ) - } - "webm" | "mp4" | "mp3" | "m4a" => { - json_get_default!(info_dict, "title", as_str, "<No title>").to_owned() - } - other => panic!("The extension '{other}' is not yet implemented"), - } - }; - match json_get!(input, "status", as_str) { "downloading" => { let elapsed = json_get_default!(input, "elapsed", as_f64, 0.0); @@ -127,12 +139,12 @@ pub(crate) fn progress_hook( } }; - clear_whole_line(); - move_to_col(1); + clear_whole_line(stderr()); + move_to_col(stderr(), 1); let should_use_color = SHOULD_DISPLAY_COLOR.load(Ordering::Relaxed); - let title = get_title(); + let title = get_title(info_dict); eprint!( "{} [{}/{} at {}] -> [{} of {}{} {}] ", @@ -175,7 +187,7 @@ pub(crate) fn progress_hook( } "finished" => { let should_use_color = SHOULD_DISPLAY_COLOR.load(Ordering::Relaxed); - let title = get_title(); + let title = get_title(info_dict); let has_already_been_printed = { let titles = TITLES.lock().expect("The lock should work"); @@ -198,7 +210,7 @@ pub(crate) fn progress_hook( "error" => { // TODO: This should probably return an Err. But I'm not so sure where the error would // bubble up to (i.e., who would catch it) <2025-01-21> - eprintln!("-> Error while downloading: {}", get_title()); + eprintln!("-> Error while downloading: {}", get_title(info_dict)); process::exit(1); } other => unreachable!("'{other}' should not be a valid state!"), @@ -207,4 +219,141 @@ pub(crate) fn progress_hook( Ok(()) } +// postprocessor_hooks: A list of functions that get called on postprocessing +// progress, with a dictionary with the entries +// * status: One of "started", "processing", or "finished". +// Check this first and ignore unknown values. +// * postprocessor: Name of the postprocessor +// * info_dict: The extracted info_dict +// +// Progress hooks are guaranteed to be called at least twice +// (with status "started" and "finished") if the processing is successful. +#[expect( + clippy::needless_pass_by_value, + clippy::unnecessary_wraps, + reason = "The macro expects this function signature" +)] +pub(crate) fn post_processor_hook( + input: serde_json::Map<String, serde_json::Value>, +) -> Result<(), std::io::Error> { + // 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 info_dict = json_get!(input, "info_dict", as_object); + + match json_get!(input, "status", as_str) { + "started" => { + // Normally `yt_dlp` just gives us a `started` and a `finished` message, so we show all + // the post_processors that have currently been recorded as `started` and not yet as + // `finished`. + let post_processor = json_get!(input, "postprocessor", as_str); + maybe_setup_post_processor_progress(ProgressChange::Started( + post_processor.to_string(), + )); + } + "processing" => { + let post_processor = json_get!(input, "postprocessor", as_str); + let title = get_title(info_dict); + + eprintln!("Processesing actually triggered, `{post_processor}` for `{title}`",); + } + "finished" => { + let post_processor = json_get!(input, "postprocessor", as_str); + maybe_setup_post_processor_progress(ProgressChange::Finished( + post_processor.to_string(), + )); + + // let should_use_color = SHOULD_DISPLAY_COLOR.load(Ordering::Relaxed); + // let title = get_title(info_dict); + // + // eprintln!( + // "{} ({}) finished.", + // title.bold().blue().render(should_use_color), + // post_processor.bold().green().render(should_use_color), + // ); + } + other => unreachable!("'{other}' should not be a valid state!"), + } + + Ok(()) +} + +enum ProgressChange { + Started(String), + Finished(String), +} + +fn maybe_setup_post_processor_progress(pc: ProgressChange) { + fn new_state_id() -> usize { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + COUNTER.fetch_add(1, Ordering::Relaxed) + } + static TX: OnceLock<Sender<ProgressChange>> = const { OnceLock::new() }; + + let should_use_color = SHOULD_DISPLAY_COLOR.load(Ordering::Relaxed); + + let tx = TX.get_or_init(|| { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + fn process_new_data(data: &mut HashSet<String>, new_data: ProgressChange) { + match new_data { + ProgressChange::Started(name) => { + data.insert(name); + } + ProgressChange::Finished(name) => { + data.remove(&name); + } + } + } + + let mut data = HashSet::new(); + + 'main: loop { + let previous_length = data.len(); + + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(new_data) => process_new_data(&mut data, new_data), + Err(err) => match err { + mpsc::RecvTimeoutError::Timeout => (), + mpsc::RecvTimeoutError::Disconnected => break 'main, + }, + } + + // If we received multiple things at once, process them all at once. + while let Ok(new_data) = rx.try_recv() { + process_new_data(&mut data, new_data); + } + + let spinner = match new_state_id() % 4 { + 0 => '|', + 1 => '/', + 2 => '-', + 3 => '\\', + _ => unreachable!("The `mod` operation does not permit these numbers"), + }; + + cursor_up(stderr(), previous_length); + erase_from_cursor_to_bottom(stderr()); + for post_processor in &data { + clear_whole_line(stderr()); + move_to_col(stderr(), 1); + + eprintln!( + "[{spinner}] {} ", + post_processor.blue().bold().render(should_use_color) + ); + } + } + }); + tx + }); + + tx.send(pc) + .expect("The recieving end should not be dropped before we drop the tx"); +} + wrap_progress_hook!(progress_hook, wrapped_progress_hook); +wrap_post_processor_hook!(post_processor_hook, wrapped_post_processor_hook); diff --git a/crates/yt/src/commands/download/implm/download/mod.rs b/crates/yt/src/commands/download/implm/download/mod.rs index ab9de80..39f7960 100644 --- a/crates/yt/src/commands/download/implm/download/mod.rs +++ b/crates/yt/src/commands/download/implm/download/mod.rs @@ -29,7 +29,7 @@ use yt_dlp::YoutubeDL; #[allow(clippy::module_name_repetitions)] pub(crate) mod download_options; -pub(crate) mod progress_hook; +pub(crate) mod hooks; #[derive(Debug)] #[allow(clippy::module_name_repetitions)] @@ -44,13 +44,13 @@ impl CurrentDownload { let extractor_hash = video.extractor_hash; debug!("Download started: {}", &video.title); - let yt_dlp = Arc::new(download_opts(app, video.subtitle_langs.as_ref())?); + let yt_dlp = Arc::new(download_opts(app, video.subtitle_langs.as_deref())?); let local_yt_dlp = Arc::clone(&yt_dlp); let task_handle = tokio::task::spawn_blocking(move || { let mut result = local_yt_dlp - .download(&[video.url.clone()]) + .download(std::slice::from_ref(&video.url)) .with_context(|| format!("Failed to download video: '{}'", video.title))?; assert_eq!(result.len(), 1); diff --git a/crates/yt/src/commands/implm.rs b/crates/yt/src/commands/implm.rs index 7c60c6a..d8ef146 100644 --- a/crates/yt/src/commands/implm.rs +++ b/crates/yt/src/commands/implm.rs @@ -27,6 +27,7 @@ impl Command { } Command::Show { cmd } => cmd.implm(&app).await?, Command::Status { cmd } => cmd.implm(&app).await?, + Command::Stats { cmd } => cmd.implm(&app).await?, Command::Subscriptions { cmd } => cmd.implm(&app).await?, Command::Update { cmd } => cmd.implm(&app).await?, Command::Videos { cmd } => cmd.implm(&app).await?, diff --git a/crates/yt/src/commands/mod.rs b/crates/yt/src/commands/mod.rs index 431acef..5eb1fad 100644 --- a/crates/yt/src/commands/mod.rs +++ b/crates/yt/src/commands/mod.rs @@ -19,11 +19,12 @@ use crate::{ commands::{ cache::CacheCommand, config::ConfigCommand, database::DatabaseCommand, download::DownloadCommand, playlist::PlaylistCommand, select::SelectCommand, - show::ShowCommand, status::StatusCommand, subscriptions::SubscriptionCommand, - update::UpdateCommand, videos::VideosCommand, watch::WatchCommand, + show::ShowCommand, stats::StatsCommand, status::StatusCommand, + subscriptions::SubscriptionCommand, update::UpdateCommand, videos::VideosCommand, + watch::WatchCommand, }, config::Config, - storage::db::subscription::Subscriptions, + storage::db::subscription::{Subscription, Subscriptions}, }; pub(super) mod implm; @@ -35,6 +36,7 @@ mod download; mod playlist; mod select; mod show; +mod stats; mod status; mod subscriptions; mod update; @@ -93,6 +95,12 @@ pub(super) enum Command { cmd: StatusCommand, }, + /// Show general stats about your `yt` usage + Stats { + #[command(flatten)] + cmd: StatsCommand, + }, + /// Manipulate subscription #[command(visible_alias = "subs")] Subscriptions { @@ -126,15 +134,24 @@ impl Default for Command { } } } +fn complete_subscription_active(current: &OsStr) -> Vec<CompletionCandidate> { + complete_subscription_condition(current, |sub| sub.is_active) +} +fn complete_subscription_inactive(current: &OsStr) -> Vec<CompletionCandidate> { + complete_subscription_condition(current, |sub| !sub.is_active) +} -fn complete_subscription(current: &OsStr) -> Vec<CompletionCandidate> { - let mut output = vec![]; +fn complete_subscription_condition( + current: &OsStr, + condition: fn(&Subscription) -> bool, +) -> Vec<CompletionCandidate> { + let output = vec![]; let Some(current_prog) = current.to_str().map(ToOwned::to_owned) else { return output; }; - let Ok(config) = Config::from_config_file(None, None, None) else { + let Ok(config) = Config::from_config_file(None, None, None, None) else { return output; }; @@ -151,14 +168,21 @@ fn complete_subscription(current: &OsStr) -> Vec<CompletionCandidate> { return output; }; - for sub in all.0.into_keys() { - if sub.starts_with(¤t_prog) { - output.push(CompletionCandidate::new(sub)); + let mut pre_output = vec![]; + for (sub_name, sub) in all.0 { + if sub_name.starts_with(¤t_prog) && condition(&sub) { + pre_output.push((sub_name.clone(), CompletionCandidate::new(sub_name))); } } - output + pre_output.sort_by_key(|(name, _)| name.clone()); + + pre_output.into_iter().map(|(_, comp)| comp).collect() }); handle.join().unwrap_or_default() } + +fn complete_subscription(current: &OsStr) -> Vec<CompletionCandidate> { + complete_subscription_condition(current, |_| true) +} diff --git a/crates/yt/src/commands/playlist/implm.rs b/crates/yt/src/commands/playlist/implm.rs index 603184b..adb75da 100644 --- a/crates/yt/src/commands/playlist/implm.rs +++ b/crates/yt/src/commands/playlist/implm.rs @@ -8,7 +8,7 @@ // 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::{fmt::Write, path::Path}; +use std::{fmt::Write, io::stdout, path::Path}; use crate::{ ansi_escape_codes, @@ -78,8 +78,8 @@ impl PlaylistCommand { .await?; // Delete the previous output - ansi_escape_codes::cursor_up(previous_output_length); - ansi_escape_codes::erase_from_cursor_to_bottom(); + ansi_escape_codes::cursor_up(stdout(), previous_output_length); + ansi_escape_codes::erase_from_cursor_to_bottom(stdout()); previous_output_length = output.chars().filter(|ch| *ch == '\n').count(); diff --git a/crates/yt/src/commands/select/implm/fs_generators/mod.rs b/crates/yt/src/commands/select/implm/fs_generators/mod.rs index 10da032..a0f9098 100644 --- a/crates/yt/src/commands/select/implm/fs_generators/mod.rs +++ b/crates/yt/src/commands/select/implm/fs_generators/mod.rs @@ -24,7 +24,7 @@ use crate::{ commands::{ Command, select::{ - SelectCommand, SelectSplitSortKey, SelectSplitSortMode, + SelectCommand, SelectFileSortKey, SelectSplitSortKey, SortMode, implm::standalone::{self, handle_select_cmd}, }, }, @@ -48,7 +48,7 @@ pub(crate) async fn select_split( app: &App, done: bool, sort_key: SelectSplitSortKey, - sort_mode: SelectSplitSortMode, + sort_mode: SortMode, ) -> Result<()> { let temp_dir = tempfile::Builder::new() .prefix("yt_video_select-") @@ -81,7 +81,7 @@ pub(crate) async fn select_split( match sort_key { SelectSplitSortKey::Publisher => { - // PERFORMANCE: The clone here should not be neeed. <2025-06-15> + // PERFORMANCE: The clone here should not be needed. <2025-06-15> temp_vec.sort_by_key(|(name, _): &(String, Vec<Video>)| name.to_owned()); } SelectSplitSortKey::Videos => { @@ -90,10 +90,10 @@ pub(crate) async fn select_split( } match sort_mode { - SelectSplitSortMode::Asc => { + SortMode::Asc => { // Std's default mode is ascending. } - SelectSplitSortMode::Desc => { + SortMode::Desc => { temp_vec.reverse(); } } @@ -171,7 +171,13 @@ pub(crate) async fn select_split( Ok(()) } -pub(crate) async fn select_file(app: &App, done: bool, use_last_selection: bool) -> Result<()> { +pub(crate) async fn select_file( + app: &App, + done: bool, + sort_key: SelectFileSortKey, + sort_mode: SortMode, + use_last_selection: bool, +) -> Result<()> { let temp_file = tempfile::Builder::new() .prefix("yt_video_select-") .suffix(".yts") @@ -182,7 +188,34 @@ pub(crate) async fn select_file(app: &App, done: bool, use_last_selection: bool) if use_last_selection { fs::copy(&app.config.paths.last_selection_path, &temp_file)?; } else { - let matching_videos = get_videos(app, done).await?; + let mut matching_videos = get_videos(app, done).await?; + + match sort_key { + SelectFileSortKey::Priority => { + // The default sort is by priority + } + SelectFileSortKey::ReleaseDate => { + matching_videos.sort_by_key(|video| video.publish_date.unwrap_or_default()); + } + SelectFileSortKey::Author => matching_videos.sort_by_key(|video| { + video + .parent_subscription_name + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("") + .to_owned() + }), + SelectFileSortKey::Title => matching_videos.sort_by_key(|video| video.title.clone()), + } + + match sort_mode { + SortMode::Asc => { + // The default is ascending + } + SortMode::Desc => { + matching_videos.reverse(); + } + } write_videos_to_file(app, temp_file.as_file(), &matching_videos).await?; } diff --git a/crates/yt/src/commands/select/implm/mod.rs b/crates/yt/src/commands/select/implm/mod.rs index f39c77f..5aaf4a1 100644 --- a/crates/yt/src/commands/select/implm/mod.rs +++ b/crates/yt/src/commands/select/implm/mod.rs @@ -21,15 +21,29 @@ impl SelectCommand { SelectCommand::File { done, use_last_selection, - } => Box::pin(fs_generators::select_file(app, done, use_last_selection)).await?, + sort_mode, + sort_key, + } => { + Box::pin(fs_generators::select_file( + app, + done, + sort_key, + sort_mode, + use_last_selection, + )) + .await? + } + SelectCommand::Split { done, sort_key, sort_mode, } => Box::pin(fs_generators::select_split(app, done, sort_key, sort_mode)).await?, + SelectCommand::Add { urls, start, stop } => { Box::pin(standalone::add::add(app, urls, start, stop)).await?; } + other => { let shared = other .clone() diff --git a/crates/yt/src/commands/select/implm/standalone/mod.rs b/crates/yt/src/commands/select/implm/standalone/mod.rs index 9512e32..dfabc2b 100644 --- a/crates/yt/src/commands/select/implm/standalone/mod.rs +++ b/crates/yt/src/commands/select/implm/standalone/mod.rs @@ -108,8 +108,8 @@ async fn handle_status_change( } if !is_single { - ansi_escape_codes::clear_whole_line(); - ansi_escape_codes::move_to_col(1); + ansi_escape_codes::clear_whole_line(stderr()); + ansi_escape_codes::move_to_col(stderr(), 1); } eprint!("{}", &video.to_line_display(app, None).await?); diff --git a/crates/yt/src/commands/select/mod.rs b/crates/yt/src/commands/select/mod.rs index db69238..085c244 100644 --- a/crates/yt/src/commands/select/mod.rs +++ b/crates/yt/src/commands/select/mod.rs @@ -32,6 +32,14 @@ pub(super) enum SelectCommand { #[arg(long, short)] done: bool, + /// Which value to use to sort videos by + #[arg(long, short = 'k', default_value_t)] + sort_key: SelectFileSortKey, + + /// Which mode to use for sorting. + #[arg(long, short = 'm', default_value_t)] + sort_mode: SortMode, + /// Use the last selection file (useful if you've spend time on it and want to get it again) #[arg(long, short, conflicts_with = "done")] use_last_selection: bool, @@ -49,7 +57,7 @@ pub(super) enum SelectCommand { /// Which mode to use for sorting. #[arg(default_value_t)] - sort_mode: SelectSplitSortMode, + sort_mode: SortMode, }, /// Add a video to the database @@ -110,6 +118,8 @@ impl Default for SelectCommand { Self::File { done: false, use_last_selection: false, + sort_mode: SortMode::default(), + sort_key: SelectFileSortKey::default(), } } } @@ -211,7 +221,7 @@ impl Display for SelectSplitSortKey { } #[derive(Default, ValueEnum, Clone, Copy, Debug)] -enum SelectSplitSortMode { +enum SortMode { /// Sort in ascending order (small -> big) #[default] Asc, @@ -220,11 +230,38 @@ enum SelectSplitSortMode { Desc, } -impl Display for SelectSplitSortMode { +impl Display for SortMode { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + SortMode::Asc => f.write_str("asc"), + SortMode::Desc => f.write_str("desc"), + } + } +} + +#[derive(Default, ValueEnum, Clone, Copy, Debug)] +enum SelectFileSortKey { + /// Sort by the priority value, given to a video + #[default] + Priority, + + /// Sort by a videos release date + ReleaseDate, + + /// Sort by the videos author + Author, + + /// Sort by the videos title + Title, +} + +impl Display for SelectFileSortKey { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { - SelectSplitSortMode::Asc => f.write_str("asc"), - SelectSplitSortMode::Desc => f.write_str("desc"), + SelectFileSortKey::Priority => f.write_str("priority"), + SelectFileSortKey::ReleaseDate => f.write_str("release_date"), + SelectFileSortKey::Author => f.write_str("author"), + SelectFileSortKey::Title => f.write_str("title"), } } } diff --git a/crates/yt/src/commands/stats/implm.rs b/crates/yt/src/commands/stats/implm.rs new file mode 100644 index 0000000..3fc9afe --- /dev/null +++ b/crates/yt/src/commands/stats/implm.rs @@ -0,0 +1,48 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 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 crate::{ + app::App, + commands::StatsCommand, + storage::db::{insert::video, txn_log::TxnLog}, +}; + +use anyhow::Result; + +macro_rules! sort_by_operation { + ($opts:expr, $operation:path) => { + { + let mut new = Vec::with_capacity($opts.inner().len()); + + for (ts, op) in $opts.inner() { + if matches!(op, $operation { .. }) { + new.push((ts, op)); + } + } + + new + } + } +} + +impl StatsCommand { + pub(in crate::commands) async fn implm(self, app: &App) -> Result<()> { + let ops = TxnLog::<video::Operation>::get(app).await?; + + let only_add = sort_by_operation!(ops, video::Operation::Add); + + eprintln!("Number of operations: {}.", ops.inner().len()); + eprintln!("Number of added videos: {}.", only_add.len()); + + // TODO(@bpeetz): Also show watch-time over time, and move other things from `yt status` here. <2026-05-26> + + Ok(()) + } +} diff --git a/crates/yt/src/commands/stats/mod.rs b/crates/yt/src/commands/stats/mod.rs new file mode 100644 index 0000000..bd1eb9d --- /dev/null +++ b/crates/yt/src/commands/stats/mod.rs @@ -0,0 +1,16 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 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 clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(super) struct StatsCommand {} diff --git a/crates/yt/src/commands/status/implm.rs b/crates/yt/src/commands/status/implm.rs index 5832fde..2177012 100644 --- a/crates/yt/src/commands/status/implm.rs +++ b/crates/yt/src/commands/status/implm.rs @@ -130,7 +130,7 @@ impl StatusCommand { "{dropped_videos_len}", dropped_videos_len.to_string().as_str(), ) - .replace("{watchtime_status}", watchtime_status.to_string().as_str()) + .replace("{watchtime_status}", watchtime_status.as_str()) .replace( "{subscriptions_len}", subscriptions_len.to_string().as_str(), diff --git a/crates/yt/src/commands/subscriptions/implm.rs b/crates/yt/src/commands/subscriptions/implm.rs index 1e2e545..a4d9d6d 100644 --- a/crates/yt/src/commands/subscriptions/implm.rs +++ b/crates/yt/src/commands/subscriptions/implm.rs @@ -12,7 +12,7 @@ use std::str::FromStr; use crate::{ app::App, - commands::subscriptions::{SubscriptionCommand, SubscriptionStatus}, + commands::subscriptions::SubscriptionCommand, storage::db::{ insert::{Operations, subscription::Operation}, subscription::{Subscription, Subscriptions, check_url}, @@ -55,21 +55,22 @@ impl SubscriptionCommand { .await .with_context(|| format!("Failed to unsubscribe from {name:?}"))?; } - SubscriptionCommand::SetStatus { name, new_status } => { + SubscriptionCommand::SetStatus { new_status } => { let mut present_subscriptions = Subscriptions::get(app).await?; let mut ops = Operations::new("Subscribe: Set Status"); + + let (new_status, name) = match new_status { + super::NewStatus::Active { name } => (true, name), + super::NewStatus::Inactive { name } => (false, name), + }; + if let Some(subscription) = present_subscriptions.0.remove(&name) { - subscription.set_is_active( - match new_status { - SubscriptionStatus::Active => true, - SubscriptionStatus::Inactive => false, - }, - &mut ops, - ); + subscription.set_is_active(new_status, &mut ops); } else { bail!("Couldn't find subscription: '{}'", &name); } + ops.commit(app) .await .with_context(|| format!("Failed to change status of {name:?}"))?; diff --git a/crates/yt/src/commands/subscriptions/mod.rs b/crates/yt/src/commands/subscriptions/mod.rs index 6a16a9a..2ffc309 100644 --- a/crates/yt/src/commands/subscriptions/mod.rs +++ b/crates/yt/src/commands/subscriptions/mod.rs @@ -10,11 +10,13 @@ use std::path::PathBuf; -use clap::{Subcommand, ValueEnum}; +use clap::Subcommand; use clap_complete::ArgValueCompleter; use url::Url; -use crate::commands::complete_subscription; +use crate::commands::{ + complete_subscription, complete_subscription_active, complete_subscription_inactive, +}; mod implm; @@ -45,12 +47,9 @@ pub(super) enum SubscriptionCommand { /// /// An active subscription will be updated in `yt update`, while an inactive one will not. SetStatus { - /// The human readable name of the subscription - #[arg(add = ArgValueCompleter::new(complete_subscription))] - name: String, - /// What should this subscription be considered now? - new_status: SubscriptionStatus, + #[command(subcommand)] + new_status: NewStatus, }, /// Import a bunch of URLs as subscriptions. @@ -77,8 +76,16 @@ pub(super) enum SubscriptionCommand { }, } -#[derive(ValueEnum, Debug, Clone, Copy)] -pub(in crate::commands) enum SubscriptionStatus { - Active, - Inactive, +#[derive(Subcommand, Debug, Clone)] +pub(in crate::commands) enum NewStatus { + Active { + /// The human readable name of the subscription + #[arg(add = ArgValueCompleter::new(complete_subscription_inactive))] + name: String, + }, + Inactive { + /// The human readable name of the subscription + #[arg(add = ArgValueCompleter::new(complete_subscription_active))] + name: String, + }, } diff --git a/crates/yt/src/commands/update/implm/mod.rs b/crates/yt/src/commands/update/implm/mod.rs index 10626ac..b3064c1 100644 --- a/crates/yt/src/commands/update/implm/mod.rs +++ b/crates/yt/src/commands/update/implm/mod.rs @@ -11,13 +11,18 @@ use crate::{ app::App, commands::update::{UpdateCommand, implm::updater::Updater}, + select::duration::MaybeDuration, storage::db::{ extractor_hash::ExtractorHash, subscription::{Subscription, Subscriptions}, + video::{TimeStamp, Video}, }, }; use anyhow::{Result, bail}; +use chrono::TimeDelta; +use colors::{Colorize, IntoCanvas}; +use log::info; mod updater; @@ -26,6 +31,7 @@ impl UpdateCommand { let UpdateCommand { max_backlog, subscriptions: subscription_names_to_update, + force, } = self; let mut all_subs = Subscriptions::get(app).await?.remove_inactive(); @@ -42,20 +48,89 @@ impl UpdateCommand { Ok(val) } else { bail!( - "Your specified subscription to update '{}' is not a subscription!", - sub + "Your specified subscription to update '{sub}' is not a subscription!", ) } }) .collect::<Result<_>>()? }; + let real_subs = if force { + subs + } else { + // We only update these subscriptions if their last videos has been uploaded for longer than + // the average time between uploads. + + let mut real_subs = vec![]; + for sub in subs { + let mut videos: Vec<TimeStamp> = Video::from_subscription(&sub, app) + .await? + .into_iter() + .filter_map(|v| v.publish_date) + .collect(); + + if videos.is_empty() { + real_subs.push(sub); + break; + } + + videos.sort(); + + let average_time_between_videos: TimeDelta = { + let mut videos = videos.iter().copied(); + let mut last = videos.next().expect("we removed the empty video case"); + + let mut deltas = vec![]; + for next in videos { + deltas.push(next - last); + last = next; + } + + let sum: TimeDelta = deltas.iter().sum(); + + sum / deltas.len() as i32 + }; + + let last_video = videos.last().expect("to exist"); + + if average_time_between_videos <= (TimeStamp::from_now() - *last_video) { + real_subs.push(sub); + } else { + info!( + "Not adding '{}', as the average time between videos ({:#}) is longer than the last upload ({} {} ago) (use --force to override)", + sub.name.blue().render(app.config.global.display_colors), + MaybeDuration::from_std( + average_time_between_videos + .to_std() + .expect("Should be strictly positive") + ) + .green() + .bold() + .render(app.config.global.display_colors), + last_video + .magenta() + .render(app.config.global.display_colors), + MaybeDuration::from_std( + (TimeStamp::from_now() - *last_video) + .to_std() + .expect("Should be strictly positive") + ) + .green() + .bold() + .render(app.config.global.display_colors), + ); + } + } + + real_subs + }; + // We can get away with not having to re-fetch the hashes every time, as the returned video // should not contain duplicates. let hashes = ExtractorHash::get_all(app).await?; let updater = Updater::new(max_backlog, app.config.update.pool_size, hashes); - updater.update(app, subs).await?; + updater.update(app, real_subs).await?; Ok(()) } diff --git a/crates/yt/src/commands/update/implm/updater.rs b/crates/yt/src/commands/update/implm/updater.rs index 2b96bf2..d6fcbd0 100644 --- a/crates/yt/src/commands/update/implm/updater.rs +++ b/crates/yt/src/commands/update/implm/updater.rs @@ -8,7 +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>. -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::{ + io, + sync::atomic::{AtomicUsize, Ordering}, +}; use anyhow::{Context, Result}; use futures::{StreamExt, future::join_all, stream}; @@ -87,14 +90,14 @@ impl Updater { .spawn_pinned(move || { async move { if !log_enabled!(Level::Debug) { - ansi_escape_codes::clear_whole_line(); - ansi_escape_codes::move_to_col(1); + ansi_escape_codes::clear_whole_line(io::stderr()); + ansi_escape_codes::move_to_col(io::stderr(), 1); eprint!( "({}/{total_number}) Checking playlist {}...", REACHED_NUMBER.fetch_add(1, Ordering::Relaxed), sub.name ); - ansi_escape_codes::move_to_col(1); + ansi_escape_codes::move_to_col(io::stderr(), 1); stderr().flush().await?; } diff --git a/crates/yt/src/commands/update/mod.rs b/crates/yt/src/commands/update/mod.rs index cb29148..f181626 100644 --- a/crates/yt/src/commands/update/mod.rs +++ b/crates/yt/src/commands/update/mod.rs @@ -21,6 +21,10 @@ pub(super) struct UpdateCommand { #[arg(short, long)] max_backlog: Option<usize>, + /// Update all specified subscriptions, regardless of average time between uploads + #[arg(short, long)] + force: bool, + /// The subscriptions to update #[arg(add = ArgValueCompleter::new(complete_subscription))] subscriptions: Vec<String>, diff --git a/crates/yt/src/config/mod.rs b/crates/yt/src/config/mod.rs index 05bb4cf..6e596de 100644 --- a/crates/yt/src/config/mod.rs +++ b/crates/yt/src/config/mod.rs @@ -48,7 +48,8 @@ mk_config! { struct Config { global: GlobalConfig = { /// Whether to display colors. - display_colors: bool where display_color: Option<bool> =! {|config_value: Option<bool>| + display_colors: bool where display_color: Option<bool> =! { + |config_value: Option<bool>| Ok::<_, anyhow::Error>( display_color .unwrap_or( @@ -57,6 +58,16 @@ mk_config! { ) ) } => set_static_should_display_color, + + /// The cookie file to pass to `yt_dlp`. + cookies: Option<PathBuf> where cookies_path: Option<PathBuf> =! { + |config_value: Option<Option<PathBuf>>| + if let Some(co) = cookies_path { + Ok::<_, anyhow::Error>(Some(co)) + } else { + Ok(config_value.unwrap_or(None)) + } + }, }, select: SelectConfig = { /// The playback speed to use, when it is not overridden. @@ -111,8 +122,13 @@ mk_config! { mpv_ipc_socket_path: PathBuf =? get_runtime_path("mpv.ipc.socket") => ensure_parent_dir, /// Path to the video database. - database_path: PathBuf where db_path: Option<PathBuf> =! {|config_value: Option<PathBuf>| { - db_path.map_or_else(|| config_value.map_or_else(|| get_data_path("videos.sqlite"), Ok), Ok) + database_path: PathBuf where db_path: Option<PathBuf> =! { + |config_value: Option<PathBuf>| { + db_path + .map_or_else( + || config_value.map_or_else(|| get_data_path("videos.sqlite"), Ok), + Ok + ) }} => ensure_parent_dir, /// Where to store the selection file before applying it. diff --git a/crates/yt/src/main.rs b/crates/yt/src/main.rs index 705e642..50e4a09 100644 --- a/crates/yt/src/main.rs +++ b/crates/yt/src/main.rs @@ -66,7 +66,9 @@ async fn main() -> Result<()> { } }); - let config = Config::from_config_file(args.config_path, args.color, args.db_path)?; + let config = + Config::from_config_file(args.config_path, args.color, args.cookies, args.db_path)?; + if args.version { version::show(&config).await?; return Ok(()); diff --git a/crates/yt/src/storage/db/get/txn_log.rs b/crates/yt/src/storage/db/get/txn_log.rs index 1a6df2c..8e3e950 100644 --- a/crates/yt/src/storage/db/get/txn_log.rs +++ b/crates/yt/src/storage/db/get/txn_log.rs @@ -23,7 +23,7 @@ impl<O: Committable> TxnLog<O> { " SELECT * FROM txn_log - ORDER BY timestamp ASC; + ORDER BY ROWID ASC; " ) .fetch_all(&app.database) diff --git a/crates/yt/src/storage/db/get/video/mod.rs b/crates/yt/src/storage/db/get/video/mod.rs index 69adb6b..c2edbdd 100644 --- a/crates/yt/src/storage/db/get/video/mod.rs +++ b/crates/yt/src/storage/db/get/video/mod.rs @@ -17,10 +17,13 @@ use yt_dlp::{info_json::InfoJson, json_cast, json_try_get}; use crate::{ app::App, - storage::db::video::{ - Video, VideoStatus, VideoStatusMarker, - comments::{Comments, raw::RawComment}, - video_from_record, + storage::db::{ + subscription::Subscription, + video::{ + Video, VideoStatus, VideoStatusMarker, + comments::{Comments, raw::RawComment}, + video_from_record, + }, }, }; @@ -187,6 +190,31 @@ impl Video { } } + pub(crate) async fn from_subscription(sub: &Subscription, app: &App) -> Result<Vec<Self>> { + debug!("Fetching videos from subscription: '{}'", sub.name); + + // NOTE: The ORDER BY statement should be the same as the one in [`next_to_download`]. <2024-08-22> + let videos = query!( + r" + SELECT * + FROM videos + WHERE parent_subscription_name = ? + ORDER BY priority DESC, publish_date DESC; + ", + sub.name + ) + .fetch_all(&app.database) + .await + .with_context(|| format!("Failed to query videos from subscription: '{}'", sub.name))?; + + let real_videos: Vec<Video> = videos + .iter() + .map(|base| -> Video { video_from_record!(base) }) + .collect(); + + Ok(real_videos) + } + /// Returns the videos that are in the `allowed_states`. /// /// # Panics diff --git a/crates/yt/src/storage/db/insert/playlist.rs b/crates/yt/src/storage/db/insert/playlist.rs index 4d3e140..dd8a9d2 100644 --- a/crates/yt/src/storage/db/insert/playlist.rs +++ b/crates/yt/src/storage/db/insert/playlist.rs @@ -40,17 +40,19 @@ impl Playlist { ) -> Result<()> { let (current_index, current_video) = self .get_focused_mut() - .expect("This should be some at this point"); + .expect("This should be some at this point"); // TODO: This expectation doesn't hold, if the video is marked as dropped before it is `quit`ed in the `yt watch` <2026-05-13> debug!( "Playlist handler will mark video '{}' {:?}.", current_video.title, new_state ); - match new_state { - VideoTransition::Watched => current_video.set_watched(ops), - VideoTransition::Picked => current_video.set_status(VideoStatus::Pick, ops), - } + let new_state = match new_state { + VideoTransition::Watched => VideoStatus::Watched, + VideoTransition::Picked => VideoStatus::Pick, + }; + current_video.remove_download_path(ops); + current_video.set_status(new_state, ops); self.save_watch_progress(mpv, current_index, ops); diff --git a/crates/yt/src/storage/db/insert/video/mod.rs b/crates/yt/src/storage/db/insert/video/mod.rs index da62e37..3c73ae7 100644 --- a/crates/yt/src/storage/db/insert/video/mod.rs +++ b/crates/yt/src/storage/db/insert/video/mod.rs @@ -597,14 +597,4 @@ impl Video { }); } } - - /// Mark this video watched. - /// This will both set the status to `Watched` and the `cache_path` to Null. - /// - /// # Panics - /// Only if assertions fail. - pub(crate) fn set_watched(&mut self, ops: &mut Operations<Operation>) { - self.remove_download_path(ops); - self.set_status(VideoStatus::Watched, ops); - } } diff --git a/crates/yt/src/storage/db/subscription.rs b/crates/yt/src/storage/db/subscription.rs index 403938e..39385b9 100644 --- a/crates/yt/src/storage/db/subscription.rs +++ b/crates/yt/src/storage/db/subscription.rs @@ -30,7 +30,11 @@ pub(crate) struct Subscription { impl Subscription { #[must_use] pub(crate) fn new(name: String, url: Url, is_active: bool) -> Self { - Self { name, url, is_active } + Self { + name, + url, + is_active, + } } } diff --git a/crates/yt/src/storage/db/video/comments/display.rs b/crates/yt/src/storage/db/video/comments/display.rs index c372603..d0c400d 100644 --- a/crates/yt/src/storage/db/video/comments/display.rs +++ b/crates/yt/src/storage/db/video/comments/display.rs @@ -33,7 +33,7 @@ impl Comments { color: bool, ) -> std::fmt::Result { let ident = &(0..ident_count).map(|_| " ").collect::<String>(); - let value = &comment.value; + let value = &comment.raw; f.write_str(ident)?; @@ -79,7 +79,7 @@ impl Comments { write!( f, " [{}]", - comment.value.like_count.bold().red().render(color) + comment.raw.like_count.bold().red().render(color) )?; f.write_str(":\n")?; @@ -102,7 +102,7 @@ impl Comments { f.write_str("\n")?; } else { let mut children = comment.replies.clone(); - children.sort_by(|a, b| a.value.timestamp.cmp(&b.value.timestamp)); + children.sort_by(|a, b| a.raw.timestamp.cmp(&b.raw.timestamp)); for child in children { format(&child, f, ident_count + 4, color)?; @@ -116,7 +116,7 @@ impl Comments { if !&self.inner.is_empty() { let mut children = self.inner.clone(); - children.sort_by(|a, b| b.value.like_count.cmp(&a.value.like_count)); + children.sort_by(|a, b| b.raw.like_count.cmp(&a.raw.like_count)); for child in children { format(&child, &mut f, 0, use_color)?; diff --git a/crates/yt/src/storage/db/video/comments/mod.rs b/crates/yt/src/storage/db/video/comments/mod.rs index 41a03be..b199346 100644 --- a/crates/yt/src/storage/db/video/comments/mod.rs +++ b/crates/yt/src/storage/db/video/comments/mod.rs @@ -8,11 +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>. -use std::mem; +use log::debug; +use url::Url; -use regex::{Captures, Regex}; - -use crate::storage::db::video::comments::raw::{Parent, RawComment}; +use crate::storage::db::video::comments::raw::{Id, RawComment}; pub(crate) mod display; pub(crate) mod raw; @@ -20,182 +19,168 @@ pub(crate) mod raw; #[cfg(test)] mod tests; -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct Comment { - value: RawComment, - replies: Vec<Self>, -} - #[derive(Debug, Default, PartialEq)] pub(crate) struct Comments { inner: Vec<Comment>, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Comment { + raw: RawComment, + replies: Vec<Self>, +} + impl Comments { - pub(crate) fn from_raw(raw: Vec<RawComment>) -> Self { - let mut me = Self::default(); + pub(crate) fn from_raw(mut raw: Vec<RawComment>) -> Self { + let mut me = Self { inner: vec![] }; + + raw.iter_mut().enumerate().for_each(|(index, raw_comment)| { + raw_comment.original_order = index; + }); + + raw.sort_by_key(|raw| match &raw.parent { + raw::Parent::Root => 0, + raw::Parent::Id(id) => id.split('.').count(), + }); - // Apply the parent -> child mapping yt provides us with. for raw_comment in raw { - if let Parent::Id(id) = &raw_comment.parent { - me.insert(&(id.clone()), Comment::from(raw_comment)); - } else { - me.inner.push(Comment::from(raw_comment)); + match raw_comment.parent.clone() { + raw::Parent::Root => me.add_toplevel(raw_comment), + raw::Parent::Id(id) => { + let ids: Vec<_> = id.split('.').collect(); + me.add_reply(&ids, raw_comment); + } } } { // Sort the final comments chronologically. - // This ensures that replies are matched with the comment they actually replied to and - // not a later comment from the same author. - for comment in &mut me.inner { - comment - .replies - .sort_by_key(|comment| comment.value.timestamp); + // This reverses our sort we did before for ids. + me.sort_replies(); + } - for reply in &comment.replies { - assert!(reply.replies.is_empty()); - } + me + } + + fn sort_replies(&mut self) { + self.inner.sort_by_key(|comment| comment.raw.original_order); + + self.inner.iter_mut().for_each(Comment::sort_replies); + } + + fn add_toplevel(&mut self, comment: RawComment) { + self.inner.push(comment.into()); + } + + fn get_id(&mut self, id: &str) -> &mut Comment { + for comment in &mut self.inner { + if comment.raw.id.id == id { + return comment; } } - { - let find_reply_indicator = - Regex::new(r"\u{200b}?(@[^\t\s]+)\u{200b}?").expect("This is hardcoded"); + unreachable!("We cannot add a comment, that is a reply to an not-yet added one.") + } - // Try to re-construct the replies for the reply comments. - for comment in &mut me.inner { - let previous_replies = mem::take(&mut comment.replies); + fn add_reply(&mut self, ids: &[&str], mut raw_comment: RawComment) { + fn first_line(text: &str) -> &str { + let end = text + .chars() + .take_while(|ch| *ch != '\n' && *ch != '.') + .map(char::len_utf8) + .sum(); - let mut reply_tree = Comments::default(); + &text[..end] + } - for reply in previous_replies { - // We try to reconstruct the parent child relation ship by looking (naively) - // for a reply indicator. Currently, this is just the `@<some_name>`, as yt - // seems to insert that by default if you press `reply-to` in their clients. - // - // This follows these steps: - // - Does this reply have a “reply indicator”? - // - If yes, try to resolve the indicator. - // - If it is resolvable, add this reply to the [`Comment`] it resolved to. - // - If not, keep the comment as reply. + debug!("**Searching for parent id: `{}`", ids.join("-")); - if let Some(reply_indicator_matches) = - find_reply_indicator.captures(&reply.value.text.clone()) - { - // We found a reply indicator. - // First we traverse the current `reply_tree` in reversed order to find a - // match, than we check if the reply indicator matches the reply tree root - // and afterward we declare it unmatching and add it as toplevel. + let first = ids + .first() + .expect("We cannot have a comment reply, without also having it's parent id encoded"); + let mut reply = self.get_id(first); + debug!(" -> {}: `{}`", first, first_line(&reply.raw.text)); - let reply_target_author = reply_indicator_matches - .get(1) - .expect("This should also exist") - .as_str(); + for id in &ids[1..] { + debug!(" **Searching for id: `{id}`"); - if let Some(parent) = reply_tree.find_author_mut(reply_target_author) { - parent - .replies - .push(comment_from_reply(reply, &reply_indicator_matches)); - } else if comment.value.author == reply_target_author { - reply_tree - .add_toplevel(comment_from_reply(reply, &reply_indicator_matches)); - } else { - eprintln!( - "Failed to find a parent for ('{}') both directly \ - and via replies! The reply text was:\n'{}'\n", - reply_target_author, reply.value.text - ); - reply_tree.add_toplevel(reply); - } - } else { - // The comment text did not contain a reply indicator, so add it as - // toplevel. - reply_tree.add_toplevel(reply); - } - } + reply = reply.get_id(id); - comment.replies = reply_tree.inner; - } + debug!(" -> {}: `{}`", id, first_line(&reply.raw.text)); } - me - } - - fn add_toplevel(&mut self, value: Comment) { - self.inner.push(value); + raw_comment.text = raw_comment + .text + .trim() + .trim_start_matches(&reply.raw.author) + .trim() + .to_owned(); + reply.replies.push(raw_comment.into()); } +} - fn insert(&mut self, id: &str, value: Comment) { - let parent = self - .inner +impl Comment { + fn maybe_get_id(&mut self, id: &str) -> Option<&mut Self> { + self.replies .iter_mut() - .find(|c| c.value.id.id == id) - .expect("One of these should exist"); - - parent.replies.push(value); + .find(|comment| comment.raw.id.id == id) } - fn find_author_mut(&mut self, reply_target_author: &str) -> Option<&mut Comment> { - fn perform_check<'a>( - comment: &'a mut Comment, - reply_target_author: &str, - ) -> Option<&'a mut Comment> { - // TODO(@bpeetz): This is a workaround until rust has lexiographic lifetime support. <2025-07-18> - fn find_in_replies<'a>( - comment: &'a mut Comment, - reply_target_author: &str, - ) -> Option<&'a mut Comment> { - comment - .replies - .iter_mut() - .rev() - .find_map(|reply: &mut Comment| perform_check(reply, reply_target_author)) + fn get_id(&mut self, id: &str) -> &mut Self { + // TODO: This `if` is a work-around, until lexicographic lifetimes are added. <2026-05-26> + if self.maybe_get_id(id).is_none() { + macro_rules! from_last { + ($field:ident, $self:expr) => { + $self + .replies + .last() + .map_or(self.raw.$field, |last| last.raw.$field) + }; } - let comment_author_matches_target = comment.value.author == reply_target_author; - match find_in_replies(comment, reply_target_author) { - Some(_) => Some( - // PERFORMANCE(@bpeetz): We should not need to run this code twice. <2025-07-18> - find_in_replies(comment, reply_target_author) - .expect("We already had a Some result for this."), - ), - None if comment_author_matches_target => Some(comment), - None => None, - } - } + debug!( + "Failed to find an id for a reply (the parent id did not exist). Assuming deleted comment" + ); - for comment in self.inner.iter_mut().rev() { - if let Some(output) = perform_check(comment, reply_target_author) { - return Some(output); - } - } + self.replies.push(Comment { + raw: RawComment { + original_order: from_last!(original_order, self) + 1, + id: Id { id: id.to_owned() }, + text: "<Deleted comment>".to_owned(), + like_count: 0, + is_pinned: false, + author_id: "@ghost".to_owned(), + author: "@ghost".to_owned(), + author_is_verified: false, + author_thumbnail: Url::parse("https://example.org/@ghost").expect("hard-coded"), + parent: raw::Parent::Id(self.raw.id.id.clone()), + edited: false, + timestamp: from_last!(timestamp, self), + author_url: None, + author_is_uploader: false, + is_favorited: false, + }, + replies: vec![], + }); - None + self.replies.last_mut().expect("We just added it") + } else { + self.maybe_get_id(id).expect("It's some") + } } -} -fn comment_from_reply(reply: Comment, reply_indicator_matches: &Captures<'_>) -> Comment { - Comment::from(RawComment { - text: { - // Remove the `@<some_name>` for the comment text. - let full_match = reply_indicator_matches - .get(0) - .expect("This will always exist"); - let text = reply.value.text[0..full_match.start()].to_owned() - + &reply.value.text[full_match.end()..]; + fn sort_replies(&mut self) { + self.replies + .sort_by_key(|comment| comment.raw.original_order); - text.trim_matches(|c: char| c == '\u{200b}' || c == '\u{2060}' || c.is_whitespace()) - .to_owned() - }, - ..reply.value - }) + self.replies.iter_mut().for_each(Comment::sort_replies); + } } impl From<RawComment> for Comment { fn from(value: RawComment) -> Self { Self { - value, + raw: value, replies: vec![], } } diff --git a/crates/yt/src/storage/db/video/comments/raw.rs b/crates/yt/src/storage/db/video/comments/raw.rs index 3b7f40f..e27eedd 100644 --- a/crates/yt/src/storage/db/video/comments/raw.rs +++ b/crates/yt/src/storage/db/video/comments/raw.rs @@ -47,10 +47,15 @@ impl From<String> for Parent { #[derive(Debug, Deserialize, Clone, Eq, PartialEq, PartialOrd, Ord)] #[allow(clippy::struct_excessive_bools)] pub(crate) struct RawComment { + /// This field is used to encode the original order of the comments in the raw vector, returned + /// by yt-dlp. + #[serde(default = "zero")] + pub(crate) original_order: usize, + pub(crate) id: Id, pub(crate) text: String, #[serde(default = "zero")] - pub(crate) like_count: u32, + pub(crate) like_count: usize, pub(crate) is_pinned: bool, pub(crate) author_id: String, #[serde(default = "unknown")] @@ -71,7 +76,7 @@ pub(crate) struct RawComment { fn unknown() -> String { "<Unknown>".to_string() } -fn zero() -> u32 { +fn zero() -> usize { 0 } fn edited_from_time_text<'de, D>(d: D) -> Result<bool, D::Error> diff --git a/crates/yt/src/storage/db/video/comments/tests.rs b/crates/yt/src/storage/db/video/comments/tests.rs index 03e3597..8cb1a9a 100644 --- a/crates/yt/src/storage/db/video/comments/tests.rs +++ b/crates/yt/src/storage/db/video/comments/tests.rs @@ -38,6 +38,11 @@ macro_rules! mk_comments { ) )+ ) => {{ + use std::sync::atomic::{AtomicUsize, Ordering}; + + static INDEX_INPUT: AtomicUsize = AtomicUsize::new(0); + static INDEX_EXPECTED: AtomicUsize = AtomicUsize::new(0); + let (nested_input, _) = mk_comments!( $( $( @@ -49,7 +54,7 @@ macro_rules! mk_comments { let mut input: Vec<RawComment> = vec![ $( - mk_comments!(@to_raw input $name $comment $parent, $actual_parent) + mk_comments!(@to_raw input $name INDEX_INPUT.fetch_add(1, Ordering::Relaxed), $comment $parent, $actual_parent) ),+ ]; input.extend(nested_input); @@ -58,7 +63,7 @@ macro_rules! mk_comments { inner: vec![ $( Comment { - value: mk_comments!(@to_raw expected $name $comment $parent, $actual_parent), + raw: mk_comments!(@to_raw expected $name INDEX_EXPECTED.fetch_add(1, Ordering::Relaxed), $comment $parent, $actual_parent), replies: { let (_, nested_expected) = mk_comments!( $( @@ -86,6 +91,11 @@ macro_rules! mk_comments { ) )+ ) => {{ + use std::sync::atomic::{AtomicUsize, Ordering}; + + static INDEX_INPUT: AtomicUsize = AtomicUsize::new(0); + static INDEX_EXPECTED: AtomicUsize = AtomicUsize::new(0); + let (nested_input, _) = mk_comments!( $( $( @@ -97,7 +107,7 @@ macro_rules! mk_comments { let mut input: Vec<RawComment> = vec![ $( - mk_comments!(@to_raw input $name $comment) + mk_comments!(@to_raw input $name INDEX_INPUT.fetch_add(1, Ordering::Relaxed), $comment) ),+ ]; input.extend(nested_input); @@ -106,7 +116,7 @@ macro_rules! mk_comments { inner: vec![ $( Comment { - value: mk_comments!(@to_raw expected $name $comment), + raw: mk_comments!(@to_raw expected $name INDEX_EXPECTED.fetch_add(1, Ordering::Relaxed), $comment), replies: { let (_, nested_expected) = mk_comments!( $( @@ -125,19 +135,30 @@ macro_rules! mk_comments { (input, expected) }}; - (@mk_id $name:ident $comment:literal) => {{ + (@mk_id $name:ident $comment:literal $($parent:expr)?) => {{ use std::hash::{Hash, Hasher}; let input = format!("{}{}", stringify!($name), $comment); let mut digest = std::hash::DefaultHasher::new(); input.hash(&mut digest); - Id { id: digest.finish().to_string() } + + #[allow(unused_mut, unused_assignments)] + { + let mut parent_id = ".".to_owned(); + + $( + parent_id = format!("{}.", $parent.id); + )? + + Id { id: format!("{parent_id}{}", digest.finish().to_string()) } + } }}; - (@to_raw $state:ident $name:ident $comment:literal $($parent:expr, $actual_parent:ident)?) => { + (@to_raw $state:ident $name:ident $index:expr, $comment:literal $($parent:expr, $actual_parent:ident)?) => { RawComment { - id: mk_comments!(@mk_id $name $comment), + original_order: $index, + id: mk_comments!(@mk_id $name $comment $($parent)?), text: mk_comments!(@mk_text $state $comment $(, $actual_parent)?), like_count: 0, is_pinned: false, diff --git a/crates/yt/src/storage/db/video/mod.rs b/crates/yt/src/storage/db/video/mod.rs index deeb82c..7fc6764 100644 --- a/crates/yt/src/storage/db/video/mod.rs +++ b/crates/yt/src/storage/db/video/mod.rs @@ -10,7 +10,7 @@ use std::{fmt::Display, path::PathBuf, time::Duration}; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeDelta, Utc}; use serde::{Deserialize, Serialize}; use url::Url; @@ -125,7 +125,7 @@ impl Display for Priority { } /// An UNIX time stamp. -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct TimeStamp { value: i64, } @@ -150,6 +150,15 @@ impl TimeStamp { } } } + +impl std::ops::Sub for TimeStamp { + type Output = TimeDelta; + + fn sub(self, rhs: Self) -> Self::Output { + TimeDelta::seconds(self.value - rhs.value) + } +} + impl Display for TimeStamp { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { DateTime::from_timestamp(self.value, 0) diff --git a/crates/yt_dlp/Cargo.toml b/crates/yt_dlp/Cargo.toml index eb2924d..7f2170e 100644 --- a/crates/yt_dlp/Cargo.toml +++ b/crates/yt_dlp/Cargo.toml @@ -28,7 +28,7 @@ pyo3 = { workspace = true } pyo3-pylogger = { path = "crates/pyo3-pylogger" } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true -thiserror = "2.0.17" +thiserror = "2.0.18" url.workspace = true [lints] diff --git a/crates/yt_dlp/crates/pyo3-pylogger/Cargo.toml b/crates/yt_dlp/crates/pyo3-pylogger/Cargo.toml index 89c827d..a2676e7 100644 --- a/crates/yt_dlp/crates/pyo3-pylogger/Cargo.toml +++ b/crates/yt_dlp/crates/pyo3-pylogger/Cargo.toml @@ -10,7 +10,7 @@ [package] name = "pyo3-pylogger" -version = "1.8.0" +version = "1.9.0" edition = "2021" authors = [ "Dylan Bobby Storey <dylan.storey@gmail.com>", diff --git a/crates/yt_dlp/crates/pyo3-pylogger/update.sh b/crates/yt_dlp/crates/pyo3-pylogger/update.sh deleted file mode 100755 index dd3e57e..0000000 --- a/crates/yt_dlp/crates/pyo3-pylogger/update.sh +++ /dev/null @@ -1,17 +0,0 @@ -#! /usr/bin/env sh - -# yt - A fully featured command line YouTube client -# -# Copyright (C) 2025 Dylan Bobby Storey <dylan.storey@gmail.com>, cpu <daniel@binaryparadox.net>, Warren Snipes <contact@warrensnipes.dev> -# SPDX-License-Identifier: Apache-2.0 -# -# 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>. - -cd "$(dirname "$0")" || exit 1 -[ "$1" = "upgrade" ] && cargo upgrade --incompatible -cargo update - -# vim: ft=sh diff --git a/crates/yt_dlp/src/hooks.rs b/crates/yt_dlp/src/hooks.rs new file mode 100644 index 0000000..df70ecd --- /dev/null +++ b/crates/yt_dlp/src/hooks.rs @@ -0,0 +1,62 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 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>. + +#[macro_export] +macro_rules! wrap_progress_hook { + ($name:ident, $new_name:ident) => { + yt_dlp::wrap_hook! {"progress_hook", $name, $new_name} + }; +} + +#[macro_export] +macro_rules! wrap_post_processor_hook { + ($name:ident, $new_name:ident) => { + yt_dlp::wrap_hook! {"post_processor_hook", $name, $new_name} + }; +} + +#[macro_export] +macro_rules! wrap_hook { + ($ty:literal, $name:ident, $new_name:ident) => { + pub(crate) fn $new_name( + py: yt_dlp::hooks::__priv::pyo3::Python<'_>, + ) -> yt_dlp::hooks::__priv::pyo3::PyResult< + yt_dlp::hooks::__priv::pyo3::Bound<'_, yt_dlp::hooks::__priv::pyo3::types::PyCFunction>, + > { + #[yt_dlp::hooks::__priv::pyo3::pyfunction] + #[pyo3(crate = "yt_dlp::hooks::__priv::pyo3")] + fn inner( + input: yt_dlp::hooks::__priv::pyo3::Bound< + '_, + yt_dlp::hooks::__priv::pyo3::types::PyDict, + >, + ) -> yt_dlp::hooks::__priv::pyo3::PyResult<()> { + let processed_input = { + let new_dict = yt_dlp::hooks::__priv::filter_dict(input); + yt_dlp::hooks::__priv::json_dumps(&new_dict) + }; + + $name(processed_input)?; + + Ok(()) + } + + let module = yt_dlp::hooks::__priv::pyo3::types::PyModule::new(py, $ty)?; + let fun = yt_dlp::hooks::__priv::pyo3::wrap_pyfunction!(inner, module)?; + + Ok(fun) + } + }; +} + +pub mod __priv { + pub use crate::info_json::{json_dumps, json_loads, filter_dict}; + pub use pyo3; +} diff --git a/crates/yt_dlp/src/info_json.rs b/crates/yt_dlp/src/info_json.rs index 402acb4..df49218 100644 --- a/crates/yt_dlp/src/info_json.rs +++ b/crates/yt_dlp/src/info_json.rs @@ -54,3 +54,39 @@ pub fn json_dumps(input: &Bound<'_, PyDict>) -> serde_json::Map<String, serde_js _ => unreachable!("These should not be json.dumps output"), } } + +/// # Panics +/// If expectation about python operations fail. +#[must_use] +pub fn filter_dict(input: Bound<'_, PyDict>) -> Bound<'_, PyDict> { + let new_dict = PyDict::new(input.py()); + + input + .into_iter() + .filter_map(|(name, value)| { + let real_name = name.extract::<String>().expect("Should always be a string"); + + if real_name.starts_with('_') { + None + } else { + let value = if value.is_instance_of::<PyDict>() { + filter_dict( + value + .cast_into::<PyDict>() + .expect("to be a dict, because we checked"), + ) + .into_any() + } else { + value + }; + Some((real_name, value)) + } + }) + .for_each(|(key, value)| { + new_dict + .set_item(&key, value) + .expect("This is a transposition, should always be valid"); + }); + + new_dict +} diff --git a/crates/yt_dlp/src/lib.rs b/crates/yt_dlp/src/lib.rs index 4b252de..abe766d 100644 --- a/crates/yt_dlp/src/lib.rs +++ b/crates/yt_dlp/src/lib.rs @@ -27,7 +27,7 @@ use crate::{ pub mod info_json; pub mod options; pub mod post_processors; -pub mod progress_hook; +pub mod hooks; pub mod python_error; #[macro_export] diff --git a/crates/yt_dlp/src/options.rs b/crates/yt_dlp/src/options.rs index 4b8906e..a87473d 100644 --- a/crates/yt_dlp/src/options.rs +++ b/crates/yt_dlp/src/options.rs @@ -21,7 +21,7 @@ use crate::{ python_error::{IntoPythonError, PythonError}, }; -pub type ProgressHookFunction = fn(py: Python<'_>) -> PyResult<Bound<'_, PyCFunction>>; +pub type HookFunction = fn(py: Python<'_>) -> PyResult<Bound<'_, PyCFunction>>; pub type PostProcessorFunction = fn(py: Python<'_>) -> PyResult<Bound<'_, PyAny>>; /// Options, that are used to customize the download behaviour. @@ -32,7 +32,8 @@ pub type PostProcessorFunction = fn(py: Python<'_>) -> PyResult<Bound<'_, PyAny> #[derive(Default, Debug)] pub struct YoutubeDLOptions { options: serde_json::Map<String, serde_json::Value>, - progress_hook: Option<ProgressHookFunction>, + progress_hook: Option<HookFunction>, + post_processor_hook: Option<HookFunction>, post_processors: Vec<PostProcessorFunction>, } @@ -42,6 +43,7 @@ impl YoutubeDLOptions { let me = Self { options: serde_json::Map::new(), progress_hook: None, + post_processor_hook: None, post_processors: vec![], }; @@ -57,7 +59,7 @@ impl YoutubeDLOptions { } #[must_use] - pub fn with_progress_hook(self, progress_hook: ProgressHookFunction) -> Self { + pub fn with_progress_hook(self, progress_hook: HookFunction) -> Self { if let Some(_previous_hook) = self.progress_hook { todo!() } else { @@ -67,6 +69,17 @@ impl YoutubeDLOptions { } } } + #[must_use] + pub fn with_post_processor_hook(self, post_processor_hook: HookFunction) -> Self { + if let Some(_previous_hook) = self.post_processor_hook { + todo!() + } else { + Self { + post_processor_hook: Some(post_processor_hook), + ..self + } + } + } #[must_use] pub fn with_post_processor(mut self, pp: PostProcessorFunction) -> Self { @@ -135,6 +148,15 @@ signal.signal(signal.SIGINT, signal.SIG_DFL) opts.set_item(intern!(py, "progress_hooks"), vec![ph(py).wrap_exc(py)?]) .wrap_exc(py)?; } + + // Setup the post_processor hook + if let Some(ph) = options.post_processor_hook { + opts.set_item( + intern!(py, "postprocessor_hooks"), + vec![ph(py).wrap_exc(py)?], + ) + .wrap_exc(py)?; + } } { diff --git a/crates/yt_dlp/src/post_processors/dearrow.rs b/crates/yt_dlp/src/post_processors/dearrow.rs index 7787d68..ff4cdfa 100644 --- a/crates/yt_dlp/src/post_processors/dearrow.rs +++ b/crates/yt_dlp/src/post_processors/dearrow.rs @@ -9,7 +9,7 @@ // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. use curl::easy::Easy; -use log::{error, info, trace, warn}; +use log::{info, trace, warn}; use pyo3::{ Bound, PyAny, PyErr, PyResult, Python, exceptions, intern, pyfunction, types::{PyAnyMethods, PyDict, PyModule}, diff --git a/crates/yt_dlp/src/progress_hook.rs b/crates/yt_dlp/src/progress_hook.rs deleted file mode 100644 index 7e5f8a5..0000000 --- a/crates/yt_dlp/src/progress_hook.rs +++ /dev/null @@ -1,67 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 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>. - -#[macro_export] -macro_rules! wrap_progress_hook { - ($name:ident, $new_name:ident) => { - pub(crate) fn $new_name( - py: yt_dlp::progress_hook::__priv::pyo3::Python<'_>, - ) -> yt_dlp::progress_hook::__priv::pyo3::PyResult< - yt_dlp::progress_hook::__priv::pyo3::Bound< - '_, - yt_dlp::progress_hook::__priv::pyo3::types::PyCFunction, - >, - > { - #[yt_dlp::progress_hook::__priv::pyo3::pyfunction] - #[pyo3(crate = "yt_dlp::progress_hook::__priv::pyo3")] - fn inner( - input: yt_dlp::progress_hook::__priv::pyo3::Bound< - '_, - yt_dlp::progress_hook::__priv::pyo3::types::PyDict, - >, - ) -> yt_dlp::progress_hook::__priv::pyo3::PyResult<()> { - let processed_input = { - let new_dict = yt_dlp::progress_hook::__priv::pyo3::types::PyDict::new(input.py()); - - input - .into_iter() - .filter_map(|(name, value)| { - let real_name = yt_dlp::progress_hook::__priv::pyo3::types::PyAnyMethods::extract::<String>(&name).expect("Should always be a string"); - - if real_name.starts_with('_') { - None - } else { - Some((real_name, value)) - } - }) - .for_each(|(key, value)| { - yt_dlp::progress_hook::__priv::pyo3::types::PyDictMethods::set_item(&new_dict, &key, value) - .expect("This is a transpositions, should always be valid"); - }); - yt_dlp::progress_hook::__priv::json_dumps(&new_dict) - }; - - $name(processed_input)?; - - Ok(()) - } - - let module = yt_dlp::progress_hook::__priv::pyo3::types::PyModule::new(py, "progress_hook")?; - let fun = yt_dlp::progress_hook::__priv::pyo3::wrap_pyfunction!(inner, module)?; - - Ok(fun) - } - }; -} - -pub mod __priv { - pub use crate::info_json::{json_dumps, json_loads}; - pub use pyo3; -} diff --git a/crates/yt_dlp/update.sh b/crates/yt_dlp/update.sh deleted file mode 100755 index 52c96b5..0000000 --- a/crates/yt_dlp/update.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env sh - -# 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>. - -"$(dirname "$0")/crates/pyo3-pylogger/update.sh" "$@" |
