diff options
author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-02-22 11:44:13 +0100 |
---|---|---|
committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-02-22 11:44:13 +0100 |
commit | 9496583cc76fbd7347384716f2898f870743f16d (patch) | |
tree | 369004c9c5a83c463b01d4ccb404ffe71856c0e3 | |
parent | feat(yt/watch/playlist): Init (diff) | |
download | yt-9496583cc76fbd7347384716f2898f870743f16d.zip |
refactor(yt/storage/video_database): Move `getters,setters` to `get,set`
This also removes some `get_`/`set_` prefixes from the functions in these modules, as `get::<function>` is more idiomatic than `get_<function>`.
-rw-r--r-- | yt/src/storage/video_database/get/mod.rs | 302 | ||||
-rw-r--r-- | yt/src/storage/video_database/get/playlist/iterator.rs | 91 | ||||
-rw-r--r-- | yt/src/storage/video_database/get/playlist/mod.rs | 157 | ||||
-rw-r--r-- | yt/src/storage/video_database/set/mod.rs | 340 | ||||
-rw-r--r-- | yt/src/storage/video_database/set/playlist.rs | 71 |
5 files changed, 961 insertions, 0 deletions
diff --git a/yt/src/storage/video_database/get/mod.rs b/yt/src/storage/video_database/get/mod.rs new file mode 100644 index 0000000..8fe0754 --- /dev/null +++ b/yt/src/storage/video_database/get/mod.rs @@ -0,0 +1,302 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +//! These functions interact with the storage db in a read-only way. They are added on-demand (as +//! you could theoretically just could do everything with the `get_videos` function), as +//! performance or convince requires. +use std::{fs::File, path::PathBuf}; + +use anyhow::{Context, Result, bail}; +use blake3::Hash; +use log::{debug, trace}; +use sqlx::query; +use yt_dlp::wrapper::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 == 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/yt/src/storage/video_database/get/playlist/iterator.rs b/yt/src/storage/video_database/get/playlist/iterator.rs new file mode 100644 index 0000000..e8ee190 --- /dev/null +++ b/yt/src/storage/video_database/get/playlist/iterator.rs @@ -0,0 +1,91 @@ +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/yt/src/storage/video_database/get/playlist/mod.rs b/yt/src/storage/video_database/get/playlist/mod.rs new file mode 100644 index 0000000..7d5e000 --- /dev/null +++ b/yt/src/storage/video_database/get/playlist/mod.rs @@ -0,0 +1,157 @@ +//! 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/yt/src/storage/video_database/set/mod.rs b/yt/src/storage/video_database/set/mod.rs new file mode 100644 index 0000000..c1f771c --- /dev/null +++ b/yt/src/storage/video_database/set/mod.rs @@ -0,0 +1,340 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +//! These functions change the database. They are added on a demand basis. + +use 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::{VideoStatusMarker, extractor_hash::ExtractorHash}, + video_from_record, +}; + +use super::{Priority, Video, VideoOptions, VideoStatus}; + +mod playlist; +pub use playlist::*; + +/// 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 = { + 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()) + } + + match (old_marker, &new_status) { + (VideoStatusMarker::Cached, VideoStatus::Cached { cache_path, .. }) => { + Some(cache_path_to_string(cache_path)?) + } + (_, VideoStatus::Cached { cache_path, .. }) => Some(cache_path_to_string(cache_path)?), + + (VideoStatusMarker::Cached | _, _) => 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 = ? + WHERE extractor_hash = ?; + "#, + new_status, + now, + new_priority, + cache_path, + 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 = ? + WHERE extractor_hash = ?; + "#, + new_status, + now, + cache_path, + video_hash + ) + .execute(&app.database) + .await?; + } + + debug!("Finished status change."); + Ok(()) +} + +/// Mark a video as watched. +/// This will both set the status to `Watched` and the `cache_path` to Null. +/// +/// # Panics +/// Only if assertions fail. +pub async fn video_watched(app: &App, video: &ExtractorHash) -> Result<()> { + let video_hash = video.hash().to_string(); + let new_status = VideoStatusMarker::Watched.as_db_integer(); + + let old = { + 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"); + } + + let now = Utc::now().timestamp(); + + query!( + r#" + UPDATE videos + SET status = ?, last_status_change = ?, cache_path = NULL + WHERE extractor_hash = ?; + "#, + new_status, + now, + video_hash + ) + .execute(&app.database) + .await?; + + Ok(()) +} + +pub(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, + ) + } else { + (None, false) + }; + + 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/yt/src/storage/video_database/set/playlist.rs b/yt/src/storage/video_database/set/playlist.rs new file mode 100644 index 0000000..8a7b2f6 --- /dev/null +++ b/yt/src/storage/video_database/set/playlist.rs @@ -0,0 +1,71 @@ +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<()> { + if let Some(old) = old_video_hash { + debug!("Unfocusing video: '{old}'"); + unfocused(app, old).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. +/// +/// # Panics +/// Only if internal assertions fail. +pub async fn unfocused(app: &App, video_hash: &ExtractorHash) -> Result<()> { + let hash = video_hash.hash().to_string(); + query!( + r#" + UPDATE videos + SET is_focused = 0 + 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(()) +} |