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
+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}]}"#,
)