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