feat(plugin/music): add youtube music as a provider (#696)

This commit is contained in:
Rhodri
2021-12-05 00:06:10 +00:00
committed by GitHub
parent e5546c820f
commit 3a24a85e60
7 changed files with 211 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
/**Mocked data */
export default function({faker, url, options, login = faker.internet.userName()}) {
if (/^https:..music.youtube.com.youtubei.v1.*$/.test(url)) {
//Get recently played tracks
if (/browse/.test(url)) {
console.debug(`metrics/compute/mocks > mocking yt music api result > ${url}`)
const artist = faker.random.word()
const track = faker.random.words(5)
const artwork = faker.image.imageUrl()
return ({
contents:{
singleColumnBrowseResultsRenderer:{
tabs:[{
tabRenderer:{
content:{
sectionListRenderer:{
contents:[{
contents:[{
musicResponsiveListItemRenderer:{
thumbnail:{
musicThumbnailRenderer:{
thumbnail:{
thumbnails:[{
url:artwork,
}]
},
}
},
flexColumns:[{
musicResponsiveListItemFlexColumnRenderer:{
text:{
runs:[{
text:track,
}]
},
}
},
{
musicResponsiveListItemFlexColumnRenderer:{
text:{
runs:[{
text:artist,
}]
},
}
}],
}
}],
}],
},
},
},
}],
},
},
})
}
}
}

View File

@@ -67,6 +67,22 @@ This mode is not supported for now.
</details>
<details>
<summary>YouTube Music</summary>
Extract the *playlist* URL of the playlist you want to share.
To do so, Open YouTube Music and select the playlist you want to share.
Extract the source link from copying it from the address bar:
```
https://music.youtube.com/playlist?list=********
```
And use this value in `plugin_music_playlist` option.
</details>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
@@ -170,6 +186,25 @@ Register your API key to finish setup.
</details>
<details>
<summary>YouTube Music</summary>
Extract your YouTube Music cookies.
To do so, open [YouTube Music](https://music.youtube.com) (whilst logged in) on any modern browser
Open the developer tools (Ctrl-Shift-I) and select the “Network” tab
![Open developer tools](/.github/readme/imgs/plugin_music_recent_youtube_cookie_1.png)
Find an authenticated POST request. The simplest way is to filter by /browse using the search bar of the developer tools. If you dont see the request, try scrolling down a bit or clicking on the library button in the top bar.
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”.
![Copy cookie value](/.github/readme/imgs/plugin_music_recent_youtube_cookie_2.png)
</details>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)

View File

@@ -1,3 +1,6 @@
//Imports
import crypto from "crypto"
//Supported providers
const providers = {
apple:{
@@ -12,6 +15,10 @@ const providers = {
name:"Last.fm",
embed:/^\b$/,
},
youtube:{
name:"YouTube Music",
embed:/^https:..music.youtube.com.playlist/,
},
}
//Supported modes
const modes = {
@@ -84,6 +91,7 @@ export default async function({login, imports, data, q, account}, {enabled = fal
console.debug(`metrics/compute/${login}/plugins > music > started ${await browser.version()}`)
const page = await browser.newPage()
console.debug(`metrics/compute/${login}/plugins > music > loading page`)
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")
await page.goto(playlist)
const frame = page.mainFrame()
//Handle provider
@@ -117,6 +125,21 @@ export default async function({login, imports, data, q, account}, {enabled = fal
]
break
}
//YouTube Music
case "youtube": {
while (await frame.evaluate(() => document.querySelector("yt-next-continuation")?.children.length ?? 0))
await frame.evaluate(() => window.scrollBy(0, window.innerHeight))
//Parse tracklist
tracks = [
...await frame.evaluate(() => [...document.querySelectorAll("ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer")].map(item => ({
name:item.querySelector("yt-formatted-string.title > a")?.innerText ?? "",
artist:item.querySelector(".secondary-flex-columns > yt-formatted-string > a")?.innerText ?? "",
artwork:item.querySelector("img").src,
})
)),
]
break
}
//Unsupported
default:
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
}
break
}
case "youtube": {
//Prepare credentials
let date = new Date().getTime()
let [, cookie] = token.split("; ").find(part => part.startsWith("SAPISID=")).split("=")
let sha1 = str => crypto.createHash("sha1").update(str).digest("hex")
let SAPISIDHASH = `SAPISIDHASH ${date}_${sha1(`${date} ${cookie} https://music.youtube.com`)}`
//API call and parse tracklist
try {
//Request access token
console.debug(`metrics/compute/${login}/plugins > music > requesting access token with youtube refresh token`)
const res = await imports.axios.post("https://music.youtube.com/youtubei/v1/browse?alt=json&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30",
{
browseEndpointContextSupportedConfigs:{
browseEndpointContextMusicConfig:{
pageType:"MUSIC_PAGE_TYPE_PLAYLIST",
}
},
context:{
client:{
clientName:"WEB_REMIX",
clientVersion:"1.20211129.00.01",
gl:"US",
hl:"en",
},
},
browseId:"FEmusic_history"
},
{
headers:{
Authorization:SAPISIDHASH,
Cookie:token,
"x-origin":"https://music.youtube.com",
},
})
//Retrieve tracks
console.debug(`metrics/compute/${login}/plugins > music > querying youtube api`)
tracks = []
let parsedHistory = get_all_with_key(res.data, "musicResponsiveListItemRenderer")
for (let i = 0; i < parsedHistory.length; i++) {
let track = parsedHistory[i]
tracks.push({
name:track.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
artist:track.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
artwork:track.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url,
})
//Early break
if (tracks.length >= limit)
break
}
}
//Handle errors
catch (error) {
if (error.isAxiosError) {
const status = error.response?.status
const description = error.response.data?.error_description ?? null
const message = `API returned ${status}${description ? ` (${description})` : ""}`
error = error.response?.data ?? null
throw {error:{message, instance:error}, ...raw}
}
throw error
}
break
}
//Unsupported
default:
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
throw {error:{message:"An error occured", instance:error}}
}
}
//get all objects that have the given key name with recursivity
function get_all_with_key(obj, key) {
const result = []
if (obj instanceof Object) {
if (key in obj)
result.push(obj[key])
for (const i in obj)
result.push(...get_all_with_key(obj[i], key))
}
return result
}

View File

@@ -23,12 +23,14 @@ inputs:
- apple # Apple Music
- spotify # Spotify
- lastfm # Last.fm
- youtube # YouTube
# Music provider token
# This may be required depending on music provider used and plugin mode
# - "apple" : not required
# - "spotify" : required for "recent" or "top" mode, format is "client_id, client_secret, refresh_token"
# - "lastfm" : required, format is "api_key"
# - "youtube" : required for "recent" mode, format is "cookie"
plugin_music_token:
description: Music provider personal token
type: token

View File

@@ -12,6 +12,13 @@
plugin_music: yes
plugin_music_playlist: https://open.spotify.com/embed/playlist/3nfA87oeJw4LFVcUDjRcqi
- name: Music plugin (playlist - yt music)
uses: lowlighter/metrics@latest
with:
token: NOT_NEEDED
plugin_music: yes
plugin_music_playlist: https://music.youtube.com/playlist?list=OLAK5uy_kU_uxp9TUOl9zVdw77xith8o9AknVwz9U
- name: Music plugin (recent - spotify)
uses: lowlighter/metrics@latest
with:
@@ -30,6 +37,15 @@
plugin_music_provider: lastfm
plugin_music_user: RJ
- name: Music plugin (recent - yt music)
uses: lowlighter/metrics@latest
with:
token: NOT_NEEDED
plugin_music_token: SAPISID=MOCKED_COOKIE; OTHER_PARAM=OTHER_VALUE;
plugin_music: yes
plugin_music_mode: recent
plugin_music_provider: youtube
- name: Music plugin (top - spotify - tracks)
uses: lowlighter/metrics@latest
with: