Skip to content

Commit 3a24a85

Browse files
authored
feat(plugin/music): add youtube music as a provider (lowlighter#696)
1 parent e5546c8 commit 3a24a85

File tree

7 files changed

+211
-0
lines changed

7 files changed

+211
-0
lines changed
57.7 KB
Loading
57.2 KB
Loading
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**Mocked data */
2+
export default function({faker, url, options, login = faker.internet.userName()}) {
3+
if (/^https:..music.youtube.com.youtubei.v1.*$/.test(url)) {
4+
//Get recently played tracks
5+
if (/browse/.test(url)) {
6+
console.debug(`metrics/compute/mocks > mocking yt music api result > ${url}`)
7+
const artist = faker.random.word()
8+
const track = faker.random.words(5)
9+
const artwork = faker.image.imageUrl()
10+
return ({
11+
contents:{
12+
singleColumnBrowseResultsRenderer:{
13+
tabs:[{
14+
tabRenderer:{
15+
content:{
16+
sectionListRenderer:{
17+
contents:[{
18+
contents:[{
19+
musicResponsiveListItemRenderer:{
20+
thumbnail:{
21+
musicThumbnailRenderer:{
22+
thumbnail:{
23+
thumbnails:[{
24+
url:artwork,
25+
}]
26+
},
27+
}
28+
},
29+
flexColumns:[{
30+
musicResponsiveListItemFlexColumnRenderer:{
31+
text:{
32+
runs:[{
33+
text:track,
34+
}]
35+
},
36+
}
37+
},
38+
{
39+
musicResponsiveListItemFlexColumnRenderer:{
40+
text:{
41+
runs:[{
42+
text:artist,
43+
}]
44+
},
45+
}
46+
}],
47+
}
48+
}],
49+
}],
50+
},
51+
},
52+
},
53+
}],
54+
},
55+
},
56+
})
57+
}
58+
}
59+
}

source/plugins/music/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,22 @@ This mode is not supported for now.
6767

6868
</details>
6969

70+
<details>
71+
<summary>YouTube Music</summary>
72+
73+
Extract the *playlist* URL of the playlist you want to share.
74+
75+
To do so, Open YouTube Music and select the playlist you want to share.
76+
77+
Extract the source link from copying it from the address bar:
78+
```
79+
https://music.youtube.com/playlist?list=********
80+
```
81+
82+
And use this value in `plugin_music_playlist` option.
83+
84+
</details>
85+
7086
#### ℹ️ Examples workflows
7187

7288
[➡️ Available options for this plugin](metadata.yml)
@@ -170,6 +186,25 @@ Register your API key to finish setup.
170186

171187
</details>
172188

189+
<details>
190+
<summary>YouTube Music</summary>
191+
192+
Extract your YouTube Music cookies.
193+
194+
To do so, open [YouTube Music](https://music.youtube.com) (whilst logged in) on any modern browser
195+
196+
Open the developer tools (Ctrl-Shift-I) and select the “Network” tab
197+
198+
![Open developer tools](/.github/readme/imgs/plugin_music_recent_youtube_cookie_1.png)
199+
200+
Find an authenticated POST request. The simplest way is to filter by /browse using the search bar of the developer tools. If you don’t see the request, try scrolling down a bit or clicking on the library button in the top bar.
201+
202+
Click on the Name of any matching request. In the “Headers” tab, scroll to the “Cookie” and copy this by right-clicking on it and selecting “Copy value”.
203+
204+
![Copy cookie value](/.github/readme/imgs/plugin_music_recent_youtube_cookie_2.png)
205+
206+
</details>
207+
173208
#### ℹ️ Examples workflows
174209

175210
[➡️ Available options for this plugin](metadata.yml)

source/plugins/music/index.mjs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
//Imports
2+
import crypto from "crypto"
3+
14
//Supported providers
25
const 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:/^https:..music.youtube.com.playlist/,
21+
},
1522
}
1623
//Supported modes
1724
const 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+
}

source/plugins/music/metadata.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ inputs:
2323
- apple # Apple Music
2424
- spotify # Spotify
2525
- lastfm # Last.fm
26+
- youtube # YouTube
2627

2728
# Music provider token
2829
# This may be required depending on music provider used and plugin mode
2930
# - "apple" : not required
3031
# - "spotify" : required for "recent" or "top" mode, format is "client_id, client_secret, refresh_token"
3132
# - "lastfm" : required, format is "api_key"
33+
# - "youtube" : required for "recent" mode, format is "cookie"
3234
plugin_music_token:
3335
description: Music provider personal token
3436
type: token

source/plugins/music/tests.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
plugin_music: yes
1313
plugin_music_playlist: https://open.spotify.com/embed/playlist/3nfA87oeJw4LFVcUDjRcqi
1414

15+
- name: Music plugin (playlist - yt music)
16+
uses: lowlighter/metrics@latest
17+
with:
18+
token: NOT_NEEDED
19+
plugin_music: yes
20+
plugin_music_playlist: https://music.youtube.com/playlist?list=OLAK5uy_kU_uxp9TUOl9zVdw77xith8o9AknVwz9U
21+
1522
- name: Music plugin (recent - spotify)
1623
uses: lowlighter/metrics@latest
1724
with:
@@ -30,6 +37,15 @@
3037
plugin_music_provider: lastfm
3138
plugin_music_user: RJ
3239

40+
- name: Music plugin (recent - yt music)
41+
uses: lowlighter/metrics@latest
42+
with:
43+
token: NOT_NEEDED
44+
plugin_music_token: SAPISID=MOCKED_COOKIE; OTHER_PARAM=OTHER_VALUE;
45+
plugin_music: yes
46+
plugin_music_mode: recent
47+
plugin_music_provider: youtube
48+
3349
- name: Music plugin (top - spotify - tracks)
3450
uses: lowlighter/metrics@latest
3551
with:

0 commit comments

Comments
 (0)