diff options
author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-07-14 16:03:50 +0200 |
---|---|---|
committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-07-14 16:03:50 +0200 |
commit | e4d6fc04f60cf7b8173df7f261428b25d009ba39 (patch) | |
tree | 236abaac0f41f75391e3b7048920168593f31608 /crates | |
parent | refactor(crates/yt): Make every `pub` item `pub(crate)` (diff) | |
download | yt-e4d6fc04f60cf7b8173df7f261428b25d009ba39.zip |
feat(crates/yt/storage): Migrate inserts to operations and use methods
This allows us to re-use the operations and in the future to provide undo-capabilities and a git-reflog like changelog. This commit also fixes some bugs with the old design.
Diffstat (limited to 'crates')
22 files changed, 1573 insertions, 1405 deletions
diff --git a/crates/yt/src/storage/video_database/extractor_hash.rs b/crates/yt/src/storage/db/extractor_hash.rs index df545d7..abe1f0f 100644 --- a/crates/yt/src/storage/video_database/extractor_hash.rs +++ b/crates/yt/src/storage/db/extractor_hash.rs @@ -15,13 +15,14 @@ use anyhow::{Context, Result, bail}; use blake3::Hash; use log::debug; use tokio::sync::OnceCell; +use yt_dlp::{info_json::InfoJson, json_cast, json_get}; -use crate::{app::App, storage::video_database::get::get_all_hashes, unreachable::Unreachable}; +use crate::app::App; static EXTRACTOR_HASH_LENGTH: OnceCell<usize> = OnceCell::const_new(); #[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)] -pub struct ExtractorHash { +pub(crate) struct ExtractorHash { hash: Hash, } @@ -32,7 +33,7 @@ impl Display for ExtractorHash { } #[derive(Debug, Clone)] -pub struct ShortHash(String); +pub(crate) struct ShortHash(String); impl Display for ShortHash { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -42,7 +43,7 @@ impl Display for ShortHash { #[derive(Debug, Clone)] #[allow(clippy::module_name_repetitions)] -pub struct LazyExtractorHash { +pub(crate) struct LazyExtractorHash { value: ShortHash, } @@ -63,28 +64,78 @@ impl FromStr for LazyExtractorHash { impl LazyExtractorHash { /// Turn the [`LazyExtractorHash`] into the [`ExtractorHash`] - pub async fn realize(self, app: &App) -> Result<ExtractorHash> { + pub(crate) async fn realize(self, app: &App) -> Result<ExtractorHash> { ExtractorHash::from_short_hash(app, &self.value).await } } impl ExtractorHash { #[must_use] - pub fn from_hash(hash: Hash) -> Self { + pub(crate) fn from_hash(hash: Hash) -> Self { Self { hash } } - pub async fn from_short_hash(app: &App, s: &ShortHash) -> Result<Self> { + + pub(crate) async fn from_short_hash(app: &App, s: &ShortHash) -> Result<Self> { Ok(Self { - hash: Self::short_hash_to_full_hash(app, s).await?, + hash: Self::short_hash_to_full_hash(app, s).await?.hash, }) } + pub(crate) fn from_info_json(entry: &InfoJson) -> Self { + // HACK(@bpeetz): The code that follows is a gross hack. + // One would expect the `id` to be unique _and_ constant for each and every possible info JSON. + // But .. it's just not. The `ARDMediathek` extractor, will sometimes return different `id`s for the same + // video, effectively causing us to insert the same video again into the db (which fails, + // because the URL is still unique). + // + // As such we _should_ probably find a constant value for all extractors, but that just does + // not exist currently, without processing each entry (which is expensive and which I would + // like to avoid). + // + // Therefor, we simply special case the `ARDBetaMediathek` extractor. <2025-07-04> + + // NOTE(@bpeetz): `yt-dlp` apparently uses these two different names for the same thing <2025-07-04> + let ie_key = { + if let Some(val) = entry.get("ie_key") { + json_cast!(val, as_str) + } else if let Some(val) = entry.get("extractor_key") { + json_cast!(val, as_str) + } else { + unreachable!( + "Either `ie_key` or `extractor_key` \ + should be set on every entry info json" + ) + } + }; + + if ie_key == "ARDBetaMediathek" { + // NOTE(@bpeetz): The mediathek is changing their Id scheme, from an `short` old Id to the + // new id. As the new id is too long for some people, yt-dlp will be default return the old + // one (when it is still available!). The new one is called `display_id`. + // Therefore, we simply check if the new one is explicitly returned, and otherwise use the + // normal `id` value, as these are cases where the old one is no longer available. <2025-07-04> + let id = if let Some(val) = entry.get("display_id") { + json_cast!(val, as_str).as_bytes() + } else { + json_get!(entry, "id", as_str).as_bytes() + }; + + Self { + hash: blake3::hash(id), + } + } else { + Self { + hash: blake3::hash(json_get!(entry, "id", as_str).as_bytes()), + } + } + } + #[must_use] - pub fn hash(&self) -> &Hash { + pub(crate) fn hash(&self) -> &Hash { &self.hash } - pub async fn into_short_hash(&self, app: &App) -> Result<ShortHash> { + pub(crate) async fn into_short_hash(&self, app: &App) -> Result<ShortHash> { let needed_chars = if let Some(needed_chars) = EXTRACTOR_HASH_LENGTH.get() { *needed_chars } else { @@ -92,9 +143,9 @@ impl ExtractorHash { .get_needed_char_len(app) .await .context("Failed to calculate needed char length")?; - EXTRACTOR_HASH_LENGTH.set(needed_chars).unreachable( - "This should work at this stage, as we checked above that it is empty.", - ); + EXTRACTOR_HASH_LENGTH + .set(needed_chars) + .expect("This should work at this stage, as we checked above that it is empty."); needed_chars }; @@ -108,15 +159,15 @@ impl ExtractorHash { )) } - async fn short_hash_to_full_hash(app: &App, s: &ShortHash) -> Result<Hash> { - let all_hashes = get_all_hashes(app) + async fn short_hash_to_full_hash(app: &App, s: &ShortHash) -> Result<Self> { + let all_hashes = Self::get_all(app) .await .context("Failed to fetch all extractor -hashesh from database")?; let needed_chars = s.0.len(); for hash in all_hashes { - if hash.to_hex()[..needed_chars] == s.0 { + if hash.hash().to_hex()[..needed_chars] == s.0 { return Ok(hash); } } @@ -126,13 +177,13 @@ impl ExtractorHash { async fn get_needed_char_len(&self, app: &App) -> Result<usize> { debug!("Calculating the needed hash char length"); - let all_hashes = get_all_hashes(app) + let all_hashes = Self::get_all(app) .await .context("Failed to fetch all extractor -hashesh from database")?; let all_char_vec_hashes = all_hashes .into_iter() - .map(|hash| hash.to_hex().chars().collect::<Vec<char>>()) + .map(|hash| hash.hash().to_hex().chars().collect::<Vec<char>>()) .collect::<Vec<Vec<_>>>(); // This value should be updated later, if not rust will panic in the assertion. diff --git a/crates/yt/src/storage/db/get/extractor_hash.rs b/crates/yt/src/storage/db/get/extractor_hash.rs new file mode 100644 index 0000000..d10b326 --- /dev/null +++ b/crates/yt/src/storage/db/get/extractor_hash.rs @@ -0,0 +1,58 @@ +use anyhow::Result; +use blake3::Hash; +use sqlx::{SqliteConnection, query}; + +use crate::{ + app::App, + storage::db::{ + extractor_hash::ExtractorHash, + video::{Video, video_from_record}, + }, +}; + +impl ExtractorHash { + pub(crate) async fn get(&self, txn: &mut SqliteConnection) -> Result<Video> { + let extractor_hash = self.hash().to_string(); + + let base = query!( + r#" + SELECT * + FROM videos + WHERE extractor_hash = ? + "#, + extractor_hash + ) + .fetch_one(txn) + .await?; + + Ok(video_from_record!(base)) + } + + pub(crate) async fn get_with_app(&self, app: &App) -> Result<Video> { + let mut txn = app.database.begin().await?; + let out = self.get(&mut txn).await?; + txn.commit().await?; + + Ok(out) + } + + pub(crate) async fn get_all(app: &App) -> Result<Vec<Self>> { + let hashes_hex = query!( + r#" + SELECT extractor_hash + FROM videos; + "# + ) + .fetch_all(&app.database) + .await?; + + Ok(hashes_hex + .iter() + .map(|hash| { + Self::from_hash(Hash::from_hex(&hash.extractor_hash).expect( + "These values started as blake3 hashes, they should stay blake3 hashes", + )) + }) + .collect()) + } +} diff --git a/crates/yt/src/storage/db/get/mod.rs b/crates/yt/src/storage/db/get/mod.rs new file mode 100644 index 0000000..8ca3075 --- /dev/null +++ b/crates/yt/src/storage/db/get/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod subscription; +pub(crate) mod video; +pub(crate) mod extractor_hash; +pub(crate) mod playlist; diff --git a/crates/yt/src/storage/db/get/playlist.rs b/crates/yt/src/storage/db/get/playlist.rs new file mode 100644 index 0000000..95a61bf --- /dev/null +++ b/crates/yt/src/storage/db/get/playlist.rs @@ -0,0 +1,58 @@ +use crate::{ + app::App, + storage::db::{ + playlist::{Playlist, PlaylistIndex}, + video::{Video, VideoStatusMarker}, + }, +}; + +use anyhow::Result; + +impl Playlist { + /// Get an video based in its index. + #[must_use] + pub(crate) fn get_mut(&mut self, index: PlaylistIndex) -> Option<&mut Video> { + self.videos.get_mut(Into::<usize>::into(index)) + } + + /// Create a playlist, by loading it from the database. + pub(crate) async fn create(app: &App) -> Result<Self> { + let videos = Video::in_states(app, &[VideoStatusMarker::Cached]).await?; + + Ok(Self { videos }) + } + + /// Return the current playlist index. + /// + /// This effectively looks for the currently focused video and returns it's index. + /// + /// # Panics + /// Only if internal assertions fail. + pub(crate) fn current_index(&self) -> Option<PlaylistIndex> { + if let Some((index, _)) = self.get_focused() { + Some(index) + } else { + None + } + } + + /// Get the currently focused video, if it exists. + #[must_use] + pub(crate) fn get_focused_mut(&mut self) -> Option<(PlaylistIndex, &mut Video)> { + self.videos + .iter_mut() + .enumerate() + .find(|(_, v)| v.is_focused()) + .map(|(index, video)| (PlaylistIndex::from(index), video)) + } + + /// Get the currently focused video, if it exists. + #[must_use] + pub(crate) fn get_focused(&self) -> Option<(PlaylistIndex, &Video)> { + self.videos + .iter() + .enumerate() + .find(|(_, v)| v.is_focused()) + .map(|(index, video)| (PlaylistIndex::from(index), video)) + } +} diff --git a/crates/yt/src/storage/db/get/subscription.rs b/crates/yt/src/storage/db/get/subscription.rs new file mode 100644 index 0000000..cdb2b9a --- /dev/null +++ b/crates/yt/src/storage/db/get/subscription.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; + +use crate::{ + app::App, + storage::db::subscription::{Subscription, Subscriptions}, +}; + +use anyhow::Result; +use sqlx::query; +use url::Url; + +impl Subscriptions { + /// Get a list of subscriptions + pub(crate) async fn get(app: &App) -> Result<Self> { + let raw_subs = query!( + " + SELECT * + FROM subscriptions; + " + ) + .fetch_all(&app.database) + .await?; + + let subscriptions: HashMap<String, Subscription> = raw_subs + .into_iter() + .map(|sub| { + ( + sub.name.clone(), + Subscription::new( + sub.name, + Url::parse(&sub.url).expect("It was an URL, when we inserted it."), + ), + ) + }) + .collect(); + + Ok(Subscriptions(subscriptions)) + } +} diff --git a/crates/yt/src/storage/db/get/video/mod.rs b/crates/yt/src/storage/db/get/video/mod.rs new file mode 100644 index 0000000..ec00934 --- /dev/null +++ b/crates/yt/src/storage/db/get/video/mod.rs @@ -0,0 +1,188 @@ +use std::{fs::File, path::PathBuf}; + +use anyhow::{Context, Result, bail}; +use log::debug; +use sqlx::query; +use yt_dlp::info_json::InfoJson; + +use crate::{ + app::App, + storage::db::video::{Video, VideoStatus, VideoStatusMarker, video_from_record}, +}; + +impl Video { + /// Returns to next video which should be downloaded. This respects the priority assigned by select. + /// It does not return videos, which are already downloaded. + /// + /// # Panics + /// Only if assertions fail. + pub(crate) async fn next_to_download(app: &App) -> Result<Option<Self>> { + let status = VideoStatus::Watch.as_marker().as_db_integer(); + + // NOTE: The ORDER BY statement should be the same as the one in [`in_states`]. <2024-08-22> + let result = query!( + r#" + SELECT * + FROM videos + WHERE status = ? AND cache_path IS NULL + ORDER BY priority DESC, publish_date DESC + LIMIT 1; + "#, + status + ) + .fetch_one(&app.database) + .await; + + if let Err(sqlx::Error::RowNotFound) = result { + Ok(None) + } else { + let base = result?; + + Ok(Some(video_from_record!(base))) + } + } + + /// Optionally returns the video that is currently focused. + /// + /// # Panics + /// Only if assertions fail. + pub(crate) async fn currently_focused(app: &App) -> Result<Option<Self>> { + let status = VideoStatusMarker::Cached.as_db_integer(); + + let result = query!( + r#" + SELECT * + FROM videos + WHERE status = ? AND is_focused = 1 + "#, + status + ) + .fetch_one(&app.database) + .await; + + if let Err(sqlx::Error::RowNotFound) = result { + Ok(None) + } else { + let base = result?; + + Ok(Some(video_from_record!(base))) + } + } + + /// Calculate the [`info_json`] location on-disk for this video. + /// + /// Will return [`None`], if the video does not have an downloaded [`info_json`] + pub(crate) fn info_json_path(&self) -> Result<Option<PathBuf>> { + if let VideoStatus::Cached { mut cache_path, .. } = self.status.clone() { + if !cache_path.set_extension("info.json") { + bail!( + "Failed to change path extension to 'info.json': {}", + cache_path.display() + ); + } + + Ok(Some(cache_path)) + } else { + Ok(None) + } + } + + /// Fetch the [`info_json`], downloaded on-disk for this video. + /// + /// Will return [`None`], if the video does not have an downloaded [`info_json`] + pub(crate) fn get_info_json(&self) -> Result<Option<InfoJson>> { + if let Some(path) = self.info_json_path()? { + let info_json_string = File::open(path)?; + let info_json = serde_json::from_reader(&info_json_string)?; + + Ok(Some(info_json)) + } else { + Ok(None) + } + } + + /// Returns this videos `is_focused` flag if it is set. + /// + /// Will return `false` for not-downloaded videos. + pub(crate) fn is_focused(&self) -> bool { + if let VideoStatus::Cached { is_focused, .. } = &self.status { + *is_focused + } else { + false + } + } + + /// Returns the videos that are in the `allowed_states`. + /// + /// # Panics + /// Only, if assertions fail. + pub(crate) async fn in_states( + app: &App, + allowed_states: &[VideoStatusMarker], + ) -> Result<Vec<Video>> { + fn test(all_states: &[VideoStatusMarker], check: VideoStatusMarker) -> Option<i64> { + if all_states.contains(&check) { + Some(check.as_db_integer()) + } else { + None + } + } + fn states_to_string(allowed_states: &[VideoStatusMarker]) -> String { + let mut states = allowed_states + .iter() + .fold(String::from("&["), |mut acc, state| { + acc.push_str(state.as_str()); + acc.push_str(", "); + acc + }); + states = states.trim().to_owned(); + states = states.trim_end_matches(',').to_owned(); + states.push(']'); + states + } + + debug!( + "Fetching videos in the states: '{}'", + states_to_string(allowed_states) + ); + let active_pick: Option<i64> = test(allowed_states, VideoStatusMarker::Pick); + let active_watch: Option<i64> = test(allowed_states, VideoStatusMarker::Watch); + let active_cached: Option<i64> = test(allowed_states, VideoStatusMarker::Cached); + let active_watched: Option<i64> = test(allowed_states, VideoStatusMarker::Watched); + let active_drop: Option<i64> = test(allowed_states, VideoStatusMarker::Drop); + let active_dropped: Option<i64> = test(allowed_states, VideoStatusMarker::Dropped); + + // NOTE: The ORDER BY statement should be the same as the one in [`next_to_download`]. <2024-08-22> + let videos = query!( + r" + SELECT * + FROM videos + WHERE status IN (?,?,?,?,?,?) + ORDER BY priority DESC, publish_date DESC; + ", + active_pick, + active_watch, + active_cached, + active_watched, + active_drop, + active_dropped, + ) + .fetch_all(&app.database) + .await + .with_context(|| { + format!( + "Failed to query videos with states: '{}'", + states_to_string(allowed_states) + ) + })?; + + let real_videos: Vec<Video> = videos + .iter() + .map(|base| -> Video { + video_from_record!(base) + }) + .collect(); + + Ok(real_videos) + } +} diff --git a/crates/yt/src/storage/db/insert/mod.rs b/crates/yt/src/storage/db/insert/mod.rs new file mode 100644 index 0000000..f1d464f --- /dev/null +++ b/crates/yt/src/storage/db/insert/mod.rs @@ -0,0 +1,73 @@ +use std::mem; + +use crate::app::App; + +use anyhow::Result; +use log::trace; +use sqlx::SqliteConnection; + +pub(crate) mod playlist; +pub(crate) mod subscription; +pub(crate) mod video; + +pub(crate) trait Committable: Sized { + async fn commit(self, txn: &mut SqliteConnection) -> Result<()>; +} + +#[derive(Debug)] +pub(crate) struct Operations<O: Committable + std::fmt::Debug> { + name: &'static str, + ops: Vec<O>, +} + +impl<O: Committable + std::fmt::Debug> Default for Operations<O> { + fn default() -> Self { + Self::new("<default impl>") + } +} + +impl<O: Committable + std::fmt::Debug> Operations<O> { + #[must_use] + pub(crate) fn new(name: &'static str) -> Self { + Self { + name, + ops: Vec::new(), + } + } + + pub(crate) async fn commit(mut self, app: &App) -> Result<()> { + let ops = mem::take(&mut self.ops); + + if ops.is_empty() { + return Ok(()); + } + + trace!("Begin commit of {}", self.name); + let mut txn = app.database.begin().await?; + + for op in ops { + trace!("Commiting operation: {op:?}"); + op.commit(&mut txn).await?; + } + + txn.commit().await?; + trace!("End commit of {}", self.name); + + Ok(()) + } + + pub(crate) fn push(&mut self, op: O) { + self.ops.push(op); + } +} + +impl<O: Committable + std::fmt::Debug> Drop for Operations<O> { + fn drop(&mut self) { + assert!( + self.ops.is_empty(), + "Trying to drop uncommitted operations (name: {}) ({:#?}). This is a bug.", + self.name, + self.ops + ); + } +} diff --git a/crates/yt/src/storage/db/insert/playlist.rs b/crates/yt/src/storage/db/insert/playlist.rs new file mode 100644 index 0000000..2613fb3 --- /dev/null +++ b/crates/yt/src/storage/db/insert/playlist.rs @@ -0,0 +1,207 @@ +use std::{cmp::Ordering, time::Duration}; + +use anyhow::{Context, Result}; +use libmpv2::Mpv; +use log::{debug, trace}; + +use crate::{ + app::App, + storage::db::{ + insert::{Operations, video::Operation}, + playlist::{Playlist, PlaylistIndex}, + video::VideoStatus, + }, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum VideoTransition { + Watched, + Picked, +} + +impl Playlist { + pub(crate) fn mark_current_done( + &mut self, + app: &App, + mpv: &Mpv, + new_state: VideoTransition, + ops: &mut Operations<Operation>, + ) -> Result<()> { + let (current_index, current_video) = self + .get_focused_mut() + .expect("This should be some at this point"); + + debug!( + "Playlist handler will mark video '{}' {:?}.", + current_video.title, new_state + ); + + match new_state { + VideoTransition::Watched => current_video.set_watched(ops), + VideoTransition::Picked => current_video.set_status(VideoStatus::Pick, ops), + } + + self.save_watch_progress(mpv, current_index, ops); + + self.videos.remove(Into::<usize>::into(current_index)); + + { + // Decide which video to mark focused now. + let index = usize::from(current_index); + let playlist_length = self.len(); + + let index = match index.cmp(&playlist_length) { + Ordering::Greater => { + unreachable!( + "The index '{index}' cannot exceed the \ + playlist length '{playlist_length}' as indices are 0 based." + ); + } + Ordering::Less => { + // The index is still valid. + // Therefore, we keep the user at this position. + index + } + Ordering::Equal => { + // The index is pointing to the end of the playlist. We could either go the second + // to last entry (i.e., one entry back) or wrap around to the start. + // We wrap around. + 0 + } + }; + + let next = self + .get_mut(PlaylistIndex::from(index)) + .expect("We checked that the index is still good"); + next.set_focused(true, ops); + + // Tell mpv about our decision. + self.resync_with_mpv(app, mpv)?; + } + + Ok(()) + } + + /// Sync the mpv playlist with this playlist. + pub(crate) fn resync_with_mpv(&self, app: &App, mpv: &Mpv) -> Result<()> { + fn get_playlist_count(mpv: &Mpv) -> Result<usize> { + mpv.get_property::<i64>("playlist/count") + .context("Failed to get mpv playlist len") + .map(|count| { + usize::try_from(count).expect("The playlist_count should always be positive") + }) + } + + if get_playlist_count(mpv)? != 0 { + // We could also use `loadlist`, but that would require use to start a unix socket or even + // write all the video paths to a file beforehand + mpv.command("playlist-clear", &[])?; + mpv.command("playlist-remove", &["current"])?; + } + + assert_eq!( + get_playlist_count(mpv)?, + 0, + "The playlist should be empty at this point." + ); + + debug!("MpvReload: Adding {} videos to playlist.", self.len()); + + self.videos + .iter() + .enumerate() + .try_for_each(|(index, video)| { + let VideoStatus::Cached { + cache_path, + is_focused, + } = &video.status + else { + unreachable!("All of the videos in a playlist are cached"); + }; + + let options = format!( + "speed={},start={}", + video + .playback_speed + .unwrap_or(app.config.select.playback_speed), + i64::try_from(video.watch_progress.as_secs()) + .expect("This should not overflow"), + ); + + mpv.command( + "loadfile", + &[ + cache_path.to_str().with_context(|| { + format!( + "Failed to parse the video cache path ('{}') as valid utf8", + cache_path.display() + ) + })?, + "append-play", + "-1", // Not used for `append-play`, but needed for the next args to take effect. + options.as_str(), + ], + )?; + + if *is_focused { + debug!("MpvReload: Setting playlist position to {index}"); + mpv.set_property("playlist-pos", index.to_string().as_str())?; + } + + Ok::<(), anyhow::Error>(()) + })?; + + Ok(()) + } + + pub(crate) fn save_current_watch_progress( + &mut self, + mpv: &Mpv, + ops: &mut Operations<Operation>, + ) { + let (index, _) = self + .get_focused_mut() + .expect("This should be some at this point"); + + self.save_watch_progress(mpv, index, ops); + } + + /// Saves the `watch_progress` of a video at the index. + pub(crate) fn save_watch_progress( + &mut self, + mpv: &Mpv, + at: PlaylistIndex, + ops: &mut Operations<Operation>, + ) { + let current_video = self + .get_mut(at) + .expect("We should never produce invalid playlist indices"); + + let watch_progress = match mpv.get_property::<i64>("time-pos") { + Ok(time) => u64::try_from(time) + .expect("This conversion should never fail as the `time-pos` property is positive"), + Err(err) => { + // We cannot hard error here, as we would open us to an race condition between mpv + // changing the current video and we saving it. + trace!( + "While trying to save the watch progress for the current video: \ + Failed to get the watchprogress of the currently playling video: \ + (This is probably expected, nevertheless showing the raw error) \ + {err}" + ); + + return; + } + }; + + let watch_progress = Duration::from_secs(watch_progress); + + debug!( + "Setting the watch progress for the current_video '{}' to {}s", + current_video.title_fmt_no_color(), + watch_progress.as_secs(), + ); + + current_video.set_watch_progress(watch_progress, ops); + } +} diff --git a/crates/yt/src/storage/db/insert/subscription.rs b/crates/yt/src/storage/db/insert/subscription.rs new file mode 100644 index 0000000..ba9a3e1 --- /dev/null +++ b/crates/yt/src/storage/db/insert/subscription.rs @@ -0,0 +1,84 @@ +use crate::storage::db::{ + insert::{Committable, Operations}, + subscription::{Subscription, Subscriptions}, +}; + +use anyhow::Result; +use sqlx::query; + +#[derive(Debug)] +pub(crate) enum Operation { + Add(Subscription), + Remove(Subscription), +} + +impl Committable for Operation { + async fn commit(self, txn: &mut sqlx::SqliteConnection) -> Result<()> { + match self { + Operation::Add(subscription) => { + let url = subscription.url.as_str(); + + query!( + " + INSERT INTO subscriptions ( + name, + url + ) VALUES (?, ?); + ", + subscription.name, + url + ) + .execute(txn) + .await?; + + println!( + "Subscribed to '{}' at '{}'", + subscription.name, subscription.url + ); + Ok(()) + } + Operation::Remove(subscription) => { + let output = query!( + " + DELETE FROM subscriptions + WHERE name = ? + ", + subscription.name, + ) + .execute(txn) + .await?; + + assert_eq!( + output.rows_affected(), + 1, + "The removed subscription query did effect more (or less) than one row. This is a bug." + ); + + println!( + "Unsubscribed from '{}' at '{}'", + subscription.name, subscription.url + ); + + Ok(()) + } + } + } +} + +impl Subscription { + pub(crate) fn add(self, ops: &mut Operations<Operation>) { + ops.push(Operation::Add(self)); + } + + pub(crate) fn remove(self, ops: &mut Operations<Operation>) { + ops.push(Operation::Remove(self)); + } +} + +impl Subscriptions { + pub(crate) fn remove(self, ops: &mut Operations<Operation>) { + for sub in self.0.into_values() { + ops.push(Operation::Remove(sub)); + } + } +} diff --git a/crates/yt/src/storage/db/insert/video/mod.rs b/crates/yt/src/storage/db/insert/video/mod.rs new file mode 100644 index 0000000..b57f043 --- /dev/null +++ b/crates/yt/src/storage/db/insert/video/mod.rs @@ -0,0 +1,599 @@ +use std::{ + path::{Path, PathBuf}, + time, +}; + +use crate::storage::db::{ + extractor_hash::ExtractorHash, + insert::{Committable, Operations}, + video::{Priority, Video, VideoStatus, VideoStatusMarker}, +}; + +use anyhow::{Context, Result}; +use chrono::Utc; +use log::debug; +use sqlx::query; +use tokio::fs; + +use super::super::video::TimeStamp; + +const fn is_focused_to_value(is_focused: bool) -> Option<i8> { + if is_focused { Some(1) } else { None } +} + +#[derive(Debug)] +pub(crate) enum Operation { + Add { + description: Option<String>, + title: String, + parent_subscription_name: Option<String>, + thumbnail_url: Option<String>, + url: String, + extractor_hash: String, + status: i64, + cache_path: Option<String>, + is_focused: Option<i8>, + duration: Option<f64>, + last_status_change: i64, + publish_date: Option<i64>, + watch_progress: i64, + }, + // TODO(@bpeetz): Could both the {`Set`,`Remove`}`DownloadPath` ops, be merged into SetStatus + // {`Cached`,`Watch`}? <2025-07-14> + SetDownloadPath { + video: ExtractorHash, + path: PathBuf, + }, + RemoveDownloadPath { + video: ExtractorHash, + }, + SetStatus { + video: ExtractorHash, + status: VideoStatus, + }, + SetPriority { + video: ExtractorHash, + priority: Priority, + }, + SetPlaybackSpeed { + video: ExtractorHash, + playback_speed: f64, + }, + SetSubtitleLangs { + video: ExtractorHash, + subtitle_langs: String, + }, + SetWatchProgress { + video: ExtractorHash, + watch_progress: time::Duration, + }, + SetIsFocused { + video: ExtractorHash, + is_focused: bool, + }, +} + +impl Committable for Operation { + #[allow(clippy::too_many_lines)] + async fn commit(self, txn: &mut sqlx::SqliteConnection) -> Result<()> { + match self { + Operation::SetDownloadPath { video, path } => { + debug!("Setting cache path from '{video}' to '{}'", path.display()); + + let path_str = path.display().to_string(); + let extractor_hash = video.hash().to_string(); + let status = VideoStatusMarker::Cached.as_db_integer(); + + query!( + r#" + UPDATE videos + SET cache_path = ?, status = ? + WHERE extractor_hash = ?; + "#, + path_str, + status, + extractor_hash + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::RemoveDownloadPath { video } => { + let extractor_hash = video.hash().to_string(); + let status = VideoStatus::Watch.as_marker().as_db_integer(); + + let old = video.get(&mut *txn).await?; + + debug!("Deleting download path of '{video}' ({}).", old.title); + + if let VideoStatus::Cached { cache_path, .. } = &old.status { + if let Ok(true) = cache_path.try_exists() { + fs::remove_file(cache_path).await?; + } + + { + let info_json_path = old.info_json_path()?.expect("Is downloaded"); + + if let Ok(true) = info_json_path.try_exists() { + fs::remove_file(info_json_path).await?; + } + } + + { + if old.subtitle_langs.is_some() { + // TODO(@bpeetz): Also clean-up the downloaded subtitle files. <2025-07-05> + } + } + } else { + unreachable!( + "A video cannot have a download path deletion \ + queued without being marked as Cached." + ); + } + + query!( + r#" + UPDATE videos + SET cache_path = NULL, status = ?, is_focused = ? + WHERE extractor_hash = ?; + "#, + status, + None::<i32>, + extractor_hash + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetStatus { video, status } => { + let extractor_hash = video.hash().to_string(); + + let old = video.get(&mut *txn).await?; + + let old_marker = old.status.as_marker(); + + let (cache_path, is_focused) = { + fn cache_path_to_string(path: &Path) -> Result<String> { + Ok(path + .to_str() + .with_context(|| { + format!( + "Failed to parse cache path ('{}') as utf8 string", + path.display() + ) + })? + .to_owned()) + } + + if let VideoStatus::Cached { + cache_path, + is_focused, + } = &status + { + ( + Some(cache_path_to_string(cache_path)?), + is_focused_to_value(*is_focused), + ) + } else { + (None, None) + } + }; + + let new_status = status.as_marker(); + + assert_ne!( + old_marker, new_status, + "We should have never generated this operation" + ); + + let now = Utc::now().timestamp(); + + debug!( + "Changing status of video ('{}' {extractor_hash}) \ + from {old_marker:#?} to {new_status:#?}.", + old.title + ); + + let new_status = new_status.as_db_integer(); + query!( + r#" + UPDATE videos + SET status = ?, last_status_change = ?, cache_path = ?, is_focused = ? + WHERE extractor_hash = ?; + "#, + new_status, + now, + cache_path, + is_focused, + extractor_hash, + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetPriority { video, priority } => { + let extractor_hash = video.hash().to_string(); + + let new_priority = priority.as_db_integer(); + query!( + r#" + UPDATE videos + SET priority = ? + WHERE extractor_hash = ?; + "#, + new_priority, + extractor_hash + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetWatchProgress { + video, + watch_progress, + } => { + let video_extractor_hash = video.hash().to_string(); + let watch_progress = i64::try_from(watch_progress.as_secs()) + .expect("This should never exceed its bounds"); + + query!( + r#" + UPDATE videos + SET watch_progress = ? + WHERE extractor_hash = ?; + "#, + watch_progress, + video_extractor_hash, + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetPlaybackSpeed { + video, + playback_speed, + } => { + let extractor_hash = video.hash().to_string(); + + query!( + r#" + UPDATE videos + SET playback_speed = ? + WHERE extractor_hash = ?; + "#, + playback_speed, + extractor_hash + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetSubtitleLangs { + video, + subtitle_langs, + } => { + let extractor_hash = video.hash().to_string(); + + query!( + r#" + UPDATE videos + SET subtitle_langs = ? + WHERE extractor_hash = ?; + "#, + subtitle_langs, + extractor_hash + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetIsFocused { video, is_focused } => { + debug!("Set is_focused of video: '{video}' to {is_focused}"); + let new_hash = video.hash().to_string(); + let new_is_focused = is_focused_to_value(is_focused); + + query!( + r#" + UPDATE videos + SET is_focused = ? + WHERE extractor_hash = ?; + "#, + new_is_focused, + new_hash, + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::Add { + parent_subscription_name, + thumbnail_url, + url, + extractor_hash, + status, + cache_path, + is_focused, + duration, + last_status_change, + publish_date, + watch_progress, + description, + title, + } => { + query!( + r#" + INSERT INTO videos ( + description, + duration, + extractor_hash, + is_focused, + last_status_change, + parent_subscription_name, + publish_date, + status, + thumbnail_url, + title, + url, + watch_progress, + cache_path + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + "#, + description, + duration, + extractor_hash, + is_focused, + last_status_change, + parent_subscription_name, + publish_date, + status, + thumbnail_url, + title, + url, + watch_progress, + cache_path, + ) + .execute(txn) + .await?; + + Ok(()) + } + } + } +} + +impl Video { + /// Add this in-memory video to the db. + pub(crate) fn add(self, ops: &mut Operations<Operation>) -> Result<Self> { + let description = self.description.clone(); + let title = self.title.clone(); + let parent_subscription_name = self.parent_subscription_name.clone(); + + let thumbnail_url = self.thumbnail_url.as_ref().map(ToString::to_string); + + let url = self.url.to_string(); + let extractor_hash = self.extractor_hash.hash().to_string(); + + let status = self.status.as_marker().as_db_integer(); + let (cache_path, is_focused) = if let VideoStatus::Cached { + cache_path, + is_focused, + } = &self.status + { + ( + Some( + cache_path + .to_str() + .with_context(|| { + format!( + "Failed to prase cache path '{}' as utf-8 string", + cache_path.display() + ) + })? + .to_string(), + ), + is_focused_to_value(*is_focused), + ) + } else { + (None, None) + }; + + let duration: Option<f64> = self.duration.as_secs_f64(); + let last_status_change: i64 = self.last_status_change.as_secs(); + let publish_date: Option<i64> = self.publish_date.map(TimeStamp::as_secs); + let watch_progress: i64 = + i64::try_from(self.watch_progress.as_secs()).expect("This should never exceed a u32"); + + ops.push(Operation::Add { + description, + title, + parent_subscription_name, + thumbnail_url, + url, + extractor_hash, + status, + cache_path, + is_focused, + duration, + last_status_change, + publish_date, + watch_progress, + }); + + Ok(self) + } + + /// Set the download path of a video. + /// + /// # Note + /// This will also set the status to `Cached`. + pub(crate) fn set_download_path(&mut self, path: &Path, ops: &mut Operations<Operation>) { + if let VideoStatus::Cached { cache_path, .. } = &mut self.status { + if cache_path != path { + // Update the in-memory video. + path.clone_into(cache_path); + + ops.push(Operation::SetDownloadPath { + video: self.extractor_hash, + path: path.to_owned(), + }); + } + } else { + self.status = VideoStatus::Cached { + cache_path: path.to_owned(), + is_focused: false, + }; + + ops.push(Operation::SetDownloadPath { + video: self.extractor_hash, + path: path.to_owned(), + }); + } + } + + /// Remove the download path of a video. + /// + /// # Note + /// This will also set the status to `Watch`. + /// + /// # Panics + /// If the status is not `Cached`. + pub(crate) fn remove_download_path(&mut self, ops: &mut Operations<Operation>) { + if let VideoStatus::Cached { .. } = &mut self.status { + self.status = VideoStatus::Watch; + ops.push(Operation::RemoveDownloadPath { + video: self.extractor_hash, + }); + } else { + unreachable!("Can only remove the path from a `Cached` video"); + } + } + + /// Update the `is_focused` flag of this video. + /// + /// # Note + /// It will only actually add operations, if the `is_focused` flag is different. + /// + /// # Panics + /// If the status is not `Cached`. + pub(crate) fn set_focused(&mut self, new_is_focused: bool, ops: &mut Operations<Operation>) { + if let VideoStatus::Cached { is_focused, .. } = &mut self.status { + if *is_focused != new_is_focused { + *is_focused = new_is_focused; + + ops.push(Operation::SetIsFocused { + video: self.extractor_hash, + is_focused: new_is_focused, + }); + } + } else { + unreachable!("Can only change `is_focused` on a Cached video."); + } + } + + /// Set the status of this video. + /// + /// # Note + /// This will not actually add any operations, if the new status equals the old one. + pub(crate) fn set_status(&mut self, status: VideoStatus, ops: &mut Operations<Operation>) { + if self.status != status { + status.clone_into(&mut self.status); + + ops.push(Operation::SetStatus { + video: self.extractor_hash, + status, + }); + } + } + + /// Set the priority of this video. + /// + /// # Note + /// This will not actually add any operations, if the new priority equals the old one. + pub(crate) fn set_priority(&mut self, priority: Priority, ops: &mut Operations<Operation>) { + if self.priority != priority { + self.priority = priority; + + ops.push(Operation::SetPriority { + video: self.extractor_hash, + priority, + }); + } + } + + /// Set the watch progress. + /// + /// # Note + /// This will not actually add any operations, + /// if the new watch progress equals the old one. + pub(crate) fn set_watch_progress( + &mut self, + watch_progress: time::Duration, + ops: &mut Operations<Operation>, + ) { + if self.watch_progress != watch_progress { + self.watch_progress = watch_progress; + + ops.push(Operation::SetWatchProgress { + video: self.extractor_hash, + watch_progress, + }); + } + } + + /// Set the playback speed of this video. + /// + /// # Note + /// This will not actually add any operations, if the new speed equals the old one. + pub(crate) fn set_playback_speed( + &mut self, + playback_speed: f64, + ops: &mut Operations<Operation>, + ) { + if self.playback_speed != Some(playback_speed) { + self.playback_speed = Some(playback_speed); + + ops.push(Operation::SetPlaybackSpeed { + video: self.extractor_hash, + playback_speed, + }); + } + } + + /// Set the subtitle langs of this video. + /// + /// # Note + /// This will not actually add any operations, if the new langs equal the old one. + pub(crate) fn set_subtitle_langs( + &mut self, + subtitle_langs: String, + ops: &mut Operations<Operation>, + ) { + if self.subtitle_langs.as_ref() != Some(&subtitle_langs) { + self.subtitle_langs = Some(subtitle_langs.clone()); + + ops.push(Operation::SetSubtitleLangs { + video: self.extractor_hash, + subtitle_langs, + }); + } + } + + /// Mark this video watched. + /// This will both set the status to `Watched` and the `cache_path` to Null. + /// + /// # Panics + /// Only if assertions fail. + pub(crate) fn set_watched(&mut self, ops: &mut Operations<Operation>) { + self.remove_download_path(ops); + self.set_status(VideoStatus::Watched, ops); + } +} diff --git a/crates/yt/src/storage/db/mod.rs b/crates/yt/src/storage/db/mod.rs new file mode 100644 index 0000000..c0e16b0 --- /dev/null +++ b/crates/yt/src/storage/db/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod get; +pub(crate) mod insert; + +pub(crate) mod extractor_hash; +pub(crate) mod subscription; +pub(crate) mod video; +pub(crate) mod playlist; diff --git a/crates/yt/src/storage/db/playlist/mod.rs b/crates/yt/src/storage/db/playlist/mod.rs new file mode 100644 index 0000000..6fca98a --- /dev/null +++ b/crates/yt/src/storage/db/playlist/mod.rs @@ -0,0 +1,49 @@ +use std::ops::Add; + +use crate::storage::db::video::Video; + +/// Zero-based index into the internal playlist. +#[derive(Debug, Clone, Copy)] +pub(crate) struct PlaylistIndex(usize); + +impl From<PlaylistIndex> for usize { + fn from(value: PlaylistIndex) -> Self { + value.0 + } +} + +impl From<usize> for PlaylistIndex { + fn from(value: usize) -> Self { + Self(value) + } +} + +impl Add<usize> for PlaylistIndex { + type Output = Self; + + fn add(self, rhs: usize) -> Self::Output { + Self(self.0 + rhs) + } +} + +impl Add for PlaylistIndex { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +/// A representation of the internal Playlist +#[derive(Debug)] +pub(crate) struct Playlist { + pub(crate) videos: Vec<Video>, +} + +impl Playlist { + /// Returns the number of videos in the playlist + #[must_use] + pub(crate) fn len(&self) -> usize { + self.videos.len() + } +} diff --git a/crates/yt/src/storage/db/subscription.rs b/crates/yt/src/storage/db/subscription.rs new file mode 100644 index 0000000..07e5eec --- /dev/null +++ b/crates/yt/src/storage/db/subscription.rs @@ -0,0 +1,41 @@ +use std::collections::HashMap; + +use anyhow::Result; +use log::debug; +use url::Url; +use yt_dlp::{json_cast, options::YoutubeDLOptions}; + +#[derive(Clone, Debug)] +pub(crate) struct Subscription { + /// The human readable name of this subscription + pub(crate) name: String, + + /// The URL this subscription subscribes to + pub(crate) url: Url, +} + +impl Subscription { + #[must_use] + pub(crate) fn new(name: String, url: Url) -> Self { + Self { name, url } + } +} + +#[derive(Default, Debug)] +pub(crate) struct Subscriptions(pub(crate) HashMap<String, Subscription>); + +/// Check whether an URL could be used as a subscription URL +pub(crate) async fn check_url(url: Url) -> Result<bool> { + let yt_dlp = YoutubeDLOptions::new() + .set("playliststart", 1) + .set("playlistend", 10) + .set("noplaylist", false) + .set("extract_flat", "in_playlist") + .build()?; + + let info = yt_dlp.extract_info(&url, false, false)?; + + debug!("{info:#?}"); + + Ok(info.get("_type").map(|v| json_cast!(v, as_str)) == Some("playlist")) +} diff --git a/crates/yt/src/storage/video_database/mod.rs b/crates/yt/src/storage/db/video.rs index 74d09f0..f1b8bb9 100644 --- a/crates/yt/src/storage/video_database/mod.rs +++ b/crates/yt/src/storage/db/video.rs @@ -1,63 +1,104 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{ - fmt::{Display, Write}, - path::PathBuf, - time::Duration, -}; +use std::{fmt::Display, path::PathBuf, time::Duration}; use chrono::{DateTime, Utc}; use url::Url; use crate::{ - app::App, select::selection_file::duration::MaybeDuration, - storage::video_database::extractor_hash::ExtractorHash, + select::selection_file::duration::MaybeDuration, storage::db::extractor_hash::ExtractorHash, }; -pub mod downloader; -pub mod extractor_hash; -pub mod get; -pub mod notify; -pub mod set; +macro_rules! video_from_record { + ($record:expr) => { + $crate::storage::db::video::Video { + description: $record.description.clone(), + duration: $crate::select::selection_file::duration::MaybeDuration::from_maybe_secs_f64( + $record.duration, + ), + extractor_hash: $crate::storage::db::extractor_hash::ExtractorHash::from_hash( + $record + .extractor_hash + .parse() + .expect("The db hash should be a valid blake3 hash"), + ), + last_status_change: $crate::storage::db::video::TimeStamp::from_secs( + $record.last_status_change, + ), + parent_subscription_name: $record.parent_subscription_name.clone(), + publish_date: $record + .publish_date + .map(|pd| $crate::storage::db::video::TimeStamp::from_secs(pd)), + status: { + let marker = + $crate::storage::db::video::VideoStatusMarker::from_db_integer($record.status); + let optional = if let Some(cache_path) = &$record.cache_path { + Some(( + std::path::PathBuf::from(cache_path), + if $record.is_focused == Some(1) { + true + } else { + false + }, + )) + } else { + None + }; + $crate::storage::db::video::VideoStatus::from_marker(marker, optional) + }, + thumbnail_url: if let Some(url) = &$record.thumbnail_url { + Some(url::Url::parse(url).expect("Parsing this as url should always work")) + } else { + None + }, + title: $record.title.clone(), + url: url::Url::parse(&$record.url).expect("Parsing this as url should always work"), + priority: $crate::storage::db::video::Priority::from($record.priority), + watch_progress: std::time::Duration::from_secs( + u64::try_from($record.watch_progress).expect("The record is positive i64"), + ), + subtitle_langs: $record.subtitle_langs.clone(), + playback_speed: $record.playback_speed, + } + }; +} +pub(crate) use video_from_record; #[derive(Debug, Clone)] -pub struct Video { - pub description: Option<String>, - pub duration: MaybeDuration, - pub extractor_hash: ExtractorHash, - pub last_status_change: TimeStamp, +pub(crate) struct Video { + pub(crate) description: Option<String>, + pub(crate) duration: MaybeDuration, + pub(crate) extractor_hash: ExtractorHash, + pub(crate) 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: Priority, - pub publish_date: Option<TimeStamp>, - pub status: VideoStatus, - pub thumbnail_url: Option<Url>, - pub title: String, - pub url: Url, + pub(crate) parent_subscription_name: Option<String>, + pub(crate) priority: Priority, + pub(crate) publish_date: Option<TimeStamp>, + pub(crate) status: VideoStatus, + pub(crate) thumbnail_url: Option<Url>, + pub(crate) title: String, + pub(crate) url: Url, /// The seconds the user has already watched the video - pub watch_progress: Duration, + pub(crate) watch_progress: Duration, + + /// Which subtitles to include, when downloading this video. + /// In the form of `lang1,lang2,lang3` (e.g. `en,de,sv`) + pub(crate) subtitle_langs: Option<String>, + + /// The playback speed to use, when watching this video. + /// Value is in percent, so 1 is 100%, 2.7 is 270%, and so on. + pub(crate) playback_speed: Option<f64>, } /// The priority of a [`Video`]. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct Priority { +pub(crate) 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 { + pub(crate) fn as_db_integer(self) -> i64 { self.value } } @@ -74,25 +115,25 @@ impl Display for Priority { /// An UNIX time stamp. #[derive(Debug, Default, Clone, Copy)] -pub struct TimeStamp { +pub(crate) struct TimeStamp { value: i64, } impl TimeStamp { /// Return the seconds since the UNIX epoch for this [`TimeStamp`]. #[must_use] - pub fn as_secs(&self) -> i64 { + pub(crate) fn as_secs(self) -> i64 { self.value } /// Construct a [`TimeStamp`] from a count of seconds since the UNIX epoch. #[must_use] - pub fn from_secs(value: i64) -> Self { + pub(crate) fn from_secs(value: i64) -> Self { Self { value } } /// Construct a [`TimeStamp`] from the current time. #[must_use] - pub fn from_now() -> Self { + pub(crate) fn from_now() -> Self { Self { value: Utc::now().timestamp(), } @@ -107,49 +148,6 @@ impl Display for TimeStamp { } } -#[derive(Debug)] -pub struct VideoOptions { - pub yt_dlp: YtDlpOptions, - pub mpv: MpvOptions, -} -impl VideoOptions { - pub(crate) fn new(subtitle_langs: String, playback_speed: f64) -> Self { - let yt_dlp = YtDlpOptions { subtitle_langs }; - let mpv = MpvOptions { playback_speed }; - Self { yt_dlp, mpv } - } - - /// This will write out the options that are different from the defaults. - /// Beware, that this does not set the priority. - #[must_use] - pub fn to_cli_flags(self, app: &App) -> String { - let mut f = String::new(); - - if (self.mpv.playback_speed - app.config.select.playback_speed).abs() > f64::EPSILON { - write!(f, " --speed '{}'", self.mpv.playback_speed).expect("Works"); - } - if self.yt_dlp.subtitle_langs != app.config.select.subtitle_langs { - write!(f, " --subtitle-langs '{}'", self.yt_dlp.subtitle_langs).expect("Works"); - } - - f.trim().to_owned() - } -} - -#[derive(Debug, Clone, Copy)] -/// Additionally settings passed to mpv on watch -pub struct MpvOptions { - /// The playback speed. (1 is 100%, 2.7 is 270%, and so on) - pub playback_speed: f64, -} - -#[derive(Debug)] -/// Additionally configuration options, passed to yt-dlp on download -pub struct YtDlpOptions { - /// In the form of `lang1,lang2,lang3` (e.g. `en,de,sv`) - pub subtitle_langs: String, -} - /// # Video Lifetime (words in <brackets> are commands): /// <Pick> /// / \ @@ -159,7 +157,7 @@ pub struct YtDlpOptions { /// | /// Watched // yt watch #[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] -pub enum VideoStatus { +pub(crate) enum VideoStatus { #[default] Pick, @@ -186,7 +184,10 @@ impl VideoStatus { /// # Panics /// Only if internal expectations fail. #[must_use] - pub fn from_marker(marker: VideoStatusMarker, optional: Option<(PathBuf, bool)>) -> Self { + pub(crate) fn from_marker( + marker: VideoStatusMarker, + optional: Option<(PathBuf, bool)>, + ) -> Self { match marker { VideoStatusMarker::Pick => Self::Pick, VideoStatusMarker::Watch => Self::Watch, @@ -204,26 +205,9 @@ impl VideoStatus { } } - /// 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 { + pub(crate) fn as_marker(&self) -> VideoStatusMarker { match self { VideoStatus::Pick => VideoStatusMarker::Pick, VideoStatus::Watch => VideoStatusMarker::Watch, @@ -237,7 +221,7 @@ impl VideoStatus { /// Unit only variant of [`VideoStatus`] #[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -pub enum VideoStatusMarker { +pub(crate) enum VideoStatusMarker { #[default] Pick, @@ -255,7 +239,7 @@ pub enum VideoStatusMarker { } impl VideoStatusMarker { - pub const ALL: &'static [Self; 6] = &[ + pub(crate) const ALL: &'static [Self; 6] = &[ Self::Pick, // Self::Watch, @@ -267,7 +251,7 @@ impl VideoStatusMarker { ]; #[must_use] - pub fn as_command(&self) -> &str { + pub(crate) fn as_command(&self) -> &str { // NOTE: Keep the serialize able variants synced with the main `select` function <2024-06-14> // Also try to ensure, that the strings have the same length match self { @@ -281,7 +265,7 @@ impl VideoStatusMarker { } #[must_use] - pub fn as_db_integer(&self) -> i64 { + pub(crate) fn as_db_integer(self) -> i64 { // These numbers should not change their mapping! // Oh, and keep them in sync with the SQLite check constraint. match self { @@ -296,7 +280,7 @@ impl VideoStatusMarker { } } #[must_use] - pub fn from_db_integer(num: i64) -> Self { + pub(crate) fn from_db_integer(num: i64) -> Self { match num { 0 => Self::Pick, @@ -314,7 +298,7 @@ impl VideoStatusMarker { } #[must_use] - pub fn as_str(&self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { Self::Pick => "Pick", diff --git a/crates/yt/src/storage/video_database/notify.rs b/crates/yt/src/storage/notify.rs index b55c00a..e0ee4e9 100644 --- a/crates/yt/src/storage/video_database/notify.rs +++ b/crates/yt/src/storage/notify.rs @@ -26,7 +26,7 @@ use tokio::task; /// This functions registers a watcher for the database and only returns once a write was /// registered for the database. -pub async fn wait_for_db_write(app: &App) -> Result<()> { +pub(crate) async fn wait_for_db_write(app: &App) -> Result<()> { let db_path: PathBuf = app.config.paths.database_path.clone(); task::spawn_blocking(move || wait_for_db_write_sync(&db_path)).await? } @@ -53,7 +53,7 @@ fn wait_for_db_write_sync(db_path: &Path) -> Result<()> { } /// This functions registers a watcher for the cache path and returns once a file was removed -pub async fn wait_for_cache_reduction(app: &App) -> Result<()> { +pub(crate) async fn wait_for_cache_reduction(app: &App) -> Result<()> { let download_directory: PathBuf = app.config.paths.download_dir.clone(); task::spawn_blocking(move || wait_for_cache_reduction_sync(&download_directory)).await? } diff --git a/crates/yt/src/storage/subscriptions.rs b/crates/yt/src/storage/subscriptions.rs deleted file mode 100644 index 0e8ae85..0000000 --- a/crates/yt/src/storage/subscriptions.rs +++ /dev/null @@ -1,141 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -//! Handle subscriptions - -use std::collections::HashMap; - -use anyhow::Result; -use log::debug; -use sqlx::query; -use url::Url; -use yt_dlp::{json_cast, options::YoutubeDLOptions}; - -use crate::{app::App, unreachable::Unreachable}; - -#[derive(Clone, Debug)] -pub struct Subscription { - /// The human readable name of this subscription - pub name: String, - - /// The URL this subscription subscribes to - pub url: Url, -} - -impl Subscription { - #[must_use] - pub fn new(name: String, url: Url) -> Self { - Self { name, url } - } -} - -/// Check whether an URL could be used as a subscription URL -pub async fn check_url(url: Url) -> Result<bool> { - let yt_dlp = YoutubeDLOptions::new() - .set("playliststart", 1) - .set("playlistend", 10) - .set("noplaylist", false) - .set("extract_flat", "in_playlist") - .build()?; - - let info = yt_dlp.extract_info(&url, false, false)?; - - debug!("{info:#?}"); - - Ok(info.get("_type").map(|v| json_cast!(v, as_str)) == Some("playlist")) -} - -#[derive(Default, Debug)] -pub struct Subscriptions(pub(crate) HashMap<String, Subscription>); - -/// Remove all subscriptions -pub async fn remove_all(app: &App) -> Result<()> { - query!( - " - DELETE FROM subscriptions; - ", - ) - .execute(&app.database) - .await?; - - Ok(()) -} - -/// Get a list of subscriptions -pub async fn get(app: &App) -> Result<Subscriptions> { - let raw_subs = query!( - " - SELECT * - FROM subscriptions; - " - ) - .fetch_all(&app.database) - .await?; - - let subscriptions: HashMap<String, Subscription> = raw_subs - .into_iter() - .map(|sub| { - ( - sub.name.clone(), - Subscription::new( - sub.name, - Url::parse(&sub.url).unreachable("It was an URL, when we inserted it."), - ), - ) - }) - .collect(); - - Ok(Subscriptions(subscriptions)) -} - -pub async fn add_subscription(app: &App, sub: &Subscription) -> Result<()> { - let url = sub.url.to_string(); - - query!( - " - INSERT INTO subscriptions ( - name, - url - ) VALUES (?, ?); - ", - sub.name, - url - ) - .execute(&app.database) - .await?; - - println!("Subscribed to '{}' at '{}'", sub.name, sub.url); - Ok(()) -} - -/// # Panics -/// Only if assertions fail -pub async fn remove_subscription(app: &App, sub: &Subscription) -> Result<()> { - let output = query!( - " - DELETE FROM subscriptions - WHERE name = ? - ", - sub.name, - ) - .execute(&app.database) - .await?; - - assert_eq!( - output.rows_affected(), - 1, - "The remove subscriptino query did effect more (or less) than one row. This is a bug." - ); - - println!("Unsubscribed from '{}' at '{}'", sub.name, sub.url); - - Ok(()) -} diff --git a/crates/yt/src/storage/video_database/downloader.rs b/crates/yt/src/storage/video_database/downloader.rs deleted file mode 100644 index a95081e..0000000 --- a/crates/yt/src/storage/video_database/downloader.rs +++ /dev/null @@ -1,130 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::path::{Path, PathBuf}; - -use anyhow::Result; -use log::debug; -use sqlx::query; - -use crate::{ - app::App, - storage::video_database::{VideoStatus, VideoStatusMarker}, - unreachable::Unreachable, - video_from_record, -}; - -use super::{ExtractorHash, Video}; - -/// Returns to next video which should be downloaded. This respects the priority assigned by select. -/// It does not return videos, which are already cached. -/// -/// # Panics -/// Only if assertions fail. -pub async fn get_next_uncached_video(app: &App) -> Result<Option<Video>> { - let status = VideoStatus::Watch.as_marker().as_db_integer(); - - // NOTE: The ORDER BY statement should be the same as the one in [`get::videos`].<2024-08-22> - let result = query!( - r#" - SELECT * - FROM videos - WHERE status = ? AND cache_path IS NULL - ORDER BY priority DESC, publish_date DESC - LIMIT 1; - "#, - status - ) - .fetch_one(&app.database) - .await; - - if let Err(sqlx::Error::RowNotFound) = result { - Ok(None) - } else { - let base = result?; - - Ok(Some(video_from_record! {base})) - } -} - -/// Update the cached path of a video. Will be set to NULL if the path is None -/// This will also set the status to `Cached` when path is Some, otherwise it set's the status to -/// `Watch`. -pub async fn set_video_cache_path( - app: &App, - video: &ExtractorHash, - path: Option<&Path>, -) -> Result<()> { - if let Some(path) = path { - debug!( - "Setting cache path from '{}' to '{}'", - video.into_short_hash(app).await?, - path.display() - ); - - let path_str = path.display().to_string(); - let extractor_hash = video.hash().to_string(); - let status = VideoStatusMarker::Cached.as_db_integer(); - - query!( - r#" - UPDATE videos - SET cache_path = ?, status = ? - WHERE extractor_hash = ?; - "#, - path_str, - status, - extractor_hash - ) - .execute(&app.database) - .await?; - - Ok(()) - } else { - debug!( - "Setting cache path from '{}' to NULL", - video.into_short_hash(app).await?, - ); - - let extractor_hash = video.hash().to_string(); - let status = VideoStatus::Watch.as_marker().as_db_integer(); - - query!( - r#" - UPDATE videos - SET cache_path = NULL, status = ? - WHERE extractor_hash = ?; - "#, - status, - extractor_hash - ) - .execute(&app.database) - .await?; - - Ok(()) - } -} - -/// Returns the number of cached videos -pub async fn get_allocated_cache(app: &App) -> Result<u32> { - let count = query!( - r#" - SELECT COUNT(cache_path) as count - FROM videos - WHERE cache_path IS NOT NULL; -"#, - ) - .fetch_one(&app.database) - .await?; - - Ok(u32::try_from(count.count) - .unreachable("The value should be strictly positive (and bolow `u32::Max`)")) -} diff --git a/crates/yt/src/storage/video_database/get/mod.rs b/crates/yt/src/storage/video_database/get/mod.rs deleted file mode 100644 index e76131e..0000000 --- a/crates/yt/src/storage/video_database/get/mod.rs +++ /dev/null @@ -1,307 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -//! 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, trace}; -use sqlx::query; -use yt_dlp::info_json::InfoJson; - -use crate::{ - app::App, - storage::{ - subscriptions::Subscription, - video_database::{Video, extractor_hash::ExtractorHash}, - }, - unreachable::Unreachable, -}; - -use super::{MpvOptions, VideoOptions, VideoStatus, VideoStatusMarker, YtDlpOptions}; - -mod playlist; -pub use playlist::*; - -#[macro_export] -macro_rules! video_from_record { - ($record:expr) => { - Video { - description: $record.description.clone(), - duration: $crate::storage::video_database::MaybeDuration::from_maybe_secs_f64( - $record.duration, - ), - extractor_hash: - $crate::storage::video_database::extractor_hash::ExtractorHash::from_hash( - $record - .extractor_hash - .parse() - .expect("The db hash should be a valid blake3 hash"), - ), - last_status_change: $crate::storage::video_database::TimeStamp::from_secs( - $record.last_status_change, - ), - parent_subscription_name: $record.parent_subscription_name.clone(), - publish_date: $record - .publish_date - .map(|pd| $crate::storage::video_database::TimeStamp::from_secs(pd)), - status: { - let marker = $crate::storage::video_database::VideoStatusMarker::from_db_integer( - $record.status, - ); - - let optional = if let Some(cache_path) = &$record.cache_path { - Some(( - PathBuf::from(cache_path), - if $record.is_focused == Some(1) { - true - } else { - false - }, - )) - } else { - None - }; - - $crate::storage::video_database::VideoStatus::from_marker(marker, optional) - }, - thumbnail_url: if let Some(url) = &$record.thumbnail_url { - Some(url::Url::parse(&url).expect("Parsing this as url should always work")) - } else { - None - }, - title: $record.title.clone(), - url: url::Url::parse(&$record.url).expect("Parsing this as url should always work"), - priority: $crate::storage::video_database::Priority::from($record.priority), - - watch_progress: std::time::Duration::from_secs( - u64::try_from($record.watch_progress).expect("The record is positive i64"), - ), - } - }; -} - -/// Returns the videos that are in the `allowed_states`. -/// -/// # Panics -/// Only, if assertions fail. -pub async fn videos(app: &App, allowed_states: &[VideoStatusMarker]) -> Result<Vec<Video>> { - fn test(all_states: &[VideoStatusMarker], check: VideoStatusMarker) -> Option<i64> { - if all_states.contains(&check) { - trace!("State '{check:?}' marked as active"); - Some(check.as_db_integer()) - } else { - trace!("State '{check:?}' marked as inactive"); - None - } - } - fn states_to_string(allowed_states: &[VideoStatusMarker]) -> String { - let mut states = allowed_states - .iter() - .fold(String::from("&["), |mut acc, state| { - acc.push_str(state.as_str()); - acc.push_str(", "); - acc - }); - states = states.trim().to_owned(); - states = states.trim_end_matches(',').to_owned(); - states.push(']'); - states - } - - debug!( - "Fetching videos in the states: '{}'", - states_to_string(allowed_states) - ); - let active_pick: Option<i64> = test(allowed_states, VideoStatusMarker::Pick); - let active_watch: Option<i64> = test(allowed_states, VideoStatusMarker::Watch); - let active_cached: Option<i64> = test(allowed_states, VideoStatusMarker::Cached); - let active_watched: Option<i64> = test(allowed_states, VideoStatusMarker::Watched); - let active_drop: Option<i64> = test(allowed_states, VideoStatusMarker::Drop); - let active_dropped: Option<i64> = test(allowed_states, VideoStatusMarker::Dropped); - - let videos = query!( - r" - SELECT * - FROM videos - WHERE status IN (?,?,?,?,?,?) - ORDER BY priority DESC, publish_date DESC; - ", - active_pick, - active_watch, - active_cached, - active_watched, - active_drop, - active_dropped, - ) - .fetch_all(&app.database) - .await - .with_context(|| { - format!( - "Failed to query videos with states: '{}'", - states_to_string(allowed_states) - ) - })?; - - let real_videos: Vec<Video> = videos - .iter() - .map(|base| -> Video { - video_from_record! {base} - }) - .collect(); - - Ok(real_videos) -} - -pub fn video_info_json(video: &Video) -> Result<Option<InfoJson>> { - if let VideoStatus::Cached { mut cache_path, .. } = video.status.clone() { - if !cache_path.set_extension("info.json") { - bail!( - "Failed to change path extension to 'info.json': {}", - cache_path.display() - ); - } - let info_json_string = File::open(cache_path)?; - let info_json: InfoJson = serde_json::from_reader(&info_json_string)?; - - Ok(Some(info_json)) - } else { - Ok(None) - } -} - -pub async fn 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}) -} - -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 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/crates/yt/src/storage/video_database/get/playlist/iterator.rs b/crates/yt/src/storage/video_database/get/playlist/iterator.rs deleted file mode 100644 index 4c45bf7..0000000 --- a/crates/yt/src/storage/video_database/get/playlist/iterator.rs +++ /dev/null @@ -1,101 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{ - collections::VecDeque, - path::{Path, PathBuf}, -}; - -use crate::storage::video_database::{Video, VideoStatus}; - -use super::Playlist; - -/// Turn a cached video into it's `cache_path` -fn to_cache_video(video: Video) -> PathBuf { - if let VideoStatus::Cached { cache_path, .. } = video.status { - cache_path - } else { - unreachable!("ALl of these videos should be cached.") - } -} - -#[derive(Debug)] -pub struct PlaylistIterator { - paths: VecDeque<PathBuf>, -} - -impl Iterator for PlaylistIterator { - type Item = <Playlist as IntoIterator>::Item; - - fn next(&mut self) -> Option<Self::Item> { - self.paths.pop_front() - } -} - -impl DoubleEndedIterator for PlaylistIterator { - fn next_back(&mut self) -> Option<Self::Item> { - self.paths.pop_back() - } -} - -impl IntoIterator for Playlist { - type Item = PathBuf; - - type IntoIter = PlaylistIterator; - - fn into_iter(self) -> Self::IntoIter { - let paths = self.videos.into_iter().map(to_cache_video).collect(); - Self::IntoIter { paths } - } -} - -#[derive(Debug)] -pub struct PlaylistIteratorBorrowed<'a> { - paths: Vec<&'a Path>, - index: usize, -} - -impl<'a> Iterator for PlaylistIteratorBorrowed<'a> { - type Item = <&'a Playlist as IntoIterator>::Item; - - fn next(&mut self) -> Option<Self::Item> { - let output = self.paths.get(self.index); - self.index += 1; - output.map(|v| &**v) - } -} - -impl<'a> Playlist { - #[must_use] - pub fn iter(&'a self) -> PlaylistIteratorBorrowed<'a> { - <&Self as IntoIterator>::into_iter(self) - } -} - -impl<'a> IntoIterator for &'a Playlist { - type Item = &'a Path; - - type IntoIter = PlaylistIteratorBorrowed<'a>; - - fn into_iter(self) -> Self::IntoIter { - let paths = self - .videos - .iter() - .map(|vid| { - if let VideoStatus::Cached { cache_path, .. } = &vid.status { - cache_path.as_path() - } else { - unreachable!("ALl of these videos should be cached.") - } - }) - .collect(); - Self::IntoIter { paths, index: 0 } - } -} diff --git a/crates/yt/src/storage/video_database/get/playlist/mod.rs b/crates/yt/src/storage/video_database/get/playlist/mod.rs deleted file mode 100644 index f6aadbf..0000000 --- a/crates/yt/src/storage/video_database/get/playlist/mod.rs +++ /dev/null @@ -1,167 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -//! This file contains the getters for the internal playlist - -use std::{ops::Add, path::PathBuf}; - -use crate::{ - app::App, - storage::video_database::{Video, VideoStatusMarker, extractor_hash::ExtractorHash}, - video_from_record, -}; - -use anyhow::Result; -use sqlx::query; - -pub mod iterator; - -/// Zero-based index into the internal playlist. -#[derive(Debug, Clone, Copy)] -pub struct PlaylistIndex(usize); - -impl From<PlaylistIndex> for usize { - fn from(value: PlaylistIndex) -> Self { - value.0 - } -} - -impl From<usize> for PlaylistIndex { - fn from(value: usize) -> Self { - Self(value) - } -} - -impl Add<usize> for PlaylistIndex { - type Output = Self; - - fn add(self, rhs: usize) -> Self::Output { - Self(self.0 + rhs) - } -} - -impl Add for PlaylistIndex { - type Output = Self; - - fn add(self, rhs: Self) -> Self::Output { - Self(self.0 + rhs.0) - } -} - -/// A representation of the internal Playlist -#[derive(Debug)] -pub struct Playlist { - videos: Vec<Video>, -} - -impl Playlist { - /// Return the videos of this playlist. - #[must_use] - pub fn as_videos(&self) -> &[Video] { - &self.videos - } - - /// Turn this playlist to it's videos - #[must_use] - pub fn to_videos(self) -> Vec<Video> { - self.videos - } - - /// Find the index of the video specified by the `video_hash`. - /// - /// # Panics - /// Only if internal assertions fail. - #[must_use] - pub fn find_index(&self, video_hash: &ExtractorHash) -> Option<PlaylistIndex> { - if let Some((index, value)) = self - .videos - .iter() - .enumerate() - .find(|(_, other)| other.extractor_hash == *video_hash) - { - assert_eq!(value.extractor_hash, *video_hash); - Some(PlaylistIndex(index)) - } else { - None - } - } - - /// Select a video based on it's index - #[must_use] - pub fn get(&self, index: PlaylistIndex) -> Option<&Video> { - self.videos.get(index.0) - } - - /// Returns the number of videos in the playlist - #[must_use] - pub fn len(&self) -> usize { - self.videos.len() - } - /// Is the playlist empty? - #[must_use] - pub fn is_empty(&self) -> bool { - self.videos.is_empty() - } -} - -/// Return the current playlist index. -/// -/// This effectively looks for the currently focused video and returns it's index. -/// -/// # Panics -/// Only if internal assertions fail. -pub async fn current_playlist_index(app: &App) -> Result<Option<PlaylistIndex>> { - if let Some(focused) = currently_focused_video(app).await? { - let playlist = playlist(app).await?; - let index = playlist - .find_index(&focused.extractor_hash) - .expect("All focused videos must also be in the playlist"); - Ok(Some(index)) - } else { - Ok(None) - } -} - -/// Return the video in the playlist at the position `index`. -pub async fn playlist_entry(app: &App, index: PlaylistIndex) -> Result<Option<Video>> { - let playlist = playlist(app).await?; - - if let Some(vid) = playlist.get(index) { - Ok(Some(vid.to_owned())) - } else { - Ok(None) - } -} - -pub async fn playlist(app: &App) -> Result<Playlist> { - let videos = super::videos(app, &[VideoStatusMarker::Cached]).await?; - - Ok(Playlist { videos }) -} - -/// This returns the video with the `is_focused` flag set. -/// # Panics -/// Only if assertions fail. -pub async fn currently_focused_video(app: &App) -> Result<Option<Video>> { - let cached_status = VideoStatusMarker::Cached.as_db_integer(); - let record = query!( - "SELECT * FROM videos WHERE is_focused = 1 AND status = ?", - cached_status - ) - .fetch_one(&app.database) - .await; - - if let Err(sqlx::Error::RowNotFound) = record { - Ok(None) - } else { - let base = record?; - Ok(Some(video_from_record! {base})) - } -} diff --git a/crates/yt/src/storage/video_database/set/mod.rs b/crates/yt/src/storage/video_database/set/mod.rs deleted file mode 100644 index 1b19011..0000000 --- a/crates/yt/src/storage/video_database/set/mod.rs +++ /dev/null @@ -1,327 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -//! These functions change the database. They are added on a demand basis. - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use chrono::Utc; -use log::{debug, info}; -use sqlx::query; -use tokio::fs; - -use crate::{app::App, storage::video_database::extractor_hash::ExtractorHash, video_from_record}; - -use super::{Priority, Video, VideoOptions, VideoStatus}; - -mod playlist; -pub use playlist::*; - -const fn is_focused_to_value(is_focused: bool) -> Option<i8> { - if is_focused { Some(1) } else { None } -} - -/// 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 video_status( - app: &App, - video_hash: &ExtractorHash, - new_status: VideoStatus, - new_priority: Option<Priority>, -) -> Result<()> { - let video_hash = video_hash.hash().to_string(); - - let old = { - let base = query!( - r#" - SELECT * - FROM videos - WHERE extractor_hash = ? - "#, - video_hash - ) - .fetch_one(&app.database) - .await?; - - video_from_record! {base} - }; - - let old_marker = old.status.as_marker(); - let (cache_path, is_focused) = { - fn cache_path_to_string(path: &Path) -> Result<String> { - Ok(path - .to_str() - .with_context(|| { - format!( - "Failed to parse cache path ('{}') as utf8 string", - path.display() - ) - })? - .to_owned()) - } - - if let VideoStatus::Cached { - cache_path, - is_focused, - } = &new_status - { - ( - Some(cache_path_to_string(cache_path)?), - is_focused_to_value(*is_focused), - ) - } else { - (None, None) - } - }; - - let new_status = new_status.as_marker(); - - if let Some(new_priority) = new_priority { - if old_marker == new_status && old.priority == new_priority { - return Ok(()); - } - - let now = Utc::now().timestamp(); - - debug!("Running status change: {old_marker:#?} -> {new_status:#?}...",); - - let new_status = new_status.as_db_integer(); - let new_priority = new_priority.as_db_integer(); - query!( - r#" - UPDATE videos - SET status = ?, last_status_change = ?, priority = ?, cache_path = ?, is_focused = ? - WHERE extractor_hash = ?; - "#, - new_status, - now, - new_priority, - cache_path, - is_focused, - video_hash - ) - .execute(&app.database) - .await?; - } else { - if old_marker == new_status { - return Ok(()); - } - - let now = Utc::now().timestamp(); - - debug!("Running status change: {old_marker:#?} -> {new_status:#?}...",); - - let new_status = new_status.as_db_integer(); - query!( - r#" - UPDATE videos - SET status = ?, last_status_change = ?, cache_path = ?, is_focused = ? - WHERE extractor_hash = ?; - "#, - new_status, - now, - cache_path, - is_focused, - 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 video_watched(app: &App, video: &ExtractorHash) -> Result<()> { - let old = { - let video_hash = video.hash().to_string(); - - let base = query!( - r#" - SELECT * - FROM videos - WHERE extractor_hash = ? - "#, - video_hash - ) - .fetch_one(&app.database) - .await?; - - video_from_record! {base} - }; - - info!("Will set video watched: '{}'", old.title); - - if let VideoStatus::Cached { cache_path, .. } = &old.status { - if let Ok(true) = cache_path.try_exists() { - fs::remove_file(cache_path).await?; - } - } else { - unreachable!("The video must be marked as Cached before it can be marked Watched"); - } - - video_status(app, video, VideoStatus::Watched, None).await?; - - Ok(()) -} - -pub(crate) async fn video_watch_progress( - app: &App, - extractor_hash: &ExtractorHash, - watch_progress: u32, -) -> std::result::Result<(), anyhow::Error> { - let video_extractor_hash = extractor_hash.hash().to_string(); - - query!( - r#" - UPDATE videos - SET watch_progress = ? - WHERE extractor_hash = ?; - "#, - watch_progress, - video_extractor_hash, - ) - .execute(&app.database) - .await?; - - Ok(()) -} - -pub async fn set_video_options( - app: &App, - hash: &ExtractorHash, - video_options: &VideoOptions, -) -> Result<()> { - let video_extractor_hash = hash.hash().to_string(); - let playback_speed = video_options.mpv.playback_speed; - let subtitle_langs = &video_options.yt_dlp.subtitle_langs; - - query!( - r#" - UPDATE video_options - SET playback_speed = ?, subtitle_langs = ? - WHERE extractor_hash = ?; - "#, - playback_speed, - subtitle_langs, - video_extractor_hash, - ) - .execute(&app.database) - .await?; - - Ok(()) -} - -/// # Panics -/// Only if internal expectations fail. -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 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 status = video.status.as_marker().as_db_integer(); - let (cache_path, is_focused) = if let VideoStatus::Cached { - cache_path, - is_focused, - } = video.status - { - ( - Some( - cache_path - .to_str() - .with_context(|| { - format!( - "Failed to prase cache path '{}' as utf-8 string", - cache_path.display() - ) - })? - .to_string(), - ), - is_focused_to_value(is_focused), - ) - } else { - (None, None) - }; - - let duration: Option<f64> = video.duration.as_secs_f64(); - let last_status_change: i64 = video.last_status_change.as_secs(); - let publish_date: Option<i64> = video.publish_date.map(|pd| pd.as_secs()); - let watch_progress: i64 = - i64::try_from(video.watch_progress.as_secs()).expect("This should never exceed a u32"); - - let mut tx = app.database.begin().await?; - query!( - r#" - INSERT INTO videos ( - description, - duration, - extractor_hash, - is_focused, - last_status_change, - parent_subscription_name, - publish_date, - status, - thumbnail_url, - title, - url, - watch_progress, - cache_path - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); - "#, - video.description, - duration, - extractor_hash, - is_focused, - last_status_change, - parent_subscription_name, - publish_date, - status, - thumbnail_url, - video.title, - url, - watch_progress, - cache_path, - ) - .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/crates/yt/src/storage/video_database/set/playlist.rs b/crates/yt/src/storage/video_database/set/playlist.rs deleted file mode 100644 index 547df21..0000000 --- a/crates/yt/src/storage/video_database/set/playlist.rs +++ /dev/null @@ -1,101 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use anyhow::Result; -use log::debug; -use sqlx::query; - -use crate::{ - app::App, - storage::video_database::{extractor_hash::ExtractorHash, get}, -}; - -/// Set a video to be focused. -/// This optionally takes another video hash, which marks the old focused video. -/// This will then be disabled. -/// -/// # Panics -/// Only if internal assertions fail. -pub async fn focused( - app: &App, - new_video_hash: &ExtractorHash, - old_video_hash: Option<&ExtractorHash>, -) -> Result<()> { - unfocused(app, old_video_hash).await?; - - debug!("Focusing video: '{new_video_hash}'"); - 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_focused_video(app) - .await? - .expect("This is some at this point") - .extractor_hash - ); - Ok(()) -} - -/// Set a video to be no longer focused. -/// This will use the supplied `video_hash` if it is [`Some`], otherwise it will simply un-focus -/// the currently focused video. -/// -/// # Panics -/// Only if internal assertions fail. -pub async fn unfocused(app: &App, video_hash: Option<&ExtractorHash>) -> Result<()> { - let hash = if let Some(hash) = video_hash { - hash.hash().to_string() - } else { - let output = query!( - r#" - SELECT extractor_hash - FROM videos - WHERE is_focused = 1; - "#, - ) - .fetch_optional(&app.database) - .await?; - - if let Some(output) = output { - output.extractor_hash - } else { - // There is no unfocused video right now. - return Ok(()); - } - }; - debug!("Unfocusing video: '{hash}'"); - - query!( - r#" - UPDATE videos - SET is_focused = NULL - WHERE extractor_hash = ?; - "#, - hash - ) - .execute(&app.database) - .await?; - - assert!( - get::currently_focused_video(app).await?.is_none(), - "We assumed that the video we just removed was actually a focused one." - ); - Ok(()) -} |