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

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