// @ts-check // NAME: Spicetify Marketplace Extension // AUTHOR: theRealPadster, CharlieS1103 // DESCRIPTION: Companion extension for Spicetify Marketplace /// // Reset any Marketplace localStorage keys (effectively resetting it completely) // eslint-disable-next-line no-redeclare const resetMarketplace = () => { console.log("Resetting Marketplace"); // Loop through and reset marketplace keys Object.keys(localStorage).forEach((key) => { if (key.startsWith("marketplace:")) { localStorage.removeItem(key); console.log(`Removed ${key}`); } }); console.log("Marketplace has been reset"); location.reload(); }; // Expose useful methods in global context // @ts-ignore window.Marketplace = { // Should allow you to reset Marketplace from the dev console if it's b0rked reset: resetMarketplace, }; // eslint-disable-next-line no-redeclare const hexToRGB = (hex) => { if (hex.length === 3) { hex = hex.split("").map((char) => char + char).join(""); } else if (hex.length != 6) { throw "Only 3- or 6-digit hex colours are allowed."; } else if (hex.match(/[^0-9a-f]/i)) { throw "Only hex colours are allowed."; } const aRgbHex = hex.match(/.{1,2}/g); const aRgb = [ parseInt(aRgbHex[0], 16), parseInt(aRgbHex[1], 16), parseInt(aRgbHex[2], 16), ]; return aRgb; }; /** * Get user, repo, and branch from a GitHub raw URL * @param {string} url Github Raw URL * @returns { { user: string, repo: string, branch: string, filePath: string } } */ const getParamsFromGithubRaw = (url) => { // eslint-disable-next-line no-useless-escape const regex_result = url.match(/https:\/\/raw\.githubusercontent\.com\/(?[^\/]+)\/(?[^\/]+)\/(?[^\/]+)\/(?.+$)/); // e.g. https://raw.githubusercontent.com/CharlieS1103/spicetify-extensions/main/featureshuffle/featureshuffle.js const obj = { user: regex_result ? regex_result.groups.user : null, repo: regex_result ? regex_result.groups.repo : null, branch: regex_result ? regex_result.groups.branch : null, filePath: regex_result ? regex_result.groups.filePath : null, }; return obj; }; (async function MarketplaceExtension() { const { LocalStorage } = Spicetify; if (!(LocalStorage)) { // console.log('Not ready, waiting...'); setTimeout(MarketplaceExtension, 1000); return; } // TODO: can we reference/require/import common files between extension and custom app? const LOCALSTORAGE_KEYS = { "installedExtensions": "marketplace:installed-extensions", "installedSnippets": "marketplace:installed-snippets", "installedThemes": "marketplace:installed-themes", "activeTab": "marketplace:active-tab", "tabs": "marketplace:tabs", // Theme installed store the localsorage key of the theme (e.g. marketplace:installed:NYRI4/Comfy-spicetify/user.css) "themeInstalled": "marketplace:theme-installed", "colorShift": "marketplace:colorShift", }; const getLocalStorageDataFromKey = (key, fallback) => { return JSON.parse(localStorage.getItem(key)) ?? fallback; }; const initializeExtension = (extensionKey) => { const extensionManifest = getLocalStorageDataFromKey(extensionKey); // Abort if no manifest found or no extension URL (i.e. a theme) if (!extensionManifest || !extensionManifest.extensionURL) return; console.log("Initializing extension: ", extensionManifest); const script = document.createElement("script"); script.defer = true; script.src = extensionManifest.extensionURL; // If it's a github raw script, use jsdelivr if (script.src.indexOf("raw.githubusercontent.com") > -1) { const { user, repo, branch, filePath } = getParamsFromGithubRaw(extensionManifest.extensionURL); if (!user || !repo || !branch || !filePath) return; script.src = `https://cdn.jsdelivr.net/gh/${user}/${repo}@${branch}/${filePath}`; } script.src = `${script.src}?time=${Date.now()}`; document.body.appendChild(script); }; /** * Loop through the snippets and add the contents of the code as a style tag in the DOM * @param { { title: string; description: string; code: string;}[] } snippets The snippets to initialize */ // TODO: keep this in sync with the index.js file const initializeSnippets = (snippets) => { // Remove any existing marketplace snippets const existingSnippets = document.querySelector("style.marketplaceSnippets"); if (existingSnippets) existingSnippets.remove(); const style = document.createElement("style"); const styleContent = snippets.reduce((accum, snippet) => { accum += `/* ${snippet.title} - ${snippet.description} */\n`; accum += `${snippet.code}\n`; return accum; }, ""); style.innerHTML = styleContent; style.classList.add("marketplaceSnippets"); document.head.appendChild(style); }; // NOTE: Keep in sync with index.js const injectColourScheme = (scheme) => { try { // Remove any existing Spicetify scheme const existingColorsCSS = document.querySelector("link[href='colors.css']"); if (existingColorsCSS) existingColorsCSS.remove(); // Remove any existing marketplace scheme const existingMarketplaceSchemeCSS = document.querySelector("style.marketplaceCSS.marketplaceScheme"); if (existingMarketplaceSchemeCSS) existingMarketplaceSchemeCSS.remove(); // Add new marketplace scheme const schemeTag = document.createElement("style"); schemeTag.classList.add("marketplaceCSS"); schemeTag.classList.add("marketplaceScheme"); // const theme = document.querySelector('#theme'); let injectStr = ":root {"; const themeIniKeys = Object.keys(scheme); themeIniKeys.forEach((key) => { injectStr += `--spice-${key}: #${scheme[key]};`; injectStr += `--spice-rgb-${key}: ${hexToRGB(scheme[key])};`; }); injectStr += "}"; schemeTag.innerHTML = injectStr; document.head.appendChild(schemeTag); } catch (error) { console.warn(error); } }; /** * Update the user.css in the DOM * @param {string} userCSS The contents of the new user.css */ const injectUserCSS = (userCSS) => { try { // Remove any existing Spicetify user.css const existingUserThemeCSS = document.querySelector("link[href='user.css']"); if (existingUserThemeCSS) existingUserThemeCSS.remove(); // Remove any existing marketplace scheme const existingMarketplaceUserCSS = document.querySelector("style.marketplaceCSS.marketplaceUserCSS"); if (existingMarketplaceUserCSS) existingMarketplaceUserCSS.remove(); // Add new marketplace scheme const userCssTag = document.createElement("style"); userCssTag.classList.add("marketplaceCSS"); userCssTag.classList.add("marketplaceUserCSS"); userCssTag.innerHTML = userCSS; document.head.appendChild(userCssTag); } catch (error) { console.warn(error); } }; // I guess this is okay to not have an end condition on the interval // because if they turn the setting on or off, // closing the settings modal will reload the page const initColorShiftLoop = (schemes) => { let i = 0; const NUM_SCHEMES = Object.keys(schemes).length; setInterval(() => { // Resets to zero when passes the last scheme i = i % NUM_SCHEMES; const style = document.createElement("style"); style.className = "colorShift-style"; style.innerHTML = `* { transition-duration: 400ms; } main-type-bass { transition-duration: unset !important; }`; document.body.appendChild(style); injectColourScheme(Object.values(schemes)[i]); i++; style.remove(); }, 60 * 1000); }; const parseCSS = async (themeManifest) => { const userCssUrl = themeManifest.cssURL.indexOf("raw.githubusercontent.com") > -1 // TODO: this should probably be the URL stored in localstorage actually (i.e. put this url in localstorage) ? `https://cdn.jsdelivr.net/gh/${themeManifest.user}/${themeManifest.repo}@${themeManifest.branch}/${themeManifest.manifest.usercss}` : themeManifest.cssURL; // TODO: Make this more versatile const assetsUrl = userCssUrl.replace("/user.css", "/assets/"); console.log("Parsing CSS: ", userCssUrl); let css = await fetch(`${userCssUrl}?time=${Date.now()}`).then(res => res.text()); // console.log("Parsed CSS: ", css); let urls = css.matchAll(/url\(['|"](?.+?)['|"]\)/gm) || []; for (const match of urls) { const url = match.groups.path; // console.log(url); // If it's a relative URL, transform it to HTTP URL if (!url.startsWith("http") && !url.startsWith("data")) { const newUrl = assetsUrl + url.replace(/\.\//g, ""); css = css.replace(url, newUrl); } } // console.log("New CSS: ", css); return css; }; const initializeTheme = async (themeKey) => { const themeManifest = getLocalStorageDataFromKey(themeKey); // Abort if no manifest found if (!themeManifest) { console.log("No theme manifest found"); return; } console.log("Initializing theme: ", themeManifest); // Inject colour scheme if found if (themeManifest.schemes) { const activeScheme = themeManifest.schemes[themeManifest.activeScheme]; injectColourScheme(activeScheme); if (localStorage.getItem(LOCALSTORAGE_KEYS.colorShift) === "true") { initColorShiftLoop(themeManifest.schemes); } } else { console.warn("No schemes found for theme"); } // Remove default css // TODO: what about if we remove the theme? Should we re-add the user.css/colors.css? // const existingUserThemeCSS = document.querySelector("link[href='user.css']"); // if (existingUserThemeCSS) existingUserThemeCSS.remove(); // Remove any existing marketplace theme const existingMarketplaceThemeCSS = document.querySelector("link.marketplaceCSS"); if (existingMarketplaceThemeCSS) existingMarketplaceThemeCSS.remove(); // Add theme css const userCSS = await parseCSS(themeManifest); injectUserCSS(userCSS); // Inject any included js if (themeManifest.include && themeManifest.include.length) { // console.log("Including js", installedThemeData.include); themeManifest.include.forEach((script) => { const newScript = document.createElement("script"); let src = script; // If it's a github raw script, use jsdelivr if (script.indexOf("raw.githubusercontent.com") > -1) { const { user, repo, branch, filePath } = getParamsFromGithubRaw(script); src = `https://cdn.jsdelivr.net/gh/${user}/${repo}@${branch}/${filePath}`; } // console.log({src}); newScript.src = `${src}?time=${Date.now()}`; newScript.classList.add("marketplaceScript"); document.body.appendChild(newScript); }); } }; console.log("Loaded Marketplace extension"); const installedThemeKey = LocalStorage.get(LOCALSTORAGE_KEYS.themeInstalled); if (installedThemeKey) initializeTheme(installedThemeKey); const installedSnippetKeys = getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.installedSnippets, []); const installedSnippets = installedSnippetKeys.map((key) => getLocalStorageDataFromKey(key)); initializeSnippets(installedSnippets); const installedExtensions = getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.installedExtensions, []); installedExtensions.forEach((extensionKey) => initializeExtension(extensionKey)); })(); const ITEMS_PER_REQUEST = 100; async function Blacklist() { const url = "https://raw.githubusercontent.com/CharlieS1103/spicetify-marketplace/main/blacklist.json"; const jsonReturned = await fetch(url).then(res => res.json()).catch(() => {}); return jsonReturned.repos; } /** * TODO * @param {"theme"|"extension"} type The repo type * @param {number} pageNum The page number * @returns TODO */ async function queryRepos(type, pageNum = 1) { const BLACKLIST = window.sessionStorage.getItem("marketplace:blacklist"); let url = `https://api.github.com/search/repositories?per_page=${ITEMS_PER_REQUEST}`; if (type === "extension") url += `&q=${encodeURIComponent("topic:spicetify-extensions")}`; else if (type === "theme") url += `&q=${encodeURIComponent("topic:spicetify-themes")}`; if (pageNum) url += `&page=${pageNum}`; const allRepos = await fetch(url).then(res => res.json()).catch(() => []); if (!allRepos.items) { Spicetify.showNotification("Too Many Requests, Cool Down."); } const filteredResults = { ...allRepos, page_count: allRepos.items.length, items: allRepos.items.filter(item => !BLACKLIST.includes(item.html_url)), }; return filteredResults; } /** * TODO * @param {"theme"|"extension"} type The repo type * @param {number} pageNum The page number * @returns TODO */ async function loadPageRecursive(type, pageNum) { const pageOfRepos = await queryRepos(type, pageNum); appendInformationToLocalStorage(pageOfRepos, type); // Sets the amount of items that have thus been fetched const soFarResults = ITEMS_PER_REQUEST * (pageNum - 1) + pageOfRepos.page_count; console.log({ pageOfRepos }); const remainingResults = pageOfRepos.total_count - soFarResults; // If still have more results, recursively fetch next page console.log(`Parsed ${soFarResults}/${pageOfRepos.total_count} ${type}s`); if (remainingResults > 0) return await loadPageRecursive(type, pageNum + 1); // There are more results. currentPage + 1 is the next page to fetch. else console.log(`No more ${type} results`); } (async function initializePreload() { console.log("Preloading extensions and themes..."); window.sessionStorage.clear(); const BLACKLIST = await Blacklist(); window.sessionStorage.setItem("marketplace:blacklist", JSON.stringify(BLACKLIST)); // TODO: does this work? // The recursion isn't super clean... // Begin by getting the themes and extensions from github // const [extensionReposArray, themeReposArray] = await Promise.all([ await Promise.all([ loadPageRecursive("extension", 1), loadPageRecursive("theme", 1), ]); // let extensionsNextPage = 1; // let themesNextPage = 1; // do { // extensionReposArray = await loadPage("extension", extensionsNextPage); // appendInformationToLocalStorage(extensionReposArray, "extension"); // } while (extensionsNextPage); // do { // themeReposArray = await loadPage("theme", themesNextPage); // appendInformationToLocalStorage(themeReposArray, "theme"); // } while (themesNextPage); })(); async function appendInformationToLocalStorage(array, type) { // This system should make it so themes and extensions are stored concurrently for (const repo of array.items) { const data = (type === "theme") ? await fetchThemeManifest(repo.contents_url, repo.default_branch) : await fetchExtensionManifest(repo.contents_url, repo.default_branch); if (data) { addToSessionStorage(data); await sleep(5000); } } } // This function is used to fetch manifest of a theme and return it async function fetchThemeManifest(contents_url, branch) { try { const regex_result = contents_url.match(/https:\/\/api\.github\.com\/repos\/(?.+)\/(?.+)\/contents/); // TODO: err handling? if (!regex_result || !regex_result.groups) return null; let { user, repo } = regex_result.groups; let manifests = await getRepoManifest(user, repo, branch); // If the manifest returned is not an array, initialize it as one if (!Array.isArray(manifests)) manifests = [manifests]; manifests.user = user; manifests.repo = repo; if (manifests[0] && manifests[0].name && manifests[0].usercss && manifests[0].description) { return manifests; } return null; } catch (err) { // console.warn(contents_url, err); return null; } } // This function is used to fetch manifest of an extension and return it async function fetchExtensionManifest(contents_url, branch) { try { // TODO: use the original search full_name ("theRealPadster/spicetify-hide-podcasts") or something to get the url better? const regex_result = contents_url.match(/https:\/\/api\.github\.com\/repos\/(?.+)\/(?.+)\/contents/); // TODO: err handling? if (!regex_result || !regex_result.groups) return null; const { user, repo } = regex_result.groups; let manifests = await getRepoManifest(user, repo, branch); // If the manifest returned is not an array, initialize it as one if (!Array.isArray(manifests)) manifests = [manifests]; manifests.user = user; manifests.repo = repo; if (manifests[0] && manifests[0].name && manifests[0].description && manifests[0].main) { return manifests; } return null; } catch (err) { // console.warn(contents_url, err); return null; } } async function getRepoManifest(user, repo, branch) { const sessionStorageItem = window.sessionStorage.getItem(`${user}-${repo}`); const failedSessionStorageItems = window.sessionStorage.getItem("noManifests"); if (sessionStorageItem) { return null; } const url = `https://raw.githubusercontent.com/${user}/${repo}/${branch}/manifest.json`; if (failedSessionStorageItems?.includes(url)) { return null; } return await fetch(url).then(res => res.json()).catch(() => addToSessionStorage([url], "noManifests")); } // This function appends an array to session storage function addToSessionStorage(items, key) { if (!items || items == null) return; items.forEach(item => { if (!key) key = `${items.user}-${items.repo}`; // If the key already exists, it will append to it instead of overwriting it const existing = window.sessionStorage.getItem(key); const parsed = existing ? JSON.parse(existing) : []; parsed.push(item); window.sessionStorage.setItem(key, JSON.stringify(parsed)); }); } // This function is used to sleep for a certain amount of time function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }