-
-
Notifications
You must be signed in to change notification settings - Fork 3
Adding a New Music Site
This guide explains how to create parsers that extract song information (title, artist, album art, duration) from any music website and display it on Discord.
| Method | Requires Code? | When to use? |
|---|---|---|
| UI (Popup) | No | If you want a quick setup and point-and-click configuration is enough for the site |
| UserScript | Yes (JS) | If you know JavaScript and the UI Method isn't enough for the site |
| Build Method | Yes (JS) | If you're a developer and want to add a built-in website |
You determine which pages the parser will run on using the domain. It is important to understand this before moving on to setup.
The parser will only run on the exact domain you specify.
example.com → only runs on example.com
You can cover all subdomains of a domain by adding *.. But beware: the root domain is not included.
*.example.com → runs on subdomains like player.example.com, app.example.com
→ does not run on example.com
This is useful for sites where every user or page has its own subdomain.
You can target multiple domains with a single parser:
// In UI and UserScript methods
site.com, app.site.com
// Only in Build Method
domain: ["site.com", "app.site.com"];If you want to restrict your parser to specific pages, you use URL patterns. These are regexes matched against the page path.
.* → all pages (default)
track/.* → only paths containing /track/
album/.* → only paths containing /album/
To use multiple patterns, separate them with a comma:
track/.*, album/.*
If you leave the pattern field blank (or type .*), it matches all pages on the domain.
The selector system supports the following CSS selector strategies:
Show
-
ID selectors
#id
-
Class selectors
.class .class1.class2
-
Tag selectors
div button
-
Attribute selectors
[data-testid="button"] [class*="button"] [data-testid^="submit"] [href$=".pdf"]
-
Partial class matching
.product-card_ .title_
-
Hierarchical selectors
A > B A B A > B > C
-
Sibling selectors
A + B A ~ B
-
Hybrid selectors
#main > .item [data-testid="card"] .title .product-card_ > .title_
-
Context-aware selectors
header > .search-input footer > .search-input
-
Position-based selectors
A:nth-child(n) A:nth-of-type(n)
The fastest way. You select elements on the page by clicking them; no code is required.
- Open the music site in your browser.
- Click the extension icon and press the "Add Music Site" button.
- In the panel that opens, click the "+" icon for each field, then click the corresponding element on the page:
- Title (required) - song title
- Artist (optional) - artist name
- Image (optional) - album art
- Time Passed (optional) - current playback position
- Duration (optional) - total track duration
- Source (optional) - the label to be displayed on Discord
- Play Status (optional) - an element or attribute that is only present when music is playing; used for cast support
- Select the selector in the "Most Stable Selector" section.
- Check the Domain field:
- The current page's domain is auto-filled.
- Change it to
*.example.comto cover all subdomains. - Separate with commas to target multiple domains:
site.com, app.site.com
- Click "Save" and refresh the page.
Artist and title in the same element: Add the same selector to both fields. The extension splits them automatically (Normalization must be enabled in settings).
Only Duration is available: Add only the Duration field. The extension will estimate the playback time from the moment the track changes.
Time Passed and Duration are combined (e.g., 0:12 / 2:20): Add the same selector to both fields.
Link field: If left blank, the current page URL is automatically used.
Regex field: Leave blank to match all pages, or enter a pattern to restrict the parser to specific pages.
You can write a custom JavaScript parser with the built-in UserScript Manager to access the page's DOM and make API calls.
- Click "Open Script Manager" in the extension popup.
- Click "+ New Script".
- Fill in the fields:
-
Script Name - name of the parser (e.g.,
My Music Parser) - Description - A brief note about the target website (optional)
- Authors - comma-separated author names (optional)
-
Domain(s) - comma-separated list of domains where the script runs (e.g.,
example.comorexample.com,example2.com) -
URL Pattern(s) - comma-separated regex patterns for matching URLs (e.g.,
player.*orplayer.*,.song.*). Use.*for all pages.
- Write the script code (see below).
- Click "Save Script".
Your script must assign values to the required variables using let. The engine automatically reads these variables as output.
Do not use return at the top-level script scope. return is allowed inside functions.
let title = ""; // Song title
let artist = ""; // Artist name
let image = ""; // Album art URL (or null)
let source = ""; // Label to show on Discord (e.g., "My Radio")
let songUrl = ""; // Direct URL of the current track
let timePassed = null; // Playback position in seconds (or null)
let duration = null; // Total track duration in seconds (or null)let isPlaying = false; // Whether music is currently playing (for cast support)
let buttons = [
// Maximum 2 buttons shown on Discord RPC
{ link: "https://...", text: "Open Track" },
{ link: "https://...", text: "View Artist" },
];The example below shows what a basic parser looks like. Each line explains what is being done:
// Get song title from page; returns empty string if not found
let title = document.querySelector(".track-title")?.textContent?.trim() ?? "";
// Get artist name
let artist = document.querySelector(".artist-name")?.textContent?.trim() ?? "";
// Get the URL of the album art image
let image = document.querySelector(".album-art")?.src ?? null;
// Label to display on Discord
let source = "My Music Site";
// URL of the current page
let songUrl = window.location.href;
// Playback position and duration - fill if available on your site, otherwise leave null
let timePassed = null;
let duration = null;
// If the audio element is not paused, music is playing
let isPlaying = !document.querySelector("audio")?.paused;
// Button to display on Discord
let buttons = [{ link: songUrl, text: "Open Track" }];Some sites provide song information through an API rather than in the DOM. In this case, you can use fetch:
let title = "";
let artist = "";
let image = null;
let source = "My Music Site";
let songUrl = window.location.href;
let timePassed = null;
let duration = null;
// Call the site's "now playing" endpoint
const response = await fetch("https://api.example.com/now-playing");
const data = await response.json();
// Fill fields from the incoming data
title = data.title ?? "";
artist = data.artist ?? "";
timePassed = data.position ?? null;
duration = data.duration ?? null;You can add settings that appear in the extension popup (toggle, text input, dropdown menu):
// Define settings that the user can change from the popup
const showArtist = await useSetting("showArtist", "Show Artist Name", "checkbox", true);
const customLabel = await useSetting("customLabel", "Source Label", "text", "My Station");
let title = document.querySelector(".track-title")?.textContent?.trim() ?? "";
// Show or hide the artist based on the setting
let artist = showArtist ? (document.querySelector(".artist-name")?.textContent?.trim() ?? "") : "";
let source = customLabel;Setting types: "text", "checkbox", "select"
Use the "Generate Settings" button in the Script Manager to automatically create these lines.
Some media players run inside an iframe. Since functions cannot be serialized in UserScripts, iframeSelectors provides a structured DOM-based extraction system that runs inside the iframe context and returns a plain object through getIframeData().
Important
If you use iframeSelectors, the Auto Detect Video Status option will be ignored due to technical limitations and to avoid data conflicts.
If you still want to use the auto-detection engine, you must explicitly include the $video macro key inside your fields object:
{
"fields": {
"title": {
"type": "text",
"selector": ".custom-track-title"
},
"$video": {
"type": "video"
}
}
}const iframe = await getIframeData();
const title = getText(".title");
const playing = iframe?.playing;
const time = iframe?.currentTime;Show
Returns { duration, currentTime, paused } from the video element on the page.
When used with the special $video key, the result is automatically assigned to the corresponding variables.
{
"fields": {
"video": {
"type": "video"
}
}
}Parameters: selector
Returns true if an element matching the selector exists; otherwise false.
{
"type": "exists",
"selector": ".playing"
}Parameters: selector
Returns true if no matching element exists; otherwise false.
{
"type": "not",
"selector": ".loading"
}Parameters: selector, trim?
Extracts textContent from an element.
- Default:
trim: true - Set
trim: falseto preserve original spacing
{
"type": "text",
"selector": "#track-title",
"trim": false
}Parameters: selector, attr
Returns the raw attribute value.
{
"type": "attr",
"selector": ".player",
"attr": "data-id"
}Parameters: selector, attr
Alias of attr. Reads an attribute from the element, typically used for ARIA attributes. Returns the raw string value.
{
"type": "aria",
"selector": ".play-btn",
"attr": "aria-label"
}Parameters: selector
Returns the .src property of media elements.
{
"type": "src",
"selector": "video"
}Parameters: selector
Returns the resolved absolute URL from an anchor element.
{
"type": "href",
"selector": "a.track-link"
}Parameters: selector
Parses textContent using parseFloat().
Returns:
-
numberif valid -
nullotherwise
{
"type": "number",
"selector": ".rating"
}Parameters: selector
Parses time strings like MM:SS or HH:MM:SS into seconds.
Invalid values return null, including:
--:---:-- empty strings
- malformed numbers
{
"type": "time",
"selector": ".current-time"
}Parameters: selector, class
Checks if element contains required CSS classes.
- string → single class check
- array → all classes must exist
{
"type": "classContains",
"selector": ".volume-btn",
"class": ["icon-mute", "active"]
}Parameters: selector, key
Returns element.dataset[key].
{
"type": "dataset",
"selector": ".track",
"key": "trackId"
}Parameters: name
Reads a meta tag by name or property and returns its content.
<meta name=""><meta property="">
Returns content.
{
"type": "meta",
"name": "og:title"
}Returns the number of matching elements:
document.querySelectorAll(selector).length;{
"type": "count",
"selector": ".playlist-item"
}Parameters: rules
Evaluates rules in order and returns the first valid result.
A value is considered valid if it is:
- not
null - not
undefined - not an empty string (
"")
⚠️ 0andfalseare treated as valid values and will stop evaluation.
If no rule returns a valid value, the result is null.
{
"type": "or",
"rules": [
{
"type": "prop",
"path": "player.getTime"
},
{
"type": "time",
"selector": ".current-time-display"
}
]
}Parameters: path
Resolves a value from the global window object using dot-notation path traversal.
If any part of the path is missing, the result is null.
If the resolved value is a function, it is executed immediately:
- without arguments
- without preserving
thiscontext
If function execution throws an error, the result is null.
{
"type": "prop",
"path": "jwplayer.getPosition"
}You add the parser directly into the extension's source code. This is the most powerful option and provides access to all advanced features.
In the project folder, go to the extension/parsers/ directory and create a new .js file named after the site (e.g., myMusicSite.js).
registerParser({
// Required
domain: "example.com", // or "*.example.com" or ["site.com", "app.site.com"]
title: "Example",
category: "platform", // "radio" | "platform" | "aggregator"
// Optional
homepage: "https://example.com",
description: "Parser for Example Music.",
authors: ["yourName"],
authorsLinks: ["https://github.com/yourName"],
tags: ["rock", "community"],
urlPatterns: [/.*/],
mode: "listen", // "listen" | "watch"
fn: async function ({ accessWindow, useSetting, iframeData }) {
return {
title: getText(".now-playing-title"), // song title
artist: getText(".now-playing-artist"), // artist name
image: getImage("img.album_art"), // album art
timePassed: getText(".time-display-played"),
duration: getText(".time-display-total"),
source: "Example",
songUrl: "https://example.com/song",
isPlaying: Boolean(document.querySelector("#pauseSongButton")),
buttons: [{ text: "Listen Along", link: "https://example.com/song" }],
};
},
});fn: async function ({ useSetting }) {
const showArtist = await useSetting("showArtist", { en: "Show Artist Name" }, "checkbox", true);
const customLabel = await useSetting("customLabel", { en: "Custom Source Label" }, "text", "My Station");
const quality = await useSetting("quality", { en: "Stream Quality" }, "select", [
{ value: "high", label: { en: "High" }, selected: true },
{ value: "medium", label: { en: "Medium" } },
]);
return {
source: showArtist ? customLabel : "",
};
},Some sites store track data in JavaScript variables instead of the DOM. You can read them using accessWindow:
fn: async function ({ accessWindow }) {
// Get current audio info from the site's global player object
const audio = await accessWindow("player.getCurrentAudio");
return {
title: audio?.title ?? "", // returns empty string if not found
artist: audio?.artist ?? "",
};
},
accessWindowreturnsnullif the property does not exist or throws an error. Always keep this in mind.
If the player runs inside an iframe, you can extract data from there using iframeFn:
registerParser({
domain: "example.com",
title: "Example",
category: "platform",
iframeOrigins: ["player.example.com"],
fn: async function ({ iframeData }) {
if (!iframeData) return {};
return {
title: iframeData.title,
isPlaying: iframeData.isPlaying,
timePassed: String(iframeData.currentTime),
duration: String(iframeData.duration),
};
},
// This function runs inside the iframe
iframeFn: function () {
const video = document.querySelector("video");
return {
title: document.querySelector(".track-title")?.textContent ?? "",
isPlaying: !video?.paused,
currentTime: video?.currentTime,
duration: video?.duration,
};
},
});
iframeData.lastValidValuesholds the latest valid values for each field. Use it as a fallback when data is temporarily unavailable.
Reads text content or an attribute from a DOM element.
| Parameter | Type | Description |
|---|---|---|
selector |
string |
CSS selector (e.g., ".now-playing-title") |
options.attr |
string |
Read attribute instead of text (e.g., "href", "title") |
options.transform |
function |
Function that modifies the result before returning it |
options.root |
Element |
Search within a specific element instead of the whole page |
getText(".now-playing-title");
getText(".song-link", { attr: "href" });
getText(".song-link", { attr: "href", transform: (v) => v.replace(/^\//, "") });Extracts an image URL from a DOM element.
Search priority: direct <img> src → CSS background-image → first child <img> src
getImage(".album-cover img");
getImage(".hero-banner"); // works with background-image too
getImage(".track-card"); // also checks the first child imgSame as getText, but returns an array for all matching elements.
getTextAll(".track-list .title");
// → ["Song 1", "Song 2", "Song 3"]Same as getImage, but returns an array of image URLs for all matching elements.
getImageAll(".playlist-item img");
// → ["cover1.jpg", "cover2.jpg", "cover3.jpg"]Searches for elements in the normal DOM and all nested Shadow DOM trees.
Standard querySelector() cannot reach inside Web Components or sites that encapsulate elements within a shadowRoot; this function solves that problem.
| Parameter | Type | Description |
|---|---|---|
selector |
string |
CSS selector to search for |
root |
Document - ShadowRoot - Element |
Root node to start searching. Default: document
|
all |
boolean |
If true, returns all matching elements as an array. Otherwise, returns the first match |
querySelectorDeep(".play-button");
querySelectorDeep(".track-title", someShadowRoot);
querySelectorDeep(".menu-item", document, true);My parser only works on one subdomain, I want it to work on all of them.
Change the domain to *.example.com. This matches all subdomains (like app.example.com, player.example.com), but the root domain example.com is not included.
The site uses different layouts based on the URL. How do I handle this?
Use UserScript or Build Method and check the URL path inside the code:
const path = window.location.pathname;
let title = "";
if (path.startsWith("/track/")) {
// Title element on the track page
title = document.querySelector(".track-title")?.textContent?.trim() ?? "";
} else if (path.startsWith("/album/")) {
// Title element on the album page
title = document.querySelector(".album-title")?.textContent?.trim() ?? "";
}I added timePassed and duration but the progress bar does not appear.
Make sure the values are numbers in seconds or strings in "m:ss" format (e.g., "1:23"). Sending null or an empty string disables the progress bar.
My parser runs on pages where it shouldn't.
Restrict it using URL patterns. For example, player.* only matches paths starting with /player. You can combine multiple patterns with a comma: track/.*, album/.*
Buttons do not appear on Discord.
The Discord desktop application does not show buttons. To test them, log into Discord via a browser using a separate account.
What does isPlaying do?
It controls cast support. Return true if music is actively playing, and false when paused or stopped.
// From audio element
let isPlaying = !document.querySelector("audio")?.paused;
// From visible pause button (if the pause button is visible, music is playing)
let isPlaying = Boolean(document.querySelector(".pause-button"));