Last active
March 30, 2025 20:49
-
-
Save manciuszz/8ed94da187c617ea73a6e4e28cb8304f to your computer and use it in GitHub Desktop.
Discord's Spotify song link replacer with available alternatives userscript using Songlink/Odesli services.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Spotify Preview to Full Song Replacer | |
// @version 1.0 | |
// @author MMWorks | |
// @include https://discord.com/channels/* | |
// @connect api.song.link | |
// @grant GM.xmlHttpRequest | |
// @grant unsafeWindow | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
let TOKEN = null; | |
const OdesliAPI = (function() { | |
const api = undefined; | |
const version = 'v1-alpha.1'; | |
const _request = async function(path) { | |
const url = `https://api.song.link/${version}/${path}${api !== undefined ? `&key=${api}` : ''}`; | |
async function getPage(URL) { | |
const response = await GM.xmlHttpRequest({ | |
method: "GET", | |
url: URL | |
}) | |
.catch(err => { | |
if (err.message == 'Unexpected token < in JSON at position 0') throw new Error('API returned an unexpected result.'); | |
}); | |
const text = response.responseText; | |
const data = JSON.parse(text); | |
return data; | |
} | |
const result = getPage(url); | |
// Handle errors | |
if (result.statusCode) { | |
// Codes in the `4xx` range indicate an error that failed given the information provided | |
if (result.statusCode === 429) throw new Error(`${result.statusCode}: ${result.code}, You are being rate limited, No API Key is 10 Requests / Minute.`); | |
// Codes in the `4xx` range indicate an error that failed given the information provided | |
if (result.statusCode.toString().startsWith(4)) throw new Error(`${result.statusCode}: ${result.code}, Codes in the 4xx range indicate an error that failed given the information provided.`); | |
// Codes in the 5xx range indicate an error with Songlink's servers. | |
if (result.statusCode.toString().startsWith(5)) throw new Error(`${result.statusCode}: ${result.code}, Codes in the 5xx range indicate an error with Songlink's servers.`); | |
// Otherwise if the code is not 200 (Success), throw a generic error. | |
if (result.statusCode !== 200) throw new Error(`${result.statusCode}: ${result.code}`); | |
// return undefined as we didn't find anything. | |
return undefined; | |
} | |
return result; | |
} | |
const fetchLink = async function(url, country = 'US') { | |
if (!url) throw new Error('No URL was provided to odesli.fetch()'); | |
const path = `links?url=${encodeURIComponent(url)}&userCountry=${country}`; | |
const song = await _request(path); | |
const id = song.entitiesByUniqueId[song.entityUniqueId].id; | |
const title = song.entitiesByUniqueId[song.entityUniqueId].title; | |
// Convert Artist into Array for easier extraction of features | |
Object.values(song.entitiesByUniqueId).forEach(function(values) { | |
values.artistName = values.artistName.split(', '); | |
}); | |
const artist = song.entitiesByUniqueId[song.entityUniqueId].artistName; | |
const type = song.entitiesByUniqueId[song.entityUniqueId].type; | |
const thumbnail = song.entitiesByUniqueId[song.entityUniqueId].thumbnailUrl; | |
return Promise.all([song, id, title, artist, type, thumbnail]).then((result) => ({ | |
...result[0], | |
id: result[1], | |
title: result[2], | |
artist: result[3], | |
type: result[4], | |
thumbnail: result[5], | |
})).catch((err) => { | |
throw new Error(err); | |
}); | |
} | |
return { | |
fetch: fetchLink | |
}; | |
})(); | |
const Observer = (function() { | |
const hookObserver = function(observer, query, onSuccess) { | |
const queryElement = document.querySelector(query); | |
if (queryElement) { | |
observer.observe(queryElement, { | |
childList: true, | |
attributes: false, | |
subtree: true | |
}); | |
return onSuccess(queryElement, query); | |
} | |
setTimeout(() => hookObserver(observer, query, onSuccess), 100); | |
}; | |
const chatManager = (function() { | |
let chatInputElement = null; | |
return { | |
getElement: function() { | |
return chatInputElement; | |
}, | |
getMessage: function() { | |
return chatInputElement.textContent; | |
}, | |
setMessage: function(msg) { | |
const inputTextKey = Object.keys(chatInputElement).find(key => key.startsWith('__reactProps$')); | |
const undo = chatInputElement[inputTextKey].children.props.node.undo; | |
// const deleteFragment = chatInputElement[inputTextKey].children.props.node.deleteFragment; | |
const insertText = chatInputElement[inputTextKey].children.props.node.insertText; | |
// const selection = chatInputElement[inputTextKey].children.props.selection; | |
undo(); | |
//deleteFragment(); | |
insertText(msg); | |
}, | |
setInputElement: function(element) { | |
chatInputElement = element; | |
} | |
}; | |
})(); | |
const ObserverSafeQuery = function(observer, queryCallback) { | |
observer.disconnect(); | |
queryCallback(); | |
observer.observe(chatManager.getElement(), { | |
childList: true, | |
attributes: false, | |
subtree: true | |
}); | |
}; | |
const queryCache = {}; | |
const chatInterceptor = async function(mutationList, observer) { | |
const msg = chatManager.getMessage(); | |
const queryMatches = msg.match(/((spotify:track:)|(https?:\/\/((open)|(play))\.spotify\.com\/((track)|(album)|(artist))\/))\w+(\?si=\w+)/g); | |
if (queryMatches?.length > 0) { | |
for (const queryMatch of queryMatches) { | |
if (queryCache[queryMatch]) { | |
chatManager.setMessage(queryCache[queryMatch]); | |
continue; | |
} | |
// if (Object.keys(queryCache).length >= 10) { | |
// const keyToRemove = Object.keys(queryCache)[0]; | |
// delete queryCache[keyToRemove]; | |
// } | |
const data = await OdesliAPI.fetch(queryMatch); | |
ObserverSafeQuery(observer, function() { | |
const youtube = data.linksByPlatform.youtube?.url; | |
const youtubeMusic = data.linksByPlatform.youtubeMusic?.url; | |
const soundcloud = data.linksByPlatform.soundcloud?.url; | |
queryCache[queryMatch] = youtubeMusic || youtube || soundcloud; | |
if (queryCache[queryMatch]) { | |
chatManager.setMessage(queryCache[queryMatch]); | |
} | |
}); | |
} | |
} | |
}; | |
const inputObserver = new MutationObserver(chatInterceptor); | |
return { | |
start: function() { | |
hookObserver(inputObserver, "div[role='textbox']", function(element, query) { | |
element.dataset.__QUERY__ = query; | |
element.dataset.__OBSERVER_HOOKED__ = true; | |
chatManager.setInputElement(element); | |
}); | |
}, | |
isObserved: function() { | |
const chatElement = chatManager.getElement(); | |
if (!chatElement) | |
return false; | |
const latestElement = document.querySelector(chatElement.dataset.__QUERY__).dataset; | |
return !!latestElement.__OBSERVER_HOOKED__; | |
} | |
}; | |
})(); | |
const interceptToken = function() { | |
const originalOpen = XMLHttpRequest.prototype.open; | |
const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; | |
const requestHeaders = new WeakMap(); | |
XMLHttpRequest.prototype.setRequestHeader = function(header, value) { | |
if (!requestHeaders.has(this)) { | |
requestHeaders.set(this, {}); | |
} | |
const headers = requestHeaders.get(this); | |
headers[header.toLowerCase()] = value; | |
originalSetRequestHeader.apply(this, arguments); | |
}; | |
XMLHttpRequest.prototype.open = function(method, url) { | |
this.addEventListener('readystatechange', () => { | |
if (this.readyState === XMLHttpRequest.OPENED) { | |
const headers = requestHeaders.get(this) || {}; | |
// console.log('Intercepted Request:', { method, url, headers }); | |
if (headers["authorization"] && (!TOKEN || !Observer.isObserved())) { | |
TOKEN = headers["authorization"]; | |
//console.log("Token intercepted!: ", TOKEN); | |
Observer.start(); | |
} | |
} | |
}); | |
originalOpen.apply(this, arguments); | |
}; | |
} | |
interceptToken(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment