about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.rs39
-rw-r--r--src/cache/mod.rs82
-rw-r--r--src/cli.rs244
-rw-r--r--src/comments/comment.rs63
-rw-r--r--src/comments/display.rs117
-rw-r--r--src/comments/mod.rs197
-rw-r--r--src/constants.rs79
-rw-r--r--src/download/download_options.rs118
-rw-r--r--src/download/mod.rs140
-rw-r--r--src/main.rs163
-rw-r--r--src/select/cmds.rs82
-rw-r--r--src/select/mod.rs184
-rw-r--r--src/select/selection_file/display.rs103
-rw-r--r--src/select/selection_file/duration.rs102
-rw-r--r--src/select/selection_file/help.str10
-rw-r--r--src/select/selection_file/help.str.license9
-rw-r--r--src/select/selection_file/mod.rs35
-rw-r--r--src/status/mod.rs91
-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
-rw-r--r--src/subscribe/mod.rs181
-rw-r--r--src/update/mod.rs207
-rw-r--r--src/watch/events.rs235
-rw-r--r--src/watch/mod.rs118
30 files changed, 3947 insertions, 0 deletions
diff --git a/src/app.rs b/src/app.rs
new file mode 100644
index 0000000..14b85a3
--- /dev/null
+++ b/src/app.rs
@@ -0,0 +1,39 @@
+// 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 anyhow::{Context, Result};
+use sqlx::{query, sqlite::SqliteConnectOptions, SqlitePool};
+
+use crate::constants;
+
+pub struct App {
+    pub database: SqlitePool,
+}
+
+impl App {
+    pub async fn new() -> Result<Self> {
+        let db_name = constants::database()?;
+
+        let options = SqliteConnectOptions::new()
+            .filename(db_name)
+            .optimize_on_close(true, None)
+            .create_if_missing(true);
+
+        let pool = SqlitePool::connect_with(options)
+            .await
+            .context("Failed to connect to database!")?;
+
+        query(include_str!("storage/video_database/schema.sql"))
+            .execute(&pool)
+            .await?;
+
+        Ok(App { database: pool })
+    }
+}
diff --git a/src/cache/mod.rs b/src/cache/mod.rs
new file mode 100644
index 0000000..ef8491a
--- /dev/null
+++ b/src/cache/mod.rs
@@ -0,0 +1,82 @@
+// 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 anyhow::Result;
+use log::info;
+use tokio::fs;
+
+use crate::{
+    app::App,
+    storage::video_database::{
+        downloader::set_video_cache_path, getters::get_videos, setters::set_state_change, Video,
+        VideoStatus,
+    },
+};
+
+async fn invalidate_video(app: &App, video: &Video, hard: bool) -> Result<()> {
+    info!("Invalidating cache of video: '{}'", video.title);
+
+    if hard {
+        if let Some(path) = &video.cache_path {
+            info!("Removing cached video at: '{}'", path.display());
+            fs::remove_file(path).await?;
+        }
+    }
+
+    set_video_cache_path(app, &video.extractor_hash, None).await?;
+
+    Ok(())
+}
+
+pub async fn invalidate(app: &App, hard: bool) -> Result<()> {
+    let all_cached_things = get_videos(app, &[VideoStatus::Cached], None).await?;
+
+    info!("Got videos to invalidate: '{}'", all_cached_things.len());
+
+    for video in all_cached_things {
+        invalidate_video(app, &video, hard).await?
+    }
+
+    Ok(())
+}
+
+pub async fn maintain(app: &App, all: bool) -> Result<()> {
+    let domain = if all {
+        vec![
+            VideoStatus::Pick,
+            //
+            VideoStatus::Watch,
+            VideoStatus::Cached,
+            VideoStatus::Watched,
+            //
+            VideoStatus::Drop,
+            VideoStatus::Dropped,
+        ]
+    } else {
+        vec![VideoStatus::Watch, VideoStatus::Cached]
+    };
+
+    let cached_videos = get_videos(app, domain.as_slice(), None).await?;
+
+    for vid in cached_videos {
+        if let Some(path) = vid.cache_path.as_ref() {
+            info!("Checking if path ('{}') exists", path.display());
+            if !path.exists() {
+                invalidate_video(app, &vid, false).await?;
+            }
+        }
+        if vid.status_change {
+            info!("Video '{}' has it's changing bit set. This is probably the result of an unexpectet exit. Clearing it", vid.title);
+            set_state_change(app, &vid.extractor_hash, false).await?;
+        }
+    }
+
+    Ok(())
+}
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..4835fc4
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,244 @@
+// 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::PathBuf;
+
+use chrono::NaiveDate;
+use clap::{ArgAction, Args, Parser, Subcommand};
+use url::Url;
+
+use crate::{
+    constants, select::selection_file::duration::Duration,
+    storage::video_database::extractor_hash::LazyExtractorHash,
+};
+
+#[derive(Parser, Debug)]
+#[clap(author, version, about, long_about = None)]
+/// An command line interface to select, download and watch videos
+pub struct CliArgs {
+    #[command(subcommand)]
+    /// The subcommand to execute [default: select]
+    pub command: Option<Command>,
+
+    /// Increase message verbosity
+    #[arg(long="verbose", short = 'v', action = ArgAction::Count)]
+    pub verbosity: u8,
+
+    /// Silence all output
+    #[arg(long, short = 'q')]
+    pub quiet: bool,
+}
+
+#[derive(Subcommand, Debug)]
+pub enum Command {
+    /// Download and cache URLs
+    Download {
+        /// Forcefully re-download all cached videos (i.e. delete the cache path, then download).
+        #[arg(short, long)]
+        force: bool,
+    },
+
+    /// Watch the already cached (and selected) videos
+    Watch {},
+
+    /// Show, which videos have been selected to be watched (and their cache status)
+    Status {},
+
+    /// Perform various tests
+    Check {
+        #[command(subcommand)]
+        command: CheckCommand,
+    },
+
+    /// Display the comments of the currently playing video
+    Comments {},
+    /// Display the description of the currently playing video
+    Description {},
+
+    /// Manipulate the video cache in the database
+    #[command(visible_alias = "db")]
+    Database {
+        #[command(subcommand)]
+        command: CacheCommand,
+    },
+
+    /// Change the state of videos in the database (the default)
+    Select {
+        #[command(subcommand)]
+        cmd: Option<SelectCommand>,
+    },
+
+    /// Update the video database
+    Update {
+        #[arg(short, long, default_value = "20")]
+        /// The number of videos to updating
+        max_backlog: u32,
+
+        #[arg(short, long)]
+        /// The subscriptions to update (can be given multiple times)
+        subscriptions: Vec<String>,
+
+        #[arg(short, long, default_value = "6")]
+        /// How many processes to spawn at the same time
+        concurrent_processes: usize,
+    },
+
+    /// Manipulate subscription
+    #[command(visible_alias = "subs")]
+    Subscriptions {
+        #[command(subcommand)]
+        cmd: SubscriptionCommand,
+    },
+}
+
+impl Default for Command {
+    fn default() -> Self {
+        Self::Select {
+            cmd: Some(SelectCommand::default()),
+        }
+    }
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum SubscriptionCommand {
+    /// Subscribe to an URL
+    Add {
+        #[arg(short, long)]
+        /// The human readable name of the subscription
+        name: Option<String>,
+
+        /// The URL to listen to
+        url: Url,
+    },
+
+    /// Unsubscribe from an URL
+    Remove {
+        /// The human readable name of the subscription
+        name: String,
+    },
+
+    /// Import a bunch of URLs as subscriptions.
+    Import {
+        /// The file containing the URLs. Will use Stdin otherwise.
+        file: Option<PathBuf>,
+
+        /// Remove any previous subscriptions
+        #[arg(short, long)]
+        force: bool
+    },
+
+    /// List all subscriptions
+    List {
+        /// Only show the URLs
+        #[arg(short, long)]
+        url: bool,
+    },
+}
+
+#[derive(Clone, Debug, Args)]
+#[command(infer_subcommands = true)]
+/// Mark the video given by the hash to be watched
+pub struct SharedSelectionCommandArgs {
+    /// The short extractor hash
+    pub hash: LazyExtractorHash,
+
+    pub title: String,
+
+    pub date: NaiveDate,
+
+    pub publisher: String,
+
+    pub duration: Duration,
+
+    pub url: Url,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+#[command(infer_subcommands = true)]
+// NOTE: Keep this in sync with the [`constants::HELP_STR`] constant. <2024-08-20>
+pub enum SelectCommand {
+    /// Open a `git rebase` like file to select the videos to watch (the default)
+    File {
+        /// Include done (watched, dropped) videos
+        #[arg(long, short)]
+        done: bool,
+    },
+
+    Watch {
+        #[command(flatten)]
+        shared: SharedSelectionCommandArgs,
+
+        /// The ordering priority (higher means more at the top)
+        #[arg(short, long)]
+        priority: Option<i64>,
+
+        /// The subtitles to download (e.g. 'en,de,sv')
+        #[arg(short = 'l', long, default_value = constants::DEFAULT_SUBTITLE_LANGS)]
+        subtitle_langs: String,
+
+        /// The speed to set mpv to
+        #[arg(short, long, default_value = "2.7")]
+        speed: f64,
+    },
+
+    /// Mark the video given by the hash to be dropped
+    Drop {
+        #[command(flatten)]
+        shared: SharedSelectionCommandArgs,
+    },
+
+    /// Open the video URL in Firefox's `timesinks.youtube` profile
+    Url {
+        #[command(flatten)]
+        shared: SharedSelectionCommandArgs,
+    },
+
+    /// Reset the videos status to 'Pick'
+    Pick {
+        #[command(flatten)]
+        shared: SharedSelectionCommandArgs,
+    },
+}
+impl Default for SelectCommand {
+    fn default() -> Self {
+        Self::File { done: false }
+    }
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum CheckCommand {
+    /// Check if the given info.json is deserializable
+    InfoJson { path: PathBuf },
+
+    /// Check if the given update info.json is deserializable
+    UpdateInfoJson { path: PathBuf },
+}
+
+#[derive(Subcommand, Clone, Copy, Debug)]
+pub enum CacheCommand {
+    /// Invalidate all cache entries
+    Invalidate {
+        /// Also delete the cache path
+        #[arg(short, long)]
+        hard: bool,
+    },
+
+    /// Perform basic maintenance operations on the database.
+    /// This helps recovering from invalid db states after a crash (or force exit via CTRL+C).
+    ///
+    /// 1. Check every path for validity (removing all invalid cache entries)
+    /// 2. Reset all `status_change` bits of videos to false.
+    #[command(verbatim_doc_comment)]
+    Maintain {
+        /// Check every video (otherwise only the videos to be watched are checked)
+        #[arg(short, long)]
+        all: bool,
+    },
+}
diff --git a/src/comments/comment.rs b/src/comments/comment.rs
new file mode 100644
index 0000000..752c510
--- /dev/null
+++ b/src/comments/comment.rs
@@ -0,0 +1,63 @@
+// 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 yt_dlp::wrapper::info_json::Comment;
+
+#[derive(Debug, Clone)]
+pub struct CommentExt {
+    pub value: Comment,
+    pub replies: Vec<CommentExt>,
+}
+
+#[derive(Debug, Default)]
+pub struct Comments {
+    pub(super) vec: Vec<CommentExt>,
+}
+
+impl Comments {
+    pub fn new() -> Self {
+        Self::default()
+    }
+    pub fn push(&mut self, value: CommentExt) {
+        self.vec.push(value);
+    }
+    pub fn get_mut(&mut self, key: &str) -> Option<&mut CommentExt> {
+        self.vec.iter_mut().filter(|c| c.value.id.id == key).last()
+    }
+    pub fn insert(&mut self, key: &str, value: CommentExt) {
+        let parent = self
+            .vec
+            .iter_mut()
+            .filter(|c| c.value.id.id == key)
+            .last()
+            .expect("One of these should exist");
+        parent.push_reply(value);
+    }
+}
+impl CommentExt {
+    pub fn push_reply(&mut self, value: CommentExt) {
+        self.replies.push(value)
+    }
+    pub fn get_mut_reply(&mut self, key: &str) -> Option<&mut CommentExt> {
+        self.replies
+            .iter_mut()
+            .filter(|c| c.value.id.id == key)
+            .last()
+    }
+}
+
+impl From<Comment> for CommentExt {
+    fn from(value: Comment) -> Self {
+        Self {
+            replies: vec![],
+            value,
+        }
+    }
+}
diff --git a/src/comments/display.rs b/src/comments/display.rs
new file mode 100644
index 0000000..7000063
--- /dev/null
+++ b/src/comments/display.rs
@@ -0,0 +1,117 @@
+// 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;
+
+use chrono::{Local, TimeZone};
+use chrono_humanize::{Accuracy, HumanTime, Tense};
+
+use crate::comments::comment::CommentExt;
+
+use super::comment::Comments;
+
+impl Comments {
+    pub fn render(&self, color: bool) -> String {
+        self.render_help(color).expect("This should never fail.")
+    }
+
+    fn render_help(&self, color: bool) -> Result<String, std::fmt::Error> {
+        let mut f = String::new();
+
+        macro_rules! c {
+            ($color_str:expr, $write:ident, $color:expr) => {
+                if $color {
+                    $write.write_str(concat!("\x1b[", $color_str, "m"))?
+                }
+            };
+        }
+
+        fn format(
+            comment: &CommentExt,
+            f: &mut String,
+            ident_count: u32,
+            color: bool,
+        ) -> std::fmt::Result {
+            let ident = &(0..ident_count).map(|_| " ").collect::<String>();
+            let value = &comment.value;
+
+            f.write_str(ident)?;
+
+            if value.author_is_uploader {
+                c!("91;1", f, color);
+            } else {
+                c!("35", f, color);
+            }
+
+            f.write_str(&value.author)?;
+            c!("0", f, color);
+            if value.edited || value.is_favorited {
+                f.write_str("[")?;
+                if value.edited {
+                    f.write_str("")?;
+                }
+                if value.edited && value.is_favorited {
+                    f.write_str(" ")?;
+                }
+                if value.is_favorited {
+                    f.write_str("")?;
+                }
+                f.write_str("]")?;
+            }
+
+            c!("36;1", f, color);
+            write!(
+                f,
+                " {}",
+                HumanTime::from(
+                    Local
+                        .timestamp_opt(value.timestamp, 0)
+                        .single()
+                        .expect("This should be valid")
+                )
+                .to_text_en(Accuracy::Rough, Tense::Past)
+            )?;
+            c!("0", f, color);
+
+            // c!("31;1", f);
+            // f.write_fmt(format_args!(" [{}]", comment.value.like_count))?;
+            // c!("0", f);
+
+            f.write_str(":\n")?;
+            f.write_str(ident)?;
+
+            f.write_str(&value.text.replace('\n', &format!("\n{}", ident)))?;
+            f.write_str("\n")?;
+
+            if !comment.replies.is_empty() {
+                let mut children = comment.replies.clone();
+                children.sort_by(|a, b| a.value.timestamp.cmp(&b.value.timestamp));
+
+                for child in children {
+                    format(&child, f, ident_count + 4, color)?;
+                }
+            } else {
+                f.write_str("\n")?;
+            }
+
+            Ok(())
+        }
+
+        if !&self.vec.is_empty() {
+            let mut children = self.vec.clone();
+            children.sort_by(|a, b| b.value.like_count.cmp(&a.value.like_count));
+
+            for child in children {
+                format(&child, &mut f, 0, color)?
+            }
+        }
+        Ok(f)
+    }
+}
diff --git a/src/comments/mod.rs b/src/comments/mod.rs
new file mode 100644
index 0000000..eba391e
--- /dev/null
+++ b/src/comments/mod.rs
@@ -0,0 +1,197 @@
+// 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::{
+    env,
+    fs::{self},
+    io::Write,
+    mem,
+    path::PathBuf,
+    process::{Command, Stdio},
+};
+
+use anyhow::{bail, Context, Result};
+use comment::{CommentExt, Comments};
+use regex::Regex;
+use yt_dlp::wrapper::info_json::{Comment, InfoJson, Parent};
+
+use crate::{
+    app::App,
+    storage::video_database::{
+        getters::{get_currently_playing_video, get_video_info_json},
+        Video,
+    },
+};
+
+mod comment;
+mod display;
+
+fn get_runtime_path(component: &'static str) -> anyhow::Result<PathBuf> {
+    let out: PathBuf = format!(
+        "{}/{}",
+        env::var("XDG_RUNTIME_DIR").expect("This should always exist"),
+        component
+    )
+    .into();
+    fs::create_dir_all(out.parent().expect("Parent should exist"))?;
+    Ok(out)
+}
+
+const STATUS_PATH: &str = "ytcc/running";
+pub fn status_path() -> anyhow::Result<PathBuf> {
+    get_runtime_path(STATUS_PATH)
+}
+
+pub async fn get_comments(app: &App) -> Result<Comments> {
+    let currently_playing_video: Video =
+        if let Some(video) = get_currently_playing_video(&app).await? {
+            video
+        } else {
+            bail!("Could not find a currently playing video!");
+        };
+
+    let mut info_json: InfoJson = get_video_info_json(&currently_playing_video)
+        .await?
+        .expect("A currently *playing* must be cached. And thus the info.json should be available");
+
+    let base_comments = mem::take(&mut info_json.comments).expect("A video should have comments");
+    drop(info_json);
+
+    let mut comments = Comments::new();
+    base_comments.into_iter().for_each(|c| {
+        if let Parent::Id(id) = &c.parent {
+            comments.insert(&(id.clone()), CommentExt::from(c));
+        } else {
+            comments.push(CommentExt::from(c));
+        }
+    });
+
+    comments.vec.iter_mut().for_each(|comment| {
+       let replies = mem::take(&mut comment.replies);
+       let mut output_replies: Vec<CommentExt>  = vec![];
+
+       let re = Regex::new(r"\u{200b}?(@[^\t\s]+)\u{200b}?").unwrap();
+       for reply in replies {
+           if let Some(replyee_match) =  re.captures(&reply.value.text){
+               let full_match = replyee_match.get(0).expect("This always exists");
+               let text = reply.
+                   value.
+                   text[0..full_match.start()]
+                   .to_owned()
+                   +
+                   &reply
+                   .value
+                   .text[full_match.end()..];
+               let text: &str = text.trim().trim_matches('\u{200b}');
+
+               let replyee = replyee_match.get(1).expect("This should exist").as_str();
+
+
+               if let Some(parent) = output_replies
+                   .iter_mut()
+                   // .rev()
+                   .flat_map(|com| &mut com.replies)
+                   .flat_map(|com| &mut com.replies)
+                   .flat_map(|com| &mut com.replies)
+                   .filter(|com| com.value.author == replyee)
+                   .last()
+               {
+                   parent.replies.push(CommentExt::from(Comment {
+                       text: text.to_owned(),
+                       ..reply.value
+                   }))
+               } else if let Some(parent) = output_replies
+                   .iter_mut()
+                   // .rev()
+                   .flat_map(|com| &mut com.replies)
+                   .flat_map(|com| &mut com.replies)
+                   .filter(|com| com.value.author == replyee)
+                   .last()
+               {
+                   parent.replies.push(CommentExt::from(Comment {
+                       text: text.to_owned(),
+                       ..reply.value
+                   }))
+               } else if let Some(parent) = output_replies
+                   .iter_mut()
+                   // .rev()
+                   .flat_map(|com| &mut com.replies)
+                   .filter(|com| com.value.author == replyee)
+                   .last()
+               {
+                   parent.replies.push(CommentExt::from(Comment {
+                       text: text.to_owned(),
+                       ..reply.value
+                   }))
+               } else if let Some(parent) = output_replies.iter_mut()
+                   // .rev()
+                   .filter(|com| com.value.author == replyee)
+                   .last()
+               {
+                   parent.replies.push(CommentExt::from(Comment {
+                       text: text.to_owned(),
+                       ..reply.value
+                   }))
+               } else {
+                   eprintln!(
+                   "Failed to find a parent for ('{}') both directly and via replies! The reply text was:\n'{}'\n",
+                   replyee,
+                   reply.value.text
+               );
+                   output_replies.push(reply);
+               }
+           } else {
+               output_replies.push(reply);
+           }
+       }
+       comment.replies = output_replies;
+    });
+
+    Ok(comments)
+}
+
+pub async fn comments(app: &App) -> Result<()> {
+    let comments = get_comments(app).await?;
+
+    let mut less = Command::new("less")
+        .args(["--raw-control-chars"])
+        .stdin(Stdio::piped())
+        .stderr(Stdio::inherit())
+        .spawn()
+        .context("Failed to run less")?;
+
+    let mut child = Command::new("fmt")
+        .args(["--uniform-spacing", "--split-only", "--width=90"])
+        .stdin(Stdio::piped())
+        .stderr(Stdio::inherit())
+        .stdout(less.stdin.take().expect("Should be open"))
+        .spawn()
+        .context("Failed to run fmt")?;
+
+    let mut stdin = child.stdin.take().context("Failed to open stdin")?;
+    std::thread::spawn(move || {
+        stdin
+            .write_all(comments.render(true).as_bytes())
+            .expect("Should be able to write to stdin of fmt");
+    });
+
+    let _ = less.wait().context("Failed to await less")?;
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod test {
+    #[test]
+    fn test_string_replacement() {
+        let s = "A \n\nB\n\nC".to_owned();
+        assert_eq!("A \n  \n  B\n  \n  C", s.replace('\n', "\n  "))
+    }
+}
diff --git a/src/constants.rs b/src/constants.rs
new file mode 100644
index 0000000..f4eef3d
--- /dev/null
+++ b/src/constants.rs
@@ -0,0 +1,79 @@
+// 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::{env::temp_dir, path::PathBuf};
+
+use anyhow::Context;
+
+pub const HELP_STR: &str = include_str!("./select/selection_file/help.str");
+pub const LOCAL_COMMENTS_LENGTH: usize = 1000;
+
+// NOTE: KEEP THIS IN SYNC WITH THE `mpv_playback_speed` in `cli.rs` <2024-08-20>
+pub const DEFAULT_MPV_PLAYBACK_SPEED: f64 = 2.7;
+pub const DEFAULT_SUBTITLE_LANGS: &str = "en";
+
+pub const CONCURRENT_DOWNLOADS: u32 = 5;
+// We download to the temp dir to avoid taxing the disk
+pub fn download_dir() -> PathBuf {
+    const DOWNLOAD_DIR: &str = "/tmp/yt";
+    PathBuf::from(DOWNLOAD_DIR)
+}
+
+const PREFIX: &str = "yt";
+fn get_runtime_path(name: &'static str) -> anyhow::Result<PathBuf> {
+    let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX)?;
+    xdg_dirs
+        .place_runtime_file(name)
+        .with_context(|| format!("Failed to place runtime file: '{}'", name))
+}
+fn get_data_path(name: &'static str) -> anyhow::Result<PathBuf> {
+    let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX)?;
+    xdg_dirs
+        .place_data_file(name)
+        .with_context(|| format!("Failed to place data file: '{}'", name))
+}
+fn get_config_path(name: &'static str) -> anyhow::Result<PathBuf> {
+    let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX)?;
+    xdg_dirs
+        .place_config_file(name)
+        .with_context(|| format!("Failed to place config file: '{}'", name))
+}
+
+pub fn mpv_config_path() -> anyhow::Result<PathBuf> {
+    const MPV_CONFIG_PATH: &str = "mpv.conf";
+    get_config_path(MPV_CONFIG_PATH)
+}
+pub fn mpv_input_path() -> anyhow::Result<PathBuf> {
+    const MPV_INPUT_CONFIG_PATH: &str = "mpv.input.conf";
+    get_config_path(MPV_INPUT_CONFIG_PATH)
+}
+
+pub fn status_path() -> anyhow::Result<PathBuf> {
+    const STATUS_PATH: &str = "running.info.json";
+    get_runtime_path(STATUS_PATH)
+}
+pub fn last_select() -> anyhow::Result<PathBuf> {
+    const LAST_SELECT: &str = "selected.yts";
+    get_runtime_path(LAST_SELECT)
+}
+
+pub fn database() -> anyhow::Result<PathBuf> {
+    const DATABASE: &str = "videos.sqlite";
+    get_data_path(DATABASE)
+}
+pub fn subscriptions() -> anyhow::Result<PathBuf> {
+    const SUBSCRIPTIONS: &str = "subscriptions.json";
+    get_data_path(SUBSCRIPTIONS)
+}
+
+pub fn cache_path() -> PathBuf {
+    let temp_dir = temp_dir();
+    temp_dir.join("ytc")
+}
diff --git a/src/download/download_options.rs b/src/download/download_options.rs
new file mode 100644
index 0000000..17cf66c
--- /dev/null
+++ b/src/download/download_options.rs
@@ -0,0 +1,118 @@
+// 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 serde_json::{json, Value};
+
+use crate::{constants, storage::video_database::YtDlpOptions};
+
+// {
+//     "ratelimit": conf.ratelimit if conf.ratelimit > 0 else None,
+//     "retries": conf.retries,
+//     "merge_output_format": conf.merge_output_format,
+//     "restrictfilenames": conf.restrict_filenames,
+//     "ignoreerrors": False,
+//     "postprocessors": [{"key": "FFmpegMetadata"}],
+//     "logger": _ytdl_logger
+// }
+
+pub fn download_opts(additional_opts: YtDlpOptions) -> serde_json::Map<String, serde_json::Value> {
+    match json!({
+      "extract_flat": false,
+      "extractor_args": {
+        "youtube": {
+          "comment_sort": [
+            "top"
+          ],
+          "max_comments": [
+            "150",
+            "all",
+            "100"
+          ]
+        }
+      },
+      "ffmpeg_location": env!("FFMPEG_LOCATION"),
+      "format": "bestvideo[height<=?1080]+bestaudio/best",
+      "fragment_retries": 10,
+      "getcomments": true,
+      "ignoreerrors": false,
+      "retries": 10,
+
+      "writeinfojson": true,
+      "writeannotations": true,
+      "writesubtitles": true,
+      "writeautomaticsub": true,
+
+      "outtmpl": {
+        "default": constants::download_dir().join("%(channel)s/%(title)s.%(ext)s"),
+        "chapter": "%(title)s - %(section_number)03d %(section_title)s [%(id)s].%(ext)s"
+      },
+      "compat_opts": {},
+      "forceprint": {},
+      "print_to_file": {},
+      "windowsfilenames": false,
+      "restrictfilenames": false,
+      "trim_file_names": false,
+      "postprocessors": [
+        {
+          "api": "https://sponsor.ajay.app",
+          "categories": [
+            "interaction",
+            "intro",
+            "music_offtopic",
+            "sponsor",
+            "outro",
+            "poi_highlight",
+            "preview",
+            "selfpromo",
+            "filler",
+            "chapter"
+          ],
+          "key": "SponsorBlock",
+          "when": "after_filter"
+        },
+        {
+          "force_keyframes": false,
+          "key": "ModifyChapters",
+          "remove_chapters_patterns": [],
+          "remove_ranges": [],
+          "remove_sponsor_segments": [
+            "sponsor"
+          ],
+          "sponsorblock_chapter_title": "[SponsorBlock]: %(category_names)l"
+        },
+        {
+          "add_chapters": true,
+          "add_infojson": null,
+          "add_metadata": false,
+          "key": "FFmpegMetadata"
+        },
+        {
+          "key": "FFmpegConcat",
+          "only_multi_video": true,
+          "when": "playlist"
+        }
+      ]
+    }) {
+        serde_json::Value::Object(mut obj) => {
+            obj.insert(
+                "subtitleslangs".to_owned(),
+                serde_json::Value::Array(
+                    additional_opts
+                        .subtitle_langs
+                        .split(',')
+                        .map(|val| Value::String(val.to_owned()))
+                        .collect::<Vec<_>>(),
+                ),
+            );
+            obj
+        }
+        _ => unreachable!("This is an object"),
+    }
+}
diff --git a/src/download/mod.rs b/src/download/mod.rs
new file mode 100644
index 0000000..62fae84
--- /dev/null
+++ b/src/download/mod.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>.
+
+use std::time::Duration;
+
+use crate::{
+    app::App,
+    download::download_options::download_opts,
+    storage::video_database::{
+        downloader::{get_next_uncached_video, set_video_cache_path}, extractor_hash::ExtractorHash, getters::get_video_yt_dlp_opts, Video
+    },
+};
+
+use anyhow::{Context, Result};
+use log::{debug, info};
+use tokio::{task::JoinHandle, time};
+
+mod download_options;
+
+#[derive(Debug)]
+pub struct CurrentDownload {
+    task_handle: JoinHandle<Result<()>>,
+    extractor_hash: ExtractorHash,
+}
+
+impl CurrentDownload {
+    fn new_from_video(video: Video) -> Self {
+        let extractor_hash = video.extractor_hash.clone();
+
+        let task_handle = tokio::spawn(async move {
+            // FIXME: Remove this app reconstruction <2024-07-29>
+            let new_app = App::new().await?;
+
+            Downloader::actually_cache_video(&new_app, &video)
+                .await
+                .with_context(|| format!("Failed to cache video: '{}'", video.title))?;
+            Ok(())
+        });
+
+        Self {
+            task_handle,
+            extractor_hash,
+        }
+    }
+}
+
+pub struct Downloader {
+    current_download: Option<CurrentDownload>,
+}
+
+impl Downloader {
+    pub fn new() -> Self {
+        Self {
+            current_download: None,
+        }
+    }
+
+    /// The entry point to the Downloader.
+    /// This Downloader will periodically check if the database has changed, and then also
+    /// change which videos it downloads.
+    /// This will run, until the database doesn't contain any watchable videos
+    pub async fn consume(&mut self, app: &App) -> Result<()> {
+        while let Some(next_video) = get_next_uncached_video(app).await? {
+            if let Some(_) = &self.current_download {
+                let current_download = self.current_download.take().expect("Is Some");
+
+                if current_download.task_handle.is_finished() {
+                    current_download.task_handle.await??;
+                    continue;
+                }
+
+                if next_video.extractor_hash != current_download.extractor_hash {
+                    info!(
+                    "Noticed, that the next video is not the video being downloaded, replacing it ('{}' vs. '{}')!",
+                        next_video.extractor_hash.into_short_hash(app).await?, current_download.extractor_hash.into_short_hash(app).await?
+                    );
+
+                    // Replace the currently downloading video
+                    current_download.task_handle.abort();
+
+                    let new_current_download = CurrentDownload::new_from_video(next_video);
+
+                    self.current_download = Some(new_current_download);
+                } else {
+                    debug!(
+                        "Currently downloading '{}'",
+                        current_download.extractor_hash.into_short_hash(app).await?
+                    );
+                    // Reset the taken value
+                    self.current_download = Some(current_download);
+                    time::sleep(Duration::new(1, 0)).await;
+                }
+            } else {
+                info!(
+                    "No video is being downloaded right now, setting it to '{}'",
+                    next_video.title
+                );
+                let new_current_download = CurrentDownload::new_from_video(next_video);
+                self.current_download = Some(new_current_download);
+            }
+
+            // if get_allocated_cache().await? < CONCURRENT {
+            //     .cache_video(next_video).await?;
+            // }
+        }
+
+        info!("Finished downloading!");
+        Ok(())
+    }
+
+    async fn actually_cache_video(app: &App, video: &Video) -> Result<()> {
+        debug!("Download started: {}", &video.title);
+
+        let addional_opts = get_video_yt_dlp_opts(&app, &video.extractor_hash).await?;
+
+        let result = yt_dlp::download(&[video.url.clone()], &download_opts(addional_opts))
+            .await
+            .with_context(|| format!("Failed to download video: '{}'", video.title))?;
+
+        assert_eq!(result.len(), 1);
+        let result = &result[0];
+
+        set_video_cache_path(app, &video.extractor_hash, Some(&result)).await?;
+
+        info!(
+            "Video '{}' was downlaoded to path: {}",
+            video.title,
+            result.display()
+        );
+
+        Ok(())
+    }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..cfd6adc
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,163 @@
+// 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, fs};
+
+use anyhow::{bail, Context, Result};
+use app::App;
+use cache::invalidate;
+use clap::Parser;
+use cli::{CacheCommand, CheckCommand, SelectCommand, SubscriptionCommand};
+use select::cmds::handle_select_cmd;
+use tokio::{
+    fs::File,
+    io::{stdin, BufReader},
+};
+use url::Url;
+use yt_dlp::wrapper::info_json::InfoJson;
+
+use crate::{cli::Command, storage::subscriptions::get_subscriptions};
+
+pub mod app;
+pub mod cli;
+
+pub mod cache;
+pub mod comments;
+pub mod constants;
+pub mod download;
+pub mod select;
+pub mod status;
+pub mod storage;
+pub mod subscribe;
+pub mod update;
+pub mod watch;
+
+#[tokio::main]
+async fn main() -> Result<()> {
+    let args = cli::CliArgs::parse();
+    stderrlog::new()
+        .module(module_path!())
+        .modules(&["yt_dlp".to_owned(), "libmpv2".to_owned()])
+        .quiet(args.quiet)
+        .show_module_names(false)
+        .color(stderrlog::ColorChoice::Auto)
+        .verbosity(args.verbosity as usize)
+        .timestamp(stderrlog::Timestamp::Off)
+        .init()
+        .expect("Let's just hope that this does not panic");
+
+    let app = App::new().await?;
+
+    match args.command.unwrap_or(Command::default()) {
+        Command::Download { force } => {
+            if force {
+                invalidate(&app, true).await?;
+            }
+
+            download::Downloader::new().consume(&app).await?;
+        }
+        Command::Select { cmd } => {
+            let cmd = cmd.unwrap_or(SelectCommand::default());
+
+            match cmd {
+                SelectCommand::File { done } => select::select(&app, done).await?,
+                _ => handle_select_cmd(&app, cmd, None).await?,
+            }
+        }
+        Command::Update {
+            max_backlog,
+            subscriptions,
+            concurrent_processes,
+        } => {
+            let all_subs = get_subscriptions(&app).await?;
+
+            for sub in &subscriptions {
+                if let None = all_subs.0.get(sub) {
+                    bail!(
+                        "Your specified subscription to update '{}' is not a subscription!",
+                        sub
+                    )
+                }
+            }
+
+            update::update(&app, max_backlog, subscriptions, concurrent_processes).await?;
+        }
+
+        Command::Subscriptions { cmd } => match cmd {
+            SubscriptionCommand::Add { name, url } => {
+                subscribe::subscribe(&app, name, url)
+                    .await
+                    .context("Failed to add a subscription")?;
+            }
+            SubscriptionCommand::Remove { name } => {
+                subscribe::unsubscribe(&app, name)
+                    .await
+                    .context("Failed to remove a subscription")?;
+            }
+            SubscriptionCommand::List { url } => {
+                let all_subs = get_subscriptions(&app).await?;
+
+                if url {
+                    for val in all_subs.0.values() {
+                        println!("{}", val.url);
+                    }
+                } else {
+                    for (key, val) in all_subs.0 {
+                        println!("{}: '{}'", key, val.url);
+                    }
+                }
+            }
+            SubscriptionCommand::Import { file, force } => {
+                if let Some(file) = file {
+                    let f = File::open(file).await?;
+
+                    subscribe::import(&app, BufReader::new(f), force).await?
+                } else {
+                    subscribe::import(&app, BufReader::new(stdin()), force).await?
+                };
+            }
+        },
+
+        Command::Watch {} => watch::watch(&app).await?,
+
+        Command::Status {} => status::show(&app).await?,
+
+        Command::Database { command } => match command {
+            CacheCommand::Invalidate { hard } => cache::invalidate(&app, hard).await?,
+            CacheCommand::Maintain { all } => cache::maintain(&app, all).await?,
+        },
+
+        Command::Check { command } => match command {
+            CheckCommand::InfoJson { path } => {
+                let string = fs::read_to_string(&path)
+                    .with_context(|| format!("Failed to read '{}' to string!", path.display()))?;
+
+                let _: InfoJson =
+                    serde_json::from_str(&string).context("Failed to deserialize value")?;
+            }
+            CheckCommand::UpdateInfoJson { path } => {
+                let string = fs::read_to_string(&path)
+                    .with_context(|| format!("Failed to read '{}' to string!", path.display()))?;
+
+                let _: HashMap<Url, InfoJson> =
+                    serde_json::from_str(&string).context("Failed to deserialize value")?;
+            }
+        },
+        Command::Comments {} => {
+            comments::comments(&app).await?;
+        }
+        Command::Description {} => {
+            todo!()
+            // description::description(&app).await?;
+        }
+    }
+
+    Ok(())
+}
diff --git a/src/select/cmds.rs b/src/select/cmds.rs
new file mode 100644
index 0000000..40e5b17
--- /dev/null
+++ b/src/select/cmds.rs
@@ -0,0 +1,82 @@
+// 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 crate::{
+    app::App,
+    cli::SelectCommand,
+    storage::video_database::{
+        getters::get_video_by_hash,
+        setters::{set_video_options, set_video_status},
+        VideoOptions, VideoStatus,
+    },
+};
+
+use anyhow::{Context, Result};
+
+pub async fn handle_select_cmd(
+    app: &App,
+    cmd: SelectCommand,
+    line_number: Option<i64>,
+) -> Result<()> {
+    match cmd {
+        SelectCommand::Pick { shared } => {
+            set_video_status(
+                app,
+                &shared.hash.realize(app).await?,
+                VideoStatus::Pick,
+                line_number,
+            )
+            .await?
+        }
+        SelectCommand::Drop { shared } => {
+            set_video_status(
+                app,
+                &shared.hash.realize(app).await?,
+                VideoStatus::Drop,
+                line_number,
+            )
+            .await?
+        }
+        SelectCommand::Watch {
+            shared,
+            priority,
+            subtitle_langs,
+            speed,
+        } => {
+            let hash = shared.hash.realize(&app).await?;
+            let video = get_video_by_hash(app, &hash).await?;
+            let video_options = VideoOptions::new(subtitle_langs, speed);
+            let priority = if let Some(pri) = priority {
+                Some(pri)
+            } else if let Some(pri) = line_number {
+                Some(pri)
+            } else {
+                None
+            };
+
+            if let Some(_) = video.cache_path {
+                set_video_status(app, &hash, VideoStatus::Cached, priority).await?;
+            } else {
+                set_video_status(app, &hash, VideoStatus::Watch, priority).await?;
+            }
+
+            set_video_options(app, hash, &video_options).await?;
+        }
+
+        SelectCommand::Url { shared } => {
+            let mut firefox = std::process::Command::new("firefox");
+            firefox.args(["-P", "timesinks.youtube"]);
+            firefox.arg(shared.url.as_str());
+            let _handle = firefox.spawn().context("Failed to run firefox")?;
+        }
+        SelectCommand::File { .. } => unreachable!("This should have been filtered out"),
+    }
+    Ok(())
+}
diff --git a/src/select/mod.rs b/src/select/mod.rs
new file mode 100644
index 0000000..6774ce6
--- /dev/null
+++ b/src/select/mod.rs
@@ -0,0 +1,184 @@
+// 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::{
+    env::{self},
+    fs,
+    io::{BufRead, Write},
+    io::{BufReader, BufWriter},
+};
+
+use crate::{
+    app::App,
+    cli::CliArgs,
+    constants::{last_select, HELP_STR},
+    storage::video_database::{getters::get_videos, VideoStatus},
+};
+
+use anyhow::{bail, Context, Result};
+use clap::Parser;
+use cmds::handle_select_cmd;
+use futures::future::join_all;
+use selection_file::process_line;
+use tempfile::Builder;
+use tokio::process::Command;
+
+pub mod cmds;
+pub mod selection_file;
+
+pub async fn select(app: &App, done: bool) -> Result<()> {
+    let matching_videos = if done {
+        get_videos(
+            app,
+            &[
+                VideoStatus::Pick,
+                //
+                VideoStatus::Watch,
+                VideoStatus::Cached,
+                VideoStatus::Watched,
+                //
+                VideoStatus::Drop,
+                VideoStatus::Dropped,
+            ],
+            None,
+        )
+        .await?
+    } else {
+        get_videos(
+            app,
+            &[
+                VideoStatus::Pick,
+                //
+                VideoStatus::Watch,
+                VideoStatus::Cached,
+            ],
+            None,
+        )
+        .await?
+    };
+
+    // Warmup the cache for the display rendering of the videos.
+    // Otherwise the futures would all try to warm it up at the same time.
+    if let Some(vid) = matching_videos.get(0) {
+        let _ = vid.to_select_file_display(app).await?;
+    }
+
+    let lines: Vec<String> = join_all(
+        matching_videos
+            .iter()
+            .map(|vid| async { vid.to_select_file_display(app).await })
+            .collect::<Vec<_>>(),
+    )
+    .await
+    .into_iter()
+    .collect::<Result<Vec<String>>>()?;
+
+    let temp_file = Builder::new()
+        .prefix("yt_video_select-")
+        .suffix(".yts")
+        .rand_bytes(6)
+        .tempfile()
+        .context("Failed to get tempfile")?;
+
+    {
+        let mut edit_file = BufWriter::new(&temp_file);
+
+        lines.iter().for_each(|line| {
+            edit_file
+                .write_all(line.as_bytes())
+                .expect("This write should not fail");
+        });
+
+        // edit_file.write_all(get_help().await?.as_bytes())?;
+        edit_file.write_all(HELP_STR.as_bytes())?;
+        edit_file.flush().context("Failed to flush edit file")?;
+
+        let editor = env::var("EDITOR").unwrap_or("nvim".to_owned());
+
+        let mut nvim = Command::new(editor);
+        nvim.arg(temp_file.path());
+        let status = nvim.status().await.context("Falied to run nvim")?;
+        if !status.success() {
+            bail!("nvim exited with error status: {}", status)
+        }
+    }
+
+    let read_file = temp_file.reopen()?;
+    fs::copy(
+        temp_file.path(),
+        last_select().context("Failed to get the persistent selection file path")?,
+    )
+    .context("Failed to persist selection file")?;
+
+    let reader = BufReader::new(&read_file);
+
+    let mut line_number = 0;
+    for line in reader.lines() {
+        let line = line.context("Failed to read a line")?;
+
+        if let Some(line) = process_line(&line)? {
+            line_number -= 1;
+
+            // debug!(
+            //     "Parsed command: `{}`",
+            //     line.iter()
+            //         .map(|val| format!("\"{}\"", val))
+            //         .collect::<Vec<String>>()
+            //         .join(" ")
+            // );
+
+            let arg_line = ["yt", "select"]
+                .into_iter()
+                .chain(line.iter().map(|val| val.as_str()));
+
+            let args = CliArgs::parse_from(arg_line);
+
+            let cmd = if let crate::cli::Command::Select { cmd } =
+                args.command.expect("This will be some")
+            {
+                cmd
+            } else {
+                unreachable!("This is checked in the `filter_line` function")
+            };
+
+            handle_select_cmd(
+                &app,
+                cmd.expect("This value should always be some here"),
+                Some(line_number),
+            )
+            .await?
+        }
+    }
+
+    Ok(())
+}
+
+// // FIXME: There should be no reason why we need to re-run yt, just to get the help string. But I've
+// // jet to find a way to do it with out the extra exec <2024-08-20>
+// async fn get_help() -> Result<String> {
+//     let binary_name = current_exe()?;
+//     let cmd = Command::new(binary_name)
+//         .args(&["select", "--help"])
+//         .output()
+//         .await?;
+//
+//     assert_eq!(cmd.status.code(), Some(0));
+//
+//     let output = String::from_utf8(cmd.stdout).expect("Our help output was not utf8?");
+//
+//     let out = output
+//         .lines()
+//         .map(|line| format!("# {}\n", line))
+//         .collect::<String>();
+//
+//     debug!("Returning help: '{}'", &out);
+//
+//     Ok(out)
+// }
diff --git a/src/select/selection_file/display.rs b/src/select/selection_file/display.rs
new file mode 100644
index 0000000..12d128c
--- /dev/null
+++ b/src/select/selection_file/display.rs
@@ -0,0 +1,103 @@
+// 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;
+
+use anyhow::Result;
+use chrono::DateTime;
+use log::debug;
+
+use crate::{
+    app::App,
+    select::selection_file::duration::Duration,
+    storage::video_database::{getters::get_video_opts, Video},
+};
+
+macro_rules! c {
+    ($color:expr, $format:expr) => {
+        format!("\x1b[{}m{}\x1b[0m", $color, $format)
+    };
+}
+
+impl Video {
+    pub async fn to_select_file_display(&self, app: &App) -> Result<String> {
+        let mut f = String::new();
+
+        let opts = get_video_opts(app, &self.extractor_hash)
+            .await?
+            .to_cli_flags();
+        let opts_white = if !opts.is_empty() { " " } else { "" };
+
+        let publish_date = if let Some(date) = self.publish_date {
+            DateTime::from_timestamp(date, 0)
+                .expect("This should not fail")
+                .format("%Y-%m-%d")
+                .to_string()
+        } else {
+            "[No release date]".to_owned()
+        };
+
+        let parent_subscription_name = if let Some(sub) = &self.parent_subscription_name {
+            sub.replace('"', "'")
+        } else {
+            "[No author]".to_owned()
+        };
+
+        debug!("Formatting video for selection file: {}", self.title);
+        write!(
+            f,
+            r#"{}{}{} {} "{}" "{}" "{}" "{}" "{}"{}"#,
+            self.status.as_command(),
+            opts_white,
+            opts,
+            self.extractor_hash.into_short_hash(app).await?,
+            self.title.replace(['"', '„', '”'], "'"),
+            publish_date,
+            parent_subscription_name,
+            Duration::from(self.duration),
+            self.url.as_str().replace('"', "\\\""),
+            "\n"
+        )?;
+
+        Ok(f)
+    }
+
+    pub fn to_color_display(&self) -> String {
+        let mut f = String::new();
+
+        let publish_date = if let Some(date) = self.publish_date {
+            DateTime::from_timestamp(date, 0)
+                .expect("This should not fail")
+                .format("%Y-%m-%d")
+                .to_string()
+        } else {
+            "[No release date]".to_owned()
+        };
+
+        let parent_subscription_name = if let Some(sub) = &self.parent_subscription_name {
+            sub.replace('"', "'")
+        } else {
+            "[No author]".to_owned()
+        };
+
+        write!(
+            f,
+            r#"{} {} {} {} {}"#,
+            c!("31;1", self.status.as_command()),
+            c!("32;1", self.title.replace(['"', '„', '”'], "'")),
+            c!("37;1", publish_date),
+            c!("34;1", parent_subscription_name),
+            c!("35;1", Duration::from(self.duration)),
+        )
+        .expect("This write should always work");
+
+        f
+    }
+}
diff --git a/src/select/selection_file/duration.rs b/src/select/selection_file/duration.rs
new file mode 100644
index 0000000..4224ead
--- /dev/null
+++ b/src/select/selection_file/duration.rs
@@ -0,0 +1,102 @@
+// 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::str::FromStr;
+
+use anyhow::{Context, Result};
+
+#[derive(Copy, Clone, Debug)]
+pub struct Duration {
+    time: u32,
+}
+
+impl FromStr for Duration {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        fn parse_num(str: &str, suffix: char) -> Result<u32> {
+            str.strip_suffix(suffix)
+                .expect("it has a 'h' suffix")
+                .parse::<u32>()
+                .context("Failed to parse hours")
+        }
+
+        let buf: Vec<_> = s.split(' ').collect();
+
+        let hours;
+        let minutes;
+        let seconds;
+
+        assert_eq!(buf.len(), 2, "Other lengths should not happen");
+
+        if buf[0].ends_with('h') {
+            hours = parse_num(buf[0], 'h')?;
+            minutes = parse_num(buf[1], 'm')?;
+            seconds = 0;
+        } else if buf[0].ends_with('m') {
+            hours = 0;
+            minutes = parse_num(buf[0], 'm')?;
+            seconds = parse_num(buf[1], 's')?;
+        } else {
+            unreachable!("The first part always ends with 'h' or 'm'")
+        }
+
+        Ok(Self {
+            time: (hours * 60 * 60) + (minutes * 60) + seconds,
+        })
+    }
+}
+
+impl From<Option<f64>> for Duration {
+    fn from(value: Option<f64>) -> Self {
+        Self {
+            time: value.unwrap_or(0.0).ceil() as u32,
+        }
+    }
+}
+
+impl std::fmt::Display for Duration {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
+        const SECOND: u32 = 1;
+        const MINUTE: u32 = 60 * SECOND;
+        const HOUR: u32 = 60 * MINUTE;
+
+        let base_hour = self.time - (self.time % HOUR);
+        let base_min = (self.time % HOUR) - ((self.time % HOUR) % MINUTE);
+        let base_sec = (self.time % HOUR) % MINUTE;
+
+        let h = base_hour / HOUR;
+        let m = base_min / MINUTE;
+        let s = base_sec / SECOND;
+
+        if self.time == 0 {
+            write!(f, "[No Duration]")
+        } else if h > 0 {
+            write!(f, "{h}h {m}m")
+        } else {
+            write!(f, "{m}m {s}s")
+        }
+    }
+}
+#[cfg(test)]
+mod test {
+    use super::Duration;
+
+    #[test]
+    fn test_display_duration_1h() {
+        let dur = Duration { time: 60 * 60 };
+        assert_eq!("1h 0m".to_owned(), dur.to_string());
+    }
+    #[test]
+    fn test_display_duration_30min() {
+        let dur = Duration { time: 60 * 30 };
+        assert_eq!("30m 0s".to_owned(), dur.to_string());
+    }
+}
diff --git a/src/select/selection_file/help.str b/src/select/selection_file/help.str
new file mode 100644
index 0000000..6e296f6
--- /dev/null
+++ b/src/select/selection_file/help.str
@@ -0,0 +1,10 @@
+# Commands:
+#   w, watch [-p,-s,-l]   Mark the video given by the hash to be watched
+#   d, drop               Mark the video given by the hash to be dropped
+#   u, url                Open the video URL in Firefox's `timesinks.youtube` profile
+#   p, pick               Reset the videos status to 'Pick'
+#
+# See `yt select <cmd_name> --help` for more help.
+#
+# These lines can be re-ordered; they are executed from top to bottom.
+# vim: filetype=yts conceallevel=2 concealcursor=nc colorcolumn=
diff --git a/src/select/selection_file/help.str.license b/src/select/selection_file/help.str.license
new file mode 100644
index 0000000..d4d410f
--- /dev/null
+++ b/src/select/selection_file/help.str.license
@@ -0,0 +1,9 @@
+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>.
diff --git a/src/select/selection_file/mod.rs b/src/select/selection_file/mod.rs
new file mode 100644
index 0000000..bdb0866
--- /dev/null
+++ b/src/select/selection_file/mod.rs
@@ -0,0 +1,35 @@
+// 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 data structures needed to express the file, which the user edits
+
+use anyhow::{Context, Result};
+use trinitry::Trinitry;
+
+pub mod display;
+pub mod duration;
+
+pub fn process_line(line: &str) -> Result<Option<Vec<String>>> {
+    // Filter out comments and empty lines
+    if line.starts_with('#') || line.trim().is_empty() {
+        Ok(None)
+    } else {
+        // pick 2195db "CouchRecherche? Gunnar und Han von STRG_F sind #mitfunkzuhause" "2020-04-01" "STRG_F - Live" "[1h 5m]" "https://www.youtube.com/watch?v=C8UXOaoMrXY"
+
+        let tri =
+            Trinitry::new(line).with_context(|| format!("Failed to parse line '{}'", line))?;
+
+        let mut vec = Vec::with_capacity(tri.arguments().len() + 1);
+        vec.push(tri.command().to_owned());
+        vec.extend(tri.arguments().to_vec().into_iter());
+
+        Ok(Some(vec))
+    }
+}
diff --git a/src/status/mod.rs b/src/status/mod.rs
new file mode 100644
index 0000000..1b24279
--- /dev/null
+++ b/src/status/mod.rs
@@ -0,0 +1,91 @@
+// 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 anyhow::Result;
+
+use crate::{
+    app::App,
+    storage::{
+        subscriptions::get_subscriptions,
+        video_database::{getters::get_videos, Video, VideoStatus},
+    },
+};
+
+macro_rules! get {
+    ($videos:expr, $status:ident) => {
+        $videos
+            .iter()
+            .filter(|vid| vid.status == VideoStatus::$status)
+            .collect::<Vec<&Video>>()
+    };
+    (@changing $videos:expr, $status:ident) => {
+        $videos
+            .iter()
+            .filter(|vid| vid.status == VideoStatus::$status && vid.status_change)
+            .collect::<Vec<&Video>>()
+    };
+}
+
+pub async fn show(app: &App) -> Result<()> {
+    let all_videos = get_videos(
+        app,
+        &[
+            VideoStatus::Pick,
+            //
+            VideoStatus::Watch,
+            VideoStatus::Cached,
+            VideoStatus::Watched,
+            //
+            VideoStatus::Drop,
+            VideoStatus::Dropped,
+        ],
+        None,
+    )
+    .await?;
+
+    // lengths
+    let picked_videos_len = (get!(all_videos, Pick)).len();
+
+    let watch_videos_len = (get!(all_videos, Watch)).len();
+    let cached_videos_len = (get!(all_videos, Cached)).len();
+    let watched_videos_len = (get!(all_videos, Watched)).len();
+
+    let drop_videos_len = (get!(all_videos, Drop)).len();
+    let dropped_videos_len = (get!(all_videos, Dropped)).len();
+
+    // changing
+    let picked_videos_changing = (get!(@changing all_videos, Pick)).len();
+
+    let watch_videos_changing = (get!(@changing all_videos, Watch)).len();
+    let cached_videos_changing = (get!(@changing all_videos, Cached)).len();
+    let watched_videos_changing = (get!(@changing all_videos, Watched)).len();
+
+    let drop_videos_changing = (get!(@changing all_videos, Drop)).len();
+    let dropped_videos_changing = (get!(@changing all_videos, Dropped)).len();
+
+    let subscriptions = get_subscriptions(&app).await?;
+    let subscriptions_len = subscriptions.0.len();
+    println!(
+        "\
+Picked   Videos: {picked_videos_len} ({picked_videos_changing} changing)
+
+Watch    Videos: {watch_videos_len} ({watch_videos_changing} changing)
+Cached   Videos: {cached_videos_len} ({cached_videos_changing} changing)
+Watched  Videos: {watched_videos_len} ({watched_videos_changing} changing)
+
+Drop     Videos: {drop_videos_len} ({drop_videos_changing} changing)
+Dropped  Videos: {dropped_videos_len} ({dropped_videos_changing} changing)
+
+
+  Subscriptions: {subscriptions_len}"
+    );
+
+    Ok(())
+}
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(())
+}
diff --git a/src/subscribe/mod.rs b/src/subscribe/mod.rs
new file mode 100644
index 0000000..1796fb4
--- /dev/null
+++ b/src/subscribe/mod.rs
@@ -0,0 +1,181 @@
+// 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::str::FromStr;
+
+use anyhow::{bail, Context, Result};
+use futures::FutureExt;
+use log::warn;
+use serde_json::{json, Value};
+use tokio::io::{AsyncBufRead, AsyncBufReadExt};
+use url::Url;
+use yt_dlp::wrapper::info_json::InfoType;
+
+use crate::{
+    app::App,
+    storage::subscriptions::{
+        add_subscription, check_url, get_subscriptions, remove_all_subscriptions,
+        remove_subscription, Subscription,
+    },
+};
+
+pub async fn unsubscribe(app: &App, name: String) -> Result<()> {
+    let present_subscriptions = get_subscriptions(&app).await?;
+
+    if let Some(subscription) = present_subscriptions.0.get(&name) {
+        remove_subscription(&app, subscription).await?;
+    } else {
+        bail!("Couldn't find subscription: '{}'", &name);
+    }
+
+    Ok(())
+}
+
+pub async fn import<W: AsyncBufRead + AsyncBufReadExt + Unpin>(
+    app: &App,
+    reader: W,
+    force: bool,
+) -> Result<()> {
+    if force {
+        remove_all_subscriptions(&app).await?;
+    }
+
+    let mut lines = reader.lines();
+    while let Some(line) = lines.next_line().await? {
+        let url =
+            Url::from_str(&line).with_context(|| format!("Failed to parse '{}' as url", line))?;
+        match subscribe(app, None, url)
+            .await
+            .with_context(|| format!("Failed to subscribe to: '{}'", line))
+        {
+            Ok(_) => (),
+            Err(err) => eprintln!(
+                "Error while subscribing to '{}': '{}'",
+                line,
+                err.source().expect("Should have a source").to_string()
+            ),
+        }
+    }
+
+    Ok(())
+}
+
+pub async fn subscribe(app: &App, name: Option<String>, url: Url) -> Result<()> {
+    if !(url.as_str().ends_with("videos")
+        || url.as_str().ends_with("streams")
+        || url.as_str().ends_with("shorts"))
+        && url.as_str().contains("youtube.com")
+    {
+        warn!("Your youtbe url does not seem like it actually tracks a channels playlist (videos, streams, shorts). Adding subscriptions for each of them...");
+
+        let url = Url::parse(&(url.as_str().to_owned() + "/"))
+            .expect("This was an url, it should stay one");
+
+        if let Some(name) = name {
+            let out: Result<()> = async move {
+                actual_subscribe(
+                    &app,
+                    Some(name.clone() + " {Videos}"),
+                    url.join("videos/").expect("Works"),
+                )
+                .await
+                .with_context(|| {
+                    format!("Failed to subscribe to '{}'", name.clone() + " {Videos}")
+                })?;
+
+                actual_subscribe(
+                    &app,
+                    Some(name.clone() + " {Streams}"),
+                    url.join("streams/").expect("Works"),
+                )
+                .await
+                .with_context(|| {
+                    format!("Failed to subscribe to '{}'", name.clone() + " {Streams}")
+                })?;
+
+                actual_subscribe(
+                    &app,
+                    Some(name.clone() + " {Shorts}"),
+                    url.join("shorts/").expect("Works"),
+                )
+                .await
+                .with_context(|| format!("Failed to subscribe to '{}'", name + " {Shorts}"))?;
+
+                Ok(())
+            }
+            .boxed()
+            .await;
+
+            out?
+        } else {
+            actual_subscribe(&app, None, url.join("videos/").expect("Works"))
+                .await
+                .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Videos}"))?;
+
+            actual_subscribe(&app, None, url.join("streams/").expect("Works"))
+                .await
+                .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Streams}"))?;
+
+            actual_subscribe(&app, None, url.join("shorts/").expect("Works"))
+                .await
+                .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Shorts}"))?;
+        }
+    } else {
+        actual_subscribe(&app, name, url).await?;
+    }
+
+    Ok(())
+}
+
+async fn actual_subscribe(app: &App, name: Option<String>, url: Url) -> Result<()> {
+    let name = if let Some(name) = name {
+        if !check_url(&url).await? {
+            bail!("The url ('{}') does not represent a playlist!", &url)
+        };
+
+        name
+    } else {
+        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?;
+
+        if info._type == Some(InfoType::Playlist) {
+            info.title.expect("This should be some for a playlist")
+        } else {
+            bail!("The url ('{}') does not represent a playlist!", &url)
+        }
+    };
+
+    let present_subscriptions = get_subscriptions(&app).await?;
+
+    if let Some(subs) = present_subscriptions.0.get(&name) {
+        bail!(
+            "The subscription '{}' could not be added, \
+                as another one with the same name ('{}') already exists. It links to the Url: '{}'",
+            name,
+            name,
+            subs.url
+        );
+    }
+
+    let sub = Subscription { name, url };
+
+    add_subscription(&app, &sub).await?;
+
+    Ok(())
+}
diff --git a/src/update/mod.rs b/src/update/mod.rs
new file mode 100644
index 0000000..9128bf7
--- /dev/null
+++ b/src/update/mod.rs
@@ -0,0 +1,207 @@
+// 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, process::Stdio, str::FromStr};
+
+use anyhow::{Context, Ok, Result};
+use chrono::{DateTime, Utc};
+use log::{error, info, warn};
+use tokio::{
+    io::{AsyncBufReadExt, BufReader},
+    process::Command,
+};
+use url::Url;
+use yt_dlp::{unsmuggle_url, wrapper::info_json::InfoJson};
+
+use crate::{
+    app::App,
+    storage::{
+        subscriptions::{get_subscriptions, Subscription},
+        video_database::{
+            extractor_hash::ExtractorHash, getters::get_all_hashes, setters::add_video, Video,
+            VideoStatus,
+        },
+    },
+};
+
+pub async fn update(
+    app: &App,
+    max_backlog: u32,
+    subs_to_update: Vec<String>,
+    _concurrent_processes: usize,
+) -> Result<()> {
+    let subscriptions = get_subscriptions(&app).await?;
+    let mut back_subs: HashMap<Url, Subscription> = HashMap::new();
+
+    let mut urls: Vec<String> = vec![];
+    for (name, sub) in subscriptions.0 {
+        if subs_to_update.contains(&name) || subs_to_update.is_empty() {
+            urls.push(sub.url.to_string());
+            back_subs.insert(sub.url.clone(), sub);
+        } else {
+            info!(
+                "Not updating subscription '{}' as it was not specified",
+                name
+            );
+        }
+    }
+
+    let mut child = Command::new("./python_update/raw_update.py")
+        .arg(max_backlog.to_string())
+        .args(&urls)
+        .stdout(Stdio::piped())
+        .stderr(Stdio::null())
+        .stdin(Stdio::null())
+        .spawn()
+        .context("Failed to call python3 update_raw")?;
+
+    let mut out = BufReader::new(
+        child
+            .stdout
+            .take()
+            .expect("Should be able to take child stdout"),
+    )
+    .lines();
+
+    let hashes = get_all_hashes(app).await?;
+
+    while let Some(line) = out.next_line().await? {
+        // use tokio::{fs::File, io::AsyncWriteExt};
+        // let mut output = File::create("output.json").await?;
+        // output.write(line.as_bytes()).await?;
+        // output.flush().await?;
+        // output.sync_all().await?;
+        // drop(output);
+
+        let output_json: HashMap<Url, InfoJson> =
+            serde_json::from_str(&line).expect("This should be valid json");
+
+        for (url, value) in output_json {
+            let sub = back_subs.get(&url).expect("This was stored before");
+            process_subscription(app, sub, value, &hashes).await?
+        }
+    }
+
+    let out = child.wait().await?;
+    if out.success() {
+        error!("A yt update-once invokation failed for all subscriptions.")
+    }
+
+    Ok(())
+}
+
+async fn process_subscription(
+    app: &App,
+    sub: &Subscription,
+    entry: InfoJson,
+    hashes: &Vec<blake3::Hash>,
+) -> Result<()> {
+    macro_rules! unwrap_option {
+        ($option:expr) => {
+            match $option {
+                Some(x) => x,
+                None => anyhow::bail!(concat!(
+                    "Expected a value, but '",
+                    stringify!($option),
+                    "' is None!"
+                )),
+            }
+        };
+    }
+
+    let publish_date = if let Some(date) = &entry.upload_date {
+        let year: u32 = date
+            .chars()
+            .take(4)
+            .collect::<String>()
+            .parse()
+            .expect("Should work.");
+        let month: u32 = date
+            .chars()
+            .skip(4)
+            .take(2)
+            .collect::<String>()
+            .parse()
+            .expect("Should work");
+        let day: u32 = date
+            .chars()
+            .skip(6)
+            .take(2)
+            .collect::<String>()
+            .parse()
+            .expect("Should work");
+
+        let date_string = format!("{year:04}-{month:02}-{day:02}T00:00:00Z");
+        Some(
+            DateTime::<Utc>::from_str(&date_string)
+                .expect("This should always work")
+                .timestamp(),
+        )
+    } else {
+        warn!(
+            "The video '{}' lacks it's upload date!",
+            unwrap_option!(&entry.title)
+        );
+        None
+    };
+
+    let thumbnail_url = match (&entry.thumbnails, &entry.thumbnail) {
+        (None, None) => None,
+        (None, Some(thumbnail)) => Some(thumbnail.to_owned()),
+
+        // TODO: The algorithm is not exactly the best <2024-05-28>
+        (Some(thumbnails), None) => Some(
+            thumbnails
+                .get(0)
+                .expect("At least one should exist")
+                .url
+                .clone(),
+        ),
+        (Some(_), Some(thumnail)) => Some(thumnail.to_owned()),
+    };
+
+    let url = {
+        let smug_url: url::Url = unwrap_option!(entry.webpage_url.clone());
+        unsmuggle_url(smug_url)?
+    };
+
+    let extractor_hash = blake3::hash(url.as_str().as_bytes());
+
+    if hashes.contains(&extractor_hash) {
+        // We already stored the video information
+        println!(
+            "(Ignoring duplicated video from: '{}' -> '{}')",
+            sub.name,
+            unwrap_option!(entry.title)
+        );
+        return Ok(());
+    } else {
+        let video = Video {
+            cache_path: None,
+            description: entry.description.clone(),
+            duration: entry.duration,
+            extractor_hash: ExtractorHash::from_hash(extractor_hash),
+            last_status_change: Utc::now().timestamp(),
+            parent_subscription_name: Some(sub.name.clone()),
+            priority: 0,
+            publish_date,
+            status: VideoStatus::Pick,
+            status_change: false,
+            thumbnail_url,
+            title: unwrap_option!(entry.title.clone()),
+            url,
+        };
+
+        println!("{}", video.to_color_display());
+        add_video(app, video).await?;
+    }
+
+    Ok(())
+}
diff --git a/src/watch/events.rs b/src/watch/events.rs
new file mode 100644
index 0000000..815ad28
--- /dev/null
+++ b/src/watch/events.rs
@@ -0,0 +1,235 @@
+// 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::{env::current_exe, mem, usize};
+
+use anyhow::{bail, Result};
+use libmpv2::{events::Event, EndFileReason, Mpv};
+use log::{debug, info};
+use tokio::process::Command;
+
+use crate::{
+    app::App,
+    comments::get_comments,
+    constants::LOCAL_COMMENTS_LENGTH,
+    storage::video_database::{
+        extractor_hash::ExtractorHash,
+        getters::{get_video_by_hash, get_video_mpv_opts, get_videos},
+        setters::{set_state_change, set_video_watched},
+        VideoStatus,
+    },
+};
+
+pub struct MpvEventHandler {
+    currently_playing_index: Option<usize>,
+    current_playlist_position: usize,
+    current_playlist: Vec<ExtractorHash>,
+}
+
+impl MpvEventHandler {
+    pub fn from_playlist(playlist: Vec<ExtractorHash>) -> Self {
+        Self {
+            currently_playing_index: None,
+            current_playlist: playlist,
+            current_playlist_position: 0,
+        }
+    }
+
+    /// Checks, whether new videos are ready to be played
+    pub async fn possibly_add_new_videos(&mut self, app: &App, mpv: &Mpv) -> Result<()> {
+        let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?;
+
+        // There is nothing to watch
+        if play_things.len() == 0 {
+            return Ok(());
+        }
+
+        let play_things = play_things
+            .into_iter()
+            .filter(|val| !self.current_playlist.contains(&val.extractor_hash))
+            .collect::<Vec<_>>();
+
+        info!(
+            "{} videos are cached and will be added to the list to be played",
+            play_things.len()
+        );
+
+        self.current_playlist.reserve(play_things.len());
+
+        for play_thing in play_things {
+            debug!("Adding '{}' to playlist.", play_thing.title);
+
+            let orig_cache_path = play_thing.cache_path.expect("Is cached and thus some");
+            let cache_path = orig_cache_path.to_str().expect("Should be vaild utf8");
+            let cache_path = format!("\"{}\"", cache_path);
+
+            let args = &[&cache_path, "append-play"];
+
+            mpv.execute("loadfile", args)?;
+            self.current_playlist.push(play_thing.extractor_hash);
+        }
+
+        Ok(())
+    }
+
+    async fn mark_video_watched(&mut self, app: &App, hash: &ExtractorHash) -> Result<()> {
+        let video = get_video_by_hash(app, hash).await?;
+        set_video_watched(&app, &video).await?;
+        Ok(())
+    }
+    async fn mark_cvideo_watched(&mut self, app: &App) -> Result<()> {
+        if let Some(index) = self.currently_playing_index {
+            let video_hash = self.current_playlist[(index) as usize].clone();
+            self.mark_video_watched(app, &video_hash).await?;
+        }
+        Ok(())
+    }
+
+    async fn mark_cvideo_inactive(&mut self, app: &App) -> Result<()> {
+        if let Some(index) = self.currently_playing_index {
+            let video_hash = &self.current_playlist[(index) as usize];
+            self.currently_playing_index = None;
+            set_state_change(&app, video_hash, false).await?;
+        }
+        Ok(())
+    }
+    async fn mark_video_active(&mut self, app: &App, playlist_index: usize) -> Result<()> {
+        let video_hash = &self.current_playlist[(playlist_index) as usize];
+        self.currently_playing_index = Some(playlist_index);
+        set_state_change(&app, video_hash, true).await?;
+        Ok(())
+    }
+
+    /// Apply the options set with e.g. `watch --speed=<speed>`
+    async fn apply_options(&self, app: &App, mpv: &Mpv, hash: &ExtractorHash) -> Result<()> {
+        let options = get_video_mpv_opts(app, hash).await?;
+
+        mpv.set_property("speed", options.playback_speed)?;
+
+        Ok(())
+    }
+
+    /// This will return [`true`], if the event handling should be stopped
+    pub async fn handle_mpv_event<'a>(
+        &mut self,
+        app: &App,
+        mpv: &Mpv,
+        event: Event<'a>,
+    ) -> Result<bool> {
+        match event {
+            Event::EndFile(r) => match r {
+                EndFileReason::Eof => {
+                    info!("Mpv reached eof of current video. Marking it watched.");
+
+                    self.mark_cvideo_watched(app).await?;
+                    self.mark_cvideo_inactive(app).await?;
+                }
+                EndFileReason::Stop => {}
+                EndFileReason::Quit => {
+                    info!("Mpv quit. Exiting playback");
+
+                    // draining the playlist is okay, as mpv is done playing
+                    let videos = mem::take(&mut self.current_playlist);
+                    for video in videos {
+                        self.mark_video_watched(app, &video).await?;
+                        set_state_change(&app, &video, false).await?;
+                    }
+                    return Ok(true);
+                }
+                EndFileReason::Error => {
+                    unreachable!("This have raised a separate error")
+                }
+                EndFileReason::Redirect => {
+                    todo!("We probably need to handle this somehow");
+                }
+            },
+            Event::StartFile(playlist_index) => {
+                self.possibly_add_new_videos(app, &mpv).await?;
+
+                self.mark_video_active(app, (playlist_index - 1) as usize)
+                    .await?;
+                self.current_playlist_position = (playlist_index - 1) as usize;
+                self.apply_options(
+                    app,
+                    mpv,
+                    &self.current_playlist[self.current_playlist_position],
+                )
+                .await?;
+            }
+            Event::FileLoaded => {}
+            Event::ClientMessage(a) => {
+                debug!("Got Client Message event: '{}'", a.join(" "));
+
+                match a.as_slice() {
+                    &["yt-comments-external"] => {
+                        let binary = current_exe().expect("A current exe should exist");
+
+                        let status = Command::new("riverctl")
+                            .args(["focus-output", "next"])
+                            .status()
+                            .await?;
+                        if !status.success() {
+                            bail!("focusing the next output failed!");
+                        }
+
+                        let status = Command::new("alacritty")
+                            .args(&[
+                                "--title",
+                                "floating please",
+                                "--command",
+                                binary.to_str().expect("Should be valid unicode"),
+                                "comments",
+                            ])
+                            .status()
+                            .await?;
+                        if !status.success() {
+                            bail!("Falied to start `yt comments`");
+                        }
+
+                        let status = Command::new("riverctl")
+                            .args(["focus-output", "next"])
+                            .status()
+                            .await?;
+                        if !status.success() {
+                            bail!("focusing the next output failed!");
+                        }
+                    }
+                    &["yt-comments-local"] => {
+                        let comments: String = get_comments(app)
+                            .await?
+                            .render(false)
+                            .replace("\"", "")
+                            .replace("'", "")
+                            .chars()
+                            .take(LOCAL_COMMENTS_LENGTH)
+                            .collect();
+
+                        mpv.execute("show-text", &[&format!("'{}'", comments), "6000"])?;
+                    }
+                    &["yt-description"] => {
+                        // let description = description(app).await?;
+                        mpv.execute("script-message", &["osc-message", "'<YT Description>'"])?;
+                    }
+                    &["yt-mark-watch-later"] => {
+                        self.mark_cvideo_inactive(app).await?;
+                        mpv.execute("write-watch-later-config", &[])?;
+                        mpv.execute("playlist-remove", &["current"])?;
+                    }
+                    other => {
+                        debug!("Unknown message: {}", other.join(" "))
+                    }
+                }
+            }
+            _ => {}
+        }
+
+        Ok(false)
+    }
+}
diff --git a/src/watch/mod.rs b/src/watch/mod.rs
new file mode 100644
index 0000000..374c1d7
--- /dev/null
+++ b/src/watch/mod.rs
@@ -0,0 +1,118 @@
+// 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 anyhow::Result;
+use events::MpvEventHandler;
+use libmpv2::{events::EventContext, Mpv};
+use log::{debug, info, warn};
+
+use crate::{
+    app::App,
+    cache::maintain,
+    constants::{mpv_config_path, mpv_input_path},
+    storage::video_database::{extractor_hash::ExtractorHash, getters::get_videos, VideoStatus},
+};
+
+pub mod events;
+
+pub async fn watch(app: &App) -> Result<()> {
+    maintain(app, false).await?;
+
+    // set some default values, to make things easier (these can be overridden by the config file,
+    // which we load later)
+    let mpv = Mpv::with_initializer(|mpv| {
+        // Enable default key bindings, so the user can actually interact with
+        // the player (and e.g. close the window).
+        mpv.set_property("input-default-bindings", "yes")?;
+        mpv.set_property("input-vo-keyboard", "yes")?;
+
+        // Show the on screen controller.
+        mpv.set_property("osc", "yes")?;
+
+        // Don't automatically advance to the next video (or exit the player)
+        mpv.set_option("keep-open", "always")?;
+        Ok(())
+    })?;
+
+    let config_path = mpv_config_path()?;
+    if config_path.try_exists()? {
+        info!("Found mpv.conf at '{}'!", config_path.display());
+        mpv.execute(
+            "load-config-file",
+            &[config_path.to_str().expect("This should be utf8-able")],
+        )?;
+    } else {
+        warn!(
+            "Did not find a mpv.conf file at '{}'",
+            config_path.display()
+        );
+    }
+
+    let input_path = mpv_input_path()?;
+    if input_path.try_exists()? {
+        info!("Found mpv.input.conf at '{}'!", input_path.display());
+        mpv.execute(
+            "load-input-conf",
+            &[input_path.to_str().expect("This should be utf8-able")],
+        )?;
+    } else {
+        warn!(
+            "Did not find a mpv.input.conf file at '{}'",
+            input_path.display()
+        );
+    }
+
+    let mut ev_ctx = EventContext::new(mpv.ctx);
+    ev_ctx.disable_deprecated_events()?;
+
+    let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?;
+    info!(
+        "{} videos are cached and ready to be played",
+        play_things.len()
+    );
+
+    // There is nothing to watch
+    if play_things.len() == 0 {
+        return Ok(());
+    }
+
+    let mut playlist_cache: Vec<ExtractorHash> = Vec::with_capacity(play_things.len());
+
+    for play_thing in play_things {
+        debug!("Adding '{}' to playlist.", play_thing.title);
+
+        let orig_cache_path = play_thing.cache_path.expect("Is cached and thus some");
+        let cache_path = orig_cache_path.to_str().expect("Should be vaild utf8");
+        let cache_path = format!("\"{}\"", cache_path);
+
+        let args = &[&cache_path, "append-play"];
+
+        mpv.execute("loadfile", args)?;
+
+        playlist_cache.push(play_thing.extractor_hash);
+    }
+
+    let mut mpv_event_handler = MpvEventHandler::from_playlist(playlist_cache);
+    loop {
+        if let Some(ev) = ev_ctx.wait_event(600.) {
+            match ev {
+                Ok(event) => {
+                    debug!("Mpv event triggered: {:#?}", event);
+                    if mpv_event_handler.handle_mpv_event(app, &mpv, event).await? {
+                        break;
+                    }
+                }
+                Err(e) => debug!("Mpv Event errored: {}", e),
+            }
+        }
+    }
+
+    Ok(())
+}