This repository has been archived on 2026-05-27. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
rustypipe/src/client/music_details.rs
2023-05-12 17:19:56 +02:00

488 lines
16 KiB
Rust

use std::borrow::Cow;
use serde::Serialize;
use crate::{
error::{Error, ExtractionError},
model::{paginator::Paginator, ArtistId, Lyrics, MusicRelated, TrackDetails, TrackItem},
param::Language,
serializer::MapResult,
};
use super::{
response::{
self,
music_item::{map_queue_item, MusicListMapper},
},
ClientType, MapResponse, QBrowse, RustyPipeQuery, YTContext,
};
#[derive(Debug, Serialize)]
struct QMusicDetails<'a> {
context: YTContext<'a>,
video_id: &'a str,
enable_persistent_playlist_panel: bool,
is_audio_only: bool,
tuner_setting_value: &'a str,
}
#[derive(Debug, Serialize)]
struct QRadio<'a> {
context: YTContext<'a>,
playlist_id: &'a str,
params: &'a str,
enable_persistent_playlist_panel: bool,
is_audio_only: bool,
tuner_setting_value: &'a str,
}
impl RustyPipeQuery {
/// Get the metadata of a YouTube music track
pub async fn music_details<S: AsRef<str>>(&self, video_id: S) -> Result<TrackDetails, Error> {
let video_id = video_id.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QMusicDetails {
context,
video_id,
enable_persistent_playlist_panel: true,
is_audio_only: true,
tuner_setting_value: "AUTOMIX_SETTING_NORMAL",
};
self.execute_request::<response::MusicDetails, _, _>(
ClientType::DesktopMusic,
"music_details",
video_id,
"next",
&request_body,
)
.await
}
/// Get the lyrics of a YouTube music track
///
/// The `lyrics_id` has to be obtained using [`RustyPipeQuery::music_details`].
pub async fn music_lyrics<S: AsRef<str>>(&self, lyrics_id: S) -> Result<Lyrics, Error> {
let lyrics_id = lyrics_id.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse {
context,
browse_id: lyrics_id,
};
self.execute_request::<response::MusicLyrics, _, _>(
ClientType::DesktopMusic,
"music_lyrics",
lyrics_id,
"browse",
&request_body,
)
.await
}
/// Get related items (tracks, playlists, artists) to a YouTube Music track
///
/// The `related_id` has to be obtained using [`RustyPipeQuery::music_details`].
pub async fn music_related<S: AsRef<str>>(&self, related_id: S) -> Result<MusicRelated, Error> {
let related_id = related_id.as_ref();
let context = self.get_context(ClientType::DesktopMusic, true, None).await;
let request_body = QBrowse {
context,
browse_id: related_id,
};
self.execute_request::<response::MusicRelated, _, _>(
ClientType::DesktopMusic,
"music_related",
related_id,
"browse",
&request_body,
)
.await
}
/// Get a YouTube Music radio (a dynamically generated playlist)
///
/// The `radio_id` can be obtained using [`RustyPipeQuery::music_artist`] to get an artist's radio.
pub async fn music_radio<S: AsRef<str>>(
&self,
radio_id: S,
) -> Result<Paginator<TrackItem>, Error> {
let radio_id = radio_id.as_ref();
let visitor_data = self.get_visitor_data().await?;
let context = self
.get_context(ClientType::DesktopMusic, true, Some(&visitor_data))
.await;
let request_body = QRadio {
context,
playlist_id: radio_id,
params: "wAEB8gECeAE%3D",
enable_persistent_playlist_panel: true,
is_audio_only: true,
tuner_setting_value: "AUTOMIX_SETTING_NORMAL",
};
self.execute_request::<response::MusicDetails, _, _>(
ClientType::DesktopMusic,
"music_radio",
radio_id,
"next",
&request_body,
)
.await
}
/// Get a YouTube Music radio (a dynamically generated playlist) for a track
pub async fn music_radio_track<S: AsRef<str>>(
&self,
video_id: S,
) -> Result<Paginator<TrackItem>, Error> {
self.music_radio(&format!("RDAMVM{}", video_id.as_ref()))
.await
}
/// Get a YouTube Music radio (a dynamically generated playlist) for a playlist
pub async fn music_radio_playlist<S: AsRef<str>>(
&self,
playlist_id: S,
) -> Result<Paginator<TrackItem>, Error> {
self.music_radio(&format!("RDAMPL{}", playlist_id.as_ref()))
.await
}
}
impl MapResponse<TrackDetails> for response::MusicDetails {
fn map_response(
self,
id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<TrackDetails>, ExtractionError> {
let tabs = self
.contents
.single_column_music_watch_next_results_renderer
.tabbed_renderer
.watch_next_tabbed_results_renderer
.tabs;
let mut content = None;
let mut lyrics_id = None;
let mut related_id = None;
for t in tabs {
match (t.tab_renderer.content, t.tab_renderer.endpoint) {
(Some(tc), _) => {
content = Some(tc.music_queue_renderer.content.playlist_panel_renderer);
}
(_, Some(endpoint)) => {
match endpoint
.browse_endpoint
.browse_endpoint_context_supported_configs
.browse_endpoint_context_music_config
.page_type
{
response::music_details::TabType::Lyrics => {
lyrics_id = Some(endpoint.browse_endpoint.browse_id);
}
response::music_details::TabType::Related => {
related_id = Some(endpoint.browse_endpoint.browse_id);
}
}
}
(None, None) => {}
}
}
let content = content.ok_or_else(|| ExtractionError::NotFound {
id: id.to_owned(),
msg: "no content".into(),
})?;
let track_item = content
.contents
.c
.into_iter()
.find_map(|item| match item {
response::music_item::PlaylistPanelVideo::PlaylistPanelVideoRenderer(track) => {
Some(track)
}
response::music_item::PlaylistPanelVideo::None => None,
})
.ok_or(ExtractionError::InvalidData(Cow::Borrowed("no video item")))?;
let mut track = map_queue_item(track_item, lang);
if track.c.id != id {
return Err(ExtractionError::WrongResult(format!(
"got wrong video id {}, expected {}",
track.c.id, id
)));
}
let mut warnings = content.contents.warnings;
warnings.append(&mut track.warnings);
Ok(MapResult {
c: TrackDetails {
track: track.c,
lyrics_id,
related_id,
},
warnings,
})
}
}
impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
fn map_response(
self,
id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
let tabs = self
.contents
.single_column_music_watch_next_results_renderer
.tabbed_renderer
.watch_next_tabbed_results_renderer
.tabs;
let content = tabs
.into_iter()
.find_map(|t| t.tab_renderer.content)
.ok_or_else(|| ExtractionError::NotFound {
id: id.to_owned(),
msg: "no content".into(),
})?
.music_queue_renderer
.content
.playlist_panel_renderer;
let mut warnings = content.contents.warnings;
let tracks = content
.contents
.c
.into_iter()
.filter_map(|item| match item {
response::music_item::PlaylistPanelVideo::PlaylistPanelVideoRenderer(item) => {
let mut track = map_queue_item(item, lang);
warnings.append(&mut track.warnings);
Some(track.c)
}
response::music_item::PlaylistPanelVideo::None => None,
})
.collect::<Vec<_>>();
let ctoken = content
.continuations
.into_iter()
.next()
.map(|c| c.next_continuation_data.continuation);
Ok(MapResult {
c: Paginator::new_ext(
None,
tracks,
ctoken,
None,
crate::model::paginator::ContinuationEndpoint::MusicNext,
),
warnings,
})
}
}
impl MapResponse<Lyrics> for response::MusicLyrics {
fn map_response(
self,
id: &str,
_lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Lyrics>, ExtractionError> {
let lyrics = self
.contents
.section_list_renderer
.and_then(|sl| {
sl.contents
.into_iter()
.find_map(|item| item.music_description_shelf_renderer)
})
.ok_or(match self.contents.message_renderer {
Some(msg) => ExtractionError::NotFound {
id: id.to_owned(),
msg: msg.text.into(),
},
None => ExtractionError::InvalidData(Cow::Borrowed("no content")),
})?;
Ok(MapResult {
c: Lyrics {
body: lyrics.description,
footer: lyrics.footer,
},
warnings: Vec::new(),
})
}
}
impl MapResponse<MusicRelated> for response::MusicRelated {
fn map_response(
self,
_id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<MusicRelated>, ExtractionError> {
// Find artist
let artist_id = self
.contents
.section_list_renderer
.contents
.iter()
.find_map(|section| match section {
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
shelf.header.as_ref().and_then(|h| {
h.music_carousel_shelf_basic_header_renderer
.title
.0
.iter()
.find_map(|c| {
let artist = ArtistId::from(c.clone());
if artist.id.is_some() {
Some(artist)
} else {
None
}
})
})
}
_ => None,
});
let mut mapper_tracks = MusicListMapper::new(lang);
let mut mapper = match artist_id {
Some(artist_id) => MusicListMapper::with_artist(lang, artist_id),
None => MusicListMapper::new(lang),
};
let mut sections = self.contents.section_list_renderer.contents.into_iter();
if let Some(response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf)) =
sections.next()
{
mapper_tracks.map_response(shelf.contents);
}
sections.for_each(|section| match section {
response::music_item::ItemSection::MusicShelfRenderer(shelf) => {
mapper.map_response(shelf.contents);
}
response::music_item::ItemSection::MusicCarouselShelfRenderer(shelf) => {
mapper.map_response(shelf.contents);
}
_ => {}
});
mapper.check_unknown()?;
mapper_tracks.check_unknown()?;
let mapped_tracks = mapper_tracks.conv_items();
let mut mapped = mapper.group_items();
let mut warnings = mapped_tracks.warnings;
warnings.append(&mut mapped.warnings);
Ok(MapResult {
c: MusicRelated {
tracks: mapped_tracks.c,
other_versions: mapped.c.tracks,
albums: mapped.c.albums,
artists: mapped.c.artists,
playlists: mapped.c.playlists,
},
warnings,
})
}
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader};
use path_macro::path;
use rstest::rstest;
use super::*;
use crate::{model, param::Language, util::tests::TESTFILES};
#[rstest]
#[case::mv("mv", "ZeerrnuLi5E")]
#[case::track("track", "7nigXQS1Xb0")]
fn map_music_details(#[case] name: &str, #[case] id: &str) {
let json_path = path!(*TESTFILES / "music_details" / format!("details_{name}.json"));
let json_file = File::open(json_path).unwrap();
let details: response::MusicDetails =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<model::TrackDetails> =
details.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_details_{name}"), map_res.c);
}
#[rstest]
#[case::mv("mv", "RDAMVMZeerrnuLi5E")]
#[case::track("track", "RDAMVM7nigXQS1Xb0")]
fn map_music_radio(#[case] name: &str, #[case] id: &str) {
let json_path = path!(*TESTFILES / "music_details" / format!("radio_{name}.json"));
let json_file = File::open(json_path).unwrap();
let radio: response::MusicDetails =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<TrackItem>> =
radio.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_radio_{name}"), map_res.c);
}
#[test]
fn map_lyrics() {
let json_path = path!(*TESTFILES / "music_details" / "lyrics.json");
let json_file = File::open(json_path).unwrap();
let lyrics: response::MusicLyrics =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Lyrics> = lyrics.map_response("", Language::En, None).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_music_lyrics"), map_res.c);
}
#[test]
fn map_related() {
let json_path = path!(*TESTFILES / "music_details" / "related.json");
let json_file = File::open(json_path).unwrap();
let lyrics: response::MusicRelated =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<MusicRelated> = lyrics.map_response("", Language::En, None).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_music_related"), map_res.c);
}
}