//! SQLite storage. The only module that speaks SQL. use crate::models::{ Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Video, }; use rusqlite::{params, Connection}; use std::path::Path; pub struct Db { conn: Connection, } const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS channels ( id TEXT PRIMARY KEY, title TEXT NOT NULL, url TEXT NOT NULL, added_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS videos ( id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', published INTEGER NOT NULL, thumb_url TEXT NOT NULL DEFAULT '', thumb_path TEXT, views INTEGER NOT NULL DEFAULT 0, is_short INTEGER NOT NULL DEFAULT 0, fetched_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_videos_published ON videos(published DESC); CREATE INDEX IF NOT EXISTS idx_videos_channel ON videos(channel_id); CREATE TABLE IF NOT EXISTS playback ( video_id TEXT PRIMARY KEY, position REAL NOT NULL, duration REAL NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS downloads ( video_id TEXT PRIMARY KEY, state TEXT NOT NULL, path TEXT, bytes_total INTEGER, bytes_done INTEGER, pct REAL, speed TEXT, eta TEXT, error TEXT, completed_at INTEGER ); "#; fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } impl Db { pub fn open(path: &Path) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create data dir: {e}"))?; } let conn = Connection::open(path).map_err(|e| format!("Cannot open database: {e}"))?; Self::init(conn) } /// In-memory database, for tests only. pub fn open_in_memory_pub() -> Result { let conn = Connection::open_in_memory().map_err(|e| e.to_string())?; Self::init(conn) } #[cfg(test)] pub fn open_in_memory() -> Result { let conn = Connection::open_in_memory().map_err(|e| e.to_string())?; Self::init(conn) } fn init(conn: Connection) -> Result { conn.execute_batch(SCHEMA) .map_err(|e| format!("Cannot create schema: {e}"))?; // CREATE TABLE IF NOT EXISTS leaves existing databases alone, so new // columns need adding explicitly. The error when it already exists is // the expected case, not a failure. let _ = conn.execute("ALTER TABLE videos ADD COLUMN duration INTEGER", []); let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_error TEXT", []); let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_checked INTEGER", []); Ok(Db { conn }) } /// Notes how a channel's last refresh went. `error` of None clears it. pub fn set_channel_result(&self, id: &str, error: Option<&str>) -> Result<(), String> { self.conn .execute( "UPDATE channels SET last_error = ?2, last_checked = ?3 WHERE id = ?1", params![id, error, now()], ) .map_err(|e| e.to_string())?; Ok(()) } /// Records a video's length in seconds. pub fn set_duration(&self, video_id: &str, seconds: i64) -> Result<(), String> { if seconds <= 0 { return Ok(()); } self.conn .execute( "UPDATE videos SET duration = ?2 WHERE id = ?1", params![video_id, seconds], ) .map_err(|e| e.to_string())?; Ok(()) } /// Of the given videos, those whose length is still unknown, in the order /// they were given so what is on screen first is filled first. pub fn filter_missing_duration( &self, ids: &[String], limit: i64, ) -> Result, String> { let mut stmt = self .conn .prepare("SELECT duration FROM videos WHERE id = ?1") .map_err(|e| e.to_string())?; let mut out = Vec::new(); for id in ids { if out.len() as i64 >= limit { break; } let known: Option> = stmt .query_row(params![id], |r| r.get::<_, Option>(0)) .ok(); if matches!(known, Some(None)) { out.push(id.clone()); } } Ok(out) } /// Videos whose length is still unknown, newest first. pub fn videos_missing_duration(&self, limit: i64) -> Result, String> { let mut stmt = self .conn .prepare( "SELECT id FROM videos WHERE duration IS NULL ORDER BY published DESC LIMIT ?1", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map(params![limit], |r| r.get::<_, String>(0)) .map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } /// What a replacing import would destroy. Callers show this before asking /// the user to confirm, so nothing is deleted without being named first. pub fn preview_replace(&self, incoming: &[Channel]) -> Result { let keep: std::collections::HashSet<&str> = incoming.iter().map(|c| c.id.as_str()).collect(); let mut removed_channels = 0i64; for id in self.channel_ids()? { if !keep.contains(id.as_str()) { removed_channels += 1; } } let mut stmt = self .conn .prepare( "SELECT v.channel_id, COUNT(*), SUM(CASE WHEN d.state = 'done' THEN 1 ELSE 0 END) FROM videos v LEFT JOIN downloads d ON d.video_id = v.id GROUP BY v.channel_id", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |r| { Ok(( r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Option>(2)?.unwrap_or(0), )) }) .map_err(|e| e.to_string())?; let mut removed_videos = 0i64; let mut removed_downloads = 0i64; for row in rows { let (cid, videos, downloads) = row.map_err(|e| e.to_string())?; if !keep.contains(cid.as_str()) { removed_videos += videos; removed_downloads += downloads; } } Ok(ImportPreview { incoming: incoming.len() as i64, removed_channels, removed_videos, removed_downloads, }) } /// Files belonging to channels that a replacing import would drop, so the /// caller can delete them rather than orphaning them on disk. pub fn paths_dropped_by_replace(&self, incoming: &[Channel]) -> Result, String> { let keep: std::collections::HashSet<&str> = incoming.iter().map(|c| c.id.as_str()).collect(); let mut stmt = self .conn .prepare( "SELECT v.channel_id, d.path FROM downloads d JOIN videos v ON v.id = d.video_id WHERE d.path IS NOT NULL", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) .map_err(|e| e.to_string())?; let mut out = Vec::new(); for row in rows { let (cid, path) = row.map_err(|e| e.to_string())?; if !keep.contains(cid.as_str()) { out.push(path); } } Ok(out) } /// Replaces the subscription list outright: the imported CSV becomes the /// whole truth. Channels no longer present are dropped along with their /// videos and download records. Channels that survive keep their videos and /// download state untouched. pub fn replace_channels(&mut self, channels: &[Channel]) -> Result { let tx = self.conn.transaction().map_err(|e| e.to_string())?; { // A temp table keeps the delete set explicit and avoids building a // giant IN(...) clause for a few hundred channels. tx.execute_batch( "CREATE TEMP TABLE IF NOT EXISTS keep_ids (id TEXT PRIMARY KEY); DELETE FROM keep_ids;", ) .map_err(|e| e.to_string())?; { let mut ins = tx .prepare("INSERT OR IGNORE INTO keep_ids (id) VALUES (?1)") .map_err(|e| e.to_string())?; for c in channels { ins.execute(params![c.id]).map_err(|e| e.to_string())?; } } tx.execute_batch( "DELETE FROM downloads WHERE video_id IN ( SELECT id FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids)); DELETE FROM playback WHERE video_id IN ( SELECT id FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids)); DELETE FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids); DELETE FROM channels WHERE id NOT IN (SELECT id FROM keep_ids);", ) .map_err(|e| e.to_string())?; let mut stmt = tx .prepare( "INSERT INTO channels (id, title, url, added_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(id) DO UPDATE SET title=excluded.title, url=excluded.url", ) .map_err(|e| e.to_string())?; let ts = now(); for c in channels { stmt.execute(params![c.id, c.title, c.url, ts]) .map_err(|e| e.to_string())?; } } tx.commit().map_err(|e| e.to_string())?; Ok(channels.len()) } pub fn upsert_channels(&mut self, channels: &[Channel]) -> Result { let tx = self.conn.transaction().map_err(|e| e.to_string())?; { let mut stmt = tx .prepare( "INSERT INTO channels (id, title, url, added_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(id) DO UPDATE SET title=excluded.title, url=excluded.url", ) .map_err(|e| e.to_string())?; let ts = now(); for c in channels { stmt.execute(params![c.id, c.title, c.url, ts]) .map_err(|e| e.to_string())?; } } tx.commit().map_err(|e| e.to_string())?; Ok(channels.len()) } pub fn list_channels(&self) -> Result, String> { let mut stmt = self .conn .prepare( "SELECT c.id, c.title, c.url, (SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id), (SELECT COUNT(*) FROM videos v JOIN downloads d ON d.video_id = v.id WHERE v.channel_id = c.id AND d.state = 'done'), c.last_error FROM channels c ORDER BY c.title COLLATE NOCASE ASC", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |r| { Ok(ChannelWithCount { id: r.get(0)?, title: r.get(1)?, url: r.get(2)?, video_count: r.get(3)?, downloaded_count: r.get(4)?, last_error: r.get(5)?, }) }) .map_err(|e| e.to_string())?; rows.collect::, _>>() .map_err(|e| e.to_string()) } pub fn channel_ids(&self) -> Result, String> { let mut stmt = self .conn .prepare("SELECT id FROM channels") .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |r| r.get::<_, String>(0)) .map_err(|e| e.to_string())?; rows.collect::, _>>() .map_err(|e| e.to_string()) } /// Upserts video metadata only. Deliberately never touches the `downloads` /// table, so a refresh cannot lose the record of what has been downloaded. /// `thumb_path` is preserved via COALESCE for the same reason. pub fn upsert_videos(&mut self, videos: &[Video]) -> Result { let tx = self.conn.transaction().map_err(|e| e.to_string())?; { let mut stmt = tx .prepare( "INSERT INTO videos (id, channel_id, title, description, published, thumb_url, views, is_short, fetched_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(id) DO UPDATE SET channel_id = excluded.channel_id, title = excluded.title, description = excluded.description, published = excluded.published, thumb_url = excluded.thumb_url, views = excluded.views, is_short = excluded.is_short, fetched_at = excluded.fetched_at", ) .map_err(|e| e.to_string())?; let ts = now(); for v in videos { stmt.execute(params![ v.id, v.channel_id, v.title, v.description, v.published, v.thumb_url, v.views, v.is_short as i64, ts ]) .map_err(|e| e.to_string())?; } } tx.commit().map_err(|e| e.to_string())?; Ok(videos.len()) } pub fn set_thumb_path(&self, video_id: &str, path: &str) -> Result<(), String> { self.conn .execute( "UPDATE videos SET thumb_path = ?2 WHERE id = ?1", params![video_id, path], ) .map_err(|e| e.to_string())?; Ok(()) } /// Videos that still need a thumbnail cached locally. pub fn videos_missing_thumbs(&self, limit: i64) -> Result, String> { let mut stmt = self .conn .prepare( "SELECT id, thumb_url FROM videos WHERE thumb_path IS NULL AND thumb_url <> '' ORDER BY published DESC LIMIT ?1", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map(params![limit], |r| Ok((r.get(0)?, r.get(1)?))) .map_err(|e| e.to_string())?; rows.collect::, _>>() .map_err(|e| e.to_string()) } pub fn list_feed(&self, f: &FeedFilter) -> Result, String> { let mut sql = String::from( "SELECT v.id, v.channel_id, COALESCE(c.title, ''), v.title, v.description, v.published, v.thumb_url, v.thumb_path, v.views, v.is_short, d.state, d.path, d.pct, d.error, p.position, COALESCE(v.duration, p.duration) FROM videos v LEFT JOIN channels c ON c.id = v.channel_id LEFT JOIN downloads d ON d.video_id = v.id LEFT JOIN playback p ON p.video_id = v.id WHERE 1=1", ); let mut args: Vec> = Vec::new(); if let Some(cid) = f.channel_id.as_ref().filter(|s| !s.is_empty()) { sql.push_str(" AND v.channel_id = ?"); args.push(Box::new(cid.clone())); } if let Some(q) = f.search.as_ref().filter(|s| !s.trim().is_empty()) { sql.push_str(" AND (v.title LIKE ? COLLATE NOCASE OR c.title LIKE ? COLLATE NOCASE)"); let pat = format!("%{}%", q.trim()); args.push(Box::new(pat.clone())); args.push(Box::new(pat)); } if f.downloaded_only { // Queued and running count: a download you started should not // vanish from the very list you are watching it in. sql.push_str(" AND d.state IN ('done','queued','running')"); } if f.hide_shorts { sql.push_str(" AND v.is_short = 0"); } sql.push_str(" ORDER BY v.published DESC"); if let Some(l) = f.limit { sql.push_str(" LIMIT ?"); args.push(Box::new(l)); } let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; let refs: Vec<&dyn rusqlite::ToSql> = args.iter().map(|b| b.as_ref()).collect(); let rows = stmt .query_map(refs.as_slice(), |r| { let state: Option = r.get(10)?; Ok(FeedItem { id: r.get(0)?, channel_id: r.get(1)?, channel_title: r.get(2)?, title: r.get(3)?, description: r.get(4)?, published: r.get(5)?, thumb_url: r.get(6)?, thumb_path: r.get(7)?, views: r.get(8)?, is_short: r.get::<_, i64>(9)? != 0, state: state.as_deref().and_then(DownloadState::from_str), path: r.get(11)?, pct: r.get(12)?, error: r.get(13)?, position: r.get(14)?, duration: r.get(15)?, }) }) .map_err(|e| e.to_string())?; rows.collect::, _>>() .map_err(|e| e.to_string()) } /// Remembers where playback got to, so the feed can show a progress bar and /// reopening a video can resume it. pub fn save_playback(&self, video_id: &str, position: f64, duration: f64) -> Result<(), String> { if !(position.is_finite() && duration.is_finite()) || duration <= 0.0 { return Ok(()); } self.conn .execute( "INSERT INTO playback (video_id, position, duration, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(video_id) DO UPDATE SET position = excluded.position, duration = excluded.duration, updated_at = excluded.updated_at", params![video_id, position.max(0.0), duration, now()], ) .map_err(|e| e.to_string())?; Ok(()) } pub fn set_download_state( &self, video_id: &str, state: DownloadState, error: Option<&str>, ) -> Result<(), String> { let completed = matches!(state, DownloadState::Done).then(now); self.conn .execute( "INSERT INTO downloads (video_id, state, error, completed_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(video_id) DO UPDATE SET state = excluded.state, error = excluded.error, completed_at = COALESCE(excluded.completed_at, downloads.completed_at)", params![video_id, state.as_str(), error, completed], ) .map_err(|e| e.to_string())?; Ok(()) } pub fn set_download_progress( &self, video_id: &str, done: u64, total: Option, pct: Option, ) -> Result<(), String> { self.conn .execute( "UPDATE downloads SET bytes_done = ?2, bytes_total = ?3, pct = ?4 WHERE video_id = ?1", params![ video_id, done as i64, total.map(|t| t as i64), pct ], ) .map_err(|e| e.to_string())?; Ok(()) } pub fn set_download_path(&self, video_id: &str, path: &str) -> Result<(), String> { self.conn .execute( "UPDATE downloads SET path = ?2 WHERE video_id = ?1", params![video_id, path], ) .map_err(|e| e.to_string())?; Ok(()) } /// The recorded state, or None if the video was never queued. pub fn download_state(&self, video_id: &str) -> Result, String> { self.conn .query_row( "SELECT state FROM downloads WHERE video_id = ?1", params![video_id], |r| r.get::<_, String>(0), ) .map(Some) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), }) .map_err(|e| e.to_string()) } pub fn get_download_path(&self, video_id: &str) -> Result, String> { self.conn .query_row( "SELECT path FROM downloads WHERE video_id = ?1", params![video_id], |r| r.get::<_, Option>(0), ) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), }) .map_err(|e| e.to_string()) } /// Paths of every completed download, for deleting them all at once. pub fn all_download_paths(&self) -> Result, String> { let mut stmt = self .conn .prepare("SELECT path FROM downloads WHERE path IS NOT NULL") .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |r| r.get::<_, String>(0)) .map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } /// Videos the database still considers in flight. Includes any left /// "running" by a crash, which no live process backs any more. pub fn active_downloads(&self) -> Result, String> { let mut stmt = self .conn .prepare("SELECT video_id FROM downloads WHERE state IN ('queued','running')") .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |r| r.get::<_, String>(0)) .map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } pub fn clear_all_downloads(&self) -> Result { let n = self .conn .execute("DELETE FROM downloads", []) .map_err(|e| e.to_string())?; Ok(n) } pub fn clear_download(&self, video_id: &str) -> Result<(), String> { self.conn .execute("DELETE FROM downloads WHERE video_id = ?1", params![video_id]) .map_err(|e| e.to_string())?; Ok(()) } pub fn video_title(&self, video_id: &str) -> Result { self.conn .query_row( "SELECT title FROM videos WHERE id = ?1", params![video_id], |r| r.get(0), ) .map_err(|e| e.to_string()) } } #[cfg(test)] mod tests { use super::*; fn vid(id: &str, chan: &str, published: i64, short: bool) -> Video { Video { id: id.into(), channel_id: chan.into(), title: format!("Title {id}"), description: "d".into(), published, thumb_url: format!("https://i.ytimg.com/vi/{id}/hq.jpg"), views: 10, is_short: short, } } fn seeded() -> Db { let mut db = Db::open_in_memory().unwrap(); db.upsert_channels(&[ Channel { id: "UC1".into(), title: "Alpha".into(), url: "u1".into() }, Channel { id: "UC2".into(), title: "Beta".into(), url: "u2".into() }, ]) .unwrap(); db.upsert_videos(&[ vid("a", "UC1", 100, false), vid("b", "UC2", 300, false), vid("c", "UC1", 200, true), ]) .unwrap(); db } #[test] fn playback_position_surfaces_on_the_feed() { let db = seeded(); db.save_playback("a", 30.0, 120.0).unwrap(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); let a = feed.iter().find(|f| f.id == "a").unwrap(); assert_eq!(a.position, Some(30.0)); assert_eq!(a.duration, Some(120.0)); // Videos never opened have no bar to draw. let b = feed.iter().find(|f| f.id == "b").unwrap(); assert_eq!(b.position, None); } #[test] fn saving_playback_twice_updates_in_place() { let db = seeded(); db.save_playback("a", 10.0, 120.0).unwrap(); db.save_playback("a", 90.0, 120.0).unwrap(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().position, Some(90.0)); } #[test] fn nonsense_playback_values_are_ignored() { let db = seeded(); db.save_playback("a", f64::NAN, 120.0).unwrap(); db.save_playback("a", 5.0, 0.0).unwrap(); db.save_playback("a", 5.0, f64::INFINITY).unwrap(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().position, None); } #[test] fn replacing_drops_playback_of_removed_channels() { let mut db = seeded(); db.save_playback("a", 10.0, 120.0).unwrap(); db.save_playback("b", 10.0, 120.0).unwrap(); db.replace_channels(&[Channel { id: "UC1".into(), title: "Alpha".into(), url: "u1".into() }]) .unwrap(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); assert!(feed.iter().all(|f| f.id != "b")); assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().position, Some(10.0)); } #[test] fn replacing_drops_channels_absent_from_the_new_csv() { let mut db = seeded(); db.set_download_state("b", DownloadState::Done, None).unwrap(); // New CSV contains only UC1; UC2 (and its downloaded video "b") must go. db.replace_channels(&[Channel { id: "UC1".into(), title: "Alpha".into(), url: "u1".into(), }]) .unwrap(); let chans = db.list_channels().unwrap(); assert_eq!(chans.len(), 1); assert_eq!(chans[0].id, "UC1"); let feed = db.list_feed(&FeedFilter::default()).unwrap(); assert!(feed.iter().all(|f| f.channel_id == "UC1")); assert!(feed.iter().all(|f| f.id != "b"), "video of dropped channel must go"); } #[test] fn replacing_keeps_download_state_for_surviving_channels() { let mut db = seeded(); db.set_download_state("a", DownloadState::Done, None).unwrap(); db.set_download_path("a", "/movies/a.mp4").unwrap(); db.replace_channels(&[Channel { id: "UC1".into(), title: "Alpha renamed".into(), url: "u1".into(), }]) .unwrap(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); let a = feed.iter().find(|f| f.id == "a").expect("surviving video kept"); assert_eq!(a.state, Some(DownloadState::Done)); assert_eq!(a.path.as_deref(), Some("/movies/a.mp4")); assert_eq!(a.channel_title, "Alpha renamed"); } #[test] fn preview_counts_what_replacing_would_remove() { let mut db = seeded(); db.set_download_state("b", DownloadState::Done, None).unwrap(); let incoming = vec![Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() }]; let p = db.preview_replace(&incoming).unwrap(); assert_eq!(p.incoming, 1); assert_eq!(p.removed_channels, 1); assert_eq!(p.removed_videos, 1); assert_eq!(p.removed_downloads, 1); } #[test] fn preview_removes_nothing_when_csv_is_a_superset() { let mut db = seeded(); let incoming = vec![ Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() }, Channel { id: "UC2".into(), title: "Beta".into(), url: "u".into() }, Channel { id: "UC3".into(), title: "Gamma".into(), url: "u".into() }, ]; let p = db.preview_replace(&incoming).unwrap(); assert_eq!(p.removed_channels, 0); assert_eq!(p.removed_videos, 0); assert_eq!(p.removed_downloads, 0); } #[test] fn dropped_paths_lists_only_files_of_removed_channels() { let mut db = seeded(); db.set_download_state("a", DownloadState::Done, None).unwrap(); db.set_download_path("a", "/movies/a.mp4").unwrap(); db.set_download_state("b", DownloadState::Done, None).unwrap(); db.set_download_path("b", "/movies/b.mp4").unwrap(); let incoming = vec![Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() }]; let paths = db.paths_dropped_by_replace(&incoming).unwrap(); assert_eq!(paths, vec!["/movies/b.mp4".to_string()]); } #[test] fn upserting_same_channel_twice_yields_one_row() { let mut db = Db::open_in_memory().unwrap(); let c = Channel { id: "UC1".into(), title: "First".into(), url: "u".into() }; db.upsert_channels(&[c.clone()]).unwrap(); db.upsert_channels(&[Channel { title: "Renamed".into(), ..c }]).unwrap(); let all = db.list_channels().unwrap(); assert_eq!(all.len(), 1); assert_eq!(all[0].title, "Renamed"); } #[test] fn refresh_preserves_download_state() { let mut db = seeded(); db.set_download_state("a", DownloadState::Done, None).unwrap(); db.set_download_path("a", "/movies/a.mp4").unwrap(); // Simulate a later refresh returning updated metadata for the same video. let mut updated = vid("a", "UC1", 100, false); updated.title = "Retitled".into(); updated.views = 999; db.upsert_videos(&[updated]).unwrap(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); let a = feed.iter().find(|f| f.id == "a").unwrap(); assert_eq!(a.title, "Retitled", "metadata should update"); assert_eq!(a.views, 999); assert_eq!(a.state, Some(DownloadState::Done), "download state must survive refresh"); assert_eq!(a.path.as_deref(), Some("/movies/a.mp4")); } #[test] fn feed_is_sorted_newest_first() { let db = seeded(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); let order: Vec<&str> = feed.iter().map(|f| f.id.as_str()).collect(); assert_eq!(order, vec!["b", "c", "a"]); } #[test] fn feed_joins_channel_title() { let db = seeded(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); let b = feed.iter().find(|f| f.id == "b").unwrap(); assert_eq!(b.channel_title, "Beta"); } #[test] fn downloaded_only_filter_excludes_undownloaded() { let db = seeded(); db.set_download_state("b", DownloadState::Done, None).unwrap(); // A failed download must not count as available offline. db.set_download_state("a", DownloadState::Failed, Some("boom")).unwrap(); let feed = db .list_feed(&FeedFilter { downloaded_only: true, ..Default::default() }) .unwrap(); assert_eq!(feed.len(), 1); assert_eq!(feed[0].id, "b"); } #[test] fn only_the_visible_videos_without_a_length_are_queued() { let db = seeded(); db.set_duration("b", 120).unwrap(); // Order follows what was asked for, so the top of the screen fills first. let want = vec!["c".to_string(), "b".to_string(), "a".to_string()]; assert_eq!( db.filter_missing_duration(&want, 10).unwrap(), vec!["c".to_string(), "a".to_string()] ); // Unknown ids are simply skipped rather than queued forever. assert!(db .filter_missing_duration(&["nope".to_string()], 10) .unwrap() .is_empty()); assert_eq!(db.filter_missing_duration(&want, 1).unwrap().len(), 1); } #[test] fn a_channel_failure_is_remembered_and_can_be_cleared() { let db = seeded(); assert!(db.list_channels().unwrap().iter().all(|c| c.last_error.is_none())); db.set_channel_result("UC1", Some("Feed returned HTTP 404")).unwrap(); let failed = db.list_channels().unwrap(); let alpha = failed.iter().find(|c| c.id == "UC1").unwrap(); assert_eq!(alpha.last_error.as_deref(), Some("Feed returned HTTP 404")); // Other channels are untouched. assert!(failed.iter().find(|c| c.id == "UC2").unwrap().last_error.is_none()); db.set_channel_result("UC1", None).unwrap(); assert!(db .list_channels() .unwrap() .iter() .all(|c| c.last_error.is_none())); } #[test] fn a_recorded_duration_reaches_the_feed() { let db = seeded(); db.set_duration("a", 754).unwrap(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().duration, Some(754.0)); } #[test] fn playback_duration_fills_in_when_the_video_length_is_unknown() { let db = seeded(); db.save_playback("b", 10.0, 300.0).unwrap(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); assert_eq!(feed.iter().find(|f| f.id == "b").unwrap().duration, Some(300.0)); } #[test] fn videos_without_a_duration_are_reported_for_lookup() { let db = seeded(); assert_eq!(db.videos_missing_duration(10).unwrap().len(), 3); db.set_duration("a", 100).unwrap(); assert_eq!(db.videos_missing_duration(10).unwrap().len(), 2); // A nonsense length is ignored rather than stored. db.set_duration("b", 0).unwrap(); assert_eq!(db.videos_missing_duration(10).unwrap().len(), 2); } #[test] fn downloads_in_progress_still_show_in_the_downloaded_filter() { let db = seeded(); db.set_download_state("a", DownloadState::Running, None).unwrap(); db.set_download_state("c", DownloadState::Queued, None).unwrap(); db.set_download_state("b", DownloadState::Failed, Some("x")).unwrap(); let feed = db .list_feed(&FeedFilter { downloaded_only: true, ..Default::default() }) .unwrap(); let ids: Vec<&str> = feed.iter().map(|f| f.id.as_str()).collect(); assert!(ids.contains(&"a"), "running should be listed"); assert!(ids.contains(&"c"), "queued should be listed"); assert!(!ids.contains(&"b"), "failed should not be"); } #[test] fn clearing_all_downloads_empties_the_filter() { let db = seeded(); db.set_download_state("a", DownloadState::Done, None).unwrap(); db.set_download_path("a", "/movies/a.mp4").unwrap(); db.set_download_state("b", DownloadState::Done, None).unwrap(); assert_eq!(db.all_download_paths().unwrap(), vec!["/movies/a.mp4".to_string()]); db.clear_all_downloads().unwrap(); assert!(db .list_feed(&FeedFilter { downloaded_only: true, ..Default::default() }) .unwrap() .is_empty()); } #[test] fn hide_shorts_filter_excludes_shorts() { let db = seeded(); let feed = db .list_feed(&FeedFilter { hide_shorts: true, ..Default::default() }) .unwrap(); assert_eq!(feed.len(), 2); assert!(feed.iter().all(|f| !f.is_short)); } #[test] fn channel_filter_scopes_to_one_channel() { let db = seeded(); let feed = db .list_feed(&FeedFilter { channel_id: Some("UC1".into()), ..Default::default() }) .unwrap(); assert_eq!(feed.len(), 2); assert!(feed.iter().all(|f| f.channel_id == "UC1")); } #[test] fn search_matches_title_case_insensitively() { let db = seeded(); let feed = db .list_feed(&FeedFilter { search: Some("title B".into()), ..Default::default() }) .unwrap(); assert_eq!(feed.len(), 1); assert_eq!(feed[0].id, "b"); } #[test] fn search_also_matches_channel_name() { let db = seeded(); let feed = db .list_feed(&FeedFilter { search: Some("beta".into()), ..Default::default() }) .unwrap(); assert_eq!(feed.len(), 1); assert_eq!(feed[0].id, "b"); } #[test] fn channel_list_reports_video_and_download_counts() { let mut db = seeded(); db.set_download_state("a", DownloadState::Done, None).unwrap(); let chans = db.list_channels().unwrap(); let alpha = chans.iter().find(|c| c.id == "UC1").unwrap(); assert_eq!(alpha.video_count, 2); assert_eq!(alpha.downloaded_count, 1); } #[test] fn progress_updates_are_recorded() { let db = seeded(); db.set_download_state("a", DownloadState::Running, None).unwrap(); db.set_download_progress("a", 50, Some(100), Some(50.0)).unwrap(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); let a = feed.iter().find(|f| f.id == "a").unwrap(); assert_eq!(a.pct, Some(50.0)); assert_eq!(a.state, Some(DownloadState::Running)); } #[test] fn download_state_is_readable_and_absent_until_queued() { let db = seeded(); assert_eq!(db.download_state("a").unwrap(), None); db.set_download_state("a", DownloadState::Queued, None).unwrap(); assert_eq!(db.download_state("a").unwrap().as_deref(), Some("queued")); // What a waiting download checks before it claims a slot. db.set_download_state("a", DownloadState::Cancelled, None).unwrap(); assert_ne!( db.download_state("a").unwrap().as_deref(), Some(DownloadState::Queued.as_str()) ); } #[test] fn active_downloads_are_the_queued_and_running_ones() { let db = seeded(); db.set_download_state("a", DownloadState::Running, None).unwrap(); db.set_download_state("b", DownloadState::Queued, None).unwrap(); db.set_download_state("c", DownloadState::Done, None).unwrap(); let mut active = db.active_downloads().unwrap(); active.sort(); assert_eq!(active, vec!["a".to_string(), "b".to_string()]); // Stopping them takes them out of flight without deleting anything. db.set_download_state("a", DownloadState::Cancelled, None).unwrap(); db.set_download_state("b", DownloadState::Cancelled, None).unwrap(); assert!(db.active_downloads().unwrap().is_empty()); } #[test] fn clearing_a_download_makes_it_undownloaded_again() { let db = seeded(); db.set_download_state("a", DownloadState::Done, None).unwrap(); db.clear_download("a").unwrap(); let feed = db.list_feed(&FeedFilter::default()).unwrap(); assert!(feed.iter().find(|f| f.id == "a").unwrap().state.is_none()); } #[test] fn thumbs_needing_cache_are_reported_then_cleared() { let db = seeded(); assert_eq!(db.videos_missing_thumbs(10).unwrap().len(), 3); db.set_thumb_path("a", "/cache/a.jpg").unwrap(); assert_eq!(db.videos_missing_thumbs(10).unwrap().len(), 2); } }