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 + +![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 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”. + +![Copy cookie value](/.github/readme/imgs/plugin_music_recent_youtube_cookie_2.png) + +
+ #### ℹ️ 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: