use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct VideoId(String);
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ChannelId(String);
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PlaylistId(String);
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum ChannelName {
Handle(String),
Custom(String),
User(String),
}
fn id_chars(s: &str) -> bool {
s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
impl VideoId {
pub fn parse(s: &str) -> Option<Self> {
(s.len() == 11 && id_chars(s)).then(|| Self(s.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn url(&self) -> String {
format!("https://www.youtube.com/watch?v={}", self.0)
}
pub fn mix_url(&self) -> String {
format!("https://www.youtube.com/watch?v={0}&list=RD{0}", self.0)
}
pub fn large_thumbnail_url(&self) -> String {
format!("https://i.ytimg.com/vi_webp/{}/hq720.webp", self.0)
}
pub fn thumbnail_url(&self) -> String {
format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", self.0)
}
}
impl ChannelId {
pub fn parse(s: &str) -> Option<Self> {
(s.len() == 24 && s.starts_with("UC") && id_chars(s)).then(|| Self(s.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn url(&self) -> String {
format!("https://www.youtube.com/channel/{}", self.0)
}
pub fn videos_url(&self) -> String {
format!("https://www.youtube.com/channel/{}/videos", self.0)
}
pub fn streams_url(&self) -> String {
format!("https://www.youtube.com/channel/{}/streams", self.0)
}
pub fn feed_url(&self) -> String {
format!(
"https://www.youtube.com/feeds/videos.xml?channel_id={}",
self.0
)
}
pub fn from_url(url: &str) -> Option<Self> {
let rest = url
.strip_prefix("https://www.youtube.com/channel/")
.or_else(|| url.strip_prefix("http://www.youtube.com/channel/"))
.or_else(|| url.strip_prefix("https://youtube.com/channel/"))?;
Self::parse(rest.split(['/', '?']).next()?)
}
}
impl PlaylistId {
pub fn parse(s: &str) -> Option<Self> {
((13..=64).contains(&s.len()) && id_chars(s)).then(|| Self(s.to_string()))
}
pub fn url(&self) -> String {
format!("https://www.youtube.com/playlist?list={}", self.0)
}
}
impl ChannelName {
pub fn handle(s: &str) -> Option<Self> {
((3..=30).contains(&s.len()) && name_chars(s)).then(|| Self::Handle(s.to_string()))
}
pub fn custom(s: &str) -> Option<Self> {
((1..=100).contains(&s.len()) && name_chars(s)).then(|| Self::Custom(s.to_string()))
}
pub fn user(s: &str) -> Option<Self> {
((1..=100).contains(&s.len()) && name_chars(s)).then(|| Self::User(s.to_string()))
}
pub fn videos_url(&self) -> String {
match self {
Self::Handle(name) => format!("https://www.youtube.com/@{name}/videos"),
Self::Custom(name) => format!("https://www.youtube.com/c/{name}/videos"),
Self::User(name) => format!("https://www.youtube.com/user/{name}/videos"),
}
}
}
impl fmt::Display for ChannelName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Handle(name) => write!(f, "@{name}"),
Self::Custom(name) | Self::User(name) => f.write_str(name),
}
}
}
fn name_chars(s: &str) -> bool {
s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
}
impl TryFrom<String> for VideoId {
type Error = String;
fn try_from(s: String) -> Result<Self, String> {
Self::parse(&s).ok_or_else(|| "not a video id".into())
}
}
impl TryFrom<String> for ChannelId {
type Error = String;
fn try_from(s: String) -> Result<Self, String> {
Self::parse(&s).ok_or_else(|| "not a channel id".into())
}
}
impl From<VideoId> for String {
fn from(id: VideoId) -> String {
id.0
}
}
impl From<ChannelId> for String {
fn from(id: ChannelId) -> String {
id.0
}
}
impl fmt::Display for VideoId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl fmt::Display for ChannelId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
pub fn image_url_allowed(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
let host = rest.split(['/', '?', '#']).next().unwrap_or("");
if host.contains(['@', ':', '\\']) {
return false;
}
host == "i.ytimg.com"
|| host == "yt3.googleusercontent.com"
|| host == "yt3.ggpht.com"
|| host
.strip_prefix('i')
.and_then(|h| h.strip_suffix(".ytimg.com"))
.is_some_and(|n| n.len() == 1 && n.as_bytes()[0].is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn video_ids_are_eleven_safe_characters() {
assert!(VideoId::parse("dQw4w9WgXcQ").is_some());
assert!(VideoId::parse("-w27jSj882I").is_some(), "may start with -");
assert!(VideoId::parse("dQw4w9WgXc").is_none());
assert!(VideoId::parse("dQw4w9WgXcQQ").is_none());
assert!(VideoId::parse("dQw4w9 gXcQ").is_none());
assert!(VideoId::parse("../../etc/p").is_none());
assert_eq!(
VideoId::parse("-w27jSj882I").unwrap().mix_url(),
"https://www.youtube.com/watch?v=-w27jSj882I&list=RD-w27jSj882I"
);
}
#[test]
fn channel_ids_start_with_uc() {
assert!(ChannelId::parse("UC7EVSn5inapL20oPSwAwEUg").is_some());
assert!(ChannelId::parse("7EVSn5inapL20oPSwAwEUg").is_none());
assert!(ChannelId::parse("UC7EVSn5inapL20oPSwAwEU/").is_none());
let from_url =
ChannelId::from_url("http://www.youtube.com/channel/UC7EVSn5inapL20oPSwAwEUg");
assert_eq!(from_url.unwrap().as_str(), "UC7EVSn5inapL20oPSwAwEUg");
assert!(
ChannelId::from_url("https://evil.example/channel/UC7EVSn5inapL20oPSwAwEUg").is_none()
);
}
#[test]
fn only_youtubes_image_hosts_are_fetched() {
assert!(image_url_allowed(
"https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
));
assert!(image_url_allowed(
"https://i3.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
));
assert!(image_url_allowed(
"https://yt3.googleusercontent.com/abc=s88"
));
assert!(!image_url_allowed("http://i.ytimg.com/vi/x/hqdefault.jpg"));
assert!(!image_url_allowed("https://i.ytimg.com@evil.example/x.jpg"));
assert!(!image_url_allowed("https://i.ytimg.com.evil.example/x.jpg"));
assert!(!image_url_allowed("https://evil.example/i.ytimg.com/x.jpg"));
assert!(!image_url_allowed("https://i.ytimg.com:8443/x.jpg"));
assert!(!image_url_allowed("https://iab.ytimg.com/x.jpg"));
}
}