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

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

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>
<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 #### Examples workflows
[➡️ Available options for this plugin](metadata.yml) [➡️ Available options for this plugin](metadata.yml)
@@ -170,6 +186,25 @@ Register your API key to finish setup.
</details> </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 #### Examples workflows
[➡️ Available options for this plugin](metadata.yml) [➡️ Available options for this plugin](metadata.yml)

View File

@@ -1,3 +1,6 @@
//Imports
import crypto from "crypto"
//Supported providers //Supported providers
const providers = { const providers = {
apple:{ apple:{
@@ -12,6 +15,10 @@ const providers = {
name:"Last.fm", name:"Last.fm",
embed:/^\b$/, embed:/^\b$/,
}, },
youtube:{
name:"YouTube Music",
embed:/^https:..music.youtube.com.playlist/,
},
} }
//Supported modes //Supported modes
const 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()}`) console.debug(`metrics/compute/${login}/plugins > music > started ${await browser.version()}`)
const page = await browser.newPage() const page = await browser.newPage()
console.debug(`metrics/compute/${login}/plugins > music > loading page`) 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) await page.goto(playlist)
const frame = page.mainFrame() const frame = page.mainFrame()
//Handle provider //Handle provider
@@ -117,6 +125,21 @@ export default async function({login, imports, data, q, account}, {enabled = fal
] ]
break 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 //Unsupported
default: default:
throw {error:{message:`Unsupported mode "${mode}" for provider "${provider}"`}, ...raw} 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 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 //Unsupported
default: default:
throw {error:{message:`Unsupported mode "${mode}" for provider "${provider}"`}, ...raw} 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}} 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 - apple # Apple Music
- spotify # Spotify - spotify # Spotify
- lastfm # Last.fm - lastfm # Last.fm
- youtube # YouTube
# Music provider token # Music provider token
# This may be required depending on music provider used and plugin mode # This may be required depending on music provider used and plugin mode
# - "apple" : not required # - "apple" : not required
# - "spotify" : required for "recent" or "top" mode, format is "client_id, client_secret, refresh_token" # - "spotify" : required for "recent" or "top" mode, format is "client_id, client_secret, refresh_token"
# - "lastfm" : required, format is "api_key" # - "lastfm" : required, format is "api_key"
# - "youtube" : required for "recent" mode, format is "cookie"
plugin_music_token: plugin_music_token:
description: Music provider personal token description: Music provider personal token
type: token type: token

View File

@@ -12,6 +12,13 @@
plugin_music: yes plugin_music: yes
plugin_music_playlist: https://open.spotify.com/embed/playlist/3nfA87oeJw4LFVcUDjRcqi 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) - name: Music plugin (recent - spotify)
uses: lowlighter/metrics@latest uses: lowlighter/metrics@latest
with: with:
@@ -30,6 +37,15 @@
plugin_music_provider: lastfm plugin_music_provider: lastfm
plugin_music_user: RJ 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) - name: Music plugin (top - spotify - tracks)
uses: lowlighter/metrics@latest uses: lowlighter/metrics@latest
with: with: