aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/yt/src/storage/db/extractor_hash.rs (renamed from crates/yt/src/storage/video_database/extractor_hash.rs)87
-rw-r--r--crates/yt/src/storage/db/get/extractor_hash.rs58
-rw-r--r--crates/yt/src/storage/db/get/mod.rs4
-rw-r--r--crates/yt/src/storage/db/get/playlist.rs58
-rw-r--r--crates/yt/src/storage/db/get/subscription.rs39
-rw-r--r--crates/yt/src/storage/db/get/video/mod.rs188
-rw-r--r--crates/yt/src/storage/db/insert/mod.rs73
-rw-r--r--crates/yt/src/storage/db/insert/playlist.rs207
-rw-r--r--crates/yt/src/storage/db/insert/subscription.rs84
-rw-r--r--crates/yt/src/storage/db/insert/video/mod.rs599
-rw-r--r--crates/yt/src/storage/db/mod.rs7
-rw-r--r--crates/yt/src/storage/db/playlist/mod.rs49
-rw-r--r--crates/yt/src/storage/db/subscription.rs41
-rw-r--r--crates/yt/src/storage/db/video.rs (renamed from crates/yt/src/storage/video_database/mod.rs)206
-rw-r--r--crates/yt/src/storage/notify.rs (renamed from crates/yt/src/storage/video_database/notify.rs)4
-rw-r--r--crates/yt/src/storage/subscriptions.rs141
-rw-r--r--crates/yt/src/storage/video_database/downloader.rs130
-rw-r--r--crates/yt/src/storage/video_database/get/mod.rs307
-rw-r--r--crates/yt/src/storage/video_database/get/playlist/iterator.rs101
-rw-r--r--crates/yt/src/storage/video_database/get/playlist/mod.rs167
-rw-r--r--crates/yt/src/storage/video_database/set/mod.rs327
-rw-r--r--crates/yt/src/storage/video_database/set/playlist.rs101
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(())
-}