diff options
Diffstat (limited to 'src/storage')
-rw-r--r-- | src/storage/mod.rs | 12 | ||||
-rw-r--r-- | src/storage/subscriptions.rs | 140 | ||||
-rw-r--r-- | src/storage/video_database/downloader.rs | 153 | ||||
-rw-r--r-- | src/storage/video_database/extractor_hash.rs | 159 | ||||
-rw-r--r-- | src/storage/video_database/getters.rs | 345 | ||||
-rw-r--r-- | src/storage/video_database/mod.rs | 179 | ||||
-rw-r--r-- | src/storage/video_database/schema.sql | 57 | ||||
-rw-r--r-- | src/storage/video_database/setters.rs | 270 |
8 files changed, 0 insertions, 1315 deletions
diff --git a/src/storage/mod.rs b/src/storage/mod.rs deleted file mode 100644 index 6a12d8b..0000000 --- a/src/storage/mod.rs +++ /dev/null @@ -1,12 +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>. - -pub mod subscriptions; -pub mod video_database; diff --git a/src/storage/subscriptions.rs b/src/storage/subscriptions.rs deleted file mode 100644 index 22edd08..0000000 --- a/src/storage/subscriptions.rs +++ /dev/null @@ -1,140 +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>. - -//! 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 deleted file mode 100644 index ccd4ca9..0000000 --- a/src/storage/video_database/downloader.rs +++ /dev/null @@ -1,153 +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::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(); - - // NOTE: The ORDER BY statement should be the same as the one in [`getters::get_videos`].<2024-08-22> - let result = query!( - r#" - SELECT * - FROM videos - WHERE status = ? AND cache_path IS NULL - ORDER BY priority DESC, publish_date DESC - LIMIT 1; - "#, - status - ) - .fetch_one(&app.database) - .await; - - if let Err(sqlx::Error::RowNotFound) = result { - Ok(None) - } else { - let base = result?; - - let thumbnail_url = base - .thumbnail_url - .as_ref() - .map(|url| Url::parse(url).expect("Parsing this as url should always work")); - - 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(PathBuf::from), - 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).expect("Parsing this as url should always work"), - }; - - 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 deleted file mode 100644 index c956919..0000000 --- a/src/storage/video_database/extractor_hash.rs +++ /dev/null @@ -1,159 +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::{collections::HashMap, fmt::Display, str::FromStr}; - -use anyhow::{bail, Context, 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, Hash)] -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); - -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() { - *needed_chars - } else { - 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"); - - needed_chars - }; - - Ok(ShortHash( - self.hash() - .to_hex() - .chars() - .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 - .context("Failed to fetch all extractor -hashesh from database")?; - - 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 - .context("Failed to fetch all extractor -hashesh from database")?; - - 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 deleted file mode 100644 index 29dd014..0000000 --- a/src/storage/video_database/getters.rs +++ /dev/null @@ -1,345 +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>. - -//! 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).expect("Parsing this as url should always work")) - } 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).expect("Parsing this as url should always work"), - 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, publish_date 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> { - Ok(Video { - cache_path: base - .get::<Option<String>, &str>("cache_path") - .as_ref() - .map(PathBuf::from), - 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: base - .get::<Option<String>, &str>("thumbnail_url") - .as_ref() - .map(|url| Url::parse(url).expect("Parsing this as url should always work")), - title: base.get::<String, &str>("title").to_owned(), - url: Url::parse(base.get("url")).expect("Parsing this as url should always work"), - 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 - .with_context(|| { - format!( - "Failed to fetch the `yt_dlp_video_opts` for video: {}", - hash - ) - })?; - - 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 - .with_context(|| format!("Failed to fetch the `mpv_video_opts` for video: {}", hash))?; - - Ok(MpvOptions { - playback_speed: mpv_options.playback_speed, - }) -} - -pub async fn get_video_opts(app: &App, hash: &ExtractorHash) -> Result<VideoOptions> { - let ehash = hash.hash().to_string(); - - let opts = query!( - r#" - SELECT playback_speed, subtitle_langs - FROM video_options - WHERE extractor_hash = ?; - "#, - ehash - ) - .fetch_one(&app.database) - .await - .with_context(|| format!("Failed to fetch the `video_opts` for video: {}", hash))?; - - 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 deleted file mode 100644 index 1765f79..0000000 --- a/src/storage/video_database/mod.rs +++ /dev/null @@ -1,179 +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, path::PathBuf}; - -use url::Url; - -use crate::{app::App, storage::video_database::extractor_hash::ExtractorHash}; - -pub mod downloader; -pub mod extractor_hash; -pub mod getters; -pub mod setters; - -#[derive(Debug, Clone)] -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, app: &App) -> String { - let mut f = String::new(); - - if self.mpv.playback_speed != app.config.select.playback_speed { - write!(f, " --speed '{}'", self.mpv.playback_speed).expect("Works"); - } - if self.yt_dlp.subtitle_langs != app.config.select.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, Clone, Copy)] -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 const ALL: &'static [Self; 6] = &[ - Self::Pick, - // - VideoStatus::Watch, - VideoStatus::Cached, - VideoStatus::Watched, - // - VideoStatus::Drop, - VideoStatus::Dropped, - ]; - - pub fn as_command(&self) -> &str { - // NOTE: Keep the serialize able variants synced with the main `select` function <2024-06-14> - // Also try to ensure, that the strings have the same length - match self { - VideoStatus::Pick => "pick ", - - VideoStatus::Watch => "watch ", - VideoStatus::Cached => "watch ", - VideoStatus::Watched => "watched", - - 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 deleted file mode 100644 index 3afd091..0000000 --- a/src/storage/video_database/schema.sql +++ /dev/null @@ -1,57 +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>. - --- All tables should be declared STRICT, as I actually like to have types checking (and a --- db that doesn't lie to me). - --- 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 REAL, - 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 -) STRICT; - --- 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) -) STRICT; - --- Store subscriptions -CREATE TABLE IF NOT EXISTS subscriptions ( - name TEXT UNIQUE NOT NULL PRIMARY KEY, - url TEXT NOT NULL -) STRICT; diff --git a/src/storage/video_database/setters.rs b/src/storage/video_database/setters.rs deleted file mode 100644 index c160138..0000000 --- a/src/storage/video_database/setters.rs +++ /dev/null @@ -1,270 +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>. - -//! These functions change the database. They are added on a demand basis. - -use anyhow::Result; -use chrono::Utc; -use log::{debug, info}; -use sqlx::query; -use tokio::fs; - -use crate::{app::App, 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(); - - info!("Will set video watched: '{}'", video.title); - - let old = query!( - r#" - SELECT status, priority - FROM videos - WHERE extractor_hash = ? - "#, - video_hash - ) - .fetch_one(&app.database) - .await?; - - assert_ne!( - old.status, new_status, - "The video should not be marked as watched already." - ); - assert_eq!( - old.status, - VideoStatus::Cached.as_db_integer(), - "The video should have been marked cached" - ); - - 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 = video.parent_subscription_name; - - let thumbnail_url = video.thumbnail_url.map(|val| val.to_string()); - - 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 = &app.config.select.subtitle_langs; - let default_mpv_playback_speed = app.config.select.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(()) -} |