feat: save readable channel handles with stable URL mappings

This commit is contained in:
2026-10-09 23:07:32 +02:00
parent b48682d133
commit 20cdd8afb2
3 changed files with 207 additions and 26 deletions
+151 -18
View File
@@ -35,15 +35,40 @@ fn resolve_path(xdg: Option<OsString>, home: Option<OsString>) -> io::Result<Pat
pub fn load(path: &Path) -> io::Result<Config> {
let (_, document) = read_document(path)?;
parse_categories(&document)
parse_categories(&document, &parse_channels(&document)?)
}
pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result<Config> {
pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) -> io::Result<Config> {
let category = category_name(category)?;
let handle = normalize_handle(handle)?;
let channel = normalize_channel(channel)?;
if !channel.starts_with("https://www.youtube.com/channel/") {
return Err(invalid(
"Channel mappings require a canonical UC channel ID",
));
}
let (original, mut document) = read_document(path)?;
// Validate every category before touching the document or creating directories.
let mut config = parse_categories(&document)?;
let mut mappings = parse_channels(&document)?;
// Validate the whole file before changing it or creating directories.
parse_categories(&document, &mappings)?;
match mappings.get(&handle) {
Some(existing) if existing != &channel => {
return Err(invalid(format!(
"Handle {handle:?} already maps to another channel"
)));
}
Some(_) => {}
None => {
if !document.contains_key("channels") {
document.insert("channels", Item::Table(Table::new()));
}
document["channels"]
.as_table_like_mut()
.expect("channel mappings were validated")
.insert(&handle, value(&channel));
mappings.insert(handle.clone(), channel.clone());
}
}
if !document.contains_key("categories") {
document.insert("categories", Item::Table(Table::new()));
}
@@ -63,12 +88,22 @@ pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result<Con
.get_mut(&key)
.and_then(Item::as_array_mut)
.expect("category was validated");
let saved_channels = config.categories.entry(category.to_owned()).or_default();
// Preserve manually written entries and their comments; do not append an equivalent URL.
if !saved_channels.contains(&channel) {
channels.push(channel.clone());
saved_channels.push(channel);
let mut found = false;
for entry in channels.iter_mut() {
if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)? == channel {
// Replace old URLs/aliases with the current handle, preserving entry comments.
if entry.as_str() != Some(&handle) {
let decor = entry.decor().clone();
*entry = handle.as_str().into();
*entry.decor_mut() = decor;
}
found = true;
}
}
if !found {
channels.push(handle);
}
let config = parse_categories(&document, &mappings)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(config)
}
@@ -130,6 +165,54 @@ fn normalize_channel(input: &str) -> io::Result<String> {
Ok(format!("https://www.youtube.com/channel/{id}"))
}
fn normalize_handle(input: &str) -> io::Result<String> {
let mut address = normalize_channel(input)?;
let prefix = "https://www.youtube.com/";
if !address[prefix.len()..].starts_with('@') {
return Err(invalid("Expected a YouTube @handle"));
}
address.drain(..prefix.len());
Ok(address)
}
fn parse_channels(document: &DocumentMut) -> io::Result<BTreeMap<String, String>> {
let mut mappings = BTreeMap::new();
let Some(item) = document.get("channels") else {
return Ok(mappings);
};
let channels = item
.as_table_like()
.ok_or_else(|| invalid("channels must be a TOML table"))?;
for (handle, address) in channels.iter() {
let handle = normalize_handle(handle)?;
let address = normalize_channel(
address
.as_str()
.ok_or_else(|| invalid("Channel mappings must be strings"))?,
)?;
if !address.starts_with("https://www.youtube.com/channel/") {
return Err(invalid(
"Channel mappings require a canonical UC channel ID",
));
}
if mappings.insert(handle, address).is_some() {
return Err(invalid("Duplicate channel handle after normalization"));
}
}
Ok(mappings)
}
fn resolve_channel(input: &str, mappings: &BTreeMap<String, String>) -> io::Result<String> {
let address = normalize_channel(input)?;
match address
.strip_prefix("https://www.youtube.com/")
.and_then(|handle| mappings.get(handle))
{
Some(mapped) => Ok(mapped.clone()),
None => Ok(address),
}
}
fn read_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
@@ -150,7 +233,10 @@ fn read_document(path: &Path) -> io::Result<(Option<Vec<u8>>, DocumentMut)> {
Ok((bytes, document))
}
fn parse_categories(document: &DocumentMut) -> io::Result<Config> {
fn parse_categories(
document: &DocumentMut,
mappings: &BTreeMap<String, String>,
) -> io::Result<Config> {
let mut config = Config::default();
let Some(item) = document.get("categories") else {
return Ok(config);
@@ -165,10 +251,11 @@ fn parse_categories(document: &DocumentMut) -> io::Result<Config> {
.ok_or_else(|| invalid(format!("Category {name:?} must be an array of channels")))?;
let mut normalized = Vec::with_capacity(channels.len());
for channel in channels.iter() {
let channel = normalize_channel(
let channel = resolve_channel(
channel
.as_str()
.ok_or_else(|| invalid("Channels must be strings"))?,
mappings,
)?;
if !normalized.contains(&channel) {
normalized.push(channel);
@@ -278,22 +365,22 @@ mod tests {
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
let directory = TestDirectory::new();
let path = directory.0.join("nested/config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
assert!(load(&path).unwrap().categories.is_empty());
assert!(!path.parent().unwrap().exists());
let config = add_channel(&path, " Music ", "@example").unwrap();
let config = add_channel(&path, " Music ", "@example", id).unwrap();
assert_eq!(
config.categories["Music"],
["https://www.youtube.com/@example"]
[format!("https://www.youtube.com/channel/{id}")]
);
let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n";
fs::write(&path, manual).unwrap();
let config = add_channel(&path, "Music", "https://youtube.com/@example").unwrap();
let config = add_channel(&path, "Music", "https://youtube.com/@example", id).unwrap();
assert_eq!(config.categories["Music"].len(), 1);
assert!(config.categories["Empty"].is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
let id = "UCabcdefghijklmnopqrstuv";
let config = add_channel(&path, "Music", id).unwrap();
let config = add_channel(&path, "Music", "@another", id).unwrap();
assert_eq!(
config.categories["Music"][1],
format!("https://www.youtube.com/channel/{id}")
@@ -318,20 +405,66 @@ mod tests {
"[categories]\nMusic = [3]\n",
"[categories]\nMusic = ['https://example.com/@example']\n",
"[categories]\nMusic = []\n' Music ' = []\n",
"channels = 3\n",
"[channels]\n'@example' = 3\n",
"[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n",
"[channels]\n'@example' = '@another'\n",
"[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n",
] {
fs::write(&path, malformed).unwrap();
assert!(load(&path).is_err());
assert!(add_channel(&path, "Music", "@example").is_err());
assert!(add_channel(&path, "Music", "@example", id).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
}
fs::write(&path, manual).unwrap();
assert!(add_channel(&path, "Music", "https://www.youtube.com/@example/videos").is_err());
assert!(
add_channel(
&path,
"Music",
"https://www.youtube.com/@example/videos",
id
)
.is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1);
}
#[test]
fn handles_resolve_to_stable_ids_and_resaving_replaces_opaque_entries() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let url = format!("https://www.youtube.com/channel/{id}");
let manual = format!(
"[channels]\n'@old' = '{url}'\n[categories]\nJapanese = [\n '{url}', # keep this entry comment\n '@old',\n]\nOther = ['@old']\nDirect = ['@RustVideos']\n"
);
fs::write(&path, manual).unwrap();
let config = load(&path).unwrap();
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
assert_eq!(
config.categories["Direct"],
["https://www.youtube.com/@RustVideos"]
);
let config = add_channel(&path, "Japanese", "@jawed", id).unwrap();
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
assert_eq!(config.categories["Other"], std::slice::from_ref(&url));
let saved = fs::read_to_string(&path).unwrap();
let document = saved.parse::<DocumentMut>().unwrap();
let entries = document["categories"]["Japanese"].as_array().unwrap();
assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed")));
assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str()));
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
assert!(saved.contains("# keep this entry comment"));
add_channel(&path, "Japanese", "@jawed", id).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
assert!(add_channel(&path, "Japanese", "@jawed", "UCabcdefghijklmnopqrstuv").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
assert_eq!(load(&path).unwrap().categories["Japanese"], [url]);
}
#[test]
fn paths_and_channel_addresses_are_strict() {
let home = Some(OsString::from("/home/test"));
+32 -5
View File
@@ -13,6 +13,10 @@
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
//! Channel categories live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
//! Under `[categories]`, write `Rust = ["@RustVideos"]`; channel URLs and UC IDs also work.
//! Saving puts the creator's @handle in the category and its canonical URL in `[channels]`.
//! Mapped handles resolve to stable channel IDs; unmapped handles still work directly.
//! Re-saving an existing channel replaces its opaque URL in that category and preserves comments.
//! Missing handle/ID metadata or a conflicting mapping prevents saving rather than guessing.
//! Normal-mode c opens the category picker; n there prompts for a new name, Enter saves, Esc cancels.
//! Tab/Shift-Tab switch the independent Search/category views; r reloads config and refreshes.
//! Feeds merge up to 20 videos per channel's /videos tab, ordered by approximate upload dates.
@@ -41,7 +45,7 @@ use youtube::{Job, Playback, Video};
#[command(
version,
about = "Search YouTube and stream videos to your chosen player",
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c saves a channel; q quits.\nTab/Shift-Tab switch Search/category views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nRust = [\"@RustVideos\"]\nChannel entries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nCATEGORY PICKER: j/k or arrows choose; Enter saves; n prompts for a new category; Esc cancels.\nNEW CATEGORY: type a name; Enter creates it and saves the channel; Esc returns to the picker; Ctrl-U clears.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a category name edits text.\nCtrl-U clears in INSERT; Ctrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c saves a channel; q quits.\nTab/Shift-Tab switch Search/category views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nCreators = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\nCategory entries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] optionally maps handles to canonical channel URLs/IDs; feeds use these stable identities.\nSaving writes a handle plus its mapping; re-saving replaces matching URLs/aliases in that category, preserving comments.\nUnmapped handles work directly. Existing URL entries still load; they are not changed until re-saved.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nCATEGORY PICKER: j/k or arrows choose; Enter saves; n prompts for a new category; Esc cancels.\nNEW CATEGORY: type a name; Enter creates it and saves the channel; Esc returns to the picker; Ctrl-U clears.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a category name edits text.\nCtrl-U clears in INSERT; Ctrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
)]
struct Options {
/// Player executable (IINA or a player supporting '-' for standard input).
@@ -84,6 +88,7 @@ impl View {
struct CategoryDialog {
channel: String,
handle: String,
label: String,
selection: ListState,
name: Option<String>,
@@ -277,8 +282,18 @@ impl App {
self.status = "This video has no available channel identity.".into();
return;
};
let Some(handle) = video
.uploader_id
.as_deref()
.filter(|handle| handle.starts_with('@'))
else {
self.status =
"This video's channel handle is unavailable; no channel was saved.".into();
return;
};
let handle = handle.to_owned();
let channel = format!("https://www.youtube.com/channel/{id}");
let label = format!(" {} ", video.channel.as_deref().unwrap_or(id));
let label = format!(" {} ({handle}) ", video.channel.as_deref().unwrap_or(id));
match config::load(&self.config_path) {
Ok(config) => self.apply_config(config),
Err(error) => {
@@ -292,6 +307,7 @@ impl App {
self.description_job = None;
self.category_dialog = Some(CategoryDialog {
channel,
handle,
label,
selection,
name: None,
@@ -366,7 +382,12 @@ impl App {
.cloned()
});
if let Some(category) = category {
match config::add_channel(&self.config_path, &category, &dialog.channel) {
match config::add_channel(
&self.config_path,
&category,
&dialog.handle,
&dialog.channel,
) {
Ok(config) => {
self.apply_config(config);
self.switch_view(self.view_index);
@@ -929,6 +950,7 @@ mod tests {
title: "Me at the zoo".into(),
channel: None,
channel_id: None,
uploader_id: None,
timestamp: None,
description: None,
},
@@ -937,6 +959,7 @@ mod tests {
title: "Rust course".into(),
channel: None,
channel_id: None,
uploader_id: None,
timestamp: None,
description: None,
},
@@ -973,7 +996,7 @@ mod tests {
#[test]
fn category_dialog_is_modal_and_views_keep_independent_selections() {
let mut app = app(&["tubular", "rust"]);
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel":"Rust","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA"},{"id":"rQ_J9WH6CGk","title":"Another video"}]}"#;
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel":"Rust","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"},{"id":"rQ_J9WH6CGk","title":"Another video"}]}"#;
app.views[0].videos = youtube::search_results(videos).unwrap();
app.views[0].loaded = true;
app.views[0].selection.select(Some(1));
@@ -981,6 +1004,10 @@ mod tests {
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
app.views[0].selection.select(Some(0));
let handle = app.views[0].videos[0].uploader_id.take();
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
app.views[0].videos[0].uploader_id = handle;
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
@@ -1050,7 +1077,7 @@ mod tests {
assert!(!app.thumbnail_open);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A"}]}"#,
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@jawed"}]}"#,
).unwrap();
app.views[0].videos[0].description = Some("A cached full description.".into());
app.views[0].selection.select(Some(0));
+24 -3
View File
@@ -14,6 +14,7 @@ pub struct Video {
pub title: String,
pub channel: Option<String>,
pub channel_id: Option<String>,
pub uploader_id: Option<String>,
pub timestamp: Option<i64>,
// Search descriptions are truncated; only full video metadata populates this cache.
#[serde(skip)]
@@ -25,6 +26,7 @@ struct SearchResults {
entries: Vec<Option<Video>>,
channel: Option<String>,
channel_id: Option<String>,
uploader_id: Option<String>,
}
pub fn search_results(output: &str) -> Result<Vec<Video>, String> {
@@ -63,6 +65,13 @@ fn validate_video(video: &mut Video) -> Result<(), String> {
{
return Err("an invalid YouTube channel ID".into());
}
if video
.uploader_id
.as_deref()
.is_some_and(|handle| handle.chars().any(char::is_control))
{
return Err("an invalid YouTube channel handle".into());
}
video.title.retain(|c| !c.is_control());
if let Some(channel) = &mut video.channel {
channel.retain(|c| !c.is_control());
@@ -88,6 +97,9 @@ pub fn feed_results(output: &str) -> Result<Vec<Video>, String> {
if video.channel_id.is_none() {
video.channel_id = playlist.channel_id.clone();
}
if video.uploader_id.is_none() {
video.uploader_id = playlist.uploader_id.clone();
}
if video.channel.is_none() {
video.channel = playlist.channel.clone();
}
@@ -485,26 +497,30 @@ mod tests {
#[test]
fn search_handles_unavailable_entries_and_rejects_invalid_ids() {
let videos = search_results(
r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null}]}"#,
r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null,"uploader_id":"@RustVideos"}]}"#,
)
.unwrap();
assert_eq!(videos[0].title, "Rust");
assert_eq!(videos[0].channel, None);
assert_eq!(videos[0].channel_id, None);
assert_eq!(videos[0].uploader_id.as_deref(), Some("@RustVideos"));
assert!(
search_results(
r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Bad","channel_id":"not-a-channel"}]}"#
)
.is_err()
);
assert!(search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Unsafe","uploader_id":"@jawed\u001b"}]}"#
).is_err());
assert!(search_results(r#"{"entries":[{"id":"--bad","title":"Bad"}]}"#).is_err());
assert!(search_results("not JSON").is_err());
}
#[test]
fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() {
let output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]}
{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","channel":"Rust","entries":[{"id":"SLV9AJxnb1U","title":"Newest","timestamp":300},{"id":"l-GaaivVJCE","title":"Newer","timestamp":200},{"id":"rQ_J9WH6CGk","title":"No metadata","channel_id":null}]}"#;
let output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]}
{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust","entries":[{"id":"SLV9AJxnb1U","title":"Newest","timestamp":300},{"id":"l-GaaivVJCE","title":"Newer","timestamp":200},{"id":"rQ_J9WH6CGk","title":"No metadata","channel_id":null}]}"#;
let videos = feed_results(output).unwrap();
assert_eq!(
videos
@@ -522,6 +538,11 @@ mod tests {
.iter()
.all(|video| video.channel_id.as_deref() == Some("UCaYhcUwRBNscFNUKTjgPFiA"))
);
assert!(
videos
.iter()
.all(|video| video.uploader_id.as_deref() == Some("@RustVideos"))
);
let unavailable = feed_results(
r#"{"entries":[null,{"id":"jNQXAC9IVRw","title":"Unknown","channel_id":null}]}"#,
)