aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-02-22 11:29:38 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-02-22 11:29:38 +0100
commit9e8657c9762dbb66f3322976606a1b4334d45a6b (patch)
tree76eb7e415e551bbeb4d17a17bbef1d093b6ef188
parentdocs(yt/cli): Remove last references to the external update and status_change... (diff)
downloadyt-9e8657c9762dbb66f3322976606a1b4334d45a6b.zip
feat(yt/): Use concrete types in the `Video` structure
-rw-r--r--yt/src/cache/mod.rs47
-rw-r--r--yt/src/cli.rs4
-rw-r--r--yt/src/comments/mod.rs18
-rw-r--r--yt/src/download/mod.rs2
-rw-r--r--yt/src/main.rs4
-rw-r--r--yt/src/select/cmds/add.rs2
-rw-r--r--yt/src/select/cmds/mod.rs29
-rw-r--r--yt/src/select/mod.rs22
-rw-r--r--yt/src/select/selection_file/duration.rs123
-rw-r--r--yt/src/status/mod.rs80
-rw-r--r--yt/src/storage/migrate/mod.rs3
-rw-r--r--yt/src/storage/video_database/downloader.rs14
-rw-r--r--yt/src/storage/video_database/extractor_hash.rs2
-rw-r--r--yt/src/storage/video_database/getters.rs347
-rw-r--r--yt/src/storage/video_database/mod.rs216
-rw-r--r--yt/src/storage/video_database/setters.rs317
-rw-r--r--yt/src/update/mod.rs19
-rw-r--r--yt/src/videos/display/format_video.rs6
-rw-r--r--yt/src/videos/display/mod.rs61
-rw-r--r--yt/src/videos/mod.rs6
-rw-r--r--yt/src/watch/events/handlers/mod.rs194
-rw-r--r--yt/src/watch/events/mod.rs322
-rw-r--r--yt/src/watch/events/playlist_handler.rs97
-rw-r--r--yt/src/watch/mod.rs103
24 files changed, 471 insertions, 1567 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(&currently_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(&currently_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: std::time::Duration) -> Self {
- Self { time: d.as_secs() }
+ pub fn from_std(d: Duration) -> Self {
+ Self { time: Some(d) }
}
#[must_use]
- pub fn value(&self) -> u64 {
- self.time
+ 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_secs(d: u64) -> Self {
+ Self {
+ time: Some(Duration::from_secs(d)),
+ }
+ }
+
+ #[must_use]
+ 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;
+ 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;
+ 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 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,
- ));
+ let total_watch_time = Duration::from_secs_f64(
+ (total_watch_time_raw.as_secs_f64()) / app.config.select.playback_speed,
+ );
- if total_watch_time.value() == 0 {
- // do not display a watchtime, if it is 0
- String::new()
- } else {
- let speed = 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: {total_watch_time_raw}\n")
- } else {
- format!(
- "Total Watchtime: {total_watch_time_raw} (at {speed} speed: {total_watch_time})\n",
- )
- }
+ // 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 {
+ 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(())
}