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);
}
}
+175
View File
@@ -0,0 +1,175 @@
//! Driving `yt-dlp` and interpreting its progress output.
//!
//! The format selector is deliberately narrow: H.264 video plus AAC audio in an
//! MP4 container. YouTube only serves H.264 up to 1080p — everything above that
//! is VP9 or AV1, which WKWebView cannot reliably play. Since FlightTube plays
//! downloads in its own window, a 4K file we cannot decode is worthless. Do not
//! widen this selector without also solving playback.
pub const FORMAT_SELECTOR: &str = "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*+ba/b";
/// Sentinel prefix so progress lines are distinguishable from yt-dlp's ordinary
/// chatter on the same stream.
pub const PROGRESS_TEMPLATE: &str = "FTPROG %(progress.downloaded_bytes)s %(progress.total_bytes)s %(progress.speed)s %(progress.eta)s";
#[derive(Debug, Clone, PartialEq)]
pub struct Progress {
pub downloaded: u64,
pub total: Option<u64>,
pub speed: Option<f64>,
pub eta: Option<u64>,
}
impl Progress {
pub fn pct(&self) -> Option<f64> {
match self.total {
Some(t) if t > 0 => Some((self.downloaded as f64 / t as f64) * 100.0),
_ => None,
}
}
}
/// yt-dlp writes `NA` for values it does not know yet (notably `total_bytes`
/// before the stream is resolved), and interleaves ordinary log lines on the
/// same stream. Anything that isn't a well-formed progress line yields `None`
/// rather than an error — a garbled line must never abort a running download.
fn opt_num<T: std::str::FromStr>(tok: &str) -> Option<T> {
if tok == "NA" || tok.is_empty() {
None
} else {
tok.parse::<T>().ok()
}
}
pub fn parse_progress_line(line: &str) -> Option<Progress> {
let rest = line.trim().strip_prefix("FTPROG")?;
let tokens: Vec<&str> = rest.split_whitespace().collect();
if tokens.len() != 4 {
return None;
}
// A progress line without a byte count tells us nothing; reject it.
let downloaded: u64 = opt_num(tokens[0])?;
Some(Progress {
downloaded,
total: opt_num::<u64>(tokens[1]),
// yt-dlp emits floats like `524288.0`; parse as f64 then keep as bytes.
speed: opt_num::<f64>(tokens[2]),
eta: opt_num::<f64>(tokens[3]).map(|v| v as u64),
})
}
/// Arguments for downloading one video. Kept separate from process spawning so
/// the argument construction is assertable in tests.
pub fn build_args(video_id: &str, out_template: &str) -> Vec<String> {
vec![
"-f".into(),
FORMAT_SELECTOR.into(),
"--merge-output-format".into(),
"mp4".into(),
"--no-playlist".into(),
"--newline".into(),
"--no-colors".into(),
"--progress".into(),
"--progress-template".into(),
PROGRESS_TEMPLATE.into(),
"--print".into(),
"after_move:FTPATH %(filepath)s".into(),
"-o".into(),
out_template.into(),
format!("https://www.youtube.com/watch?v={video_id}"),
]
}
/// yt-dlp reports the final path via `--print after_move:`, which is more
/// reliable than guessing the extension after a merge.
pub fn parse_final_path(line: &str) -> Option<String> {
line.trim()
.strip_prefix("FTPATH ")
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_complete_line() {
let p = parse_progress_line("FTPROG 1048576 10485760 524288.0 18").unwrap();
assert_eq!(p.downloaded, 1_048_576);
assert_eq!(p.total, Some(10_485_760));
assert_eq!(p.speed, Some(524_288.0));
assert_eq!(p.eta, Some(18));
}
#[test]
fn handles_na_fields_before_stream_resolves() {
let p = parse_progress_line("FTPROG 4096 NA NA NA").unwrap();
assert_eq!(p.downloaded, 4096);
assert_eq!(p.total, None);
assert_eq!(p.speed, None);
assert_eq!(p.eta, None);
}
#[test]
fn tolerates_float_eta() {
let p = parse_progress_line("FTPROG 10 100 5.5 12.0").unwrap();
assert_eq!(p.eta, Some(12));
}
#[test]
fn ignores_ordinary_yt_dlp_output() {
assert!(parse_progress_line("[youtube] Extracting URL: https://x").is_none());
assert!(parse_progress_line("[Merger] Merging formats into \"x.mp4\"").is_none());
assert!(parse_progress_line("").is_none());
assert!(parse_progress_line("[download] 50% of 10MiB").is_none());
}
#[test]
fn ignores_garbage_without_panicking() {
assert!(parse_progress_line("FTPROG").is_none());
assert!(parse_progress_line("FTPROG a b c d").is_none());
assert!(parse_progress_line("FTPROG 1 2").is_none());
assert!(parse_progress_line("FTPROG 1 2 3 4 5").is_none());
assert!(parse_progress_line("FTPROG NA NA NA NA").is_none());
}
#[test]
fn computes_percent() {
let p = parse_progress_line("FTPROG 5000 10000 1.0 1").unwrap();
assert_eq!(p.pct(), Some(50.0));
let q = parse_progress_line("FTPROG 5000 NA NA NA").unwrap();
assert_eq!(q.pct(), None);
}
#[test]
fn percent_guards_against_zero_total() {
let p = Progress {
downloaded: 5,
total: Some(0),
speed: None,
eta: None,
};
assert_eq!(p.pct(), None);
}
#[test]
fn args_pin_h264_and_mp4() {
let args = build_args("abc123", "/tmp/%(id)s.%(ext)s");
assert!(args.contains(&FORMAT_SELECTOR.to_string()));
assert!(args.contains(&"mp4".to_string()));
assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string()));
assert!(args.contains(&"--no-playlist".to_string()));
}
#[test]
fn extracts_final_path() {
assert_eq!(
parse_final_path("FTPATH /Users/x/Movies/FlightTube/abc.mp4").unwrap(),
"/Users/x/Movies/FlightTube/abc.mp4"
);
assert!(parse_final_path("FTPATH ").is_none());
assert!(parse_final_path("[download] done").is_none());
}
}
+278
View File
@@ -0,0 +1,278 @@
//! Fetching and parsing of YouTube's public per-channel Atom feed.
//!
//! `https://www.youtube.com/feeds/videos.xml?channel_id=<id>` is public and
//! unauthenticated. It returns roughly the 15 most recent videos per channel
//! with no pagination and no backfill — that ceiling is inherent to the source.
use crate::models::Video;
use quick_xml::events::Event;
use quick_xml::Reader;
pub fn feed_url(channel_id: &str) -> String {
format!("https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}")
}
/// Local names we capture text for, tracked as a small state machine while walking events.
#[derive(PartialEq, Clone, Copy)]
enum Field {
None,
VideoId,
ChannelId,
Title,
Published,
Description,
}
fn attr(e: &quick_xml::events::BytesStart, key: &str) -> Option<String> {
e.attributes()
.flatten()
.find(|a| a.key.local_name().as_ref() == key)
.map(|a| a.value.into_owned())
}
/// RFC 3339 timestamp -> Unix seconds. Returns 0 if unparseable so a single
/// bad entry sorts to the bottom rather than failing the whole feed.
fn to_unix(s: &str) -> i64 {
chrono::DateTime::parse_from_rfc3339(s.trim())
.map(|d| d.timestamp())
.unwrap_or(0)
}
pub fn parse_atom(xml: &str) -> Result<Vec<Video>, String> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut videos = Vec::new();
let mut in_entry = false;
let mut field = Field::None;
// media:group repeats <title> and <description>; keep the first (Atom) title
// and don't let the later media one overwrite it.
let mut cur: Option<Video> = None;
let mut saw_title = false;
loop {
match reader.read_event() {
Err(e) => return Err(format!("Malformed feed XML: {e}")),
// An unclosed <entry> at EOF means the response was truncated. quick-xml
// does not treat that as an error, but we must: silently storing half a
// channel's videos is worse than failing the refresh for that channel.
Ok(Event::Eof) => {
if in_entry {
return Err("Feed XML ended inside an <entry> (truncated response).".into());
}
break;
}
Ok(Event::Start(e)) => {
let name = e.name().local_name();
match name.as_ref() {
"entry" => {
in_entry = true;
saw_title = false;
cur = Some(Video {
id: String::new(),
channel_id: String::new(),
title: String::new(),
description: String::new(),
published: 0,
thumb_url: String::new(),
views: 0,
is_short: false,
});
}
"videoId" if in_entry => field = Field::VideoId,
"channelId" if in_entry => field = Field::ChannelId,
"title" if in_entry && !saw_title => field = Field::Title,
"published" if in_entry => field = Field::Published,
"description" if in_entry => field = Field::Description,
_ => {}
}
}
Ok(Event::Empty(e)) => {
if !in_entry {
continue;
}
let name = e.name().local_name();
if let Some(v) = cur.as_mut() {
match name.as_ref() {
"link" => {
if attr(&e, "rel").as_deref() == Some("alternate") {
if let Some(href) = attr(&e, "href") {
v.is_short = href.contains("/shorts/");
}
}
}
"thumbnail" => {
if let Some(u) = attr(&e, "url") {
if v.thumb_url.is_empty() {
v.thumb_url = u;
}
}
}
"statistics" => {
if let Some(views) = attr(&e, "views") {
v.views = views.parse().unwrap_or(0);
}
}
_ => {}
}
}
}
Ok(Event::Text(t)) => {
if !in_entry || field == Field::None {
continue;
}
let text = t.xml10_content();
if let Some(v) = cur.as_mut() {
match field {
Field::VideoId => v.id.push_str(&text),
Field::ChannelId => v.channel_id.push_str(&text),
Field::Title => v.title.push_str(&text),
Field::Published => v.published = to_unix(&text),
Field::Description => v.description.push_str(&text),
Field::None => {}
}
}
}
Ok(Event::End(e)) => {
let name = e.name().local_name();
if name.as_ref() == "title" && field == Field::Title {
saw_title = true;
}
if name.as_ref() == "entry" {
in_entry = false;
if let Some(v) = cur.take() {
if !v.id.is_empty() {
videos.push(v);
}
}
}
field = Field::None;
}
_ => {}
}
}
Ok(videos)
}
pub async fn fetch_channel(
client: &reqwest::Client,
channel_id: &str,
) -> Result<Vec<Video>, String> {
let resp = client
.get(feed_url(channel_id))
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("Feed returned HTTP {}", resp.status()));
}
let body = resp
.text()
.await
.map_err(|e| format!("Could not read feed body: {e}"))?;
let mut videos = parse_atom(&body)?;
// Trust the requested id over the payload for channels that return an empty one.
for v in &mut videos {
if v.channel_id.is_empty() {
v.channel_id = channel_id.to_string();
}
}
Ok(videos)
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = include_str!("fixtures/ltt_feed.xml");
#[test]
fn parses_all_entries_from_real_feed() {
let v = parse_atom(FIXTURE).unwrap();
assert_eq!(v.len(), 15);
}
#[test]
fn extracts_core_fields() {
let v = parse_atom(FIXTURE).unwrap();
let first = &v[0];
assert_eq!(first.id, "tklAv8hcG9s");
assert_eq!(first.channel_id, "UCXuqSBlHAE6Xw-yeJA0Tunw");
assert!(first.title.contains("Linus"), "got title: {}", first.title);
assert!(first.published > 1_700_000_000);
assert!(first.thumb_url.contains("tklAv8hcG9s"));
assert!(first.views > 0);
assert!(!first.description.is_empty());
}
#[test]
fn title_is_the_atom_title_not_the_media_group_one() {
let v = parse_atom(FIXTURE).unwrap();
assert_eq!(
v[0].title,
"Former LTT employee Shares a Memorable Linus Story"
);
}
#[test]
fn feed_entries_are_all_from_one_channel() {
let v = parse_atom(FIXTURE).unwrap();
assert!(v.iter().all(|x| x.channel_id == "UCXuqSBlHAE6Xw-yeJA0Tunw"));
}
#[test]
fn detects_shorts() {
let v = parse_atom(FIXTURE).unwrap();
assert!(v[0].is_short, "first fixture entry is a /shorts/ link");
assert!(
v.iter().any(|x| !x.is_short),
"fixture should contain regular videos too"
);
}
#[test]
fn tolerates_missing_optional_fields() {
let xml = r#"<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:yt="http://www.youtube.com/xml/schemas/2015"
xmlns:media="http://search.yahoo.com/mrss/">
<entry><yt:videoId>abc</yt:videoId><yt:channelId>UCq</yt:channelId>
<title>Bare</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=abc"/>
<published>2026-01-01T00:00:00+00:00</published>
</entry></feed>"#;
let v = parse_atom(xml).unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].views, 0);
assert_eq!(v[0].description, "");
assert_eq!(v[0].thumb_url, "");
assert!(!v[0].is_short);
assert_eq!(v[0].published, 1_767_225_600);
}
#[test]
fn errors_on_malformed_xml() {
assert!(parse_atom("<feed><entry>").is_err());
}
#[test]
fn returns_empty_for_feed_with_no_entries() {
let xml = r#"<feed xmlns="http://www.w3.org/2005/Atom"><title>Empty</title></feed>"#;
assert!(parse_atom(xml).unwrap().is_empty());
}
#[test]
fn builds_expected_feed_url() {
assert_eq!(
feed_url("UCq"),
"https://www.youtube.com/feeds/videos.xml?channel_id=UCq"
);
}
}
+749
View File
@@ -0,0 +1,749 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">
<link rel="self" href="http://www.youtube.com/feeds/videos.xml?channel_id=UCXuqSBlHAE6Xw-yeJA0Tunw"/>
<id>yt:channel:XuqSBlHAE6Xw-yeJA0Tunw</id>
<yt:channelId>XuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>Linus Tech Tips</title>
<link rel="alternate" href="https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2008-11-25T00:46:52+00:00</published>
<entry>
<id>yt:video:tklAv8hcG9s</id>
<yt:videoId>tklAv8hcG9s</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>Former LTT employee Shares a Memorable Linus Story</title>
<link rel="alternate" href="https://www.youtube.com/shorts/tklAv8hcG9s"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-27T18:36:12+00:00</published>
<updated>2026-08-28T08:57:15+00:00</updated>
<media:group>
<media:title>Former LTT employee Shares a Memorable Linus Story</media:title>
<media:content url="https://www.youtube.com/v/tklAv8hcG9s?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i1.ytimg.com/vi/tklAv8hcG9s/hqdefault.jpg" width="480" height="360"/>
<media:description>Brandon Lee, former cam op at Linus Media Group, shares a memorable story of Linus of how he almost broke a $7,000 Sony FSR700 Camera.</media:description>
<media:community>
<media:starRating count="30624" average="5.00" min="1" max="5"/>
<media:statistics views="1190412"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:oeqUHEp4sYM</id>
<yt:videoId>oeqUHEp4sYM</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>How to Stop Smart TV Spying</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=oeqUHEp4sYM"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-26T17:08:24+00:00</published>
<updated>2026-08-26T22:48:26+00:00</updated>
<media:group>
<media:title>How to Stop Smart TV Spying</media:title>
<media:content url="https://www.youtube.com/v/oeqUHEp4sYM?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i4.ytimg.com/vi/oeqUHEp4sYM/hqdefault.jpg" width="480" height="360"/>
<media:description>Click this link https://boot.dev/?promo=LTT and use my code LTT to get 25% off your first payment for boot.dev!
Is your smart TV watching you right back? With the massive amount of encrypted traffic flying off modern displays, hiding telemetry is childs play, and simply opting-out feels pretty naive. So what can you do if you dont want to be watched?
Discuss on the forum: https://linustechtips.com/topic/1642180-i-stopped-my-smart-tv-from-spying/
Check out our Channel Partners:
Secretlab - Grab a TITAN Evo ergonomic gaming chair: https://lmg.gg/secretlabltt
PIA - Get the VPN of our choice: https://www.piavpn.com/ltt
dbrand - Buy a &quot;Circuit&quot; series skin for your device: https://dbrand.com/pcb
► SHOP LTT PRODUCTS: https://lttstore.com
► GET EXCLUSIVE CONTENT ON FLOATPLANE: https://lmg.gg/lttfloatplane
► DIVE DEEPER ON THE LTT LABS WEBSITE: https://lmg.gg/labs
► SPONSORS, AFFILIATES, AND PARTNERS: https://lmg.gg/partners
Purchases made through some store links may provide some compensation to Linus Media Group. Affiliate links powered in part by https://affilimate.com/?aid=ghz1izbpb
Linus Sebastian is an investor in Framework Computer, Inc and HexOS by Eshtek.
CHAPTERS
---------------------------------------------------
0:00 Intro
1:50 What is all this?
2:50 ACR
4:20 A whole lot of problems
5:51 And it only gets worse
8:00 But what about...?
9:44 The surefire solution...for now...
11:10 Credits</media:description>
<media:community>
<media:starRating count="21582" average="5.00" min="1" max="5"/>
<media:statistics views="655470"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:H4BKUbtCmp0</id>
<yt:videoId>H4BKUbtCmp0</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>How do you upgrade a lifelong Tech??? - AMD $5000 Ultimate Tech Upgrade</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=H4BKUbtCmp0"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-25T17:00:27+00:00</published>
<updated>2026-08-25T18:03:20+00:00</updated>
<media:group>
<media:title>How do you upgrade a lifelong Tech??? - AMD $5000 Ultimate Tech Upgrade</media:title>
<media:content url="https://www.youtube.com/v/H4BKUbtCmp0?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i1.ytimg.com/vi/H4BKUbtCmp0/hqdefault.jpg" width="480" height="360"/>
<media:description>Thanks to AMD for being a great partner and sponsoring this series! Check out their latest offerings at https://lmg.gg/f6PMg
Go to https://bit.ly/4gx31UP to enter the sweepstakes to win an AMD RYZEN™ 7 9800X3D CPU and a GIGABYTE Gaming OC RADEON™ RX 9070 XT GPU!
It's that time again for another $5000 ULTIMATE AMD Upgrade! This time for our writer Jordan. What do you buy for the man that D.I.Y.s everything? More to D.I.Y.? Furniture for a proper dining room instead of a workshop? Nah to the last one, he chose a new Linux PC instead... That we have to put together so strap in!
Discuss on the forum: https://linustechtips.com/topic/1642137-how-do-you-upgrade-a-lifelong-tech-amd-5000-ultimate-tech-upgrade/
Check out the stuff from Jordan's upgrade!
PC Build:
AMD RYZEN 7 9800X3D: https://geni.us/iGDDB
GIGABYTE Radeon RX 9070 XT Gaming OC GPU: https://geni.us/2fj93y
Thermalright Aqua Elite 360 V6 ARGB Black CPU Liquid Cooler: https://geni.us/wO2gXS
GIGABYTE B850 Eagle WIFI6E AMD AM5 ATX Motherboard: https://geni.us/yZ1ynM
Crucial Pro DDR5 RAM 32GB Kit (2x16GB) 6400MHz CL38: https://geni.us/NyeAQoM
Samsung SSD 990 EVO Plus 1TB Gen4 NVME M.2 SSD: https://geni.us/HUsBAI
CORSAIR RM850e (2025) ATX 3.1 850W PSU: https://geni.us/7Uo5BN
Canon EOS R50 V Mirrorless Camera: https://geni.us/LxmwOWH
Canon Stereo Microphone DME1D: https://geni.us/1Dzszq
Smallrig R50V Camera Cage: https://geni.us/AS8E9
Busy Bee Tools 8&quot; Bench Top Jointer Planer Combo BBJP8: https://geni.us/JiFo
Jackery Solar Generator 1000 v2: https://prsm2.com/y_b17fqn_
Check out our Channel Partners:
Secretlab - Grab a TITAN Evo ergonomic gaming chair: https://lmg.gg/secretlabltt
PIA - Get the VPN of our choice: https://www.piavpn.com/ltt
dbrand - Buy a &quot;Circuit&quot; series skin for your device: https://dbrand.com/pcb
► SHOP LTT PRODUCTS: https://lttstore.com
► GET EXCLUSIVE CONTENT ON FLOATPLANE: https://lmg.gg/lttfloatplane
► DIVE DEEPER ON THE LTT LABS WEBSITE: https://lmg.gg/labs
► SPONSORS, AFFILIATES, AND PARTNERS: https://lmg.gg/partners
Purchases made through some store links may provide some compensation to Linus Media Group. Affiliate links powered in part by https://affilimate.com/?aid=ghz1izbpb
Linus Sebastian is an investor in Framework Computer, Inc and HexOS by Eshtek.
CHAPTERS
---------------------------------------------------
0:00 Intro
1:17 Sweepstakes
1:57 An LTT Writer's habitat
4:25 Let's checkout the upgrade
6:18 Planer / Jointer
9:29 I wanna start PC Building
11:04 Our parts selection
12:14 What about airflow?
14:45 Reece Update
16:11 Back to PC Building!
17:58 Way to go Linus
20:33 A cromulent PSU install
22:05 ??? More on Floatplane
22:45 Wrapping up the build
25:38 Cyberpunk 2077
26:11 Jordan's JDM whip
28:03 Star Trek vs. Star Wars
28:58 Conclusion
29:47 Outro</media:description>
<media:community>
<media:starRating count="34322" average="5.00" min="1" max="5"/>
<media:statistics views="1071453"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:ZWCAXHNOIcA</id>
<yt:videoId>ZWCAXHNOIcA</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>Your Next Budget PC Upgrade is...Custom Watercooling??</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=ZWCAXHNOIcA"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-24T17:08:01+00:00</published>
<updated>2026-08-26T21:22:34+00:00</updated>
<media:group>
<media:title>Your Next Budget PC Upgrade is...Custom Watercooling??</media:title>
<media:content url="https://www.youtube.com/v/ZWCAXHNOIcA?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i3.ytimg.com/vi/ZWCAXHNOIcA/hqdefault.jpg" width="480" height="360"/>
<media:description>Thanks to Meter for sponsoring this video! Go to https://meter.com/ltt to book a demo now!
Custom water cooling has always been expensive, but high-end AIOs are getting pretty pricey too. So we used Bykski parts to build a full CPU and GPU custom loop for less than some premium AIOs. The question is, is going custom actually worth the extra hassle?
Discuss on the forum: https://linustechtips.com/topic/1642120-custom-watercooling-for-less-than-an-aio/
Check out the Bykski Water Cooling Kit: https://geni.us/L3Vnx
Check out our Channel Partners:
Secretlab - Grab a TITAN Evo ergonomic gaming chair: https://lmg.gg/secretlabltt
PIA - Get the VPN of our choice: https://www.piavpn.com/ltt
dbrand - Buy a &quot;Circuit&quot; series skin for your device: https://dbrand.com/pcb
► SHOP LTT PRODUCTS: https://lttstore.com
► GET EXCLUSIVE CONTENT ON FLOATPLANE: https://lmg.gg/lttfloatplane
► DIVE DEEPER ON THE LTT LABS WEBSITE: https://lmg.gg/labs
► SPONSORS, AFFILIATES, AND PARTNERS: https://lmg.gg/partners
Purchases made through some store links may provide some compensation to Linus Media Group. Affiliate links powered in part by https://affilimate.com/?aid=ghz1izbpb
Linus Sebastian is an investor in Framework Computer, Inc and HexOS by Eshtek.
CHAPTERS
---------------------------------------------------
0:00 Intro
1:50 Bykski's Pre-Configured Kit
3:04 I Have to Build the WHOLE Computer!?
4:20 Disassembling the GPU
6:04 Adding the GPU Waterblock
9:55 CPU Waterblock
10:52 Planning the Loop
12:55 Drill, Baby Drill!
15:05 Hardline Bends
17:39 Double Bend Woes
18:17 lol alcohol
19:08 Dealing with a Leak
20:25 The Result
23:53 Outro
Correction: 3:30 We used a Ryzen 7, not a Ryzen 9</media:description>
<media:community>
<media:starRating count="16764" average="5.00" min="1" max="5"/>
<media:statistics views="542364"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:DXK-F0SjC_E</id>
<yt:videoId>DXK-F0SjC_E</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>I put the NEWEST CPU in the OLDEST Motherboard</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=DXK-F0SjC_E"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-22T17:00:02+00:00</published>
<updated>2026-08-22T17:00:36+00:00</updated>
<media:group>
<media:title>I put the NEWEST CPU in the OLDEST Motherboard</media:title>
<media:content url="https://www.youtube.com/v/DXK-F0SjC_E?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i1.ytimg.com/vi/DXK-F0SjC_E/hqdefault.jpg" width="480" height="360"/>
<media:description>Secure your business with ThreatLocker today using our link: https://www.threatlocker.com/ltt and learn more about how ThreatLocker can help your business safely integrate AI agenic tools here: https://www.threatlocker.com/blog/applying-threatlocker-to-agentic-ai-tools
The AM4 platform refuses to die, spanning four generations of Zen processors. That got me thinking… Could the best upgrade you could make right now drop straight into the board that kickstarted it all back in 2017? To find out, we paired the oldest AM4 motherboard we could find with the newest AM4 CPU.
Discuss on the forum: https://linustechtips.com/topic/1642050-i-put-the-newest-cpu-in-the-oldest-motherboard/
Buy these parts today!
AMD Ryzen 7 5800X3D Processor: https://geni.us/2zxGa
ASUS TUF RTX 4070 Ti OC Edition: https://prsm2.com/KLvHfXvmc
ASUS Prime X370-Pro Motherboard: https://prsm2.com/DvdIwo790
ASUS ROG Strix X570-E Motherboard: https://prsm2.com/V55hzbdU2
Check out our Channel Partners:
Secretlab - Grab a TITAN Evo ergonomic gaming chair: https://lmg.gg/secretlabltt
PIA - Get the VPN of our choice: https://www.piavpn.com/ltt
dbrand - Buy a &quot;Circuit&quot; series skin for your device: https://dbrand.com/pcb
► SHOP LTT PRODUCTS: https://lttstore.com
► GET EXCLUSIVE CONTENT ON FLOATPLANE: https://lmg.gg/lttfloatplane
► DIVE DEEPER ON THE LTT LABS WEBSITE: https://lmg.gg/labs
► SPONSORS, AFFILIATES, AND PARTNERS: https://lmg.gg/partners
Purchases made through some store links may provide some compensation to Linus Media Group. Affiliate links powered in part by https://affilimate.com/?aid=ghz1izbpb
Linus Sebastian is an investor in Framework Computer, Inc and HexOS by Eshtek.
CHAPTERS
---------------------------------------------------
0:00 Intro
2:19 Sponsor
2:41 Our test systems
3:36 AM4 Quirks (or Features?)
5:17 Let's get testing
7:06 Cyberpunk 2077
8:50 CS2
10:22 Performance Takeaway
11:07 So why is AM4 still kicking?
12:29 ThreatLocker
13:40 Outro</media:description>
<media:community>
<media:starRating count="30125" average="5.00" min="1" max="5"/>
<media:statistics views="1002358"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:04MFcE-ntxQ</id>
<yt:videoId>04MFcE-ntxQ</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>Creators Share Embarrassing Stories of Linus</title>
<link rel="alternate" href="https://www.youtube.com/shorts/04MFcE-ntxQ"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-21T16:51:24+00:00</published>
<updated>2026-08-22T08:58:49+00:00</updated>
<media:group>
<media:title>Creators Share Embarrassing Stories of Linus</media:title>
<media:content url="https://www.youtube.com/v/04MFcE-ntxQ?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i1.ytimg.com/vi/04MFcE-ntxQ/hqdefault.jpg" width="480" height="360"/>
<media:description>Zip Tie Tuning, Sammit and Luke from the WAN Show all share embarrassing and funny stories of Linus!</media:description>
<media:community>
<media:starRating count="15145" average="5.00" min="1" max="5"/>
<media:statistics views="643434"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:QfCW5bXvwII</id>
<yt:videoId>QfCW5bXvwII</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>Pranking Linus for his 40th Birthday</title>
<link rel="alternate" href="https://www.youtube.com/shorts/QfCW5bXvwII"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-20T18:10:26+00:00</published>
<updated>2026-08-21T07:25:06+00:00</updated>
<media:group>
<media:title>Pranking Linus for his 40th Birthday</media:title>
<media:content url="https://www.youtube.com/v/QfCW5bXvwII?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i2.ytimg.com/vi/QfCW5bXvwII/hqdefault.jpg" width="480" height="360"/>
<media:description>We pranked Linus for his 40th birthday, and rebranding Linus Media Group isn't the only thing we did...</media:description>
<media:community>
<media:starRating count="33054" average="5.00" min="1" max="5"/>
<media:statistics views="1328256"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:36oL-7C_qR4</id>
<yt:videoId>36oL-7C_qR4</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>Theres NO Excuse Not To Try Linux!</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=36oL-7C_qR4"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-20T16:54:26+00:00</published>
<updated>2026-08-21T08:44:47+00:00</updated>
<media:group>
<media:title>Theres NO Excuse Not To Try Linux!</media:title>
<media:content url="https://www.youtube.com/v/36oL-7C_qR4?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i4.ytimg.com/vi/36oL-7C_qR4/hqdefault.jpg" width="480" height="360"/>
<media:description>Thanks to MSI for sponsoring this video! Check out their Intel Gamer Days deals at https://msi.gm/SA322D11
People are clearly wanting to try Linux or at least a bit interested in trying Linux, but how do you try Linux without completely getting rid of the current Windows Install. Dual Booting allows you to have multiple operating systems installed on your computer for many different reasons. Today we look at Luke's setup with CachyOS and Windows to see how it works and why he does it.
Discuss on the forum: https://linustechtips.com/topic/1642012-there%E2%80%99s-no-excuse-not-to-try-linux/
Check out our Channel Partners:
Secretlab - Grab a TITAN Evo ergonomic gaming chair: https://lmg.gg/secretlabltt
PIA - Get the VPN of our choice: https://www.piavpn.com/ltt
dbrand - Buy a &quot;Circuit&quot; series skin for your device: https://dbrand.com/pcb
► SHOP LTT PRODUCTS: https://lttstore.com
► GET EXCLUSIVE CONTENT ON FLOATPLANE: https://lmg.gg/lttfloatplane
► DIVE DEEPER ON THE LTT LABS WEBSITE: https://lmg.gg/labs
► SPONSORS, AFFILIATES, AND PARTNERS: https://lmg.gg/partners
Purchases made through some store links may provide some compensation to Linus Media Group. Affiliate links powered in part by https://affilimate.com/?aid=ghz1izbpb
Linus Sebastian is an investor in Framework Computer, Inc and HexOS by Eshtek.
CHAPTERS
---------------------------------------------------
0:00 Intro
1:45 Who Is Luke?
2:00 How Long has he Been Dual Booting
2:21 Why Keep Windows?
3:40 How is it Setup
6:47 How To Switch
10:25 Secure Boot?
14:02 Funny Windows Issue...
15:50 Rebooting to CachyOS
17:45 Outro</media:description>
<media:community>
<media:starRating count="31786" average="5.00" min="1" max="5"/>
<media:statistics views="743782"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:AHP423JqiLc</id>
<yt:videoId>AHP423JqiLc</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>$5 vs $500 Guitar Hero Controller!</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=AHP423JqiLc"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-19T17:00:11+00:00</published>
<updated>2026-08-22T21:05:08+00:00</updated>
<media:group>
<media:title>$5 vs $500 Guitar Hero Controller!</media:title>
<media:content url="https://www.youtube.com/v/AHP423JqiLc?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i2.ytimg.com/vi/AHP423JqiLc/hqdefault.jpg" width="480" height="360"/>
<media:description>Wherever today takes you, go comfortably with Vessi Weekend Chelsea—made for exploring, rain or shine. One pair. Countless adventures.
✨ Grab 15% off your first pair here: https://vessi.com/ltt
• Free shipping • 30day returns • 1year warranty
The nostalgia cycle has sent Guitar Hero controller prices through the roof, but you don't need to break the bank to relive the glory days. From $5 keyboard setups to $500 custom builds, were testing every budget option to find the best way to shred.
Discuss on the forum: https://linustechtips.com/topic/1641977-5-vs-500-guitar-hero-controller/
HDE Controller Adapter for PlayStation 2 Controllers, Converter Cable for PC &amp; PS3: https://geni.us/j56JWV
RetroCultMods V3 Wii/USB Adapter With Tilt: https://geni.us/eCrrzfz
USB Breakaway Cable for Xbox 360: https://geni.us/4klkbvy
RetroCultMods Solderless Revival Kit for Guitar Hero Controllers: https://geni.us/wGpXJv
PDP Riffmaster Wireless Guitar Controller for Xbox Series X|S, Xbox One, PC: https://geni.us/Iy8luI
NBCP Guitar Hero Controller for PC and PS3: https://geni.us/HkFER
CRKD Gibson Les Paul Blueberry Burst Pro Edition Guitar Controller (Multi-platform): https://geni.us/99vge
Hammer-On Guitars: https://geni.us/XQuf
Check out our Channel Partners:
Secretlab - Grab a TITAN Evo ergonomic gaming chair: https://lmg.gg/secretlabltt
PIA - Get the VPN of our choice: https://www.piavpn.com/ltt
dbrand - Buy a &quot;Circuit&quot; series skin for your device: https://dbrand.com/pcb
► SHOP LTT PRODUCTS: https://lttstore.com
► GET EXCLUSIVE CONTENT ON FLOATPLANE: https://lmg.gg/lttfloatplane
► DIVE DEEPER ON THE LTT LABS WEBSITE: https://lmg.gg/labs
► SPONSORS, AFFILIATES, AND PARTNERS: https://lmg.gg/partners
Purchases made through some store links may provide some compensation to Linus Media Group. Affiliate links powered in part by https://affilimate.com/?aid=ghz1izbpb
Linus Sebastian is an investor in Framework Computer, Inc and HexOS by Eshtek.
CHAPTERS
---------------------------------------------------
0:00 Guitar Hero?!? In 2026?!?
0:45 $20 and $5 Options
3:43 Sponsor Spot and Intro
4:09 $50ish is Great Place to start
7:03 $100 Nostalgia Take Over
8:00 Our Choice of Rhythm Game
9:11 $150 Gets you something NEW!
16:01 $250-500 Axe of the GODS!
21:50 What should you buy?
22:16 A Sponsor Spot Worth Watching + Outro</media:description>
<media:community>
<media:starRating count="13006" average="5.00" min="1" max="5"/>
<media:statistics views="319243"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:vzg5cv785j4</id>
<yt:videoId>vzg5cv785j4</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>Whats the Most Cost Effective Tech Upgrades</title>
<link rel="alternate" href="https://www.youtube.com/shorts/vzg5cv785j4"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-18T22:39:01+00:00</published>
<updated>2026-08-22T00:50:53+00:00</updated>
<media:group>
<media:title>Whats the Most Cost Effective Tech Upgrades</media:title>
<media:content url="https://www.youtube.com/v/vzg5cv785j4?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i3.ytimg.com/vi/vzg5cv785j4/hqdefault.jpg" width="480" height="360"/>
<media:description>What are the most cost effective ways to improve my gaming experience? There's more than one...
lmg.gg/nexigo</media:description>
<media:community>
<media:starRating count="6617" average="5.00" min="1" max="5"/>
<media:statistics views="230144"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:MQeJYEN_lrg</id>
<yt:videoId>MQeJYEN_lrg</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>This DIY Datacenter is NUTS - Hetzner Tour</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=MQeJYEN_lrg"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-17T17:16:01+00:00</published>
<updated>2026-08-20T21:53:29+00:00</updated>
<media:group>
<media:title>This DIY Datacenter is NUTS - Hetzner Tour</media:title>
<media:content url="https://www.youtube.com/v/MQeJYEN_lrg?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i2.ytimg.com/vi/MQeJYEN_lrg/hqdefault.jpg" width="480" height="360"/>
<media:description>Thanks to Hetzner for sponsoring this video! Use code ltt20 to get $20 off Hetzner products by using our link: https://htznr.li/LTT26
We traveled to Falkenstein, Germany, to tour one of Hetzners data centers and see how their hands-on, DIY approach helps power their infrastructure at scale. From custom-built solutions to the servers themselves, we got a behind-the-scenes look at how Hetzner does things a little differently.
Discuss on the forum: https://linustechtips.com/topic/1641785-they-let-me-build-a-real-customer-server/
Check out our Channel Partners:
Secretlab - Grab a TITAN Evo ergonomic gaming chair: https://lmg.gg/secretlabltt
PIA - Get the VPN of our choice: https://www.piavpn.com/ltt
dbrand - Buy a &quot;Circuit&quot; series skin for your device: https://dbrand.com/pcb
► SHOP LTT PRODUCTS: https://lttstore.com
► GET EXCLUSIVE CONTENT ON FLOATPLANE: https://lmg.gg/lttfloatplane
► DIVE DEEPER ON THE LTT LABS WEBSITE: https://lmg.gg/labs
► SPONSORS, AFFILIATES, AND PARTNERS: https://lmg.gg/partners
Purchases made through some store links may provide some compensation to Linus Media Group. Affiliate links powered in part by https://affilimate.com/?aid=ghz1izbpb
Linus Sebastian is an investor in Framework Computer, Inc and HexOS by Eshtek.
CHAPTERS
---------------------------------------------------
00:00 - Intro
01:16 - Why Falkenstein?
01:58 - Modular building &amp; roof design
03:25 - Custom &quot;Bare Metal&quot; solutions
05:35 - Consumer hardware in the data center
06:38 - Technician workstations &amp; repairs
07:05 - Networking &amp; physical private networks
07:49 - Exploring under the Cold Aisles
08:42 - Power backup &amp; cooling systems
10:58 - Linus builds a server.. the German way!
11:58 - 3D printed shrouds
14:40 - Self tapping screws
15:52 - Mistakes were made
18:07 - Final assembly
19:32 - R&amp;D and testing racks
20:38 - TOP secret experimental room
21:13 - Final thoughts</media:description>
<media:community>
<media:starRating count="51885" average="5.00" min="1" max="5"/>
<media:statistics views="1296198"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:ea3EvwkW_28</id>
<yt:videoId>ea3EvwkW_28</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>Legends Never Die</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=ea3EvwkW_28"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-15T17:00:34+00:00</published>
<updated>2026-08-20T01:45:46+00:00</updated>
<media:group>
<media:title>Legends Never Die</media:title>
<media:content url="https://www.youtube.com/v/ea3EvwkW_28?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i2.ytimg.com/vi/ea3EvwkW_28/hqdefault.jpg" width="480" height="360"/>
<media:description>Thanks to UGREEN for sponsoring this video! Check out the Nexode Pro 160W Retractable Charger and more below!
UGREEN Nexode Pro 160W Retractable Charger (AMZ, Up to 20%) https://amzn.to/4aXNCeq
UGREEN Nexode Pro 160W Retractable Charger (DTC, Up to 20%) https://shop.us.ugreen.com/FZ9wXc
UGREEN Nexode Display Charger Series (AMZ US, Up to 20%) https://amzn.to/4wMuOag
Before NVIDIA became all about AI this and AI that, they had the hearts and minds of gamers all over the world captivated by the release of what could be considered the greatest GPU of all time. The 1080 was truly ahead of its time, dominating the market in just about every way. But does it still hold up 10 years later?
Check out the LABS article! https://www.lttlabs.com/articles/2026/07/15/gtx-1080s-revisiting-legends#gtx-in-an-rtx-world
Discuss on the forum: https://linustechtips.com/topic/1641848-legends-never-die/
Check out our Channel Partners:
Secretlab - Grab a TITAN Evo ergonomic gaming chair: https://lmg.gg/secretlabltt
PIA - Get the VPN of our choice: https://www.piavpn.com/ltt
dbrand - Buy a &quot;Circuit&quot; series skin for your device: https://dbrand.com/pcb
► SHOP LTT PRODUCTS: https://lttstore.com
► GET EXCLUSIVE CONTENT ON FLOATPLANE: https://lmg.gg/lttfloatplane
► DIVE DEEPER ON THE LTT LABS WEBSITE: https://lmg.gg/labs
► SPONSORS, AFFILIATES, AND PARTNERS: https://lmg.gg/partners
Purchases made through some store links may provide some compensation to Linus Media Group. Affiliate links powered in part by https://affilimate.com/?aid=ghz1izbpb
Linus Sebastian is an investor in Framework Computer, Inc and HexOS by Eshtek.
CHAPTERS
---------------------------------------------------
0:00 Intro
2:52 How Viable Are They Today?
4:27 Raytracing Required Games
4:50 Who Cares About Those
5:16 NVIDIA Price Shock
6:05 Compared to Newer Cards
7:12 Let's Talk DLSS
7:45 What About Other Applications?
8:53 The Used Market
11:01 Outro</media:description>
<media:community>
<media:starRating count="27549" average="5.00" min="1" max="5"/>
<media:statistics views="805801"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:J261Rg0LTDA</id>
<yt:videoId>J261Rg0LTDA</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>&quot;Pro&quot; phones are stupid - Pixel 11 Announcement</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=J261Rg0LTDA"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-13T08:19:30+00:00</published>
<updated>2026-08-26T18:10:19+00:00</updated>
<media:group>
<media:title>&quot;Pro&quot; phones are stupid - Pixel 11 Announcement</media:title>
<media:content url="https://www.youtube.com/v/J261Rg0LTDA?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i3.ytimg.com/vi/J261Rg0LTDA/hqdefault.jpg" width="480" height="360"/>
<media:description>Start building your website today! Visit https://squarespace.syuh.net/LTT and use offer code LTT for 10% off
At their Made By Google 2026 event, the company announced their new Pixel 11, Pixel 11 Pro, and Pixel 11 Pro Fold phones- as well as new watches, buds, and an AirTag competitor, the Google Pixel Tag. Too bad they didn't announce cheaper RAM...
Check out the Google Pixel 11 Pro: https://geni.us/2YjDA
Check out the Google Pixel 11 Pro Fold: https://geni.us/bsyiIuu
Google Pixel Watch 5: https://geni.us/xARWTy
Check out the Google Pixel Tag: https://geni.us/Dr5is
Discuss on the forum: https://linustechtips.com/topic/1641792-pro-phones-are-stupid/
Check out our Channel Partners:
Secretlab - Grab a TITAN Evo ergonomic gaming chair: https://lmg.gg/secretlabltt
PIA - Get the VPN of our choice: https://www.piavpn.com/ltt
dbrand - Buy a &quot;Circuit&quot; series skin for your device: https://dbrand.com/pcb
► SHOP LTT PRODUCTS: https://lttstore.com
► GET EXCLUSIVE CONTENT ON FLOATPLANE: https://lmg.gg/lttfloatplane
► DIVE DEEPER ON THE LTT LABS WEBSITE: https://lmg.gg/labs
► SPONSORS, AFFILIATES, AND PARTNERS: https://lmg.gg/partners
Purchases made through some store links may provide some compensation to Linus Media Group. Affiliate links powered in part by https://affilimate.com/?aid=ghz1izbpb
Linus Sebastian is an investor in Framework Computer, Inc and HexOS by Eshtek.
CHAPTERS
---------------------------------------------------
0:00 Intro
1:40 Pixel Tag
2:13 The Phones
6:02 Pixel Watch 5
6:26 Software
8:26 New camera things
10:53 Outro</media:description>
<media:community>
<media:starRating count="20356" average="5.00" min="1" max="5"/>
<media:statistics views="700444"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:ofNcSiFpDUk</id>
<yt:videoId>ofNcSiFpDUk</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>Tech Russian Roulette with True or False Questions!</title>
<link rel="alternate" href="https://www.youtube.com/shorts/ofNcSiFpDUk"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-11T18:08:30+00:00</published>
<updated>2026-08-16T18:53:23+00:00</updated>
<media:group>
<media:title>Tech Russian Roulette with True or False Questions!</media:title>
<media:content url="https://www.youtube.com/v/ofNcSiFpDUk?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i4.ytimg.com/vi/ofNcSiFpDUk/hqdefault.jpg" width="480" height="360"/>
<media:description>Can you determine if this tech question is true or false under pressure?</media:description>
<media:community>
<media:starRating count="11090" average="5.00" min="1" max="5"/>
<media:statistics views="534457"/>
</media:community>
</media:group>
</entry>
<entry>
<id>yt:video:rbQtX_thQtQ</id>
<yt:videoId>rbQtX_thQtQ</yt:videoId>
<yt:channelId>UCXuqSBlHAE6Xw-yeJA0Tunw</yt:channelId>
<title>It Shouldnt be this Hard to Buy A Printer…</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=rbQtX_thQtQ"/>
<author>
<name>Linus Tech Tips</name>
<uri>https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw</uri>
</author>
<published>2026-08-11T16:56:25+00:00</published>
<updated>2026-08-12T13:53:02+00:00</updated>
<media:group>
<media:title>It Shouldnt be this Hard to Buy A Printer…</media:title>
<media:content url="https://www.youtube.com/v/rbQtX_thQtQ?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://i3.ytimg.com/vi/rbQtX_thQtQ/hqdefault.jpg" width="480" height="360"/>
<media:description>Get a free 15-day trial of Odoos all-in-one business solution and see how it can make your life easier! Check it out at https://www.odoo.com/ltt
Linus and Elijah are on a mission to buy a Printer. The problem? Neither of them have bought a printer in YEARS. What Printer should they buy? Do they want Ink Jet, Ink Tank, Laser? Well the Boys head to Staples to take a look
Discuss on the forum: https://linustechtips.com/topic/1641726-i-hate-printer-shopping/
Check out the printers from the video!
Linus' Pick: Epson EcoTank ET-2900 Wireless All-in-One Color Supertank Printer: https://geni.us/t4MdxU
Elijah's Pick: Brother Wireless HL-L2465DW Compact Monochrome Multi-Function Laser Printer: https://prsm2.com/HrR-IqBPH
Check out our Channel Partners:
Secretlab - Grab a TITAN Evo ergonomic gaming chair: https://lmg.gg/secretlabltt
PIA - Get the VPN of our choice: https://www.piavpn.com/ltt
dbrand - Buy a &quot;Circuit&quot; series skin for your device: https://dbrand.com/pcb
► SHOP LTT PRODUCTS: https://lttstore.com
► GET EXCLUSIVE CONTENT ON FLOATPLANE: https://lmg.gg/lttfloatplane
► DIVE DEEPER ON THE LTT LABS WEBSITE: https://lmg.gg/labs
► SPONSORS, AFFILIATES, AND PARTNERS: https://lmg.gg/partners
Purchases made through some store links may provide some compensation to Linus Media Group. Affiliate links powered in part by https://affilimate.com/?aid=ghz1izbpb
Linus Sebastian is an investor in Framework Computer, Inc and HexOS by Eshtek.
CHAPTERS
---------------------------------------------------
0:00 Intro
1:47 Going Undercover
2:08 The Hunt Begins
3:06 EXPENSIVE Printers
3:43 HP Printer
4:02 Canon Printer
5:38 Epson Printer
6:53 Another Isle
8:25 Bad Joke Elijah...
8:51 Trying to Decide...
10:37 MORE PRINTERS
11:15 Toner and Ink Costs
13:39 Choice Paralysis
14:15 What did we Pick
16:00 Comparing Prints
19:29 Outro</media:description>
<media:community>
<media:starRating count="51793" average="5.00" min="1" max="5"/>
<media:statistics views="1724543"/>
</media:community>
</media:group>
</entry>
</feed>
+6 -6
View File
@@ -1,14 +1,14 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
pub mod db;
pub mod downloader;
pub mod feed;
pub mod models;
pub mod takeout;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.plugin(tauri_plugin_dialog::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+104
View File
@@ -0,0 +1,104 @@
use serde::{Deserialize, Serialize};
/// A YouTube channel the user is subscribed to, as imported from Takeout.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Channel {
pub id: String,
pub title: String,
pub url: String,
}
/// A video as described by a channel's public Atom feed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Video {
pub id: String,
pub channel_id: String,
pub title: String,
pub description: String,
/// Unix seconds, so feed ordering is a plain indexed sort.
pub published: i64,
pub thumb_url: String,
pub views: i64,
pub is_short: bool,
}
/// A channel plus how many of its videos we currently know about.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelWithCount {
pub id: String,
pub title: String,
pub url: String,
pub video_count: i64,
pub downloaded_count: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DownloadState {
Queued,
Running,
Done,
Failed,
Cancelled,
}
impl DownloadState {
pub fn as_str(&self) -> &'static str {
match self {
DownloadState::Queued => "queued",
DownloadState::Running => "running",
DownloadState::Done => "done",
DownloadState::Failed => "failed",
DownloadState::Cancelled => "cancelled",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"queued" => Some(DownloadState::Queued),
"running" => Some(DownloadState::Running),
"done" => Some(DownloadState::Done),
"failed" => Some(DownloadState::Failed),
"cancelled" => Some(DownloadState::Cancelled),
_ => None,
}
}
}
/// A feed row: video metadata joined with whatever we know about its download.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedItem {
pub id: String,
pub channel_id: String,
pub channel_title: String,
pub title: String,
pub description: String,
pub published: i64,
pub thumb_url: String,
pub thumb_path: Option<String>,
pub views: i64,
pub is_short: bool,
pub state: Option<DownloadState>,
pub path: Option<String>,
pub pct: Option<f64>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FeedFilter {
pub channel_id: Option<String>,
pub search: Option<String>,
#[serde(default)]
pub downloaded_only: bool,
#[serde(default)]
pub hide_shorts: bool,
pub limit: Option<i64>,
}
/// Result of checking that the external binaries we shell out to exist.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prereqs {
pub yt_dlp: Option<String>,
pub ffmpeg: Option<String>,
pub library_path: String,
}
+116
View File
@@ -0,0 +1,116 @@
//! Parsing of Google Takeout's `subscriptions.csv`.
//!
//! The export's real header is `Channel Id,Channel Url,Channel Title`, but the
//! column order has varied across Takeout versions and locales, so we resolve
//! columns by header name rather than by position.
use crate::models::Channel;
fn find_col(headers: &csv::StringRecord, name: &str) -> Option<usize> {
headers
.iter()
.position(|h| h.trim().eq_ignore_ascii_case(name))
}
pub fn parse_csv(input: &str) -> Result<Vec<Channel>, String> {
if input.trim().is_empty() {
return Err("The file is empty.".into());
}
let mut reader = csv::ReaderBuilder::new()
.flexible(true)
.from_reader(input.as_bytes());
let headers = reader
.headers()
.map_err(|e| format!("Could not read the CSV header: {e}"))?
.clone();
let id_col = find_col(&headers, "Channel Id")
.ok_or("This does not look like a Takeout subscriptions.csv: no 'Channel Id' column.")?;
let title_col = find_col(&headers, "Channel Title")
.ok_or("This does not look like a Takeout subscriptions.csv: no 'Channel Title' column.")?;
let url_col = find_col(&headers, "Channel Url");
let mut out = Vec::new();
for record in reader.records() {
let record = record.map_err(|e| format!("Malformed CSV row: {e}"))?;
let id = record.get(id_col).unwrap_or("").trim();
if id.is_empty() {
continue;
}
let title = record.get(title_col).unwrap_or("").trim();
let url = url_col
.and_then(|c| record.get(c))
.map(str::trim)
.filter(|u| !u.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("https://www.youtube.com/channel/{id}"));
out.push(Channel {
id: id.to_string(),
title: if title.is_empty() {
id.to_string()
} else {
title.to_string()
},
url,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_standard_export() {
let csv = "Channel Id,Channel Url,Channel Title\n\
UCabc,http://www.youtube.com/channel/UCabc,Linus Tech Tips\n";
let out = parse_csv(csv).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].id, "UCabc");
assert_eq!(out[0].title, "Linus Tech Tips");
assert_eq!(out[0].url, "http://www.youtube.com/channel/UCabc");
}
#[test]
fn handles_commas_and_unicode_in_titles() {
let csv = "Channel Id,Channel Url,Channel Title\n\
UCx,http://y.com/UCx,\"Kurzgesagt In a Nutshell, Ltd\"\n";
let out = parse_csv(csv).unwrap();
assert_eq!(out[0].title, "Kurzgesagt In a Nutshell, Ltd");
}
#[test]
fn tolerates_reordered_columns() {
let csv = "Channel Title,Channel Id,Channel Url\nVeritasium,UCz,http://y.com/UCz\n";
let out = parse_csv(csv).unwrap();
assert_eq!(out[0].id, "UCz");
assert_eq!(out[0].title, "Veritasium");
}
#[test]
fn derives_url_when_column_absent() {
let csv = "Channel Id,Channel Title\nUCq,Some Channel\n";
let out = parse_csv(csv).unwrap();
assert_eq!(out[0].url, "https://www.youtube.com/channel/UCq");
}
#[test]
fn skips_blank_lines_and_empty_file() {
assert!(parse_csv("Channel Id,Channel Url,Channel Title\n\n")
.unwrap()
.is_empty());
assert!(parse_csv("").is_err());
}
#[test]
fn errors_on_missing_required_column() {
assert!(parse_csv("Foo,Bar\n1,2\n").is_err());
}
}