Skip to content

Instantly share code, notes, and snippets.

@manciuszz
Last active March 30, 2025 20:49
Show Gist options
  • Save manciuszz/8ed94da187c617ea73a6e4e28cb8304f to your computer and use it in GitHub Desktop.
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.
// ==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