diff options
-rw-r--r-- | yt/src/cache/mod.rs | 47 | ||||
-rw-r--r-- | yt/src/cli.rs | 4 | ||||
-rw-r--r-- | yt/src/comments/mod.rs | 18 | ||||
-rw-r--r-- | yt/src/download/mod.rs | 2 | ||||
-rw-r--r-- | yt/src/main.rs | 4 | ||||
-rw-r--r-- | yt/src/select/cmds/add.rs | 2 | ||||
-rw-r--r-- | yt/src/select/cmds/mod.rs | 29 | ||||
-rw-r--r-- | yt/src/select/mod.rs | 22 | ||||
-rw-r--r-- | yt/src/select/selection_file/duration.rs | 127 | ||||
-rw-r--r-- | yt/src/status/mod.rs | 82 | ||||
-rw-r--r-- | yt/src/storage/migrate/mod.rs | 3 | ||||
-rw-r--r-- | yt/src/storage/video_database/downloader.rs | 14 | ||||
-rw-r--r-- | yt/src/storage/video_database/extractor_hash.rs | 2 | ||||
-rw-r--r-- | yt/src/storage/video_database/getters.rs | 347 | ||||
-rw-r--r-- | yt/src/storage/video_database/mod.rs | 216 | ||||
-rw-r--r-- | yt/src/storage/video_database/setters.rs | 317 | ||||
-rw-r--r-- | yt/src/update/mod.rs | 19 | ||||
-rw-r--r-- | yt/src/videos/display/format_video.rs | 6 | ||||
-rw-r--r-- | yt/src/videos/display/mod.rs | 61 | ||||
-rw-r--r-- | yt/src/videos/mod.rs | 6 | ||||
-rw-r--r-- | yt/src/watch/events/handlers/mod.rs | 194 | ||||
-rw-r--r-- | yt/src/watch/events/mod.rs | 322 | ||||
-rw-r--r-- | yt/src/watch/events/playlist_handler.rs | 97 | ||||
-rw-r--r-- | yt/src/watch/mod.rs | 103 |
24 files changed, 474 insertions, 1570 deletions
diff --git a/yt/src/cache/mod.rs b/yt/src/cache/mod.rs index 6cd240c..f5a0da9 100644 --- a/yt/src/cache/mod.rs +++ b/yt/src/cache/mod.rs @@ -15,7 +15,7 @@ use tokio::fs; use crate::{ app::App, storage::video_database::{ - Video, VideoStatus, downloader::set_video_cache_path, getters::get_videos, + Video, VideoStatus, VideoStatusMarker, downloader::set_video_cache_path, get, }, }; @@ -23,15 +23,17 @@ 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 { + if let VideoStatus::Cached { + cache_path: path, .. + } = &video.status + { info!("Removing cached video at: '{}'", path.display()); if let Err(err) = fs::remove_file(path).await.map_err(|err| err.kind()) { match err { std::io::ErrorKind::NotFound => { // The path is already gone debug!( - "Not actually removing path: '{}'. \ - It is already gone.", + "Not actually removing path: '{}'. It is already gone.", path.display() ); } @@ -53,7 +55,7 @@ async fn invalidate_video(app: &App, video: &Video, hard: bool) -> Result<()> { } pub async fn invalidate(app: &App, hard: bool) -> Result<()> { - let all_cached_things = get_videos(app, &[VideoStatus::Cached], None).await?; + let all_cached_things = get::videos(app, &[VideoStatusMarker::Cached]).await?; info!("Got videos to invalidate: '{}'", all_cached_things.len()); @@ -64,34 +66,39 @@ pub async fn invalidate(app: &App, hard: bool) -> Result<()> { Ok(()) } +/// # Panics +/// Only if internal assertions fail. 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, - ] + VideoStatusMarker::ALL.as_slice() } else { - vec![VideoStatus::Watch, VideoStatus::Cached] + &[VideoStatusMarker::Watch, VideoStatusMarker::Cached] }; - let cached_videos = get_videos(app, domain.as_slice(), None).await?; + let cached_videos = get::videos(app, domain).await?; + let mut found_focused = 0; for vid in cached_videos { - if let Some(path) = vid.cache_path.as_ref() { + if let VideoStatus::Cached { + cache_path: path, + is_focused, + } = &vid.status + { info!("Checking if path ('{}') exists", path.display()); if !path.exists() { invalidate_video(app, &vid, false).await?; } - } - // TODO(@bpeetz): Check if only one video is set `is_focused`. <2025-02-17> + if *is_focused { + found_focused += 1; + } + } } + assert!( + found_focused <= 1, + "Only one video can be focused at a time" + ); + Ok(()) } diff --git a/yt/src/cli.rs b/yt/src/cli.rs index fd0dfbe..948138d 100644 --- a/yt/src/cli.rs +++ b/yt/src/cli.rs @@ -17,7 +17,7 @@ use clap::{ArgAction, Args, Parser, Subcommand}; use url::Url; use crate::{ - select::selection_file::duration::Duration, + select::selection_file::duration::MaybeDuration, storage::video_database::extractor_hash::LazyExtractorHash, }; @@ -233,7 +233,7 @@ pub struct SharedSelectionCommandArgs { pub publisher: Option<OptionalPublisher>, - pub duration: Option<Duration>, + pub duration: Option<MaybeDuration>, pub url: Option<Url>, } diff --git a/yt/src/comments/mod.rs b/yt/src/comments/mod.rs index 97b2c24..1482f15 100644 --- a/yt/src/comments/mod.rs +++ b/yt/src/comments/mod.rs @@ -18,10 +18,7 @@ use yt_dlp::wrapper::info_json::{Comment, InfoJson, Parent}; use crate::{ app::App, - storage::video_database::{ - Video, - getters::{get_currently_playing_video, get_video_info_json}, - }, + storage::video_database::{Video, get}, unreachable::Unreachable, }; @@ -29,20 +26,21 @@ mod comment; mod display; pub mod output; +pub mod description; +pub use description::*; + #[allow(clippy::too_many_lines)] pub async fn get(app: &App) -> Result<Comments> { let currently_playing_video: Video = - if let Some(video) = get_currently_playing_video(app).await? { + if let Some(video) = get::currently_focused_video(app).await? { video } else { bail!("Could not find a currently playing video!"); }; - let mut info_json: InfoJson = get_video_info_json(¤tly_playing_video) - .await? - .unreachable( - "A currently *playing* must be cached. And thus the info.json should be available", - ); + let mut info_json: InfoJson = get::video_info_json(¤tly_playing_video)?.unreachable( + "A currently *playing* must be cached. And thus the info.json should be available", + ); let base_comments = mem::take(&mut info_json.comments).with_context(|| { format!( diff --git a/yt/src/download/mod.rs b/yt/src/download/mod.rs index 317f636..168c1b2 100644 --- a/yt/src/download/mod.rs +++ b/yt/src/download/mod.rs @@ -17,7 +17,7 @@ use crate::{ Video, YtDlpOptions, downloader::{get_next_uncached_video, set_video_cache_path}, extractor_hash::ExtractorHash, - getters::get_video_yt_dlp_opts, + get::get_video_yt_dlp_opts, notify::wait_for_cache_reduction, }, unreachable::Unreachable, diff --git a/yt/src/main.rs b/yt/src/main.rs index 7c550af..ed24262 100644 --- a/yt/src/main.rs +++ b/yt/src/main.rs @@ -23,7 +23,7 @@ use cli::{CacheCommand, CheckCommand, SelectCommand, SubscriptionCommand, Videos use config::Config; use log::info; use select::cmds::handle_select_cmd; -use storage::video_database::getters::get_video_by_hash; +use storage::video_database::get::video_by_hash; use tokio::{ fs::File, io::{BufReader, stdin}, @@ -140,7 +140,7 @@ async fn main() -> Result<()> { .context("Failed to query videos")?; } VideosCommand::Info { hash } => { - let video = get_video_by_hash(&app, &hash.realize(&app).await?).await?; + let video = video_by_hash(&app, &hash.realize(&app).await?).await?; print!( "{}", diff --git a/yt/src/select/cmds/add.rs b/yt/src/select/cmds/add.rs index 1e14995..154bb0a 100644 --- a/yt/src/select/cmds/add.rs +++ b/yt/src/select/cmds/add.rs @@ -2,7 +2,7 @@ use crate::{ app::App, download::download_options::download_opts, storage::video_database::{ - self, extractor_hash::ExtractorHash, getters::get_all_hashes, setters::add_video, + self, extractor_hash::ExtractorHash, get::get_all_hashes, set::add_video, }, unreachable::Unreachable, update::video_entry_to_video, diff --git a/yt/src/select/cmds/mod.rs b/yt/src/select/cmds/mod.rs index 2a8a44f..e7f8594 100644 --- a/yt/src/select/cmds/mod.rs +++ b/yt/src/select/cmds/mod.rs @@ -12,9 +12,9 @@ use crate::{ app::App, cli::{SelectCommand, SharedSelectionCommandArgs}, storage::video_database::{ - VideoOptions, VideoStatus, - getters::get_video_by_hash, - setters::{set_video_options, set_video_status}, + Priority, VideoOptions, VideoStatus, + get::video_by_hash, + set::{set_video_options, video_status}, }, }; @@ -41,9 +41,18 @@ pub async fn handle_select_cmd( 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?; + let video = video_by_hash(app, &hash).await?; + + if let VideoStatus::Cached { + cache_path, + is_focused, + } = video.status + { + handle_status_change(app, shared, line_number, VideoStatus::Cached { + cache_path, + is_focused, + }) + .await?; } else { handle_status_change(app, shared, line_number, VideoStatus::Watch).await?; } @@ -79,16 +88,16 @@ async fn handle_status_change( ); let priority = compute_priority(line_number, shared.priority); - set_video_status(app, &hash, new_status, priority).await?; + 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> { +fn compute_priority(line_number: Option<i64>, priority: Option<i64>) -> Option<Priority> { if let Some(pri) = priority { - Some(pri) + Some(Priority::from(pri)) } else { - line_number + line_number.map(Priority::from) } } diff --git a/yt/src/select/mod.rs b/yt/src/select/mod.rs index 34262af..44c8d4f 100644 --- a/yt/src/select/mod.rs +++ b/yt/src/select/mod.rs @@ -19,7 +19,7 @@ use crate::{ app::App, cli::CliArgs, constants::HELP_STR, - storage::video_database::{Video, VideoStatus, getters::get_videos}, + storage::video_database::{Video, VideoStatusMarker, get}, unreachable::Unreachable, }; @@ -35,7 +35,7 @@ pub mod cmds; pub mod selection_file; async fn to_select_file_display_owned(video: Video, app: &App) -> Result<String> { - (&video).to_select_file_display(&app).await + video.to_select_file_display(app).await } pub async fn select(app: &App, done: bool, use_last_selection: bool) -> Result<()> { @@ -50,18 +50,14 @@ pub async fn select(app: &App, done: bool, use_last_selection: bool) -> Result<( fs::copy(&app.config.paths.last_selection_path, &temp_file)?; } else { let matching_videos = if done { - get_videos(app, VideoStatus::ALL, None).await? + get::videos(app, VideoStatusMarker::ALL).await? } else { - get_videos( - app, - &[ - VideoStatus::Pick, - // - VideoStatus::Watch, - VideoStatus::Cached, - ], - None, - ) + get::videos(app, &[ + VideoStatusMarker::Pick, + // + VideoStatusMarker::Watch, + VideoStatusMarker::Cached, + ]) .await? }; diff --git a/yt/src/select/selection_file/duration.rs b/yt/src/select/selection_file/duration.rs index 2953bd3..4fb3d4c 100644 --- a/yt/src/select/selection_file/duration.rs +++ b/yt/src/select/selection_file/duration.rs @@ -9,34 +9,66 @@ // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. use std::str::FromStr; +use std::time::Duration; use anyhow::{Context, Result}; -use crate::unreachable::Unreachable; - const SECOND: u64 = 1; const MINUTE: u64 = 60 * SECOND; const HOUR: u64 = 60 * MINUTE; const DAY: u64 = 24 * HOUR; #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct Duration { - time: u64, +pub struct MaybeDuration { + time: Option<Duration>, } -impl Duration { +impl MaybeDuration { + #[must_use] + pub fn from_std(d: Duration) -> Self { + Self { time: Some(d) } + } + + #[must_use] + pub fn from_secs_f64(d: f64) -> Self { + Self { + time: Some(Duration::from_secs_f64(d)), + } + } + #[must_use] + pub fn from_maybe_secs_f64(d: Option<f64>) -> Self { + Self { + time: d.map(Duration::from_secs_f64), + } + } #[must_use] - pub fn from_std(d: std::time::Duration) -> Self { - Self { time: d.as_secs() } + pub fn from_secs(d: u64) -> Self { + Self { + time: Some(Duration::from_secs(d)), + } } #[must_use] - pub fn value(&self) -> u64 { - self.time + pub fn zero() -> Self { + Self { + time: Some(Duration::default()), + } + } + + /// Try to return the current duration encoded as seconds. + #[must_use] + pub fn as_secs(&self) -> Option<u64> { + self.time.map(|v| v.as_secs()) + } + + /// Try to return the current duration encoded as seconds and nanoseconds. + #[must_use] + pub fn as_secs_f64(&self) -> Option<f64> { + self.time.map(|v| v.as_secs_f64()) } } -impl FromStr for Duration { +impl FromStr for MaybeDuration { type Err = anyhow::Error; fn from_str(s: &str) -> Result<Self, Self::Err> { @@ -48,7 +80,7 @@ impl FromStr for Duration { } if s == "[No duration]" { - return Ok(Self { time: 0 }); + return Ok(Self { time: None }); } let buf: Vec<_> = s.split(' ').collect(); @@ -83,41 +115,35 @@ impl FromStr for Duration { } Ok(Self { - time: days * DAY + hours * HOUR + minutes * MINUTE + seconds * SECOND, + time: Some(Duration::from_secs( + days * DAY + hours * HOUR + minutes * MINUTE + seconds * SECOND, + )), }) } } -impl From<Option<f64>> for Duration { - fn from(value: Option<f64>) -> Self { - Self { - #[allow(clippy::cast_possible_truncation)] - time: u64::try_from(value.unwrap_or(0.0).ceil() as i128) - .unreachable("This should not exceed `u64::MAX`"), - } - } -} - -impl std::fmt::Display for Duration { +impl std::fmt::Display for MaybeDuration { fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - let base_day = self.time - (self.time % DAY); - let base_hour = (self.time % DAY) - ((self.time % DAY) % HOUR); - let base_min = (self.time % HOUR) - (((self.time % DAY) % HOUR) % MINUTE); - let base_sec = ((self.time % DAY) % HOUR) % MINUTE; - - let d = base_day / DAY; - let h = base_hour / HOUR; - let m = base_min / MINUTE; - let s = base_sec / SECOND; - - if self.time == 0 { - write!(fmt, "[No duration]") - } else if d > 0 { - write!(fmt, "{d}d {h}h {m}m") - } else if h > 0 { - write!(fmt, "{h}h {m}m") + if let Some(self_seconds) = self.as_secs() { + let base_day = self_seconds - (self_seconds % DAY); + let base_hour = (self_seconds % DAY) - ((self_seconds % DAY) % HOUR); + let base_min = (self_seconds % HOUR) - (((self_seconds % DAY) % HOUR) % MINUTE); + let base_sec = ((self_seconds % DAY) % HOUR) % MINUTE; + + let d = base_day / DAY; + let h = base_hour / HOUR; + let m = base_min / MINUTE; + let s = base_sec / SECOND; + + if d > 0 { + write!(fmt, "{d}d {h}h {m}m") + } else if h > 0 { + write!(fmt, "{h}h {m}m") + } else { + write!(fmt, "{m}m {s}s") + } } else { - write!(fmt, "{m}m {s}s") + write!(fmt, "[No duration]") } } } @@ -125,23 +151,34 @@ impl std::fmt::Display for Duration { mod test { use std::str::FromStr; - use super::Duration; + use crate::select::selection_file::duration::{DAY, HOUR, MINUTE}; + + use super::MaybeDuration; #[test] fn test_display_duration_1h() { - let dur = Duration { time: 60 * 60 }; + let dur = MaybeDuration::from_secs(HOUR); assert_eq!("1h 0m".to_owned(), dur.to_string()); } #[test] fn test_display_duration_30min() { - let dur = Duration { time: 60 * 30 }; + let dur = MaybeDuration::from_secs(MINUTE * 30); assert_eq!("30m 0s".to_owned(), dur.to_string()); } #[test] + fn test_display_duration_1d() { + let dur = MaybeDuration::from_secs(DAY + MINUTE * 30 + HOUR * 2); + assert_eq!("1d 2h 30m".to_owned(), dur.to_string()); + } + + #[test] fn test_display_duration_roundtrip() { - let dur = Duration { time: 0 }; + let dur = MaybeDuration::zero(); let dur_str = dur.to_string(); - assert_eq!(Duration { time: 0 }, Duration::from_str(&dur_str).unwrap()); + assert_eq!( + MaybeDuration::zero(), + MaybeDuration::from_str(&dur_str).unwrap() + ); } } diff --git a/yt/src/status/mod.rs b/yt/src/status/mod.rs index 501bcf3..9ffec27 100644 --- a/yt/src/status/mod.rs +++ b/yt/src/status/mod.rs @@ -11,53 +11,36 @@ use std::time::Duration; use crate::{ - select::selection_file::duration::Duration as YtDuration, storage::migrate::get_version, -}; - -use anyhow::{Context, Result}; -use bytes::Bytes; - -use crate::{ app::App, download::Downloader, + select::selection_file::duration::MaybeDuration, storage::{ - subscriptions::get, - video_database::{VideoStatus, getters::get_videos}, + subscriptions, + video_database::{VideoStatusMarker, get}, }, }; +use anyhow::{Context, Result}; +use bytes::Bytes; + macro_rules! get { ($videos:expr, $status:ident) => { $videos .iter() - .filter(|vid| vid.status == VideoStatus::$status) + .filter(|vid| vid.status.as_marker() == VideoStatusMarker::$status) .count() }; (@collect $videos:expr, $status:ident) => { $videos .iter() - .filter(|vid| vid.status == VideoStatus::$status) + .filter(|vid| vid.status.as_marker() == VideoStatusMarker::$status) .collect() }; } 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?; + let all_videos = get::videos(app, VideoStatusMarker::ALL).await?; // lengths let picked_videos_len = get!(all_videos, Pick); @@ -70,39 +53,36 @@ pub async fn show(app: &App) -> Result<()> { let drop_videos_len = get!(all_videos, Drop); let dropped_videos_len = get!(all_videos, Dropped); - let subscriptions = get(app).await?; + let subscriptions = subscriptions::get(app).await?; let subscriptions_len = subscriptions.0.len(); let watchtime_status = { - let total_watch_time_raw = YtDuration::from_std(Duration::from_secs_f64( - watched_videos - .iter() - .fold(0f64, |acc, vid| acc + vid.duration.unwrap_or(0f64)), - )); + let total_watch_time_raw = watched_videos + .iter() + .fold(Duration::default(), |acc, vid| acc + vid.watch_progress); // Most things are watched at a speed of s (which is defined in the config file). // Thus // y = x * s -> y / s = x - #[allow(clippy::cast_precision_loss)] - let total_watch_time = YtDuration::from_std(Duration::from_secs_f64( - (total_watch_time_raw.value() as f64) / app.config.select.playback_speed, - )); - - if total_watch_time.value() == 0 { - // do not display a watchtime, if it is 0 - String::new() + let total_watch_time = Duration::from_secs_f64( + (total_watch_time_raw.as_secs_f64()) / app.config.select.playback_speed, + ); + + let speed = app.config.select.playback_speed; + + // Do not print the adjusted time, if the user has keep the speed level at 1. + #[allow(clippy::float_cmp)] + if speed == 1.0 { + format!( + "Total Watchtime: {}\n", + MaybeDuration::from_std(total_watch_time_raw) + ) } else { - let speed = app.config.select.playback_speed; - - // Do not print the adjusted time, if the user has keep the speed level at 1. - #[allow(clippy::float_cmp)] - if speed == 1.0 { - format!("Total Watchtime: {total_watch_time_raw}\n") - } else { - format!( - "Total Watchtime: {total_watch_time_raw} (at {speed} speed: {total_watch_time})\n", - ) - } + format!( + "Total Watchtime: {} (at {speed} speed: {})\n", + MaybeDuration::from_std(total_watch_time_raw), + MaybeDuration::from_std(total_watch_time), + ) } }; diff --git a/yt/src/storage/migrate/mod.rs b/yt/src/storage/migrate/mod.rs index 9696616..ee43008 100644 --- a/yt/src/storage/migrate/mod.rs +++ b/yt/src/storage/migrate/mod.rs @@ -1,12 +1,13 @@ use std::{ fmt::Display, + future::Future, time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result, bail}; use chrono::TimeDelta; use log::{debug, info}; -use sqlx::{Sqlite, Transaction, query}; +use sqlx::{Sqlite, SqlitePool, Transaction, query}; use crate::app::App; diff --git a/yt/src/storage/video_database/downloader.rs b/yt/src/storage/video_database/downloader.rs index d8b2041..e843d6d 100644 --- a/yt/src/storage/video_database/downloader.rs +++ b/yt/src/storage/video_database/downloader.rs @@ -13,10 +13,12 @@ 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, unreachable::Unreachable, video_from_record, + app::App, + storage::video_database::{VideoStatus, VideoStatusMarker}, + unreachable::Unreachable, + video_from_record, }; use super::{ExtractorHash, Video}; @@ -27,9 +29,9 @@ use super::{ExtractorHash, Video}; /// # Panics /// Only if assertions fail. pub async fn get_next_uncached_video(app: &App) -> Result<Option<Video>> { - let status = VideoStatus::Watch.as_db_integer(); + let status = VideoStatus::Watch.as_marker().as_db_integer(); - // NOTE: The ORDER BY statement should be the same as the one in [`getters::get_videos`].<2024-08-22> + // NOTE: The ORDER BY statement should be the same as the one in [`get::videos`].<2024-08-22> let result = query!( r#" SELECT * @@ -69,7 +71,7 @@ pub async fn set_video_cache_path( let path_str = path.display().to_string(); let extractor_hash = video.hash().to_string(); - let status = VideoStatus::Cached.as_db_integer(); + let status = VideoStatusMarker::Cached.as_db_integer(); query!( r#" @@ -92,7 +94,7 @@ pub async fn set_video_cache_path( ); let extractor_hash = video.hash().to_string(); - let status = VideoStatus::Watch.as_db_integer(); + let status = VideoStatus::Watch.as_marker().as_db_integer(); query!( r#" diff --git a/yt/src/storage/video_database/extractor_hash.rs b/yt/src/storage/video_database/extractor_hash.rs index d080f97..57f4f19 100644 --- a/yt/src/storage/video_database/extractor_hash.rs +++ b/yt/src/storage/video_database/extractor_hash.rs @@ -15,7 +15,7 @@ use blake3::Hash; use log::debug; use tokio::sync::OnceCell; -use crate::{app::App, storage::video_database::getters::get_all_hashes, unreachable::Unreachable}; +use crate::{app::App, storage::video_database::get::get_all_hashes, unreachable::Unreachable}; static EXTRACTOR_HASH_LENGTH: OnceCell<usize> = OnceCell::const_new(); diff --git a/yt/src/storage/video_database/getters.rs b/yt/src/storage/video_database/getters.rs deleted file mode 100644 index 09cc9ee..0000000 --- a/yt/src/storage/video_database/getters.rs +++ /dev/null @@ -1,347 +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-demand (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::{Context, Result, bail}; -use blake3::Hash; -use log::debug; -use sqlx::{QueryBuilder, Row, Sqlite, query}; -use url::Url; -use yt_dlp::wrapper::info_json::InfoJson; - -use crate::{ - app::App, - storage::{ - subscriptions::Subscription, - video_database::{InPlaylist, Video, extractor_hash::ExtractorHash}, - }, - unreachable::Unreachable, -}; - -use super::{MpvOptions, VideoOptions, VideoStatus, YtDlpOptions}; - -#[macro_export] -macro_rules! video_from_record { - ($record:expr) => { - 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: if let Some(url) = &$record.thumbnail_url { - Some(Url::parse(&url).expect("Parsing this as url should always work")) - } else { - None - }, - title: $record.title.clone(), - url: Url::parse(&$record.url).expect("Parsing this as url should always work"), - priority: $record.priority, - - in_playlist: { - if $record.in_playlist == 1 && $record.is_focused == 1 { - super::InPlaylist::Focused - } else if $record.in_playlist == 1 && $record.is_focused == 0 { - super::InPlaylist::Hidden - } else if $record.in_playlist == 0 && $record.is_focused == 0 { - super::InPlaylist::Excluded - } else { - unreachable!("Other combinations should not exist") - } - }, - - watch_progress: $record.watch_progress as u32, - } - }; -} - -/// 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 -/// -/// # Panics -/// Only, if assertions fail. -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() - .unreachable("The db hash should always 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).unreachable( - "Parsing this as url should always work. \ - As it was an URL when we put it in.", - ) - }), - title: base.get::<String, &str>("title").clone(), - url: Url::parse(base.get("url")).unreachable( - "Parsing this as url should always work. \ - As it was an URL when we put it in.", - ), - priority: base.get("priority"), - - in_playlist: { - let in_playlist = base.get::<u8, &str>("in_playlist"); - let is_focused = base.get::<u8, &str>("is_focused"); - - if in_playlist == 1 && is_focused == 1 { - InPlaylist::Focused - } else if in_playlist == 1 && is_focused == 0 { - InPlaylist::Hidden - } else if in_playlist == 0 && is_focused == 0 { - InPlaylist::Excluded - } else { - unreachable!("Other combinations should not be possible") - } - }, - watch_progress: base.get::<i64, &str>("watch_progress") as u32, - }) - }) - .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?; - - Ok(video_from_record! {raw_video}) -} - -/// # Panics -/// Only if assertions fail. -pub async fn get_currently_playing_video(app: &App) -> Result<Option<Video>> { - let record = query!("SELECT * FROM videos WHERE is_focused = 1 AND in_playlist = 1") - .fetch_one(&app.database) - .await; - - if let Err(sqlx::Error::RowNotFound) = record { - Ok(None) - } else { - let base = record?; - Ok(Some(video_from_record! {base})) - } -} - -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).unreachable( - "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).unreachable( - "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 with hash: '{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 with hash: '{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 with hash: '{hash}'"))?; - - let mpv = MpvOptions { - playback_speed: opts.playback_speed, - }; - let yt_dlp = YtDlpOptions { - subtitle_langs: opts.subtitle_langs, - }; - - Ok(VideoOptions { yt_dlp, mpv }) -} diff --git a/yt/src/storage/video_database/mod.rs b/yt/src/storage/video_database/mod.rs index 22628b5..34b91fe 100644 --- a/yt/src/storage/video_database/mod.rs +++ b/yt/src/storage/video_database/mod.rs @@ -8,52 +8,102 @@ // 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 std::{ + fmt::{Display, Write}, + path::PathBuf, + time::Duration, +}; +use chrono::{DateTime, Utc}; use url::Url; -use crate::{app::App, storage::video_database::extractor_hash::ExtractorHash}; +use crate::{ + app::App, select::selection_file::duration::MaybeDuration, + storage::video_database::extractor_hash::ExtractorHash, +}; pub mod downloader; pub mod extractor_hash; -pub mod getters; -pub mod setters; +pub mod get; pub mod notify; +pub mod set; #[derive(Debug, Clone)] pub struct Video { - pub cache_path: Option<PathBuf>, pub description: Option<String>, - pub duration: Option<f64>, + pub duration: MaybeDuration, pub extractor_hash: ExtractorHash, - pub last_status_change: i64, + pub last_status_change: TimeStamp, /// 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 priority: Priority, + pub publish_date: Option<TimeStamp>, pub status: VideoStatus, pub thumbnail_url: Option<Url>, pub title: String, pub url: Url, - pub in_playlist: InPlaylist, - /// The seconds the user has already watched the video - pub watch_progress: u32, + pub watch_progress: Duration, +} + +/// The priority of a [`Video`]. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Priority { + value: i64, +} +impl Priority { + /// Return the underlying value to insert that into the database + #[must_use] + pub fn as_db_integer(&self) -> i64 { + self.value + } +} +impl From<i64> for Priority { + fn from(value: i64) -> Self { + Self { value } + } +} +impl Display for Priority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.value.fmt(f) + } } -#[derive(Clone, Copy, Debug)] -pub enum InPlaylist { - /// The video is not in the playlist. - Excluded, +/// An UNIX time stamp. +#[derive(Debug, Default, Clone, Copy)] +pub struct TimeStamp { + value: i64, +} +impl TimeStamp { + /// Return the seconds since the UNIX epoch for this [`TimeStamp`]. + #[must_use] + pub fn as_secs(&self) -> i64 { + self.value + } - /// The video is in the playlist, but not visible - Hidden, + /// Construct a [`TimeStamp`] from a count of seconds since the UNIX epoch. + #[must_use] + pub fn from_secs(value: i64) -> Self { + Self { value } + } - /// It is visible and focused. - /// Only one video should have this state. - Focused, + /// Construct a [`TimeStamp`] from the current time. + #[must_use] + pub fn from_now() -> Self { + Self { + value: Utc::now().timestamp(), + } + } +} +impl Display for TimeStamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + DateTime::from_timestamp(self.value, 0) + .expect("The timestamps should always be valid") + .format("%Y-%m-%d") + .fmt(f) + } } #[derive(Debug)] @@ -107,7 +157,7 @@ pub struct YtDlpOptions { /// Cache // yt cache /// | /// Watched // yt watch -#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] pub enum VideoStatus { #[default] Pick, @@ -115,7 +165,10 @@ pub enum VideoStatus { /// The video has been select to be watched Watch, /// The video has been cached and is ready to be watched - Cached, + Cached { + cache_path: PathBuf, + is_focused: bool, + }, /// The video has been watched Watched, @@ -126,15 +179,90 @@ pub enum VideoStatus { } impl VideoStatus { + /// Reconstruct a [`VideoStatus`] for it's marker and the optional parts. + /// This should only be used by the db record to [`Video`] code. + /// + /// # Panics + /// Only if internal expectations fail. + #[must_use] + pub fn from_marker(marker: VideoStatusMarker, optional: Option<(PathBuf, bool)>) -> Self { + match marker { + VideoStatusMarker::Pick => Self::Pick, + VideoStatusMarker::Watch => Self::Watch, + VideoStatusMarker::Cached => { + let (cache_path, is_focused) = + optional.expect("This should be some, when the video status is cached"); + Self::Cached { + cache_path, + is_focused, + } + } + VideoStatusMarker::Watched => Self::Watched, + VideoStatusMarker::Drop => Self::Drop, + VideoStatusMarker::Dropped => Self::Dropped, + } + } + + /// Turn the [`VideoStatus`] to its internal parts. This is only really useful for the database + /// functions. + #[must_use] + pub fn to_parts_for_db(self) -> (VideoStatusMarker, Option<(PathBuf, bool)>) { + match self { + VideoStatus::Pick => (VideoStatusMarker::Pick, None), + VideoStatus::Watch => (VideoStatusMarker::Watch, None), + VideoStatus::Cached { + cache_path, + is_focused, + } => (VideoStatusMarker::Cached, Some((cache_path, is_focused))), + VideoStatus::Watched => (VideoStatusMarker::Watched, None), + VideoStatus::Drop => (VideoStatusMarker::Drop, None), + VideoStatus::Dropped => (VideoStatusMarker::Dropped, None), + } + } + + /// Return the associated [`VideoStatusMarker`] for this [`VideoStatus`]. + #[must_use] + pub fn as_marker(&self) -> VideoStatusMarker { + match self { + VideoStatus::Pick => VideoStatusMarker::Pick, + VideoStatus::Watch => VideoStatusMarker::Watch, + VideoStatus::Cached { .. } => VideoStatusMarker::Cached, + VideoStatus::Watched => VideoStatusMarker::Watched, + VideoStatus::Drop => VideoStatusMarker::Drop, + VideoStatus::Dropped => VideoStatusMarker::Dropped, + } + } +} + +/// Unit only variant of [`VideoStatus`] +#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +pub enum VideoStatusMarker { + #[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 VideoStatusMarker { pub const ALL: &'static [Self; 6] = &[ Self::Pick, // - VideoStatus::Watch, - VideoStatus::Cached, - VideoStatus::Watched, + Self::Watch, + Self::Cached, + Self::Watched, // - VideoStatus::Drop, - VideoStatus::Dropped, + Self::Drop, + Self::Dropped, ]; #[must_use] @@ -142,12 +270,12 @@ impl VideoStatus { // 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 ", + Self::Pick => "pick ", - VideoStatus::Watch | VideoStatus::Cached => "watch ", - VideoStatus::Watched => "watched", + Self::Watch | Self::Cached => "watch ", + Self::Watched => "watched", - VideoStatus::Drop | VideoStatus::Dropped => "drop ", + Self::Drop | Self::Dropped => "drop ", } } @@ -156,14 +284,14 @@ impl VideoStatus { // These numbers should not change their mapping! // Oh, and keep them in sync with the SQLite check constraint. match self { - VideoStatus::Pick => 0, + Self::Pick => 0, - VideoStatus::Watch => 1, - VideoStatus::Cached => 2, - VideoStatus::Watched => 3, + Self::Watch => 1, + Self::Cached => 2, + Self::Watched => 3, - VideoStatus::Drop => 4, - VideoStatus::Dropped => 5, + Self::Drop => 4, + Self::Dropped => 5, } } #[must_use] @@ -187,14 +315,14 @@ impl VideoStatus { #[must_use] pub fn as_str(&self) -> &'static str { match self { - VideoStatus::Pick => "Pick", + Self::Pick => "Pick", - VideoStatus::Watch => "Watch", - VideoStatus::Cached => "Cache", - VideoStatus::Watched => "Watched", + Self::Watch => "Watch", + Self::Cached => "Cache", + Self::Watched => "Watched", - VideoStatus::Drop => "Drop", - VideoStatus::Dropped => "Dropped", + Self::Drop => "Drop", + Self::Dropped => "Dropped", } } } diff --git a/yt/src/storage/video_database/setters.rs b/yt/src/storage/video_database/setters.rs deleted file mode 100644 index 32a745b..0000000 --- a/yt/src/storage/video_database/setters.rs +++ /dev/null @@ -1,317 +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, getters::get_currently_playing_video, - }, -}; - -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. -/// -/// # Panics -/// Only if assertions fail. -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(()) -} - -/// Set a video to be focused. -/// This optionally takes the `old_video_hash` to disable. -pub async fn set_focused( - app: &App, - new_video_hash: &ExtractorHash, - old_video_hash: Option<&ExtractorHash>, -) -> Result<()> { - if let Some(old) = old_video_hash { - let hash = old.hash().to_string(); - query!( - r#" - UPDATE videos - SET is_focused = 0 - WHERE extractor_hash = ?; - "#, - hash - ) - .execute(&app.database) - .await?; - } - - let new_hash = new_video_hash.hash().to_string(); - query!( - r#" - UPDATE videos - SET is_focused = 1 - WHERE extractor_hash = ?; - "#, - new_hash, - ) - .execute(&app.database) - .await?; - - assert_eq!( - *new_video_hash, - get_currently_playing_video(app) - .await? - .expect("This is some at this point") - .extractor_hash - ); - 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 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; - - let (in_playlist, is_focused) = { - match video.in_playlist { - super::InPlaylist::Excluded => (false, false), - super::InPlaylist::Hidden => (true, false), - super::InPlaylist::Focused => (true, true), - } - }; - - let mut tx = app.database.begin().await?; - query!( - r#" - INSERT INTO videos ( - description, - duration, - extractor_hash, - in_playlist, - is_focused, - last_status_change, - parent_subscription_name, - publish_date, - status, - thumbnail_url, - title, - url, - watch_progress - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); - "#, - video.description, - video.duration, - extractor_hash, - in_playlist, - is_focused, - video.last_status_change, - parent_subscription_name, - video.publish_date, - status, - thumbnail_url, - video.title, - url, - video.watch_progress - ) - .execute(&mut *tx) - .await?; - - query!( - r#" - INSERT INTO video_options ( - extractor_hash, - subtitle_langs, - playback_speed) - VALUES (?, ?, ?); - "#, - extractor_hash, - default_subtitle_langs, - default_mpv_playback_speed - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(()) -} diff --git a/yt/src/update/mod.rs b/yt/src/update/mod.rs index da19bae..c462b1e 100644 --- a/yt/src/update/mod.rs +++ b/yt/src/update/mod.rs @@ -8,7 +8,7 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -use std::str::FromStr; +use std::{str::FromStr, time::Duration}; use anyhow::{Context, Ok, Result}; use chrono::{DateTime, Utc}; @@ -18,11 +18,12 @@ use yt_dlp::{unsmuggle_url, wrapper::info_json::InfoJson}; use crate::{ app::App, + select::selection_file::duration::MaybeDuration, storage::{ subscriptions::{self, Subscription}, video_database::{ - InPlaylist, Video, VideoStatus, extractor_hash::ExtractorHash, getters::get_all_hashes, - setters::add_video, + Priority, TimeStamp, Video, VideoStatus, extractor_hash::ExtractorHash, + get::get_all_hashes, set::add_video, }, }, }; @@ -160,20 +161,18 @@ pub fn video_entry_to_video(entry: InfoJson, sub: Option<&Subscription>) -> Resu }; let video = Video { - cache_path: None, description: entry.description.clone(), - duration: entry.duration, + duration: MaybeDuration::from_maybe_secs_f64(entry.duration), extractor_hash: ExtractorHash::from_hash(extractor_hash), - last_status_change: Utc::now().timestamp(), + last_status_change: TimeStamp::from_now(), parent_subscription_name: subscription_name, - priority: 0, - publish_date, + priority: Priority::default(), + publish_date: publish_date.map(TimeStamp::from_secs), status: VideoStatus::Pick, thumbnail_url, title: unwrap_option!(entry.title.clone()), url, - in_playlist: InPlaylist::Excluded, - watch_progress: 0, + watch_progress: Duration::default(), }; Ok(video) } diff --git a/yt/src/videos/display/format_video.rs b/yt/src/videos/display/format_video.rs index f9c50af..535a418 100644 --- a/yt/src/videos/display/format_video.rs +++ b/yt/src/videos/display/format_video.rs @@ -31,10 +31,10 @@ impl Video { let video_options = self.video_options_fmt(app).await?; let watched_percentage_fmt = { - if let Some(duration) = self.duration { + if let Some(duration) = self.duration.as_secs() { format!( - " (watched: {:0.1}%)", - f64::from(self.watch_progress) / duration + " (watched: {:0.0}%)", + (self.watch_progress.as_secs() / duration) * 100 ) } else { format!(" {watch_progress}") diff --git a/yt/src/videos/display/mod.rs b/yt/src/videos/display/mod.rs index 21ab1d4..2b87add 100644 --- a/yt/src/videos/display/mod.rs +++ b/yt/src/videos/display/mod.rs @@ -8,16 +8,13 @@ // 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 owo_colors::OwoColorize; use url::Url; use crate::{ app::App, - select::selection_file::duration::Duration, - storage::video_database::{InPlaylist, Video, getters::get_video_opts}, + select::selection_file::duration::MaybeDuration, + storage::video_database::{TimeStamp, Video, VideoStatus, get::get_video_opts}, }; use anyhow::{Context, Result}; @@ -34,12 +31,6 @@ macro_rules! get { }; } -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() -} fn maybe_add_color<F>(app: &App, input: String, mut color_fn: F) -> String where F: FnMut(String) -> String, @@ -53,12 +44,15 @@ where impl Video { #[must_use] pub fn cache_path_fmt(&self, app: &App) -> String { - let cache_path = get!( - self, + let cache_path = if let VideoStatus::Cached { cache_path, - "Cache Path", - (|value: &PathBuf| value.to_string_lossy().to_string()) - ); + is_focused: _, + } = &self.status + { + cache_path.to_string_lossy().to_string() + } else { + "[No Cache Path]".to_owned() + }; maybe_add_color(app, cache_path, |v| v.blue().bold().to_string()) } @@ -74,7 +68,7 @@ impl Video { #[must_use] pub fn duration_fmt_no_color(&self) -> String { - Duration::from(self.duration).to_string() + self.duration.to_string() } #[must_use] pub fn duration_fmt(&self, app: &App) -> String { @@ -84,8 +78,11 @@ impl Video { #[must_use] pub fn watch_progress_fmt(&self, app: &App) -> String { - let progress = Duration::from(Some(f64::from(self.watch_progress))).to_string(); - maybe_add_color(app, progress, |v| v.cyan().bold().to_string()) + maybe_add_color( + app, + MaybeDuration::from_std(self.watch_progress).to_string(), + |v| v.cyan().bold().to_string(), + ) } pub async fn extractor_hash_fmt_no_color(&self, app: &App) -> Result<String> { @@ -111,17 +108,27 @@ impl Video { #[must_use] pub fn in_playlist_fmt(&self, app: &App) -> String { - let output = match self.in_playlist { - InPlaylist::Excluded => "Not in the playlist", - InPlaylist::Hidden => "In the playlist", - InPlaylist::Focused => "In the playlist and focused", + let output = match &self.status { + VideoStatus::Pick + | VideoStatus::Watch + | VideoStatus::Watched + | VideoStatus::Drop + | VideoStatus::Dropped => "Not in the playlist", + VideoStatus::Cached { is_focused, .. } => { + if *is_focused { + "In the playlist and focused" + } else { + "In the playlist" + } + } }; maybe_add_color(app, output.to_owned(), |v| v.yellow().italic().to_string()) } #[must_use] pub fn last_status_change_fmt(&self, app: &App) -> String { - let lsc = date_from_stamp(self.last_status_change); - maybe_add_color(app, lsc, |v| v.bright_cyan().to_string()) + maybe_add_color(app, self.last_status_change.to_string(), |v| { + v.bright_cyan().to_string() + }) } #[must_use] @@ -150,7 +157,7 @@ impl Video { self, publish_date, "release date", - (|date: &i64| date_from_stamp(*date)) + (|date: &TimeStamp| date.to_string()) ) } #[must_use] @@ -163,7 +170,7 @@ impl Video { pub fn status_fmt_no_color(&self) -> String { // TODO: We might support `.trim()`ing that, as the extra whitespace could be bad in the // selection file. <2024-10-07> - self.status.as_command().to_string() + self.status.as_marker().as_command().to_string() } #[must_use] pub fn status_fmt(&self, app: &App) -> String { diff --git a/yt/src/videos/mod.rs b/yt/src/videos/mod.rs index 2f9d8af..2860ad4 100644 --- a/yt/src/videos/mod.rs +++ b/yt/src/videos/mod.rs @@ -19,15 +19,15 @@ pub mod display; use crate::{ app::App, - storage::video_database::{Video, VideoStatus, getters::get_videos}, + storage::video_database::{Video, VideoStatusMarker, get}, }; async fn to_line_display_owned(video: Video, app: &App) -> Result<String> { - (&video).to_line_display(&app).await + video.to_line_display(app).await } pub async fn query(app: &App, limit: Option<usize>, search_query: Option<String>) -> Result<()> { - let all_videos = get_videos(app, VideoStatus::ALL, None).await?; + let all_videos = get::videos(app, VideoStatusMarker::ALL).await?; // turn one video to a color display, to pre-warm the hash shrinking cache if let Some(val) = all_videos.first() { diff --git a/yt/src/watch/events/handlers/mod.rs b/yt/src/watch/events/handlers/mod.rs deleted file mode 100644 index 8d4304b..0000000 --- a/yt/src/watch/events/handlers/mod.rs +++ /dev/null @@ -1,194 +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::current_exe, mem}; - -use crate::{app::App, comments, description, storage::video_database::setters::set_state_change}; - -use super::MpvEventHandler; - -use anyhow::{Context, Result, bail}; -use libmpv2::{ - Mpv, - events::{EndFileEvent, PlaylistEntryId}, -}; -use log::info; -use tokio::process::Command; - -impl MpvEventHandler { - // EndFile {{{ - pub async fn handle_end_file_eof( - &mut self, - app: &App, - mpv: &Mpv, - end_file_event: EndFileEvent, - ) -> Result<()> { - info!("Mpv reached eof of current video. Marking it inactive."); - - self.mark_video_inactive(app, mpv, end_file_event.playlist_entry_id) - .await?; - - Ok(()) - } - pub async fn handle_end_file_stop( - &mut self, - app: &App, - mpv: &Mpv, - end_file_event: EndFileEvent, - ) -> Result<()> { - // 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, end_file_event.playlist_entry_id) - .await?; - - Ok(()) - } - pub async fn handle_end_file_quit( - &mut self, - app: &App, - mpv: &Mpv, - _end_file_event: EndFileEvent, - ) -> Result<()> { - 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?; - } - - Ok(()) - } - // }}} - - // StartFile {{{ - pub async fn handle_start_file( - &mut self, - app: &App, - mpv: &Mpv, - entry_id: PlaylistEntryId, - ) -> Result<()> { - 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?; - - Ok(()) - } - // }}} - - // ClientMessage {{{ - async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> { - let binary = - current_exe().context("Failed to determine the current executable to re-execute")?; - - let status = Command::new("riverctl") - .args(["focus-output", "next"]) - .status() - .await?; - if !status.success() { - bail!("focusing the next output failed!"); - } - - let arguments = [ - &[ - "--title", - "floating please", - "--command", - binary - .to_str() - .context("Failed to turn the executable path to a utf8-string")?, - "--db-path", - app.config - .paths - .database_path - .to_str() - .context("Failed to parse the database_path as a utf8-string")?, - ], - args, - ] - .concat(); - - let status = Command::new("alacritty").args(arguments).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!"); - } - - Ok(()) - } - - pub async fn handle_client_message_yt_description_external(app: &App) -> Result<()> { - Self::run_self_in_external_command(app, &["description"]).await?; - Ok(()) - } - pub async fn handle_client_message_yt_description_local(app: &App, mpv: &Mpv) -> Result<()> { - let description: String = description::get(app) - .await? - .replace(['"', '\''], "") - .chars() - .take(app.config.watch.local_displays_length) - .collect(); - - Self::message(mpv, &description, "6000")?; - Ok(()) - } - - pub async fn handle_client_message_yt_comments_external(app: &App) -> Result<()> { - Self::run_self_in_external_command(app, &["comments"]).await?; - Ok(()) - } - pub async fn handle_client_message_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> { - let comments: String = comments::get(app) - .await? - .render(false) - .replace(['"', '\''], "") - .chars() - .take(app.config.watch.local_displays_length) - .collect(); - - Self::message(mpv, &comments, "6000")?; - Ok(()) - } - - /// # Panics - /// Only if internal assertions fail. - pub fn handle_client_message_yt_mark_watch_later(&mut self, mpv: &Mpv) -> Result<()> { - mpv.command("write-watch-later-config", &[])?; - - let hash = self.remove_cvideo_from_playlist(mpv)?; - assert!( - self.watch_later_block_list.insert(hash), - "A video should not be blocked *and* in the playlist" - ); - - Self::message(mpv, "Marked the video to be watched later", "3000")?; - - Ok(()) - } - // }}} -} diff --git a/yt/src/watch/events/mod.rs b/yt/src/watch/events/mod.rs deleted file mode 100644 index 7a08610..0000000 --- a/yt/src/watch/events/mod.rs +++ /dev/null @@ -1,322 +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, HashSet}; - -use anyhow::{Context, Result}; -use libmpv2::{ - EndFileReason, Mpv, - events::{Event, PlaylistEntryId}, -}; -use log::{debug, info}; - -use crate::{ - app::App, - storage::video_database::{ - VideoStatus, - extractor_hash::ExtractorHash, - getters::{get_video_by_hash, get_video_mpv_opts, get_videos}, - setters::{set_state_change, set_video_watched}, - }, - unreachable::Unreachable, -}; - -use playlist_handler::PlaylistHandler; - -mod handlers; -mod playlist_handler; - -#[derive(Debug, Clone, Copy)] -pub enum IdleCheckOutput { - /// There are no videos already downloaded and no more marked to be watched. - /// Waiting is pointless. - NoMoreAvailable, - - /// There are no videos cached, but some (>0) are marked to be watched. - /// So we should wait for them to become available. - NoCached { marked_watched: usize }, - - /// There are videos cached and ready to be inserted into the playback queue. - Available { newly_available: Option<usize> }, -} - -#[derive(Debug)] -pub struct MpvEventHandler { - watch_later_block_list: HashSet<ExtractorHash>, - playlist_handler: PlaylistHandler, -} - -impl MpvEventHandler { - #[must_use] - 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: HashSet::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(&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.unreachable("Is cached and thus some"); - let cache_path = orig_cache_path.to_str().with_context(|| { - format!( - "Failed to parse video cache_path as vaild utf8: '{}'", - orig_cache_path.display() - ) - })?; - - mpv.command("loadfile", &[cache_path, "append-play"])?; - self.playlist_handler - .add(cache_path.to_owned(), play_thing.extractor_hash); - } - - if force_message || num > 0 { - Self::message( - mpv, - format!("Added {num} videos ({blocked_videos} are marked as watch later)").as_str(), - "3000", - )?; - } - Ok(num) - } - - fn message(mpv: &Mpv, message: &str, time: &str) -> Result<()> { - mpv.command("show-text", &[message, 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!( - "Tried to get the currently playing video hash, but failed to access the mpv 'playlist-pos' property! This is a bug, as this function should only be called, when a current video exists. Current state: '{:#?}'", - self - ); - } else { - usize::try_from(raw + offset).with_context(|| format!("Failed to calculate playlist position because of usize overflow: '{raw} + {offset}'"))? - } - }; - - let raw = - mpv.get_property::<i64>(format!("playlist/{playlist_position}/id").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.command("playlist-remove", &["current"])?; - Ok(hash) - } - - /// Check if the playback queue is empty - pub async fn check_idle(&mut self, app: &App, mpv: &Mpv) -> Result<IdleCheckOutput> { - if mpv.get_property::<bool>("idle-active")? { - // The playback is currently not running, but we might still have more videos lined up - // to be inserted into the queue. - - let number_of_new_videos = self.possibly_add_new_videos(app, mpv, false).await?; - - if number_of_new_videos == 0 { - let watch_videos = get_videos(app, &[VideoStatus::Watch], None).await?.len(); - - if watch_videos == 0 { - // There are no more videos left. We should exit now. - Ok(IdleCheckOutput::NoMoreAvailable) - } else { - // There are still videos that *could* get downloaded. Wait for them. - Ok(IdleCheckOutput::NoCached { - marked_watched: watch_videos, - }) - } - } else { - Ok(IdleCheckOutput::Available { - newly_available: Some(number_of_new_videos), - }) - } - } else { - // The playback is running. Obviously, something is available. - Ok(IdleCheckOutput::Available { - newly_available: None, - }) - } - } - - /// This will return [`true`], if the event handling should be stopped - pub async fn handle_mpv_event( - &mut self, - app: &App, - mpv: &Mpv, - event: Event<'_>, - ) -> Result<bool> { - match event { - Event::EndFile(r) => match r.reason { - EndFileReason::Eof => { - self.handle_end_file_eof(app, mpv, r).await?; - } - EndFileReason::Stop => { - self.handle_end_file_stop(app, mpv, r).await?; - } - EndFileReason::Quit => { - self.handle_end_file_quit(app, mpv, r).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.handle_start_file(app, mpv, entry_id).await?; - } - Event::ClientMessage(a) => { - debug!("Got Client Message event: '{}'", a.join(" ")); - - match a.as_slice() { - &["yt-comments-external"] => { - Self::handle_client_message_yt_comments_external(app).await?; - } - &["yt-comments-local"] => { - Self::handle_client_message_yt_comments_local(app, mpv).await?; - } - &["yt-description-external"] => { - Self::handle_client_message_yt_description_external(app).await?; - } - &["yt-description-local"] => { - Self::handle_client_message_yt_description_local(app, mpv).await?; - } - &["yt-mark-watch-later"] => { - self.handle_client_message_yt_mark_watch_later(mpv)?; - } - &["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/yt/src/watch/events/playlist_handler.rs b/yt/src/watch/events/playlist_handler.rs deleted file mode 100644 index 8565ea8..0000000 --- a/yt/src/watch/events/playlist_handler.rs +++ /dev/null @@ -1,97 +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::{Mpv, events::PlaylistEntryId, mpv_node::MpvNode}; - -use crate::storage::video_database::extractor_hash::ExtractorHash; - -#[derive(Debug, Default)] -pub(crate) 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(crate) fn from_cache(cache: HashMap<String, ExtractorHash>) -> Self { - Self { - playlist_cache: cache, - playlist_ids: HashMap::new(), - } - } - - pub(crate) fn reserve(&mut self, len: usize) { - self.playlist_cache.reserve(len); - } - pub(crate) 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(crate) 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/yt/src/watch/mod.rs b/yt/src/watch/mod.rs index 630de68..16d4899 100644 --- a/yt/src/watch/mod.rs +++ b/yt/src/watch/mod.rs @@ -8,27 +8,31 @@ // 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, time::Duration}; +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use anyhow::{Context, Result}; -use events::{IdleCheckOutput, MpvEventHandler}; use libmpv2::{Mpv, events::EventContext}; use log::{debug, info, trace, warn}; -use tokio::time; +use playlist_handler::{reload_mpv_playlist, save_watch_progress}; +use tokio::{task, time::sleep}; +use self::playlist_handler::Status; use crate::{ app::App, cache::maintain, - storage::video_database::{VideoStatus, extractor_hash::ExtractorHash, getters::get_videos}, - unreachable::Unreachable, + storage::video_database::{get, notify::wait_for_db_write}, }; -pub mod events; - -#[allow(clippy::too_many_lines)] -pub async fn watch(app: &App) -> Result<()> { - maintain(app, false).await?; +pub mod playlist; +pub mod playlist_handler; +fn init_mpv(app: &App) -> Result<(Mpv, EventContext)> { // 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| { @@ -76,72 +80,82 @@ pub async fn watch(app: &App) -> Result<()> { ); } - let mut ev_ctx = EventContext::new(mpv.ctx); + let 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() - ); + Ok((mpv, ev_ctx)) +} - let mut playlist_cache: HashMap<String, ExtractorHash> = - HashMap::with_capacity(play_things.len()); +pub async fn watch(app: Arc<App>) -> Result<()> { + maintain(&app, false).await?; - for play_thing in play_things { - debug!("Adding '{}' to playlist.", play_thing.title); + let (mpv, mut ev_ctx) = init_mpv(&app).context("Failed to initialize mpv instance")?; + let mpv = Arc::new(mpv); + reload_mpv_playlist(&app, &mpv, None, None).await?; - let orig_cache_path = play_thing.cache_path.unreachable("Is cached and thus some"); - let cache_path = orig_cache_path.to_str().with_context(|| { - format!( - "Failed to parse the cache_path of a video as utf8: '{}'", - orig_cache_path.display() - ) - })?; + let should_break = Arc::new(AtomicBool::new(false)); - mpv.command("loadfile", &[&cache_path, "append-play"])?; + let local_app = Arc::clone(&app); + let local_mpv = Arc::clone(&mpv); + let local_should_break = Arc::clone(&should_break); + let progress_handle = task::spawn(async move { + loop { + if local_should_break.load(Ordering::Relaxed) { + break; + } - playlist_cache.insert(cache_path.to_owned(), play_thing.extractor_hash); - } + if get::currently_focused_video(&local_app).await?.is_some() { + save_watch_progress(&local_app, &local_mpv).await?; + } + + sleep(Duration::from_secs(30)).await; + } + + Ok::<(), anyhow::Error>(()) + }); - let mut mpv_event_handler = MpvEventHandler::from_playlist(playlist_cache); let mut have_warned = (false, 0); 'watchloop: loop { - 'waitloop: while let Ok(value) = mpv_event_handler.check_idle(app, &mpv).await { + 'waitloop: while let Ok(value) = playlist_handler::status(&app).await { match value { - IdleCheckOutput::NoMoreAvailable => { + Status::NoMoreAvailable => { break 'watchloop; } - IdleCheckOutput::NoCached { marked_watched } => { + Status::NoCached { marked_watch } => { // try again next time. if have_warned.0 { - if have_warned.1 != marked_watched { - warn!("Now {} videos are marked as watched.", marked_watched); - have_warned.1 = marked_watched; + if have_warned.1 != marked_watch { + warn!("Now {} videos are marked as to be watched.", marked_watch); + have_warned.1 = marked_watch; } } else { warn!( "There is nothing to watch yet, but still {} videos marked as to be watched. \ Will idle, until they become available", - marked_watched + marked_watch ); - have_warned = (true, marked_watched); + have_warned = (true, marked_watch); } - time::sleep(Duration::from_secs(10)).await; + wait_for_db_write(&app).await?; } - IdleCheckOutput::Available { newly_available: _ } => { + Status::Available { newly_available } => { + debug!("Check and found {newly_available} videos!"); have_warned.0 = false; + // Something just became available! break 'waitloop; } } } - if let Some(ev) = ev_ctx.wait_event(600.) { + if let Some(ev) = ev_ctx.wait_event(30.) { match ev { Ok(event) => { trace!("Mpv event triggered: {:#?}", event); - if mpv_event_handler.handle_mpv_event(app, &mpv, event).await? { + if playlist_handler::handle_mpv_event(&app, &mpv, &event) + .await + .with_context(|| format!("Failed to handle mpv event: '{event:#?}'"))? + { break; } } @@ -150,5 +164,8 @@ pub async fn watch(app: &App) -> Result<()> { } } + should_break.store(true, Ordering::Relaxed); + progress_handle.await??; + Ok(()) } |