feat: add music playlist

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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