about summary refs log tree commit diff stats
path: root/src/storage
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/storage/mod.rs12
-rw-r--r--src/storage/subscriptions.rs140
-rw-r--r--src/storage/video_database/downloader.rs210
-rw-r--r--src/storage/video_database/extractor_hash.rs151
-rw-r--r--src/storage/video_database/getters.rs339
-rw-r--r--src/storage/video_database/mod.rs170
-rw-r--r--src/storage/video_database/schema.sql56
-rw-r--r--src/storage/video_database/setters.rs270
8 files changed, 1348 insertions, 0 deletions
diff --git a/src/storage/mod.rs b/src/storage/mod.rs
new file mode 100644
index 0000000..6a12d8b
--- /dev/null
+++ b/src/storage/mod.rs
@@ -0,0 +1,12 @@
+// 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>.
+
+pub mod subscriptions;
+pub mod video_database;
diff --git a/src/storage/subscriptions.rs b/src/storage/subscriptions.rs
new file mode 100644
index 0000000..22edd08
--- /dev/null
+++ b/src/storage/subscriptions.rs
@@ -0,0 +1,140 @@
+// 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>.
+
+//! Handle subscriptions
+
+use std::collections::HashMap;
+
+use anyhow::Result;
+use log::debug;
+use serde_json::{json, Value};
+use sqlx::query;
+use url::Url;
+use yt_dlp::wrapper::info_json::InfoType;
+
+use crate::app::App;
+
+#[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 {
+    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_opts = match json!( {
+        "playliststart": 1,
+        "playlistend": 10,
+        "noplaylist": false,
+        "extract_flat": "in_playlist",
+    }) {
+        Value::Object(map) => map,
+        _ => unreachable!("This is hardcoded"),
+    };
+
+    let info = yt_dlp::extract_info(&yt_opts, url, false, false).await?;
+
+    debug!("{:#?}", info);
+
+    Ok(info._type == Some(InfoType::Playlist))
+}
+
+#[derive(Default)]
+pub struct Subscriptions(pub(crate) HashMap<String, Subscription>);
+
+pub async fn remove_all_subscriptions(app: &App) -> Result<()> {
+    query!(
+        "
+        DELETE FROM subscriptions;
+    ",
+    )
+    .execute(&app.database)
+    .await?;
+
+    Ok(())
+}
+
+/// Get a list of subscriptions
+pub async fn get_subscriptions(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).expect("This should be valid"),
+                ),
+            )
+        })
+        .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(())
+}
+
+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/src/storage/video_database/downloader.rs b/src/storage/video_database/downloader.rs
new file mode 100644
index 0000000..c04ab8d
--- /dev/null
+++ b/src/storage/video_database/downloader.rs
@@ -0,0 +1,210 @@
+// 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>.
+
+use std::path::{Path, PathBuf};
+
+use anyhow::Result;
+use log::debug;
+use sqlx::query;
+use url::Url;
+
+use crate::{app::App, storage::video_database::VideoStatus};
+
+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.
+pub async fn get_next_uncached_video(app: &App) -> Result<Option<Video>> {
+    let status = VideoStatus::Watch.as_db_integer();
+
+    let result = query!(
+        r#"
+        SELECT *
+        FROM videos
+        WHERE status = ? AND cache_path IS NULL
+        ORDER BY priority ASC
+        LIMIT 1;
+    "#,
+        status
+    )
+    .fetch_one(&app.database)
+    .await;
+
+    if let Err(sqlx::Error::RowNotFound) = result {
+        Ok(None)
+    } else {
+        let base = result?;
+
+        let thumbnail_url = if let Some(url) = &base.thumbnail_url {
+            Some(Url::parse(&url)?)
+        } else {
+            None
+        };
+
+        let status_change = if base.status_change == 1 {
+            true
+        } else {
+            assert_eq!(base.status_change, 0, "Can only be 1 or 0");
+            false
+        };
+
+        let video = Video {
+            cache_path: base.cache_path.as_ref().map(|val| PathBuf::from(val)),
+            description: base.description.clone(),
+            duration: base.duration,
+            extractor_hash: ExtractorHash::from_hash(
+                base.extractor_hash
+                    .parse()
+                    .expect("The hash in the db should be valid"),
+            ),
+            last_status_change: base.last_status_change,
+            parent_subscription_name: base.parent_subscription_name.clone(),
+            priority: base.priority,
+            publish_date: base.publish_date,
+            status: VideoStatus::from_db_integer(base.status),
+            status_change,
+            thumbnail_url,
+            title: base.title.clone(),
+            url: Url::parse(&base.url)?,
+        };
+
+        Ok(Some(video))
+    }
+}
+
+/// Returns to next video which can be watched (i.e. is cached).
+/// This respects the priority assigned by select.
+pub async fn get_next_video_watchable(app: &App) -> Result<Option<Video>> {
+    let result = query!(
+        r#"
+        SELECT *
+        FROM videos
+        WHERE status = 'Watching' AND cache_path IS NOT NULL
+        ORDER BY priority ASC
+        LIMIT 1;
+    "#
+    )
+    .fetch_one(&app.database)
+    .await;
+
+    if let Err(sqlx::Error::RowNotFound) = result {
+        Ok(None)
+    } else {
+        let base = result?;
+
+        let thumbnail_url = if let Some(url) = &base.thumbnail_url {
+            Some(Url::parse(&url)?)
+        } else {
+            None
+        };
+
+        let status_change = if base.status_change == 1 {
+            true
+        } else {
+            assert_eq!(base.status_change, 0, "Can only be 1 or 0");
+            false
+        };
+
+        let video = Video {
+            cache_path: base.cache_path.as_ref().map(|val| PathBuf::from(val)),
+            description: base.description.clone(),
+            duration: base.duration,
+            extractor_hash: ExtractorHash::from_hash(
+                base.extractor_hash
+                    .parse()
+                    .expect("The db extractor_hash should be valid blake3 hash"),
+            ),
+            last_status_change: base.last_status_change,
+            parent_subscription_name: base.parent_subscription_name.clone(),
+            priority: base.priority,
+            publish_date: base.publish_date,
+            status: VideoStatus::from_db_integer(base.status),
+            status_change,
+            thumbnail_url,
+            title: base.title.clone(),
+            url: Url::parse(&base.url)?,
+        };
+
+        Ok(Some(video))
+    }
+}
+
+/// 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 = VideoStatus::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_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(count.count as u32)
+}
diff --git a/src/storage/video_database/extractor_hash.rs b/src/storage/video_database/extractor_hash.rs
new file mode 100644
index 0000000..3af4f60
--- /dev/null
+++ b/src/storage/video_database/extractor_hash.rs
@@ -0,0 +1,151 @@
+// 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>.
+
+use std::{collections::HashMap, fmt::Display, str::FromStr};
+
+use anyhow::{bail, Result};
+use blake3::Hash;
+use log::debug;
+use tokio::sync::OnceCell;
+
+use crate::{app::App, storage::video_database::getters::get_all_hashes};
+
+static EXTRACTOR_HASH_LENGTH: OnceCell<usize> = OnceCell::const_new();
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ExtractorHash {
+    hash: Hash,
+}
+
+#[derive(Debug, Clone)]
+pub struct ShortHash(String);
+
+impl Display for ShortHash {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(f)
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct LazyExtractorHash {
+    value: ShortHash,
+}
+
+impl FromStr for LazyExtractorHash {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
+        // perform some cheap validation
+        if s.len() > 64 {
+            bail!("A hash can only contain 64 bytes!");
+        }
+
+        Ok(Self {
+            value: ShortHash(s.to_owned()),
+        })
+    }
+}
+
+impl LazyExtractorHash {
+    /// Turn the [`LazyExtractorHash`] into the [`ExtractorHash`]
+    pub async fn realize(self, app: &App) -> Result<ExtractorHash> {
+        ExtractorHash::from_short_hash(app, &self.value).await
+    }
+}
+
+impl ExtractorHash {
+    pub fn from_hash(hash: Hash) -> Self {
+        Self { hash }
+    }
+    pub async fn from_short_hash(app: &App, s: &ShortHash) -> Result<Self> {
+        Ok(Self {
+            hash: Self::short_hash_to_full_hash(app, s).await?,
+        })
+    }
+
+    pub fn hash(&self) -> &Hash {
+        &self.hash
+    }
+
+    pub async fn into_short_hash(&self, app: &App) -> Result<ShortHash> {
+        let needed_chars = if let Some(needed_chars) = EXTRACTOR_HASH_LENGTH.get() {
+            debug!("Using cached char length: {}", needed_chars);
+            *needed_chars
+        } else {
+            let needed_chars = self.get_needed_char_len(app).await?;
+            debug!("Setting the needed has char lenght.");
+            EXTRACTOR_HASH_LENGTH
+                .set(needed_chars)
+                .expect("This should work at this stage");
+
+            needed_chars
+        };
+
+        debug!("Formatting a hash with char length: {}", needed_chars);
+
+        Ok(ShortHash(
+            self.hash()
+                .to_hex()
+                .chars()
+                .into_iter()
+                .take(needed_chars)
+                .collect::<String>(),
+        ))
+    }
+
+    async fn short_hash_to_full_hash(app: &App, s: &ShortHash) -> Result<Hash> {
+        let all_hashes = get_all_hashes(app).await?;
+
+        let needed_chars = s.0.len();
+
+        for hash in all_hashes {
+            if &hash.to_hex()[..needed_chars] == s.0 {
+                return Ok(hash);
+            }
+        }
+
+        bail!("Your shortend hash, does not match a real hash (this is probably a bug)!");
+    }
+
+    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).await?;
+
+        let all_char_vec_hashes = all_hashes
+            .into_iter()
+            .map(|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.
+        let mut needed_chars: usize = 1000;
+        'outer: for i in 1..64 {
+            let i_chars: Vec<String> = all_char_vec_hashes
+                .iter()
+                .map(|vec| vec.iter().take(i).collect::<String>())
+                .collect();
+
+            let mut uniqnes_hashmap: HashMap<String, ()> = HashMap::new();
+            for ch in i_chars {
+                if let Some(()) = uniqnes_hashmap.insert(ch, ()) {
+                    // The key was already in the hash map, thus we have a duplicated char and need
+                    // at least one char more
+                    continue 'outer;
+                }
+            }
+
+            needed_chars = i;
+            break 'outer;
+        }
+
+        assert!(needed_chars <= 64, "Hashes are only 64 bytes long");
+
+        Ok(needed_chars)
+    }
+}
diff --git a/src/storage/video_database/getters.rs b/src/storage/video_database/getters.rs
new file mode 100644
index 0000000..ca4164d
--- /dev/null
+++ b/src/storage/video_database/getters.rs
@@ -0,0 +1,339 @@
+// 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-demaned (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::{bail, Context, Result};
+use blake3::Hash;
+use log::debug;
+use sqlx::{query, QueryBuilder, Row, Sqlite};
+use url::Url;
+use yt_dlp::wrapper::info_json::InfoJson;
+
+use crate::{
+    app::App,
+    storage::{
+        subscriptions::Subscription,
+        video_database::{extractor_hash::ExtractorHash, Video},
+    },
+};
+
+use super::{MpvOptions, VideoOptions, VideoStatus, YtDlpOptions};
+
+macro_rules! video_from_record {
+    ($record:expr) => {
+        let thumbnail_url = if let Some(url) = &$record.thumbnail_url {
+            Some(Url::parse(&url)?)
+        } else {
+            None
+        };
+
+        Ok(Video {
+            cache_path: $record.cache_path.as_ref().map(|val| PathBuf::from(val)),
+            description: $record.description.clone(),
+            duration: $record.duration,
+            extractor_hash: ExtractorHash::from_hash(
+                $record
+                    .extractor_hash
+                    .parse()
+                    .expect("The db hash should be a valid blake3 hash"),
+            ),
+            last_status_change: $record.last_status_change,
+            parent_subscription_name: $record.parent_subscription_name.clone(),
+            publish_date: $record.publish_date,
+            status: VideoStatus::from_db_integer($record.status),
+            thumbnail_url,
+            title: $record.title.clone(),
+            url: Url::parse(&$record.url)?,
+            priority: $record.priority,
+            status_change: if $record.status_change == 1 {
+                true
+            } else {
+                assert_eq!($record.status_change, 0);
+                false
+            },
+        })
+    };
+}
+
+/// Get the lines to display at the selection file
+/// [`changing` = true]: Means that we include *only* videos, that have the `status_changing` flag set
+/// [`changing` = None]: Means that we include *both* videos, that have the `status_changing` flag set and not set
+pub async fn get_videos(
+    app: &App,
+    allowed_states: &[VideoStatus],
+    changing: Option<bool>,
+) -> Result<Vec<Video>> {
+    let mut qb: QueryBuilder<Sqlite> = QueryBuilder::new(
+        "\
+    SELECT *
+    FROM videos
+    WHERE status IN ",
+    );
+
+    qb.push("(");
+    allowed_states
+        .iter()
+        .enumerate()
+        .for_each(|(index, state)| {
+            qb.push("'");
+            qb.push(state.as_db_integer());
+            qb.push("'");
+
+            if index != allowed_states.len() - 1 {
+                qb.push(",");
+            }
+        });
+    qb.push(")");
+
+    if let Some(val) = changing {
+        if val {
+            qb.push(" AND status_change = 1");
+        } else {
+            qb.push(" AND status_change = 0");
+        }
+    }
+
+    qb.push("\n    ORDER BY priority DESC;");
+
+    debug!("Will run: \"{}\"", qb.sql());
+
+    let videos = qb.build().fetch_all(&app.database).await.with_context(|| {
+        format!(
+            "Failed to query videos with states: '{}'",
+            allowed_states.iter().fold(String::new(), |mut acc, state| {
+                acc.push(' ');
+                acc.push_str(&state.as_str());
+                acc
+            }),
+        )
+    })?;
+
+    let real_videos: Vec<Video> = videos
+        .iter()
+        .map(|base| -> Result<Video> {
+            let thumbnail_url = if let Some(url) = base.get("thumbnail_url") {
+                Some(Url::parse(url)?)
+            } else {
+                None
+            };
+            Ok(Video {
+                cache_path: base
+                    .get::<Option<String>, &str>("cache_path")
+                    .as_ref()
+                    .map(|val| PathBuf::from(val)),
+                description: base.get::<Option<String>, &str>("description").clone(),
+                duration: base.get("duration"),
+                extractor_hash: ExtractorHash::from_hash(
+                    base.get::<String, &str>("extractor_hash")
+                        .parse()
+                        .expect("The db hash should be a valid blake3 hash"),
+                ),
+                last_status_change: base.get("last_status_change"),
+                parent_subscription_name: base
+                    .get::<Option<String>, &str>("parent_subscription_name")
+                    .clone(),
+                publish_date: base.get("publish_date"),
+                status: VideoStatus::from_db_integer(base.get("status")),
+                thumbnail_url,
+                title: base.get::<String, &str>("title").to_owned(),
+                url: Url::parse(base.get("url"))?,
+                priority: base.get("priority"),
+                status_change: {
+                    let val = base.get::<i64, &str>("status_change");
+                    if val == 1 {
+                        true
+                    } else {
+                        assert_eq!(val, 0, "Can only be 1 or 0");
+                        false
+                    }
+                },
+            })
+        })
+        .collect::<Result<Vec<Video>>>()?;
+
+    Ok(real_videos)
+}
+
+pub async fn get_video_info_json(video: &Video) -> Result<Option<InfoJson>> {
+    if let Some(mut path) = video.cache_path.clone() {
+        if !path.set_extension("info.json") {
+            bail!(
+                "Failed to change path extension to 'info.json': {}",
+                path.display()
+            );
+        }
+        let info_json_string = File::open(path)?;
+        let info_json: InfoJson = serde_json::from_reader(&info_json_string)?;
+
+        Ok(Some(info_json))
+    } else {
+        Ok(None)
+    }
+}
+
+pub async fn get_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?;
+
+    video_from_record! {raw_video}
+}
+
+pub async fn get_currently_playing_video(app: &App) -> Result<Option<Video>> {
+    let mut videos: Vec<Video> = get_changing_videos(app, VideoStatus::Cached).await?;
+
+    if videos.is_empty() {
+        Ok(None)
+    } else {
+        assert_eq!(
+            videos.len(),
+            1,
+            "Only one video can change from cached to watched at once!"
+        );
+
+        Ok(Some(videos.remove(0)))
+    }
+}
+
+pub async fn get_changing_videos(app: &App, old_state: VideoStatus) -> Result<Vec<Video>> {
+    let status = old_state.as_db_integer();
+
+    let matching = query!(
+        r#"
+        SELECT *
+        FROM videos
+        WHERE status_change = 1 AND status = ?;
+    "#,
+        status
+    )
+    .fetch_all(&app.database)
+    .await?;
+
+    let real_videos: Vec<Video> = matching
+        .iter()
+        .map(|base| -> Result<Video> {
+            video_from_record! {base}
+        })
+        .collect::<Result<Vec<Video>>>()?;
+
+    Ok(real_videos)
+}
+
+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)
+                .expect("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)
+                .expect("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?;
+
+    Ok(YtDlpOptions {
+        subtitle_langs: yt_dlp_options.subtitle_langs,
+    })
+}
+pub async fn get_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?;
+
+    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?;
+
+    let mpv = MpvOptions {
+        playback_speed: opts.playback_speed,
+    };
+    let yt_dlp = YtDlpOptions {
+        subtitle_langs: opts.subtitle_langs,
+    };
+
+    Ok(VideoOptions { mpv, yt_dlp })
+}
diff --git a/src/storage/video_database/mod.rs b/src/storage/video_database/mod.rs
new file mode 100644
index 0000000..28263ca
--- /dev/null
+++ b/src/storage/video_database/mod.rs
@@ -0,0 +1,170 @@
+// 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>.
+
+use std::{fmt::Write, path::PathBuf};
+
+use url::Url;
+
+use crate::{
+    constants::{DEFAULT_MPV_PLAYBACK_SPEED, DEFAULT_SUBTITLE_LANGS},
+    storage::video_database::extractor_hash::ExtractorHash,
+};
+
+pub mod downloader;
+pub mod extractor_hash;
+pub mod getters;
+pub mod setters;
+
+#[derive(Debug)]
+pub struct Video {
+    pub cache_path: Option<PathBuf>,
+    pub description: Option<String>,
+    pub duration: Option<f64>,
+    pub extractor_hash: ExtractorHash,
+    pub last_status_change: i64,
+    /// The associated subscription this video was fetched from (null, when the video was `add`ed)
+    pub parent_subscription_name: Option<String>,
+    pub priority: i64,
+    pub publish_date: Option<i64>,
+    pub status: VideoStatus,
+    /// The video is currently changing its state (for example from being `SELECT` to being `CACHE`)
+    pub status_change: bool,
+    pub thumbnail_url: Option<Url>,
+    pub title: String,
+    pub url: Url,
+}
+
+#[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.
+    pub fn to_cli_flags(self) -> String {
+        let mut f = String::new();
+
+        if self.mpv.playback_speed != DEFAULT_MPV_PLAYBACK_SPEED {
+            write!(f, " --speed '{}'", self.mpv.playback_speed).expect("Works");
+        }
+        if self.yt_dlp.subtitle_langs != DEFAULT_SUBTITLE_LANGS {
+            write!(f, " --subtitle-langs '{}'", self.yt_dlp.subtitle_langs).expect("Works");
+        }
+
+        f.trim().to_owned()
+    }
+}
+
+#[derive(Debug)]
+/// 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>
+///     /    \
+/// <Watch>   <Drop> -> Dropped // yt select
+///     |
+/// Cache                       // yt cache
+///     |
+/// Watched                     // yt watch
+#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum VideoStatus {
+    #[default]
+    Pick,
+
+    /// The video has been select to be watched
+    Watch,
+    /// The video has been cached and is ready to be watched
+    Cached,
+    /// The video has been watched
+    Watched,
+
+    /// The video has been select to be dropped
+    Drop,
+    /// The video has been dropped
+    Dropped,
+}
+
+impl VideoStatus {
+    pub fn as_command(&self) -> &str {
+        // NOTE: Keep the serialize able variants synced with the main `select` function <2024-06-14>
+        match self {
+            VideoStatus::Pick => "pick",
+
+            VideoStatus::Watch => "watch",
+            VideoStatus::Cached => "watch",
+            VideoStatus::Watched => "watch",
+
+            VideoStatus::Drop => "drop",
+            VideoStatus::Dropped => "drop",
+        }
+    }
+
+    pub 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 {
+            VideoStatus::Pick => 0,
+
+            VideoStatus::Watch => 1,
+            VideoStatus::Cached => 2,
+            VideoStatus::Watched => 3,
+
+            VideoStatus::Drop => 4,
+            VideoStatus::Dropped => 5,
+        }
+    }
+    pub fn from_db_integer(num: i64) -> Self {
+        match num {
+            0 => Self::Pick,
+
+            1 => Self::Watch,
+            2 => Self::Cached,
+            3 => Self::Watched,
+
+            4 => Self::Drop,
+            5 => Self::Dropped,
+            other => unreachable!(
+                "The database returned a enum discriminator, unknown to us: '{}'",
+                other
+            ),
+        }
+    }
+
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            VideoStatus::Pick => "Pick",
+
+            VideoStatus::Watch => "Watch",
+            VideoStatus::Cached => "Cache",
+            VideoStatus::Watched => "Watched",
+
+            VideoStatus::Drop => "Drop",
+            VideoStatus::Dropped => "Dropped",
+        }
+    }
+}
diff --git a/src/storage/video_database/schema.sql b/src/storage/video_database/schema.sql
new file mode 100644
index 0000000..b05d908
--- /dev/null
+++ b/src/storage/video_database/schema.sql
@@ -0,0 +1,56 @@
+-- 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>.
+
+-- The base schema
+
+-- Keep this table in sync with the `Video` structure
+CREATE TABLE IF NOT EXISTS videos (
+    cache_path                  TEXT UNIQUE                    CHECK (CASE WHEN cache_path IS NOT NULL THEN
+                                                                            status == 2
+                                                                      ELSE
+                                                                            1
+                                                                      END),
+    description                 TEXT,
+    duration                    FLOAT,
+    extractor_hash              TEXT UNIQUE NOT NULL PRIMARY KEY,
+    last_status_change          INTEGER     NOT NULL,
+    parent_subscription_name    TEXT,
+    priority                    INTEGER     NOT NULL DEFAULT 0,
+    publish_date                INTEGER,
+    status                      INTEGER     NOT NULL DEFAULT 0 CHECK (status IN (0, 1, 2, 3, 4, 5) AND
+                                                                      CASE WHEN status == 2 THEN
+                                                                           cache_path IS NOT NULL
+                                                                      ELSE
+                                                                           1
+                                                                      END AND
+                                                                      CASE WHEN status != 2 THEN
+                                                                           cache_path IS NULL
+                                                                      ELSE
+                                                                           1
+                                                                      END),
+    status_change               INTEGER     NOT NULL DEFAULT 0 CHECK (status_change IN (0, 1)),
+    thumbnail_url               TEXT,
+    title                       TEXT        NOT NULL,
+    url                         TEXT UNIQUE NOT NULL
+);
+
+-- Store additional metadata for the videos marked to be watched
+CREATE TABLE IF NOT EXISTS video_options (
+    extractor_hash              TEXT UNIQUE NOT NULL PRIMARY KEY,
+    subtitle_langs              TEXT        NOT NULL,
+    playback_speed              REAL        NOT NULL,
+    FOREIGN KEY(extractor_hash) REFERENCES videos (extractor_hash)
+);
+
+-- Store subscriptions
+CREATE TABLE IF NOT EXISTS subscriptions (
+    name              TEXT UNIQUE NOT NULL PRIMARY KEY,
+    url               TEXT        NOT NULL
+);
diff --git a/src/storage/video_database/setters.rs b/src/storage/video_database/setters.rs
new file mode 100644
index 0000000..ec5a5e1
--- /dev/null
+++ b/src/storage/video_database/setters.rs
@@ -0,0 +1,270 @@
+// 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 anyhow::Result;
+use chrono::Utc;
+use log::debug;
+use sqlx::query;
+use tokio::fs;
+
+use crate::{app::App, constants, storage::video_database::extractor_hash::ExtractorHash};
+
+use super::{Video, VideoOptions, VideoStatus};
+
+/// 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 set_video_status(
+    app: &App,
+    video_hash: &ExtractorHash,
+    new_status: VideoStatus,
+    new_priority: Option<i64>,
+) -> Result<()> {
+    let video_hash = video_hash.hash().to_string();
+
+    let old = query!(
+        r#"
+    SELECT status, priority, cache_path
+    FROM videos
+    WHERE extractor_hash = ?
+    "#,
+        video_hash
+    )
+    .fetch_one(&app.database)
+    .await?;
+
+    let cache_path = if (VideoStatus::from_db_integer(old.status) == VideoStatus::Cached)
+        && (new_status != VideoStatus::Cached)
+    {
+        None
+    } else {
+        old.cache_path.as_deref()
+    };
+
+    let new_status = new_status.as_db_integer();
+
+    if let Some(new_priority) = new_priority {
+        if old.status == new_status && old.priority == new_priority {
+            return Ok(());
+        }
+
+        let now = Utc::now().timestamp();
+
+        debug!(
+            "Running status change: {:#?} -> {:#?}...",
+            VideoStatus::from_db_integer(old.status),
+            VideoStatus::from_db_integer(new_status),
+        );
+
+        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.status == new_status {
+            return Ok(());
+        }
+
+        let now = Utc::now().timestamp();
+
+        debug!(
+            "Running status change: {:#?} -> {:#?}...",
+            VideoStatus::from_db_integer(old.status),
+            VideoStatus::from_db_integer(new_status),
+        );
+
+        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.
+pub async fn set_video_watched(app: &App, video: &Video) -> Result<()> {
+    let video_hash = video.extractor_hash.hash().to_string();
+    let new_status = VideoStatus::Watched.as_db_integer();
+
+    let old = query!(
+        r#"
+    SELECT status, priority
+    FROM videos
+    WHERE extractor_hash = ?
+    "#,
+        video_hash
+    )
+    .fetch_one(&app.database)
+    .await?;
+
+    if old.status == new_status {
+        return Ok(());
+    }
+
+    let now = Utc::now().timestamp();
+
+    if let Some(path) = &video.cache_path {
+        if let Ok(true) = path.try_exists() {
+            fs::remove_file(path).await?
+        }
+    }
+
+    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 async fn set_state_change(
+    app: &App,
+    video_extractor_hash: &ExtractorHash,
+    changing: bool,
+) -> Result<()> {
+    let state_change = if changing { 1 } else { 0 };
+    let video_extractor_hash = video_extractor_hash.hash().to_string();
+
+    query!(
+        r#"
+            UPDATE videos
+            SET status_change = ?
+            WHERE extractor_hash = ?;
+        "#,
+        state_change,
+        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(())
+}
+
+pub async fn add_video(app: &App, video: Video) -> Result<()> {
+    let parent_subscription_name = if let Some(subs) = video.parent_subscription_name {
+        subs
+    } else {
+        "NULL".to_owned()
+    };
+
+    let thumbnail_url = if let Some(thum) = video.thumbnail_url {
+        thum.to_string()
+    } else {
+        "NULL".to_owned()
+    };
+
+    let status = video.status.as_db_integer();
+    let status_change = if video.status_change { 1 } else { 0 };
+    let url = video.url.to_string();
+    let extractor_hash = video.extractor_hash.hash().to_string();
+
+    let default_subtitle_langs = constants::DEFAULT_SUBTITLE_LANGS;
+    let default_mpv_playback_speed = constants::DEFAULT_MPV_PLAYBACK_SPEED;
+
+    query!(
+        r#"
+        BEGIN;
+        INSERT INTO videos (
+            parent_subscription_name,
+            status,
+            status_change,
+            last_status_change,
+            title,
+            url,
+            description,
+            duration,
+            publish_date,
+            thumbnail_url,
+            extractor_hash)
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
+
+        INSERT INTO video_options (
+            extractor_hash,
+            subtitle_langs,
+            playback_speed)
+        VALUES (?, ?, ?);
+        COMMIT;
+    "#,
+        parent_subscription_name,
+        status,
+        status_change,
+        video.last_status_change,
+        video.title,
+        url,
+        video.description,
+        video.duration,
+        video.publish_date,
+        thumbnail_url,
+        extractor_hash,
+        extractor_hash,
+        default_subtitle_langs,
+        default_mpv_playback_speed
+    )
+    .execute(&app.database)
+    .await?;
+
+    Ok(())
+}