1+ //Imports
2+ import crypto from "crypto"
3+
14//Supported providers
25const providers = {
36 apple :{
@@ -12,6 +15,10 @@ const providers = {
1215 name :"Last.fm" ,
1316 embed :/ ^ \b $ / ,
1417 } ,
18+ youtube :{
19+ name :"YouTube Music" ,
20+ embed :/ ^ h t t p s : ..m u s i c .y o u t u b e .c o m .p l a y l i s t / ,
21+ } ,
1522}
1623//Supported modes
1724const modes = {
@@ -84,6 +91,7 @@ export default async function({login, imports, data, q, account}, {enabled = fal
8491 console . debug ( `metrics/compute/${ login } /plugins > music > started ${ await browser . version ( ) } ` )
8592 const page = await browser . newPage ( )
8693 console . debug ( `metrics/compute/${ login } /plugins > music > loading page` )
94+ await page . setUserAgent ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36 Edg/96.0.1054.34" )
8795 await page . goto ( playlist )
8896 const frame = page . mainFrame ( )
8997 //Handle provider
@@ -117,6 +125,21 @@ export default async function({login, imports, data, q, account}, {enabled = fal
117125 ]
118126 break
119127 }
128+ //YouTube Music
129+ case "youtube" : {
130+ while ( await frame . evaluate ( ( ) => document . querySelector ( "yt-next-continuation" ) ?. children . length ?? 0 ) )
131+ await frame . evaluate ( ( ) => window . scrollBy ( 0 , window . innerHeight ) )
132+ //Parse tracklist
133+ tracks = [
134+ ...await frame . evaluate ( ( ) => [ ...document . querySelectorAll ( "ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer" ) ] . map ( item => ( {
135+ name :item . querySelector ( "yt-formatted-string.title > a" ) ?. innerText ?? "" ,
136+ artist :item . querySelector ( ".secondary-flex-columns > yt-formatted-string > a" ) ?. innerText ?? "" ,
137+ artwork :item . querySelector ( "img" ) . src ,
138+ } )
139+ ) ) ,
140+ ]
141+ break
142+ }
120143 //Unsupported
121144 default :
122145 throw { error :{ message :`Unsupported mode "${ mode } " for provider "${ provider } "` } , ...raw }
@@ -224,6 +247,70 @@ export default async function({login, imports, data, q, account}, {enabled = fal
224247 }
225248 break
226249 }
250+ case "youtube" : {
251+ //Prepare credentials
252+ let date = new Date ( ) . getTime ( )
253+ let [ , cookie ] = token . split ( "; " ) . find ( part => part . startsWith ( "SAPISID=" ) ) . split ( "=" )
254+ let sha1 = str => crypto . createHash ( "sha1" ) . update ( str ) . digest ( "hex" )
255+ let SAPISIDHASH = `SAPISIDHASH ${ date } _${ sha1 ( `${ date } ${ cookie } https://music.youtube.com` ) } `
256+ //API call and parse tracklist
257+ try {
258+ //Request access token
259+ console . debug ( `metrics/compute/${ login } /plugins > music > requesting access token with youtube refresh token` )
260+ const res = await imports . axios . post ( "https://music.youtube.com/youtubei/v1/browse?alt=json&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" ,
261+ {
262+ browseEndpointContextSupportedConfigs :{
263+ browseEndpointContextMusicConfig :{
264+ pageType :"MUSIC_PAGE_TYPE_PLAYLIST" ,
265+ }
266+ } ,
267+ context :{
268+ client :{
269+ clientName :"WEB_REMIX" ,
270+ clientVersion :"1.20211129.00.01" ,
271+ gl :"US" ,
272+ hl :"en" ,
273+ } ,
274+ } ,
275+ browseId :"FEmusic_history"
276+ } ,
277+ {
278+ headers :{
279+ Authorization :SAPISIDHASH ,
280+ Cookie :token ,
281+ "x-origin" :"https://music.youtube.com" ,
282+ } ,
283+ } )
284+ //Retrieve tracks
285+ console . debug ( `metrics/compute/${ login } /plugins > music > querying youtube api` )
286+ tracks = [ ]
287+ let parsedHistory = get_all_with_key ( res . data , "musicResponsiveListItemRenderer" )
288+
289+ for ( let i = 0 ; i < parsedHistory . length ; i ++ ) {
290+ let track = parsedHistory [ i ]
291+ tracks . push ( {
292+ name :track . flexColumns [ 0 ] . musicResponsiveListItemFlexColumnRenderer . text . runs [ 0 ] . text ,
293+ artist :track . flexColumns [ 1 ] . musicResponsiveListItemFlexColumnRenderer . text . runs [ 0 ] . text ,
294+ artwork :track . thumbnail . musicThumbnailRenderer . thumbnail . thumbnails [ 0 ] . url ,
295+ } )
296+ //Early break
297+ if ( tracks . length >= limit )
298+ break
299+ }
300+ }
301+ //Handle errors
302+ catch ( error ) {
303+ if ( error . isAxiosError ) {
304+ const status = error . response ?. status
305+ const description = error . response . data ?. error_description ?? null
306+ const message = `API returned ${ status } ${ description ? ` (${ description } )` : "" } `
307+ error = error . response ?. data ?? null
308+ throw { error :{ message, instance :error } , ...raw }
309+ }
310+ throw error
311+ }
312+ break
313+ }
227314 //Unsupported
228315 default :
229316 throw { error :{ message :`Unsupported mode "${ mode } " for provider "${ provider } "` } , ...raw }
@@ -428,3 +515,15 @@ export default async function({login, imports, data, q, account}, {enabled = fal
428515 throw { error :{ message :"An error occured" , instance :error } }
429516 }
430517}
518+
519+ //get all objects that have the given key name with recursivity
520+ function get_all_with_key ( obj , key ) {
521+ const result = [ ]
522+ if ( obj instanceof Object ) {
523+ if ( key in obj )
524+ result . push ( obj [ key ] )
525+ for ( const i in obj )
526+ result . push ( ...get_all_with_key ( obj [ i ] , key ) )
527+ }
528+ return result
529+ }
0 commit comments