#![allow(clippy::too_many_arguments, clippy::items_after_test_module)] use std::collections::{HashMap, HashSet}; use std::fmt::Display; use std::str::FromStr; use rstest::{fixture, rstest}; use rustypipe::model::TrackType; use rustypipe::param::{AlbumOrder, LANGUAGES}; use time::{macros::date, OffsetDateTime}; use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery}; use rustypipe::error::{Error, ExtractionError, UnavailabilityReason}; use rustypipe::model::{ paginator::{ContinuationEndpoint, Paginator}, richtext::ToPlaintext, traits::{FromYtItem, YtStream}, AlbumType, AudioCodec, AudioFormat, AudioTrackType, Channel, Frameset, MusicGenre, MusicItem, UrlTarget, Verification, VideoCodec, VideoFormat, VideoId, YouTubeItem, }; use rustypipe::param::{ search_filter::{self, SearchFilter}, ChannelOrder, ChannelVideoTab, Country, Language, }; use rustypipe::validate; //#PLAYER #[rstest] #[case::desktop(ClientType::Desktop)] #[case::tv(ClientType::Tv)] #[case::mobile(ClientType::Mobile)] #[case::android(ClientType::Android)] #[case::ios(ClientType::Ios)] #[tokio::test] async fn get_player_from_client(#[case] client_type: ClientType, rp: RustyPipe) { let player_data = rp .query() .player_from_client("n4tK7LYFxI0", client_type) .await .unwrap(); // dbg!(&player_data); assert_eq!(player_data.details.id, "n4tK7LYFxI0"); assert_eq!(player_data.details.duration, 259); assert!(!player_data.details.thumbnail.is_empty()); assert_eq!(player_data.details.channel_id, "UC_aEa8K-EOJ3D6gOs7HcyNg"); assert!(!player_data.details.is_live_content); // The TV client dows not output most video metadata if client_type != ClientType::Tv { assert_eq!( player_data.details.name.expect("name"), "Spektrem - Shine | Progressive House | NCS - Copyright Free Music" ); assert_eq!( player_data.details.channel_name.expect("channel name"), "NoCopyrightSounds" ); assert_gte( player_data.details.view_count.expect("view count"), 146_818_808, "view count", ); assert!(player_data .details .description .expect("description") .contains( "NCS (NoCopyrightSounds): Empowering Creators through Copyright / Royalty Free Music" )); assert_eq!(player_data.details.keywords[0], "spektrem"); } // Ios uses different A/V formats if client_type == ClientType::Ios { let video = player_data .video_only_streams .into_iter() .find(|s| s.itag == 136) .expect("video #136"); let audio = player_data .audio_streams .into_iter() .find(|s| s.itag == 140) .expect("audio #140"); // Bitrates may change between requests assert_approx(video.bitrate, 2_341_408); assert_eq!(video.average_bitrate, 1_661_658); assert_eq!(video.size, Some(53_801_380)); assert_eq!(video.width, 1280); assert_eq!(video.height, 720); assert_eq!(video.fps, 30); assert_eq!(video.quality, "720p"); assert!(!video.hdr); assert_eq!(video.mime, "video/mp4; codecs=\"avc1.4D401F\""); assert_eq!(video.format, VideoFormat::Mp4); assert_eq!(video.codec, VideoCodec::Avc1); assert_approx(audio.bitrate, 130_685); assert_approx(audio.average_bitrate, 129_496); assert_approx(audio.size as f64, 4_193_863); assert_eq!(audio.mime, "audio/mp4; codecs=\"mp4a.40.2\""); assert_eq!(audio.format, AudioFormat::M4a); assert_eq!(audio.codec, AudioCodec::Mp4a); check_video_stream(video).await; check_video_stream(audio).await; } else { let video = player_data .video_only_streams .into_iter() .find(|s| s.itag == 398) .expect("video stream not found"); let audio = player_data .audio_streams .into_iter() .find(|s| s.itag == 251) .expect("audio stream not found"); assert_approx(video.bitrate, 1_340_829); assert_approx(video.average_bitrate, 1_046_557); assert_approx(video.size.expect("video size") as f64, 33_885_572); assert_eq!(video.width, 1280); assert_eq!(video.height, 720); assert_eq!(video.fps, 30); assert_eq!(video.quality, "720p"); assert!(!video.hdr); assert_eq!(video.mime, "video/mp4; codecs=\"av01.0.05M.08\""); assert_eq!(video.format, VideoFormat::Mp4); assert_eq!(video.codec, VideoCodec::Av01); assert_approx(audio.bitrate, 142_718); assert_approx(audio.average_bitrate, 130_708); assert_approx(audio.size as f64, 4_232_344); assert_eq!(audio.mime, "audio/webm; codecs=\"opus\""); assert_eq!(audio.format, AudioFormat::Webm); assert_eq!(audio.codec, AudioCodec::Opus); // Desktop client now requires pot token so the streams cannot be tested here if !matches!(client_type, ClientType::Desktop | ClientType::Mobile) { check_video_stream(video).await; check_video_stream(audio).await; } } assert!(player_data.expires_in_seconds > 10000); } /// Request the given stream to check if it returns a valid response async fn check_video_stream(s: impl YtStream) { let http = reqwest::Client::new(); let resp = http .get(s.url()) .send() .await .unwrap() .error_for_status() .unwrap(); if let Some(size) = s.size() { assert_eq!(resp.content_length(), Some(size)) } } #[rstest] #[case::music( "ihUZMeYFZHA", "Oonagh - Nan Úye", "Offizielle AlbumPlaylist:", 260, "UC2llNlEM62gU-_fXPHfgbDg", "Oonagh", 830_900, false, false )] #[case::hdr( "LXb3EKWsInQ", "COSTA RICA IN 4K 60fps HDR (ULTRA HD)", "We've re-mastered and re-uploaded our favorite video in HDR!", 314, "UCYq-iAOSZBvoUxvfzwKIZWA", "Jacob + Katie Schwarz", 220_000_000, false, false )] #[case::multilanguage( "tVWWp1PqDus", "100 Boys Vs 100 Girls For $500,000", "Giving away $25k on Current!", 1013, "UCX6OQ3DkcsbYNE6H8uQQuVA", "MrBeast", 82_000_000, false, false )] #[case::live( "jfKfPfyJRdk", "lofi hip hop radio 📚 beats to relax/study to", "Listen on Spotify, Apple music and more", 0, "UCSJ4gkVC6NrvII8umztf0Ow", "Lofi Girl", 100, true, true )] #[case::was_live( "pxY4OXVyMe4", "Minecraft GENESIS LIVESTREAM!!", "FÜR MEHR LIVESTREAMS AUF YOUTUBE MACHT FOLGENDES", 5535, "UCQM0bS4_04-Y4JuYrgmnpZQ", "Chaosflo44", 500_000, false, true )] #[case::agelimit( "ZDKQmBWTRnw", "The Rinky Pink Pounder. Hitachi Magic Wand clone teardown.", "violent adult toys for disassembly", 1333, "UCtM5z2gkrGRuWd0JQMx76qA", "bigclivedotcom", 250_000, false, false )] #[tokio::test] #[allow(clippy::too_many_arguments)] async fn get_player_videos( #[case] id: &str, #[case] name: &str, #[case] description: &str, #[case] duration: u32, #[case] channel_id: &str, #[case] channel_name: &str, #[case] views: u64, #[case] is_live: bool, #[case] is_live_content: bool, auth_enabled: bool, rp: RustyPipe, ) { if id == "ZDKQmBWTRnw" && !auth_enabled { eprintln!("unauthenticated; age-limited video cannot be tested"); return; } let player_data = rp.query().player(id).await.unwrap(); let details = player_data.details; assert_eq!(details.id, id); if let Some(n) = &details.name { assert_eq!(n, name); } if let Some(desc) = &details.description { assert!(desc.contains(description), "description: {desc}"); } assert_eq!(details.duration, duration); assert_eq!(details.channel_id, channel_id); if let Some(cn) = &details.channel_name { assert_eq!(cn, channel_name); } if let Some(vc) = details.view_count { assert_gte(vc, views, "views"); } assert_eq!(details.is_live, is_live); assert_eq!(details.is_live_content, is_live_content); if is_live { assert!(player_data.hls_manifest_url.is_some() || player_data.dash_manifest_url.is_some()); } else { assert!(!player_data.video_only_streams.is_empty()); assert!(!player_data.audio_streams.is_empty()); } match id { // HDR "LXb3EKWsInQ" => { assert!( player_data .video_only_streams .iter() .any(|stream| stream.hdr), "no hdr streams" ); } // Multilanguage "tVWWp1PqDus" => { let langs = player_data .audio_streams .iter() .filter_map(|stream| { stream .track .as_ref() .map(|t| (t.lang.as_deref().unwrap(), t.track_type.unwrap())) }) .collect::>(); assert_eq!( langs.get("en-US"), Some(&AudioTrackType::Original), "missing lang: en-US" ); for l in ["es", "fr", "pt", "ru"] { assert_eq!( langs.get(l), Some(&AudioTrackType::Dubbed), "missing lang: {l}" ); } } _ => {} }; assert_gte(player_data.expires_in_seconds, 10_000, "expiry time"); if !is_live { assert_gte(player_data.preview_frames.len(), 3, "preview framesets"); player_data.preview_frames.iter().for_each(assert_frameset); } } #[rstest] #[case::not_found("86abcdefghi", UnavailabilityReason::Deleted)] #[case::deleted("64DYi_8ESh0", UnavailabilityReason::Deleted)] #[case::censored("6SJNVb0GnPI", UnavailabilityReason::Deleted)] // This video is geoblocked outside of Japan, so expect this test case to fail when using a Japanese IP address. #[case::geoblock("sJL6WA-aGkQ", UnavailabilityReason::Geoblocked)] #[case::private("s7_qI6_mIXc", UnavailabilityReason::Private)] #[case::age_restricted("CUO8secmc0g", UnavailabilityReason::AgeRestricted)] #[case::premium_only("3LvozjEOUxU", UnavailabilityReason::Premium)] #[case::members_only("vYmAhoZYg64", UnavailabilityReason::MembersOnly)] #[tokio::test] async fn get_player_error(#[case] id: &str, #[case] expect: UnavailabilityReason, rp: RustyPipe) { let err = rp.query().unauthenticated().player(id).await.unwrap_err(); match err { Error::Extraction(ExtractionError::Unavailable { reason, .. }) => { assert_eq!(reason, expect, "got {err}") } _ => panic!("got {err}"), } } #[rstest] #[tokio::test] async fn get_player_error_paid(rp: RustyPipe) { let err = rp.query().player("N8ee9OLumrs").await.unwrap_err(); match err { // Sometimes YouTube shows an 'unplayable' error on paid videos Error::Extraction(ExtractionError::Unavailable { reason, .. }) => { assert!( matches!( reason, UnavailabilityReason::Paid | UnavailabilityReason::Unplayable ), "got {err}" ) } _ => panic!("got {err}"), } } //#PLAYLIST #[rstest] #[case::long( "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ", "Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2020 2022", true, None, Some(("UCIekuFeMaV78xYfvpmoCnPg", "Best Music")), )] #[case::nomusic( "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe", "Minecraft SHINE", false, Some("SHINE - Survival Hardcore in New Environment: Auf einem Server machen sich tapfere Spieler auf, mystische Welten zu erkunden, magische Technologien zu erforschen und vorallem zu überleben...".to_owned()), Some(("UCQM0bS4_04-Y4JuYrgmnpZQ", "Chaosflo44")), )] #[case::live( "UULVvqRdlKsE5Q8mf8YXbdIJLw", "Live streams", true, None, Some(("UCvqRdlKsE5Q8mf8YXbdIJLw", "LoL Esports")) )] #[tokio::test] async fn get_playlist( #[case] id: &str, #[case] name: &str, #[case] is_long: bool, #[case] description: Option, #[case] channel: Option<(&str, &str)>, rp: RustyPipe, unlocalized: bool, ) { let playlist = rp.query().playlist(id).await.unwrap(); assert_eq!(playlist.id, id); if unlocalized { assert_eq!(playlist.name, name); } assert!(!playlist.videos.is_empty()); assert_eq!(!playlist.videos.is_exhausted(), is_long); assert_gte( playlist.video_count, if is_long { 100 } else { 10 }, "track count", ); assert_eq!(playlist.description.map(|d| d.to_plaintext()), description); if let Some(expect) = channel { let c = playlist.channel.expect("channel"); assert_eq!(c.id, expect.0); assert_eq!(c.name, expect.1); } assert!(!playlist.thumbnail.is_empty()); } #[rstest] #[tokio::test] async fn playlist_cont(rp: RustyPipe) { let mut playlist = rp .query() .playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi") .await .unwrap(); playlist .videos .extend_pages(rp.query(), usize::MAX) .await .unwrap(); check_duplicates(&playlist.videos.items); assert_gte(playlist.videos.items.len(), 101, "video items"); assert_gteo(playlist.videos.count, 101, "video count"); } #[rstest] #[tokio::test] async fn playlist_cont2(rp: RustyPipe) { let mut playlist = rp .query() .playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi") .await .unwrap(); playlist.videos.extend_limit(rp.query(), 101).await.unwrap(); check_duplicates(&playlist.videos.items); assert_gte(playlist.videos.items.len(), 101, "video items"); assert_gteo(playlist.videos.count, 101, "video count"); } #[rstest] #[tokio::test] async fn playlist_not_found(rp: RustyPipe) { let err = rp .query() .playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qz") .await .unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } //#VIDEO DETAILS #[rstest] #[tokio::test] async fn get_video_details(rp: RustyPipe) { let details = rp.query().video_details("ZeerrnuLi5E").await.unwrap(); // dbg!(&details); assert_eq!(details.id, "ZeerrnuLi5E"); assert_eq!(details.name, "aespa 에스파 'Black Mamba' MV"); let desc = details.description.to_plaintext(); assert!( desc.contains("Listen and download aespa's debut single \"Black Mamba\""), "bad description: {desc}" ); assert_eq!(details.channel.id, "UCEf_Bc-KVd7onSeifS3py9g"); assert_eq!(details.channel.name, "SMTOWN"); assert!(!details.channel.avatar.is_empty(), "no channel avatars"); assert_eq!(details.channel.verification, Verification::Verified); assert_gteo(details.channel.subscriber_count, 30_000_000, "subscribers"); assert_gte(details.view_count, 232_000_000, "views"); assert_gteo(details.like_count, 4_000_000, "likes"); let date = details.publish_date.expect("publish_date"); assert_eq!(date.date(), date!(2020 - 11 - 17)); assert!(!details.is_live); assert!(!details.is_ccommons); assert!(details.recommended.visitor_data.is_some()); assert_next(details.recommended, rp.query(), 10, 1, false).await; assert_gteo(details.top_comments.count, 700_000, "comments"); assert!(!details.top_comments.is_exhausted()); assert!(!details.latest_comments.is_exhausted()); } #[rstest] #[tokio::test] async fn get_video_details_music(rp: RustyPipe) { let details = rp.query().video_details("XuM2onMGvTI").await.unwrap(); // dbg!(&details); assert_eq!(details.id, "XuM2onMGvTI"); assert_eq!(details.name, "Gäa"); let desc = details.description.to_plaintext(); assert!(desc.contains("Gäa · Oonagh"), "bad description: {desc}"); assert_eq!(details.channel.id, "UCVGvnqB-5znqPSbMGlhF4Pw"); assert_eq!(details.channel.name, "Sentamusic"); assert!(!details.channel.avatar.is_empty(), "no channel avatars"); assert_eq!(details.channel.verification, Verification::Artist); assert_gteo(details.channel.subscriber_count, 33_000, "subscribers"); assert_gte(details.view_count, 20_309, "views"); assert_gteo(details.like_count, 145, "likes"); let date = details.publish_date.unwrap(); assert_eq!(date.date(), date!(2020 - 8 - 6)); assert!(!details.is_live); assert!(!details.is_ccommons); assert!(details.recommended.visitor_data.is_some()); assert_next(details.recommended, rp.query(), 10, 1, false).await; // Update(01.11.2022): comments are sometimes enabled /* assert_eq!(details.top_comments.count, Some(0)); assert_eq!(details.latest_comments.count, Some(0)); assert!(details.top_comments.is_empty()); assert!(details.latest_comments.is_empty()); */ } #[rstest] #[tokio::test] async fn get_video_details_ccommons(rp: RustyPipe) { let details = rp.query().video_details("0rb9CfOvojk").await.unwrap(); // dbg!(&details); assert_eq!(details.id, "0rb9CfOvojk"); assert_eq!( details.name, "BahnMining - Pünktlichkeit ist eine Zier (David Kriesel)" ); let desc = details.description.to_plaintext(); assert!( desc.contains("Seit Anfang 2019 hat David jeden einzelnen Halt jeder einzelnen Zugfahrt auf jedem einzelnen Fernbahnhof in ganz Deutschland"), "bad description: {desc}" ); assert_eq!(details.channel.id, "UC2TXq_t06Hjdr2g_KdKpHQg"); assert_eq!(details.channel.name, "media.ccc.de"); assert!(!details.channel.avatar.is_empty(), "no channel avatars"); assert_eq!(details.channel.verification, Verification::None); assert_gteo(details.channel.subscriber_count, 170_000, "subscribers"); assert_gte(details.view_count, 2_517_358, "views"); assert_gteo(details.like_count, 52_330, "likes"); let date = details.publish_date.expect("publish_date"); assert_eq!(date.date(), date!(2019 - 12 - 29)); assert!(!details.is_live); assert!(details.is_ccommons); assert!(details.recommended.visitor_data.is_some()); assert_next(details.recommended, rp.query(), 10, 1, false).await; assert_eq!(details.top_comments.count.unwrap(), 0); assert!(details.top_comments.is_exhausted()); assert!(details.latest_comments.is_exhausted()); } #[rstest] #[tokio::test] async fn get_video_details_chapters(rp: RustyPipe) { let details = rp.query().video_details("nFDBxBUfE74").await.unwrap(); // dbg!(&details); assert_eq!(details.id, "nFDBxBUfE74"); assert_eq!(details.name, "The Prepper PC"); let desc = details.description.to_plaintext(); assert!( desc.contains("These days, you can game almost anywhere on the planet, anytime. But what if that planet was in the middle of an apocalypse"), "bad description: {desc}" ); assert_eq!(details.channel.id, "UCXuqSBlHAE6Xw-yeJA0Tunw"); assert_eq!(details.channel.name, "Linus Tech Tips"); assert!(!details.channel.avatar.is_empty(), "no channel avatars"); assert_eq!(details.channel.verification, Verification::Verified); assert_gteo(details.channel.subscriber_count, 14_700_000, "subscribers"); assert_gte(details.view_count, 1_157_262, "views"); assert_gteo(details.like_count, 54_670, "likes"); let date = details.publish_date.expect("publish_date"); assert_eq!(date.date(), date!(2022 - 9 - 15)); assert!(!details.is_live); assert!(!details.is_ccommons); // In rare cases, YouTube does not return chapters here if !details.chapters.is_empty() { insta::assert_ron_snapshot!(details.chapters, { "[].thumbnail" => insta::dynamic_redaction(move |value, _path| { assert!(!value.as_slice().unwrap().is_empty()); "[ok]" }), }, @r###" [ Chapter( name: "Intro", position: 0, thumbnail: "[ok]", ), Chapter( name: "The PC Built for Super Efficiency", position: 42, thumbnail: "[ok]", ), Chapter( name: "Our BURIAL ENCLOSURE?!", position: 161, thumbnail: "[ok]", ), Chapter( name: "Our Power Solution (Thanks Jackery!)", position: 211, thumbnail: "[ok]", ), Chapter( name: "Diggin\' Holes", position: 287, thumbnail: "[ok]", ), Chapter( name: "Colonoscopy?", position: 330, thumbnail: "[ok]", ), Chapter( name: "Diggin\' like a man", position: 424, thumbnail: "[ok]", ), Chapter( name: "The world\'s worst woodsman", position: 509, thumbnail: "[ok]", ), Chapter( name: "Backyard cable management", position: 543, thumbnail: "[ok]", ), Chapter( name: "Time to bury this boy", position: 602, thumbnail: "[ok]", ), Chapter( name: "Solar Power Generation", position: 646, thumbnail: "[ok]", ), Chapter( name: "Issues", position: 697, thumbnail: "[ok]", ), Chapter( name: "First Play Test", position: 728, thumbnail: "[ok]", ), Chapter( name: "Conclusion", position: 800, thumbnail: "[ok]", ), ] "###); } assert!(details.recommended.visitor_data.is_some()); assert_next(details.recommended, rp.query(), 10, 1, false).await; assert_gteo(details.top_comments.count, 3000, "comments"); assert!(!details.top_comments.is_exhausted()); assert!(!details.latest_comments.is_exhausted()); } #[rstest] #[tokio::test] async fn get_video_details_live(rp: RustyPipe) { let details = rp.query().video_details("jfKfPfyJRdk").await.unwrap(); // dbg!(&details); assert_eq!(details.id, "jfKfPfyJRdk"); assert_eq!( details.name, "lofi hip hop radio 📚 beats to relax/study to" ); let desc = details.description.to_plaintext(); assert!( desc.contains("Listen on Spotify, Apple music and more"), "bad description: {desc}" ); assert_eq!(details.channel.id, "UCSJ4gkVC6NrvII8umztf0Ow"); assert_eq!(details.channel.name, "Lofi Girl"); assert!(!details.channel.avatar.is_empty(), "no channel avatars"); assert_eq!(details.channel.verification, Verification::Verified); assert_gteo(details.channel.subscriber_count, 5_500_000, "subscribers"); assert_gte(details.view_count, 100, "views"); assert_gteo(details.like_count, 1_800_000, "likes"); let date = details.publish_date.expect("publish_date"); assert_eq!(date.date(), date!(2022 - 7 - 12)); assert!(details.is_live); assert!(!details.is_ccommons); assert!(details.recommended.visitor_data.is_some()); assert_next(details.recommended, rp.query(), 10, 1, false).await; // No comments because livestream assert_eq!(details.top_comments.count, Some(0)); assert_eq!(details.latest_comments.count, Some(0)); assert!(details.top_comments.is_empty()); assert!(details.latest_comments.is_empty()); } #[rstest] #[tokio::test] async fn get_video_details_agelimit(rp: RustyPipe) { let details = rp.query().video_details("ZDKQmBWTRnw").await.unwrap(); // dbg!(&details); assert_eq!(details.id, "ZDKQmBWTRnw"); assert_eq!( details.name, "The Rinky Pink Pounder. Hitachi Magic Wand clone teardown." ); insta::assert_ron_snapshot!(details.description, @"RichText([])"); assert_eq!(details.channel.id, "UCtM5z2gkrGRuWd0JQMx76qA"); assert_eq!(details.channel.name, "bigclivedotcom"); assert!(!details.channel.avatar.is_empty(), "no channel avatars"); assert_eq!(details.channel.verification, Verification::Verified); assert_gteo(details.channel.subscriber_count, 1_000_000, "subscribers"); assert_gte(details.view_count, 250_000, "views"); assert_gteo(details.like_count, 5_000, "likes"); let date = details.publish_date.expect("publish_date"); assert_eq!(date.date(), date!(2017 - 3 - 09)); assert!(!details.is_live); assert!(!details.is_ccommons); // No recommendations because age limit assert_eq!(details.recommended.count, Some(0)); assert!(details.recommended.items.is_empty()); } #[rstest] #[tokio::test] async fn get_video_details_no_desc(rp: RustyPipe) { let details = rp.query().video_details("BXpTGEEZpV8").await.unwrap(); assert_eq!(details.id, "BXpTGEEZpV8"); assert_eq!( details.name, "The Streets Make Me Feel Alive Stitches Official Audio" ); assert_eq!(details.channel.id, "UC3oJqv3NXiHkBVUW4i9R_pQ"); assert_eq!(details.channel.name, "Test Upload channel"); assert!(!details.channel.avatar.is_empty(), "no channel avatars"); assert!( details.description.is_empty(), "got desc: `{}`", details.description.to_plaintext() ); let date = details.publish_date.expect("publish_date"); assert_eq!(date.date(), date!(2017 - 4 - 28)); assert!(!details.is_live); assert!(!details.is_ccommons); } #[rstest] #[tokio::test] async fn get_video_details_not_found(rp: RustyPipe) { let err = rp.query().video_details("abcdefgLi5X").await.unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ) } #[rstest] #[tokio::test] async fn get_video_comments(rp: RustyPipe) { let details = rp.query().video_details("ZeerrnuLi5E").await.unwrap(); let top_comments = details .top_comments .next(rp.query()) .await .unwrap() .unwrap(); assert_gte(top_comments.items.len(), 10, "comments"); assert!(!top_comments.is_exhausted()); assert!(top_comments.visitor_data.is_some()); assert_gteo(top_comments.count, 700_000, "comments"); let latest_comments = details .latest_comments .next(rp.query()) .await .unwrap() .unwrap(); assert_gte(latest_comments.items.len(), 10, "next comments"); assert!(!latest_comments.is_exhausted()); assert!(latest_comments.visitor_data.is_some()); } //#CHANNEL #[rstest] #[tokio::test] async fn channel_videos(rp: RustyPipe) { let channel = rp .query() .channel_videos("UC2DjFE7Xf11URZqWBigcVOQ") .await .unwrap(); // dbg!(&channel); assert_channel_eevblog(&channel); assert!( !channel.content.items.is_empty() && !channel.content.is_exhausted(), "got no videos" ); let first_video = &channel.content.items[0]; let first_video_date = first_video.publish_date.unwrap(); let age_days = (OffsetDateTime::now_utc() - first_video_date).whole_days(); assert!(age_days < 60, "latest video older than 60 days"); assert_next(channel.content, rp.query(), 15, 2, true).await; } #[rstest] #[tokio::test] async fn channel_shorts(rp: RustyPipe) { let vd = rp.query().get_visitor_data().await.unwrap(); let channel = rp .query() .visitor_data(vd) .channel_videos_tab("UCh8gHdtzO2tXd593_bjErWg", ChannelVideoTab::Shorts) .await .unwrap(); // dbg!(&channel); assert_eq!(channel.id, "UCh8gHdtzO2tXd593_bjErWg"); assert_eq!(channel.name, "Doobydobap"); assert_eq!(channel.handle.as_deref(), Some("@Doobydobap")); assert_gteo(channel.subscriber_count, 2_800_000, "subscribers"); assert!(!channel.avatar.is_empty(), "got no thumbnails"); assert_eq!(channel.verification, Verification::Verified); assert!(channel .description .contains("Hi, I\u{2019}m Tina, aka Doobydobap")); assert!(!channel.banner.is_empty(), "got no banners"); assert!( !channel.content.items.is_empty() && !channel.content.is_exhausted(), "got no shorts" ); // assert_next(channel.content, rp.query(), 15, 1, true).await; } #[rstest] #[tokio::test] async fn channel_livestreams(rp: RustyPipe) { let channel = rp .query() .channel_videos_tab("UC2DjFE7Xf11URZqWBigcVOQ", ChannelVideoTab::Live) .await .unwrap(); // dbg!(&channel); assert_channel_eevblog(&channel); assert!( !channel.content.items.is_empty() && !channel.content.is_exhausted(), "got no streams" ); assert_next(channel.content, rp.query(), 5, 1, true).await; } #[rstest] #[tokio::test] async fn channel_playlists(rp: RustyPipe) { let channel = rp .query() .channel_playlists("UC2DjFE7Xf11URZqWBigcVOQ") .await .unwrap(); assert_channel_eevblog(&channel); assert!( !channel.content.items.is_empty() && !channel.content.is_exhausted(), "got no playlists" ); assert_next(channel.content, rp.query(), 15, 1, true).await; } #[rstest] #[tokio::test] async fn channel_info(rp: RustyPipe) { let info = rp .query() .channel_info("UC2DjFE7Xf11URZqWBigcVOQ") .await .unwrap(); assert_eq!(info.create_date, Some(date!(2009 - 4 - 4))); assert_gteo(info.view_count, 186_854_340, "channel views"); assert_gteo(info.video_count, 1920, "channel videos"); assert_gteo(info.subscriber_count, 920_000, "subscribers"); assert_eq!(info.country, Some(Country::Au)); insta::assert_ron_snapshot!(info.links, @r###" [ ("EEVblog Web Site", "http://www.eevblog.com/"), ("Twitter", "http://www.twitter.com/eevblog"), ("Facebook", "http://www.facebook.com/EEVblog"), ("EEVdiscover", "https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ"), ("The EEVblog Forum", "http://www.eevblog.com/forum"), ("EEVblog Merchandise (T-Shirts)", "http://www.eevblog.com/merch"), ("EEVblog Donations", "http://www.eevblog.com/donations/"), ("Patreon", "https://www.patreon.com/eevblog"), ("SubscribeStar", "https://www.subscribestar.com/eevblog"), ("The AmpHour Radio Show", "http://www.theamphour.com/"), ("Flickr", "http://www.flickr.com/photos/eevblog"), ("EEVblog AMAZON Store", "http://www.amazon.com/gp/redirect.html?ie=UTF8&location=http%3A%2F%2Fwww.amazon.com%2F&tag=ee04-20&linkCode=ur2&camp=1789&creative=390957"), ("2nd EEVblog Channel", "http://www.youtube.com/EEVblog2"), ] "###); } #[rstest] #[tokio::test] async fn channel_search(rp: RustyPipe) { let channel = rp .query() .channel_search("UC2DjFE7Xf11URZqWBigcVOQ", "test") .await .unwrap(); assert_channel_eevblog(&channel); assert_next(channel.content, rp.query(), 18, 2, true).await; } fn assert_channel_eevblog(channel: &Channel) { assert_eq!(channel.id, "UC2DjFE7Xf11URZqWBigcVOQ"); assert_eq!(channel.name, "EEVblog"); assert_eq!(channel.handle.as_deref(), Some("@EEVblog")); assert_gteo(channel.subscriber_count, 880_000, "subscribers"); assert!(!channel.avatar.is_empty(), "got no thumbnails"); assert_eq!(channel.verification, Verification::Verified); assert_eq!(channel.description, "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON'T DO PAID VIDEO SPONSORSHIPS, DON'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don't be offended if I don't have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA"); assert!(!channel.tags.is_empty(), "got no tags"); assert!(!channel.banner.is_empty(), "got no banners"); } #[rstest] #[case::artist("UC_vmjW5e1xEHhYjY2a0kK1A", "Oonagh - Topic", false, false, false)] #[case::shorts("UCh8gHdtzO2tXd593_bjErWg", "Doobydobap", true, true, true)] #[case::livestream( "UChs0pSaEoNLV4mevBFGaoKA", "The Good Life Radio x Sensual Musique", true, true, true )] #[case::music("UC-9-kyTW8ZkZNDHQJ6FgpwQ", "Music", false, false, false)] #[tokio::test] async fn channel_more( #[case] id: &str, #[case] name: &str, #[case] has_videos: bool, #[case] has_playlists: bool, #[case] name_unlocalized: bool, rp: RustyPipe, unlocalized: bool, ) { fn assert_channel(channel: &Channel, id: &str, name: &str, unlocalized: bool) { assert_eq!(channel.id, id); if unlocalized { assert_eq!(channel.name, name); } } if has_videos { let channel_videos = rp.query().channel_videos(&id).await.unwrap(); assert_channel(&channel_videos, id, name, unlocalized || name_unlocalized); assert!(!channel_videos.content.items.is_empty(), "got no videos"); } if has_playlists { let channel_playlists = rp.query().channel_playlists(&id).await.unwrap(); assert_channel( &channel_playlists, id, name, unlocalized || name_unlocalized, ); assert!( !channel_playlists.content.items.is_empty(), "got no playlists" ); } let info = rp.query().channel_info(&id).await.unwrap(); assert_eq!(info.id, id); } #[rstest] #[case::videos("UCcdwLMPsaU2ezNSJU1nFoBQ", ChannelVideoTab::Videos)] #[case::live("UCvqRdlKsE5Q8mf8YXbdIJLw", ChannelVideoTab::Live)] #[case::shorts("UCcdwLMPsaU2ezNSJU1nFoBQ", ChannelVideoTab::Shorts)] #[tokio::test] async fn channel_order_latest(#[case] id: &str, #[case] tab: ChannelVideoTab, rp: RustyPipe) { let latest = rp .query() .channel_videos_tab_order(id, tab, ChannelOrder::Latest) .await .unwrap(); // Upload dates should be in descending order if tab == ChannelVideoTab::Videos { let mut latest_items = latest.items.iter().peekable(); while let (Some(v), Some(next_v)) = (latest_items.next(), latest_items.peek()) { if !v.is_upcoming && !v.is_live && !next_v.is_upcoming && !next_v.is_live { assert_gte( v.publish_date.expect("publish_date"), next_v.publish_date.expect("publish_date"), "latest video date", ); } } } if tab != ChannelVideoTab::Shorts { assert_next(latest, rp.query(), 15, 1, true).await; } } #[rstest] #[case::videos("UCcdwLMPsaU2ezNSJU1nFoBQ", ChannelVideoTab::Videos, "XqZsoesa55w")] #[case::live("UCvqRdlKsE5Q8mf8YXbdIJLw", ChannelVideoTab::Live, "ojes5ULOqhc")] #[case::shorts("UCcdwLMPsaU2ezNSJU1nFoBQ", ChannelVideoTab::Shorts, "k91vRvXGwHs")] #[tokio::test] async fn channel_order_popular( #[case] id: &str, #[case] tab: ChannelVideoTab, #[case] most_popular: &str, rp: RustyPipe, ) { let popular = rp .query() .channel_videos_tab_order(id, tab, ChannelOrder::Popular) .await .unwrap(); // Most popular video should be in top 5 assert!( popular.items.iter().take(5).any(|v| v.id == most_popular), "most popular video {most_popular} not found" ); // View counts should be in descending order if tab != ChannelVideoTab::Shorts { let mut popular_items = popular.items.iter().peekable(); while let (Some(v), Some(next_v)) = (popular_items.next(), popular_items.peek()) { let vc = v.view_count.expect("views"); assert_gte( vc + (vc as f64 * 0.05) as u64, next_v.view_count.expect("views"), "most popular view count", ); } } if tab != ChannelVideoTab::Shorts { assert_next(popular, rp.query(), 15, 1, true).await; } } #[rstest] #[case::videos("UCcdwLMPsaU2ezNSJU1nFoBQ", ChannelVideoTab::Videos, "P2gDffkC0rY")] #[case::live("UCvqRdlKsE5Q8mf8YXbdIJLw", ChannelVideoTab::Live, "aW43RH1kQ70")] #[tokio::test] async fn channel_order_oldest( #[case] id: &str, #[case] tab: ChannelVideoTab, #[case] oldest: &str, rp: RustyPipe, ) { let videos = rp .query() .channel_videos_tab_order(id, tab, ChannelOrder::Oldest) .await .unwrap(); // Check oldest video assert_eq!(videos.items.first().expect("no videos").id, oldest); // Upload dates should be in ascending order let mut latest_items = videos.items.iter().peekable(); while let (Some(v), Some(next_v)) = (latest_items.next(), latest_items.peek()) { if !v.is_upcoming && !v.is_live && !next_v.is_upcoming && !next_v.is_live { assert_gte( next_v.publish_date.expect("publish_date"), v.publish_date.expect("publish_date"), "oldest video date", ); } } assert_next(videos, rp.query(), 15, 1, true).await; } #[rstest] #[case::not_exist("UCOpNcN46UbXVtpKMrmU4Abx")] #[case::gaming("UCOpNcN46UbXVtpKMrmU4Abg")] #[case::movies("UCuJcl0Ju-gPDoksRjK1ya-w")] #[case::sports("UCEgdi0XIXXZ-qJOFPf4JSKw")] #[case::learning("UCtFRv9O2AHqOZjjynzrv-xg")] #[case::live("UC4R8DWoMoI7CAwX8_LjQHig")] // #[case::news("UCYfdidRxbB8Qhf0Nx7ioOYw")] #[tokio::test] async fn channel_not_found(#[case] id: &str, rp: RustyPipe) { let err = rp.query().channel_videos(&id).await.unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } #[rstest] #[case::shorts(ChannelVideoTab::Shorts)] #[case::live(ChannelVideoTab::Live)] #[tokio::test] async fn channel_tab_not_found(#[case] tab: ChannelVideoTab, rp: RustyPipe) { let channel = rp .query() .channel_videos_tab("UCGiJh0NZ52wRhYKYnuZI08Q", tab) .await; // YouTube removed empty tabs from the menu, so they may return no data match channel { Ok(channel) => assert!(channel.content.is_empty(), "got: {:?}", channel.content), Err(err) => assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ), } } #[rstest] #[tokio::test] async fn channel_age_restriction(rp: RustyPipe) { let id = "UCbfnHqxXs_K3kvaH-WlNlig"; let res = rp.query().channel_videos(&id).await; if let Err(Error::Extraction(ExtractionError::Unavailable { reason, msg })) = res { assert_eq!(reason, UnavailabilityReason::AgeRestricted); assert!(msg.starts_with("Laphroaig Whisky: ")); } else { panic!("invalid res: {res:?}") } let res = rp.query().channel_info(&id).await; if let Err(Error::Extraction(ExtractionError::Unavailable { reason, msg })) = res { assert_eq!(reason, UnavailabilityReason::AgeRestricted); assert!(msg.starts_with("Laphroaig Whisky: ")); } else { panic!("invalid res: {res:?}") } } //#CHANNEL_RSS #[cfg(feature = "rss")] mod channel_rss { use super::*; use time::macros::datetime; #[rstest] #[tokio::test] async fn get_channel_rss(rp: RustyPipe) { let channel = rp .query() .channel_rss("UCHnyfMqiRRG1u-2MsSQLbXA") .await .unwrap(); assert_eq!(channel.id, "UCHnyfMqiRRG1u-2MsSQLbXA"); assert_eq!(channel.name, "Veritasium"); assert_eq!(channel.create_date, datetime!(2010-07-21 7:18:02 +0)); assert!(!channel.videos.is_empty()); } #[rstest] #[tokio::test] async fn get_channel_rss_empty(rp: RustyPipe) { let channel = rp .query() .channel_rss("UCAyFbMjB3qAQSZBj6NCuBSg") .await .unwrap(); assert_eq!(channel.id, "UCAyFbMjB3qAQSZBj6NCuBSg"); assert_eq!(channel.name, "Cheryl Calogero"); assert!(channel.videos.is_empty()); } #[rstest] #[tokio::test] async fn get_channel_rss_not_found(rp: RustyPipe) { let err = rp .query() .channel_rss("UCHnyfMqiRRG1u-2MsSQLbXZ") .await .unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {}", err ); } } //#SEARCH #[rstest] #[tokio::test] async fn search(rp: RustyPipe, unlocalized: bool) { let result = rp .query() .search::("arudino") .await .unwrap(); assert_gteo( result.items.count, if unlocalized { 7000 } else { 150 }, "results", ); if unlocalized { assert_eq!(result.corrected_query.as_deref(), Some("arduino")); } assert_next(result.items, rp.query(), 10, 2, true).await; } #[rstest] #[case::video(search_filter::ItemType::Video)] #[case::channel(search_filter::ItemType::Channel)] #[case::playlist(search_filter::ItemType::Playlist)] #[tokio::test] async fn search_filter_item_type(#[case] item_type: search_filter::ItemType, rp: RustyPipe) { let mut result = rp .query() .search_filter::( "with no videos", &SearchFilter::new().item_type(item_type), ) .await .unwrap(); result.items.extend(rp.query()).await.unwrap(); assert_gte(result.items.items.len(), 20, "items"); result.items.items.iter().for_each(|item| match item { YouTubeItem::Video(_) => { assert_eq!(item_type, search_filter::ItemType::Video); } YouTubeItem::Channel(_) => { assert_eq!(item_type, search_filter::ItemType::Channel); } YouTubeItem::Playlist(_) => { assert_eq!(item_type, search_filter::ItemType::Playlist); } }); } #[rstest] #[tokio::test] async fn search_empty(rp: RustyPipe) { let result = rp .query() .search_filter::( "3gig84hgi34gu8vj34gj489", &search_filter::SearchFilter::new() .feature(search_filter::Feature::IsLive) .feature(search_filter::Feature::Is3d), ) .await .unwrap(); assert!(result.items.is_empty()); } #[rstest] #[case::no_filter(false)] #[case::filter(true)] #[tokio::test] async fn search_sensitive(rp: RustyPipe, #[case] filter: bool) { let q = "suicide"; let result = if filter { rp.query() .search_filter::(q, &search_filter::SearchFilter::new()) .await } else { rp.query().search::(q).await } .unwrap(); assert_gteo(result.items.count, 10_000, "results"); assert_next(result.items, rp.query(), 10, 2, true).await; } #[rstest] #[tokio::test] async fn search_suggestion(rp: RustyPipe) { let result = rp.query().search_suggestion("hunger ga").await.unwrap(); assert!(result.iter().any(|s| s.starts_with("hunger games"))); assert_gte(result.len(), 10, "search suggestions"); } #[rstest] #[tokio::test] async fn search_suggestion_empty(rp: RustyPipe) { let result = rp .query() .search_suggestion("fjew327p4ifjelwfvnewg49") .await .unwrap(); assert!(result.is_empty()); } //#URL RESOLVER #[rstest] #[case("https://www.youtube.com/LinusTechTips", UrlTarget::Channel {id: "UCXuqSBlHAE6Xw-yeJA0Tunw".to_owned()})] #[case("https://www.youtube.com/@AndroidAuthority", UrlTarget::Channel {id: "UCgyqtNWZmIxTx3b6OxTSALw".to_owned()})] #[case("https://www.youtube.com/channel/UC5I2hjZYiW9gZPVkvzM8_Cw", UrlTarget::Channel {id: "UC5I2hjZYiW9gZPVkvzM8_Cw".to_owned()})] #[case("https://www.youtube.com/c", UrlTarget::Channel {id: "UCXE6F2oZzy_6xEXiJiUFo2w".to_owned()})] #[case("https://www.youtube.com/user/MrBeast6000", UrlTarget::Channel {id: "UCX6OQ3DkcsbYNE6H8uQQuVA".to_owned()})] #[case("https://www.youtube.com/watch?v=dQw4w9WgXcQ", UrlTarget::Video {id: "dQw4w9WgXcQ".to_owned(), start_time: 0})] #[case("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=60", UrlTarget::Video {id: "dQw4w9WgXcQ".to_owned(), start_time: 60})] #[case("https://www.youtube.com/playlist?list=PL4lEESSgxM_5O81EvKCmBIm_JT5Q7JeaI", UrlTarget::Playlist {id: "PL4lEESSgxM_5O81EvKCmBIm_JT5Q7JeaI".to_owned()})] #[case("https://www.youtube.com/playlist?list=RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk", UrlTarget::Playlist {id: "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk".to_owned()})] #[case("https://youtu.be/dQw4w9WgXcQ", UrlTarget::Video {id: "dQw4w9WgXcQ".to_owned(), start_time: 0})] #[case("https://youtu.be/dQw4w9WgXcQ?t=60", UrlTarget::Video {id: "dQw4w9WgXcQ".to_owned(), start_time: 60})] #[case("https://youtu.be/dQw4w9WgXcQ", UrlTarget::Video {id: "dQw4w9WgXcQ".to_owned(), start_time: 0})] #[case("https://youtu.be/dQw4w9WgXcQ?t=60", UrlTarget::Video {id: "dQw4w9WgXcQ".to_owned(), start_time: 60})] #[case("https://piped.mha.fi/watch?v=dQw4w9WgXcQ", UrlTarget::Video {id: "dQw4w9WgXcQ".to_owned(), start_time: 0})] // Both a video ID and a channel name => returns channel #[case("https://piped.mha.fi/dQw4w9WgXcQ", UrlTarget::Channel {id: "UCoG6BrhgmivrkcbEHcYtK4Q".to_owned()})] // Both a video ID and a channel name + video time param => returns video #[case("https://piped.mha.fi/dQw4w9WgXcQ?t=0", UrlTarget::Video {id: "dQw4w9WgXcQ".to_owned(), start_time: 0})] #[case("https://music.youtube.com/playlist?list=OLAK5uy_k0yFrZlFRgCf3rLPza-lkRmCrtLPbK9pE", UrlTarget::Album {id: "MPREb_GyH43gCvdM5".to_owned()})] #[case("https://music.youtube.com/browse/MPREb_GyH43gCvdM5", UrlTarget::Album {id: "MPREb_GyH43gCvdM5".to_owned()})] #[case("https://music.youtube.com/browse/UC5I2hjZYiW9gZPVkvzM8_Cw", UrlTarget::Channel {id: "UC5I2hjZYiW9gZPVkvzM8_Cw".to_owned()})] #[case("https://music.youtube.com/browse/MPADUC7cl4MmM6ZZ2TcFyMk_b4pg", UrlTarget::Channel {id: "UC7cl4MmM6ZZ2TcFyMk_b4pg".to_owned()})] // Music album playlist URL from regular YouTube site (redirects to music album) #[case("https://music.youtube.com/playlist?list=OLAK5uy_noT8bq6-DUEJ5KsdX1D4-wWcYtjiuYEnU", UrlTarget::Album {id: "MPREb_5CPCpzS3imM".to_owned()})] #[tokio::test] async fn resolve_url(#[case] url: &str, #[case] expect: UrlTarget, rp: RustyPipe) { let target = rp.query().resolve_url(url, true).await.unwrap(); assert_eq!(target, expect); } #[rstest] #[case("LinusTechTips", UrlTarget::Channel {id: "UCXuqSBlHAE6Xw-yeJA0Tunw".to_owned()})] #[case("@AndroidAuthority", UrlTarget::Channel {id: "UCgyqtNWZmIxTx3b6OxTSALw".to_owned()})] #[case("UC5I2hjZYiW9gZPVkvzM8_Cw", UrlTarget::Channel {id: "UC5I2hjZYiW9gZPVkvzM8_Cw".to_owned()})] #[case("c", UrlTarget::Channel {id: "UCXE6F2oZzy_6xEXiJiUFo2w".to_owned()})] #[case("user/MrBeast6000", UrlTarget::Channel {id: "UCX6OQ3DkcsbYNE6H8uQQuVA".to_owned()})] #[case("@AndroidAuthority", UrlTarget::Channel {id: "UCgyqtNWZmIxTx3b6OxTSALw".to_owned()})] #[case("dQw4w9WgXcQ", UrlTarget::Video {id: "dQw4w9WgXcQ".to_owned(), start_time: 0})] #[case("PL4lEESSgxM_5O81EvKCmBIm_JT5Q7JeaI", UrlTarget::Playlist {id: "PL4lEESSgxM_5O81EvKCmBIm_JT5Q7JeaI".to_owned()})] #[case("RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk", UrlTarget::Playlist {id: "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk".to_owned()})] #[case("OLAK5uy_k0yFrZlFRgCf3rLPza-lkRmCrtLPbK9pE", UrlTarget::Album {id: "MPREb_GyH43gCvdM5".to_owned()})] #[case("MPREb_GyH43gCvdM5", UrlTarget::Album {id: "MPREb_GyH43gCvdM5".to_owned()})] #[case("MPADUC7cl4MmM6ZZ2TcFyMk_b4pg", UrlTarget::Channel {id: "UC7cl4MmM6ZZ2TcFyMk_b4pg".to_owned()})] #[tokio::test] async fn resolve_string(#[case] string: &str, #[case] expect: UrlTarget, rp: RustyPipe) { let target = rp.query().resolve_string(string, true).await.unwrap(); assert_eq!(target, expect); } #[rstest] #[tokio::test] async fn resolve_channel_not_found(rp: RustyPipe) { let err = rp .query() .resolve_url("https://www.youtube.com/feeqegnhq3rkwghjq43ruih43io3", true) .await .unwrap_err(); assert!(matches!( err, Error::Extraction(ExtractionError::NotFound { .. }) )); } //#TRENDS #[rstest] #[tokio::test] async fn trending(rp: RustyPipe) { let result = rp.query().trending().await.unwrap(); assert_gte(result.len(), 40, "items"); } //#MUSIC #[rstest] #[case::long( "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ", "Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2020 2022", true, None, Some(("UCIekuFeMaV78xYfvpmoCnPg", "Best Music")), false, )] #[case::short( "RDCLAK5uy_nLNY4ReQKH2kx5U23cyGMHql9ciHD9RSM", "Presenting BLACKPINK (블랙핑크)", false, Some("The most played hits and essential tracks. #blackpink #best #kpop".to_owned()), None, true )] #[case::nomusic( "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe", "Minecraft SHINE", false, Some("SHINE - Survival Hardcore in New Environment: Auf einem Server machen sich tapfere Spieler auf, mystische Welten zu erkunden, magische Technologien zu erforschen und vorallem zu überleben...".to_owned()), Some(("UCQM0bS4_04-Y4JuYrgmnpZQ", "Chaosflo44")), false, )] #[tokio::test] async fn music_playlist( #[case] id: &str, #[case] name: &str, #[case] is_long: bool, #[case] description: Option, #[case] channel: Option<(&str, &str)>, #[case] from_ytm: bool, rp: RustyPipe, unlocalized: bool, ) { let playlist = rp.query().music_playlist(id).await.unwrap(); assert_eq!(playlist.id, id); assert!(!playlist.tracks.is_empty()); assert_eq!(!playlist.tracks.is_exhausted(), is_long); assert_gteo( playlist.track_count, if is_long { 100 } else { 10 }, "track count", ); if unlocalized { assert_eq!(playlist.name, name); assert_eq!(playlist.description.map(|d| d.to_plaintext()), description); } assert_eq!( playlist.from_ytm, from_ytm, "got channel: {:?}", playlist.channel ); if let Some(expect) = channel { let c = playlist.channel.expect("channel"); assert_eq!(c.id, expect.0); assert_eq!(c.name, expect.1); } assert!(!playlist.thumbnail.is_empty()); } #[rstest] #[case::user("PLZN_exA7d4RVmCQrG5VlWIjMOkMFZVVOc")] #[case::ytm("RDCLAK5uy_kb7EBi6y3GrtJri4_ZH56Ms786DFEimbM")] #[tokio::test] async fn music_playlist_cont(#[case] id: &str, rp: RustyPipe) { let mut playlist = rp.query().music_playlist(id).await.unwrap(); playlist.tracks.extend_pages(rp.query(), 5).await.unwrap(); check_duplicates(&playlist.tracks.items); let track_count = playlist.track_count.unwrap(); assert_gte(track_count, 90, "tracks"); assert_eq!(track_count, playlist.tracks.count.unwrap()); assert_gte( usize::try_from(track_count).unwrap(), playlist.tracks.items.len(), "tracks", ); } #[rstest] #[tokio::test] async fn music_playlist_related(rp: RustyPipe) { let mut playlist = rp .query() .music_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi") .await .unwrap(); playlist.related_playlists.extend(rp.query()).await.unwrap(); assert_gte( playlist.related_playlists.items.len(), 10, "related playlists", ); } #[rstest] #[tokio::test] async fn music_playlist_not_found(rp: RustyPipe) { let err = rp .query() .music_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qz") .await .unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } #[rstest] #[case::one_artist("one_artist", "MPREb_rMegpebUBPU")] #[case::various_artists("various_artists", "MPREb_8QkDeEIawvX")] #[case::single("single", "MPREb_bHfHGoy7vuv")] #[case::ep("ep", "MPREb_u1I69lSAe5v")] #[case::audiobook("audiobook", "MPREb_gaoNzsQHedo")] #[case::show("show", "MPREb_aDDw2kVEFtM")] #[case::unavailable("unavailable", "MPREb_AzuWg8qAVVl")] #[case::no_year("no_year", "MPREb_F3Af9UZZVxX")] #[case::no_artist("no_artist", "MPREb_Z81wHtF9fhC")] #[tokio::test] async fn music_album(#[case] name: &str, #[case] id: &str, rp: RustyPipe, unlocalized: bool) { let album = rp.query().music_album(id).await.unwrap(); assert!(!album.cover.is_empty(), "got no cover"); if unlocalized { insta::assert_ron_snapshot!(format!("music_album_{name}"), album, {".cover" => "[cover]", ".tracks[].view_count" => "[view_count]"} ); } else { insta::assert_ron_snapshot!(format!("music_album_{name}_intl"), album, { ".name" => "[name]", ".cover" => "[cover]", ".description" => "[description]", ".artists[].name" => "[name]", ".tracks[].name" => "[name]", ".tracks[].view_count" => "[view_count]", ".tracks[].album.name" => "[name]", ".tracks[].artists[].name" => "[name]", ".variants[].artists[].name" => "[name]", } ); } } #[rstest] #[tokio::test] async fn music_album_not_found(rp: RustyPipe) { let err = rp .query() .music_album("MPREb_nlBWQROfvjoz") .await .unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } #[rstest] #[case::basic_all("basic_all", "UCFKUUtHjT4iq3p0JJA13SOA", true, 15, 1)] #[case::basic("basic", "UC7cl4MmM6ZZ2TcFyMk_b4pg", false, 15, 0)] #[case::no_more_albums("no_more_albums", "UCOR4_bSVIXPsGa4BbCSt60Q", true, 15, 0)] #[case::only_singles("only_singles", "UCfwCE5VhPMGxNPFxtVv7lRw", false, 13, 0)] #[case::no_artist("no_artist", "UCh8gHdtzO2tXd593_bjErWg", false, 0, 0)] // querying Trailerpark's secondary YouTube channel should result in the YTM channel being fetched #[case::secondary_channel("no_more_albums", "UCC9192yGQD25eBZgFZ84MPw", true, 15, 0)] #[tokio::test] async fn music_artist( #[case] name: &str, #[case] id: &str, #[case] all_albums: bool, #[case] min_tracks: usize, #[case] min_playlists: usize, rp: RustyPipe, unlocalized: bool, ) { let mut artist = rp.query().music_artist(id, all_albums).await.unwrap(); assert_gte(artist.tracks.len(), min_tracks, "tracks"); assert_gte(artist.playlists.len(), min_playlists, "playlists"); if name == "no_artist" { assert!(artist.similar_artists.is_empty()); assert!(artist.subscriber_count.is_none()); } else { assert_gteo(artist.subscriber_count, 10_000, "subscribers"); } artist.tracks.iter().for_each(|t| { assert!(!t.cover.is_empty()); if t.track_type.is_video() { assert!(t.view_count.is_some()); } else { assert!(t.album.is_some()); } }); // Check images assert!(!artist.header_image.is_empty(), "got no header image"); artist .albums .iter() .for_each(|t| assert!(!t.cover.is_empty())); artist .playlists .iter() .for_each(|t| assert!(!t.thumbnail.is_empty())); artist .similar_artists .iter() .for_each(|t| assert!(!t.avatar.is_empty())); // Sort albums to ensure consistent order artist.albums.sort_by_key(|a| a.id.clone()); if unlocalized { insta::assert_ron_snapshot!(format!("music_artist_{name}"), artist, { ".header_image" => "[header_image]", ".subscriber_count" => "[subscriber_count]", ".albums[].cover" => "[cover]", ".tracks" => "[tracks]", ".playlists" => "[playlists]", ".similar_artists" => "[artists]", }); } else { insta::assert_ron_snapshot!(format!("music_artist_{name}_intl"), artist, { ".name" => "[name]", ".header_image" => "[header_image]", ".description" => "[description]", ".wikipedia_url" => "[wikipedia_url]", ".subscriber_count" => "[subscriber_count]", ".albums[].name" => "[name]", ".albums[].cover" => "[cover]", ".albums[].artists[].name" => "[name]", ".tracks" => "[tracks]", ".playlists" => "[playlists]", ".similar_artists" => "[artists]", }); } // Fetch albums seperately if name != "no_artist" { let albums = rp .query() .music_artist_albums(id, None, Some(AlbumOrder::Recency)) .await .unwrap(); let albums_expect = artist .albums .iter() .map(|a| a.id.to_owned()) .collect::>(); let albums_got = albums .iter() .map(|a| a.id.to_owned()) .collect::>(); if all_albums { assert_eq!(albums_got, albums_expect); } else { assert!(albums_expect.is_subset(&albums_expect)); } } } #[rstest] #[tokio::test] async fn music_artist_albums_recency(rp: RustyPipe) { let albums = rp .query() .music_artist_albums("UCPC0L1d253x-KuMNwa05TpA", None, Some(AlbumOrder::Recency)) .await .unwrap(); assert_gte(albums.len(), 110, "albums"); let mut latest_items = albums.iter().peekable(); while let (Some(b), Some(next_b)) = (latest_items.next(), latest_items.peek()) { assert_gte( b.year.expect("year"), next_b.year.expect("year"), "latest album year", ); } } #[rstest] #[tokio::test] async fn music_artist_not_found(rp: RustyPipe) { let err = rp .query() .music_artist("UC7cl4MmM6ZZ2TcFyMk_b4pq", false) .await .unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } #[rstest] #[tokio::test] async fn music_artist_albums_not_found(rp: RustyPipe) { let err = rp .query() .music_artist_albums("UC7cl4MmM6ZZ2TcFyMk_b4pq", None, None) .await .unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } #[rstest] #[case::default(false)] #[case::typo(true)] #[tokio::test] async fn music_search_main(#[case] typo: bool, rp: RustyPipe, unlocalized: bool) { let res = rp .query() .music_search_main(match typo { false => "lieblingsmensch namika", true => "lieblingsmesch namika", }) .await .unwrap(); let items = res.items.items; check_search_result(&items); if typo { if unlocalized { assert_eq!( res.corrected_query.as_deref(), Some("lieblingsmensch namika") ); } } else { assert_eq!(res.corrected_query, None); } let track = items .iter() .find_map(|itm| { if let MusicItem::Track(track) = itm { if track.id == "6485PhOtHzY" { Some(track) } else { None } } else { None } }) .unwrap_or_else(|| { panic!("could not find track, got {:#?}", &items); }); assert_eq!(track.name, "Lieblingsmensch"); assert_eq!(track.duration, Some(191)); assert!(!track.cover.is_empty(), "got no cover"); assert_eq!(track.artists.len(), 1); let track_artist = &track.artists[0]; assert_eq!(track_artist.id.as_deref(), Some("UCIh4j8fXWf2U0ro0qnGU8Mg")); if unlocalized { assert_eq!(track_artist.name, "Namika"); } let track_album = track.album.as_ref().expect("track_album"); assert_eq!(track_album.id, "MPREb_RXHxrUFfrvQ"); assert_eq!(track_album.name, "Lieblingsmensch"); assert_eq!(track.track_type, TrackType::Track); assert_eq!(track.track_nr, None); } #[rstest] #[tokio::test] async fn music_search_main2(rp: RustyPipe, unlocalized: bool) { let res = rp.query().music_search_main("taylor swift").await.unwrap(); let items = res.items.items; check_search_result(&items); let artist = items .iter() .find_map(|itm| { if let MusicItem::Artist(artist) = itm { if artist.id == "UCPC0L1d253x-KuMNwa05TpA" { Some(artist) } else { None } } else { None } }) .unwrap_or_else(|| { panic!("could not find artist, got {:#?}", &items); }); if unlocalized { assert_eq!(artist.name, "Taylor Swift"); } assert!(!artist.avatar.is_empty(), "got no avatar"); } fn check_search_result(items: &[MusicItem]) { assert_gte(items.len(), 10, "search results"); let mut has_tracks = false; let mut has_videos = false; let mut has_albums = false; let mut has_artists = false; let mut has_playlists = false; let mut has_users = false; for itm in items { match itm { MusicItem::Track(t) => { if t.track_type == TrackType::Video { has_videos = true } else if t.track_type == TrackType::Track { has_tracks = true } } MusicItem::Album(_) => has_albums = true, MusicItem::Artist(_) => has_artists = true, MusicItem::Playlist(_) => has_playlists = true, MusicItem::User(_) => has_users = true, } } assert!(has_tracks, "no tracks"); assert!(has_videos, "no videos"); assert!(has_albums, "no albums"); assert!(has_artists, "no artists"); assert!(has_playlists, "no playlists"); assert!(has_users, "no users"); } #[rstest] #[tokio::test] async fn music_search_tracks(rp: RustyPipe, unlocalized: bool) { let res = rp.query().music_search_tracks("black mamba").await.unwrap(); let track = &res .items .items .iter() .find(|a| a.id == "BL-aIpCLWnU") .unwrap_or_else(|| { panic!("could not find track, got {:#?}", &res.items.items); }); assert_eq!(track.name, "Black Mamba"); assert!(!track.cover.is_empty(), "got no cover"); assert_eq!(track.track_type, TrackType::Track); assert_eq!(track.track_nr, None); assert_eq!(track.artists.len(), 1); let track_artist = &track.artists[0]; assert_eq!(track_artist.id.as_deref(), Some("UCEdZAdnnKqbaHOlv8nM6OtA")); if unlocalized { assert_eq!(track_artist.name, "aespa"); } assert_eq!(track.duration, Some(175)); let album = track.album.as_ref().expect("track_album"); assert_eq!(album.id, "MPREb_OpHWHwyNOuY"); assert_eq!(album.name, "Black Mamba"); assert_next(res.items, rp.query(), 15, 2, true).await; } #[rstest] #[tokio::test] async fn music_search_videos(rp: RustyPipe, unlocalized: bool) { let res = rp.query().music_search_videos("black mamba").await.unwrap(); let track = &res .items .items .iter() .find(|a| a.id == "ZeerrnuLi5E") .unwrap_or_else(|| { panic!("could not find video, got {:#?}", &res.items.items); }); assert_eq!(track.name, "Black Mamba"); assert!(!track.cover.is_empty(), "got no cover"); assert_eq!(track.track_type, TrackType::Video); assert_eq!(track.track_nr, None); assert_eq!(track.artists.len(), 1); let track_artist = &track.artists[0]; assert_eq!(track_artist.id.as_deref(), Some("UCEdZAdnnKqbaHOlv8nM6OtA")); if unlocalized { assert_eq!(track_artist.name, "aespa"); } assert_eq!(track.duration, Some(230)); assert_eq!(track.album, None); assert_gteo(track.view_count, 230_000_000, "views"); assert_next(res.items, rp.query(), 15, 2, true).await; } #[rstest] #[tokio::test] async fn music_search_episode(rp: RustyPipe) { let query = "Blond - Da muss man dabei gewesen sein: Das Hörspiel - Fall #1"; let track_id = "Zq_-LDy7AgE"; let items = rp .query() .music_search_main(query) .await .unwrap() .items .items; let track = items .iter() .find_map(|itm| { if let MusicItem::Track(track) = itm { if track.id == track_id { Some(track) } else { None } } else { None } }) .cloned() .expect("could not find episode"); assert_eq!(track.artists.len(), 1); let track_artist = &track.artists[0]; assert_eq!( track.name, "Blond - Da muss man dabei gewesen sein: Das Hörspiel - Fall #1" ); assert_eq!(track_artist.name, "Da muss man dabei gewesen sein"); assert_eq!(track_artist.id.as_deref(), None); assert_eq!(track.artist_id.as_deref(), None); assert!(!track.cover.is_empty(), "got no cover"); assert_eq!(track.track_type, TrackType::Episode); } #[rstest] #[case::single( "lea okay", "Okay", "MPREb_3t4vp0Dj8B0", "LEA", "UC_MxOdawj_BStPs4CKBYD0Q", 2020, AlbumType::Single, false )] #[case::ep( "waldbrand", "Waldbrand", "MPREb_u1I69lSAe5v", "Madeline Juno", "UCpJyCbFbdTrx0M90HCNBHFQ", 2016, AlbumType::Ep, false )] #[case::album( "where we come alive", "Where We Come Alive", "MPREb_Rh1zJSc0xPG", "Ruelle", "UCUCg0Z83rDmZUeBAEBlCdCw", 2018, AlbumType::Single, true )] #[tokio::test] async fn music_search_albums( #[case] query: &str, #[case] name: &str, #[case] id: &str, #[case] artist: &str, #[case] artist_id: &str, #[case] year: u16, #[case] album_type: AlbumType, #[case] more: bool, rp: RustyPipe, unlocalized: bool, ) { let res = rp.query().music_search_albums(query).await.unwrap(); let album = &res .items .items .iter() .find(|a| a.id == id) .unwrap_or_else(|| { panic!("could not find album, got {:#?}", &res.items.items); }); assert_eq!(album.name, name); assert_eq!(album.artists.len(), 1); let album_artist = &album.artists[0]; assert_eq!(album_artist.id.as_ref().expect("artist.id"), artist_id); if unlocalized { assert_eq!(album_artist.name, artist); } assert_eq!(album.artist_id.as_ref().expect("artist_id"), artist_id); assert!(!album.cover.is_empty(), "got no cover"); assert_eq!(album.year, Some(year)); assert_eq!(album.album_type, album_type); assert_eq!(res.corrected_query, None); if more && unlocalized { assert_next(res.items, rp.query(), 15, 1, true).await; } } #[rstest] #[tokio::test] async fn music_search_artists(rp: RustyPipe, unlocalized: bool) { let res = rp.query().music_search_artists("namika").await.unwrap(); let artist = res .items .items .iter() .find(|a| a.id == "UCIh4j8fXWf2U0ro0qnGU8Mg") .unwrap_or_else(|| { panic!("could not find artist, got {:#?}", &res.items.items); }); if unlocalized { assert_eq!(artist.name, "Namika"); } assert!(!artist.avatar.is_empty(), "got no avatar"); assert_gteo(artist.subscriber_count, 735_000, "subscribers"); assert_eq!(res.corrected_query, None); } #[rstest] #[tokio::test] async fn music_search_artists_cont(rp: RustyPipe) { let res = rp.query().music_search_artists("band").await.unwrap(); assert_eq!(res.corrected_query, None); assert_next(res.items, rp.query(), 15, 2, true).await; } #[rstest] #[tokio::test] async fn music_search_playlists(rp: RustyPipe, unlocalized: bool) { let res = rp .query() .music_search_playlists("Massive Rock Hits", false) .await .unwrap(); assert_eq!(res.corrected_query, None); let playlist = res .items .items .iter() .find(|p| p.id == "RDCLAK5uy_k7h5535MeHE8xmgHsrZx7HOKH4lb5vAfY") .unwrap_or_else(|| { panic!("could not find playlist, got {:#?}", &res.items.items); }); if unlocalized { assert_eq!(playlist.name, "Massive Rock Hits"); } assert!(!playlist.thumbnail.is_empty(), "got no thumbnail"); assert_gteo(playlist.track_count, 40, "tracks"); assert_eq!(playlist.channel, None); assert!(playlist.from_ytm); assert!( res.items.items.iter().all(|p| p.from_ytm), "community items found" ) } #[rstest] #[tokio::test] async fn music_search_playlists_community(rp: RustyPipe) { let res = rp .query() .music_search_playlists("Miku my beloved (Jaiden Animation Miku Playlist)", true) .await .unwrap(); assert_eq!(res.corrected_query, None); let playlist = res .items .items .iter() .find(|p| p.id == "PLgAAMoX4rK3KhSGmIsN0LEoC3qowEr2Lz") .unwrap_or_else(|| { panic!("could not find playlist, got {:#?}", &res.items.items); }); assert_eq!( playlist.name, "Miku my beloved (Jaiden Animation Miku Playlist)" ); assert!(!playlist.thumbnail.is_empty(), "got no thumbnail"); let channel = playlist.channel.as_ref().unwrap(); assert_eq!(channel.id, "UCsXOMpqp3_ZPOmk-HGKEPRg"); assert_eq!(channel.name, "Beanie Bean"); assert!(!playlist.from_ytm); } #[rstest] #[tokio::test] async fn music_search_users(rp: RustyPipe) { let res = rp .query() .music_search_users("amyprincesspink") .await .unwrap(); assert_eq!(res.corrected_query, None); let user = res .items .items .iter() .find(|u| u.id == "UC-CeCRHc8D47hh8P_9MR5Vg") .unwrap_or_else(|| { panic!("could not find user, got {:#?}", &res.items.items); }); assert_eq!(user.name, "amyprincesspink"); assert_eq!(user.handle.as_deref().unwrap(), "@amyprincesspink"); assert!(!user.avatar.is_empty(), "got no avatar"); } /// The YouTube Music search sometimes shows genre radio items. They should be skipped. #[rstest] #[tokio::test] async fn music_search_genre_radio(rp: RustyPipe) { rp.query().music_search_main("pop radio").await.unwrap(); } #[rstest] #[case::default("ed sheer", Some("ed sheeran"), Some("UClmXPfaYhXOYsNn_QUyheWQ"))] #[case::empty("reujbhevmfndxnjrze", None, None)] #[tokio::test] async fn music_search_suggestion( #[case] query: &str, #[case] term: Option<&str>, #[case] artist: Option<&str>, rp: RustyPipe, ) { let suggestion = rp.query().music_search_suggestion(query).await.unwrap(); match term { Some(expect) => assert!( suggestion.terms.iter().any(|s| s == expect), "suggestion: {suggestion:?}, expected: {expect}" ), None => assert!( suggestion.terms.is_empty(), "suggestion: {suggestion:?}, expected to be empty" ), } if let Some(artist) = artist { assert!(suggestion.items.iter().any(|s| match s { rustypipe::model::MusicItem::Artist(a) => a.id == artist, _ => false, })); } } #[rstest] #[case::mv("mv", "ZeerrnuLi5E")] #[case::track("track", "qIZ-vvg-wiU")] #[case::track_details("track_details", "1eekOcpx_iQ")] #[tokio::test] async fn music_details(#[case] name: &str, #[case] id: &str, rp: RustyPipe) { let track = rp.query().music_details(id).await.unwrap(); assert!(!track.track.cover.is_empty(), "got no cover"); if name == "mv" { assert_gteo(track.track.view_count, 235_000_000, "view count"); } else { assert!(track.track.view_count.is_none()); } insta::assert_ron_snapshot!(format!("music_details_{name}"), track, { ".track.cover" => "[cover]", ".track.view_count" => "[view_count]" } ); } #[rstest] #[tokio::test] async fn music_lyrics(rp: RustyPipe) { let track = rp.query().music_details("60ImQ8DS3Vs").await.unwrap(); let lyrics = rp .query() .music_lyrics(&track.lyrics_id.unwrap()) .await .unwrap(); insta::assert_ron_snapshot!(lyrics.body); assert!( lyrics.footer.contains("Musixmatch"), "footer text: {}", lyrics.footer ) } #[rstest] #[tokio::test] async fn music_lyrics_not_found(rp: RustyPipe) { let track = rp.query().music_details("ekXI8qrbe1s").await.unwrap(); let err = rp .query() .music_lyrics(&track.lyrics_id.unwrap()) .await .unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } #[rstest] #[case::a("7nigXQS1Xb0", true)] #[case::b("4t3SUDZCBaQ", false)] #[tokio::test] async fn music_related(#[case] id: &str, #[case] full: bool, rp: RustyPipe) { let track = rp.query().music_details(id).await.unwrap(); let related = rp .query() .music_related(&track.related_id.unwrap()) .await .unwrap(); let n_tracks = related.tracks.len(); let mut track_artists = 0; let mut track_artist_ids = 0; let mut n_tracks_ytm = 0; let mut track_albums = 0; for track in related.tracks { validate::video_id(&track.id).unwrap(); assert!(!track.name.is_empty()); assert!(!track.cover.is_empty(), "got no cover"); if let Some(artist_id) = track.artist_id { validate::channel_id(&artist_id).unwrap(); track_artist_ids += 1; } let artist = track.artists.first().expect("track_artist"); assert!(!artist.name.is_empty()); if let Some(artist_id) = &artist.id { validate::channel_id(artist_id).unwrap(); track_artists += 1; } if track.track_type.is_video() { assert!(track.album.is_none()); assert_gteo(track.view_count, 10_000, "views") } else { n_tracks_ytm += 1; assert!(track.view_count.is_none()); if let Some(album) = track.album { validate::album_id(&album.id).unwrap(); assert!(!album.name.is_empty()); track_albums += 1; } } } assert_gte(n_tracks, 20, "tracks"); assert_gte(n_tracks_ytm, 10, "tracks_ytm"); assert_gte(track_artists, n_tracks - 4, "track_artists"); assert_gte(track_artist_ids, n_tracks - 4, "track_artists"); assert_gte(track_albums, n_tracks_ytm - 4, "track_artists"); if full { assert_gte(related.albums.len(), 10, "albums"); for album in related.albums { validate::album_id(&album.id).unwrap(); assert!(!album.name.is_empty()); assert!(!album.cover.is_empty(), "got no cover"); let artist = album.artists.first().expect("album artist"); validate::channel_id(artist.id.as_ref().expect("artist id")).unwrap(); assert!(!artist.name.is_empty()); } assert_gte(related.artists.len(), 10, "artists"); for artist in related.artists { validate::channel_id(&artist.id).unwrap(); assert!(!artist.name.is_empty()); assert!(!artist.avatar.is_empty(), "got no avatar"); assert_gteo(artist.subscriber_count, 5000, "subscribers") } assert_gte(related.playlists.len(), 10, "playlists"); for playlist in related.playlists { validate::playlist_id(&playlist.id).unwrap(); assert!(!playlist.name.is_empty()); assert!( !playlist.thumbnail.is_empty(), "pl: {}, got no playlist thumbnail", playlist.id ); if !playlist.from_ytm { assert!( playlist.channel.is_some(), "pl: {}, got no channel", playlist.id ); let channel = playlist.channel.expect("playlist channel"); validate::channel_id(&channel.id).unwrap(); assert!(!channel.name.is_empty()); } else { assert!(playlist.channel.is_none()); } } } } #[rstest] #[tokio::test] async fn music_details_not_found(rp: RustyPipe) { let err = rp.query().music_details("7nigXQS1XbZ").await.unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } #[rstest] #[tokio::test] async fn music_radio_track(rp: RustyPipe) { let tracks = rp.query().music_radio_track("ZeerrnuLi5E").await.unwrap(); assert_next_items(tracks, rp.query(), 20).await; } #[rstest] #[tokio::test] async fn music_radio_track_not_found(rp: RustyPipe) { let err = rp .query() .music_radio_track("7nigXQS1XbZ") .await .unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } #[rstest] #[tokio::test] async fn music_radio_playlist(rp: RustyPipe) { let tracks = rp .query() .music_radio_playlist("PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ") .await .unwrap(); assert_next_items(tracks, rp.query(), 20).await; } #[rstest] #[tokio::test] async fn music_radio_playlist_not_found(rp: RustyPipe) { let res = rp .query() .music_radio_playlist("PL5dDx681T4bR7ZF1IuWzOv1omlZZZZZZZ") .await; if let Err(err) = res { assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } } #[rstest] #[tokio::test] async fn music_radio_artist(rp: RustyPipe) { let tracks = rp .query() .music_radio("RDEM_Ktu-TilkxtLvmc9wX1MLQ") .await .unwrap(); assert_next_items(tracks, rp.query(), 20).await; } #[rstest] #[tokio::test] async fn music_radio_not_found(rp: RustyPipe) { let err = rp.query().music_radio("RDEM_foo").await.unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } #[rstest] #[case::de( Country::De, "PL4fGSI1pDJn4X-OicSCOy-dChXWdTgziQ", "OLAK5uy_nPQBmQpOIYYxuvYW4gIHsjgDqX9kc--Dg" )] #[case::us( Country::Us, "PL4fGSI1pDJn69On1f-8NAvX_CYlx7QyZc", "PLrEnWoR732-DtKgaDdnPkezM_nDidBU9H" )] #[tokio::test] async fn music_charts( #[case] country: Country, #[case] plid_top: &str, #[case] plid_trend: &str, rp: RustyPipe, ) { let charts = rp.query().music_charts(Some(country)).await.unwrap(); assert_eq!(charts.top_playlist_id.expect("top_playlist_id"), plid_top); assert_gte(charts.top_tracks.len(), 30, "top tracks"); assert_gte(charts.artists.len(), 30, "top artists"); // Currently (01.02.2024) is no trending playlist shown for Global and US if country != Country::Us { assert_eq!( charts.trending_playlist_id.expect("trending_playlist_id"), plid_trend ); assert_gte(charts.trending_tracks.len(), 15, "trending tracks"); } // Chart playlists only available in USA if country == Country::Us { assert_gte(charts.playlists.len(), 8, "charts playlists"); } } #[rstest] #[tokio::test] async fn music_new_albums(rp: RustyPipe) { let albums = rp.query().music_new_albums().await.unwrap(); assert_gte(albums.len(), 10, "albums"); for album in albums { validate::album_id(&album.id).unwrap(); assert!(!album.name.is_empty()); assert!(!album.cover.is_empty(), "got no cover"); } } #[rstest] #[tokio::test] async fn music_new_videos(rp: RustyPipe) { let videos = rp.query().music_new_videos().await.unwrap(); assert_gte(videos.len(), 5, "videos"); for video in videos { validate::video_id(&video.id).unwrap(); assert!(!video.name.is_empty()); assert!(!video.cover.is_empty(), "got no cover"); if let Some(view_count) = video.view_count { assert_gte(view_count, 500, "views"); } else { // Podcast episode: shows duration instead of view count assert!(video.duration.is_some(), "no view count or duration"); } assert!(video.track_type.is_video()); } } #[rstest] #[tokio::test] async fn music_genres(rp: RustyPipe, unlocalized: bool) { let genres = rp.query().music_genres().await.unwrap(); let chill = genres .iter() .find(|g| g.id == "ggMPOg1uX1JOQWZFeDByc2Jm") .expect("genre: Chill"); if unlocalized { assert_eq!(chill.name, "Chill"); } assert!(chill.is_mood); let pop = genres .iter() .find(|g| g.id == "ggMPOg1uX1lMbVZmbzl6NlJ3" || g.id == "ggMPOg1uX1BmNzc2V2p0YXJ5") .expect("genre: Pop"); if unlocalized { assert_eq!(pop.name, "Pop"); } assert!(!pop.is_mood); for g in &genres { validate::genre_id(&g.id).unwrap(); assert_gte(g.color, 0xff00_0000, "color"); } } #[rstest] #[case::chill("ggMPOg1uX1JOQWZFeDByc2Jm", "Chill")] #[case::pop("ggMPOg1uX1lMbVZmbzl6NlJ3", "Pop")] #[tokio::test] async fn music_genre(#[case] id: &str, #[case] name: &str, rp: RustyPipe, unlocalized: bool) { let genre = rp.query().music_genre(id).await.unwrap(); fn check_music_genre( genre: MusicGenre, id: &str, name: &str, unlocalized: bool, ) -> Vec<(String, String)> { assert_eq!(genre.id, id); if unlocalized { assert_eq!(genre.name, name); } assert_gte(genre.sections.len(), 2, "genre sections"); let mut subgenres = Vec::new(); genre.sections.iter().for_each(|section| { assert!(!section.name.is_empty()); section.playlists.iter().for_each(|playlist| { validate::playlist_id(&playlist.id).unwrap(); assert!(!playlist.name.is_empty()); assert!(!playlist.thumbnail.is_empty(), "got no cover"); if !playlist.from_ytm { if let Some(channel) = playlist.channel.as_ref() { validate::channel_id(&channel.id).unwrap(); assert!(!channel.name.is_empty()); } } else { assert!(playlist.channel.is_none()); } }); if let Some(subgenre_id) = §ion.subgenre_id { subgenres.push((subgenre_id.clone(), section.name.clone())); } }); subgenres } let subgenres = check_music_genre(genre, id, name, unlocalized); if name == "Chill" { assert_gte(subgenres.len(), 2, "subgenres"); } for (id, name) in subgenres { let genre = rp.query().music_genre(&id).await.unwrap(); check_music_genre(genre, &id, &name, unlocalized); } } #[rstest] #[tokio::test] #[ignore] async fn music_genre_not_found(rp: RustyPipe) { let err = rp .query() .music_genre("ggMPOg1uX1JOQWZFeDByc2zz") .await .unwrap_err(); assert!( matches!(err, Error::Extraction(ExtractionError::NotFound { .. })), "got: {err}" ); } //#MISCELLANEOUS #[rstest] #[case::desktop(ContinuationEndpoint::Browse)] #[case::music(ContinuationEndpoint::MusicBrowse)] #[tokio::test] async fn invalid_ctoken(#[case] ep: ContinuationEndpoint, rp: RustyPipe) { let e = rp .query() .continuation::("Abcd", ep, None) .await .unwrap_err(); match e { Error::Extraction(e) => match e { ExtractionError::BadRequest(msg) => { assert_eq!(msg, "Request contains an invalid argument.") } _ => panic!("invalid error: {e}"), }, _ => panic!("invalid error: {e}"), } } /// YouTube Music allows searching for ISRC codes /// This feature does not seem to work with all languages and it has changed in the past. /// This test is used to check which languages are working #[rstest] #[tokio::test] async fn isrc_search_languages(rp: RustyPipe) { for lang in LANGUAGES { // flaky for English, skipping for now if matches!(lang, Language::En | Language::EnGb | Language::EnIn) { continue; } let tracks = rp .query() .lang(lang) .music_search_tracks("DEUM71602459") .await .unwrap(); let working = tracks.items.items.iter().any(|t| t.id == "g0iRiJ_ck48"); assert!(working, "lang: {lang}"); } } #[rstest] #[tokio::test] async fn user_auth_check_login(rp: RustyPipe, auth_enabled: bool) { if auth_enabled { assert!(rp.query().auth_enabled(ClientType::Tv)); rp.user_auth_check_login().await.unwrap(); } } #[rstest] #[tokio::test] async fn history(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let videos = rp.query().history().await.unwrap(); assert_next_items(videos, rp.query(), 100).await; } #[rstest] #[tokio::test] async fn history_search(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let videos = rp.query().history_search("a").await.unwrap(); assert_next_items(videos, rp.query(), 5).await; } #[rstest] #[tokio::test] async fn subscriptions(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let channels = rp.query().subscriptions().await.unwrap(); assert_next_items(channels, rp.query(), 5).await; } #[rstest] #[tokio::test] async fn subscription_feed(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let videos = rp.query().subscription_feed().await.unwrap(); assert_next_items(videos, rp.query(), 50).await; } #[rstest] #[tokio::test] async fn liked_videos(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let videos = rp.query().liked_videos().await.unwrap(); assert_next_items(videos.videos, rp.query(), 5).await; } #[rstest] #[tokio::test] async fn watch_later(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let videos = rp.query().watch_later().await.unwrap(); assert_next_items(videos.videos, rp.query(), 5).await; } #[rstest] #[tokio::test] async fn music_history(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let tracks = rp.query().music_history().await.unwrap(); assert_next_items(tracks, rp.query(), 150).await; } #[rstest] #[tokio::test] async fn music_saved_artists(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let artists = rp.query().music_saved_artists().await.unwrap(); assert_next_items(artists, rp.query(), 5).await; } #[rstest] #[tokio::test] async fn music_saved_albums(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let albums = rp.query().music_saved_albums().await.unwrap(); assert_next_items(albums, rp.query(), 5).await; } #[rstest] #[tokio::test] async fn music_saved_tracks(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let tracks = rp.query().music_saved_tracks().await.unwrap(); assert_next_items(tracks, rp.query(), 50).await; } #[rstest] #[tokio::test] async fn music_saved_playlists(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let playlists = rp.query().music_saved_playlists().await.unwrap(); assert_next_items(playlists, rp.query(), 5).await; } #[rstest] #[tokio::test] async fn music_liked_tracks(rp: RustyPipe, cookie_auth_enabled: bool) { if !cookie_auth_enabled { return; } let tracks = rp.query().music_liked_tracks().await.unwrap(); assert_next_items(tracks.tracks, rp.query(), 5).await; } //#TESTUTIL /// Get the language setting from the environment variable #[fixture] fn lang() -> Language { std::env::var("YT_LANG") .ok() .map_or(Language::En, |l| Language::from_str(&l).unwrap()) } /// Get a new RustyPipe instance #[fixture] fn rp(lang: Language) -> RustyPipe { let vdata = std::env::var("YT_VDATA").ok(); RustyPipe::builder() .strict() .storage_dir(env!("CARGO_MANIFEST_DIR")) .lang(lang) .visitor_data_opt(vdata) .build() .unwrap() } /// Get a flag signaling if the language is set to English #[fixture] fn unlocalized(lang: Language) -> bool { lang == Language::En } /// Get a flag signaling if an authenticated user is expected #[fixture] fn auth_enabled(rp: RustyPipe) -> bool { std::env::var("YT_AUTHENTICATED") .map(|v| !v.is_empty() && v.to_ascii_lowercase() != "false") .unwrap_or_default() || rp.query().auth_enabled(ClientType::Desktop) || rp.query().auth_enabled(ClientType::Tv) } /// Get a flag signaling if an authenticated user is expected #[fixture] fn cookie_auth_enabled(rp: RustyPipe) -> bool { std::env::var("YT_AUTHENTICATED") .map(|v| !v.is_empty() && v.to_ascii_lowercase() != "false") .unwrap_or_default() || rp.query().auth_enabled(ClientType::Desktop) } /* /// Get a new RustyPipe instance with pre-set visitor data fn rp_visitor_data(vdata: &str) -> RustyPipe { RustyPipe::builder() .strict() .visitor_data(vdata) .build() .unwrap() }*/ /// Assert equality within 10% margin #[track_caller] fn assert_approx, B: Into>(left: A, right: B) { let left = left.into(); let right = right.into(); if left != right { let f = left / right; assert!( 0.9 < f && f < 1.1, "{left} not within 10% margin of {right}" ); } } /// Assert that number A is greater than or equal to number B #[track_caller] fn assert_gte(a: T, b: T, msg: &str) { assert!(a >= b, "expected >= {b} {msg}, got {a}"); } /// Assert that optional number A is greater than or equal to number B #[track_caller] fn assert_gteo(a: Option, b: T, msg: &str) { match a { Some(a) => assert_gte(a, b, msg), None => panic!("expected >= {b} {msg}, got None"), } } /// Assert that the paginator produces at least n pages async fn assert_next>( paginator: Paginator, query: Q, min_items: usize, n_pages: usize, on_first: bool, ) { let mut p = paginator; let query = query.as_ref(); if on_first { assert_gte(p.items.len(), min_items, "items on page 0"); } for i in 0..n_pages { p = p.next(query).await.unwrap().expect("paginator exhausted"); assert_gte( p.items.len(), min_items, &format!("items on page {}", i + 1), ); } } /// Assert that the paginator produces at least n items async fn assert_next_items>( paginator: Paginator, query: Q, n_items: usize, ) { let mut p = paginator; let query = query.as_ref(); p.extend_limit(query, n_items).await.unwrap(); assert_gte(p.items.len(), n_items, "items"); } #[track_caller] fn assert_frameset(frameset: &Frameset) { assert_gte(frameset.frame_height, 20, "frame height"); assert_gte(frameset.frame_height, 20, "frame width"); assert_gte(frameset.page_count, 1, "page count"); assert_gte(frameset.total_count, 50, "total count"); assert_gte(frameset.frames_per_page_x, 3, "frames per page x"); assert_gte(frameset.frames_per_page_y, 3, "frames per page y"); let n = frameset.urls().count() as u32; assert_eq!(n, frameset.page_count); } #[track_caller] fn check_duplicates>(items: &[T]) { let ids = items .iter() .map(|itm| itm.clone().into().id) .collect::>(); assert_eq!(ids.len(), items.len(), "duplicate items"); }