aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to '')
-rwxr-xr-xcrates/libmpv2/libmpv2-sys/update.sh15
-rwxr-xr-xcrates/libmpv2/update.sh13
-rw-r--r--crates/yt/Cargo.toml23
-rw-r--r--crates/yt/src/ansi_escape_codes.rs18
-rw-r--r--crates/yt/src/cli.rs5
-rw-r--r--crates/yt/src/commands/download/implm/download/download_options.rs22
-rw-r--r--crates/yt/src/commands/download/implm/download/hooks.rs (renamed from crates/yt/src/commands/download/implm/download/progress_hook.rs)197
-rw-r--r--crates/yt/src/commands/download/implm/download/mod.rs6
-rw-r--r--crates/yt/src/commands/implm.rs1
-rw-r--r--crates/yt/src/commands/mod.rs44
-rw-r--r--crates/yt/src/commands/playlist/implm.rs6
-rw-r--r--crates/yt/src/commands/select/implm/fs_generators/mod.rs47
-rw-r--r--crates/yt/src/commands/select/implm/mod.rs16
-rw-r--r--crates/yt/src/commands/select/implm/standalone/mod.rs4
-rw-r--r--crates/yt/src/commands/select/mod.rs47
-rw-r--r--crates/yt/src/commands/stats/implm.rs48
-rw-r--r--crates/yt/src/commands/stats/mod.rs16
-rw-r--r--crates/yt/src/commands/status/implm.rs2
-rw-r--r--crates/yt/src/commands/subscriptions/implm.rs19
-rw-r--r--crates/yt/src/commands/subscriptions/mod.rs29
-rw-r--r--crates/yt/src/commands/update/implm/mod.rs81
-rw-r--r--crates/yt/src/commands/update/implm/updater.rs11
-rw-r--r--crates/yt/src/commands/update/mod.rs4
-rw-r--r--crates/yt/src/config/mod.rs22
-rw-r--r--crates/yt/src/main.rs4
-rw-r--r--crates/yt/src/storage/db/get/txn_log.rs2
-rw-r--r--crates/yt/src/storage/db/get/video/mod.rs36
-rw-r--r--crates/yt/src/storage/db/insert/playlist.rs12
-rw-r--r--crates/yt/src/storage/db/insert/video/mod.rs10
-rw-r--r--crates/yt/src/storage/db/subscription.rs6
-rw-r--r--crates/yt/src/storage/db/video/comments/display.rs8
-rw-r--r--crates/yt/src/storage/db/video/comments/mod.rs259
-rw-r--r--crates/yt/src/storage/db/video/comments/raw.rs9
-rw-r--r--crates/yt/src/storage/db/video/comments/tests.rs37
-rw-r--r--crates/yt/src/storage/db/video/mod.rs13
-rw-r--r--crates/yt_dlp/Cargo.toml2
-rw-r--r--crates/yt_dlp/crates/pyo3-pylogger/Cargo.toml2
-rwxr-xr-xcrates/yt_dlp/crates/pyo3-pylogger/update.sh17
-rw-r--r--crates/yt_dlp/src/hooks.rs62
-rw-r--r--crates/yt_dlp/src/info_json.rs36
-rw-r--r--crates/yt_dlp/src/lib.rs2
-rw-r--r--crates/yt_dlp/src/options.rs28
-rw-r--r--crates/yt_dlp/src/post_processors/dearrow.rs2
-rw-r--r--crates/yt_dlp/src/progress_hook.rs67
-rwxr-xr-xcrates/yt_dlp/update.sh13
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(&current_prog) {
- output.push(CompletionCandidate::new(sub));
+ let mut pre_output = vec![];
+ for (sub_name, sub) in all.0 {
+ if sub_name.starts_with(&current_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" "$@"