feat: add album variants
This commit is contained in:
parent
3b738a55ad
commit
44da9c7cc5
11 changed files with 459 additions and 199 deletions
|
|
@ -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<MusicPlaylist> for response::MusicPlaylist {
|
|||
fn map_response(
|
||||
self,
|
||||
id: &str,
|
||||
_lang: crate::param::Language,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||
) -> Result<MapResult<MusicPlaylist>, ExtractionError> {
|
||||
// dbg!(&self);
|
||||
|
|
@ -113,7 +116,7 @@ impl MapResponse<MusicPlaylist> 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<MusicPlaylist> for response::MusicPlaylist {
|
|||
.into_iter()
|
||||
.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);
|
||||
|
||||
let ctoken = shelf
|
||||
|
|
@ -134,7 +137,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
|
|||
.second_subtitle
|
||||
.first()
|
||||
.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 {
|
||||
|
|
@ -146,7 +149,7 @@ impl MapResponse<MusicPlaylist> 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<Paginator<TrackItem>> for response::MusicPlaylistCont {
|
|||
fn map_response(
|
||||
self,
|
||||
_id: &str,
|
||||
_lang: crate::param::Language,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||
) -> 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;
|
||||
mapper.map_response(shelf.contents);
|
||||
|
||||
|
|
@ -170,7 +173,7 @@ impl MapResponse<Paginator<TrackItem>> 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<MusicAlbum> for response::MusicPlaylist {
|
|||
fn map_response(
|
||||
self,
|
||||
id: &str,
|
||||
_lang: crate::param::Language,
|
||||
lang: crate::param::Language,
|
||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||
) -> Result<MapResult<MusicAlbum>, ExtractionError> {
|
||||
// dbg!(&self);
|
||||
|
|
@ -197,12 +200,12 @@ impl MapResponse<MusicAlbum> 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<MusicAlbum> 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::<TrackItem>::new(),
|
||||
true => MusicListMapper::new(lang),
|
||||
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);
|
||||
if let Some(res) = album_variants {
|
||||
mapper.map_response(res)
|
||||
}
|
||||
|
||||
Ok(MapResult {
|
||||
c: MusicAlbum {
|
||||
|
|
@ -281,7 +262,8 @@ impl MapResponse<MusicAlbum> for response::MusicPlaylist {
|
|||
album_type,
|
||||
year,
|
||||
by_va,
|
||||
tracks: mapper.items,
|
||||
tracks: mapper.tracks,
|
||||
variants: mapper.albums,
|
||||
},
|
||||
warnings: mapper.warnings,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
|
|
@ -79,145 +109,265 @@ impl From<MusicThumbnailRenderer> for Vec<model::Thumbnail> {
|
|||
*/
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct MusicListMapper<T> {
|
||||
artists: Option<(Vec<ChannelId>, String)>,
|
||||
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 items: Vec<T>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl<T> MusicListMapper<T> {
|
||||
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<ChannelId>, artists_txt: String) -> Self {
|
||||
pub fn with_artists(
|
||||
lang: Language,
|
||||
artists: Vec<ChannelId>,
|
||||
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<model::YouTubeMusicItem> {
|
||||
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::<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;
|
||||
|
||||
let album = columns.try_swap_remove(2).and_then(|col| {
|
||||
col.renderer
|
||||
.text
|
||||
.0
|
||||
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()
|
||||
.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);
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
(artists, artists_txt)
|
||||
}
|
||||
|
||||
/*
|
||||
impl MusicListMapper<model::YouTubeMusicItem> {
|
||||
fn map_item(&mut self, item: MusicItem) {
|
||||
if let Some(mapped) = self.map_music_item(item) {
|
||||
self.items.push(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_response(&mut self, mut res: MapResult<Vec<MusicItem>>) {
|
||||
self.warnings.append(&mut res.warnings);
|
||||
res.c.into_iter().for_each(|item| self.map_item(item));
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
impl MusicListMapper<model::TrackItem> {
|
||||
fn map_item(&mut self, item: MusicItem) {
|
||||
if let Some(model::YouTubeMusicItem::Track(track)) = self.map_music_item(item) {
|
||||
self.items.push(track);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_response(&mut self, mut res: MapResult<Vec<MusicItem>>) {
|
||||
self.warnings.append(&mut res.warnings);
|
||||
res.c.into_iter().for_each(|item| self.map_item(item));
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<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: u16,
|
||||
pub year: Option<u16>,
|
||||
}
|
||||
|
||||
/// YouTube Music playlist list item
|
||||
|
|
@ -943,7 +941,7 @@ pub struct MusicPlaylistItem {
|
|||
/// Playlist thumbnail
|
||||
pub thumbnail: Vec<Thumbnail>,
|
||||
/// Channel of the playlist
|
||||
pub channel: Option<ChannelTag>,
|
||||
pub channel: Option<ChannelId>,
|
||||
/// Number of tracks in the playlist
|
||||
pub track_count: Option<u64>,
|
||||
/// 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<Thumbnail>,
|
||||
/// Artists of the album
|
||||
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.
|
||||
/// 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<u16>,
|
||||
/// Is the album by 'Various artists'?
|
||||
pub by_va: bool,
|
||||
/// Album tracks
|
||||
pub tracks: Vec<TrackItem>,
|
||||
/// Album variants
|
||||
pub variants: Vec<AlbumItem>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -381,6 +381,28 @@ impl TextComponents {
|
|||
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 {
|
||||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
"###);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-_";
|
||||
|
||||
|
|
|
|||
Reference in a new issue