use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::transcript::error::{Result, TranscriptError};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VideoMetadata {
#[serde(default = "default_schema")]
pub schema: u32,
pub video_id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub like_count: Option<u64>,
#[serde(default)]
pub is_live_content: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_unlisted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbnail_url: Option<String>,
pub fetched_at: DateTime<Utc>,
}
fn default_schema() -> u32 {
SCHEMA_VERSION
}
pub fn parse(raw: &str, fetched_at: DateTime<Utc>) -> Result<VideoMetadata> {
let web: WebPlayerResponse = serde_json::from_str(raw)
.map_err(|e| TranscriptError::ParseError(format!("WEB playerResponse: {e}")))?;
let details = web.video_details.ok_or_else(|| {
TranscriptError::ParseError("WEB playerResponse: missing videoDetails".to_string())
})?;
let micro = web.microformat.and_then(|m| m.player_microformat_renderer);
let view_count = details
.view_count
.as_ref()
.and_then(Count::to_u64)
.or_else(|| {
micro
.as_ref()
.and_then(|m| m.view_count.as_ref())
.and_then(Count::to_u64)
});
let thumbnail_url = micro
.as_ref()
.and_then(|m| m.thumbnail.as_ref())
.and_then(ThumbnailList::best)
.or_else(|| details.thumbnail.as_ref().and_then(ThumbnailList::best));
let description = details.short_description.filter(|s| !s.is_empty());
Ok(VideoMetadata {
schema: SCHEMA_VERSION,
video_id: details.video_id,
title: details.title,
channel: details
.author
.or_else(|| micro.as_ref().and_then(|m| m.owner_channel_name.clone())),
channel_id: details
.channel_id
.or_else(|| micro.as_ref().and_then(|m| m.external_channel_id.clone())),
channel_url: micro.as_ref().and_then(|m| m.owner_profile_url.clone()),
category: micro.as_ref().and_then(|m| m.category.clone()),
published_at: micro.as_ref().and_then(|m| m.publish_date.clone()),
duration_seconds: details.length_seconds.as_ref().and_then(Count::to_u64),
description,
keywords: details.keywords,
view_count,
like_count: micro
.as_ref()
.and_then(|m| m.like_count.as_ref())
.and_then(Count::to_u64),
is_live_content: details.is_live_content.unwrap_or(false),
is_unlisted: micro.as_ref().and_then(|m| m.is_unlisted),
thumbnail_url,
fetched_at,
})
}
pub fn read_fetched_at(yaml: &str) -> Option<DateTime<Utc>> {
serde_yaml::from_str::<SidecarStamp>(yaml)
.ok()
.map(|s| s.fetched_at)
}
#[derive(Deserialize)]
struct SidecarStamp {
fetched_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebPlayerResponse {
#[serde(default)]
video_details: Option<WebVideoDetails>,
#[serde(default)]
microformat: Option<Microformat>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebVideoDetails {
video_id: String,
title: String,
#[serde(default)]
author: Option<String>,
#[serde(default)]
channel_id: Option<String>,
#[serde(default)]
length_seconds: Option<Count>,
#[serde(default)]
short_description: Option<String>,
#[serde(default)]
keywords: Vec<String>,
#[serde(default)]
view_count: Option<Count>,
#[serde(default)]
is_live_content: Option<bool>,
#[serde(default)]
thumbnail: Option<ThumbnailList>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Microformat {
#[serde(default)]
player_microformat_renderer: Option<PlayerMicroformatRenderer>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PlayerMicroformatRenderer {
#[serde(default)]
publish_date: Option<String>,
#[serde(default)]
like_count: Option<Count>,
#[serde(default)]
view_count: Option<Count>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
owner_channel_name: Option<String>,
#[serde(default)]
owner_profile_url: Option<String>,
#[serde(default)]
external_channel_id: Option<String>,
#[serde(default)]
is_unlisted: Option<bool>,
#[serde(default)]
thumbnail: Option<ThumbnailList>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ThumbnailList {
#[serde(default)]
thumbnails: Vec<Thumbnail>,
}
impl ThumbnailList {
fn best(&self) -> Option<String> {
self.thumbnails.last().and_then(|t| t.url.clone())
}
}
#[derive(Debug, Deserialize)]
struct Thumbnail {
#[serde(default)]
url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Count {
Str(String),
Num(u64),
}
impl Count {
fn to_u64(&self) -> Option<u64> {
match self {
Self::Str(s) => s.parse().ok(),
Self::Num(n) => Some(*n),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const WITH_MICROFORMAT: &str = include_str!("fixtures/player_response_web_metadata.json");
const NO_MICROFORMAT: &str = include_str!("fixtures/player_response_web_no_microformat.json");
fn fixed_fetched_at() -> DateTime<Utc> {
"2026-06-11T03:12:45Z".parse().unwrap()
}
#[test]
fn parse_projects_full_metadata_with_microformat() {
let meta = parse(WITH_MICROFORMAT, fixed_fetched_at()).unwrap();
assert_eq!(meta.schema, SCHEMA_VERSION);
assert_eq!(meta.video_id, "dQw4w9WgXcQ");
assert_eq!(meta.title, "Rick Astley - Never Gonna Give You Up");
assert_eq!(meta.channel.as_deref(), Some("Rick Astley"));
assert_eq!(meta.channel_id.as_deref(), Some("UCuAXFkgsw1L7xaCfnd5JJOw"));
assert_eq!(
meta.channel_url.as_deref(),
Some("http://www.youtube.com/@RickAstleyYT")
);
assert_eq!(meta.category.as_deref(), Some("Music"));
assert_eq!(
meta.published_at.as_deref(),
Some("2009-10-24T23:57:33-07:00")
);
assert_eq!(meta.duration_seconds, Some(213));
assert!(meta
.description
.as_deref()
.unwrap()
.contains("official video"));
assert_eq!(
meta.keywords,
vec!["rick astley", "Never Gonna Give You Up"]
);
assert_eq!(meta.view_count, Some(1_781_429_760));
assert_eq!(meta.like_count, Some(19_148_727));
assert!(!meta.is_live_content);
assert_eq!(meta.is_unlisted, Some(false));
assert_eq!(
meta.thumbnail_url.as_deref(),
Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg")
);
assert_eq!(meta.fetched_at, fixed_fetched_at());
}
#[test]
fn parse_tolerates_absent_microformat() {
let meta = parse(NO_MICROFORMAT, fixed_fetched_at()).unwrap();
assert_eq!(meta.video_id, "noMicro1234");
assert_eq!(meta.title, "Private-ish video");
assert_eq!(meta.channel.as_deref(), Some("Some Channel"));
assert_eq!(meta.published_at, None);
assert_eq!(meta.like_count, None);
assert_eq!(meta.category, None);
assert_eq!(meta.is_unlisted, None);
assert_eq!(meta.view_count, Some(42));
assert_eq!(meta.duration_seconds, Some(100));
}
#[test]
fn parse_errors_when_video_details_missing() {
let err = parse(
r#"{"playabilityStatus":{"status":"ERROR"}}"#,
fixed_fetched_at(),
)
.unwrap_err();
assert!(matches!(err, TranscriptError::ParseError(_)));
}
#[test]
fn parse_errors_on_malformed_json() {
let err = parse("{ not json", fixed_fetched_at()).unwrap_err();
assert!(matches!(err, TranscriptError::ParseError(_)));
}
#[test]
fn count_accepts_string_or_number() {
let raw = r#"{"videoDetails":{"videoId":"x","title":"t","viewCount":7}}"#;
let meta = parse(raw, fixed_fetched_at()).unwrap();
assert_eq!(meta.view_count, Some(7));
}
#[test]
fn serialised_sidecar_round_trips_and_skips_empties() {
let meta = parse(NO_MICROFORMAT, fixed_fetched_at()).unwrap();
let yaml = serde_yaml::to_string(&meta).unwrap();
assert!(!yaml.contains("like_count"));
assert!(!yaml.contains("category"));
assert!(yaml.contains("schema: 1"));
assert!(yaml.contains("video_id: noMicro1234"));
assert!(yaml.contains("fetched_at:"));
let back: VideoMetadata = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(back, meta);
}
#[test]
fn read_fetched_at_recovers_stamp() {
let meta = parse(WITH_MICROFORMAT, fixed_fetched_at()).unwrap();
let yaml = serde_yaml::to_string(&meta).unwrap();
assert_eq!(read_fetched_at(&yaml), Some(fixed_fetched_at()));
}
#[test]
fn read_fetched_at_returns_none_for_corrupt_or_missing() {
assert_eq!(read_fetched_at("{ not yaml"), None);
assert_eq!(read_fetched_at("schema: 1\nvideo_id: x\n"), None);
}
}