1use super::{
2 BADGE_LABEL, DELETION_ENTITY_ID, EpisodeDate, EpisodeDuration, MENU_ITEMS, MENU_LIKE_STATUS,
3 MRLIR, MUSIC_SHELF, ParseFrom, ParsedSongAlbum, ParsedUploadArtist, ParsedUploadSongAlbum,
4 TEXT_RUN_TEXT, THUMBNAILS, TITLE_TEXT, fixed_column_item_pointer, flex_column_item_pointer,
5 parse_library_management_items_from_menu, parse_upload_song_album, parse_upload_song_artists,
6};
7use crate::Result;
8use crate::common::{
9 ApiOutcome, ArtistChannelID, EpisodeID, Explicit, FeedbackTokenRemoveFromHistory,
10 LibraryManager, LikeStatus, PlaylistID, Thumbnail, UploadEntityID, VideoID,
11};
12use crate::nav_consts::{
13 FEEDBACK_TOKEN, LIVE_BADGE_LABEL, MENU_SERVICE, NAVIGATION_BROWSE_ID, NAVIGATION_PLAYLIST_ID,
14 NAVIGATION_VIDEO_TYPE, PLAY_BUTTON, SECTION_LIST, SINGLE_COLUMN_TAB, TEXT_RUN, WATCH_VIDEO_ID,
15};
16use crate::parse::parse_flex_column_item;
17use crate::query::{AddHistoryItemQuery, GetHistoryQuery, RemoveHistoryItemsQuery};
18use crate::youtube_enums::YoutubeMusicVideoType;
19use const_format::concatcp;
20use json_crawler::{JsonCrawler, JsonCrawlerBorrowed, JsonCrawlerIterator, JsonCrawlerOwned};
21use serde::{Deserialize, Serialize};
22
23#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
24#[non_exhaustive]
25pub struct HistoryPeriod {
26 pub period_name: String,
27 pub items: Vec<HistoryItem>,
28}
29
30#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
31pub enum HistoryItem {
32 Song(HistoryItemSong),
33 Video(HistoryItemVideo),
34 Episode(HistoryItemEpisode),
35 UploadSong(HistoryItemUploadSong),
36}
37
38#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
39#[non_exhaustive]
40pub struct HistoryItemSong {
43 pub video_id: VideoID<'static>,
44 pub album: ParsedSongAlbum,
45 pub duration: String,
46 pub library_management: Option<LibraryManager>,
49 pub title: String,
50 pub artists: Vec<super::ParsedSongArtist>,
51 pub like_status: LikeStatus,
53 pub thumbnails: Vec<super::Thumbnail>,
54 pub explicit: Explicit,
55 pub is_available: bool,
56 pub playlist_id: PlaylistID<'static>,
58 pub feedback_token_remove: FeedbackTokenRemoveFromHistory<'static>,
59}
60
61#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
62#[non_exhaustive]
63pub struct HistoryItemVideo {
64 pub video_id: VideoID<'static>,
65 pub duration: String,
66 pub title: String,
67 pub channel_name: String,
69 pub channel_id: ArtistChannelID<'static>,
70 pub like_status: LikeStatus,
72 pub thumbnails: Vec<super::Thumbnail>,
73 pub is_available: bool,
74 pub playlist_id: PlaylistID<'static>,
76 pub feedback_token_remove: FeedbackTokenRemoveFromHistory<'static>,
77}
78
79#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
80#[non_exhaustive]
81pub struct HistoryItemEpisode {
82 pub episode_id: EpisodeID<'static>,
83 pub date: EpisodeDate,
85 pub duration: EpisodeDuration,
86 pub title: String,
87 pub podcast_name: String,
88 pub podcast_id: PlaylistID<'static>,
89 pub like_status: LikeStatus,
91 pub thumbnails: Vec<super::Thumbnail>,
92 pub is_available: bool,
93 pub feedback_token_remove: FeedbackTokenRemoveFromHistory<'static>,
94}
95
96#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
97#[non_exhaustive]
98pub struct HistoryItemUploadSong {
101 pub entity_id: UploadEntityID<'static>,
102 pub video_id: VideoID<'static>,
103 pub album: ParsedUploadSongAlbum,
104 pub duration: String,
105 pub like_status: LikeStatus,
106 pub title: String,
107 pub artists: Vec<ParsedUploadArtist>,
108 pub thumbnails: Vec<Thumbnail>,
109 pub feedback_token_remove: FeedbackTokenRemoveFromHistory<'static>,
110}
111
112impl ParseFrom<GetHistoryQuery> for Vec<HistoryPeriod> {
113 fn parse_from(p: super::ProcessedResult<GetHistoryQuery>) -> Result<Self> {
114 let json_crawler = JsonCrawlerOwned::from(p);
115 let contents = json_crawler.navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST))?;
116 contents
117 .try_into_iter()?
118 .map(parse_history_period)
119 .collect()
120 }
121}
122impl ParseFrom<RemoveHistoryItemsQuery<'_>> for Vec<ApiOutcome> {
123 fn parse_from(p: super::ProcessedResult<RemoveHistoryItemsQuery>) -> Result<Self> {
124 let json_crawler = JsonCrawlerOwned::from(p);
125 json_crawler
126 .navigate_pointer("/feedbackResponses")?
127 .try_into_iter()?
128 .map(|mut response| {
129 response
130 .take_value_pointer::<bool>("/isProcessed")
131 .map(|p| {
132 if p {
133 return ApiOutcome::Success;
134 }
135 ApiOutcome::Failure
137 })
138 })
139 .rev()
140 .collect::<json_crawler::CrawlerResult<_>>()
141 .map_err(Into::into)
142 }
143}
144impl ParseFrom<AddHistoryItemQuery<'_>> for () {
145 fn parse_from(_: crate::parse::ProcessedResult<AddHistoryItemQuery>) -> crate::Result<Self> {
146 Ok(())
148 }
149}
150
151fn parse_history_period(json: JsonCrawlerOwned) -> Result<HistoryPeriod> {
152 let mut data = json.navigate_pointer(MUSIC_SHELF)?;
153 let period_name = data.take_value_pointer(TITLE_TEXT)?;
154 let items = data
155 .navigate_pointer("/contents")?
156 .try_into_iter()?
157 .filter_map(|item| parse_history_item(item).transpose())
158 .collect::<Result<_>>()?;
159 Ok(HistoryPeriod { period_name, items })
160}
161fn parse_history_item(mut json: JsonCrawlerOwned) -> Result<Option<HistoryItem>> {
162 let Ok(mut data) = json.borrow_pointer(MRLIR) else {
163 return Ok(None);
164 };
165 let title = super::parse_flex_column_item(&mut data, 0, 0)?;
166 if title == "Shuffle all" {
167 return Ok(None);
168 }
169 let video_type_path = concatcp!(
170 PLAY_BUTTON,
171 "/playNavigationEndpoint",
172 NAVIGATION_VIDEO_TYPE
173 );
174 let video_type: YoutubeMusicVideoType = data.take_value_pointer(video_type_path)?;
175 let item = match video_type {
176 YoutubeMusicVideoType::Upload => Some(HistoryItem::UploadSong(
178 parse_history_item_upload_song(title, data)?,
179 )),
180 YoutubeMusicVideoType::Episode => Some(HistoryItem::Episode(parse_history_item_episode(
182 title, data,
183 )?)),
184 YoutubeMusicVideoType::Ugc
185 | YoutubeMusicVideoType::Omv
186 | YoutubeMusicVideoType::Shoulder
187 | YoutubeMusicVideoType::OfficialSourceMusic => {
188 Some(HistoryItem::Video(parse_history_item_video(title, data)?))
189 }
190 YoutubeMusicVideoType::Atv => {
191 Some(HistoryItem::Song(parse_history_item_song(title, data)?))
192 }
193 };
194 Ok(item)
195}
196
197fn parse_history_item_episode(
198 title: String,
199 mut data: JsonCrawlerBorrowed,
200) -> Result<HistoryItemEpisode> {
201 let video_id = data.take_value_pointer(concatcp!(
202 PLAY_BUTTON,
203 "/playNavigationEndpoint",
204 WATCH_VIDEO_ID
205 ))?;
206 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
207 let is_live = data.path_exists(LIVE_BADGE_LABEL);
208 let (duration, date) = match is_live {
209 true => (EpisodeDuration::Live, EpisodeDate::Live),
210 false => {
211 let date = parse_flex_column_item(&mut data, 2, 0)?;
212 let duration = data
213 .borrow_pointer(fixed_column_item_pointer(0))?
214 .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
215 (
216 EpisodeDuration::Recorded { duration },
217 EpisodeDate::Recorded { date },
218 )
219 }
220 };
221 let podcast_name = parse_flex_column_item(&mut data, 1, 0)?;
222 let podcast_id = data
223 .borrow_pointer(flex_column_item_pointer(1))?
224 .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
225 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
226 let is_available = data
227 .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
228 .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
229 .unwrap_or(true);
230 let feedback_token_remove = data
231 .navigate_pointer(MENU_ITEMS)?
232 .try_into_iter()?
233 .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
234 .take_value()?;
235 Ok(HistoryItemEpisode {
236 episode_id: video_id,
237 duration,
238 title,
239 like_status,
240 thumbnails,
241 date,
242 podcast_name,
243 podcast_id,
244 is_available,
245 feedback_token_remove,
246 })
247}
248fn parse_history_item_video(
249 title: String,
250 mut data: JsonCrawlerBorrowed,
251) -> Result<HistoryItemVideo> {
252 let video_id = data.take_value_pointer(concatcp!(
253 PLAY_BUTTON,
254 "/playNavigationEndpoint",
255 WATCH_VIDEO_ID
256 ))?;
257 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
258 let channel_name = parse_flex_column_item(&mut data, 1, 0)?;
259 let channel_id = data
260 .borrow_pointer(flex_column_item_pointer(1))?
261 .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
262 let duration = data
263 .borrow_pointer(fixed_column_item_pointer(0))?
264 .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
265 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
266 let is_available = data
267 .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
268 .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
269 .unwrap_or(true);
270 let mut menu = data.navigate_pointer(MENU_ITEMS)?;
271 let playlist_id = menu.take_value_pointer(concatcp!(
272 "/0/menuNavigationItemRenderer",
273 NAVIGATION_PLAYLIST_ID
274 ))?;
275 let feedback_token_remove = menu
276 .try_into_iter()?
277 .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
278 .take_value()?;
279 Ok(HistoryItemVideo {
280 video_id,
281 duration,
282 title,
283 like_status,
284 thumbnails,
285 playlist_id,
286 is_available,
287 channel_name,
288 channel_id,
289 feedback_token_remove,
290 })
291}
292fn parse_history_item_upload_song(
293 title: String,
294 mut data: JsonCrawlerBorrowed,
295) -> Result<HistoryItemUploadSong> {
296 let duration = data
297 .borrow_pointer(fixed_column_item_pointer(0))?
298 .take_value_pointer(TEXT_RUN_TEXT)?;
299 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
300 let video_id = data.take_value_pointer(concatcp!(
301 PLAY_BUTTON,
302 "/playNavigationEndpoint/watchEndpoint/videoId"
303 ))?;
304 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
305 let artists = parse_upload_song_artists(data.borrow_mut(), 1)?;
306 let album = parse_upload_song_album(data.borrow_mut(), 2)?;
307 let mut menu = data.navigate_pointer(MENU_ITEMS)?;
308 let entity_id = menu
309 .try_iter_mut()?
310 .find_path(DELETION_ENTITY_ID)?
311 .take_value()?;
312 let feedback_token_remove = menu
313 .try_into_iter()?
314 .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
315 .take_value()?;
316 Ok(HistoryItemUploadSong {
317 entity_id,
318 video_id,
319 album,
320 duration,
321 like_status,
322 title,
323 artists,
324 thumbnails,
325 feedback_token_remove,
326 })
327}
328fn parse_history_item_song(
329 title: String,
330 mut data: JsonCrawlerBorrowed,
331) -> Result<HistoryItemSong> {
332 let video_id = data.take_value_pointer(concatcp!(
333 PLAY_BUTTON,
334 "/playNavigationEndpoint",
335 WATCH_VIDEO_ID
336 ))?;
337 let library_management =
338 parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
339 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
340 let artists = super::parse_song_artists(&mut data, 1)?;
341 let album = super::parse_song_album(&mut data, 2)?;
342 let duration = data
343 .borrow_pointer(fixed_column_item_pointer(0))?
344 .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
345 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
346 let is_available = data
347 .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
348 .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
349 .unwrap_or(true);
350 let explicit = if data.path_exists(BADGE_LABEL) {
351 Explicit::IsExplicit
352 } else {
353 Explicit::NotExplicit
354 };
355 let mut menu = data.navigate_pointer(MENU_ITEMS)?;
356 let playlist_id = menu.take_value_pointer(concatcp!(
357 "/0/menuNavigationItemRenderer",
358 NAVIGATION_PLAYLIST_ID
359 ))?;
360 let feedback_token_remove = menu
361 .try_into_iter()?
362 .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
363 .take_value()?;
364 Ok(HistoryItemSong {
365 video_id,
366 duration,
367 library_management,
368 title,
369 artists,
370 like_status,
371 thumbnails,
372 explicit,
373 album,
374 playlist_id,
375 is_available,
376 feedback_token_remove,
377 })
378}
379
380#[cfg(test)]
381mod tests {
382 use crate::auth::BrowserToken;
383 use crate::common::{SongTrackingUrl, YoutubeID};
384 use crate::query::AddHistoryItemQuery;
385
386 #[tokio::test]
387 async fn test_add_history_item_query() {
388 let source = String::new();
389 crate::process_json::<_, BrowserToken>(
390 source,
391 AddHistoryItemQuery::new(SongTrackingUrl::from_raw("")),
392 )
393 .unwrap();
394 }
395 #[tokio::test]
396 async fn test_get_history() {
397 parse_test!(
398 "./test_json/get_history_20240701.json",
399 "./test_json/get_history_20240701_output.txt",
400 crate::query::GetHistoryQuery,
401 BrowserToken
402 );
403 }
404 #[tokio::test]
405 async fn test_get_history_with_upload_song() {
406 parse_test!(
407 "./test_json/get_history_20240713.json",
408 "./test_json/get_history_20240713_output.txt",
409 crate::query::GetHistoryQuery,
410 BrowserToken
411 );
412 }
413 #[tokio::test]
414 async fn test_remove_history_items() {
415 parse_test!(
416 "./test_json/remove_history_items_20240704.json",
417 "./test_json/remove_history_items_20240704_output.txt",
418 crate::query::RemoveHistoryItemsQuery::new(Vec::new()),
419 BrowserToken
420 );
421 }
422}