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, PartialEq, Eq)]
pub struct Video {
pub id: String,
pub title: String,
pub channel: String,
pub duration: u32, pub thumbs: Thumbs,
}
impl Video {
pub fn watch_url(&self) -> String {
format!("https://www.youtube.com/watch?v={}", self.id)
}
}
#[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>,
}
#[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 = "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
};
Some(Video {
id,
title: item.snippet.title,
channel,
duration: parse_duration(&item.content_details.duration),
thumbs: item.snippet.thumbnails.into(),
})
}
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 })
}
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 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");
}
}