use crate::error::{YdlError, YdlResult};
use regex::Regex;
use url::Url;
pub struct YouTubeParser {
video_id_regex: Regex,
youtube_domains: Vec<&'static str>,
}
impl Default for YouTubeParser {
fn default() -> Self {
Self::new()
}
}
impl YouTubeParser {
pub fn new() -> Self {
let video_id_regex = Regex::new(r"^[a-zA-Z0-9_-]{11}$").expect("Valid video ID regex");
let youtube_domains = vec![
"youtube.com",
"www.youtube.com",
"youtu.be",
"m.youtube.com",
"youtube-nocookie.com",
"www.youtube-nocookie.com",
];
Self {
video_id_regex,
youtube_domains,
}
}
pub fn parse_url(&self, url_str: &str) -> YdlResult<String> {
let url = Url::parse(url_str).map_err(|_| YdlError::InvalidUrl {
url: url_str.to_string(),
})?;
self.validate_domain(&url)?;
self.extract_video_id(&url)
}
fn validate_domain(&self, url: &Url) -> YdlResult<()> {
let domain = url.domain().ok_or_else(|| YdlError::InvalidUrl {
url: url.to_string(),
})?;
if !self.youtube_domains.contains(&domain) {
return Err(YdlError::InvalidUrl {
url: url.to_string(),
});
}
Ok(())
}
fn extract_video_id(&self, url: &Url) -> YdlResult<String> {
let domain = url.domain().unwrap();
match domain {
"youtu.be" => {
let path = url.path().trim_start_matches('/');
let video_id = path.split('/').next().unwrap_or("");
self.validate_and_return_video_id(video_id, url)
}
_ => {
if let Ok(id) = self.extract_from_watch_url(url) {
return Ok(id);
}
if let Ok(id) = self.extract_from_embed_url(url) {
return Ok(id);
}
if let Ok(id) = self.extract_from_shorts_url(url) {
return Ok(id);
}
Err(YdlError::InvalidUrl {
url: url.to_string(),
})
}
}
}
fn extract_from_watch_url(&self, url: &Url) -> YdlResult<String> {
if url.path() != "/watch" {
return Err(YdlError::InvalidUrl {
url: url.to_string(),
});
}
let video_id = url
.query_pairs()
.find(|(key, _)| key == "v")
.map(|(_, value)| value.to_string())
.ok_or_else(|| YdlError::InvalidUrl {
url: url.to_string(),
})?;
self.validate_and_return_video_id(&video_id, url)
}
fn extract_from_embed_url(&self, url: &Url) -> YdlResult<String> {
let path_segments: Vec<&str> = url
.path_segments()
.ok_or_else(|| YdlError::InvalidUrl {
url: url.to_string(),
})?
.collect();
if path_segments.len() >= 2 && path_segments[0] == "embed" {
let video_id = path_segments[1];
return self.validate_and_return_video_id(video_id, url);
}
Err(YdlError::InvalidUrl {
url: url.to_string(),
})
}
fn extract_from_shorts_url(&self, url: &Url) -> YdlResult<String> {
let path_segments: Vec<&str> = url
.path_segments()
.ok_or_else(|| YdlError::InvalidUrl {
url: url.to_string(),
})?
.collect();
if path_segments.len() >= 2 && path_segments[0] == "shorts" {
let video_id = path_segments[1];
return self.validate_and_return_video_id(video_id, url);
}
Err(YdlError::InvalidUrl {
url: url.to_string(),
})
}
fn validate_and_return_video_id(&self, video_id: &str, _url: &Url) -> YdlResult<String> {
if self.is_valid_video_id(video_id) {
Ok(video_id.to_string())
} else {
Err(YdlError::InvalidVideoId {
video_id: video_id.to_string(),
})
}
}
pub fn is_valid_video_id(&self, video_id: &str) -> bool {
self.video_id_regex.is_match(video_id)
}
pub fn normalize_url(&self, url_str: &str) -> YdlResult<String> {
let video_id = self.parse_url(url_str)?;
Ok(format!("https://www.youtube.com/watch?v={}", video_id))
}
pub fn extract_video_id_direct(&self, input: &str) -> YdlResult<String> {
if self.is_valid_video_id(input) {
Ok(input.to_string())
} else {
self.parse_url(input)
}
}
}
pub fn parse_youtube_url(url: &str) -> YdlResult<String> {
YouTubeParser::new().parse_url(url)
}
pub fn is_valid_video_id(video_id: &str) -> bool {
YouTubeParser::new().is_valid_video_id(video_id)
}
pub fn normalize_youtube_url(url: &str) -> YdlResult<String> {
YouTubeParser::new().normalize_url(url)
}
#[cfg(test)]
mod tests {
use super::*;
fn parser() -> YouTubeParser {
YouTubeParser::new()
}
#[test]
fn test_parse_standard_watch_url() {
let parser = parser();
let urls = vec![
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtube.com/watch?v=dQw4w9WgXcQ",
"http://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://m.youtube.com/watch?v=dQw4w9WgXcQ",
];
for url in urls {
let result = parser.parse_url(url);
assert!(result.is_ok(), "Failed to parse: {}", url);
assert_eq!(result.unwrap(), "dQw4w9WgXcQ");
}
}
#[test]
fn test_parse_short_urls() {
let parser = parser();
let urls = vec![
"https://youtu.be/dQw4w9WgXcQ",
"http://youtu.be/dQw4w9WgXcQ",
"youtu.be/dQw4w9WgXcQ",
];
for url in urls {
let result = parser.parse_url(url);
if result.is_err() {
let full_url = format!("https://{}", url);
let result = parser.parse_url(&full_url);
assert!(result.is_ok(), "Failed to parse: {}", url);
assert_eq!(result.unwrap(), "dQw4w9WgXcQ");
} else {
assert_eq!(result.unwrap(), "dQw4w9WgXcQ");
}
}
}
#[test]
fn test_parse_embed_urls() {
let parser = parser();
let urls = vec![
"https://www.youtube.com/embed/dQw4w9WgXcQ",
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ",
];
for url in urls {
let result = parser.parse_url(url);
assert!(result.is_ok(), "Failed to parse: {}", url);
assert_eq!(result.unwrap(), "dQw4w9WgXcQ");
}
}
#[test]
fn test_parse_shorts_urls() {
let parser = parser();
let url = "https://www.youtube.com/shorts/dQw4w9WgXcQ";
let result = parser.parse_url(url);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "dQw4w9WgXcQ");
}
#[test]
fn test_parse_urls_with_additional_params() {
let parser = parser();
let urls = vec![
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=10s",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLrCZdFsaG",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=10s&list=PLrCZdFsaG",
"https://youtu.be/dQw4w9WgXcQ?t=10s",
];
for url in urls {
let result = parser.parse_url(url);
assert!(result.is_ok(), "Failed to parse: {}", url);
assert_eq!(result.unwrap(), "dQw4w9WgXcQ");
}
}
#[test]
fn test_invalid_urls() {
let parser = parser();
let invalid_urls = vec![
"https://www.google.com/watch?v=dQw4w9WgXcQ", "https://www.youtube.com/watch", "https://www.youtube.com/watch?list=PLrCZdFsaG", "https://www.youtube.com/user/someuser", "not-a-url-at-all", "", ];
for url in invalid_urls {
let result = parser.parse_url(url);
assert!(result.is_err(), "Should fail to parse: {}", url);
}
}
#[test]
fn test_invalid_video_ids() {
let parser = parser();
let invalid_ids = vec![
"short", "way_too_long_video_id", "invalid-chars!", "dQw4w9WgXc", "dQw4w9WgXcQQ", ];
for id in invalid_ids {
assert!(!parser.is_valid_video_id(id), "Should be invalid: {}", id);
}
}
#[test]
fn test_valid_video_ids() {
let parser = parser();
let valid_ids = vec!["dQw4w9WgXcQ", "aBc_123-XyZ", "0123456789a", "_-_-_-_-_-_"];
for id in valid_ids {
assert!(parser.is_valid_video_id(id), "Should be valid: {}", id);
}
}
#[test]
fn test_normalize_url() {
let parser = parser();
let test_cases = vec![
(
"https://youtu.be/dQw4w9WgXcQ",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
(
"https://www.youtube.com/embed/dQw4w9WgXcQ",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
(
"https://m.youtube.com/watch?v=dQw4w9WgXcQ&t=10s",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
];
for (input, expected) in test_cases {
let result = parser.normalize_url(input);
assert!(result.is_ok(), "Failed to normalize: {}", input);
assert_eq!(result.unwrap(), expected);
}
}
#[test]
fn test_extract_video_id_direct() {
let parser = parser();
let result = parser.extract_video_id_direct("dQw4w9WgXcQ");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "dQw4w9WgXcQ");
let result = parser.extract_video_id_direct("https://youtu.be/dQw4w9WgXcQ");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "dQw4w9WgXcQ");
let result = parser.extract_video_id_direct("invalid");
assert!(result.is_err());
}
#[test]
fn test_convenience_functions() {
let result = parse_youtube_url("https://youtu.be/dQw4w9WgXcQ");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "dQw4w9WgXcQ");
assert!(is_valid_video_id("dQw4w9WgXcQ"));
assert!(!is_valid_video_id("invalid"));
let result = normalize_youtube_url("https://youtu.be/dQw4w9WgXcQ");
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
"https://www.youtube.com/watch?v=dQw4w9WgXcQ"
);
}
}