feat: add album variants

This commit is contained in:
ThetaDev 2022-10-30 22:59:02 +01:00
parent 3b738a55ad
commit 44da9c7cc5
11 changed files with 459 additions and 199 deletions

View file

@ -2,13 +2,16 @@ use std::borrow::Cow;
use crate::{ use crate::{
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::{AlbumType, ChannelId, MusicAlbum, MusicPlaylist, Paginator, TrackItem}, model::{ChannelId, MusicAlbum, MusicPlaylist, Paginator, TrackItem},
serializer::MapResult, serializer::MapResult,
util::{self, TryRemove}, util::{self, TryRemove},
}; };
use super::{ use super::{
response::{self, music_item::MusicListMapper}, response::{
self,
music_item::{map_album_type, map_artists, MusicListMapper},
},
ClientType, MapResponse, QBrowse, QContinuation, RustyPipeQuery, ClientType, MapResponse, QBrowse, QContinuation, RustyPipeQuery,
}; };
@ -72,7 +75,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
fn map_response( fn map_response(
self, self,
id: &str, id: &str,
_lang: crate::param::Language, lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>, _deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<MusicPlaylist>, ExtractionError> { ) -> Result<MapResult<MusicPlaylist>, ExtractionError> {
// dbg!(&self); // dbg!(&self);
@ -113,7 +116,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
.subtitle .subtitle
.0 .0
.iter() .iter()
.any(|c| c.as_str() == "YouTube Music"); .any(|c| c.as_str() == util::YT_MUSIC_NAME);
let channel = header let channel = header
.subtitle .subtitle
@ -121,7 +124,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
.into_iter() .into_iter()
.find_map(|c| ChannelId::try_from(c).ok()); .find_map(|c| ChannelId::try_from(c).ok());
let mut mapper = MusicListMapper::<TrackItem>::new(); let mut mapper = MusicListMapper::new(lang);
mapper.map_response(shelf.contents); mapper.map_response(shelf.contents);
let ctoken = shelf let ctoken = shelf
@ -134,7 +137,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
.second_subtitle .second_subtitle
.first() .first()
.and_then(|txt| util::parse_numeric::<u64>(txt).ok()), .and_then(|txt| util::parse_numeric::<u64>(txt).ok()),
None => Some(mapper.items.len() as u64), None => Some(mapper.tracks.len() as u64),
}; };
Ok(MapResult { Ok(MapResult {
@ -146,7 +149,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
description: header.description, description: header.description,
track_count, track_count,
from_ytm, from_ytm,
tracks: Paginator::new(track_count, mapper.items, ctoken), tracks: Paginator::new(track_count, mapper.tracks, ctoken),
}, },
warnings: mapper.warnings, warnings: mapper.warnings,
}) })
@ -157,10 +160,10 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicPlaylistCont {
fn map_response( fn map_response(
self, self,
_id: &str, _id: &str,
_lang: crate::param::Language, lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>, _deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> { ) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
let mut mapper = MusicListMapper::<TrackItem>::new(); let mut mapper = MusicListMapper::new(lang);
let mut shelf = self.continuation_contents.music_playlist_shelf_continuation; let mut shelf = self.continuation_contents.music_playlist_shelf_continuation;
mapper.map_response(shelf.contents); mapper.map_response(shelf.contents);
@ -170,7 +173,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicPlaylistCont {
.map(|cont| cont.next_continuation_data.continuation); .map(|cont| cont.next_continuation_data.continuation);
Ok(MapResult { Ok(MapResult {
c: Paginator::new(None, mapper.items, ctoken), c: Paginator::new(None, mapper.tracks, ctoken),
warnings: mapper.warnings, warnings: mapper.warnings,
}) })
} }
@ -180,7 +183,7 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
fn map_response( fn map_response(
self, self,
id: &str, id: &str,
_lang: crate::param::Language, lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::Deobfuscator>, _deobf: Option<&crate::deobfuscate::Deobfuscator>,
) -> Result<MapResult<MusicAlbum>, ExtractionError> { ) -> Result<MapResult<MusicAlbum>, ExtractionError> {
// dbg!(&self); // dbg!(&self);
@ -197,12 +200,12 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
.contents; .contents;
let mut shelf = None; let mut shelf = None;
let mut album_versions = None; let mut album_variants = None;
for section in sections { for section in sections {
match section { match section {
response::music_playlist::ItemSection::MusicShelfRenderer(sh) => shelf = Some(sh), response::music_playlist::ItemSection::MusicShelfRenderer(sh) => shelf = Some(sh),
response::music_playlist::ItemSection::MusicCarouselShelfRenderer { contents } => { response::music_playlist::ItemSection::MusicCarouselShelfRenderer { contents } => {
album_versions = Some(contents) album_variants = Some(contents)
} }
response::music_playlist::ItemSection::None => (), response::music_playlist::ItemSection::None => (),
} }
@ -223,52 +226,30 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
}) })
}); });
let subtitle_len = header.subtitle.0.len(); let mut subtitle_split = header.subtitle.split(util::DOT_SEPARATOR);
if subtitle_len < 5 { let year_txt = subtitle_split.try_swap_remove(2).map(|cmp| cmp.to_string());
return Err(ExtractionError::InvalidData(Cow::Owned(format!(
"header text is missing elements: {}",
header.subtitle.to_string()
))));
}
let mut artists = Vec::new(); let artists_p = subtitle_split.try_swap_remove(1);
let mut artists_txt = String::new(); let (artists, artists_txt) = map_artists(artists_p);
let album_type_txt = subtitle_split
let mut st_parts = header.subtitle.0.into_iter(); .try_swap_remove(0)
let album_type_txt = st_parts.next().unwrap(); .map(|part| part.to_string())
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()
.unwrap_or_default(); .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 { let mut mapper = match by_va {
true => MusicListMapper::<TrackItem>::new(), true => MusicListMapper::new(lang),
false => { false => {
MusicListMapper::<TrackItem>::with_artists(artists.clone(), artists_txt.clone()) MusicListMapper::with_artists(lang, artists.clone(), artists_txt.clone(), false)
} }
}; };
mapper.map_response(shelf.contents); mapper.map_response(shelf.contents);
if let Some(res) = album_variants {
mapper.map_response(res)
}
Ok(MapResult { Ok(MapResult {
c: MusicAlbum { c: MusicAlbum {
@ -281,7 +262,8 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
album_type, album_type,
year, year,
by_va, by_va,
tracks: mapper.items, tracks: mapper.tracks,
variants: mapper.albums,
}, },
warnings: mapper.warnings, warnings: mapper.warnings,
}) })

View file

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

View file

@ -2,23 +2,28 @@ use serde::Deserialize;
use serde_with::{serde_as, DefaultOnError}; use serde_with::{serde_as, DefaultOnError};
use crate::{ use crate::{
model::{self, ChannelId}, model::{self, AlbumItem, AlbumType, ArtistItem, ChannelId, MusicPlaylistItem, TrackItem},
serializer::{text::TextComponents, MapResult}, param::Language,
serializer::{
text::{Text, TextComponents},
MapResult,
},
util::{self, TryRemove}, util::{self, TryRemove},
}; };
use super::ThumbnailsWrap; use super::{url_endpoint::NavigationEndpoint, ThumbnailsWrap};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct MusicItem { pub(crate) enum MusicItem {
pub music_responsive_list_item_renderer: InnerMusicItem, MusicResponsiveListItemRenderer(ListMusicItem),
MusicTwoRowItemRenderer(CoverMusicItem),
} }
#[serde_as] #[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct InnerMusicItem { pub(crate) struct ListMusicItem {
#[serde(default)] #[serde(default)]
pub thumbnail: MusicThumbnailRenderer, pub thumbnail: MusicThumbnailRenderer,
#[serde(default)] #[serde(default)]
@ -28,6 +33,31 @@ pub(crate) struct InnerMusicItem {
pub fixed_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)] #[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct MusicThumbnailRenderer { pub(crate) struct MusicThumbnailRenderer {
@ -79,145 +109,265 @@ impl From<MusicThumbnailRenderer> for Vec<model::Thumbnail> {
*/ */
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct MusicListMapper<T> { pub(crate) struct MusicListMapper {
artists: Option<(Vec<ChannelId>, String)>, 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 items: Vec<T>,
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }
impl<T> MusicListMapper<T> { impl MusicListMapper {
pub fn new() -> Self { pub fn new(lang: Language) -> Self {
Self { Self {
artists: None, lang,
items: Vec::new(), o_artists: None,
artist_page: false,
tracks: Vec::new(),
albums: Vec::new(),
artists: Vec::new(),
playlists: Vec::new(),
warnings: Vec::new(), warnings: Vec::new(),
} }
} }
pub fn with_artists(artists: Vec<ChannelId>, artists_txt: String) -> Self { pub fn with_artists(
lang: Language,
artists: Vec<ChannelId>,
artists_txt: String,
artist_page: bool,
) -> Self {
Self { Self {
artists: Some((artists, artists_txt)), lang,
items: Vec::new(), o_artists: Some((artists, artists_txt)),
artist_page,
tracks: Vec::new(),
albums: Vec::new(),
artists: Vec::new(),
playlists: Vec::new(),
warnings: Vec::new(), warnings: Vec::new(),
} }
} }
fn map_music_item(&mut self, item: MusicItem) -> Option<model::YouTubeMusicItem> { fn map_item(&mut self, item: MusicItem) -> Result<(), String> {
let item = item.music_responsive_list_item_renderer; match item {
MusicItem::MusicResponsiveListItemRenderer(item) => {
let first_tn = item
.thumbnail
.music_thumbnail_renderer
.thumbnail
.thumbnails
.first();
let first_tn = item let id = item
.thumbnail .playlist_item_data
.music_thumbnail_renderer .map(|d| d.video_id)
.thumbnail .or_else(|| first_tn.and_then(|tn| util::video_id_from_thumbnail_url(&tn.url)))
.thumbnails .ok_or_else(|| "no video id".to_owned())?;
.first();
let id = some_or_bail!( let is_video = !first_tn.map(|tn| tn.height == tn.width).unwrap_or_default();
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 duration = item.fixed_columns.first().and_then(|col| { let mut columns = item.flex_columns;
col.renderer
.text let album = columns.try_swap_remove(2).and_then(|col| {
.0 col.renderer
.first() .text
.and_then(|txt| util::parse_video_length(txt.as_str())) .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);
}
}); });
}
}
let mut columns = item.flex_columns; pub(crate) fn map_artists(artists_p: Option<TextComponents>) -> (Vec<ChannelId>, String) {
let artists_txt = artists_p
let album = columns.try_swap_remove(2).and_then(|col| { .as_ref()
col.renderer .map(|p| p.to_string())
.text .unwrap_or_default();
.0 let artists = artists_p
.map(|part| {
part.0
.into_iter() .into_iter()
.find_map(|c| model::AlbumId::try_from(c).ok()) .filter_map(|c| ChannelId::try_from(c).ok())
}); .collect::<Vec<_>>()
})
.unwrap_or_default();
let artists_col = columns.try_swap_remove(1); (artists, artists_txt)
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.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
}
}
}
} }
/* pub(crate) fn map_album_type(txt: &str) -> AlbumType {
impl MusicListMapper<model::YouTubeMusicItem> { // TODO: add support for different languages
fn map_item(&mut self, item: MusicItem) { match txt {
if let Some(mapped) = self.map_music_item(item) { "Single" => AlbumType::Single,
self.items.push(mapped); "EP" => AlbumType::Ep,
} _ => AlbumType::Album,
}
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

@ -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

@ -35,8 +35,8 @@ MusicAlbum(
), ),
], ],
artists_txt: "Oonagh", artists_txt: "Oonagh",
album_type: Album, album_type: album,
year: 2016, year: Some(2016),
by_va: false, by_va: false,
tracks: [ tracks: [
TrackItem( TrackItem(
@ -328,4 +328,31 @@ MusicAlbum(
is_video: true, 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

@ -39,8 +39,8 @@ MusicAlbum(
), ),
], ],
artists_txt: "Joel Brandenstein & Vanessa Mai", artists_txt: "Joel Brandenstein & Vanessa Mai",
album_type: Single, album_type: single,
year: 2020, year: Some(2020),
by_va: false, by_va: false,
tracks: [ tracks: [
TrackItem( TrackItem(
@ -64,4 +64,5 @@ MusicAlbum(
is_video: true, is_video: true,
), ),
], ],
variants: [],
) )

View file

@ -30,8 +30,8 @@ MusicAlbum(
], ],
artists: [], artists: [],
artists_txt: "Various Artists", artists_txt: "Various Artists",
album_type: Single, album_type: single,
year: 2022, year: Some(2022),
by_va: true, by_va: true,
tracks: [ tracks: [
TrackItem( TrackItem(
@ -106,4 +106,5 @@ MusicAlbum(
is_video: true, is_video: true,
), ),
], ],
variants: [],
) )

View file

@ -859,15 +859,6 @@ pub struct PlaylistItem {
#MUSIC #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 /// YouTube Music track list item
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive] #[non_exhaustive]
@ -928,8 +919,15 @@ pub struct AlbumItem {
pub cover: Vec<Thumbnail>, pub cover: Vec<Thumbnail>,
/// Artists of the album /// Artists of the album
pub artists: Vec<ChannelId>, 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 /// Release year of the album
pub year: u16, pub year: Option<u16>,
} }
/// YouTube Music playlist list item /// YouTube Music playlist list item
@ -943,7 +941,7 @@ pub struct MusicPlaylistItem {
/// Playlist thumbnail /// Playlist thumbnail
pub thumbnail: Vec<Thumbnail>, pub thumbnail: Vec<Thumbnail>,
/// Channel of the playlist /// Channel of the playlist
pub channel: Option<ChannelTag>, pub channel: Option<ChannelId>,
/// Number of tracks in the playlist /// Number of tracks in the playlist
pub track_count: Option<u64>, pub track_count: Option<u64>,
/// True if the playlist is from YouTube Music /// True if the playlist is from YouTube Music
@ -952,6 +950,7 @@ pub struct MusicPlaylistItem {
/// YouTube Music album type /// YouTube Music album type
#[derive(Default, Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Default, Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AlbumType { pub enum AlbumType {
/// Regular album (default) /// Regular album (default)
#[default] #[default]
@ -1008,17 +1007,19 @@ pub struct MusicAlbum {
pub cover: Vec<Thumbnail>, pub cover: Vec<Thumbnail>,
/// Artists of the album /// Artists of the album
pub artists: Vec<ChannelId>, pub artists: Vec<ChannelId>,
/// Full content of the artists column /// Full content of the artists field
/// ///
/// Conjunction words/characters depend on language and fetched page. /// Conjunction words/characters depend on language and fetched page.
/// Includes unlinked artists. /// Includes unlinked artists.
pub artists_txt: String, pub artists_txt: String,
/// Music album type /// Album type (Album/Single/EP)
pub album_type: AlbumType, pub album_type: AlbumType,
/// Release year /// Release year
pub year: u16, pub year: Option<u16>,
/// Is the album by 'Various artists'? /// Is the album by 'Various artists'?
pub by_va: bool, pub by_va: bool,
/// Album tracks /// Album tracks
pub tracks: Vec<TrackItem>, pub tracks: Vec<TrackItem>,
/// Album variants
pub variants: Vec<AlbumItem>,
} }

View file

@ -381,6 +381,28 @@ impl TextComponents {
Some(self.to_string()) 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 { 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",
},
],
),
]
"###);
}
} }

View file

@ -27,6 +27,12 @@ pub static PLAYLIST_ID_REGEX: Lazy<Regex> =
pub static VANITY_PATH_REGEX: Lazy<Regex> = pub static VANITY_PATH_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^/?(?:(?:c\/|user\/)?[A-z0-9]+)|(?:@[A-z0-9-_.]+)$").unwrap()); 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] = const CONTENT_PLAYBACK_NONCE_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

View file

@ -280,10 +280,11 @@ async fn get_player(
"1bfOsni7EgI", "1bfOsni7EgI",
"extraction error: Video cant be played because of DRM/Geoblock. Reason (from YT): " "extraction error: Video cant be played because of DRM/Geoblock. Reason (from YT): "
)] )]
#[case::private( // YouTube sometimes returns "Video unavailable" for this video
"s7_qI6_mIXc", // #[case::private(
"extraction error: Video cant be played because of private video. Reason (from YT): " // "s7_qI6_mIXc",
)] // "extraction error: Video cant be played because of being private. Reason (from YT): "
// )]
#[case::t1( #[case::t1(
"CUO8secmc0g", "CUO8secmc0g",
"extraction error: Video cant be played because of DRM/Geoblock. Reason (from YT): " "extraction error: Video cant be played because of DRM/Geoblock. Reason (from YT): "