about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2024-10-14 12:32:23 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2024-10-14 12:34:56 +0200
commit145a776039248a9460e9473e4bc9ef3d533b60c1 (patch)
tree7b2a948ae1f08335eba477c26bf1d5e83cdac24b
parentfix(downloader): Don't display changed cache size on first run (diff)
downloadyt-145a776039248a9460e9473e4bc9ef3d533b60c1.tar.gz
yt-145a776039248a9460e9473e4bc9ef3d533b60c1.zip
feat(videos): Provide a consistent display for the `Video` struct
Before, `Video`s where colourized differently, just because the
colourization was not standardized. It now is.
-rw-r--r--Cargo.lock7
-rw-r--r--Cargo.toml1
-rw-r--r--src/main.rs12
-rw-r--r--src/select/cmds.rs6
-rw-r--r--src/select/mod.rs12
-rw-r--r--src/select/selection_file/display.rs108
-rw-r--r--src/select/selection_file/mod.rs1
-rw-r--r--src/storage/video_database/extractor_hash.rs21
-rw-r--r--src/storage/video_database/getters.rs14
-rw-r--r--src/storage/video_database/mod.rs4
-rw-r--r--src/update/mod.rs19
-rw-r--r--src/videos/display/format_video.rs166
-rw-r--r--src/videos/display/mod.rs314
-rw-r--r--src/videos/mod.rs14
14 files changed, 567 insertions, 132 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 027c460..aa719bd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1034,6 +1034,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
 
 [[package]]
+name = "owo-colors"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56"
+
+[[package]]
 name = "parking"
 version = "2.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2343,6 +2349,7 @@ dependencies = [
  "libmpv2",
  "log",
  "nucleo-matcher",
+ "owo-colors",
  "regex",
  "serde",
  "serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 9854fb6..7995ab9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -44,6 +44,7 @@ bytes = { path = "./crates/bytes", features = ["serde"] }
 trinitry = { version = "0.2.2" }
 toml = "0.8.19"
 nucleo-matcher = "0.3.1"
+owo-colors = "4.1.0"
 
 [[bin]]
 name = "yt"
diff --git a/src/main.rs b/src/main.rs
index 3f7e410..37283a1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -26,6 +26,7 @@ use tokio::{
     task::JoinHandle,
 };
 use url::Url;
+use videos::display::format_video::FormatVideo;
 use yt_dlp::wrapper::info_json::InfoJson;
 
 use crate::{cli::Command, storage::subscriptions::get_subscriptions};
@@ -128,7 +129,16 @@ async fn main() -> Result<()> {
             }
             VideosCommand::Info { hash } => {
                 let video = get_video_by_hash(&app, &hash.realize(&app).await?).await?;
-                dbg!(video);
+
+                print!(
+                    "{}",
+                    (&video
+                        .to_formatted_video(&app)
+                        .await
+                        .context("Failed to format video")?
+                        .colorize())
+                        .to_info_display()
+                );
             }
         },
         Command::Update {
diff --git a/src/select/cmds.rs b/src/select/cmds.rs
index b45cc48..6e71607 100644
--- a/src/select/cmds.rs
+++ b/src/select/cmds.rs
@@ -19,6 +19,7 @@ use crate::{
         VideoOptions, VideoStatus,
     },
     update::video_entry_to_video,
+    videos::display::format_video::FormatVideo,
 };
 
 use anyhow::{bail, Context, Result};
@@ -57,7 +58,10 @@ pub async fn handle_select_cmd(
                     entry: yt_dlp::wrapper::info_json::InfoJson,
                 ) -> Result<()> {
                     let video = video_entry_to_video(entry, None)?;
-                    println!("{}", video.to_color_display(app).await?);
+                    println!(
+                        "{}",
+                        (&video.to_formatted_video(app).await?.colorize()).to_line_display()
+                    );
                     add_video(app, video).await?;
 
                     Ok(())
diff --git a/src/select/mod.rs b/src/select/mod.rs
index 2663a04..ca7a203 100644
--- a/src/select/mod.rs
+++ b/src/select/mod.rs
@@ -20,6 +20,7 @@ use crate::{
     cli::CliArgs,
     constants::HELP_STR,
     storage::video_database::{getters::get_videos, VideoStatus},
+    videos::display::format_video::FormatVideo,
 };
 
 use anyhow::{bail, Context, Result};
@@ -63,23 +64,24 @@ pub async fn select(app: &App, done: bool, use_last_selection: bool) -> Result<(
         // 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.first() {
-            let _ = vid.to_select_file_display(app).await?;
+            let _ = vid.to_formatted_video(app).await?;
         }
 
         let mut edit_file = BufWriter::new(&temp_file);
 
         join_all(
             matching_videos
-                .iter()
-                .map(|vid| async { vid.to_select_file_display(app).await })
+                .into_iter()
+                .map(|vid| async { vid.to_formatted_video_owned(app).await })
                 .collect::<Vec<_>>(),
         )
         .await
         .into_iter()
         .try_for_each(|line| -> Result<()> {
-            let line = line?;
+            let formatted_line = (&line?).to_select_file_display();
+
             edit_file
-                .write_all(line.as_bytes())
+                .write_all(formatted_line.as_bytes())
                 .expect("This write should not fail");
 
             Ok(())
diff --git a/src/select/selection_file/display.rs b/src/select/selection_file/display.rs
deleted file mode 100644
index 0714015..0000000
--- a/src/select/selection_file/display.rs
+++ /dev/null
@@ -1,108 +0,0 @@
-// 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::{Context, 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
-            .with_context(|| format!("Failed to get video options for video: '{}'", self.title))?
-            .to_cli_flags(app);
-        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().trim(),
-            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 async fn to_color_display_owned(self, app: &App) -> Result<String> {
-        self.to_color_display(app).await
-    }
-    pub async fn to_color_display(&self, app: &App) -> Result<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!("95;3", self.extractor_hash.into_short_hash(app).await?),
-            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");
-
-        Ok(f)
-    }
-}
diff --git a/src/select/selection_file/mod.rs b/src/select/selection_file/mod.rs
index d228023..45809fa 100644
--- a/src/select/selection_file/mod.rs
+++ b/src/select/selection_file/mod.rs
@@ -13,7 +13,6 @@
 use anyhow::{Context, Result};
 use trinitry::Trinitry;
 
-pub mod display;
 pub mod duration;
 
 pub fn process_line(line: &str) -> Result<Option<Vec<String>>> {
diff --git a/src/storage/video_database/extractor_hash.rs b/src/storage/video_database/extractor_hash.rs
index 62a9eda..c956919 100644
--- a/src/storage/video_database/extractor_hash.rs
+++ b/src/storage/video_database/extractor_hash.rs
@@ -10,7 +10,7 @@
 
 use std::{collections::HashMap, fmt::Display, str::FromStr};
 
-use anyhow::{bail, Result};
+use anyhow::{bail, Context, Result};
 use blake3::Hash;
 use log::debug;
 use tokio::sync::OnceCell;
@@ -24,6 +24,12 @@ pub struct ExtractorHash {
     hash: Hash,
 }
 
+impl Display for ExtractorHash {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.hash.fmt(f)
+    }
+}
+
 #[derive(Debug, Clone)]
 pub struct ShortHash(String);
 
@@ -78,7 +84,10 @@ impl ExtractorHash {
         let needed_chars = if let Some(needed_chars) = EXTRACTOR_HASH_LENGTH.get() {
             *needed_chars
         } else {
-            let needed_chars = self.get_needed_char_len(app).await?;
+            let needed_chars = self
+                .get_needed_char_len(app)
+                .await
+                .context("Failed to calculate needed char length")?;
             EXTRACTOR_HASH_LENGTH
                 .set(needed_chars)
                 .expect("This should work at this stage");
@@ -96,7 +105,9 @@ impl ExtractorHash {
     }
 
     async fn short_hash_to_full_hash(app: &App, s: &ShortHash) -> Result<Hash> {
-        let all_hashes = get_all_hashes(app).await?;
+        let all_hashes = get_all_hashes(app)
+            .await
+            .context("Failed to fetch all extractor -hashesh from database")?;
 
         let needed_chars = s.0.len();
 
@@ -111,7 +122,9 @@ impl ExtractorHash {
 
     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_hashes = get_all_hashes(app)
+            .await
+            .context("Failed to fetch all extractor -hashesh from database")?;
 
         let all_char_vec_hashes = all_hashes
             .into_iter()
diff --git a/src/storage/video_database/getters.rs b/src/storage/video_database/getters.rs
index f2b0507..29dd014 100644
--- a/src/storage/video_database/getters.rs
+++ b/src/storage/video_database/getters.rs
@@ -287,7 +287,13 @@ pub async fn get_video_yt_dlp_opts(app: &App, hash: &ExtractorHash) -> Result<Yt
         ehash
     )
     .fetch_one(&app.database)
-    .await?;
+    .await
+    .with_context(|| {
+        format!(
+            "Failed to fetch the `yt_dlp_video_opts` for video: {}",
+            hash
+        )
+    })?;
 
     Ok(YtDlpOptions {
         subtitle_langs: yt_dlp_options.subtitle_langs,
@@ -305,7 +311,8 @@ pub async fn get_video_mpv_opts(app: &App, hash: &ExtractorHash) -> Result<MpvOp
         ehash
     )
     .fetch_one(&app.database)
-    .await?;
+    .await
+    .with_context(|| format!("Failed to fetch the `mpv_video_opts` for video: {}", hash))?;
 
     Ok(MpvOptions {
         playback_speed: mpv_options.playback_speed,
@@ -324,7 +331,8 @@ pub async fn get_video_opts(app: &App, hash: &ExtractorHash) -> Result<VideoOpti
         ehash
     )
     .fetch_one(&app.database)
-    .await?;
+    .await
+    .with_context(|| format!("Failed to fetch the `video_opts` for video: {}", hash))?;
 
     let mpv = MpvOptions {
         playback_speed: opts.playback_speed,
diff --git a/src/storage/video_database/mod.rs b/src/storage/video_database/mod.rs
index 0251eb1..1765f79 100644
--- a/src/storage/video_database/mod.rs
+++ b/src/storage/video_database/mod.rs
@@ -19,7 +19,7 @@ pub mod extractor_hash;
 pub mod getters;
 pub mod setters;
 
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 pub struct Video {
     pub cache_path: Option<PathBuf>,
     pub description: Option<String>,
@@ -88,7 +88,7 @@ pub struct YtDlpOptions {
 /// Cache                       // yt cache
 ///     |
 /// Watched                     // yt watch
-#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
 pub enum VideoStatus {
     #[default]
     Pick,
diff --git a/src/update/mod.rs b/src/update/mod.rs
index ce3a7f9..6abb8c4 100644
--- a/src/update/mod.rs
+++ b/src/update/mod.rs
@@ -29,6 +29,7 @@ use crate::{
             VideoStatus,
         },
     },
+    videos::display::format_video::FormatVideo,
 };
 
 pub async fn update(
@@ -103,7 +104,9 @@ pub async fn update(
 
         for (url, value) in output_json {
             let sub = back_subs.get(&url).expect("This was stored before");
-            process_subscription(app, sub, value, &hashes).await?
+            process_subscription(app, sub, value, &hashes)
+                .await
+                .with_context(|| format!("Failed to process subscription: '{}'", sub.name))?
         }
     }
 
@@ -237,8 +240,18 @@ async fn process_subscription(
         // We already stored the video information
         unreachable!("The python update script should have never provided us a duplicated video");
     } else {
-        println!("{}", video.to_color_display(app).await?);
-        add_video(app, video).await?;
+        add_video(app, video.clone())
+            .await
+            .with_context(|| format!("Failed to add video to database: '{}'", video.title))?;
+        println!(
+            "{}",
+            (&video
+                .to_formatted_video(app)
+                .await
+                .with_context(|| format!("Failed to format video: '{}'", video.title))?
+                .colorize())
+                .to_line_display()
+        );
         Ok(())
     }
 }
diff --git a/src/videos/display/format_video.rs b/src/videos/display/format_video.rs
new file mode 100644
index 0000000..50646a1
--- /dev/null
+++ b/src/videos/display/format_video.rs
@@ -0,0 +1,166 @@
+// 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::Display;
+
+pub trait FormatVideo {
+    type Output;
+
+    fn cache_path(&self) -> Self::Output;
+    fn description(&self) -> Self::Output;
+    fn duration(&self) -> Self::Output;
+    fn extractor_hash(&self) -> Self::Output;
+    fn last_status_change(&self) -> Self::Output;
+    fn parent_subscription_name(&self) -> Self::Output;
+    fn priority(&self) -> Self::Output;
+    fn publish_date(&self) -> Self::Output;
+    fn status(&self) -> Self::Output;
+    fn status_change(&self) -> Self::Output;
+    fn thumbnail_url(&self) -> Self::Output;
+    fn title(&self) -> Self::Output;
+    fn url(&self) -> Self::Output;
+    fn video_options(&self) -> Self::Output;
+
+    fn to_parts(
+        &self,
+    ) -> (
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+        Self::Output,
+    ) {
+        let cache_path = self.cache_path();
+        let description = self.description();
+        let duration = self.duration();
+        let extractor_hash = self.extractor_hash();
+        let last_status_change = self.last_status_change();
+        let parent_subscription_name = self.parent_subscription_name();
+        let priority = self.priority();
+        let publish_date = self.publish_date();
+        let status = self.status();
+        let status_change = self.status_change();
+        let thumbnail_url = self.thumbnail_url();
+        let title = self.title();
+        let url = self.url();
+        let video_options = self.video_options();
+
+        (
+            cache_path,
+            description,
+            duration,
+            extractor_hash,
+            last_status_change,
+            parent_subscription_name,
+            priority,
+            publish_date,
+            status,
+            status_change,
+            thumbnail_url,
+            title,
+            url,
+            video_options,
+        )
+    }
+
+    fn to_info_display(&self) -> String
+    where
+        <Self as FormatVideo>::Output: Display,
+    {
+        let (
+            cache_path,
+            description,
+            duration,
+            extractor_hash,
+            last_status_change,
+            parent_subscription_name,
+            priority,
+            publish_date,
+            status,
+            status_change,
+            thumbnail_url,
+            title,
+            url,
+            video_options,
+        ) = self.to_parts();
+
+        let status_change = if status_change.to_string().as_str() == "false" {
+            "currently not changing"
+        } else if status_change.to_string().as_str() == "true" {
+            "currently changing"
+        } else {
+            unreachable!("This is an formatted boolean");
+        };
+
+        let string = format!(
+            "\
+{title} ({extractor_hash})
+| -> {cache_path}
+| -> {duration}
+| -> {parent_subscription_name}
+| -> priority: {priority}
+| -> {publish_date}
+| -> status: {status} since {last_status_change}
+| -> {status_change}
+| -> {thumbnail_url}
+| -> {url}
+| -> options: {}
+{description}\n",
+            video_options.to_string().trim()
+        );
+        string
+    }
+
+    fn to_line_display(&self) -> String
+    where
+        Self::Output: Display,
+    {
+        let f = format!(
+            "{} {} {} {} {} {}",
+            self.status(),
+            self.extractor_hash(),
+            self.title(),
+            self.publish_date(),
+            self.parent_subscription_name(),
+            self.duration()
+        );
+
+        f
+    }
+
+    fn to_select_file_display(&self) -> String
+    where
+        Self::Output: Display,
+    {
+        let f = format!(
+            r#"{}{} {} "{}" "{}" "{}" "{}" "{}"{}"#,
+            self.status(),
+            self.video_options(),
+            self.extractor_hash(),
+            self.title(),
+            self.publish_date(),
+            self.parent_subscription_name(),
+            self.duration(),
+            self.url(),
+            '\n'
+        );
+
+        f
+    }
+}
diff --git a/src/videos/display/mod.rs b/src/videos/display/mod.rs
new file mode 100644
index 0000000..d919dd2
--- /dev/null
+++ b/src/videos/display/mod.rs
@@ -0,0 +1,314 @@
+// 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::DateTime;
+use format_video::FormatVideo;
+use owo_colors::OwoColorize;
+use url::Url;
+
+use crate::{
+    app::App,
+    select::selection_file::duration::Duration,
+    storage::video_database::{getters::get_video_opts, Video},
+};
+
+use anyhow::{Context, Result};
+
+pub mod format_video;
+
+macro_rules! get {
+    ($value:expr, $key:ident, $name:expr, $code:tt) => {
+        if let Some(value) = &$value.$key {
+            $code(value)
+        } else {
+            concat!("[No ", $name, "]").to_owned()
+        }
+    };
+}
+
+/// This is identical to a [`FormattedVideo`], but has colorized fields.
+pub struct ColorizedFormattedVideo(FormattedVideo);
+
+impl FormattedVideo {
+    pub fn colorize(self) -> ColorizedFormattedVideo {
+        let Self {
+            cache_path,
+            description,
+            duration,
+            extractor_hash,
+            last_status_change,
+            parent_subscription_name,
+            priority,
+            publish_date,
+            status,
+            status_change,
+            thumbnail_url,
+            title,
+            url,
+            video_options,
+        } = self;
+
+        ColorizedFormattedVideo(Self {
+            cache_path: cache_path.blue().bold().to_string(),
+            description,
+            duration: duration.cyan().bold().to_string(),
+            extractor_hash: extractor_hash.bright_purple().italic().to_string(),
+            last_status_change: last_status_change.bright_cyan().to_string(),
+            parent_subscription_name: parent_subscription_name.bright_magenta().to_string(),
+            priority,
+            publish_date: publish_date.bright_white().bold().to_string(),
+            status: status.red().bold().to_string(),
+            status_change,
+            thumbnail_url,
+            title: title.green().bold().to_string(),
+            url: url.italic().to_string(),
+            video_options: video_options.bright_green().to_string(),
+        })
+    }
+}
+
+/// This is a version of [`Video`] that has all the fields of the original [`Video`] structure
+/// turned to [`String`]s to facilitate displaying it.
+///
+/// This structure provides a way to display a [`Video`] in a coherent way, as it enforces to
+/// always use the same colors for one field.
+#[derive(Debug)]
+pub struct FormattedVideo {
+    cache_path: String,
+    description: String,
+    duration: String,
+    extractor_hash: String,
+    last_status_change: String,
+    parent_subscription_name: String,
+    priority: String,
+    publish_date: String,
+    status: String,
+    status_change: String,
+    thumbnail_url: String,
+    title: String,
+    url: String,
+    /// This string contains the video options (speed, subtitle_languages, etc.).
+    /// It already starts with an extra whitespace, when these are not empty.
+    video_options: String,
+}
+
+impl Video {
+    pub async fn to_formatted_video_owned(self, app: &App) -> Result<FormattedVideo> {
+        Self::to_formatted_video(&self, app).await
+    }
+
+    pub async fn to_formatted_video(&self, app: &App) -> Result<FormattedVideo> {
+        fn date_from_stamp(stamp: i64) -> String {
+            DateTime::from_timestamp(stamp, 0)
+                .expect("The timestamps should always be valid")
+                .format("%Y-%m-%d")
+                .to_string()
+        }
+
+        let cache_path: String = get!(
+            self,
+            cache_path,
+            "Cache Path",
+            (|value: &PathBuf| value.to_string_lossy().to_string())
+        );
+        let description = get!(
+            self,
+            description,
+            "Description",
+            (|value: &str| value.to_owned())
+        );
+        let duration = Duration::from(self.duration);
+        let extractor_hash = self
+            .extractor_hash
+            .into_short_hash(app)
+            .await
+            .with_context(|| {
+                format!(
+                    "Failed to format extractor hash, whilst formatting video: '{}'",
+                    self.title
+                )
+            })?;
+        let last_status_change = date_from_stamp(self.last_status_change);
+        let parent_subscription_name = get!(
+            self,
+            parent_subscription_name,
+            "author",
+            (|sub: &str| sub.replace('"', "'"))
+        );
+        let priority = self.priority;
+        let publish_date = get!(
+            self,
+            publish_date,
+            "release date",
+            (|date: &i64| date_from_stamp(*date))
+        );
+        // TODO: We might support `.trim()`ing that, as the extra whitespace could be bad in the
+        // selection file. <2024-10-07>
+        let status = self.status.as_command();
+        let status_change = self.status_change;
+        let thumbnail_url = get!(
+            self,
+            thumbnail_url,
+            "thumbnail URL",
+            (|url: &Url| url.to_string())
+        );
+        let title = self.title.replace(['"', '„', '”'], "'");
+        let url = self.url.as_str().replace('"', "\\\"");
+
+        let video_options = {
+            let opts = get_video_opts(app, &self.extractor_hash)
+                .await
+                .with_context(|| {
+                    format!("Failed to get video options for video: '{}'", self.title)
+                })?
+                .to_cli_flags(app);
+            let opts_white = if !opts.is_empty() { " " } else { "" };
+            format!("{}{}", opts_white, opts)
+        };
+
+        Ok(FormattedVideo {
+            cache_path,
+            description,
+            duration: duration.to_string(),
+            extractor_hash: extractor_hash.to_string(),
+            last_status_change,
+            parent_subscription_name,
+            priority: priority.to_string(),
+            publish_date,
+            status: status.to_string(),
+            status_change: status_change.to_string(),
+            thumbnail_url,
+            title,
+            url,
+            video_options,
+        })
+    }
+}
+
+impl<'a> FormatVideo for &'a FormattedVideo {
+    type Output = &'a str;
+
+    fn cache_path(&self) -> Self::Output {
+        &self.cache_path
+    }
+
+    fn description(&self) -> Self::Output {
+        &self.description
+    }
+
+    fn duration(&self) -> Self::Output {
+        &self.duration
+    }
+
+    fn extractor_hash(&self) -> Self::Output {
+        &self.extractor_hash
+    }
+
+    fn last_status_change(&self) -> Self::Output {
+        &self.last_status_change
+    }
+
+    fn parent_subscription_name(&self) -> Self::Output {
+        &self.parent_subscription_name
+    }
+
+    fn priority(&self) -> Self::Output {
+        &self.priority
+    }
+
+    fn publish_date(&self) -> Self::Output {
+        &self.publish_date
+    }
+
+    fn status(&self) -> Self::Output {
+        &self.status
+    }
+
+    fn status_change(&self) -> Self::Output {
+        &self.status_change
+    }
+
+    fn thumbnail_url(&self) -> Self::Output {
+        &self.thumbnail_url
+    }
+
+    fn title(&self) -> Self::Output {
+        &self.title
+    }
+
+    fn url(&self) -> Self::Output {
+        &self.url
+    }
+
+    fn video_options(&self) -> Self::Output {
+        &self.video_options
+    }
+}
+impl<'a> FormatVideo for &'a ColorizedFormattedVideo {
+    type Output = &'a str;
+
+    fn cache_path(&self) -> Self::Output {
+        &self.0.cache_path
+    }
+
+    fn description(&self) -> Self::Output {
+        &self.0.description
+    }
+
+    fn duration(&self) -> Self::Output {
+        &self.0.duration
+    }
+
+    fn extractor_hash(&self) -> Self::Output {
+        &self.0.extractor_hash
+    }
+
+    fn last_status_change(&self) -> Self::Output {
+        &self.0.last_status_change
+    }
+
+    fn parent_subscription_name(&self) -> Self::Output {
+        &self.0.parent_subscription_name
+    }
+
+    fn priority(&self) -> Self::Output {
+        &self.0.priority
+    }
+
+    fn publish_date(&self) -> Self::Output {
+        &self.0.publish_date
+    }
+
+    fn status(&self) -> Self::Output {
+        &self.0.status
+    }
+
+    fn status_change(&self) -> Self::Output {
+        &self.0.status_change
+    }
+
+    fn thumbnail_url(&self) -> Self::Output {
+        &self.0.thumbnail_url
+    }
+
+    fn title(&self) -> Self::Output {
+        &self.0.title
+    }
+
+    fn url(&self) -> Self::Output {
+        &self.0.url
+    }
+
+    fn video_options(&self) -> Self::Output {
+        &self.0.video_options
+    }
+}
diff --git a/src/videos/mod.rs b/src/videos/mod.rs
index 3876bd1..59baa8c 100644
--- a/src/videos/mod.rs
+++ b/src/videos/mod.rs
@@ -9,12 +9,15 @@
 // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
 
 use anyhow::Result;
+use display::{format_video::FormatVideo, FormattedVideo};
 use futures::{stream::FuturesUnordered, TryStreamExt};
 use nucleo_matcher::{
     pattern::{CaseMatching, Normalization, Pattern},
     Matcher,
 };
 
+pub mod display;
+
 use crate::{
     app::App,
     storage::video_database::{getters::get_videos, VideoStatus},
@@ -25,7 +28,7 @@ pub async fn query(app: &App, limit: Option<usize>, search_query: Option<String>
 
     // turn one video to a color display, to pre-warm the hash shrinking cache
     if let Some(val) = all_videos.first() {
-        val.to_color_display(app).await?;
+        val.to_formatted_video(app).await?;
     }
 
     let limit = limit.unwrap_or(all_videos.len());
@@ -33,10 +36,13 @@ pub async fn query(app: &App, limit: Option<usize>, search_query: Option<String>
     let all_video_strings: Vec<String> = all_videos
         .into_iter()
         .take(limit)
-        .map(|vid| vid.to_color_display_owned(app))
+        .map(|vid| vid.to_formatted_video_owned(app))
         .collect::<FuturesUnordered<_>>()
-        .try_collect()
-        .await?;
+        .try_collect::<Vec<FormattedVideo>>()
+        .await?
+        .into_iter()
+        .map(|vid| (&vid.colorize()).to_line_display())
+        .collect();
 
     if let Some(query) = search_query {
         let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT.match_paths());