use serde::Deserialize;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Thumbs {
pub default: String,
pub medium: String,
pub high: String,
}
impl Thumbs {
pub fn list(&self) -> &str {
[&self.medium, &self.high, &self.default]
.into_iter()
.find(|u| !u.is_empty())
.map(String::as_str)
.unwrap_or("")
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Video {
pub id: String,
pub title: String,
pub channel: String,
pub duration: u32, pub thumbs: Thumbs,
pub views: String, pub age: String, }
impl Video {
pub fn watch_url(&self) -> String {
format!("https://www.youtube.com/watch?v={}", self.id)
}
pub fn sub(&self) -> String {
[&self.channel, &self.age, &self.views]
.into_iter()
.filter(|s| !s.is_empty())
.map(String::as_str)
.collect::<Vec<_>>()
.join(" · ")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Channel {
pub id: String,
pub title: String,
pub thumbs: Thumbs,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Playlist {
pub id: String,
pub title: String,
pub count: u32,
pub thumbs: Thumbs,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mine {
pub id: String,
pub title: String,
pub uploads: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Page<T> {
pub items: Vec<T>,
pub next: Option<String>,
}
#[derive(Deserialize, Default)]
struct RawThumb {
#[serde(default)]
url: String,
}
#[derive(Deserialize, Default)]
struct RawThumbs {
#[serde(default)]
default: RawThumb,
#[serde(default)]
medium: RawThumb,
#[serde(default)]
high: RawThumb,
}
impl From<RawThumbs> for Thumbs {
fn from(r: RawThumbs) -> Self {
Thumbs { default: r.default.url, medium: r.medium.url, high: r.high.url }
}
}
#[derive(Deserialize, Default)]
struct Snippet {
#[serde(default)]
title: String,
#[serde(default, rename = "channelTitle")]
channel_title: String,
#[serde(default, rename = "videoOwnerChannelTitle")]
owner_channel_title: String,
#[serde(default)]
thumbnails: RawThumbs,
#[serde(default, rename = "resourceId")]
resource_id: Option<ResourceId>,
#[serde(default, rename = "publishedAt")]
published_at: String,
}
#[derive(Deserialize, Default)]
struct ResourceId {
#[serde(default, rename = "channelId")]
channel_id: String,
}
#[derive(Deserialize, Default)]
struct ContentDetails {
#[serde(default)]
duration: String,
#[serde(default, rename = "videoId")]
video_id: String,
#[serde(default, rename = "videoPublishedAt")]
video_published_at: String, #[serde(default, rename = "itemCount")]
item_count: u32,
#[serde(default, rename = "relatedPlaylists")]
related: Option<RelatedPlaylists>,
}
#[derive(Deserialize, Default)]
struct RelatedPlaylists {
#[serde(default)]
uploads: String,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Id {
String(String),
Object {
#[serde(default, rename = "videoId")]
video_id: String,
#[serde(default, rename = "channelId")]
channel_id: String,
#[serde(default, rename = "playlistId")]
playlist_id: String,
},
}
impl Default for Id {
fn default() -> Self {
Id::String(String::new())
}
}
#[derive(Deserialize, Default)]
struct Item {
#[serde(default)]
id: Id,
#[serde(default)]
snippet: Snippet,
#[serde(default, rename = "contentDetails")]
content_details: ContentDetails,
}
#[derive(Deserialize, Default)]
struct ListResponse {
#[serde(default)]
items: Vec<Item>,
#[serde(default, rename = "nextPageToken")]
next_page_token: Option<String>,
#[serde(default)]
error: Option<ApiError>,
}
#[derive(Deserialize, Default)]
struct ApiError {
#[serde(default)]
code: u32,
#[serde(default)]
message: String,
}
fn decode(body: &str) -> Result<ListResponse, String> {
let resp: ListResponse = serde_json::from_str(body).map_err(|e| format!("json: {e}"))?;
if let Some(err) = &resp.error {
return Err(format!("api {}: {}", err.code, err.message));
}
Ok(resp)
}
pub fn videos(body: &str) -> Result<Page<Video>, String> {
let resp = decode(body)?;
let items = resp
.items
.into_iter()
.filter_map(video_from_item)
.collect();
Ok(Page { items, next: resp.next_page_token })
}
fn video_from_item(item: Item) -> Option<Video> {
let id = match &item.id {
Id::Object { video_id, .. } if !video_id.is_empty() => video_id.clone(),
Id::String(s) if !s.is_empty() && item.content_details.video_id.is_empty() => s.clone(),
_ => item.content_details.video_id.clone(),
};
if id.is_empty() {
return None;
}
let channel = if item.snippet.channel_title.is_empty() {
item.snippet.owner_channel_title
} else {
item.snippet.channel_title
};
let published = if item.content_details.video_published_at.is_empty() {
&item.snippet.published_at
} else {
&item.content_details.video_published_at
};
let age = rfc3339_secs(published).map(|t| ago(now_secs() - t)).unwrap_or_default();
Some(Video {
id,
title: item.snippet.title,
channel,
duration: parse_duration(&item.content_details.duration),
thumbs: item.snippet.thumbnails.into(),
age,
..Default::default()
})
}
pub fn subscriptions(body: &str) -> Result<Page<Channel>, String> {
let resp = decode(body)?;
let items = resp
.items
.into_iter()
.filter_map(|item| {
let id = item.snippet.resource_id.map(|r| r.channel_id).unwrap_or_default();
(!id.is_empty()).then(|| Channel {
id,
title: item.snippet.title,
thumbs: item.snippet.thumbnails.into(),
})
})
.collect();
Ok(Page { items, next: resp.next_page_token })
}
pub fn playlists(body: &str) -> Result<Page<Playlist>, String> {
let resp = decode(body)?;
let items = resp
.items
.into_iter()
.filter_map(|item| {
let id = match &item.id {
Id::String(s) => s.clone(),
Id::Object { playlist_id, .. } => playlist_id.clone(),
};
(!id.is_empty()).then(|| Playlist {
id,
title: item.snippet.title,
count: item.content_details.item_count,
thumbs: item.snippet.thumbnails.into(),
})
})
.collect();
Ok(Page { items, next: resp.next_page_token })
}
pub fn mine(body: &str) -> Result<Mine, String> {
let resp = decode(body)?;
let item = resp.items.into_iter().next().ok_or("no channel for account")?;
let uploads = item
.content_details
.related
.map(|r| r.uploads)
.unwrap_or_default();
let id = match item.id {
Id::String(s) => s,
Id::Object { channel_id, .. } => channel_id,
};
Ok(Mine { id, title: item.snippet.title, uploads })
}
fn now_secs() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}
pub fn rfc3339_secs(s: &str) -> Option<i64> {
let b = s.as_bytes();
if b.len() < 19 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
return None;
}
let n = |r: std::ops::Range<usize>| s.get(r)?.parse::<i64>().ok();
let (y, m, d) = (n(0..4)?, n(5..7)?, n(8..10)?);
let (hh, mm, ss) = (n(11..13)?, n(14..16)?, n(17..19)?);
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
Some(days * 86400 + hh * 3600 + mm * 60 + ss)
}
pub fn ago(secs: i64) -> String {
let s = secs.max(0);
let (n, unit) = match s {
0..60 => (s, "second"),
60..3600 => (s / 60, "minute"),
3600..86400 => (s / 3600, "hour"),
86400..604800 => (s / 86400, "day"),
604800..2592000 => (s / 604800, "week"),
2592000..31536000 => (s / 2592000, "month"),
_ => (s / 31536000, "year"),
};
format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" })
}
pub fn parse_duration(iso: &str) -> u32 {
let Some(rest) = iso.strip_prefix("PT") else {
return 0;
};
let mut seconds = 0u32;
let mut value = 0u32;
for ch in rest.chars() {
match ch {
'0'..='9' => value = value.saturating_mul(10).saturating_add(ch as u32 - '0' as u32),
'H' => {
seconds = seconds.saturating_add(value.saturating_mul(3600));
value = 0;
}
'M' => {
seconds = seconds.saturating_add(value.saturating_mul(60));
value = 0;
}
'S' => {
seconds = seconds.saturating_add(value);
value = 0;
}
_ => return 0,
}
}
seconds
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc3339_and_age_wording() {
assert_eq!(rfc3339_secs("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(rfc3339_secs("2000-03-01T00:00:00Z"), Some(951_868_800));
assert_eq!(rfc3339_secs("2026-10-30T12:34:56.789Z"), Some(1_793_363_696));
assert_eq!(rfc3339_secs("bad"), None);
assert_eq!(ago(30), "30 seconds ago");
assert_eq!(ago(3600), "1 hour ago");
assert_eq!(ago(2 * 86400), "2 days ago");
assert_eq!(ago(14 * 86400), "2 weeks ago");
assert_eq!(ago(400 * 86400), "1 year ago");
}
#[test]
fn durations_cover_hours_minutes_seconds() {
assert_eq!(parse_duration("PT1H2M3S"), 3723);
assert_eq!(parse_duration("PT4M13S"), 253);
assert_eq!(parse_duration("PT45S"), 45);
assert_eq!(parse_duration("PT2H"), 7200);
assert_eq!(parse_duration("P0D"), 0);
assert_eq!(parse_duration(""), 0);
}
#[test]
fn search_items_become_videos_with_watch_urls() {
let body = r#"{"items":[
{"id":{"kind":"youtube#video","videoId":"abc123"},
"snippet":{"title":"First","channelTitle":"Chan",
"thumbnails":{"medium":{"url":"https://i/mq.jpg"},"high":{"url":"https://i/hq.jpg"}}}},
{"id":{"kind":"youtube#channel","channelId":"UCxxxx"},
"snippet":{"title":"a channel"}}
],"nextPageToken":"NEXT"}"#;
let page = videos(body).unwrap();
assert_eq!(page.items.len(), 1); assert_eq!(page.items[0].id, "abc123");
assert_eq!(page.items[0].title, "First");
assert_eq!(page.items[0].channel, "Chan");
assert_eq!(page.items[0].watch_url(), "https://www.youtube.com/watch?v=abc123");
assert_eq!(page.items[0].thumbs.list(), "https://i/mq.jpg");
assert_eq!(page.next.as_deref(), Some("NEXT"));
}
#[test]
fn playlist_items_use_content_details_video_id() {
let body = r#"{"items":[
{"snippet":{"title":"Song","videoOwnerChannelTitle":"Artist"},
"contentDetails":{"videoId":"vid9","duration":"PT3M"}}
]}"#;
let page = videos(body).unwrap();
assert_eq!(page.items[0].id, "vid9");
assert_eq!(page.items[0].channel, "Artist");
assert_eq!(page.items[0].duration, 180);
assert_eq!(page.next, None);
}
#[test]
fn subscriptions_read_resource_channel_id() {
let body = r#"{"items":[
{"snippet":{"title":"Cool Channel","resourceId":{"channelId":"UC777"},
"thumbnails":{"default":{"url":"https://i/ch.jpg"}}}},
{"snippet":{"title":"no id"}}
]}"#;
let page = subscriptions(body).unwrap();
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].id, "UC777");
assert_eq!(page.items[0].thumbs.list(), "https://i/ch.jpg");
}
#[test]
fn playlists_carry_item_count() {
let body = r#"{"items":[
{"id":"PL1","snippet":{"title":"Faves"},"contentDetails":{"itemCount":12}}
]}"#;
let page = playlists(body).unwrap();
assert_eq!((page.items[0].id.as_str(), page.items[0].count), ("PL1", 12));
}
#[test]
fn mine_extracts_uploads_playlist() {
let body = r#"{"items":[
{"id":"UCme","snippet":{"title":"My Account"},
"contentDetails":{"relatedPlaylists":{"uploads":"UUme"}}}
]}"#;
let m = mine(body).unwrap();
assert_eq!((m.id.as_str(), m.title.as_str(), m.uploads.as_str()), ("UCme", "My Account", "UUme"));
}
#[test]
fn api_error_body_is_surfaced() {
let body = r#"{"error":{"code":403,"message":"quotaExceeded"}}"#;
assert_eq!(videos(body).unwrap_err(), "api 403: quotaExceeded");
}
}