aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.rs41
-rw-r--r--src/cache/mod.rs88
-rw-r--r--src/cli.rs318
-rw-r--r--src/comments/comment.rs63
-rw-r--r--src/comments/display.rs117
-rw-r--r--src/comments/mod.rs178
-rw-r--r--src/config/default.rs102
-rw-r--r--src/config/definitions.rs59
-rw-r--r--src/config/file_system.rs112
-rw-r--r--src/config/mod.rs62
-rw-r--r--src/constants.rs11
-rw-r--r--src/download/download_options.rs121
-rw-r--r--src/download/mod.rs303
-rw-r--r--src/main.rs252
-rw-r--r--src/select/cmds.rs147
-rw-r--r--src/select/mod.rs173
-rw-r--r--src/select/selection_file/duration.rs111
-rw-r--r--src/select/selection_file/help.str12
-rw-r--r--src/select/selection_file/help.str.license9
-rw-r--r--src/select/selection_file/mod.rs34
-rw-r--r--src/status/mod.rs107
-rw-r--r--src/storage/mod.rs12
-rw-r--r--src/storage/subscriptions.rs140
-rw-r--r--src/storage/video_database/downloader.rs153
-rw-r--r--src/storage/video_database/extractor_hash.rs159
-rw-r--r--src/storage/video_database/getters.rs345
-rw-r--r--src/storage/video_database/mod.rs179
-rw-r--r--src/storage/video_database/schema.sql57
-rw-r--r--src/storage/video_database/setters.rs270
-rw-r--r--src/subscribe/mod.rs184
-rw-r--r--src/update/mod.rs257
-rw-r--r--src/videos/display/format_video.rs166
-rw-r--r--src/videos/display/mod.rs314
-rw-r--r--src/videos/mod.rs66
-rw-r--r--src/watch/events/mod.rs369
-rw-r--r--src/watch/events/playlist_handler.rs94
-rw-r--r--src/watch/mod.rs117
37 files changed, 0 insertions, 5302 deletions
diff --git a/src/app.rs b/src/app.rs
deleted file mode 100644
index b7d136e..0000000
--- a/src/app.rs
+++ /dev/null
@@ -1,41 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use anyhow::{Context, Result};
-use sqlx::{query, sqlite::SqliteConnectOptions, SqlitePool};
-
-use crate::config::Config;
-
-pub struct App {
- pub database: SqlitePool,
- pub config: Config,
-}
-
-impl App {
- pub async fn new(config: Config) -> Result<Self> {
- let options = SqliteConnectOptions::new()
- .filename(&config.paths.database_path)
- .optimize_on_close(true, None)
- .create_if_missing(true);
-
- let pool = SqlitePool::connect_with(options)
- .await
- .context("Failed to connect to database!")?;
-
- query(include_str!("storage/video_database/schema.sql"))
- .execute(&pool)
- .await?;
-
- Ok(App {
- database: pool,
- config,
- })
- }
-}
diff --git a/src/cache/mod.rs b/src/cache/mod.rs
deleted file mode 100644
index a3e08c9..0000000
--- a/src/cache/mod.rs
+++ /dev/null
@@ -1,88 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use anyhow::{Context, Result};
-use log::info;
-use tokio::fs;
-
-use crate::{
- app::App,
- storage::video_database::{
- downloader::set_video_cache_path, getters::get_videos, setters::set_state_change, Video,
- VideoStatus,
- },
-};
-
-async fn invalidate_video(app: &App, video: &Video, hard: bool) -> Result<()> {
- info!("Invalidating cache of video: '{}'", video.title);
-
- if hard {
- if let Some(path) = &video.cache_path {
- info!("Removing cached video at: '{}'", path.display());
- fs::remove_file(path).await.with_context(|| {
- format!(
- "Failed to delete video ('{}') cache path: '{}'.",
- video.title,
- path.display()
- )
- })?;
- }
- }
-
- set_video_cache_path(app, &video.extractor_hash, None).await?;
-
- Ok(())
-}
-
-pub async fn invalidate(app: &App, hard: bool) -> Result<()> {
- let all_cached_things = get_videos(app, &[VideoStatus::Cached], None).await?;
-
- info!("Got videos to invalidate: '{}'", all_cached_things.len());
-
- for video in all_cached_things {
- invalidate_video(app, &video, hard).await?
- }
-
- Ok(())
-}
-
-pub async fn maintain(app: &App, all: bool) -> Result<()> {
- let domain = if all {
- vec![
- VideoStatus::Pick,
- //
- VideoStatus::Watch,
- VideoStatus::Cached,
- VideoStatus::Watched,
- //
- VideoStatus::Drop,
- VideoStatus::Dropped,
- ]
- } else {
- vec![VideoStatus::Watch, VideoStatus::Cached]
- };
-
- let cached_videos = get_videos(app, domain.as_slice(), None).await?;
-
- for vid in cached_videos {
- if let Some(path) = vid.cache_path.as_ref() {
- info!("Checking if path ('{}') exists", path.display());
- if !path.exists() {
- invalidate_video(app, &vid, false).await?;
- }
- }
- if vid.status_change {
- info!("Video '{}' has it's changing bit set. This is probably the result of an unexpectet exit. Clearing it", vid.title);
- set_state_change(app, &vid.extractor_hash, false).await?;
- }
- }
-
- Ok(())
-}
diff --git a/src/cli.rs b/src/cli.rs
deleted file mode 100644
index d19586e..0000000
--- a/src/cli.rs
+++ /dev/null
@@ -1,318 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::path::PathBuf;
-
-use anyhow::Context;
-use bytes::Bytes;
-use chrono::NaiveDate;
-use clap::{ArgAction, Args, Parser, Subcommand};
-use url::Url;
-
-use crate::{
- select::selection_file::duration::Duration,
- storage::video_database::extractor_hash::LazyExtractorHash,
-};
-
-#[derive(Parser, Debug)]
-#[clap(author, version, about, long_about = None)]
-/// An command line interface to select, download and watch videos
-pub struct CliArgs {
- #[command(subcommand)]
- /// The subcommand to execute [default: select]
- pub command: Option<Command>,
-
- /// Increase message verbosity
- #[arg(long="verbose", short = 'v', action = ArgAction::Count)]
- pub verbosity: u8,
-
- /// Set the path to the videos.db. This overrides the default and the config file.
- #[arg(long, short)]
- pub db_path: Option<PathBuf>,
-
- /// Set the path to the config.toml.
- /// This overrides the default.
- #[arg(long, short)]
- pub config_path: Option<PathBuf>,
-
- /// Silence all output
- #[arg(long, short = 'q')]
- pub quiet: bool,
-}
-
-#[derive(Subcommand, Debug)]
-pub enum Command {
- /// Download and cache URLs
- Download {
- /// Forcefully re-download all cached videos (i.e. delete the cache path, then download).
- #[arg(short, long)]
- force: bool,
-
- /// The maximum size the download dir should have. Beware that the value must be given in
- /// bytes.
- #[arg(short, long, value_parser = byte_parser)]
- max_cache_size: Option<u64>,
- },
-
- /// Select, download and watch in one command.
- Sedowa {},
- /// Download and watch in one command.
- Dowa {},
-
- /// Work with single videos
- Videos {
- #[command(subcommand)]
- cmd: VideosCommand,
- },
-
- /// Watch the already cached (and selected) videos
- Watch {},
-
- /// Show, which videos have been selected to be watched (and their cache status)
- Status {},
-
- /// Show, the configuration options in effect
- Config {},
-
- /// Perform various tests
- Check {
- #[command(subcommand)]
- command: CheckCommand,
- },
-
- /// Display the comments of the currently playing video
- Comments {},
- /// Display the description of the currently playing video
- Description {},
-
- /// Manipulate the video cache in the database
- #[command(visible_alias = "db")]
- Database {
- #[command(subcommand)]
- command: CacheCommand,
- },
-
- /// Change the state of videos in the database (the default)
- Select {
- #[command(subcommand)]
- cmd: Option<SelectCommand>,
- },
-
- /// Update the video database
- Update {
- #[arg(short, long)]
- /// The number of videos to updating
- max_backlog: Option<u32>,
-
- #[arg(short, long)]
- /// The subscriptions to update (can be given multiple times)
- subscriptions: Vec<String>,
- },
-
- /// Manipulate subscription
- #[command(visible_alias = "subs")]
- Subscriptions {
- #[command(subcommand)]
- cmd: SubscriptionCommand,
- },
-}
-
-fn byte_parser(input: &str) -> Result<u64, anyhow::Error> {
- Ok(input
- .parse::<Bytes>()
- .with_context(|| format!("Failed to parse '{}' as bytes!", input))?
- .as_u64())
-}
-
-impl Default for Command {
- fn default() -> Self {
- Self::Select {
- cmd: Some(SelectCommand::default()),
- }
- }
-}
-
-#[derive(Subcommand, Clone, Debug)]
-pub enum VideosCommand {
- /// List the videos in the database
- #[command(visible_alias = "ls")]
- List {
- /// An optional search query to limit the results
- #[arg(action = ArgAction::Append)]
- search_query: Option<String>,
-
- /// The number of videos to show
- #[arg(short, long)]
- limit: Option<usize>,
- },
-
- /// Get detailed information about a video
- Info {
- /// The short hash of the video
- hash: LazyExtractorHash,
- },
-}
-
-#[derive(Subcommand, Clone, Debug)]
-pub enum SubscriptionCommand {
- /// Subscribe to an URL
- Add {
- #[arg(short, long)]
- /// The human readable name of the subscription
- name: Option<String>,
-
- /// The URL to listen to
- url: Url,
- },
-
- /// Unsubscribe from an URL
- Remove {
- /// The human readable name of the subscription
- name: String,
- },
-
- /// Import a bunch of URLs as subscriptions.
- Import {
- /// The file containing the URLs. Will use Stdin otherwise.
- file: Option<PathBuf>,
-
- /// Remove any previous subscriptions
- #[arg(short, long)]
- force: bool,
- },
- /// Write all subscriptions in an format understood by `import`
- Export {},
-
- /// List all subscriptions
- List {},
-}
-
-#[derive(Clone, Debug, Args)]
-#[command(infer_subcommands = true)]
-/// Mark the video given by the hash to be watched
-pub struct SharedSelectionCommandArgs {
- /// The ordering priority (higher means more at the top)
- #[arg(short, long)]
- pub priority: Option<i64>,
-
- /// The subtitles to download (e.g. 'en,de,sv')
- #[arg(short = 'l', long)]
- pub subtitle_langs: Option<String>,
-
- /// The speed to set mpv to
- #[arg(short, long)]
- pub speed: Option<f64>,
-
- /// The short extractor hash
- pub hash: LazyExtractorHash,
-
- pub title: String,
-
- pub date: NaiveDate,
-
- pub publisher: String,
-
- pub duration: Duration,
-
- pub url: Url,
-}
-
-#[derive(Subcommand, Clone, Debug)]
-// NOTE: Keep this in sync with the [`constants::HELP_STR`] constant. <2024-08-20>
-pub enum SelectCommand {
- /// Open a `git rebase` like file to select the videos to watch (the default)
- File {
- /// Include done (watched, dropped) videos
- #[arg(long, short)]
- done: bool,
-
- /// 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,
- },
-
- /// Add a video to the database
- #[command(visible_alias = "a")]
- Add { urls: Vec<Url> },
-
- /// Mark the video given by the hash to be watched
- #[command(visible_alias = "w")]
- Watch {
- #[command(flatten)]
- shared: SharedSelectionCommandArgs,
- },
-
- /// Mark the video given by the hash to be dropped
- #[command(visible_alias = "d")]
- Drop {
- #[command(flatten)]
- shared: SharedSelectionCommandArgs,
- },
-
- /// Mark the video given by the hash as already watched
- #[command(visible_alias = "wd")]
- Watched {
- #[command(flatten)]
- shared: SharedSelectionCommandArgs,
- },
-
- /// Open the video URL in Firefox's `timesinks.youtube` profile
- #[command(visible_alias = "u")]
- Url {
- #[command(flatten)]
- shared: SharedSelectionCommandArgs,
- },
-
- /// Reset the videos status to 'Pick'
- #[command(visible_alias = "p")]
- Pick {
- #[command(flatten)]
- shared: SharedSelectionCommandArgs,
- },
-}
-impl Default for SelectCommand {
- fn default() -> Self {
- Self::File {
- done: false,
- use_last_selection: false,
- }
- }
-}
-
-#[derive(Subcommand, Clone, Debug)]
-pub enum CheckCommand {
- /// Check if the given info.json is deserializable
- InfoJson { path: PathBuf },
-
- /// Check if the given update info.json is deserializable
- UpdateInfoJson { path: PathBuf },
-}
-
-#[derive(Subcommand, Clone, Copy, Debug)]
-pub enum CacheCommand {
- /// Invalidate all cache entries
- Invalidate {
- /// Also delete the cache path
- #[arg(short, long)]
- hard: bool,
- },
-
- /// Perform basic maintenance operations on the database.
- /// This helps recovering from invalid db states after a crash (or force exit via CTRL+C).
- ///
- /// 1. Check every path for validity (removing all invalid cache entries)
- /// 2. Reset all `status_change` bits of videos to false.
- #[command(verbatim_doc_comment)]
- Maintain {
- /// Check every video (otherwise only the videos to be watched are checked)
- #[arg(short, long)]
- all: bool,
- },
-}
diff --git a/src/comments/comment.rs b/src/comments/comment.rs
deleted file mode 100644
index 752c510..0000000
--- a/src/comments/comment.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use yt_dlp::wrapper::info_json::Comment;
-
-#[derive(Debug, Clone)]
-pub struct CommentExt {
- pub value: Comment,
- pub replies: Vec<CommentExt>,
-}
-
-#[derive(Debug, Default)]
-pub struct Comments {
- pub(super) vec: Vec<CommentExt>,
-}
-
-impl Comments {
- pub fn new() -> Self {
- Self::default()
- }
- pub fn push(&mut self, value: CommentExt) {
- self.vec.push(value);
- }
- pub fn get_mut(&mut self, key: &str) -> Option<&mut CommentExt> {
- self.vec.iter_mut().filter(|c| c.value.id.id == key).last()
- }
- pub fn insert(&mut self, key: &str, value: CommentExt) {
- let parent = self
- .vec
- .iter_mut()
- .filter(|c| c.value.id.id == key)
- .last()
- .expect("One of these should exist");
- parent.push_reply(value);
- }
-}
-impl CommentExt {
- pub fn push_reply(&mut self, value: CommentExt) {
- self.replies.push(value)
- }
- pub fn get_mut_reply(&mut self, key: &str) -> Option<&mut CommentExt> {
- self.replies
- .iter_mut()
- .filter(|c| c.value.id.id == key)
- .last()
- }
-}
-
-impl From<Comment> for CommentExt {
- fn from(value: Comment) -> Self {
- Self {
- replies: vec![],
- value,
- }
- }
-}
diff --git a/src/comments/display.rs b/src/comments/display.rs
deleted file mode 100644
index 7000063..0000000
--- a/src/comments/display.rs
+++ /dev/null
@@ -1,117 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::fmt::Write;
-
-use chrono::{Local, TimeZone};
-use chrono_humanize::{Accuracy, HumanTime, Tense};
-
-use crate::comments::comment::CommentExt;
-
-use super::comment::Comments;
-
-impl Comments {
- pub fn render(&self, color: bool) -> String {
- self.render_help(color).expect("This should never fail.")
- }
-
- fn render_help(&self, color: bool) -> Result<String, std::fmt::Error> {
- let mut f = String::new();
-
- macro_rules! c {
- ($color_str:expr, $write:ident, $color:expr) => {
- if $color {
- $write.write_str(concat!("\x1b[", $color_str, "m"))?
- }
- };
- }
-
- fn format(
- comment: &CommentExt,
- f: &mut String,
- ident_count: u32,
- color: bool,
- ) -> std::fmt::Result {
- let ident = &(0..ident_count).map(|_| " ").collect::<String>();
- let value = &comment.value;
-
- f.write_str(ident)?;
-
- if value.author_is_uploader {
- c!("91;1", f, color);
- } else {
- c!("35", f, color);
- }
-
- f.write_str(&value.author)?;
- c!("0", f, color);
- if value.edited || value.is_favorited {
- f.write_str("[")?;
- if value.edited {
- f.write_str("")?;
- }
- if value.edited && value.is_favorited {
- f.write_str(" ")?;
- }
- if value.is_favorited {
- f.write_str("")?;
- }
- f.write_str("]")?;
- }
-
- c!("36;1", f, color);
- write!(
- f,
- " {}",
- HumanTime::from(
- Local
- .timestamp_opt(value.timestamp, 0)
- .single()
- .expect("This should be valid")
- )
- .to_text_en(Accuracy::Rough, Tense::Past)
- )?;
- c!("0", f, color);
-
- // c!("31;1", f);
- // f.write_fmt(format_args!(" [{}]", comment.value.like_count))?;
- // c!("0", f);
-
- f.write_str(":\n")?;
- f.write_str(ident)?;
-
- f.write_str(&value.text.replace('\n', &format!("\n{}", ident)))?;
- f.write_str("\n")?;
-
- if !comment.replies.is_empty() {
- let mut children = comment.replies.clone();
- children.sort_by(|a, b| a.value.timestamp.cmp(&b.value.timestamp));
-
- for child in children {
- format(&child, f, ident_count + 4, color)?;
- }
- } else {
- f.write_str("\n")?;
- }
-
- Ok(())
- }
-
- if !&self.vec.is_empty() {
- let mut children = self.vec.clone();
- children.sort_by(|a, b| b.value.like_count.cmp(&a.value.like_count));
-
- for child in children {
- format(&child, &mut f, 0, color)?
- }
- }
- Ok(f)
- }
-}
diff --git a/src/comments/mod.rs b/src/comments/mod.rs
deleted file mode 100644
index 5fbc3fb..0000000
--- a/src/comments/mod.rs
+++ /dev/null
@@ -1,178 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::{
- io::Write,
- mem,
- process::{Command, Stdio},
-};
-
-use anyhow::{bail, Context, Result};
-use comment::{CommentExt, Comments};
-use regex::Regex;
-use yt_dlp::wrapper::info_json::{Comment, InfoJson, Parent};
-
-use crate::{
- app::App,
- storage::video_database::{
- getters::{get_currently_playing_video, get_video_info_json},
- Video,
- },
-};
-
-mod comment;
-mod display;
-
-pub async fn get_comments(app: &App) -> Result<Comments> {
- let currently_playing_video: Video =
- if let Some(video) = get_currently_playing_video(app).await? {
- video
- } else {
- bail!("Could not find a currently playing video!");
- };
-
- let mut info_json: InfoJson = get_video_info_json(&currently_playing_video)
- .await?
- .expect("A currently *playing* must be cached. And thus the info.json should be available");
-
- let base_comments = mem::take(&mut info_json.comments).expect("A video should have comments");
- drop(info_json);
-
- let mut comments = Comments::new();
- base_comments.into_iter().for_each(|c| {
- if let Parent::Id(id) = &c.parent {
- comments.insert(&(id.clone()), CommentExt::from(c));
- } else {
- comments.push(CommentExt::from(c));
- }
- });
-
- comments.vec.iter_mut().for_each(|comment| {
- let replies = mem::take(&mut comment.replies);
- let mut output_replies: Vec<CommentExt> = vec![];
-
- let re = Regex::new(r"\u{200b}?(@[^\t\s]+)\u{200b}?").unwrap();
- for reply in replies {
- if let Some(replyee_match) = re.captures(&reply.value.text){
- let full_match = replyee_match.get(0).expect("This always exists");
- let text = reply.
- value.
- text[0..full_match.start()]
- .to_owned()
- +
- &reply
- .value
- .text[full_match.end()..];
- let text: &str = text.trim().trim_matches('\u{200b}');
-
- let replyee = replyee_match.get(1).expect("This should exist").as_str();
-
-
- if let Some(parent) = output_replies
- .iter_mut()
- // .rev()
- .flat_map(|com| &mut com.replies)
- .flat_map(|com| &mut com.replies)
- .flat_map(|com| &mut com.replies)
- .filter(|com| com.value.author == replyee)
- .last()
- {
- parent.replies.push(CommentExt::from(Comment {
- text: text.to_owned(),
- ..reply.value
- }))
- } else if let Some(parent) = output_replies
- .iter_mut()
- // .rev()
- .flat_map(|com| &mut com.replies)
- .flat_map(|com| &mut com.replies)
- .filter(|com| com.value.author == replyee)
- .last()
- {
- parent.replies.push(CommentExt::from(Comment {
- text: text.to_owned(),
- ..reply.value
- }))
- } else if let Some(parent) = output_replies
- .iter_mut()
- // .rev()
- .flat_map(|com| &mut com.replies)
- .filter(|com| com.value.author == replyee)
- .last()
- {
- parent.replies.push(CommentExt::from(Comment {
- text: text.to_owned(),
- ..reply.value
- }))
- } else if let Some(parent) = output_replies.iter_mut()
- // .rev()
- .filter(|com| com.value.author == replyee)
- .last()
- {
- parent.replies.push(CommentExt::from(Comment {
- text: text.to_owned(),
- ..reply.value
- }))
- } else {
- eprintln!(
- "Failed to find a parent for ('{}') both directly and via replies! The reply text was:\n'{}'\n",
- replyee,
- reply.value.text
- );
- output_replies.push(reply);
- }
- } else {
- output_replies.push(reply);
- }
- }
- comment.replies = output_replies;
- });
-
- Ok(comments)
-}
-
-pub async fn comments(app: &App) -> Result<()> {
- let comments = get_comments(app).await?;
-
- let mut less = Command::new("less")
- .args(["--raw-control-chars"])
- .stdin(Stdio::piped())
- .stderr(Stdio::inherit())
- .spawn()
- .context("Failed to run less")?;
-
- let mut child = Command::new("fmt")
- .args(["--uniform-spacing", "--split-only", "--width=90"])
- .stdin(Stdio::piped())
- .stderr(Stdio::inherit())
- .stdout(less.stdin.take().expect("Should be open"))
- .spawn()
- .context("Failed to run fmt")?;
-
- let mut stdin = child.stdin.take().context("Failed to open stdin")?;
- std::thread::spawn(move || {
- stdin
- .write_all(comments.render(true).as_bytes())
- .expect("Should be able to write to stdin of fmt");
- });
-
- let _ = less.wait().context("Failed to await less")?;
-
- Ok(())
-}
-
-#[cfg(test)]
-mod test {
- #[test]
- fn test_string_replacement() {
- let s = "A \n\nB\n\nC".to_owned();
- assert_eq!("A \n \n B\n \n C", s.replace('\n', "\n "))
- }
-}
diff --git a/src/config/default.rs b/src/config/default.rs
deleted file mode 100644
index 59063f5..0000000
--- a/src/config/default.rs
+++ /dev/null
@@ -1,102 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::path::PathBuf;
-
-use anyhow::{Context, Result};
-
-fn get_runtime_path(name: &'static str) -> Result<PathBuf> {
- let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX)?;
- xdg_dirs
- .place_runtime_file(name)
- .with_context(|| format!("Failed to place runtime file: '{}'", name))
-}
-fn get_data_path(name: &'static str) -> Result<PathBuf> {
- let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX)?;
- xdg_dirs
- .place_data_file(name)
- .with_context(|| format!("Failed to place data file: '{}'", name))
-}
-fn get_config_path(name: &'static str) -> Result<PathBuf> {
- let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX)?;
- xdg_dirs
- .place_config_file(name)
- .with_context(|| format!("Failed to place config file: '{}'", name))
-}
-
-pub(super) fn create_path(path: PathBuf) -> Result<PathBuf> {
- if !path.exists() {
- if let Some(parent) = path.parent() {
- std::fs::create_dir_all(parent)
- .with_context(|| format!("Failed to create the '{}' directory", path.display()))?
- }
- }
-
- Ok(path)
-}
-
-pub const PREFIX: &str = "yt";
-
-pub mod select {
- pub fn playback_speed() -> f64 {
- 2.7
- }
- pub fn subtitle_langs() -> &'static str {
- ""
- }
-}
-
-pub mod watch {
- pub fn local_comments_length() -> usize {
- 1000
- }
-}
-
-pub mod update {
- pub fn max_backlog() -> u32 {
- 20
- }
-}
-
-pub mod paths {
- use std::{env::temp_dir, path::PathBuf};
-
- use anyhow::Result;
-
- use super::{create_path, get_config_path, get_data_path, get_runtime_path, PREFIX};
-
- // We download to the temp dir to avoid taxing the disk
- pub fn download_dir() -> Result<PathBuf> {
- let temp_dir = temp_dir();
-
- create_path(temp_dir.join(PREFIX))
- }
- pub fn mpv_config_path() -> Result<PathBuf> {
- get_config_path("mpv.conf")
- }
- pub fn mpv_input_path() -> Result<PathBuf> {
- get_config_path("mpv.input.conf")
- }
- pub fn database_path() -> Result<PathBuf> {
- get_data_path("videos.sqlite")
- }
- pub fn config_path() -> Result<PathBuf> {
- get_config_path("config.toml")
- }
- pub fn last_selection_path() -> Result<PathBuf> {
- get_runtime_path("selected.yts")
- }
-}
-
-pub mod download {
- pub fn max_cache_size() -> &'static str {
- "3 GiB"
- }
-}
diff --git a/src/config/definitions.rs b/src/config/definitions.rs
deleted file mode 100644
index d37e6da..0000000
--- a/src/config/definitions.rs
+++ /dev/null
@@ -1,59 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::path::PathBuf;
-
-use serde::Deserialize;
-
-#[derive(Debug, Deserialize, PartialEq)]
-#[serde(deny_unknown_fields)]
-pub struct ConfigFile {
- pub select: Option<SelectConfig>,
- pub watch: Option<WatchConfig>,
- pub paths: Option<PathsConfig>,
- pub download: Option<DownloadConfig>,
- pub update: Option<UpdateConfig>,
-}
-
-#[derive(Debug, Deserialize, PartialEq, Clone, Copy)]
-#[serde(deny_unknown_fields)]
-pub struct UpdateConfig {
- pub max_backlog: Option<u32>,
-}
-
-#[derive(Debug, Deserialize, PartialEq, Clone)]
-#[serde(deny_unknown_fields)]
-pub struct DownloadConfig {
- /// This will then be converted to an u64
- pub max_cache_size: Option<String>,
-}
-
-#[derive(Debug, Deserialize, PartialEq, Clone)]
-#[serde(deny_unknown_fields)]
-pub struct SelectConfig {
- pub playback_speed: Option<f64>,
- pub subtitle_langs: Option<String>,
-}
-
-#[derive(Debug, Deserialize, PartialEq, Clone, Copy)]
-#[serde(deny_unknown_fields)]
-pub struct WatchConfig {
- pub local_comments_length: Option<usize>,
-}
-
-#[derive(Debug, Deserialize, PartialEq, Clone)]
-#[serde(deny_unknown_fields)]
-pub struct PathsConfig {
- pub download_dir: Option<PathBuf>,
- pub mpv_config_path: Option<PathBuf>,
- pub mpv_input_path: Option<PathBuf>,
- pub database_path: Option<PathBuf>,
- pub last_selection_path: Option<PathBuf>,
-}
diff --git a/src/config/file_system.rs b/src/config/file_system.rs
deleted file mode 100644
index 5751583..0000000
--- a/src/config/file_system.rs
+++ /dev/null
@@ -1,112 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use crate::config::{DownloadConfig, PathsConfig, SelectConfig, WatchConfig};
-
-use super::{
- default::{create_path, download, paths, select, update, watch},
- Config, UpdateConfig,
-};
-
-use std::{fs::read_to_string, path::PathBuf};
-
-use anyhow::{Context, Result};
-use bytes::Bytes;
-
-macro_rules! get {
- ($default:path, $config:expr, $key_one:ident, $($keys:ident),*) => {
- {
- let maybe_value = get!{@option $config, $key_one, $($keys),*};
- if let Some(value) = maybe_value {
- value
- } else {
- $default().to_owned()
- }
- }
- };
-
- (@option $config:expr, $key_one:ident, $($keys:ident),*) => {
- if let Some(key) = $config.$key_one.clone() {
- get!{@option key, $($keys),*}
- } else {
- None
- }
- };
- (@option $config:expr, $key_one:ident) => {
- $config.$key_one
- };
-
- (@path_if_none $config:expr, $option_default:expr, $default:path, $key_one:ident, $($keys:ident),*) => {
- {
- let maybe_download_dir: Option<PathBuf> =
- get! {@option $config, $key_one, $($keys),*};
-
- let down_dir = if let Some(dir) = maybe_download_dir {
- PathBuf::from(dir)
- } else {
- if let Some(path) = $option_default {
- path
- } else {
- $default()
- .with_context(|| format!("Failed to get default path for: '{}.{}'", stringify!($key_one), stringify!($($keys),*)))?
- }
- };
- create_path(down_dir)?
- }
- };
- (@path $config:expr, $default:path, $key_one:ident, $($keys:ident),*) => {
- get! {@path_if_none $config, None, $default, $key_one, $($keys),*}
- };
-}
-
-impl Config {
- pub fn from_config_file(
- db_path: Option<PathBuf>,
- config_path: Option<PathBuf>,
- ) -> Result<Self> {
- let config_file_path = config_path
- .map(Ok)
- .unwrap_or_else(|| -> Result<_> { paths::config_path() })?;
-
- let config: super::definitions::ConfigFile =
- toml::from_str(&read_to_string(config_file_path).unwrap_or("".to_owned()))
- .context("Failed to parse the config file as toml")?;
-
- Ok(Self {
- select: SelectConfig {
- playback_speed: get! {select::playback_speed, config, select, playback_speed},
- subtitle_langs: get! {select::subtitle_langs, config, select, subtitle_langs},
- },
- watch: WatchConfig {
- local_comments_length: get! {watch::local_comments_length, config, watch, local_comments_length},
- },
- update: UpdateConfig {
- max_backlog: get! {update::max_backlog, config, update, max_backlog},
- },
- paths: PathsConfig {
- download_dir: get! {@path config, paths::download_dir, paths, download_dir},
- mpv_config_path: get! {@path config, paths::mpv_config_path, paths, mpv_config_path},
- mpv_input_path: get! {@path config, paths::mpv_input_path, paths, mpv_input_path},
- database_path: get! {@path_if_none config, db_path, paths::database_path, paths, database_path},
- last_selection_path: get! {@path config, paths::last_selection_path, paths, last_selection_path},
- },
- download: DownloadConfig {
- max_cache_size: {
- let bytes_str: String =
- get! {download::max_cache_size, config, download, max_cache_size};
- let number: Bytes = bytes_str
- .parse()
- .context("Failed to parse max_cache_size")?;
- number
- },
- },
- })
- }
-}
diff --git a/src/config/mod.rs b/src/config/mod.rs
deleted file mode 100644
index ea40055..0000000
--- a/src/config/mod.rs
+++ /dev/null
@@ -1,62 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::path::PathBuf;
-
-use bytes::Bytes;
-use serde::Serialize;
-
-mod default;
-mod definitions;
-pub mod file_system;
-
-#[derive(Serialize)]
-pub struct Config {
- pub select: SelectConfig,
- pub watch: WatchConfig,
- pub paths: PathsConfig,
- pub download: DownloadConfig,
- pub update: UpdateConfig,
-}
-#[derive(Serialize)]
-pub struct UpdateConfig {
- pub max_backlog: u32,
-}
-#[derive(Serialize)]
-pub struct DownloadConfig {
- pub max_cache_size: Bytes,
-}
-#[derive(Serialize)]
-pub struct SelectConfig {
- pub playback_speed: f64,
- pub subtitle_langs: String,
-}
-#[derive(Serialize)]
-pub struct WatchConfig {
- pub local_comments_length: usize,
-}
-#[derive(Serialize)]
-pub struct PathsConfig {
- pub download_dir: PathBuf,
- pub mpv_config_path: PathBuf,
- pub mpv_input_path: PathBuf,
- pub database_path: PathBuf,
- pub last_selection_path: PathBuf,
-}
-
-// pub fn status_path() -> anyhow::Result<PathBuf> {
-// const STATUS_PATH: &str = "running.info.json";
-// get_runtime_path(STATUS_PATH)
-// }
-
-// pub fn subscriptions() -> anyhow::Result<PathBuf> {
-// const SUBSCRIPTIONS: &str = "subscriptions.json";
-// get_data_path(SUBSCRIPTIONS)
-// }
diff --git a/src/constants.rs b/src/constants.rs
deleted file mode 100644
index 54cae89..0000000
--- a/src/constants.rs
+++ /dev/null
@@ -1,11 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-pub const HELP_STR: &str = include_str!("./select/selection_file/help.str");
diff --git a/src/download/download_options.rs b/src/download/download_options.rs
deleted file mode 100644
index e93170a..0000000
--- a/src/download/download_options.rs
+++ /dev/null
@@ -1,121 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use serde_json::{json, Value};
-
-use crate::{app::App, storage::video_database::YtDlpOptions};
-
-// {
-// "ratelimit": conf.ratelimit if conf.ratelimit > 0 else None,
-// "retries": conf.retries,
-// "merge_output_format": conf.merge_output_format,
-// "restrictfilenames": conf.restrict_filenames,
-// "ignoreerrors": False,
-// "postprocessors": [{"key": "FFmpegMetadata"}],
-// "logger": _ytdl_logger
-// }
-
-pub fn download_opts(
- app: &App,
- additional_opts: YtDlpOptions,
-) -> serde_json::Map<String, serde_json::Value> {
- match json!({
- "extract_flat": false,
- "extractor_args": {
- "youtube": {
- "comment_sort": [
- "top"
- ],
- "max_comments": [
- "150",
- "all",
- "100"
- ]
- }
- },
- "ffmpeg_location": env!("FFMPEG_LOCATION"),
- "format": "bestvideo[height<=?1080]+bestaudio/best",
- "fragment_retries": 10,
- "getcomments": true,
- "ignoreerrors": false,
- "retries": 10,
-
- "writeinfojson": true,
- "writeannotations": true,
- "writesubtitles": true,
- "writeautomaticsub": true,
-
- "outtmpl": {
- "default": app.config.paths.download_dir.join("%(channel)s/%(title)s.%(ext)s"),
- "chapter": "%(title)s - %(section_number)03d %(section_title)s [%(id)s].%(ext)s"
- },
- "compat_opts": {},
- "forceprint": {},
- "print_to_file": {},
- "windowsfilenames": false,
- "restrictfilenames": false,
- "trim_file_names": false,
- "postprocessors": [
- {
- "api": "https://sponsor.ajay.app",
- "categories": [
- "interaction",
- "intro",
- "music_offtopic",
- "sponsor",
- "outro",
- "poi_highlight",
- "preview",
- "selfpromo",
- "filler",
- "chapter"
- ],
- "key": "SponsorBlock",
- "when": "after_filter"
- },
- {
- "force_keyframes": false,
- "key": "ModifyChapters",
- "remove_chapters_patterns": [],
- "remove_ranges": [],
- "remove_sponsor_segments": [
- "sponsor"
- ],
- "sponsorblock_chapter_title": "[SponsorBlock]: %(category_names)l"
- },
- {
- "add_chapters": true,
- "add_infojson": null,
- "add_metadata": false,
- "key": "FFmpegMetadata"
- },
- {
- "key": "FFmpegConcat",
- "only_multi_video": true,
- "when": "playlist"
- }
- ]
- }) {
- serde_json::Value::Object(mut obj) => {
- obj.insert(
- "subtitleslangs".to_owned(),
- serde_json::Value::Array(
- additional_opts
- .subtitle_langs
- .split(',')
- .map(|val| Value::String(val.to_owned()))
- .collect::<Vec<_>>(),
- ),
- );
- obj
- }
- _ => unreachable!("This is an object"),
- }
-}
diff --git a/src/download/mod.rs b/src/download/mod.rs
deleted file mode 100644
index 56910f9..0000000
--- a/src/download/mod.rs
+++ /dev/null
@@ -1,303 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration};
-
-use crate::{
- app::App,
- download::download_options::download_opts,
- storage::video_database::{
- downloader::{get_next_uncached_video, set_video_cache_path},
- extractor_hash::ExtractorHash,
- getters::get_video_yt_dlp_opts,
- Video, YtDlpOptions,
- },
-};
-
-use anyhow::{bail, Context, Result};
-use bytes::Bytes;
-use futures::{future::BoxFuture, FutureExt};
-use log::{debug, error, info, warn};
-use tokio::{fs, task::JoinHandle, time};
-
-pub mod download_options;
-
-#[derive(Debug)]
-pub struct CurrentDownload {
- task_handle: JoinHandle<Result<()>>,
- extractor_hash: ExtractorHash,
-}
-
-impl CurrentDownload {
- fn new_from_video(app: Arc<App>, video: Video) -> Self {
- let extractor_hash = video.extractor_hash.clone();
-
- let task_handle = tokio::spawn(async move {
- Downloader::actually_cache_video(&app, &video)
- .await
- .with_context(|| format!("Failed to cache video: '{}'", video.title))?;
- Ok(())
- });
-
- Self {
- task_handle,
- extractor_hash,
- }
- }
-}
-
-enum CacheSizeCheck {
- /// The video can be downloaded
- Fits,
-
- /// The video and the current cache size together would exceed the size
- TooLarge,
-
- /// The video would not even fit into the empty cache
- ExceedsMaxCacheSize,
-}
-
-pub struct Downloader {
- current_download: Option<CurrentDownload>,
- video_size_cache: HashMap<ExtractorHash, u64>,
- printed_warning: bool,
- cached_cache_allocation: Option<u64>,
-}
-
-impl Default for Downloader {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl Downloader {
- pub fn new() -> Self {
- Self {
- current_download: None,
- video_size_cache: HashMap::new(),
- printed_warning: false,
- cached_cache_allocation: None,
- }
- }
-
- /// Check if enough cache is available. Will wait for 10s if it's not.
- async fn is_enough_cache_available(
- &mut self,
- app: &App,
- max_cache_size: u64,
- next_video: &Video,
- ) -> Result<CacheSizeCheck> {
- if let Some(cdownload) = &self.current_download {
- if cdownload.extractor_hash == next_video.extractor_hash {
- // If the video is already being downloaded it will always fit. Otherwise the
- // download would not have been started.
- return Ok(CacheSizeCheck::Fits);
- }
- }
- let cache_allocation = Self::get_current_cache_allocation(app).await?;
- let video_size = self.get_approx_video_size(app, next_video).await?;
-
- if video_size >= max_cache_size {
- error!(
- "The video '{}' ({}) exceeds the maximum cache size ({})! \
- Please set a bigger maximum (`--max-cache-size`) or skip it.",
- next_video.title,
- Bytes::new(video_size),
- Bytes::new(max_cache_size)
- );
-
- return Ok(CacheSizeCheck::ExceedsMaxCacheSize);
- }
-
- if cache_allocation + video_size >= max_cache_size {
- if !self.printed_warning {
- warn!(
- "Can't download video: '{}' ({}) as it's too large for the cache ({} of {} allocated). \
- Waiting for cache size reduction..",
- next_video.title, Bytes::new(video_size), Bytes::new(cache_allocation), Bytes::new(max_cache_size)
- );
- self.printed_warning = true;
- self.cached_cache_allocation = Some(cache_allocation);
- }
- if let Some(cca) = self.cached_cache_allocation {
- if cca != cache_allocation {
- warn!(
- "Current cache size has changed, it's now: '{}'",
- Bytes::new(cache_allocation)
- );
- self.cached_cache_allocation = Some(cache_allocation);
- }
- } else {
- info!(
- "Current cache size allocation: '{}'",
- Bytes::new(cache_allocation)
- );
- self.cached_cache_allocation = Some(cache_allocation);
- }
-
- // Wait and hope, that a large video is deleted from the cache.
- time::sleep(Duration::from_secs(10)).await;
- Ok(CacheSizeCheck::TooLarge)
- } else {
- self.printed_warning = false;
- Ok(CacheSizeCheck::Fits)
- }
- }
-
- /// The entry point to the Downloader.
- /// This Downloader will periodically check if the database has changed, and then also
- /// change which videos it downloads.
- /// This will run, until the database doesn't contain any watchable videos
- pub async fn consume(&mut self, app: Arc<App>, max_cache_size: u64) -> Result<()> {
- while let Some(next_video) = get_next_uncached_video(&app).await? {
- match self
- .is_enough_cache_available(&app, max_cache_size, &next_video)
- .await?
- {
- CacheSizeCheck::Fits => (),
- CacheSizeCheck::TooLarge => continue,
- CacheSizeCheck::ExceedsMaxCacheSize => bail!("Giving up."),
- };
-
- if self.current_download.is_some() {
- let current_download = self.current_download.take().expect("Is Some");
-
- if current_download.task_handle.is_finished() {
- current_download.task_handle.await??;
- continue;
- }
-
- if next_video.extractor_hash != current_download.extractor_hash {
- info!(
- "Noticed, that the next video is not the video being downloaded, replacing it ('{}' vs. '{}')!",
- next_video.extractor_hash.into_short_hash(&app).await?, current_download.extractor_hash.into_short_hash(&app).await?
- );
-
- // Replace the currently downloading video
- current_download.task_handle.abort();
-
- let new_current_download =
- CurrentDownload::new_from_video(Arc::clone(&app), next_video);
-
- self.current_download = Some(new_current_download);
- } else {
- // Reset the taken value
- self.current_download = Some(current_download);
- }
- } else {
- info!(
- "No video is being downloaded right now, setting it to '{}'",
- next_video.title
- );
- let new_current_download =
- CurrentDownload::new_from_video(Arc::clone(&app), next_video);
- self.current_download = Some(new_current_download);
- }
-
- time::sleep(Duration::new(1, 0)).await;
- }
-
- info!("Finished downloading!");
- Ok(())
- }
-
- pub async fn get_current_cache_allocation(app: &App) -> Result<u64> {
- fn dir_size(mut dir: fs::ReadDir) -> BoxFuture<'static, Result<u64>> {
- async move {
- let mut acc = 0;
- while let Some(entry) = dir.next_entry().await? {
- let size = match entry.metadata().await? {
- data if data.is_dir() => {
- let path = entry.path();
- let read_dir = fs::read_dir(path).await?;
-
- dir_size(read_dir).await?
- }
- data => data.len(),
- };
- acc += size;
- }
- Ok(acc)
- }
- .boxed()
- }
-
- dir_size(fs::read_dir(&app.config.paths.download_dir).await?).await
- }
-
- async fn get_approx_video_size(&mut self, app: &App, video: &Video) -> Result<u64> {
- if let Some(value) = self.video_size_cache.get(&video.extractor_hash) {
- Ok(*value)
- } else {
- // the subtitle file size should be negligible
- let add_opts = YtDlpOptions {
- subtitle_langs: "".to_owned(),
- };
- let opts = &download_opts(app, add_opts);
-
- let result = yt_dlp::extract_info(opts, &video.url, false, true)
- .await
- .with_context(|| {
- format!("Failed to extract video information: '{}'", video.title)
- })?;
-
- let size = if let Some(val) = result.filesize {
- val
- } else if let Some(val) = result.filesize_approx {
- val
- } else if result.duration.is_some() && result.tbr.is_some() {
- let duration = result.duration.expect("Is some").ceil() as u64;
-
- // TODO: yt_dlp gets this from the format
- let tbr = result.tbr.expect("Is Some").ceil() as u64;
-
- duration * tbr * (1000 / 8)
- } else {
- let hardcoded_default = Bytes::from_str("250 MiB").expect("This is hardcoded");
- error!(
- "Failed to find a filesize for video: '{}' (Using hardcoded value of {})",
- video.title, hardcoded_default
- );
- hardcoded_default.as_u64()
- };
-
- assert_eq!(
- self.video_size_cache
- .insert(video.extractor_hash.clone(), size),
- None
- );
-
- Ok(size)
- }
- }
-
- async fn actually_cache_video(app: &App, video: &Video) -> Result<()> {
- debug!("Download started: {}", &video.title);
-
- let addional_opts = get_video_yt_dlp_opts(app, &video.extractor_hash).await?;
-
- let result = yt_dlp::download(&[video.url.clone()], &download_opts(app, addional_opts))
- .await
- .with_context(|| format!("Failed to download video: '{}'", video.title))?;
-
- assert_eq!(result.len(), 1);
- let result = &result[0];
-
- set_video_cache_path(app, &video.extractor_hash, Some(result)).await?;
-
- info!(
- "Video '{}' was downlaoded to path: {}",
- video.title,
- result.display()
- );
-
- Ok(())
- }
-}
diff --git a/src/main.rs b/src/main.rs
deleted file mode 100644
index 37283a1..0000000
--- a/src/main.rs
+++ /dev/null
@@ -1,252 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::{collections::HashMap, fs, sync::Arc};
-
-use anyhow::{bail, Context, Result};
-use app::App;
-use bytes::Bytes;
-use cache::invalidate;
-use clap::Parser;
-use cli::{CacheCommand, CheckCommand, SelectCommand, SubscriptionCommand, VideosCommand};
-use config::Config;
-use log::info;
-use select::cmds::handle_select_cmd;
-use storage::video_database::getters::get_video_by_hash;
-use tokio::{
- fs::File,
- io::{stdin, BufReader},
- task::JoinHandle,
-};
-use url::Url;
-use videos::display::format_video::FormatVideo;
-use yt_dlp::wrapper::info_json::InfoJson;
-
-use crate::{cli::Command, storage::subscriptions::get_subscriptions};
-
-pub mod app;
-pub mod cli;
-
-pub mod cache;
-pub mod comments;
-pub mod config;
-pub mod constants;
-pub mod download;
-pub mod select;
-pub mod status;
-pub mod storage;
-pub mod subscribe;
-pub mod update;
-pub mod videos;
-pub mod watch;
-
-#[tokio::main]
-async fn main() -> Result<()> {
- let args = cli::CliArgs::parse();
-
- // The default verbosity is 1 (Warn)
- let verbosity: u8 = args.verbosity + 1;
-
- stderrlog::new()
- .module(module_path!())
- .modules(&["yt_dlp".to_owned(), "libmpv2".to_owned()])
- .quiet(args.quiet)
- .show_module_names(false)
- .color(stderrlog::ColorChoice::Auto)
- .verbosity(verbosity as usize)
- .timestamp(stderrlog::Timestamp::Off)
- .init()
- .expect("Let's just hope that this does not panic");
-
- info!("Using verbosity level: '{} ({})'", verbosity, {
- match verbosity {
- 0 => "Error",
- 1 => "Warn",
- 2 => "Info",
- 3 => "Debug",
- 4.. => "Trace",
- }
- });
-
- let app = {
- let config = Config::from_config_file(args.db_path, args.config_path)?;
- App::new(config).await?
- };
-
- match args.command.unwrap_or(Command::default()) {
- Command::Download {
- force,
- max_cache_size,
- } => {
- let max_cache_size =
- max_cache_size.unwrap_or(app.config.download.max_cache_size.as_u64());
- info!("Max cache size: '{}'", Bytes::new(max_cache_size));
-
- if force {
- invalidate(&app, true).await?;
- }
-
- download::Downloader::new()
- .consume(Arc::new(app), max_cache_size)
- .await?;
- }
- Command::Select { cmd } => {
- let cmd = cmd.unwrap_or(SelectCommand::default());
-
- match cmd {
- SelectCommand::File {
- done,
- use_last_selection,
- } => select::select(&app, done, use_last_selection).await?,
- _ => handle_select_cmd(&app, cmd, None).await?,
- }
- }
- Command::Sedowa {} => {
- select::select(&app, false, false).await?;
-
- let arc_app = Arc::new(app);
- dowa(arc_app).await?;
- }
- Command::Dowa {} => {
- let arc_app = Arc::new(app);
- dowa(arc_app).await?;
- }
- Command::Videos { cmd } => match cmd {
- VideosCommand::List {
- search_query,
- limit,
- } => {
- videos::query(&app, limit, search_query)
- .await
- .context("Failed to query videos")?;
- }
- VideosCommand::Info { hash } => {
- let video = get_video_by_hash(&app, &hash.realize(&app).await?).await?;
-
- print!(
- "{}",
- (&video
- .to_formatted_video(&app)
- .await
- .context("Failed to format video")?
- .colorize())
- .to_info_display()
- );
- }
- },
- Command::Update {
- max_backlog,
- subscriptions,
- } => {
- let all_subs = get_subscriptions(&app).await?;
-
- for sub in &subscriptions {
- if !all_subs.0.contains_key(sub) {
- bail!(
- "Your specified subscription to update '{}' is not a subscription!",
- sub
- )
- }
- }
-
- let max_backlog = max_backlog.unwrap_or(app.config.update.max_backlog);
-
- update::update(&app, max_backlog, subscriptions, verbosity).await?;
- }
- Command::Subscriptions { cmd } => match cmd {
- SubscriptionCommand::Add { name, url } => {
- subscribe::subscribe(&app, name, url)
- .await
- .context("Failed to add a subscription")?;
- }
- SubscriptionCommand::Remove { name } => {
- subscribe::unsubscribe(&app, name)
- .await
- .context("Failed to remove a subscription")?;
- }
- SubscriptionCommand::List {} => {
- let all_subs = get_subscriptions(&app).await?;
-
- for (key, val) in all_subs.0 {
- println!("{}: '{}'", key, val.url);
- }
- }
- SubscriptionCommand::Export {} => {
- let all_subs = get_subscriptions(&app).await?;
- for val in all_subs.0.values() {
- println!("{}", val.url);
- }
- }
- SubscriptionCommand::Import { file, force } => {
- if let Some(file) = file {
- let f = File::open(file).await?;
-
- subscribe::import(&app, BufReader::new(f), force).await?
- } else {
- subscribe::import(&app, BufReader::new(stdin()), force).await?
- };
- }
- },
-
- Command::Watch {} => watch::watch(&app).await?,
-
- Command::Status {} => status::show(&app).await?,
- Command::Config {} => status::config(&app)?,
-
- Command::Database { command } => match command {
- CacheCommand::Invalidate { hard } => cache::invalidate(&app, hard).await?,
- CacheCommand::Maintain { all } => cache::maintain(&app, all).await?,
- },
-
- Command::Check { command } => match command {
- CheckCommand::InfoJson { path } => {
- let string = fs::read_to_string(&path)
- .with_context(|| format!("Failed to read '{}' to string!", path.display()))?;
-
- let _: InfoJson =
- serde_json::from_str(&string).context("Failed to deserialize value")?;
- }
- CheckCommand::UpdateInfoJson { path } => {
- let string = fs::read_to_string(&path)
- .with_context(|| format!("Failed to read '{}' to string!", path.display()))?;
-
- let _: HashMap<Url, InfoJson> =
- serde_json::from_str(&string).context("Failed to deserialize value")?;
- }
- },
- Command::Comments {} => {
- comments::comments(&app).await?;
- }
- Command::Description {} => {
- todo!()
- // description::description(&app).await?;
- }
- }
-
- Ok(())
-}
-
-async fn dowa(arc_app: Arc<App>) -> Result<()> {
- let max_cache_size = arc_app.config.download.max_cache_size;
- info!("Max cache size: '{}'", max_cache_size);
-
- let arc_app_clone = Arc::clone(&arc_app);
- let download: JoinHandle<Result<()>> = tokio::spawn(async move {
- download::Downloader::new()
- .consume(arc_app_clone, max_cache_size.as_u64())
- .await?;
-
- Ok(())
- });
-
- watch::watch(&arc_app).await?;
- download.await??;
- Ok(())
-}
diff --git a/src/select/cmds.rs b/src/select/cmds.rs
deleted file mode 100644
index 6e71607..0000000
--- a/src/select/cmds.rs
+++ /dev/null
@@ -1,147 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use crate::{
- app::App,
- cli::{SelectCommand, SharedSelectionCommandArgs},
- download::download_options::download_opts,
- storage::video_database::{
- self,
- getters::get_video_by_hash,
- setters::{add_video, set_video_options, set_video_status},
- VideoOptions, VideoStatus,
- },
- update::video_entry_to_video,
- videos::display::format_video::FormatVideo,
-};
-
-use anyhow::{bail, Context, Result};
-use futures::future::join_all;
-use yt_dlp::wrapper::info_json::InfoType;
-
-pub async fn handle_select_cmd(
- app: &App,
- cmd: SelectCommand,
- line_number: Option<i64>,
-) -> Result<()> {
- match cmd {
- SelectCommand::Pick { shared } => {
- handle_status_change(app, shared, line_number, VideoStatus::Pick).await?;
- }
- SelectCommand::Drop { shared } => {
- handle_status_change(app, shared, line_number, VideoStatus::Drop).await?;
- }
- SelectCommand::Watched { shared } => {
- handle_status_change(app, shared, line_number, VideoStatus::Watched).await?;
- }
- SelectCommand::Add { urls } => {
- for url in urls {
- let opts = download_opts(
- &app,
- video_database::YtDlpOptions {
- subtitle_langs: "".to_owned(),
- },
- );
- let entry = yt_dlp::extract_info(&opts, &url, false, true)
- .await
- .with_context(|| format!("Failed to fetch entry for url: '{}'", url))?;
-
- async fn add_entry(
- app: &App,
- entry: yt_dlp::wrapper::info_json::InfoJson,
- ) -> Result<()> {
- let video = video_entry_to_video(entry, None)?;
- println!(
- "{}",
- (&video.to_formatted_video(app).await?.colorize()).to_line_display()
- );
- add_video(app, video).await?;
-
- Ok(())
- }
-
- match entry._type {
- Some(InfoType::Video) => {
- add_entry(&app, entry).await?;
- }
- Some(InfoType::Playlist) => {
- if let Some(mut entries) = entry.entries {
- if !entries.is_empty() {
- // Pre-warm the cache
- add_entry(app, entries.remove(0)).await?;
-
- let futures: Vec<_> = entries
- .into_iter()
- .map(|entry| add_entry(&app, entry))
- .collect();
-
- join_all(futures).await.into_iter().collect::<Result<_>>()?;
- }
- } else {
- bail!("Your playlist does not seem to have any entries!")
- }
- }
- other => bail!(
- "Your URL should point to a video or a playlist, but points to a '{:#?}'",
- other
- ),
- }
- }
- }
- SelectCommand::Watch { shared } => {
- let hash = shared.hash.clone().realize(app).await?;
-
- let video = get_video_by_hash(app, &hash).await?;
- if video.cache_path.is_some() {
- handle_status_change(app, shared, line_number, VideoStatus::Cached).await?;
- } else {
- handle_status_change(app, shared, line_number, VideoStatus::Watch).await?;
- }
- }
-
- SelectCommand::Url { shared } => {
- let mut firefox = std::process::Command::new("firefox");
- firefox.args(["-P", "timesinks.youtube"]);
- firefox.arg(shared.url.as_str());
- let _handle = firefox.spawn().context("Failed to run firefox")?;
- }
- SelectCommand::File { .. } => unreachable!("This should have been filtered out"),
- }
- Ok(())
-}
-
-async fn handle_status_change(
- app: &App,
- shared: SharedSelectionCommandArgs,
- line_number: Option<i64>,
- new_status: VideoStatus,
-) -> Result<()> {
- let hash = shared.hash.realize(app).await?;
- let video_options = VideoOptions::new(
- shared
- .subtitle_langs
- .unwrap_or(app.config.select.subtitle_langs.clone()),
- shared.speed.unwrap_or(app.config.select.playback_speed),
- );
- let priority = compute_priority(line_number, shared.priority);
-
- set_video_status(app, &hash, new_status, priority).await?;
- set_video_options(app, &hash, &video_options).await?;
-
- Ok(())
-}
-
-fn compute_priority(line_number: Option<i64>, priority: Option<i64>) -> Option<i64> {
- if let Some(pri) = priority {
- Some(pri)
- } else {
- line_number
- }
-}
diff --git a/src/select/mod.rs b/src/select/mod.rs
deleted file mode 100644
index ca7a203..0000000
--- a/src/select/mod.rs
+++ /dev/null
@@ -1,173 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::{
- env::{self},
- fs,
- io::{BufRead, Write},
- io::{BufReader, BufWriter},
-};
-
-use crate::{
- app::App,
- cli::CliArgs,
- constants::HELP_STR,
- storage::video_database::{getters::get_videos, VideoStatus},
- videos::display::format_video::FormatVideo,
-};
-
-use anyhow::{bail, Context, Result};
-use clap::Parser;
-use cmds::handle_select_cmd;
-use futures::future::join_all;
-use selection_file::process_line;
-use tempfile::Builder;
-use tokio::process::Command;
-
-pub mod cmds;
-pub mod selection_file;
-
-pub async fn select(app: &App, done: bool, use_last_selection: bool) -> Result<()> {
- let temp_file = Builder::new()
- .prefix("yt_video_select-")
- .suffix(".yts")
- .rand_bytes(6)
- .tempfile()
- .context("Failed to get tempfile")?;
-
- if use_last_selection {
- fs::copy(&app.config.paths.last_selection_path, &temp_file)?;
- } else {
- let matching_videos = if done {
- get_videos(app, VideoStatus::ALL, None).await?
- } else {
- get_videos(
- app,
- &[
- VideoStatus::Pick,
- //
- VideoStatus::Watch,
- VideoStatus::Cached,
- ],
- None,
- )
- .await?
- };
-
- // Warmup the cache for the display rendering of the videos.
- // Otherwise the futures would all try to warm it up at the same time.
- if let Some(vid) = matching_videos.first() {
- let _ = vid.to_formatted_video(app).await?;
- }
-
- let mut edit_file = BufWriter::new(&temp_file);
-
- join_all(
- matching_videos
- .into_iter()
- .map(|vid| async { vid.to_formatted_video_owned(app).await })
- .collect::<Vec<_>>(),
- )
- .await
- .into_iter()
- .try_for_each(|line| -> Result<()> {
- let formatted_line = (&line?).to_select_file_display();
-
- edit_file
- .write_all(formatted_line.as_bytes())
- .expect("This write should not fail");
-
- Ok(())
- })?;
-
- edit_file.write_all(HELP_STR.as_bytes())?;
- edit_file.flush().context("Failed to flush edit file")?;
- };
-
- {
- let editor = env::var("EDITOR").unwrap_or("nvim".to_owned());
-
- let mut nvim = Command::new(editor);
- nvim.arg(temp_file.path());
- let status = nvim.status().await.context("Falied to run nvim")?;
- if !status.success() {
- bail!("nvim exited with error status: {}", status)
- }
- }
-
- let read_file = temp_file.reopen()?;
- fs::copy(temp_file.path(), &app.config.paths.last_selection_path)
- .context("Failed to persist selection file")?;
-
- let reader = BufReader::new(&read_file);
-
- let mut line_number = 0;
- for line in reader.lines() {
- let line = line.context("Failed to read a line")?;
-
- if let Some(line) = process_line(&line)? {
- line_number -= 1;
-
- // debug!(
- // "Parsed command: `{}`",
- // line.iter()
- // .map(|val| format!("\"{}\"", val))
- // .collect::<Vec<String>>()
- // .join(" ")
- // );
-
- let arg_line = ["yt", "select"]
- .into_iter()
- .chain(line.iter().map(|val| val.as_str()));
-
- let args = CliArgs::parse_from(arg_line);
-
- let cmd = if let crate::cli::Command::Select { cmd } =
- args.command.expect("This will be some")
- {
- cmd
- } else {
- unreachable!("This is checked in the `filter_line` function")
- };
-
- handle_select_cmd(
- app,
- cmd.expect("This value should always be some here"),
- Some(line_number),
- )
- .await?
- }
- }
-
- Ok(())
-}
-
-// // FIXME: There should be no reason why we need to re-run yt, just to get the help string. But I've
-// // yet to find a way to do it with out the extra exec <2024-08-20>
-// async fn get_help() -> Result<String> {
-// let binary_name = current_exe()?;
-// let cmd = Command::new(binary_name)
-// .args(&["select", "--help"])
-// .output()
-// .await?;
-//
-// assert_eq!(cmd.status.code(), Some(0));
-//
-// let output = String::from_utf8(cmd.stdout).expect("Our help output was not utf8?");
-//
-// let out = output
-// .lines()
-// .map(|line| format!("# {}\n", line))
-// .collect::<String>();
-//
-// debug!("Returning help: '{}'", &out);
-//
-// Ok(out)
-// }
diff --git a/src/select/selection_file/duration.rs b/src/select/selection_file/duration.rs
deleted file mode 100644
index a38981c..0000000
--- a/src/select/selection_file/duration.rs
+++ /dev/null
@@ -1,111 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::str::FromStr;
-
-use anyhow::{Context, Result};
-
-#[derive(Copy, Clone, Debug)]
-pub struct Duration {
- time: u32,
-}
-
-impl FromStr for Duration {
- type Err = anyhow::Error;
-
- fn from_str(s: &str) -> Result<Self, Self::Err> {
- fn parse_num(str: &str, suffix: char) -> Result<u32> {
- str.strip_suffix(suffix)
- .with_context(|| {
- format!("Failed to strip suffix '{}' of number: '{}'", suffix, str)
- })?
- .parse::<u32>()
- .with_context(|| format!("Failed to parse '{}'", suffix))
- }
-
- if s == "[No Duration]" {
- return Ok(Self { time: 0 });
- }
-
- let buf: Vec<_> = s.split(' ').collect();
-
- let hours;
- let minutes;
- let seconds;
-
- assert_eq!(buf.len(), 2, "Other lengths should not happen");
-
- if buf[0].ends_with('h') {
- hours = parse_num(buf[0], 'h')?;
- minutes = parse_num(buf[1], 'm')?;
- seconds = 0;
- } else if buf[0].ends_with('m') {
- hours = 0;
- minutes = parse_num(buf[0], 'm')?;
- seconds = parse_num(buf[1], 's')?;
- } else {
- unreachable!(
- "The first part always ends with 'h' or 'm', but was: {:#?}",
- buf
- )
- }
-
- Ok(Self {
- time: (hours * 60 * 60) + (minutes * 60) + seconds,
- })
- }
-}
-
-impl From<Option<f64>> for Duration {
- fn from(value: Option<f64>) -> Self {
- Self {
- time: value.unwrap_or(0.0).ceil() as u32,
- }
- }
-}
-
-impl std::fmt::Display for Duration {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
- const SECOND: u32 = 1;
- const MINUTE: u32 = 60 * SECOND;
- const HOUR: u32 = 60 * MINUTE;
-
- let base_hour = self.time - (self.time % HOUR);
- let base_min = (self.time % HOUR) - ((self.time % HOUR) % MINUTE);
- let base_sec = (self.time % HOUR) % MINUTE;
-
- let h = base_hour / HOUR;
- let m = base_min / MINUTE;
- let s = base_sec / SECOND;
-
- if self.time == 0 {
- write!(f, "[No Duration]")
- } else if h > 0 {
- write!(f, "{h}h {m}m")
- } else {
- write!(f, "{m}m {s}s")
- }
- }
-}
-#[cfg(test)]
-mod test {
- use super::Duration;
-
- #[test]
- fn test_display_duration_1h() {
- let dur = Duration { time: 60 * 60 };
- assert_eq!("1h 0m".to_owned(), dur.to_string());
- }
- #[test]
- fn test_display_duration_30min() {
- let dur = Duration { time: 60 * 30 };
- assert_eq!("30m 0s".to_owned(), dur.to_string());
- }
-}
diff --git a/src/select/selection_file/help.str b/src/select/selection_file/help.str
deleted file mode 100644
index eb76ce5..0000000
--- a/src/select/selection_file/help.str
+++ /dev/null
@@ -1,12 +0,0 @@
-# Commands:
-# w, watch [-p,-s,-l] Mark the video given by the hash to be watched
-# wd, watched [-p,-s,-l] Mark the video given by the hash as already watched
-# d, drop [-p,-s,-l] Mark the video given by the hash to be dropped
-# u, url [-p,-s,-l] Open the video URL in Firefox's `timesinks.youtube` profile
-# p, pick [-p,-s,-l] Reset the videos status to 'Pick'
-# a, add URL Add a video, defined by the URL
-#
-# See `yt select <cmd_name> --help` for more help.
-#
-# These lines can be re-ordered; they are executed from top to bottom.
-# vim: filetype=yts conceallevel=2 concealcursor=nc colorcolumn=
diff --git a/src/select/selection_file/help.str.license b/src/select/selection_file/help.str.license
deleted file mode 100644
index d4d410f..0000000
--- a/src/select/selection_file/help.str.license
+++ /dev/null
@@ -1,9 +0,0 @@
-yt - A fully featured command line YouTube client
-
-Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-SPDX-License-Identifier: GPL-3.0-or-later
-
-This file is part of Yt.
-
-You should have received a copy of the License along with this program.
-If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
diff --git a/src/select/selection_file/mod.rs b/src/select/selection_file/mod.rs
deleted file mode 100644
index 45809fa..0000000
--- a/src/select/selection_file/mod.rs
+++ /dev/null
@@ -1,34 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-//! The data structures needed to express the file, which the user edits
-
-use anyhow::{Context, Result};
-use trinitry::Trinitry;
-
-pub mod duration;
-
-pub fn process_line(line: &str) -> Result<Option<Vec<String>>> {
- // Filter out comments and empty lines
- if line.starts_with('#') || line.trim().is_empty() {
- Ok(None)
- } else {
- // pick 2195db "CouchRecherche? Gunnar und Han von STRG_F sind #mitfunkzuhause" "2020-04-01" "STRG_F - Live" "[1h 5m]" "https://www.youtube.com/watch?v=C8UXOaoMrXY"
-
- let tri =
- Trinitry::new(line).with_context(|| format!("Failed to parse line '{}'", line))?;
-
- let mut vec = Vec::with_capacity(tri.arguments().len() + 1);
- vec.push(tri.command().to_owned());
- vec.extend(tri.arguments().to_vec());
-
- Ok(Some(vec))
- }
-}
diff --git a/src/status/mod.rs b/src/status/mod.rs
deleted file mode 100644
index 7ffe8d7..0000000
--- a/src/status/mod.rs
+++ /dev/null
@@ -1,107 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use anyhow::{Context, Result};
-use bytes::Bytes;
-
-use crate::{
- app::App,
- download::Downloader,
- storage::{
- subscriptions::get_subscriptions,
- video_database::{getters::get_videos, VideoStatus},
- },
-};
-
-macro_rules! get {
- ($videos:expr, $status:ident) => {
- $videos
- .iter()
- .filter(|vid| vid.status == VideoStatus::$status)
- .count()
- };
- (@changing $videos:expr, $status:ident) => {
- $videos
- .iter()
- .filter(|vid| vid.status == VideoStatus::$status && vid.status_change)
- .count()
- };
-}
-
-pub async fn show(app: &App) -> Result<()> {
- let all_videos = get_videos(
- app,
- &[
- VideoStatus::Pick,
- //
- VideoStatus::Watch,
- VideoStatus::Cached,
- VideoStatus::Watched,
- //
- VideoStatus::Drop,
- VideoStatus::Dropped,
- ],
- None,
- )
- .await?;
-
- // lengths
- let picked_videos_len = get!(all_videos, Pick);
-
- let watch_videos_len = get!(all_videos, Watch);
- let cached_videos_len = get!(all_videos, Cached);
- let watched_videos_len = get!(all_videos, Watched);
-
- let drop_videos_len = get!(all_videos, Drop);
- let dropped_videos_len = get!(all_videos, Dropped);
-
- // changing
- let picked_videos_changing = get!(@changing all_videos, Pick);
-
- let watch_videos_changing = get!(@changing all_videos, Watch);
- let cached_videos_changing = get!(@changing all_videos, Cached);
- let watched_videos_changing = get!(@changing all_videos, Watched);
-
- let drop_videos_changing = get!(@changing all_videos, Drop);
- let dropped_videos_changing = get!(@changing all_videos, Dropped);
-
- let subscriptions = get_subscriptions(app).await?;
- let subscriptions_len = subscriptions.0.len();
-
- let cache_usage_raw = Downloader::get_current_cache_allocation(app)
- .await
- .context("Failed to get current cache allocation")?;
- let cache_usage = Bytes::new(cache_usage_raw);
- println!(
- "\
-Picked Videos: {picked_videos_len} ({picked_videos_changing} changing)
-
-Watch Videos: {watch_videos_len} ({watch_videos_changing} changing)
-Cached Videos: {cached_videos_len} ({cached_videos_changing} changing)
-Watched Videos: {watched_videos_len} ({watched_videos_changing} changing)
-
-Drop Videos: {drop_videos_len} ({drop_videos_changing} changing)
-Dropped Videos: {dropped_videos_len} ({dropped_videos_changing} changing)
-
-
- Subscriptions: {subscriptions_len}
- Cache usage: {cache_usage}"
- );
-
- Ok(())
-}
-
-pub fn config(app: &App) -> Result<()> {
- let config_str = toml::to_string(&app.config)?;
-
- print!("{}", config_str);
-
- Ok(())
-}
diff --git a/src/storage/mod.rs b/src/storage/mod.rs
deleted file mode 100644
index 6a12d8b..0000000
--- a/src/storage/mod.rs
+++ /dev/null
@@ -1,12 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-pub mod subscriptions;
-pub mod video_database;
diff --git a/src/storage/subscriptions.rs b/src/storage/subscriptions.rs
deleted file mode 100644
index 22edd08..0000000
--- a/src/storage/subscriptions.rs
+++ /dev/null
@@ -1,140 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-//! Handle subscriptions
-
-use std::collections::HashMap;
-
-use anyhow::Result;
-use log::debug;
-use serde_json::{json, Value};
-use sqlx::query;
-use url::Url;
-use yt_dlp::wrapper::info_json::InfoType;
-
-use crate::app::App;
-
-#[derive(Clone, Debug)]
-pub struct Subscription {
- /// The human readable name of this subscription
- pub name: String,
-
- /// The URL this subscription subscribes to
- pub url: Url,
-}
-
-impl Subscription {
- pub fn new(name: String, url: Url) -> Self {
- Self { name, url }
- }
-}
-
-/// Check whether an URL could be used as a subscription URL
-pub async fn check_url(url: &Url) -> Result<bool> {
- let yt_opts = match json!( {
- "playliststart": 1,
- "playlistend": 10,
- "noplaylist": false,
- "extract_flat": "in_playlist",
- }) {
- Value::Object(map) => map,
- _ => unreachable!("This is hardcoded"),
- };
-
- let info = yt_dlp::extract_info(&yt_opts, url, false, false).await?;
-
- debug!("{:#?}", info);
-
- Ok(info._type == Some(InfoType::Playlist))
-}
-
-#[derive(Default)]
-pub struct Subscriptions(pub(crate) HashMap<String, Subscription>);
-
-pub async fn remove_all_subscriptions(app: &App) -> Result<()> {
- query!(
- "
- DELETE FROM subscriptions;
- ",
- )
- .execute(&app.database)
- .await?;
-
- Ok(())
-}
-
-/// Get a list of subscriptions
-pub async fn get_subscriptions(app: &App) -> Result<Subscriptions> {
- let raw_subs = query!(
- "
- SELECT *
- FROM subscriptions;
- "
- )
- .fetch_all(&app.database)
- .await?;
-
- let subscriptions: HashMap<String, Subscription> = raw_subs
- .into_iter()
- .map(|sub| {
- (
- sub.name.clone(),
- Subscription::new(
- sub.name,
- Url::parse(&sub.url).expect("This should be valid"),
- ),
- )
- })
- .collect();
-
- Ok(Subscriptions(subscriptions))
-}
-
-pub async fn add_subscription(app: &App, sub: &Subscription) -> Result<()> {
- let url = sub.url.to_string();
-
- query!(
- "
- INSERT INTO subscriptions (
- name,
- url
- ) VALUES (?, ?);
- ",
- sub.name,
- url
- )
- .execute(&app.database)
- .await?;
-
- println!("Subscribed to '{}' at '{}'", sub.name, sub.url);
- Ok(())
-}
-
-pub async fn remove_subscription(app: &App, sub: &Subscription) -> Result<()> {
- let output = query!(
- "
- DELETE FROM subscriptions
- WHERE name = ?
- ",
- sub.name,
- )
- .execute(&app.database)
- .await?;
-
- assert_eq!(
- output.rows_affected(),
- 1,
- "The remove subscriptino query did effect more (or less) than one row. This is a bug."
- );
-
- println!("Unsubscribed from '{}' at '{}'", sub.name, sub.url);
-
- Ok(())
-}
diff --git a/src/storage/video_database/downloader.rs b/src/storage/video_database/downloader.rs
deleted file mode 100644
index ccd4ca9..0000000
--- a/src/storage/video_database/downloader.rs
+++ /dev/null
@@ -1,153 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::path::{Path, PathBuf};
-
-use anyhow::Result;
-use log::debug;
-use sqlx::query;
-use url::Url;
-
-use crate::{app::App, storage::video_database::VideoStatus};
-
-use super::{ExtractorHash, Video};
-
-/// Returns to next video which should be downloaded. This respects the priority assigned by select.
-/// It does not return videos, which are already cached.
-pub async fn get_next_uncached_video(app: &App) -> Result<Option<Video>> {
- let status = VideoStatus::Watch.as_db_integer();
-
- // NOTE: The ORDER BY statement should be the same as the one in [`getters::get_videos`].<2024-08-22>
- let result = query!(
- r#"
- SELECT *
- FROM videos
- WHERE status = ? AND cache_path IS NULL
- ORDER BY priority DESC, publish_date DESC
- LIMIT 1;
- "#,
- status
- )
- .fetch_one(&app.database)
- .await;
-
- if let Err(sqlx::Error::RowNotFound) = result {
- Ok(None)
- } else {
- let base = result?;
-
- let thumbnail_url = base
- .thumbnail_url
- .as_ref()
- .map(|url| Url::parse(url).expect("Parsing this as url should always work"));
-
- let status_change = if base.status_change == 1 {
- true
- } else {
- assert_eq!(base.status_change, 0, "Can only be 1 or 0");
- false
- };
-
- let video = Video {
- cache_path: base.cache_path.as_ref().map(PathBuf::from),
- description: base.description.clone(),
- duration: base.duration,
- extractor_hash: ExtractorHash::from_hash(
- base.extractor_hash
- .parse()
- .expect("The hash in the db should be valid"),
- ),
- last_status_change: base.last_status_change,
- parent_subscription_name: base.parent_subscription_name.clone(),
- priority: base.priority,
- publish_date: base.publish_date,
- status: VideoStatus::from_db_integer(base.status),
- status_change,
- thumbnail_url,
- title: base.title.clone(),
- url: Url::parse(&base.url).expect("Parsing this as url should always work"),
- };
-
- Ok(Some(video))
- }
-}
-
-/// Update the cached path of a video. Will be set to NULL if the path is None
-/// This will also set the status to `Cached` when path is Some, otherwise it set's the status to
-/// `Watch`.
-pub async fn set_video_cache_path(
- app: &App,
- video: &ExtractorHash,
- path: Option<&Path>,
-) -> Result<()> {
- if let Some(path) = path {
- debug!(
- "Setting cache path from '{}' to '{}'",
- video.into_short_hash(app).await?,
- path.display()
- );
-
- let path_str = path.display().to_string();
- let extractor_hash = video.hash().to_string();
- let status = VideoStatus::Cached.as_db_integer();
-
- query!(
- r#"
- UPDATE videos
- SET cache_path = ?, status = ?
- WHERE extractor_hash = ?;
- "#,
- path_str,
- status,
- extractor_hash
- )
- .execute(&app.database)
- .await?;
-
- Ok(())
- } else {
- debug!(
- "Setting cache path from '{}' to NULL",
- video.into_short_hash(app).await?,
- );
-
- let extractor_hash = video.hash().to_string();
- let status = VideoStatus::Watch.as_db_integer();
-
- query!(
- r#"
- UPDATE videos
- SET cache_path = NULL, status = ?
- WHERE extractor_hash = ?;
- "#,
- status,
- extractor_hash
- )
- .execute(&app.database)
- .await?;
-
- Ok(())
- }
-}
-
-/// Returns the number of cached videos
-pub async fn get_allocated_cache(app: &App) -> Result<u32> {
- let count = query!(
- r#"
- SELECT COUNT(cache_path) as count
- FROM videos
- WHERE cache_path IS NOT NULL;
-"#,
- )
- .fetch_one(&app.database)
- .await?;
-
- Ok(count.count as u32)
-}
diff --git a/src/storage/video_database/extractor_hash.rs b/src/storage/video_database/extractor_hash.rs
deleted file mode 100644
index c956919..0000000
--- a/src/storage/video_database/extractor_hash.rs
+++ /dev/null
@@ -1,159 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::{collections::HashMap, fmt::Display, str::FromStr};
-
-use anyhow::{bail, Context, Result};
-use blake3::Hash;
-use log::debug;
-use tokio::sync::OnceCell;
-
-use crate::{app::App, storage::video_database::getters::get_all_hashes};
-
-static EXTRACTOR_HASH_LENGTH: OnceCell<usize> = OnceCell::const_new();
-
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub struct ExtractorHash {
- hash: Hash,
-}
-
-impl Display for ExtractorHash {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- self.hash.fmt(f)
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct ShortHash(String);
-
-impl Display for ShortHash {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- self.0.fmt(f)
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct LazyExtractorHash {
- value: ShortHash,
-}
-
-impl FromStr for LazyExtractorHash {
- type Err = anyhow::Error;
-
- fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
- // perform some cheap validation
- if s.len() > 64 {
- bail!("A hash can only contain 64 bytes!");
- }
-
- Ok(Self {
- value: ShortHash(s.to_owned()),
- })
- }
-}
-
-impl LazyExtractorHash {
- /// Turn the [`LazyExtractorHash`] into the [`ExtractorHash`]
- pub async fn realize(self, app: &App) -> Result<ExtractorHash> {
- ExtractorHash::from_short_hash(app, &self.value).await
- }
-}
-
-impl ExtractorHash {
- pub fn from_hash(hash: Hash) -> Self {
- Self { hash }
- }
- pub async fn from_short_hash(app: &App, s: &ShortHash) -> Result<Self> {
- Ok(Self {
- hash: Self::short_hash_to_full_hash(app, s).await?,
- })
- }
-
- pub fn hash(&self) -> &Hash {
- &self.hash
- }
-
- pub async fn into_short_hash(&self, app: &App) -> Result<ShortHash> {
- let needed_chars = if let Some(needed_chars) = EXTRACTOR_HASH_LENGTH.get() {
- *needed_chars
- } else {
- let needed_chars = self
- .get_needed_char_len(app)
- .await
- .context("Failed to calculate needed char length")?;
- EXTRACTOR_HASH_LENGTH
- .set(needed_chars)
- .expect("This should work at this stage");
-
- needed_chars
- };
-
- Ok(ShortHash(
- self.hash()
- .to_hex()
- .chars()
- .take(needed_chars)
- .collect::<String>(),
- ))
- }
-
- async fn short_hash_to_full_hash(app: &App, s: &ShortHash) -> Result<Hash> {
- let all_hashes = get_all_hashes(app)
- .await
- .context("Failed to fetch all extractor -hashesh from database")?;
-
- let needed_chars = s.0.len();
-
- for hash in all_hashes {
- if hash.to_hex()[..needed_chars] == s.0 {
- return Ok(hash);
- }
- }
-
- bail!("Your shortend hash, does not match a real hash (this is probably a bug)!");
- }
-
- async fn get_needed_char_len(&self, app: &App) -> Result<usize> {
- debug!("Calculating the needed hash char length");
- let all_hashes = get_all_hashes(app)
- .await
- .context("Failed to fetch all extractor -hashesh from database")?;
-
- let all_char_vec_hashes = all_hashes
- .into_iter()
- .map(|hash| hash.to_hex().chars().collect::<Vec<char>>())
- .collect::<Vec<Vec<_>>>();
-
- // This value should be updated later, if not rust will panic in the assertion.
- let mut needed_chars: usize = 1000;
- 'outer: for i in 1..64 {
- let i_chars: Vec<String> = all_char_vec_hashes
- .iter()
- .map(|vec| vec.iter().take(i).collect::<String>())
- .collect();
-
- let mut uniqnes_hashmap: HashMap<String, ()> = HashMap::new();
- for ch in i_chars {
- if let Some(()) = uniqnes_hashmap.insert(ch, ()) {
- // The key was already in the hash map, thus we have a duplicated char and need
- // at least one char more
- continue 'outer;
- }
- }
-
- needed_chars = i;
- break 'outer;
- }
-
- assert!(needed_chars <= 64, "Hashes are only 64 bytes long");
-
- Ok(needed_chars)
- }
-}
diff --git a/src/storage/video_database/getters.rs b/src/storage/video_database/getters.rs
deleted file mode 100644
index 29dd014..0000000
--- a/src/storage/video_database/getters.rs
+++ /dev/null
@@ -1,345 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-//! These functions interact with the storage db in a read-only way. They are added on-demaned (as
-//! you could theoretically just could do everything with the `get_videos` function), as
-//! performance or convince requires.
-use std::{fs::File, path::PathBuf};
-
-use anyhow::{bail, Context, Result};
-use blake3::Hash;
-use log::debug;
-use sqlx::{query, QueryBuilder, Row, Sqlite};
-use url::Url;
-use yt_dlp::wrapper::info_json::InfoJson;
-
-use crate::{
- app::App,
- storage::{
- subscriptions::Subscription,
- video_database::{extractor_hash::ExtractorHash, Video},
- },
-};
-
-use super::{MpvOptions, VideoOptions, VideoStatus, YtDlpOptions};
-
-macro_rules! video_from_record {
- ($record:expr) => {
- let thumbnail_url = if let Some(url) = &$record.thumbnail_url {
- Some(Url::parse(&url).expect("Parsing this as url should always work"))
- } else {
- None
- };
-
- Ok(Video {
- cache_path: $record.cache_path.as_ref().map(|val| PathBuf::from(val)),
- description: $record.description.clone(),
- duration: $record.duration,
- extractor_hash: ExtractorHash::from_hash(
- $record
- .extractor_hash
- .parse()
- .expect("The db hash should be a valid blake3 hash"),
- ),
- last_status_change: $record.last_status_change,
- parent_subscription_name: $record.parent_subscription_name.clone(),
- publish_date: $record.publish_date,
- status: VideoStatus::from_db_integer($record.status),
- thumbnail_url,
- title: $record.title.clone(),
- url: Url::parse(&$record.url).expect("Parsing this as url should always work"),
- priority: $record.priority,
- status_change: if $record.status_change == 1 {
- true
- } else {
- assert_eq!($record.status_change, 0);
- false
- },
- })
- };
-}
-
-/// Get the lines to display at the selection file
-/// [`changing` = true]: Means that we include *only* videos, that have the `status_changing` flag set
-/// [`changing` = None]: Means that we include *both* videos, that have the `status_changing` flag set and not set
-pub async fn get_videos(
- app: &App,
- allowed_states: &[VideoStatus],
- changing: Option<bool>,
-) -> Result<Vec<Video>> {
- let mut qb: QueryBuilder<Sqlite> = QueryBuilder::new(
- "\
- SELECT *
- FROM videos
- WHERE status IN ",
- );
-
- qb.push("(");
- allowed_states
- .iter()
- .enumerate()
- .for_each(|(index, state)| {
- qb.push("'");
- qb.push(state.as_db_integer());
- qb.push("'");
-
- if index != allowed_states.len() - 1 {
- qb.push(",");
- }
- });
- qb.push(")");
-
- if let Some(val) = changing {
- if val {
- qb.push(" AND status_change = 1");
- } else {
- qb.push(" AND status_change = 0");
- }
- }
-
- qb.push("\n ORDER BY priority DESC, publish_date DESC;");
-
- debug!("Will run: \"{}\"", qb.sql());
-
- let videos = qb.build().fetch_all(&app.database).await.with_context(|| {
- format!(
- "Failed to query videos with states: '{}'",
- allowed_states.iter().fold(String::new(), |mut acc, state| {
- acc.push(' ');
- acc.push_str(state.as_str());
- acc
- }),
- )
- })?;
-
- let real_videos: Vec<Video> = videos
- .iter()
- .map(|base| -> Result<Video> {
- Ok(Video {
- cache_path: base
- .get::<Option<String>, &str>("cache_path")
- .as_ref()
- .map(PathBuf::from),
- description: base.get::<Option<String>, &str>("description").clone(),
- duration: base.get("duration"),
- extractor_hash: ExtractorHash::from_hash(
- base.get::<String, &str>("extractor_hash")
- .parse()
- .expect("The db hash should be a valid blake3 hash"),
- ),
- last_status_change: base.get("last_status_change"),
- parent_subscription_name: base
- .get::<Option<String>, &str>("parent_subscription_name")
- .clone(),
- publish_date: base.get("publish_date"),
- status: VideoStatus::from_db_integer(base.get("status")),
- thumbnail_url: base
- .get::<Option<String>, &str>("thumbnail_url")
- .as_ref()
- .map(|url| Url::parse(url).expect("Parsing this as url should always work")),
- title: base.get::<String, &str>("title").to_owned(),
- url: Url::parse(base.get("url")).expect("Parsing this as url should always work"),
- priority: base.get("priority"),
- status_change: {
- let val = base.get::<i64, &str>("status_change");
- if val == 1 {
- true
- } else {
- assert_eq!(val, 0, "Can only be 1 or 0");
- false
- }
- },
- })
- })
- .collect::<Result<Vec<Video>>>()?;
-
- Ok(real_videos)
-}
-
-pub async fn get_video_info_json(video: &Video) -> Result<Option<InfoJson>> {
- if let Some(mut path) = video.cache_path.clone() {
- if !path.set_extension("info.json") {
- bail!(
- "Failed to change path extension to 'info.json': {}",
- path.display()
- );
- }
- let info_json_string = File::open(path)?;
- let info_json: InfoJson = serde_json::from_reader(&info_json_string)?;
-
- Ok(Some(info_json))
- } else {
- Ok(None)
- }
-}
-
-pub async fn get_video_by_hash(app: &App, hash: &ExtractorHash) -> Result<Video> {
- let ehash = hash.hash().to_string();
-
- let raw_video = query!(
- "
- SELECT * FROM videos WHERE extractor_hash = ?;
- ",
- ehash
- )
- .fetch_one(&app.database)
- .await?;
-
- video_from_record! {raw_video}
-}
-
-pub async fn get_currently_playing_video(app: &App) -> Result<Option<Video>> {
- let mut videos: Vec<Video> = get_changing_videos(app, VideoStatus::Cached).await?;
-
- if videos.is_empty() {
- Ok(None)
- } else {
- assert_eq!(
- videos.len(),
- 1,
- "Only one video can change from cached to watched at once!"
- );
-
- Ok(Some(videos.remove(0)))
- }
-}
-
-pub async fn get_changing_videos(app: &App, old_state: VideoStatus) -> Result<Vec<Video>> {
- let status = old_state.as_db_integer();
-
- let matching = query!(
- r#"
- SELECT *
- FROM videos
- WHERE status_change = 1 AND status = ?;
- "#,
- status
- )
- .fetch_all(&app.database)
- .await?;
-
- let real_videos: Vec<Video> = matching
- .iter()
- .map(|base| -> Result<Video> {
- video_from_record! {base}
- })
- .collect::<Result<Vec<Video>>>()?;
-
- Ok(real_videos)
-}
-
-pub async fn get_all_hashes(app: &App) -> Result<Vec<Hash>> {
- let hashes_hex = query!(
- r#"
- SELECT extractor_hash
- FROM videos;
- "#
- )
- .fetch_all(&app.database)
- .await?;
-
- Ok(hashes_hex
- .iter()
- .map(|hash| {
- Hash::from_hex(&hash.extractor_hash)
- .expect("These values started as blake3 hashes, they should stay blake3 hashes")
- })
- .collect())
-}
-
-pub async fn get_video_hashes(app: &App, subs: &Subscription) -> Result<Vec<Hash>> {
- let hashes_hex = query!(
- r#"
- SELECT extractor_hash
- FROM videos
- WHERE parent_subscription_name = ?;
- "#,
- subs.name
- )
- .fetch_all(&app.database)
- .await?;
-
- Ok(hashes_hex
- .iter()
- .map(|hash| {
- Hash::from_hex(&hash.extractor_hash)
- .expect("These values started as blake3 hashes, they should stay blake3 hashes")
- })
- .collect())
-}
-
-pub async fn get_video_yt_dlp_opts(app: &App, hash: &ExtractorHash) -> Result<YtDlpOptions> {
- let ehash = hash.hash().to_string();
-
- let yt_dlp_options = query!(
- r#"
- SELECT subtitle_langs
- FROM video_options
- WHERE extractor_hash = ?;
- "#,
- ehash
- )
- .fetch_one(&app.database)
- .await
- .with_context(|| {
- format!(
- "Failed to fetch the `yt_dlp_video_opts` for video: {}",
- hash
- )
- })?;
-
- Ok(YtDlpOptions {
- subtitle_langs: yt_dlp_options.subtitle_langs,
- })
-}
-pub async fn get_video_mpv_opts(app: &App, hash: &ExtractorHash) -> Result<MpvOptions> {
- let ehash = hash.hash().to_string();
-
- let mpv_options = query!(
- r#"
- SELECT playback_speed
- FROM video_options
- WHERE extractor_hash = ?;
- "#,
- ehash
- )
- .fetch_one(&app.database)
- .await
- .with_context(|| format!("Failed to fetch the `mpv_video_opts` for video: {}", hash))?;
-
- Ok(MpvOptions {
- playback_speed: mpv_options.playback_speed,
- })
-}
-
-pub async fn get_video_opts(app: &App, hash: &ExtractorHash) -> Result<VideoOptions> {
- let ehash = hash.hash().to_string();
-
- let opts = query!(
- r#"
- SELECT playback_speed, subtitle_langs
- FROM video_options
- WHERE extractor_hash = ?;
- "#,
- ehash
- )
- .fetch_one(&app.database)
- .await
- .with_context(|| format!("Failed to fetch the `video_opts` for video: {}", hash))?;
-
- let mpv = MpvOptions {
- playback_speed: opts.playback_speed,
- };
- let yt_dlp = YtDlpOptions {
- subtitle_langs: opts.subtitle_langs,
- };
-
- Ok(VideoOptions { mpv, yt_dlp })
-}
diff --git a/src/storage/video_database/mod.rs b/src/storage/video_database/mod.rs
deleted file mode 100644
index 1765f79..0000000
--- a/src/storage/video_database/mod.rs
+++ /dev/null
@@ -1,179 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::{fmt::Write, path::PathBuf};
-
-use url::Url;
-
-use crate::{app::App, storage::video_database::extractor_hash::ExtractorHash};
-
-pub mod downloader;
-pub mod extractor_hash;
-pub mod getters;
-pub mod setters;
-
-#[derive(Debug, Clone)]
-pub struct Video {
- pub cache_path: Option<PathBuf>,
- pub description: Option<String>,
- pub duration: Option<f64>,
- pub extractor_hash: ExtractorHash,
- pub last_status_change: i64,
- /// The associated subscription this video was fetched from (null, when the video was `add`ed)
- pub parent_subscription_name: Option<String>,
- pub priority: i64,
- pub publish_date: Option<i64>,
- pub status: VideoStatus,
- /// The video is currently changing its state (for example from being `SELECT` to being `CACHE`)
- pub status_change: bool,
- pub thumbnail_url: Option<Url>,
- pub title: String,
- pub url: Url,
-}
-
-#[derive(Debug)]
-pub struct VideoOptions {
- pub yt_dlp: YtDlpOptions,
- pub mpv: MpvOptions,
-}
-impl VideoOptions {
- pub(crate) fn new(subtitle_langs: String, playback_speed: f64) -> Self {
- let yt_dlp = YtDlpOptions { subtitle_langs };
- let mpv = MpvOptions { playback_speed };
- Self { yt_dlp, mpv }
- }
-
- /// This will write out the options that are different from the defaults.
- /// Beware, that this does not set the priority.
- pub fn to_cli_flags(self, app: &App) -> String {
- let mut f = String::new();
-
- if self.mpv.playback_speed != app.config.select.playback_speed {
- write!(f, " --speed '{}'", self.mpv.playback_speed).expect("Works");
- }
- if self.yt_dlp.subtitle_langs != app.config.select.subtitle_langs {
- write!(f, " --subtitle-langs '{}'", self.yt_dlp.subtitle_langs).expect("Works");
- }
-
- f.trim().to_owned()
- }
-}
-
-#[derive(Debug)]
-/// Additionally settings passed to mpv on watch
-pub struct MpvOptions {
- /// The playback speed. (1 is 100%, 2.7 is 270%, and so on)
- pub playback_speed: f64,
-}
-
-#[derive(Debug)]
-/// Additionally configuration options, passed to yt-dlp on download
-pub struct YtDlpOptions {
- /// In the form of `lang1,lang2,lang3` (e.g. `en,de,sv`)
- pub subtitle_langs: String,
-}
-
-/// # Video Lifetime (words in <brackets> are commands):
-/// <Pick>
-/// / \
-/// <Watch> <Drop> -> Dropped // yt select
-/// |
-/// Cache // yt cache
-/// |
-/// Watched // yt watch
-#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
-pub enum VideoStatus {
- #[default]
- Pick,
-
- /// The video has been select to be watched
- Watch,
- /// The video has been cached and is ready to be watched
- Cached,
- /// The video has been watched
- Watched,
-
- /// The video has been select to be dropped
- Drop,
- /// The video has been dropped
- Dropped,
-}
-
-impl VideoStatus {
- pub const ALL: &'static [Self; 6] = &[
- Self::Pick,
- //
- VideoStatus::Watch,
- VideoStatus::Cached,
- VideoStatus::Watched,
- //
- VideoStatus::Drop,
- VideoStatus::Dropped,
- ];
-
- pub fn as_command(&self) -> &str {
- // NOTE: Keep the serialize able variants synced with the main `select` function <2024-06-14>
- // Also try to ensure, that the strings have the same length
- match self {
- VideoStatus::Pick => "pick ",
-
- VideoStatus::Watch => "watch ",
- VideoStatus::Cached => "watch ",
- VideoStatus::Watched => "watched",
-
- VideoStatus::Drop => "drop ",
- VideoStatus::Dropped => "drop ",
- }
- }
-
- pub fn as_db_integer(&self) -> i64 {
- // These numbers should not change their mapping!
- // Oh, and keep them in sync with the SQLite check constraint.
- match self {
- VideoStatus::Pick => 0,
-
- VideoStatus::Watch => 1,
- VideoStatus::Cached => 2,
- VideoStatus::Watched => 3,
-
- VideoStatus::Drop => 4,
- VideoStatus::Dropped => 5,
- }
- }
- pub fn from_db_integer(num: i64) -> Self {
- match num {
- 0 => Self::Pick,
-
- 1 => Self::Watch,
- 2 => Self::Cached,
- 3 => Self::Watched,
-
- 4 => Self::Drop,
- 5 => Self::Dropped,
- other => unreachable!(
- "The database returned a enum discriminator, unknown to us: '{}'",
- other
- ),
- }
- }
-
- pub fn as_str(&self) -> &'static str {
- match self {
- VideoStatus::Pick => "Pick",
-
- VideoStatus::Watch => "Watch",
- VideoStatus::Cached => "Cache",
- VideoStatus::Watched => "Watched",
-
- VideoStatus::Drop => "Drop",
- VideoStatus::Dropped => "Dropped",
- }
- }
-}
diff --git a/src/storage/video_database/schema.sql b/src/storage/video_database/schema.sql
deleted file mode 100644
index 3afd091..0000000
--- a/src/storage/video_database/schema.sql
+++ /dev/null
@@ -1,57 +0,0 @@
--- yt - A fully featured command line YouTube client
---
--- Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
--- SPDX-License-Identifier: GPL-3.0-or-later
---
--- This file is part of Yt.
---
--- You should have received a copy of the License along with this program.
--- If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
--- All tables should be declared STRICT, as I actually like to have types checking (and a
--- db that doesn't lie to me).
-
--- Keep this table in sync with the `Video` structure
-CREATE TABLE IF NOT EXISTS videos (
- cache_path TEXT UNIQUE CHECK (CASE WHEN cache_path IS NOT NULL THEN
- status == 2
- ELSE
- 1
- END),
- description TEXT,
- duration REAL,
- extractor_hash TEXT UNIQUE NOT NULL PRIMARY KEY,
- last_status_change INTEGER NOT NULL,
- parent_subscription_name TEXT,
- priority INTEGER NOT NULL DEFAULT 0,
- publish_date INTEGER,
- status INTEGER NOT NULL DEFAULT 0 CHECK (status IN (0, 1, 2, 3, 4, 5) AND
- CASE WHEN status == 2 THEN
- cache_path IS NOT NULL
- ELSE
- 1
- END AND
- CASE WHEN status != 2 THEN
- cache_path IS NULL
- ELSE
- 1
- END),
- status_change INTEGER NOT NULL DEFAULT 0 CHECK (status_change IN (0, 1)),
- thumbnail_url TEXT,
- title TEXT NOT NULL,
- url TEXT UNIQUE NOT NULL
-) STRICT;
-
--- Store additional metadata for the videos marked to be watched
-CREATE TABLE IF NOT EXISTS video_options (
- extractor_hash TEXT UNIQUE NOT NULL PRIMARY KEY,
- subtitle_langs TEXT NOT NULL,
- playback_speed REAL NOT NULL,
- FOREIGN KEY(extractor_hash) REFERENCES videos (extractor_hash)
-) STRICT;
-
--- Store subscriptions
-CREATE TABLE IF NOT EXISTS subscriptions (
- name TEXT UNIQUE NOT NULL PRIMARY KEY,
- url TEXT NOT NULL
-) STRICT;
diff --git a/src/storage/video_database/setters.rs b/src/storage/video_database/setters.rs
deleted file mode 100644
index c160138..0000000
--- a/src/storage/video_database/setters.rs
+++ /dev/null
@@ -1,270 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-//! These functions change the database. They are added on a demand basis.
-
-use anyhow::Result;
-use chrono::Utc;
-use log::{debug, info};
-use sqlx::query;
-use tokio::fs;
-
-use crate::{app::App, storage::video_database::extractor_hash::ExtractorHash};
-
-use super::{Video, VideoOptions, VideoStatus};
-
-/// Set a new status for a video.
-/// This will only update the status time stamp/priority when the status or the priority has changed .
-pub async fn set_video_status(
- app: &App,
- video_hash: &ExtractorHash,
- new_status: VideoStatus,
- new_priority: Option<i64>,
-) -> Result<()> {
- let video_hash = video_hash.hash().to_string();
-
- let old = query!(
- r#"
- SELECT status, priority, cache_path
- FROM videos
- WHERE extractor_hash = ?
- "#,
- video_hash
- )
- .fetch_one(&app.database)
- .await?;
-
- let cache_path = if (VideoStatus::from_db_integer(old.status) == VideoStatus::Cached)
- && (new_status != VideoStatus::Cached)
- {
- None
- } else {
- old.cache_path.as_deref()
- };
-
- let new_status = new_status.as_db_integer();
-
- if let Some(new_priority) = new_priority {
- if old.status == new_status && old.priority == new_priority {
- return Ok(());
- }
-
- let now = Utc::now().timestamp();
-
- debug!(
- "Running status change: {:#?} -> {:#?}...",
- VideoStatus::from_db_integer(old.status),
- VideoStatus::from_db_integer(new_status),
- );
-
- query!(
- r#"
- UPDATE videos
- SET status = ?, last_status_change = ?, priority = ?, cache_path = ?
- WHERE extractor_hash = ?;
- "#,
- new_status,
- now,
- new_priority,
- cache_path,
- video_hash
- )
- .execute(&app.database)
- .await?;
- } else {
- if old.status == new_status {
- return Ok(());
- }
-
- let now = Utc::now().timestamp();
-
- debug!(
- "Running status change: {:#?} -> {:#?}...",
- VideoStatus::from_db_integer(old.status),
- VideoStatus::from_db_integer(new_status),
- );
-
- query!(
- r#"
- UPDATE videos
- SET status = ?, last_status_change = ?, cache_path = ?
- WHERE extractor_hash = ?;
- "#,
- new_status,
- now,
- cache_path,
- video_hash
- )
- .execute(&app.database)
- .await?;
- }
-
- debug!("Finished status change.");
- Ok(())
-}
-
-/// Mark a video as watched.
-/// This will both set the status to `Watched` and the cache_path to Null.
-pub async fn set_video_watched(app: &App, video: &Video) -> Result<()> {
- let video_hash = video.extractor_hash.hash().to_string();
- let new_status = VideoStatus::Watched.as_db_integer();
-
- info!("Will set video watched: '{}'", video.title);
-
- let old = query!(
- r#"
- SELECT status, priority
- FROM videos
- WHERE extractor_hash = ?
- "#,
- video_hash
- )
- .fetch_one(&app.database)
- .await?;
-
- assert_ne!(
- old.status, new_status,
- "The video should not be marked as watched already."
- );
- assert_eq!(
- old.status,
- VideoStatus::Cached.as_db_integer(),
- "The video should have been marked cached"
- );
-
- let now = Utc::now().timestamp();
-
- if let Some(path) = &video.cache_path {
- if let Ok(true) = path.try_exists() {
- fs::remove_file(path).await?
- }
- }
-
- query!(
- r#"
- UPDATE videos
- SET status = ?, last_status_change = ?, cache_path = NULL
- WHERE extractor_hash = ?;
- "#,
- new_status,
- now,
- video_hash
- )
- .execute(&app.database)
- .await?;
-
- Ok(())
-}
-
-pub async fn set_state_change(
- app: &App,
- video_extractor_hash: &ExtractorHash,
- changing: bool,
-) -> Result<()> {
- let state_change = if changing { 1 } else { 0 };
- let video_extractor_hash = video_extractor_hash.hash().to_string();
-
- query!(
- r#"
- UPDATE videos
- SET status_change = ?
- WHERE extractor_hash = ?;
- "#,
- state_change,
- video_extractor_hash,
- )
- .execute(&app.database)
- .await?;
-
- Ok(())
-}
-
-pub async fn set_video_options(
- app: &App,
- hash: &ExtractorHash,
- video_options: &VideoOptions,
-) -> Result<()> {
- let video_extractor_hash = hash.hash().to_string();
- let playback_speed = video_options.mpv.playback_speed;
- let subtitle_langs = &video_options.yt_dlp.subtitle_langs;
-
- query!(
- r#"
- UPDATE video_options
- SET playback_speed = ?, subtitle_langs = ?
- WHERE extractor_hash = ?;
- "#,
- playback_speed,
- subtitle_langs,
- video_extractor_hash,
- )
- .execute(&app.database)
- .await?;
-
- Ok(())
-}
-
-pub async fn add_video(app: &App, video: Video) -> Result<()> {
- let parent_subscription_name = video.parent_subscription_name;
-
- let thumbnail_url = video.thumbnail_url.map(|val| val.to_string());
-
- let status = video.status.as_db_integer();
- let status_change = if video.status_change { 1 } else { 0 };
- let url = video.url.to_string();
- let extractor_hash = video.extractor_hash.hash().to_string();
-
- let default_subtitle_langs = &app.config.select.subtitle_langs;
- let default_mpv_playback_speed = app.config.select.playback_speed;
-
- query!(
- r#"
- BEGIN;
- INSERT INTO videos (
- parent_subscription_name,
- status,
- status_change,
- last_status_change,
- title,
- url,
- description,
- duration,
- publish_date,
- thumbnail_url,
- extractor_hash)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
-
- INSERT INTO video_options (
- extractor_hash,
- subtitle_langs,
- playback_speed)
- VALUES (?, ?, ?);
- COMMIT;
- "#,
- parent_subscription_name,
- status,
- status_change,
- video.last_status_change,
- video.title,
- url,
- video.description,
- video.duration,
- video.publish_date,
- thumbnail_url,
- extractor_hash,
- extractor_hash,
- default_subtitle_langs,
- default_mpv_playback_speed
- )
- .execute(&app.database)
- .await?;
-
- Ok(())
-}
diff --git a/src/subscribe/mod.rs b/src/subscribe/mod.rs
deleted file mode 100644
index 74d88b4..0000000
--- a/src/subscribe/mod.rs
+++ /dev/null
@@ -1,184 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::str::FromStr;
-
-use anyhow::{bail, Context, Result};
-use futures::FutureExt;
-use log::warn;
-use serde_json::{json, Value};
-use tokio::io::{AsyncBufRead, AsyncBufReadExt};
-use url::Url;
-use yt_dlp::wrapper::info_json::InfoType;
-
-use crate::{
- app::App,
- storage::subscriptions::{
- add_subscription, check_url, get_subscriptions, remove_all_subscriptions,
- remove_subscription, Subscription,
- },
-};
-
-pub async fn unsubscribe(app: &App, name: String) -> Result<()> {
- let present_subscriptions = get_subscriptions(app).await?;
-
- if let Some(subscription) = present_subscriptions.0.get(&name) {
- remove_subscription(app, subscription).await?;
- } else {
- bail!("Couldn't find subscription: '{}'", &name);
- }
-
- Ok(())
-}
-
-pub async fn import<W: AsyncBufRead + AsyncBufReadExt + Unpin>(
- app: &App,
- reader: W,
- force: bool,
-) -> Result<()> {
- if force {
- remove_all_subscriptions(app).await?;
- }
-
- let mut lines = reader.lines();
- while let Some(line) = lines.next_line().await? {
- let url =
- Url::from_str(&line).with_context(|| format!("Failed to parse '{}' as url", line))?;
- match subscribe(app, None, url)
- .await
- .with_context(|| format!("Failed to subscribe to: '{}'", line))
- {
- Ok(_) => (),
- Err(err) => eprintln!(
- "Error while subscribing to '{}': '{}'",
- line,
- err.source().expect("Should have a source")
- ),
- }
- }
-
- Ok(())
-}
-
-pub async fn subscribe(app: &App, name: Option<String>, url: Url) -> Result<()> {
- if !(url.as_str().ends_with("videos")
- || url.as_str().ends_with("streams")
- || url.as_str().ends_with("shorts")
- || url.as_str().ends_with("videos/")
- || url.as_str().ends_with("streams/")
- || url.as_str().ends_with("shorts/"))
- && url.as_str().contains("youtube.com")
- {
- warn!("Your youtbe url does not seem like it actually tracks a channels playlist (videos, streams, shorts). Adding subscriptions for each of them...");
-
- let url = Url::parse(&(url.as_str().to_owned() + "/"))
- .expect("This was an url, it should stay one");
-
- if let Some(name) = name {
- let out: Result<()> = async move {
- actual_subscribe(
- app,
- Some(name.clone() + " {Videos}"),
- url.join("videos/").expect("Works"),
- )
- .await
- .with_context(|| {
- format!("Failed to subscribe to '{}'", name.clone() + " {Videos}")
- })?;
-
- actual_subscribe(
- app,
- Some(name.clone() + " {Streams}"),
- url.join("streams/").expect("Works"),
- )
- .await
- .with_context(|| {
- format!("Failed to subscribe to '{}'", name.clone() + " {Streams}")
- })?;
-
- actual_subscribe(
- app,
- Some(name.clone() + " {Shorts}"),
- url.join("shorts/").expect("Works"),
- )
- .await
- .with_context(|| format!("Failed to subscribe to '{}'", name + " {Shorts}"))?;
-
- Ok(())
- }
- .boxed()
- .await;
-
- out?
- } else {
- actual_subscribe(app, None, url.join("videos/").expect("Works"))
- .await
- .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Videos}"))?;
-
- actual_subscribe(app, None, url.join("streams/").expect("Works"))
- .await
- .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Streams}"))?;
-
- actual_subscribe(app, None, url.join("shorts/").expect("Works"))
- .await
- .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Shorts}"))?;
- }
- } else {
- actual_subscribe(app, name, url).await?;
- }
-
- Ok(())
-}
-
-async fn actual_subscribe(app: &App, name: Option<String>, url: Url) -> Result<()> {
- if !check_url(&url).await? {
- bail!("The url ('{}') does not represent a playlist!", &url)
- };
-
- let name = if let Some(name) = name {
- name
- } else {
- let yt_opts = match json!( {
- "playliststart": 1,
- "playlistend": 10,
- "noplaylist": false,
- "extract_flat": "in_playlist",
- }) {
- Value::Object(map) => map,
- _ => unreachable!("This is hardcoded"),
- };
-
- let info = yt_dlp::extract_info(&yt_opts, &url, false, false).await?;
-
- if info._type == Some(InfoType::Playlist) {
- info.title.expect("This should be some for a playlist")
- } else {
- bail!("The url ('{}') does not represent a playlist!", &url)
- }
- };
-
- let present_subscriptions = get_subscriptions(app).await?;
-
- if let Some(subs) = present_subscriptions.0.get(&name) {
- bail!(
- "The subscription '{}' could not be added, \
- as another one with the same name ('{}') already exists. It links to the Url: '{}'",
- name,
- name,
- subs.url
- );
- }
-
- let sub = Subscription { name, url };
-
- add_subscription(app, &sub).await?;
-
- Ok(())
-}
diff --git a/src/update/mod.rs b/src/update/mod.rs
deleted file mode 100644
index 6abb8c4..0000000
--- a/src/update/mod.rs
+++ /dev/null
@@ -1,257 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::{collections::HashMap, process::Stdio, str::FromStr};
-
-use anyhow::{Context, Ok, Result};
-use chrono::{DateTime, Utc};
-use log::{error, info, warn};
-use tokio::{
- io::{AsyncBufReadExt, BufReader},
- process::Command,
-};
-use url::Url;
-use yt_dlp::{unsmuggle_url, wrapper::info_json::InfoJson};
-
-use crate::{
- app::App,
- storage::{
- subscriptions::{get_subscriptions, Subscription},
- video_database::{
- extractor_hash::ExtractorHash, getters::get_all_hashes, setters::add_video, Video,
- VideoStatus,
- },
- },
- videos::display::format_video::FormatVideo,
-};
-
-pub async fn update(
- app: &App,
- max_backlog: u32,
- subs_to_update: Vec<String>,
- verbosity: u8,
-) -> Result<()> {
- let subscriptions = get_subscriptions(app).await?;
- let mut back_subs: HashMap<Url, Subscription> = HashMap::new();
- let logging = verbosity > 0;
- let log_level = match verbosity {
- // 0 => 50, // logging.CRITICAL
- 0 => 40, // logging.ERROR
- 1 => 30, // logging.WARNING
- 2 => 20, // logging.INFO
- 3.. => 10, // logging.DEBUG
- };
- info!("Passing log_level {} to the update script", log_level);
-
- let mut urls: Vec<String> = vec![];
- for (name, sub) in subscriptions.0 {
- if subs_to_update.contains(&name) || subs_to_update.is_empty() {
- urls.push(sub.url.to_string());
- back_subs.insert(sub.url.clone(), sub);
- } else {
- info!(
- "Not updating subscription '{}' as it was not specified",
- name
- );
- }
- }
-
- // We can get away with not having to re-fetch the hashes every time, as the returned video
- // should not contain duplicates.
- let hashes = get_all_hashes(app).await?;
-
- let mut child = Command::new("raw_update.py")
- .arg(max_backlog.to_string())
- .arg(urls.len().to_string())
- .arg(log_level.to_string())
- .args(&urls)
- .args(hashes.iter().map(|haz| haz.to_string()).collect::<Vec<_>>())
- .stdout(Stdio::piped())
- .stderr(if logging {
- Stdio::inherit()
- } else {
- Stdio::null()
- })
- .stdin(Stdio::null())
- .spawn()
- .context("Failed to call python3 update_raw")?;
-
- let mut out = BufReader::new(
- child
- .stdout
- .take()
- .expect("Should be able to take child stdout"),
- )
- .lines();
-
- while let Some(line) = out.next_line().await? {
- // use tokio::{fs::File, io::AsyncWriteExt};
- // let mut output = File::create("output.json").await?;
- // output.write(line.as_bytes()).await?;
- // output.flush().await?;
- // output.sync_all().await?;
- // drop(output);
-
- let output_json: HashMap<Url, InfoJson> =
- serde_json::from_str(&line).expect("This should be valid json");
-
- for (url, value) in output_json {
- let sub = back_subs.get(&url).expect("This was stored before");
- process_subscription(app, sub, value, &hashes)
- .await
- .with_context(|| format!("Failed to process subscription: '{}'", sub.name))?
- }
- }
-
- let out = child.wait().await?;
- if !out.success() {
- error!(
- "The update_raw.py invokation failed (exit code: {}).",
- out.code()
- .map(|f| f.to_string())
- .unwrap_or("<No exit code>".to_owned())
- )
- }
-
- Ok(())
-}
-
-pub fn video_entry_to_video(entry: InfoJson, sub: Option<&Subscription>) -> Result<Video> {
- macro_rules! unwrap_option {
- ($option:expr) => {
- match $option {
- Some(x) => x,
- None => anyhow::bail!(concat!(
- "Expected a value, but '",
- stringify!($option),
- "' is None!"
- )),
- }
- };
- }
-
- let publish_date = if let Some(date) = &entry.upload_date {
- let year: u32 = date
- .chars()
- .take(4)
- .collect::<String>()
- .parse()
- .expect("Should work.");
- let month: u32 = date
- .chars()
- .skip(4)
- .take(2)
- .collect::<String>()
- .parse()
- .expect("Should work");
- let day: u32 = date
- .chars()
- .skip(6)
- .take(2)
- .collect::<String>()
- .parse()
- .expect("Should work");
-
- let date_string = format!("{year:04}-{month:02}-{day:02}T00:00:00Z");
- Some(
- DateTime::<Utc>::from_str(&date_string)
- .expect("This should always work")
- .timestamp(),
- )
- } else {
- warn!(
- "The video '{}' lacks it's upload date!",
- unwrap_option!(&entry.title)
- );
- None
- };
-
- let thumbnail_url = match (&entry.thumbnails, &entry.thumbnail) {
- (None, None) => None,
- (None, Some(thumbnail)) => Some(thumbnail.to_owned()),
-
- // TODO: The algorithm is not exactly the best <2024-05-28>
- (Some(thumbnails), None) => Some(
- thumbnails
- .first()
- .expect("At least one should exist")
- .url
- .clone(),
- ),
- (Some(_), Some(thumnail)) => Some(thumnail.to_owned()),
- };
-
- let url = {
- let smug_url: url::Url = unwrap_option!(entry.webpage_url.clone());
- unsmuggle_url(smug_url)?
- };
-
- let extractor_hash = blake3::hash(unwrap_option!(entry.id).as_bytes());
-
- let subscription_name = if let Some(sub) = sub {
- Some(sub.name.clone())
- } else {
- if let Some(uploader) = entry.uploader {
- if entry.webpage_url_domain == Some("youtube.com".to_owned()) {
- Some(format!("{} - Videos", uploader))
- } else {
- Some(uploader.clone())
- }
- } else {
- None
- }
- };
-
- let video = Video {
- cache_path: None,
- description: entry.description.clone(),
- duration: entry.duration,
- extractor_hash: ExtractorHash::from_hash(extractor_hash),
- last_status_change: Utc::now().timestamp(),
- parent_subscription_name: subscription_name,
- priority: 0,
- publish_date,
- status: VideoStatus::Pick,
- status_change: false,
- thumbnail_url,
- title: unwrap_option!(entry.title.clone()),
- url,
- };
- Ok(video)
-}
-
-async fn process_subscription(
- app: &App,
- sub: &Subscription,
- entry: InfoJson,
- hashes: &[blake3::Hash],
-) -> Result<()> {
- let video =
- video_entry_to_video(entry, Some(sub)).context("Failed to parse search entry as Video")?;
-
- if hashes.contains(&video.extractor_hash.hash()) {
- // We already stored the video information
- unreachable!("The python update script should have never provided us a duplicated video");
- } else {
- add_video(app, video.clone())
- .await
- .with_context(|| format!("Failed to add video to database: '{}'", video.title))?;
- println!(
- "{}",
- (&video
- .to_formatted_video(app)
- .await
- .with_context(|| format!("Failed to format video: '{}'", video.title))?
- .colorize())
- .to_line_display()
- );
- Ok(())
- }
-}
diff --git a/src/videos/display/format_video.rs b/src/videos/display/format_video.rs
deleted file mode 100644
index 50646a1..0000000
--- a/src/videos/display/format_video.rs
+++ /dev/null
@@ -1,166 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::fmt::Display;
-
-pub trait FormatVideo {
- type Output;
-
- fn cache_path(&self) -> Self::Output;
- fn description(&self) -> Self::Output;
- fn duration(&self) -> Self::Output;
- fn extractor_hash(&self) -> Self::Output;
- fn last_status_change(&self) -> Self::Output;
- fn parent_subscription_name(&self) -> Self::Output;
- fn priority(&self) -> Self::Output;
- fn publish_date(&self) -> Self::Output;
- fn status(&self) -> Self::Output;
- fn status_change(&self) -> Self::Output;
- fn thumbnail_url(&self) -> Self::Output;
- fn title(&self) -> Self::Output;
- fn url(&self) -> Self::Output;
- fn video_options(&self) -> Self::Output;
-
- fn to_parts(
- &self,
- ) -> (
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- Self::Output,
- ) {
- let cache_path = self.cache_path();
- let description = self.description();
- let duration = self.duration();
- let extractor_hash = self.extractor_hash();
- let last_status_change = self.last_status_change();
- let parent_subscription_name = self.parent_subscription_name();
- let priority = self.priority();
- let publish_date = self.publish_date();
- let status = self.status();
- let status_change = self.status_change();
- let thumbnail_url = self.thumbnail_url();
- let title = self.title();
- let url = self.url();
- let video_options = self.video_options();
-
- (
- cache_path,
- description,
- duration,
- extractor_hash,
- last_status_change,
- parent_subscription_name,
- priority,
- publish_date,
- status,
- status_change,
- thumbnail_url,
- title,
- url,
- video_options,
- )
- }
-
- fn to_info_display(&self) -> String
- where
- <Self as FormatVideo>::Output: Display,
- {
- let (
- cache_path,
- description,
- duration,
- extractor_hash,
- last_status_change,
- parent_subscription_name,
- priority,
- publish_date,
- status,
- status_change,
- thumbnail_url,
- title,
- url,
- video_options,
- ) = self.to_parts();
-
- let status_change = if status_change.to_string().as_str() == "false" {
- "currently not changing"
- } else if status_change.to_string().as_str() == "true" {
- "currently changing"
- } else {
- unreachable!("This is an formatted boolean");
- };
-
- let string = format!(
- "\
-{title} ({extractor_hash})
-| -> {cache_path}
-| -> {duration}
-| -> {parent_subscription_name}
-| -> priority: {priority}
-| -> {publish_date}
-| -> status: {status} since {last_status_change}
-| -> {status_change}
-| -> {thumbnail_url}
-| -> {url}
-| -> options: {}
-{description}\n",
- video_options.to_string().trim()
- );
- string
- }
-
- fn to_line_display(&self) -> String
- where
- Self::Output: Display,
- {
- let f = format!(
- "{} {} {} {} {} {}",
- self.status(),
- self.extractor_hash(),
- self.title(),
- self.publish_date(),
- self.parent_subscription_name(),
- self.duration()
- );
-
- f
- }
-
- fn to_select_file_display(&self) -> String
- where
- Self::Output: Display,
- {
- let f = format!(
- r#"{}{} {} "{}" "{}" "{}" "{}" "{}"{}"#,
- self.status(),
- self.video_options(),
- self.extractor_hash(),
- self.title(),
- self.publish_date(),
- self.parent_subscription_name(),
- self.duration(),
- self.url(),
- '\n'
- );
-
- f
- }
-}
diff --git a/src/videos/display/mod.rs b/src/videos/display/mod.rs
deleted file mode 100644
index d919dd2..0000000
--- a/src/videos/display/mod.rs
+++ /dev/null
@@ -1,314 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::path::PathBuf;
-
-use chrono::DateTime;
-use format_video::FormatVideo;
-use owo_colors::OwoColorize;
-use url::Url;
-
-use crate::{
- app::App,
- select::selection_file::duration::Duration,
- storage::video_database::{getters::get_video_opts, Video},
-};
-
-use anyhow::{Context, Result};
-
-pub mod format_video;
-
-macro_rules! get {
- ($value:expr, $key:ident, $name:expr, $code:tt) => {
- if let Some(value) = &$value.$key {
- $code(value)
- } else {
- concat!("[No ", $name, "]").to_owned()
- }
- };
-}
-
-/// This is identical to a [`FormattedVideo`], but has colorized fields.
-pub struct ColorizedFormattedVideo(FormattedVideo);
-
-impl FormattedVideo {
- pub fn colorize(self) -> ColorizedFormattedVideo {
- let Self {
- cache_path,
- description,
- duration,
- extractor_hash,
- last_status_change,
- parent_subscription_name,
- priority,
- publish_date,
- status,
- status_change,
- thumbnail_url,
- title,
- url,
- video_options,
- } = self;
-
- ColorizedFormattedVideo(Self {
- cache_path: cache_path.blue().bold().to_string(),
- description,
- duration: duration.cyan().bold().to_string(),
- extractor_hash: extractor_hash.bright_purple().italic().to_string(),
- last_status_change: last_status_change.bright_cyan().to_string(),
- parent_subscription_name: parent_subscription_name.bright_magenta().to_string(),
- priority,
- publish_date: publish_date.bright_white().bold().to_string(),
- status: status.red().bold().to_string(),
- status_change,
- thumbnail_url,
- title: title.green().bold().to_string(),
- url: url.italic().to_string(),
- video_options: video_options.bright_green().to_string(),
- })
- }
-}
-
-/// This is a version of [`Video`] that has all the fields of the original [`Video`] structure
-/// turned to [`String`]s to facilitate displaying it.
-///
-/// This structure provides a way to display a [`Video`] in a coherent way, as it enforces to
-/// always use the same colors for one field.
-#[derive(Debug)]
-pub struct FormattedVideo {
- cache_path: String,
- description: String,
- duration: String,
- extractor_hash: String,
- last_status_change: String,
- parent_subscription_name: String,
- priority: String,
- publish_date: String,
- status: String,
- status_change: String,
- thumbnail_url: String,
- title: String,
- url: String,
- /// This string contains the video options (speed, subtitle_languages, etc.).
- /// It already starts with an extra whitespace, when these are not empty.
- video_options: String,
-}
-
-impl Video {
- pub async fn to_formatted_video_owned(self, app: &App) -> Result<FormattedVideo> {
- Self::to_formatted_video(&self, app).await
- }
-
- pub async fn to_formatted_video(&self, app: &App) -> Result<FormattedVideo> {
- fn date_from_stamp(stamp: i64) -> String {
- DateTime::from_timestamp(stamp, 0)
- .expect("The timestamps should always be valid")
- .format("%Y-%m-%d")
- .to_string()
- }
-
- let cache_path: String = get!(
- self,
- cache_path,
- "Cache Path",
- (|value: &PathBuf| value.to_string_lossy().to_string())
- );
- let description = get!(
- self,
- description,
- "Description",
- (|value: &str| value.to_owned())
- );
- let duration = Duration::from(self.duration);
- let extractor_hash = self
- .extractor_hash
- .into_short_hash(app)
- .await
- .with_context(|| {
- format!(
- "Failed to format extractor hash, whilst formatting video: '{}'",
- self.title
- )
- })?;
- let last_status_change = date_from_stamp(self.last_status_change);
- let parent_subscription_name = get!(
- self,
- parent_subscription_name,
- "author",
- (|sub: &str| sub.replace('"', "'"))
- );
- let priority = self.priority;
- let publish_date = get!(
- self,
- publish_date,
- "release date",
- (|date: &i64| date_from_stamp(*date))
- );
- // TODO: We might support `.trim()`ing that, as the extra whitespace could be bad in the
- // selection file. <2024-10-07>
- let status = self.status.as_command();
- let status_change = self.status_change;
- let thumbnail_url = get!(
- self,
- thumbnail_url,
- "thumbnail URL",
- (|url: &Url| url.to_string())
- );
- let title = self.title.replace(['"', '„', '”'], "'");
- let url = self.url.as_str().replace('"', "\\\"");
-
- let video_options = {
- let opts = get_video_opts(app, &self.extractor_hash)
- .await
- .with_context(|| {
- format!("Failed to get video options for video: '{}'", self.title)
- })?
- .to_cli_flags(app);
- let opts_white = if !opts.is_empty() { " " } else { "" };
- format!("{}{}", opts_white, opts)
- };
-
- Ok(FormattedVideo {
- cache_path,
- description,
- duration: duration.to_string(),
- extractor_hash: extractor_hash.to_string(),
- last_status_change,
- parent_subscription_name,
- priority: priority.to_string(),
- publish_date,
- status: status.to_string(),
- status_change: status_change.to_string(),
- thumbnail_url,
- title,
- url,
- video_options,
- })
- }
-}
-
-impl<'a> FormatVideo for &'a FormattedVideo {
- type Output = &'a str;
-
- fn cache_path(&self) -> Self::Output {
- &self.cache_path
- }
-
- fn description(&self) -> Self::Output {
- &self.description
- }
-
- fn duration(&self) -> Self::Output {
- &self.duration
- }
-
- fn extractor_hash(&self) -> Self::Output {
- &self.extractor_hash
- }
-
- fn last_status_change(&self) -> Self::Output {
- &self.last_status_change
- }
-
- fn parent_subscription_name(&self) -> Self::Output {
- &self.parent_subscription_name
- }
-
- fn priority(&self) -> Self::Output {
- &self.priority
- }
-
- fn publish_date(&self) -> Self::Output {
- &self.publish_date
- }
-
- fn status(&self) -> Self::Output {
- &self.status
- }
-
- fn status_change(&self) -> Self::Output {
- &self.status_change
- }
-
- fn thumbnail_url(&self) -> Self::Output {
- &self.thumbnail_url
- }
-
- fn title(&self) -> Self::Output {
- &self.title
- }
-
- fn url(&self) -> Self::Output {
- &self.url
- }
-
- fn video_options(&self) -> Self::Output {
- &self.video_options
- }
-}
-impl<'a> FormatVideo for &'a ColorizedFormattedVideo {
- type Output = &'a str;
-
- fn cache_path(&self) -> Self::Output {
- &self.0.cache_path
- }
-
- fn description(&self) -> Self::Output {
- &self.0.description
- }
-
- fn duration(&self) -> Self::Output {
- &self.0.duration
- }
-
- fn extractor_hash(&self) -> Self::Output {
- &self.0.extractor_hash
- }
-
- fn last_status_change(&self) -> Self::Output {
- &self.0.last_status_change
- }
-
- fn parent_subscription_name(&self) -> Self::Output {
- &self.0.parent_subscription_name
- }
-
- fn priority(&self) -> Self::Output {
- &self.0.priority
- }
-
- fn publish_date(&self) -> Self::Output {
- &self.0.publish_date
- }
-
- fn status(&self) -> Self::Output {
- &self.0.status
- }
-
- fn status_change(&self) -> Self::Output {
- &self.0.status_change
- }
-
- fn thumbnail_url(&self) -> Self::Output {
- &self.0.thumbnail_url
- }
-
- fn title(&self) -> Self::Output {
- &self.0.title
- }
-
- fn url(&self) -> Self::Output {
- &self.0.url
- }
-
- fn video_options(&self) -> Self::Output {
- &self.0.video_options
- }
-}
diff --git a/src/videos/mod.rs b/src/videos/mod.rs
deleted file mode 100644
index 59baa8c..0000000
--- a/src/videos/mod.rs
+++ /dev/null
@@ -1,66 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use anyhow::Result;
-use display::{format_video::FormatVideo, FormattedVideo};
-use futures::{stream::FuturesUnordered, TryStreamExt};
-use nucleo_matcher::{
- pattern::{CaseMatching, Normalization, Pattern},
- Matcher,
-};
-
-pub mod display;
-
-use crate::{
- app::App,
- storage::video_database::{getters::get_videos, VideoStatus},
-};
-
-pub async fn query(app: &App, limit: Option<usize>, search_query: Option<String>) -> Result<()> {
- let all_videos = get_videos(app, VideoStatus::ALL, None).await?;
-
- // turn one video to a color display, to pre-warm the hash shrinking cache
- if let Some(val) = all_videos.first() {
- val.to_formatted_video(app).await?;
- }
-
- let limit = limit.unwrap_or(all_videos.len());
-
- let all_video_strings: Vec<String> = all_videos
- .into_iter()
- .take(limit)
- .map(|vid| vid.to_formatted_video_owned(app))
- .collect::<FuturesUnordered<_>>()
- .try_collect::<Vec<FormattedVideo>>()
- .await?
- .into_iter()
- .map(|vid| (&vid.colorize()).to_line_display())
- .collect();
-
- if let Some(query) = search_query {
- let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT.match_paths());
-
- let matches = Pattern::parse(
- &query.replace(' ', "\\ "),
- CaseMatching::Ignore,
- Normalization::Smart,
- )
- .match_list(all_video_strings, &mut matcher);
-
- matches
- .iter()
- .rev()
- .for_each(|(val, key)| println!("{} ({})", val, key));
- } else {
- println!("{}", all_video_strings.join("\n"))
- }
-
- Ok(())
-}
diff --git a/src/watch/events/mod.rs b/src/watch/events/mod.rs
deleted file mode 100644
index 41a7772..0000000
--- a/src/watch/events/mod.rs
+++ /dev/null
@@ -1,369 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::{collections::HashMap, env::current_exe, mem, time::Duration};
-
-use anyhow::{bail, Result};
-use libmpv2::{
- events::{Event, PlaylistEntryId},
- EndFileReason, Mpv,
-};
-use log::{debug, info, warn};
-use tokio::{process::Command, time};
-
-use crate::{
- app::App,
- comments::get_comments,
- storage::video_database::{
- extractor_hash::ExtractorHash,
- getters::{get_video_by_hash, get_video_mpv_opts, get_videos},
- setters::{set_state_change, set_video_watched},
- VideoStatus,
- },
-};
-
-use playlist_handler::PlaylistHandler;
-
-mod playlist_handler;
-
-#[derive(Debug)]
-pub struct MpvEventHandler {
- watch_later_block_list: HashMap<ExtractorHash, ()>,
- playlist_handler: PlaylistHandler,
-}
-
-impl MpvEventHandler {
- pub fn from_playlist(playlist_cache: HashMap<String, ExtractorHash>) -> Self {
- let playlist_handler = PlaylistHandler::from_cache(playlist_cache);
- Self {
- playlist_handler,
- watch_later_block_list: HashMap::new(),
- }
- }
-
- /// Checks, whether new videos are ready to be played
- pub async fn possibly_add_new_videos(
- &mut self,
- app: &App,
- mpv: &Mpv,
- force_message: bool,
- ) -> Result<usize> {
- let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?;
-
- // There is nothing to watch
- if play_things.is_empty() {
- if force_message {
- Self::message(mpv, "No new videos available to add", "3000")?;
- }
- return Ok(0);
- }
-
- let mut blocked_videos = 0;
- let current_playlist = self.playlist_handler.playlist_ids(mpv)?;
- let play_things = play_things
- .into_iter()
- .filter(|val| !current_playlist.values().any(|a| a == &val.extractor_hash))
- .filter(|val| {
- if self
- .watch_later_block_list
- .contains_key(&val.extractor_hash)
- {
- blocked_videos += 1;
- false
- } else {
- true
- }
- })
- .collect::<Vec<_>>();
-
- info!(
- "{} videos are cached and will be added to the list to be played ({} are blocked)",
- play_things.len(),
- blocked_videos
- );
-
- let num = play_things.len();
- self.playlist_handler.reserve(play_things.len());
- for play_thing in play_things {
- debug!("Adding '{}' to playlist.", play_thing.title);
-
- let orig_cache_path = play_thing.cache_path.expect("Is cached and thus some");
- let cache_path = orig_cache_path.to_str().expect("Should be vaild utf8");
- let fmt_cache_path = format!("\"{}\"", cache_path);
-
- let args = &[&fmt_cache_path, "append-play"];
-
- mpv.execute("loadfile", args)?;
- self.playlist_handler
- .add(cache_path.to_owned(), play_thing.extractor_hash);
- }
-
- if force_message || num > 0 {
- Self::message(
- mpv,
- format!(
- "Added {} videos ({} are marked as watch later)",
- num, blocked_videos
- )
- .as_str(),
- "3000",
- )?;
- }
- Ok(num)
- }
-
- fn message(mpv: &Mpv, message: &str, time: &str) -> Result<()> {
- mpv.execute("show-text", &[format!("\"{}\"", message).as_str(), time])?;
- Ok(())
- }
-
- /// Get the hash of the currently playing video.
- /// You can specify an offset, which is added to the playlist_position to get, for example, the
- /// previous video (-1) or the next video (+1).
- /// Beware that setting an offset can cause an property error if it's out of bound.
- fn get_cvideo_hash(&mut self, mpv: &Mpv, offset: i64) -> Result<ExtractorHash> {
- let playlist_entry_id = {
- let playlist_position = {
- let raw = mpv.get_property::<i64>("playlist-pos")?;
- if raw == -1 {
- unreachable!( "This should only be called when a current video exists. Current state: '{:#?}'", self);
- } else {
- (raw + offset) as usize
- }
- };
-
- let raw =
- mpv.get_property::<i64>(format!("playlist/{}/id", playlist_position).as_str())?;
- PlaylistEntryId::new(raw)
- };
-
- // debug!("Trying to get playlist entry: '{}'", playlist_entry_id);
-
- let video_hash = self
- .playlist_handler
- .playlist_ids(mpv)?
- .get(&playlist_entry_id)
- .expect("The stored playling index should always be in the playlist")
- .to_owned();
-
- Ok(video_hash)
- }
- async fn mark_video_watched(&self, app: &App, hash: &ExtractorHash) -> Result<()> {
- let video = get_video_by_hash(app, hash).await?;
- debug!("MPV handler will mark video '{}' watched.", video.title);
- set_video_watched(app, &video).await?;
- Ok(())
- }
-
- async fn mark_video_inactive(
- &mut self,
- app: &App,
- mpv: &Mpv,
- playlist_index: PlaylistEntryId,
- ) -> Result<()> {
- let current_playlist = self.playlist_handler.playlist_ids(mpv)?;
- let video_hash = current_playlist
- .get(&playlist_index)
- .expect("The video index should always be correctly tracked");
-
- set_state_change(app, video_hash, false).await?;
- Ok(())
- }
- async fn mark_video_active(
- &mut self,
- app: &App,
- mpv: &Mpv,
- playlist_index: PlaylistEntryId,
- ) -> Result<()> {
- let current_playlist = self.playlist_handler.playlist_ids(mpv)?;
- let video_hash = current_playlist
- .get(&playlist_index)
- .expect("The video index should always be correctly tracked");
-
- set_state_change(app, video_hash, true).await?;
- Ok(())
- }
-
- /// Apply the options set with e.g. `watch --speed=<speed>`
- async fn apply_options(&self, app: &App, mpv: &Mpv, hash: &ExtractorHash) -> Result<()> {
- let options = get_video_mpv_opts(app, hash).await?;
-
- mpv.set_property("speed", options.playback_speed)?;
- Ok(())
- }
-
- /// This also returns the hash of the current video
- fn remove_cvideo_from_playlist(&mut self, mpv: &Mpv) -> Result<ExtractorHash> {
- let hash = self.get_cvideo_hash(mpv, 0)?;
- mpv.execute("playlist-remove", &["current"])?;
- Ok(hash)
- }
-
- /// Check if the playback queue is empty
- pub async fn check_idle(&mut self, app: &App, mpv: &Mpv) -> Result<bool> {
- if mpv.get_property::<bool>("idle-active")? {
- warn!("There is nothing to watch yet. Will idle, until something is available");
- let number_of_new_videos = self.possibly_add_new_videos(app, mpv, false).await?;
-
- if number_of_new_videos == 0 {
- time::sleep(Duration::from_secs(10)).await;
- Ok(true)
- } else {
- Ok(false)
- }
- } else {
- Ok(false)
- }
- }
-
- /// This will return [`true`], if the event handling should be stopped
- pub async fn handle_mpv_event<'a>(
- &mut self,
- app: &App,
- mpv: &Mpv,
- event: Event<'a>,
- ) -> Result<bool> {
- match event {
- Event::EndFile(r) => match r.reason {
- EndFileReason::Eof => {
- info!("Mpv reached eof of current video. Marking it inactive.");
-
- self.mark_video_inactive(app, mpv, r.playlist_entry_id)
- .await?;
- }
- EndFileReason::Stop => {
- // This reason is incredibly ambiguous. It _both_ means actually pausing a
- // video and going to the next one in the playlist.
- // Oh, and it's also called, when a video is removed from the playlist (at
- // least via "playlist-remove current")
- info!("Paused video (or went to next playlist entry); Marking it inactive");
-
- self.mark_video_inactive(app, mpv, r.playlist_entry_id)
- .await?;
- }
- EndFileReason::Quit => {
- info!("Mpv quit. Exiting playback");
-
- // draining the playlist is okay, as mpv is done playing
- let mut handler = mem::take(&mut self.playlist_handler);
- let videos = handler.playlist_ids(mpv)?;
- for hash in videos.values() {
- self.mark_video_watched(app, hash).await?;
- set_state_change(app, hash, false).await?;
- }
- return Ok(true);
- }
- EndFileReason::Error => {
- unreachable!("This will be raised as a separate error")
- }
- EndFileReason::Redirect => {
- todo!("We probably need to handle this somehow");
- }
- },
- Event::StartFile(entry_id) => {
- self.possibly_add_new_videos(app, mpv, false).await?;
-
- // We don't need to check, whether other videos are still active, as they should
- // have been marked inactive in the `Stop` handler.
- self.mark_video_active(app, mpv, entry_id).await?;
- let hash = self.get_cvideo_hash(mpv, 0)?;
- self.apply_options(app, mpv, &hash).await?;
- }
- Event::ClientMessage(a) => {
- debug!("Got Client Message event: '{}'", a.join(" "));
-
- match a.as_slice() {
- &["yt-comments-external"] => {
- let binary = current_exe().expect("A current exe should exist");
-
- let status = Command::new("riverctl")
- .args(["focus-output", "next"])
- .status()
- .await?;
- if !status.success() {
- bail!("focusing the next output failed!");
- }
-
- let status = Command::new("alacritty")
- .args([
- "--title",
- "floating please",
- "--command",
- binary.to_str().expect("Should be valid unicode"),
- "--db-path",
- app.config
- .paths
- .database_path
- .to_str()
- .expect("This should be convertible?"),
- "comments",
- ])
- .status()
- .await?;
- if !status.success() {
- bail!("Falied to start `yt comments`");
- }
-
- let status = Command::new("riverctl")
- .args(["focus-output", "next"])
- .status()
- .await?;
- if !status.success() {
- bail!("focusing the next output failed!");
- }
- }
- &["yt-comments-local"] => {
- let comments: String = get_comments(app)
- .await?
- .render(false)
- .replace("\"", "")
- .replace("'", "")
- .chars()
- .take(app.config.watch.local_comments_length)
- .collect();
-
- Self::message(mpv, &comments, "6000")?;
- }
- &["yt-description"] => {
- // let description = description(app).await?;
- Self::message(mpv, "<YT Description>", "6000")?;
- }
- &["yt-mark-watch-later"] => {
- mpv.execute("write-watch-later-config", &[])?;
-
- let hash = self.remove_cvideo_from_playlist(mpv)?;
- assert_eq!(
- self.watch_later_block_list.insert(hash, ()),
- None,
- "A video should not be blocked *and* in the playlist"
- );
-
- Self::message(mpv, "Marked the video to be watched later", "3000")?;
- }
- &["yt-mark-done-and-go-next"] => {
- let cvideo_hash = self.remove_cvideo_from_playlist(mpv)?;
- self.mark_video_watched(app, &cvideo_hash).await?;
-
- Self::message(mpv, "Marked the video watched", "3000")?;
- }
- &["yt-check-new-videos"] => {
- self.possibly_add_new_videos(app, mpv, true).await?;
- }
- other => {
- debug!("Unknown message: {}", other.join(" "))
- }
- }
- }
- _ => {}
- }
-
- Ok(false)
- }
-}
diff --git a/src/watch/events/playlist_handler.rs b/src/watch/events/playlist_handler.rs
deleted file mode 100644
index 0933856..0000000
--- a/src/watch/events/playlist_handler.rs
+++ /dev/null
@@ -1,94 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::collections::HashMap;
-
-use anyhow::Result;
-use libmpv2::{events::PlaylistEntryId, mpv_node::MpvNode, Mpv};
-
-use crate::storage::video_database::extractor_hash::ExtractorHash;
-
-#[derive(Debug, Default)]
-pub struct PlaylistHandler {
- /// A map of the original file paths to the videos extractor hashes.
- /// Used to get the extractor hash from a video returned by mpv
- playlist_cache: HashMap<String, ExtractorHash>,
-
- /// A map of the playlist_entry_id field to their corresponding extractor hashes.
- playlist_ids: HashMap<PlaylistEntryId, ExtractorHash>,
-}
-impl PlaylistHandler {
- pub fn from_cache(cache: HashMap<String, ExtractorHash>) -> Self {
- Self {
- playlist_cache: cache,
- playlist_ids: HashMap::new(),
- }
- }
-
- pub fn reserve(&mut self, len: usize) {
- self.playlist_cache.reserve(len)
- }
- pub fn add(&mut self, cache_path: String, extractor_hash: ExtractorHash) {
- assert_eq!(
- self.playlist_cache.insert(cache_path, extractor_hash),
- None,
- "Only new video should ever be added"
- );
- }
-
- pub fn playlist_ids(&mut self, mpv: &Mpv) -> Result<&HashMap<PlaylistEntryId, ExtractorHash>> {
- let mpv_playlist: Vec<(String, PlaylistEntryId)> = match mpv.get_property("playlist")? {
- MpvNode::ArrayIter(array) => array
- .map(|val| match val {
- MpvNode::MapIter(map) => {
- struct BuildPlaylistEntry {
- filename: Option<String>,
- id: Option<PlaylistEntryId>,
- }
- let mut entry = BuildPlaylistEntry {
- filename: None,
- id: None,
- };
-
- map.for_each(|(key, value)| match key.as_str() {
- "filename" => {
- entry.filename = Some(value.str().expect("work").to_owned())
- }
- "id" => {
- entry.id = Some(PlaylistEntryId::new(value.i64().expect("Works")))
- }
- _ => (),
- });
- (entry.filename.expect("is some"), entry.id.expect("is some"))
- }
- _ => unreachable!(),
- })
- .collect(),
- _ => unreachable!(),
- };
-
- let mut playlist: HashMap<PlaylistEntryId, ExtractorHash> =
- HashMap::with_capacity(mpv_playlist.len());
- for (path, key) in mpv_playlist {
- let hash = self
- .playlist_cache
- .get(&path)
- .expect("All path should also be stored in the cache")
- .to_owned();
- playlist.insert(key, hash);
- }
-
- for (id, hash) in playlist {
- self.playlist_ids.entry(id).or_insert(hash);
- }
-
- Ok(&self.playlist_ids)
- }
-}
diff --git a/src/watch/mod.rs b/src/watch/mod.rs
deleted file mode 100644
index 3bcf1fc..0000000
--- a/src/watch/mod.rs
+++ /dev/null
@@ -1,117 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::collections::HashMap;
-
-use anyhow::Result;
-use events::MpvEventHandler;
-use libmpv2::{events::EventContext, Mpv};
-use log::{debug, info, warn};
-
-use crate::{
- app::App,
- cache::maintain,
- storage::video_database::{extractor_hash::ExtractorHash, getters::get_videos, VideoStatus},
-};
-
-pub mod events;
-
-pub async fn watch(app: &App) -> Result<()> {
- maintain(app, false).await?;
-
- // set some default values, to make things easier (these can be overridden by the config file,
- // which we load later)
- let mpv = Mpv::with_initializer(|mpv| {
- // Enable default key bindings, so the user can actually interact with
- // the player (and e.g. close the window).
- mpv.set_property("input-default-bindings", "yes")?;
- mpv.set_property("input-vo-keyboard", "yes")?;
-
- // Show the on screen controller.
- mpv.set_property("osc", "yes")?;
-
- // Don't automatically advance to the next video (or exit the player)
- mpv.set_option("keep-open", "always")?;
- Ok(())
- })?;
-
- let config_path = &app.config.paths.mpv_config_path;
- if config_path.try_exists()? {
- info!("Found mpv.conf at '{}'!", config_path.display());
- mpv.execute(
- "load-config-file",
- &[config_path.to_str().expect("This should be utf8-able")],
- )?;
- } else {
- warn!(
- "Did not find a mpv.conf file at '{}'",
- config_path.display()
- );
- }
-
- let input_path = &app.config.paths.mpv_input_path;
- if input_path.try_exists()? {
- info!("Found mpv.input.conf at '{}'!", input_path.display());
- mpv.execute(
- "load-input-conf",
- &[input_path.to_str().expect("This should be utf8-able")],
- )?;
- } else {
- warn!(
- "Did not find a mpv.input.conf file at '{}'",
- input_path.display()
- );
- }
-
- let mut ev_ctx = EventContext::new(mpv.ctx);
- ev_ctx.disable_deprecated_events()?;
-
- let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?;
- info!(
- "{} videos are cached and ready to be played",
- play_things.len()
- );
-
- let mut playlist_cache: HashMap<String, ExtractorHash> =
- HashMap::with_capacity(play_things.len());
-
- for play_thing in play_things {
- debug!("Adding '{}' to playlist.", play_thing.title);
-
- let orig_cache_path = play_thing.cache_path.expect("Is cached and thus some");
- let cache_path = orig_cache_path.to_str().expect("Should be vaild utf8");
- let fmt_cache_path = format!("\"{}\"", cache_path);
-
- let args = &[&fmt_cache_path, "append-play"];
-
- mpv.execute("loadfile", args)?;
-
- playlist_cache.insert(cache_path.to_owned(), play_thing.extractor_hash);
- }
-
- let mut mpv_event_handler = MpvEventHandler::from_playlist(playlist_cache);
- loop {
- while mpv_event_handler.check_idle(app, &mpv).await? {}
-
- if let Some(ev) = ev_ctx.wait_event(600.) {
- match ev {
- Ok(event) => {
- debug!("Mpv event triggered: {:#?}", event);
- if mpv_event_handler.handle_mpv_event(app, &mpv, event).await? {
- break;
- }
- }
- Err(e) => debug!("Mpv Event errored: {}", e),
- }
- }
- }
-
- Ok(())
-}