1use super::{
2 parse_playlist_items, ParseFrom, PlaylistItem, ProcessedResult, DESCRIPTION_SHELF_RUNS,
3 HEADER_DETAIL, STRAPLINE_TEXT, STRAPLINE_THUMBNAIL, SUBTITLE2, SUBTITLE3, THUMBNAIL_CROPPED,
4 TITLE_TEXT, TWO_COLUMN,
5};
6use crate::{
7 common::{ApiOutcome, PlaylistID, SetVideoID, Thumbnail, VideoID},
8 nav_consts::{
9 RESPONSIVE_HEADER, SECOND_SUBTITLE_RUNS, SECTION_LIST_ITEM, SINGLE_COLUMN_TAB, TAB_CONTENT,
10 },
11 query::{
12 AddPlaylistItemsQuery, CreatePlaylistQuery, CreatePlaylistType, DeletePlaylistQuery,
13 EditPlaylistQuery, GetPlaylistQuery, PrivacyStatus, RemovePlaylistItemsQuery,
14 SpecialisedQuery,
15 },
16 Error, Result,
17};
18use const_format::concatcp;
19use json_crawler::{JsonCrawler, JsonCrawlerIterator, JsonCrawlerOwned};
20use serde::{Deserialize, Serialize};
21
22#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
23#[non_exhaustive]
24pub struct GetPlaylist {
25 pub id: PlaylistID<'static>,
26 pub privacy: Option<PrivacyStatus>,
29 pub title: String,
30 pub description: Option<String>,
31 pub author: String,
32 pub year: String,
33 pub duration: String,
34 pub track_count_text: String,
35 pub views: Option<String>,
37 pub thumbnails: Vec<Thumbnail>,
38 pub suggestions: Vec<()>,
40 pub related: Vec<()>,
42 pub tracks: Vec<PlaylistItem>,
43}
44#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
45pub struct AddPlaylistItem {
48 pub video_id: VideoID<'static>,
49 pub set_video_id: SetVideoID<'static>,
50}
51
52impl<'a> ParseFrom<RemovePlaylistItemsQuery<'a>> for () {
53 fn parse_from(_: ProcessedResult<RemovePlaylistItemsQuery<'a>>) -> crate::Result<Self> {
54 Ok(())
55 }
56}
57impl<'a, C: CreatePlaylistType> ParseFrom<CreatePlaylistQuery<'a, C>> for PlaylistID<'static> {
58 fn parse_from(p: ProcessedResult<CreatePlaylistQuery<'a, C>>) -> crate::Result<Self> {
59 let mut json_crawler: JsonCrawlerOwned = p.into();
60 json_crawler
61 .take_value_pointer("/playlistId")
62 .map_err(Into::into)
63 }
64}
65impl<'a, T: SpecialisedQuery> ParseFrom<AddPlaylistItemsQuery<'a, T>> for Vec<AddPlaylistItem> {
66 fn parse_from(p: ProcessedResult<AddPlaylistItemsQuery<'a, T>>) -> crate::Result<Self> {
67 let mut json_crawler: JsonCrawlerOwned = p.into();
68 let status: ApiOutcome = json_crawler.borrow_pointer("/status")?.take_value()?;
69 if let ApiOutcome::Failure = status {
70 return Err(Error::status_failed());
71 }
72 json_crawler
73 .navigate_pointer("/playlistEditResults")?
74 .try_iter_mut()?
75 .map(|r| {
76 let mut r = r.navigate_pointer("/playlistEditVideoAddedResultData")?;
77 Ok(AddPlaylistItem {
78 video_id: r.take_value_pointer("/videoId")?,
79 set_video_id: r.take_value_pointer("/setVideoId")?,
80 })
81 })
82 .collect()
83 }
84}
85impl<'a> ParseFrom<EditPlaylistQuery<'a>> for ApiOutcome {
86 fn parse_from(p: ProcessedResult<EditPlaylistQuery<'a>>) -> crate::Result<Self> {
87 let json_crawler: JsonCrawlerOwned = p.into();
88 json_crawler
89 .navigate_pointer("/status")?
90 .take_value()
91 .map_err(Into::into)
92 }
93}
94impl<'a> ParseFrom<DeletePlaylistQuery<'a>> for () {
95 fn parse_from(_: ProcessedResult<DeletePlaylistQuery<'a>>) -> crate::Result<Self> {
96 Ok(())
97 }
98}
99
100impl<'a> ParseFrom<GetPlaylistQuery<'a>> for GetPlaylist {
101 fn parse_from(p: ProcessedResult<GetPlaylistQuery<'a>>) -> crate::Result<Self> {
102 let json_crawler: JsonCrawlerOwned = p.into();
103 if json_crawler.path_exists("/header") {
104 get_playlist(json_crawler)
105 } else {
106 get_playlist_2024(json_crawler)
107 }
108 }
109}
110
111fn get_playlist(mut json_crawler: JsonCrawlerOwned) -> Result<GetPlaylist> {
112 let mut header = json_crawler.borrow_pointer(HEADER_DETAIL)?;
113 let title = header.take_value_pointer(TITLE_TEXT)?;
114 let privacy = None;
115 let suggestions = Vec::new();
117 let related = Vec::new();
119 let description = None;
121 let author = header.take_value_pointer(SUBTITLE2)?;
122 let year = header.take_value_pointer(SUBTITLE3)?;
123 let thumbnails = header.take_value_pointer(THUMBNAIL_CROPPED)?;
124 let mut second_subtitle_runs = header.navigate_pointer(SECOND_SUBTITLE_RUNS)?;
125 let duration = second_subtitle_runs
126 .try_iter_mut()?
127 .try_last()?
128 .take_value_pointer("/text")?;
129 let track_count_text = second_subtitle_runs.try_expect(
130 "second subtitle runs should count at least 3 runs",
131 |second_subtitle_runs| {
132 second_subtitle_runs
133 .try_iter_mut()?
134 .rev()
135 .nth(2)
136 .map(|mut run| run.take_value_pointer("/text"))
137 .transpose()
138 },
139 )?;
140 let views = second_subtitle_runs
141 .try_iter_mut()?
142 .rev()
143 .nth(4)
144 .map(|mut item| item.take_value_pointer("/text"))
145 .transpose()?;
146
147 let mut results = json_crawler.borrow_pointer(concatcp!(
148 SINGLE_COLUMN_TAB,
149 SECTION_LIST_ITEM,
150 "/musicPlaylistShelfRenderer"
151 ))?;
152 let id = results.take_value_pointer("/playlistId")?;
153 let music_shelf = results.navigate_pointer("/contents")?;
154 let tracks = parse_playlist_items(music_shelf)?;
155 Ok(GetPlaylist {
156 id,
157 privacy,
158 title,
159 description,
160 author,
161 year,
162 duration,
163 track_count_text,
164 thumbnails,
165 suggestions,
166 related,
167 views,
168 tracks,
169 })
170}
171
172fn get_playlist_2024(json_crawler: JsonCrawlerOwned) -> Result<GetPlaylist> {
174 let mut columns = json_crawler.navigate_pointer(TWO_COLUMN)?;
175 let header =
176 columns.borrow_pointer(concatcp!(TAB_CONTENT, SECTION_LIST_ITEM, RESPONSIVE_HEADER));
177 let mut header = match header {
179 Ok(header) => header,
180 Err(_) => columns.borrow_pointer(concatcp!(
181 TAB_CONTENT,
182 SECTION_LIST_ITEM,
183 "/musicEditablePlaylistDetailHeaderRenderer/header",
184 RESPONSIVE_HEADER
185 ))?,
186 };
187 let suggestions = Vec::new();
189 let related = Vec::new();
191 let title = header.take_value_pointer(TITLE_TEXT)?;
192 let author = header.take_value_pointer(STRAPLINE_TEXT)?;
193 let thumbnails: Vec<Thumbnail> = header
195 .take_value_pointer(STRAPLINE_THUMBNAIL)
196 .unwrap_or_default();
197 let description = header
198 .borrow_pointer(DESCRIPTION_SHELF_RUNS)
199 .and_then(|d| d.try_into_iter())
200 .ok()
201 .map(|r| {
202 r.map(|mut r| r.take_value_pointer::<String>("/text"))
203 .collect::<std::result::Result<String, _>>()
204 })
205 .transpose()?;
206 let mut subtitle = header.borrow_pointer("/subtitle/runs")?;
207 let subtitle_len = subtitle.try_iter_mut()?.len();
208 let privacy = if subtitle_len == 5 {
209 Some(subtitle.take_value_pointer("/2/text")?)
210 } else {
211 None
212 };
213 let year = subtitle.take_value_pointer(format!("/{}/text", subtitle_len.saturating_sub(1)))?;
214 let mut second_subtitle_runs = header.borrow_pointer(SECOND_SUBTITLE_RUNS)?;
215 let duration = second_subtitle_runs
216 .try_iter_mut()?
217 .try_last()?
218 .take_value_pointer("/text")?;
219 let track_count_text = second_subtitle_runs.try_expect(
220 "second subtitle runs should count at least 3 runs",
221 |second_subtitle_runs| {
222 second_subtitle_runs
223 .try_iter_mut()?
224 .rev()
225 .nth(2)
226 .map(|mut run| run.take_value_pointer("/text"))
227 .transpose()
228 },
229 )?;
230 let views = second_subtitle_runs
231 .try_iter_mut()?
232 .rev()
233 .nth(4)
234 .map(|mut item| item.take_value_pointer("/text"))
235 .transpose()?;
236 let id = header
237 .navigate_pointer("/buttons")?
238 .try_into_iter()?
239 .find_path("/musicPlayButtonRenderer")?
240 .take_value_pointer("/playNavigationEndpoint/watchEndpoint/playlistId")?;
241 let music_shelf = columns.borrow_pointer(
242 "/secondaryContents/sectionListRenderer/contents/0/musicPlaylistShelfRenderer/contents",
243 )?;
244 let tracks = parse_playlist_items(music_shelf)?;
245 Ok(GetPlaylist {
246 id,
247 privacy,
248 title,
249 description,
250 author,
251 year,
252 duration,
253 track_count_text,
254 thumbnails,
255 suggestions,
256 related,
257 views,
258 tracks,
259 })
260}
261
262#[cfg(test)]
263mod tests {
264 use crate::{
265 auth::BrowserToken,
266 common::{ApiOutcome, PlaylistID, YoutubeID},
267 process_json,
268 query::{AddPlaylistItemsQuery, EditPlaylistQuery, GetPlaylistQuery},
269 Error,
270 };
271 use pretty_assertions::assert_eq;
272 use std::path::Path;
273
274 #[tokio::test]
275 async fn test_get_playlist_query() {
276 parse_test!(
277 "./test_json/get_playlist_20240617.json",
278 "./test_json/get_playlist_20240617_output.txt",
279 GetPlaylistQuery::new(PlaylistID::from_raw("")),
280 BrowserToken
281 );
282 }
283 #[tokio::test]
284 async fn test_add_playlist_items_query_failure() {
285 let source_path = Path::new("./test_json/add_playlist_items_failure_20240626.json");
286 let source = tokio::fs::read_to_string(source_path)
287 .await
288 .expect("Expect file read to pass during tests");
289 let query = AddPlaylistItemsQuery::new_from_playlist(
291 PlaylistID::from_raw(""),
292 PlaylistID::from_raw(""),
293 );
294 let output = process_json::<_, BrowserToken>(source, query);
295 let err: crate::Result<()> = Err(Error::status_failed());
296 assert_eq!(format!("{:?}", err), format!("{:?}", output));
297 }
298 #[tokio::test]
299 async fn test_add_playlist_items_query() {
300 parse_test!(
301 "./test_json/add_playlist_items_20240626.json",
302 "./test_json/add_playlist_items_20240626_output.txt",
303 AddPlaylistItemsQuery::new_from_playlist(
304 PlaylistID::from_raw(""),
305 PlaylistID::from_raw(""),
306 ),
307 BrowserToken
308 );
309 }
310 #[tokio::test]
311 async fn test_edit_playlist_title_query() {
312 parse_test_value!(
313 "./test_json/edit_playlist_title_20240626.json",
314 ApiOutcome::Success,
315 EditPlaylistQuery::new_title(PlaylistID::from_raw(""), ""),
316 BrowserToken
317 );
318 }
319 #[tokio::test]
320 async fn test_get_playlist_query_2024() {
321 parse_test!(
322 "./test_json/get_playlist_20240624.json",
323 "./test_json/get_playlist_20240624_output.txt",
324 GetPlaylistQuery::new(PlaylistID::from_raw("")),
325 BrowserToken
326 );
327 }
328 #[tokio::test]
329 async fn test_get_playlist_query_2024_no_channel_thumbnail() {
330 parse_test!(
331 "./test_json/get_playlist_no_channel_thumbnail_20240818.json",
332 "./test_json/get_playlist_no_channel_thumbnail_20240818_output.txt",
333 GetPlaylistQuery::new(PlaylistID::from_raw("")),
334 BrowserToken
335 );
336 }
337}