1use super::{
2 DESCRIPTION_SHELF_RUNS, EpisodeDate, EpisodeDuration, ParseFrom, ParsedSongAlbum,
3 ParsedUploadArtist, ParsedUploadSongAlbum, ProcessedResult, STRAPLINE_TEXT, TITLE_TEXT,
4 TWO_COLUMN, fixed_column_item_pointer, flex_column_item_pointer, parse_flex_column_item,
5 parse_library_management_items_from_menu, parse_upload_song_album, parse_upload_song_artists,
6};
7use crate::common::{
8 ApiOutcome, ArtistChannelID, ContinuationParams, EpisodeID, Explicit, LibraryManager,
9 LikeStatus, PlaylistID, SetVideoID, Thumbnail, UploadEntityID, VideoID,
10};
11use crate::continuations::ParseFromContinuable;
12use crate::nav_consts::{
13 APPEND_CONTINUATION_ITEMS, BADGE_LABEL, CONTENT, CONTINUATION_RENDERER_COMMAND,
14 DELETION_ENTITY_ID, DISPLAY_POLICY, FACEPILE_AVATAR_URL, FACEPILE_TEXT, LIVE_BADGE_LABEL,
15 MENU_ITEMS, MENU_LIKE_STATUS, MRLIR, MUSIC_PLAYLIST_SHELF, NAVIGATION_BROWSE_ID,
16 NAVIGATION_PLAYLIST_ID, NAVIGATION_VIDEO_ID, NAVIGATION_VIDEO_TYPE, PLAY_BUTTON,
17 PLAYLIST_PANEL_CONTINUATION, PPR, RADIO_CONTINUATION_PARAMS, RESPONSIVE_HEADER, RUN_TEXT,
18 SECOND_SUBTITLE_RUNS, SECONDARY_SECTION_LIST_RENDERER, SECTION_LIST_ITEM, TAB_CONTENT,
19 TEXT_RUN, TEXT_RUN_TEXT, THUMBNAIL, THUMBNAILS, WATCH_NEXT_CONTENT, WATCH_VIDEO_ID,
20};
21use crate::query::playlist::{
22 CreatePlaylistType, GetPlaylistDetailsQuery, GetWatchPlaylistQueryID, PrivacyStatus,
23 SpecialisedQuery,
24};
25use crate::query::{
26 AddPlaylistItemsQuery, CreatePlaylistQuery, DeletePlaylistQuery, EditPlaylistQuery,
27 GetPlaylistTracksQuery, GetWatchPlaylistQuery, RemovePlaylistItemsQuery,
28};
29use crate::youtube_enums::YoutubeMusicVideoType;
30use crate::{Error, Result};
31use const_format::concatcp;
32use json_crawler::{JsonCrawler, JsonCrawlerIterator, JsonCrawlerOwned};
33use serde::{Deserialize, Serialize};
34
35#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
36#[non_exhaustive]
37pub struct GetPlaylistDetails {
38 pub id: PlaylistID<'static>,
39 pub privacy: Option<PrivacyStatus>,
42 pub title: String,
43 pub description: Option<String>,
44 pub author: String,
45 pub author_avatar_url: Option<String>,
46 pub year: String,
47 pub duration: String,
48 pub track_count_text: String,
49 pub views: Option<String>,
51 pub thumbnails: Vec<Thumbnail>,
52}
53#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
54pub struct AddPlaylistItem {
57 pub video_id: VideoID<'static>,
58 pub set_video_id: SetVideoID<'static>,
59}
60#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
61#[non_exhaustive]
62pub struct WatchPlaylistTrack {
63 pub title: String,
64 pub author: String,
65 pub duration: String,
66 pub thumbnails: Vec<Thumbnail>,
67 pub video_id: VideoID<'static>,
68}
69
70#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
71#[non_exhaustive]
72pub struct PlaylistSong {
75 pub video_id: VideoID<'static>,
76 pub track_no: usize,
77 pub album: ParsedSongAlbum,
78 pub duration: String,
79 pub library_management: Option<LibraryManager>,
82 pub title: String,
83 pub artists: Vec<super::ParsedSongArtist>,
84 pub like_status: LikeStatus,
86 pub thumbnails: Vec<Thumbnail>,
87 pub explicit: Explicit,
88 pub is_available: bool,
89 pub playlist_id: PlaylistID<'static>,
91}
92
93#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
94pub enum PlaylistItem {
95 Song(PlaylistSong),
96 Video(PlaylistVideo),
97 Episode(PlaylistEpisode),
98 UploadSong(PlaylistUploadSong),
99}
100
101#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
102#[non_exhaustive]
103pub struct PlaylistVideo {
104 pub video_id: VideoID<'static>,
105 pub track_no: usize,
106 pub duration: String,
107 pub title: String,
108 pub channel_name: String,
110 pub channel_id: ArtistChannelID<'static>,
111 pub like_status: LikeStatus,
113 pub thumbnails: Vec<Thumbnail>,
114 pub is_available: bool,
115 pub playlist_id: PlaylistID<'static>,
117}
118
119#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
120#[non_exhaustive]
121pub struct PlaylistEpisode {
122 pub episode_id: EpisodeID<'static>,
123 pub track_no: usize,
124 pub date: EpisodeDate,
125 pub duration: EpisodeDuration,
126 pub title: String,
127 pub podcast_name: String,
128 pub podcast_id: PlaylistID<'static>,
129 pub like_status: LikeStatus,
131 pub thumbnails: Vec<Thumbnail>,
132 pub is_available: bool,
133}
134
135#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
136#[non_exhaustive]
137pub struct PlaylistUploadSong {
138 pub entity_id: UploadEntityID<'static>,
139 pub video_id: VideoID<'static>,
140 pub track_no: usize,
141 pub duration: String,
142 pub album: Option<ParsedUploadSongAlbum>,
144 pub title: String,
145 pub artists: Vec<ParsedUploadArtist>,
147 pub like_status: LikeStatus,
149 pub thumbnails: Vec<Thumbnail>,
150}
151
152impl<'a> ParseFrom<RemovePlaylistItemsQuery<'a>> for () {
153 fn parse_from(_: ProcessedResult<RemovePlaylistItemsQuery<'a>>) -> crate::Result<Self> {
154 Ok(())
155 }
156}
157impl<'a, C: CreatePlaylistType> ParseFrom<CreatePlaylistQuery<'a, C>> for PlaylistID<'static> {
158 fn parse_from(p: ProcessedResult<CreatePlaylistQuery<'a, C>>) -> crate::Result<Self> {
159 let mut json_crawler: JsonCrawlerOwned = p.into();
160 json_crawler
161 .take_value_pointer("/playlistId")
162 .map_err(Into::into)
163 }
164}
165impl<'a, T: SpecialisedQuery> ParseFrom<AddPlaylistItemsQuery<'a, T>> for Vec<AddPlaylistItem> {
166 fn parse_from(p: ProcessedResult<AddPlaylistItemsQuery<'a, T>>) -> crate::Result<Self> {
167 let mut json_crawler: JsonCrawlerOwned = p.into();
168 let status: ApiOutcome = json_crawler.borrow_pointer("/status")?.take_value()?;
169 if let ApiOutcome::Failure = status {
170 return Err(Error::status_failed());
171 }
172 json_crawler
173 .navigate_pointer("/playlistEditResults")?
174 .try_iter_mut()?
175 .map(|r| {
176 let mut r = r.navigate_pointer("/playlistEditVideoAddedResultData")?;
177 Ok(AddPlaylistItem {
178 video_id: r.take_value_pointer("/videoId")?,
179 set_video_id: r.take_value_pointer("/setVideoId")?,
180 })
181 })
182 .collect()
183 }
184}
185impl<'a> ParseFrom<EditPlaylistQuery<'a>> for ApiOutcome {
186 fn parse_from(p: ProcessedResult<EditPlaylistQuery<'a>>) -> crate::Result<Self> {
187 let json_crawler: JsonCrawlerOwned = p.into();
188 json_crawler
189 .navigate_pointer("/status")?
190 .take_value()
191 .map_err(Into::into)
192 }
193}
194impl<'a> ParseFrom<DeletePlaylistQuery<'a>> for () {
195 fn parse_from(_: ProcessedResult<DeletePlaylistQuery<'a>>) -> crate::Result<Self> {
196 Ok(())
197 }
198}
199
200impl<'a> ParseFrom<GetPlaylistDetailsQuery<'a>> for GetPlaylistDetails {
201 fn parse_from(p: ProcessedResult<GetPlaylistDetailsQuery<'a>>) -> crate::Result<Self> {
202 let json_crawler: JsonCrawlerOwned = p.into();
203 get_playlist_details(json_crawler)
204 }
205}
206
207impl<'a> ParseFromContinuable<GetPlaylistTracksQuery<'a>> for Vec<PlaylistItem> {
208 fn parse_from_continuable(
209 p: ProcessedResult<GetPlaylistTracksQuery<'a>>,
210 ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
211 let json_crawler: JsonCrawlerOwned = p.into();
212 let music_playlist_shelf = json_crawler.navigate_pointer(concatcp!(
213 TWO_COLUMN,
214 SECONDARY_SECTION_LIST_RENDERER,
215 CONTENT,
216 MUSIC_PLAYLIST_SHELF,
217 "/contents"
218 ))?;
219 parse_playlist_items(music_playlist_shelf)
220 }
221 fn parse_continuation(
222 p: ProcessedResult<crate::query::GetContinuationsQuery<'_, GetPlaylistTracksQuery<'a>>>,
223 ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
224 let json_crawler: JsonCrawlerOwned = p.into();
225 let continuation_items = json_crawler.navigate_pointer(APPEND_CONTINUATION_ITEMS)?;
226 parse_playlist_items(continuation_items)
227 }
228}
229
230impl<T: GetWatchPlaylistQueryID> ParseFromContinuable<GetWatchPlaylistQuery<T>>
231 for Vec<WatchPlaylistTrack>
232{
233 fn parse_from_continuable(
234 p: ProcessedResult<GetWatchPlaylistQuery<T>>,
235 ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
236 let json_crawler: JsonCrawlerOwned = p.into();
237 let mut playlist_panel =
238 json_crawler.navigate_pointer(concatcp!(WATCH_NEXT_CONTENT, PPR))?;
239 let continuation_params = playlist_panel
240 .take_value_pointer(RADIO_CONTINUATION_PARAMS)
241 .ok();
242 let tracks = playlist_panel
243 .navigate_pointer("/contents")?
244 .try_into_iter()?
245 .map(parse_watch_playlist_track)
246 .collect::<Result<Vec<_>>>()?;
247 Ok((tracks, continuation_params))
248 }
249 fn parse_continuation(
250 p: ProcessedResult<crate::query::GetContinuationsQuery<'_, GetWatchPlaylistQuery<T>>>,
251 ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
252 let json_crawler: JsonCrawlerOwned = p.into();
253 let mut playlist_panel = json_crawler.navigate_pointer(PLAYLIST_PANEL_CONTINUATION)?;
254 let continuation_params = playlist_panel
255 .take_value_pointer(RADIO_CONTINUATION_PARAMS)
256 .ok();
257 let tracks = playlist_panel
258 .navigate_pointer("/contents")?
259 .try_into_iter()?
260 .map(parse_watch_playlist_track)
261 .collect::<Result<Vec<_>>>()?;
262 Ok((tracks, continuation_params))
263 }
264}
265
266fn parse_watch_playlist_track(mut item: impl JsonCrawler) -> Result<WatchPlaylistTrack> {
267 let video_renderer_paths = [
268 "/playlistPanelVideoRenderer",
269 "/playlistPanelVideoWrapperRenderer/primaryRenderer/playlistPanelVideoRenderer",
270 ];
271 item.apply_function_at_paths(
272 &video_renderer_paths,
273 parse_watch_playlist_track_from_video_renderer,
274 )?
275}
276
277fn parse_watch_playlist_track_from_video_renderer<C: JsonCrawler>(
278 mut video_renderer: C::BorrowTo<'_>,
279) -> Result<WatchPlaylistTrack> {
280 let title = video_renderer.take_value_pointer(TITLE_TEXT)?;
281 let author = video_renderer.take_value_pointer(concatcp!("/shortBylineText", RUN_TEXT))?;
282 let duration = video_renderer.take_value_pointer(concatcp!("/lengthText", RUN_TEXT))?;
283 let video_id = video_renderer.take_value_pointer(NAVIGATION_VIDEO_ID)?;
284 let thumbnails = video_renderer.take_value_pointer(THUMBNAIL)?;
285 Ok(WatchPlaylistTrack {
286 title,
287 author,
288 duration,
289 thumbnails,
290 video_id,
291 })
292}
293
294pub(crate) fn parse_playlist_song(
295 title: String,
296 track_no: usize,
297 mut data: impl JsonCrawler,
298) -> Result<PlaylistSong> {
299 let video_id = data.take_value_pointer(concatcp!(
300 PLAY_BUTTON,
301 "/playNavigationEndpoint",
302 WATCH_VIDEO_ID
303 ))?;
304 let library_management =
305 parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
306 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
307 let artists = super::parse_song_artists(&mut data, 1)?;
308 let album_col_idx = if data.path_exists("/flexColumns/3") {
313 3
314 } else {
315 2
316 };
317 let album = super::parse_song_album(&mut data, album_col_idx)?;
318 let duration = data
319 .borrow_pointer(fixed_column_item_pointer(0))?
320 .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
321 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
322 let is_available = data
323 .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
324 .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
325 .unwrap_or(true);
326
327 let explicit = if data.path_exists(BADGE_LABEL) {
328 Explicit::IsExplicit
329 } else {
330 Explicit::NotExplicit
331 };
332 let playlist_id = data.take_value_pointer(concatcp!(
333 MENU_ITEMS,
334 "/0/menuNavigationItemRenderer",
335 NAVIGATION_PLAYLIST_ID
336 ))?;
337 Ok(PlaylistSong {
338 video_id,
339 track_no,
340 duration,
341 library_management,
342 title,
343 artists,
344 like_status,
345 thumbnails,
346 explicit,
347 album,
348 playlist_id,
349 is_available,
350 })
351}
352pub(crate) fn parse_playlist_upload_song(
353 title: String,
354 track_no: usize,
355 mut data: impl JsonCrawler,
356) -> Result<PlaylistUploadSong> {
357 let duration = data
358 .borrow_pointer(fixed_column_item_pointer(0))?
359 .take_value_pointer(TEXT_RUN_TEXT)?;
360 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
361 let video_id = data.take_value_pointer(concatcp!(
362 PLAY_BUTTON,
363 "/playNavigationEndpoint/watchEndpoint/videoId"
364 ))?;
365 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
366 let artists = parse_upload_song_artists(data.borrow_mut(), 1).unwrap_or_default();
368 let album = parse_upload_song_album(data.borrow_mut(), 2).ok();
370 let mut menu = data.navigate_pointer(MENU_ITEMS)?;
371 let entity_id = menu
372 .try_iter_mut()?
373 .find_path(DELETION_ENTITY_ID)?
374 .take_value()?;
375 Ok(PlaylistUploadSong {
376 entity_id,
377 video_id,
378 album,
379 duration,
380 like_status,
381 title,
382 artists,
383 thumbnails,
384 track_no,
385 })
386}
387
388pub(crate) fn parse_playlist_episode(
389 title: String,
390 track_no: usize,
391 mut data: impl JsonCrawler,
392) -> Result<PlaylistEpisode> {
393 let video_id = data.take_value_pointer(concatcp!(
394 PLAY_BUTTON,
395 "/playNavigationEndpoint",
396 WATCH_VIDEO_ID
397 ))?;
398 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
399 let is_live = data.path_exists(LIVE_BADGE_LABEL);
400 let (duration, date) = match is_live {
401 true => (EpisodeDuration::Live, EpisodeDate::Live),
402 false => {
403 let date = parse_flex_column_item(&mut data, 2, 0)?;
404 let duration =
405 data.borrow_pointer(fixed_column_item_pointer(0))
406 .and_then(|mut i| {
407 i.take_value_pointer("/text/simpleText")
408 .or_else(|_| i.take_value_pointer("/text/runs/0/text"))
409 })?;
410 (
411 EpisodeDuration::Recorded { duration },
412 EpisodeDate::Recorded { date },
413 )
414 }
415 };
416 let podcast_name = parse_flex_column_item(&mut data, 1, 0)?;
417 let podcast_id = data
418 .borrow_pointer(flex_column_item_pointer(1))?
419 .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
420 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
421 let is_available = data
422 .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
423 .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
424 .unwrap_or(true);
425 Ok(PlaylistEpisode {
426 episode_id: video_id,
427 duration,
428 title,
429 like_status,
430 thumbnails,
431 date,
432 podcast_name,
433 podcast_id,
434 is_available,
435 track_no,
436 })
437}
438pub(crate) fn parse_playlist_video(
439 title: String,
440 track_no: usize,
441 mut data: impl JsonCrawler,
442) -> Result<PlaylistVideo> {
443 let video_id = data.take_value_pointer(concatcp!(
444 PLAY_BUTTON,
445 "/playNavigationEndpoint",
446 WATCH_VIDEO_ID
447 ))?;
448 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
449 let channel_name = parse_flex_column_item(&mut data, 1, 0)?;
450 let channel_id = data
451 .borrow_pointer(flex_column_item_pointer(1))?
452 .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
453 let duration = data
454 .borrow_pointer(fixed_column_item_pointer(0))?
455 .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
456 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
457 let is_available = data
458 .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
459 .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
460 .unwrap_or(true);
461
462 let playlist_id = data.take_value_pointer(concatcp!(
463 MENU_ITEMS,
464 "/0/menuNavigationItemRenderer",
465 NAVIGATION_PLAYLIST_ID
466 ))?;
467 Ok(PlaylistVideo {
468 video_id,
469 track_no,
470 duration,
471 title,
472 like_status,
473 thumbnails,
474 playlist_id,
475 is_available,
476 channel_name,
477 channel_id,
478 })
479}
480
481pub(crate) fn parse_playlist_item(
488 track_no: usize,
489 mut json: impl JsonCrawler,
490) -> Result<Option<PlaylistItem>> {
491 let Ok(mut data) = json.borrow_pointer(MRLIR) else {
492 return Ok(None);
493 };
494 let title = super::parse_flex_column_item(&mut data, 0, 0)?;
495 if title == "Song deleted" {
496 return Ok(None);
497 }
498 if let Ok("MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT") =
500 data.take_value_pointer::<String>(DISPLAY_POLICY).as_deref()
501 {
502 return Ok(None);
503 };
504 let video_type_path = concatcp!(
505 PLAY_BUTTON,
506 "/playNavigationEndpoint",
507 NAVIGATION_VIDEO_TYPE
508 );
509 let video_type: YoutubeMusicVideoType = data.take_value_pointer(video_type_path)?;
510 let item = match video_type {
511 YoutubeMusicVideoType::Ugc
512 | YoutubeMusicVideoType::Omv
513 | YoutubeMusicVideoType::Shoulder
514 | YoutubeMusicVideoType::OfficialSourceMusic => Some(PlaylistItem::Video(
515 parse_playlist_video(title, track_no, data)?,
516 )),
517 YoutubeMusicVideoType::Atv => Some(PlaylistItem::Song(parse_playlist_song(
518 title, track_no, data,
519 )?)),
520 YoutubeMusicVideoType::Upload => Some(PlaylistItem::UploadSong(
521 parse_playlist_upload_song(title, track_no, data)?,
522 )),
523 YoutubeMusicVideoType::Episode => Some(PlaylistItem::Episode(parse_playlist_episode(
524 title, track_no, data,
525 )?)),
526 };
527 Ok(item)
528}
529pub(crate) fn parse_playlist_items<C>(
531 json: C,
532) -> Result<(Vec<PlaylistItem>, Option<ContinuationParams<'static>>)>
533where
534 C: JsonCrawler,
535 C::IntoIter: DoubleEndedIterator,
536{
537 let mut items = json.try_into_iter()?;
538 let mut last_item = items.next_back();
539 let continuation_params = last_item.as_mut().and_then(|ref mut last_item| {
540 last_item
541 .take_value_pointer(CONTINUATION_RENDERER_COMMAND)
542 .ok()
543 });
544 let items = items
545 .chain(last_item)
546 .enumerate()
547 .filter_map(|(idx, item)| parse_playlist_item(idx + 1, item).transpose())
548 .collect::<Result<_>>()?;
549 Ok((items, continuation_params))
550}
551
552fn get_playlist_details(json_crawler: JsonCrawlerOwned) -> Result<GetPlaylistDetails> {
554 let mut columns = json_crawler.navigate_pointer(TWO_COLUMN)?;
555 let header =
556 columns.borrow_pointer(concatcp!(TAB_CONTENT, SECTION_LIST_ITEM, RESPONSIVE_HEADER));
557 let mut header = match header {
559 Ok(header) => header,
560 Err(_) => columns.borrow_pointer(concatcp!(
561 TAB_CONTENT,
562 SECTION_LIST_ITEM,
563 "/musicEditablePlaylistDetailHeaderRenderer/header",
564 RESPONSIVE_HEADER
565 ))?,
566 };
567 let title = header.take_value_pointer(TITLE_TEXT)?;
568 let author = header.take_value_pointers(&[STRAPLINE_TEXT, FACEPILE_TEXT])?;
570 let thumbnails: Vec<Thumbnail> = header.take_value_pointer(THUMBNAILS)?;
571 let author_avatar_url: Option<String> = header.take_value_pointer(FACEPILE_AVATAR_URL).ok();
572 let description = header
573 .borrow_pointer(DESCRIPTION_SHELF_RUNS)
574 .and_then(|d| d.try_into_iter())
575 .ok()
576 .map(|r| {
577 r.map(|mut r| r.take_value_pointer::<String>("/text"))
578 .collect::<std::result::Result<String, _>>()
579 })
580 .transpose()?;
581 let mut subtitle = header.borrow_pointer("/subtitle/runs")?;
582 let subtitle_len = subtitle.try_iter_mut()?.len();
583 let privacy = if subtitle_len == 5 {
584 Some(subtitle.take_value_pointer("/2/text")?)
585 } else {
586 None
587 };
588 let year = subtitle.take_value_pointer(format!("/{}/text", subtitle_len.saturating_sub(1)))?;
589 let mut second_subtitle_runs = header.borrow_pointer(SECOND_SUBTITLE_RUNS)?;
590 let duration = second_subtitle_runs
591 .try_iter_mut()?
592 .try_last()?
593 .take_value_pointer("/text")?;
594 let track_count_text = second_subtitle_runs.try_expect(
595 "second subtitle runs should count at least 3 runs",
596 |second_subtitle_runs| {
597 second_subtitle_runs
598 .try_iter_mut()?
599 .rev()
600 .nth(2)
601 .map(|mut run| run.take_value_pointer("/text"))
602 .transpose()
603 },
604 )?;
605 let views = second_subtitle_runs
606 .try_iter_mut()?
607 .rev()
608 .nth(4)
609 .map(|mut item| item.take_value_pointer("/text"))
610 .transpose()?;
611 let id = header
612 .navigate_pointer("/buttons")?
613 .try_into_iter()?
614 .find_path("/musicPlayButtonRenderer")?
615 .take_value_pointer("/playNavigationEndpoint/watchEndpoint/playlistId")?;
616 Ok(GetPlaylistDetails {
617 id,
618 privacy,
619 title,
620 description,
621 author,
622 year,
623 duration,
624 track_count_text,
625 thumbnails,
626 views,
627 author_avatar_url,
628 })
629}
630
631#[cfg(test)]
632mod tests {
633 use crate::auth::BrowserToken;
634 use crate::common::{ApiOutcome, PlaylistID, VideoID, YoutubeID};
635 use crate::query::playlist::GetPlaylistDetailsQuery;
636 use crate::query::{
637 AddPlaylistItemsQuery, EditPlaylistQuery, GetPlaylistTracksQuery, GetWatchPlaylistQuery,
638 };
639 use crate::{Error, process_json};
640 use pretty_assertions::assert_eq;
641 use std::path::Path;
642
643 #[tokio::test]
644 async fn test_add_playlist_items_query_failure() {
645 let source_path = Path::new("./test_json/add_playlist_items_failure_20240626.json");
646 let source = tokio::fs::read_to_string(source_path)
647 .await
648 .expect("Expect file read to pass during tests");
649 let query = AddPlaylistItemsQuery::new_from_playlist(
651 PlaylistID::from_raw(""),
652 PlaylistID::from_raw(""),
653 );
654 let output = process_json::<_, BrowserToken>(source, query);
655 let err: crate::Result<()> = Err(Error::status_failed());
656 assert_eq!(format!("{:?}", err), format!("{:?}", output));
657 }
658 #[tokio::test]
659 async fn test_add_playlist_items_query() {
660 parse_test!(
661 "./test_json/add_playlist_items_20240626.json",
662 "./test_json/add_playlist_items_20240626_output.txt",
663 AddPlaylistItemsQuery::new_from_playlist(
664 PlaylistID::from_raw(""),
665 PlaylistID::from_raw(""),
666 ),
667 BrowserToken
668 );
669 }
670 #[tokio::test]
671 async fn test_edit_playlist_title_query() {
672 parse_test_value!(
673 "./test_json/edit_playlist_title_20240626.json",
674 ApiOutcome::Success,
675 EditPlaylistQuery::new_title(PlaylistID::from_raw(""), ""),
676 BrowserToken
677 );
678 }
679 #[tokio::test]
680 async fn test_get_playlist_details_query_2024() {
681 parse_test!(
682 "./test_json/get_playlist_20240624.json",
683 "./test_json/get_playlist_details_20240624_output.txt",
684 GetPlaylistDetailsQuery::new(PlaylistID::from_raw("")),
685 BrowserToken
686 );
687 }
688 #[tokio::test]
689 async fn test_get_playlist_details_query_2025() {
691 parse_test!(
692 "./test_json/get_playlist_20250604.json",
693 "./test_json/get_playlist_details_20250604_output.txt",
694 GetPlaylistDetailsQuery::new(PlaylistID::from_raw("")),
695 BrowserToken
696 );
697 }
698 #[tokio::test]
699 async fn test_get_playlist_details_query_2024_no_channel_thumbnail() {
700 parse_test!(
701 "./test_json/get_playlist_no_channel_thumbnail_20240818.json",
702 "./test_json/get_playlist_details_no_channel_thumbnail_20240818_output.txt",
703 GetPlaylistDetailsQuery::new(PlaylistID::from_raw("")),
704 BrowserToken
705 );
706 }
707 #[tokio::test]
708 async fn test_get_playlist_tracks_query() {
709 parse_with_matching_continuation_test!(
710 "./test_json/get_playlist_20250604.json",
711 "./test_json/get_playlist_continuation_20250604.json",
712 "./test_json/get_playlist_tracks_20250604_output.txt",
713 GetPlaylistTracksQuery::new(PlaylistID::from_raw("")),
714 BrowserToken
715 );
716 }
717 #[tokio::test]
718 async fn test_get_watch_playlist_query() {
719 parse_with_matching_continuation_test!(
720 "./test_json/get_watch_playlist_20250630.json",
721 "./test_json/get_watch_playlist_continuation_20250630.json",
722 "./test_json/get_watch_playlist_20250630_output.txt",
723 GetWatchPlaylistQuery::new_from_video_id(VideoID::from_raw("")),
724 BrowserToken
725 );
726 }
727}