From 44da9c7cc5fffa0defe9119818fa6a011a720467 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sun, 30 Oct 2022 22:59:02 +0100 Subject: [PATCH] feat: add album variants --- src/client/music_playlist.rs | 86 ++-- src/client/player.rs | 2 +- src/client/response/music_item.rs | 394 ++++++++++++------ src/client/response/url_endpoint.rs | 15 + ...st__tests__map_music_album_one_artist.snap | 31 +- ...aylist__tests__map_music_album_single.snap | 5 +- ...ests__map_music_album_various_artists.snap | 5 +- src/model/mod.rs | 29 +- src/serializer/text.rs | 76 ++++ src/util/mod.rs | 6 + tests/youtube.rs | 9 +- 11 files changed, 459 insertions(+), 199 deletions(-) diff --git a/src/client/music_playlist.rs b/src/client/music_playlist.rs index 07cae6a..1d46005 100644 --- a/src/client/music_playlist.rs +++ b/src/client/music_playlist.rs @@ -2,13 +2,16 @@ use std::borrow::Cow; use crate::{ error::{Error, ExtractionError}, - model::{AlbumType, ChannelId, MusicAlbum, MusicPlaylist, Paginator, TrackItem}, + model::{ChannelId, MusicAlbum, MusicPlaylist, Paginator, TrackItem}, serializer::MapResult, util::{self, TryRemove}, }; use super::{ - response::{self, music_item::MusicListMapper}, + response::{ + self, + music_item::{map_album_type, map_artists, MusicListMapper}, + }, ClientType, MapResponse, QBrowse, QContinuation, RustyPipeQuery, }; @@ -72,7 +75,7 @@ impl MapResponse for response::MusicPlaylist { fn map_response( self, id: &str, - _lang: crate::param::Language, + lang: crate::param::Language, _deobf: Option<&crate::deobfuscate::Deobfuscator>, ) -> Result, ExtractionError> { // dbg!(&self); @@ -113,7 +116,7 @@ impl MapResponse for response::MusicPlaylist { .subtitle .0 .iter() - .any(|c| c.as_str() == "YouTube Music"); + .any(|c| c.as_str() == util::YT_MUSIC_NAME); let channel = header .subtitle @@ -121,7 +124,7 @@ impl MapResponse for response::MusicPlaylist { .into_iter() .find_map(|c| ChannelId::try_from(c).ok()); - let mut mapper = MusicListMapper::::new(); + let mut mapper = MusicListMapper::new(lang); mapper.map_response(shelf.contents); let ctoken = shelf @@ -134,7 +137,7 @@ impl MapResponse for response::MusicPlaylist { .second_subtitle .first() .and_then(|txt| util::parse_numeric::(txt).ok()), - None => Some(mapper.items.len() as u64), + None => Some(mapper.tracks.len() as u64), }; Ok(MapResult { @@ -146,7 +149,7 @@ impl MapResponse for response::MusicPlaylist { description: header.description, track_count, from_ytm, - tracks: Paginator::new(track_count, mapper.items, ctoken), + tracks: Paginator::new(track_count, mapper.tracks, ctoken), }, warnings: mapper.warnings, }) @@ -157,10 +160,10 @@ impl MapResponse> for response::MusicPlaylistCont { fn map_response( self, _id: &str, - _lang: crate::param::Language, + lang: crate::param::Language, _deobf: Option<&crate::deobfuscate::Deobfuscator>, ) -> Result>, ExtractionError> { - let mut mapper = MusicListMapper::::new(); + let mut mapper = MusicListMapper::new(lang); let mut shelf = self.continuation_contents.music_playlist_shelf_continuation; mapper.map_response(shelf.contents); @@ -170,7 +173,7 @@ impl MapResponse> for response::MusicPlaylistCont { .map(|cont| cont.next_continuation_data.continuation); Ok(MapResult { - c: Paginator::new(None, mapper.items, ctoken), + c: Paginator::new(None, mapper.tracks, ctoken), warnings: mapper.warnings, }) } @@ -180,7 +183,7 @@ impl MapResponse for response::MusicPlaylist { fn map_response( self, id: &str, - _lang: crate::param::Language, + lang: crate::param::Language, _deobf: Option<&crate::deobfuscate::Deobfuscator>, ) -> Result, ExtractionError> { // dbg!(&self); @@ -197,12 +200,12 @@ impl MapResponse for response::MusicPlaylist { .contents; let mut shelf = None; - let mut album_versions = 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_versions = Some(contents) + album_variants = Some(contents) } response::music_playlist::ItemSection::None => (), } @@ -223,52 +226,30 @@ impl MapResponse for response::MusicPlaylist { }) }); - let subtitle_len = header.subtitle.0.len(); - if subtitle_len < 5 { - return Err(ExtractionError::InvalidData(Cow::Owned(format!( - "header text is missing elements: {}", - header.subtitle.to_string() - )))); - } + 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 mut artists = Vec::new(); - let mut artists_txt = String::new(); - - let mut st_parts = header.subtitle.0.into_iter(); - let album_type_txt = st_parts.next().unwrap(); - st_parts.next(); - - for _ in 0..subtitle_len - 4 { - let part = st_parts.next().unwrap(); - artists_txt += part.as_str(); - - if let Ok(a) = ChannelId::try_from(part) { - artists.push(a); - } - } - - st_parts.next(); - let year_txt = st_parts.next().unwrap(); - - let by_va = artists_txt == "Various Artists"; - - // TODO: add support for different languages - let album_type = match album_type_txt.as_str() { - "Single" => AlbumType::Single, - "EP" => AlbumType::Ep, - _ => AlbumType::Album, - }; - let year = util::parse_numeric(year_txt.as_str()) - .ok() + 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(), + true => MusicListMapper::new(lang), false => { - MusicListMapper::::with_artists(artists.clone(), artists_txt.clone()) + 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 { @@ -281,7 +262,8 @@ impl MapResponse for response::MusicPlaylist { album_type, year, by_va, - tracks: mapper.items, + tracks: mapper.tracks, + variants: mapper.albums, }, warnings: mapper.warnings, }) diff --git a/src/client/player.rs b/src/client/player.rs index 148064f..674efea 100644 --- a/src/client/player.rs +++ b/src/client/player.rs @@ -149,7 +149,7 @@ impl MapResponse 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( diff --git a/src/client/response/music_item.rs b/src/client/response/music_item.rs index d6f3a57..ce1e549 100644 --- a/src/client/response/music_item.rs +++ b/src/client/response/music_item.rs @@ -2,23 +2,28 @@ use serde::Deserialize; use serde_with::{serde_as, DefaultOnError}; use crate::{ - model::{self, ChannelId}, - serializer::{text::TextComponents, MapResult}, + model::{self, AlbumItem, AlbumType, ArtistItem, ChannelId, MusicPlaylistItem, TrackItem}, + param::Language, + serializer::{ + text::{Text, TextComponents}, + MapResult, + }, util::{self, TryRemove}, }; -use super::ThumbnailsWrap; +use super::{url_endpoint::NavigationEndpoint, ThumbnailsWrap}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct MusicItem { - pub music_responsive_list_item_renderer: InnerMusicItem, +pub(crate) enum MusicItem { + MusicResponsiveListItemRenderer(ListMusicItem), + MusicTwoRowItemRenderer(CoverMusicItem), } #[serde_as] #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct InnerMusicItem { +pub(crate) struct ListMusicItem { #[serde(default)] pub thumbnail: MusicThumbnailRenderer, #[serde(default)] @@ -28,6 +33,31 @@ pub(crate) struct InnerMusicItem { pub fixed_columns: Vec, } +#[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 { @@ -79,145 +109,265 @@ impl From for Vec { */ #[derive(Debug)] -pub(crate) struct MusicListMapper { - artists: Option<(Vec, String)>, +pub(crate) struct MusicListMapper { + lang: Language, + o_artists: Option<(Vec, String)>, + artist_page: bool, + + pub tracks: Vec, + pub albums: Vec, + pub artists: Vec, + pub playlists: Vec, - pub items: Vec, pub warnings: Vec, } -impl MusicListMapper { - pub fn new() -> Self { +impl MusicListMapper { + pub fn new(lang: Language) -> Self { Self { - artists: None, - items: Vec::new(), + 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(artists: Vec, artists_txt: String) -> Self { + pub fn with_artists( + lang: Language, + artists: Vec, + artists_txt: String, + artist_page: bool, + ) -> Self { Self { - artists: Some((artists, artists_txt)), - items: Vec::new(), + 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_music_item(&mut self, item: MusicItem) -> Option { - let item = item.music_responsive_list_item_renderer; + 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 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 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 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 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::>() + }) + .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>) { + 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); + } }); + } +} - let mut columns = item.flex_columns; - - let album = columns.try_swap_remove(2).and_then(|col| { - col.renderer - .text - .0 +pub(crate) fn map_artists(artists_p: Option) -> (Vec, 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() - .find_map(|c| model::AlbumId::try_from(c).ok()) - }); + .filter_map(|c| ChannelId::try_from(c).ok()) + .collect::>() + }) + .unwrap_or_default(); - 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::>() - }) - .unwrap_or_default(); - if let Some(a) = &self.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)) => { - 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 - } - } - } + (artists, artists_txt) } -/* -impl MusicListMapper { - 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>) { - self.warnings.append(&mut res.warnings); - res.c.into_iter().for_each(|item| self.map_item(item)); - } -} -*/ - -impl MusicListMapper { - 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>) { - self.warnings.append(&mut res.warnings); - res.c.into_iter().for_each(|item| self.map_item(item)); +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, } } diff --git a/src/client/response/url_endpoint.rs b/src/client/response/url_endpoint.rs index 396dc89..23b8b3c 100644 --- a/src/client/response/url_endpoint.rs +++ b/src/client/response/url_endpoint.rs @@ -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, + } + } +} diff --git a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_one_artist.snap b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_one_artist.snap index 3a54789..befbd6f 100644 --- a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_one_artist.snap +++ b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_one_artist.snap @@ -35,8 +35,8 @@ MusicAlbum( ), ], artists_txt: "Oonagh", - album_type: Album, - year: 2016, + album_type: album, + year: Some(2016), by_va: false, tracks: [ TrackItem( @@ -328,4 +328,31 @@ MusicAlbum( 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, + ), + ], ) diff --git a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_single.snap b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_single.snap index 2ec01af..0dc5d8b 100644 --- a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_single.snap +++ b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_single.snap @@ -39,8 +39,8 @@ MusicAlbum( ), ], artists_txt: "Joel Brandenstein & Vanessa Mai", - album_type: Single, - year: 2020, + album_type: single, + year: Some(2020), by_va: false, tracks: [ TrackItem( @@ -64,4 +64,5 @@ MusicAlbum( is_video: true, ), ], + variants: [], ) diff --git a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_various_artists.snap b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_various_artists.snap index d350412..d30763c 100644 --- a/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_various_artists.snap +++ b/src/client/snapshots/rustypipe__client__music_playlist__tests__map_music_album_various_artists.snap @@ -30,8 +30,8 @@ MusicAlbum( ], artists: [], artists_txt: "Various Artists", - album_type: Single, - year: 2022, + album_type: single, + year: Some(2022), by_va: true, tracks: [ TrackItem( @@ -106,4 +106,5 @@ MusicAlbum( is_video: true, ), ], + variants: [], ) diff --git a/src/model/mod.rs b/src/model/mod.rs index 6a9f1c6..f9916d2 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -859,15 +859,6 @@ pub struct PlaylistItem { #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] @@ -928,8 +919,15 @@ pub struct AlbumItem { pub cover: Vec, /// Artists of the album pub artists: Vec, + /// 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: u16, + pub year: Option, } /// YouTube Music playlist list item @@ -943,7 +941,7 @@ pub struct MusicPlaylistItem { /// Playlist thumbnail pub thumbnail: Vec, /// Channel of the playlist - pub channel: Option, + pub channel: Option, /// Number of tracks in the playlist pub track_count: Option, /// True if the playlist is from YouTube Music @@ -952,6 +950,7 @@ pub struct MusicPlaylistItem { /// 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] @@ -1008,17 +1007,19 @@ pub struct MusicAlbum { pub cover: Vec, /// Artists of the album pub artists: Vec, - /// Full content of the artists column + /// Full content of the artists field /// /// Conjunction words/characters depend on language and fetched page. /// Includes unlinked artists. pub artists_txt: String, - /// Music album type + /// Album type (Album/Single/EP) pub album_type: AlbumType, /// Release year - pub year: u16, + pub year: Option, /// Is the album by 'Various artists'? pub by_va: bool, /// Album tracks pub tracks: Vec, + /// Album variants + pub variants: Vec, } diff --git a/src/serializer/text.rs b/src/serializer/text.rs index 4bf8555..03195c9 100644 --- a/src/serializer/text.rs +++ b/src/serializer/text.rs @@ -381,6 +381,28 @@ impl TextComponents { Some(self.to_string()) } } + + pub fn split(self, separator: &str) -> Vec { + 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 { @@ -1186,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", + }, + ], + ), + ] + "###); + } } diff --git a/src/util/mod.rs b/src/util/mod.rs index 7d03e78..8ea6877 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -27,6 +27,12 @@ pub static PLAYLIST_ID_REGEX: Lazy = pub static VANITY_PATH_REGEX: Lazy = 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-_"; diff --git a/tests/youtube.rs b/tests/youtube.rs index ec410ce..d710b98 100644 --- a/tests/youtube.rs +++ b/tests/youtube.rs @@ -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): "