diff --git a/.github/readme/imgs/plugin_music_recent_youtube_cookie_1.png b/.github/readme/imgs/plugin_music_recent_youtube_cookie_1.png
new file mode 100644
index 00000000..3e34e42a
Binary files /dev/null and b/.github/readme/imgs/plugin_music_recent_youtube_cookie_1.png differ
diff --git a/.github/readme/imgs/plugin_music_recent_youtube_cookie_2.png b/.github/readme/imgs/plugin_music_recent_youtube_cookie_2.png
new file mode 100644
index 00000000..6932a4a1
Binary files /dev/null and b/.github/readme/imgs/plugin_music_recent_youtube_cookie_2.png differ
diff --git a/source/app/mocks/api/axios/post/youtubemusic.mjs b/source/app/mocks/api/axios/post/youtubemusic.mjs
new file mode 100644
index 00000000..8210176a
--- /dev/null
+++ b/source/app/mocks/api/axios/post/youtubemusic.mjs
@@ -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,
+ }]
+ },
+ }
+ }],
+ }
+ }],
+ }],
+ },
+ },
+ },
+ }],
+ },
+ },
+ })
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/plugins/music/README.md b/source/plugins/music/README.md
index 6e148412..a1c34522 100644
--- a/source/plugins/music/README.md
+++ b/source/plugins/music/README.md
@@ -67,6 +67,22 @@ This mode is not supported for now.
+
+YouTube Music
+
+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.
+
+
+
#### ℹ️ Examples workflows
[➡️ Available options for this plugin](metadata.yml)
@@ -170,6 +186,25 @@ Register your API key to finish setup.
+
+YouTube Music
+
+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”.
+
+
+
+
+
#### ℹ️ Examples workflows
[➡️ Available options for this plugin](metadata.yml)
diff --git a/source/plugins/music/index.mjs b/source/plugins/music/index.mjs
index c14afa13..17fd26dc 100644
--- a/source/plugins/music/index.mjs
+++ b/source/plugins/music/index.mjs
@@ -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
+}
\ No newline at end of file
diff --git a/source/plugins/music/metadata.yml b/source/plugins/music/metadata.yml
index 6b7dc4b3..2bad8010 100644
--- a/source/plugins/music/metadata.yml
+++ b/source/plugins/music/metadata.yml
@@ -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
diff --git a/source/plugins/music/tests.yml b/source/plugins/music/tests.yml
index 80dc68b2..7b954e85 100644
--- a/source/plugins/music/tests.yml
+++ b/source/plugins/music/tests.yml
@@ -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: