feat(plugin/music): add youtube music as a provider (#696)
This commit is contained in:
BIN
.github/readme/imgs/plugin_music_recent_youtube_cookie_1.png
vendored
Normal file
BIN
.github/readme/imgs/plugin_music_recent_youtube_cookie_1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
.github/readme/imgs/plugin_music_recent_youtube_cookie_2.png
vendored
Normal file
BIN
.github/readme/imgs/plugin_music_recent_youtube_cookie_2.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
59
source/app/mocks/api/axios/post/youtubemusic.mjs
Normal file
59
source/app/mocks/api/axios/post/youtubemusic.mjs
Normal 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,
|
||||
}]
|
||||
},
|
||||
}
|
||||
}],
|
||||
}
|
||||
}],
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
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”.
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
#### ℹ️ Examples workflows
|
||||
|
||||
[➡️ Available options for this plugin](metadata.yml)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user