// YoutubePlaylistLinkHandlerFactory — accepts: // * https://www.youtube.com/playlist?list= // * https://www.youtube.com/watch?v=...&list= // * https://music.youtube.com/playlist?list= // // YT playlist IDs prefix: // * PL user-curated playlists // * RD mix / radio // * OLAK5uy_ album / single // * LL liked-videos (private — won't extract anonymously) // * WL watch-later (private) // * UU uploads (auto-generated per channel) use url::Url; use crate::youtube::linkhandler::{host_is_youtube, LinkError}; pub fn extract_playlist_id(url_str: &str) -> Result { let url = Url::parse(url_str) .map_err(|e| LinkError::InvalidUrl(format!("{url_str}: {e}")))?; let host = url .host_str() .ok_or_else(|| LinkError::InvalidUrl("no host".into()))?; if !host_is_youtube(host) { return Err(LinkError::UnsupportedHost(host.into())); } url.query_pairs() .find(|(k, _)| k == "list") .map(|(_, v)| v.into_owned()) .filter(|s| !s.is_empty()) .ok_or_else(|| LinkError::MissingId(url_str.into())) } pub fn playlist_url(playlist_id: &str) -> String { format!("https://www.youtube.com/playlist?list={playlist_id}") } #[cfg(test)] mod tests { use super::*; #[test] fn standalone_playlist() { let id = extract_playlist_id( "https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", ) .unwrap(); assert_eq!(id, "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj"); } #[test] fn watch_with_list() { let id = extract_playlist_id( "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLxxx", ) .unwrap(); assert_eq!(id, "PLxxx"); } #[test] fn music_subdomain() { let id = extract_playlist_id( "https://music.youtube.com/playlist?list=OLAK5uy_kFooBar", ) .unwrap(); assert_eq!(id, "OLAK5uy_kFooBar"); } #[test] fn rejects_no_list_param() { let err = extract_playlist_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ") .unwrap_err(); assert!(matches!(err, LinkError::MissingId(_))); } #[test] fn rejects_non_youtube_host() { let err = extract_playlist_id("https://invidious.io/playlist?list=PLxxx").unwrap_err(); assert!(matches!(err, LinkError::UnsupportedHost(_))); } }