Files
FlightTube/src-tauri/src/db.rs
T
vincent 967bcdde93 feat: stop every download at once
A stop button appears beside Download all whenever anything is
downloading or waiting to, and disappears when nothing is. It kills the
running processes, marks everything the database still calls queued or
running as cancelled, and clears the part files. Downloads already
finished are untouched.

That also covers two cases a per-video Cancel cannot reach: a download
parked on a slot, which stands down when its turn comes, and a row left
"running" by a crash that no process backs any more.

Cancelling no longer reports itself as a failure. download_video
returned Err("Download was cancelled.") when it found its child gone,
which the caller turned into an error dialog — pressing Stop all would
have raised one per download. Stopping something is a normal outcome:
the state is already recorded and the event already sent, so it returns
cleanly.
2026-08-29 16:31:41 +02:00

1068 lines
39 KiB
Rust

//! 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<Db, String> {
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<Db, String> {
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
Self::init(conn)
}
#[cfg(test)]
pub fn open_in_memory() -> Result<Db, String> {
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
Self::init(conn)
}
fn init(conn: Connection) -> Result<Db, String> {
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<Vec<String>, 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<Option<i64>> = stmt
.query_row(params![id], |r| r.get::<_, Option<i64>>(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<Vec<String>, 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::<Result<Vec<_>, _>>().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<ImportPreview, String> {
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<i64>>(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<Vec<String>, 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<usize, String> {
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<usize, String> {
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<Vec<ChannelWithCount>, 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::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())
}
pub fn channel_ids(&self) -> Result<Vec<String>, 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::<Result<Vec<_>, _>>()
.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<usize, String> {
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<Vec<(String, String)>, 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::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())
}
pub fn list_feed(&self, f: &FeedFilter) -> Result<Vec<FeedItem>, 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<Box<dyn rusqlite::ToSql>> = 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<String> = 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::<Result<Vec<_>, _>>()
.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<u64>,
pct: Option<f64>,
) -> 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<Option<String>, 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<Option<String>, String> {
self.conn
.query_row(
"SELECT path FROM downloads WHERE video_id = ?1",
params![video_id],
|r| r.get::<_, Option<String>>(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<Vec<String>, 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::<Result<Vec<_>, _>>().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<Vec<String>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
pub fn clear_all_downloads(&self) -> Result<usize, String> {
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<String, String> {
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);
}
}