// ==UserScript== // @name MusicBrainz: Link Harmony release actions // @version 2025.5.26 // @namespace https://github.com/Dr-Blank // @author Dr.Blank // @description Adds a Harmony actions link icon next to release titles on MusicBrainz release, release group, and artist releases pages. // @icon https://harmony.pulsewidth.org.uk/favicon.svg // @homepageURL https://github.com/kellnerd/musicbrainz-scripts#link-harmony-release-actions // @downloadURL https://raw.github.com/kellnerd/musicbrainz-scripts/main/dist/linkHarmonyReleaseActions.user.js // @updateURL https://raw.github.com/kellnerd/musicbrainz-scripts/main/dist/linkHarmonyReleaseActions.user.js // @supportURL https://github.com/kellnerd/musicbrainz-scripts/issues // @grant GM_getValue // @grant GM_setValue // @run-at document-idle // @match *://*.musicbrainz.org/release/* // @match *://*.musicbrainz.org/release-group/* // @match *://*.musicbrainz.org/artist/*/releases* // ==/UserScript== (function () { 'use strict'; /** * Extracts the entity type and ID from a MusicBrainz URL (can be incomplete and/or with additional path components and query parameters). * @param {string} url URL of a MusicBrainz entity page. * @returns {{ type: CoreEntityTypeT | 'mbid', mbid: MB.MBID } | undefined} Type and ID. */ function extractEntityFromURL(url) { const entity = url.match(/(area|artist|event|genre|instrument|label|mbid|place|recording|release|release-group|series|url|work)\/([0-9a-f-]{36})(?:$|\/|\?)/); return entity ? { type: entity[1], mbid: entity[2] } : undefined; } // --- Default Configuration --- const DEFAULT_ENABLE_ON_RELEASE_GROUP = true; const DEFAULT_ENABLE_ON_ARTIST_RELEASES = true; const DEFAULT_DIGITAL_MEDIA_ONLY = true; const DEFAULT_ICON_SIZE = "1.1em"; const DEFAULT_ICON_MARGIN_LEFT = "5px"; const DEFAULT_HARMONY_ICON_URL = "https://harmony.pulsewidth.org.uk/favicon.svg"; const DEFAULT_HARMONY_BASE_URL = "https://harmony.pulsewidth.org.uk/release/actions?release_mbid="; // --- Key names for GM storage --- const KEY_ENABLE_RG = "harmonyEnableRG"; const KEY_ENABLE_ARTIST = "harmonyEnableArtist"; const KEY_DIGITAL_ONLY = "harmonyDigitalOnly"; const KEY_ICON_SIZE = "harmonyIconSize"; const KEY_ICON_MARGIN = "harmonyIconMarginLeft"; // --- CSS Class Names --- const CSS_LINK_CLASS = "harmony-userscript-link"; const CSS_ICON_CLASS = "harmony-userscript-icon"; // --- Common Selectors --- const releaseTableSelector = "table.tbl.mergeable-table"; const releaseTitleLinkSelector = 'a[href*="/release/"] > bdi'; /** * Injects CSS rules into the document's head. * Prevents duplicate injection using an ID. * @param {string} css - The CSS rules to inject. * @param {string} id - An ID for the style element to prevent duplicates. */ function injectStylesheet(css, id) { const styleId = `harmony-userscript-style-${id}`; if (document.getElementById(styleId)) return; const style = document.createElement("style"); style.id = styleId; style.textContent = css; document.head.appendChild(style); } /** * Fetches a configuration value from GM storage, using a default if not found. * @param {string} key - The key for GM_getValue. * @param {any} defaultValue - The default value to return if the key is not found. * @returns {Promise} - The retrieved or default value. */ async function getConfigValue(key, defaultValue) { try { let value = await GM_getValue(key, defaultValue); if (typeof defaultValue === "boolean") { value = Boolean(value); } if (typeof value !== typeof defaultValue && defaultValue !== undefined) { console.warn( `Harmony Link Script: Config value for '${key}' has unexpected type. Using default.` ); return defaultValue; } return value; } catch (e) { console.error( `Harmony Link Script: Error getting config value for '${key}'. Using default.`, e ); return defaultValue; } } /** * Creates the Harmony link anchor () element with the icon (). * @param {string} mbid - The MusicBrainz Release MBID. * @returns {HTMLAnchorElement|null} The created anchor element or null if mbid is invalid. */ function createHarmonyLinkElement(mbid) { if (!mbid || typeof mbid !== "string" || !/^[a-f0-9\-]{36}$/.test(mbid)) { console.warn( "Harmony Link Script: Invalid MBID passed to createHarmonyLinkElement:", mbid ); return null; } const harmonyLink = document.createElement("a"); harmonyLink.href = `${DEFAULT_HARMONY_BASE_URL}${mbid}`; harmonyLink.target = "_blank"; harmonyLink.rel = "noopener noreferrer"; harmonyLink.title = `View Harmony Release Actions (opens in new tab)`; harmonyLink.classList.add(CSS_LINK_CLASS); const harmonyIcon = document.createElement("img"); harmonyIcon.src = DEFAULT_HARMONY_ICON_URL; harmonyIcon.alt = "Harmony Logo"; harmonyIcon.classList.add(CSS_ICON_CLASS); harmonyLink.appendChild(harmonyIcon); return harmonyLink; } /** * Finds the 1-based index of a table header cell by its text content. * @param {HTMLTableElement} table - The table element to search within. * @param {string} headerText - The text content of the header to find (case-insensitive, trimmed). * @returns {number} The 1-based column index, or -1 if not found. */ function findTableHeaderIndex(table, headerText) { const lowerHeaderText = headerText.toLowerCase().trim(); const headerRow = table.querySelector("thead tr"); if (!headerRow) return -1; const headerCells = Array.from(headerRow.querySelectorAll("th")); for (let i = 0; i < headerCells.length; i++) { if (headerCells[i].textContent.toLowerCase().trim() === lowerHeaderText) { return i + 1; // 1-based index for nth-of-type } } return -1; // Not found } /** * Processes a table containing release links, adding Harmony icons after the links, * optionally filtering by format using a dynamically found "Format" column. * @param {string} tableSelector - CSS selector for the table element. * @param {boolean} digitalOnly - If true, only add icons for "Digital Media" releases. */ function processReleaseTable(tableSelector, digitalOnly) { const tableElement = document.querySelector(tableSelector); if (!tableElement) { console.warn( `Harmony Link Script: Could not find release table using selector: "${tableSelector}".` ); return; } const releaseTableBody = tableElement.querySelector("tbody"); if (!releaseTableBody) { console.warn( `Harmony Link Script: Could not find tbody in table: "${tableSelector}".` ); return; } let formatColumnIndex = -1; if (digitalOnly) { formatColumnIndex = findTableHeaderIndex(tableElement, "Format"); if (formatColumnIndex === -1) { console.warn( 'Harmony Link Script: Could not find "Format" column header. Digital media filtering disabled for this table.' ); } } const releaseTitleBDIElements = releaseTableBody.querySelectorAll( `tr:not(.subh) > td:nth-of-type(2) ${releaseTitleLinkSelector}` ); let addedCount = 0; releaseTitleBDIElements.forEach((bdiElement) => { const releaseLink = bdiElement.parentElement; if (!releaseLink || releaseLink.tagName !== "A") return; const parentRow = releaseLink.closest("tr"); // Get the table row if (!parentRow) return; // --- Format Check --- if (digitalOnly && formatColumnIndex !== -1) { // Use the dynamically found formatColumnIndex const formatCell = parentRow.querySelector( `td:nth-of-type(${formatColumnIndex})` ); const formatText = formatCell ? formatCell.textContent.trim() : ""; if (!formatText.includes("Digital Media")) { return; } } const linkEntity = extractEntityFromURL(releaseLink.href); if (linkEntity && linkEntity.type === "release" && linkEntity.mbid) { const mbid = linkEntity.mbid; const harmonyLink = createHarmonyLinkElement(mbid); const parentTd = releaseLink.closest("td"); // The containing cell (TD) if (harmonyLink && parentTd) { let nodeToInsertAfter = releaseLink; // Traverse up the DOM tree from the release link // until we find the element that is a DIRECT child of the TD. // This handles cases where the link is wrapped in other elements (like span.mp). while (nodeToInsertAfter.parentElement !== parentTd) { nodeToInsertAfter = nodeToInsertAfter.parentElement; if ( !nodeToInsertAfter || nodeToInsertAfter === parentTd || nodeToInsertAfter === document.body ) { console.error( "Harmony Link Script: Could not find the correct insertion point within the TD for link:", releaseLink.href ); nodeToInsertAfter = null; // Prevent insertion if logic fails break; } } // Only insert if we successfully found the correct reference node if (nodeToInsertAfter) { try { parentTd.insertBefore(harmonyLink, nodeToInsertAfter.nextSibling); addedCount++; } catch (e) { console.error("Harmony Link Script: Error during insertion:", e); } } } } }); } /** * Main async function to execute the script logic. */ async function runHarmonyLinker() { // --- Get Configuration from GM Storage --- const enableRG = await getConfigValue( KEY_ENABLE_RG, DEFAULT_ENABLE_ON_RELEASE_GROUP ); const enableArtist = await getConfigValue( KEY_ENABLE_ARTIST, DEFAULT_ENABLE_ON_ARTIST_RELEASES ); const digitalOnly = await getConfigValue( KEY_DIGITAL_ONLY, DEFAULT_DIGITAL_MEDIA_ONLY ); const iconSize = await getConfigValue(KEY_ICON_SIZE, DEFAULT_ICON_SIZE); const iconMarginLeft = await getConfigValue( KEY_ICON_MARGIN, DEFAULT_ICON_MARGIN_LEFT ); const dynamicCSS = ` .${CSS_LINK_CLASS} { margin-left: ${iconMarginLeft}; text-decoration: none !important; display: inline-flex; vertical-align: middle; line-height: 1; } .${CSS_ICON_CLASS} { height: ${iconSize}; width: ${iconSize}; vertical-align: middle; border: none; line-height: 1; } `; injectStylesheet(dynamicCSS, "harmony-dynamic-styles"); // --- Page Specific Logic --- const currentPath = window.location.pathname; const currentEntity = extractEntityFromURL(window.location.href); // 1. Handle Single Release Page if (currentEntity && currentEntity.type === "release") { let applyLink = true; // Assume we apply unless digitalOnly says otherwise if (digitalOnly) { // Need to find the format on the release page header. const formatDt = Array.from( document.querySelectorAll("#sidebar dl.properties dt") ).find((dt) => dt.textContent.trim() === "Format:"); const formatDd = formatDt?.nextElementSibling; // Should be the
const formatText = formatDd ? formatDd.textContent.trim() : ""; if (!formatText.includes("Digital Media")) { applyLink = false; // Do not apply if format isn't digital } } if (applyLink) { const mbid = currentEntity.mbid; const headingElement = document.querySelector("div.releaseheader h1"); const releaseTitleBDI = headingElement ? headingElement.querySelector(releaseTitleLinkSelector) : null; const releaseTitleLink = releaseTitleBDI?.parentElement; if (headingElement && releaseTitleLink) { const harmonyLink = createHarmonyLinkElement(mbid); if (harmonyLink) { try { headingElement.insertBefore( harmonyLink, releaseTitleLink.nextSibling ); } catch (e) { console.error("Harmony Link Script: Error inserting into H1:", e); } } } } } // 2. Handle Release Group Page else if ( enableRG && currentEntity && currentEntity.type === "release-group" ) { processReleaseTable(releaseTableSelector, digitalOnly); } // 3. Handle Artist Releases Page else if ( enableArtist && currentEntity && currentEntity.type === "artist" && currentPath.includes("/releases") ) { processReleaseTable(releaseTableSelector, digitalOnly); } } // --- Initial Setup & Execution --- (async () => { // Set default values in storage if they don't exist await GM_setValue( KEY_ENABLE_RG, await GM_getValue(KEY_ENABLE_RG, DEFAULT_ENABLE_ON_RELEASE_GROUP) ); await GM_setValue( KEY_ENABLE_ARTIST, await GM_getValue(KEY_ENABLE_ARTIST, DEFAULT_ENABLE_ON_ARTIST_RELEASES) ); await GM_setValue( KEY_DIGITAL_ONLY, await GM_getValue(KEY_DIGITAL_ONLY, DEFAULT_DIGITAL_MEDIA_ONLY) ); await GM_setValue( KEY_ICON_SIZE, await GM_getValue(KEY_ICON_SIZE, DEFAULT_ICON_SIZE) ); await GM_setValue( KEY_ICON_MARGIN, await GM_getValue(KEY_ICON_MARGIN, DEFAULT_ICON_MARGIN_LEFT) ); // Run main logic after slight delay setTimeout(runHarmonyLinker, 50); })(); })();