feat: Takeout CSV, Atom feed, yt-dlp progress, and SQLite layers

37 unit tests covering the three parsers and the database, including
that a feed refresh preserves download state and that a truncated
feed response fails rather than storing partial results.
This commit is contained in:
vincent
2026-08-29 02:26:17 +02:00
parent 6fdad60190
commit b724e9d56f
11 changed files with 7139 additions and 6 deletions
+527
View File
@@ -0,0 +1,527 @@
//! SQLite storage. The only module that speaks SQL.
use crate::models::{Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, 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 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)
}
#[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}"))?;
Ok(Db { conn })
}
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')
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)?,
})
})
.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
FROM videos v
LEFT JOIN channels c ON c.id = v.channel_id
LEFT JOIN downloads d ON d.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 {
sql.push_str(" AND d.state = 'done'");
}
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)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())
}
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(())
}
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())
}
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 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 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 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);
}
}