// 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, 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> { 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 }) }