import urlMatch from "./url.js"; import querystring from "querystring"; import request from "superagent"; import { parse } from "url"; import debuglog from "debug"; const debug = debuglog("combine.fm:ytmusic"); const standard_body = { context: { capabilities: {}, client: { clientName: "WEB_REMIX", clientVersion: "1.20250514.03.00", experimentIds: [], experimentsToken: "", gl: "DE", hl: "en", locationInfo: { locationPermissionAuthorizationStatus: "LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED" }, musicAppInfo: { musicActivityMasterSwitch: "MUSIC_ACTIVITY_MASTER_SWITCH_INDETERMINATE", musicLocationMasterSwitch: "MUSIC_LOCATION_MASTER_SWITCH_INDETERMINATE", pwaInstallabilityStatus: "PWA_INSTALLABILITY_STATUS_UNKNOWN" }, utcOffsetMinutes: 60 }, request: { internalExperimentFlags: [ { key: "force_music_enable_outertube_tastebuilder_browse", value: "true" }, { key: "force_music_enable_outertube_playlist_detail_browse", value: "true" }, { key: "force_music_enable_outertube_search_suggestions", value: "true" } ], sessionIndex: {} }, user: { enableSafetyMode: false } } }; // {"context":{"client":{"hl":"en","gl":"IE","remoteHost":"109.255.114.183","deviceMake":"","deviceModel":"","visitorData":"Cgs1WHNrQ2ZJSlZQYyju17HBBjInCgJJRRIhEh0SGwsMDg8QERITFBUWFxgZGhscHR4fICEiIyQlJiBG","userAgent":"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0,gzip(gfe)","clientName":"WEB_REMIX","clientVersion":"1.20250514.03.00","osName":"X11","osVersion":"","originalUrl":"https://music.youtube.com/youtube/v1/search","screenPixelDensity":2,"platform":"DESKTOP","clientFormFactor":"UNKNOWN_FORM_FACTOR","configInfo":{"appInstallData":"CO7XscEGEOGCgBMQpZ3PHBDtoM8cEP7z_xIQmvTOHBCJsM4cEIeszhwQ2vfOHBDM364FEL2azxwQgc3OHBCLgoATEN68zhwQyfevBRDn484cEJmNsQUQvoqwBRDvnc8cEO79zhwQ6-j-EhCd0LAFEN-4zhwQ6ZvPHBD8ss4cEODczhwQnJvPHBD-ns8cELvZzhwQt-r-EhC0jIATEOK4sAUQgoS4IhDJ5rAFEPDizhwQiIewBRC9tq4FEJT-sAUQmZixBRC52c4cEIjjrwUQ9quwBRC9mbAFELCJzxwQ9v7_EhDwnLAFENeczxwQ0-GvBRDMic8cEOTn_xIQ4Z7PHBC45M4cEODg_xIQoaHPHBCb-M4cKixDQU1TR3hVUW9MMndETkhrQnBTQ0V0WFM2Z3Y1N0FQSjNBV2hwQVFkQnc9PQ%3D%3D","coldConfigData":"CO7XscEGGjJBT2pGb3gzcE9TeHpiVUxCMkdoT0xCZHI0eThDNVRLSTN4OXhLVXR0V2h0RGJaZmZnZyIyQU9qRm94MXFoUkxFb2JKVzlJd2dWZm8wVWJJbHNrTTllQUZyVF81UkJBZHZSSTRjTmc%3D","coldHashData":"CO7XscEGEhM4MzcyMjg4Nzg1MDY2MDg0NzkyGO7XscEGMjJBT2pGb3gzcE9TeHpiVUxCMkdoT0xCZHI0eThDNVRLSTN4OXhLVXR0V2h0RGJaZmZnZzoyQU9qRm94MXFoUkxFb2JKVzlJd2dWZm8wVWJJbHNrTTllQUZyVF81UkJBZHZSSTRjTmc%3D","hotHashData":"CO7XscEGEhQxMTE4MDExNDAyNDI2OTEwMTQxMxju17HBBjIyQU9qRm94M3BPU3h6YlVMQjJHaE9MQmRyNHk4QzVUS0kzeDl4S1V0dFdodERiWmZmZ2c6MkFPakZveDFxaFJMRW9iSlc5SXdnVmZvMFViSWxza005ZUFGclRfNVJCQWR2Ukk0Y05n"},"screenDensityFloat":2,"userInterfaceTheme":"USER_INTERFACE_THEME_DARK","timeZone":"Europe/Dublin","browserName":"Firefox","browserVersion":"138.0","acceptHeader":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","deviceExperimentId":"ChxOelV3TmpRNU16TTFNRGMxTXpjNU9EZzBPQT09EO7XscEGGO7XscEG","rolloutToken":"CODP7vvb9ci_dRC-iJ6MorWMAxi-vbDI7q-NAw%3D%3D","screenWidthPoints":1440,"screenHeightPoints":434,"utcOffsetMinutes":60,"musicAppInfo":{"pwaInstallabilityStatus":"PWA_INSTALLABILITY_STATUS_UNKNOWN","webDisplayMode":"WEB_DISPLAY_MODE_BROWSER","storeDigitalGoodsApiSupportStatus":{"playStoreDigitalGoodsApiSupportStatus":"DIGITAL_GOODS_API_SUPPORT_STATUS_UNSUPPORTED"}}},"user":{"lockedSafetyMode":false},"request":{"useSsl":true,"internalExperimentFlags":[],"consistencyTokenJars":[]},"adSignalsInfo":{"params":[{"key":"dt","value":"1747741679421"},{"key":"flash","value":"0"},{"key":"frm","value":"0"},{"key":"u_tz","value":"60"},{"key":"u_his","value":"2"},{"key":"u_h","value":"960"},{"key":"u_w","value":"1440"},{"key":"u_ah","value":"960"},{"key":"u_aw","value":"1440"},{"key":"u_cd","value":"24"},{"key":"bc","value":"31"},{"key":"bih","value":"434"},{"key":"biw","value":"1440"},{"key":"brdim","value":"0,0,0,0,1440,0,1440,928,1440,434"},{"key":"vis","value":"1"},{"key":"wgl","value":"true"},{"key":"ca_type","value":"image"}]}},"query":"banger","suggestStats":{"validationStatus":"VALID","parameterValidationStatus":"VALID_PARAMETERS","clientName":"youtube-music","searchMethod":"ENTER_KEY","inputMethods":["KEYBOARD"],"originalQuery":"banger","availableSuggestions":[{"index":0,"type":0},{"index":1,"type":0},{"index":2,"type":0},{"index":3,"type":0},{"index":4,"type":0},{"index":5,"type":0},{"index":6,"type":46},{"index":7,"type":46},{"index":8,"type":46}],"zeroPrefixEnabled":true,"firstEditTimeMsec":13046,"lastEditTimeMsec":14576}} const standard_headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:72.0) Gecko/20100101 Firefox/72.0", Accept: "*/*", "Accept-Language": "en-US,en;q=0.5", "Content-Type": "application/json", "X-Goog-AuthUser": "0", origin: "https://music.youtube.com", "X-Goog-Visitor-Id": "CgtWaTB2WWRDeEFUYyjhv-X8BQ%3D%3D" }; const standard_params = { alt: "json", key: "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" }; // INNERTUBE_API_KEY from music.youtube.com const base_filter = "Eg-KAQwIA"; const albums_filter = "BAAGAEgACgA"; const tracks_filter = "RAAGAAgACgA"; // If you make a typo, ytmusic searches for a correction. With this filter it will look for the exact match // since we don't let users type, no sense in letting it autocorrect const exact_search_filter = "MABqChAEEAMQCRAFEAo%3D"; // The logic here comes from https://github.com/sigma67/ytmusicapi // If something doesn't work, looking up back there might be a good idea. export async function search(data, original = {}) { let query; const various = data.artist.name === "Various Artists" || data.artist.name === "Various"; if (various) { data.artist.name = ""; } if (data.type == "track") { query = [data.name, data.artist.name, data.albumName]; } else if (data.type == "album") { query = [data.name, data.artist.name]; } else { throw new Error(); } // Add "" to try and make the search better, works for stuff like "The beatles" to reduce noise query = query .filter(String) .map(entry => '"' + entry + '"') .join(" "); let params = base_filter + (data.type == "track" ? tracks_filter : albums_filter) + exact_search_filter; let request_body = { query, params, ...standard_body }; try { const { body } = await request .post("https://music.youtube.com/youtubei/v1/search") .set(standard_headers) .query(standard_params) .send(request_body); } catch (err) { debug(err); } // no results if (body.contents === undefined) { debug("Empty body, no results"); return { service: "ytmusic" }; } let results; if (body.contents.tabbedSearchResultsRenderer !== undefined) { results = body.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content; } else { results = body.contents.sectionListRenderer.contents; } // no results if (results.length == 1 && results.itemSectionRenderer !== undefined) { debug("Only itemSectionRenderer, no results"); return { service: "ytmusic" }; } for (const result of results) { if (result.musicShelfRenderer === undefined) { continue; } const matches = parse_result_content( result.musicShelfRenderer.contents, data.type ); // This could probably be done without extra lookups, but it would involve parsing deeply the response. // If there's some kind of rate limit on ytmusic's side, this is a good play to start refactoring for (const match of matches) { const possibleMatch = await lookupId(match, data.type); const nameMatch = possibleMatch.name == data.name; const artistMatch = data.artist.name == "" ? possibleMatch.artist.name === "Various Artists" : data.artist.name == possibleMatch.artist.name; if (nameMatch && artistMatch) { return possibleMatch; } } } debug("Finished looking up, no results"); return { service: "ytmusic" }; } function parse_result_content(contents, type) { let matches = []; for (const result of contents) { const data = result.musicResponsiveListItemRenderer; const informed_type = data.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0] .text; if (["Video", "Playlist"].includes(informed_type)) { continue; } let matchId; if (type == "track") { matchId = data.overlay?.musicItemThumbnailOverlayRenderer.content .musicPlayButtonRenderer.playNavigationEndpoint.watchEndpoint ?.videoId; } else if (type == "album") { matchId = data.navigationEndpoint?.browseEndpoint.browseId; } if (matchId) { matches.push(matchId); } } return matches; } async function lookupTrack(id) { let request_body = { video_id: id, ...standard_body }; const { body } = await request .post("https://music.youtube.com/youtubei/v1/player") .set(standard_headers) .query(standard_params) .send(request_body); let song_meta = body.videoDetails; let description = body.microformat.microformatDataRenderer.description.split( " · " ); let possible_album_name = description[description.length - 1].split("℗")[0]; if (!description[description.length - 1].includes("℗")) { possible_album_name = ""; } let tags = body.microformat.microformatDataRenderer.tags; let album_name = ""; for (const tag of tags) { if (possible_album_name.includes(tag)) { album_name = tag; } } let artists = song_meta.author; artists = artists.replace(" - Topic", ""); const artwork = { small: song_meta.thumbnail.thumbnails[0].url, large: song_meta.thumbnail.thumbnails[song_meta.thumbnail.thumbnails.length - 1] .url }; let track_info = { service: "ytmusic", type: "track", id: song_meta.videoId, name: song_meta.title, streamUrl: `https://music.youtube.com/watch?v=${song_meta.videoId}`, purchaseUrl: null, artwork, artist: { name: artists }, album: { name: album_name } }; return Promise.resolve(track_info); } async function lookupAlbum(id) { let request_body = { browseEndpointContextSupportedConfigs: { browseEndpointContextMusicConfig: { pageType: "MUSIC_PAGE_TYPE_ALBUM" } }, browseId: id, ...standard_body }; const { body } = await request .post("https://music.youtube.com/youtubei/v1/browse") .set(standard_headers) .query(standard_params) .send(request_body); let data = body.frameworkUpdates?.entityBatchUpdate.mutations; if (data === undefined) { throw new Error(); } let album_data = data.find(entry => { if (entry.payload.musicAlbumRelease !== undefined) { return true; } return false; }).payload.musicAlbumRelease; let artists; if (album_data.primaryArtists) { artists = data .filter(entry => { if (entry.payload.musicArtist !== undefined) { if (album_data.primaryArtists.includes(entry.entityKey)) { return true; } } return false; }) .map(entry => entry.payload.musicArtist.name); } else { // Various artists, most likely artists = [album_data.artistDisplayName]; } const artwork = { small: album_data.thumbnailDetails.thumbnails[0].url, large: album_data.thumbnailDetails.thumbnails[ album_data.thumbnailDetails.thumbnails.length - 1 ].url }; return Promise.resolve({ service: "ytmusic", type: "album", id, name: album_data.title, streamUrl: null, streamUrl: `https://music.youtube.com/browse/${id}`, purchaseUrl: null, artwork, artist: { name: artists.join(", ") }, playlistId: album_data.audioPlaylistId }); } async function lookupPlaylist(id) { const endpoint = "https://music.youtube.com/playlist"; const response = await request .get(endpoint) .set(standard_headers) .query({ list: id }); let match = response.text.match(/"MPRE[_a-zA-Z0-9]+/); let albumId; if (match) { albumId = match[0].substr(1); } else { debug("Couldn't match album id"); throw new Error(); } const possibleAlbum = await lookupAlbum(albumId); if ((possibleAlbum.playlistId = id)) { return possibleAlbum; } throw new Error(); } export async function lookupId(id, type) { if (type == "track") { return lookupTrack(id); } else if (type == "album") { return lookupAlbum(id); } else if (type == "playlist") { return lookupPlaylist(id); } return { service: "ytmusic", id }; } export function parseUrl(url) { const parsed = parse(url); const query = querystring.parse(parsed.query); let id = query.v; let list_id = query.list; let match; if (parsed.path.match(/^\/watch/) && id !== undefined) { return lookupId(id, "track"); } else if ((match = parsed.path.match(/^\/browse\/([A-Za-z0-9_]+)/))) { return lookupId(match[1], "album"); } else if ( (match = parsed.path.match(/^\/playlist/) && list_id !== undefined) ) { return lookupId(list_id, "playlist"); } throw new Error(); } export const id = "ytmusic"; export const match = urlMatch;