Compare commits

..

3 commits

Author SHA1 Message Date
44da9c7cc5 feat: add album variants 2022-10-30 22:59:02 +01:00
3b738a55ad feat: add music album 2022-10-29 23:45:03 +02:00
566b3e5bfc feat: add music playlist 2022-10-29 19:57:28 +02:00
33 changed files with 254118 additions and 61271 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,10 @@ 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;
music_album(&testfiles).await;
}
const CLIENT_TYPES: [ClientType; 5] = [
@ -458,3 +462,58 @@ 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();
}
async fn music_album(testfiles: &Path) {
for (name, id) in [
("one_artist", "MPREb_nlBWQROfvjo"),
("various_artists", "MPREb_8QkDeEIawvX"),
("single", "MPREb_bHfHGoy7vuv"),
] {
let mut json_path = testfiles.to_path_buf();
json_path.push("music_playlist");
json_path.push(format!("album_{}.json", name));
if json_path.exists() {
continue;
}
let rp = rp_testfile(&json_path);
rp.query().music_album(id).await.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,346 @@
use std::borrow::Cow;
use crate::{
error::{Error, ExtractionError},
model::{ChannelId, MusicAlbum, MusicPlaylist, Paginator, TrackItem},
serializer::MapResult,
util::{self, TryRemove},
};
use super::{
response::{
self,
music_item::{map_album_type, map_artists, 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
}
pub async fn music_album(&self, album_id: &str) -> Result<MusicAlbum, Error> {
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse {
context,
browse_id: album_id.to_owned(),
};
self.execute_request::<response::MusicPlaylist, _, _>(
ClientType::DesktopMusic,
"music_album",
album_id,
"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
.into_iter()
.find_map(|section| match section {
response::music_playlist::ItemSection::MusicShelfRenderer(shelf) => Some(shelf),
_ => None,
})
.ok_or(ExtractionError::InvalidData(Cow::Borrowed(
"no sectionListRenderer content",
)))?;
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() == util::YT_MUSIC_NAME);
let channel = header
.subtitle
.0
.into_iter()
.find_map(|c| ChannelId::try_from(c).ok());
let mut mapper = MusicListMapper::new(lang);
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.tracks.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.tracks, 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::new(lang);
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.tracks, ctoken),
warnings: mapper.warnings,
})
}
}
impl MapResponse<MusicAlbum> for response::MusicPlaylist {
fn map_response(
self,
id: &str,
lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<MusicAlbum>, ExtractionError> {
// dbg!(&self);
let header = self.header.music_detail_header_renderer;
let mut content = self.contents.single_column_browse_results_renderer.contents;
let sections = content
.try_swap_remove(0)
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no content")))?
.tab_renderer
.content
.section_list_renderer
.contents;
let mut shelf = None;
let mut album_variants = None;
for section in sections {
match section {
response::music_playlist::ItemSection::MusicShelfRenderer(sh) => shelf = Some(sh),
response::music_playlist::ItemSection::MusicCarouselShelfRenderer { contents } => {
album_variants = Some(contents)
}
response::music_playlist::ItemSection::None => (),
}
}
let shelf = shelf.ok_or(ExtractionError::InvalidData(Cow::Borrowed(
"no sectionListRenderer content",
)))?;
let playlist_id = header.menu.and_then(|mut menu| {
menu.menu_renderer
.top_level_buttons
.try_swap_remove(0)
.map(|btn| {
btn.button_renderer
.navigation_endpoint
.watch_playlist_endpoint
.playlist_id
})
});
let mut subtitle_split = header.subtitle.split(util::DOT_SEPARATOR);
let year_txt = subtitle_split.try_swap_remove(2).map(|cmp| cmp.to_string());
let artists_p = subtitle_split.try_swap_remove(1);
let (artists, artists_txt) = map_artists(artists_p);
let album_type_txt = subtitle_split
.try_swap_remove(0)
.map(|part| part.to_string())
.unwrap_or_default();
let by_va = artists_txt == util::VARIOUS_ARTISTS;
let album_type = map_album_type(album_type_txt.as_str());
let year = year_txt.and_then(|txt| util::parse_numeric(&txt).ok());
let mut mapper = match by_va {
true => MusicListMapper::new(lang),
false => {
MusicListMapper::with_artists(lang, artists.clone(), artists_txt.clone(), false)
}
};
mapper.map_response(shelf.contents);
if let Some(res) = album_variants {
mapper.map_response(res)
}
Ok(MapResult {
c: MusicAlbum {
id: id.to_owned(),
playlist_id,
name: header.title,
cover: header.thumbnail.into(),
artists,
artists_txt,
album_type,
year,
by_va,
tracks: mapper.tracks,
variants: mapper.albums,
},
warnings: mapper.warnings,
})
}
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader, path::Path};
use rstest::rstest;
use super::*;
use crate::{model, 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: MapResult<model::MusicPlaylist> =
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);
}
#[rstest]
#[case::one_artist("one_artist", "MPREb_nlBWQROfvjo")]
#[case::various_artists("various_artists", "MPREb_8QkDeEIawvX")]
#[case::single("single", "MPREb_bHfHGoy7vuv")]
fn map_music_album(#[case] name: &str, #[case] id: &str) {
let filename = format!("testfiles/music_playlist/album_{}.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: MapResult<model::MusicAlbum> =
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_album_{}", name), map_res.c, {
".last_update" => "[date]"
});
}
}

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

@ -149,7 +149,7 @@ impl MapResponse<VideoPlayer> for response::Player {
{
return Err(ExtractionError::VideoAgeRestricted);
}
return Err(ExtractionError::VideoUnavailable("private video", reason));
return Err(ExtractionError::VideoUnavailable("being private", reason));
}
response::player::PlayabilityStatus::LiveStreamOffline { reason } => {
return Err(ExtractionError::VideoUnavailable(

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,373 @@
use serde::Deserialize;
use serde_with::{serde_as, DefaultOnError};
use crate::{
model::{self, AlbumItem, AlbumType, ArtistItem, ChannelId, MusicPlaylistItem, TrackItem},
param::Language,
serializer::{
text::{Text, TextComponents},
MapResult,
},
util::{self, TryRemove},
};
use super::{url_endpoint::NavigationEndpoint, ThumbnailsWrap};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum MusicItem {
MusicResponsiveListItemRenderer(ListMusicItem),
MusicTwoRowItemRenderer(CoverMusicItem),
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListMusicItem {
#[serde(default)]
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>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CoverMusicItem {
#[serde_as(as = "Text")]
pub title: String,
/// Content type + Channel/Artist
///
/// `"Album", " • ", <"Oonagh">` Album variants, new releases
///
/// `"Album", " • ", "2022"` Artist albums
///
/// `"2022"` Artist singles
///
/// `"Playlist", " • ", <"ThetaDev"> " • ", "26 songs"`
///
/// `"Playlist", " • ", "YouTube Music" Featured on
#[serde(default)]
pub subtitle: TextComponents,
#[serde(default)]
pub thumbnail_renderer: MusicThumbnailRenderer,
/// Content type + ID
pub navigation_endpoint: NavigationEndpoint,
}
#[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 {
lang: Language,
o_artists: Option<(Vec<ChannelId>, String)>,
artist_page: bool,
pub tracks: Vec<TrackItem>,
pub albums: Vec<AlbumItem>,
pub artists: Vec<ArtistItem>,
pub playlists: Vec<MusicPlaylistItem>,
pub warnings: Vec<String>,
}
impl MusicListMapper {
pub fn new(lang: Language) -> Self {
Self {
lang,
o_artists: None,
artist_page: false,
tracks: Vec::new(),
albums: Vec::new(),
artists: Vec::new(),
playlists: Vec::new(),
warnings: Vec::new(),
}
}
pub fn with_artists(
lang: Language,
artists: Vec<ChannelId>,
artists_txt: String,
artist_page: bool,
) -> Self {
Self {
lang,
o_artists: Some((artists, artists_txt)),
artist_page,
tracks: Vec::new(),
albums: Vec::new(),
artists: Vec::new(),
playlists: Vec::new(),
warnings: Vec::new(),
}
}
fn map_item(&mut self, item: MusicItem) -> Result<(), String> {
match item {
MusicItem::MusicResponsiveListItemRenderer(item) => {
let first_tn = item
.thumbnail
.music_thumbnail_renderer
.thumbnail
.thumbnails
.first();
let id = item
.playlist_item_data
.map(|d| d.video_id)
.or_else(|| first_tn.and_then(|tn| util::video_id_from_thumbnail_url(&tn.url)))
.ok_or_else(|| "no video id".to_owned())?;
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 mut artists_txt = artists_col
.as_ref()
.and_then(|col| col.renderer.text.to_opt_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.o_artists {
if artists.is_empty() && artists_txt.is_none() {
let xa = a.clone();
artists = xa.0;
artists_txt = Some(xa.1);
}
}
let title = columns
.try_swap_remove(0)
.map(|col| col.renderer.text.to_string());
match (title, duration) {
(Some(title), Some(duration)) => {
self.tracks.push(TrackItem {
id,
title,
duration,
cover: item.thumbnail.into(),
artists,
artists_txt,
album,
view_count: None,
is_video,
});
Ok(())
}
(None, _) => Err(format!("track {}: could not get title", id)),
(_, None) => Err(format!("track {}: could not parse duration", id)),
}
}
MusicItem::MusicTwoRowItemRenderer(item) => {
let mut subtitle_parts = item.subtitle.split(util::DOT_SEPARATOR).into_iter();
let subtitle_p1 = subtitle_parts.next();
let subtitle_p2 = subtitle_parts.next();
let subtitle_p3 = subtitle_parts.next();
let (page_type, browse_id) = item
.navigation_endpoint
.music_page()
.ok_or_else(|| "could not get navigation endpoint".to_owned())?;
match page_type {
super::url_endpoint::PageType::Album => {
let mut year = None;
let mut album_type = AlbumType::Single;
let (artists, artists_txt) =
match (subtitle_p1, subtitle_p2, &self.o_artists, self.artist_page) {
// "2022" (Artist singles)
(Some(year_txt), None, Some((artists, artists_txt)), true) => {
year = util::parse_numeric(&year_txt.to_string()).ok();
(artists.clone(), artists_txt.clone())
}
// "Album", "2022" (Artist albums)
(
Some(atype_txt),
Some(year_txt),
Some((artists, artists_txt)),
true,
) => {
year = util::parse_numeric(&year_txt.to_string()).ok();
album_type = map_album_type(&atype_txt.to_string());
(artists.clone(), artists_txt.clone())
}
// "Album", <"Oonagh"> (Album variants, new releases)
(Some(atype_txt), Some(p2), _, false) => {
album_type = map_album_type(&atype_txt.to_string());
map_artists(Some(p2))
}
_ => {
return Err(format!(
"could not parse subtitle of album {}",
browse_id
));
}
};
self.albums.push(AlbumItem {
id: browse_id,
name: item.title,
cover: item.thumbnail_renderer.into(),
artists,
artists_txt,
year,
album_type,
});
Ok(())
}
super::url_endpoint::PageType::Playlist => {
// TODO: make component to string zero-copy if len=1
let from_ytm = subtitle_p2
.as_ref()
.and_then(|p| {
p.0.first().map(|txt| txt.as_str() == util::YT_MUSIC_NAME)
})
.unwrap_or_default();
let channel = subtitle_p2.and_then(|p| {
p.0.into_iter().find_map(|c| ChannelId::try_from(c).ok())
});
self.playlists.push(MusicPlaylistItem {
id: browse_id,
name: item.title,
thumbnail: item.thumbnail_renderer.into(),
channel,
track_count: subtitle_p3
.and_then(|p| util::parse_numeric(&p.to_string()).ok()),
from_ytm,
});
Ok(())
}
super::url_endpoint::PageType::Artist => {
let subscriber_count = subtitle_p1
.and_then(|p| util::parse_large_numstr(&p.to_string(), self.lang));
self.artists.push(ArtistItem {
id: browse_id,
name: item.title,
avatar: item.thumbnail_renderer.into(),
subscriber_count,
});
Ok(())
}
super::url_endpoint::PageType::Channel => {
Err(format!("channel items unsupported. id: {}", browse_id))
}
}
}
}
}
pub fn map_response(&mut self, mut res: MapResult<Vec<MusicItem>>) {
self.warnings.append(&mut res.warnings);
res.c.into_iter().for_each(|item| {
if let Err(e) = self.map_item(item) {
self.warnings.push(e);
}
});
}
}
pub(crate) fn map_artists(artists_p: Option<TextComponents>) -> (Vec<ChannelId>, String) {
let artists_txt = artists_p
.as_ref()
.map(|p| p.to_string())
.unwrap_or_default();
let artists = artists_p
.map(|part| {
part.0
.into_iter()
.filter_map(|c| ChannelId::try_from(c).ok())
.collect::<Vec<_>>()
})
.unwrap_or_default();
(artists, artists_txt)
}
pub(crate) fn map_album_type(txt: &str) -> AlbumType {
// TODO: add support for different languages
match txt {
"Single" => AlbumType::Single,
"EP" => AlbumType::Ep,
_ => AlbumType::Album,
}
}

View file

@ -0,0 +1,157 @@
use serde::Deserialize;
use serde_with::{serde_as, DefaultOnError, VecSkipError};
use crate::serializer::{
ignore_any,
text::{Text, TextComponents},
MapResult, VecLogError,
};
use super::music_item::{MusicContentsRenderer, MusicItem, MusicThumbnailRenderer};
use super::{ContentRenderer, ContentsRenderer, MusicContinuation};
/// Response model for YouTube Music playlists and albums
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
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 {
pub single_column_browse_results_renderer: ContentsRenderer<Tab>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Tab {
pub tab_renderer: ContentRenderer<SectionList>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SectionList {
/// Includes a continuation token for fetching recommendations
pub section_list_renderer: MusicContentsRenderer<ItemSection>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum ItemSection {
#[serde(alias = "musicPlaylistShelfRenderer")]
MusicShelfRenderer(MusicShelf),
MusicCarouselShelfRenderer {
#[serde_as(as = "VecLogError<_>")]
contents: MapResult<Vec<MusicItem>>,
},
#[serde(other, deserialize_with = "ignore_any")]
None,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicShelf {
/// Playlist ID (only for playlists)
pub playlist_id: Option<String>,
#[serde_as(as = "VecLogError<_>")]
pub contents: MapResult<Vec<MusicItem>>,
/// Continuation token for fetching more (>100) playlist items
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub continuations: Vec<MusicContinuation>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Header {
pub music_detail_header_renderer: HeaderRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HeaderRenderer {
#[serde_as(as = "Text")]
pub title: String,
/// Content type + Channel/Artist + Year.
/// Missing on artist_tracks view.
///
/// `"Playlist", " • ", <"Best Music">, " • ", "2022"`
///
/// `"Album", " • ", <"Helene Fischer">, " • ", "2021"`
#[serde(default)]
pub subtitle: TextComponents,
/// Playlist description. May contain hashtags which are
/// displayed as search links on the YouTube website.
#[serde_as(as = "Option<Text>")]
pub description: Option<String>,
/// Playlist thumbnail / album cover.
/// Missing on artist_tracks view.
#[serde(default)]
pub thumbnail: MusicThumbnailRenderer,
/// Number of tracks + playtime.
/// Missing on artist_tracks view.
///
/// `"64 songs", " • ", "3 hours, 40 minutes"`
#[serde(default)]
#[serde_as(as = "Text")]
pub second_subtitle: Vec<String>,
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
pub menu: Option<HeaderMenu>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HeaderMenu {
pub menu_renderer: HeaderMenuRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HeaderMenuRenderer {
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub top_level_buttons: Vec<TopLevelButton>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TopLevelButton {
pub button_renderer: ButtonRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ButtonRenderer {
pub navigation_endpoint: PlaylistEndpoint,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistEndpoint {
pub watch_playlist_endpoint: PlaylistWatchEndpoint,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistWatchEndpoint {
pub playlist_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationContents {
pub music_playlist_shelf_continuation: MusicShelf,
}

View file

@ -1,95 +0,0 @@
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,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistMusic {
pub contents: Contents,
pub header: Header,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Contents {
pub single_column_browse_results_renderer: ContentsRenderer<Tab>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Tab {
pub tab_renderer: ContentRenderer<SectionList>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SectionList {
/// Includes a continuation token for fetching recommendations
pub section_list_renderer: MusicContentsRenderer<ItemSection>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ItemSection {
#[serde(alias = "musicPlaylistShelfRenderer")]
pub music_shelf_renderer: MusicShelf,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicShelf {
/// Playlist ID (only for playlists)
pub playlist_id: Option<String>,
#[serde_as(as = "VecSkipError<_>")]
pub contents: Vec<PlaylistMusicItem>,
/// 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,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Header {
pub music_detail_header_renderer: HeaderRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HeaderRenderer {
#[serde_as(as = "crate::serializer::text::Text")]
pub title: String,
/// Content type + Channel/Artist + Year.
/// Missing on artist_tracks view.
///
/// `"Playlist", " • ", <"Best Music">, " • ", "2022"`
///
/// `"Album", " • ", <"Helene Fischer">, " • ", "2021"`
pub subtitle: Option<Text>,
/// Playlist description. May contain hashtags which are
/// displayed as search links on the YouTube website.
#[serde_as(as = "Option<crate::serializer::text::Text>")]
pub description: Option<String>,
/// Playlist thumbnail / album cover.
/// Missing on artist_tracks view.
pub thumbnail: Option<MusicThumbnailRenderer>,
/// Number of tracks + playtime.
/// Missing on artist_tracks view.
///
/// `"64 songs", " • ", "3 hours, 40 minutes"`
pub second_subtitle: Option<Text>,
}

View file

@ -98,3 +98,18 @@ impl PageType {
}
}
}
impl NavigationEndpoint {
pub(crate) fn music_page(self) -> Option<(PageType, String)> {
match self.browse_endpoint {
Some(browse) => match browse.browse_endpoint_context_supported_configs {
Some(config) => Some((
config.browse_endpoint_context_music_config.page_type,
browse.browse_id,
)),
None => None,
},
None => None,
}
}
}

View file

@ -0,0 +1,358 @@
---
source: src/client/music_playlist.rs
expression: map_res.c
---
MusicAlbum(
id: "MPREb_nlBWQROfvjo",
playlist_id: Some("OLAK5uy_myZkBX2d2TzcrlQhIwLy3hCj2MkAMaPR4"),
name: "Märchen enden gut",
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/Z5CF2JCRD5o7fBywh9Spg_Wvmrqkg0M01FWsSm_mdmUSfplv--9NgIiBRExudt7s0TTd3tgpJ7CLRFal=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/Z5CF2JCRD5o7fBywh9Spg_Wvmrqkg0M01FWsSm_mdmUSfplv--9NgIiBRExudt7s0TTd3tgpJ7CLRFal=w120-h120-l90-rj",
width: 120,
height: 120,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/Z5CF2JCRD5o7fBywh9Spg_Wvmrqkg0M01FWsSm_mdmUSfplv--9NgIiBRExudt7s0TTd3tgpJ7CLRFal=w226-h226-l90-rj",
width: 226,
height: 226,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/Z5CF2JCRD5o7fBywh9Spg_Wvmrqkg0M01FWsSm_mdmUSfplv--9NgIiBRExudt7s0TTd3tgpJ7CLRFal=w544-h544-l90-rj",
width: 544,
height: 544,
),
],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: "Oonagh",
album_type: album,
year: Some(2016),
by_va: false,
tracks: [
TrackItem(
id: "g0iRiJ_ck48",
title: "Aulë und Yavanna",
duration: 216,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "rREEBXp0y9s",
title: "Numenor",
duration: 224,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "zvU5Y8Q19hU",
title: "Das Mädchen und die Liebe (feat. Santiano)",
duration: 176,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "ARKLrzzTQA0",
title: "Niënna",
duration: 215,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "tstLgN8A_Ng",
title: "Der fahle Mond",
duration: 268,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "k2DjgQOY3Ts",
title: "Weise den Weg",
duration: 202,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "azHwhecxEsI",
title: "Zeit der Sommernächte",
duration: 185,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "_FcsdYIQ2co",
title: "Märchen enden gut",
duration: 226,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "27bOWEbshyE",
title: "Das Mädchen und der Tod",
duration: 207,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "riD_3oZwt8w",
title: "Wir sehn uns wieder",
duration: 211,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "8GNvjF3no9s",
title: "Tanz mit mir",
duration: 179,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "YHMFzf1uN2U",
title: "Nachtigall",
duration: 218,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "jvV-z5F3oAo",
title: "Gayatri Mantra",
duration: 277,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "u8_9cxlrh8k",
title: "Sing mir deine Lieder",
duration: 204,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "gSvKcvM1Wk0",
title: "Laurië lantar",
duration: 202,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "wQHgKRJ0pDQ",
title: "Wächter vor dem Tor",
duration: 222,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "Ckz5i6-hzf0",
title: "Stroh zu Gold",
duration: 177,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "y5zuUgyFqrc",
title: "Sonnenwendnacht",
duration: 220,
cover: [],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: Some("Oonagh"),
album: None,
view_count: None,
is_video: true,
),
],
variants: [
AlbumItem(
id: "MPREb_jk6Msw8izou",
name: "Märchen enden gut (Nyáre Ranta (Märchenedition))",
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/BKgnW_-hapCHk599AtRfTYZGdXVIo0C4bJp1Bh7qUpGK7fNAXGW8Bhv2x-ukeFM8cuxKbGqqGaTo8fZASA=w226-h226-l90-rj",
width: 226,
height: 226,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/BKgnW_-hapCHk599AtRfTYZGdXVIo0C4bJp1Bh7qUpGK7fNAXGW8Bhv2x-ukeFM8cuxKbGqqGaTo8fZASA=w544-h544-l90-rj",
width: 544,
height: 544,
),
],
artists: [
ChannelId(
id: "UC_vmjW5e1xEHhYjY2a0kK1A",
name: "Oonagh",
),
],
artists_txt: "Oonagh",
album_type: album,
year: None,
),
],
)

View file

@ -0,0 +1,68 @@
---
source: src/client/music_playlist.rs
expression: map_res.c
---
MusicAlbum(
id: "MPREb_bHfHGoy7vuv",
playlist_id: Some("OLAK5uy_kdSWBZ-9AZDkYkuy0QCc3p0KO9DEHVNH0"),
name: "Der Himmel reißt auf",
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/sfYeIuiLljpCsDLSooCOkNON1jZwHsEui3fD1FnLSyCMYjLCPQtEgy4_6qBmSGOz3eNWyS-aW4WcZMo8=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/sfYeIuiLljpCsDLSooCOkNON1jZwHsEui3fD1FnLSyCMYjLCPQtEgy4_6qBmSGOz3eNWyS-aW4WcZMo8=w120-h120-l90-rj",
width: 120,
height: 120,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/sfYeIuiLljpCsDLSooCOkNON1jZwHsEui3fD1FnLSyCMYjLCPQtEgy4_6qBmSGOz3eNWyS-aW4WcZMo8=w226-h226-l90-rj",
width: 226,
height: 226,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/sfYeIuiLljpCsDLSooCOkNON1jZwHsEui3fD1FnLSyCMYjLCPQtEgy4_6qBmSGOz3eNWyS-aW4WcZMo8=w544-h544-l90-rj",
width: 544,
height: 544,
),
],
artists: [
ChannelId(
id: "UCXGYZ-OhdOpPBamHX3K9YRg",
name: "Joel Brandenstein",
),
ChannelId(
id: "UCFTcSVPYRWlDoHisR-ZKwgw",
name: "Vanessa Mai",
),
],
artists_txt: "Joel Brandenstein & Vanessa Mai",
album_type: single,
year: Some(2020),
by_va: false,
tracks: [
TrackItem(
id: "XX0epju-YvY",
title: "Der Himmel reißt auf",
duration: 183,
cover: [],
artists: [
ChannelId(
id: "UCXGYZ-OhdOpPBamHX3K9YRg",
name: "Joel Brandenstein",
),
ChannelId(
id: "UCFTcSVPYRWlDoHisR-ZKwgw",
name: "Vanessa Mai",
),
],
artists_txt: Some("Joel Brandenstein & Vanessa Mai"),
album: None,
view_count: None,
is_video: true,
),
],
variants: [],
)

View file

@ -0,0 +1,110 @@
---
source: src/client/music_playlist.rs
expression: map_res.c
---
MusicAlbum(
id: "MPREb_8QkDeEIawvX",
playlist_id: Some("OLAK5uy_mEX9ljZeeEWgTM1xLL1isyiGaWXoPyoOk"),
name: "Queendom2 FINAL",
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/Imv7uGEOmI-jpyxbRv1Yk9sajaZMxzK2zs3bQuu9W9FyXmiVrPEZ8F7NsY-DCxDwDGIzBNDRGossSi2KVA=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/Imv7uGEOmI-jpyxbRv1Yk9sajaZMxzK2zs3bQuu9W9FyXmiVrPEZ8F7NsY-DCxDwDGIzBNDRGossSi2KVA=w120-h120-l90-rj",
width: 120,
height: 120,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/Imv7uGEOmI-jpyxbRv1Yk9sajaZMxzK2zs3bQuu9W9FyXmiVrPEZ8F7NsY-DCxDwDGIzBNDRGossSi2KVA=w226-h226-l90-rj",
width: 226,
height: 226,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/Imv7uGEOmI-jpyxbRv1Yk9sajaZMxzK2zs3bQuu9W9FyXmiVrPEZ8F7NsY-DCxDwDGIzBNDRGossSi2KVA=w544-h544-l90-rj",
width: 544,
height: 544,
),
],
artists: [],
artists_txt: "Various Artists",
album_type: single,
year: Some(2022),
by_va: true,
tracks: [
TrackItem(
id: "8IqLxg0GqXc",
title: "Waka Boom (My Way) (feat. Lee Young Ji)",
duration: 274,
cover: [],
artists: [],
artists_txt: Some("HYOLYN"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "9WYpLYAEub0",
title: "AURA",
duration: 216,
cover: [],
artists: [],
artists_txt: Some("WJSN"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "R48tE237bW4",
title: "THE GIRLS (Cant turn me down)",
duration: 239,
cover: [],
artists: [
ChannelId(
id: "UCAKvDuIX3m1AUdPpDSqV_3w",
name: "Kep1er",
),
],
artists_txt: Some("Kep1er"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "-UzsoR6z-vg",
title: "Red Sun!",
duration: 254,
cover: [],
artists: [],
artists_txt: Some("VIVIZ"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "kbNVyn8Ex28",
title: "POSE",
duration: 187,
cover: [],
artists: [],
artists_txt: Some("LOONA"),
album: None,
view_count: None,
is_video: true,
),
TrackItem(
id: "NJrQZUzWP5Y",
title: "Whistle",
duration: 224,
cover: [],
artists: [],
artists_txt: Some("Brave Girls"),
album: None,
view_count: None,
is_video: true,
),
],
variants: [],
)

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,172 @@ pub struct PlaylistItem {
/// Number of playlist videos
pub video_count: Option<u64>,
}
/*
#MUSIC
*/
/// 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>,
/// Full content of the artists field
///
/// Conjunction words/characters depend on language and fetched page.
/// Includes unlinked artists.
pub artists_txt: String,
/// Album type (Album/Single/EP)
pub album_type: AlbumType,
/// Release year of the album
pub year: Option<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<ChannelId>,
/// 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)]
#[serde(rename_all = "snake_case")]
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>,
/// Full content of the artists field
///
/// Conjunction words/characters depend on language and fetched page.
/// Includes unlinked artists.
pub artists_txt: String,
/// Album type (Album/Single/EP)
pub album_type: AlbumType,
/// Release year
pub year: Option<u16>,
/// Is the album by 'Various artists'?
pub by_va: bool,
/// Album tracks
pub tracks: Vec<TrackItem>,
/// Album variants
pub variants: Vec<AlbumItem>,
}

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,55 @@ 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 TextComponents {
pub fn to_opt_string(&self) -> Option<String> {
if self.0.is_empty() {
None
} else {
Some(self.to_string())
}
}
pub fn split(self, separator: &str) -> Vec<TextComponents> {
let mut buf = Vec::new();
let mut inner = Vec::new();
for c in self.0 {
if c.as_str() == separator {
if !inner.is_empty() {
buf.push(TextComponents(inner));
inner = Vec::new();
}
} else {
inner.push(c);
}
}
if !inner.is_empty() {
buf.push(TextComponents(inner))
}
buf
}
}
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 +737,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#"{
@ -1132,4 +1208,58 @@ mod tests {
}
"###);
}
#[test]
fn split_text_cmp() {
let text = TextComponents(vec![
TextComponent::Text {
text: "Hello".to_owned(),
},
TextComponent::Text {
text: " World".to_owned(),
},
TextComponent::Text {
text: util::DOT_SEPARATOR.to_owned(),
},
TextComponent::Text {
text: "T2".to_owned(),
},
TextComponent::Text {
text: util::DOT_SEPARATOR.to_owned(),
},
TextComponent::Text {
text: "T3".to_owned(),
},
]);
let split = text.split(util::DOT_SEPARATOR);
insta::assert_debug_snapshot!(split, @r###"
[
TextComponents(
[
Text {
text: "Hello",
},
Text {
text: " World",
},
],
),
TextComponents(
[
Text {
text: "T2",
},
],
),
TextComponents(
[
Text {
text: "T3",
},
],
),
]
"###);
}
}

View file

@ -27,6 +27,12 @@ pub static PLAYLIST_ID_REGEX: Lazy<Regex> =
pub static VANITY_PATH_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^/?(?:(?:c\/|user\/)?[A-z0-9]+)|(?:@[A-z0-9-_.]+)$").unwrap());
/// Separator string for YouTube Music subtitles
pub const DOT_SEPARATOR: &str = "";
/// YouTube Music name (author of official playlists)
pub const YT_MUSIC_NAME: &str = "YouTube Music";
pub const VARIOUS_ARTISTS: &str = "Various Artists";
const CONTENT_PLAYBACK_NONCE_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
@ -330,6 +336,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

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

@ -280,10 +280,11 @@ async fn get_player(
"1bfOsni7EgI",
"extraction error: Video cant be played because of DRM/Geoblock. Reason (from YT): "
)]
#[case::private(
"s7_qI6_mIXc",
"extraction error: Video cant be played because of private video. Reason (from YT): "
)]
// YouTube sometimes returns "Video unavailable" for this video
// #[case::private(
// "s7_qI6_mIXc",
// "extraction error: Video cant be played because of being private. Reason (from YT): "
// )]
#[case::t1(
"CUO8secmc0g",
"extraction error: Video cant be played because of DRM/Geoblock. Reason (from YT): "
@ -305,7 +306,7 @@ async fn get_player_error(#[case] id: &str, #[case] msg: &str) {
true,
None,
Some(("UCIekuFeMaV78xYfvpmoCnPg", "Best Music")),
)]
)]
#[case::short(
"RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk",
"Easy Pop",
@ -1370,6 +1371,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