feat: add music playlist

This commit is contained in:
ThetaDev 2022-10-29 19:56:52 +02:00
parent b64aabb6b6
commit 566b3e5bfc
24 changed files with 238892 additions and 61192 deletions

View file

@ -19,7 +19,7 @@ inspired by [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor).
### YouTube Music
- [ ] **Playlist**
- [X] **Playlist**
- [ ] **Album**
- [ ] **Artist**
- [ ] **Search**

View file

@ -35,6 +35,9 @@ pub async fn download_testfiles(project_root: &Path) {
startpage(&testfiles).await;
startpage_cont(&testfiles).await;
trending(&testfiles).await;
music_playlist(&testfiles).await;
music_playlist_cont(&testfiles).await;
}
const CLIENT_TYPES: [ClientType; 5] = [
@ -458,3 +461,40 @@ async fn trending(testfiles: &Path) {
let rp = rp_testfile(&json_path);
rp.query().trending().await.unwrap();
}
async fn music_playlist(testfiles: &Path) {
for (name, id) in [
("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"),
("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ"),
("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"),
] {
let mut json_path = testfiles.to_path_buf();
json_path.push("music_playlist");
json_path.push(format!("playlist_{}.json", name));
if json_path.exists() {
continue;
}
let rp = rp_testfile(&json_path);
rp.query().music_playlist(id).await.unwrap();
}
}
async fn music_playlist_cont(testfiles: &Path) {
let mut json_path = testfiles.to_path_buf();
json_path.push("music_playlist");
json_path.push("playlist_cont.json");
if json_path.exists() {
return;
}
let rp = RustyPipe::new();
let playlist = rp
.query()
.music_playlist("PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")
.await
.unwrap();
let rp = rp_testfile(&json_path);
playlist.tracks.next(&rp.query()).await.unwrap().unwrap();
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@
pub(crate) mod response;
mod channel;
mod music_playlist;
mod pagination;
mod player;
mod playlist;

View file

@ -0,0 +1,207 @@
use std::borrow::Cow;
use crate::{
error::{Error, ExtractionError},
model::{ChannelId, MusicPlaylist, Paginator, TrackItem},
serializer::MapResult,
util::{self, TryRemove},
};
use super::{
response::{self, music_item::MusicListMapper},
ClientType, MapResponse, QBrowse, QContinuation, RustyPipeQuery,
};
impl RustyPipeQuery {
pub async fn music_playlist(&self, playlist_id: &str) -> Result<MusicPlaylist, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse {
context,
browse_id: "VL".to_owned() + playlist_id,
};
self.execute_request::<response::MusicPlaylist, _, _>(
ClientType::DesktopMusic,
"music_playlist",
playlist_id,
"browse",
&request_body,
)
.await
}
pub async fn music_playlist_continuation(
&self,
ctoken: &str,
) -> Result<Paginator<TrackItem>, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QContinuation {
context,
continuation: ctoken,
};
self.execute_request::<response::MusicPlaylistCont, _, _>(
ClientType::DesktopMusic,
"music_playlist_continuation",
ctoken,
"browse",
&request_body,
)
.await
}
}
impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
fn map_response(
self,
id: &str,
_lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<MusicPlaylist>, ExtractionError> {
// dbg!(&self);
let header = self.header.music_detail_header_renderer;
let mut content = self.contents.single_column_browse_results_renderer.contents;
let mut shelf = content
.try_swap_remove(0)
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
.tab_renderer
.content
.section_list_renderer
.contents
.try_swap_remove(0)
.ok_or(ExtractionError::InvalidData(Cow::Borrowed(
"no sectionListRenderer content",
)))?
.music_shelf_renderer;
let playlist_id = shelf
.playlist_id
.ok_or(ExtractionError::InvalidData(Cow::Borrowed(
"no playlist id",
)))?;
if playlist_id != id {
return Err(ExtractionError::WrongResult(format!(
"got wrong playlist id {}, expected {}",
playlist_id, id
)));
}
let from_ytm = header
.subtitle
.0
.iter()
.any(|c| c.as_str() == "YouTube Music");
let channel = header
.subtitle
.0
.into_iter()
.find_map(|c| ChannelId::try_from(c).ok());
let mut mapper = MusicListMapper::<TrackItem>::new();
mapper.map_response(shelf.contents);
let ctoken = shelf
.continuations
.try_swap_remove(0)
.map(|cont| cont.next_continuation_data.continuation);
let track_count = match ctoken {
Some(_) => header
.second_subtitle
.first()
.and_then(|txt| util::parse_numeric::<u64>(txt).ok()),
None => Some(mapper.items.len() as u64),
};
Ok(MapResult {
c: MusicPlaylist {
id: playlist_id,
name: header.title,
thumbnail: header.thumbnail.into(),
channel,
description: header.description,
track_count,
from_ytm,
tracks: Paginator::new(track_count, mapper.items, ctoken),
},
warnings: mapper.warnings,
})
}
}
impl MapResponse<Paginator<TrackItem>> for response::MusicPlaylistCont {
fn map_response(
self,
_id: &str,
_lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
let mut mapper = MusicListMapper::<TrackItem>::new();
let mut shelf = self.continuation_contents.music_playlist_shelf_continuation;
mapper.map_response(shelf.contents);
let ctoken = shelf
.continuations
.try_swap_remove(0)
.map(|cont| cont.next_continuation_data.continuation);
Ok(MapResult {
c: Paginator::new(None, mapper.items, ctoken),
warnings: mapper.warnings,
})
}
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader, path::Path};
use rstest::rstest;
use super::*;
use crate::param::Language;
#[rstest]
#[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")]
#[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")]
#[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")]
fn map_music_playlist(#[case] name: &str, #[case] id: &str) {
let filename = format!("testfiles/music_playlist/playlist_{}.json", name);
let json_path = Path::new(&filename);
let json_file = File::open(json_path).unwrap();
let playlist: response::MusicPlaylist =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res = playlist.map_response(id, Language::En, None).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_music_playlist_{}", name), map_res.c, {
".last_update" => "[date]"
});
}
#[test]
fn map_music_playlist_cont() {
let json_path = Path::new("testfiles/music_playlist/playlist_cont.json");
let json_file = File::open(json_path).unwrap();
let playlist: response::MusicPlaylistCont =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res = playlist.map_response("", Language::En, None).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!("map_music_playlist_cont", map_res.c);
}
}

View file

@ -1,7 +1,7 @@
use std::borrow::Cow;
use crate::error::{Error, ExtractionError};
use crate::model::{Comment, Paginator, PlaylistVideo, YouTubeItem};
use crate::model::{Comment, Paginator, PlaylistVideo, TrackItem, YouTubeItem};
use crate::param::ContinuationEndpoint;
use crate::serializer::MapResult;
use crate::util::TryRemove;
@ -165,6 +165,15 @@ impl Paginator<PlaylistVideo> {
}
}
impl Paginator<TrackItem> {
pub async fn next(&self, query: &RustyPipeQuery) -> Result<Option<Self>, Error> {
Ok(match &self.ctoken {
Some(ctoken) => Some(query.music_playlist_continuation(ctoken).await?),
None => None,
})
}
}
macro_rules! paginator {
($entity_type:ty) => {
impl Paginator<$entity_type> {
@ -216,6 +225,7 @@ macro_rules! paginator {
paginator!(Comment);
paginator!(PlaylistVideo);
paginator!(TrackItem);
#[cfg(test)]
mod tests {

View file

@ -1,7 +1,8 @@
pub(crate) mod channel;
pub(crate) mod music_item;
pub(crate) mod music_playlist;
pub(crate) mod player;
pub(crate) mod playlist;
// pub(crate) mod playlist_music;
pub(crate) mod search;
pub(crate) mod trends;
pub(crate) mod url_endpoint;
@ -9,10 +10,11 @@ pub(crate) mod video_details;
pub(crate) mod video_item;
pub(crate) use channel::Channel;
pub(crate) use music_playlist::MusicPlaylist;
pub(crate) use music_playlist::MusicPlaylistCont;
pub(crate) use player::Player;
pub(crate) use playlist::Playlist;
pub(crate) use playlist::PlaylistCont;
// pub(crate) use playlist_music::PlaylistMusic;
pub(crate) use search::Search;
pub(crate) use trends::Startpage;
pub(crate) use trends::Trending;
@ -47,7 +49,7 @@ pub(crate) struct ContentsRenderer<T> {
pub contents: Vec<T>,
}
#[derive(Debug, Deserialize)]
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ThumbnailsWrap {
#[serde(default)]
@ -195,61 +197,6 @@ pub(crate) struct RichGridContinuation {
pub contents: MapResult<Vec<YouTubeListItem>>,
}
// YouTube Music
/*
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicItem {
pub thumbnail: MusicThumbnailRenderer,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub playlist_item_data: Option<PlaylistItemData>,
#[serde_as(as = "VecSkipError<_>")]
pub flex_columns: Vec<MusicColumn>,
#[serde_as(as = "VecSkipError<_>")]
pub fixed_columns: Vec<MusicColumn>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicThumbnailRenderer {
#[serde(alias = "croppedSquareThumbnailRenderer")]
pub music_thumbnail_renderer: ThumbnailsWrap,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistItemData {
pub video_id: String,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicContentsRenderer<T> {
pub contents: Vec<T>,
#[serde_as(as = "Option<VecSkipError<_>>")]
pub continuations: Option<Vec<MusicContinuation>>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct MusicColumn {
#[serde(
rename = "musicResponsiveListItemFlexColumnRenderer",
alias = "musicResponsiveListItemFixedColumnRenderer"
)]
pub renderer: MusicColumnRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
pub(crate) struct MusicColumnRenderer {
pub text: TextComponent,
}
*/
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicContinuation {

View file

@ -0,0 +1,220 @@
use serde::Deserialize;
use serde_with::{serde_as, DefaultOnError};
use crate::{
model::{self, ChannelId},
serializer::{text::TextComponents, MapResult},
util::{self, TryRemove},
};
use super::ThumbnailsWrap;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicItem {
pub music_responsive_list_item_renderer: InnerMusicItem,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct InnerMusicItem {
pub thumbnail: MusicThumbnailRenderer,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub playlist_item_data: Option<PlaylistItemData>,
pub flex_columns: Vec<MusicColumn>,
pub fixed_columns: Vec<MusicColumn>,
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicThumbnailRenderer {
#[serde(alias = "croppedSquareThumbnailRenderer")]
pub music_thumbnail_renderer: ThumbnailsWrap,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistItemData {
pub video_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicContentsRenderer<T> {
pub contents: Vec<T>,
/*
/// Continuation token for fetching recommended items
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub continuations: Vec<MusicContinuation>,
*/
}
#[derive(Debug, Deserialize)]
pub(crate) struct MusicColumn {
#[serde(
rename = "musicResponsiveListItemFlexColumnRenderer",
alias = "musicResponsiveListItemFixedColumnRenderer"
)]
pub renderer: MusicColumnRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
pub(crate) struct MusicColumnRenderer {
pub text: TextComponents,
}
impl From<MusicThumbnailRenderer> for Vec<model::Thumbnail> {
fn from(tr: MusicThumbnailRenderer) -> Self {
tr.music_thumbnail_renderer.thumbnail.into()
}
}
/*
#MAPPER
*/
#[derive(Debug)]
pub(crate) struct MusicListMapper<T> {
artists: Option<Vec<ChannelId>>,
pub items: Vec<T>,
pub warnings: Vec<String>,
}
impl<T> MusicListMapper<T> {
pub fn new() -> Self {
Self {
artists: None,
items: Vec::new(),
warnings: Vec::new(),
}
}
pub fn with_artists(artists: Vec<ChannelId>) -> Self {
Self {
artists: Some(artists),
items: Vec::new(),
warnings: Vec::new(),
}
}
fn map_music_item(&mut self, item: MusicItem) -> Option<model::YouTubeMusicItem> {
let item = item.music_responsive_list_item_renderer;
let first_tn = item
.thumbnail
.music_thumbnail_renderer
.thumbnail
.thumbnails
.first();
let id = some_or_bail!(
item.playlist_item_data
.map(|d| d.video_id)
.or_else(|| first_tn.and_then(|tn| util::video_id_from_thumbnail_url(&tn.url))),
None
);
let is_video = !first_tn.map(|tn| tn.height == tn.width).unwrap_or_default();
let duration = item.fixed_columns.first().and_then(|col| {
col.renderer
.text
.0
.first()
.and_then(|txt| util::parse_video_length(txt.as_str()))
});
let mut columns = item.flex_columns;
let album = columns.try_swap_remove(2).and_then(|col| {
col.renderer
.text
.0
.into_iter()
.find_map(|c| model::AlbumId::try_from(c).ok())
});
let artists_col = columns.try_swap_remove(1);
let artists_txt = artists_col
.as_ref()
.map(|col| col.renderer.text.to_string());
let mut artists = artists_col
.map(|col| {
col.renderer
.text
.0
.into_iter()
.filter_map(|c| ChannelId::try_from(c).ok())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let Some(a) = &self.artists {
if artists.is_empty() {
artists = a.clone();
}
}
let title = columns
.try_swap_remove(0)
.map(|col| col.renderer.text.to_string());
match (title, duration) {
(Some(title), Some(duration)) => {
Some(model::YouTubeMusicItem::Track(model::TrackItem {
id,
title,
duration,
cover: item.thumbnail.into(),
artists,
artists_txt,
album,
view_count: None,
is_video,
}))
}
(None, _) => {
self.warnings
.push(format!("track {}: could not get title", id));
None
}
(_, None) => {
self.warnings
.push(format!("track {}: could not parse duration", id));
None
}
}
}
}
/*
impl MusicListMapper<model::YouTubeMusicItem> {
fn map_item(&mut self, item: MusicItem) {
if let Some(mapped) = self.map_music_item(item) {
self.items.push(mapped);
}
}
pub(crate) fn map_response(&mut self, mut res: MapResult<Vec<MusicItem>>) {
self.warnings.append(&mut res.warnings);
res.c.into_iter().for_each(|item| self.map_item(item));
}
}
*/
impl MusicListMapper<model::TrackItem> {
fn map_item(&mut self, item: MusicItem) {
if let Some(model::YouTubeMusicItem::Track(track)) = self.map_music_item(item) {
self.items.push(track);
}
}
pub(crate) fn map_response(&mut self, mut res: MapResult<Vec<MusicItem>>) {
self.warnings.append(&mut res.warnings);
res.c.into_iter().for_each(|item| self.map_item(item));
}
}

View file

@ -2,20 +2,27 @@ use serde::Deserialize;
use serde_with::serde_as;
use serde_with::VecSkipError;
use crate::serializer::text::Text;
use super::MusicThumbnailRenderer;
use super::{
ContentRenderer, ContentsRenderer, MusicContentsRenderer, MusicContinuation, MusicItem,
use crate::serializer::{
text::{Text, TextComponents},
MapResult, VecLogError,
};
use super::music_item::{MusicContentsRenderer, MusicItem, MusicThumbnailRenderer};
use super::{ContentRenderer, ContentsRenderer, MusicContinuation};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistMusic {
pub(crate) struct MusicPlaylist {
pub contents: Contents,
pub header: Header,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicPlaylistCont {
pub continuation_contents: ContinuationContents,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Contents {
@ -48,17 +55,12 @@ pub(crate) struct ItemSection {
pub(crate) struct MusicShelf {
/// Playlist ID (only for playlists)
pub playlist_id: Option<String>,
#[serde_as(as = "VecSkipError<_>")]
pub contents: Vec<PlaylistMusicItem>,
#[serde_as(as = "VecLogError<_>")]
pub contents: MapResult<Vec<MusicItem>>,
/// Continuation token for fetching more (>100) playlist items
#[serde_as(as = "Option<VecSkipError<_>>")]
pub continuations: Option<Vec<MusicContinuation>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistMusicItem {
pub music_responsive_list_item_renderer: MusicItem,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub continuations: Vec<MusicContinuation>,
}
#[derive(Debug, Deserialize)]
@ -71,7 +73,7 @@ pub(crate) struct Header {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HeaderRenderer {
#[serde_as(as = "crate::serializer::text::Text")]
#[serde_as(as = "Text")]
pub title: String,
/// Content type + Channel/Artist + Year.
/// Missing on artist_tracks view.
@ -79,17 +81,27 @@ pub(crate) struct HeaderRenderer {
/// `"Playlist", " • ", <"Best Music">, " • ", "2022"`
///
/// `"Album", " • ", <"Helene Fischer">, " • ", "2021"`
pub subtitle: Option<Text>,
#[serde(default)]
pub subtitle: TextComponents,
/// Playlist description. May contain hashtags which are
/// displayed as search links on the YouTube website.
#[serde_as(as = "Option<crate::serializer::text::Text>")]
#[serde_as(as = "Option<Text>")]
pub description: Option<String>,
/// Playlist thumbnail / album cover.
/// Missing on artist_tracks view.
pub thumbnail: Option<MusicThumbnailRenderer>,
#[serde(default)]
pub thumbnail: MusicThumbnailRenderer,
/// Number of tracks + playtime.
/// Missing on artist_tracks view.
///
/// `"64 songs", " • ", "3 hours, 40 minutes"`
pub second_subtitle: Option<Text>,
#[serde(default)]
#[serde_as(as = "Text")]
pub second_subtitle: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationContents {
pub music_playlist_shelf_continuation: MusicShelf,
}

View file

@ -25,8 +25,11 @@ use self::richtext::RichText;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct Thumbnail {
/// Thumbnail URL
pub url: String,
/// Thumbnail image width
pub width: u32,
/// Thumbnail image height
pub height: u32,
}
@ -590,7 +593,7 @@ pub struct ChannelTag {
}
/*
@COMMENTS
#COMMENTS
*/
/// Verification status of a channel
@ -840,7 +843,7 @@ pub struct ChannelItem {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct PlaylistItem {
/// Unique YouTube Playlist-ID (e.g. `PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ`)
/// Unique YouTube playlist ID (e.g. `PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ`)
pub id: String,
/// Playlist name
pub name: String,
@ -851,3 +854,164 @@ pub struct PlaylistItem {
/// Number of playlist videos
pub video_count: Option<u64>,
}
/*
#MUSIC
*/
/// YouTube Music list item
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum YouTubeMusicItem {
Track(TrackItem),
Artist(ArtistItem),
Album(AlbumItem),
Playlist(MusicPlaylistItem),
}
/// YouTube Music track list item
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct TrackItem {
/// Unique YouTube video ID
pub id: String,
/// Track title
pub title: String,
/// Track duration in seconds
pub duration: u32,
/// Album cover
pub cover: Vec<Thumbnail>,
/// Artists of the track
///
/// **Note:** this field only contains artists that have a link attached
/// to them. You may want to use `artists_txt` as a fallback.
pub artists: Vec<ChannelId>,
/// Full content of the artists column
///
/// Conjunction words/characters depend on language and fetched page.
/// Includes unlinked artists.
pub artists_txt: Option<String>,
/// Album of the track
pub album: Option<AlbumId>,
/// View count
///
/// [`None`] if it is a not a video or the view count could not be extracted.
pub view_count: Option<u64>,
/// True if the track is a music video
pub is_video: bool,
}
/// YouTube Music artist list item
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct ArtistItem {
/// Unique YouTube channel ID
pub id: String,
/// Artist name
pub name: String,
/// Artist avatar/profile picture
pub avatar: Vec<Thumbnail>,
/// Approximate number of subscribers
///
/// [`None`] if hidden by the owner or not present.
pub subscriber_count: Option<u64>,
}
/// YouTube Music album list item
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct AlbumItem {
/// Unique YouTube album ID (e.g. `OLAK5uy_nZpcQys48R0aNb046hV-n1OAHGE4reftQ`)
pub id: String,
/// Album name
pub name: String,
/// Album cover
pub cover: Vec<Thumbnail>,
/// Artists of the album
pub artists: Vec<ChannelId>,
/// Release year of the album
pub year: u16,
}
/// YouTube Music playlist list item
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct MusicPlaylistItem {
/// Unique YouTube playlist ID (e.g. `PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ`)
pub id: String,
/// Playlist name
pub name: String,
/// Playlist thumbnail
pub thumbnail: Vec<Thumbnail>,
/// Channel of the playlist
pub channel: Option<ChannelTag>,
/// Number of tracks in the playlist
pub track_count: Option<u64>,
/// True if the playlist is from YouTube Music
pub from_ytm: bool,
}
/// YouTube Music album type
#[derive(Default, Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AlbumType {
/// Regular album (default)
#[default]
Album,
/// Extended play
Ep,
/// Single
Single,
}
/// Album identifier
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct AlbumId {
/// Unique YouTube album ID (e.g. `MPREb_O2gXCdCVGsZ`)
pub id: String,
/// Album name
pub name: String,
}
/// YouTube music playlist object
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct MusicPlaylist {
/// Unique YouTube playlist ID (e.g. `PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ`)
pub id: String,
/// Playlist/album name
pub name: String,
/// Playlist thumbnail
pub thumbnail: Vec<Thumbnail>,
/// Channel of the playlist
pub channel: Option<ChannelId>,
/// Playlist description in plaintext format
pub description: Option<String>,
/// Number of tracks in the playlist
pub track_count: Option<u64>,
/// True if the playlist is from YouTube Music
pub from_ytm: bool,
/// Playlist tracks
pub tracks: Paginator<TrackItem>,
}
/// YouTube music album object
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct MusicAlbum {
/// Unique YouTube album ID (e.g. `MPREb_O2gXCdCVGsZ`)
pub id: String,
/// Unique YouTube playlist ID (e.g. `OLAK5uy_nZpcQys48R0aNb046hV-n1OAHGE4reftQ`)
pub playlist_id: Option<String>,
/// Album name
pub name: String,
/// Album cover
pub cover: Vec<Thumbnail>,
/// Artists of the album
pub artists: Vec<ChannelId>,
/// Music album type
pub album_type: AlbumType,
/// Release year
pub year: u16,
/// Album tracks
pub tracks: Vec<TrackItem>,
}

View file

@ -113,6 +113,7 @@ pub(crate) enum TextComponent {
/// runs aka components, which can be simple strings or links.
#[derive(Deserialize)]
struct RichTextInternal {
#[serde(default)]
runs: Vec<RichTextRun>,
}
@ -295,7 +296,7 @@ impl TryFrom<TextComponent> for crate::model::ChannelId {
page_type,
browse_id,
} => match page_type {
PageType::Channel => Ok(crate::model::ChannelId {
PageType::Channel | PageType::Artist => Ok(crate::model::ChannelId {
id: browse_id,
name: text,
}),
@ -306,6 +307,24 @@ impl TryFrom<TextComponent> for crate::model::ChannelId {
}
}
impl TryFrom<TextComponent> for crate::model::AlbumId {
type Error = ();
fn try_from(value: TextComponent) -> Result<Self, Self::Error> {
match value {
TextComponent::Browse {
text,
page_type: PageType::Album,
browse_id,
} => Ok(Self {
id: browse_id,
name: text,
}),
_ => Err(()),
}
}
}
impl From<TextComponent> for crate::model::richtext::TextComponent {
fn from(component: TextComponent) -> Self {
match component {
@ -343,6 +362,23 @@ impl From<TextComponents> for crate::model::richtext::RichText {
}
}
impl TextComponent {
pub fn as_str(&self) -> &str {
match self {
TextComponent::Video { text, .. } => text,
TextComponent::Browse { text, .. } => text,
TextComponent::Web { text, .. } => text,
TextComponent::Text { text } => text,
}
}
}
impl ToString for TextComponents {
fn to_string(&self) -> String {
self.0.iter().map(|x| x.as_str()).collect::<String>()
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AccessibilityText {
@ -669,6 +705,14 @@ mod tests {
"###);
}
#[test]
fn t_links_empty() {
let test_json = r#"{"ln": {}}"#;
let res = serde_json::from_str::<SLinks>(&test_json).unwrap();
assert!(res.ln.0.is_empty())
}
#[test]
fn t_attributed_description() {
let test_json = r#"{

View file

@ -330,6 +330,16 @@ pub fn escape_html(input: &str) -> String {
buf
}
pub fn video_id_from_thumbnail_url(url: &str) -> Option<String> {
static URL_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^https://i.ytimg.com/vi/([A-Za-z0-9_-]{11})/").unwrap());
URL_REGEX
.captures(url)
.ok()
.flatten()
.and_then(|cap| cap.get(1).map(|x| x.as_str().to_owned()))
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader, path::Path};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -300,12 +300,12 @@ async fn get_player_error(#[case] id: &str, #[case] msg: &str) {
#[rstest]
#[case::long(
"PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ",
"Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022",
true,
None,
Some(("UCIekuFeMaV78xYfvpmoCnPg", "Best Music")),
)]
"PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ",
"Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022",
true,
None,
Some(("UCIekuFeMaV78xYfvpmoCnPg", "Best Music")),
)]
#[case::short(
"RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk",
"Easy Pop",
@ -1370,6 +1370,99 @@ async fn trending() {
);
}
//#MUSIC
#[rstest]
#[case::long(
"PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ",
"Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022",
true,
None,
Some(("UCIekuFeMaV78xYfvpmoCnPg", "Best Music")),
false,
)]
#[case::short(
"RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk",
"Easy Pop",
false,
Some("Stress-free tunes from classic rockers and newer artists.".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<String>,
#[case] channel: Option<(&str, &str)>,
#[case] from_ytm: bool,
) {
let rp = RustyPipe::builder().strict().build();
let playlist = rp.query().music_playlist(id).await.unwrap();
assert_eq!(playlist.id, id);
assert_eq!(playlist.name, name);
assert!(!playlist.tracks.is_empty());
assert_eq!(!playlist.tracks.is_exhausted(), is_long);
assert!(playlist.track_count.unwrap() > 10);
assert_eq!(playlist.track_count.unwrap() > 100, is_long);
assert_eq!(playlist.description, description);
if let Some(expect) = channel {
let c = playlist.channel.unwrap();
assert_eq!(c.id, expect.0);
assert_eq!(c.name, expect.1);
}
assert!(!playlist.thumbnail.is_empty());
assert_eq!(playlist.from_ytm, from_ytm);
}
#[tokio::test]
async fn music_playlist_cont() {
let rp = RustyPipe::builder().strict().build();
let mut playlist = rp
.query()
.music_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi")
.await
.unwrap();
playlist
.tracks
.extend_pages(&rp.query(), usize::MAX)
.await
.unwrap();
assert!(playlist.tracks.items.len() > 100);
assert!(playlist.tracks.count.unwrap() > 100);
}
#[tokio::test]
async fn music_playlist_not_found() {
let rp = RustyPipe::builder().strict().build();
let err = rp
.query()
.music_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qz")
.await
.unwrap_err();
assert!(
matches!(
err,
Error::Extraction(ExtractionError::ContentUnavailable(_))
),
"got: {}",
err
);
}
//#TESTUTIL
/// Assert equality within 10% margin