aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-02-22 11:44:13 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-02-22 11:44:13 +0100
commit9496583cc76fbd7347384716f2898f870743f16d (patch)
tree369004c9c5a83c463b01d4ccb404ffe71856c0e3
parentfeat(yt/watch/playlist): Init (diff)
downloadyt-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.rs302
-rw-r--r--yt/src/storage/video_database/get/playlist/iterator.rs91
-rw-r--r--yt/src/storage/video_database/get/playlist/mod.rs157
-rw-r--r--yt/src/storage/video_database/set/mod.rs340
-rw-r--r--yt/src/storage/video_database/set/playlist.rs71
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(())
+}