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 Some(HistoryItem::Video(parse_history_item_video(title, data)?))
188 }
189 YoutubeMusicVideoType::Atv => {
190 Some(HistoryItem::Song(parse_history_item_song(title, data)?))
191 }
192 };
193 Ok(item)
194}
195
196fn parse_history_item_episode(
197 title: String,
198 mut data: JsonCrawlerBorrowed,
199) -> Result<HistoryItemEpisode> {
200 let video_id = data.take_value_pointer(concatcp!(
201 PLAY_BUTTON,
202 "/playNavigationEndpoint",
203 WATCH_VIDEO_ID
204 ))?;
205 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
206 let is_live = data.path_exists(LIVE_BADGE_LABEL);
207 let (duration, date) = match is_live {
208 true => (EpisodeDuration::Live, EpisodeDate::Live),
209 false => {
210 let date = parse_flex_column_item(&mut data, 2, 0)?;
211 let duration = data
212 .borrow_pointer(fixed_column_item_pointer(0))?
213 .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
214 (
215 EpisodeDuration::Recorded { duration },
216 EpisodeDate::Recorded { date },
217 )
218 }
219 };
220 let podcast_name = parse_flex_column_item(&mut data, 1, 0)?;
221 let podcast_id = data
222 .borrow_pointer(flex_column_item_pointer(1))?
223 .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
224 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
225 let is_available = data
226 .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
227 .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
228 .unwrap_or(true);
229 let feedback_token_remove = data
230 .navigate_pointer(MENU_ITEMS)?
231 .try_into_iter()?
232 .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
233 .take_value()?;
234 Ok(HistoryItemEpisode {
235 episode_id: video_id,
236 duration,
237 title,
238 like_status,
239 thumbnails,
240 date,
241 podcast_name,
242 podcast_id,
243 is_available,
244 feedback_token_remove,
245 })
246}
247fn parse_history_item_video(
248 title: String,
249 mut data: JsonCrawlerBorrowed,
250) -> Result<HistoryItemVideo> {
251 let video_id = data.take_value_pointer(concatcp!(
252 PLAY_BUTTON,
253 "/playNavigationEndpoint",
254 WATCH_VIDEO_ID
255 ))?;
256 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
257 let channel_name = parse_flex_column_item(&mut data, 1, 0)?;
258 let channel_id = data
259 .borrow_pointer(flex_column_item_pointer(1))?
260 .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
261 let duration = data
262 .borrow_pointer(fixed_column_item_pointer(0))?
263 .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
264 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
265 let is_available = data
266 .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
267 .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
268 .unwrap_or(true);
269 let mut menu = data.navigate_pointer(MENU_ITEMS)?;
270 let playlist_id = menu.take_value_pointer(concatcp!(
271 "/0/menuNavigationItemRenderer",
272 NAVIGATION_PLAYLIST_ID
273 ))?;
274 let feedback_token_remove = menu
275 .try_into_iter()?
276 .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
277 .take_value()?;
278 Ok(HistoryItemVideo {
279 video_id,
280 duration,
281 title,
282 like_status,
283 thumbnails,
284 playlist_id,
285 is_available,
286 channel_name,
287 channel_id,
288 feedback_token_remove,
289 })
290}
291fn parse_history_item_upload_song(
292 title: String,
293 mut data: JsonCrawlerBorrowed,
294) -> Result<HistoryItemUploadSong> {
295 let duration = data
296 .borrow_pointer(fixed_column_item_pointer(0))?
297 .take_value_pointer(TEXT_RUN_TEXT)?;
298 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
299 let video_id = data.take_value_pointer(concatcp!(
300 PLAY_BUTTON,
301 "/playNavigationEndpoint/watchEndpoint/videoId"
302 ))?;
303 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
304 let artists = parse_upload_song_artists(data.borrow_mut(), 1)?;
305 let album = parse_upload_song_album(data.borrow_mut(), 2)?;
306 let mut menu = data.navigate_pointer(MENU_ITEMS)?;
307 let entity_id = menu
308 .try_iter_mut()?
309 .find_path(DELETION_ENTITY_ID)?
310 .take_value()?;
311 let feedback_token_remove = menu
312 .try_into_iter()?
313 .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
314 .take_value()?;
315 Ok(HistoryItemUploadSong {
316 entity_id,
317 video_id,
318 album,
319 duration,
320 like_status,
321 title,
322 artists,
323 thumbnails,
324 feedback_token_remove,
325 })
326}
327fn parse_history_item_song(
328 title: String,
329 mut data: JsonCrawlerBorrowed,
330) -> Result<HistoryItemSong> {
331 let video_id = data.take_value_pointer(concatcp!(
332 PLAY_BUTTON,
333 "/playNavigationEndpoint",
334 WATCH_VIDEO_ID
335 ))?;
336 let library_management =
337 parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
338 let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
339 let artists = super::parse_song_artists(&mut data, 1)?;
340 let album = super::parse_song_album(&mut data, 2)?;
341 let duration = data
342 .borrow_pointer(fixed_column_item_pointer(0))?
343 .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
344 let thumbnails = data.take_value_pointer(THUMBNAILS)?;
345 let is_available = data
346 .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
347 .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
348 .unwrap_or(true);
349 let explicit = if data.path_exists(BADGE_LABEL) {
350 Explicit::IsExplicit
351 } else {
352 Explicit::NotExplicit
353 };
354 let mut menu = data.navigate_pointer(MENU_ITEMS)?;
355 let playlist_id = menu.take_value_pointer(concatcp!(
356 "/0/menuNavigationItemRenderer",
357 NAVIGATION_PLAYLIST_ID
358 ))?;
359 let feedback_token_remove = menu
360 .try_into_iter()?
361 .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
362 .take_value()?;
363 Ok(HistoryItemSong {
364 video_id,
365 duration,
366 library_management,
367 title,
368 artists,
369 like_status,
370 thumbnails,
371 explicit,
372 album,
373 playlist_id,
374 is_available,
375 feedback_token_remove,
376 })
377}
378
379#[cfg(test)]
380mod tests {
381 use crate::auth::BrowserToken;
382 use crate::common::{SongTrackingUrl, YoutubeID};
383 use crate::query::AddHistoryItemQuery;
384
385 #[tokio::test]
386 async fn test_add_history_item_query() {
387 let source = String::new();
388 crate::process_json::<_, BrowserToken>(
389 source,
390 AddHistoryItemQuery::new(SongTrackingUrl::from_raw("")),
391 )
392 .unwrap();
393 }
394 #[tokio::test]
395 async fn test_get_history() {
396 parse_test!(
397 "./test_json/get_history_20240701.json",
398 "./test_json/get_history_20240701_output.txt",
399 crate::query::GetHistoryQuery,
400 BrowserToken
401 );
402 }
403 #[tokio::test]
404 async fn test_get_history_with_upload_song() {
405 parse_test!(
406 "./test_json/get_history_20240713.json",
407 "./test_json/get_history_20240713_output.txt",
408 crate::query::GetHistoryQuery,
409 BrowserToken
410 );
411 }
412 #[tokio::test]
413 async fn test_remove_history_items() {
414 parse_test!(
415 "./test_json/remove_history_items_20240704.json",
416 "./test_json/remove_history_items_20240704_output.txt",
417 crate::query::RemoveHistoryItemsQuery::new(Vec::new()),
418 BrowserToken
419 );
420 }
421}