about summary refs log tree commit diff stats
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(())
+}