// ==UserScript== // @name String Theory // @namespace https://github.com/majkinetor/musicbrainz-userscripts // @version 2026.10.16.085122 // @description Unified bundle of 11 MusicBrainz userscripts (apollo_editor, art_station, credit_hoarder, falcon, first_contact, fusion, group_therapy, isrc_scout, mammoth, mission_control, platform_check). Built by userscripts/string_theory/build.mjs — do not hand-edit. // @author majkinetor // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5TdHJpbmcgVGhlb3J5PC90aXRsZT4KPHBhdGggZD0iTTY0IDEwIEw2Ni40IDEwLjIgTDY4LjcgMTAuNiBMNzAuOSAxMS40IEw3My4xIDEyLjQgTDc1LjIgMTMuNyBMNzcuMSAxNS4yIEw3OC44IDE3IEw4MC40IDE4LjkgTDgxLjggMjEgTDgzLjEgMjMuMSBMODQuMSAyNS40IEw4NSAyNy42IEw4NS43IDI5LjkgTDg2LjMgMzIuMSBMODYuOCAzNC4zIEw4Ny4xIDM2LjQgTDg3LjQgMzguNCBMODcuNyA0MC4zIEw4Ny45IDQyLjEgTDg4LjIgNDMuNyBMODguNSA0NS4yIEw4OC45IDQ2LjYgTDg5LjQgNDcuOCBMOTAgNDkgTDkwLjcgNTAuMSBMOTEuNiA1MS4xIEw5Mi42IDUyLjIgTDkzLjcgNTMuMiBMOTUgNTQuMiBMOTYuNCA1NS4zIEw5Ny45IDU2LjUgTDk5LjUgNTcuNyBMMTAxLjEgNTkuMSBMMTAyLjcgNjAuNiBMMTA0LjQgNjIuMiBMMTA2IDY0IEwxMDcuNSA2NS45IEwxMDguOSA2Ny45IEwxMTAuMiA3MC4xIEwxMTEuMyA3Mi4zIEwxMTIuMSA3NC43IEwxMTIuOCA3Ny4xIEwxMTMuMSA3OS41IEwxMTMuMiA4MS45IEwxMTMgODQuMyBMMTEyLjYgODYuNiBMMTExLjggODguOSBMMTEwLjggOTEgTDEwOS41IDkzIEwxMDcuOSA5NC43IEwxMDYuMSA5Ni4zIEwxMDQuMSA5Ny43IEwxMDIgOTguOCBMOTkuNyA5OS43IEw5Ny4zIDEwMC40IEw5NC45IDEwMC44IEw5Mi40IDEwMSBMODkuOSAxMDAuOSBMODcuNCAxMDAuNyBMODUgMTAwLjQgTDgyLjcgOTkuOSBMODAuNCA5OS4zIEw3OC4zIDk4LjYgTDc2LjMgOTcuOCBMNzQuNCA5Ny4xIEw3Mi43IDk2LjQgTDcxIDk1LjcgTDY5LjUgOTUuMSBMNjggOTQuNiBMNjYuNyA5NC4zIEw2NS4zIDk0LjEgTDY0IDk0IEw2Mi43IDk0LjEgTDYxLjMgOTQuMyBMNjAgOTQuNiBMNTguNSA5NS4xIEw1NyA5NS43IEw1NS4zIDk2LjQgTDUzLjYgOTcuMSBMNTEuNyA5Ny44IEw0OS43IDk4LjYgTDQ3LjYgOTkuMyBMNDUuMyA5OS45IEw0MyAxMDAuNCBMNDAuNiAxMDAuNyBMMzguMSAxMDAuOSBMMzUuNiAxMDEgTDMzLjEgMTAwLjggTDMwLjcgMTAwLjQgTDI4LjMgOTkuNyBMMjYgOTguOCBMMjMuOSA5Ny43IEwyMS45IDk2LjMgTDIwLjEgOTQuNyBMMTguNSA5MyBMMTcuMiA5MSBMMTYuMiA4OC45IEwxNS40IDg2LjYgTDE1IDg0LjMgTDE0LjggODEuOSBMMTQuOSA3OS41IEwxNS4yIDc3LjEgTDE1LjkgNzQuNyBMMTYuNyA3Mi4zIEwxNy44IDcwLjEgTDE5LjEgNjcuOSBMMjAuNSA2NS45IEwyMiA2NCBMMjMuNiA2Mi4yIEwyNS4zIDYwLjYgTDI2LjkgNTkuMSBMMjguNSA1Ny43IEwzMC4xIDU2LjUgTDMxLjYgNTUuMyBMMzMgNTQuMiBMMzQuMyA1My4yIEwzNS40IDUyLjIgTDM2LjQgNTEuMSBMMzcuMyA1MC4xIEwzOCA0OSBMMzguNiA0Ny44IEwzOS4xIDQ2LjYgTDM5LjUgNDUuMiBMMzkuOCA0My43IEw0MC4xIDQyLjEgTDQwLjMgNDAuMyBMNDAuNiAzOC40IEw0MC45IDM2LjQgTDQxLjIgMzQuMyBMNDEuNyAzMi4xIEw0Mi4zIDI5LjkgTDQzIDI3LjYgTDQzLjkgMjUuNCBMNDQuOSAyMy4xIEw0Ni4yIDIxIEw0Ny42IDE4LjkgTDQ5LjIgMTcgTDUwLjkgMTUuMiBMNTIuOCAxMy43IEw1NC45IDEyLjQgTDU3LjEgMTEuNCBMNTkuMyAxMC42IEw2MS42IDEwLjJaIiBmaWxsPSJub25lIiBzdHJva2U9IiM5Yzg2ZWEiIHN0cm9rZS13aWR0aD0iMTAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPHBhdGggZD0iTTY0IDM0IEw2NS4zIDMzLjkgTDY2LjcgMzMuNyBMNjggMzMuNCBMNjkuNSAzMi45IEw3MSAzMi4zIEw3Mi43IDMxLjYgTDc0LjQgMzAuOSBMNzYuMyAzMC4yIEw3OC4zIDI5LjQgTDgwLjQgMjguNyBMODIuNyAyOC4xIEw4NSAyNy42IEw4Ny40IDI3LjMgTDg5LjkgMjcuMSBMOTIuNCAyNyBMOTQuOSAyNy4yIEw5Ny4zIDI3LjYgTDk5LjcgMjguMyBMMTAyIDI5LjIgTDEwNC4xIDMwLjMgTDEwNi4xIDMxLjcgTDEwNy45IDMzLjMgTDEwOS41IDM1IEwxMTAuOCAzNyBMMTExLjggMzkuMSBMMTEyLjYgNDEuNCBMMTEzIDQzLjcgTDExMy4yIDQ2LjEgTDExMy4xIDQ4LjUgTDExMi44IDUwLjkgTDExMi4xIDUzLjMgTDExMS4zIDU1LjcgTDExMC4yIDU3LjkgTDEwOC45IDYwLjEgTDEwNy41IDYyLjEgTDEwNiA2NCBMMTA0LjQgNjUuOCBMMTAyLjcgNjcuNCBMMTAxLjEgNjguOSBMOTkuNSA3MC4zIEw5Ny45IDcxLjUgTDk2LjQgNzIuNyBMOTUgNzMuOCBMOTMuNyA3NC44IEw5Mi42IDc1LjggTDkxLjYgNzYuOSBMOTAuNyA3Ny45IEw5MCA3OSBMODkuNCA4MC4yIEw4OC45IDgxLjQgTDg4LjUgODIuOCBMODguMiA4NC4zIEw4Ny45IDg1LjkgTDg3LjcgODcuNyBMODcuNCA4OS42IEw4Ny4xIDkxLjYgTDg2LjggOTMuNyBMODYuMyA5NS45IEw4NS43IDk4LjEgTDg1IDEwMC40IEw4NC4xIDEwMi42IEw4My4xIDEwNC45IEw4MS44IDEwNyBMODAuNCAxMDkuMSBMNzguOCAxMTEgTDc3LjEgMTEyLjggTDc1LjIgMTE0LjMgTDczLjEgMTE1LjYgTDcwLjkgMTE2LjYgTDY4LjcgMTE3LjQgTDY2LjQgMTE3LjggTDY0IDExOCBMNjEuNiAxMTcuOCBMNTkuMyAxMTcuNCBMNTcuMSAxMTYuNiBMNTQuOSAxMTUuNiBMNTIuOCAxMTQuMyBMNTAuOSAxMTIuOCBMNDkuMiAxMTEgTDQ3LjYgMTA5LjEgTDQ2LjIgMTA3IEw0NC45IDEwNC45IEw0My45IDEwMi42IEw0MyAxMDAuNCBMNDIuMyA5OC4xIEw0MS43IDk1LjkgTDQxLjIgOTMuNyBMNDAuOSA5MS42IEw0MC42IDg5LjYgTDQwLjMgODcuNyBMNDAuMSA4NS45IEwzOS44IDg0LjMgTDM5LjUgODIuOCBMMzkuMSA4MS40IEwzOC42IDgwLjIgTDM4IDc5IEwzNy4zIDc3LjkgTDM2LjQgNzYuOSBMMzUuNCA3NS44IEwzNC4zIDc0LjggTDMzIDczLjggTDMxLjYgNzIuNyBMMzAuMSA3MS41IEwyOC41IDcwLjMgTDI2LjkgNjguOSBMMjUuMyA2Ny40IEwyMy42IDY1LjggTDIyIDY0IEwyMC41IDYyLjEgTDE5LjEgNjAuMSBMMTcuOCA1Ny45IEwxNi43IDU1LjcgTDE1LjkgNTMuMyBMMTUuMiA1MC45IEwxNC45IDQ4LjUgTDE0LjggNDYuMSBMMTUgNDMuNyBMMTUuNCA0MS40IEwxNi4yIDM5LjEgTDE3LjIgMzcgTDE4LjUgMzUgTDIwLjEgMzMuMyBMMjEuOSAzMS43IEwyMy45IDMwLjMgTDI2IDI5LjIgTDI4LjMgMjguMyBMMzAuNyAyNy42IEwzMy4xIDI3LjIgTDM1LjYgMjcgTDM4LjEgMjcuMSBMNDAuNiAyNy4zIEw0MyAyNy42IEw0NS4zIDI4LjEgTDQ3LjYgMjguNyBMNDkuNyAyOS40IEw1MS43IDMwLjIgTDUzLjYgMzAuOSBMNTUuMyAzMS42IEw1NyAzMi4zIEw1OC41IDMyLjkgTDYwIDMzLjQgTDYxLjMgMzMuNyBMNjIuNyAzMy45WiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmZmZmIiBzdHJva2Utd2lkdGg9IjIwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+CjxwYXRoIGQ9Ik02NCAzNCBMNjUuMyAzMy45IEw2Ni43IDMzLjcgTDY4IDMzLjQgTDY5LjUgMzIuOSBMNzEgMzIuMyBMNzIuNyAzMS42IEw3NC40IDMwLjkgTDc2LjMgMzAuMiBMNzguMyAyOS40IEw4MC40IDI4LjcgTDgyLjcgMjguMSBMODUgMjcuNiBMODcuNCAyNy4zIEw4OS45IDI3LjEgTDkyLjQgMjcgTDk0LjkgMjcuMiBMOTcuMyAyNy42IEw5OS43IDI4LjMgTDEwMiAyOS4yIEwxMDQuMSAzMC4zIEwxMDYuMSAzMS43IEwxMDcuOSAzMy4zIEwxMDkuNSAzNSBMMTEwLjggMzcgTDExMS44IDM5LjEgTDExMi42IDQxLjQgTDExMyA0My43IEwxMTMuMiA0Ni4xIEwxMTMuMSA0OC41IEwxMTIuOCA1MC45IEwxMTIuMSA1My4zIEwxMTEuMyA1NS43IEwxMTAuMiA1Ny45IEwxMDguOSA2MC4xIEwxMDcuNSA2Mi4xIEwxMDYgNjQgTDEwNC40IDY1LjggTDEwMi43IDY3LjQgTDEwMS4xIDY4LjkgTDk5LjUgNzAuMyBMOTcuOSA3MS41IEw5Ni40IDcyLjcgTDk1IDczLjggTDkzLjcgNzQuOCBMOTIuNiA3NS44IEw5MS42IDc2LjkgTDkwLjcgNzcuOSBMOTAgNzkgTDg5LjQgODAuMiBMODguOSA4MS40IEw4OC41IDgyLjggTDg4LjIgODQuMyBMODcuOSA4NS45IEw4Ny43IDg3LjcgTDg3LjQgODkuNiBMODcuMSA5MS42IEw4Ni44IDkzLjcgTDg2LjMgOTUuOSBMODUuNyA5OC4xIEw4NSAxMDAuNCBMODQuMSAxMDIuNiBMODMuMSAxMDQuOSBMODEuOCAxMDcgTDgwLjQgMTA5LjEgTDc4LjggMTExIEw3Ny4xIDExMi44IEw3NS4yIDExNC4zIEw3My4xIDExNS42IEw3MC45IDExNi42IEw2OC43IDExNy40IEw2Ni40IDExNy44IEw2NCAxMTggTDYxLjYgMTE3LjggTDU5LjMgMTE3LjQgTDU3LjEgMTE2LjYgTDU0LjkgMTE1LjYgTDUyLjggMTE0LjMgTDUwLjkgMTEyLjggTDQ5LjIgMTExIEw0Ny42IDEwOS4xIEw0Ni4yIDEwNyBMNDQuOSAxMDQuOSBMNDMuOSAxMDIuNiBMNDMgMTAwLjQgTDQyLjMgOTguMSBMNDEuNyA5NS45IEw0MS4yIDkzLjcgTDQwLjkgOTEuNiBMNDAuNiA4OS42IEw0MC4zIDg3LjcgTDQwLjEgODUuOSBMMzkuOCA4NC4zIEwzOS41IDgyLjggTDM5LjEgODEuNCBMMzguNiA4MC4yIEwzOCA3OSBMMzcuMyA3Ny45IEwzNi40IDc2LjkgTDM1LjQgNzUuOCBMMzQuMyA3NC44IEwzMyA3My44IEwzMS42IDcyLjcgTDMwLjEgNzEuNSBMMjguNSA3MC4zIEwyNi45IDY4LjkgTDI1LjMgNjcuNCBMMjMuNiA2NS44IEwyMiA2NCBMMjAuNSA2Mi4xIEwxOS4xIDYwLjEgTDE3LjggNTcuOSBMMTYuNyA1NS43IEwxNS45IDUzLjMgTDE1LjIgNTAuOSBMMTQuOSA0OC41IEwxNC44IDQ2LjEgTDE1IDQzLjcgTDE1LjQgNDEuNCBMMTYuMiAzOS4xIEwxNy4yIDM3IEwxOC41IDM1IEwyMC4xIDMzLjMgTDIxLjkgMzEuNyBMMjMuOSAzMC4zIEwyNiAyOS4yIEwyOC4zIDI4LjMgTDMwLjcgMjcuNiBMMzMuMSAyNy4yIEwzNS42IDI3IEwzOC4xIDI3LjEgTDQwLjYgMjcuMyBMNDMgMjcuNiBMNDUuMyAyOC4xIEw0Ny42IDI4LjcgTDQ5LjcgMjkuNCBMNTEuNyAzMC4yIEw1My42IDMwLjkgTDU1LjMgMzEuNiBMNTcgMzIuMyBMNTguNSAzMi45IEw2MCAzMy40IEw2MS4zIDMzLjcgTDYyLjcgMzMuOVoiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzZjNDdlMCIgc3Ryb2tlLXdpZHRoPSIxMyIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K // @homepageURL https://github.com/majkinetor/musicbrainz-userscripts/tree/main/userscripts/string_theory // @run-at document-start // @match https://*.musicbrainz.org/release/add* // @match https://*.musicbrainz.org/release/*/edit // @match https://*.musicbrainz.org/*/edit_annotation // @match https://*.musicbrainz.org/artist/* // @match https://*.musicbrainz.org/label/* // @match https://*.musicbrainz.org/release/* // @match https://*.musicbrainz.org/release-group/* // @grant GM_xmlhttpRequest // @grant GM_openInTab // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @connect * // @match *://*.musicbrainz.org/release/* // @match *://*.musicbrainz.org/release/*/cover-art* // @match *://*.musicbrainz.org/release/*/add-cover-art* // @match *://*.musicbrainz.org/event/*/event-art* // @match *://*.musicbrainz.org/event/*/add-event-art* // @grant GM.xmlHttpRequest // @match https://*.musicbrainz.org/release/*/edit-relationships // @match https://*.musicbrainz.org/place/* // @match https://tidal.com/album/* // @match https://listen.tidal.com/album/* // @match https://www.metal-archives.com/albums/* // @connect qobuz.com // @connect www.qobuz.com // @connect deezer.com // @connect www.deezer.com // @connect music.apple.com // @connect amp-api.music.apple.com // @connect www.metal-archives.com // @connect music.youtube.com // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @match https://*.musicbrainz.org/* // @match https://harmony.pulsewidth.org.uk/* // @match https://www.deezer.com/* // @match https://*.bandcamp.com/album/* // @match https://www.discogs.com/* // @match https://music.apple.com/* // @match https://tidal.com/* // @match https://listen.tidal.com/* // @match https://www.qobuz.com/*/album/* // @match https://www.beatport.com/* // @match https://open.spotify.com/* // @match https://music.youtube.com/* // @match https://www.youtube.com/playlist* // @match https://volumo.com/* // @match https://www.hdtracks.com/* // @match https://soundcloud.com/* // @match https://audiomack.com/* // @match https://*.7digital.com/artist/* // @match https://ototoy.jp/_/default/p/* // @match https://music.amazon.com/* // @match https://music.amazon.co.uk/* // @match https://music.amazon.de/* // @match https://music.amazon.fr/* // @match https://music.amazon.it/* // @match https://music.amazon.es/* // @match https://music.amazon.ca/* // @match https://music.amazon.co.jp/* // @match https://music.amazon.com.au/* // @match https://music.amazon.com.br/* // @match https://music.amazon.com.mx/* // @match https://music.amazon.in/* // @grant GM_listValues // @grant window.close // @connect api.deezer.com // @connect api.discogs.com // @connect itunes.apple.com // @connect auth.tidal.com // @connect openapi.tidal.com // @connect api.beatport.com // @connect api-partner.spotify.com // @connect spclient.wg.spotify.com // @connect volumo.com // @connect hdtracks.azurewebsites.net // @connect soundcloud.com // @connect a-v2.sndcdn.com // @connect api-v2.soundcloud.com // @connect api.audiomack.com // @connect api.7digital.com // @connect us.7digital.com // @connect js-cdn.7digital.com // @connect music.amazon.com // @connect na.mesk.skill.music.a2z.com // @connect web.archive.org // @connect bandcamp.com // @match https://*.musicbrainz.org/recording/* // @match https://*.musicbrainz.org/artist/*/recordings // @connect musicbrainz.org // @connect beta.musicbrainz.org // @connect api.acoustid.org // @match *://*.musicbrainz.org/release/*/edit-relationships // @match *://*.musicbrainz.org/artist/* // @match *://*.musicbrainz.org/label/* // @match *://*.musicbrainz.org/place/* // @match https://*.musicbrainz.org/oauth2/oob* // @match https://www.beatport.com/release/* // @connect isrc-api.soundexchange.com // @connect isrc.soundexchange.com // @connect isrchunt.com // @connect isrc.mollamusicgroup.com // @connect open.spotify.com // @connect www.youtube.com // @connect query.wikidata.org // @connect search.brave.com // @connect html.duckduckgo.com // @connect duckduckgo.com // @connect www.discogs.com // @connect tools.wallstream.com // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // ==/UserScript== // Auto-generated by userscripts/string_theory/build.mjs — do NOT edit by hand. // Bundles (verbatim, each wrapped in a URL gate and a run-at gate): apollo_editor, art_station, credit_hoarder, falcon, first_contact, fusion, group_therapy, isrc_scout, mammoth, mission_control, platform_check. try { console.log('%c String Theory %c v2026.10.16.085122 ', 'background:#7c5cff;color:#fff;font-weight:bold;border-radius:3px;padding:2px 6px', 'color:#7c5cff;font-weight:bold'); console.log("String Theory bundles:\n · Apollo Editor v2026.10.16\n · Art Station v2026.10.16\n · Credit Hoarder v2026.10.16\n · Falcon v2026.10.16\n · First Contact v2026.10.16\n · Fusion v2026.10.16\n · Group Therapy v2026.10.16\n · ISRC Scout v2026.10.16\n · Mammoth v2026.10.16\n · Mission Control v2026.10.16\n · Platform Check v2026.10.16"); } catch (e) {} function __stReadOff() { try { var v = GM_getValue("string_theory.off", []); return Array.isArray(v) ? v : []; } catch (e) { return []; } } var __stOff = __stReadOff(); (function () { if (typeof GM_registerMenuCommand !== 'function') return; try { if (window.top !== window.self) return; } catch (e) { return; } var members = [["apollo_editor","Apollo Editor","2026.10.16"],["art_station","Art Station","2026.10.16"],["credit_hoarder","Credit Hoarder","2026.10.16"],["falcon","Falcon","2026.10.16"],["first_contact","First Contact","2026.10.16"],["fusion","Fusion","2026.10.16"],["group_therapy","Group Therapy","2026.10.16"],["isrc_scout","ISRC Scout","2026.10.16"],["mammoth","Mammoth","2026.10.16"],["mission_control","Mission Control","2026.10.16"],["platform_check","Platform Check","2026.10.16"]]; // Each entry carries a stable id (options.id): Violentmonkey and Tampermonkey then relabel it in // place, where it stands. Unregister-and-add left Violentmonkey's open menu with the old entry // still there and the new one appended at the end. A manager that ignores options.id hands back // an id of its own, and only then is the old entry unregistered. var ids = {}; function put(id, caption, fn, title) { var got = GM_registerMenuCommand(caption, fn, { id: id, autoClose: false, title: title }); if (ids[id] != null && ids[id] !== got && typeof GM_unregisterMenuCommand === 'function') { try { GM_unregisterMenuCommand(ids[id]); } catch (e) {} } ids[id] = got; } function draw() { var off = GM_getValue("string_theory.off", []); members.forEach(function (m) { var on = off.indexOf(m[0]) < 0; put('st-' + m[0], (on ? '☑ ' : '☐ ') + m[1] + ' v' + m[2], function () { flip(m[0]); }, (on ? 'On' : 'Off') + ' — click to turn ' + (on ? 'off' : 'on') + '; applies from the next page load'); }); var row = mbuShared('cornerFlow') === 'row'; put('st-flow', row ? '↔ Launchers in a row' : '↕ Launchers in a column', flipFlow, 'The round launchers in the page corner — click to put them in a ' + (row ? 'column' : 'row')); } function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } function flipFlow() { mbuShared('cornerFlow', mbuShared('cornerFlow') === 'row' ? null : 'row'); ['br', 'bl', 'tr', 'tl'].forEach(mbRestackCorner); draw(); } function flip(key) { var off = GM_getValue("string_theory.off", []); if (!Array.isArray(off)) off = []; var i = off.indexOf(key); if (i < 0) off.push(key); else off.splice(i, 1); GM_setValue("string_theory.off", off); draw(); } try { draw(); } catch (e) { try { console.error('[String Theory] menu failed:', e); } catch (x) {} } })(); try { if (__stOff.length) console.log('String Theory: turned off in the manager menu — ' + __stOff.join(', ')); } catch (e) {} var __stInFrame = (function () { try { return window.top !== window.self; } catch (e) { return true; } })(); var __stSkipped = []; var __stRunsHere = function runsHere(rules, href, inFrame) { if (rules.noframes && inFrame) return false; const url = String(href).replace(/#.*$/, ''); const any = list => list.some(src => new RegExp(src).test(url)); return (!rules.inc.length || any(rules.inc)) && !any(rules.exc); }; function __stRuns(name, rules) { var yes; try { yes = __stRunsHere(rules, location.href, __stInFrame); } catch (e) { yes = true; } if (!yes) __stSkipped.push(name); return yes; } // ===== apollo_editor (@run-at document-start) ===================================== if (__stOff.indexOf("apollo_editor") < 0 && __stRuns("apollo_editor", {"inc":["^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/add.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*/edit$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/.*/edit_annotation$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/artist/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/label/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release-group/.*$"],"exc":[],"noframes":false})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Apollo Editor*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Speed up per-track artist-credit resolution in the MusicBrainz release editor — bulk-match each track's artist text to an MB artist (sibling releases in the release group first, then search), one-click apply, multi-artist aware, create-on-the-fly. Same table whether floating or replacing the integrated tracklist.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/apollo_editor/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5BcG9sbG8gRWRpdG9yPC90aXRsZT4KPG1hc2sgaWQ9ImFwLW0iIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjAiIHk9IjAiIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4Ij48Zz48ZyBmaWxsPSIjZmZmIj48cGF0aCBkPSJNNDIgNjQgQzI4IDcyIDIyIDkwIDI0IDExMCBMNDIgOTggWiIvPjxwYXRoIGQ9Ik04NiA2NCBDMTAwIDcyIDEwNiA5MCAxMDQgMTEwIEw4NiA5OCBaIi8+PC9nPjxnIGZpbGw9IiNmZmYiPjxwYXRoIGQ9Ik02NCA4IEM4NCAyNCA5MCA1MiA4OCA5MCBMNDAgOTAgQzM4IDUyIDQ0IDI0IDY0IDggWiIvPjxwYXRoIGQ9Ik00OCA5MCBMODAgOTAgTDc2IDEwMCBMNTIgMTAwIFoiLz48L2c+PGcgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjMuNSI+PHBhdGggZD0iTTM2IDkwLjUgTDkyIDkwLjUiLz48L2c+PGNpcmNsZSBjeD0iNjQiIGN5PSI1MCIgcj0iOCIgZmlsbD0iIzAwMCIvPjwvZz48L21hc2s+PGc+PHBhdGggZD0iTTUyIDEwMCBDNTQgMTEyIDYwIDExOCA2NCAxMjYgQzY4IDExOCA3NCAxMTIgNzYgMTAwIFoiIGZpbGw9IiNlYjc0M2IiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgMykiLz48L2c+PHJlY3Qgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiIGZpbGw9IiNiYTQ3OGYiIG1hc2s9InVybCgjYXAtbSkiLz4KPC9zdmc+Cg=="}) }) : { script: {"name":"Apollo Editor*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Speed up per-track artist-credit resolution in the MusicBrainz release editor — bulk-match each track's artist text to an MB artist (sibling releases in the release group first, then search), one-click apply, multi-artist aware, create-on-the-fly. Same table whether floating or replacing the integrated tracklist.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/apollo_editor/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5BcG9sbG8gRWRpdG9yPC90aXRsZT4KPG1hc2sgaWQ9ImFwLW0iIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjAiIHk9IjAiIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4Ij48Zz48ZyBmaWxsPSIjZmZmIj48cGF0aCBkPSJNNDIgNjQgQzI4IDcyIDIyIDkwIDI0IDExMCBMNDIgOTggWiIvPjxwYXRoIGQ9Ik04NiA2NCBDMTAwIDcyIDEwNiA5MCAxMDQgMTEwIEw4NiA5OCBaIi8+PC9nPjxnIGZpbGw9IiNmZmYiPjxwYXRoIGQ9Ik02NCA4IEM4NCAyNCA5MCA1MiA4OCA5MCBMNDAgOTAgQzM4IDUyIDQ0IDI0IDY0IDggWiIvPjxwYXRoIGQ9Ik00OCA5MCBMODAgOTAgTDc2IDEwMCBMNTIgMTAwIFoiLz48L2c+PGcgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjMuNSI+PHBhdGggZD0iTTM2IDkwLjUgTDkyIDkwLjUiLz48L2c+PGNpcmNsZSBjeD0iNjQiIGN5PSI1MCIgcj0iOCIgZmlsbD0iIzAwMCIvPjwvZz48L21hc2s+PGc+PHBhdGggZD0iTTUyIDEwMCBDNTQgMTEyIDYwIDExOCA2NCAxMjYgQzY4IDExOCA3NCAxMTIgNzYgMTAwIFoiIGZpbGw9IiNlYjc0M2IiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgMykiLz48L2c+PHJlY3Qgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiIGZpbGw9IiNiYTQ3OGYiIG1hc2s9InVybCgjYXAtbSkiLz4KPC9zdmc+Cg=="} }; (f=>f())(function(){ try { (function(){ /* * Editor model (discovered via test/ spikes): * read MB.releaseEditor.rootField.release().mediums()[m].tracks()[t] * .artistCredit() → { names:[{ artist:{name,gid,id}, name(creditedAs), joinPhrase }] } * search GET /ws/js/artist?q=&direct=false → full entities (incl. numeric id). * sibling GET /ws/2/release?release-group=&inc=recordings+artist-credits → other versions' * per-track credits with gids; disambiguates search hits by title. * write track.artistCredit({ names:[{ artist: fullEntity, name: creditedAs, joinPhrase }] }) * ops ed.removeTrack(t) · ed.moveTrackUp(t)/moveTrackDown(t) · track.name(s) · * track.length(ms) · track.formattedLength() · ed.utils.unformatTrackLength('3:53') */ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('apollo_editor', 'Apollo Editor')) return; // the shared MusicBrainz request gate (#633) — first, so nothing can call it before its consts exist // — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT const MBN_GAP = 1000; const MBN_BURST = 3; const MBN_KEY = "mbu:mb-gate"; const MBN_LOCK = "mbu-mb-gate"; const MBN_MAX_HOLD = 60000; function mbnGated(url) { const s = String(url || ''); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null'); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; // no storage: this page's copy only else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) {} return out; }; // The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is // stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang // every request behind it. let timer = 0; try { if (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === 'function' ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3000); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState(s => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? 'MusicBrainz asked everyone to wait' : 'pacing, one request a second' }; }); let told = false; for (;;) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); // a hold that appeared while we queued if (left <= 0) break; if (!told && until - t0 > 1000 && o.log) { told = true; o.log('info', 'MusicBrainz gate: ' + (o.label || 'request') + ' waits ' + Math.round((until - t0) / 100) / 10 + 's (' + (cool > booked.at ? 'MusicBrainz asked everyone to wait' : booked.why) + ')'); } await new Promise(r => setTimeout(r, Math.min(left, 250))); // in slices, so a cancel lands promptly } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header('Retry-After') : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState(s => { const now = Date.now(); // One throttle, one step: the requests already in flight when it began all answer // 503 too, and each must not stretch the hold again. A throttle long gone doesn't // count at all: `hot` is shared and persists, and would otherwise slow every later // page for good. if (now > (s.cool || 0) + 30000) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1000, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log('warn', 'MusicBrainz gate: HTTP ' + status + (raw != null ? ' (Retry-After: ' + raw + ')' : '') + ' — every script holds ' + Math.round(hold / 100) / 10 + 's'); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState(s => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 30000 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) { const r = await fetch(url, init); if (!mbnWebsite(url) || !(await mbnIsCheck(r))) return r; // #733: MusicBrainz's browser check answered instead: nothing was done (a POST is safe // to repeat), so pass the check and ask again return (await mbnReverify(url, init, o)) ? fetch(url, init) : r; } const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, n => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnHot() { let s; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; } return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over } function mbnRawHeader(raw, name) { const want = String(name).toLowerCase(); for (const line of String(raw || '').split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim(); } return null; } function mbnWebsite(url) { const s = String(url || ''); if (mbnGated(s)) return false; if (/^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org(\/|$)/i.test(s)) return true; return /^\/(?!\/)/.test(s) && typeof location !== 'undefined' && /(^|\.)musicbrainz\.org$/i.test(location.hostname); } async function mbnIsCheck(r) { try { if (!r || !/text\/html/i.test((r.headers && r.headers.get('content-type')) || '')) return false; const head = (await r.clone().text()).slice(0, 3000); return /\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(head); } catch (e) { return false; } } function mbnReverify(url, init, o) { o = o || {}; const log = (lvl, m) => { try { if (o.log) o.log(lvl, m); else (lvl === 'warn' ? console.warn : console.info)('[MusicBrainz gate] ' + m); } catch (e) {} }; const g = globalThis.__mbnCheck || (globalThis.__mbnCheck = { busy: null, failedAt: 0 }); if (g.busy) return g.busy; let target; try { target = new URL(String(url), location.href); } catch (e) { return Promise.resolve(false); } if (target.host !== location.host || typeof document === 'undefined') { log('warn', 'MusicBrainz asked to verify the browser — open any MusicBrainz page, then retry'); return Promise.resolve(false); } if (Date.now() - g.failedAt < 60000) return Promise.resolve(false); // just failed: don't loop // a POST's URL can't be loaded: the check is per browser, so any light GET passes it const method = String((init && init.method) || 'GET').toUpperCase(); const src = method === 'GET' ? target.href : location.origin + '/ws/js/entity/89ad4ac3-39f7-470e-963a-56509c546377'; const t0 = Date.now(); log('warn', 'MusicBrainz asked to verify the browser again ("Verifying your browser") — passing it in a hidden frame'); g.busy = new Promise(resolve => { const fr = document.createElement('iframe'); // fresh each time, never re-navigated fr.setAttribute('aria-hidden', 'true'); fr.setAttribute('data-mbn-check', ''); fr.tabIndex = -1; fr.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;top:0;border:0;opacity:0;pointer-events:none'; let loads = 0, done = false, timer = 0; const finish = ok => { if (done) return; done = true; clearTimeout(timer); fr.remove(); g.busy = null; if (ok) log('info', 'browser check passed in ' + (Date.now() - t0) + ' ms — retrying'); else { g.failedAt = Date.now(); const m = 'MusicBrainz wants to verify your browser — open any MusicBrainz page in another tab, then retry'; log('warn', m + ' (the hidden frame did not clear it in 20 s)'); try { if (o.notify) o.notify(m); } catch (e) {} } resolve(ok); }; // the first load is the check; it posts its answer and is redirected to the real URL fr.onload = () => { loads++; let still; try { const d = fr.contentDocument; still = !d || /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(d.documentElement ? d.documentElement.outerHTML.slice(0, 3000) : ''); } catch (e) { still = loads < 2; } if (!still) finish(true); }; timer = setTimeout(() => finish(false), 20000); fr.src = src; (document.body || document.documentElement).appendChild(fr); }); return g.busy; } // </ST-NET> // #522 follow-up (majkinetor, live): "why is LastPass recognizing edits as // passwords? I noticed that in apollo credited as too" — password managers // heuristically flag plain text inputs with no autocomplete/ignore hint. // None of this tool's inputs are credentials, so opt them out. const noPw = e => { e.autocomplete = 'off'; e.setAttribute('data-lpignore', 'true'); e.setAttribute('data-1p-ignore', 'true'); e.setAttribute('data-bwignore', 'true'); e.setAttribute('data-form-type', 'other'); return e; }; const NOPW_ATTRS = 'autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other"'; // #283 every Log.* call is captured into an in-page buffer and surfaced in a // dedicated log viewer — opened from a Log button next to "? Help" — // copy/pastable as a Markdown <details> block, like the other scripts. (The // console output was dropped: it just duplicated this buffer.) The buffer and // window are the shared mbuLog (ST-UI block), since X12 of #623. const Log = mbuLog({ name: 'Apollo Editor', version: () => scriptVersion(), key: 'apolloEditor.logwin', load: k => gmLoad(k), save: (k, v) => gmSave(k, v), before: () => style(), }); const logMarkdown = () => Log.markdown(); const openLog = () => Log.open(); // #501: settings live in GM storage (backed up and synced by the script manager). // The one-time adoption of an older localStorage copy is retired (#623). const gmLoad = (key) => { try { return GM_getValue(key, undefined); } catch (e) { return undefined; } }; const gmSave = (key, raw) => { try { GM_setValue(key, raw); } catch (e) {} }; // first log line: the script + version. The MB release line is logged once the // editor is ready (so it carries the real title) — see init(). Log.cat('System').info(mbuStartupInfo('Apollo Editor')); const W = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; const ORIGIN = location.origin; const u = v => { try { return typeof v === 'function' ? v() : v; } catch (e) { return undefined; } }; const getEditor = () => { try { return W.MB && W.MB.releaseEditor; } catch (e) { return null; } }; // #613 shared matching (dev/match/artist-match.mjs) — the exact-identity rule, the // special-purpose artist list, and the name folding Apollo, Group Therapy and Credit // Hoarder share. Filled in by dev/match/sync-match.mjs (pre-commit hook). // <ST-MATCH> — generated by dev/match/sync-match.mjs from dev/match/artist-match.mjs — DO NOT EDIT const MBM_EXACT_LIMIT = 100; const MBM_SPECIAL_PURPOSE = new Set(["125ec42a-7229-4250-afc5-e057484327fe","f731ccc4-e22a-43af-a747-64213329e088","33cf029c-63b0-41a0-9855-be2a3665fb3b","314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc","eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61","9be7f096-97ec-4615-8957-8d40b5dcbc41","89ad4ac3-39f7-470e-963a-56509c546377","7e84f845-ac16-41fe-9ff8-df12eb32af55","66ea0139-149f-4a0c-8fbf-5ea9ec4a6e49","a0ef7e1d-44ff-4039-9435-7d5fefdeecc9","90068d37-bae7-4292-be4a-704c145bd616","80a8851f-444c-4539-892b-ad2a49292aa9"]); function mbmFold(s) { return String(s == null ? '' : s).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').toLowerCase().replace(/\s+/g, ' ').trim(); } function mbmFoldKeepCase(s) { return String(s == null ? '' : s).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').replace(/\s+/g, ' ').trim(); } function mbmSameName(a, b) { return mbmFold(a) === mbmFold(b) && mbmFold(a) !== ''; } function mbmSameNameCase(a, b) { return mbmFoldKeepCase(a) === mbmFoldKeepCase(b) && mbmFoldKeepCase(a) !== ''; } function mbmHolds(entity, name, caseExact) { if (!entity) return null; const same = caseExact ? mbmSameNameCase : mbmSameName; if (same(entity.name, name)) return 'name'; if ((entity.aliases || []).some(al => same(al && (al.name != null ? al.name : al), name))) return 'alias'; return null; } function mbmIdentityQuery(name, field) { const q = String(name == null ? '' : name).replace(/["\\]/g, ' ').replace(/\s+/g, ' ').trim(); return q ? 'alias:"' + q + '" OR ' + (field || 'artist') + ':"' + q + '"' : ''; } function mbmExactIdentity(json, name, opts) { const o = opts || {}; if (!json || typeof json !== 'object') return { status: 'failed', exact: [] }; const list = json.artists || json.labels || json.places || []; let exact = list.filter(e => mbmHolds(e, name)); if (exact.length > 1) { // several case-insensitive holders, exactly one WITH case → that one (#445 "Kasane Teto") const caseExact = exact.filter(e => mbmHolds(e, name, true)); if (caseExact.length === 1) exact = caseExact; else if (o.scoreGap) { const scored = exact.filter(e => typeof e.score === 'number').sort((a, b) => b.score - a.score); if (scored.length >= 2 && scored[0].score - scored[1].score >= o.scoreGap) exact = [scored[0]]; } } const offset = typeof json.offset === 'number' ? json.offset : 0; const complete = typeof json.count === 'number' && json.count <= offset + list.length; if (exact.length === 1 && complete) return { status: 'unique', hit: exact[0], via: mbmHolds(exact[0], name) === 'name' ? 'name' : 'alias', exact, complete }; if (exact.length > 1) return { status: 'ambiguous', exact, complete }; if (!complete) return { status: 'incomplete', exact, complete }; return { status: 'none', exact, complete }; } function mbmRelatedArtists(artistJson) { if (!artistJson || !artistJson.id) return []; const out = [{ gid: artistJson.id, name: artistJson.name || '', aliases: (artistJson.aliases || []).map(a => a && a.name).filter(Boolean), rel: 'self' }]; for (const r of artistJson.relations || []) { const a = r && r.artist; if (!a || !a.id || out.some(x => x.gid === a.id)) continue; out.push({ gid: a.id, name: a.name || '', aliases: [], rel: r.type || '' }); } return out; } function mbmContextHolders(related, name, candidates) { const cand = new Map((candidates || []).map(c => [c.id || c.gid, c])); const out = []; for (const r of related || []) { let via = mbmSameName(r.name, name) ? 'name' : (r.aliases || []).some(a => mbmSameName(a, name)) ? 'alias' : null; if (!via) { const c = cand.get(r.gid); if (c && mbmHolds(c, name)) via = mbmHolds(c, name); } if (via && !out.some(x => x.gid === r.gid)) out.push({ gid: r.gid, name: r.name, via, rel: r.rel }); } return out; } function mbmCoCreditHits(recordingsJson, ctxGid, name) { const out = []; for (const rec of (recordingsJson && recordingsJson.recordings) || []) { for (const c of rec['artist-credit'] || []) { const a = c && c.artist; if (!a || !a.id || a.id === ctxGid) continue; if ((mbmSameName(c.name, name) || mbmSameName(a.name, name)) && !out.some(x => x.gid === a.id)) out.push({ gid: a.id, name: a.name }); } } return out; } function mbmGuessSortName(name) { if (!name || !name.trim()) return name; name = name.trim().replace(/\s+/g, ' '); // names in other scripts are not "given family" in Latin order: leave them to the editor if (/[^\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]/u.test(name)) return name; const words = name.split(' '); if (words.length === 1) return name; const article = name.match(/^(the|a|an)\s+(.+)$/i); if (article) return article[2] + ', ' + article[1].charAt(0).toUpperCase() + article[1].slice(1).toLowerCase(); let base = name, suffix = ''; const sfx = name.match(/^(.*?),?\s+(jr\.?|sr\.?|ii|iii|iv|v|esq\.?)$/i); if (sfx) { base = sfx[1].trim(); suffix = ' ' + sfx[2]; } const parts = base.split(' '); if (parts.length === 1) return name; return parts[parts.length - 1] + ', ' + parts.slice(0, -1).join(' ') + suffix; } // </ST-MATCH> // normalize hyphen/dash look-alikes (MB uses ‐ U+2010, others use - U+002D, en/em // dashes, minus…) to a plain '-' so e.g. "Gol‐e Yakh" folds the same as "Gol-e Yakh" const fold = s => (s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').toLowerCase().replace(/\s+/g, ' ').trim(); const sameName = (a, b) => fold(a) === fold(b); // gid → artist disambiguation, harvested from every WS2/js artist-credit the // script fetches (search results, recording lookups). The MB page (KO) model // doesn't carry disambiguations for freshly-picked entities, so the recordings // table falls back to this cache to show them after a pick. #195 const _disamb = new Map(); const noteDisamb = (gid, c) => { if (gid && c) _disamb.set(gid, c); }; const getDisamb = gid => (gid && _disamb.get(gid)) || ''; const MBID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; // a MusicBrainz /artist/<mbid> URL, a bare MBID, or an MBID pasted anywhere in the text → the gid function mbidFrom(v) { v = (v || '').trim(); const url = v.match(new RegExp('musicbrainz\\.org/artist/(' + MBID_RE.source + ')', 'i')); if (url) return url[1].toLowerCase(); const m = v.match(new RegExp('(?:^|[\\s/])(' + MBID_RE.source + ')(?:[\\s/?#]|$)', 'i')); return m ? m[1].toLowerCase() : null; } // an ISRC (CC-XXX-YY-NNNNN), with or without separators, found as a standalone // token → the normalised 12-char uppercase form. Word-boundaried so it won't // fire mid-string; the structure (2 alpha · 3 alphanum · 7 digit) is specific // enough not to collide with a bare MBID. #196 const ISRC_TOKEN = /\b([A-Za-z]{2})-?([A-Za-z0-9]{3})-?([0-9]{2})-?([0-9]{5})\b/; function isrcFrom(v) { const m = String(v || '').match(ISRC_TOKEN); return m ? (m[1] + m[2] + m[3] + m[4]).toUpperCase() : null; } /* ── create-artist-in-a-tab → auto-insert (BroadcastChannel handshake, like the Discogs importer) ── */ const ART_CHANNEL = ('BroadcastChannel' in W) ? new W.BroadcastChannel('apollo-editor-artist') : null; const PENDING_KEY = 'apolloEditor.pendingArtist'; const CLOSE_KEY = 'apolloEditor.closeAfterEdit'; // tab opened just to add a Discogs link → self-close after submit const _pendingCreates = new Map(); let _createSeq = 0; if (ART_CHANNEL) ART_CHANNEL.addEventListener('message', e => { const d = e.data; if (!d || d.type !== 'tc-artist-created') return; const pend = _pendingCreates.get(d.token); if (!pend) return; _pendingCreates.delete(d.token); if (!d.gid) { Log.cat('Create').warn('artist created but no gid came back'); return; } // The postMessage can only carry a plain {gid,name,id}, but commitTrack writes // `artist: <entity>` into the NATIVE artist credit — and MB only treats it as a // real linked artist when it's the WHOLE entity (the same shape fetchEntity / the // paste path produce). A plain object renders in the Apollo table but is dropped // from the native model on submit. So re-fetch the full entity in THIS tab (the // artist is already indexed — the create tab fetched it before posting back). #191 fetchEntity(d.gid).then(async ent => { let landed = true; if (pend.slot && typeof pend.slot._onPick === 'function') landed = (await pend.slot._onPick(ent || { gid: d.gid, name: d.name, id: d.id })) !== false; // #652: a release artist else pickArtist(pend.slot, ent || { gid: d.gid, name: d.name, id: d.id }); // #273: close the background create tab via its handle (a GM-opened tab can't always self-close). try { if (pend.bgTab && typeof pend.bgTab.close === 'function') pend.bgTab.close(); } catch (x) {} if (landed) Log.cat('Create').info('inserted newly-created artist', JSON.stringify(d.name), 'into the table' + (ent ? '' : ' (plain fallback — native link may be incomplete)')); else Log.cat('Create').warn('created artist', JSON.stringify(d.name), '(' + d.gid + ') could not be set: its cell is gone; pick it by hand'); }); }); /* ── settings ── */ const SKEY = 'apolloEditor.settings.v1'; function loadSettings() { const d = { apolloEnabled: true, colWidths: {}, applyMode: 'all', altRows: false, gridCols: false, gridRows: true, replaceReleaseInfo: true, replaceTracklist: true, replaceRecordings: true, modifyAnnotation: true, modifyDuplicates: true, autoMatch: true, autoMatchRec: true, autoMatchOnV: 1, autoMatchLabel: true, autoMatchArtist: true, autoMatchRG: true, discogsUrlMatch: true, recLenTol: 5, recIgnoreCase: true, recIgnorePunct: true, recTitleTol: 1, recCutoff: 'near', recDetailedHl: true, recPunctSize: 3, recHlColor: '#e53935', lastTool: '', layout: 'normal', lastView: 'apollo', zenMode: true, autoConfirmSeed: true, keepCaretColumn: true, hoverHighlight: false, srRegex: false, srTemplates: [], srSeedV: 0, srHistory: [], srDefault: '', srHistoryOpen: false, archive: true, archiveMenu: true }; try { const stored = JSON.parse(gmLoad(SKEY) || '{}'); const s = Object.assign(d, stored); if (stored.gridCols === undefined && stored.grid !== undefined) s.gridCols = stored.grid; if (stored.autoMatchOnV === undefined) { s.autoMatch = true; s.autoMatchRec = true; } /* tracklist + recordings auto-match became on by default; the whole object is saved, so an existing install has the old false stored and needs flipping once (autoMatchOnV, from the defaults, marks it done on the next save) */ return s; } catch (e) { return d; } } function saveSettings() { try { gmSave(SKEY, JSON.stringify(SETTINGS)); } catch (e) {} } let SETTINGS = loadSettings(); try { srSeedTemplates(); } catch (e) {} // #375 seed the default S&R templates once let _cfgTab = 'general'; // remembered settings tab (#294), per session // measure a name's pixel width so an artist input can shrink-wrap to its text (so the // clear × hugs the name instead of sitting at the box's far right). #284 follow-up. const _nmMeasCtx = (() => { try { return document.createElement('canvas').getContext('2d'); } catch (e) { return null; } })(); const fitNmWidth = inp => { if (!_nmMeasCtx) return; const v = inp.value; if (!v) { inp.style.removeProperty('width'); return; } // empty → let flex give it the full width _nmMeasCtx.font = '13px Arial'; // matches .tc-search .nm // #731: !important — the release artist cell resets MusicBrainz's pinned 354px inputs with // `width:auto!important`, which beat a plain inline width: an unfocused name fell back to the // input's default ~20 characters and was cut off inp.style.setProperty('width', (Math.ceil(_nmMeasCtx.measureText(v).width) + 5) + 'px', 'important'); }; // #394 any entity's standalone Edit-annotation page — /<type>/<gid>/edit_annotation (artist, recording, // work, event, label, release, …), not just release. The annotation editor is generic (it wraps the // textarea[name="edit-annotation.text"] every entity's page has). const ANNO_PAGE_RE = /^\/[a-z][a-z-]*\/[0-9a-f-]{36}\/edit_annotation/i; // FOUC guard (we run at document-start): on the standalone Edit annotation page, hide the native form until our // editor mounts (and removes #tc-anno-fouc), so the original interface never flashes. Skipped when off. if (ANNO_PAGE_RE.test(location.pathname) && SETTINGS.apolloEnabled !== false && SETTINGS.modifyAnnotation !== false) { const s = document.createElement('style'); s.id = 'tc-anno-fouc'; s.textContent = '#content > form{visibility:hidden}'; (document.head || document.documentElement).appendChild(s); setTimeout(() => document.getElementById('tc-anno-fouc')?.remove(), 6000); // safety: never hide forever if the editor fails to mount } function waitFor(check, { tries = 120, every = 500 } = {}) { return new Promise(res => { let n = 0; const t = () => { let v; try { v = check(); } catch (e) {} if (v) return res(v); if (++n >= tries) return res(null); setTimeout(t, every); }; t(); }); } /* ── model access ── */ function release() { return u(getEditor().rootField.release); } function mediums() { return u(release().mediums) || []; } function koTrack(mi, ti) { return u(mediums()[mi].tracks)[ti]; } // #376 pending-edit state for a track's recording: {rec} if the recording itself has open edits, // {art} if any artist in its credit does — MB marks each with span.mp (gold). function recPendingState(mi, ti) { try { const ko = koTrack(mi, ti), rec = u(ko.recording); // #376 check the CURRENTLY-shown artist credit (the track's), not the recording's — so picking a // different (non-pending) artist clears the highlight. Title pending stays recording-level. // Only a SELECTED artist (has a gid) counts — a free-text credit with no linked entity gets no // highlight (majkinetor: "remove highlight when no artist is selected — it's for a previously // selected artist"). artGids drives the per-SLOT gold so a free-text slot beside a pending one stays clean. const artGids = new Set(); const ac = u(ko.artistCredit), names = ac && u(ac.names); if (names) for (const n of names) { const a = u(n.artist); if (a && u(a.editsPending)) { const g = u(a.gid); if (g) artGids.add(g); } } return { rec: rec ? !!u(rec.editsPending) : false, art: artGids.size > 0, artGids }; } catch (e) { return null; } } // #376 a slot golds only when it's a SELECTED artist (committed, with a gid) whose entity is pending — // so editing a field to free text (which unselects the mbid) drops the gold on the next re-adorn. const slotIsPending = (artGids, slot) => !!(slot && slot.committed && slot.gid && artGids && artGids.has(slot.gid)); function liveNames(track) { const ac = u(track.artistCredit) || {}; return u(ac.names) || []; } const ORIGINALS = new Map(); /* chaban-mb on #586, verified in test/probe-586f-pregap-revert.mjs: the page-load snapshot was keyed by "medium:INDEX", and an index is not an identity. Tick the Pregap box and MusicBrainz inserts a track at index 0, so every later track shifts down one — "Revert all" then writes each snapshot onto the track BELOW the one it came from, and unticking Pregap deletes what is now index 0. The tracklist comes back shifted by one with the first track gone and the last duplicated. Nothing about it looks like a failure. MB gives every track a `uniqueID` that survives inserts and removals and is present on newly added tracks too ("new-17"), so the snapshot is keyed by that. The index remains only as a last resort for a track that somehow has none, which is also the only case that can still drift. */ const trackUid = t => { try { return t && t.uniqueID != null ? t.uniqueID : (t && t.id != null ? t.id : null); } catch (e) { return null; } }; const origKeyOf = (mi, t, ti) => { const uid = trackUid(t); return uid != null ? mi + ':#' + uid : mi + ':@' + ti; }; const origKey = (mi, ti) => { let t = null; try { t = koTrack(mi, ti); } catch (e) {} return origKeyOf(mi, t, ti); }; const snapTrack = t => ({ title: u(t.name) || '', number: u(t.number), length: u(t.formattedLength) || '', // #586 (chaban-mb: "Revert all function seems to not affect data tracks") — // the snapshot never carried the data-track flag, so Revert all restored the // cells and left the section where it was. isDataTrack: typeof t.isDataTrack === 'function' ? !!t.isDataTrack() : false, names: liveNames(t).map(n => ({ artist: u(n.artist) || { name: u(n.name) || '' }, creditedAs: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '' })), }); function snapshotOriginals() { ORIGINALS.clear(); mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => ORIGINALS.set(origKeyOf(mi, t, ti), snapTrack(t)))); Log.cat('System').info('snapshot of', ORIGINALS.size, 'original tracks'); } // MB lazy-loads each medium's tracks asynchronously, so the startup snapshot misses mediums that // hadn't loaded yet. Capture the page-load state of any track that appears later — before matching // writes to it — so change-tracking (the ↺ button + the changed-row border) works on every medium. function snapshotMissing() { let added = 0; mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const k = origKeyOf(mi, t, ti); if (!ORIGINALS.has(k)) { ORIGINALS.set(k, snapTrack(t)); added++; } })); if (added) Log.cat('System').info('snapshot +', added, 'newly loaded original track(s) →', ORIGINALS.size, 'total'); } function readTracklist() { const out = []; mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const names = liveNames(t).map(n => { const a = u(n.artist) || null; return { creditedAs: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '', artistGid: a ? u(a.gid) : null, artistName: a ? u(a.name) : '' }; }); out.push({ mi, ti, number: u(t.number), title: u(t.name) || '', length: u(t.formattedLength) || '', names, resolved: names.length > 0 && names.every(n => n.artistGid) }); })); return out; } // MusicBrainz's website-side JSON (/ws/js/*) through the shared gate, which passes MusicBrainz's // "Verifying your browser" check again when it answers mid-session (#733; dev/net/mb-gate.mjs) const mbWebFetch = (url, init) => mbnFetch(url, init, { log: (lvl, m) => Log.cat('MusicBrainz')[lvl](m), notify: m => toast(m, 'warn') }); // #735: place a dropdown under its input, or above it when the window has more room there // (an artist picker on the last tracks ran off the bottom); its height is capped to the room. // o.right align its left edge this many px left of the input's right edge (the join list) function acPlace(pop, inp, o) { o = o || {}; const r = inp.getBoundingClientRect(), gap = o.gap || 2, margin = 8, cap = 300; pop.style.left = (o.right ? Math.max(4, r.right - o.right) : r.left) + 'px'; pop.style.minWidth = (o.minWidth || Math.max(210, r.width)) + 'px'; pop.style.maxHeight = cap + 'px'; const want = Math.min(pop.scrollHeight, cap); const below = innerHeight - r.bottom - gap - margin, above = r.top - gap - margin; if (want <= below || below >= above) { pop.style.bottom = ''; pop.style.top = (r.bottom + gap) + 'px'; pop.style.maxHeight = Math.max(60, Math.min(cap, below)) + 'px'; } else { pop.style.top = 'auto'; pop.style.bottom = (innerHeight - r.top + gap) + 'px'; pop.style.maxHeight = Math.min(cap, above) + 'px'; } } /* ── search + siblings ── */ const _cache = new Map(); // resolve an MBID to a full entity (incl. the numeric id needed for the credit write-back) async function fetchEntity(gid, kind) { try { const j = await mbWebFetch(`${ORIGIN}/ws/js/entity/${gid}`, { headers: { Accept: 'application/json' } }).then(r => r.json()); // return the WHOLE entity (like a search hit) so the credit write-back has every field it needs if (j && j.gid) { if (!j.entityType) j.entityType = kind || 'artist'; return j; } } catch (e) { Log.cat(kind === 'label' ? 'Label' : 'Artist').warn('fetch entity failed', gid, e.message); } return null; } async function searchArtist(name, limit) { limit = limit || 8; const k = fold(name) + '|' + limit; if (!fold(name)) return []; if (_cache.has(k)) return _cache.get(k); let list = []; try { const j = await mbWebFetch(`${ORIGIN}/ws/js/artist?q=${encodeURIComponent(name)}&limit=${limit}&direct=false`, { headers: { Accept: 'application/json' } }).then(r => r.json()); list = Array.isArray(j) ? j : (j.results || []); } catch (e) { Log.cat('Artist').warn('search failed:', name, e.message); } list = list.filter(c => c && (c.name || '').trim()); // drop the trailing empty placeholder entry list.forEach(c => noteDisamb(c.gid || c.id, c.comment)); // cache disambiguations for the table after a pick (#195) _cache.set(k, list); return list; } // #442/#445: the UNIFIED exact-identity resolver — the single source of truth for a // confident exact match in matchSlot. Resolves a credited name to the unique MB artist // that carries it as an exact NAME or ALIAS (an alias is just an alternative name), and // accepts it ONLY when EXACTLY ONE artist across name+alias space has it. That makes the // check alias-AWARE like MB's own duplicate/dedup logic: a name that is ALSO another // artist's alias is ambiguous → declined (a candidate, not an auto-link) — the #445 fix. // The fast /ws/js search matchSlot uses is alias-blind, so this one /ws/2 lookup (which // carries aliases + matches the alias field) both catches an alias-only credit ("Don Abi" // → "Abiodun", #442) and enforces the unified unambiguity. Returns { entity, via } where // via is 'name' or 'alias' (label only), else null (ambiguous OR none). Cached per name. const _aliasMatchCache = new Map(); // `kind`: 'artist' (default) or 'label' — the label auto-match uses the same rule (#623, A5) const _aliasSeen = new Map(); // #654: what each name/alias search found, for the match tooltip const aliasSeen = (name, kind) => _aliasSeen.get(((kind || 'artist') === 'artist' ? '' : kind + ':') + fold(name)) || null; async function resolveByExactAlias(name, kind) { kind = kind || 'artist'; const key = (kind === 'artist' ? '' : kind + ':') + fold(name); if (!fold(name)) return null; if (_aliasMatchCache.has(key)) return _aliasMatchCache.get(key); const q = mbmIdentityQuery(name, kind); if (!q) return null; // a throttled lookup must NOT be cached as "no match" — that would freeze a // transient 503 into a permanent auto-match failure for this name (#555) const res = await wsJson(`${ORIGIN}/ws/2/${kind}?query=${encodeURIComponent(q)}&fmt=json&limit=${MBM_EXACT_LIMIT}`, { label: kind + ' alias search' }); if (!res.json) { Log.cat(kind === 'label' ? 'Label' : 'Artist').warn('alias search failed:', name, '— not cached, a later pass retries'); return null; } // #613: unique only when MB returned EVERY match. The search doesn't rank exact holders // first — `artist:"kim"` matches 2,777 artists and the one exact "Kim" among the first 25 // was taken as unique. A common name that can't be proven unique stays a candidate. const idn = mbmExactIdentity(res.json, name); Log.cat(kind === 'label' ? 'Label' : 'Artist').debug(kind + ' alias search:', JSON.stringify(name), '→', (res.json.artists || res.json.labels || []).length, 'of', res.json.count, 'match(es),', idn.exact.length, 'exact —', idn.status); if (idn.status === 'incomplete' && idn.exact.length) Log.cat(kind === 'label' ? 'Label' : 'Artist').info(JSON.stringify(name), '— one exact name/alias seen, but', res.json.count, 'artists match; not provably unique → left to pick'); _aliasSeen.set(key, { status: idn.status, exact: idn.exact.length, count: res.json.count, via: idn.via || null }); // #654: the tooltip says why it was or wasn't unique let out = null; // unambiguous only if (idn.status === 'unique') { // #445: 'name' when it's a real NAME hit (the /ws/js search under-ranked it), 'alias' when // only an alias matches — so the log/badge never calls a name match "via exact alias" const ent = await fetchEntity(idn.hit.id, kind); if (ent && ent.gid) out = { entity: ent, via: idn.via }; } _aliasMatchCache.set(key, out); return out; } /* ── label auto-match (#407) ────────────────────────────────────────────── * The release-info Label field is seeded (from Discogs, or typed) as plain text * with no MBID — the single most common thing majkinetor forgets to resolve. When * the name has exactly ONE exact MB label, select it automatically; when it's * ambiguous (e.g. "Columbia" → several) or has no exact hit, leave it for a human. * Labels live in the KO release model — `release().labels()[i].label` is an * observable holding the label entity, so we set it directly (verified: this also * fills the #label-N input and is picked up on submit). */ function riSetLabel(lf, ent, st) { let e = ent; // #623 (sweep, A1): the page's MB (unsafeWindow), as everywhere else — the sandbox // `window.MB` is absent in managers that isolate it, and the bare entity got written try { if (W.MB && typeof W.MB.entity === 'function') e = W.MB.entity(ent, 'label'); } catch (x) {} lf.label(e); _riLab.set(lf, Object.assign({ gid: ent.gid }, st)); } // Resolve every still-unset release label whose name has a unique exact MB hit. async function matchReleaseLabels() { if (SETTINGS.autoMatchLabel === false) return; const rel = release(); if (!rel) return; const labels = u(rel.labels) || []; if (!labels.length) return; const todo = labels.filter(lf => { const c = lf && typeof lf.label === 'function' ? lf.label() : null; return c && c.name && !c.gid; }); todo.forEach(lf => _riLabBusy.add(lf)); renderRiLabels(); // the spinner shows it's under way try { await riMatchLabels(labels); } finally { todo.forEach(lf => _riLabBusy.delete(lf)); renderRiLabels(); } riWatchDupLabels(); } async function riMatchLabels(labels) { // #652: a label credited by the release's Discogs page, or by First Contact's platform, is // linked by that link when exactly one MB label has it const dmap = SETTINGS.discogsUrlMatch === false ? null : await loadDiscogsMap().catch(() => null); const h = fcHandoff(); let linked = 0, lastName = ''; for (let i = 0; i < labels.length; i++) { const lf = labels[i]; if (!lf || typeof lf.label !== 'function') continue; try { const cur = lf.label(); const name = cur && cur.name; if (!name || (cur && cur.gid)) continue; // empty slot, or already resolved → leave it const set = (ent, st) => { riSetLabel(lf, ent, st); linked++; lastName = ent.name; }; const url = riLabelUrl(name, i, labels.length, dmap, h); if (url) { const p = platformOf(url) || { abbr: 'link', name: 'platform' }; const hits = (await resolvePlatformUrls([url], 'label')).get(url); if (hits && !hits.length) _riLabUrl.set(lf, { name, url }); // no label has it yet: + creates the label with it if (hits && hits.length === 1) { const ent = await fetchEntity(hits[0].gid, 'label'); if (ent && ent.gid) { try { set(ent, { status: p.abbr === 'disc' ? 'disc' : 'plat', abbr: p.abbr, title: `matched by its ${p.name} link: ${url}` }); Log.cat('Label').info(name, '→', ent.name, '(' + ent.gid + ') — via', p.name, 'link', url); continue; } catch (e) { Log.cat('Label').warn('label set failed', name, e.message); } } } else Log.cat('Label').debug(name, '—', p.name, 'link', url, hits == null ? 'lookup unavailable' : hits.length ? hits.length + ' labels have it' : 'not in MusicBrainz', '→ the name'); } // #623 (sweep, A5): "exactly one exact hit" was judged on the first 8 search results, // which don't rank exact matches first — a second label of that name further down was // invisible. The same rule as track artists now: unique by name or alias among ALL // matches (#613), else left for a human. const idHit = await resolveByExactAlias(name, 'label'); if (!idHit) { if (await riLabelFromDups(lf, name, set)) continue; Log.cat('Label').info(name, '— no unique exact MB label (name or alias), none settled by the duplicates — left unset'); continue; } try { set(idHit.entity, { status: idHit.via === 'alias' ? 'alias' : 'high', title: idHit.via === 'alias' ? 'the only MusicBrainz label with this name as an alias' : 'the only MusicBrainz label with this name' }); Log.cat('Label').info(name, '→', idHit.entity.name, '(' + idHit.entity.gid + ')'); } catch (e) { Log.cat('Label').warn('label set failed', name, e.message); } } finally { if (_riLabBusy.delete(lf)) renderRiLabels(); } // this one is settled: its badge (or nothing) in place of the spinner } if (linked) toast(linked === 1 ? ('✓ Label matched: ' + lastName) : ('✓ Auto-matched ' + linked + ' labels')); } /* #750: a name more than one MB label carries ("Mole Listening Pearls": the label and its * bootleg page) is settled by the Duplicates tab: when the similar releases MusicBrainz lists * credit exactly one label of that name, that's the one. Free: the tab's own search already * carries each release's labels (`releaseEditor.similarReleases`, gid + name). The tab fills * after load, so a label still unset is tried again each time it does. */ function riDupLabels(name) { const re = W.MB && W.MB.releaseEditor; const sims = (re && typeof re.similarReleases === 'function' && re.similarReleases()) || []; const by = new Map(); sims.forEach(r => (u(r && r.labels) || []).forEach(l => { const g = u(l && l.gid), n = u(l && l.name) || ''; if (!g || fold(n) !== fold(name)) return; const v = by.get(g) || { gid: g, name: n, rels: 0 }; v.rels++; by.set(g, v); })); return { of: sims.length, labels: [...by.values()] }; } async function riLabelFromDups(lf, name, set) { const d = riDupLabels(name); if (d.labels.length !== 1) { Log.cat('Label').debug(name, '— duplicates:', d.of, 'similar release(s),', d.labels.length ? d.labels.length + ' different labels of this name' : 'none credits it'); return false; } const hit = d.labels[0], ent = await fetchEntity(hit.gid, 'label'); if (!ent || !ent.gid) { Log.cat('Label').warn(name, '— duplicates name', hit.gid, 'but its lookup failed'); return false; } const cur = lf.label(); if (!cur || cur.gid || fold(cur.name) !== fold(name)) return false; // picked or retyped meanwhile try { set(ent, { status: 'pos', abbr: 'dup', title: `the label of ${hit.rels === 1 ? 'a similar release' : hit.rels + ' similar releases'} on the Duplicates tab` }); Log.cat('Label').info(name, '→', ent.name, '(' + ent.gid + ') — via', hit.rels, 'of', d.of, 'similar release(s)'); return true; } catch (e) { Log.cat('Label').warn('label set failed', name, e.message); return false; } } let _riDupLabSub = null; function riWatchDupLabels() { const re = W.MB && W.MB.releaseEditor, obs = re && re.similarReleases; if (_riDupLabSub || SETTINGS.autoMatchLabel === false || !obs || typeof obs.subscribe !== 'function') return; let busy = false; _riDupLabSub = obs.subscribe(async () => { const rel = release(); if (busy || !rel) return; busy = true; try { let linked = 0, lastName = ''; for (const lf of u(rel.labels) || []) { const cur = lf && typeof lf.label === 'function' ? lf.label() : null; if (!cur || !cur.name || cur.gid) continue; const set = (ent, st) => { riSetLabel(lf, ent, st); linked++; lastName = ent.name; }; await riLabelFromDups(lf, cur.name, set); } if (linked) { renderRiLabels(); toast(linked === 1 ? ('✓ Label matched: ' + lastName) : ('✓ Auto-matched ' + linked + ' labels')); } } catch (e) { Log.cat('Label').warn('duplicates label match failed', e.message); } finally { busy = false; } }); } /* Release information's matching, once per page, ahead of the Tracklist's (loadAndRender awaits it): * its requests are booked in the MusicBrainz gate before the Tracklist's dozens, so they're answered * first. #407: the label is matched whatever the Tracklist auto-match toggle says (it lives in the * release-info model, not the tracklist). */ let _riMatchRun = null, _riArtistDone = false; // _riArtistDone: the release group waits for the artist (#716) function matchReleaseInfo() { if (_riMatchRun) return _riMatchRun; const t0 = Date.now(); Log.cat('Release').debug('Release information: matching the artist and labels before the Tracklist'); _riMatchRun = Promise.all([ matchReleaseLabels().catch(e => Log.cat('Label').warn('label auto-match failed', e.message)), matchReleaseArtist().catch(e => Log.cat('Artist').warn('artist auto-match failed', e.message)).then(() => { _riArtistDone = true; }), ]).then(() => Log.cat('Release').info('Release information matched in', ((Date.now() - t0) / 1000).toFixed(1) + 's; the Tracklist goes next')); return _riMatchRun; } /* The release artist's co-credit stage reads the artists on the tracks, which a new release only * has once the Tracklist is matched: now that Release information goes first, a name it left * unlinked gets one more try with them, once (the other stages' lookups are cached). */ let _riRematched = false; function riRematchWithTracks() { if (_riRematched || _riMatching || _matchStop || SETTINGS.autoMatchArtist === false) return; // not after a Stop const open = _riArt.filter(p => !p.committed && p.creditedAs); if (!open.length || !riContextGids().length) return; const cell = document.querySelector('#information .tc-ri-art'); if (cell && cell.contains(document.activeElement)) { Log.cat('Artist').debug('release artist: not re-matched with the track artists, you are editing it'); return; } _riRematched = true; Log.cat('Artist').debug('release artist: re-matching', open.length, 'unlinked name(s) with the track artists as co-credit context'); matchReleaseArtist().catch(e => Log.cat('Artist').warn('artist re-match failed', e.message)); } /* ── #652: the release artist, matched like a track artist ───────────────────────── * The release artist credit runs through the Tracklist's stages, most confident first: * the release's Discogs artists, First Contact's platform links, the release artist of the * other editions (the release group's, then the duplicates': the requests the Tracklist * already makes), the exact name or alias, and co-credit with the artists on the tracks. * A confident match is written into the credit (the full entity, #348); each name gets the * Tracklist's badge, match card and offers (+ / 🔗 / ⚠) next to the field. */ let _riArt = []; // one slot-like record per release artist name: what matching found const riNames = () => { try { const ac = u(release().artistCredit); return (ac && u(ac.names)) || []; } catch (e) { return []; } }; // the artists already on the tracks, most credited first: the co-credit stage's context function riContextGids() { const count = new Map(); try { readTracklist().forEach(t => t.names.forEach(n => { if (n.artistGid && coCreditCtx(n.artistGid)) count.set(n.artistGid, (count.get(n.artistGid) || 0) + 1); })); } catch (e) {} return [...count.entries()].sort((a, b) => b[1] - a[1]).map(e => e[0]).slice(0, 6); } // the other editions' release artists that match a credited name → the position stage's shape function riTallyEditions(rels, creditedAs, self) { const by = new Map(), editions = []; rels.forEach(r => { if (!r || !r.id || r.id === self) return; const ac = r['artist-credit'] || []; if (!ac.length) return; editions.push({ gid: r.id, from: r._from || 'rg', title: r.title || '', date: r.date || '', format: '', names: ac.map(a => (a.artist && a.artist.name) || a.name), gids: ac.map(a => a.artist && a.artist.id).filter(Boolean) }); ac.forEach(a => { const g = a.artist && a.artist.id; if (!g || SPECIAL_PURPOSE_ARTISTS.has(g)) return; if (!posNameMatch(creditedAs, a.name) && !posNameMatch(creditedAs, a.artist.name)) return; const v = by.get(g) || { gid: g, name: a.artist.name || a.name, rels: new Set() }; v.rels.add(r.id); by.set(g, v); }); }); return { artists: [...by.values()].map(v => ({ gid: v.gid, name: v.name, votes: v.rels.size })).sort((a, b) => b.votes - a.votes), of: editions.length, editions }; } async function riEditionArtists(creditedAs) { const rel = release(), self = rel ? u(rel.gid) || null : null; const rg = rel && u(rel.releaseGroup), rgGid = rg ? u(rg.gid) : null; let rels = rgGid ? ((await rgReleases(rgGid)) || []).map(r => Object.assign({ _from: 'rg' }, r)) : []; let res = riTallyEditions(rels, creditedAs, self); if (res.artists.length !== 1) { const title = rel ? (u(rel.name) || '') : ''; const cached = [..._dupRawCache.values()]; const dups = cached.length ? cached : await duplicateReleases(title, null, rgGid); rels = rels.concat((dups || []).map(r => Object.assign({ _from: 'dup' }, r))); res = riTallyEditions(rels, creditedAs, self); } Log.cat('Artist').debug('release artist editions:', JSON.stringify(creditedAs), '—', res.of, 'edition(s);', res.artists.length ? res.artists.map(a => a.name + ' ×' + a.votes).join(', ') : 'none credit a matching name'); return res.artists.length ? res : null; } // write one name of the release artist credit, keeping the others as they are function riWriteArtist(i, ent) { const rel = release(), names = riNames(); if (!names[i]) return false; const e = ent; const out = names.map((n, j) => ({ artist: j === i ? e : u(n.artist), name: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '' })); try { rel.artistCredit({ names: out }); return true; } catch (x) { Log.cat('Artist').warn('release artist: writing', ent && ent.name, 'failed —', x.message); return false; } } // the user's pick (a candidate, or an artist just created) into name i async function riPick(i, ent, how) { if (ent && ent.gid && !ent.id) ent = (await fetchEntity(ent.gid)) || ent; const ps = _riArt[i]; if (!ent || !ent.gid || !ps) { Log.cat('Artist').warn('Release artist', i + 1, '— no such name in the credit;', ent && ent.name, 'not set'); return false; } pickArtist(ps, ent); // the cell's own pick: writes the whole credit through riCommit Log.cat('Artist').info('Release artist', i + 1, '→', ent.name, '(' + ent.gid + ')', how || 'picked'); await riRetag(); return true; } // #725: a create started before a match pass or a re-read finished holds a slot those have // since replaced; find its successor by the credited name, then by its position function riSlotIndex(ps) { const slots = riEntry.slots, i = slots.indexOf(ps); if (i >= 0) return i; const byName = slots.findIndex(p => !p.committed && ps.creditedAs && sameName(p.creditedAs, ps.creditedAs)); const j = byName >= 0 ? byName : (ps._ri >= 0 && ps._ri < slots.length ? ps._ri : -1); Log.cat('Artist').debug('release artist cell was rebuilt since the pick started;', JSON.stringify(ps.creditedAs), '→ name', j + 1, byName >= 0 ? '(same credited name)' : '(same position)'); return j; } /* #652: the release Artist field IS the Tracklist's artist cell. MusicBrainz's box, its Edit * bubble and three-column popup are hidden; riEntry is a track-like entry whose slots are * _riArt, so every cell action (pick, credited-as, join, add, split, reorder, remove, create) * runs the Tracklist's code, and commitTrack hands it to riCommit, which writes the release * artist credit instead of a track's. */ const riEntry = { release: true, mi: -1, ti: -1, number: 'release', title: '', slots: [] }; let _riCommittedKey = null, _riMatching = false; // the live credit as we last wrote or read it const riLiveKey = () => JSON.stringify(riNames().map(n => { const a = u(n.artist); return [(a && u(a.gid)) || '', u(n.name) || '', u(n.joinPhrase) || '']; })); function riBind() { riEntry.slots = _riArt; _riArt.forEach((ps, i) => { ps._entry = riEntry; ps._ri = i; ps._onPick = ent => riPick(riSlotIndex(ps), ent, 'created'); }); } // the credit changed under us (seeded late, MusicBrainz's own editor in the Original view, a // paste): rebuild the slots from it, keeping what matching found for an artist still there function riSyncSlots() { const key = riLiveKey(); if (key === _riCommittedKey && _riArt.length) return; const prev = new Map(_riArt.filter(p => p.gid).map(p => [p.gid, p])); _riArt = riNames().map(n => { const a = u(n.artist), gid = (a && u(a.gid)) || null, cr = u(n.name) || '', nm = (a && u(a.name)) || cr, jp = u(n.joinPhrase) || ''; const p = gid && prev.get(gid); if (p) return Object.assign(p, { creditedAs: cr || nm, joinPhrase: jp }); return { creditedAs: cr || nm, joinPhrase: jp, status: gid ? 'set' : 'none', entity: gid ? a : null, gid, name: gid ? nm : '', candidates: [], committed: !!gid }; }); if (!_riArt.length) _riArt = [{ creditedAs: '', joinPhrase: '', status: 'none', entity: null, gid: null, name: '', candidates: [], committed: false }]; riBind(); _riCommittedKey = key; Log.cat('Artist').debug('release artist cell: read the credit', key); } function riCommit(entry) { const rel = release(); if (!rel || typeof rel.artistCredit !== 'function') return; const liveByGid = new Map(); riNames().forEach(n => { const a = u(n.artist), g = a && u(a.gid); if (g && !liveByGid.has(g)) liveByGid.set(g, a); }); const names = entry.slots.map(p => { const cr = p.creditedAs || p.name || ''; if (p.committed && p.gid) return { artist: liveByGid.get(p.gid) || p.entity || { name: p.name }, name: cr, joinPhrase: p.joinPhrase || '' }; return { artist: { name: cr }, name: cr, joinPhrase: p.joinPhrase || '' }; }); _selfEdit = true; try { rel.artistCredit({ names }); } catch (x) { Log.cat('Artist').warn('release artist: writing the credit failed —', x.message); } finally { _selfEdit = false; } _riCommittedKey = riLiveKey(); Log.cat('Artist').info('release artist credit →', JSON.stringify(names.map(n => n.name + n.joinPhrase).join('')), '·', names.map(n => (n.artist && n.artist.gid) ? 'linked' : 'unlinked').join(', ')); } async function matchReleaseArtist(manual) { if (SETTINGS.autoMatchArtist === false && !manual) return; const rel = release(); if (!rel || typeof rel.artistCredit !== 'function') return; const names = riNames(); if (!names.length) return; // the pass writes as it goes: the cell waits for the whole result, and shows a spinner for each name meanwhile _riMatching = true; const td = document.querySelector('#information td.release-artist'); if (td) riBadges(td); try { await riMatchArtist(names); } finally { _riMatching = false; renderRiArtist(true); } } async function riMatchArtist(names) { const dmap = SETTINGS.discogsUrlMatch === false ? null : await loadDiscogsMap().catch(() => null); const relDisc = dmap && dmap.releaseArtists && dmap.releaseArtists.length === names.length ? dmap.releaseArtists : null; const h = fcHandoff(); // one batched lookup for every artist link in the handoff, the tracks' too: Release information goes // first now (#754), and the Tracklist's pass then finds its links answered instead of a second batch if (h) await resolvePlatformUrls(fcArtistUrls(h)); const ctx = riContextGids(); Log.cat('Artist').debug('release artist: matching', names.length, 'name(s);', relDisc ? 'Discogs artists known;' : '', h ? 'First Contact links known;' : '', ctx.length, 'track artist(s) as co-credit context'); let linked = 0, lastName = ''; const state = []; const prevBy = new Map(_riArt.filter(p => p.gid && p.committed && p.status !== 'set').map(p => [p.gid, p])); // a re-match (after a split) keeps the badges it had for (let i = 0; i < names.length; i++) { const n = names[i], cur = u(n.artist), creditedAs = u(n.name) || '', joinPhrase = u(n.joinPhrase) || ''; const gid = cur && u(cur.gid), nm = (cur && u(cur.name)) || creditedAs; const ps = { creditedAs: creditedAs || nm, joinPhrase, _ri: i, status: gid ? 'set' : 'none', entity: null, gid: gid || null, name: gid ? nm : '', candidates: [], committed: !!gid }; const was = gid && prevBy.get(gid); if (was) Object.assign(ps, { status: was.status, entity: was.entity, candidates: was.candidates || [], _pos: was._pos || null, _why: was._why || null, _at: was._at }); const durl = relDisc ? relDisc[i] : null, purl = h ? fcCreditUrl(h.credit, i, ps.creditedAs, names.length) : null; if (!gid && nm) { const m = await matchSlot(nm, null, durl, ctx, () => riEditionArtists(nm), purl, 'release artist'); if (m.why && m.why.pos) m.why.pos.ri = true; Object.assign(ps, { status: slotStatusOf(m), entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates || [], _pos: m.pos || null, _why: m.why || null, _at: Date.now() }); if (m.entity && autoCommittable(ps)) { const ent = m.entity.id ? m.entity : await fetchEntity(m.entity.gid); if (ent && ent.id && riWriteArtist(i, ent)) { ps.committed = true; ps.entity = ent; linked++; lastName = ent.name; Log.cat('Artist').info(nm, '→', ent.name, '(' + ent.gid + ') —', ps.status); } } else Log.cat('Artist').info(nm, '—', m.entity ? 'uncertain, ' + m.entity.name + ' is the best candidate; left for you' : 'no match; left unset'); } await tagDiscogsAddable(ps, durl); await tagPlatformAddable(ps, purl); state.push(ps); } _riArt = state; riBind(); _riCommittedKey = riLiveKey(); if (linked) toast(linked === 1 ? ('✓ Artist matched: ' + lastName) : ('✓ Auto-matched ' + linked + ' artists')); } // after a link was added or an artist picked: the offers again async function riRetag() { for (const ps of _riArt) { if (ps._discogsUrl) await tagDiscogsAddable(ps, ps._discogsUrl); if (ps._platUrl) await tagPlatformAddable(ps, ps._platUrl); } renderRiArtist(); } // the badges and offers, after MusicBrainz's artist credit field function renderRiArtist(force) { const td = document.querySelector('#information td.release-artist'); if (!td) return; let cell = td.querySelector(':scope > .tc-ri-art'), box = td.querySelector(':scope > .tc-ri-am'); const th = td.previousElementSibling; let lead = th && th.querySelector(':scope > .tc-ri-lead'); if (!riWant()) { if (cell) cell.remove(); if (box) box.remove(); if (lead) lead.remove(); td.classList.remove('tc-ri-artcell'); if (th) th.classList.remove('tc-ri-artlbl'); return; } if (box) box.remove(); // the badges sit on their own lines now if (lead) lead.remove(); // (older builds kept the credited-as column in the label cell) if (th) { th.classList.add('tc-ri-artlbl'); td.style.setProperty('--tc-ri-lead-w', Math.max(80, th.getBoundingClientRect().width - 14) + 'px'); } if (!cell) { cell = document.createElement('div'); cell.className = 'tc-ri-art mbu-ui'; td.prepend(cell); td.classList.add('tc-ri-artcell'); Log.cat('Artist').debug('release artist: the Tracklist cell replaces MusicBrainz\'s artist field'); } if ((_riMatching && cell.dataset.sig) || (!force && cell.contains(document.activeElement))) return; // never rebuild under the user's caret, nor mid-pass (once drawn); the next tick catches up riSyncSlots(); // a release artist already "set" carries no entity of ours: feed the disambiguation cache from MB's own // credit (the KO artist has its comment) so the bar shows it, as the Tracklist does (#195) riNames().forEach(n => { const a = u(n.artist); if (a) noteDisamb(u(a.gid), u(a.comment)); }); const sig = JSON.stringify(_riArt.map(p => [p.status, p.gid, !!p.committed, p.creditedAs, p.joinPhrase, p.name, !!p._discogsAddable, !!p._platAddable, p._platConflict && p._platConflict.gid, p._discogsConflict && p._discogsConflict.gid, !!p._pending])); if (!force && cell.dataset.sig === sig) return; // re-applied every tick: only touch the DOM on a change cell.dataset.sig = sig; cell.textContent = ''; const tbl = document.createElement('table'); tbl.className = 'tc-mirror tc-ri-mirror ' + (SETTINGS.gridRows !== false ? 'gridrows ' : '') + (SETTINGS.layout || 'normal'); const tr = tbl.appendChild(document.createElement('tbody')).appendChild(document.createElement('tr')); const art = tr.appendChild(document.createElement('td')); art.className = 'c-art'; const paintBadges = () => riBadges(td); _riArt.forEach((p, i) => { const line = art.appendChild(slotEl(riEntry, p, i, paintBadges)); // #652 (majkinetor): "Credit as instead of Artist label, selector aligned with edit above it" — // the credited-as box and the artist-type icon stay in the line, drawn out into the label // column: they move with it, so they are level whatever the theme does to either cell const row = document.createElement('div'); row.className = 'tc-ri-leadrow'; const cw = line.querySelector(':scope > .tc-credwrap'), ic = cw && cw.nextElementSibling; if (cw) row.appendChild(cw); if (ic && !ic.classList.contains('tc-search')) row.appendChild(ic); line.prepend(row); const rb = document.createElement('span'); rb.className = 'tc-ri-rb'; rb.dataset.ri = i; line.appendChild(rb); }); cell.appendChild(tbl); paintBadges(); } // the match badge at the end of each artist's line (the Tracklist shows them in its own column) function riBadges(td) { td.querySelectorAll('.tc-ri-art .tc-ri-rb').forEach(rb => { const i = +rb.dataset.ri, p = _riArt[i]; rb.textContent = ''; if (_riMatching && p && !p.committed && p.creditedAs) { rb.innerHTML = `<span class="tc-ri-spin" title="${esc(RI_BUSY_TIP)}"></span>`; return; } const txt = p && badgeText(p); if (!txt) return; const chip = rb; const b = document.createElement('span'); b.className = 'tc-badge ' + p.status; b.dataset.ri = i; b.textContent = txt; if (p.status === 'low' && p.entity) { b.classList.add('tc-ri-pick'); b.title = `uncertain: click to link ${p.entity.name}`; b.onclick = () => riPick(i, p.entity, 'picked (the uncertain candidate)'); } chip.appendChild(b); }); } /* ── #652: a label by its Discogs or platform link ───────────────────────────────── */ const _riLabInit = new WeakMap(); // the label field → the label gid it had when Apollo first saw it ('' = empty) const _riLab = new WeakMap(); // the label field → { status, abbr } for its badge const _riLabUrl = new WeakMap(); // the label field → { name, url }: its Discogs or platform link no MB label has yet const _riLabBusy = new WeakSet(); // the label fields matchReleaseLabels is still looking up: a spinner in place of the badge const RI_BUSY_TIP = 'Apollo is still matching this on MusicBrainz…'; function riLabelUrl(name, i, n, dmap, h) { const pick = (list, prefer) => { if (!list || !list.length) return null; const by = list.find(l => l.url && sameName(l.name, name)); return by ? by.url : (list.length === n && list[i] && list[i].url ? list[i].url : null); }; const d = pick(dmap && dmap.labels); if (d) return d; return pick(h && h.labels); } function renderRiLabels() { const rel = release(); if (!rel) return; const labels = u(rel.labels) || []; labels.forEach((lf, i) => { const inp = document.getElementById('label-' + i); if (!inp) return; const host = inp.closest('span.autocomplete') || inp.parentElement; riLabelTakeover(inp, host, lf); let b = host.querySelector(':scope > .tc-ri-lab'); let st = _riLab.get(lf); const cur = lf && typeof lf.label === 'function' ? lf.label() : null; // #652: a label already on the release (not matched by Apollo) shows "set", as the artist does const mine = st && cur && cur.gid === st.gid; if (!_riLabInit.has(lf) && lf) _riLabInit.set(lf, cur && cur.gid || ''); if (riWant()) mbuCls(host, 'tc-lab-matched', !!(cur && cur.gid)); const show = riWant() && cur && cur.gid; if (!show && riWant() && _riLabBusy.has(lf)) { if (!b) { b = document.createElement('span'); b.className = 'tc-ri-lab mbu-ui'; host.appendChild(b); } if (b.dataset.t !== 'busy') { b.dataset.t = 'busy'; b.dataset.gid = ''; b.innerHTML = `<span class="tc-ri-spin" title="${esc(RI_BUSY_TIP)}"></span>`; } return; } if (!show) { if (b) b.remove(); inp.style.removeProperty('padding-right'); riLabelDisamb(inp, host, null, null); return; } // a label picked by hand (differs from what the release had and from Apollo's match) shows "user", as the artist does if (!mine) st = cur.gid !== _riLabInit.get(lf) ? { status: 'user', title: 'chosen by you' } : { status: 'set', title: 'already set on the release' }; const txt = st.abbr || ({ disc: 'disc', high: 'name', alias: 'alias' })[st.status] || st.status; if (!b) { b = document.createElement('span'); b.className = 'tc-ri-lab mbu-ui'; host.appendChild(b); } // click opens the label in a new tab; the tooltip names it with its disambiguation const tip = `${cur.name || ''}${cur.comment ? ' (' + cur.comment + ')' : ''}${st.title ? ' — ' + st.title : ''} click to open the label`; const key = st.status + txt + cur.gid + tip; if (b.dataset.t !== key) { b.dataset.t = key; b.innerHTML = `<span class="tc-badge ${esc(st.status)}" title="${esc(tip)}" style="cursor:pointer">${esc(txt)}</span>`; } b.dataset.gid = cur.gid; if (!b._wired) { b._wired = true; b.addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); }); b.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); Log.cat('Label').debug('label badge: open', b.dataset.gid); if (b.dataset.gid) window.open(`${ORIGIN}/label/${b.dataset.gid}`, '_blank', 'noopener'); }); } // the badge sits inside the field: reserve its room so it never covers the label's name const pr = Math.ceil(host.getBoundingClientRect().right - b.getBoundingClientRect().left) + 4; if (pr > 4 && inp.style.getPropertyValue('padding-right') !== pr + 'px') { inp.style.setProperty('box-sizing', 'border-box', 'important'); inp.style.setProperty('padding-right', pr + 'px', 'important'); } riLabelDisamb(inp, host, cur, b); }); } /* ── #716: the release group, picked from MusicBrainz's own suggestions ───────────── * A new release lists "Existing release groups with similar names" under the field * (`$root.duplicateReleaseGroups`) but leaves "Add a new release group" checked, even when * one of them is plainly this release's. When exactly one has the release's title and its * artist credit (the same artists once linked, else the same credited names), Apollo clicks * its radio, as a human would. The title may differ in punctuation, spaces, case and & / and * ("Tall, Dark & Handsome" / "Tall Dark & Handsome"), never in a letter or digit (Vol. 1 / * Vol. 2); an exact title wins over a loose one. Once per title and artist: "Add a new release group" picked * after that stays picked. */ let _rgAutoKey = null, _rgAuto = null; // the title|artists last judged; the group Apollo picked ({ gid, title }) function autoSelectReleaseGroup() { if (SETTINGS.autoMatchRG === false || !apolloEnabled()) return; const re = W.MB && W.MB.releaseEditor, rel = release(); if (!re || !rel || typeof re.duplicateReleaseGroups !== 'function') return; const rg = u(rel.releaseGroup); if (rg && u(rg.gid)) return; // an existing release, or already picked if (!_riArtistDone || _riMatching) return; // the release artist is still being matched const dups = re.duplicateReleaseGroups() || []; if (!dups.length) return; const title = u(rel.name) || '', names = riNames(); const gids = names.map(n => { const a = u(n.artist); return (a && u(a.gid)) || ''; }); const linked = gids.length && gids.every(Boolean); const credit = ns => (ns || []).map(n => (u(n.name) || '') + (u(n.joinPhrase) || '')).join(''); const key = fold(title) + '|' + (linked ? gids.join(',') : fold(credit(names))) + '|' + dups.map(d => d.gid).join(','); if (key === _rgAutoKey) return; _rgAutoKey = key; const byArtist = dups.filter(d => { const ac = (d.artistCredit && d.artistCredit.names) || []; if (linked) return ac.length === gids.length && ac.map(n => n.artist && n.artist.gid).sort().join() === [...gids].sort().join(); return sameName(credit(ac), credit(names)); }); const exact = byArtist.filter(d => sameName(d.name, title)), tk = posNameKey(title); const same = exact.length ? exact : byArtist.filter(d => tk && posNameKey(d.name) === tk); if (same.length !== 1) { Log.cat('Release').info('release group:', JSON.stringify(title), '—', same.length ? same.length + ' suggested groups have this title and artist; left for you' : 'none of the ' + dups.length + ' suggested groups has this title and artist; left as new'); return; } const hit = same[0], i = dups.indexOf(hit); const radio = document.querySelectorAll('.duplicate-release-groups-list input[data-change="selectReleaseGroup"]')[i]; if (!radio) { _rgAutoKey = null; return; } // the list isn't drawn yet: the next tick radio.click(); const now = u(rel.releaseGroup); if (!now || u(now.gid) !== hit.gid) { Log.cat('Release').warn('release group: selecting', hit.name, 'did not take'); return; } _rgAuto = { gid: hit.gid, title: hit.name, loose: !exact.length }; Log.cat('Release').info('release group →', hit.name, '(' + hit.gid + ') — the only suggested group with this title' + (exact.length ? '' : ' (ignoring punctuation and case)') + ' and artist'); toast('✓ Release group selected: ' + hit.name); } // the badge in the Release group field while it holds the group Apollo picked function renderRiReleaseGroup() { const inp = document.getElementById('release-group'); if (!inp) return; const host = inp.closest('span.autocomplete') || inp.parentElement; let b = host.querySelector(':scope > .tc-ri-rgb'); const rel = release(), cur = rel && u(rel.releaseGroup), gid = cur && u(cur.gid); if (!riWant() || !_rgAuto || gid !== _rgAuto.gid) { if (b) { b.remove(); inp.style.removeProperty('padding-right'); } return; } if (!b) { b = document.createElement('span'); b.className = 'tc-ri-lab tc-ri-rgb mbu-ui'; host.appendChild(b); b.innerHTML = `<span class="tc-badge high" title="${esc(_rgAuto.title + ' — picked by Apollo: the only suggested release group with this title' + (_rgAuto.loose ? ' (ignoring punctuation and case)' : '') + ' and artist\nclick to open the release group')}" style="cursor:pointer">auto</span>`; b.addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); }); b.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); window.open(`${ORIGIN}/release-group/${_rgAuto.gid}`, '_blank', 'noopener'); }); } const pr = Math.ceil(host.getBoundingClientRect().right - b.getBoundingClientRect().left) + 4; if (pr > 4 && inp.style.getPropertyValue('padding-right') !== pr + 'px') { inp.style.setProperty('box-sizing', 'border-box', 'important'); inp.style.setProperty('padding-right', pr + 'px', 'important'); } } /* ── the Label field's search: Apollo's picker in place of MusicBrainz's ───────────────── majkinetor: "replace native label search with ours, like artist … along with + on the right to create new". MusicBrainz's own input stays (its id, Knockout binding and the scripts that read it, Mammoth's recall among them); only its jQuery UI lookup is switched off, and the search icon gives its place to +. Typing still renames the label through MusicBrainz's binding, which unlinks it, as before. */ const LABEL_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M2 2.5h6l6 6-5.5 5.5-6.5-6z"/><circle cx="5.5" cy="5.5" r="1.2" fill="currentColor" stroke="none"/></svg>'; // a MusicBrainz /label/<mbid> URL or a bare MBID → the gid function labelMbidFrom(v) { v = (v || '').trim(); const url = v.match(new RegExp('musicbrainz\\.org/label/(' + MBID_RE.source + ')', 'i')); if (url) return url[1].toLowerCase(); const m = v.match(new RegExp('^(' + MBID_RE.source + ')$', 'i')); return m ? m[1].toLowerCase() : null; } const _labCache = new Map(); async function searchLabel(name, limit) { limit = limit || 8; const k = fold(name) + '|' + limit; if (!fold(name)) return []; if (_labCache.has(k)) return _labCache.get(k); let list = []; try { const j = await mbWebFetch(`${ORIGIN}/ws/js/label?q=${encodeURIComponent(name)}&limit=${limit}&direct=false`, { headers: { Accept: 'application/json' } }).then(r => r.json()); list = Array.isArray(j) ? j : (j.results || []); } catch (e) { Log.cat('Label').warn('label search failed:', name, e.message); return []; } list = list.filter(c => c && c.gid && (c.name || '').trim()); // drop the trailing paging entry _labCache.set(k, list); return list; } // type · area · label code · years, as MusicBrainz's own popup lists them function labelMeta(c) { const y = d => (d && (d.year || String(d).slice(0, 4))) || ''; const b = y(c.begin_date), e = y(c.end_date); return [c.typeName || '', (c.area && c.area.name) || '', c.label_code ? 'LC ' + String(c.label_code).padStart(5, '0') : '', b || e ? (b || '?') + '–' + e : ''].filter(Boolean).join(' · '); } async function riPickLabel(lf, c) { if (!lf || typeof lf.label !== 'function' || !c || !c.gid) return; const ent = c.id ? c : await fetchEntity(c.gid, 'label'); if (!ent || !ent.gid) { Log.cat('Label').warn('label pick: could not load', c.gid); return; } let e = ent; try { if (W.MB && typeof W.MB.entity === 'function') e = W.MB.entity(ent, 'label'); } catch (x) {} lf.label(e); _riLab.delete(lf); Log.cat('Label').info('Label picked:', ent.name, '(' + ent.gid + ')'); renderRiLabels(); } // switch the field to Apollo's search, or back to MusicBrainz's in the Original view function riLabelTakeover(inp, host, lf) { inp._tcLabLf = lf; // re-read on every tick: the row's label field, whatever Knockout did to the rows const on = riWant(); if (on) mbuCls(host, 'tc-lab-empty', !inp.value.trim()); if (!!inp._tcLabOn === on) return; inp._tcLabOn = on; try { const w = W.jQuery && W.jQuery(inp).data('mbEntitylookup'); if (w) { if (on) w.close(); w.option('disabled', on); // MusicBrainz's recent-items list (on focus/click of the empty field) calls _suggest // directly, past the disabled check, so it opened greyed out over the form; keep it shut if (!w._tcNoSuggest) { w._tcNoSuggest = true; const sug = w._suggest; w._suggest = function () { if (this.options.disabled) return; return sug.apply(this, arguments); }; } } } catch (e) { Log.cat('Label').warn('label field: MusicBrainz\'s lookup could not be switched', on ? 'off' : 'on', '—', e.message); } mbuCls(host, 'tc-ri-labhost', on); if (!on) { host.querySelectorAll(':scope > .tc-ri-labmk, :scope > .tc-ri-labdis').forEach(x => x.remove()); return; } if (!inp._tcLabWired) { inp._tcLabWired = true; wireLabelPicker(inp, host); } if (!host.querySelector(':scope > .tc-ri-labmk')) { const mk = document.createElement('button'); mk.type = 'button'; mk.className = 'tc-ri-labmk mbu-ui'; mk.textContent = '+'; mk.title = 'create this label on MusicBrainz · right-click: create silently in a background tab'; const go = bg => { const n = inp.value.trim(); if (n) createLabel(n, inp._tcLabLf, bg); else inp.focus(); }; mk.onmousedown = e => { if (e.button === 2) return; e.preventDefault(); e.stopPropagation(); go(false); }; mk.oncontextmenu = e => { e.preventDefault(); e.stopPropagation(); go(true); }; host.appendChild(mk); } Log.cat('Label').debug('label field: Apollo\'s search replaces MusicBrainz\'s'); } // the label's disambiguation, grey after its name, as on the artist bar function riLabelDisamb(inp, host, cur, badge) { let d = host.querySelector(':scope > .tc-ri-labdis'); const txt = riWant() && cur && cur.gid && cur.comment ? cur.comment : ''; if (!txt || !_nmMeasCtx) { if (d) d.remove(); return; } if (!d) { d = document.createElement('span'); d.className = 'tc-ri-labdis mbu-ui'; host.appendChild(d); } if (d.title !== txt) { d.title = txt; d.textContent = '(' + txt + ')'; } const cs = getComputedStyle(inp); _nmMeasCtx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; const left = Math.ceil(inp.offsetLeft + parseFloat(cs.borderLeftWidth || 0) + parseFloat(cs.paddingLeft || 0) + _nmMeasCtx.measureText(inp.value).width + 6) + 'px'; const right = Math.ceil(badge ? host.getBoundingClientRect().right - badge.getBoundingClientRect().left + 6 : 30) + 'px'; if (d.style.left !== left) d.style.left = left; if (d.style.right !== right) d.style.right = right; } function wireLabelPicker(inp, host) { let pop = null, list = [], hi = -1, seq = 0, tmr = 0, onScroll = null, curQuery = '', curLimit = 8; const live = () => inp._tcLabOn && inp.isConnected; const position = () => { if (pop) acPlace(pop, inp); }; // #735: stays on screen (flips above near the bottom) const close = () => { if (pop) pop.remove(); pop = null; hi = -1; list = []; if (onScroll) { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onScroll); onScroll = null; } }; const ensure = () => { if (pop) return; pop = document.createElement('div'); pop.className = 'tc-acpop tc-labpop'; pop._tcOwner = inp; document.body.appendChild(pop); onScroll = () => { if (!pop || !pop.isConnected) close(); else position(); }; window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onScroll); position(); }; const say = msg => { ensure(); list = []; pop.innerHTML = `<div class="tc-acrow none">${esc(msg)}</div>`; position(); }; const choose = c => { close(); riPickLabel(inp._tcLabLf, c); }; const hiliteRow = () => { const rows = [...pop.querySelectorAll('[data-i]')]; rows.forEach((r, i) => r.classList.toggle('hi', i === hi)); if (rows[hi]) rows[hi].scrollIntoView({ block: 'nearest' }); }; const draw = (res, q) => { ensure(); list = res; hi = -1; pop.innerHTML = res.length ? res.map((c, i) => { const aka = aliasStr(c), meta = labelMeta(c); return `<div class="tc-acrow${sameName(c.name, q) ? ' exact' : ''}" data-i="${i}" title="click to set this label"><a class="tic" href="${ORIGIN}/label/${esc(c.gid)}" target="_blank" rel="noopener" title="open label page">${LABEL_SVG}</a><span class="nm">${esc(c.name)}</span>${aka ? `<span class="tc-aka">${esc(aka)}</span>` : ''}${c.comment ? `<span class="cmt">${esc(c.comment)}</span>` : ''}${meta ? `<span class="tc-acmeta">${esc(meta)}</span>` : ''}</div>`; }).join('') : '<div class="tc-acrow none">no matches — use + to create</div>'; pop.querySelectorAll('.tc-acrow[data-i]').forEach(row => { row.onmousedown = e => { e.preventDefault(); choose(res[+row.dataset.i]); }; }); // the icon opens the label in a new tab: its mousedown stays off the row (no pick) and off the input (the popup stays) pop.querySelectorAll('a.tic').forEach(a => { a.onmousedown = e => { e.preventDefault(); e.stopPropagation(); }; }); if (res.length >= curLimit && curLimit < 100) { // a full page: probably more, as MusicBrainz's popup offers const more = document.createElement('div'); more.className = 'tc-acrow tc-acmore'; more.textContent = 'Show more…'; more.onmousedown = e => { e.preventDefault(); more.textContent = 'Loading…'; run(curQuery, curLimit >= 50 ? 100 : curLimit >= 25 ? 50 : 25); }; pop.appendChild(more); } position(); }; const run = (q, limit) => { curQuery = q; curLimit = limit || 8; const my = ++seq; if (!pop || curLimit === 8) say('Searching…'); searchLabel(q, curLimit).then(res => { if (my === seq && live() && document.activeElement === inp) draw(res, q); }); }; // a pasted MBID or /label/ URL, or one Mammoth recalls: set that label straight away const resolveByGid = async gid => { const focused = document.activeElement === inp; if (focused) say('Resolving…'); const ent = await fetchEntity(gid, 'label'); if (labelMbidFrom(inp.value) !== gid) return; if (ent && ent.gid && (ent.entityType || 'label') === 'label') { close(); riPickLabel(inp._tcLabLf, ent); } else if (focused && document.activeElement === inp) say('no label has this MBID'); }; inp.addEventListener('focus', () => { if (!live()) return; host.classList.add('tc-lab-focus'); const q = inp.value.trim(); if (q && !labelMbidFrom(q)) run(q); }); inp.addEventListener('input', () => { if (!live()) return; clearTimeout(tmr); ++seq; mbuCls(host, 'tc-lab-empty', !inp.value.trim()); const gid = labelMbidFrom(inp.value); if (gid) { resolveByGid(gid); return; } if (!inp.value.trim() || document.activeElement !== inp) { close(); return; } // a value written from outside opens no popup if (!pop) say('Searching…'); tmr = setTimeout(() => run(inp.value.trim()), 250); }); inp.addEventListener('keydown', e => { if (!live()) return; const browsing = pop && list.length; if (e.key === 'Escape') { if (pop) { e.preventDefault(); e.stopPropagation(); close(); } } else if (e.key === 'ArrowDown') { e.preventDefault(); if (browsing) { hi = Math.min(list.length - 1, hi + 1); hiliteRow(); } else if (inp.value.trim()) run(inp.value.trim()); } else if (e.key === 'ArrowUp') { if (browsing) { e.preventDefault(); hi = Math.max(0, hi - 1); hiliteRow(); } } else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); // never MusicBrainz's form (Enter switches its tabs) if (browsing) choose(list[hi >= 0 ? hi : 0]); else close(); } }); inp.addEventListener('blur', () => { host.classList.remove('tc-lab-focus'); setTimeout(() => { if (document.activeElement !== inp) close(); }, 160); }); } // full alias arrays for display (the js search only carries primaryAlias, often empty). One WS2 // search per query returns every result's aliases with locale — no per-artist fetch. Cached. const _aliasCache = new Map(); // query → { gid: aliases } const _gidAliases = new Map(); // gid → aliases — survives table rebuilds (so the bar keeps its alias) const cacheAliases = (gid, aks) => { if (gid && aks) _gidAliases.set(gid, aks); }; // aliases for already-resolved artists (existing releases / auto-matched) WITHOUT a fetch each — // one batched WS2 query per ~90 gids (arid:g1 OR arid:g2 …), cached by gid async function fetchAliasesByGids(gids) { const uniq = [...new Set((gids || []).filter(g => g && !_gidAliases.has(g)))]; for (let i = 0; i < uniq.length; i += 90) { const q = uniq.slice(i, i + 90).map(g => 'arid:' + g).join(' OR '); const res = await wsJson(`${ORIGIN}/ws/2/artist?query=${encodeURIComponent(q)}&limit=100&fmt=json`, { label: 'batch alias fetch' }); if (!res.json) { Log.cat('Artist').warn('batch alias fetch failed for', uniq.slice(i, i + 90).length, 'artist(s)'); continue; } (res.json.artists || []).forEach(a => { cacheAliases(a.id, a.aliases || []); noteDisamb(a.id, a.disambiguation); }); } } const isEditingNow = () => { const a = document.activeElement; return a && /^(INPUT|SELECT)$/.test(a.tagName) && (a.closest('.tc-medsec') || a.closest('#tc-panel')); }; // re-run adorn for every rendered slot (adds/updates the alias span) WITHOUT rebuilding rows — so it // can't steal focus or detach the slot an in-flight edit is using function refreshAdorns() { if (!MODEL) return; MODEL.tracks.forEach(t => { const row = rowEl(t.mi, t.ti); if (!row) return; const ps = recPendingState(t.mi, t.ti); const pg = ps && ps.artGids; const searches = row.querySelectorAll('.tc-search'); t.slots.forEach((s, i) => { const search = searches[i]; if (search) { adorn(search, s, search.querySelector('.nm')); search.classList.toggle('tc-slot-pending', slotIsPending(pg, s)); } }); }); } // batch-fetch aliases for every committed artist we don't have yet, then refresh the bars in place async function enrichResolvedAliases() { if (!MODEL) return; const need = []; MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.committed && s.gid && !_gidAliases.has(s.gid)) need.push(s.gid); })); if (!need.length) return; await fetchAliasesByGids(need); refreshAdorns(); } // #575 round 2: this is called from loadAndRender and rebuild, and rebuild runs // again for each artist committed during matching — so on majkinetor's release // it fired 29 batched alias fetches, several of them for a SINGLE gid, where // fetchAliasesByGids is built to take 90 at a time. Coalesced on a trailing // timer: the last call of a burst does the work, and since `need` is recomputed // from the model every time, it picks up everyone who arrived meanwhile. The // aliases are decoration beside a name, so arriving a moment later costs // nothing; a run of one-gid searches costs a start slot each. let _enrichT = 0; function enrichResolvedAliasesSoon() { clearTimeout(_enrichT); _enrichT = setTimeout(() => { enrichResolvedAliases().catch(e => Log.cat('Artist').warn('alias enrich failed', e.message)); }, 1200); } // the alias(es) to show next to a result: the English-locale one(s) if present, otherwise the first // alias — joined with ", " and capped so it never gets too long // MusicBrainz special-purpose artists carry hundreds of junk/locale aliases // (e.g. [unknown] → '"Gold Diggers of 1937" Chorus'); never surface one as an AKA. // Keyed by MBID, not a name pattern — not all are bracketed (Various Artists) and // plenty of real artists DO use brackets. (#171, per @chaban-mb) // #613: the list now lives in dev/match/artist-match.mjs (inlined above as // MBM_SPECIAL_PURPOSE) — shared with Group Therapy and Credit Hoarder; only // discogs_credits/src/data/special-purpose.js still keeps its own copy. const SPECIAL_PURPOSE_ARTISTS = MBM_SPECIAL_PURPOSE; function aliasStr(c) { if (c.gid && SPECIAL_PURPOSE_ARTISTS.has(c.gid)) return null; // #171 — no AKA for special-purpose artists const name = c.name || '', aks = c.aliases || [], diff = s => s && fold(s) !== fold(name); const en = aks.filter(a => /^en/i.test(a.locale || '') && diff(a.name)).sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0)).map(a => a.name); let out = en; if (!out.length) { const first = aks.find(a => diff(a.name)); out = first ? [first.name] : (diff(c.primaryAlias) ? [c.primaryAlias] : []); } const seen = new Set(); out = out.filter(s => { const f = fold(s); if (!f || seen.has(f)) return false; seen.add(f); return true; }); if (!out.length) return null; let s = out.join(', '); const MAX = 48; if (s.length > MAX) { const cut = s.lastIndexOf(', ', MAX); s = (cut > 12 ? s.slice(0, cut) : s.slice(0, MAX - 1)) + '…'; } return s; } // #626: the release group's editions, with their tracklists and credits — ONE request per // release group, shared by the rg sibling map below, the artist position source and the // Recordings pass's position index (#440), which used to fetch this same URL each on their // own. A failed lookup is not cached (#555), so the next caller retries. // Cached as a promise, so callers that arrive together (the matching lanes) share it. const _rgRelCache = new Map(); // rgGid → Promise<releases[] | null> function rgReleases(rgGid) { if (!rgGid) return Promise.resolve(null); if (_rgRelCache.has(rgGid)) return _rgRelCache.get(rgGid); const p = wsJson(`${ORIGIN}/ws/2/release?release-group=${rgGid}&inc=recordings+artist-credits&fmt=json&limit=100`, { label: 'release-group editions' }).then(res => { if (!res.json) { _rgRelCache.delete(rgGid); Log.cat('MusicBrainz').warn('release-group editions: lookup failed for', rgGid, '— not cached'); return null; } const rels = res.json.releases || []; Log.cat('MusicBrainz').debug('release-group editions:', rels.length, 'release(s) in', rgGid); return rels; }, e => { _rgRelCache.delete(rgGid); Log.cat('MusicBrainz').warn('release-group editions failed:', e.message); return null; }); _rgRelCache.set(rgGid, p); return p; } async function fetchSiblings(rgGid) { const map = new Map(); try { const rels = await rgReleases(rgGid); if (!rels) return map; rels.forEach(rel => (rel.media || []).forEach(med => (med.tracks || []).forEach(t => { const title = fold(t.title || (t.recording && t.recording.title)); const ac = (t['artist-credit'] && t['artist-credit'].length) ? t['artist-credit'] : ((t.recording && t.recording['artist-credit']) || []); if (!title || map.has(title) || !ac.length || !ac.every(x => x.artist && x.artist.id)) return; const src = { gid: rel.id, title: rel.title || '', date: rel.date || '', format: med.format || '', pos: (med.position || 1) + '.' + (t.position || t.number || ''), track: t.title || (t.recording && t.recording.title) || '' }; // #654: the tooltip names it map.set(title, ac.map(x => ({ gid: x.artist.id, name: x.artist.name, creditedAs: x.name || x.artist.name, joinPhrase: x.joinphrase || '', src }))); }))); } catch (e) { Log.cat('Artist').warn('sibling load failed:', e.message); } return map; } let _sibCache = { gid: undefined, map: null }; async function loadSiblingMap(force) { const rg = u(release().releaseGroup); const rgGid = rg ? u(rg.gid) : null; if (!rgGid) { Log.cat('Artist').info('no release group linked → search-only'); return new Map(); } if (!force && _sibCache.gid === rgGid && _sibCache.map && _sibCache.map.size) return _sibCache.map; let map = new Map(); for (let i = 0; i < 3 && !map.size; i++) { if (i) await new Promise(r => setTimeout(r, 1100)); map = await fetchSiblings(rgGid); } _sibCache = { gid: rgGid, map }; if (map.size) Log.cat('Artist').info('sibling map:', map.size, 'titles from RG', rgGid); else Log.cat('Artist').warn('sibling map empty (RG', rgGid + ') — search only; retries on rebuild'); return map; } /* ── Discogs artist-link matching (#224) ────────────────────────────────────── * When the release carries a Discogs link, match each track artist by its * Discogs URL — a strong, human-verified signal — before the name search. * Self-contained: Apollo stays fully independent of Credit Hoarder. Gated by * the "Discogs artist link matching" option. Resolves one slot at a time * (matchModel is already sequential), waiting + retrying on rate limits. */ const DISCOGS_TOKEN = 'gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ'; // Scrape the release's Discogs link from the page — edit-relationships anchors // or the release editor's external-link inputs. First match wins (#224). let _discogsUrlLogged = false; let _discVerifyUrl = null, _lastDiscCheck = null; // dedupe the per-run "verifying…"/outcome log lines function discogsReleaseUrlFromPage() { const re = /discogs\.com\/(?:[a-z]{2}\/)?(?:[^/\s"']*\/)?release\/(\d+)/i; let url = null; for (const a of document.querySelectorAll('a[href*="discogs.com/release/"]')) { const m = re.exec(a.getAttribute('href') || ''); if (m) { url = `https://www.discogs.com/release/${m[1]}`; break; } } if (!url) for (const inp of document.querySelectorAll('input')) { const m = re.exec(inp.value || ''); if (m) { url = `https://www.discogs.com/release/${m[1]}`; break; } } if (url && !_discogsUrlLogged) { _discogsUrlLogged = true; Log.cat('Discogs').info('release link found —', url); } return url; } // Map folded track title → array of Discogs artist www-URLs (one per credited // track artist, in order). Keyed by title like the sibling map, so it's robust // to medium/ordering differences. Cached per release link. let _discogsMap = { url: undefined, map: null }; // Release information's label and artist passes start together: share one fetch in flight let _discogsMapFlight = null; function loadDiscogsMap(force) { if (force) return loadDiscogsMapOnce(true); if (!_discogsMapFlight) _discogsMapFlight = loadDiscogsMapOnce().finally(() => { _discogsMapFlight = null; }); return _discogsMapFlight; } async function loadDiscogsMapOnce(force) { if (SETTINGS.discogsUrlMatch === false) return null; const url = discogsReleaseUrlFromPage(); if (!url) { _discogsMap = { url: null, map: null }; return null; } if (!force && _discogsMap.url === url && _discogsMap.map) return _discogsMap.map; const api = `${url.replace('https://www.discogs.com/release/', 'https://api.discogs.com/releases/')}?token=${DISCOGS_TOKEN}`; // #281: the Discogs API (one shared token) is rate-limited — a 429 used to make // the fetch return null, which was then CACHED as an empty map, so the whole // session showed no links until a full page reload. Retry with backoff, and // crucially never cache a FAILED fetch (return null → caller retries / shows a // retry affordance). Only a real JSON response is cached (success or genuine // empty), keyed by URL. for (let attempt = 0; attempt < 4; attempt++) { let r; try { r = await fetch(api); } catch (e) { Log.cat('Discogs').warn('fetch failed —', e.message); await _sleep(800 * (attempt + 1)); continue; } // network → retry, don't cache if (r.status === 429 || r.status === 503) { // rate limited → back off, don't cache const ra = parseInt(r.headers.get('retry-after') || '', 10); Log.cat('Discogs').warn('rate limited (HTTP', r.status + ') — backing off'); await _sleep(Math.max(1000, (ra > 0 ? ra : 2) * 1000)); continue; } if (!r.ok) { Log.cat('Discogs').warn('HTTP', r.status); await _sleep(800 * (attempt + 1)); continue; } // transient → retry, don't cache let json = null; try { json = await r.json(); } catch (e) { await _sleep(800); continue; } const map = new Map(); // #283: the release-level artist(s) (194 = Discogs "Various", skip) — used as a // fallback for tracks that credit no per-track artist (single-artist releases). map.releaseArtists = (json && Array.isArray(json.artists)) ? json.artists.filter(a => a && a.id && a.id !== 194).map(a => `https://www.discogs.com/artist/${a.id}`) : []; // #652: the release's labels, to match the Label field by its Discogs link (1818 = Discogs's "Not On Label") map.labels = (json && Array.isArray(json.labels)) ? json.labels.filter(l => l && l.id && l.id !== 1818).map(l => ({ name: l.name || '', url: `https://www.discogs.com/label/${l.id}` })) : []; // #283: also keep the per-track artist URLs in Discogs order, so a track whose // TITLE doesn't match (transliteration / punctuation the fold can't catch) can // still be matched BY POSITION when the two tracklists are the same length. map.byPos = []; // #442: Discogs models a "feat." credit as an extraartist with a Featuring role, // NOT a main track artist (`t.artists`), so it never reached the by-title/by-pos // maps above — a feat slot split from the MB title (e.g. "Don Abi") then had no // Discogs URL to match on, even though the artist IS credited on Discogs and its // MB entry links that Discogs artist (often the split name is only an ALIAS of the // MB artist, making the link the only reliable bridge). Keep the featured artists // separately, keyed by folded title and by position, as [{ name, url }]. map.featByPos = []; map.featByTitle = new Map(); // #447: a track's performing credit on Discogs is a "Featuring" extra-artist on some // releases and a "Vocals" / "Backing Vocals" / "Rap" / "MC" one on others (e.g. a // "feat. X" in the MB title where Discogs credits X as Vocals). Capture ANY performing // role — vocals, rap, voice, performer, narration, choir — so those artists' Discogs // links are available to match a feat slot. Non-performing roles (producer, remix, // written-by, mixed/engineer, instruments) stay out; and matching is name-gated anyway, // so a captured artist only links when the slot's credited name matches it. const CREDIT_ROLE_RE = /feat|vocal|voice|\brap\b|\bmc\b|\bemcee\b|narrat|spoken|perform|sung|choir|chorus|chant/i; if (json && Array.isArray(json.tracklist)) { json.tracklist.filter(t => (t.type_ || 'track') === 'track').forEach(t => { const urls = (t.artists || []).map(a => (a && a.id) ? `https://www.discogs.com/artist/${a.id}` : null); map.byPos.push(urls); const feats = (t.extraartists || []).filter(a => a && a.id && CREDIT_ROLE_RE.test(a.role || '')).map(a => ({ name: a.name || '', url: `https://www.discogs.com/artist/${a.id}` })); map.featByPos.push(feats); const key = fold(t.title || ''); if (key && !map.featByTitle.has(key)) map.featByTitle.set(key, feats); if (!key || map.has(key)) return; map.set(key, urls); }); // this loads the Discogs release for the link CHECK (and for URL-matching unset // artists) — not re-matching already-set ones, hence "loaded" not "matched". Log.cat('Discogs').info('loaded release —', map.size, 'track title(s)' + (map.releaseArtists.length ? ` + ${map.releaseArtists.length} release artist(s)` : ''), 'from', url); } else { Log.cat('Discogs').warn('release JSON had no tracklist'); } _discogsMap = { url, map }; // cache ONLY a real response (success or genuine empty) return map; } Log.cat('Discogs').warn('could not load release JSON after retries (rate-limited?) — leaving uncached'); return null; // all retries exhausted → unknown, NOT cached, so the next call retries } // #283: a track's per-slot Discogs artist URLs — by folded title, falling back to // POSITION when the title doesn't match and the two tracklists are the same length // (so subtle title differences don't drop the track). { urls, byPos }. function discogsUrlsForTrack(dmap, title, index, total) { if (!dmap) return { urls: null, byPos: false }; const byTitle = dmap.get(fold(title)); if (byTitle) return { urls: byTitle, byPos: false }; if (dmap.byPos && dmap.byPos.length === total && total > 0) return { urls: dmap.byPos[index] || null, byPos: true }; return { urls: null, byPos: false }; } // #442: the Discogs "Featuring" artist URL for a feat slot split out of the title, // matched to the slot's credited name (Discogs credits e.g. "Don Abi", which may be // an ALIAS of the MB artist named "Abiodun", so the shared Discogs link is the only // reliable bridge — a name search alone can't confidently pick it). By folded title, // falling back to POSITION when titles differ but the two tracklists are equal length. function discogsFeatUrlFor(dmap, title, index, total, creditedAs) { if (!dmap || !creditedAs) return null; let feats = dmap.featByTitle && dmap.featByTitle.get(fold(title)); if (!feats && dmap.featByPos && dmap.featByPos.length === total && total > 0) feats = dmap.featByPos[index]; if (!feats || !feats.length) return null; const want = fold(creditedAs); const hit = feats.find(f => fold(f.name) === want) || feats.find(f => { const fn = fold(f.name); return fn && want && (fn.includes(want) || want.includes(fn)); }); return hit ? hit.url : null; } // Resolve a Discogs artist URL to MB artist(s) via its URL relationship. // Returns [{ gid, name }] (0 / 1 / many) on success, or `null` when the lookup // could not complete (rate-limited / network) — callers must treat null as // "unknown" and NOT cache it, so a transient 503 never becomes a false // negative (#227). Only successful responses are cached. const _discogsResolveCache = new Map(); const _sleep = ms => new Promise(z => setTimeout(z, ms)); // The public /ws/2 endpoint is rate-limited (~1 req/s), so requests are paced // here (#227). This used to pace them by CHAINING: each call waited for the // previous one to come back, then waited out the gap. That makes our rate // depend on MusicBrainz's latency instead of on us. // // #575 round 2, measured on majkinetor's log once duplicate requests were gone: // 70 requests took 843 seconds — an actual rate of 0.08/s, more than ten times // UNDER the limit we were pacing for. The searches themselves are slow (an // alias search on his data returns in ~12s), and with a chain every one of // those blocks everything behind it. So the queue wasn't protecting us from // the limiter, it was idling in front of a slow server. // // Pacing applies to request STARTS, not completions, so a slow response doesn't stall the // ones behind it; up to WS_MAX_INFLIGHT outstanding. // // #575 round 3 (majkinetor's 2026-09-08 log: 90 of 121 responses were HTTP 503): a // throttle is a SHARED signal. One 503 holds every lane behind one deadline, and // concurrency collapses to a single probing request until a response comes back clean, // so the retries don't keep the limiter tripped. // // #633: both are now the shared MusicBrainz request gate's (the ST-NET block), so the // schedule and the hold cover every script and tab on the origin, not Apollo alone. // What stays here: the lanes, the Stop and the stale checks. const WS_MAX_INFLIGHT = 4; // a burst ceiling; the gate's pace is what bounds the rate let _wsInFlight = 0; const wsLanes = () => (mbnHot() ? 1 : WS_MAX_INFLIGHT); // while throttled, probe with one const wsGateLog = (lv, m) => (lv === 'warn' ? Log.cat('MusicBrainz').warn : Log.cat('MusicBrainz').info)(m); // opts.stale() → true means "this request has been superseded" (the picker types // a new query while an older one still queues). Checked while it waits for its slot // AND once it has one, so a stale call costs no request. #555 // A picker search (it has a stale()) is interactive for the gate: it doesn't queue // behind a matching pass's background lookups. async function wsGet(url, opts) { const o = opts || {}; if (o.stale && o.stale()) return null; // The wait is interruptible: a lane parked in the gate cannot notice a Stop otherwise, // and a pass whose lanes are all parked looks unstoppable (#577). const slot = await mbnSlot({ background: !o.stale, label: o.label || 'ws2', log: wsGateLog, cancelled: () => _matchStop || !!(o.stale && o.stale()) }); if (!slot.ok) return null; // caller treats null as superseded while (_wsInFlight >= wsLanes()) { if (_matchStop) return null; await _sleep(120); } if (o.stale && o.stale()) return null; _wsInFlight++; try { return await fetch(url, { headers: { Accept: 'application/json' } }); } finally { _wsInFlight--; } } // tell the gate how MusicBrainz answered: a 429/503 holds everybody, a clean answer earns room back const wsAnswer = r => mbnAnswer(r.status, n => (r.headers && r.headers.get ? r.headers.get(n) : null), { log: wsGateLog }); // EVERY /ws/2 read goes through here. MusicBrainz answers a throttled request // with HTTP 503 (or 429) and a body of `{"error":"…"}` — no `recordings` / // `artists` key. Callers that did `fetch(url).then(r => r.json())` therefore // read `undefined`, mapped it to an empty list and rendered "no matches" with // NOTHING logged: the same query worked or didn't at random, depending purely // on how many requests happened to be in flight. So: retry with backoff, // honour Retry-After, and log every attempt. #555 const WS_TRIES = 4; // #575 (majkinetor, "Matching takes too long"): every per-caller cache in here // — _aliasMatchCache, _credCache, the duplicates ones — is CHECKED before its // await and WRITTEN after it. When several track slots resolve the same artist // at once they all miss, all enqueue, and the cache only starts helping once // the first answer lands. A classic cache stampede. // // It is expensive here because /ws/2 reads are paced (the shared gate, one a second), so // every duplicate costs a whole start slot — and the extra volume is what trips // MusicBrainz's rate limiter, whose backoff then slows down everything behind // it. Measured on his log: // 115 requests for 48 distinct URLs (67 wasted, 58%), 25 throttle events, one // query — arid+"Martha Badibala" — fetched TEN times. // // Coalescing in flight fixes every caller at once and changes no caller's // logic: a request for a URL already in flight joins that flight instead of // queueing behind it. The entry is dropped the moment the flight settles, so // nothing is remembered across time — a throttled miss is still not cached // (#555) and a later retry really does re-fetch. // // Two option shapes opt out, because they make a response caller-specific // rather than a property of the URL: `stale` (the caller may want the request // dropped when its popup closed — sharing would drop it for someone else too) // and `onThrottle` (a per-caller UI hint that only the owning caller can act on). const _wsFlight = new Map(); function wsJson(url, opts) { const o = opts || {}; if (o.stale || o.onThrottle) return wsJsonOnce(url, o); const inflight = _wsFlight.get(url); if (inflight) { Log.cat('MusicBrainz').debug((o.label || 'ws2') + ': joined an identical request already in flight —', url); return inflight; } const p = wsJsonOnce(url, o); _wsFlight.set(url, p); // settle-or-fail, then release: the next caller must be able to retry p.then(() => {}, () => {}).then(() => { if (_wsFlight.get(url) === p) _wsFlight.delete(url); }); return p; } async function wsJsonOnce(url, opts) { const o = opts || {}, label = o.label || 'ws2'; for (let attempt = 1; attempt <= WS_TRIES; attempt++) { let r; try { r = await wsGet(url, o); } catch (e) { Log.cat('MusicBrainz').warn(label + ': network error —', e.message, '(attempt ' + attempt + '/' + WS_TRIES + ')', url); await _sleep(600 * attempt); continue; } if (!r) { Log.cat('MusicBrainz').debug(label + ': superseded while queued, dropped —', url); return { stale: true }; } Log.cat('MusicBrainz').debug(label + ': HTTP', r.status, url); const a = await wsAnswer(r); if (a.throttled) { // #575 round 3: hold every lane, not just this one. wsGet waits in the gate for // the shared deadline, so the retry needs no sleep of its own — sleeping here // too would stack a private penalty on top of the shared one. Log.cat('MusicBrainz').warn(label + ': throttled by MusicBrainz (HTTP ' + r.status + ') — all requests holding ' + a.hold + 'ms (attempt ' + attempt + '/' + WS_TRIES + ')'); if (o.onThrottle) { try { o.onThrottle(attempt); } catch (e) {} } continue; } if (r.status === 404) { Log.cat('MusicBrainz').debug(label + ': 404 not found —', url); return { notFound: true }; } if (!r.ok) { Log.cat('MusicBrainz').warn(label + ': HTTP ' + r.status + ' — retrying (attempt ' + attempt + '/' + WS_TRIES + ')', url); await _sleep(600 * attempt); continue; } let j; try { j = await r.json(); } catch (e) { Log.cat('MusicBrainz').warn(label + ': unparsable JSON —', e.message, url); return { failed: true }; } // some edges answer 200 with an error envelope — treat it as a failure, never as 0 hits if (j && typeof j.error === 'string') { // "The MusicBrainz web server is currently busy. Please try again later." is a throttle, retried if (/busy|try again later|rate limit/i.test(j.error)) { Log.cat('MusicBrainz').warn(label + ': web service busy — retrying (attempt ' + attempt + '/' + WS_TRIES + ')', url); await _sleep(1000 * attempt); continue; } Log.cat('MusicBrainz').warn(label + ': web service error —', j.error); return { failed: true }; } return { json: j }; } Log.cat('MusicBrainz').err(label + ': gave up after ' + WS_TRIES + ' attempts —', url); return { failed: true }; } async function resolveByDiscogsUrl(discogsUrl, force) { if (!discogsUrl) return []; if (!force && _discogsResolveCache.has(discogsUrl)) return _discogsResolveCache.get(discogsUrl); for (let attempt = 0; attempt < 5; attempt++) { let r; try { r = await wsGet(`${ORIGIN}/ws/2/url?resource=${encodeURIComponent(discogsUrl)}&inc=artist-rels&fmt=json`); } catch (e) { await _sleep(1200); continue; } // network error → retry, don't cache if (!r) return null; // stopped while it queued → unknown, not cached if (r.status === 404) { _discogsResolveCache.set(discogsUrl, []); _dput('resolve', discogsUrl, []); return []; } // URL not in MB → 0 owners (cacheable) if ((await wsAnswer(r)).throttled) continue; // rate limited → the shared hold, don't cache (#575, #633) if (!r.ok) { await _sleep(1200); continue; } // other transient error → retry, don't cache let j; try { j = await r.json(); } catch (e) { await _sleep(1200); continue; } const seen = new Set(); const out = (j.relations || []).filter(rel => rel.artist && rel.artist.id).map(rel => ({ gid: rel.artist.id, name: rel.artist.name })).filter(e => !seen.has(e.gid) && seen.add(e.gid)); _discogsResolveCache.set(discogsUrl, out); _dput('resolve', discogsUrl, out); // cache ONLY a successful response return out; } return null; // all retries exhausted — unknown, leave uncached so it's retried later } // slot status from a match result — 'disc' for a confident Discogs-URL match, // 'rg' for a release-group sibling, else the name-search confidence. const slotStatusOf = m => !m.entity ? 'none' : (m.source === 'rg' ? 'rg' : m.source === 'pos' ? 'pos' : m.source === 'cred' ? 'cred' : m.source === 'alias' ? 'alias' : (m.source === 'discogs' && m.confidence === 'high') ? 'disc' : (m.source === 'plat' && m.confidence === 'high') ? 'plat' : m.confidence); /* ── add / create the Discogs link for a slot (#227) ────────────────────────── * Stash the slot's Discogs artist URL and decide whether a link can be added: * - unresolved slot + known URL → create the artist seeded with the link * - matched slot + URL that has NO MB owner yet → add it to that artist * - URL already owned (by this or another artist) → nothing to add * Reuses the cached `resolveByDiscogsUrl` (no extra request during matching). */ const DISCOGS_ARTIST_LINK_TYPE = '180'; // MB Discogs artist-URL relationship (string, for seeding) const DISCOGS_LINK_TYPE_ID = 180; // numeric, for matching the js entity rels const discogsIdOf = u => (String(u).match(/\/artist\/(\d+)/) || [])[1] || null; // The Discogs URLs a MB artist links, via the INTERNAL /ws/js entity endpoint // (not rate-limited). Returns an array (possibly empty) on success, or null on // failure. Cached per gid (#227 speed: linked artists cost no /ws/2 call). const _artistRelsCache = new Map(); // #231: persist the Discogs link caches across reloads/releases so the // rate-limited /ws/2/url lookups (and the internal rels reads) aren't repeated // on F5 or when revisiting a release. 1-day TTL (links change rarely; a fresh // edit still drops the entry immediately via the #227 invalidation). Invisible // (no UI), apollo-only. Stored as { resolve: {url:{v,t}}, rels: {gid:{v,t}} }. const DCACHE_KEY = 'apollo:discogs-link-cache-v1'; const DCACHE_TTL = 24 * 60 * 60 * 1000; let _dpersist = { resolve: {}, rels: {} }; let _dsaveTimer = null; function _dsave() { if (_dsaveTimer) return; _dsaveTimer = setTimeout(() => { _dsaveTimer = null; try { localStorage.setItem(DCACHE_KEY, JSON.stringify(_dpersist)); } catch (e) {} }, 700); } function _dput(kind, key, val) { _dpersist[kind][key] = { v: val, t: Date.now() }; _dsave(); } function _ddrop(kind, key) { if (_dpersist[kind] && _dpersist[kind][key]) { delete _dpersist[kind][key]; _dsave(); } } (function _dloadPersist() { try { const o = JSON.parse(localStorage.getItem(DCACHE_KEY) || 'null'); if (!o || typeof o !== 'object') return; const now = Date.now(); let pruned = false; for (const [k, e] of Object.entries(o.resolve || {})) { if (e && now - e.t < DCACHE_TTL) { _dpersist.resolve[k] = e; _discogsResolveCache.set(k, e.v); } else pruned = true; } for (const [k, e] of Object.entries(o.rels || {})) { if (e && now - e.t < DCACHE_TTL) { _dpersist.rels[k] = e; _artistRelsCache.set(k, e.v); } else pruned = true; } if (pruned) _dsave(); // write back without the expired entries } catch (e) {} })(); /* ── #651: artist matching by the platform links First Contact hands over ────────── First Contact seeds /release/add from a platform's album page and publishes, as its handoff, every credited artist with that artist's page on the platform (Deezer, Spotify, …). An MB artist that carries the link is as certain as one carrying the release's Discogs link, so it is the same stage: one owner → linked, badged with the platform. */ // #672: Apollo knows no platform. First Contact's handoff says what the platform is // (`platform: { abbr, name, artistLinkType? }`: the badge, the name in logs and edit notes, the // artist link type to seed where MB's URL cleanup can't pick one) and, on each link MB may store // in other forms (Apple Music's other storefronts, Amazon's other domains, ...), all of them // (`urlForms`). A link the handoff doesn't have, or an older (v1) handoff, gets the generic // "link" badge and is looked up as it is. Discogs is Apollo's own source (the release's Discogs // page), so a Discogs link is known without any handoff. const DISCOGS_PLATFORM = { abbr: 'disc', name: 'Discogs', artistLinkType: DISCOGS_ARTIST_LINK_TYPE }; function platformOf(url) { try { if (/(^|\.)discogs\.com$/i.test(new URL(url).hostname)) return DISCOGS_PLATFORM; } catch (e) { return null; } const h = fcHandoff(); if (h && _fcPlat.has(url)) return _fcPlat.get(url); return h && h.platform && h.platform.abbr && fcUrlForms(url) ? h.platform : null; } // The /ws/2/url lookup matches the URL exactly as MusicBrainz stores it: every form the handoff // gives is asked in the same batched request. function platformUrlForms(url) { const forms = [url]; // a bare site (a Bandcamp account is handed over as its origin) is stored with the trailing slash if (/^https?:\/\/[^/?#]+$/i.test(url)) forms.push(url + '/'); else if (/^https?:\/\/[^/?#]+\/$/i.test(url)) forms.push(url.slice(0, -1)); forms.push(...(fcUrlForms(url) || [])); return [...new Set(forms)]; } // Resolve platform links to their MB owners, batched: /ws/2/url takes up to 100 `resource`s per // call and answers only the ones it knows. → Map url → [{ gid, name }] (the owners across all of // its forms), or null for a url whose lookup failed (not cached, so it's asked again later). // Shares the Discogs cache: both are "who owns this URL", keyed by the URL. async function resolvePlatformUrls(urls, kind) { kind = kind || 'artist'; const key = f => (kind === 'artist' ? f : kind + ':' + f); // #652: a label's owners, cached apart urls = [...new Set(urls.filter(Boolean))]; const need = [...new Set(urls.flatMap(platformUrlForms))].filter(f => !_discogsResolveCache.has(key(f))); const failed = new Set(); for (let i = 0; i < need.length;) { const chunk = []; let len = 0; while (i < need.length && chunk.length < 100 && len < 6000) { chunk.push(need[i]); len += encodeURIComponent(need[i]).length + 10; i++; } const q = chunk.map(f => 'resource=' + encodeURIComponent(f)).join('&'); const r = await wsJson(`${ORIGIN}/ws/2/url?${q}&inc=${kind}-rels&fmt=json`, { label: kind === 'artist' ? 'platform links' : kind + ' links' }); if (r.notFound) { chunk.forEach(f => { _discogsResolveCache.set(key(f), []); _dput('resolve', key(f), []); }); Log.cat('Links').debug('none of', chunk.length, 'URL(s) is in MusicBrainz'); continue; } if (!r.json) { chunk.forEach(f => failed.add(f)); Log.cat('Links').warn('lookup of', chunk.length, 'URL(s) failed; they are asked again on the next pass'); continue; } const list = r.json.urls || (r.json.resource ? [r.json] : []); const by = new Map(list.map(x => [String(x.resource).toLowerCase(), x])); let known = 0; chunk.forEach(f => { const x = by.get(f.toLowerCase()); const seen = new Set(); const own = x ? (x.relations || []).filter(rel => rel[kind] && rel[kind].id).map(rel => ({ gid: rel[kind].id, name: rel[kind].name })).filter(e => !seen.has(e.gid) && seen.add(e.gid)) : []; if (own.length) known++; _discogsResolveCache.set(key(f), own); _dput('resolve', key(f), own); }); Log.cat('Links').debug('looked up', chunk.length, 'URL form(s) in one request,', known, 'owned by an MB artist'); } const out = new Map(); urls.forEach(url => { const forms = platformUrlForms(url), seen = new Set(), own = []; let unknown = false; forms.forEach(f => { if (failed.has(f) || !_discogsResolveCache.has(key(f))) { unknown = true; return; } _discogsResolveCache.get(key(f)).forEach(e => { if (!seen.has(e.gid)) { seen.add(e.gid); own.push(e); } }); }); out.set(url, own.length ? own : unknown ? null : []); }); return out; } const resolveByPlatformUrl = async url => (url ? (await resolvePlatformUrls([url])).get(url) : []); // drop a link's cached owners (after we added it, or created an artist with it) const forgetPlatformUrl = url => platformUrlForms(url).forEach(f => { _discogsResolveCache.delete(f); _ddrop('resolve', f); }); // First Contact's handoff: on <html data-first-contact>, and again on its 'first-contact:seed' // event (asked for with 'first-contact:request', in case it came before us). let _fcHandoff = null, _fcUrls = new Map(); // #702: a handoff from Mission Control's consolidation gives each artist its links on the other // platforms too (alt: [{ url, urlForms, platform }]): each one's platform, and the artist's other links const _fcPlat = new Map(); const _fcSame = new Map(); // every link in a handoff → the forms it says MB may store it under ([] when only its own) function handoffUrlIndex(h) { _fcPlat.clear(); _fcSame.clear(); const idx = new Map(), add = a => { if (!a) return; if (a.url) idx.set(a.url, Array.isArray(a.urlForms) ? a.urlForms : []); const alt = Array.isArray(a.alt) ? a.alt.filter(x => x && x.url) : []; alt.forEach(x => { idx.set(x.url, Array.isArray(x.urlForms) ? x.urlForms : []); if (x.platform && x.platform.abbr) _fcPlat.set(x.url, x.platform); }); const all = [a.url].concat(alt.map(x => x.url)).filter(Boolean); all.forEach(u => _fcSame.set(u, [...new Set((_fcSame.get(u) || []).concat(all.filter(o => o !== u)))])); }; (h.credit || []).forEach(add); (h.labels || []).forEach(add); (h.mediums || []).forEach(m => (m.tracks || []).forEach(t => (t.credit || []).forEach(add))); return idx; } function takeFcHandoff(json, from) { try { const h = JSON.parse(json || 'null'); if (!h || !h.mediums || (_fcHandoff && _fcHandoff.token === h.token)) return; _fcHandoff = h; _fcUrls = handoffUrlIndex(h); if (_fcPlat.size) Log.cat('Links').info('First Contact handoff from Mission Control:', _fcPlat.size, 'artist link(s) on other platforms (' + [...new Set([..._fcPlat.values()].map(p => p.name))].join(', ') + ') to try when an artist\'s own link matches no one'); const p = h.platform; if (p) Log.cat('Links').debug('First Contact platform:', p.name, '- badge', p.abbr + ', artist link type', (p.artistLinkType || '(MB picks it)') + ',', [..._fcUrls.values()].filter(x => x.length).length, 'link(s) with other forms'); else Log.cat('Links').debug('First Contact handoff v' + (h.v || '?') + ' says nothing about the platform: generic "link" badge, links looked up as they are, no link type seeded'); const n = [h.credit || []].concat(...h.mediums.map(m => m.tracks.map(t => t.credit || []))).reduce((k, c) => k + c.filter(a => a.url).length, 0); Log.cat('Links').info('First Contact handoff (' + from + '):', h.sourceName || h.source, '—', n, 'artist credit(s) carry a platform link', h.url ? '(' + h.url + ')' : ''); } catch (e) { Log.cat('Links').warn('First Contact handoff unreadable:', e.message); } } function fcHandoff() { if (!_fcHandoff && document.documentElement.dataset.firstContact) takeFcHandoff(document.documentElement.dataset.firstContact, 'page'); return _fcHandoff; } // a link's forms per the handoff, or undefined for a link the handoff doesn't have const fcUrlForms = url => (fcHandoff(), _fcUrls.get(url)); if (/\/release\/add/.test(location.pathname)) { document.addEventListener('first-contact:seed', e => takeFcHandoff(e.detail, 'event')); try { document.dispatchEvent(new CustomEvent('first-contact:request')); } catch (e) { /* no First Contact */ } } // the handoff's track for an Apollo track: the same medium, the same title (a reordered // tracklist still finds it), else the same position function fcTrackFor(t) { const h = fcHandoff(); if (!h) return null; const med = h.mediums[t.mi]; if (!med || !med.tracks) return null; return med.tracks.find(x => sameName(x.title, t.title)) || med.tracks[t.ti] || null; } // a credit's platform link: the credited name first, else the same position when the credit // still has as many artists as the handoff's function fcCreditUrl(credit, i, creditedAs, nSlots) { if (!credit || !credit.length) return null; const urlOf = a => (a && (a.url || (Array.isArray(a.alt) && a.alt[0] && a.alt[0].url))) || null; // #702: alt when the source has none const byName = credit.find(a => urlOf(a) && (sameName(a.name, creditedAs) || sameName(a.artistName, creditedAs))); if (byName) return urlOf(byName); return credit.length === nSlots && credit[i] && urlOf(credit[i]) ? urlOf(credit[i]) : null; } const fcPlatformUrl = (t, i, creditedAs) => { const ht = fcTrackFor(t); return ht ? fcCreditUrl(ht.credit, i, creditedAs, t.slots ? t.slots.length : (t.names || []).length) : null; }; // every platform link the handoff has for the pending slots, resolved in a few batched requests // before the match pass, so each slot's own lookup is answered from the cache const fcArtistUrls = h => { const out = [], add = a => { if (a && a.url) out.push(a.url, ...(_fcSame.get(a.url) || [])); }; (h.credit || []).forEach(add); (h.mediums || []).forEach(m => (m.tracks || []).forEach(t => (t.credit || []).forEach(add))); return [...new Set(out)]; }; async function prewarmPlatformLinks(tracks) { if (!fcHandoff()) return; const urls = []; tracks.forEach(t => t.slots.forEach((s, i) => { const u = fcPlatformUrl(t, i, s.creditedAs); if (u) urls.push(u, ...(_fcSame.get(u) || [])); })); if (!urls.length) { Log.cat('Links').debug('the handoff has none for these slots'); return; } const t0 = Date.now(), res = await resolvePlatformUrls(urls), uniq = [...res.keys()]; const owned = uniq.filter(u => res.get(u) && res.get(u).length).length, failed = uniq.filter(u => res.get(u) === null).length; Log.cat('Links').info(uniq.length, 'distinct artist link(s) from', _fcHandoff.sourceName || 'First Contact', '—', owned, 'owned by an MB artist,', uniq.length - owned - failed, 'not in MusicBrainz' + (failed ? ', ' + failed + ' lookup(s) failed' : ''), '(' + (Date.now() - t0) + ' ms)'); } // 🔗 / ⚠ for a slot's platform link, as for Discogs (#227): a matched artist without the link // can have it added (⚠ when another artist owns it); an unmatched slot whose link nobody owns // can create its artist with it. async function tagPlatformAddable(slot, url) { slot._platUrl = url || null; slot._platAddable = false; slot._platConflict = null; if (!url) return; if (slot.gid && SPECIAL_PURPOSE_ARTISTS.has(slot.gid)) return; // never link a special-purpose artist (#306) const hits = await resolveByPlatformUrl(url); if (hits === null) return; // unknown: offer nothing rather than a wrong offer if (slot.committed && slot.gid) { if (hits.some(h => h.gid === slot.gid)) return; // the artist already has it slot._platAddable = true; if (hits.length) slot._platConflict = hits[0]; return; } slot._platAddable = hits.length === 0; if (hits.length) slot._platConflict = hits[0]; } let _tagPlatRunning = false, _tagPlatQueued = false; async function tagPlatformForAll() { if (!MODEL || !fcHandoff()) { if (_riArt.length) riRetag(); return; } if (_tagPlatRunning) { _tagPlatQueued = true; return; } _tagPlatRunning = true; try { const jobs = []; MODEL.tracks.forEach(t => t.slots.forEach((s, i) => { const u = fcPlatformUrl(t, i, s.creditedAs); if (u || s._platUrl) jobs.push([s, u]); })); await resolvePlatformUrls(jobs.map(j => j[1])); for (const [s, u] of jobs) await tagPlatformAddable(s, u); const linked = s => !!(s.committed && s.gid); // an uncommitted candidate isn't the slot's artist yet const add = jobs.filter(([s]) => s._platAddable && linked(s)).length, create = jobs.filter(([s]) => s._platAddable && !linked(s)).length, conf = jobs.filter(([s]) => s._platConflict).length; const outcome = `${jobs.length} slot(s) with a platform link: ${add} link(s) can be added, ${create} artist(s) can be created with theirs, ${conf} owned by another artist`; if (outcome !== _lastPlatCheck) { _lastPlatCheck = outcome; Log.cat('Links').info(outcome); } if (!isEditingNow()) rerender(); } finally { _tagPlatRunning = false; if (_tagPlatQueued) { _tagPlatQueued = false; tagPlatformForAll(); } else if (_riArt.length) riRetag(); // #652: the release artist's offers too } } let _lastPlatCheck = ''; function platAddTooltip(s) { const p = platformOf(s._platUrl) || { name: 'platform' }, conf = s._platConflict; if (!(s.committed && s.gid)) return `Create this artist with its ${p.name} link (${s._platUrl})`; return conf ? `${p.name}: ${s._platUrl} is linked to ${conf.name}, not to this artist — click to add it here anyway` : `${p.name}: this artist doesn't have ${s._platUrl} yet — click to add it`; } // add the slot's platform link to its artist (an edit of the artist), or create the artist with it function addOrCreatePlatformLink(slot, background) { const url = slot._platUrl; if (!url) return; // an unmatched slot, or one with only an uncertain candidate: create the credited artist with the link if (!(slot.committed && slot.gid)) { createArtist((slot.creditedAs || '').trim() || slot.name || '', slot, slot._discogsUrl || null, background); return; } const p = platformOf(url) || { name: 'platform' }, gid = slot.gid; const q = { 'edit-artist.url.0.text': url, 'edit-artist.edit_note': entityActionNote('Added ' + p.name + ' link') }; if (p.artistLinkType) q['edit-artist.url.0.link_type_id'] = p.artistLinkType; const editUrl = `${ORIGIN}/artist/${gid}/edit?${new URLSearchParams(q)}`; const done = async () => { forgetPlatformUrl(url); let hits = null; for (let attempt = 0; attempt < 5; attempt++) { // read-after-write: the new link can take a moment to show (#479) hits = await resolveByPlatformUrl(url); if (hits && hits.some(h => h.gid === gid)) break; forgetPlatformUrl(url); await _sleep(800 * (attempt + 1)); } if (hits && hits.some(h => h.gid === gid)) { MODEL && MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.gid === gid) s._flash = true; })); discMsg(`added ${p.name} link to ${slot.name || 'the artist'}`); } else Log.cat('Links').warn(p.name, 'link for', slot.name || gid, 'not visible yet; it stays offered until the next check'); await tagPlatformForAll(); }; if (background && typeof GM_openInTab === 'function' && ART_CHANNEL) { const bgTab = GM_openInTab(`${editUrl}#tc-autocommit`, { active: false, insert: true }); const onCommitted = e => { if (!e.data || e.data.type !== 'tc-edit-committed' || e.data.gid !== gid) return; ART_CHANNEL.removeEventListener('message', onCommitted); try { if (bgTab && typeof bgTab.close === 'function') bgTab.close(); } catch (x) {} done(); }; ART_CHANNEL.addEventListener('message', onCommitted); Log.cat('Links').info(p.name, 'link (background) for', slot.name || gid, '→', url); return; } const tab = W.open(editUrl, '_blank'); if (tab) { const trySet = () => { try { tab.sessionStorage.setItem(CLOSE_KEY, '1'); } catch (e) { setTimeout(trySet, 50); } }; trySet(); } Log.cat('Links').info(p.name, 'link: opening the edit of', slot.name || gid, '→', url); const onReturn = async () => { if (document.visibilityState !== 'visible') return; document.removeEventListener('visibilitychange', onReturn); await done(); }; document.addEventListener('visibilitychange', onReturn); } async function artistDiscogsUrls(gid, force) { if (!gid) return []; if (!force && _artistRelsCache.has(gid)) return _artistRelsCache.get(gid); let out = null; for (let attempt = 0; attempt < 4; attempt++) { try { const r = await mbWebFetch(`${ORIGIN}/ws/js/entity/${gid}?inc=rels`, { headers: { Accept: 'application/json' } }); if (r.status === 429 || r.status === 503) { await _sleep(800); continue; } // rate limited → back off, don't cache a miss if (!r.ok) { await _sleep(500); continue; } const j = await r.json(); out = (j.relationships || j.relations || []) .filter(x => x.linkTypeID === DISCOGS_LINK_TYPE_ID && (x.target_type === 'url' || (x.target && x.target.entityType === 'url'))) .map(x => (x.target && (x.target.name || x.target.decoded || x.target.pretty_name)) || ''); break; } catch (e) { await _sleep(500); } } if (out !== null) { _artistRelsCache.set(gid, out); _dput('rels', gid, out); } // cache only a real response (a rate-limited miss stays uncached → retried) return out; } async function tagDiscogsAddable(slot, durl) { slot._discogsUrl = durl || null; slot._discogsConflict = null; slot._discogsMismatch = null; slot._discogsPending = false; slot._discogsChecked = false; if (!durl || SETTINGS.discogsUrlMatch === false) { slot._discogsAddable = false; return; } // #281: only a RESOLVED (committed) artist can meaningfully have/lack a Discogs // link — an unresolved slot has no MB artist to link, and on add-release that // littered every row with chains/warnings. Skip those (the + create button still // seeds slot._discogsUrl, so create-with-link is unaffected). Keep _discogsUrl set. if (!slot.committed || !slot.gid) { slot._discogsAddable = false; return; } // #306 — special-purpose artists ([no artist], Various Artists, [Disney], …) must // never carry a Discogs link (some can't even be edited), so never offer to add one. if (SPECIAL_PURPOSE_ARTISTS.has(slot.gid)) { slot._discogsAddable = false; return; } slot._discogsChecked = true; if (slot.gid) { // FREE check first: this artist's own Discogs links (internal endpoint) const own = await artistDiscogsUrls(slot.gid); if (own === null) { slot._discogsAddable = false; slot._discogsPending = true; return; } // unknown const want = discogsIdOf(durl); if (own.some(u => discogsIdOf(u) === want)) { slot._discogsAddable = false; return; } // already linked to THIS Discogs page → legit, no badge if (own.length) { // #227: artist links a DIFFERENT Discogs page than the release credits → mismatch. slot._discogsAddable = true; // still offer to add the release's link (majkinetor) — flagged with ⚠ slot._discogsMismatch = own.find(u => discogsIdOf(u)) || own[0]; return; } // /ws/js found no Discogs link — confirm with the AUTHORITATIVE URL→artist // lookup before declaring it missing. This also catches a false-empty // /ws/js response (the artist actually owns the URL) so we never offer to // add a link it already has (#227). const hits = await resolveByDiscogsUrl(durl); if (hits === null) { slot._discogsAddable = false; slot._discogsPending = true; return; } if (hits.some(h => h.gid === slot.gid)) { slot._discogsAddable = false; return; } // this artist already owns the URL → legit slot._discogsAddable = true; if (hits.length) slot._discogsConflict = hits[0]; // URL owned by a different artist → add anyway, but warn return; } // unresolved slot — need the URL→artist lookup to decide create vs. pick const hits = await resolveByDiscogsUrl(durl); if (hits === null) { slot._discogsAddable = false; slot._discogsPending = true; return; } slot._discogsAddable = hits.length === 0; if (hits.length) slot._discogsConflict = hits[0]; } // Tag every slot in the model — covers page-load 'set' artists, which skip the // match pass (#227). Updates each row as it resolves (real-time) and shows // progress in the always-visible toolbar status. Cached resolves are instant. // The release's Discogs link can land in the DOM a beat after we're invoked — // it lives on the Release-Information tab and switching to Tracklist first // races its render. Poll briefly (silently — blank badge, not "checking…") so // the check still runs on the FIRST visit instead of bailing to nothing. A // release that genuinely has no Discogs link just polls out and stays blank. async function discogsReleaseUrlSoon(maxMs = 8000) { const t0 = Date.now(); for (;;) { const u = discogsReleaseUrlFromPage(); if (u || Date.now() - t0 >= maxMs) return u || null; await _sleep(400); } } let _tagDiscogsRunning = false, _tagDiscogsQueued = false; let _discMapFailed = false, _discMapRetried = false; // #281: Discogs API unreachable → retry affordance async function tagDiscogsForAll() { if (!MODEL || SETTINGS.discogsUrlMatch === false) return; // #281: MB lazy-loads the rest of the tracks a beat after we start, which rebuilds // the model (fresh slots) and calls us again mid-run. The old guard DROPPED that // call, so the rebuilt model was never checked and the badge went blank until a // reload. Queue the call and re-run against the latest model when this run ends. if (_tagDiscogsRunning) { _tagDiscogsQueued = true; return; } _tagDiscogsRunning = true; try { // wait for the release Discogs link to exist before deciding there's nothing // to do — this is the #1 reason a first visit showed a blank badge. const relUrl = await discogsReleaseUrlSoon(); if (!relUrl) { _discMapFailed = false; setDiscProgress(''); return; } // genuinely no Discogs link on this release setDiscProgress('checking Discogs links…'); // now that we know there's work, show it const dmap = await loadDiscogsMap(); if (dmap === null) { // #281: couldn't reach the Discogs API (rate-limited / network) after retries. // DON'T show a misleading blank (which looked like "no links, all good") — flag // it so the badge offers a one-click retry, and auto-retry once shortly so it // usually self-heals without the user having to reload the page. _discMapFailed = true; if (!_discMapRetried) { _discMapRetried = true; setTimeout(() => { if (_discMapFailed) tagDiscogsForAll(); }, 6000); } return; } _discMapFailed = false; _discMapRetried = false; const jobs = []; const relArtists = (dmap && dmap.releaseArtists) || []; let posUsed = 0; const total = MODEL.tracks.length; if (dmap) MODEL.tracks.forEach((t, ti) => { const { urls, byPos } = discogsUrlsForTrack(dmap, t.title, ti, total); const durls = urls || []; const hasTrackArtists = durls.some(Boolean); if (byPos && hasTrackArtists) posUsed++; t.slots.forEach((s, i) => { // per-track Discogs artist (by title, else by position), else inherit the // release-level artist POSITIONALLY (#283/#287). Strictly relArtists[i]: a // single release artist only fills slot 0, NOT a track's feat. guests — // assigning the release artist to a guest slot caused false mismatches (#287). const durl = durls[i] || discogsFeatUrlFor(dmap, t.title, ti, total, s.creditedAs) || (!hasTrackArtists ? (relArtists[i] || null) : null); // #442 feat credit if (durl) { s._discByPos = !!(durls[i] && byPos); jobs.push([s, durl]); } }); }); if (!jobs.length) { setDiscProgress(''); // nothing to check (no per-track and no release-level artist links). Empty // model = still loading → stay silent; dedupe so re-runs don't repeat it. if (MODEL.tracks && MODEL.tracks.length && _lastDiscCheck !== 'none') { _lastDiscCheck = 'none'; Log.cat('Links').info('no Discogs artist links to verify'); } return; } const firstCheck = _discVerifyUrl !== relUrl; if (firstCheck) { _discVerifyUrl = relUrl; Log.cat('Links').info('verifying', jobs.length, 'Discogs artist link(s) across', total, total === 1 ? 'track' : 'tracks', (posUsed ? `(${posUsed} matched by position)` : '') + '…'); } let done = 0, lastRender = 0; for (const [s, durl] of jobs) { await tagDiscogsAddable(s, durl); if (firstCheck) Log.cat('Links').debug('Discogs:', (s.name || s.gid || 'slot'), '—', s._discogsPending ? 'pending (will re-check)' : !s._discogsAddable ? 'already linked' : discAddTooltip(s), s._discByPos ? '(matched by position)' : ''); done++; // update rows + the progress text together, throttled — set the text AFTER // rerender so refreshStatus can't blank it const now = Date.now(); if (now - lastRender > 300) { if (!isEditingNow()) rerender(); setDiscProgress(`checking Discogs links ${done}/${jobs.length}…`); lastRender = now; } } // A slot whose lookup returned null (cold cache / a transient rate-limit) is // left "pending" and never shows in the badge — that's why the FIRST visit // could read "0 links" while the SECOND (warm persistent cache) suddenly // revealed them. Retry the pending slots once now that the per-request gate // has paced out and the caches are partly warm, so the count settles on the // first visit. Anything still pending is surfaced by the badge (not hidden). const pend = jobs.filter(([s]) => s._discogsPending); if (pend.length) { let r = 0; for (const [s, durl] of pend) { setDiscProgress(`re-checking Discogs links ${++r}/${pend.length}…`); await tagDiscogsAddable(s, durl); } } const addable = jobs.filter(([s]) => s._discogsAddable).length; const pendLeft = jobs.filter(([s]) => s._discogsPending).length; const outcome = (addable === 0 ? `all ${jobs.length} artist link(s) already in MusicBrainz ✓` : `${addable} of ${jobs.length} link(s) can be added to MusicBrainz`) + (pendLeft ? ` (${pendLeft} pending)` : ''); if (outcome !== _lastDiscCheck) { _lastDiscCheck = outcome; Log.cat('Links').info('Discogs:', outcome); } // dedupe identical re-run results if (!isEditingNow()) rerender(); } finally { _tagDiscogsRunning = false; // #281: if a rebuild raced us, re-run for the latest model (it repaints the // badge); otherwise settle the persistent badge now. (#227) if (_tagDiscogsQueued) { _tagDiscogsQueued = false; tagDiscogsForAll(); } else setDiscStat(); } } // chain glyph shown in place of the artist-type icon when a Discogs link can be added const DISCOGS_LINK_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'; // warning triangle shown when the Discogs URL links a DIFFERENT MB artist (#227) const DISCOGS_WARN_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'; // #—: a distinct glyph for a Discogs "mismatch" (artist links a different page) — a circle-! so it // reads apart from the "conflict" triangle at a glance (and not by colour alone). const DISCOGS_MISMATCH_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="12.5"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>'; // after the artist's Discogs link changes (foreground return or background // postback): drop the stale caches and re-tag every slot crediting that artist. // // #479 (majkinetor: "it does change them, although probably due to rate limit // it fails to always do so"): a single artistDiscogsUrls() call right after the // edit landed can genuinely come back empty — not from a 429 (this hits MB's // internal /ws/js/entity endpoint, which the request itself already retries on // rate-limit), but from read-after-write lag: the edit was JUST committed, and // the internal endpoint's data can take a beat to catch up. artistDiscogsUrls // caches ANY successful response, including that stale empty one, so it and // every slot that reuses the cache (the tagDiscogsForAll() call right below) // then stay wrong until something else happens to drop the cache — the button // never flips to normal. Retry with force=true (bypassing the cache each time) // for a few seconds, since — unlike the general "does this artist have a // Discogs link at all" check elsewhere — we know FOR CERTAIN a write just // happened, so it's worth waiting for it to show up rather than accepting the // first answer. async function reTagAfterDiscogsLink(gid, url, name) { _discogsResolveCache.delete(url); _ddrop('resolve', url); const wantId = discogsIdOf(url); let own = null; for (let attempt = 0; attempt < 6; attempt++) { own = await artistDiscogsUrls(gid, true); if (own && own.some(u => discogsIdOf(u) === wantId)) break; if (attempt < 5) await _sleep(800 * (attempt + 1)); } if (own && own.some(u => discogsIdOf(u) === wantId)) { MODEL && MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.gid === gid) s._flash = true; })); discMsg(`added Discogs link to ${name}`); } else Log.cat('Links').warn('Discogs link for', name || gid, 'still not visible via /ws/js/entity after retrying — will keep showing as addable until the next check'); await tagDiscogsForAll(); if (_riArt.length) await riRetag(); // #652 } function addOrCreateDiscogsLink(slot, background) { const url = slot._discogsUrl; if (!url) return; if (slot.gid) { const gid = slot.gid; const p = new URLSearchParams({ 'edit-artist.url.0.text': url, 'edit-artist.url.0.link_type_id': DISCOGS_ARTIST_LINK_TYPE, 'edit-artist.edit_note': entityActionNote('Added Discogs link') }); const editUrl = `${ORIGIN}/artist/${gid}/edit?${p}`; // #273: right-click → add the link SILENTLY in a background tab + auto-submit. // The edit-page bootstrap clicks "Enter edit"; the saved entity page posts // `tc-edit-committed` back, and we re-tag + close the GM tab here (no focus // return happens for a background tab). if (background && typeof GM_openInTab === 'function' && ART_CHANNEL) { const bgTab = GM_openInTab(`${editUrl}#tc-autocommit`, { active: false, insert: true }); const onCommitted = (e) => { if (!e.data || e.data.type !== 'tc-edit-committed' || e.data.gid !== gid) return; ART_CHANNEL.removeEventListener('message', onCommitted); try { if (bgTab && typeof bgTab.close === 'function') bgTab.close(); } catch (x) {} reTagAfterDiscogsLink(gid, url, slot.name); }; ART_CHANNEL.addEventListener('message', onCommitted); Log.cat('Links').info('Discogs link (background) for', slot.name || gid, '→', url); return; } // foreground: open the edit form, flag it to auto-close after submit, verify on return const tab = W.open(editUrl, '_blank'); if (tab) { const trySet = () => { try { tab.sessionStorage.setItem(CLOSE_KEY, '1'); } catch (e) { setTimeout(trySet, 50); } }; trySet(); } Log.cat('Links').info('Discogs link: opening edit for', slot.name || gid, '→', url); const onReturn = async () => { if (document.visibilityState !== 'visible') return; document.removeEventListener('visibilitychange', onReturn); await reTagAfterDiscogsLink(gid, url, slot.name); }; document.addEventListener('visibilitychange', onReturn); } else { // no MB artist yet — create one seeded with the link (same as +) createArtist((slot.creditedAs || '').trim() || slot.name || '', slot, url, background); } } // #437 — the gids of artists already known on THIS release: a slot's own split // co-artists (strongest) + the release artist(s). These seed the credit-based // disambiguation below. Capped so a wide "Various Artists" release can't fan out. function releaseArtistGids() { try { return acArtistGids(u(release().artistCredit)); } catch (e) { return []; } } // #618: a special-purpose artist (Various Artists, [unknown], [traditional]…) is never a // co-credit context — no track is credited "alongside Various Artists", so a search seeded // with it can only cost a paced request per ambiguous name for nothing. Dropped BEFORE the // cap, so it can't take one of the six places either. const coCreditCtx = g => !!g && !SPECIAL_PURPOSE_ARTISTS.has(g); function slotContextGids(entry, idx) { const gids = []; if (entry && entry.slots) entry.slots.forEach((s, j) => { if (j !== idx && s && s.gid) gids.push(s.gid); }); releaseArtistGids().forEach(g => gids.push(g)); return [...new Set(gids)].filter(coCreditCtx).slice(0, 6); } // #437 — disambiguate a common name ("Joni") by CO-OCCURRENCE. A bare name search // is hopeless for a non-unique name, but if the release already credits a known // artist A (a split co-artist, or the release artist), one indexed search asks MB // for a recording that credits A *alongside* an artist credited as <name> — that // co-credited artist is a far more confident hit than a name search (majkinetor: // rank above 'search'). ONLY an exact credited-as / name hit counts; fuzz would // just reintroduce the ambiguity. Ranked by how many distinct context artists // produce each candidate; a lone / clear winner auto-matches, a tie is surfaced. const _credCache = new Map(); // `${arid}\n${fold(name)}` → [{gid,name}] const _lucenePhrase = s => '"' + String(s).replace(/(["\\])/g, '\\$1') + '"'; async function resolveByCredit(creditedAs, contextGids) { const name = (creditedAs || '').trim(); if (contextGids && contextGids.some(g => g && !coCreditCtx(g))) { // #618 belt and braces — whatever a caller passes Log.cat('Artist').debug('cred lookup: skipping special-purpose context artist(s)', contextGids.filter(g => g && !coCreditCtx(g)).join(', ')); contextGids = contextGids.filter(coCreditCtx); } if (!name || !contextGids || !contextGids.length) return { entity: null, candidates: [] }; const tally = new Map(); // candidate gid → { count, name } for (const ctx of contextGids) { const ck = ctx + '\n' + fold(name); let hits = _credCache.get(ck); if (!hits) { hits = []; const q = `arid:${ctx} AND artistname:${_lucenePhrase(name)}`; const res = await wsJson(`${ORIGIN}/ws/2/recording?query=${encodeURIComponent(q)}&inc=artist-credits&limit=25&fmt=json`, { label: 'cred lookup' }); if (!res.json) { Log.cat('Artist').warn('cred lookup failed for', JSON.stringify(name), 'in', ctx, '— not cached'); continue; } // a throttled miss must not be cached (#555) for (const rec of (res.json.recordings || [])) { for (const c of (rec['artist-credit'] || [])) { const a = c.artist; if (!a || !a.id || a.id === ctx) continue; // the co-credited artist, not the context one if (sameName(c.name, name) || sameName(a.name, name)) hits.push({ gid: a.id, name: a.name }); } } _credCache.set(ck, hits); } // one vote per distinct context artist (dedupe repeats within its own hits) new Set(hits.map(h => h.gid)).forEach(gid => { const nm = (hits.find(h => h.gid === gid) || {}).name; const t = tally.get(gid) || { count: 0, name: nm }; t.count++; tally.set(gid, t); }); } if (!tally.size) return { entity: null, candidates: [] }; const ranked = [...tally.entries()].sort((a, b) => b[1].count - a[1].count); const unique = ranked.length === 1 || ranked[0][1].count > ranked[1][1].count; // clear winner? const candidates = []; for (const [gid] of ranked) { const e = await fetchEntity(gid); if (e && e.gid) candidates.push(e); } return { entity: unique ? candidates[0] : null, candidates, counts: ranked.map(([gid, v]) => ({ gid, name: v.name, count: v.count })), ctx: contextGids.length }; // counts/ctx: #654 tooltip } // #441 — pick the RG-sibling artist for a slot by NAME, not blind index. Editions // differ in how many artists they credit (a title that omits a co-feature — "feat. // Nile Rodgers" where the full credit is "feat. John Newman & Nile Rodgers" — has // fewer slots), so sib[i] by position grabbed the wrong artist. Prefer the positional // one only when its name/credited-as actually agrees; else find the sibling artist // whose name/credited-as matches this slot's credited name; else none (fall through). function pickSibArtist(sibArr, creditedAs, idx) { if (!Array.isArray(sibArr) || !sibArr.length) return null; const agrees = s => s && s.gid && (sameName(s.creditedAs, creditedAs) || sameName(s.name, creditedAs)); const at = sibArr[idx]; if (agrees(at)) return at; return sibArr.find(agrees) || null; } /* ── #626: the artist credited at this track's position on other editions ────── The Recordings pass links a track by the recording at its position on the release group's editions and on the duplicates (#440). The artist credited on that same track is just as good evidence for the track ARTIST, and it catches what the name lookups can't: a spelling variant ("The Revolution of St.Vincent" → "…of St. Vincent", "Juan Formel" → "Juan Formell", "Cedric Im Brooks" → "Cedric “Im” Brooks") and, with no release group linked, a name several MB artists share ("Duke", "Sambo", "Ophelia"). A position counts only when its track passes the Recordings test — a similar title and a length within tolerance (an exact title when either length is unknown) — and then the NAME only has to match loosely, because the position has already narrowed the choice to the one to three artists credited on that track. Loose against all of MusicBrainz would be reckless; loose among three is safe. */ const posNameKey = s => fold(s).replace(/&/g, ' and ').replace(/^the\s+/, '').replace(/[^\p{L}\p{N}]+/gu, ''); function posNameMatch(a, b) { const x = posNameKey(a), y = posNameKey(b); if (!x || !y) return false; return x === y || 1 - recLev(x, y) / Math.max(x.length, y.length) >= 0.85; } // position "<medium>.<track>" → the tracks there on other releases, with their credits. // Every release is kept (unlike the recordings' index, which keeps one entry per // recording), so "N of M editions" counts releases. function addReleaseToArtistPos(rel, idx, skipGid, from) { // from: 'rg' or 'dup' (#654: the card says which) if (!rel || !rel.id || rel.id === skipGid) return; // never the release being edited (rel.media || []).forEach(med => { const medLens = mediumLensOf(med); (med.tracks || []).forEach(t => { const ac = (t['artist-credit'] && t['artist-credit'].length) ? t['artist-credit'] : ((t.recording && t.recording['artist-credit']) || []); if (!ac.length) return; const key = (med.position || 1) + '.' + (t.position || 0); if (!idx.has(key)) idx.set(key, []); idx.get(key).push({ rel: rel.id, from: from || 'rg', relTitle: rel.title || '', relDate: rel.date || '', format: med.format || '', title: t.title || (t.recording && t.recording.title) || '', length: t.length || (t.recording && t.recording.length) || null, ac, medLens }); }); }); } let _artPosRg = { gid: null, self: null, idx: null }; let _artPosDup = { key: null, p: null }; let _artPosDupFromTab = false; // the duplicates came from the Duplicates tab, not our own search (the card says which) // the release group's editions (free: the rg source's own request) … async function artistPosRgIndex() { const rel = release(), self = rel ? u(rel.gid) || null : null; const rg = rel && u(rel.releaseGroup), rgGid = rg ? u(rg.gid) : null; if (!rgGid) return null; if (_artPosRg.gid === rgGid && _artPosRg.self === self && _artPosRg.idx) return _artPosRg.idx; const rels = await rgReleases(rgGid); if (!rels) return null; const idx = new Map(); rels.forEach(r => addReleaseToArtistPos(r, idx, self, 'rg')); _artPosRg = { gid: rgGid, self, idx }; Log.cat('Artist').debug('artist position index: release group', rgGid, '→', rels.length, 'edition(s),', idx.size, 'position(s)'); return idx; } // … and, only when those don't settle a credit, the duplicates: first the ones the // Duplicates tab already fetched for its Similarity column (free), else the same search // the Recordings pass makes (shared, so the two passes run it once between them). function artistPosDupIndex() { const rel = release(), self = rel ? u(rel.gid) || null : null; const rg = rel && u(rel.releaseGroup), rgGid = rg ? u(rg.gid) : null; const title = rel ? (u(rel.name) || '') : ''; const artistGid = (acArtistGids(u(rel && rel.artistCredit)) || [])[0] || null; const key = [fold(title), artistGid, rgGid, self, _dupRawCache.size].join('|'); if (_artPosDup.key === key) return _artPosDup.p; const p = (async () => { let rels = [..._dupRawCache.values()], from = 'the Duplicates tab'; if (!rels.length) { rels = await duplicateReleases(title, artistGid, rgGid); from = 'a duplicate search'; } _artPosDupFromTab = from === "the Duplicates tab"; const idx = new Map(); rels.forEach(r => addReleaseToArtistPos(r, idx, self, 'dup')); Log.cat('Artist').debug('artist position index: duplicates from', from, '→', rels.length, 'release(s),', idx.size, 'position(s)'); return idx; })(); _artPosDup = { key, p }; return p; } // the artists at this track's position that match the credited name → { artists: [{ gid, // name, votes }], of } — `of` = how many other editions' tracks passed the test there function tallyPosArtists(hits, creditedAs) { const rels = new Set(hits.map(h => h.rel)), by = new Map(); hits.forEach(h => h.ac.forEach(a => { const g = a.artist && a.artist.id; if (!g || SPECIAL_PURPOSE_ARTISTS.has(g)) return; if (!posNameMatch(creditedAs, a.name) && !posNameMatch(creditedAs, a.artist.name)) return; const v = by.get(g) || { gid: g, name: a.artist.name || a.name, rels: new Set() }; v.rels.add(h.rel); by.set(g, v); })); // #654: each edition, and whom it credits there, for the match tooltip const seen = new Set(), editions = []; hits.forEach(h => { if (seen.has(h.rel)) return; seen.add(h.rel); editions.push({ gid: h.rel, from: h.from || 'rg', title: h.relTitle, date: h.relDate || '', format: h.format || '', names: h.ac.map(a => (a.artist && a.artist.name) || a.name), gids: h.ac.map(a => a.artist && a.artist.id).filter(Boolean) }); }); return { artists: [...by.values()].map(v => ({ gid: v.gid, name: v.name, votes: v.rels.size })).sort((a, b) => b.votes - a.votes), of: rels.size, editions }; } async function positionArtists(entry, creditedAs) { if (!entry || !(creditedAs || '').trim()) return null; let key, len; try { const ko = koTrack(entry.mi, entry.ti); key = (u(mediums()[entry.mi].position) || (entry.mi + 1)) + '.' + (u(ko.position) || (entry.ti + 1)); len = u(ko.length) || null; } catch (e) { return null; } const agrees = c => (recSimilar(c.title, entry.title) && (c.length && len ? recLenGap(c.length, len) === 0 : recFold(c.title) === recFold(entry.title))) || (scriptsDiffer(c.title, entry.title) && mediumAligned(c.medLens, entry.mi)); // one title in another script (#626) const at = idx => ((idx && idx.get(key)) || []).filter(agrees); let hits = at(await artistPosRgIndex()); let res = tallyPosArtists(hits, creditedAs); if (res.artists.length !== 1) { hits = hits.concat(at(await artistPosDupIndex())); res = tallyPosArtists(hits, creditedAs); } Log.cat('Artist').debug('position source:', JSON.stringify(creditedAs), 'at', key, '—', res.of, 'edition(s) agree on the track;', res.artists.length ? res.artists.map(a => a.name + ' ×' + a.votes).join(', ') : 'none credit a matching name'); return res.artists.length ? res : null; } // `pos` (#626): a function returning positionArtists() for this slot — called only once the // Discogs link and the rg source have had their turn, so a slot they settle costs nothing. /* #654 (majkinetor: a richer tooltip "that contains not only description but also more match details visible in the log"): every match result carries `why`, what each stage it reached found — the Discogs link, the release-group sibling (which release and track), the other editions at this position, the name/alias search, the co-credits — and when. The slot keeps it as _why and the badge's card shows it. Nothing extra is fetched for it. */ /* A pass over a tracklist where one artist sits on every track logged the same "Lou Rawls → Lou Rawls — via Qobuz link …" line once per track, with nothing to tell the lines apart (majkinetor). Each match line now says which track it is, and while a pass runs (_matchSeen) only the first track a name resolves a given way logs it at info; the rest go to debug and endMatchLog() sums them up in one line. A match that isn't a track's (the release artist, which can resolve while a pass runs) always logs, as does one outside a pass (a revert). */ let _matchSeen = null; // Map: the line's text → the tracks it came from, while a pass runs function matchLog(where, ...args) { const L = Log.cat('Artist'), head = where ? [where + ':'] : []; if (_matchSeen && /^track /.test(where || '')) { const key = args.join(' '), seen = _matchSeen.get(key); if (seen) { seen.push(where); L.debug(...head, ...args); return; } _matchSeen.set(key, [where]); } L.info(...head, ...args); } function startMatchLog() { _matchSeen = new Map(); } function endMatchLog() { const seen = _matchSeen; _matchSeen = null; if (seen) seen.forEach((wh, key) => { if (wh.length < 2) return; const more = wh.slice(1).map(w => w.replace(/^track /, '')).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); Log.cat('Artist').info(key, '— also on track' + (more.length === 1 ? '' : 's'), more.join(', ')); }); } const trackWhere = t => 'track ' + (t.number || (t.ti + 1)); async function matchSlot(creditedAs, sib, discogsUrl, contextGids, pos, platUrl, where) { const why = { at: Date.now() }; const m = await matchSlotCore(creditedAs, sib, discogsUrl, contextGids, pos, why, platUrl, where); m.why = why; return m; } async function matchSlotCore(creditedAs, sib, discogsUrl, contextGids, pos, why, platUrl, where) { const who = creditedAs || '(track artist)'; let posInfo = null; // { of, artists: [{ entity, votes }] } — kept on the slot for the picker's section // #224: a Discogs artist-link match outranks the name search. if (SETTINGS.discogsUrlMatch !== false && discogsUrl) { const hits = await resolveByDiscogsUrl(discogsUrl); why.discogs = { url: discogsUrl, n: hits ? hits.length : null }; if (hits && hits.length === 1) { const e = await fetchEntity(hits[0].gid); if (e && e.gid) { matchLog(where, who, '→', e.name, '— via Discogs URL'); return { entity: e, source: 'discogs', confidence: 'high', candidates: [e] }; } } else if (hits && hits.length > 1) { // ambiguous — surface every linked artist plus the name-search hits and let the user pick const named = await searchArtist(creditedAs); const ents = []; for (const h of hits) { const e = await fetchEntity(h.gid); if (e && e.gid) ents.push(e); } const merged = [...ents, ...named.filter(c => !ents.some(e => e.gid === (c.gid || c.id)))]; if (ents.length) { matchLog(where, who, '—', ents.length, 'MB artists link that Discogs URL; pick one'); return { entity: ents[0], source: 'discogs', confidence: 'low', candidates: merged }; } } // couldn't resolve via the URL → fall back to name search. 0 hits = the // Discogs URL the release credits isn't linked to any MB artist (e.g. the // mismatch case); null = the lookup was rate-limited. if (hits && hits.length === 0) Log.cat('Artist').debug(who, '— Discogs URL not linked to any MB artist → name search'); else if (hits == null) Log.cat('Artist').debug(who, '— Discogs URL lookup unavailable (rate-limited) → name search'); } // #651: the artist's page on the platform First Contact imported from — as certain as Discogs // #702: then the same artist's links on the other platforms Mission Control read, while none matched for (const tryUrl of platUrl ? [platUrl, ...(_fcSame.get(platUrl) || [])] : []) { const platUrl = tryUrl; const p = platformOf(platUrl) || { abbr: 'link', name: 'platform' }; const hits = await resolveByPlatformUrl(platUrl); if (!why.plat || (hits && hits.length)) why.plat = { url: platUrl, abbr: p.abbr, name: p.name, n: hits ? hits.length : null }; if (hits && hits.length === 1) { const e = await fetchEntity(hits[0].gid); if (e && e.gid) { matchLog(where, who, '→', e.name, '— via', p.name, 'link', platUrl); return { entity: e, source: 'plat', confidence: 'high', candidates: [e] }; } } else if (hits && hits.length > 1) { const named = await searchArtist(creditedAs); const ents = []; for (const h of hits) { const e = await fetchEntity(h.gid); if (e && e.gid) ents.push(e); } const merged = [...ents, ...named.filter(c => !ents.some(e => e.gid === (c.gid || c.id)))]; if (ents.length) { matchLog(where, who, '—', ents.length, 'MB artists link', p.name, platUrl + '; pick one'); return { entity: ents[0], source: 'plat', confidence: 'low', candidates: merged }; } } if (hits && hits.length === 0) Log.cat('Artist').debug(who, '—', p.name, 'link', platUrl, 'not in MusicBrainz → the next stages'); else if (hits == null) Log.cat('Artist').debug(who, '—', p.name, 'link lookup unavailable → the next stages'); } let candidates = await searchArtist(creditedAs); let entity = null, source = 'search', confidence = 'low'; if (sib && sib.gid) { // the sibling release names the EXACT artist (gid) — use it. Prefer a search hit (richer data), but // if the gid isn't in the results (ambiguous/duplicate name like "Eva", or a case-only difference // vs the recording artist), resolve the gid directly so the RG match never gets lost. let hit = candidates.find(c => c.gid === sib.gid) || (await fetchEntity(sib.gid)); if (hit && hit.gid) { entity = hit; source = 'rg'; confidence = 'high'; } } why.rg = sib && sib.gid ? { src: sib.src || null, creditedAs: sib.creditedAs || '', found: !!entity } : null; // #626: the artist at this track's position on other editions. One artist → confident; // several (the editions disagree) → none is committed, they head the picker instead. const posRes = !entity && typeof pos === 'function' ? await pos() : null; if (!entity && typeof pos === 'function') why.pos = posRes ? { of: posRes.of, artists: posRes.artists, editions: posRes.editions || [] } : { of: 0, artists: [], editions: [] }; if (posRes && posRes.artists.length) { const ents = []; for (const a of posRes.artists) { const e = candidates.find(c => (c.gid || c.id) === a.gid) || (await fetchEntity(a.gid)); if (e && e.gid) ents.push({ entity: e, votes: a.votes }); } if (ents.length) posInfo = { of: posRes.of, artists: ents, editions: posRes.editions || [] }; if (ents.length === 1) { const e = ents[0].entity; matchLog(where, who, '→', e.name, `— via the same position on ${ents[0].votes} of ${posRes.of} other edition${posRes.of === 1 ? '' : 's'}`); return { entity: e, source: 'pos', confidence: 'high', candidates: [e, ...candidates.filter(c => (c.gid || c.id) !== e.gid)], pos: posInfo }; } if (ents.length > 1) { matchLog(where, who, '— other editions credit', ents.length, 'different matching artists at this position (' + ents.map(x => x.entity.name + ' ×' + x.votes).join(', ') + '); pick one'); candidates = [...ents.map(x => x.entity), ...candidates.filter(c => !ents.some(x => x.entity.gid === (c.gid || c.id)))]; } } if (!entity) { let top = candidates[0] || null; // #445: UNIFIED exact-identity match. An alias is just an alternative name, so name and // alias resolve together with ONE unambiguity check — confident (auto-committed) only // when EXACTLY ONE MB artist carries the credited string as its name OR an alias. This // is alias-AWARE (mirroring MB's own duplicate/dedup check): a name that is ALSO another // artist's alias is now ambiguous → a candidate, not a confident auto-link (that was the // old alias-BLIND name check's error surface). Only the label differs — 'via name (exact)' // vs 'via exact alias'. It also resolves an exact name the fast /ws/js search under-ranked // below a look-alike (e.g. "Tee Vee" below "Tee-vee", #445) and an alias-only credit // (e.g. "Don Abi" → the artist named "Abiodun", #442), neither of which /ws/js can. const idHit = await resolveByExactAlias(creditedAs); // {entity, via} — unique name-or-alias, else null (ambiguous OR none) why.ident = aliasSeen(creditedAs) || { status: 'unknown' }; if (idHit && idHit.entity && idHit.entity.gid) { const e = idHit.entity, rest = candidates.filter(c => (c.gid || c.id) !== e.gid); matchLog(where, who, '→', e.name, idHit.via === 'alias' ? '— via exact alias' : '— via name (exact)'); return { entity: e, source: idHit.via === 'alias' ? 'alias' : 'search', confidence: 'high', candidates: [e, ...rest], pos: posInfo }; } // #437: no unique exact identity → try credit co-occurrence against the release's known // artists (the common-name case). A clear winner is confident; a tie seeds the picker. if (contextGids && contextGids.length) { const cred = await resolveByCredit(creditedAs, contextGids); why.cred = { counts: cred.counts || [], ctx: cred.ctx || 0 }; if (cred.entity) { matchLog(where, who, '→', cred.entity.name, '— via existing artist credits'); return { entity: cred.entity, source: 'cred', confidence: 'high', candidates: [cred.entity, ...candidates.filter(c => (c.gid || c.id) !== cred.entity.gid)], pos: posInfo }; } if (cred.candidates.length) { candidates = [...cred.candidates, ...candidates.filter(c => !cred.candidates.some(e => e.gid === (c.gid || c.id)))]; top = candidates[0]; } } if (!top) return { entity: null, source: 'none', confidence: 'none', candidates: [], pos: posInfo }; entity = top; confidence = 'low'; // no unique exact identity and no co-occurrence winner → user picks } return { entity, source, confidence, candidates: [entity, ...candidates.filter(c => c.gid !== entity.gid)], pos: posInfo }; } async function buildModel(onProgress) { const tl = readTracklist(); const siblings = await loadSiblingMap(); const dmap = await loadDiscogsMap(); const tracks = []; const todo = tl.filter(t => t.names.some(n => !n.artistGid)); let done = 0; startMatchLog(); for (let ti = 0; ti < tl.length; ti++) { const t = tl[ti]; const sib = siblings.get(fold(t.title)) || null; const durls = discogsUrlsForTrack(dmap, t.title, ti, tl.length).urls; // title, else by position (#283) const slots = []; for (let i = 0; i < t.names.length; i++) { const n = t.names[i]; if (n.artistGid) { slots.push({ creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status: 'set', entity: null, gid: n.artistGid, name: n.artistName, candidates: [], committed: true }); } else { const ctxGids = [...new Set(slots.map(s => s.gid).filter(Boolean).concat(releaseArtistGids()))].filter(coCreditCtx).slice(0, 6); // #437 co-artists so far + release artist(s), never special-purpose (#618) const dUrl = (durls && durls[i]) || discogsFeatUrlFor(dmap, t.title, ti, tl.length, n.creditedAs); // #442 fall back to the Discogs "Featuring" credit for a feat slot const m = await matchSlot(n.creditedAs, sib && pickSibArtist(sib, n.creditedAs, i), dUrl, ctxGids, undefined, fcCreditUrl((fcTrackFor(t) || {}).credit, i, n.creditedAs, t.names.length), trackWhere(t)); const status = slotStatusOf(m); const slot = { creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status, entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates, committed: false, _pos: m.pos || null, _why: m.why || null }; await tagDiscogsAddable(slot, dUrl); // #227 slots.push(slot); } } const te = { mi: t.mi, ti: t.ti, number: t.number, title: t.title, length: t.length, slots }; te.slots.forEach(s => { s._entry = te; }); te.guessTitle = guessTitleStr(te); tracks.push(te); if (t.names.some(n => !n.artistGid)) { done++; if (onProgress) onProgress(done, todo.length); } } endMatchLog(); return { tracks }; } /* ── live commit / reset / structural ops (no apply phase — every change writes through) ── */ // write a whole track's artist credit from its slots: committed slots use the picked entity, // uncommitted ones stay as unresolved credited text. // #483: guarded — #472 started watching track.artistCredit individually, so // every ordinary commit (matching, auto-commit, a picked artist) tripped the // "external change" watcher too, without this. function commitTrack(entry) { if (entry && entry.release) return riCommit(entry); // #652: the release artist cell const track = koTrack(entry.mi, entry.ti), live = liveNames(track); // #366 map each already-set slot to the live full entity by GID, NOT by position. Positional lookup // (live[i]) broke after a slot was removed/reordered: every later slot picked up the previous artist's // entity — resurrecting a just-removed artist under the next slot's credited-as and dropping the last. const liveByGid = new Map(); live.forEach(n => { const a = u(n.artist); const g = a && u(a.gid); if (g && !liveByGid.has(g)) liveByGid.set(g, a); }); _selfEdit = true; try { track.artistCredit({ names: entry.slots.map(s => { if (s.status === 'set') { const a = (s.gid && liveByGid.get(s.gid)) || s.entity || { name: s.name || s.creditedAs }; return { artist: a, name: s.creditedAs, joinPhrase: s.joinPhrase }; } if (s.committed && s.entity) return { artist: s.entity, name: s.creditedAs, joinPhrase: s.joinPhrase }; return { artist: { name: s.creditedAs }, name: s.creditedAs, joinPhrase: s.joinPhrase }; }) }); } finally { _selfEdit = false; } } // on load, immediately write the confident matches (RG/HIGH) — that's the "no apply phase" behaviour // #580: never commit over a slot the user has focused — that is the write that // used to replace a half-typed name with the matched artist. const autoCommittable = s => !s._editing && (s.status === 'rg' || s.status === 'high' || s.status === 'disc' || s.status === 'plat' || s.status === 'cred' || s.status === 'alias' || s.status === 'pos'); function autoCommitTrack(t) { let any = false; t.slots.forEach(s => { if (autoCommittable(s)) { s.committed = true; any = true; } }); if (any) commitTrack(t); } // build the table model WITHOUT matching (instant) — unresolved slots are flagged _pending function buildShell() { snapshotMissing(); // capture page-load state for any lazily-loaded medium before matching touches it // a rebuild re-reads the live model, where every linked artist looks identical — so without this we'd // collapse all match badges (rg / name / user) back to "set". Carry the match source forward by gid. // #626: per SLOT first — one artist is often matched differently on different tracks ("pos" on // the misspelled one, "rg" on the exact ones), and a gid-only key let the last one relabel them // all. The gid alone stays the fallback, for a slot that moved (track reordered / removed). const prevStatus = new Map(), slotKey = (mi, ti, j, gid) => mi + '.' + ti + '.' + j + '.' + gid; if (MODEL && MODEL.tracks) MODEL.tracks.forEach(t => t.slots.forEach((s, j) => { if (s.gid && s.committed && s.status && s.status !== 'set') { const v = { status: s.status, entity: s.entity, candidates: s.candidates, pos: s._pos || null, why: s._why || null, at: s._at || null }; prevStatus.set(slotKey(t.mi, t.ti, j, s.gid), v); prevStatus.set(s.gid, v); } })); const tracks = readTracklist().map(t => { const slots = t.names.map((n, j) => { if (!n.artistGid) return { creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status: 'none', entity: null, gid: null, name: '', candidates: [], committed: false, _pending: true }; const carry = prevStatus.get(slotKey(t.mi, t.ti, j, n.artistGid)) || prevStatus.get(n.artistGid); // preserve rg / name / user across the rebuild; genuine page-load links stay "set" return { creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status: carry ? carry.status : 'set', entity: carry ? carry.entity : null, gid: n.artistGid, name: n.artistName, candidates: carry ? (carry.candidates || []) : [], _pos: carry ? carry.pos : null, _why: carry ? carry.why : null, _at: carry ? carry.at : null, committed: true }; }); const te = { mi: t.mi, ti: t.ti, number: t.number, title: t.title, length: t.length, slots }; te.slots.forEach(s => { s._entry = te; }); te.guessTitle = guessTitleStr(te); return te; }); // #638: say what a rebuild kept, so a badge that went missing can be traced from the log. // A match badge (rg, pos, disc, alias, cred, name, a pick) is carried forward above; one that // turns into plain "set" for the same artist in the same slot was lost, and that is a bug worth a // report from the wild: a toast, once per page, offers the log. if (MODEL && MODEL.tracks) { const prev = MODEL.tracks.flatMap(t => t.slots.map((s, j) => (t.ti + 1) + '.' + (j + 1) + '=' + (s.status || '?') + (s.committed ? '' : '(uncommitted)') + (s._pending ? '(pending)' : ''))); const now = tracks.flatMap(t => t.slots.map((s, j) => (t.ti + 1) + '.' + (j + 1) + '=' + s.status)); Log.cat('System').debug('table rebuilt: before ' + prev.join(' ') + ' → after ' + now.join(' ')); const was = new Map(); MODEL.tracks.forEach(t => t.slots.forEach((s, j) => { if (s.gid && s.status && !['set', 'none', 'low'].includes(s.status)) was.set(t.mi + '.' + t.ti + '.' + j, s); })); const lost = []; tracks.forEach(t => t.slots.forEach((s, j) => { const p = was.get(t.mi + '.' + t.ti + '.' + j); if (p && s.status === 'set' && s.gid === p.gid) lost.push('track ' + (t.ti + 1) + ' "' + s.creditedAs + '" lost ' + p.status.toUpperCase() + (p.committed ? '' : ' (it was uncommitted)') + (p._pending ? ' (it was pending)' : '')); })); if (lost.length) { Log.cat('System').warn('a table rebuild lost ' + lost.length + ' match badge(s): ' + lost.join('; ')); if (!_badgeLossToasted) { _badgeLossToasted = true; mbuToast('⚠ Apollo lost ' + lost.length + ' match badge' + (lost.length === 1 ? '' : 's') + ' (#638). The links are fine — please copy the log to the issue.', { action: { label: 'Copy log', onClick: b => Log.copy(b) } }); } } } return { tracks }; } let _badgeLossToasted = false; // #638: the toast once per page; every loss is still logged // match the _pending slots, updating the table row-by-row as results come in async function matchModel(onProgress) { const isEditing = isEditingNow; // don't rebuild rows (and orphan the search popup) while the user is in a field // #577: a fresh pass starts un-stopped — but only if the OTHER pass is not // mid-stop. #575 round 4: the flag is shared, and whichever pass exited // first used to clear it, so a Stop pressed on the Recordings pass was // wiped by the tracklist pass finishing and the recordings loop carried // straight on. That is the "Recordings Match is unresponsive" report. if (!_autoMatching) _matchStop = false; setMatching(true); // hoisted out of the try so the finally can report how far it got (#577) let stopped = false, done = 0, planned = 0; startMatchLog(); try { const siblings = await loadSiblingMap(); const dmap = await loadDiscogsMap(); const total = MODEL.tracks.length; // ti is the track's position in the release, which discogsUrlsForTrack // needs (#283 falls back to matching by position), so carry it along // rather than re-deriving it from a filtered list. const todo = MODEL.tracks.map((t, ti) => ({ t, ti })).filter(x => x.t.slots.some(s => s._pending)); planned = todo.length; await prewarmPlatformLinks(todo.map(x => x.t)); // #651: First Contact's platform links, a few batched lookups up front /* #575 (majkinetor, "Matching takes too long"): this loop used to await each track before starting the next. The /ws/2 pacer allows four reads in flight — measured on a 14-track fixture against production, the pass never got past TWO, and 17 requests took 124 seconds: about 7 seconds per request, all of it spent waiting on MusicBrainz rather than on our own rate limit. A sequential caller turns a slow server day into a multi- minute wait, which is exactly what his 4-minute log shows. So: run the tracks through a small pool instead. This adds no delay and does not raise the REQUEST RATE — the pacer still releases one start per second, which is the published guidance — it only stops the lanes the pacer already allows from sitting idle. Slots within a track stay sequential, so a track's artists still commit together. */ const MATCH_LANES = 4; // == WS_MAX_INFLIGHT; the pacer is what bounds the rate let nextIdx = 0; const lane = async () => { for (;;) { if (_matchStop) { stopped = true; return; } // #577 const job = todo[nextIdx++]; if (!job) return; const { t, ti } = job; const sib = siblings.get(fold(t.title)) || null; const durls = discogsUrlsForTrack(dmap, t.title, ti, total).urls; // title, else by position (#283) for (let i = 0; i < t.slots.length; i++) { if (_matchStop) { stopped = true; break; } // #577: a track can hold several artists, each its own round trip const s = t.slots[i]; if (!s._pending) continue; if (s._editing) { Log.cat('Artist').debug('slot skipped — the user is editing it'); continue; } // #580: stays _pending, so leaving the field lets a later pass have it const dUrl = (durls && durls[i]) || discogsFeatUrlFor(dmap, t.title, ti, total, s.creditedAs); // #442 fall back to the Discogs "Featuring" credit for a feat slot const m = await matchSlot(s.creditedAs, sib && pickSibArtist(sib, s.creditedAs, i), dUrl, slotContextGids(t, i), () => positionArtists(t, s.creditedAs), fcPlatformUrl(t, i, s.creditedAs), trackWhere(t)); // #437, #626, #651 Object.assign(s, { status: slotStatusOf(m), entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates, _pos: m.pos || null, _why: m.why || null }); delete s._pending; await tagDiscogsAddable(s, dUrl); // #227 } autoCommitTrack(t); if (!isEditing()) rerender(); done++; /* #575: the counter used to be this loop's own index, so it read "5/14" with one row left on screen — the Discogs pass resolves slots too, and those tracks never showed up in it. Count what is actually still queued instead, so the number matches the table. */ if (onProgress) { const left = todo.filter(x => x.t.slots.some(s => s._pending)).length; onProgress(todo.length - left, todo.length); } } }; await Promise.all(Array.from({ length: Math.min(MATCH_LANES, todo.length) }, lane)); endMatchLog(); if (!isEditing()) rerender(); // #577: say so, and say what survived — a silent stop looks like a crash. // Announced before the finally's refreshStatus, which would overwrite it, // so the message is set after instead. if (stopped) Log.cat('Artist').info('matching stopped after ' + done + ' of ' + planned + ' track' + (planned === 1 ? '' : 's') + ' — what matched is kept'); // #575: the artist pass had no end marker, so the recordings pass's summary // looked like the whole thing finishing while artists were still resolving. else if (planned) Log.cat('Artist').info('tracklist match: ' + planned + ' track' + (planned === 1 ? '' : 's') + ' matched — Discogs link checks and alias enrichment continue in the background'); } finally { endMatchLog(); // a no-op once the pass summed up; covers a pass that threw setMatching(false); refreshStatus(); // set the final per-medium badges once the pass is done if (stopped) updateStatus('matching stopped — ' + done + '/' + planned + ' done, the rest are still unmatched'); if (!_autoMatching) _matchStop = false; // #575: leave it set while the recordings pass is still winding down } // #227: tag/resolve Discogs links AFTER the match finally (so its summary // message isn't overwritten by refreshStatus) — covers 'set' artists too. // Not awaited: tagDiscogsForAll may now poll a few seconds for the release // Discogs link to load, and we don't want that holding up the caller's // alias-enrichment. It owns the badge and updates it when it settles. tagDiscogsForAll(); tagPlatformForAll(); // #651: 🔗 / ⚠ for First Contact's platform links, 'set' artists too } // (re-)match every still-unmatched slot — the "Match" button / used when auto-match is off async function matchAll() { if (!MODEL) return; MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.status !== 'set' && !s.committed) s._pending = true; })); await matchModel((d, n) => updateStatus(`matching ${d}/${n}…`)); } // has this track changed from its page-load state (title/#/length or any artist credit)? function trackChanged(entry) { const orig = ORIGINALS.get(origKey(entry.mi, entry.ti)); if (!orig) return false; const t = koTrack(entry.mi, entry.ti); if ((u(t.name) || '') !== orig.title || String(u(t.number)) !== String(orig.number) || (u(t.formattedLength) || '') !== orig.length) return true; if (entry.slots.length !== orig.names.length) return true; for (let i = 0; i < entry.slots.length; i++) { const s = entry.slots[i], o = orig.names[i]; const curGid = (s.committed && s.gid) ? s.gid : ''; const origGid = (o.artist && u(o.artist.gid)) || ''; if (curGid !== origGid || (s.creditedAs || '') !== (o.creditedAs || '') || (s.joinPhrase || '') !== (o.joinPhrase || '')) return true; } return false; } function resetTrack(entry, restoreDataFlag) { const orig = ORIGINALS.get(origKey(entry.mi, entry.ti)); if (!orig) return; const t = koTrack(entry.mi, entry.ti); _selfEdit = true; try { t.artistCredit({ names: orig.names.map(o => ({ artist: o.artist, name: o.creditedAs, joinPhrase: o.joinPhrase })) }); try { t.name(orig.title); } catch (e) {} try { t.number(orig.number); } catch (e) {} try { if (typeof t.formattedLength === 'function') t.formattedLength(orig.length); } catch (e) {} /* #586 — the data-track flag is restored only on a whole-tracklist revert. A data section has to stay a TRAILING, contiguous block, and putting one row back on its own can break that (revert the middle of three and the section is split), so a single ↺ leaves the boundary to ⤓/⤒. */ if (restoreDataFlag && typeof t.isDataTrack === 'function' && !!t.isDataTrack() !== !!orig.isDataTrack) t.isDataTrack(!!orig.isDataTrack); } finally { _selfEdit = false; } if (!restoreDataFlag && typeof t.isDataTrack === 'function' && !!t.isDataTrack() !== !!orig.isDataTrack) Log.cat('Tracklist').info('reset track ' + entry.number + ': it is still ' + (t.isDataTrack() ? 'a data track' : 'an audio track') + ' — use ⤒/⤓ to move the data-track boundary, which has to stay one trailing block'); Log.cat('Tracklist').info('reset track', entry.number, 'to original (all cells)'); } let _selfEdit = false; // true while WE mutate the tracklist, so the change-watcher ignores it // a medium with a CD disc ID (TOC) has a fixed track count — native MB locks adding/removing/ // reordering its tracks. Mirror that so Apollo never silently corrupts the disc-ID association. #125 function mediumLocked(mi) { try { const m = mediums()[mi]; return !!(m && typeof m.hasToc === 'function' && m.hasToc()); } catch (e) { return false; } } // #329/#330: classify a track. Pregap = position 0; data track = isDataTrack(); else audio. function trackKind(entry) { try { const ko = koTrack(entry.mi, entry.ti); if (typeof ko.position === 'function' && ko.position() === 0) return 'pregap'; if (typeof ko.isDataTrack === 'function' && !!ko.isDataTrack()) return 'data'; } catch (e) {} return 'audio'; } /* #584 follow-up (majkinetor: "also show in recording table") — the video marker existed only on the Recordings tab; the Tracklist showed nothing, so the very tracks you go looking for (his release marks its last three as video, and #586 is about pushing exactly those into the data section) were invisible in the table where you do the work. Read live off the linked recording, which does carry .video in the release editor's own model (verified in test/probe-584-video-flag.mjs — 3 of 13 true, none undefined). */ function trackRecIsVideo(entry) { try { const t = koTrack(entry.mi, entry.ti); if (typeof t.hasNewRecording === 'function' && u(t.hasNewRecording)) return false; // #655: unset → a new recording, not a video const rec = u(t.recording); return !!(rec && u(rec.video)); } catch (e) { return false; } } /* #655 (chaban-mb): "When unsetting a wrongly matched video recording the video indicator is not removed from the tracklist." The Tracklist is built once and refreshed once per visit, so a recording linked or unset on the Recordings tab (or by MB itself) left its rows showing the old marker. Bring each row's marker in line with the live recording, in place: a rebuild would cost the match badges nothing, but it is far more work than one icon. */ function syncTlVideoMarks() { let added = 0, removed = 0; document.querySelectorAll('.tc-mirror tr[data-tk]').forEach(tr => { const want = trackRecIsVideo({ mi: +tr.dataset.mi, ti: +tr.dataset.ti }); const mk = tr.querySelector('.t-wrap > .tc-rec-video'); if (want && !mk) { const wrap = tr.querySelector('.t-wrap'), before = wrap && wrap.querySelector(':scope > .t-title-disp, :scope > .t-title'); if (!before) return; before.insertAdjacentHTML('beforebegin', VIDEO_MARK_PRE); added++; } else if (!want && mk) { mk.remove(); removed++; } }); if (added || removed) Log.cat('Tracklist').debug('tracklist video markers: ' + added + ' added, ' + removed + ' removed (a recording link changed)'); } // #329: on a disc-ID (TOC) medium the audio tracks' lengths are fixed by the TOC, so // native MB makes them read-only. Pregap + data tracks aren't covered by the audio TOC // and stay editable — mirror that for Apollo's length cells. function trackLenLocked(entry) { return mediumLocked(entry.mi) && trackKind(entry) === 'audio'; } function removeTrack(entry) { if (mediumLocked(entry.mi)) { Log.cat('Tracklist').info('medium', entry.mi + 1, 'disc-ID locked — remove blocked'); return; } _selfEdit = true; try { getEditor().removeTrack(koTrack(entry.mi, entry.ti)); } finally { _selfEdit = false; } Log.cat('Tracklist').info('removed track', entry.number); } function moveTrack(entry, dir) { if (mediumLocked(entry.mi)) { Log.cat('Tracklist').info('medium', entry.mi + 1, 'disc-ID locked — move blocked'); return; } const ed = getEditor(); const t = koTrack(entry.mi, entry.ti); _selfEdit = true; try { (dir < 0 ? ed.moveTrackUp : ed.moveTrackDown).call(ed, t); } finally { _selfEdit = false; } } // move a track to a target index WITHIN its medium by stepping MB's own up/down ops — never touches the // model array directly, so the editor can't diverge (drag-to-reorder rides on this) function moveTrackToIndex(entry, destTi) { if (mediumLocked(entry.mi)) { Log.cat('Tracklist').info('medium', entry.mi + 1, 'disc-ID locked — reorder blocked'); return false; } const ed = getEditor(), t = koTrack(entry.mi, entry.ti); const n = (u(mediums()[entry.mi].tracks) || []).length; destTi = Math.max(0, Math.min(n - 1, destTi)); let cur = entry.ti; if (cur === destTi) return false; _selfEdit = true; try { while (cur > destTi) { ed.moveTrackUp.call(ed, t); cur--; } while (cur < destTi) { ed.moveTrackDown.call(ed, t); cur++; } } catch (e) { Log.cat('Tracklist').warn('move-to-index failed', e.message); } finally { _selfEdit = false; } Log.cat('Tracklist').info('moved track', entry.number, 'from', entry.ti, '→', destTi, 'in medium', entry.mi + 1); return true; } // add N blank tracks to a medium by driving MB's own "Add tracks" control (the green +) function addTracks(mi, n) { if (mediumLocked(mi)) { Log.cat('Tracklist').info('medium', mi + 1, 'disc-ID locked — add blocked'); return; } const btns = [...document.querySelectorAll('button[data-click="addNewTracks"]')]; const inputs = [...document.querySelectorAll('input[data-bind*="addTrackCount"]')]; const btn = btns[mi] || btns[btns.length - 1]; const inp = inputs[mi] || inputs[inputs.length - 1]; if (!btn) { Log.cat('Tracklist').warn('no native add-tracks button found'); return; } const med = mediums()[mi]; const before = med ? (u(med.tracks) || []).length : 0; _selfEdit = true; try { if (inp) { inp.value = String(n); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } btn.click(); } finally { _selfEdit = false; } // MB seeds each new track with the *previous* track's artist credit — clear it so new tracks are blank. // #483: this ran AFTER the _selfEdit guard above already reset to false — its own guard now. if (med) { const tks = u(med.tracks) || []; _selfEdit = true; try { for (let i = before; i < tks.length; i++) try { tks[i].artistCredit({ names: [{ artist: null, name: '', joinPhrase: '' }] }); } catch (e) {} } finally { _selfEdit = false; } } Log.cat('Tracklist').info('added', n, 'track(s) to medium', mi + 1); // refresh immediately (blank tracks need no matching) instead of the 400ms watcher + match pass MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); } // #483: was unguarded — since #472 started watching track.name individually // (to catch bookmarklets editing tracks in place), every normal title edit // through Apollo's own mirror input ALSO tripped that watcher, scheduling a // full mirror rebuild ~400ms later that yanked focus wherever the user had // since moved it (tab/click to the next field). _selfEdit tells the watcher // this write is ours, same guard already used for remove/move/reset/etc. function setTitle(entry, v) { _selfEdit = true; try { koTrack(entry.mi, entry.ti).name(v); } finally { _selfEdit = false; } } function setNumber(entry, v) { try { koTrack(entry.mi, entry.ti).number(v); } catch (e) { Log.cat('Tracklist').warn('set number failed', v, e.message); } } function setLength(entry, v) { const t = koTrack(entry.mi, entry.ti); try { if (typeof t.formattedLength === 'function') t.formattedLength(v); else { const ed = getEditor(); const ms = ed.utils && ed.utils.unformatTrackLength ? ed.utils.unformatTrackLength(v) : null; if (ms != null && !isNaN(ms)) t.length(ms); } } catch (e) { Log.cat('Tracklist').warn('set length failed', v, e.message); } } // MB guess case: preview into track.previewName (no mutation) to detect the diff; click-type to apply function guessTitleStr(entry) { const ed = getEditor(), t = koTrack(entry.mi, entry.ti); try { ed.guessCaseTrackName(t, { type: 'mouseenter', buttons: 0 }); const g = u(t.previewName); ed.guessCaseTrackName(t, { type: 'mouseleave' }); return (g == null) ? u(t.name) : g; } catch (e) { return u(t.name); } } function applyGuessTitle(entry) { try { getEditor().guessCaseTrackName(koTrack(entry.mi, entry.ti), { type: 'click' }); } catch (e) { Log.cat('Tool').warn('guess case failed', e.message); } } // Lazily get the absolute overlay that hosts a title cell's action buttons // (Aa / ⋔), so they don't reserve flex width and shrink the input. #153 function tActions(wrap) { let a = wrap.querySelector('.t-actions'); if (!a) { a = document.createElement('span'); a.className = 't-actions'; wrap.appendChild(a); } return a; } /* ── create artist ── */ // the sort-name guess is shared with Credit Hoarder (mbmGuessSortName, the ST-MATCH block) const guessSortName = name => mbmGuessSortName(name); // open MB's create-artist form; when it's saved, the new artist page posts the MBID back over the // channel (handshake via sessionStorage token) and closes itself, and we drop it into the slot. function createArtist(name, slot, discogsUrl, background) { let url = `${ORIGIN}/artist/create?edit-artist.name=${encodeURIComponent(name)}&edit-artist.sort_name=${encodeURIComponent(guessSortName(name))}`; // #227: seed the Discogs link so the new artist is born already linked if (discogsUrl) { url += `&edit-artist.url.0.text=${encodeURIComponent(discogsUrl)}&edit-artist.url.0.link_type_id=${DISCOGS_ARTIST_LINK_TYPE}`; _discogsResolveCache.delete(discogsUrl); } // #651: and First Contact's platform link, unless another artist already has it const platUrl = slot && slot._platUrl && !slot._platConflict && slot._platUrl !== discogsUrl ? slot._platUrl : null; if (platUrl) { const k = discogsUrl ? 1 : 0, pt = (platformOf(platUrl) || {}).artistLinkType; url += `&edit-artist.url.${k}.text=${encodeURIComponent(platUrl)}` + (pt ? `&edit-artist.url.${k}.link_type_id=${pt}` : ''); forgetPlatformUrl(platUrl); Log.cat('Create').info('seeding the', (platformOf(platUrl) || { name: 'platform' }).name, 'link', platUrl); } url += `&edit-artist.edit_note=${encodeURIComponent(entityActionNote('Created this artist'))}`; // proper attribution on the created artist const token = (slot && ART_CHANNEL) ? ('tc-' + Date.now() + '-' + (++_createSeq)) : null; // #273: right-click → create SILENTLY in a background tab and auto-submit. // GM_openInTab gives no tab handle to write sessionStorage on, so we carry the // postback token in the URL hash; the create-page bootstrap stores it as the // pending marker, waits for the seeded form to render, and clicks "Enter edit". // The saved /artist/<gid> page posts the MBID back (existing handler) and we // close the GM tab here on that postback. if (background && token && typeof GM_openInTab === 'function') { const bgTab = GM_openInTab(`${url}#tc-autocommit=${encodeURIComponent(token)}`, { active: false, insert: true }); _pendingCreates.set(token, { slot, bgTab }); Log.cat('Create').info('artist (background) for', JSON.stringify(name), '— will auto-insert on save'); return; } const tab = W.open(url, '_blank'); // NOT noopener — we set a token on the new tab's sessionStorage if (tab && token) { _pendingCreates.set(token, { slot }); const trySet = () => { try { tab.sessionStorage.setItem(PENDING_KEY, token); } catch (e) { setTimeout(trySet, 50); } }; trySet(); Log.cat('Create').info('artist for', JSON.stringify(name), '— will auto-insert on save'); } else { Log.cat('Create').info('open MB create-artist for', JSON.stringify(name)); } } // the Label field's +: MusicBrainz's create-label form, seeded with the name and with the label's // Discogs or platform link when no label has it yet; the saved label is set on the field, as a // created artist is function createLabel(name, lf, background) { let url = `${ORIGIN}/label/create?edit-label.name=${encodeURIComponent(name)}`; const link = lf && _riLabUrl.get(lf); if (link && sameName(link.name, name)) { url += `&edit-label.url.0.text=${encodeURIComponent(link.url)}`; platformUrlForms(link.url).forEach(f => { _discogsResolveCache.delete('label:' + f); _ddrop('resolve', 'label:' + f); }); Log.cat('Create').info('seeding the link', link.url); } url += `&edit-label.edit_note=${encodeURIComponent(entityActionNote('Created this label'))}`; const token = (lf && ART_CHANNEL) ? ('tc-' + Date.now() + '-' + (++_createSeq)) : null; const pend = { slot: { _onPick: ent => riPickLabel(lf, ent) } }; if (background && token && typeof GM_openInTab === 'function') { pend.bgTab = GM_openInTab(`${url}#tc-autocommit=${encodeURIComponent(token)}`, { active: false, insert: true }); _pendingCreates.set(token, pend); Log.cat('Create').info('label (background) for', JSON.stringify(name), '— will be set on save'); return; } const tab = W.open(url, '_blank'); // NOT noopener — we set a token on the new tab's sessionStorage if (tab && token) { _pendingCreates.set(token, pend); const trySet = () => { try { tab.sessionStorage.setItem(PENDING_KEY, token); } catch (e) { setTimeout(trySet, 50); } }; trySet(); Log.cat('Create').info('label for', JSON.stringify(name), '— will be set on save'); } else Log.cat('Create').info('open MB create-label for', JSON.stringify(name)); } // runs on a freshly-saved /artist/<mbid> (or /label/<mbid>) page opened by createArtist (createLabel): post the MBID back, then close function handleArtistPageCallback() { const m = location.pathname.match(new RegExp('^/(?:artist|label)/(' + MBID_RE.source + ')', 'i')); if (!m) return false; let token = null; try { token = sessionStorage.getItem(PENDING_KEY); } catch (e) {} if (!token) return false; try { sessionStorage.removeItem(PENDING_KEY); } catch (e) {} const gid = m[1].toLowerCase(); fetchEntity(gid).then(ent => { if (ART_CHANNEL) ART_CHANNEL.postMessage({ type: 'tc-artist-created', token, gid, id: ent ? ent.id : null, name: ent ? ent.name : '' }); setTimeout(() => { try { W.close(); } catch (e) {} }, 80); }); return true; } // a tab opened just to ADD a Discogs link to an existing artist closes itself // once the edit submits and redirects to the clean /artist/<gid> page. Guarded // to the clean entity page ($-anchored, so NOT /artist/<gid>/edit) — it never // closes before the user submits. The opener re-verifies on focus return. function handleEditLinkClose() { const m = location.pathname.match(new RegExp('^/artist/(' + MBID_RE.source + ')$', 'i')); if (!m) return false; let mark = null; try { mark = sessionStorage.getItem(CLOSE_KEY); } catch (e) {} if (!mark) return false; try { sessionStorage.removeItem(CLOSE_KEY); } catch (e) {} // #273: tell the opener the link edit committed so a BACKGROUND add-link (which // never regains focus) can re-tag the slots + close this GM tab. A foreground // add-link has no listener for it — harmless there (it uses focus-return). if (ART_CHANNEL) { try { ART_CHANNEL.postMessage({ type: 'tc-edit-committed', gid: m[1].toLowerCase() }); } catch (e) {} } setTimeout(() => { try { W.close(); } catch (e) {} }, 80); return true; } // #273: on the MB create/edit form opened in a BACKGROUND tab (hash flag), store // the right marker (so the saved entity page posts back / closes), wait for the // seeded change to RENDER — the "Enter edit" button shows before MB's React // external-links editor applies the seeded URL, so an early click would submit // an empty edit — then click it. function handleAutoCommit() { const onCreate = /\/(artist|label|place)\/create\b/i.test(location.pathname); const onEdit = new RegExp('/(artist|label|place)/' + MBID_RE.source + '/edit\\b', 'i').test(location.pathname); if (!onCreate && !onEdit) return false; const hm = location.hash.match(/tc-autocommit(?:=([^&]+))?/); if (!hm) return false; if (onCreate) { let tok = ''; try { tok = decodeURIComponent(hm[1] || ''); } catch (e) { tok = hm[1] || ''; } try { sessionStorage.setItem(PENDING_KEY, tok); } catch (e) {} } else { try { sessionStorage.setItem(CLOSE_KEY, '1'); } catch (e) {} } const et = (location.pathname.match(/\/(artist|label|place)\//) || [])[1] || 'artist'; const seedUrl = new URLSearchParams(location.search).get(`edit-${et}.url.0.text`) || ''; const seedKey = seedUrl ? seedUrl.replace(/^https?:\/\//i, '').replace(/^www\./i, '').replace(/\/+$/, '').toLowerCase() : ''; // MusicBrainz cleans some links as it seeds them (Apple Music drops the name: // music.apple.com/us/artist/quinteto-winoe/6796549074 shows as …/us/artist/6796549074), so the // exact form never showed and the tab gave up, left for you to submit. The host and the link's // id (its last path segment) survive the cleanup. const seedHost = (seedKey.match(/^[^/?#]+/) || [''])[0], seedId = (seedKey.split(/[?#]/)[0].match(/\/([^/]{4,})$/) || [])[1] || ''; // Only in the links editor: the page's language links carry the seed too, URL-encoded, with the // host and id readable — matched there, it submitted before the link was on the form const seeded = () => { if (document.body.innerHTML.toLowerCase().includes(seedKey)) return true; const ed = document.getElementById('external-links-editor'); if (!ed || !seedHost || !seedId) return false; return [...ed.querySelectorAll('a[href], input')].some(x => { const v = String(x.href || x.value || '').toLowerCase(); return v.includes(seedHost) && v.includes(seedId); }); }; let tries = 0; const submit = () => { const seedReady = !seedKey || seeded(); const btn = document.querySelector('button.submit.positive') || [...document.querySelectorAll('button[type="submit"]')].find(b => /enter edit/i.test(b.textContent || '')); if (seedReady && btn && !btn.disabled) { btn.click(); return; } if (tries++ < 100) setTimeout(submit, 200); // ~20s grace (background tabs are slower) else Log.cat('Links').warn('auto-submit: the seeded link', seedUrl, 'never showed on the form' + (btn ? '' : ', and no Enter edit button') + ' — left for you to submit'); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', submit); else submit(); return true; } /* ════════════════════════ UI ════════════════════════ */ // mbRestackCorner, the #468 corner slots, is in the ST-UI block (dev/ui/ui-components.mjs). const HELP_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/apollo_editor/README.md'; const VERSION = '?'; // GM_info carries the real one; every script manager provides it (a hard-coded copy only ever went stale) const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } }; // shared attribution header (same shape as the other scripts' edit notes) const apolloAttribution = () => { const s = (typeof GM_info !== 'undefined' && GM_info.script) || {}; return (s.name || 'Apollo Editor') + ' v' + scriptVersion() + ' by ' + (s.author || 'majkinetor') + ' - ' + (s.homepageURL || s.homepage || HELP_URL); }; // edit note for an artist Apollo creates or links from a credit slot — // attribution + what was done + which release was being edited function entityActionNote(action) { const releaseUrl = location.href.split(/[?#]/)[0].replace(/\/edit(-relationships)?$/, ''); return apolloAttribution() + '\n\n' + action + ' while editing ' + releaseUrl; } // the corner launcher wears the script's own icon (the same picture as @icon) const ICON_URL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5BcG9sbG8gRWRpdG9yPC90aXRsZT4KPG1hc2sgaWQ9ImFwLW0iIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjAiIHk9IjAiIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4Ij48Zz48ZyBmaWxsPSIjZmZmIj48cGF0aCBkPSJNNDIgNjQgQzI4IDcyIDIyIDkwIDI0IDExMCBMNDIgOTggWiIvPjxwYXRoIGQ9Ik04NiA2NCBDMTAwIDcyIDEwNiA5MCAxMDQgMTEwIEw4NiA5OCBaIi8+PC9nPjxnIGZpbGw9IiNmZmYiPjxwYXRoIGQ9Ik02NCA4IEM4NCAyNCA5MCA1MiA4OCA5MCBMNDAgOTAgQzM4IDUyIDQ0IDI0IDY0IDggWiIvPjxwYXRoIGQ9Ik00OCA5MCBMODAgOTAgTDc2IDEwMCBMNTIgMTAwIFoiLz48L2c+PGcgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjMuNSI+PHBhdGggZD0iTTM2IDkwLjUgTDkyIDkwLjUiLz48L2c+PGNpcmNsZSBjeD0iNjQiIGN5PSI1MCIgcj0iOCIgZmlsbD0iIzAwMCIvPjwvZz48L21hc2s+PGc+PHBhdGggZD0iTTUyIDEwMCBDNTQgMTEyIDYwIDExOCA2NCAxMjYgQzY4IDExOCA3NCAxMTIgNzYgMTAwIFoiIGZpbGw9IiNlYjc0M2IiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgMykiLz48L2c+PHJlY3Qgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiIGZpbGw9IiNiYTQ3OGYiIG1hc2s9InVybCgjYXAtbSkiLz4KPC9zdmc+Cg=='; // the settings header and the floating window wear the same picture as @icon and the launcher const ICON = '<img class="tc-ico" src="' + ICON_URL + '" width="22" height="22" alt="" aria-hidden="true" style="vertical-align:-5px">'; // outline person / group type icons (use currentColor so they take the link colour) const PERSON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="3.4"/><path d="M5 20 C5 14.5 19 14.5 19 20"/></svg>'; const GROUP_SVG = '<svg viewBox="0 0 24 24" width="17" height="15" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8.5" cy="9" r="2.7"/><circle cx="15.5" cy="9" r="2.7"/><path d="M3 19 C3 15 11 15 11 19"/><path d="M13 19 C13 15 21 15 21 19"/></svg>'; const typeSvg = c => { const t = ((c && c.typeName) || '').toLowerCase(); return (t === 'group' || t === 'orchestra' || t === 'choir') ? GROUP_SVG : PERSON_SVG; }; const JOIN_OPTIONS = [ { label: '&', value: ' & ' }, { label: ',', value: ', ' }, { label: 'feat.', value: ' feat. ' }, { label: 'ft.', value: ' ft. ' }, { label: 'featuring', value: ' featuring ' }, { label: 'and', value: ' and ' }, { label: 'vs.', value: ' vs. ' }, { label: 'x', value: ' x ' }, { label: 'with', value: ' with ' }, { label: '/', value: ' / ' }, { label: '·', value: ' · ' }, { label: 'presents', value: ' presents ' }, ]; const COLS = [{ k: 'mv', w: 32, label: '' }, { k: 'num', w: 38, label: '#' }, { k: 'title', w: 360, label: 'Title' }, { k: 'art', w: 380, label: 'Artist' }, { k: 'len', w: 52, label: 'Length' }, { k: 'badge', w: 56, label: 'Match' }, { k: 'act', w: 44, label: '' }]; const badgeText = s => s.status === 'plat' ? ((s._why && s._why.plat && s._why.plat.abbr) || 'link') : ({ rg: 'rg', disc: 'disc', pos: 'pos', cred: 'cred', alias: 'alias', high: 'name', user: 'user', set: 'set', low: 'low' })[s.status] || ''; // #654: the track-actions column is fixed at two icons wide; it is never resized const colW = (k, d) => (k !== 'act' && SETTINGS.colWidths && SETTINGS.colWidths[k]) || d; function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } // The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and are // inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never in this block. // <ST-TOKENS> — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // </ST-TOKENS> // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here. // <ST-UI> — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on <html>, which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'<svg…>' | '<img…>', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // o.history opt-in history of past sessions (#705), below // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // viewer appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) : '--:--:--.---'; }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + (e.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button, input, select') || pop.classList.contains('full')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); views = views.filter(function (v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in <body>: a permanent stray node under <html>, outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No <body> yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and <head> and <body> don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } // </ST-UI> // <ST-ARCHIVE> — generated by dev/archive/sync-archive.mjs from dev/archive/wayback.mjs — DO NOT EDIT const MBA_KEY = "mbu.archive"; function mbaTs(d) { return (d || new Date()).toISOString().replace(/[^0-9]/g, '').slice(0, 14); } function mbaShort(url) { return String(url).replace(/^https?:\/\//i, '').replace(/^www\./i, ''); } function mbaSnapshot(ts, url) { return 'https://web.archive.org/web/' + ts + '/' + mbaShort(url); } function mbaCleanUrl(href) { const [base, query = ''] = String(href).split('#')[0].split(/\?(.*)/s); const kept = query.split('&').filter(p => p && !/^(si|utm_\w+|fbclid|gclid|ref|from|context|nd|feature|redirected)$/i.test(decodeURIComponent(p.split('=')[0].replace(/\+/g, ' ')))); return base + (kept.length ? '?' + kept.join('&') : ''); } function mbaSavable(url) { const d = String(url).match(/^https?:\/\/(?:www\.)?discogs\.com\/(?:[a-z]{2}\/)?(release|master|artist|label)\/(\d+)(?:[-/?#]|$)/i); return d ? `https://api.discogs.com/${d[1].toLowerCase()}s/${d[2]}` : url; } function mbaKeys() { let v = null; try { v = GM_getValue(MBA_KEY, null); } catch (e) { /* no storage: anonymous */ } return v && v.iaKey && v.iaSecret ? { key: v.iaKey, secret: v.iaSecret } : null; } function mbaKeysStored() { let v = null; try { v = GM_getValue(MBA_KEY, null); } catch (e) { /* no storage */ } return { iaKey: (v && v.iaKey) || '', iaSecret: (v && v.iaSecret) || '' }; } function mbaSetKeys(iaKey, iaSecret) { const prev = (() => { try { return GM_getValue(MBA_KEY, null) || {}; } catch (e) { return {}; } })(); GM_setValue(MBA_KEY, Object.assign({}, prev, { iaKey: String(iaKey || '').trim(), iaSecret: String(iaSecret || '').trim() })); } function mbaSaveAnon(url, log) { const t0 = Date.now(), wait = 180000; return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url: 'https://web.archive.org/save/' + url, timeout: wait, anonymous: true, headers: { Accept: 'text/html' }, onload: r => { const snap = /\/web\/\d{14}\//.test(r.finalUrl || '') ? r.finalUrl : null; const ms = Date.now() - t0; if (r.status >= 200 && r.status < 300 && snap) log.ok(`archive: saved ${url} → ${snap} (${ms} ms)`); else if (r.status === 429) log.warn(`archive: ${url} not saved: Save Page Now is limiting anonymous saves (HTTP 429). archive.org keys in the settings raise the limit`); else log.warn(`archive: ${url} → HTTP ${r.status}${snap ? '' : ', no snapshot in the answer'} (${ms} ms)${r.finalUrl ? ', ended at ' + r.finalUrl : ''}`); resolve(); }, onerror: () => { log.warn(`archive: network error saving ${url}`); resolve(); }, ontimeout: () => { log.warn(`archive: no answer for ${url} in ${wait / 60000} minutes (the save may still finish)`); resolve(); }, }); }); } function mbaRequest(method, url, auth, body) { return new Promise(resolve => { GM_xmlhttpRequest({ method, url, data: body, timeout: 30000, anonymous: true, headers: Object.assign({ Accept: 'application/json', Authorization: 'LOW ' + auth }, body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}), onload: r => { let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { /* not JSON */ } resolve({ status: r.status, json: j, text: r.responseText || '' }); }, onerror: () => resolve({ status: 0, json: null, text: 'network error' }), ontimeout: () => resolve({ status: 0, json: null, text: 'timeout' }), }); }); } async function mbaSaveWithKeys(url, keys, log, follow) { const auth = keys.key + ':' + keys.secret, wait = 180000; const body = new URLSearchParams({ url, if_not_archived_within: '30d', capture_screenshot: '1' }).toString(); const r = await mbaRequest('POST', 'https://web.archive.org/save', auth, body); log.debug(`archive: POST /save ${url} → HTTP ${r.status} ${r.text.slice(0, 300)}`); if (r.status === 401 || r.status === 403) { log.warn(`archive: archive.org refused your keys (HTTP ${r.status}); check them in the settings`); return; } const job = r.json && r.json.job_id; if (!job) { const msg = (r.json && (r.json.message || r.json.status_ext)) || r.text.slice(0, 200) || 'no answer'; if (r.status === 200 && /archived|already/i.test(msg)) log.ok(`archive: ${url} has a recent snapshot, not saved again (${msg})`); else log.warn(`archive: ${url} not saved (HTTP ${r.status}: ${msg})`); return; } log.info(`archive: ${url} queued as job ${job}`); if (!follow) return; const t0 = Date.now(); while (Date.now() - t0 < wait) { await new Promise(res => setTimeout(res, 6000)); const st = await mbaRequest('GET', 'https://web.archive.org/save/status/' + encodeURIComponent(job), auth); const j = st.json || {}; log.debug(`archive: job ${job} → HTTP ${st.status}, ${j.status || '?'}`); if (j.status === 'pending') continue; if (j.status === 'success') log.ok(`archive: saved ${url} → https://web.archive.org/web/${j.timestamp}/${j.original_url || url}${j.screenshot ? ', screenshot ' + j.screenshot : ''} (${Date.now() - t0} ms)`); else log.warn(`archive: job ${job} for ${url} ended ${j.status || 'HTTP ' + st.status}: ${j.message || j.status_ext || st.text.slice(0, 200)}`); return; } log.info(`archive: job ${job} for ${url} still running after ${wait / 60000} minutes; archive.org finishes it on its side`); } async function mbaSave(url, log, follow) { const keys = mbaKeys(); try { await (keys ? mbaSaveWithKeys(url, keys, log, follow) : mbaSaveAnon(url, log)); } catch (e) { log.warn(`archive: ${url}: ${e && e.message || e}`); } } function mbaKeysState(k, s) { k = String(k || '').trim(); s = String(s || '').trim(); return k && s ? '✓ set' : k ? 'secret missing' : s ? 'access key missing' : 'not set'; } function mbaKeysHtml(off) { const k = mbaKeysStored(), q = v => String(v).replace(/&/g, '&').replace(/"/g, '"'); // no password-manager icon in the fields: they opt out by attribute (LastPass, 1Password, // Bitwarden, Dashlane), and the secret is a text field masked in CSS, since type=password draws every manager in const opt = 'autocomplete="off" data-lpignore="true" data-1p-ignore data-bwignore data-form-type="other" spellcheck="false"'; return `<div class="mbu-ia${off ? ' mbu-ia-off' : ''}" title="Optional, and shared by First Contact and Apollo Editor. With your archive.org keys the save uses Save Page Now 2: higher limits, and a screenshot of the page, which is the evidence for platforms that build their pages in the browser (Spotify, Tidal, YouTube Music). Without keys the save is anonymous.">` + `<div class="mbu-ia-h">archive.org keys <span class="mbu-ia-st">${mbaKeysState(k.iaKey, k.iaSecret)}</span> <a href="https://archive.org/account/s3.php" target="_blank" rel="noopener">get them</a></div>` + `<input type="text" class="mbu-ia-key" placeholder="access key" ${opt} value="${q(k.iaKey)}">` + `<input type="text" class="mbu-ia-secret" placeholder="secret" ${opt} value="${q(k.iaSecret)}"></div>`; } function mbaKeysWire(root, log) { const key = root.querySelector('.mbu-ia-key'), secret = root.querySelector('.mbu-ia-secret'), st = root.querySelector('.mbu-ia-st'); if (!key || !secret) return; const save = () => { mbaSetKeys(key.value, secret.value); if (st) { st.textContent = mbaKeysState(key.value, secret.value); st.classList.toggle('mbu-ia-ok', !!(key.value.trim() && secret.value.trim())); } }; const logged = () => log.info(`archive.org keys: ${mbaKeysState(key.value, secret.value)}`); for (const f of [key, secret]) { f.addEventListener('input', save); f.addEventListener('change', logged); } if (st) st.classList.toggle('mbu-ia-ok', !!(key.value.trim() && secret.value.trim())); } function mbaKeysCss(scope) { const s = scope + ' .mbu-ia'; return `${s} { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 6px; } ${s}.mbu-ia-off { opacity: .5; } ${s} .mbu-ia-secret { -webkit-text-security: disc; } ${s} .mbu-ia-h { grid-column: 1 / -1; color: var(--mbu-text-weak); } ${s} .mbu-ia-h a { color: var(--mbu-accent-text); margin-left: 4px; } ${s} .mbu-ia-st { margin-left: 4px; } ${s} .mbu-ia-st.mbu-ia-ok { color: var(--mbu-ok); } ${s} input { all: revert; box-sizing: border-box; width: 100%; min-width: 0; font: 12px var(--mbu-font-mono, monospace); padding: 3px 6px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg-sunken); color: var(--mbu-text); } ${s} input::placeholder { color: var(--mbu-text-weak); opacity: 1; font-style: normal; } `; } // </ST-ARCHIVE> const css = MBU_TOKENS + MBU_UI_CSS + ` .tc-badge{font-size:10px;font-weight:bold;border-radius:9px;padding:1px 7px;color:var(--mbu-text-on-accent);white-space:nowrap} .tc-badge.rg{background:#1f8a4c}.tc-badge.set{background:#6c757d}.tc-badge.high{background:#2f6fd6}.tc-badge.disc{background:#0a7a8c} .tc-badge.low{background:#e0a800}.tc-badge.user{background:var(--mbu-accent)}.tc-badge.none{background:#c0392b}.tc-badge.cred{background:#b5179e}.tc-badge.alias{background:#1f8a7a}.tc-badge.pos{background:#a0522d}.tc-badge.plat{background:#0b6e99} .tc-btn{padding:4px 11px;border:1px solid transparent;border-radius:3px;background:transparent;cursor:pointer;font:13px Arial;color:var(--mbu-text)} .tc-btn:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-border)} .tc-btn.primary{color:var(--mbu-accent-text);font-weight:bold}.tc-btn.primary:hover{background:linear-gradient(#7a52df,var(--mbu-accent));color:var(--mbu-text-on-accent);border-color:var(--mbu-accent)} /* #577: while a pass runs, Match IS the stop button — it has to stop looking like the thing you press to start one. Tokens, not literals, so a dark userstyle gets a readable pair (#564). */ .tc-btn.tc-stopping,#tc-recwrap .tc-rec-am.busy{color:var(--mbu-error);border-color:var(--mbu-error-border);background:var(--mbu-error-bg);font-weight:bold} .tc-btn.tc-stopping:hover,#tc-recwrap .tc-rec-am.busy:hover{background:var(--mbu-error);color:var(--mbu-text-on-accent);border-color:var(--mbu-error)} .tc-tbsep{width:1px;height:18px;background:var(--mbu-bg-sunken);flex:none;margin:0 2px} /* vertical divider before the Match cluster, shared by both toolbars */ .tc-btn:disabled,.tc-btn:disabled:hover{color:var(--mbu-text-weak);background:transparent;border-color:transparent;cursor:default;font-weight:normal} .tc-btn.mini{padding:1px 6px;font-size:11px} .tc-icon{cursor:pointer;border:none;background:none;font-size:13px;padding:0 2px;color:var(--mbu-text-dim)} #tc-panel a,#tc-mirror-wrap a{color:var(--mbu-accent-deep-text);text-decoration:none}#tc-panel a:hover,#tc-mirror-wrap a:hover{text-decoration:underline} .tc-mirror{table-layout:fixed;width:100%;border-collapse:collapse;font:13px Arial,Helvetica,sans-serif;background:var(--mbu-bg)} /* clean "normal" look, shared with the Recordings table: light header, no column fill/borders, soft row rule */ .tc-mirror th{position:relative;background:transparent;border-bottom:1px solid var(--mbu-border);text-align:left;padding:4px 7px;font-size:11px;font-weight:bold;color:var(--mbu-text-dim);overflow:hidden} .tc-mirror th:last-child{border-right:none} .tc-mirror td{padding:4px 7px;vertical-align:middle;overflow:hidden;background:var(--mbu-bg)} .tc-mirror.gridrows td{border-bottom:1px solid var(--mbu-border)} /* row line BETWEEN tracks (a track is one row, never between its artists) */ .tc-mirror td.c-art{vertical-align:top;padding-top:0;padding-bottom:0} /* green matched boxes touch row-to-row (no white gap) */ .tc-mirror td.c-badge{vertical-align:top} .tc-mirror td.c-badge{position:relative;padding:0;text-align:center} .tc-mirror .tc-resizer{position:absolute;right:-1px;top:0;height:100%;width:9px;cursor:col-resize;border-right:2px solid transparent} .tc-mirror th:hover .tc-resizer,.tc-mirror .tc-resizer:hover{border-right-color:var(--mbu-accent)} .tc-mirror .c-num{color:var(--mbu-text-weak);font-variant-numeric:tabular-nums;text-align:center} .tc-mirror th.c-len{text-align:right} .tc-mirror .c-mv{white-space:nowrap;text-align:center} /* #564: these inputs are created WITHOUT a type attribute, which is exactly what kellnerd's "Dark Side of MusicBrainz" targets -- its rule input:not([type]):not(.lookup-performed):not(.required):not([style*=background]) paints them --background-dimmed (#222) at specificity (0,4,1), outranking every rule below, so in a dark theme they read as black boxes floating on the row instead of being invisible as they are in light. Measured rather than guessed: test/probe-564-tracklist-bg.mjs prints the computed colour of each one under the real userstyle. !important is what outranks it without specificity golf, and the state fills need it too -- an !important base would otherwise bury the diff/preview/pending colours that actually carry meaning. */ .tc-mirror input.t-title,.tc-mirror input.t-len,.tc-mirror input.t-num{width:100%;box-sizing:border-box;border:1px solid transparent;background:transparent!important;font:13px Arial;padding:3px 2px} .tc-mirror input.t-len,.tc-mirror input.t-num{text-align:right;color:var(--mbu-text-dim)} .tc-mirror input.t-num{text-align:center} .tc-mirror input.t-title:hover,.tc-mirror input.t-title:focus,.tc-mirror input.t-len:hover,.tc-mirror input.t-len:focus,.tc-mirror input.t-num:hover,.tc-mirror input.t-num:focus{border-color:var(--mbu-border);background:var(--mbu-bg)!important} .tc-mirror input.t-len[readonly]{color:var(--mbu-text-weak);cursor:default} /* #329: TOC-fixed length — not editable */ .tc-mirror input.t-len[readonly]:hover,.tc-mirror input.t-len[readonly]:focus{border-color:transparent;background:transparent!important} .tc-mirror .t-wrap{display:flex;align-items:center;gap:3px;position:relative}.tc-mirror .t-wrap input.t-title{flex:1;min-width:0;width:auto} /* In-cell action buttons (Aa / ⋔) overlay the input's right edge instead of sitting in the flex flow, so they don't reserve width and shrink the input — otherwise "Fit" sizes the column to the text but the reserved button space clips long titles. #153 */ .tc-mirror .t-actions{position:absolute;right:2px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:3px;pointer-events:none} .tc-mirror .t-actions>*{pointer-events:auto} .tc-mirror input.t-title.diff{background:var(--mbu-warn-bg)!important;border-color:var(--mbu-warn);border-radius:3px} .tc-mirror input.t-title.gcpreview{background:var(--mbu-ok-bg)!important;border-color:var(--mbu-ok);border-radius:3px} /* #203: rich title display — read-only styled text (confusable chars enlarged) shown when the title isn't being edited; clicking/tabbing into it shows the native input. Mirrors the input's diff/gcpreview/hasfeat backgrounds so the cell looks unchanged. */ .tc-mirror .t-title-disp{flex:1;min-width:0;min-height:1.55em;box-sizing:border-box;border:1px solid transparent;border-radius:3px;background:transparent;font:13px Arial;padding:3px 2px;white-space:pre;overflow:hidden;cursor:text;display:flex;align-items:center} .tc-mirror .t-title-ph{color:var(--mbu-text-weak);font-style:italic} /* #357: empty-title placeholder in the rich display */ .tc-mirror .t-title-disp:hover{border-color:var(--mbu-border);background:var(--mbu-bg)} .tc-mirror .t-title-disp.diff{background:var(--mbu-warn-bg);border-color:var(--mbu-warn)} .tc-mirror .t-title-disp.gcpreview{background:var(--mbu-ok-bg);border-color:var(--mbu-ok)} .tc-mirror .t-title-disp.hasfeat{background:var(--mbu-bg-raised);border-color:var(--mbu-info)} .tc-mirror .t-title-disp.tc-hidden{display:none} /* #376 mirror MB's native gold "modification pending" mark — color ONLY the pending field (the title for a recording edit, the artist for an artist edit), not the whole row */ .tc-mirror tr.tc-rec-pending td.c-title .t-title-disp,.tc-mirror tr.tc-rec-pending td.c-title input.t-title{background:var(--mbu-warn-bg)!important;border-color:var(--mbu-warn)} .tc-mirror td.c-art .tc-search.tc-slot-pending{background:var(--mbu-warn-bg)!important;border-color:var(--mbu-warn)} /* #376 per-slot: only a selected artist with pending edits golds, never a free-text slot */ .tc-mirror.compact .t-title-disp{padding:0 2px;font-size:12px} .tc-mirror input.t-title.tc-eml:not(.tc-editing){position:absolute;width:1px;height:1px;min-width:0;padding:0;margin:0;border:0;opacity:0;pointer-events:none} /* MB medium-format select made to read as plain text — click still opens the native dropdown */ select.tc-fmt-flat{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:1px solid transparent;background:transparent;font:bold 15px Arial;color:var(--mbu-text);padding:2px 5px;cursor:pointer} select.tc-fmt-flat:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);border-radius:3px} /* #154: theme the native medium header (legend · collapse · format · "Medium title" · move/remove) to match Apollo while the tracklist takeover is on. Scoped to body.tc-tl-on so the original look returns the instant you switch back to the native editor — AND every button rule is further scoped to fieldset.advanced-medium, because tc-tl-on lives on <body> (stays on while you visit other tabs) and remove-item / guesscase-title are generic classes that also exist on the Release-information tab (external-link ✕, title Aa). Without the medium scope they leaked their glyphs onto that tab (#160). */ body.tc-tl-on fieldset.advanced-medium{border:1px solid var(--mbu-accent);border-radius:8px;background:var(--mbu-bg-raised);margin:0 0 12px;padding:3px 12px 6px} body.tc-tl-on fieldset.advanced-medium > legend{font:700 12px Arial;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-accent-text)!important;padding:0 6px;margin-left:2px} body.tc-tl-on table.advanced-format{width:100%;border-collapse:collapse;margin:0} body.tc-tl-on table.advanced-format > tbody > tr > td{padding:3px 5px;vertical-align:middle;border:none} body.tc-tl-on table.advanced-format td.format{display:flex;align-items:center;gap:8px;flex-wrap:wrap} /* the flat ▸ / ▾ icon buttons (collapse, move, remove) — drop MB's sprite, draw a themed glyph */ body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium,body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium, body.tc-tl-on fieldset.advanced-medium button.icon.medium-up,body.tc-tl-on fieldset.advanced-medium button.icon.medium-down, body.tc-tl-on fieldset.advanced-medium button.icon.remove-item{background:none!important;border:none;width:30px;height:28px;padding:0;margin:0;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;border-radius:5px;color:var(--mbu-accent-text);font-size:18px;line-height:1} /* the triangle glyphs render small for their font-size — pump the arrows up so they read clearly (#154) */ body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium::before,body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium::before, body.tc-tl-on fieldset.advanced-medium button.icon.medium-up::before,body.tc-tl-on fieldset.advanced-medium button.icon.medium-down::before{font-size:21px} body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium:hover,body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium:hover, body.tc-tl-on fieldset.advanced-medium button.icon.medium-up:hover,body.tc-tl-on fieldset.advanced-medium button.icon.medium-down:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)} body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium::before{content:'▸'} body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium::before{content:'▾'} body.tc-tl-on fieldset.advanced-medium button.icon.medium-up::before{content:'▴'} body.tc-tl-on fieldset.advanced-medium button.icon.medium-down::before{content:'▾'} body.tc-tl-on fieldset.advanced-medium button.icon.remove-item{color:var(--mbu-error);margin-left:14px!important} body.tc-tl-on fieldset.advanced-medium button.icon.remove-item::before{content:'✕';font-weight:bold} body.tc-tl-on fieldset.advanced-medium button.icon.remove-item:hover{background:var(--mbu-error-bg);color:var(--mbu-error)} /* "Medium title:" label + input + the Aa guess-case button */ body.tc-tl-on table.advanced-format td.format > label[for^="medium-title"]{font:12px Arial;color:var(--mbu-accent-text);margin-left:6px} body.tc-tl-on input[id^="medium-title-"]{flex:1 1 220px;min-width:200px;border:1px solid var(--mbu-accent);border-radius:4px;padding:3px 7px;font:13px Arial;background:var(--mbu-bg);box-shadow:none} body.tc-tl-on input[id^="medium-title-"]:focus{border-color:var(--mbu-accent);outline:none} body.tc-tl-on fieldset.advanced-medium button.icon.guesscase-title{background:none!important;border:1px solid var(--mbu-accent);border-radius:4px;width:auto;height:auto;min-width:0;padding:2px 7px;margin:0;cursor:pointer;color:var(--mbu-accent-text);font:bold 11px Arial;line-height:1.4} body.tc-tl-on fieldset.advanced-medium button.icon.guesscase-title::before{content:'Aa'} body.tc-tl-on fieldset.advanced-medium button.icon.guesscase-title:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);color:var(--mbu-accent-text)} .tc-mirror .t-gc{flex:none;cursor:pointer;border:1px solid var(--mbu-warn);background:var(--mbu-warn-bg);color:var(--mbu-warn);font:bold 10px Arial;border-radius:3px;padding:1px 4px;visibility:hidden}.tc-mirror .t-gc:hover{background:var(--mbu-warn-bg)} .tc-mirror tr:hover .t-gc{visibility:visible} .tc-mirror input.t-title.hasfeat{background:var(--mbu-bg-raised)!important;border-color:var(--mbu-info);border-radius:3px} .tc-mirror .t-feat{flex:none;cursor:pointer;border:1px solid var(--mbu-info);background:var(--mbu-bg-raised);color:var(--mbu-info);font:bold 12px Arial;border-radius:3px;padding:0 4px;line-height:16px;visibility:hidden}.tc-mirror .t-feat:hover{background:var(--mbu-info-bg)} .tc-mirror tr:hover .t-feat{visibility:visible} .tc-mirror .mv{cursor:pointer;color:var(--mbu-accent-text);font-size:12px;padding:0 1px} /* drag-to-reorder: ⠿ handle + drop indicators (a purple line at the row edge you'll drop against) */ .tc-mirror .tc-drag{cursor:grab;color:var(--mbu-accent-text);font-size:15px;line-height:1;padding:0 3px;user-select:none} .tc-mirror .tc-drag:hover{color:var(--mbu-accent-text)}.tc-mirror .tc-drag:active{cursor:grabbing} .tc-mirror tr.tc-dragging td{opacity:.45} .tc-mirror tr.tc-drop-before td{box-shadow:inset 0 2px 0 #5f3ec0} .tc-mirror tr.tc-drop-after td{box-shadow:inset 0 -2px 0 #5f3ec0} /* alternate row colors / grid (toggled in ⚙) */ .tc-mirror.alt tbody tr:nth-child(even) td{background:var(--mbu-bg-raised)} .tc-mirror.gridcols td{border-right:1px solid var(--mbu-border)}.tc-mirror.gridcols td:last-child{border-right:none} /* density layouts: compact (tight) · normal (default, shared with Recordings) · cozy (airy) */ .tc-mirror.cozy th{padding:7px 7px}.tc-mirror.cozy td{padding:8px 7px} .tc-mirror.compact th{padding:2px 6px} .tc-mirror.compact td{padding:0 6px} .tc-mirror.compact .tc-aslot,.tc-mirror.compact .tc-bl{height:21px} .tc-mirror.compact input.t-title,.tc-mirror.compact input.t-len,.tc-mirror.compact input.t-num{padding:0 2px;font-size:12px} .tc-mirror.compact .tc-search{padding:0 5px}.tc-mirror.compact .tc-search .nm{padding:1px 0;font-size:12px} .tc-mirror.compact .tc-cred{padding:0 4px 0 15px} /* badge column: pills per artist line; on row hover the track ↺/✕ show at the cell's right edge. Only the buttons take the pointer — the rest of the overlay lets it through — so the pills stay visible and their tooltips (pos: on how many editions) reachable; a cell narrow enough that a button lands on a pill gives the button a backing so it still reads (majkinetor: "those 2 icons covering Match column"). */ .tc-bl{height:28px;box-sizing:border-box;display:flex;align-items:center;justify-content:center} .tc-mirror td.c-act{vertical-align:top;padding:0} .tc-mirror th.c-badge{text-align:center} /* #654: over its centred pills */ .tc-mirror .tc-badge[data-slot]{cursor:help} /* #654: the match card, a badge's rich tooltip */ #tc-mtip{position:absolute;z-index:var(--mbu-z-pop);width:380px;max-width:94vw;background:var(--mbu-bg);color:var(--mbu-text);border:1px solid var(--mbu-border);border-radius:9px;box-shadow:var(--mbu-shadow-lg);font:13px/1.4 var(--mbu-font);overflow:hidden} #tc-mtip .tc-mt-h{display:flex;align-items:center;gap:8px;padding:8px 11px;border-bottom:1px solid var(--mbu-border-soft);background:var(--mbu-bg-raised)} #tc-mtip .tc-mt-st{margin-left:auto;font-size:11px;color:var(--mbu-text-weak);white-space:nowrap} #tc-mtip .tc-mt-bd{padding:8px 11px;display:flex;flex-direction:column;gap:6px} #tc-mtip a{color:var(--mbu-accent-text)} #tc-mtip .tc-mt-who a{font-weight:600;text-decoration:none} #tc-mtip .tc-mt-meta{display:block;font-size:11px;color:var(--mbu-text-weak)} #tc-mtip .tc-mt-dim{color:var(--mbu-text-weak)} #tc-mtip .tc-mt-means{color:var(--mbu-text-dim);font-size:12px} #tc-mtip .tc-mt-why{color:var(--mbu-text-dim)} #tc-mtip .tc-mt-alt{font-size:12px;color:var(--mbu-text-dim)} #tc-mtip .tc-mt-tbl{border-collapse:collapse;width:100%;font-size:12px} #tc-mtip .tc-mt-tbl td{padding:2px 4px;border-top:1px solid var(--mbu-divider);vertical-align:top} #tc-mtip .tc-mt-tbl td.ok{color:var(--mbu-ok);font-weight:bold;width:14px} #tc-mtip .tc-mt-tbl td.no{color:var(--mbu-error);font-weight:bold;width:14px} #tc-mtip .tc-mt-tbl td.w{color:var(--mbu-text-weak);text-align:right} #tc-mtip .tc-mt-ft{display:flex;gap:10px;padding:6px 11px;border-top:1px solid var(--mbu-border-soft);font-size:11px;color:var(--mbu-text-weak)} #tc-mtip .tc-mt-ft .k{margin-left:auto} .tc-trackacts{height:28px;display:flex;align-items:center;justify-content:center;gap:0;visibility:hidden} .tc-mirror tr:hover .tc-trackacts{visibility:visible} .tc-trackacts button{cursor:pointer;border:none;border-radius:4px;width:22px;padding:2px 0;background:none;font-size:16px;line-height:1} .tc-trackacts button:hover{background:var(--mbu-bg-hover)} .tc-trackacts button.void{visibility:hidden} /* the slot keeps its width, so ✕ never moves */ .tc-trackacts .trev{color:var(--mbu-accent-text)}.tc-trackacts .trev:hover{color:var(--mbu-accent-text)} .tc-trackacts .rm{color:var(--mbu-error);font-weight:bold}.tc-trackacts .rm:hover{color:var(--mbu-error)} /* #564 (majkinetor): "you should also change the blue color on the left that marks modified row, it doesn't look very visible". It was a hard-coded #5f3ec0 — the LIGHT theme's accent — so on a dark row it was a dark violet sliver against a dark background. --mbu-accent-text carries the theme (#b9a7f0 on dark), and 4px rather than 3 gives it a little more presence. Same marker in all three places it is drawn: tracklist rows, the mirror's own row rule, and the Recordings tab. */ .tc-mirror tr.tc-changed td:first-child{box-shadow:inset 4px 0 0 var(--mbu-accent-text)} /* a track that differs from its page-load state */ /* one artist = one aligned fixed-height line: credited-as · icon · search box · acts (no line between artists) */ .tc-aslot{display:flex;align-items:center;gap:5px;height:28px;box-sizing:border-box} .tc-cred{flex:none;width:130px;text-align:right;box-sizing:border-box;font:11px Arial;color:var(--mbu-text);border:1px solid transparent;background:transparent!important;padding:1px 4px 1px 15px;transition:color .12s} .tc-cred::placeholder{color:var(--mbu-text-weak)} .tc-credwrap{position:relative;flex:none;display:inline-flex;align-items:center} .tc-cred-clr{position:absolute;left:2px;top:50%;transform:translateY(-50%);z-index:2;display:none;border:none;background:none;color:var(--mbu-text-weak);cursor:pointer;font-size:12px;line-height:1;padding:0} .tc-aslot.tc-has-cred:hover .tc-cred-clr{display:block} .tc-cred-clr:hover{color:var(--mbu-error)} .tc-cred:hover,.tc-cred:focus{border-color:var(--mbu-accent);background:var(--mbu-bg)!important;color:var(--mbu-text)} .tc-aslot.tc-can-split .tc-cred{background:var(--mbu-warn-bg)!important;border-color:var(--mbu-warn);border-radius:3px;color:var(--mbu-warn)} .tc-aslot.tc-can-split .tc-cred::placeholder{color:var(--mbu-warn)} .tc-tic{flex:none;width:18px;height:16px;display:inline-flex;align-items:center;justify-content:center;color:var(--mbu-accent-text);text-decoration:none} .tc-tic.link{cursor:pointer}.tc-tic.link:hover{color:var(--mbu-accent-deep-text)}.tc-tic.dim{color:var(--mbu-accent-text)} .tc-tic.discogs-add{color:var(--mbu-info);cursor:pointer;background:var(--mbu-info-bg);border-radius:4px}.tc-tic.discogs-add:hover{color:var(--mbu-info);background:var(--mbu-info-bg)} .tc-tic.discogs-conflict{color:var(--mbu-error);cursor:pointer;background:var(--mbu-error-bg);border-radius:4px}.tc-tic.discogs-conflict:hover{color:var(--mbu-error);background:var(--mbu-error-bg)} .tc-tic.discogs-mismatch{color:var(--mbu-warn);cursor:pointer;background:var(--mbu-warn-bg);border-radius:4px}.tc-tic.discogs-mismatch:hover{color:var(--mbu-warn);background:var(--mbu-warn-bg)} /* one fixed-width search box per artist (so all lines align); name fills it, + + join sit at the right */ .tc-search{flex:1 1 0;min-width:0;align-self:stretch;display:flex;align-items:center;gap:4px;border:none;border-radius:4px;background:var(--mbu-bg);padding:0 6px;overflow:hidden;transition:box-shadow .12s} /* unmatched = plain white; the green fill marks a match. transition: fade the #284 hover-highlight ring in/out */ .tc-nm-clr{flex:none;display:none;align-items:center;border:none;background:none;color:var(--mbu-text-weak);cursor:pointer;font-size:16px;line-height:1;padding:0 2px;visibility:hidden} /* a little bigger */ .tc-search.tc-has-nm .tc-nm-clr{display:inline-flex} /* a filled slot always reserves the × slot (invisible) → hover doesn't shift the text */ .tc-search.tc-has-nm:hover .tc-nm-clr{visibility:visible} .tc-search.tc-has-nm .nm:not(:focus){flex:0 1 auto} /* when not editing, size the input to the name (its size attr) so the × sits right after it. Focused stays full-width for comfortable typing */ .tc-nm-clr:hover{color:var(--mbu-error)} .tc-search:focus-within{box-shadow:inset 0 0 0 1px #b9a4e0} .tc-search.matched{background:var(--mbu-ok-bg)} /* #284: hover-highlight every instance of the same artist (ring keeps the underlying matched/unmatched colour) */ /* #284 hover-highlight: a soft 1px ring + faint inset wash (box-shadow, so it doesn't disturb the matched-green BACKGROUND), and no bold (which reflowed → the "jump"). The base .tc-search/.tc-cred carry a short transition so it fades in AND out. */ .tc-aslot.tc-arthl .tc-search{box-shadow:inset 0 0 0 1px #b79ee8, inset 0 0 0 100px rgba(123,79,208,.06)} .tc-aslot.tc-arthl .tc-cred{color:var(--mbu-accent-text)} /* "Alternate row colors": tint the matched box a touch deeper on every other track (per row, so a multi-artist group stays one shade) — the only way the banding shows through the green fill */ .tc-mirror.alt tbody tr:nth-child(even) .tc-search.matched{background:var(--mbu-ok-bg)} /* group ALL of a track's artist boxes under ONE border: collapse adjacent boxes, round only the outer corners; the inner borders become subtle dividers between the individual artists. #119 */ .tc-mirror td.c-art .tc-aslot .tc-search{border-radius:0} .tc-mirror td.c-art .tc-aslot:first-child .tc-search{border-top-left-radius:4px;border-top-right-radius:4px} .tc-mirror td.c-art .tc-aslot:last-child .tc-search{border-bottom-left-radius:4px;border-bottom-right-radius:4px} .tc-mirror td.c-art .tc-aslot:not(:first-child) .tc-search{border-top:none} .tc-mirror td.c-art .tc-aslot:not(:last-child) .tc-search{border-bottom:none} /* no horizontal line between a track's artists — one seamless box */ /* split/multi-artist cue: a faint purple left stripe on tracks that have 2+ artists */ .tc-mirror td.c-art:has(.tc-aslot ~ .tc-aslot){box-shadow:inset 2px 0 0 #d8cbf0} @keyframes tcflash{0%{box-shadow:0 0 0 3px #e0a800}70%{box-shadow:0 0 0 3px #e0a800}100%{box-shadow:0 0 0 0 rgba(224,168,0,0)}} .tc-search.tc-flash{animation:tcflash 1.5s ease-out} .tc-search.tc-marked{border:2px solid var(--mbu-warn)} /* persists when a pick changed several tracks */ .tc-search .nm{flex:1 1 0;min-width:0;border:none;background:transparent!important;font:13px Arial;padding:3px 0;outline:none} .tc-search .tc-bar-aka{flex:0 1 auto;min-width:0;max-width:55%;margin-left:2px;color:var(--mbu-ok);font-size:11px;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none} .tc-search .tc-bar-disamb{flex:0 1 auto;min-width:0;max-width:55%;margin-left:4px;color:var(--mbu-text-weak);font-size:11px;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none} /* artist disambiguation, grey like native #195 */ .tc-search .mk{flex:none;order:9;cursor:pointer;border:none;background:none;color:var(--mbu-ok);font-weight:bold;font-size:15px;line-height:1;padding:0 2px}.tc-search .mk:hover{color:var(--mbu-ok)} /* order:9 keeps + pinned at the far right, past the join-phrase */ /* #580: + belongs to an unresolved slot, but it must also be reachable on a RESOLVED one the moment you focus it — that is how you create an artist that happens to share its name with the matched one. Hidden again on blur, so a settled table stays quiet. */ .tc-search.matched .mk{display:none} .tc-search.matched:has(input.nm:focus) .mk{display:inline-block} /* the name field specifically — not :focus-within, which the join-phrase input would also trip */ /* push + to the right edge only when there's NO join-phrase; with a join, .tc-joinwrap's margin-left:auto already pushes the right-group and + (order:9) follows it (two auto-margins would split the gap). #298 follow-up */ .tc-search:not(:has(.tc-joinwrap)) .mk{margin-left:auto} .tc-joinwrap{flex:none;margin-left:auto;display:flex;align-items:center;gap:0} .tc-join{width:auto;text-align:right;border:1px solid transparent;background:transparent!important;color:var(--mbu-text-dim);font:italic 900 12px var(--mbu-font);padding:1px 2px;border-radius:3px} .tc-join:hover,.tc-join:focus{border-color:var(--mbu-border);background:var(--mbu-bg)!important;color:var(--mbu-text)} /* #419: the caret needs a REAL hit target (padding-grown), not a bare 10px glyph */ .tc-joinarrow{cursor:pointer;border:none;background:none;color:var(--mbu-accent-text);font-size:12px;padding:3px 5px;margin:-3px 0;line-height:1;border-radius:3px} .tc-joinarrow:hover{color:var(--mbu-accent-text);background:var(--mbu-bg-hover)} /* #208 join-phrase spacing flags: ␣ where a space is missing, ␣?␣ when the phrase is missing entirely */ .tc-joinwrap.tc-jp-bad .tc-join{border-color:var(--tc-hl,#e53935);background:var(--mbu-error-bg)!important;color:var(--mbu-error)} .tc-jp-nolead::before,.tc-jp-notrail::after,.tc-jp-nophrase::before{background:var(--tc-hl,#e53935);color:var(--mbu-text-on-accent);border-radius:2px;padding:0 1px;font:700 11px Arial;line-height:1} .tc-jp-nolead::before{content:'␣'} .tc-jp-notrail::after{content:'␣'} .tc-jp-nophrase::before{content:'␣?␣'} .tc-joinpop .tc-acrow{justify-content:space-between;gap:14px}.tc-joinpop .cmt{color:var(--mbu-text-weak)} .tc-acts{flex:none;width:76px;display:flex;align-items:center;justify-content:flex-start;gap:4px;padding-left:4px} .tc-enter,.tc-slotx,.tc-splitb,.tc-slotgrab{cursor:pointer;border:none;background:none;padding:0 1px;visibility:hidden;line-height:1} .tc-enter{color:var(--mbu-accent-text);font-size:19px}.tc-enter:hover{color:var(--mbu-accent-text)} .tc-splitb{color:var(--mbu-accent-text);font-size:16px;font-weight:bold}.tc-splitb:hover{color:var(--mbu-accent-text)} .tc-aslot:not(.tc-can-split) .tc-splitb{display:none} .tc-slotgrab{cursor:grab;color:var(--mbu-accent-text);font-size:13px;user-select:none}.tc-slotgrab:hover{color:var(--mbu-accent-text)}.tc-slotgrab:active{cursor:grabbing} /* #150: drag to reorder this artist within the credit */ .tc-aslot.tc-slotdragging{opacity:.45} .tc-aslot.tc-slotdrop-before{box-shadow:inset 0 2px 0 #5f3ec0}.tc-aslot.tc-slotdrop-after{box-shadow:inset 0 -2px 0 #5f3ec0} .tc-slotx{color:var(--mbu-error);font-size:13px}.tc-slotx:hover{color:var(--mbu-error)} .tc-mirror tr:hover .tc-enter,.tc-mirror tr:hover .tc-slotx,.tc-mirror tr:hover .tc-splitb,.tc-mirror tr:hover .tc-slotgrab{visibility:visible} .tc-acpop{position:fixed;z-index:100002;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:4px;box-shadow:0 6px 22px rgba(40,20,80,.3);max-height:300px;overflow:auto;font:12px Arial;min-width:210px} .tc-acrow{display:flex;align-items:center;gap:7px;padding:4px 9px;cursor:pointer} .tc-acrow:hover,.tc-acrow.hi{background:var(--mbu-bg-hover)} .tc-acrow .tic{flex:none;width:17px;display:inline-flex;align-items:center;justify-content:center;color:var(--mbu-accent-text)}.tc-acrow a.tic{text-decoration:none}.tc-acrow a.tic:hover{color:var(--mbu-accent-deep-text)} .tc-acrow .nm{font-weight:600;color:var(--mbu-text)}.tc-acrow .cmt{color:var(--mbu-text-weak);font-size:11px} .tc-acrow .tc-aka{color:var(--mbu-ok);font-size:11px;font-style:italic} .tc-acrow.none{color:var(--mbu-text-weak);font-style:italic;cursor:default} .tc-acsec{padding:5px 9px 2px;font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:var(--mbu-text-weak);cursor:default}.tc-acsec:not(:first-child){border-top:1px solid var(--mbu-border-soft);margin-top:3px} .tc-acrow .tc-acvotes{margin-left:auto;padding-left:10px;font-size:10px;color:var(--mbu-text-weak);white-space:nowrap} .tc-acmore{justify-content:center;font-style:italic;color:var(--mbu-accent-text);border-top:1px solid var(--mbu-accent);position:sticky;bottom:0;background:var(--mbu-bg-raised)} .tc-acrow.exact{background:var(--mbu-ok-bg)}.tc-acrow.exact .nm{color:var(--mbu-ok)}.tc-acrow.exact:hover,.tc-acrow.exact.hi{background:var(--mbu-ok-bg)} .tc-toolbar{padding:5px 4px;font-size:12px;color:var(--mbu-text-dim);display:flex;align-items:center;gap:6px} .tc-toolbar select{font:12px Arial;padding:1px} .tc-medhdr{background:var(--mbu-bg-hover);font-weight:bold;color:var(--mbu-accent-deep-text);padding:4px 8px} #tc-panel{position:fixed;top:90px;right:18px;width:720px;max-width:96vw;max-height:84vh;background:var(--mbu-bg); border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 8px 34px rgba(40,20,80,.32);z-index:99999; display:flex;flex-direction:column;font:13px/1.4 Arial,Helvetica,sans-serif;color:var(--mbu-text)} #tc-hdr{display:flex;align-items:center;gap:8px;padding:8px 11px;background:var(--mbu-bg-hover);border-bottom:1px solid var(--mbu-accent);border-radius:6px 6px 0 0;cursor:move;user-select:none} #tc-hdr b{flex:1;color:var(--mbu-accent-text);font-size:14px}#tc-hdr .meta{font-size:12px;color:var(--mbu-text-dim)} #tc-body{flex:1;overflow:auto} #tc-foot{display:flex;align-items:center;gap:8px;padding:8px 11px;border-top:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);border-radius:0 0 6px 6px} #tc-foot .sp{flex:1} /* the global toolbar stays pinned at the top while scrolling the tracklist */ #tc-mirror-wrap{margin:4px 0 6px;position:sticky;top:0;z-index:50;background:var(--mbu-bg);border-bottom:1px solid var(--mbu-accent);box-shadow:0 3px 8px rgba(40,20,80,.07)} /* #412: MusicBrainz can take a while to save a big edit — pulse the pinned Apollo bar the user actually presses Enter-edit on (the compact nav bar), so it's obvious the save is in flight. inset box-shadow tints without disturbing the bar's own background. Also covers the tracklist / recordings toolbars when the compact nav is off. */ @keyframes tc-saving-pulse{0%,100%{box-shadow:inset 0 0 0 9999px rgba(28,111,214,0)}50%{box-shadow:inset 0 0 0 9999px rgba(28,111,214,.18)}} body.tc-saving #tc-nav-bar,body.tc-saving #tc-bar,body.tc-saving #tc-recwrap .tc-recbar{animation:tc-saving-pulse 1.1s ease-in-out infinite;border-radius:5px} .tc-medsec{margin:2px 0 14px} #tc-bar{display:flex;align-items:center;gap:8px;padding:6px 4px;flex-wrap:nowrap;min-width:0} /* #280: never wrap → toast / "added link" text can't push the toolbar taller */ #tc-bar b{color:var(--mbu-accent-text)}#tc-bar .sp{flex:1 1 0;min-width:0} #tc-bar > .tc-btn{flex:none;white-space:nowrap} /* #280: Match / ▾ never shrink → never wrap to 2 lines when toast text appears */ .tc-globalstat{flex:none;font-size:12px;color:var(--mbu-text-weak);font-style:italic;white-space:nowrap} .tc-am-lbl{flex:none;display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--mbu-text-dim);white-space:nowrap}.tc-am-lbl b{color:var(--mbu-accent)}.tc-applymode{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;min-width:84px;border:1px solid var(--mbu-accent);border-radius:5px;background:var(--mbu-bg)}.tc-am-thumb{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);background:var(--mbu-accent);border-radius:3px;transition:transform .15s ease}.tc-applymode:has(input[value=single]:checked) .tc-am-thumb{transform:translateX(100%)}.tc-applymode input{position:absolute;opacity:0;width:0;height:0;margin:0}.tc-applymode label{position:relative;z-index:1;text-align:center;padding:1px 8px;cursor:pointer;color:var(--mbu-text-dim)}.tc-applymode label:hover{color:var(--mbu-text)}.tc-applymode label:has(input:checked){color:var(--mbu-text-on-accent);font-weight:bold}.tc-applymode:has(input:focus-visible){outline:2px solid var(--mbu-accent);outline-offset:2px} .tc-globalstat.tc-unres{font-style:normal;font-weight:bold;color:var(--mbu-text-on-accent);background:#d6342c;padding:1px 8px;border-radius:9px} /* #227: persistent "N missing Discogs links" badge (teal, like the DISC match badge) */ .tc-discstat{flex:none;font-size:12px;color:var(--mbu-text-weak);font-style:italic;white-space:nowrap} .tc-discstat.tc-disc-badge{font-style:normal;font-weight:bold;color:var(--mbu-text-on-accent);background:#0a7a8c;padding:1px 8px;border-radius:9px} .tc-discstat.tc-disc-ok{font-style:normal;font-weight:bold;color:var(--mbu-ok)} .tc-discstat.tc-disc-pend{font-style:normal;font-weight:bold;color:var(--mbu-text-on-accent);background:#c98a00;padding:1px 8px;border-radius:9px} .tc-tablewrap{overflow-x:auto} .tc-addrow{padding:8px 4px;font-size:13px;color:var(--mbu-text-dim);display:flex;align-items:center;gap:6px} .tc-addrow input.tc-addn{width:54px;font:13px Arial;padding:2px 4px;border:1px solid var(--mbu-border);border-radius:3px} .tc-addbtn{width:22px;height:22px;border-radius:50%;border:1px solid var(--mbu-accent);background:transparent;color:var(--mbu-accent-text);font:bold 15px/1 Arial;cursor:pointer;display:inline-flex;align-items:center;justify-content:center} .tc-addbtn:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-text);border-color:var(--mbu-accent)} /* #330: pregap/data-track toggles + row markers */ .tc-medopts{display:inline-flex;align-items:center;gap:12px;margin-left:14px} .tc-mmo{display:inline-flex;align-items:center;flex-wrap:wrap;gap:2px 8px}.tc-mmo label{display:inline-flex;align-items:center;gap:2px;cursor:pointer;font-size:12px}.tc-mmo-lab{font-size:12px;color:var(--mbu-text-dim);margin-right:2px} /* #615 */ .tc-spo{display:inline-flex;align-items:center;gap:6px} /* #615 */ .tc-medopt{display:inline-flex;align-items:center;gap:3px;font-size:12px;color:var(--mbu-text-dim);cursor:pointer;white-space:nowrap} .tc-medopt:has(input:disabled){color:var(--mbu-text-weak);cursor:default} /* #330: disc-ID medium — toggle shown but locked */ .tc-trkkind{flex:none;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--mbu-ok);background:var(--mbu-ok-bg);border:1px solid var(--mbu-ok);border-radius:8px;padding:0 5px;margin-right:5px} .tc-mirror tr.tc-row-pregap td,.tc-mirror tr.tc-row-data td{background:var(--mbu-bg-raised)} /* #584 — the video marker also rides in the tracklist; its own rules live in recStyle(), which only runs once the Recordings tab has been opened, so repeat them here rather than leaving an unstyled glyph on first paint. */ .tc-mirror .tc-rec-video{display:inline-flex;flex:none;vertical-align:-2px;color:var(--mbu-accent-text);margin-right:4px} .tc-mirror .tc-rec-video svg{display:block} .tc-mirror tr.tc-datadiv td{background:var(--mbu-bg-hover);color:var(--mbu-accent-deep-text);font-size:11px;font-weight:600;letter-spacing:.04em;padding:3px 8px} /* #586 data-track boundary buttons — hover-revealed, but always in layout */ .tc-mirror .tc-dtmv{visibility:hidden;cursor:pointer;background:none;border:none;padding:0 2px;margin-left:2px;font-size:13px;line-height:1;color:var(--mbu-text-weak)} .tc-mirror tr:hover .tc-dtmv,.tc-mirror tr.tc-row-data .tc-dtmv{visibility:visible} .tc-mirror .tc-dtmv:hover{color:var(--mbu-accent-text)} .tc-mirror .tc-dtmv.void{visibility:hidden!important;cursor:default} /* holds the slot on the last track */ .tc-mirror th .tc-hstatus{font-weight:normal;font-style:italic;color:var(--mbu-text-weak);margin-left:12px;font-size:11px} .tc-mirror th .tc-hstatus.tc-unres{font-style:normal;font-weight:bold;color:var(--mbu-text-on-accent);background:#d6342c;padding:1px 7px;border-radius:9px;font-size:11px} .tc-mirror th .tc-hdr-am{float:right;font-weight:normal;font-style:normal;font-size:11px;color:var(--mbu-text);margin-right:14px;max-width:140px} .tc-tools{display:flex;align-items:center;gap:6px 8px;flex:1 1 auto;min-width:0;flex-wrap:wrap} /* #280: label + tools wrap together; wrapped rows start at the left (under the label) */ .tc-toolslabel{flex:none;font:800 11px Arial;letter-spacing:.03em;text-transform:uppercase;color:var(--mbu-accent-text);cursor:pointer;white-space:nowrap;border-bottom:1px dotted var(--mbu-accent);line-height:1.5} .tc-toolslabel:hover{color:var(--mbu-accent-deep-text)} .tc-toolbtns{display:contents} /* #280: its buttons/groups are direct flex items of .tc-tools so they wrap to the left edge */ .tc-toolbtn{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);border-radius:5px;padding:4px 9px;font:13px Arial;color:var(--mbu-text-dim);cursor:pointer;white-space:nowrap;flex:none} .tc-toolbtn:hover{border-color:var(--mbu-accent);background:var(--mbu-bg-hover)} .tc-toolbtn.active{background:var(--mbu-accent);border-color:var(--mbu-accent);color:var(--mbu-text-on-accent);font-weight:600} .tc-toolbtn .tc-tbic{font-size:13px;line-height:1} .tc-icimg{height:14px;width:auto;vertical-align:-2px;display:inline-block} /* image-based tool icon (bridged tools can ship their own) */ .tc-mi-ic .tc-icimg{height:15px} .tc-tc-ic .tc-icimg{height:16px} .tc-tc-seg.cb-icon .tc-icimg{height:13px} .tc-toolbtn.instant .tc-tbic{color:var(--mbu-ok)} .tc-toolbtn.active .tc-tbic{color:var(--mbu-text-on-accent)} .tc-toolbtn.tc-more{font-weight:bold;color:var(--mbu-accent-text);border-style:dashed} .tc-toolopts{display:flex;align-items:center;gap:6px;min-width:0} .tc-gco,.tc-sro,.tc-colso,.tc-medo{display:flex;align-items:center;gap:8px} .tc-colso{gap:4px} .tc-colbtn{font:12px Arial;padding:2px 9px;border:1px solid var(--mbu-border);border-radius:4px;background:var(--mbu-bg);cursor:pointer;color:var(--mbu-text)} /* #455 track length parser panel — centred, draggable, textarea+chooser | list */ .tc-lppop{position:fixed;z-index:100003;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:7px;box-shadow:0 8px 26px rgba(40,20,80,.28);font:12px Arial;color:var(--mbu-text);width:832px;height:64vh;min-width:520px;min-height:300px;max-width:94vw;max-height:88vh;display:flex;flex-direction:column;resize:both;overflow:hidden} .tc-lp-hd{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--mbu-accent);cursor:move;user-select:none} .tc-lp-back{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font:600 11px Arial;border-radius:5px;padding:3px 9px;white-space:nowrap}.tc-lp-back:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-lp-t{font:700 11px Arial;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-accent-text);flex:1} .tc-lp-err{font:bold 11px Arial;color:var(--mbu-text-on-accent);background:var(--mbu-error-bg);border-radius:var(--mbu-radius-lg);padding:2px 9px;white-space:nowrap;box-shadow:0 1px 3px rgba(197,48,48,.4)} /* #455.3 header invalid badge */ .tc-lp-med,.tc-lp-med1{font:12px Arial;color:var(--mbu-text);cursor:default} .tc-lp-x{border:none;background:none;cursor:pointer;font-size:14px;color:var(--mbu-text-weak);padding:0 2px}.tc-lp-x:hover{color:var(--mbu-text)} /* body: left (textarea + chooser overlay) | list */ .tc-lp-body{flex:1;min-height:0;display:flex;gap:8px;padding:8px 10px} .tc-lp-left{flex:0 0 45%;position:relative;display:flex} .tc-lp-ta{flex:1;resize:none;font:12px ui-monospace,Consolas,monospace;border:1px solid var(--mbu-accent);border-radius:5px;padding:6px 8px} /* chooser — overlaid on the empty textarea (#455 round 3) */ .tc-lp-choose{position:absolute;inset:0;background:var(--mbu-bg-raised);border:1px solid var(--mbu-accent);border-radius:5px;display:flex;flex-direction:column;gap:12px;align-items:center;justify-content:center;padding:16px} .tc-lp-crow{display:flex;gap:10px;flex-wrap:wrap;justify-content:center} .tc-lp-cbtn{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-deep-text);font:600 13px Arial;border-radius:7px;padding:11px 16px}.tc-lp-cbtn:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-lp-clbl{font:600 12px Arial;color:var(--mbu-accent-text);margin-top:6px} .tc-lp-favs{display:flex;gap:10px;flex-wrap:wrap;justify-content:center;align-items:center;min-height:34px} .tc-lp-fav{width:32px;height:32px;cursor:pointer;border:1px solid var(--mbu-accent);border-radius:7px;padding:4px;background:var(--mbu-bg)}.tc-lp-fav:hover{border-color:var(--mbu-accent);background:var(--mbu-bg-hover);transform:scale(1.08)} .tc-lp-favtxt{cursor:pointer;border:1px solid var(--mbu-accent);border-radius:7px;padding:6px 9px;background:var(--mbu-bg);font:600 11px Arial;color:var(--mbu-accent-deep-text);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tc-lp-favtxt:hover{border-color:var(--mbu-accent);background:var(--mbu-bg-hover)} .tc-lp-favload,.tc-lp-nofav{font:12px Arial;color:var(--mbu-text-weak);font-style:italic} .tc-lp-list{flex:1;min-width:0;overflow:auto;border:1px solid var(--mbu-divider);border-radius:5px;padding:3px} .tc-lp-row{display:flex;align-items:center;gap:6px;padding:2px 3px;border-radius:3px}.tc-lp-row:hover{background:var(--mbu-bg-raised)} .tc-lp-tk{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--mbu-text);font-size:12px} .tc-lp-tk.none{color:var(--mbu-text-weak);font-style:italic} .tc-lp-val{width:80px;font:12px ui-monospace,Consolas,monospace;text-align:right;border:1px solid var(--mbu-border);border-radius:4px;padding:2px 5px} .tc-lp-val.bad{border-color:var(--mbu-error);background:var(--mbu-bg-raised);color:var(--mbu-error)} .tc-lp-add,.tc-lp-del{border:1px solid transparent;background:none;cursor:pointer;font-size:13px;line-height:1;border-radius:4px;padding:2px 5px} .tc-lp-add{color:var(--mbu-ok)}.tc-lp-add:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-ok)} .tc-lp-del{color:var(--mbu-error)}.tc-lp-del:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-error)} .tc-lp-addend{display:block;width:calc(100% - 6px);margin:4px 3px 2px;border:1px dashed var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);cursor:pointer;font:12px Arial;border-radius:5px;padding:5px}.tc-lp-addend:hover{background:var(--mbu-bg-hover)} .tc-lp-ft{display:flex;align-items:center;gap:10px;padding:7px 12px;border-top:1px solid var(--mbu-accent)} .tc-lp-cnt{flex:1;font:11px Arial;color:var(--mbu-text-dim)} .tc-lp-cnt.ok{color:var(--mbu-ok)}.tc-lp-cnt.warn{color:var(--mbu-warn)}.tc-lp-cnt.bad{color:var(--mbu-error);font-weight:bold} .tc-lp-acts{display:flex;gap:8px} .tc-lp-cancel{cursor:pointer;border:1px solid var(--mbu-border);background:var(--mbu-bg);font:12px Arial;border-radius:4px;padding:4px 12px} .tc-lp-ok{cursor:pointer;border:1px solid var(--mbu-ok);background:var(--mbu-bg-raised);color:var(--mbu-ok);font:bold 12px Arial;border-radius:4px;padding:4px 12px}.tc-lp-ok:hover:not(:disabled){background:var(--mbu-ok-bg)}.tc-lp-ok:disabled{opacity:.5;cursor:not-allowed} /* #456 pattern Track parser */ .tc-tpppop{position:fixed;top:9vh;left:50%;transform:translateX(-50%);z-index:100003;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:7px;box-shadow:0 8px 26px rgba(40,20,80,.28);font:12px Arial;color:var(--mbu-text);width:920px;height:70vh;min-width:640px;min-height:340px;max-width:96vw;max-height:88vh;display:flex;flex-direction:column;resize:both;overflow:hidden} .tc-tpp-hd{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--mbu-accent);cursor:move;user-select:none} .tc-tpp-t{font:700 11px Arial;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-accent-text);flex:1} .tc-tpp-med,.tc-tpp-med1{font:12px Arial;color:var(--mbu-text)} .tc-tpp-max{border:none;background:none;cursor:pointer;font-size:13px;color:var(--mbu-text-weak);padding:0 2px}.tc-tpp-max:hover{color:var(--mbu-accent-text)} .tc-tpp-x{border:none;background:none;cursor:pointer;font-size:14px;color:var(--mbu-text-weak);padding:0 2px}.tc-tpp-x:hover{color:var(--mbu-text)} .tc-tpp-pat{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--mbu-accent);flex-wrap:wrap} .tc-tpp-plbl{font:700 11px Arial;color:var(--mbu-accent-text)} .tc-tpp-piwrap{flex:1;min-width:160px;position:relative;display:flex} .tc-tpp-pi{flex:1;min-width:0;font:13px ui-monospace,Consolas,monospace;border:1px solid var(--mbu-accent);border-radius:5px;padding:5px 24px 5px 8px;color:var(--mbu-accent-deep-text)} .tc-tpp-piclr{position:absolute;right:5px;top:50%;transform:translateY(-50%);display:none;border:none;background:none;cursor:pointer;color:var(--mbu-accent-text);font-size:12px;line-height:1;padding:2px}.tc-tpp-piclr:hover{color:var(--mbu-error)} .tc-tpp-piwrap.has .tc-tpp-piclr{display:block} .tc-tpp-presets{display:flex;gap:5px;flex-wrap:wrap} .tc-tpp-chip{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font:12px ui-monospace,Consolas,monospace;border-radius:4px;padding:3px 7px}.tc-tpp-chip:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-tpp-split{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font:11px Arial;border-radius:4px;padding:3px 8px;white-space:nowrap}.tc-tpp-split:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)}.tc-tpp-split b{color:var(--mbu-accent-text)} .tc-tpp-freeze{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font:11px Arial;border-radius:4px;padding:3px 8px;white-space:nowrap}.tc-tpp-freeze:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-tpp-chipbar{position:fixed;z-index:100005;display:flex;gap:2px;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 4px 16px rgba(40,20,80,.32);padding:3px} .tc-tpp-chipbar button{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font:bold 12px ui-monospace,Consolas,monospace;border-radius:4px;padding:3px 8px;min-width:22px}.tc-tpp-chipbar button:hover{background:var(--mbu-accent);color:var(--mbu-text-on-accent);border-color:var(--mbu-accent)} .tc-tpp-chipbar button[data-clr]{color:var(--mbu-error)}.tc-tpp-chipbar button[data-clr]:hover{background:#c0392b;color:var(--mbu-text-on-accent);border-color:var(--mbu-error)} .tc-tpp-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;padding:8px 10px} .tc-tpp-src{flex:0 0 auto;display:flex;flex-direction:column;gap:4px} .tc-tpp-srctgl{align-self:flex-start;cursor:pointer;border:none;background:none;color:var(--mbu-accent-text);font:600 11px Arial;padding:2px}.tc-tpp-srctgl:hover{color:var(--mbu-accent-deep-text);text-decoration:underline} .tc-tpp-src.tc-collapsed .tc-tpp-ta{display:none} .tc-tpp-ta{height:15vh;min-height:44px;resize:vertical;font:12px ui-monospace,Consolas,monospace;border:1px solid var(--mbu-accent);border-radius:5px;padding:6px 8px} .tc-tpp-tblwrap{flex:1;min-height:0;overflow:auto;border:1px solid var(--mbu-divider);border-radius:5px} .tc-tpp-tbl{width:100%;border-collapse:collapse;font-size:12px} .tc-tpp-tbl th{position:sticky;top:0;background:var(--mbu-bg-raised);text-align:left;font:600 10px Arial;letter-spacing:.03em;text-transform:uppercase;color:var(--mbu-accent-text);padding:4px 6px;border-bottom:1px solid var(--mbu-accent)} .tc-tpp-tbl td{padding:2px 6px;border-bottom:1px solid var(--mbu-accent);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px} .tc-tpp-tr.notrk{opacity:.5} .tc-tpp-dot{width:14px;padding-left:8px!important}.tc-tpp-dot span{display:inline-block;width:8px;height:8px;border-radius:50%} .tc-tpp-pcell{width:120px} .tc-tpp-ovwrap{position:relative;display:inline-flex;width:112px} .tc-tpp-ov{width:100%;font:11px ui-monospace,Consolas,monospace;border:1px solid var(--mbu-accent);border-radius:4px;padding:2px 16px 2px 5px;color:var(--mbu-accent-text);background:var(--mbu-bg-raised)}.tc-tpp-ov:placeholder-shown{color:var(--mbu-text-weak);border-style:dashed} .tc-tpp-ovclr{position:absolute;right:2px;top:50%;transform:translateY(-50%);display:none;border:none;background:none;cursor:pointer;color:var(--mbu-accent-text);font-size:10px;line-height:1;padding:1px}.tc-tpp-ovclr:hover{color:var(--mbu-error)} .tc-tpp-ovwrap.has .tc-tpp-ovclr{display:block} /* #456: raw stays fully visible/selectable — no ellipsis clipping (you select spans here to bind fields). The td.tc-tpp-raw specificity beats the generic .tc-tpp-tbl td ellipsis rule above. */ .tc-tpp-tbl td.tc-tpp-raw{color:var(--mbu-text-dim);font:11px ui-monospace,Consolas,monospace;max-width:320px;cursor:text;user-select:text;white-space:normal;overflow:visible;text-overflow:clip;overflow-wrap:anywhere;outline:none} /* #732: the raw cell is editable in place — an edit goes back to that line of the paste box */ .tc-tpp-tbl td.tc-tpp-raw:hover{background:var(--mbu-bg-hover)} .tc-tpp-tbl td.tc-tpp-raw:focus{color:var(--mbu-text);background:var(--mbu-bg-sunken);box-shadow:inset 0 0 0 1px var(--mbu-accent)} .tc-tpp-tbl td.tc-tpp-raw:empty::before{content:'(empty)';color:var(--mbu-text-weak);font-style:italic} .tc-tpp-c{color:var(--mbu-text)} .tc-tpp-ft{display:flex;align-items:center;gap:10px;padding:7px 12px;border-top:1px solid var(--mbu-accent)} .tc-tpp-cnt{flex:1;font:11px Arial;color:var(--mbu-text-dim)}.tc-tpp-cnt.ok{color:var(--mbu-ok)}.tc-tpp-cnt.warn{color:var(--mbu-warn)} .tc-tpp-acts{display:flex;gap:0;position:relative} .tc-tpp-ok{cursor:pointer;border:1px solid var(--mbu-ok);background:var(--mbu-bg-raised);color:var(--mbu-ok);font:bold 12px Arial;border-radius:4px 0 0 4px;padding:4px 12px}.tc-tpp-ok:hover:not(:disabled){background:var(--mbu-ok-bg)}.tc-tpp-ok:disabled{opacity:.5;cursor:not-allowed} .tc-tpp-menu{cursor:pointer;border:1px solid var(--mbu-ok);border-left:none;background:var(--mbu-bg-raised);color:var(--mbu-ok);font:bold 12px Arial;border-radius:0 4px 4px 0;padding:4px 7px}.tc-tpp-menu:hover:not(:disabled){background:var(--mbu-ok-bg)}.tc-tpp-menu:disabled{opacity:.5;cursor:not-allowed} .tc-tpp-mpop{position:absolute;bottom:calc(100% + 4px);right:0;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 6px 22px rgba(40,20,80,.3);padding:4px 0;min-width:200px;z-index:5} .tc-tpp-mi{display:block;width:100%;text-align:left;border:none;background:none;cursor:pointer;font:12px Arial;color:var(--mbu-text);padding:6px 12px}.tc-tpp-mi:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)} .tc-tpp-mhr{height:1px;background:var(--mbu-bg-sunken);margin:4px 0} .tc-colbtn:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} /* #152/#375: Search & Replace — RE toggle, search caret, invalid-regex flag, Saved & History popup */ .tc-srbtn{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);font:bold 11px Arial;border-radius:4px;padding:3px 8px;white-space:nowrap} .tc-srbtn:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-srbtn.on{background:var(--mbu-accent);color:var(--mbu-text-on-accent);border-color:var(--mbu-accent)} .tc-sr-find.tc-sr-bad{border-color:var(--mbu-error)!important;background:var(--mbu-bg-raised)} /* #409 chain mode — the read-only chain chip replaces the search/replace inputs */ .tc-sro-chain .tc-sr-find,.tc-sro-chain .tc-sr-rep,.tc-sro-chain .tc-sr-re{display:none} .tc-sr-chainchip{display:none;align-items:center;gap:6px;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);border-radius:4px;padding:3px 6px 3px 9px;color:var(--mbu-accent-text);font:bold 12px Arial;white-space:nowrap} .tc-sro-chain .tc-sr-chainchip{display:inline-flex} .tc-sr-chainx{border:none;background:none;color:var(--mbu-accent-text);cursor:pointer;font-size:12px;line-height:1;padding:0 2px} .tc-sr-chainx:hover{color:var(--mbu-error)} .tc-sr-star{color:var(--mbu-warn);font-size:13px;line-height:1;padding:3px 8px} /* #375 fav-star button opens Saved & History */ .tc-sr-star:hover{color:var(--mbu-warn);background:var(--mbu-bg-raised);border-color:var(--mbu-warn)} .tc-srtpl{position:fixed;z-index:100003;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:7px;box-shadow:0 8px 26px rgba(40,20,80,.28);font:12px Arial;color:var(--mbu-text);min-width:460px;max-width:680px;max-height:70vh;overflow:auto} .tc-srtpl-hd{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--mbu-accent);position:sticky;top:0;background:var(--mbu-bg);z-index:1} .tc-srtpl-hdt{font:700 11px Arial;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-accent-text)} .tc-srtpl-savebtn{cursor:pointer;border:1px solid var(--mbu-ok);background:var(--mbu-bg-raised);color:var(--mbu-ok);font:bold 11px Arial;border-radius:4px;padding:3px 9px;white-space:nowrap} .tc-srtpl-savebtn:hover{background:var(--mbu-ok-bg);border-color:var(--mbu-ok)} .tc-srtpl-savewrap{display:flex;align-items:center;gap:6px;flex:1} .tc-srtpl-saveok{cursor:pointer;border:1px solid var(--mbu-ok);background:var(--mbu-bg-raised);color:var(--mbu-ok);font:bold 12px Arial;border-radius:4px;padding:2px 8px} .tc-srtpl-saveok:hover{background:var(--mbu-ok-bg)} /* #409 redesign: Import/Export button pinned right; JSON textarea; section headers right-aligned */ .tc-srtpl-iobtn{margin-left:auto;cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);font:600 11px Arial;border-radius:4px;padding:3px 10px;white-space:nowrap} .tc-srtpl-iobtn:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-srtpl-impok{margin-left:auto} .tc-srtpl-io{width:calc(100% - 24px);margin:10px 12px;min-height:260px;box-sizing:border-box;border:1px solid var(--mbu-accent);border-radius:4px;padding:8px;font:12px ui-monospace,Consolas,Menlo,monospace;color:var(--mbu-text);resize:vertical} .tc-srtpl-io:focus{outline:none;border-color:var(--mbu-accent)} .tc-srtpl-empty{padding:12px;color:var(--mbu-text-weak);font-style:italic} .tc-srtpl-sec{font:700 10px Arial;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-accent-text);background:var(--mbu-bg-raised);padding:5px 12px;border-top:1px solid var(--mbu-accent);border-bottom:1px solid var(--mbu-accent);text-align:right} .tc-srtpl-sectog:hover{color:var(--mbu-accent-text);background:var(--mbu-bg-raised)} .tc-srtpl-caret{display:inline-block;color:var(--mbu-accent-text);font-size:9px} .tc-srtpl-row{display:grid;grid-template-columns:1.1fr 1.4fr 1.4fr 24px 82px;align-items:center;gap:8px;padding:5px 12px;cursor:pointer;border-bottom:1px solid var(--mbu-accent)} .tc-srtpl-defrow{background:var(--mbu-bg-raised)} .tc-srtpl-def{visibility:hidden;border:none;background:none;color:var(--mbu-accent-text);cursor:pointer;font-size:12px;padding:0;line-height:1} .tc-srtpl-row:hover .tc-srtpl-def,.tc-srtpl-row.tc-srtpl-sel .tc-srtpl-def{visibility:visible}.tc-srtpl-def:hover{color:var(--mbu-warn)} .tc-srtpl-def.on{visibility:visible;color:var(--mbu-warn)} .tc-srtpl-row:hover,.tc-srtpl-row.tc-srtpl-sel{background:var(--mbu-bg-hover)} .tc-srtpl-nm{font-weight:600;color:var(--mbu-accent-deep-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tc-srtpl-f,.tc-srtpl-r{font-family:ui-monospace,Consolas,'Liberation Mono',Menlo,monospace;color:var(--mbu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* #375: curly quotes render cleanly (Courier New mangled them) */ .tc-srtpl-re{font:bold 10px Arial;color:var(--mbu-accent-text);text-align:center} .tc-srtpl-tail{display:inline-flex;gap:5px;align-items:center;justify-content:flex-end} .tc-srtpl-x{visibility:hidden;border:none;background:none;color:var(--mbu-error);cursor:pointer;font-size:12px;padding:0;line-height:1} .tc-srtpl-row:hover .tc-srtpl-x,.tc-srtpl-row.tc-srtpl-sel .tc-srtpl-x{visibility:visible}.tc-srtpl-x:hover{color:var(--mbu-error)} /* #409 chains */ .tc-srtpl-chainadd{visibility:hidden;border:none;background:none;color:var(--mbu-accent-text);cursor:pointer;font-size:12px;padding:0;line-height:1} .tc-srtpl-row:hover .tc-srtpl-chainadd,.tc-srtpl-row.tc-srtpl-sel .tc-srtpl-chainadd{visibility:visible}.tc-srtpl-chainadd:hover{color:var(--mbu-accent-text)} .tc-srtpl-rename{visibility:hidden;border:none;background:none;color:var(--mbu-accent-text);cursor:pointer;font-size:12px;padding:0;line-height:1} .tc-srtpl-row:hover .tc-srtpl-rename,.tc-srtpl-row.tc-srtpl-sel .tc-srtpl-rename{visibility:visible}.tc-srtpl-rename:hover{color:var(--mbu-accent-text)} .tc-srtpl-renameinp{width:100%;box-sizing:border-box;border:1px solid var(--mbu-accent);border-radius:3px;padding:1px 5px;font:600 12px Arial;color:var(--mbu-accent-deep-text)} .tc-srtpl-renameinp:focus{outline:none;border-color:var(--mbu-accent)} .tc-srtpl-chnm{color:var(--mbu-accent-text)} .tc-srtpl-chm{font-family:Arial !important;color:var(--mbu-accent-text) !important;font-style:italic} .tc-srtpl-cpick{position:absolute;z-index:5;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 6px 18px rgba(40,20,80,.22);padding:4px;min-width:130px} .tc-srtpl-cpick-row{padding:4px 9px;cursor:pointer;border-radius:4px;color:var(--mbu-accent-deep-text);white-space:nowrap} .tc-srtpl-cpick-row:hover{background:var(--mbu-bg-hover)} .tc-srtpl-cpick-empty{padding:6px 9px;color:var(--mbu-text-weak);font-style:italic;white-space:nowrap} .tc-srtpl-name{flex:1;min-width:120px;box-sizing:border-box;border:1px solid var(--mbu-accent);border-radius:4px;padding:3px 7px;font:13px Arial} .tc-srtpl-name:focus{border-color:var(--mbu-accent);outline:none} .tc-toolopts label,.tc-opt label{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text-dim)} .tc-toolopts input[type=text],.tc-opt input[type=text]{font:12px Arial;padding:2px 5px;border:1px solid var(--mbu-border);border-radius:3px;width:120px} .tc-toolopts input[type=text]::placeholder,.tc-opt input[type=text]::placeholder{color:var(--mbu-text-weak)} .tc-toolopts select,.tc-opt select{font:12px Arial;padding:1px} /* #280 — pinned tools' params on the 2nd toolbar row (scrolls if they overflow) */ .tc-bar2{display:flex;align-items:center;gap:10px;padding:2px 4px 7px;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:thin} .tc-opt{display:inline-flex;align-items:center;gap:7px;background:var(--mbu-bg-raised);border:1px solid var(--mbu-accent);border-radius:7px;padding:3px 9px;flex:none} .tc-opt.tc-collapsed{cursor:pointer} .tc-optname{font:700 12px Arial;color:var(--mbu-accent-text);display:inline-flex;align-items:center;gap:4px;white-space:nowrap} .tc-opttrig{cursor:pointer;border-radius:5px;padding:2px 5px;border:1px solid transparent} .tc-opttrig:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-opt .tc-tbic{font-size:13px} /* #280 — right-click a tool name to collapse its params; they flyout on hover/focus */ .tc-opt.tc-collapsed{position:relative} .tc-opt.tc-collapsed > .tc-optname{border-bottom:1px dotted var(--mbu-accent)} /* flyout TOUCHES the name (top:100% over the group's padding-bottom) so the hover area is contiguous — no dead gap that would drop :hover before you reach it */ .tc-opt.tc-collapsed > :not(.tc-optname){display:none;position:absolute;top:100%;left:-1px;z-index:100000;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:0 7px 7px 7px;box-shadow:0 6px 22px rgba(40,20,80,.3);padding:7px 10px} .tc-opt.tc-collapsed:hover > :not(.tc-optname),.tc-opt.tc-collapsed:focus-within > :not(.tc-optname){display:flex} /* The flyout is positioned against the collapsed group, which is only as wide as its icon — so it shrank to that and every label wrapped a word per line ("into / first / ticked:"). Size it to its content instead, capped to the viewport. And a flyout kept open only by :focus-within (a ticked checkbox keeps focus) closes while you hover another collapsed tool — two flyouts overlapped. */ .tc-opt.tc-collapsed > :not(.tc-optname){width:max-content;max-width:min(720px,92vw);white-space:nowrap;flex-wrap:wrap;align-items:center;row-gap:4px} .tc-tools:has(.tc-opt.tc-collapsed:hover) .tc-opt.tc-collapsed:focus-within:not(:hover) > :not(.tc-optname){display:none} /* #280 — Customize tools popover */ .tc-toolcfg{position:fixed;z-index:100002;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:9px;box-shadow:0 10px 30px rgba(40,20,80,.32);padding:8px 0 4px;font:13px Arial;width:340px} .tc-tc-h{font-weight:800;color:var(--mbu-accent-deep-text);padding:3px 14px 8px} .tc-tc-list{max-height:62vh;overflow:auto} .tc-tc-row{display:flex;align-items:center;gap:8px;padding:5px 12px} .tc-tc-row:hover{background:var(--mbu-bg-raised)} .tc-tc-row.off{opacity:.6} .tc-tc-row.over-bottom{box-shadow:inset 0 -2px 0 #8a72c8} .tc-tc-row.over-top{box-shadow:inset 0 2px 0 #8a72c8} .tc-tc-row.drag{opacity:.4} .tc-tc-grab{color:var(--mbu-accent-text);cursor:grab;font-size:15px;line-height:1;flex:none;user-select:none} /* same ⠿ grip as the tracklist drag handle (.tc-drag) */ .tc-tc-grab:hover{color:var(--mbu-accent-text)}.tc-tc-grab:active{cursor:grabbing} .tc-tc-ic{width:26px;text-align:center;color:var(--mbu-accent-text);flex:none} .tc-tc-lab{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tc-tc-onbar{display:inline-flex;flex:none}.tc-tc-onbar input{accent-color:#6f42c1} .tc-tc-dens{display:inline-flex;flex:none;border:1px solid var(--mbu-accent);border-radius:5px;overflow:hidden} .tc-tc-seg{flex:none;width:30px;box-sizing:border-box;border:none;border-left:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);font:bold 12px Arial;height:22px;padding:0;cursor:pointer;display:inline-flex;align-items:center;justify-content:center} /* #280: fixed width → the [icon|T] groups line up across rows */ .tc-tc-seg:first-child{border-left:none} .tc-tc-seg:hover{background:var(--mbu-bg-hover)} .tc-tc-seg.on{background:var(--mbu-accent);color:var(--mbu-text-on-accent)} .tc-tc-seg.on:hover{background:var(--mbu-accent)} .tc-tc-pin{flex:none;box-sizing:border-box;width:22px;text-align:center;border:none;background:none;cursor:pointer;font-size:13px;line-height:1;filter:grayscale(1);opacity:.35;padding:1px 0;display:inline-flex;align-items:center;justify-content:center} /* fixed width so the column aligns even when a tool has no pin */ .tc-tc-pin.on{filter:none;opacity:1} .tc-tc-pin.none{visibility:hidden} .tc-tc-hint{font-size:11px;color:var(--mbu-text-weak);padding:8px 14px 2px;border-top:1px solid var(--mbu-accent);margin-top:5px} .tc-mi-ic{flex:0 0 auto;display:inline-flex;justify-content:center;min-width:18px;text-align:center;color:var(--mbu-accent-text);margin-right:8px;white-space:nowrap} .tc-menu{position:fixed;z-index:100001;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 6px 22px rgba(40,20,80,.3);padding:4px 0;font:13px Arial;min-width:170px} .tc-menu.tc-mini{width:max-content;max-width:260px}.tc-menu.tc-mini .tc-mi{white-space:nowrap} .tc-menu .tc-mi{display:flex;align-items:center;padding:6px 15px;cursor:pointer;color:var(--mbu-text);font-weight:bold}.tc-menu .tc-mi:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-deep-text)} .tc-menu .tc-sep{border-top:1px solid var(--mbu-accent);margin:4px 0} .tc-settings label{display:flex;gap:8px;align-items:center;margin:7px 0;color:var(--mbu-text)}.tc-settings label.opt{font-size:12px} .tc-settings input[type=text],.tc-settings #tc-sr-find,.tc-settings #tc-sr-rep{font:13px Arial;padding:3px 5px;border:1px solid var(--mbu-border);border-radius:3px} .tc-settings .srrow{display:flex;align-items:center;gap:8px;margin-top:8px}.tc-settings .srrow span{flex:1;color:var(--mbu-text-dim);font-size:12px} @keyframes tctitleflash{0%{background:var(--mbu-warn-bg)}100%{background:transparent}} .tc-mirror input.t-title.srflash{animation:tctitleflash 1.8s ease-out} #tc-settings{position:fixed;z-index:100001;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 6px 24px rgba(40,20,80,.3);padding:11px 13px;font:13px Arial;color:var(--mbu-text);width:380px;max-width:calc(100vw - 24px);box-sizing:border-box} #tc-settings .mbu-cfg-ver{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis} /* a long time-stamped version shrinks instead of pushing Log/Help out */ /* #283 activity-log popup */ /* floating, movable, NON-modal window (no backdrop) */ #tc-settings label{display:flex;gap:8px;align-items:center;margin:7px 0;color:var(--mbu-text)} #tc-settings label input[type=checkbox]{margin:0;flex:none} #tc-settings .hint{color:var(--mbu-text-dim);font-size:11px;margin:0 0 4px 24px} #tc-settings .tc-s-sec{font-weight:bold;color:var(--mbu-text);margin:12px 0 5px} #tc-settings .tc-s-group{padding-left:8px} #tc-settings .tc-s-top{padding-left:0;margin-top:2px} #tc-settings .mbu-ia{margin:-2px 0 7px 22px} /* #736 the archive menu by each sidebar link: a faint clock at the row's end, its menu in full words */ #sidebar ul.external_links > li > .tc-wb{float:right;width:18px;height:18px;margin:0 0 0 4px;padding:0;border:0;border-radius:4px;background:none;color:var(--mbu-text-weak);opacity:.5;cursor:pointer;display:inline-flex;align-items:center;justify-content:center} #sidebar ul.external_links > li > .tc-wb svg{width:13px;height:13px;display:block} #sidebar ul.external_links > li > .tc-wb:hover,#sidebar ul.external_links > li > .tc-wb.on{opacity:1;color:var(--mbu-accent-text);background:var(--mbu-accent-soft)} .tc-wbmenu{position:fixed;z-index:var(--mbu-z-pop);background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:7px;box-shadow:var(--mbu-shadow-lg);padding:4px;min-width:220px;font:13px var(--mbu-font)} .tc-wbmenu a{display:block;padding:5px 10px;border-radius:5px;color:var(--mbu-text);text-decoration:none;white-space:nowrap} .tc-wbmenu a:hover{background:var(--mbu-accent-soft);color:var(--mbu-accent-text)} .tc-wbmenu-h{font-size:11px;color:var(--mbu-text-weak);padding:3px 10px 2px} .tc-wbmenu-sep{height:1px;background:var(--mbu-border-soft);margin:4px 2px} /* #736 the archive.org keys, under Archive links */ #tc-settings .tc-s-sub{font-weight:bold;color:var(--mbu-text);margin:0} #tc-settings div.tc-s-sub{margin:8px 0 3px} #tc-settings div.tc-s-sub.tc-s-gap{margin-top:16px} /* #736 a section after the plain list */ #tc-settings .tc-s-row{display:flex;align-items:center;gap:12px;margin:7px 0;color:var(--mbu-text)} #tc-settings .tc-s-rad{display:inline-flex;align-items:center;gap:4px;margin:0;font-weight:normal;cursor:pointer;white-space:nowrap} #tc-settings .tc-s-row input[type=radio]{margin:0} #tc-settings #tc-s-lentol,#tc-settings #tc-s-titletol,#tc-settings #tc-s-punctsize{width:48px;font:13px Arial;padding:2px 5px;border:1px solid var(--mbu-border);border-radius:3px} #tc-settings #tc-s-hlcolor{width:34px;height:22px;padding:0;border:1px solid var(--mbu-border);border-radius:3px;cursor:pointer;background:none} /* #294 tabbed config: one short pane at a time instead of one tall column */ #tc-settings .tc-tabs{display:flex;gap:2px;margin:0 0 8px;border-bottom:1px solid var(--mbu-accent)} #tc-settings .tc-tab-btn{flex:1;font:600 12px Arial;color:var(--mbu-text-weak);background:none;border:none;border-bottom:2px solid transparent;margin-bottom:-1px;padding:5px 4px 7px;cursor:pointer} #tc-settings .tc-tab-btn:hover{color:var(--mbu-accent-text)} #tc-settings .tc-tab-btn.active{color:var(--mbu-accent-text);border-bottom-color:var(--mbu-accent)} #tc-settings .tc-tab-pane{display:flow-root} /* #407: contain child margins so the pinned height captures the full box (constant dialog height across tabs) */ #tc-settings .tc-tab-pane[hidden]{display:none} #tc-settings .tc-tab-pane .tc-s-top{margin-top:0} #tc-settings .tc-s-row.lentol{gap:7px} /* #695: a round icon like every other corner launcher (Falcon, Fusion); in colour while Apollo is on, monochrome while off */ #tc-launch{position:fixed;bottom:14px;right:14px;z-index:var(--mbu-z-pop);width:40px;height:40px;border-radius:50%;border:none;padding:0;display:flex;align-items:center;justify-content:center;cursor:pointer;background:transparent;box-shadow:none;opacity:.85;transition:background .15s,transform .1s,opacity .15s} #tc-launch:hover{opacity:1;transform:scale(1.08)} #tc-launch.on{opacity:1} #tc-launch:not(.on) img{filter:grayscale(1);opacity:.55} #tc-launch img{width:34px;height:34px;display:block;pointer-events:none} #tc-btn,#tc-gear-btn{vertical-align:middle} /* ────────────────────────────────────────────────────────────────── MOBILE / NARROW VIEWPORTS — Tracklist MusicBrainz serves width=device-width, so the fixed-layout mirror (Title 360px + #/len/badge ≈ 540px of fixed columns) overflows the viewport and collapses the flexible Artist column to a few pixels — the artist search box becomes unusable. Below ~700px each track becomes a full-width card: #/title/length/match on the top line and the artist credit(s) spanning the FULL width beneath. Hover-only controls are revealed (touch screens have no hover). 820px covers phones and small tablets (the artist column only gets comfortable above ~1000px desktop width). ────────────────────────────────────────────────────────────────── */ @media (max-width: 820px) { /* tools bar: wrap so "⚡ Match" never gets pushed off the edge */ #tc-bar{flex-wrap:wrap;gap:6px 8px} #tc-bar > .sp{flex-basis:100%;height:0} /* the spacer forces the Match cluster onto its own line */ .tc-am-lbl{white-space:normal} .tc-tablewrap{overflow-x:visible} .tc-mirror{display:block} .tc-mirror > colgroup,.tc-mirror > thead{display:none} .tc-mirror > tbody{display:block} .tc-mirror > tbody > tr{display:grid;grid-template-columns:20px 34px 1fr 46px 30px 44px; align-items:center;column-gap:6px;row-gap:3px;padding:7px 8px;background:transparent} .tc-mirror.gridrows > tbody > tr{border-bottom:1px solid var(--mbu-border)} .tc-mirror > tbody > tr > td{display:block;padding:0;border:none!important;background:transparent;overflow:visible} .tc-mirror td.c-mv{grid-column:1;grid-row:1} .tc-mirror td.c-num{grid-column:2;grid-row:1} .tc-mirror td.c-title{grid-column:3;grid-row:1} .tc-mirror td.c-len{grid-column:4;grid-row:1} .tc-mirror td.c-badge{grid-column:5;grid-row:1} .tc-mirror td.c-act{grid-column:6;grid-row:1} .tc-mirror td.c-art{grid-column:1 / -1;grid-row:2;width:auto} .tc-mirror input.t-len{width:100%;text-align:right} /* the medium header keeps its own full-width line */ .tc-mirror > tbody > tr:has(.tc-medhdr){display:block;padding:0} .tc-mirror td.tc-medhdr{display:block} /* zebra + changed-marker move to the row (cells are transparent now) */ .tc-mirror.alt > tbody > tr:nth-child(even){background:var(--mbu-bg-raised)} .tc-mirror > tbody > tr.tc-changed{box-shadow:inset 4px 0 0 var(--mbu-accent-text)} .tc-mirror > tbody > tr.tc-changed td:first-child{box-shadow:none} /* trim the fixed credited-as + actions so the search box gets the width */ .tc-cred{width:84px} .tc-acts{width:auto;gap:3px;padding-left:3px} /* touch = no hover: reveal the per-row / per-artist controls permanently */ .tc-mirror tr .t-gc,.tc-mirror tr .t-feat, .tc-enter,.tc-splitb,.tc-slotgrab,.tc-slotx,.tc-trackacts{visibility:visible} .tc-aslot:not(.tc-can-split) .tc-splitb{display:none} } ` + mbaKeysCss('#tc-settings'); function style() { if (document.getElementById('tc-css')) return; const s = document.createElement('style'); s.id = 'tc-css'; s.textContent = css; document.head.appendChild(s); } /* ── settings popover (view options) ── */ function applyViewClasses() { const layout = SETTINGS.layout || 'normal'; document.querySelectorAll('.tc-mirror, .tc-rectbl').forEach(t => { // both tables share the layout/alt/grid options t.classList.toggle('alt', !!SETTINGS.altRows); t.classList.toggle('gridcols', !!SETTINGS.gridCols); t.classList.toggle('gridrows', SETTINGS.gridRows !== false); t.classList.remove('compact', 'cozy', 'normal'); t.classList.add(layout); }); } function openSettings(anchor) { style(); let s = document.getElementById('tc-settings'); if (s) { s.remove(); return; } s = document.createElement('div'); s.id = 'tc-settings'; s.innerHTML = `${mbuCfgHeader({ script: 'apollo_editor', name: 'Apollo Editor', version: scriptVersion(), icon: ICON, log: true, logClass: 'tc-logbtn' })} <div class="tc-tabs" role="tablist"> <button type="button" class="tc-tab-btn" data-tab="general">General</button> <button type="button" class="tc-tab-btn" data-tab="matching">Matching</button> <button type="button" class="tc-tab-btn" data-tab="appearance">Appearance</button> </div> <div class="tc-tab-pane" data-pane="general"> <div class="tc-s-group tc-s-top"> <label title="Tidy the Release information tab: remove the help bubble, clean up the external links and use the right column. Apollo's corner icon still toggles it anytime."><input type="checkbox" id="tc-s-replri"> <span>Modify Release information</span></label> <label title="Replace the native Tracklist editor with the Apollo table on page load. Apollo's corner icon still toggles it anytime."><input type="checkbox" id="tc-s-repltl"> <span>Modify Tracklist</span></label> <label title="Replace the native Recordings editor with the Apollo table on page load. Apollo's corner icon still toggles it anytime."><input type="checkbox" id="tc-s-replrec"> <span>Modify Recordings</span></label> <label title="On the Add-release Duplicates tab, add a Similarity column scoring how closely each existing release matches the one you are entering (by track-title overlap), coloured red→green — so you can pick the right release to base yours on."><input type="checkbox" id="tc-s-moddupes"> <span>Modify Duplicates</span></label> <label title="Edit the annotation as Markdown with a live preview, in the release editor's Additional information and on the standalone Edit annotation page."><input type="checkbox" id="tc-s-modanno"> <span>Modify annotations with Markdown</span></label> <label title="Hide the native step-tab row and footer; show a compact step switcher, wizard buttons by the title, and Add medium at the table's right."><input type="checkbox" id="tc-s-compactnav"> <span>Modify header and footer</span></label> <label title="Zen editing: hide everything above the Apollo nav bar (the site header, release title and entity tabs) and the page footer — leaving just the editor. The release title / artist (with version count) move into the nav bar. Needs the compact nav."><input type="checkbox" id="tc-s-zen"> <span>Zen editing</span></label> <label title="When another site seeds the Add/Edit-release form, MusicBrainz shows a confirmation page before the editor — automatically click its submit button to skip that step (integrates chaban's auto-confirm script). Add ?skip_confirmation to a seed URL to bypass it once."><input type="checkbox" id="tc-s-autoconfirm"> <span>Auto confirm release submissions</span></label> <div class="tc-s-sub tc-s-gap">Archive</div> <label title="At Enter edit, ask the Internet Archive's Wayback Machine to save the release's external links. This sends those URLs to archive.org. The archive menu by each link on the release's page finds the snapshots. First Contact archives the album page of its own import itself."><input type="checkbox" id="tc-s-archive"> <span>Archive links on the Internet Archive</span></label> ${mbaKeysHtml(SETTINGS.archive === false)} <label title="On release, release group, artist and label pages: a faint clock by each external link opens a menu with its snapshots on the Wayback Machine, its screenshot, all its snapshots, and Save it now. Nothing is sent to archive.org until you pick one."><input type="checkbox" id="tc-s-archivemenu"> <span>Archive menu on external links</span></label> </div> </div> <div class="tc-tab-pane" data-pane="matching" hidden> <div class="tc-s-group"> <div class="tc-s-sub">Auto-match on start</div> <div class="tc-s-group"> <div class="tc-s-row" style="gap:6px 14px;flex-wrap:wrap"><label class="tc-s-rad" title="Tracklist tab: match track artists to MusicBrainz on load. Off: use the Match button."><input type="checkbox" id="tc-s-automatch"> Tracklist</label><label class="tc-s-rad" title="Recordings tab: auto-match unset recordings on load. Off: use the Match button."><input type="checkbox" id="tc-s-automatchrec"> Recordings</label><label class="tc-s-rad" title="Release-info Label field: when the seeded/typed label name has exactly one exact MusicBrainz match, select it automatically on load. An ambiguous name (e.g. Columbia) is linked when the similar releases on the Duplicates tab credit exactly one label of that name, else left for you to pick."><input type="checkbox" id="tc-s-automatchlabel"> Label</label><label class="tc-s-rad" title="Release-info Artist field: when the seeded/typed release artist has exactly one exact MusicBrainz match, select it automatically on load. Ambiguous names are left for you to pick."><input type="checkbox" id="tc-s-automatchartist"> Artist</label><label class="tc-s-rad" title="Release-info Release group field: when exactly one of the existing release groups MusicBrainz suggests has the release's title and artist, select it instead of adding a new one. Marked with an auto badge."><input type="checkbox" id="tc-s-automatchrg"> Release group</label></div> <label title="When the release has a Discogs link, match each track artist by its Discogs URL (a strong, human-verified signal) before the name search. A single linked MusicBrainz artist is used directly; several are offered as candidates."><input type="checkbox" id="tc-s-discogsmatch"> <span>Discogs artist link matching</span></label> <label title="When the release has no language or script, fill them in from the release and track titles. A field a seed or you set is never touched, nor one you change after Apollo filled it. Everything found is offered at the top of the field's list. The language needs three titles or more and 80% confidence from lande, Harmony's detector (50 languages); when it isn't sure, a script that implies its language (Japanese, Korean, Greek…) names one."><input type="checkbox" id="tc-s-langscript"> <span>Detect language and script</span></label> </div> <div class="tc-s-sub">Recording</div> <div class="tc-s-group"> <div class="tc-s-row lentol" title="A length difference up to this many seconds counts as a match (not a length mismatch)."><span>Length tolerance</span><input type="number" id="tc-s-lentol" min="0" max="60" step="1"> <span>seconds</span></div> <div class="tc-s-row lentol" title="Allow up to this many differing characters in the title (edit distance) and still count it as a match. 0 = exact."><span>Title tolerance</span><input type="number" id="tc-s-titletol" min="0" max="20" step="1"> <span>characters</span></div> <label title="Treat a case / accent / spacing-only difference in title or artist as a match (recommended)."><input type="checkbox" id="tc-s-ignorecase"> <span>Ignore casing</span></label> <label title="Ignore punctuation & symbols in titles/artists (& → and, brackets, quotes, dashes, dots…)."><input type="checkbox" id="tc-s-ignorepunct"> <span>Ignore punctuation</span></label> <div class="tc-s-row" style="gap:8px"><label title="Highlight the exact differing characters in a mismatching title (instead of the whole field), and shade a length mismatch by how large the gap is."><input type="checkbox" id="tc-s-detailhl"> <span>Enable detailed highlighting</span></label><input type="color" id="tc-s-hlcolor" title="Highlight colour — the differing characters and the length-mismatch shading"></div> </div> </div> </div> <div class="tc-tab-pane" data-pane="appearance" hidden> <div class="tc-s-group"> <div class="tc-s-row"><span>Row layout</span><label class="tc-s-rad"><input type="radio" name="tc-s-layout" value="compact"> compact</label><label class="tc-s-rad"><input type="radio" name="tc-s-layout" value="normal"> normal</label><label class="tc-s-rad"><input type="radio" name="tc-s-layout" value="cozy"> cozy</label></div> <label><input type="checkbox" id="tc-s-alt"> <span>Alternate row colors</span></label> <div class="tc-s-row"><span>Show grid</span><label class="tc-s-rad" title="vertical column separators"><input type="checkbox" id="tc-s-gridcols"> columns</label><label class="tc-s-rad" title="horizontal lines between tracks"><input type="checkbox" id="tc-s-gridrows"> rows</label></div> <div class="tc-s-row" title="In the detailed-highlight diff, enlarge a differing punctuation / confusable / invisible character (straight vs curly quote, hyphen vs dash, no-break space…) by this many pixels so look-alikes are obvious. 0 = no enlargement (hover tooltip still names the character)."><span>Enlarge punctuation by</span><input type="number" id="tc-s-punctsize" min="0" max="12" step="1"> <span>px (0 = no enlargement)</span></div> <label title="Moving between tracklist cells with ↑ / ↓ / Enter keeps the text caret at the same column (clamped to the field's length) so you can fix casing or edit in place. Off: select the whole field on arrival (so the next keystroke overwrites it)."><input type="checkbox" id="tc-s-keepcaret"> <span>Keep caret position on row navigation</span></label> <label title="When you hover an artist in the tracklist, highlight every other slot where that same artist appears (#284). Off by default."><input type="checkbox" id="tc-s-hoverhl"> <span>Highlight all instances of an artist on hover</span></label> </div> </div>`; document.body.appendChild(s); const r = anchor ? anchor.getBoundingClientRect() : { left: 60, right: 60, bottom: 80 }; // keep it fully on-screen — right-align to the gear if it would overflow (uses the real width), and // clamp/scroll vertically so a tall dialog never runs off the bottom (#119). Re-run on each tab // switch since panes differ in height. const maxH = window.innerHeight - 16; s.style.maxHeight = maxH + 'px'; s.style.overflowY = 'auto'; const place = () => { s.style.left = Math.max(8, Math.min(r.right - s.offsetWidth, window.innerWidth - s.offsetWidth - 10)) + 'px'; const h = Math.min(s.offsetHeight, maxH); let top = r.bottom + 6; if (top + h > window.innerHeight - 8) top = r.top - h - 6 >= 8 ? r.top - h - 6 : Math.max(8, window.innerHeight - h - 8); // #695: from the corner icon, open above it s.style.top = top + 'px'; }; // tabs: show one pane at a time so the panel stays short (#294) const tabBtns = s.querySelectorAll('.tc-tab-btn'), panes = s.querySelectorAll('.tc-tab-pane'); // #407: pin every pane to the TALLEST pane's height so the dialog keeps a constant // height as you switch tabs (it used to grow/shrink, which was jumpy). Show all panes // at once and take the max — run in a rAF so the read happens after layout has settled // (a synchronous read right after append under-measured the tallest pane by a few px). const pinPaneHeight = () => { const prev = [...panes].map(p => p.hidden); panes.forEach(p => { p.style.minHeight = ''; p.hidden = false; }); // reveal all, unpinned let max = 0; panes.forEach(p => { if (p.offsetHeight > max) max = p.offsetHeight; }); panes.forEach((p, i) => { p.hidden = prev[i]; p.style.minHeight = max + 'px'; }); place(); // re-anchor now the constant height is known }; const showTab = name => { _cfgTab = name; tabBtns.forEach(b => b.classList.toggle('active', b.dataset.tab === name)); panes.forEach(p => { p.hidden = p.dataset.pane !== name; }); place(); }; tabBtns.forEach(b => b.onclick = () => showTab(b.dataset.tab)); showTab(_cfgTab); requestAnimationFrame(pinPaneHeight); const am = s.querySelector('#tc-s-automatch'), amRec = s.querySelector('#tc-s-automatchrec'), amLbl = s.querySelector('#tc-s-automatchlabel'), amArt = s.querySelector('#tc-s-automatchartist'), amRg = s.querySelector('#tc-s-automatchrg'), alt = s.querySelector('#tc-s-alt'), gridcols = s.querySelector('#tc-s-gridcols'), gridrows = s.querySelector('#tc-s-gridrows'); am.checked = SETTINGS.autoMatch !== false; amRec.checked = SETTINGS.autoMatchRec !== false; amLbl.checked = SETTINGS.autoMatchLabel !== false; amArt.checked = SETTINGS.autoMatchArtist !== false; amRg.checked = SETTINGS.autoMatchRG !== false; alt.checked = !!SETTINGS.altRows; gridcols.checked = !!SETTINGS.gridCols; gridrows.checked = SETTINGS.gridRows !== false; const curLayout = SETTINGS.layout || 'normal'; s.querySelectorAll('input[name="tc-s-layout"]').forEach(rb => { rb.checked = rb.value === curLayout; rb.onchange = () => { if (rb.checked) { SETTINGS.layout = rb.value; saveSettings(); applyViewClasses(); } }; }); am.onchange = () => { SETTINGS.autoMatch = am.checked; saveSettings(); }; amRec.onchange = () => { SETTINGS.autoMatchRec = amRec.checked; saveSettings(); }; amLbl.onchange = () => { SETTINGS.autoMatchLabel = amLbl.checked; saveSettings(); }; amArt.onchange = () => { SETTINGS.autoMatchArtist = amArt.checked; saveSettings(); }; amRg.onchange = () => { SETTINGS.autoMatchRG = amRg.checked; saveSettings(); }; const lsc = s.querySelector('#tc-s-langscript'); if (lsc) { lsc.checked = SETTINGS.autoLangScript !== false; lsc.onchange = () => { SETTINGS.autoLangScript = lsc.checked; saveSettings(); }; } const dmatch = s.querySelector('#tc-s-discogsmatch'); if (dmatch) { dmatch.checked = SETTINGS.discogsUrlMatch !== false; dmatch.onchange = () => { SETTINGS.discogsUrlMatch = dmatch.checked; saveSettings(); }; } const lentol = s.querySelector('#tc-s-lentol'), titletol = s.querySelector('#tc-s-titletol'), igc = s.querySelector('#tc-s-ignorecase'), igp = s.querySelector('#tc-s-ignorepunct'); lentol.value = SETTINGS.recLenTol != null ? SETTINGS.recLenTol : 5; titletol.value = SETTINGS.recTitleTol || 0; igc.checked = SETTINGS.recIgnoreCase !== false; igp.checked = !!SETTINGS.recIgnorePunct; const refreshRec = () => { try { if (document.getElementById('tc-recwrap')) rerenderRec(); } catch (e) {} }; // live-update the table lentol.onchange = () => { const v = Math.max(0, Math.min(60, parseInt(lentol.value, 10) || 0)); SETTINGS.recLenTol = v; lentol.value = v; saveSettings(); refreshRec(); }; titletol.onchange = () => { const v = Math.max(0, Math.min(20, parseInt(titletol.value, 10) || 0)); SETTINGS.recTitleTol = v; titletol.value = v; saveSettings(); refreshRec(); }; igc.onchange = () => { SETTINGS.recIgnoreCase = igc.checked; saveSettings(); refreshRec(); }; igp.onchange = () => { SETTINGS.recIgnorePunct = igp.checked; saveSettings(); refreshRec(); }; const detailhl = s.querySelector('#tc-s-detailhl'); if (detailhl) { detailhl.checked = !!SETTINGS.recDetailedHl; detailhl.onchange = () => { SETTINGS.recDetailedHl = detailhl.checked; saveSettings(); refreshRec(); }; } const hlcol = s.querySelector('#tc-s-hlcolor'); if (hlcol) { hlcol.value = SETTINGS.recHlColor || '#e53935'; hlcol.oninput = () => { SETTINGS.recHlColor = hlcol.value; applyHlColor(); }; hlcol.onchange = () => { SETTINGS.recHlColor = hlcol.value; applyHlColor(); saveSettings(); refreshRec(); }; } const punctsz = s.querySelector('#tc-s-punctsize'); if (punctsz) { punctsz.value = SETTINGS.recPunctSize != null ? SETTINGS.recPunctSize : 3; punctsz.onchange = () => { const v = Math.max(0, Math.min(12, parseInt(punctsz.value, 10) || 0)); SETTINGS.recPunctSize = v; punctsz.value = v; saveSettings(); refreshRec(); }; } alt.onchange = () => { SETTINGS.altRows = alt.checked; saveSettings(); applyViewClasses(); }; const hoverhl = s.querySelector('#tc-s-hoverhl'); if (hoverhl) { hoverhl.checked = !!SETTINGS.hoverHighlight; hoverhl.onchange = () => { SETTINGS.hoverHighlight = hoverhl.checked; saveSettings(); if (!hoverhl.checked) { _hlCur = null; document.querySelectorAll('.tc-aslot.tc-arthl').forEach(e => e.classList.remove('tc-arthl')); } }; } gridcols.onchange = () => { SETTINGS.gridCols = gridcols.checked; saveSettings(); applyViewClasses(); }; gridrows.onchange = () => { SETTINGS.gridRows = gridrows.checked; saveSettings(); applyViewClasses(); }; const replri = s.querySelector('#tc-s-replri'), repltl = s.querySelector('#tc-s-repltl'), replrec = s.querySelector('#tc-s-replrec'), modanno = s.querySelector('#tc-s-modanno'), cnav = s.querySelector('#tc-s-compactnav'), zen = s.querySelector('#tc-s-zen'), moddupes = s.querySelector('#tc-s-moddupes'); replri.checked = SETTINGS.replaceReleaseInfo !== false; repltl.checked = SETTINGS.replaceTracklist !== false; replrec.checked = SETTINGS.replaceRecordings !== false; modanno.checked = SETTINGS.modifyAnnotation !== false; cnav.checked = SETTINGS.compactNav !== false; zen.checked = !!SETTINGS.zenMode; if (moddupes) { moddupes.checked = !!SETTINGS.modifyDuplicates; moddupes.onchange = () => { SETTINGS.modifyDuplicates = moddupes.checked; saveSettings(); applyDuplicates(); }; } replri.onchange = () => { SETTINGS.replaceReleaseInfo = replri.checked; saveSettings(); applyView(); }; repltl.onchange = () => { SETTINGS.replaceTracklist = repltl.checked; saveSettings(); applyView(); }; replrec.onchange = () => { SETTINGS.replaceRecordings = replrec.checked; saveSettings(); applyView(); }; modanno.onchange = () => { SETTINGS.modifyAnnotation = modanno.checked; saveSettings(); applyView(); applyAnnotationPage(); }; cnav.onchange = () => { SETTINGS.compactNav = cnav.checked; saveSettings(); applyNav(); applyZen(); }; zen.onchange = () => { SETTINGS.zenMode = zen.checked; saveSettings(); applyZen(); }; const aconf = s.querySelector('#tc-s-autoconfirm'); if (aconf) { aconf.checked = SETTINGS.autoConfirmSeed !== false; aconf.onchange = () => { SETTINGS.autoConfirmSeed = aconf.checked; saveSettings(); }; } const arch = s.querySelector('#tc-s-archive'); if (arch) { arch.checked = SETTINGS.archive !== false; arch.onchange = () => { SETTINGS.archive = arch.checked; saveSettings(); const ia = s.querySelector('.mbu-ia'); if (ia) ia.classList.toggle('mbu-ia-off', !arch.checked); Log.cat('Archive').info('archive links on the Internet Archive:', arch.checked ? 'on' : 'off'); }; } // #736 mbaKeysWire(s, Log.cat('Archive')); const amenu = s.querySelector('#tc-s-archivemenu'); if (amenu) { amenu.checked = SETTINGS.archiveMenu !== false; amenu.onchange = () => { SETTINGS.archiveMenu = amenu.checked; saveSettings(); }; } const kcaret = s.querySelector('#tc-s-keepcaret'); if (kcaret) { kcaret.checked = SETTINGS.keepCaretColumn !== false; kcaret.onchange = () => { SETTINGS.keepCaretColumn = kcaret.checked; saveSettings(); }; } // #279 const off = e => { if (!s.contains(e.target) && !(anchor && anchor.contains(e.target))) { s.remove(); document.removeEventListener('mousedown', off); } }; const lbtn = s.querySelector('.tc-logbtn'); if (lbtn) lbtn.onclick = () => { s.remove(); document.removeEventListener('mousedown', off); openLog(); }; setTimeout(() => document.addEventListener('mousedown', off), 0); } /* ── the one shared table ── */ let MODEL = null; let ACTIVE = {}; // { mode, tbody, statusEl } // transient message (e.g. "matching d/n") shown in every table's Artist header const updateStatus = t => { document.querySelectorAll('.tc-medsec .tc-hstatus, #tc-panel .tc-hstatus, .tc-globalstat').forEach(e => { e.textContent = t; e.classList.remove('tc-unres'); }); }; // the Tracklist's tables: the release artist cell is drawn as one too (#652), and is not one of them const TL_MIRROR = '.tc-mirror:not(.tc-ri-mirror)'; // scroll to + focus the first unresolved artist search box (the white, non-matched one) function focusFirstUnresolved() { const box = document.querySelector(`${TL_MIRROR} .tc-search:not(.matched)`); if (!box) return; box.scrollIntoView({ block: 'center', behavior: 'smooth' }); (box.querySelector('input.nm') || box).focus(); } // the always-visible total in the toolbar (left of Match) — shows the release-wide unresolved count / progress; // when there are unresolved artists the badge is clickable and jumps to the first one const setGlobalStat = n => { document.querySelectorAll('.tc-globalstat').forEach(e => { e.textContent = n ? statusText(n) : ''; e.classList.toggle('tc-unres', n > 0); e.onclick = n > 0 ? focusFirstUnresolved : null; e.style.cursor = n > 0 ? 'pointer' : ''; e.title = n > 0 ? 'jump to the first unresolved artist' : ''; }); }; // #227: persistent missing-Discogs-links badge. Stays until every link is // added; each click steps to the NEXT track missing one and focuses its credit. let _discNavIdx = -1; // an artist whose Discogs link needs attention: missing (addable) OR mismatched // (links a different Discogs page than the release credits). #227 const discNeedsAttention = s => !!s._discogsAddable; // mismatch is now addable too (offer the release's link, flagged ⚠) // The tooltip text for an addable slot's link/⚠ icon — shared so the log shows // the exact same message (a mismatch/conflict reads differently from a plain add). function discAddTooltip(s) { const mism = s._discogsMismatch, conf = s._discogsConflict; return mism ? `Discogs mismatch: this artist links ${mism}, the release credits ${s._discogsUrl} — click to add the release's link anyway` : conf ? `Discogs links a different MB artist: ${conf.name} — click to add it to ${s.name || 'this artist'} anyway` : (s.gid ? 'Add the Discogs link to this artist' : 'Create this artist with its Discogs link'); } function focusNextMissingDiscogs() { const list = []; MODEL.tracks.forEach(t => t.slots.forEach((s, i) => { if (discNeedsAttention(s)) list.push([t, i]); })); if (!list.length) { _discNavIdx = -1; return; } _discNavIdx = (_discNavIdx + 1) % list.length; const [t, i] = list[_discNavIdx]; const row = rowEl(t.mi, t.ti); if (!row) return; row.scrollIntoView({ block: 'center', behavior: 'smooth' }); const creds = row.querySelectorAll('.tc-cred'); (creds[i] || row).focus(); } const setDiscStat = () => { // #281: Discogs API unreachable → an amber, clickable "retry" badge instead of a // blank that reads as "nothing to do". (It also auto-retries once on its own.) if (_discMapFailed) { document.querySelectorAll('.tc-discstat').forEach(e => { e.classList.remove('tc-disc-badge', 'tc-disc-ok'); e.classList.add('tc-disc-pend'); e.textContent = '🔗 Discogs ⟳'; e.style.cursor = 'pointer'; e.title = 'Couldn’t reach Discogs (rate-limited) — click to retry'; e.onclick = () => tagDiscogsForAll(); }); return; } let miss = 0, mism = 0, pend = 0, checked = false; if (MODEL) MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s._discogsChecked) checked = true; // #281: a RESOLVED slot was actually checked (unresolved slots don't count) if (s._discogsMismatch) mism++; else if (s._discogsAddable) miss++; else if (s._discogsPending) pend++; })); const n = miss + mism; document.querySelectorAll('.tc-discstat').forEach(e => { e.classList.remove('tc-disc-badge', 'tc-disc-ok', 'tc-disc-pend'); e.onclick = null; e.style.cursor = ''; e.title = ''; if (n) { // links to add / mismatches → teal action badge, click steps to the next e.textContent = `🔗 ${n} link${n === 1 ? '' : 's'}` + (pend ? ` · ${pend}?` : ''); e.classList.add('tc-disc-badge'); e.onclick = focusNextMissingDiscogs; e.style.cursor = 'pointer'; e.title = `${miss} missing, ${mism} mismatched Discogs link${n === 1 ? '' : 's'}` + (pend ? `, ${pend} unchecked` : '') + ' — click to step to the next'; } else if (pend) { // every job ran but some lookups couldn't complete (rate-limited / cold) → // say so honestly and let a click retry, rather than reading as "all OK" e.textContent = `🔗 ${pend} unchecked`; e.classList.add('tc-disc-pend'); e.onclick = () => tagDiscogsForAll(); e.style.cursor = 'pointer'; e.title = `${pend} Discogs link${pend === 1 ? '' : 's'} couldn't be checked (rate-limited) — click to retry`; } else if (checked) { // the check ran and every Discogs artist credit is already linked in MB — // a positive confirmation so a blank badge no longer means "did it run?" e.textContent = '✓ Discogs links OK'; e.classList.add('tc-disc-ok'); e.title = 'every Discogs artist credit on this release is already linked in MusicBrainz'; } else { e.textContent = ''; // no Discogs link on the release / not checked yet } }); }; // transient progress text in the same slot, while the check runs (not a pill) const setDiscProgress = (t) => document.querySelectorAll('.tc-discstat').forEach(e => { e.textContent = t || ''; e.classList.remove('tc-disc-badge', 'tc-disc-ok', 'tc-disc-pend'); e.onclick = null; e.style.cursor = ''; e.title = ''; }); // transient action feedback (a pick propagated, S&R count, …) — lives in the toolbar so it never // overwrites a medium's unresolved badge; auto-clears // majkinetor: "it doesn't seem like a good idea to show messages in the toolbar as it moves // buttons around" — the old .tc-toast span sat in the toolbar's flex row and took width from // the tool buttons (they wrapped). One-off results now go to the shared floating toast // (mbuToast, #563: bottom centre, fades, same in every script); the toolbar keeps only // fixed status (links badge, match state, unresolved count). Colour follows the text: // failures red, refusals/“try again” amber, ✓ green (mbuToast's own inference), else neutral. const TOAST_ERROR = /\bfailed\b|invalid json/i; const TOAST_WARN = /^(merge|split):|couldn[’']t|unavailable|did not load|disc id|no durations|already used|is empty|needs a |type a search|empty chain/i; // #281's "added Discogs link to X" note used its own toolbar span next to the links badge — the // same layout push as the old toast, so it goes to the floating toast too (green). const discMsg = msg => toast(msg ? '✓ ' + msg : '', 'ok'); const toast = (msg, kind) => { if (!msg) return; const k = kind || (TOAST_ERROR.test(msg) ? 'error' : TOAST_WARN.test(msg) ? 'warn' : null); mbuToast(msg, k ? { kind: k, ms: 4000 } : { ms: 4000 }); }; // #281: "added Discogs link to X" feedback sits right next to the "N links" badge // (not in the centre toast), so it reads together with the count it just changed. const unresolvedIn = mi => { let n = 0; MODEL.tracks.forEach(t => { if (mi != null && t.mi !== mi) return; t.slots.forEach(s => { if (!(s.status === 'set' || s.committed)) n++; }); }); return n; }; const statusText = n => (n ? `⚠ ${n} unresolved!` : 'all matched'); const setStatusSpan = (span, n) => { if (!span) return; span.textContent = statusText(n); span.classList.toggle('tc-unres', n > 0); }; // disable the Match button while a match pass is running // #577 (majkinetor): "Tracklist and recording matching can't be stopped, // unlike all others (GT work matching etc.)". One flag shared by both loops: // whichever is running checks it between items and returns early. // // Cooperative rather than an AbortController, because Apollo's requests are // paced and coalesced in one place (wsGet) and several callers can be sharing // a single flight — aborting it would cancel someone else's request too. The // cost is that a stop lands after the request in flight comes back rather than // instantly; the win is that it cannot cancel work that another caller still // wants. The UI flips the moment it is clicked, so the wait isn't felt as // "nothing happened". // // Stopping is not undoing: every artist and recording matched before the stop // stays matched, and the leftovers stay unmatched for a later pass — the same // bargain Group Therapy's work matcher makes. let _matchStop = false; /* #575 round 4 (majkinetor): "both Matches can be 100% unresponsive to clicks, especially Recordings one". The main thread turned out to be fine — a rAF round trip stayed at 0ms through a whole 81s pass on his own release — so nothing was frozen. What was missing was any ANSWER to the press: the loops only notice the flag between items, and one item can be a multi-second search or a shared throttle hold, so the button sat there still reading "Stop" and the click read as ignored. Both buttons now say so immediately. The pass still finishes the request in flight before it exits — cancelling it would cancel a flight other callers may be sharing — but that wait is now visible rather than silent. */ const stopMatching = () => { if (_matchStop) return; _matchStop = true; // Driven off each pass's own running flag, not off what its button happens // to say. The label is not reliable — a Recordings shell rebuilt mid-pass // reads "Match" — and the flag is shared, so stopping the tracklist must not // relabel an idle Recordings button either. // Deliberately not disabled: setMatching / setBusy own that, and a button // disabled here would stay dead if its pass had already ended. const ack = (b, live) => { if (!b || !live) return; (b.querySelector('.tc-rec-am-lbl') || b).textContent = '⏳ Stopping…'; b.title = 'Stopping once the request in flight comes back — everything matched so far is kept'; }; ack(document.querySelector('#tc-bar [data-act="match"], #tc-hdr [data-act="match"]'), _matching); ack(document.querySelector('#tc-recwrap .tc-rec-am'), _autoMatching); }; let _matching = false; // The Match button BECOMES the Stop button for the duration. It used to just // go disabled, which is what made an eight-minute pass feel unstoppable, and // reusing it costs no room in a toolbar that already collapses to icons. function setMatching(on) { _matching = on; // #575: take the status line the moment the pass starts. The first progress // write only lands when track one finishes, which on a slow day is several // seconds — until then the idle "auto-match off — click Match" was still // sitting there, reading as if the press had done nothing. if (on) updateStatus('matching…'); const b = document.querySelector('#tc-bar [data-act="match"], #tc-hdr [data-act="match"]'); if (!b) return; b.disabled = false; b.classList.toggle('tc-stopping', on); b.textContent = on ? '■ Stop' : '⚡ Match'; b.title = on ? 'Stop matching — what has already been matched is kept' : 'search MusicBrainz for the unmatched artists'; } // re-fill every active tbody (per-medium sections in mirror mode, or the single panel table) const rerender = () => { const restore = keepScroll(); _hlCur = null; renderRiArtist(true); if (ACTIVE.sections) ACTIVE.sections.forEach(s => fillRows(s.tbody, s.mi)); else if (ACTIVE.tbody) fillRows(ACTIVE.tbody); refreshStatus(); restore(); }; // #685: re-filling the rows mustn't scroll the view // our rendered row for a track, wherever it lives (a per-medium section or the floating panel) const rowEl = (mi, ti) => document.querySelector(`.tc-medsec tr[data-tk="${mi}:${ti}"], #tc-panel tr[data-tk="${mi}:${ti}"]`); // ↑/↓ : move to the same field in the prev/next ROW — but for the per-artist fields (search box, // credited-as) walk EVERY line in document order, so multi-artist tracks and media boundaries are // all included. Returns true if it moved. function focusSameField(inp, dir) { const sel = inp.classList.contains('t-num') ? '.t-num' : inp.classList.contains('t-title') ? '.t-title' : inp.classList.contains('t-len') ? '.t-len' : inp.classList.contains('tc-cred') ? '.tc-cred' : inp.classList.contains('nm') ? '.tc-search input.nm' : null; if (!sel) return false; const scope = inp.closest('#tc-panel') ? '#tc-panel' : '.tc-medsec'; const all = [...document.querySelectorAll(`${scope} ${sel}`)]; // flat list across all rows/sections/artist lines const cur = all.indexOf(inp); if (cur < 0) return false; const dest = all[cur + dir]; if (!dest) return false; // remember the destination by row + its slot index within that row (survives a commit-rebuild) const destRow = dest.closest('tr[data-tk]'); const destTk = destRow ? destRow.dataset.tk : null; const destIdx = destRow ? [...destRow.querySelectorAll(sel)].indexOf(dest) : 0; const destPos = cur + dir; // #279: carry the caret COLUMN across the move instead of selecting the whole // field (jesus2099's keyboard-select behaviour) — so you keep typing / fix // casing at the same spot rather than overwriting. Clamped to the destination's // length, so a shorter field just drops the caret at its end. const caret = (inp.selectionStart != null) ? inp.selectionStart : (inp.value || '').length; inp.blur(); // committing the current field on blur can rebuild the rows — focus AFTER, from the fresh DOM const go = () => { let t = null; if (destTk) { const d = document.querySelector(`${scope} tr[data-tk="${destTk}"]`); if (d) { const xs = [...d.querySelectorAll(sel)]; t = xs[Math.min(destIdx, xs.length - 1)]; } } if (!t) t = [...document.querySelectorAll(`${scope} ${sel}`)][destPos]; if (t && document.activeElement !== t) { t.focus(); // #279: keep the caret column by default; the toggle restores select-all-on-arrive. if (SETTINGS.keepCaretColumn !== false) { if (typeof t.setSelectionRange === 'function') { const p = Math.min(caret, (t.value || '').length); try { t.setSelectionRange(p, p); } catch (e) {} } } else if (t.select && !t.classList.contains('nm')) { t.select(); } } }; go(); setTimeout(go, 0); return true; } // ↓/Enter → next field, ↑/Shift+Enter → prev. NOT wired on the artist search box (Enter picks there). function wireRowNav(inp) { inp.addEventListener('keydown', e => { if (e.key === 'ArrowDown') { if (focusSameField(inp, 1)) e.preventDefault(); } else if (e.key === 'ArrowUp') { if (focusSameField(inp, -1)) e.preventDefault(); } else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); if (!focusSameField(inp, e.shiftKey ? -1 : 1)) inp.blur(); } // move on; blur (commit) if at the edge }); } // show each medium's OWN unresolved count in its header (or the global count for the floating panel) function refreshStatus() { if (!MODEL || _matching) return; // while a pass runs the headers show "matching d/n" — don't flicker the badge if (ACTIVE.sections) ACTIVE.sections.forEach(s => setStatusSpan(s.sec.querySelector('.tc-hstatus'), unresolvedIn(s.mi))); else document.querySelectorAll('#tc-panel .tc-hstatus').forEach(span => setStatusSpan(span, unresolvedIn(null))); setGlobalStat(unresolvedIn(null)); // release-wide total in the toolbar if (!_tagDiscogsRunning) setDiscStat(); // #227: keep the missing-links badge in sync (the check owns it while running) } function buildTable() { const t = document.createElement('table'); t.className = 'tc-mirror' + (SETTINGS.altRows ? ' alt' : '') + (SETTINGS.gridCols ? ' gridcols' : '') + (SETTINGS.gridRows !== false ? ' gridrows' : '') + ' ' + (SETTINGS.layout || 'normal'); // the Artist column is the flexible filler (no fixed width) — it absorbs the slack so every OTHER // column keeps its EXACT width (table-layout:fixed) and resizes 1:1 with the mouse (no jump) t.innerHTML = `<colgroup>${COLS.map(c => c.k === 'art' ? '<col>' : `<col style="width:${colW(c.k, c.w)}px">`).join('')}</colgroup>` + `<thead><tr>${COLS.map(c => `<th class="c-${c.k}">${c.label}${c.k === 'art' || c.k === 'act' || c.k === 'badge' ? '' : '<span class="tc-resizer"></span>'}</th>`).join('')}</tr></thead><tbody></tbody>`; return t; } // the artist-selection-mode switch (all | one, radios under a sliding thumb) — live in the toolbar; value is SETTINGS.applyMode ('all' | 'single') let _amSeq = 0; const amRadios = () => { const n = 'tc-am-' + (++_amSeq); return `<span class="tc-applymode" role="radiogroup" title="when you pick an artist, apply it to…"><span class="tc-am-thumb"></span><label title="all matching tracks"><input type="radio" name="${n}" value="all">all</label><label title="single track"><input type="radio" name="${n}" value="single">one</label></span>`; }; function syncApplyMode() { const v = SETTINGS.applyMode || 'all'; document.querySelectorAll('.tc-applymode input').forEach(r => { r.checked = r.value === v; }); } function wireApplyMode(root) { const am = (root || document).querySelector('.tc-applymode'); if (!am) return; am.querySelectorAll('input').forEach(r => { r.checked = r.value === (SETTINGS.applyMode || 'all'); r.onchange = () => { if (!r.checked) return; SETTINGS.applyMode = r.value; saveSettings(); syncApplyMode(); Log.cat('Artist').info('applyMode =', r.value); }; }); } // #330: per-medium "Pregap" / "Data track" checkboxes that drive MB's writable medium // observables hasPregap()/hasDataTracks() (the same thing native MB's buttons do), then // rebuild so the new/removed track shows. Returns a <span> or null (locked / no medium). // Deep data-section editing (its own Add-track, bounded reorder) intentionally stays native. function mediumOpts(target) { if (target == null) return null; const med = mediums()[target]; if (!med) return null; // #330: show the toggles on EVERY medium and keep them ENABLED even on a disc-ID medium. // Pregap/data tracks live outside the audio TOC (the same reason their lengths stay editable, // #329), and native MB leaves these controls enabled there too. const opts = document.createElement('span'); opts.className = 'tc-medopts'; const mkOpt = (label, prop, help) => { const lbl = document.createElement('label'); lbl.className = 'tc-medopt'; lbl.title = help; const cb = document.createElement('input'); cb.type = 'checkbox'; try { cb.checked = typeof med[prop] === 'function' && !!med[prop](); } catch (e) {} cb.onchange = () => { _selfEdit = true; try { med[prop](cb.checked); } catch (e) { Log.cat('Tracklist').warn(`${label} toggle failed`, e.message); } finally { _selfEdit = false; } Log.cat('Tracklist').info(`medium ${target + 1}: ${label} ${cb.checked ? 'added' : 'removed'}`); MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); }; lbl.appendChild(cb); lbl.appendChild(document.createTextNode(' ' + label)); return lbl; }; opts.appendChild(mkOpt('Pregap', 'hasPregap', 'Add / remove a hidden pregap track (position 0)')); opts.appendChild(mkOpt('Data track', 'hasDataTracks', 'Add / remove a trailing data track (for several, use the native editor)')); return opts; } /* ── #586 — move tracks across the data-track boundary ──────────────────── majkinetor: "The original tracklist editor allows moving tracks between normal and data track section. In Apollo there are no move buttons but drag-n-drop which doesn't allow the same." (His example: the last three video tracks of 55530bc0 should be data tracks.) How native does it, read off MB's own handlers: moveTrackDown(t): if the track BELOW is already data and this one isn't, set isDataTrack(true); otherwise swap the two. moveTrackUp(t): if this is the FIRST data track, set isDataTrack(false); otherwise swap. So native can only ever move the boundary one track at a time, and only from the inside out — three video tracks means opening the section and then clicking down repeatedly. A data section is by definition a TRAILING block, which means the whole state is just a boundary index, so Apollo moves the boundary in one go instead: ⤓ on an audio track → it and everything below it become data tracks ⤒ on a data track → it and every data track above it become audio Both keep the block trailing and contiguous by construction. isDataTrack is a plain writable observable and medium.audioTracks / dataTracks / hasDataTracks all recompute off it (verified live in test/probe-586e-datasection.mjs), so setting the flags is enough — no array surgery. medium.toc(null) is cleared exactly as native does, since the audio track count just changed. Disc-ID media are excluded, matching native's disableBecauseDiscIDs on both buttons: crossing the boundary either way would contradict the TOC. */ function setDataBoundary(mi, ti, toData) { const med = mediums()[mi]; if (!med) return; if (mediumLocked(mi)) { Log.cat('Tracklist').warn('medium ' + (mi + 1) + ': the data-track boundary is fixed by the Disc ID'); return; } const tracks = u(med.tracks) || []; let n = 0; _selfEdit = true; try { tracks.forEach((t, i) => { if (typeof t.isDataTrack !== 'function') return; const is = !!t.isDataTrack(); // down: this track and everything after it · up: this track and every data track before it const want = toData ? (i >= ti ? true : is) : (i <= ti ? false : is); if (want !== is) { t.isDataTrack(want); n++; } }); if (typeof med.toc === 'function') med.toc(null); // native clears the TOC on every boundary move } catch (e) { Log.cat('Tracklist').warn('data-track move failed', e.message); } finally { _selfEdit = false; } Log.cat('Tracklist').info('medium ' + (mi + 1) + ': moved ' + n + ' track' + (n === 1 ? '' : 's') + (toData ? ' into the data-track section (from #' + (ti + 1) + ' down)' : ' back into the audio section (through #' + (ti + 1) + ')')); MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); } // one Apollo table for a single medium (its own header row + Add footer); returns the tbody. // mi == null renders the whole release into one table (the floating panel). function mountTable(container, mi) { container.innerHTML = ''; const wrap = document.createElement('div'); wrap.className = 'tc-tablewrap'; container.appendChild(wrap); const table = buildTable(); wrap.appendChild(table); wireResizers(table); // Footer per medium: "Add N track(s)" (only when the medium can take new tracks — // a disc-ID medium has a fixed track count, #125) PLUS the pregap/data toggles. The // toggles render even on a locked medium (disabled, #330), so every medium shows them. // In the combined multi-medium view the toggles live on each medium header instead // (see fillRows), so only attach them to this footer for a per-medium section or a // single-medium release. const target = (mi == null) ? Math.max(0, mediums().length - 1) : mi; const canAdd = !mediumLocked(target); const opts = (mi != null || mediums().length <= 1) ? mediumOpts(target) : null; if (canAdd || opts) { const addrow = document.createElement('div'); addrow.className = 'tc-addrow'; if (canAdd) { addrow.innerHTML = `Add <input type="number" class="tc-addn" min="1" value="1"> track(s) <button class="tc-addbtn" title="add blank tracks">+</button>`; const addn = addrow.querySelector('.tc-addn'), addbtn = addrow.querySelector('.tc-addbtn'); addbtn.onclick = () => addTracks(target, Math.max(1, parseInt(addn.value, 10) || 1)); } if (opts) addrow.appendChild(opts); container.appendChild(addrow); } return table.querySelector('tbody'); } // resize a column by dragging near its right border from ANY row (or the header) function wireResizers(table) { const cols = [...table.querySelectorAll('col')]; const TOL = 5; const artIdx = COLS.findIndex(c => c.k === 'art'); // the flexible filler column // detect a column boundary near the cursor (each th's right edge), including Artist's right edge. #119 // #654: the boundary before the fixed-width actions column resizes nothing, so it isn't one // (it set the drag going and returned, and the cursor stayed a resize arrow for good) const actIdx = COLS.findIndex(c => c.k === 'act'); const borderIdx = clientX => { const ths = table.querySelectorAll('thead th'); for (let i = 0; i < ths.length - 1; i++) { if (i + 1 === actIdx) continue; if (Math.abs(ths[i].getBoundingClientRect().right - clientX) <= TOL) return i; } return -1; }; let dragging = false; table.addEventListener('mousemove', e => { if (!dragging) table.style.cursor = borderIdx(e.clientX) >= 0 ? 'col-resize' : ''; }); table.addEventListener('mousedown', e => { const i = borderIdx(e.clientX); if (i < 0) return; e.preventDefault(); dragging = true; // data columns have exact fixed widths (the spacer column absorbs slack), so the style width IS the // rendered width — resize is 1:1, no jump. Columns LEFT of the Artist filler resize from their own // right edge; columns AT/AFTER it (Length, badge) have no room to their right, so each boundary // resizes the column to its RIGHT, inversely (drag right = that column shrinks, Artist absorbs). #119 const ths = [...table.querySelectorAll('thead th')]; const inverse = i >= artIdx; const ci = inverse ? i + 1 : i; const col = cols[ci], startX = e.clientX, startW = parseInt(col.style.width) || (ths[ci] && ths[ci].offsetWidth) || 100; const mm = ev => { col.style.width = Math.max(36, startW + (ev.clientX - startX) * (inverse ? -1 : 1)) + 'px'; }; const mu = () => { document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu); dragging = false; SETTINGS.colWidths = SETTINGS.colWidths || {}; SETTINGS.colWidths[COLS[ci].k] = parseInt(col.style.width); saveSettings(); }; document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu); }); } // ── column sizing (the "Resize columns" tool) ── Artist stays the flexible filler; the rest get // explicit widths in SETTINGS.colWidths and are pushed to every live table in place (no rebuild). function applyColWidths() { SETTINGS.colWidths = SETTINGS.colWidths || {}; document.querySelectorAll('.tc-mirror').forEach(table => { const cols = [...table.querySelectorAll('colgroup col')]; COLS.forEach((c, i) => { if (!cols[i]) return; cols[i].style.width = c.k === 'art' ? '' : colW(c.k, c.w) + 'px'; }); }); } function colsDefault() { SETTINGS.colWidths = {}; saveSettings(); applyColWidths(); Log.cat('Tool').info('columns → default widths'); } // fit each text column (#, Title, Length) to its widest content; Artist absorbs the slack function colsFit() { const tables = [...document.querySelectorAll(TL_MIRROR)]; if (!tables.length) return; SETTINGS.colWidths = SETTINGS.colWidths || {}; const probe = tables[0].querySelector('tbody input') || tables[0]; const cx = (colsFit._cv || (colsFit._cv = document.createElement('canvas'))).getContext('2d'); cx.font = getComputedStyle(probe).font || '13px sans-serif'; const PAD = { num: 22, title: 32, len: 22 }, CAP = { num: 90, title: 720, len: 90 }; ['num', 'title', 'len'].forEach(k => { const def = COLS.find(c => c.k === k); let max = cx.measureText(def.label || '').width; tables.forEach(t => t.querySelectorAll(`tbody td.c-${k} input`).forEach(inp => { max = Math.max(max, cx.measureText(inp.value || '').width); })); SETTINGS.colWidths[k] = Math.min(CAP[k], Math.max(36, Math.round(max) + PAD[k])); }); saveSettings(); applyColWidths(); Log.cat('Tool').info('columns → fit content', JSON.stringify(SETTINGS.colWidths)); } // "centered" / balanced: give Title and Artist an equal share of the row (Artist flexes to the other half) function colsBalanced() { const table = document.querySelector(TL_MIRROR); if (!table) return; SETTINGS.colWidths = SETTINGS.colWidths || {}; const total = table.clientWidth || table.offsetWidth || 900; const fixed = colW('mv', 32) + colW('num', 38) + colW('len', 52) + colW('badge', 56) + colW('act', 44); SETTINGS.colWidths.title = Math.max(160, Math.round((total - fixed) / 2)); saveSettings(); applyColWidths(); Log.cat('Tool').info('columns → balanced (Title = Artist)', SETTINGS.colWidths.title); } // picking an artist writes through immediately; in "all" mode it also copies to every other // track credited to the same text, committing each. function pickArtist(slot, c) { if (!c || !c.gid) return; noteDisamb(c.gid, c.comment); // #450: cache the disambiguation — a created / pasted-MBID pick never went through search (where it's cached), so the table showed no comment if (c.aliases) cacheAliases(c.gid, c.aliases); // keep the chosen artist's aliases for the bar else if (!_gidAliases.has(c.gid)) fetchAliasesByGids([c.gid]).then(() => refreshAdorns()); // alias not loaded yet (fast pick / "Show more" result) — fetch + show it without re-searching #128 if (MODEL) MODEL.tracks.forEach(t => t.slots.forEach(s => { delete s._marked; })); // clear the previous selection's outlines const entry = slot._entry, beforeKey = creditKey(entry); // whole-credit snapshot BEFORE the pick (and credited-as auto-fill) slot.entity = c; slot.gid = c.gid; slot.name = c.name; slot.status = 'user'; slot._at = Date.now(); slot.committed = true; slot.query = null; slot._flash = true; if (!(slot.creditedAs || '').trim()) slot.creditedAs = c.name; // auto-fill the credited-as when the user hasn't set one commitTrack(entry); // whole-credit match, like MB's native "all matching tracks": copy this track's resulting // credit (the picked artist included) to every other track that shared its credit string. const copies = propagateCredit(entry, beforeKey); if (copies) { slot._marked = true; Log.cat('Artist').info('propagated', c.name, '→', copies, 'matching track(s)'); } rerender(); if (copies) toast(`linked “${c.name}” — also on ${copies} matching track${copies > 1 ? 's' : ''}`); // #227: the new artist may lack the slot's Discogs link — recompute the add affordance if (slot._discogsUrl) tagDiscogsAddable(slot, slot._discogsUrl).then(() => rerender()); if (slot._platUrl) tagPlatformAddable(slot, slot._platUrl).then(() => rerender()); // #651 } // Ctrl/Cmd-click a search result → set that artist on EVERY still-unresolved slot (bulk-fill, e.g. a // various-artists comp that's actually one artist). Resolved (green) slots are left untouched. function pickArtistAllUnresolved(c) { if (!c || !c.gid || !MODEL) return; if (c.aliases) cacheAliases(c.gid, c.aliases); else if (!_gidAliases.has(c.gid)) fetchAliasesByGids([c.gid]).then(() => refreshAdorns()); MODEL.tracks.forEach(t => t.slots.forEach(s => { delete s._marked; })); const touched = new Set(); let n = 0; MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.committed) return; // skip already-resolved slots Object.assign(s, { entity: c, gid: c.gid, name: c.name, status: 'user', _at: Date.now(), committed: true, query: null, _flash: true, _marked: true }); if (!(s.creditedAs || '').trim()) s.creditedAs = c.name; touched.add(s._entry); n++; })); touched.forEach(commitTrack); rerender(); toast(n ? `linked “${c.name}” on ${n} unresolved track${n > 1 ? 's' : ''}` : 'no unresolved tracks'); } async function revertSlot(entry, i) { const orig = ORIGINALS.get(origKey(entry.mi, entry.ti)); if (!orig || !orig.names[i]) return; const on = orig.names[i], slot = entry.slots[i]; slot.creditedAs = on.creditedAs; slot.joinPhrase = on.joinPhrase; slot.query = null; const a = u(on.artist) || {}, gid = u(a.gid); if (gid) Object.assign(slot, { status: 'set', gid, name: u(a.name), entity: { gid, name: u(a.name), id: u(a.id) }, candidates: [], committed: true }); else { const sib = (await loadSiblingMap()).get(fold(entry.title)); const durls = (await loadDiscogsMap())?.get(fold(entry.title)); const pUrl = fcPlatformUrl(entry, i, on.creditedAs); const m = await matchSlot(on.creditedAs, sib && pickSibArtist(sib, on.creditedAs, i), durls && durls[i], slotContextGids(slot._entry, i), () => positionArtists(entry, on.creditedAs), pUrl, trackWhere(entry)); Object.assign(slot, { status: slotStatusOf(m), entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates, _pos: m.pos || null, _why: m.why || null, committed: false }); await tagDiscogsAddable(slot, durls && durls[i]); await tagPlatformAddable(slot, pUrl); } commitTrack(entry); Log.cat('Artist').info('reverted slot', i, 'of track', entry.number); rerender(); } const blankSlot = entry => ({ creditedAs: '', joinPhrase: '', status: 'none', entity: null, gid: null, name: '', candidates: [], committed: false, _entry: entry }); function focusSlotInput(entry, idx) { if (entry.release) { const ins = document.querySelectorAll('#information .tc-ri-art .tc-search input.nm'); if (ins[idx]) ins[idx].focus(); return; } // #652 const row = rowEl(entry.mi, entry.ti); if (row) { const ins = row.querySelectorAll('.tc-search input.nm'); if (ins[idx]) ins[idx].focus(); } } /* ── "all matching tracks" propagation (mirrors MB's native changeMatchingTrackArtists) ── The match key is the WHOLE artist-credit string — each slot's as-credited text (its credited-as, or the artist name when there's no override) + its join phrase, in order. Linked-artist identity is ignored, exactly like MB's reduceArtistCredit. An empty credit never propagates. Peers are re-derived per action; since every artist action propagates, a matched group stays in lockstep through a multi-step edit (add slot → type → pick). */ const creditKey = entry => entry.slots.map(s => (s.creditedAs || s.name || '') + (s.joinPhrase || '')).join(''); function cloneSlots(src, destEntry) { return src.slots.map(s => ({ creditedAs: s.creditedAs, joinPhrase: s.joinPhrase, // committed+linked slots become 'user' so commitTrack writes our entity (not the peer's stale live one) status: (s.committed && s.entity && s.status === 'set') ? 'user' : s.status, entity: s.entity, gid: s.gid, name: s.name, candidates: (s.candidates || []).slice(), committed: s.committed, query: s.query || null, _entry: destEntry, _flash: true, _marked: true, })); } // Apply `entry`'s resulting credit to every OTHER track whose credit string still equals // `beforeKey` (its string before this edit). Returns how many peer tracks were changed. function propagateCredit(entry, beforeKey) { if (entry.release || !MODEL) return 0; // #652: the release credit is never copied onto tracks if ((SETTINGS.applyMode || 'all') !== 'all' || !beforeKey || !beforeKey.trim()) return 0; MODEL.tracks.forEach(t => t.slots.forEach(s => { delete s._marked; })); // clear the previous action's outline let n = 0; MODEL.tracks.forEach(t => { if (t === entry || creditKey(t) !== beforeKey) return; t.slots = cloneSlots(entry, t); commitTrack(t); n++; }); if (n) entry.slots.forEach(s => { s._marked = true; }); return n; } // Run a credit mutation on `entry`, commit it, then propagate to matching tracks. `liveRerender` // false (text edits) skips the table rebuild when nothing propagated, so the field keeps focus. function editCredit(entry, mutate, verb, liveRerender = true) { const beforeKey = creditKey(entry); mutate(); commitTrack(entry); const n = propagateCredit(entry, beforeKey); if (liveRerender || n) rerender(); Log.cat('Artist').info(verb, 'on track', entry.number, n ? ('· +' + n + ' matching') : ''); if (n) toast(`${verb} — also on ${n} matching track${n > 1 ? 's' : ''}`); return n; } // split a credit: append an artist slot (the + create-row / API uses this) function addSlot(entry) { editCredit(entry, () => { const last = entry.slots[entry.slots.length - 1]; if (last && !(last.joinPhrase || '').trim()) last.joinPhrase = ' & '; entry.slots.push(blankSlot(entry)); }, 'added artist slot'); focusSlotInput(entry, entry.slots.length - 1); } // ↵ : insert an artist slot right after this one function addSlotAfter(entry, idx) { editCredit(entry, () => { if (!(entry.slots[idx].joinPhrase || '').trim()) entry.slots[idx].joinPhrase = ' & '; const s = blankSlot(entry); s.joinPhrase = idx + 1 < entry.slots.length ? ' & ' : ''; entry.slots.splice(idx + 1, 0, s); }, 'inserted artist slot'); focusSlotInput(entry, idx + 1); } // merge: remove an artist slot (clearing the trailing join on the new last slot) function removeSlot(entry, idx) { if (entry.slots.length <= 1) return; editCredit(entry, () => { entry.slots.splice(idx, 1); const last = entry.slots[entry.slots.length - 1]; if (last) last.joinPhrase = ''; }, 'removed artist slot'); } function revertTrack(entry) { resetTrack(entry); rebuild(true); } // parse a combined credit ("A feat. B & C") into [{name, sep}] — sep is the separator AFTER each name const SEP_RE = /\s*(\bfeat\.?|\bft\.?|\bfeaturing|&|\band\b|\bvs\.?|\bwith\b|×|・|,|;)\s*/gi; function splitArtistText(text) { const parts = (text || '').split(SEP_RE); const out = []; for (let i = 0; i < parts.length; i += 2) { const name = (parts[i] || '').trim(); if (name) out.push({ name, sep: parts[i + 1] || '' }); } return out; } function normJoin(sep) { const s = (sep || '').trim().toLowerCase(); if (!s) return ' & '; if (s === '&') return ' & '; if (/^feat|^ft/.test(s)) return ' feat. '; if (s === 'and') return ' and '; if (s === ',') return ', '; if (s === ';') return '; '; if (/^vs/.test(s)) return ' vs. '; if (s === 'with') return ' with '; if (s === '×' || s === 'x') return ' × '; return ' ' + sep.trim() + ' '; } // ⋔ : split this slot's combined credit into one slot per artist, auto-match (if on), drop the credited-as override async function splitSlot(entry, idx) { const slot = entry.slots[idx]; const parts = splitArtistText(slot.creditedAs || slot.name || slot.query || ''); if (parts.length < 2) return; const beforeKey = creditKey(entry); // snapshot before the split, for "all matching tracks" const fresh = parts.map((p, i) => { const s = blankSlot(entry); s.creditedAs = p.name; s.joinPhrase = i < parts.length - 1 ? normJoin(p.sep) : ''; s._pending = true; return s; }); entry.slots.splice(idx, 1, ...fresh); entry.slots.forEach(s => { s._entry = entry; }); commitTrack(entry); rerender(); if (entry.release) { fresh.forEach(s => { delete s._pending; }); Log.cat('Artist').info('split the release artist', JSON.stringify(slot.creditedAs || slot.name), '→', parts.map(p => p.name).join(' · ')); await matchReleaseArtist(true); return; } // #652: matched by the release artist's stages Log.cat('Artist').info('split', JSON.stringify(slot.creditedAs || slot.name), '→', parts.map(p => p.name).join(' · ')); if (SETTINGS.autoMatch !== false) await matchModel(); else fresh.forEach(s => { delete s._pending; }); // remove the credited-as override on the matched parts (the artist name is the credit) entry.slots.forEach(s => { if (s.committed && s.gid) s.creditedAs = ''; }); commitTrack(entry); const n = propagateCredit(entry, beforeKey); // apply the finished split to every track that shared the old credit rerender(); if (n) { Log.cat('Artist').info('split — also on', n, 'matching track(s)'); toast(`split — also on ${n} matching track${n > 1 ? 's' : ''}`); } } // + create-button at the right end of the box (before the join), only when the slot is unmatched; // and the alias on the resolved bar — only while the slot stays committed (gone the moment you edit) function adorn(search, slot, inp) { [...search.querySelectorAll('.mk, .tc-bar-aka, .tc-bar-disamb')].forEach(e => e.remove()); search.classList.toggle('matched', !!slot.committed); const ref = search.querySelector('.tc-joinwrap'); const aks = _gidAliases.get(slot.gid); const aka = slot.committed ? aliasStr({ gid: slot.gid, name: slot.name, aliases: aks || (slot.entity && slot.entity.aliases) || [], primaryAlias: slot.entity && slot.entity.primaryAlias }) : null; // when matched, size the name field to its content so the alias sits right after it on the LEFT // (instead of being pushed to the far right by a full-width field); reset when unmatched. #128 if (slot.committed) { inp.style.flex = '0 1 auto'; inp.size = Math.max(3, String(slot.name || inp.value || '').length + 1); } else { inp.style.flex = ''; inp.removeAttribute('size'); } // content-sizing the matched field leaves a short name with almost no click target, so clicking // anywhere in the rest of the bar (the empty space / alias) focuses it to open the search. #128 search.onmousedown = e => { if (e.target === inp || e.target.closest('.tc-joinwrap, .mk')) return; e.preventDefault(); inp.focus(); }; if (aka) { const al = document.createElement('span'); al.className = 'tc-bar-aka'; al.textContent = aka; al.title = aka; search.insertBefore(al, ref); } // artist disambiguation, like native MB's credit editor — grey, after the alias. // Sourced from the cache (filled by the same gid alias fetch); shows even for // special-purpose artists like [unknown], whose alias is suppressed. #195 const dis = slot.committed ? (getDisamb(slot.gid) || (slot.entity && slot.entity.comment) || '') : ''; // #450 entity fallback (created/pasted picks) if (dis) { const ds = document.createElement('span'); ds.className = 'tc-bar-disamb'; ds.textContent = '(' + dis + ')'; ds.title = dis; search.insertBefore(ds, ref); } // #580: always built. On an unresolved slot it shows outright; on a resolved // one the .tc-search.matched rules keep it hidden until the slot has focus. const mk = document.createElement('button'); mk.className = 'mk'; mk.textContent = '+'; mk.title = 'create this artist on MusicBrainz · right-click: create silently in a background tab'; mk.onmousedown = e => { if (e.button === 2) return; e.preventDefault(); createArtist(inp.value.trim() || slot.creditedAs, slot, slot._discogsUrl || null); }; mk.oncontextmenu = e => { e.preventDefault(); createArtist(inp.value.trim() || slot.creditedAs, slot, slot._discogsUrl || null, true); }; search.insertBefore(mk, ref); } // #626: what a `pos` match rests on, on hover /* ── #654: the match card — a badge's rich tooltip ───────────────────────────── majkinetor: "maybe we should have richer tooltip (visually as HTML and more relevant info) that contains not only description but also more match details visible in the log" — and, on the mock, "go with the mock". Hover a badge for half a second and a card opens under it: the stage and what it means, the artist it linked, the evidence the stage had (the slot's _why, recorded while matching: no request is made for the card), what else was considered, and when. The card stays while the pointer is on it, so its links can be followed; Esc or moving away closes it. */ const MATCH_STAGES = { disc: { name: 'Discogs link', n: 1, means: 'The Discogs artist credited on the release is linked from this MusicBrainz artist.' }, plat: { name: 'Platform link', n: 1, means: 'The artist\'s page on the platform the release was imported from is linked from this MusicBrainz artist.' }, rg: { name: 'Release group', n: 2, means: 'Another release in this release group credits this artist on the same track.' }, pos: { name: 'Same position on other editions', n: 3, means: 'Other editions credit this artist on the track at this position.' }, high: { name: 'Exact name', n: 4, means: 'The only MusicBrainz artist with this name; aliases were checked too.' }, alias: { name: 'Alias', n: 4, means: 'The only MusicBrainz artist with this credit as a name or alias.' }, cred: { name: 'Co-credit', n: 5, means: 'Credited alongside an artist already on this release, more often than any other artist of this name.' }, user: { name: 'Picked by you', n: null, means: 'You chose this artist.' }, set: { name: 'Already linked', n: null, means: 'Linked before Apollo matched: by the release, the seed or the page.' }, low: { name: 'Uncertain', n: null, means: 'No stage found one certain artist. This is the best candidate.' }, }; const mtClock = t => { const d = new Date(t); const p = n => String(n).padStart(2, '0'); return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); }; const mtArtistLink = (gid, name) => `<a href="${ORIGIN}/artist/${esc(gid)}" target="_blank" rel="noopener">${esc(name || gid)}</a>`; const mtRelLink = (gid, title) => `<a href="${ORIGIN}/release/${esc(gid)}" target="_blank" rel="noopener">${esc(title || gid)}</a>`; function mtEntityHtml(e, gid, name) { e = e || {}; const g = e.gid || gid; if (!g) return ''; const type = e.typeName || (e.type && (e.type.name || e.type)) || ''; const area = (e.area && e.area.name) || ''; const b = (e.begin_date && (e.begin_date.year || e.begin_date)) || (e['life-span'] && e['life-span'].begin) || ''; const en = (e.end_date && (e.end_date.year || e.end_date)) || (e['life-span'] && e['life-span'].end) || ''; const life = b || en ? String(b || '?').slice(0, 4) + '–' + String(en || '').slice(0, 4) : ''; const meta = [typeof type === 'string' ? type : '', area, life].filter(Boolean).join(' · '); return `<div class="tc-mt-who">${mtArtistLink(g, e.name || name)}${e.comment ? ` <span class="tc-mt-dim">(${esc(e.comment)})</span>` : ''}${meta ? `<span class="tc-mt-meta">${esc(meta)}</span>` : ''}</div>`; } const mtEditionLine = ed => [mtRelLink(ed.gid, ed.title), ed.date ? esc(String(ed.date).slice(0, 4)) : '', ed.format ? esc(ed.format) : ''].filter(Boolean).join(' · '); function mtEvidenceHtml(s) { const w = s._why || {}, st = s.status; if (st === 'plat' && w.plat) return `<div class="tc-mt-why">First Contact imported this release from ${esc(w.plat.name)}, which credits <a href="${esc(w.plat.url)}" target="_blank" rel="noopener">this artist page</a>, and exactly one MusicBrainz artist links it.</div>`; if (st === 'disc' && w.discogs) return `<div class="tc-mt-why">The release credits <a href="${esc(w.discogs.url)}" target="_blank" rel="noopener">this Discogs artist</a>, and exactly one MusicBrainz artist links it.</div>`; if (st === 'rg' && w.rg) { const src = w.rg.src; return '<div class="tc-mt-why">' + (src ? `Credited${w.rg.creditedAs && w.rg.creditedAs !== s.creditedAs ? ` as <b>${esc(w.rg.creditedAs)}</b>` : ''} on the same track of another release in the release group:<br>${mtEditionLine(src)} · track ${esc(src.pos)} <i>“${esc(src.track)}”</i>` : 'Credited on the same track of another release in the release group.') + '</div>'; } if (st === 'pos') { const p = s._pos || w.pos || {}; const eds = p.editions || []; const a = (p.artists || []).find(x => (x.entity ? x.entity.gid : x.gid) === s.gid); // #654 (majkinetor: "I didn't select any other edition"): an edition is the release group's // or a duplicate — a release elsewhere with this title and artist, found by search, as the // Duplicates tab lists them. Say which. const dups = eds.filter(ed => ed.from === 'dup').length; const where = !eds.length ? 'other edition' : dups === eds.length ? 'release' : 'other edition'; let h = `<div class="tc-mt-why">Credited ${w.pos && w.pos.ri ? 'as the release artist' : 'on this track'} on ${a ? a.votes : '?'} of ${p.of || '?'} ${where}${p.of === 1 ? '' : 's'}${eds.length ? ':' : '.'}</div>`; if (dups) h += `<div class="tc-mt-dim">${dups === eds.length ? 'Not in this release group: found' : 'Marked ⧉: found'} ${_artPosDupFromTab ? "by the duplicate search, a release with this title and artist (see the Duplicates tab)" : "by Apollo's own duplicate search (the Duplicates tab had none), a release with this title and artist"}. It may be this release, already in MusicBrainz.</div>`; if (eds.length) h += '<table class="tc-mt-tbl">' + eds.slice(0, 8).map(ed => { const ok = (ed.gids || []).includes(s.gid); return `<tr><td class="${ok ? 'ok' : 'no'}">${ok ? '✓' : '✗'}</td><td>${ed.from === 'dup' && dups !== eds.length ? '⧉ ' : ''}${mtEditionLine(ed)}</td><td class="w">${ok ? '' : esc((ed.names || []).join(', '))}</td></tr>`; }).join('') + (eds.length > 8 ? `<tr><td></td><td class="tc-mt-dim">and ${eds.length - 8} more</td><td></td></tr>` : '') + '</table>'; return h; } if (st === 'alias' || st === 'high') { const id = w.ident || {}; const named = s.entity && s.entity.name && !sameName(s.entity.name, s.creditedAs); return `<div class="tc-mt-why">${st === 'alias' || named ? `Credited as <b>${esc(s.creditedAs)}</b>, ${st === 'alias' ? 'an alias' : 'a name'} of this artist.` : `Credited as <b>${esc(s.creditedAs)}</b>.`} No other MusicBrainz artist has it as a name or alias${id.count != null ? ` (the search found ${id.count} artist${id.count === 1 ? '' : 's'} like it, ${id.exact} exact)` : ''}.</div>`; } if (st === 'cred' && w.cred) { const c = w.cred.counts || [], top = c.find(x => x.gid === s.gid) || c[0]; const rest = c.filter(x => x !== top).slice(0, 3); return `<div class="tc-mt-why">Co-credited with ${top ? top.count : '?'} of the ${w.cred.ctx} artist${w.cred.ctx === 1 ? '' : 's'} already on this release${rest.length ? '; next: ' + rest.map(x => `${esc(x.name)} (${x.count})`).join(', ') : ', and no other artist of this name is'}.</div>`; } if (st === 'low') return mtStagesHtml(w); return ''; } // what each stage found: the whole story of a slot no stage was sure about function mtStagesHtml(w) { const rows = []; if (w.discogs) rows.push(['Discogs link', w.discogs.n == null ? 'lookup unavailable' : w.discogs.n === 0 ? 'no MusicBrainz artist links it' : `${w.discogs.n} artists link it`]); if (w.plat) rows.push([w.plat.name + ' link', w.plat.n == null ? 'lookup unavailable' : w.plat.n === 0 ? 'no MusicBrainz artist links it' : `${w.plat.n} artists link it`]); rows.push(['Release group', w.rg ? (w.rg.found ? 'found' : 'credit found, artist not') : 'no other release credits it']); if (w.pos) rows.push(['Other editions', w.pos.of ? `${w.pos.of} agree on the track; ${w.pos.artists.length ? w.pos.artists.map(a => `${a.name} ×${a.votes}`).join(', ') : 'none credit this name'}` : 'none']); if (w.ident) rows.push(['Name / alias', w.ident.status === 'unique' ? 'unique' : w.ident.count != null ? `${w.ident.exact} exact of ${w.ident.count} similar${w.ident.status === 'incomplete' ? ', not all seen' : ''}` : 'search unavailable']); if (w.cred) rows.push(['Co-credit', w.cred.counts.length ? (w.cred.counts.length > 1 && w.cred.counts[0].count === w.cred.counts[1].count ? `tie: ${w.cred.counts.slice(0, 3).map(x => `${x.name} ×${x.count}`).join(', ')}` : w.cred.counts.slice(0, 3).map(x => `${x.name} ×${x.count}`).join(', ')) : 'none shared']); return '<div class="tc-mt-why">What each stage found:</div><table class="tc-mt-tbl">' + rows.map(r => `<tr><td class="no">–</td><td>${esc(r[0])}</td><td class="w">${esc(r[1])}</td></tr>`).join('') + '</table>'; } function matchCardHtml(s) { const st = MATCH_STAGES[s.status] || { name: s.status, means: '' }; const auto = s.status === 'user' ? 'your pick' : s.status === 'set' ? 'on the page' : s.status === 'low' ? 'not linked' : 'auto-linked'; const alts = (s.candidates || []).filter(c => (c.gid || c.id) !== s.gid).slice(0, 3); const at = s._at || (s._why && s._why.at); const ev = mtEvidenceHtml(s); // the stage's own evidence says what it means; the generic line only when there is none const body = (s.gid ? mtEntityHtml(s.entity, s.gid, s.name) : '') + (ev || `<div class="tc-mt-means">${esc(st.means)}</div>`) + (alts.length ? `<div class="tc-mt-alt">Also considered: ${alts.map(c => `${mtArtistLink(c.gid || c.id, c.name)}${c.comment ? ` <span class="tc-mt-dim">(${esc(c.comment)})</span>` : ''}`).join(', ')}</div>` : ''); return `<div class="tc-mt-h"><span class="tc-badge ${esc(s.status)}">${esc(badgeText(s))}</span><b>${esc(st.name)}</b><span class="tc-mt-st">${st.n ? 'stage ' + st.n + ' · ' : ''}${auto}</span></div>` + `<div class="tc-mt-bd">${body}</div>` + `<div class="tc-mt-ft"><span>${at ? (s.status === 'user' ? 'picked ' : 'matched ') + mtClock(at) : ''}</span><span class="k">click the artist to change</span></div>`; } let _mtCard = null, _mtShowT = 0, _mtHideT = 0, _mtFor = null; function closeMatchCard() { clearTimeout(_mtShowT); clearTimeout(_mtHideT); if (_mtCard) { _mtCard.remove(); _mtCard = null; _mtFor = null; } } function openMatchCard(badge, slot) { closeMatchCard(); const c = document.createElement('div'); c.id = 'tc-mtip'; c.className = 'mbu-ui'; c.setAttribute('role', 'tooltip'); c.innerHTML = matchCardHtml(slot); document.body.appendChild(c); const b = badge.getBoundingClientRect(), w = c.offsetWidth, h = c.offsetHeight; let left = Math.min(b.right - w, window.innerWidth - w - 8); left = Math.max(8, left); let top = b.bottom + 6; if (top + h > window.innerHeight - 8 && b.top - h - 6 > 8) top = b.top - h - 6; c.style.left = (left + window.scrollX) + 'px'; c.style.top = (top + window.scrollY) + 'px'; c.addEventListener('mouseenter', () => clearTimeout(_mtHideT)); c.addEventListener('mouseleave', () => { _mtHideT = setTimeout(closeMatchCard, 250); }); _mtCard = c; _mtFor = badge; Log.cat('Artist').debug('match card: ' + slot.status + ' for "' + slot.creditedAs + '"' + (slot._why ? '' : ' (no match record: linked before this session or picked)')); } // one listener for every badge, now and after any re-render function wireMatchCards() { if (wireMatchCards.done) return; wireMatchCards.done = true; document.addEventListener('mouseover', e => { const badge = e.target.closest && e.target.closest('.tc-mirror .tc-badge[data-slot]:not([data-ri]), .tc-ri-rb .tc-badge[data-ri]'); if (!badge || badge === _mtFor) return; clearTimeout(_mtShowT); clearTimeout(_mtHideT); _mtShowT = setTimeout(() => { if (!badge.isConnected || !badge.matches(':hover')) return; if (badge.dataset.ri != null) { const ps = _riArt[+badge.dataset.ri]; if (ps) openMatchCard(badge, ps); return; } // #652 const row = badge.closest('tr[data-tk]'); if (!row || !MODEL) return; const t = MODEL.tracks.find(x => x.mi + ':' + x.ti === row.dataset.tk); const slot = t && t.slots[+badge.dataset.slot]; if (slot) openMatchCard(badge, slot); }, 500); }); document.addEventListener('mouseout', e => { const badge = e.target.closest && e.target.closest('.tc-mirror .tc-badge[data-slot]:not([data-ri]), .tc-ri-rb .tc-badge[data-ri]'); if (!badge || (e.relatedTarget && badge.contains(e.relatedTarget))) return; clearTimeout(_mtShowT); if (_mtFor === badge) _mtHideT = setTimeout(closeMatchCard, 250); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && _mtCard) closeMatchCard(); }, true); } function posBadgeTitle(s) { if (s.status !== 'pos' || !s._pos) return ''; const a = s._pos.artists.find(x => x.entity && x.entity.gid === s.gid); if (!a) return ''; return ` aria-label="${esc(`matched by position: ${a.entity.name} is credited on this track on ${a.votes} of ${s._pos.of} other edition${s._pos.of === 1 ? '' : 's'} of the release`)}"`; } /* the badge column: a pill per artist line. #654 (majkinetor): the track ↺/✕ overlaid it and covered the pills; they get their own column after Match, "fit to 2 icon width", empty until the row is hovered. (Not the gutter by the drag handle: the data-track ⤓ lives there, and a ✕ next to it is easy to hit by accident.) */ function renderBadgeCell(cell, track) { const changed = trackChanged(track); // ↺ only makes sense (and only shows) when there's something to revert const locked = mediumLocked(track.mi); // disc-ID medium: no remove button (#125) wireMatchCards(); // #654: hover a badge for its match card cell.innerHTML = track.slots.map((s, i) => `<div class="tc-bl">${s.committed ? `<span class="tc-badge ${s.status}" data-slot="${i}"${posBadgeTitle(s)}>${badgeText(s)}</span>` : ''}</div>`).join(''); const row = cell.closest('tr'); const actCell = (row && row.querySelector('td.c-act')) || cell; // both slots always rendered, so ✕ never moves when ↺ comes and goes actCell.innerHTML = `<div class="tc-trackacts"><button class="trev${changed ? '' : ' void'}" title="revert this track"${changed ? '' : ' tabindex="-1" aria-hidden="true"'}>↺</button>` + `<button class="rm${locked ? ' void' : ''}" title="remove track"${locked ? ' tabindex="-1" aria-hidden="true"' : ''}>✕</button></div>`; const trev = actCell.querySelector('.trev:not(.void)'); if (trev) trev.onclick = () => revertTrack(track); const rm = actCell.querySelector('.rm:not(.void)'); if (rm) rm.onclick = () => { removeTrack(track); rebuild(); }; if (row) row.classList.toggle('tc-changed', changed); // mark the row (left border) } // join phrase: editable text that grows right-to-left, plus a ▾ that opens the presets list function joinControl(entry, slot, refreshBadges) { const wrap = document.createElement('span'); wrap.className = 'tc-joinwrap'; const inp = noPw(document.createElement('input')); inp.className = 'tc-join'; inp.value = slot.joinPhrase || ''; inp.title = 'join phrase to the next artist (editable; ▾ for presets)'; const fit = () => { inp.size = Math.max(2, inp.value.length || 2); }; fit(); // #208: flag a join phrase missing a space on either side (␣) or missing // entirely (␣?␣). Every rendered join control sits BETWEEN two artists // (the last slot has none), so an empty value here is a genuine gap. // Same px>0 master switch as the #203 marking. const markJoin = () => { wrap.classList.remove('tc-jp-nolead', 'tc-jp-notrail', 'tc-jp-nophrase', 'tc-jp-bad'); if (!dhMark()) return; // #443: gated by detailed highlighting, not the px size const v = inp.value; if (v === '') { wrap.classList.add('tc-jp-nophrase', 'tc-jp-bad'); return; } // CJK scripts (Japanese と / 、, Chinese, Korean) don't space their joins — // skip the space flags when the join or an adjacent name is CJK (#208, chaban) const idx = entry.slots.indexOf(slot); const nxt = entry.slots[idx + 1]; const cjk = isCjk(v) || isCjk(slot.creditedAs || slot.name) || (nxt && isCjk(nxt.creditedAs || nxt.name)); let bad = false; // leading space wanted unless the join is a tight ","/";" separator (#208, chaban) if (!cjk && !/^\s/.test(v) && !/^\s*[,;]/.test(v)) { wrap.classList.add('tc-jp-nolead'); bad = true; } if (!cjk && !/\s$/.test(v)) { wrap.classList.add('tc-jp-notrail'); bad = true; } if (bad) wrap.classList.add('tc-jp-bad'); }; const commit = () => { editCredit(entry, () => { slot.joinPhrase = inp.value; }, 'join phrase', false); markJoin(); if (refreshBadges) refreshBadges(); }; inp.oninput = () => { fit(); markJoin(); if (inp.value.trim()) open(inp.value); else close(); }; // #419: typing filters the presets live inp.onchange = () => { commit(); }; const arrow = document.createElement('button'); arrow.className = 'tc-joinarrow'; arrow.textContent = '▾'; arrow.title = 'common join phrases (type to filter · ↑↓ + Enter)'; // #419: keyboard-first presets — typing filters ("fe" → feat. / featuring), ArrowDown // opens/moves, Enter picks the highlighted row, Esc closes; same keys on the ▾ button. let pop = null, items = [], hi = -1; const close = () => { if (pop) { pop.remove(); pop = null; hi = -1; } }; const pick = v => { inp.value = v; fit(); commit(); close(); }; const paint = () => { pop.innerHTML = items.map((o, i) => `<div class="tc-acrow${i === hi ? ' hi' : ''}" data-i="${i}"><span class="nm">${esc(o.label)}</span><span class="cmt">"${esc(o.value)}"</span></div>`).join(''); [...pop.querySelectorAll('[data-i]')].forEach(row => { row.onmousedown = e => { e.preventDefault(); pick(items[+row.dataset.i].value); }; }); }; const open = q => { const needle = String(q || '').trim().toLowerCase(); items = needle ? JOIN_OPTIONS.filter(o => o.label.toLowerCase().includes(needle) || o.value.trim().toLowerCase().includes(needle)) : JOIN_OPTIONS.slice(); if (!items.length) { close(); return; } if (!pop) { pop = document.createElement('div'); pop.className = 'tc-acpop tc-joinpop'; pop._tcOwner = inp; document.body.appendChild(pop); pop.style.minWidth = '150px'; const off = e => { if (!pop) { document.removeEventListener('mousedown', off); return; } if (!pop.contains(e.target) && e.target !== arrow && e.target !== inp) { close(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } hi = needle ? 0 : -1; // typing pre-highlights the top hit so Enter picks it straight away paint(); acPlace(pop, inp, { right: 150, gap: 4, minWidth: 150 }); // #735: on screen, after the list is filled }; const keys = e => { if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); if (!pop) open(inp.value); else { hi = (hi + 1) % items.length; paint(); } } else if (e.key === 'ArrowUp') { if (!pop) return; e.preventDefault(); e.stopPropagation(); hi = (hi - 1 + items.length) % items.length; paint(); } else if (e.key === 'Enter') { if (!pop && e.currentTarget === arrow) return; // let the button's native click open the menu // Enter must never reach MB's form (it switches tabs) — same contract enterBlurs had e.preventDefault(); e.stopPropagation(); if (pop && hi >= 0 && items[hi]) pick(items[hi].value); else { close(); inp.blur(); } } else if (e.key === 'Escape') { if (pop) { e.preventDefault(); e.stopPropagation(); close(); } } }; inp.addEventListener('keydown', keys); arrow.addEventListener('keydown', keys); inp.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== arrow) close(); }, 120)); // Tab away closes (row picks keep focus via preventDefault) arrow.onclick = () => { if (pop) close(); else open(''); }; wrap.appendChild(inp); wrap.appendChild(arrow); markJoin(); return wrap; } // attach the type-to-search autocomplete to an existing <input> function wireAutocomplete(inp, slot, refresh) { let pop = null, list = [], hi = -1, seq = 0, onScroll = null; let curQuery = '', curLimit = 8; // "Show more…" pagination: bump the limit and re-search const position = () => { if (pop) acPlace(pop, inp); }; // #735: stays on screen (flips above near the bottom) const ensure = () => { if (pop) return; pop = document.createElement('div'); pop.className = 'tc-acpop'; pop._tcOwner = inp; document.body.appendChild(pop); onScroll = () => { if (!pop || !pop.isConnected) { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onScroll); pop = null; } else position(); }; window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onScroll); position(); }; const close = () => { if (pop) pop.remove(); pop = null; hi = -1; if (onScroll) { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onScroll); onScroll = null; } }; const choose = c => { close(); pickArtist(slot, c); }; const searching = () => { ensure(); list = []; pop.innerHTML = `<div class="tc-acrow none">Searching…</div>`; position(); }; const akaHtml = c => { const a = aliasStr(c); return a ? `<span class="tc-aka">${esc(a)}</span>` : ''; }; // a full page of results probably means there are more — offer "Show more…" (like MB's native popup) const loadMore = () => { curLimit = curLimit >= 50 ? 100 : curLimit >= 25 ? 50 : 25; const my = ++seq; const more = pop && pop.querySelector('.tc-acmore'); if (more) more.textContent = 'Loading…'; searchArtist(curQuery, curLimit).then(res => { if (my === seq && document.activeElement === inp) showResults(res, curQuery); }); }; // #626: the artists other editions credit at this track's position lead the list, in a // section of their own, with how many editions back each — also once the slot resolved. const posLead = () => ((slot._pos && slot._pos.artists) || []).filter(x => x.entity && x.entity.gid); const draw = res => { ensure(); const q = inp.value.trim() || slot.creditedAs; const lead = posLead(), leadG = new Set(lead.map(x => x.entity.gid)), votes = new Map(lead.map(x => [x.entity.gid, x.votes])); Log.cat('Artist').debug(`picker "${slot.creditedAs}": ${lead.length} artist(s) from other editions at this position, ${res.length} from the search (slot ${slot.status}${slot._pos ? ', position data kept' : ', no position data'})`); const arr = lead.length ? [...lead.map(x => x.entity), ...res.filter(c => !leadG.has(c.gid || c.id))] : res; list = arr; const of = lead.length ? slot._pos.of : 0; const sec = (i) => !lead.length ? '' : i === 0 ? '<div class="tc-acsec">On other editions at this position</div>' : i === lead.length ? '<div class="tc-acsec">Search</div>' : ''; const votesHtml = c => votes.has(c.gid) ? `<span class="tc-acvotes" title="credited on this track on ${votes.get(c.gid)} of ${of} other edition${of === 1 ? '' : 's'} of the release">${votes.get(c.gid)} of ${of} edition${of === 1 ? '' : 's'}</span>` : ''; pop.innerHTML = arr.length ? arr.map((c, i) => sec(i) + `<div class="tc-acrow${sameName(c.name, q) ? ' exact' : ''}" data-i="${i}">${c.gid ? `<a class="tic" href="${ORIGIN}/artist/${esc(c.gid)}" target="_blank" rel="noopener" title="open artist page">${typeSvg(c)}</a>` : `<span class="tic">${typeSvg(c)}</span>`}<span class="nm">${esc(c.name)}</span>${akaHtml(c)}${c.comment ? `<span class="cmt">${esc(c.comment)}</span>` : ''}${votesHtml(c)}</div>`).join('') : `<div class="tc-acrow none">no matches — use + to create</div>`; [...pop.querySelectorAll('.tc-acrow[data-i]')].forEach(row => { row.title = 'click to set · Ctrl-click to set on all unresolved tracks'; row.onmousedown = e => { e.preventDefault(); const c = arr[+row.dataset.i]; if (e.ctrlKey || e.metaKey) { close(); pickArtistAllUnresolved(c); } else choose(c); }; }); // #689: the type icon opens the artist in a new tab, like the one beside a resolved field — its // mousedown stays off the row (no pick) and off the input (focus, so the popup stays open) pop.querySelectorAll('a.tic').forEach(a => { a.onmousedown = e => { e.preventDefault(); e.stopPropagation(); }; }); if (curQuery && res.length >= curLimit && curLimit < 100) { // likely more available → a clickable "Show more…" footer const more = document.createElement('div'); more.className = 'tc-acrow tc-acmore'; more.textContent = 'Show more…'; more.onmousedown = e => { e.preventDefault(); loadMore(); }; pop.appendChild(more); } position(); return arr; }; // patch in the full aliases (one WS2 search) without a full redraw, so it doesn't reset the keyboard highlight const patchAliases = arr => { if (!pop) return; arr.forEach((c, i) => { const a = aliasStr(c); if (!a) return; const row = pop.querySelector(`.tc-acrow[data-i="${i}"]`); if (!row) return; let sp = row.querySelector('.tc-aka'); if (!sp) { sp = document.createElement('span'); sp.className = 'tc-aka'; const nm = row.querySelector('.nm'); nm.parentNode.insertBefore(sp, nm.nextSibling); } sp.textContent = a; }); }; const showResults = (res, q) => { const arr = draw(res); // #626: the rows actually drawn (position artists first) // Fetch aliases for the EXACT result gids (batched, limit 100) — not the old by-name query // capped at 12, which left every "Show more" result past the 12th with no alias. Caches into // _gidAliases so a pick (and the resolved bar) get the alias too. #128 fetchAliasesByGids(arr.map(c => c.gid)).then(() => { if (document.activeElement !== inp || !pop) return; arr.forEach(c => { const a = _gidAliases.get(c.gid); if (a && a.length) c.aliases = a; }); patchAliases(arr); }); }; const runSearch = q => { curQuery = q; curLimit = 8; const my = ++seq; searching(); searchArtist(q).then(res => { if (my === seq && document.activeElement === inp) showResults(res, q); }); }; // paste an MBID or a MusicBrainz /artist/<mbid> URL → resolve it straight to that artist. Gate on the // field value (not focus): a commit-rerender can steal focus before the fetch returns. const resolveByGid = async gid => { ensure(); list = []; pop.innerHTML = `<div class="tc-acrow none">Resolving…</div>`; position(); const ent = await fetchEntity(gid); if (mbidFrom(inp.value) !== gid) return; if (ent && ent.id) { const entry = slot._entry, i = entry.slots.indexOf(slot); close(); pickArtist(slot, ent); focusSlotInput(entry, i); } else { pop.innerHTML = `<div class="tc-acrow none">MBID not found</div>`; } }; // refocus the (re-rendered) field so keyboard nav keeps working after a paste inp.onfocus = () => { slot._editing = true; // #580: this slot is the user's now — auto-match leaves it alone until they leave it if (slot.committed && slot.candidates && slot.candidates.length) { curQuery = inp.value.trim() || slot.creditedAs || slot.name; curLimit = 8; showResults(slot.candidates, curQuery); return; } const q = inp.value.trim() || (slot.creditedAs || '').trim(); if (q) runSearch(q); else close(); // empty → no dropdown }; let tmr; inp.oninput = () => { slot.query = inp.value; clearTimeout(tmr); const gid = mbidFrom(inp.value); if (gid) { resolveByGid(gid); return; } // pasted an MBID / artist URL → resolve directly (pickArtist replaces whatever was there; no un-link needed) // editing away from the matched artist un-links it: bar goes white, + creates the typed name if (slot.committed && !sameName(inp.value, slot.name)) { slot.committed = false; slot.status = 'none'; slot.entity = null; slot.gid = null; commitTrack(slot._entry); if (refresh) refresh(); } if (!inp.value.trim()) { close(); return; } // nothing typed → don't search searching(); const my = ++seq; tmr = setTimeout(async () => { curQuery = inp.value; curLimit = 8; const res = await searchArtist(inp.value); if (my === seq && document.activeElement === inp) showResults(res, inp.value); }, 250); }; // arrows browse the results popup WHILE searching; once the slot is resolved they move row-to-row instead const browsing = () => pop && !slot.committed && list.length; // highlight the row at index `hi` and keep it on screen (the popup scrolls, so a selection past the // last visible row was going off-screen) — #128-adjacent nav fix const hiliteRow = () => { const rows = [...pop.querySelectorAll('[data-i]')]; rows.forEach((r, i) => r.classList.toggle('hi', i === hi)); const cur = rows[hi]; if (cur) cur.scrollIntoView({ block: 'nearest' }); }; inp.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); inp.focus(); } // close the popup but keep the field focused, so the next ↓ navigates rows else if (e.key === 'ArrowDown') { if (browsing()) { hi = Math.min(list.length - 1, hi + 1); hiliteRow(); e.preventDefault(); } else { close(); if (focusSameField(inp, 1)) e.preventDefault(); } } else if (e.key === 'ArrowUp') { if (browsing()) { hi = Math.max(0, hi - 1); hiliteRow(); e.preventDefault(); } else { close(); if (focusSameField(inp, -1)) e.preventDefault(); } } else if (e.key === 'Enter') { e.preventDefault(); const c = list[hi >= 0 ? hi : 0]; if (c) { const entry = slot._entry, i = entry.slots.indexOf(slot); choose(c); focusSlotInput(entry, i); } } // keep focus on the field after picking (so ↓ moves on) }; inp.onblur = () => { slot._editing = false; setTimeout(close, 160); }; // keep whatever the user typed (no reset); #580 hands the slot back to auto-match } // #284: hovering an artist highlights every OTHER instance of that same artist in // the tracklist (matched by gid when resolved, else by the typed/credited name) — // the same idea as the green "matched" bars, but live on hover. STICKY: the last // highlighted artist stays lit even when the mouse isn't over it; moving onto a // DIFFERENT real artist clears the previous highlight (and lights the new one up if // it appears more than once). Only empty slots / gaps keep the previous highlight — // so moving the mouse across rows/gaps never flickers, but landing on another artist // (even a one-off) drops the stale highlight. function hlArtist(id) { if (!SETTINGS.hoverHighlight) return; // #284: opt-in (Appearance setting, off by default) if (!id || id === 'n:' || id === 'g:' || id === _hlCur) return; // empty slot or already current → keep current _hlCur = id; document.querySelectorAll('.tc-aslot.tc-arthl').forEach(e => e.classList.remove('tc-arthl')); const matches = [...document.querySelectorAll('.tc-aslot')].filter(e => e.dataset.art === id); if (matches.length > 1) matches.forEach(e => e.classList.add('tc-arthl')); // single instance → nothing to show } let _hlCur = null; const slotArtId = s => (s.committed && s.gid) ? 'g:' + s.gid : 'n:' + fold(s.creditedAs || s.name || s.query || ''); // one artist = one aligned line: [credited-as][icon][green/white search bar][join][↵ hover][✕ hover] function slotEl(entry, s, idx, refreshBadges) { const line = document.createElement('div'); line.className = 'tc-aslot'; line.dataset.art = slotArtId(s); line.addEventListener('mouseenter', () => hlArtist(line.dataset.art)); // "splittable" (several artists) drives both the credited-as highlight and the ⋔ button, via a line // class that updates live as you edit if (splitArtistText(s.creditedAs || s.name || s.query || '').length > 1) line.classList.add('tc-can-split'); // credited-as: shown empty when it's exactly the artist name (the name is the placeholder); only a real override shows const same = s.name && s.creditedAs === s.name; const cred = noPw(document.createElement('input')); cred.className = 'tc-cred'; cred.value = (s.creditedAs && !same) ? s.creditedAs : ''; cred.placeholder = s.name || 'credit…'; cred.title = 'credited-as override (blank = same as the artist name)'; // #228: a hover × on the left of the field clears the credited-as override const credClr = document.createElement('button'); credClr.type = 'button'; credClr.className = 'tc-cred-clr'; credClr.textContent = '×'; credClr.title = 'clear the credited-as override · right-click: clear every credited-as on the tracklist'; const updCredClr = () => line.classList.toggle('tc-has-cred', !!cred.value.trim()); credClr.onmousedown = e => e.preventDefault(); credClr.onclick = () => { if (!cred.value) return; cred.value = ''; cred.onchange(); updCredClr(); }; credClr.oncontextmenu = e => { e.preventDefault(); clearAllCredits(); }; // #735 cred.oninput = () => { line.classList.toggle('tc-can-split', splitArtistText(cred.value || s.name || '').length > 1); updCredClr(); }; // re-evaluate the highlight / ⋔ + clear button as you type cred.onchange = () => { const v = cred.value.trim(); const newCred = v || (s.name || ''); // whole-credit "all matching tracks" propagation (liveRerender=false → keep focus when nothing propagates) editCredit(entry, () => { s.creditedAs = newCred; if (s.creditedAs === s.name) cred.value = ''; }, 'credited-as', false); refreshBadges(); // a credited-as edit changes the track → update the ↺ button + changed-row border now }; wireRowNav(cred); // wrap so the × can be absolutely positioned over the field's (empty, right- // aligned) left edge — it must not take flow space or it shifts the row (#228) const credWrap = document.createElement('span'); credWrap.className = 'tc-credwrap'; credWrap.appendChild(cred); credWrap.appendChild(credClr); line.appendChild(credWrap); updCredClr(); let ic; if (s._discogsAddable) { // #227: a Discogs link can be added — swap the artist-type icon for a link icon. // Amber warning glyph (still clickable to add) when the URL belongs to a // different MB artist (conflict) or the artist already links a different // Discogs page (mismatch); plain teal link icon otherwise. const conf = s._discogsConflict, mism = s._discogsMismatch; // conflict (URL belongs to a different MB artist) → red triangle; mismatch (artist links a // different Discogs page) → amber circle-! ; conflict wins the icon when both are flagged. const warnCls = conf ? 'discogs-conflict' : 'discogs-mismatch', warnSvg = conf ? DISCOGS_WARN_SVG : DISCOGS_MISMATCH_SVG; ic = document.createElement('a'); ic.className = 'tc-tic ' + ((conf || mism) ? warnCls : 'discogs-add'); ic.href = '#'; ic.innerHTML = (conf || mism) ? warnSvg : DISCOGS_LINK_SVG; ic.title = discAddTooltip(s); ic.title += ' · right-click: do it silently in a background tab'; ic.onmousedown = e => e.preventDefault(); ic.onclick = e => { e.preventDefault(); addOrCreateDiscogsLink(s); }; ic.oncontextmenu = e => { e.preventDefault(); addOrCreateDiscogsLink(s, true); }; // #273 background } else if (s._platAddable) { // #651: the same offer for First Contact's platform link const conf = s._platConflict; ic = document.createElement('a'); ic.className = 'tc-tic ' + (conf ? 'discogs-conflict' : 'discogs-add') + ' plat-offer'; ic.href = '#'; ic.innerHTML = conf ? DISCOGS_WARN_SVG : DISCOGS_LINK_SVG; ic.title = platAddTooltip(s) + ' · right-click: do it silently in a background tab'; ic.onmousedown = e => e.preventDefault(); ic.onclick = e => { e.preventDefault(); addOrCreatePlatformLink(s); }; ic.oncontextmenu = e => { e.preventDefault(); addOrCreatePlatformLink(s, true); }; } else if (s._discogsConflict) { // unresolved slot whose Discogs URL already belongs to an MB artist — info only (pick that artist) ic = document.createElement('a'); ic.className = 'tc-tic discogs-conflict'; ic.innerHTML = DISCOGS_WARN_SVG; ic.href = `${ORIGIN}/artist/${s._discogsConflict.gid}`; ic.target = '_blank'; ic.rel = 'noopener'; ic.title = `Discogs links this URL to ${s._discogsConflict.name} — pick that artist`; } else if (s._platConflict && !(s.committed && s.gid)) { // #651: an unmatched slot whose platform link belongs to an MB artist — info only (pick that artist) const p = platformOf(s._platUrl) || { name: 'platform' }; ic = document.createElement('a'); ic.className = 'tc-tic discogs-conflict plat-offer'; ic.innerHTML = DISCOGS_WARN_SVG; ic.href = `${ORIGIN}/artist/${s._platConflict.gid}`; ic.target = '_blank'; ic.rel = 'noopener'; ic.title = `${p.name}: ${s._platUrl} is linked to ${s._platConflict.name} — pick that artist`; } else { ic = document.createElement(s.gid ? 'a' : 'span'); ic.className = 'tc-tic ' + (s.gid ? 'link' : 'dim'); ic.innerHTML = typeSvg(s.entity); if (s.gid) { ic.href = `${ORIGIN}/artist/${s.gid}`; ic.target = '_blank'; ic.rel = 'noopener'; ic.title = 'open artist page'; } else ic.title = 'no artist linked yet'; } line.appendChild(ic); const search = document.createElement('span'); search.className = 'tc-search'; const inp = noPw(document.createElement('input')); inp.className = 'nm'; inp.value = s.committed ? (s.name || s.creditedAs) : (s.query || s.creditedAs || ''); inp.placeholder = 'search artist…'; inp.title = inp.value; search.appendChild(inp); // #228: hover × right after the name clears the field (and unsets a matched // artist — the input handler un-links on an empty value) const nmClr = document.createElement('button'); nmClr.type = 'button'; nmClr.className = 'tc-nm-clr'; nmClr.textContent = '×'; nmClr.title = 'clear / unset the artist'; // size the input to its text (so the clear × hugs the name when not editing — #284 follow-up); // `size` is in characters, close enough. Min 2 keeps a clicked-but-empty field usable. const updNmClr = () => { const v = inp.value.trim(); search.classList.toggle('tc-has-nm', !!v); fitNmWidth(inp); }; nmClr.onmousedown = e => e.preventDefault(); nmClr.onclick = () => { inp.value = ''; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.focus(); updNmClr(); }; inp.addEventListener('input', updNmClr); search.appendChild(nmClr); updNmClr(); if (idx < entry.slots.length - 1) search.appendChild(joinControl(entry, s, refreshBadges)); // join lives inside the box, right side // #376 keep the pending gold in sync with the slot's selected/committed state on every re-adorn — // editing the field to free text (unselecting the mbid) must drop the gold, not just the green. const applyPend = () => { const ps = recPendingState(entry.mi, entry.ti); search.classList.toggle('tc-slot-pending', slotIsPending(ps && ps.artGids, s)); }; adorn(search, s, inp); applyPend(); if (s._marked) search.classList.add('tc-marked'); if (s._flash) { search.classList.add('tc-flash'); delete s._flash; } line.appendChild(search); wireAutocomplete(inp, s, () => { adorn(search, s, inp); applyPend(); refreshBadges(); refreshStatus(); }); // fixed-width actions area (keeps all search boxes the same width); both reveal on row hover const acts = document.createElement('span'); acts.className = 'tc-acts'; const add = document.createElement('button'); add.className = 'tc-enter'; add.textContent = '↵'; add.title = 'add another artist to this credit'; add.onclick = () => addSlotAfter(entry, idx); acts.appendChild(add); // ⋔ split: only when this credit looks like several artists (& / feat. / , …) { const sp = document.createElement('button'); sp.className = 'tc-splitb'; sp.textContent = '⋔'; sp.title = 'split into separate artists (& / feat. …) and match'; sp.onclick = () => splitSlot(entry, idx); acts.appendChild(sp); } if (entry.slots.length > 1) { const g = document.createElement('span'); g.className = 'tc-slotgrab'; g.textContent = '⠿'; g.draggable = true; g.title = 'drag to reorder this artist within the credit'; acts.appendChild(g); // #150 const x = document.createElement('button'); x.className = 'tc-slotx'; x.textContent = '✕'; x.title = 'remove this artist'; x.onclick = () => removeSlot(entry, idx); acts.appendChild(x); } line.appendChild(acts); if (entry.slots.length > 1) wireSlotDrag(line, entry, idx); // #150 return line; } // #150: reorder an artist within a track's credit by dragging its ⠿ handle onto another slot. // Join phrases ("feat." / "&" / …) are positional separators that stay put as artists move through // them — so "A feat. B" reordered reads "B feat. A", and the final position is always join-less. function moveSlot(entry, from, to) { const n0 = entry.slots.length; if (from === to || from < 0 || to < 0 || from >= n0 || to >= n0) return; editCredit(entry, () => { const joins = entry.slots.map(s => s.joinPhrase); const [s] = entry.slots.splice(from, 1); entry.slots.splice(to, 0, s); entry.slots.forEach((x, i) => { x.joinPhrase = i < n0 - 1 ? (joins[i] || ' & ') : ''; x._entry = entry; }); }, 'reordered artist'); } let _slotDrag = null; // { entry, from } of the artist slot being dragged function wireSlotDrag(line, entry, idx) { const handle = line.querySelector('.tc-slotgrab'); if (handle) { handle.addEventListener('dragstart', e => { _slotDrag = { entry, from: idx }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', 'slot'); } catch (x) {} try { e.dataTransfer.setDragImage(line, 18, 12); } catch (x) {} line.classList.add('tc-slotdragging'); }); handle.addEventListener('dragend', () => { line.classList.remove('tc-slotdragging'); clearSlotDropMarks(line.parentElement); _slotDrag = null; }); } const after = e => (e.clientY - line.getBoundingClientRect().top) > line.getBoundingClientRect().height / 2; line.addEventListener('dragover', e => { if (!_slotDrag || _slotDrag.entry !== entry) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearSlotDropMarks(line.parentElement); line.classList.add(after(e) ? 'tc-slotdrop-after' : 'tc-slotdrop-before'); }); line.addEventListener('dragleave', () => line.classList.remove('tc-slotdrop-before', 'tc-slotdrop-after')); line.addEventListener('drop', e => { if (!_slotDrag || _slotDrag.entry !== entry) return; e.preventDefault(); const from = _slotDrag.from, gap = idx + (after(e) ? 1 : 0), dest = gap > from ? gap - 1 : gap; clearSlotDropMarks(line.parentElement); _slotDrag = null; moveSlot(entry, from, dest); }); } const clearSlotDropMarks = host => host && host.querySelectorAll('.tc-slotdrop-before,.tc-slotdrop-after').forEach(l => l.classList.remove('tc-slotdrop-before', 'tc-slotdrop-after')); // drag-to-reorder WITHIN a medium: grab the ⠿ handle and drop a track anywhere in its medium. The actual // move rides on MB's own up/down ops (moveTrackToIndex), so the editor never diverges. Cross-medium drops // are ignored (same-medium only). Replaces the old ▲▼ buttons. let _drag = null; // { mi, ti } of the row being dragged const clearDropMarks = tb => tb && tb.querySelectorAll('.tc-drop-before,.tc-drop-after').forEach(r => r.classList.remove('tc-drop-before', 'tc-drop-after')); const dropAfter = (tr, clientY) => { const r = tr.getBoundingClientRect(); return (clientY - r.top) > r.height / 2; }; function wireDragReorder(tr, t) { const handle = tr.querySelector('.tc-drag'); if (handle) { handle.addEventListener('dragstart', e => { _drag = { mi: t.mi, ti: t.ti, kind: trackKind(t) }; e.dataTransfer.effectAllowed = 'move'; // #586: kind, so a drag can't cross the data boundary try { e.dataTransfer.setData('text/plain', t.mi + ':' + t.ti); } catch (x) {} try { e.dataTransfer.setDragImage(tr, 18, 12); } catch (x) {} tr.classList.add('tc-dragging'); }); handle.addEventListener('dragend', () => { tr.classList.remove('tc-dragging'); clearDropMarks(tr.parentElement); _drag = null; }); } tr.addEventListener('dragover', e => { /* #586 — same medium AND same side of the data-track boundary. Crossing it is what ⤓/⤒ are for: MB's moveTrackUp DEMOTES the first data track out of the section instead of swapping, so a drag across the line would strand a track mid-list, which is a state MB won't accept. */ if (!_drag || _drag.mi !== t.mi || _drag.kind !== trackKind(t)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearDropMarks(tr.parentElement); tr.classList.add(dropAfter(tr, e.clientY) ? 'tc-drop-after' : 'tc-drop-before'); }); tr.addEventListener('dragleave', () => tr.classList.remove('tc-drop-before', 'tc-drop-after')); tr.addEventListener('drop', e => { if (!_drag || _drag.mi !== t.mi || _drag.kind !== trackKind(t)) return; // #586 e.preventDefault(); const fromTi = _drag.ti, gap = t.ti + (dropAfter(tr, e.clientY) ? 1 : 0), dest = gap > fromTi ? gap - 1 : gap; clearDropMarks(tr.parentElement); _drag = null; if (moveTrackToIndex({ mi: t.mi, ti: fromTi, number: fromTi + 1 }, dest)) rebuild(); }); } function fillRows(tbody, mi) { // rebuilding rows detaches inputs — drop any open search/join popups so they can't orphan. Only // the Tracklist's: a matching pass rebuilds the rows again and again, and this also shut the // Label and release artist pickers on Release information the moment they opened document.querySelectorAll('.tc-acpop').forEach(p => { if (!(p._tcOwner && p._tcOwner.closest('#information'))) p.remove(); }); tbody.innerHTML = ''; let lastMi = -1, lastDataMi = -1; const multi = mediums().length > 1 && mi == null; const tracks = (mi == null) ? MODEL.tracks : MODEL.tracks.filter(t => t.mi === mi); tracks.forEach(t => { if (multi && t.mi !== lastMi) { const r = document.createElement('tr'); const td = document.createElement('td'); td.className = 'tc-medhdr'; td.colSpan = COLS.length; td.textContent = `Medium ${t.mi + 1}`; const o = mediumOpts(t.mi); if (o) td.appendChild(o); r.appendChild(td); tbody.appendChild(r); lastMi = t.mi; } // #330: per-medium pregap/data toggles in the combined view const kind = trackKind(t); // #330: pregap / data / audio if (kind === 'data' && t.mi !== lastDataMi) { const dr = document.createElement('tr'); dr.className = 'tc-datadiv'; dr.innerHTML = `<td colspan="${COLS.length}">⤓ Data tracks</td>`; tbody.appendChild(dr); lastDataMi = t.mi; } const tr = document.createElement('tr'); tr.dataset.tk = t.mi + ':' + t.ti; tr.dataset.mi = t.mi; tr.dataset.ti = t.ti; if (kind !== 'audio') tr.classList.add(kind === 'pregap' ? 'tc-row-pregap' : 'tc-row-data'); // #376 mirror MB's native "modification pending" mark (span.mp): flag recordings that have open edits const ps = recPendingState(t.mi, t.ti); if (ps && (ps.rec || ps.art)) { if (ps.rec) tr.classList.add('tc-rec-pending'); if (ps.art) tr.classList.add('tc-art-pending'); tr.title = ps.rec && ps.art ? 'Recording and artist have pending edits' : ps.rec ? 'This recording has pending edits' : 'This recording’s artist has pending edits'; } const locked = mediumLocked(t.mi); // disc-ID medium: no reorder handle (#125) const lenLocked = trackLenLocked(t); // disc-ID medium: audio-track length fixed by the TOC (#329) /* #586 (chaban-mb: "is it intentional within the data section tracks cannot be reordered?") — data tracks now drag among themselves. #330 excluded them along with pregap, but native reorders inside the section freely: moveTrackDown falls through to swapTracks when both tracks are data, and it isn't disc-ID-disabled in that case either. Pregap stays out — it is pinned at position 0 by definition. */ const canDrag = !locked && kind !== 'pregap'; /* #586 — boundary buttons in the move column, where native keeps its own. ⤓ only where it can do something (an audio track that isn't the last on its medium, i.e. there is a section to open below it); ⤒ on every data track. Hidden until the row is hovered so the column doesn't gain a permanent second glyph — visibility, not display, so nothing shifts. */ const lastOfMedium = !MODEL.tracks.some(x => x.mi === t.mi && x.ti > t.ti); /* chaban-mb: "Last track is not preserving space for move track down arrow." ⤓ can't do anything on the last track of a medium (there is no section to open below it), but OMITTING the element left that row's ⠿ handle at a different x than every other row's. The slot is always rendered; on the last track it is inert and permanently invisible, so the column lines up. */ const dtBtn = locked || kind === 'pregap' ? '' : kind === 'data' ? '<button type="button" class="tc-dtmv up" title="Move this track — and any data track above it — back into the audio section">⤒</button>' : lastOfMedium ? '<button type="button" class="tc-dtmv down void" tabindex="-1" aria-hidden="true">⤓</button>' // the glyph, hidden: an EMPTY button is ~6px and still misaligns the handle : '<button type="button" class="tc-dtmv down" title="Move this track and everything below it on this medium into the data-track section">⤓</button>'; tr.innerHTML = `<td class="c-mv">${canDrag ? '<span class="tc-drag" draggable="true" title="drag to reorder within this medium">⠿</span>' : ''}${dtBtn}</td> <td class="c-num"><input class="t-num" ${NOPW_ATTRS} value="${esc(t.number)}" title="track number"></td> <td class="c-title"><div class="t-wrap">${kind === 'pregap' ? '<span class="tc-trkkind" title="Hidden pregap track (position 0)">pregap</span>' : ''}${trackRecIsVideo(t) ? VIDEO_MARK_PRE : ''}<input class="t-title" ${NOPW_ATTRS} value="${esc(t.title)}" placeholder="title…"></div></td> <td class="c-art"></td> <td class="c-len"><input class="t-len" ${NOPW_ATTRS} value="${esc(t.length)}"${lenLocked ? ' readonly tabindex="-1" title="Length is fixed by this medium’s Disc ID"' : ''}></td> <td class="c-badge"></td> <td class="c-act"></td>`; const badgeCell = tr.querySelector('.c-badge'); const refreshBadges = () => renderBadgeCell(badgeCell, t); const art = tr.querySelector('.c-art'); const pgids = ps && ps.artGids; // #376 gold ONLY the slot(s) whose SELECTED artist has pending edits — not free-text neighbours, and not a field edited to free text t.slots.forEach((s, si) => { const se = slotEl(t, s, si, refreshBadges); const sr = se.querySelector('.tc-search'); if (sr) sr.classList.toggle('tc-slot-pending', slotIsPending(pgids, s)); art.appendChild(se); }); refreshBadges(); // guess-case: highlight when the title differs from its guessed form; a per-title button applies it const tin = tr.querySelector('.t-title'); const diff = t.guessTitle && t.guessTitle !== t.title; if (t._srFlash) { tin.classList.add('srflash'); delete t._srFlash; } // flash titles changed by search & replace // #203: rich title display — show the title as styled read-only text (confusable / // invisible chars enlarged + named on hover) when not editing, and drop into the // native input on click/tab. Only when enlargement is on (recPunctSize > 0). let disp = null, paintDisp = null; if ((SETTINGS.recPunctSize | 0) > 0) { disp = document.createElement('span'); disp.className = 't-title-disp'; paintDisp = (val) => { const v = val != null ? val : tin.value; // empty title → a faint "title…" placeholder so there's a visible, full-height // click target (an empty disp collapses to a hard-to-hit thin line). #357 disp.innerHTML = v ? dhRun(v) : '<span class="t-title-ph">title…</span>'; ['diff', 'gcpreview', 'hasfeat', 'srflash'].forEach(c => disp.classList.toggle(c, tin.classList.contains(c))); }; tin.classList.add('tc-eml'); tin.parentElement.insertBefore(disp, tin); // sits in the input's flex slot while resting // #601: work out WHICH character was clicked before anything moves — // focus() hides the span (display:none), after which there is nothing // left to hit-test against. disp.addEventListener('mousedown', e => { if (e.button !== 0) return; const at = dispCaretIndex(disp, tin, e.clientX, e.clientY); e.preventDefault(); tin.focus(); // after focus(), so it wins over the focus listeners — `restore()` // assigns tin.value, and assigning value resets the selection. try { tin.setSelectionRange(at, at); } catch (err) {} }); tin.addEventListener('focus', () => { tin.classList.add('tc-editing'); disp.classList.add('tc-hidden'); }); tin.addEventListener('blur', () => { tin.classList.remove('tc-editing'); paintDisp(tin.value); disp.classList.remove('tc-hidden'); }); paintDisp(t.title); } if (diff) { tin.classList.add('diff'); tin.title = 'Guess case → ' + t.guessTitle; const gb = document.createElement('button'); gb.className = 't-gc'; gb.textContent = 'Aa'; gb.title = 'Guess case → ' + t.guessTitle + '\n(right-click: guess case all tracks)'; const wrap = tr.querySelector('.t-wrap'); // like MB's integrated guess case: hovering the title cell previews the guessed name // (highlighted), leaving restores it, clicking Aa applies it. Never preview while editing. const preview = () => { if (document.activeElement !== tin) { tin.value = t.guessTitle; tin.classList.add('gcpreview'); if (paintDisp) paintDisp(t.guessTitle); } }; const restore = () => { tin.value = t.title; tin.classList.remove('gcpreview'); if (paintDisp) paintDisp(t.title); }; wrap.onmouseenter = preview; wrap.onmouseleave = () => { if (document.activeElement !== tin) restore(); }; tin.addEventListener('focus', restore); // clicking in to edit shows the real title, not the preview gb.onclick = () => { restore(); applyGuessTitle(t); t.title = u(koTrack(t.mi, t.ti).name); t.guessTitle = guessTitleStr(t); rerender(); }; // right-click the [Aa] runs guess case on every track (same as the Tools-menu action) — #123 gb.oncontextmenu = e => { e.preventDefault(); restore(); guessCaseAll(); }; tActions(wrap).appendChild(gb); } // featured-artist split: flag titles carrying "feat./ft./featuring" and offer the split inline, // mirroring [Aa] — click ⋔ splits this track, right-click splits all (#124) if (FEAT_RE.test(t.title)) { tin.classList.add('hasfeat'); if (!tin.title) tin.title = 'Title has a featured artist'; const fb = document.createElement('button'); fb.className = 't-feat'; fb.textContent = '⋔'; fb.title = 'Split featured artist out of the title into the artist credit\n(right-click: split all tracks)'; fb.onclick = () => guessFeatTrack(t); fb.oncontextmenu = e => { e.preventDefault(); guessFeatAll(); }; tActions(tr.querySelector('.t-wrap')).appendChild(fb); } tin.onchange = e => { setTitle(t, e.target.value); t.title = e.target.value; t.guessTitle = guessTitleStr(t); rerender(); }; wireRowNav(tin); if (paintDisp) paintDisp(t.title); // re-sync after the diff / feat blocks set their state classes const numIn = tr.querySelector('.t-num'), lenIn = tr.querySelector('.t-len'); numIn.onchange = e => { setNumber(t, e.target.value); refreshBadges(); }; wireRowNav(numIn); if (!lenLocked) lenIn.onchange = e => { let v = e.target.value.trim(); if (v) { const ed = getEditor(); const ms = ed && ed.utils && ed.utils.unformatTrackLength ? ed.utils.unformatTrackLength(v) : NaN; if (ms == null || isNaN(ms)) v = ''; } // invalid (letters/garbage) → delete; valid shorthand like "111" is kept (MB normalizes it to 1:11) setLength(t, v); try { const ko = koTrack(t.mi, t.ti); const norm = typeof ko.formattedLength === 'function' ? (u(ko.formattedLength()) || '') : v; e.target.value = norm; t.length = norm; } catch (err) { e.target.value = v; t.length = v; } // reflect MB's normalized value back into the cell immediately refreshBadges(); }; wireRowNav(lenIn); const dtb = tr.querySelector('.tc-dtmv'); // #586 if (dtb && !dtb.classList.contains('void')) dtb.onclick = () => setDataBoundary(t.mi, t.ti, dtb.classList.contains('down')); if (canDrag) wireDragReorder(tr, t); // #330: don't make pregap/data rows drag sources or drop targets tbody.appendChild(tr); }); } async function loadAndRender(onProgress) { // #685: a resync (a tracklist change the table didn't make itself: MB's guess case // behind [Aa], a bookmarklet, MB's own inputs) re-mounts it too — keep the scroll const restore = keepScroll(); MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } // (re)build per-medium tables + hide/tidy native rerender(); // show the tables instantly restore(); /* #575: no idle message at all. It wrote into the same spans that carry each medium's "N unresolved" badge, so every reload wiped the badge to say something the toolbar's own Match button already says — and with reloads landing mid-pass it also took turns with "matching N/M". majkinetor: "lets remove the auto-match off - click Match". refreshStatus then owns the line and the badges survive. */ // Release information first: it's the page one lands on, and the Tracklist's matching leans on // the release artist picked there. It used to start only once this whole pass had ended, so on // a slow MusicBrainz its badges dropped in up to half a minute later, out of nowhere await matchReleaseInfo(); if (SETTINGS.autoMatch !== false) { await matchModel(onProgress); restore(true); riRematchWithTracks(); } else { tagDiscogsForAll(); tagPlatformForAll(); refreshStatus(); } // #227: tag 'set' artists even when not matching enrichResolvedAliasesSoon(); // batch-fetch aliases for resolved artists (existing releases too) — coalesced, see #575 srApplyDefaultOnStart(); // #410: run the marked default S&R once, now the tracklist is rendered } /* #685: a rebuild removes and re-adds every row (mirror mode the whole section), so the page and any scrolling panel briefly lose their height and the browser clamps the scroll to the top: a dropped track sent the view back to track 1. Note every scroll position first and put it back after the rows are there. The same happened on every other re-fill (majkinetor: a background-created artist landing, [Aa], "the problem appears on various places"), so every render entry point keeps it: rerender, loadAndRender, rebuild, renderRecBody. */ function keepScroll() { const at = [[document.scrollingElement || document.documentElement, 0]]; const seen = new Set(); [ACTIVE.tbody, ...(ACTIVE.sections || []).map(s => s.tbody), document.querySelector('#tc-recwrap tbody')].forEach(tb => { for (let n = tb && tb.parentElement; n && n !== document.body; n = n.parentElement) if (n.scrollHeight > n.clientHeight && !seen.has(n)) { seen.add(n); at.push([n, 0]); } }); at.forEach(p => { p[1] = p[0].scrollTop; }); // clamped = only where the view sits at the top now: a later call (after matching) mustn't undo the user's own scrolling return clamped => at.forEach(([n, top]) => { if (!n.isConnected || n.scrollTop === top || (clamped && n.scrollTop !== 0)) return; Log.cat('System').debug('scroll kept:', (n.id || n.className || n.tagName) + ' ' + n.scrollTop + ' → ' + top); n.scrollTop = top; }); } async function rebuild(noMatch) { const restore = keepScroll(); MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); restore(); if (!noMatch && SETTINGS.autoMatch !== false) { await matchModel(); restore(true); } else if (!noMatch) tagDiscogsForAll(); // #227: tag 'set' artists when auto-match is off — but NOT on a clear/revert (it would check zero matched artists); refreshStatus clears the badge instead enrichResolvedAliasesSoon(); } // revert to the page-load state, but DON'T auto-match (that only runs on startup) — Match is manual here function revertAll() { if (!MODEL) return; if (!W.confirm("Revert every track to what it was when the page loaded?")) return; MODEL.tracks.forEach(t => resetTrack(t, true)); rebuild(true); } // #586: true = also restore the data-track boundary function guessCaseAll() { if (!MODEL) return; MODEL.tracks.forEach(t => { applyGuessTitle(t); t.title = u(koTrack(t.mi, t.ti).name); t.guessTitle = guessTitleStr(t); }); rerender(); Log.cat('Tool').info('guess case → all titles'); } // titles carrying a featured-artist credit ("Foo feat. X", "ft.", "featuring") — detect so the // row can flag them and offer the split inline (#124). Needs a space/bracket/start before the // marker and whitespace/bracket/end after, so words like "soft"/"feats"/"drift" don't trip it. const FEAT_RE = /(?:^|[\s([])(?:feat|ft|featuring)\.?(?=[\s)\]]|$)/i; // integrated MB feature: pull "feat. X" out of titles into artist credits, then re-read + re-match async function guessFeatAll() { const ed = getEditor(); mediums().forEach(med => (u(med.tracks) || []).forEach(t => { try { ed.guessTrackFeatArtists(t); } catch (e) { try { ed.guessTrackFeatArtists(t, { type: 'click' }); } catch (e2) { Log.cat('Tool').warn('guess feat failed', e2.message); } } })); await loadAndRender(); Log.cat('Tool').info('guessed feat artists from titles'); } // single-track variant — fired by the per-track ⋔ split button (#124) async function guessFeatTrack(entry) { const ed = getEditor(), t = koTrack(entry.mi, entry.ti); try { ed.guessTrackFeatArtists(t); } catch (e) { try { ed.guessTrackFeatArtists(t, { type: 'click' }); } catch (e2) { Log.cat('Tool').warn('guess feat failed', e2.message); } } await loadAndRender(); Log.cat('Tool').info('guessed feat artists for track', entry.number); } // medium-scoped tools — each acts on one medium (chosen via the inline medium combo) async function swapMedium(mi) { const ed = getEditor(), m = mediums()[mi]; if (!m) return; _selfEdit = true; try { ed.swapTitlesWithArtists(m); } catch (e) { Log.cat('Tool').warn('swap failed', e.message); } finally { _selfEdit = false; } await loadAndRender(); Log.cat('Tool').info('swapped titles ↔ artists on medium', mi + 1); } function resetNumbers(mi) { const ed = getEditor(), m = mediums()[mi]; if (!m) return; _selfEdit = true; try { ed.resetTrackNumbers(m); } catch (e) { Log.cat('Tool').warn('reset numbers failed', e.message); } finally { _selfEdit = false; } rebuild(); } /* ── #615 Merge / Split mediums ───────────────────────────────────────────── * majkinetor: "Merge all or subset of mediums (keep only one and move tracks to * it and reset # on it)" and "Split 1 medium on track (add medium, move tracks to * it and reset # on new one)". * * MB's release editor can't move a track between mediums (moveTrackUp/Down and * swapTracks stay inside one medium). What it DOES do, in its own "use the * tracklist of another medium" path (Medium.tracksLoaded), is copy tracks as NEW * tracks — no track id — that keep their recording. That's what happens here, from * the LIVE track values (so unsaved edits come along): title, length, artist * credit, data-track flag and the linked recording. Emptied mediums are removed * with MB's own removeMedium, numbers reset with its resetTrackNumbers. So the * edits are the ones a person doing it by hand produces: Edit medium (tracks * added, existing recordings reused) + Remove medium, or Add medium for a split. * Recordings are untouched; the moved tracks get new track MBIDs, as by hand. * * A medium with a disc ID has its tracklist locked by MB, so it's refused. */ const medHasToc = m => { try { return !!(m && typeof m.hasToc === 'function' && m.hasToc()); } catch (e) { return false; } }; const medLoaded = m => { try { return typeof m.loaded === 'function' ? !!u(m.loaded) : true; } catch (e) { return true; } }; const medLabel = (m, i) => { const n = medLoaded(m) ? u(m.tracks).length : null, nm = (u(m.name) || '').trim(); return 'Medium ' + (i + 1) + (nm ? ' — ' + nm : '') + (n == null ? ' (not loaded)' : ' (' + n + ' track' + (n === 1 ? '' : 's') + ')'); }; // load collapsed mediums' tracks (MB lazy-loads them) and wait until every one reports loaded async function loadMediums(meds) { const pending = meds.filter(m => !medLoaded(m)); if (!pending.length) return true; Log.cat('Tool').info('loading', pending.length, 'collapsed medium(s) first'); pending.forEach(m => { try { if (typeof m.loadTracks === 'function') m.loadTracks(); } catch (e) { Log.cat('Tool').warn('loadTracks failed', e.message); } }); for (let n = 0; n < 150 && !pending.every(medLoaded); n++) await new Promise(r => setTimeout(r, 200)); const ok = pending.every(medLoaded); if (!ok) Log.cat('Tool').warn('medium(s) did not finish loading:', pending.filter(m => !medLoaded(m)).map(m => u(m.position)).join(', ')); return ok; } // a live KO track → constructor data for a NEW track that keeps the same recording (MB's own copy path) function trackCopyData(t) { const d = { name: u(t.name) || '', length: u(t.length), artistCredit: u(t.artistCredit), isDataTrack: !!u(t.isDataTrack) }; const rec = u(t.recording); if (!u(t.hasNewRecording) && rec && rec.gid) d.recording = rec; return d; } async function mergeMediums(indices) { const ed = getEditor(), all = mediums(), F = W.MB && W.MB.releaseEditor && W.MB.releaseEditor.fields; const sel = [...new Set(indices)].filter(i => all[i]).sort((a, b) => a - b); if (sel.length < 2) { toast('Merge: tick at least two mediums'); Log.cat('Tool').warn('merge: fewer than two mediums selected', JSON.stringify(indices)); return; } if (!F || !F.Track) { Log.cat('Tool').err('merge: MB release-editor fields not available'); toast('Merge failed — see Log'); return; } const meds = sel.map(i => all[i]), target = meds[0], sources = meds.slice(1); const locked = sel.filter(i => medHasToc(all[i])); if (locked.length) { toast('Merge: medium ' + locked.map(i => i + 1).join(', ') + ' has a disc ID — MB locks its tracklist'); Log.cat('Tool').warn('merge refused — disc ID on medium(s)', locked.map(i => i + 1).join(', ')); return; } if (!(await loadMediums(meds))) { toast('Merge: a medium did not load — try again'); return; } Log.cat('Tool').info('merge: mediums', sel.map(i => i + 1).join(', '), '→ medium', sel[0] + 1, '·', meds.map((m, k) => (sel[k] + 1) + ':' + u(m.tracks).length).join(' ')); _selfEdit = true; let moved = 0; try { const own = u(target.tracks), ownAudio = own.filter(t => !u(t.isDataTrack)), ownData = own.filter(t => u(t.isDataTrack)); const movAudio = [], movData = []; for (const src of sources) { for (const t of u(src.tracks)) { if (u(t.position) === 0) Log.cat('Tool').warn('merge: medium', u(src.position), 'pregap track "' + (u(t.name) || '') + '" becomes a regular track'); const nt = new F.Track(trackCopyData(t), target); (u(nt.isDataTrack) ? movData : movAudio).push(nt); Log.cat('Tool').debug(''+ u(src.position) + '.' + u(t.number) + ' "' + (u(t.name) || '') + '" → medium ' + (sel[0] + 1) + (nt.recording && u(nt.recording) && u(nt.recording).gid ? ' · recording ' + u(nt.recording).gid : ' · new recording')); moved++; } } // audio stays ahead of data tracks (MB keeps data tracks at the end of a medium) target.tracks([...ownAudio, ...movAudio, ...ownData, ...movData]); for (const src of sources.slice().reverse()) ed.removeMedium(src); // last first, so MB's renumbering of the rest stays simple ed.resetTrackNumbers(target); try { target.toc(null); } catch (e) {} } catch (e) { Log.cat('Tool').err('merge failed:', e.message); toast('Merge failed — see Log'); } finally { _selfEdit = false; } Log.cat('Tool').ok('merged', sources.length, 'medium(s) into medium', sel[0] + 1, '—', moved, 'track(s) moved, recordings kept, numbers reset'); toast('Merged ' + (sources.length + 1) + ' mediums into medium ' + (sel[0] + 1) + ' (' + moved + ' tracks moved)'); _mergeSel = null; await loadAndRender(); renderToolbar(); } // split medium `mi` so a NEW medium (right after it, same format) starts at track index `at` async function splitMedium(mi, at) { const ed = getEditor(), rel = release(), m = mediums()[mi], F = W.MB && W.MB.releaseEditor && W.MB.releaseEditor.fields; if (!m) return; if (!F || !F.Track || !F.Medium) { Log.cat('Tool').err('split: MB release-editor fields not available'); toast('Split failed — see Log'); return; } if (medHasToc(m)) { toast('Split: medium ' + (mi + 1) + ' has a disc ID — MB locks its tracklist'); Log.cat('Tool').warn('split refused — disc ID on medium', mi + 1); return; } if (!(await loadMediums([m]))) { toast('Split: the medium did not load — try again'); return; } const tracks = u(m.tracks); if (!(at > 0 && at < tracks.length)) { toast('Split: pick a track after the first one'); Log.cat('Tool').warn('split: bad split index', at, 'of', tracks.length); return; } const moving = tracks.slice(at); Log.cat('Tool').info('split: medium', mi + 1, 'at track', u(tracks[at].number), '"' + (u(tracks[at].name) || '') + '" —', moving.length, 'of', tracks.length, 'track(s) to a new medium', mi + 2); _selfEdit = true; try { const nm = new F.Medium({ position: u(m.position) + 1, format_id: u(m.formatID), name: '', tracks: [] }, rel); nm.tracks(moving.map((t, i) => new F.Track(Object.assign(trackCopyData(t), { position: i + 1, number: String(i + 1) }), nm))); m.tracks.removeAll(moving); const list = u(rel.mediums), idx = list.indexOf(m); list.slice(idx + 1).forEach(x => x.position(u(x.position) + 1)); // make room right after the split medium rel.mediums.splice(idx + 1, 0, nm); ed.resetTrackNumbers(m); ed.resetTrackNumbers(nm); try { m.toc(null); } catch (e) {} try { if (typeof nm.loaded === 'function') nm.loaded(true); if (typeof nm.collapsed === 'function') nm.collapsed(false); } catch (e) {} } catch (e) { Log.cat('Tool').err('split failed:', e.message); toast('Split failed — see Log'); } finally { _selfEdit = false; } Log.cat('Tool').ok('split medium', mi + 1, '→ new medium', mi + 2, 'with', moving.length, 'track(s), recordings kept, numbers reset on both'); toast('Split medium ' + (mi + 1) + ': ' + moving.length + ' tracks moved to new medium ' + (mi + 2)); await loadAndRender(); renderToolbar(); } // params state for the two tools (kept while the toolbar re-renders) let _mergeSel = null; // Set of medium indexes ticked for Merge (null → all) let _splitAt = null; // track index the Split tool starts the new medium at (null → middle) let _splitLoading = false; // a collapsed medium's tracks are being loaded for the Split params /* ── #456 pattern Track parser ───────────────────────────────────────────── * MB's native parser is rigid, so we roll our own. A one-line PATTERN describes * a tracklist line; we compile it to a regex and run every pasted line through * it, filling only the fields the pattern declares. Tokens: # T A L M (+ _ skip), * $X explicit, X[slice] positional (1-based; ~ = from end); separators match any * of a list (- – — / :); literals are literal; whitespace is elastic; text fields * are lazy (split-on-first) except the last which is greedy. Engine is unit-tested * in test/pattern-engine.test.mjs — keep the two in sync. */ const TP_FIELDS = { '#': 'pos', 'T': 'title', 'A': 'artist', 'L': 'length', 'M': 'medium' }; const TP_DUR = '\\d{1,2}:\\d{2}(?::\\d{2})?'; // #520 (majkinetor, live): "The pattern that worked is `#. A ‐ T - _ (L)`, // however, using `-` as separator doesn't work." His source used U+2010 // HYPHEN (‐) between artist and title — visually a hyphen, but a different // codepoint from plain ASCII `-` (U+002D). Typing the literal ‐ in the // pattern happened to match by coincidence; the natural, easiest-to-type // `-` didn't, because U+2010 wasn't in the separator class it expands to. // Adding it here means any of these separator KINDS in the pattern now // matches any of them in the source, same as en/em dash already do. const TP_SEPS = ['-', '‐', '–', '—', '/', ':']; const TP_PRESETS = ['#. T', '#. T - A (L)', '# T L', '# A - T', '# A - T (L)', 'T L']; // #. T - A (L) = the native parser's format let _tpPattern = '#. T'; // remembered across opens this session const tpEsc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const tpUsable = out => !!out && Object.values(out).some(v => v && String(v).trim()); function tpTokenize(pattern, seps) { const segs = [], sepSet = new Set(seps); const isFieldLetter = c => c === '#' || c === 'M' || c === 'T' || c === 'A' || c === 'L'; const readSlice = (str, i) => { if (str[i] !== '[') return null; const close = str.indexOf(']', i); if (close < 0) return null; const inner = str.slice(i + 1, close); let m; // colon form [FROM:TO] — FROM is a numeric position (~ = from end) OR a literal char to start // AFTER; TO is a literal char to stop BEFORE (empty = to end). e.g. #[1:.] (pos 1 → first dot), // L[(:)] (first "(" → first ")"), both excluding the delimiters. A leading ~ on a CHAR (not a // position) means the LAST occurrence of that char, not the first — e.g. L[~(:)] for a title // that has its own "(...)" before the real "(length)" (#456). const colon = inner.indexOf(':'); if (colon >= 0) { let left = inner.slice(0, colon); const hadTilde = left[0] === '~'; if (hadTilde) left = left.slice(1); let toDelim = inner.slice(colon + 1); const toLast = toDelim[0] === '~'; if (toLast) toDelim = toDelim.slice(1); const slice = /^\d*$/.test(left) ? { a: left === '' ? null : parseInt(left, 10), fromEnd: hadTilde, toDelim, toLast } // numeric position (~ = from end) : { fromDelim: left, fromLast: hadTilde, toDelim, toLast }; // literal char (~ = last occurrence) return { slice, next: close + 1 }; } // dash form [a-b] / [a] — numeric char range (1-based inclusive; ~ = from the end) if ((m = /^(~?)(\d*)(-?)(\d*)$/.exec(inner))) { const a = m[2] === '' ? null : parseInt(m[2], 10), b = m[4] === '' ? null : parseInt(m[4], 10); return { slice: m[3] === '-' ? { a, b, fromEnd: m[1] === '~' } : { a, b: a, fromEnd: m[1] === '~' }, next: close + 1 }; } return null; }; for (let i = 0; i < pattern.length;) { const c = pattern[i]; if (c === '$') { const letter = pattern[i + 1]; if (isFieldLetter(letter)) { const sl = readSlice(pattern, i + 2); segs.push({ kind: 'field', field: TP_FIELDS[letter], slice: sl ? sl.slice : null }); i = sl ? sl.next : i + 2; continue; } segs.push({ kind: 'lit', text: '$' }); i++; continue; } if (c === '_') { segs.push({ kind: 'skip' }); i++; continue; } if (isFieldLetter(c)) { const letterish = ch => ch && /[A-Za-z]/.test(ch); if (!(letterish(pattern[i - 1]) || (c !== '#' && letterish(pattern[i + 1])))) { const sl = readSlice(pattern, i + 1); segs.push({ kind: 'field', field: TP_FIELDS[c], slice: sl ? sl.slice : null }); i = sl ? sl.next : i + 1; continue; } } if (sepSet.has(c)) { segs.push({ kind: 'sep' }); i++; continue; } if (/\s/.test(c)) { let j = i; while (j < pattern.length && /\s/.test(pattern[j])) j++; segs.push({ kind: 'ws' }); i = j; continue; } segs.push({ kind: 'lit', text: c }); i++; } return segs; } function tpCompile(pattern, opts = {}) { const seps = opts.separators || TP_SEPS; const segs = tpTokenize(pattern, seps); // #522 (majkinetor, live): "In pattern tracker, `#. A ` doesn't work // (space at the end)" — also `#. A - `. A dangling ws/sep token at // either edge, with no field or literal past it, compiles to a HARD // requirement ("\s+" or a real separator char) right at the string // boundary — but exec() trims the input first, so a trimmed line can // never have trailing whitespace to offer, and a real line never ends // on a bare separator either. The regex already anchors both ends with // its own elastic `\s*` (below), so a leading/trailing ws or sep token // adds nothing but an impossible-to-satisfy demand — drop any run of // them from either edge before compiling. while (segs.length && (segs[0].kind === 'ws' || segs[0].kind === 'sep')) segs.shift(); while (segs.length && (segs[segs.length - 1].kind === 'ws' || segs[segs.length - 1].kind === 'sep')) segs.pop(); const fieldSegs = segs.filter(s => s.kind === 'field'); const fields = new Set(fieldSegs.map(s => s.field)); const sliced = fieldSegs.filter(s => s.slice); const sepClass = '(?:' + seps.map(tpEsc).join('|') + ')'; if (fieldSegs.length > 0 && sliced.length === fieldSegs.length) { // slice-mode (all fields positional) return { fields, exec(line) { const s = String(line), out = {}; for (const seg of fieldSegs) { const { a, b, fromEnd, fromDelim, fromLast, toDelim, toLast } = seg.slice; let start, end; if (fromDelim != null) { const fi = fromDelim === '' ? 0 : (fromLast ? s.lastIndexOf(fromDelim) : s.indexOf(fromDelim)); if (fi < 0) { out[seg.field] = ''; continue; } start = fi + fromDelim.length; } else if (fromEnd) start = a == null ? 0 : s.length - a; else start = (a == null ? 1 : a) - 1; start = Math.max(0, start); if (toDelim != null) { let ti; if (toDelim === '') ti = -1; else if (toLast) { ti = s.lastIndexOf(toDelim); if (ti < start) ti = -1; } else ti = s.indexOf(toDelim, start); end = ti < 0 ? s.length : ti; } else if (fromEnd) end = b == null ? s.length : s.length - b + 1; else end = b == null ? s.length : b; out[seg.field] = s.slice(start, Math.max(start, end)).trim(); } return tpUsable(out) ? out : null; } }; } // one text field is greedy so the split lands on the FIRST separator (default) — or the LAST // one when splitLast is on (#456 v2 ‹first|last› toggle): then the FIRST text field is greedy. const textSegs = fieldSegs.filter(s => s.field === 'title' || s.field === 'artist'); const greedyText = opts.splitLast ? textSegs[0] : textSegs[textSegs.length - 1]; let re = '^\\s*'; for (let idx = 0; idx < segs.length; idx++) { const seg = segs[idx]; if (seg.kind === 'ws') re += '\\s+'; else if (seg.kind === 'lit') re += /\s/.test(seg.text) ? '\\s+' : tpEsc(seg.text); else if (seg.kind === 'sep') re += '\\s*' + sepClass + '\\s*'; else if (seg.kind === 'skip') re += '.*?'; else if (seg.kind === 'field') { const f = seg.field; if (seg.slice && (seg.slice.toDelim != null || seg.slice.fromDelim != null)) { const sl = seg.slice; // skip to fromDelim: lazy .*? = first occurrence, greedy .* = last (~) — mirrors slice-mode's indexOf/lastIndexOf re += (sl.fromDelim ? (sl.fromLast ? '.*' : '.*?') + tpEsc(sl.fromDelim) : '') + (sl.toDelim ? (sl.toLast ? '(.+)' : '(.+?)') + tpEsc(sl.toDelim) : '(.+)'); } // X[from:to] in flow: skip-to-fromDelim, capture, toDelim else if (f === 'pos') re += '([A-Za-z]?\\d+(?:[-.]\\d+)?)'; else if (f === 'medium') re += '(\\d+)'; else if (f === 'length') re += '(' + TP_DUR + ')'; else { // #522 follow-up (majkinetor, live): "#. T (_" on // "1. Ndzirombi (Conflict Monger) - Zig Zag Band (5:21)" gave the // WHOLE line minus the length as the title — expected just // "Ndzirombi". Root cause: T was the only/last text field, so it // compiled greedy; greedy backtracks from the END of the string, // so with an unconstrained "(_" skip after it (matches ANY "("), // it finds the LAST "(" in the source first, not the first one. // A literal immediately following a field is the user explicitly // marking where that field should stop — always lazy there // (finds the FIRST occurrence), regardless of the "is it the // last text field" heuristic that governs plain field-to-field // splits (# A - T with nothing after T still takes the rest). let j = idx + 1; while (j < segs.length && segs[j].kind === 'ws') j++; const followedByLiteral = j < segs.length && segs[j].kind === 'lit'; re += (seg === greedyText && !followedByLiteral) ? '(.+)' : '(.+?)'; } } } re += '\\s*$'; const rx = new RegExp(re), order = fieldSegs.map(s => s.field); return { fields, exec(line) { const m = rx.exec(String(line).trim()); if (!m) return null; const out = {}; order.forEach((f, i) => { out[f] = (m[i + 1] || '').trim(); }); return tpUsable(out) ? out : null; } }; } function openParser(mi) { const ed = getEditor(), m = mediums()[mi]; if (!m) return; try { ed.openTrackParser(m); } catch (e) { Log.cat('Tool').warn('open parser failed', e.message); } } function openTrackPatternParser(mi) { document.getElementById('tc-tpppop')?.remove(); let curMi = (mi != null ? mi : toolMedium()); _tpPattern = '#. T - A (L)'; // #456: open like the native parser — prefilled current tracklist in this format let rows = []; // [{ raw, override }] let _splitLast = false; // #456 v2 ‹first|last›: which separator instance a text field splits on const tracks = () => u(mediums()[curMi].tracks) || []; // the current medium's tracklist rendered in the #. T - A (L) format, to seed the paste box const acStr = t => (liveNames(t) || []).map(n => (u(n.name) || (u(n.artist) && u(u(n.artist).name)) || '') + (u(n.joinPhrase) || '')).join('').trim(); const currentText = () => tracks().map((t, i) => { const num = u(t.number) || (i + 1); const title = u(t.name) || ''; const artist = acStr(t); const len = u(t.formattedLength) || ''; return `${num}. ${title}` + (artist ? ` - ${artist}` : '') + (len ? ` (${len})` : ''); }).join('\n'); const compiled = new Map(); // pattern+mode → compiled (cached) const compileFor = ov => { const key = (_splitLast ? 'L\x01' : 'F\x01') + (ov || _tpPattern); if (!compiled.has(key)) { try { compiled.set(key, tpCompile(ov || _tpPattern, { separators: TP_SEPS, splitLast: _splitLast })); } catch (e) { compiled.set(key, null); } } return compiled.get(key); }; const parseRow = r => { const c = compileFor(r.override && r.override.trim()); try { return c ? c.exec(r.raw) : null; } catch (e) { return null; } }; const p = document.createElement('div'); p.id = 'tc-tpppop'; p.className = 'tc-tpppop'; const medSel = mediums().length > 1 ? `<select class="tc-tpp-med">${mediums().map((m, i) => `<option value="${i}"${i === curMi ? ' selected' : ''}>Medium ${i + 1}</option>`).join('')}</select>` : `<span class="tc-tpp-med1">Medium ${curMi + 1}</span>`; p.innerHTML = ` <div class="tc-tpp-hd"><span class="tc-tpp-t">Pattern parser</span>${medSel}<button type="button" class="tc-tpp-max" title="Maximize / restore">⛶</button><button type="button" class="tc-tpp-x" title="Close (Esc)">✕</button></div> <div class="tc-tpp-pat"> <span class="tc-tpp-plbl">Pattern</span> <span class="tc-tpp-piwrap"><input type="text" ${NOPW_ATTRS} class="tc-tpp-pi" spellcheck="false" value="${esc(_tpPattern)}" title="# pos · T title · A artist · L length · M medium · _ skip · \$X explicit · X[a-b] slice · X[a:.] up to a char · X[~a:.] from the LAST occurrence"><button type="button" class="tc-tpp-piclr" title="Clear pattern" tabindex="-1">✕</button></span> <button type="button" class="tc-tpp-freeze" title="Lock this pattern onto every still-«default» row that already matches it, so a later pattern change won't affect them — then try a new pattern on what's left">🔒 Freeze matched</button> <span class="tc-tpp-presets">${TP_PRESETS.map(x => `<button type="button" class="tc-tpp-chip" data-p="${esc(x)}">${esc(x)}</button>`).join('')}</span> <button type="button" class="tc-tpp-split" title="Which separator a text field splits on when it repeats (A - T on 'a - b - c')">split: <b>first</b></button> </div> <div class="tc-tpp-body"> <div class="tc-tpp-src"> <button type="button" class="tc-tpp-srctgl" title="Show / hide the paste box">▾ Paste tracklist</button> <textarea class="tc-tpp-ta" spellcheck="false" placeholder="Paste the tracklist — one track per line"></textarea> </div> <div class="tc-tpp-tblwrap"><table class="tc-tpp-tbl"><thead><tr><th></th><th>pattern</th><th>raw</th><th>#</th><th>artist</th><th>title</th><th>length</th></tr></thead><tbody></tbody></table></div> </div> <div class="tc-tpp-ft"><span class="tc-tpp-cnt"></span><span class="tc-tpp-acts"><button type="button" class="tc-tpp-ok" disabled>Apply</button><button type="button" class="tc-tpp-menu" title="Apply options" disabled>▾</button></span></div>`; document.body.appendChild(p); const $ = s => p.querySelector(s); const ta = $('.tc-tpp-ta'), tbody = $('.tc-tpp-tbl tbody'), patIn = $('.tc-tpp-pi'); const src = $('.tc-tpp-src'), srctgl = $('.tc-tpp-srctgl'); // #456: the paste box only seeds the rows, so fold it away once there's content (the raw // column keeps the source visible); the caret toggles it back to re-paste/edit. const setSrc = collapsed => { src.classList.toggle('tc-collapsed', collapsed); const n = rows.length; srctgl.textContent = (collapsed ? '▸ ' : '▾ ') + (collapsed && n ? `Paste tracklist (${n} line${n !== 1 ? 's' : ''})` : 'Paste tracklist'); }; const close = () => { p.remove(); chipBar.remove(); document.removeEventListener('mousedown', outsideHide, true); }; function syncRows() { const lines = ta.value.split('\n'); // keep overrides aligned to line index across edits rows = lines.map((raw, i) => ({ raw, override: (rows[i] && rows[i].override) || '' })) .filter((r, i) => !(i === lines.length - 1 && r.raw.trim() === '' && lines.length > 1)); } const DOT = { exact: '#2e7d32', over: '#b26a00', none: '#c62828' }; // #733: like the native parser, each non-empty line is one track — blank lines take no track const filled = () => rows.filter(r => r.raw.trim() !== ''); function render() { const nT = tracks().length, locked = mediumLocked(curMi); tbody.innerHTML = ''; let k = 0; // track index of the next non-empty row rows.forEach((r, i) => { const parsed = parseRow(r); const state = !parsed ? 'none' : (r.override && r.override.trim() ? 'over' : 'exact'); const ti = r.raw.trim() === '' ? -1 : k++; // dimmed: a row with no track to land on (a disc-ID-locked medium can't grow) const tr = document.createElement('tr'); tr.className = 'tc-tpp-tr' + (locked && ti >= nT ? ' notrk' : ''); const cell = v => `<td class="tc-tpp-c" title="${esc(v || '')}">${esc(v || '')}</td>`; tr.innerHTML = `<td class="tc-tpp-dot"><span style="background:${DOT[state]}" title="${state === 'none' ? 'no match — adjust the pattern' : state === 'over' ? 'matched via a per-row pattern' : 'matched'}"></span></td>` + `<td class="tc-tpp-pcell"><span class="tc-tpp-ovwrap${r.override ? ' has' : ''}"><input class="tc-tpp-ov" ${NOPW_ATTRS} spellcheck="false" placeholder="«default»" value="${esc(r.override || '')}"><button type="button" class="tc-tpp-ovclr" title="Clear this row’s pattern" tabindex="-1">✕</button></span></td>` + `<td class="tc-tpp-raw" contenteditable="plaintext-only" spellcheck="false" title="Click to edit this line — the change goes back to the pasted text">${esc(r.raw)}</td>` + cell(parsed && parsed.pos) + cell(parsed && parsed.artist) + cell(parsed && parsed.title) + cell(parsed && parsed.length); const ov = tr.querySelector('.tc-tpp-ov'), ovwrap = tr.querySelector('.tc-tpp-ovwrap'); // re-parse just this row in place (a full render() would steal the focus from the cell being typed in) const refreshRow = () => { const pr = parseRow(r); const st = !pr ? 'none' : (r.override && r.override.trim() ? 'over' : 'exact'); tr.querySelector('.tc-tpp-dot span').style.background = DOT[st]; const cells = tr.querySelectorAll('.tc-tpp-c'); [pr && pr.pos, pr && pr.artist, pr && pr.title, pr && pr.length].forEach((v, k) => { cells[k].textContent = v || ''; cells[k].title = v || ''; }); refreshFoot(); }; ov.oninput = () => { r.override = ov.value; ovwrap.classList.toggle('has', !!ov.value); refreshRow(); }; // #732: edit the raw line in place; it's written back to the same line of the paste box const rawTd = tr.querySelector('.tc-tpp-raw'); rawTd.oninput = () => { let v = rawTd.textContent; if (/[\r\n]/.test(v)) { v = v.replace(/[\r\n]+/g, ' '); rawTd.textContent = v; } // one row = one line r.raw = v; const lines = ta.value.split('\n'); while (lines.length <= i) lines.push(''); lines[i] = v; ta.value = lines.join('\n'); refreshRow(); }; rawTd.onkeydown = e => { if ((e.key === 'Enter' && !e.ctrlKey && !e.metaKey) || e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); rawTd.blur(); } // Enter/Esc end the edit; Ctrl+Enter still applies }; let rawWas = r.raw; rawTd.onblur = () => { if (rawTd.textContent === '' && rawTd.firstChild) rawTd.textContent = ''; // drop a leftover <br> so :empty shows the placeholder if (r.raw !== rawWas) { Log.cat('Tool').debug('pattern parser: raw row', i + 1, 'edited', JSON.stringify(rawWas), '→', JSON.stringify(r.raw)); rawWas = r.raw; } }; tr.querySelector('.tc-tpp-ovclr').onclick = () => { r.override = ''; render(); }; tbody.appendChild(tr); }); refreshFoot(); } function stats() { const nT = tracks().length; let matched = 0, unmatched = 0; filled().forEach(r => { parseRow(r) ? matched++ : unmatched++; }); return { nT, matched, unmatched, n: matched + unmatched, locked: mediumLocked(curMi) }; } function refreshFoot() { const s = stats(), cnt = $('.tc-tpp-cnt'), ok = $('.tc-tpp-ok'), menu = $('.tc-tpp-menu'); cnt.className = 'tc-tpp-cnt' + (s.unmatched ? ' warn' : s.matched ? ' ok' : ''); const tk = n => `${n} track${n !== 1 ? 's' : ''}`; // #733: Apply makes the medium as long as the line count — say what that adds or removes const delta = s.n === s.nT ? '' : s.locked ? ` (disc ID: stays ${tk(s.nT)})` : s.n > s.nT ? ` (+${s.n - s.nT} new)` : ` (−${s.nT - s.n} removed from the end)`; cnt.textContent = !s.n ? `${tk(s.nT)} in Medium ${curMi + 1} — paste a tracklist` : `${s.matched} matched · ${s.unmatched} unmatched → ${tk(s.locked ? s.nT : s.n)}${delta}`; ok.disabled = menu.disabled = !s.matched; ok.textContent = `Apply ${s.locked ? Math.min(s.n, s.nT) : s.n} → Medium ${curMi + 1}`; } // Apply captured fields (only the ones each row's pattern produced) to tracks in line order. // #733: the full Apply also syncs the track count to the non-empty lines, like the native // parser: missing tracks are added, extra ones removed from the end. "Only …" keeps the count. function apply(which) { const s = stats(); if (which === 'all' && !s.locked) { if (s.n > s.nT) addTracks(curMi, s.n - s.nT); else if (s.n < s.nT) { for (let ti = s.nT - 1; ti >= s.n; ti--) removeTrack({ mi: curMi, ti, number: ti + 1 }); Log.cat('Tool').info('track parser: removed', s.nT - s.n, 'track(s) from the end of medium', curMi + 1, 'to match', s.n, 'line(s)'); } } const nT = tracks().length; let wrote = 0; _selfEdit = true; // #483: bulk-apply is our own write, and rebuild(true) below already does the one resync it needs try { filled().forEach((r, i) => { if (i >= nT) return; const pr = parseRow(r); if (!pr) return; const entry = { mi: curMi, ti: i }; if ((which === 'all' || which === 'title') && pr.title != null && pr.title !== '') setTitle(entry, pr.title); if ((which === 'all' || which === 'artist') && pr.artist != null && pr.artist !== '') { try { koTrack(curMi, i).artistCredit({ names: [{ artist: null, name: pr.artist, joinPhrase: '' }] }); } catch (e) { Log.cat('Tool').warn('set artist failed', e.message); } } if ((which === 'all' || which === 'length') && pr.length && lpValid(pr.length)) setLength(entry, pr.length); if ((which === 'all' || which === 'pos') && pr.pos != null && pr.pos !== '') setNumber(entry, pr.pos); wrote++; }); } finally { _selfEdit = false; } rebuild(true); Log.cat('Tool').info('track parser: applied', which, 'from pattern', JSON.stringify(_tpPattern), 'to', wrote, 'row(s) on medium', curMi + 1); toast(`Applied ${wrote} row${wrote !== 1 ? 's' : ''} to Medium ${curMi + 1}`); close(); } function openMenu() { p.querySelector('.tc-tpp-mpop')?.remove(); const m = document.createElement('div'); m.className = 'tc-tpp-mpop'; const item = (label, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-tpp-mi'; b.textContent = label; b.onclick = () => { m.remove(); fn(); }; return b; }; m.append(item('Only titles', () => apply('title')), item('Only artists', () => apply('artist')), item('Only lengths', () => apply('length')), item('Only track #s', () => apply('pos'))); $('.tc-tpp-acts').appendChild(m); const off = e => { if (!m.contains(e.target) && !e.target.closest('.tc-tpp-menu')) { m.remove(); document.removeEventListener('mousedown', off, true); } }; setTimeout(() => document.addEventListener('mousedown', off, true), 0); } ta.oninput = () => { syncRows(); render(); }; ta.onblur = () => { if (ta.value.trim()) setSrc(true); }; // fold away once seeded srctgl.onclick = () => { const collapse = !src.classList.contains('tc-collapsed'); setSrc(collapse); if (!collapse) ta.focus(); }; const piwrap = $('.tc-tpp-piwrap'); const syncClr = () => piwrap.classList.toggle('has', !!patIn.value); // #456 show the clear-✕ only when there's a pattern to clear patIn.oninput = () => { _tpPattern = patIn.value; compiled.clear(); syncClr(); render(); }; $('.tc-tpp-piclr').onclick = () => { patIn.value = ''; _tpPattern = ''; compiled.clear(); syncClr(); render(); patIn.focus(); }; syncClr(); p.querySelectorAll('.tc-tpp-chip').forEach(c => c.onclick = () => { patIn.value = c.dataset.p; _tpPattern = c.dataset.p; compiled.clear(); syncClr(); render(); patIn.focus(); }); $('.tc-tpp-split').onclick = () => { _splitLast = !_splitLast; $('.tc-tpp-split').innerHTML = 'split: <b>' + (_splitLast ? 'last' : 'first') + '</b>'; render(); }; // #456 round 5 — iterative refinement: lock the CURRENT pattern onto every row that's still on // «default» and already matches, so trying a new pattern afterward only affects what's left. // Repeat with a different pattern until every row is solved. $('.tc-tpp-freeze').onclick = () => { let n = 0; rows.forEach(r => { if (r.override && r.override.trim()) return; if (parseRow(r)) { r.override = _tpPattern; n++; } }); render(); toast(n ? `Froze ${n} matched row${n !== 1 ? 's' : ''} to this pattern` : 'No still-default rows match this pattern'); }; /* #456 v2 — interactive fix: select a span in a raw cell → a chip bar pops above it to bind * that span to a field for THAT row, writing a slice into its pattern cell (T[a-b], …). */ const FIELD_CHIPS = [['#', 'pos'], ['A', 'artist'], ['T', 'title'], ['L', 'length']]; const chipBar = document.createElement('div'); chipBar.className = 'tc-tpp-chipbar'; chipBar.style.display = 'none'; chipBar.innerHTML = FIELD_CHIPS.map(([g, f]) => `<button type="button" data-f="${f}" data-g="${g}" title="Bind the selection to ${f}">${g}</button>`).join('') + '<button type="button" data-clr="1" title="Clear this row’s pattern">✕</button>'; document.body.appendChild(chipBar); // on body, not the modal — the modal's transform would otherwise re-base position:fixed (#456) let _selCtx = null; // { rowIdx, a, b } — 1-based inclusive char range in the raw text const hideChips = () => { chipBar.style.display = 'none'; _selCtx = null; }; // char offsets of the current selection within a raw cell (the cell text == the row's raw) function rawSelection() { const sel = window.getSelection(); if (!sel || !sel.rangeCount || sel.isCollapsed) return null; const range = sel.getRangeAt(0); const cell = (range.startContainer.nodeType === 1 ? range.startContainer : range.startContainer.parentElement)?.closest('.tc-tpp-raw'); if (!cell || !cell.contains(range.endContainer)) return null; const tr = cell.closest('tr'); const rowIdx = tr ? [...tbody.children].indexOf(tr) : -1; if (rowIdx < 0) return null; const pre = document.createRange(); pre.selectNodeContents(cell); pre.setEnd(range.startContainer, range.startOffset); const start = pre.toString().length, len = range.toString().length; if (!len) return null; return { rowIdx, a: start + 1, b: start + len, rect: range.getBoundingClientRect() }; // 1-based inclusive } tbody.addEventListener('mouseup', () => setTimeout(() => { const s = rawSelection(); if (!s) { hideChips(); return; } _selCtx = s; chipBar.style.display = 'flex'; const bw = chipBar.offsetWidth || 160; chipBar.style.left = Math.max(6, Math.min(s.rect.left + s.rect.width / 2 - bw / 2, innerWidth - bw - 6)) + 'px'; chipBar.style.top = Math.max(6, s.rect.top - chipBar.offsetHeight - 6) + 'px'; }, 0)); // set/replace a field's slice in a row's override (overrides built this way are pure slice-mode) function bindSlice(row, letter, a, b) { const esc2 = letter === '#' ? '\\#' : letter; let ov = (row.override || '').replace(new RegExp('\\$?' + esc2 + '\\[[^\\]]*\\]\\s*', 'g'), '').replace(/\s+/g, ' ').trim(); row.override = (ov ? ov + ' ' : '') + letter + '[' + a + '-' + b + ']'; } chipBar.addEventListener('mousedown', e => e.preventDefault()); // keep the text selection alive through the click chipBar.querySelectorAll('button').forEach(b => b.onclick = () => { if (!_selCtx) return; const row = rows[_selCtx.rowIdx]; if (!row) return; if (b.dataset.clr) row.override = ''; else bindSlice(row, b.dataset.g, _selCtx.a, _selCtx.b); hideChips(); window.getSelection()?.removeAllRanges(); render(); }); const outsideHide = e => { if (!chipBar.contains(e.target) && !e.target.closest('.tc-tpp-raw')) hideChips(); }; document.addEventListener('mousedown', outsideHide, true); // removed in close() $('.tc-tpp-ok').onclick = () => apply('all'); $('.tc-tpp-menu').onclick = openMenu; $('.tc-tpp-x').onclick = close; let _maxed = false, _prevBox = null; // maximize toggle: fill the viewport, restore to the prior box $('.tc-tpp-max').onclick = () => { const btn = $('.tc-tpp-max'); if (!_maxed) { _prevBox = { left: p.style.left, top: p.style.top, width: p.style.width, height: p.style.height, transform: p.style.transform }; p.style.transform = 'none'; p.style.left = '2vw'; p.style.top = '2vh'; p.style.width = '96vw'; p.style.height = '94vh'; _maxed = true; btn.textContent = '❐'; btn.title = 'Restore'; } else { Object.assign(p.style, _prevBox); _maxed = false; btn.textContent = '⛶'; btn.title = 'Maximize / restore'; } }; const medEl = $('.tc-tpp-med'); if (medEl) medEl.onchange = () => { curMi = parseInt(medEl.value, 10) || 0; render(); }; p.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); if (!$('.tc-tpp-ok').disabled) apply('all'); } }; $('.tc-tpp-hd').onmousedown = e => { if (e.target.closest('button, select')) return; e.preventDefault(); const r = p.getBoundingClientRect(); p.style.transform = 'none'; p.style.left = r.left + 'px'; p.style.top = r.top + 'px'; // #456: detach from the centering transform so the drag math is absolute (else it clamps early) const ox = e.clientX - r.left, oy = e.clientY - r.top; const mv = ev => { p.style.left = Math.max(0, Math.min(ev.clientX - ox, innerWidth - p.offsetWidth)) + 'px'; p.style.top = Math.max(0, Math.min(ev.clientY - oy, innerHeight - p.offsetHeight)) + 'px'; }; const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }; // #456: seed the paste box with the current tracklist (like the native parser), so it opens showing what's there. // Don't auto-focus when prefilled — focusing then clicking elsewhere would blur→auto-collapse the box. const seed = currentText(); if (seed) { ta.value = seed; syncRows(); render(); } else { render(); ta.focus(); } } /* ── Track length parser (#455) ──────────────────────────────────────────── * MB's native track parser wants a specific format; lengths copied off a site * (Bandcamp, foobar, …) rarely fit. This greps every DURATION out of arbitrary * pasted text — jesus2099's battle-tested regex, which only matches things * shaped like a time so track numbers / titles / years are ignored — into a * REVIEWABLE, editable list (add / delete / edit rows, invalid times blocked), * then writes them to the medium's tracks in order. Nothing is written until * you commit. */ const LP_DUR_RE = /(?:(?:(\b\d\b)[°h])?(\b\d{1,3}\b)[′’'m])?(\b\d{2}\b)[″”"s]|(?:(?:(\b\d\b):)?(\b\d{1,3}\b):)(\b\d{2}\b)/g; // Extract durations (in reading order) → [{ value, raw }]. value is m:ss, or // h:mm:ss when hours are present (hours 0 collapse back to m:ss). function lpParse(text) { if (!text) return []; const item = new RegExp(LP_DUR_RE.source); return (String(text).match(LP_DUR_RE) || []).map(raw => { const m = raw.match(item); const h = +(m[1] || m[4] || 0), mn = +(m[2] || m[5] || 0), s = +(m[3] || m[6] || 0); const value = h > 0 ? `${h}:${String(mn).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${mn}:${String(s).padStart(2, '0')}`; return { value, raw }; }); } // A valid MB length: m:ss (ss<60; minutes unbounded) or h:mm:ss (mm<60, ss<60). const lpValid = v => /^\d+:[0-5]\d$/.test(String(v).trim()) || /^\d+:[0-5]\d:[0-5]\d$/.test(String(v).trim()); // #621 (majkinetor): "I should be able to commit this (3 tracks missing len)". A source that // simply has no length for a track is not an error — the row stays EMPTY and that track keeps // whatever length it already has (nothing is cleared). Only a non-empty value that isn't a // time is invalid. const lpEmpty = v => !String(v == null ? '' : v).trim(); const lpBad = v => !lpEmpty(v) && !lpValid(v); // #455.1 fetch an external page's HTML (any host — @connect *) via GM. function lpFetchHtml(url) { return new Promise((resolve, reject) => { const gmx = (typeof GM_xmlhttpRequest !== 'undefined' && GM_xmlhttpRequest) || (typeof GM !== 'undefined' && GM && GM.xmlHttpRequest); if (!gmx) { reject(new Error('no GM_xmlhttpRequest')); return; } try { gmx({ method: 'GET', url, timeout: 20000, onload: r => (r.status >= 200 && r.status < 400) ? resolve(r.responseText || '') : reject(new Error('HTTP ' + r.status)), onerror: () => reject(new Error('fetch failed')), ontimeout: () => reject(new Error('timeout')) }); } catch (e) { reject(e); } }); } // #455.1 pull the tracklist text out of fetched HTML: the SMALLEST element whose text // still holds at least `want` durations (trims nav/footer noise), else the whole body. function lpExtractFromHtml(html, want) { let doc; try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch (e) { return ''; } if (!doc || !doc.body) return ''; doc.querySelectorAll('script,style,noscript,svg').forEach(e => e.remove()); // textContent glues adjacent cells/rows ("12Song 1213:55") — durations lose their word // boundary and stop matching. Insert separators: table cells → space, block/rows → newline. doc.querySelectorAll('td,th').forEach(e => e.append(document.createTextNode(' '))); doc.querySelectorAll('tr,li,p,div,br,h1,h2,h3,h4,h5,h6,section,article').forEach(e => e.append(document.createTextNode('\n'))); const count = t => (String(t).match(LP_DUR_RE) || []).length; let best = doc.body, bestLen = (doc.body.textContent || '').length + 1; if (want > 0) for (const el of doc.body.querySelectorAll('*')) { const t = el.textContent || ''; if (t.length > bestLen) continue; if (count(t) >= want) { best = el; bestLen = t.length; } } return (best.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{2,}/g, '\n'); } // #455.1 external links to offer: the release's link rows (Release-Info / relationships // tab), else — for a saved release — its url relationships from the web service. async function lpExternalLinks() { const dom = (typeof linkRows === 'function' ? linkRows() : []).map(l => l.url).filter(Boolean); if (dom.length) return [...new Set(dom)]; const gid = (location.pathname.match(/release\/([0-9a-f-]{36})/i) || [])[1]; if (!gid) return []; const res = await wsJson(`${ORIGIN}/ws/2/release/${gid}?inc=url-rels&fmt=json`, { label: 'release url-rels' }); return res.json ? [...new Set((res.json.relations || []).map(r => r.url && r.url.resource).filter(Boolean))] : []; } // #455.2 append the source URL to the release edit note (React-safe native setter). function lpNoteSource(url) { const ta = document.getElementById('edit-note-text'); if (!ta || !url) return; const line = 'Track lengths from ' + url; const cur = ta.value || ''; if (cur.includes(line)) return; const kept = cur.replace(/\s+$/, ''); const val = kept ? kept + '\n' + line : line; try { Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set.call(ta, val); } catch (e) { ta.value = val; } ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); } function openLengthParser(mi) { document.getElementById('tc-lppop')?.remove(); let curMi = (mi != null ? mi : toolMedium()); let items = []; // [{ value, raw }] — the editable list (item i → track i) let sourceUrl = null; // set when the lengths came from an external link → stamped in the edit note const p = document.createElement('div'); p.id = 'tc-lppop'; p.className = 'tc-lppop'; const medSel = mediums().length > 1 ? `<select class="tc-lp-med">${mediums().map((m, i) => `<option value="${i}"${i === curMi ? ' selected' : ''}>Medium ${i + 1}</option>`).join('')}</select>` : `<span class="tc-lp-med1">Medium ${curMi + 1}</span>`; p.innerHTML = ` <div class="tc-lp-hd"><button type="button" class="tc-lp-back" title="Back to the source chooser" style="display:none">‹ Sources</button><span class="tc-lp-t">Length parser</span><span class="tc-lp-err" style="display:none"></span>${medSel}<button type="button" class="tc-lp-x" title="Close (Esc)">✕</button></div> <div class="tc-lp-body"> <div class="tc-lp-left"> <textarea class="tc-lp-ta" placeholder="Paste a tracklist here — any text with durations (5:50, 1′23″, 1:02:03). Track numbers, titles and other noise are ignored."></textarea> <div class="tc-lp-choose"> <div class="tc-lp-crow"><button type="button" class="tc-lp-cbtn" data-o="text">Enter text</button><button type="button" class="tc-lp-cbtn" data-o="clip">Paste from clipboard</button></div> <div class="tc-lp-clbl" title="Copy a link to a page with the tracklist, then press Ctrl+V anywhere in this window">Parse from external link — or paste one (Ctrl+V)</div> <div class="tc-lp-favs"><span class="tc-lp-favload">…</span></div> </div> </div> <div class="tc-lp-list"></div> </div> <div class="tc-lp-ft"><span class="tc-lp-cnt"></span><span class="tc-lp-acts"><button type="button" class="tc-lp-ok" disabled>Apply</button></span></div>`; document.body.appendChild(p); const cw = p.offsetWidth || 832, ch = p.offsetHeight || 460; p.style.left = Math.max(8, Math.round((innerWidth - cw) / 2)) + 'px'; p.style.top = Math.max(8, Math.round((innerHeight - ch) / 2)) + 'px'; const $ = s => p.querySelector(s); const ta = $('.tc-lp-ta'), listEl = $('.tc-lp-list'), chooser = $('.tc-lp-choose'), errBadge = $('.tc-lp-err'), backBtn = $('.tc-lp-back'); const tracks = () => u(mediums()[curMi].tracks) || []; const trackTitle = i => { const t = tracks()[i]; return t ? (u(t.name) || '') : ''; }; const close = () => { document.removeEventListener('paste', onPaste, true); p.remove(); }; // onPaste: #622, below // the chooser overlays the empty box; the header "‹ Sources" button appears once a // source is picked so you can go back and try another (#455: some links, e.g. Spotify, // have no parsable text). setChooser(true) forces it back even over fetched content. const setChooser = show => { chooser.style.display = show ? '' : 'none'; backBtn.style.display = show ? 'none' : ''; }; const showChooser = () => setChooser(!ta.value && !items.length); backBtn.onclick = () => { setChooser(true); }; // ── source chooser (overlaid on the empty textarea, beside the list) ── chooser.querySelector('[data-o="text"]').onclick = () => { setChooser(false); ta.focus(); }; chooser.querySelector('[data-o="clip"]').onclick = () => { if (navigator.clipboard && navigator.clipboard.readText) navigator.clipboard.readText().then(t => { ta.value = t; sourceUrl = null; items = lpParse(t); render(); showChooser(); ta.focus(); }).catch(() => { setChooser(false); ta.focus(); toast('Clipboard blocked — paste into the box'); }); else { setChooser(false); ta.focus(); toast('Clipboard unavailable — paste into the box'); } }; // external links → one favicon each; clicking fetches + parses immediately lpExternalLinks().then(urls => { const favs = chooser.querySelector('.tc-lp-favs'); if (!urls.length) { favs.innerHTML = `<span class="tc-lp-nofav">no external links on this release</span>`; return; } favs.innerHTML = urls.map(x => { let h = ''; try { h = new URL(x).hostname.replace(/^www\./, ''); } catch (e) {} return `<img class="tc-lp-fav" src="https://www.google.com/s2/favicons?sz=64&domain=${encodeURIComponent(h)}" title="Read track lengths from ${esc(x)}" data-u="${esc(x)}" data-h="${esc(h)}" alt="${esc(h)}" referrerpolicy="no-referrer">`; }).join(''); favs.querySelectorAll('.tc-lp-fav').forEach(im => { im.onclick = () => fetchLink(im.dataset.u); im.onerror = () => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-lp-favtxt'; b.textContent = im.dataset.h || 'link'; b.title = im.title; b.onclick = () => fetchLink(im.dataset.u); im.replaceWith(b); }; // favicon blocked/offline → clickable hostname chip }); }); async function fetchLink(url) { setChooser(false); ta.disabled = true; ta.value = 'Fetching the page…\n' + url; let html; try { html = await lpFetchHtml(url); } catch (e) { ta.disabled = false; ta.value = ''; items = []; render(); showChooser(); toast(`Couldn't fetch that page (${e.message}) — try another link or paste`); return; } ta.disabled = false; const text = lpExtractFromHtml(html, tracks().length); ta.value = text; sourceUrl = url; items = lpParse(text); render(); showChooser(); if (!items.length) toast('No durations found on that page — try another link or paste the text'); } ta.oninput = () => { sourceUrl = null; items = lpParse(ta.value); render(); showChooser(); }; // typing overrides an external source // #622 (majkinetor): "CTRL+v like in AS so external link is parsed the same way release links // are parsed". A real paste gesture carries its data with no clipboard permission (Art Station // #554), so Ctrl+V of a single URL anywhere in the open parser — the chooser, the box, the list // — goes through fetchLink exactly like clicking one of the release's link icons, edit-note // credit included. Any other text pastes as it always did; on the chooser (where the box is // covered and nothing has focus) it goes into the box, like "Paste from clipboard". A paste // into some other field on the page is none of our business. const LP_URL_RE = /^https?:\/\/\S+$/i; function onPaste(e) { if (!p.isConnected) { document.removeEventListener('paste', onPaste, true); return; } // replaced by a re-open, which skips close() const t = e.target; const inside = !!(t && t.closest && t.closest('#tc-lppop')); const typing = !!(t && (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable)); if (typing && !inside) return; const txt = ((e.clipboardData && e.clipboardData.getData('text')) || '').trim(); if (!txt) return; if (LP_URL_RE.test(txt)) { e.preventDefault(); e.stopPropagation(); Log.cat('Tool').info('length parser: pasted a link — reading track lengths from', txt); fetchLink(txt); return; } if (!typing && chooser.style.display !== 'none') { e.preventDefault(); e.stopPropagation(); Log.cat('Tool').debug('length parser: pasted', txt.length, 'chars of text onto the chooser'); setChooser(false); ta.value = txt; sourceUrl = null; items = lpParse(txt); render(); showChooser(); ta.focus(); } } document.addEventListener('paste', onPaste, true); function render() { const nT = tracks().length; listEl.innerHTML = ''; items.forEach((it, i) => { const row = document.createElement('div'); row.className = 'tc-lp-row'; row.innerHTML = `<span class="tc-lp-tk${i < nT ? '' : ' none'}" title="${esc(i < nT ? (trackTitle(i) || '') : 'no track — ignored')}">${i < nT ? (i + 1) + '. ' + esc(trackTitle(i) || '—') : '— (no track)'}</span>`; const inp = noPw(document.createElement('input')); inp.type = 'text'; inp.className = 'tc-lp-val' + (lpBad(it.value) ? ' bad' : ''); inp.value = it.value; inp.placeholder = '—'; // #621 an empty row is allowed: that track keeps its current length const tip = () => { inp.title = lpEmpty(inp.value) ? 'empty — this track keeps its current length' : (it.raw ? ('detected: ' + it.raw) : ''); }; tip(); inp.oninput = () => { it.value = inp.value; inp.classList.toggle('bad', lpBad(inp.value)); tip(); refreshFoot(); }; // clears the red the moment it's valid (or emptied) const add = document.createElement('button'); add.type = 'button'; add.className = 'tc-lp-add'; add.textContent = '+'; add.title = 'insert a length below (rows shift down)'; add.onclick = () => { items.splice(i + 1, 0, { value: '', raw: '' }); render(); listEl.querySelectorAll('.tc-lp-val')[i + 1]?.focus(); }; const del = document.createElement('button'); del.type = 'button'; del.className = 'tc-lp-del'; del.textContent = '✕'; del.title = 'delete (rows below shift up)'; del.onclick = () => { items.splice(i, 1); render(); }; row.append(inp, add, del); listEl.appendChild(row); }); const end = document.createElement('button'); end.type = 'button'; end.className = 'tc-lp-addend'; end.textContent = items.length ? '+ add length' : '+ add a length (or paste into the box on the left)'; end.onclick = () => { items.push({ value: '', raw: '' }); render(); const all = listEl.querySelectorAll('.tc-lp-val'); all[all.length - 1]?.focus(); }; listEl.appendChild(end); refreshFoot(); } function refreshFoot() { const cntEl = $('.tc-lp-cnt'), okBtn = $('.tc-lp-ok'); const nT = tracks().length, n = items.length, bad = items.filter(it => lpBad(it.value)).length; // #621 empty rows are fine; only the ones that land on a track count ("left as is") const mapped = items.slice(0, nT), empty = mapped.filter(it => lpEmpty(it.value)).length, toApply = mapped.length - empty; cntEl.className = 'tc-lp-cnt' + (bad ? ' bad' : n === nT ? ' ok' : n ? ' warn' : ''); cntEl.textContent = (!n ? `${nT} track${nT !== 1 ? 's' : ''} in Medium ${curMi + 1}` : `${n} length${n !== 1 ? 's' : ''} ↔ ${nT} track${nT !== 1 ? 's' : ''}` + (bad ? ` · ${bad} invalid — fix or delete` : n !== nT ? ' · count mismatch' : '') + (empty ? ` · ${empty} empty — left as is` : '')) + (sourceUrl ? ' · from external link' : ''); okBtn.disabled = !toApply || bad > 0; okBtn.textContent = `Apply ${toApply} to Medium ${curMi + 1}`; if (bad > 0) { errBadge.style.display = ''; errBadge.textContent = `⚠ ${bad} invalid — fix or delete`; } // #455.3 prominent badge in the header else errBadge.style.display = 'none'; } function commit() { const nT = tracks().length, n = Math.min(items.length, nT); if (!items.length || items.some(it => lpBad(it.value))) return; // #621 an empty row leaves its track's length untouched — it is never written as "clear" let applied = 0; const kept = []; for (let i = 0; i < n; i++) { if (lpEmpty(items[i].value)) { kept.push(i + 1); continue; } setLength({ mi: curMi, ti: i }, items[i].value); applied++; } if (!applied) return; if (sourceUrl) lpNoteSource(sourceUrl); // #455.2 credit the source in the edit note rebuild(true); Log.cat('Tool').info('length parser: applied', applied, 'track length(s) on medium', curMi + 1, kept.length ? ('· left as is (empty): track ' + kept.join(', ')) : '', sourceUrl ? ('from ' + sourceUrl) : ''); toast(`Applied ${applied} track length${applied !== 1 ? 's' : ''}` + (kept.length ? ` · ${kept.length} left as is` : '')); close(); } // static header/footer wiring $('.tc-lp-ok').onclick = commit; $('.tc-lp-x').onclick = close; const medEl = $('.tc-lp-med'); if (medEl) medEl.onchange = () => { curMi = parseInt(medEl.value, 10) || 0; render(); }; // Esc / Ctrl+Enter only — never dismisses on an outside click (#455.6) p.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commit(); } }; // draggable by the header $('.tc-lp-hd').onmousedown = e => { if (e.target.closest('button, select')) return; e.preventDefault(); const r = p.getBoundingClientRect(), ox = e.clientX - r.left, oy = e.clientY - r.top; const mv = ev => { p.style.left = Math.max(0, Math.min(ev.clientX - ox, innerWidth - p.offsetWidth)) + 'px'; p.style.top = Math.max(0, Math.min(ev.clientY - oy, innerHeight - p.offsetHeight)) + 'px'; }; const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }; render(); showChooser(); } function runMediumTool(act, mi) { if (act === 'parser') openParser(mi); else if (act === 'patternparser') openTrackPatternParser(mi); else if (act === 'lengthparser') openLengthParser(mi); else if (act === 'resetnum') resetNumbers(mi); else if (act === 'swap') swapMedium(mi); } function runAction(a) { // #378 a bridged external tool (x:…). The PINNED toolbar button reaches us via bindActions (which // rebinds every [data-act] to runAction, overriding pickTool), so re-sync the bridge map HERE too — // otherwise a stale map (a prior sync while the external button was hidden) means nothing fires, while // the Tools menu (which goes through pickTool/getToolCfg) still works. if (String(a).startsWith('x:')) { syncBridges(); const b = _bridgeMap[a]; if (b) fireBridge(b); else Log.cat('Tool').warn('bridge not present:', a); return; } if (a === 'match') { if (_matching) stopMatching(); else matchAll(); } // #577: the same button stops a running pass else if (a === 'revert') revertAll(); else if (a === 'guesscase') guessCaseAll(); else if (a === 'guessfeat') guessFeatAll(); else if (a === 'cols') colsFit(); // the Columns button's default action is Fit else if (a === 'mergemed') { const all = mediums(); const sel = _mergeSel ? [..._mergeSel].filter(i => all[i]) : all.map((_, i) => i); mergeMediums(sel); } // #615 else if (a === 'splitmed') { const mi = toolMedium(); const m = mediums()[mi]; const n = m && medLoaded(m) ? u(m.tracks).length : 0; Log.cat('Tool').debug('split requested: medium', mi + 1, '· chosen index', _splitAt, '· tracks', n); splitMedium(mi, _splitAt != null ? _splitAt : Math.floor(n / 2)); } // #615 else if (MEDIUM_TOOLS.has(a)) runMediumTool(a, 0); else if (_bridgeMap[a]) fireBridge(_bridgeMap[a]); } function bindActions(host) { host.querySelectorAll('[data-act]').forEach(b => { const a = b.dataset.act; b.onclick = () => { if (a === 'menu') openToolsMenu(b); else if (a === 'tool') runActiveTool(); else if (a === 'toolsmenu') openToolsMenu(b); else if (a === 'toolscfg') openToolsConfig(b); else if (a === 'gear') openSettings(b); else if (a === 'revertmenu') openMiniMenu(b, [{ label: '↺ Revert all', title: 'revert every track to its page-load state', onClick: revertAll }, { label: '✕ Clear all', title: 'unselect every track artist, keeping the credited-as text (titles and lengths kept)', onClick: clearAllTracks }]); else if (a === 'close') { host.remove(); ACTIVE = {}; } else runAction(a); }; }); } // a small one-off dropdown (e.g. the ▾ next to "Revert all"); items: {label, title?, onClick} function openMiniMenu(anchor, items) { document.querySelectorAll('.tc-menu.tc-mini').forEach(m => m.remove()); const m = document.createElement('div'); m.className = 'tc-menu tc-mini'; m.innerHTML = items.map((it, i) => `<div class="tc-mi" data-i="${i}"${it.title ? ` title="${esc(it.title)}"` : ''}>${esc(it.label)}</div>`).join(''); document.body.appendChild(m); const r = anchor.getBoundingClientRect(); const w = m.offsetWidth, h = m.offsetHeight; // right-align the menu to the anchor (opens leftward) and clamp on-screen, so it never runs off the edge m.style.left = Math.round(Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8))) + 'px'; m.style.top = Math.round(Math.max(8, Math.min(r.bottom + 4, window.innerHeight - h - 8))) + 'px'; m.querySelectorAll('.tc-mi').forEach(el => el.onclick = () => { const it = items[+el.dataset.i]; m.remove(); it.onClick(); }); const off = e => { if (!m.contains(e.target) && e.target !== anchor) { m.remove(); document.removeEventListener('mousedown', off, true); } }; setTimeout(() => document.addEventListener('mousedown', off, true), 0); } // #735: right-click on a credited-as × — drop the credited-as override on EVERY track (an import's // lowercase "kassav" left beside the matched artist "Kassav'"), so each credit reads as the artist's // own name. Only slots with an artist: an unresolved slot's credited-as is all the text it has. function clearAllCredits() { if (!MODEL) return; let slots = 0, tracks = 0; MODEL.tracks.forEach(t => { let n = 0; (t.slots || []).forEach(s => { if (s.name && s.creditedAs && s.creditedAs !== s.name) { s.creditedAs = s.name; n++; } }); if (!n) return; try { commitTrack(t); slots += n; tracks++; } catch (e) { Log.cat('Artist').warn('clear credited-as failed on track', t.number, e.message); } }); Log.cat('Artist').info('cleared every credited-as override:', slots, 'on', tracks, 'track(s)'); if (slots) rerender(); toast(slots ? `Cleared ${slots} credited-as override${slots !== 1 ? 's' : ''} on ${tracks} track${tracks !== 1 ? 's' : ''}` : 'No credited-as overrides to clear'); } // Tracklist "Clear all": empty the title, artist credit and length of every track (with confirm) function clearAllTracks() { if (!MODEL) return; if (!W.confirm('Unselect the artist of EVERY track? (the credited-as text, titles and lengths are kept; does not submit)')) return; _selfEdit = true; // #483: bulk write across every track — rebuild(true) below is the one resync it needs try { MODEL.tracks.forEach(t => { try { const ko = koTrack(t.mi, t.ti); const names = u(u(ko.artistCredit).names) || []; // keep each credit's display text (the credited-as name, or the artist's name if none) but drop the // selected entity — turning a matched artist back into unmatched text (artist: bare {name}), ready // to re-match. Mirrors how commitTrack writes an unresolved slot. ko.artistCredit({ names: names.map(n => { const text = u(n.name) || (n.artist && u(u(n.artist).name)) || ''; return { artist: { name: text }, name: text, joinPhrase: u(n.joinPhrase) || '' }; }) }); } catch (e) {} }); } finally { _selfEdit = false; } Log.cat('Artist').info('cleared all track artist selections (kept credited-as text)'); rebuild(true); // no re-match, or it would instantly re-link the kept text } /* ── Configurable Tools bar (#280) ─────────────────────────────────────────── A row of tool buttons you choose (the rest live behind ⋯). The ACTIVE tool's params render inline, right after the buttons; tools you "pin" keep their params on a 2nd row. Click the "Tools" label to customise (visibility, order, per-tool icon/text, pinning). ── */ const MENU = [ { act: 'parser', label: 'Track parser', icon: '☰' }, // ☰ native MB parser { act: 'patternparser', label: 'Pattern parser', icon: '▦' }, // ▦ #456 our pattern-based parser { act: 'lengthparser', label: 'Length parser', icon: '⏱' }, // ⏱ #455 { act: 'swap', label: 'Swap', icon: '⇅' }, // ⇅ { act: 'resetnum', label: 'Reset #', icon: '#' }, { act: 'mergemed', label: 'Merge mediums', icon: '⊕', params: true }, // #615 { act: 'splitmed', label: 'Split medium', icon: '✂', params: true }, // #615 { act: 'guessfeat', label: 'Guess feat.', icon: 'ft', instant: true }, { act: 'guesscase', label: 'Guess case', icon: 'Aa', params: true }, { act: 'sr', label: 'Search and Replace', icon: 'S&R', params: true }, { act: 'cols', label: 'Resize columns', icon: '↔', params: true }, // ↔ ]; const TOOL = Object.fromEntries(MENU.map(m => [m.act, m])); const MEDIUM_TOOLS = new Set(['parser', 'patternparser', 'lengthparser', 'resetnum', 'swap']); // act on ONE medium (inline medium combo when >1) const PICK_SHOWS_ONLY = new Set(['mergemed', 'splitmed']); // #615: structural — picking from the menu must not run them const OPTLESS = new Set(['guessfeat']); // global, no options — fires on pick (non-sticky) const hasParams = act => !!(TOOL[act] && TOOL[act].params); /* ── Tier-0 tool bridges (#322) ─────────────────────────────────────────── Surface a button that some OTHER (user)script drops on the page as a fire-and-forget Apollo Tool, but ONLY when it's actually present. Two ways: (a) Built-in registry — for known buttons we want to adopt UNMODIFIED (e.g. kellnerd's "Guess punctuation"), matched by visible text. Add another by appending one { label, icon, find } entry; `find()` returns the live element or null. (b) Convention — any element tagged `class="apollo-tool"` (optional `data-apollo-label` / `data-apollo-icon`) is surfaced too, so a cooperating script self-registers with NO Apollo change. Either kind is param-less: activating clicks the element, then Apollo re-reads (the external code writes the native fields / KO model, which Apollo doesn't watch per-value), so its grid reflects the change. ── */ const bridgeBtn = re => [...document.querySelectorAll('button, input[type="button"], input[type="submit"]')] // #378 must NOT match Apollo's OWN surfaced copy of the tool (the pinned bar button / Tools-menu item // carry the same label) — otherwise the pinned button fires itself and nothing happens. Skip our UI. .find(b => b.offsetParent !== null && !b.closest('.tc-tools, .tc-toolbtns, .tc-menu, #tc-panel, .tc-mirror') && re.test((b.textContent || b.value || '').trim())) || null; // built-in registry: known external buttons, each with a STABLE `act` so its // bar/menu placement persists. Add one entry { act, label, icon, find } to bridge another. const BRIDGE = [ { act: 'x:gpunct', label: 'Guess punctuation', icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAHYgAAB2IBOHqZ2wAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAFpSURBVDiNpZOxjwFBGMV/e5FspZeoFETlL9Bug0RDL5FolVpRUqxCr1iNUelUEhmFZqlEVAolFRuxsswVl9uzWVfceeX73nvzfTPzaUIIxRuIAJTL5X+ZR6MRH++cDrwOOBwOdLtdbrdbqDafzxmPx78H2LZNtVplt9txPp993vM8TNOk1WoFeIQQ6htSSmUYhur3++rxePi853mq0WioUqmkttutzwshVOS57U6nQy6Xo1KpBLoaDAYsl0t6vR6pVOr1HViWheM4IfPlcmE4HJLNZkPmQMBqtSIajbJYLFiv175gs9lwvV653+/MZjOOx2MgwB/BdV1OpxPtdhuAYrFIvV73X0JKiZQSXdcxTZN0Oh3sIBaLBZInkwlKqRDvui7T6TQ8gmEYAWE8HkfTNBKJBMlkMlQLjVAoFHAcB9u20XWdWq3mi5rNJpZlsd/vyWQy5PP5n7Tnf/BXCCHU27sQga+t+i8+AYUS9lO02Bg3AAAAAElFTkSuQmCC', find: () => bridgeBtn(/^guess\s+punctuation$/i) }, ]; let _bridges = [], _bridgeMap = {}, _lastBridgeSig = null; // #441 dedupe the sync-diagnostics log // refresh the tools available right now: built-in matches that resolve + elements // tagged class="apollo-tool" (self-registering). Each carries a stable act so the // normal tool-config (bar / menu / Customize, persisted) treats it like a native tool. function syncBridges() { const out = []; for (const b of BRIDGE) { let el = null; try { el = b.find(); } catch (e) {} if (el) out.push({ act: b.act, label: b.label, icon: b.icon, el }); } document.querySelectorAll('.apollo-tool').forEach(el => { const label = el.dataset.apolloLabel || (el.textContent || '').trim() || 'Tool'; const act = 'x:' + (el.dataset.apolloId || el.id || label.toLowerCase().replace(/[^a-z0-9]+/g, '-')); if (!out.some(o => o.act === act)) out.push({ act, label, icon: el.dataset.apolloIcon || '🔧', el }); }); _bridges = out; _bridgeMap = Object.fromEntries(out.map(b => [b.act, b])); // #441 — only log when the set actually CHANGES; syncBridges runs on every tick and // was spamming the same line ~2×/s. Dedupe against the last signature. const sig = out.length ? out.map(b => b.act + (b.el ? '' : '(no-el)')).join(', ') : '(none)'; if (sig !== _lastBridgeSig) { _lastBridgeSig = sig; Log.cat('Tool').debug('bridges synced:', sig); } // #378 diagnostics return out; } // tool-def accessor: native tools + present bridges, so render/customize treat them alike const TD = act => TOOL[act] || _bridgeMap[act] || { act, icon: '?', label: act }; // a tool icon is normally a short text glyph (Aa, S&R, ↔); a data:/http(s) URL // renders as a small <img> instead, so a bridged tool can ship its own icon. const isImgIcon = v => typeof v === 'string' && /^(?:data:|https?:\/\/)/.test(v); const iconHtml = v => isImgIcon(v) ? `<img class="tc-icimg" src="${esc(v)}" alt="">` : esc(v == null ? '' : v); async function fireBridge(it) { if (!it) { Log.cat('Tool').warn('bridge: nothing to fire (no tool)'); return; } // #378 re-resolve the LIVE element at fire time: the cached one can be stale, and a fresh find() also // avoids firing Apollo's own surfaced copy of the tool. Fall back to the cached element. let el = it.el; const reg = BRIDGE.find(b => b.act === it.act); try { if (reg && reg.find) { const fresh = reg.find(); if (fresh) el = fresh; } } catch (e) { Log.cat('Tool').warn('bridge: find() threw', e.message); } Log.cat('Tool').info('bridge:', it.label, '· cached el', it.el ? 'yes' : 'null', '· resolved', !el ? 'null' : (el === it.el ? 'cached' : 'fresh'), '· ourUI', !!(el && el.closest && el.closest('.tc-tools,.tc-toolbtns,.tc-menu,#tc-panel'))); if (!el) { Log.cat('Tool').warn('bridge: element gone —', it.label); return; } Log.cat('Tool').info('bridge: clicking', it.label, '→', el.tagName + '.' + ((el.className || '').toString().split(' ')[0] || '')); el.click(); try { await rebuild(true); } catch (e) { Log.cat('Tool').warn('bridge: re-render failed', e.message); } // re-read (no re-match) } // default Tools bar (in display order) for a fresh install — every other tool starts // in the Tools ▾ menu. Each entry sets the tool's icon/text and whether its params // start collapsed (right-click a name toggles that later). const TOOL_DEFAULTS = [ { act: 'guesscase', icon: true, text: false, hideParams: true }, // Aa, collapsed { act: 'cols', icon: false, text: true, hideParams: true }, // Resize columns, collapsed { act: 'sr', icon: true, text: false, hideParams: false }, // S&R, params shown ]; const TOOL_DEF = Object.fromEntries(TOOL_DEFAULTS.map(d => [d.act, d])); let _toolMedium = 0; // the medium chosen in the inline combo — shared across all medium-scoped tools const toolMedium = () => Math.min(Math.max(0, _toolMedium), mediums().length - 1); // per-tool config: { act, onBar, pinned, icon, text } in display order. Saved // tools merge over defaults; any tool not yet saved (a future one) is appended // and defaults to the ⋯ menu. function getToolCfg() { syncBridges(); // #322: present bridges become first-class tools (cfg/bar/menu/customize) const known = act => !!(TOOL[act] || _bridgeMap[act]); // drop saved entries for tools/bridges no longer present const saved = Array.isArray(SETTINGS.toolCfg) ? SETTINGS.toolCfg.filter(t => t && known(t.act)) : []; const order = saved.map(t => t.act); // fresh install: the default on-bar tools lead (in their order), then the rest TOOL_DEFAULTS.forEach(d => { if (!order.includes(d.act)) order.push(d.act); }); MENU.forEach(m => { if (!order.includes(m.act)) order.push(m.act); }); _bridges.forEach(b => { if (!order.includes(b.act)) order.push(b.act); }); // present bridges default to the Tools menu const byAct = Object.fromEntries(saved.map(t => [t.act, t])); return order.map(act => { const s = byAct[act] || {}, d = TOOL_DEF[act]; let icon = s.icon != null ? !!s.icon : (d ? d.icon : true); let text = s.text != null ? !!s.text : (d ? d.text : true); if (!icon && !text) text = true; // at least one of icon/text const onBar = s.onBar != null ? !!s.onBar : !!d; const hideParams = s.hideParams != null ? !!s.hideParams : (d ? !!d.hideParams : false); return { act, onBar, icon, text, hideParams }; }); } function saveToolCfg(cfg) { SETTINGS.toolCfg = cfg.map(t => ({ act: t.act, onBar: !!t.onBar, icon: t.icon !== false, text: t.text !== false, hideParams: !!t.hideParams })); saveSettings(); } const cfgOf = act => getToolCfg().find(t => t.act === act) || { act, onBar: false, icon: true, text: true }; // tools surfaced on the bar for THIS session only (picked from the Tools menu) — // never persisted; they fall back to the menu next session (#280). const TEMP_BAR = new Set(); // hovering the "Guess case" tool button previews the guessed form on every differing title function previewAllGuess(on) { if (!MODEL) return; MODEL.tracks.forEach(t => { if (!(t.guessTitle && t.guessTitle !== t.title)) return; const row = rowEl(t.mi, t.ti); if (!row) return; const tin = row.querySelector('.t-title'); if (!tin || document.activeElement === tin) return; const val = on ? t.guessTitle : t.title; tin.value = val; tin.classList.toggle('gcpreview', on); // #203: when the rich title display is on, the visible text is a .t-title-disp // span over the (hidden) input — repaint it too, or the preview is invisible const disp = row.querySelector('.t-title-disp'); if (disp) { disp.innerHTML = dhRun(val); disp.classList.toggle('gcpreview', on); } }); } // #280: render every on-bar tool inline at its position — a plain button when it // has no params, or a group (icon/name trigger + its params) when it does. The // toolbar grows and WRAPS (flex) when it runs out of room (CSS), never shoving Match. function renderToolbar() { const host = document.querySelector('.tc-toolbtns'); if (!host) return; host.innerHTML = ''; getToolCfg().filter(t => t.onBar || TEMP_BAR.has(t.act)).forEach(t => host.appendChild(hasInlineParams(t.act) ? makeToolGroup(t) : makeToolButton(t))); } function makeToolButton(t) { const m = TD(t.act); const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-toolbtn' + (m.instant ? ' instant' : ''); b.dataset.act = t.act; b.innerHTML = (t.icon ? `<span class="tc-tbic">${iconHtml(m.icon)}</span>` : '') + (t.text ? `<span class="tc-tblab">${esc(m.label)}</span>` : ''); if (!t.text) b.title = m.label; b.onclick = () => pickTool(t.act); return b; } function pickTool(act) { if (String(act).startsWith('x:')) Log.cat('Tool').info('external tool activated —', act, '· in map:', !!_bridgeMap[act]); // #378 diagnostics (bar button or Tools-menu item) if (_bridgeMap[act]) return runAction(act); // bridged external tool — fire-and-forget if (OPTLESS.has(act)) return runAction(act); // instant (Guess feat.) // #280: a param tool picked from the Tools menu has nowhere to show its controls // unless it's on the bar — so surface it there for THIS session (not persisted; // it returns to the menu next session). Customize is where you make it permanent. if (hasInlineParams(act)) { if (!cfgOf(act).onBar) { TEMP_BAR.add(act); renderToolbar(); } // #615: Merge / Split restructure the release — picking them only SHOWS their params // (which mediums / which track); they run on their icon/name click, never on the pick. if (PICK_SHOWS_ONLY.has(act)) { Log.cat('Tool').info(TD(act).label, '— set it up in the toolbar, then click its name to run'); return; } return triggerTool(act); } if (MEDIUM_TOOLS.has(act)) return runMediumTool(act, toolMedium()); runAction(act); } function runActiveTool() { const act = SETTINGS.lastTool; if (!act) return; if (MEDIUM_TOOLS.has(act)) return runMediumTool(act, toolMedium()); runAction(act); } // #280: a pinned tool's panel icon runs its primary action (what the row-1 button did) function triggerTool(act) { if (act === 'sr') { _srChain = null; const box = document.querySelector('.tc-sro'); if (box) { box.classList.remove('tc-sro-chain'); } const f = document.querySelector('.tc-sr-find'), r = document.querySelector('.tc-sr-rep'); if (f) f.value = ''; if (r) r.value = ''; srActivate(); MODEL && MODEL.tracks.forEach(t => { delete t._srFlash; }); rerender(); if (f) f.focus(); return; } if (MEDIUM_TOOLS.has(act)) return runMediumTool(act, toolMedium()); runAction(act); // guesscase → guess-case all · cols → Fit } // the "Tools" label menu: the tools NOT on the bar, then Customize… function openToolsMenu(anchor) { let m = document.getElementById('tc-menu'); if (m) { m.remove(); return; } const off = getToolCfg().filter(t => !t.onBar && !TEMP_BAR.has(t.act)); // includes off-bar bridges (#322) m = document.createElement('div'); m.id = 'tc-menu'; m.className = 'tc-menu'; m.innerHTML = off.map(t => `<div class="tc-mi" data-act="${t.act}"><span class="tc-mi-ic">${iconHtml(TD(t.act).icon)}</span>${esc(TD(t.act).label)}</div>`).join('') + (off.length ? '<div class="tc-sep"></div>' : '') + '<div class="tc-mi tc-mi-cfg" data-act="__cfg"><span class="tc-mi-ic">' + MBU_CFG_ICON + '</span>Customize…</div>'; document.body.appendChild(m); const r = anchor.getBoundingClientRect(), mw = m.offsetWidth, mh = m.offsetHeight; const below = r.bottom + 4, above = r.top - 4 - mh; let top = (below + mh > window.innerHeight - 8 && above >= 8) ? above : below; top = Math.max(8, Math.min(top, window.innerHeight - mh - 8)); m.style.left = Math.max(8, Math.min(r.left, window.innerWidth - mw - 8)) + 'px'; m.style.top = top + 'px'; m.querySelectorAll('.tc-mi').forEach(el => el.onclick = () => { m.remove(); if (el.dataset.act === '__cfg') openToolsConfig(anchor); else pickTool(el.dataset.act); }); const off2 = e => { if (!m.contains(e.target) && e.target !== anchor) { m.remove(); document.removeEventListener('mousedown', off2); } }; setTimeout(() => document.addEventListener('mousedown', off2), 0); } // build a tool's parameter controls into `host` (used both inline on row 1 and on row 2) function buildToolParams(act, host) { if (act === 'guesscase') { const g = gcNative(); const box = document.createElement('span'); box.className = 'tc-gco'; if (g && g.lang) { const sel = g.lang.cloneNode(true); sel.className = 'tc-gc-lang'; sel.value = g.lang.value; sel.title = 'Guess Case language'; sel.onchange = () => { setNative(g.lang, sel.value); recomputeGuesses(); }; box.appendChild(sel); } const mkChk = (text, el) => { const l = document.createElement('label'); const c = document.createElement('input'); c.type = 'checkbox'; c.checked = el ? el.checked : false; c.disabled = !el; c.onchange = () => { setNative(el, c.checked); recomputeGuesses(); }; l.append(c, document.createTextNode(' ' + text)); return l; }; if (g) { box.appendChild(mkChk('Keep uppercased', g.keepUC)); box.appendChild(mkChk('Keep Roman', g.roman)); } host.appendChild(box); // #280: no Apply button — clicking the "Aa Guess case" icon/name applies it } else if (act === 'sr') { srActivate(); const box = document.createElement('span'); box.className = 'tc-sro'; // #375 open Saved & History via ↓ in either field, or a ★ button after RE (the in-field caret was hard to hit) const find = noPw(document.createElement('input')); find.type = 'text'; find.className = 'tc-sr-find'; find.placeholder = srRegexOn() ? 'search (regex)' : 'search'; const rep = noPw(document.createElement('input')); rep.type = 'text'; rep.className = 'tc-sr-rep'; rep.placeholder = 'replace'; const run = () => srLive(find.value, rep.value, true); find.oninput = rep.oninput = run; const openOnDown = e => { if (e.key === 'ArrowDown' && !_srPop) { e.preventDefault(); openSrTemplates(find, find, rep, re); } }; // popup anchors to the search field (left-aligned) find.addEventListener('keydown', openOnDown); rep.addEventListener('keydown', openOnDown); const re = document.createElement('button'); re.type = 'button'; re.className = 'tc-srbtn tc-sr-re' + (srRegexOn() ? ' on' : ''); re.textContent = 'RE'; re.title = 'Use regular expressions (search is a regex; $1, $<name> work in replace)'; re.onclick = () => { SETTINGS.srRegex = !srRegexOn(); saveSettings(); re.classList.toggle('on', srRegexOn()); find.placeholder = srRegexOn() ? 'search (regex)' : 'search'; run(); }; const star = document.createElement('button'); star.type = 'button'; star.className = 'tc-srbtn tc-sr-star'; star.textContent = '★'; star.title = 'Saved patterns, chains & recent (or press ↓ in the search field)'; // #409: in chain mode `find` is hidden (display:none → a 0,0 rect that flung the popup to the // corner). Anchor to whatever's actually visible — the chain chip, else the ★ button. star.onclick = () => { const b = star.closest('.tc-sro'); const anchor = (find.offsetParent !== null) ? find : ((b && b.querySelector('.tc-sr-chainchip')) || star); openSrTemplates(anchor, find, rep, re); }; box.append(find, rep, re, star); host.appendChild(box); if (_srChain) srShowChain(_srChain); // #409: restore the chain chip if a chain was active } else if (act === 'cols') { const box = document.createElement('span'); box.className = 'tc-colso'; const mk = (label, title, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-colbtn'; b.textContent = label; b.title = title; b.onclick = fn; return b; }; box.append( mk('Fit', 'size #, Title and Length to their content (Artist absorbs the slack)', colsFit), mk('Centered', 'balance Title and Artist to equal width', colsBalanced), mk('Default', 'reset every column to its default width', colsDefault), ); host.appendChild(box); } else if (act === 'mergemed') { // #615: tick the mediums to merge — they all go into the FIRST ticked one const box = document.createElement('span'); box.className = 'tc-mmo'; const all = mediums(); if (!_mergeSel) _mergeSel = new Set(all.map((_, i) => i)); const lab = document.createElement('span'); lab.className = 'tc-mmo-lab'; lab.textContent = all.length > 1 ? 'into first ticked:' : 'only one medium'; box.appendChild(lab); all.forEach((m, i) => { const l = document.createElement('label'); l.title = medLabel(m, i); const c = document.createElement('input'); c.type = 'checkbox'; c.checked = _mergeSel.has(i); c.onchange = () => { if (c.checked) _mergeSel.add(i); else _mergeSel.delete(i); }; l.append(c, document.createTextNode(String(i + 1))); box.appendChild(l); }); host.appendChild(box); // no Run button — click the tool's icon/name (same as the other param tools) } else if (act === 'splitmed') { // #615: which medium, and the track the NEW medium starts at const box = document.createElement('span'); box.className = 'tc-medo tc-spo'; const all = mediums(); let mi = toolMedium(); if (!all[mi]) mi = 0; if (all.length > 1) { const sel = document.createElement('select'); sel.className = 'tc-medsel'; sel.title = 'which medium'; all.forEach((m, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = 'Medium ' + (i + 1); o.title = medLabel(m, i); sel.appendChild(o); }); sel.value = String(mi); sel.onchange = () => { _toolMedium = parseInt(sel.value, 10) || 0; _splitAt = null; renderToolbar(); }; box.appendChild(sel); } const m = all[mi], tr = m && medLoaded(m) ? u(m.tracks) : []; // MB lazy-loads a collapsed medium's tracks — load it, then rebuild these params with its tracks if (m && !medLoaded(m) && !_splitLoading) { _splitLoading = true; loadMediums([m]).then(() => { _splitLoading = false; renderToolbar(); }); } const lab = document.createElement('span'); lab.className = 'tc-mmo-lab'; lab.textContent = m && !medLoaded(m) ? 'loading medium…' : 'new medium from #'; const at = document.createElement('select'); at.className = 'tc-medsel tc-spat'; at.title = 'the first track of the new medium'; tr.forEach((t, i) => { if (i === 0) return; const o = document.createElement('option'); o.value = String(i); o.textContent = String(u(t.number) || i + 1); o.title = u(t.name) || ''; at.appendChild(o); }); if (_splitAt == null || _splitAt <= 0 || _splitAt >= tr.length) _splitAt = Math.floor(tr.length / 2) || null; if (_splitAt != null) at.value = String(_splitAt); at.disabled = tr.length < 2; at.onchange = () => { _splitAt = parseInt(at.value, 10) || null; Log.cat('Tool').debug('split: new medium will start at track index', _splitAt, '(#' + at.options[at.selectedIndex].textContent + ')'); }; box.append(lab, at); host.appendChild(box); } else if (MEDIUM_TOOLS.has(act) && mediums().length > 1) { const box = document.createElement('span'); box.className = 'tc-medo'; const sel = document.createElement('select'); sel.className = 'tc-medsel'; sel.title = 'which medium'; mediums().forEach((m, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = 'Medium ' + (i + 1); sel.appendChild(o); }); sel.value = String(toolMedium()); sel.onchange = () => { _toolMedium = parseInt(sel.value, 10) || 0; }; // #280: no Run button — pick the medium here, then click the tool's icon/name to run it // (matches Guess case / S&R / Resize, which all apply on the icon click). box.append(sel); host.appendChild(box); } } const hasInlineParams = act => hasParams(act) || (MEDIUM_TOOLS.has(act) && mediums().length > 1); // an inline param tool: a clickable icon/name trigger (obeys icon/text cfg) + its params function makeToolGroup(t) { const act = t.act; const grp = document.createElement('span'); grp.className = 'tc-opt'; grp.dataset.tool = act; const name = document.createElement('span'); name.className = 'tc-optname tc-opttrig'; name.innerHTML = (t.icon ? `<span class="tc-tbic">${iconHtml(TD(act).icon)}</span>` : '') + (t.text ? `<span class="tc-tblab">${esc(TD(act).label)}</span>` : ''); name.onclick = () => triggerTool(act); // collapsed: the group's padding around the trigger acts as the trigger too (clicks on the // params/flyout bubble here with a different target, so they're left alone) grp.addEventListener('click', e => { if (e.target === grp && grp.classList.contains('tc-collapsed')) triggerTool(act); }); grp.addEventListener('contextmenu', e => { if (e.target === grp) name.oncontextmenu(e); }); // collapsed: drop the title so the native tooltip can't pop up over the flyout const setTitle = () => { name.title = grp.classList.contains('tc-collapsed') ? '' : TOOL[act].label + ' — click to run, right-click to collapse parameters'; }; // #280: right-click the name collapses/expands its params (state remembered); // collapsed params fly out on hover/focus. Toggle in place so typed S&R text survives. name.oncontextmenu = (e) => { e.preventDefault(); const now = !grp.classList.contains('tc-collapsed'); grp.classList.toggle('tc-collapsed', now); setTitle(); const cfg = getToolCfg(); const c = cfg.find(x => x.act === act); if (c) { c.hideParams = now; saveToolCfg(cfg); } }; // preview on the whole group (not just the name) so it persists while the cursor // moves onto the params/flyout — mouseenter/leave ignore parent↔descendant moves if (act === 'guesscase') { grp.onmouseenter = () => previewAllGuess(true); grp.onmouseleave = () => previewAllGuess(false); } if (t.hideParams) grp.classList.add('tc-collapsed'); setTitle(); grp.appendChild(name); buildToolParams(act, grp); return grp; } let _bridgeKey = '', _bridgeT = 0, _bridgeObs = null; const bridgeKeyNow = () => _bridges.map(b => b.act).sort().join('|'); function refreshBridgesIfChanged() { if (!document.querySelector('.tc-toolbtns')) return; // toolbar not mounted syncBridges(); const key = bridgeKeyNow(); if (key !== _bridgeKey) { _bridgeKey = key; renderToolbar(); } } // #322: an external tool button (e.g. Guess punctuation) may be injected AFTER // Apollo first builds its toolbar, so a pinned bridge wouldn't show until a // re-render. Watch for the present-bridge set changing and re-render the bar. function watchBridges() { if (_bridgeObs) return; _bridgeObs = new MutationObserver(() => { clearTimeout(_bridgeT); _bridgeT = setTimeout(refreshBridgesIfChanged, 300); }); _bridgeObs.observe(document.body, { childList: true, subtree: true }); } function initTools() { renderToolbar(); _bridgeKey = bridgeKeyNow(); watchBridges(); } // ── "Customize tools" popover (opens from the Tools label) ── function openToolsConfig(anchor) { let p = document.getElementById('tc-toolcfg'); if (p) { p.remove(); return; } p = document.createElement('div'); p.id = 'tc-toolcfg'; p.className = 'tc-toolcfg'; let dragAct = null; const apply = () => { renderToolbar(); }; const update = (act, patch) => { const cfg = getToolCfg(); const t = cfg.find(x => x.act === act); if (!t) return; Object.assign(t, patch); if (!t.icon && !t.text) { if ('icon' in patch) t.text = true; else t.icon = true; } // keep ≥1 saveToolCfg(cfg); render(); apply(); }; const reorder = (fromAct, toAct, after) => { const cfg = getToolCfg(); const fi = cfg.findIndex(t => t.act === fromAct); if (fi < 0) return; const item = cfg.splice(fi, 1)[0]; let ti = cfg.findIndex(t => t.act === toAct); if (ti < 0) ti = cfg.length - 1; if (after) ti += 1; cfg.splice(ti, 0, item); saveToolCfg(cfg); render(); apply(); }; const below = (e, row) => { const rc = row.getBoundingClientRect(); return e.clientY > rc.top + rc.height / 2; }; // drop after/before per cursor (#280) function render() { p.innerHTML = '<div class="tc-tc-h">Customize tools</div><div class="tc-tc-list"></div>' + '<div class="tc-tc-hint">Drag ⠿ to reorder · ☑ shows it on the bar (else under the Tools menu) · tools with settings show them inline</div>'; const list = p.querySelector('.tc-tc-list'); getToolCfg().forEach(t => { const m = TD(t.act); const row = document.createElement('div'); row.className = 'tc-tc-row' + (t.onBar ? '' : ' off'); row.dataset.act = t.act; row.draggable = true; row.innerHTML = '<span class="tc-tc-grab" title="drag to reorder">⠿</span>' + `<label class="tc-tc-onbar" title="show on the toolbar"><input type="checkbox" class="cb-onbar"${t.onBar ? ' checked' : ''}></label>` + `<span class="tc-tc-ic">${iconHtml(m.icon)}</span><span class="tc-tc-lab">${esc(m.label)}</span>` + `<span class="tc-tc-dens" title="what shows on the button — icon and/or text"><button type="button" class="tc-tc-seg cb-icon${t.icon ? ' on' : ''}" title="show the icon">${iconHtml(m.icon)}</button><button type="button" class="tc-tc-seg cb-text${t.text ? ' on' : ''}" title="show the text">T</button></span>`; list.appendChild(row); }); list.querySelectorAll('.tc-tc-row').forEach(row => { const act = row.dataset.act; row.querySelector('.cb-onbar').onchange = e => update(act, { onBar: e.target.checked }); row.querySelector('.cb-icon').onclick = () => update(act, { icon: !cfgOf(act).icon }); row.querySelector('.cb-text').onclick = () => update(act, { text: !cfgOf(act).text }); row.ondragstart = e => { dragAct = act; row.classList.add('drag'); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', act); } catch (x) {} }; row.ondragend = () => { dragAct = null; p.querySelectorAll('.tc-tc-row').forEach(r => r.classList.remove('drag', 'over-top', 'over-bottom')); }; row.ondragover = e => { e.preventDefault(); row.classList.toggle('over-bottom', below(e, row)); row.classList.toggle('over-top', !below(e, row)); }; // #280: indicate drop direction row.ondragleave = () => row.classList.remove('over-top', 'over-bottom'); row.ondrop = e => { e.preventDefault(); const dir = below(e, row); row.classList.remove('over-top', 'over-bottom'); const from = dragAct || (e.dataTransfer && e.dataTransfer.getData('text/plain')); if (from && from !== act) reorder(from, act, dir); }; }); } render(); document.body.appendChild(p); const r = anchor.getBoundingClientRect(); // open BELOW the whole toolbar (it may have wrapped to several rows), not just // below the Tools label — so the popover never covers the 2nd row (#280) const bar = document.getElementById('tc-bar'); const barBottom = bar ? bar.getBoundingClientRect().bottom : r.bottom; p.style.left = Math.max(8, Math.min(r.left, window.innerWidth - p.offsetWidth - 8)) + 'px'; p.style.top = Math.max(8, Math.min(barBottom + 6, window.innerHeight - p.offsetHeight - 8)) + 'px'; const off = e => { if (!p.contains(e.target) && e.target !== anchor) { p.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } // proxy MusicBrainz's own (hidden) Guess-case options so they actually affect its guessing function gcNative() { const fs = document.querySelector('fieldset.guesscase, .guesscase'); if (!fs) return null; const lang = fs.querySelector('select'); const checks = [...fs.querySelectorAll('input[type=checkbox]')]; const txt = c => ((c.closest('label') || {}).textContent || '').toLowerCase(); const keepUC = checks.find(c => txt(c).includes('keep') && txt(c).includes('uppercas')) || checks.find(c => txt(c).includes('keep')) || checks[0] || null; const roman = checks.find(c => txt(c).includes('roman')) || checks[1] || null; return { lang, keepUC, roman }; } // MB's guess-case options are React-controlled: a synthetic `change` is ignored, so setting .checked // never writes the option/cookie. A real .click() fires MB's own handler (the mode <select>, by contrast, // is read live from .value so a dispatched change is enough). #156 function setNative(el, val) { if (!el) return; if (el.type === 'checkbox') { if (el.checked !== val) el.click(); return; } el.value = val; el.dispatchEvent(new Event('change', { bubbles: true })); } function recomputeGuesses() { if (!MODEL) return; MODEL.tracks.forEach(t => { t.guessTitle = guessTitleStr(t); }); rerender(); } // search & replace in titles — real-time, recomputed from a snapshot each keystroke (no apply, non-compounding). // #152: an "RE" toggle switches between literal matching and full regular expressions ($1, $<name>, …). const srRegexOn = () => SETTINGS.srRegex === true; // build the matcher. regex mode → raw pattern; literal mode → escape it. invalid regex returns null (no-op). function srRe(find, ci, g) { const flags = (g ? 'g' : '') + (ci ? 'i' : ''); try { return new RegExp(srRegexOn() ? find : find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags); } catch (e) { return null; } } // in literal mode the replacement is literal too — escape `$` so "$1" inserts "$1", not a backref const srRepl = replace => srRegexOn() ? replace : replace.replace(/\$/g, '$$$$'); // ── #409 S&R chains ─────────────────────────────────────────────────────── // A "chain" is a named template whose `members` array lists other (non-chain) template // names, applied in order as one action — so "All Quotes" runs Quotes then Single quote // in a single click. Chains live in the same srTemplates list; `members` marks a chain. // Each member carries its OWN `re` flag (regex or literal), independent of the global RE // toggle, so a chain mixing a regex and a literal pattern behaves as saved. function srIsChain(t) { return !!(t && Array.isArray(t.members)); } // hoisted: used by srSeedTemplates() at init const srReFor = (find, re, ci, g) => { const flags = (g ? 'g' : '') + (ci ? 'i' : ''); try { return new RegExp(re ? find : find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags); } catch (e) { return null; } }; const srReplFor = (replace, re) => re ? String(replace) : String(replace).replace(/\$/g, '$$$$'); // resolve a chain's member names to their (non-chain) pattern entries, in order function srChainPatterns(chain) { const byName = new Map(srTemplates().filter(t => !srIsChain(t)).map(t => [t.name, t])); return (chain.members || []).map(n => byName.get(n)).filter(Boolean); } let _srSnap = null; function srActivate() { _srSnap = MODEL ? MODEL.tracks.map(t => t.title) : []; if (MODEL) MODEL.tracks.forEach(t => { delete t._srLastResult; }); } function srLive(find, replace, ci) { if (!MODEL) return; if (!_srSnap || _srSnap.length !== MODEL.tracks.length) srActivate(); const re = find ? srRe(find, ci, true) : null; const bad = !!(find && srRegexOn() && !re); // invalid user regex — flag the field, change nothing const findEl = document.querySelector('.tc-sr-find'); if (findEl) findEl.classList.toggle('tc-sr-bad', bad); if (bad) { srRememberLast(find, replace); return; } const repl = srRepl(replace); let changed = 0; MODEL.tracks.forEach((t, i) => { // #375: if the title changed since our last replace (a manual edit, Guess Case, etc.), re-base the // snapshot to it — otherwise we'd replace from the stale original and clobber the manual change. if (t._srLastResult == null || t.title !== t._srLastResult) _srSnap[i] = t.title; // #409: re-base to the CURRENT title on a manual edit — incl. before the first replace (was stale until re-activation) const base = _srSnap[i] != null ? _srSnap[i] : t.title; const nt = re ? base.replace(re, repl) : base; if (nt !== base) changed++; if (nt !== t.title) { setTitle(t, nt); t.title = nt; t.guessTitle = guessTitleStr(t); } t._srLastResult = t.title; // remember what we left it as, to detect the next manual edit t._srFlash = !!(find && nt !== base); }); rerender(); toast(changed ? `Search and Replace: ${changed} title${changed !== 1 ? 's' : ''} replaced` : ''); srRememberLast(find, replace); } // #409: apply every member pattern of a chain, in order, CUMULATIVELY (each pattern sees the // previous one's output) — recomputed from the snapshot like srLive, so it stays non-compounding // across re-applies. Members use their own per-pattern `re` flag. // `auto`: the once-per-session default run (#410) — it fires while the Tracklist may not even be // visible, so a run that changed nothing only logs instead of toasting "no matches" over the page. function srApplyChain(chain, auto) { if (!MODEL) return; if (!_srSnap || _srSnap.length !== MODEL.tracks.length) srActivate(); const pats = srChainPatterns(chain); let changed = 0; MODEL.tracks.forEach((t, i) => { if (t._srLastResult == null || t.title !== t._srLastResult) _srSnap[i] = t.title; // #409: re-base to the CURRENT title on a manual edit — incl. before the first replace (was stale until re-activation) const base = _srSnap[i] != null ? _srSnap[i] : t.title; let text = base; for (const p of pats) { const re = p.find ? srReFor(p.find, p.re, true, true) : null; if (re) text = text.replace(re, srReplFor(p.replace, p.re)); } if (text !== base) changed++; if (text !== t.title) { setTitle(t, text); t.title = text; t.guessTitle = guessTitleStr(t); } t._srLastResult = t.title; t._srFlash = !!(pats.length && text !== base); }); rerender(); Log.cat('Tool').debug('S&R chain', chain.name, auto ? '(auto default)' : '', '→', changed, 'title(s) replaced'); if (changed) toast(`Search and Replace: ${changed} title${changed !== 1 ? 's' : ''} replaced (${chain.name})`); else if (!auto) toast(`Search and Replace: ${chain.name}: no matches`); } // create an empty chain; returns false on a bad/duplicate name function srAddChain(name) { name = (name || '').trim(); if (!name) return false; const list = srTemplates(); if (list.some(t => t.name === name)) return false; // no name clash with a pattern or chain list.push({ name, members: [] }); saveSettings(); return true; } // toggle a (non-chain) template's membership in a chain function srChainToggle(chainName, itemName) { const c = srTemplates().find(t => srIsChain(t) && t.name === chainName); if (!c) return; c.members = c.members || []; const i = c.members.indexOf(itemName); if (i >= 0) c.members.splice(i, 1); else c.members.push(itemName); saveSettings(); } // #410: the "default" S&R — one pattern OR chain marked default (name in SETTINGS.srDefault), // applied automatically the first time the Tracklist opens this session. Setting a new default // toggles the old one off (only one). const srDefaultName = () => SETTINGS.srDefault || ''; function srSetDefault(name) { SETTINGS.srDefault = (SETTINGS.srDefault === name) ? '' : name; saveSettings(); } // Apply a default item (called on Tracklist open + when the user clicks it). A chain runs all its // members + enters chain mode; a pattern fills the fields, sets RE to its own flag, and replaces. function srApplyDefaultItem(item, auto) { if (srIsChain(item)) { srApplyChain(item, auto); srShowChain(item.name); return; } SETTINGS.srRegex = !!item.re; saveSettings(); const f = document.querySelector('.tc-sr-find'), r = document.querySelector('.tc-sr-rep'), reBtn = document.querySelector('.tc-sr-re'); if (f) { f.value = item.find; f.placeholder = srRegexOn() ? 'search (regex)' : 'search'; } if (r) r.value = item.replace; if (reBtn) reBtn.classList.toggle('on', !!item.re); srLive(item.find, item.replace, true); } let _srDefaultAppliedOnce = false; // Apply the marked default once per session, when the Tracklist first renders. function srApplyDefaultOnStart() { if (_srDefaultAppliedOnce) return; const name = srDefaultName(); if (!name) { _srDefaultAppliedOnce = true; return; } const item = srTemplates().find(t => t.name === name); if (!item) { _srDefaultAppliedOnce = true; return; } // Don't consume the one-shot until the tracklist is actually loaded — a first render with an // empty/lazy model would "apply" to nothing and then never retry. Try again on the next render. if (!MODEL || !MODEL.tracks || !MODEL.tracks.length) return; _srDefaultAppliedOnce = true; // Force a FRESH snapshot of the real titles: the S&R tool is on the bar by default, so its // buildToolParams already called srActivate() BEFORE the model existed — leaving a stale/empty // snapshot that srApplyChain/srLive would then reuse (→ "no match", or a no-op for a pattern). srActivate(); try { srApplyDefaultItem(item, true); Log.cat('Tool').info('Applied default S&R:', name); } catch (e) { Log.cat('Tool').warn('default S&R failed', e.message); } } // #409: chain "mode" — a chain isn't editable (it's several patterns), so when one is applied the // search/replace inputs are swapped for a read-only chip showing the chain name (✕ exits back to S&R). let _srChain = null; function srShowChain(name) { _srChain = name; const box = document.querySelector('.tc-sro'); if (!box) return; box.classList.add('tc-sro-chain'); let chip = box.querySelector('.tc-sr-chainchip'); if (!chip) { chip = document.createElement('span'); chip.className = 'tc-sr-chainchip'; const lbl = document.createElement('span'); lbl.className = 'tc-sr-chainlbl'; const x = document.createElement('button'); x.type = 'button'; x.className = 'tc-sr-chainx'; x.textContent = '✕'; x.title = 'Exit chain — back to search / replace'; x.onclick = srExitChain; chip.append(lbl, x); box.insertBefore(chip, box.firstChild); } chip.querySelector('.tc-sr-chainlbl').textContent = '⛓ ' + name; chip.title = 'S&R chain "' + name + '" (read-only — several patterns)'; } function srExitChain() { _srChain = null; const box = document.querySelector('.tc-sro'); if (!box) return; box.classList.remove('tc-sro-chain'); const f = box.querySelector('.tc-sr-find'), r = box.querySelector('.tc-sr-rep'); if (f) f.value = ''; if (r) r.value = ''; srActivate(); if (MODEL) MODEL.tracks.forEach(t => { delete t._srFlash; }); rerender(); if (f) f.focus(); } // #152 — named search/replace templates, persisted in settings. "_Last" is a special auto-kept entry // (the most recent pattern) that sorts first because "_" precedes letters. function srTemplates() { if (!Array.isArray(SETTINGS.srTemplates)) SETTINGS.srTemplates = []; return SETTINGS.srTemplates; } // #375: seed two handy defaults on first run only — never re-add once seeded, so we don't fight an // editor who cleared them or already has their own set. function srSeedTemplates() { // migrate the legacy "_Last" entry out of Saved (history lives in srHistory now) if (srTemplates().some(t => t.name === '_Last')) SETTINGS.srTemplates = srTemplates().filter(t => t.name !== '_Last'); // v1 — seed the two quote patterns (once) if ((SETTINGS.srSeedV || 0) < 1) { // seed only when there are no USER (non-"_") templates — an existing `_Last` no longer blocks it if (!srTemplates().some(t => t.name && t.name[0] !== '_')) { srTemplates().push({ name: 'Quotes', find: '"(.+?)"', replace: '“$1”', re: true }); srTemplates().push({ name: 'Single quote', find: "'", replace: '’', re: false }); } SETTINGS.srSeedV = 1; } // v2 (#409) — seed the "All Quotes" chain that runs both quote patterns, once, and only if // both members exist and no chain named "All Quotes" is already present. if ((SETTINGS.srSeedV || 0) < 2) { const names = new Set(srTemplates().map(t => t.name)); if (names.has('Quotes') && names.has('Single quote') && !srTemplates().some(t => srIsChain(t) && t.name === 'All Quotes')) { srTemplates().push({ name: 'All Quotes', members: ['Quotes', 'Single quote'] }); } SETTINGS.srSeedV = 2; } saveSettings(); } let _srLastTimer = null; // #375: History — the 5 most-recent (distinct) patterns, most-recent first. Separate from the named // Saved templates. (Supersedes the old single "_Last" entry that used to live inside srTemplates.) function srHistoryList() { if (!Array.isArray(SETTINGS.srHistory)) SETTINGS.srHistory = []; return SETTINGS.srHistory; } function srRememberLast(find, replace) { if (!find) return; const re = srRegexOn(); // #375 debounce the ENTRY itself — record only after typing settles (~1.4s), so one search isn't // logged character-by-character (h, he, hel, …). The latest pattern wins. clearTimeout(_srLastTimer); _srLastTimer = setTimeout(() => { const hist = srHistoryList(); const i = hist.findIndex(h => h.find === find && h.replace === replace && !!h.re === re); if (i === 0) return; // already the most-recent if (i > 0) hist.splice(i, 1); // seen before → move to front hist.unshift({ find, replace, re }); if (hist.length > 5) hist.length = 5; saveSettings(); }, 1400); } function srSaveTemplate(name, find, replace) { name = (name || '').trim(); if (!name || !find) return false; const list = srTemplates(); const ex = list.find(t => t.name === name); const ent = { name, find, replace, re: srRegexOn() }; if (ex) Object.assign(ex, ent); else list.push(ent); saveSettings(); return true; } function srRemoveTemplate(name) { SETTINGS.srTemplates = srTemplates().filter(t => t.name !== name); saveSettings(); } // #409: rename a pattern or chain in place. Keeps chain memberships pointing at the renamed // pattern. Rejects an empty name or a clash with an existing pattern/chain. function srRenameTemplate(oldName, newName) { newName = (newName || '').trim(); if (!newName) return false; if (newName === oldName) return true; const list = srTemplates(); if (list.some(t => t.name === newName)) return false; const t = list.find(x => x.name === oldName); if (!t) return false; t.name = newName; list.filter(srIsChain).forEach(c => { if (Array.isArray(c.members)) c.members = c.members.map(m => (m === oldName ? newName : m)); }); saveSettings(); return true; } // the Templates popup — sorted list (「_Last」 first), click a row to load+apply, ✕ to remove, // and a "new template" section (shown only when the search field is non-empty). #152 let _srPop = null, _srPopOff = null; let _srPopKey = null; function closeSrTemplates() { if (_srPop) { _srPop.remove(); _srPop = null; } if (_srPopOff) { document.removeEventListener('mousedown', _srPopOff, true); _srPopOff = null; } if (_srPopKey) { document.removeEventListener('keydown', _srPopKey, true); _srPopKey = null; } } // #375 the Saved & History popup — Saved (named) templates on top with an inline "Save current", // then a History section of the 5 most-recent patterns. Opens off the search field (caret or ↓); // ↑/↓ move the selection, Enter loads. Left-aligned to the search field. function openSrTemplates(anchor, findEl, repEl, reBtn) { if (_srPop) { closeSrTemplates(); return; } const pop = document.createElement('div'); pop.className = 'tc-srtpl'; _srPop = pop; let navRows = [], sel = -1; const highlight = () => navRows.forEach((r, i) => { r.classList.toggle('tc-srtpl-sel', i === sel); if (i === sel) r.scrollIntoView({ block: 'nearest' }); }); const applyEntry = (find, replace, re) => { findEl.value = find; repEl.value = replace; SETTINGS.srRegex = !!re; saveSettings(); if (reBtn) { reBtn.classList.toggle('on', !!re); findEl.placeholder = srRegexOn() ? 'search (regex)' : 'search'; } srLive(findEl.value, repEl.value, true); closeSrTemplates(); findEl.focus(); }; // #409 redesign: Import / Export — a JSON view of the saved patterns + chains + the default // marker (NOT history). Export = read the textarea; Import = paste + ✓ Import (replaces the set). const showImportExport = () => { pop.innerHTML = ''; navRows = []; sel = -1; const hd = document.createElement('div'); hd.className = 'tc-srtpl-hd'; const back = document.createElement('button'); back.type = 'button'; back.className = 'tc-srtpl-savebtn'; back.textContent = '‹ Back'; back.onclick = () => render(); const ttl = document.createElement('span'); ttl.className = 'tc-srtpl-hdt'; ttl.textContent = 'Import / Export'; const imp = document.createElement('button'); imp.type = 'button'; imp.className = 'tc-srtpl-saveok tc-srtpl-impok'; imp.textContent = '✓ Import'; imp.title = 'Replace your saved patterns & chains with the JSON below'; hd.append(back, ttl, imp); pop.appendChild(hd); const ta = document.createElement('textarea'); ta.className = 'tc-srtpl-io'; ta.spellcheck = false; ta.value = JSON.stringify({ templates: srTemplates().filter(t => t.name && t.name[0] !== '_'), default: srDefaultName() }, null, 2); ta.onclick = e => e.stopPropagation(); ta.onkeydown = e => e.stopPropagation(); pop.appendChild(ta); imp.onclick = () => { let data; try { data = JSON.parse(ta.value); } catch (e) { toast('Invalid JSON: ' + e.message); return; } const raw = Array.isArray(data) ? data : (data.templates || []); if (!Array.isArray(raw)) { toast('JSON needs a "templates" array'); return; } const clean = raw.filter(t => t && t.name).map(t => Array.isArray(t.members) ? { name: String(t.name), members: t.members.map(String) } : { name: String(t.name), find: String(t.find || ''), replace: String(t.replace || ''), re: !!t.re }); SETTINGS.srTemplates = clean; const def = !Array.isArray(data) ? data.default : ''; SETTINGS.srDefault = (def && clean.some(t => t.name === def)) ? String(def) : ''; saveSettings(); toast('Imported ' + clean.length + ' pattern' + (clean.length === 1 ? '' : 's') + ' / chain(s)'); render(); }; }; const mkRow = (cells, onClick, extras) => { const row = document.createElement('div'); row.className = 'tc-srtpl-row' + (extras && extras.isDefault ? ' tc-srtpl-defrow' : ''); cells.forEach(c => { const s = document.createElement('span'); s.className = c.cls; s.textContent = c.txt; s.title = c.txt; row.appendChild(s); }); const rec = document.createElement('span'); rec.className = 'tc-srtpl-re'; rec.textContent = extras && extras.re ? 'RE' : ''; row.appendChild(rec); const tail = document.createElement('span'); tail.className = 'tc-srtpl-tail'; // #410: mark this pattern/chain as the default (runs on Tracklist open). Filled ◉ + always // visible when it IS the default; a faint ○ on hover otherwise. if (extras && extras.onDefault) { const d = document.createElement('button'); d.type = 'button'; d.className = 'tc-srtpl-def' + (extras.isDefault ? ' on' : ''); d.textContent = extras.isDefault ? '◉' : '○'; d.title = extras.isDefault ? 'Default — runs on Tracklist open · click to unset' : 'Set as default — runs on Tracklist open'; d.onclick = e => { e.stopPropagation(); extras.onDefault(); }; tail.appendChild(d); } // #409: hover action — add this (non-chain) pattern to a chain if (extras && extras.onAddChain) { const c = document.createElement('button'); c.type = 'button'; c.className = 'tc-srtpl-chainadd'; c.textContent = '⛓'; c.title = 'Add / remove this pattern in a chain'; c.onclick = e => { e.stopPropagation(); extras.onAddChain(c); }; tail.appendChild(c); } // #409: hover action — rename this pattern / chain in place if (extras && extras.onRename) { const ed = document.createElement('button'); ed.type = 'button'; ed.className = 'tc-srtpl-rename'; ed.textContent = '✎'; ed.title = 'Rename'; ed.onclick = e => { e.stopPropagation(); startRename(row, extras.renameValue, extras.onRename); }; tail.appendChild(ed); } if (extras && extras.onRemove) { const x = document.createElement('button'); x.type = 'button'; x.className = 'tc-srtpl-x'; x.textContent = '✕'; x.title = 'Remove'; x.onclick = e => { e.stopPropagation(); extras.onRemove(); }; tail.appendChild(x); } row.appendChild(tail); row.onclick = onClick; row.onmousemove = () => { sel = navRows.indexOf(row); highlight(); }; navRows.push(row); return row; }; // #409: inline rename — turn the row's name cell into a text field; Enter/blur commits. const startRename = (row, curName, cb) => { const nmCell = row.querySelector('.tc-srtpl-nm'); if (!nmCell) return; const prev = nmCell.textContent; const inp = noPw(document.createElement('input')); inp.type = 'text'; inp.className = 'tc-srtpl-renameinp'; inp.value = curName || ''; nmCell.textContent = ''; nmCell.appendChild(inp); inp.focus(); inp.select(); let done = false; const finish = (commit) => { if (done) return; done = true; if (commit && cb(inp.value)) { render(); return; } if (commit) toast('Name is empty or already used'); nmCell.textContent = prev; }; inp.onclick = e => e.stopPropagation(); inp.onkeydown = e => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); finish(true); } else if (e.key === 'Escape') { e.preventDefault(); finish(false); } }; inp.onblur = () => finish(true); }; // #409: a small inline picker (child of the popup, so it doesn't trigger the outside-close) // listing every chain with a ✓/○ membership toggle for the given pattern. const openChainPicker = (btn, itemName) => { const ex = pop.querySelector('.tc-srtpl-cpick'); if (ex) { ex.remove(); if (ex._for === itemName) return; } const chains = srTemplates().filter(srIsChain).sort((a, b) => a.name.localeCompare(b.name)); const menu = document.createElement('div'); menu.className = 'tc-srtpl-cpick'; menu._for = itemName; if (!chains.length) { const e = document.createElement('div'); e.className = 'tc-srtpl-cpick-empty'; e.textContent = 'No chains yet — use + Add chain'; menu.appendChild(e); } else chains.forEach(c => { const isM = (c.members || []).includes(itemName); const row = document.createElement('div'); row.className = 'tc-srtpl-cpick-row'; row.textContent = (isM ? '✓ ' : '○ ') + c.name; row.onclick = e => { e.stopPropagation(); srChainToggle(c.name, itemName); render(); }; menu.appendChild(row); }); pop.appendChild(menu); const br = btn.getBoundingClientRect(), pr = pop.getBoundingClientRect(); menu.style.left = Math.max(4, br.right - pr.left - menu.offsetWidth) + 'px'; menu.style.top = (br.bottom - pr.top + 2) + 'px'; }; const render = () => { pop.innerHTML = ''; navRows = []; sel = -1; // header: "Saved" + inline "Save current" that unrolls a name field const hd = document.createElement('div'); hd.className = 'tc-srtpl-hd'; // #409 redesign: no "Patterns" label; action buttons on the LEFT, Import/Export on the RIGHT. const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'tc-srtpl-savebtn'; saveBtn.textContent = '+ Save current'; const wrap = document.createElement('span'); wrap.className = 'tc-srtpl-savewrap'; wrap.style.display = 'none'; const nm = noPw(document.createElement('input')); nm.type = 'text'; nm.className = 'tc-srtpl-name'; nm.placeholder = 'name this pattern'; const ok = document.createElement('button'); ok.type = 'button'; ok.className = 'tc-srtpl-saveok'; ok.textContent = '✓'; ok.title = 'Save'; // #409: "Add chain" button next to "Save current", each unrolling its own name field const chainBtn = document.createElement('button'); chainBtn.type = 'button'; chainBtn.className = 'tc-srtpl-savebtn tc-srtpl-chainbtn'; chainBtn.textContent = '+ Add chain'; chainBtn.title = 'Create a chain that runs several saved patterns in one click'; const cwrap = document.createElement('span'); cwrap.className = 'tc-srtpl-savewrap'; cwrap.style.display = 'none'; const cnm = noPw(document.createElement('input')); cnm.type = 'text'; cnm.className = 'tc-srtpl-name'; cnm.placeholder = 'name this chain'; const cok = document.createElement('button'); cok.type = 'button'; cok.className = 'tc-srtpl-saveok'; cok.textContent = '✓'; cok.title = 'Create chain'; const resetHd = () => { saveBtn.style.display = ''; chainBtn.style.display = ''; wrap.style.display = 'none'; cwrap.style.display = 'none'; }; const doSave = () => { if (srSaveTemplate(nm.value, findEl.value, repEl.value)) render(); }; const doAddChain = () => { if (srAddChain(cnm.value)) render(); else toast('Name is empty or already used'); }; ok.onclick = doSave; cok.onclick = doAddChain; nm.onkeydown = e => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); doSave(); } else if (e.key === 'Escape') { e.preventDefault(); resetHd(); } }; cnm.onkeydown = e => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); doAddChain(); } else if (e.key === 'Escape') { e.preventDefault(); resetHd(); } }; saveBtn.onclick = () => { if (!findEl.value.trim()) { toast('Type a search first, then save it'); return; } saveBtn.style.display = 'none'; chainBtn.style.display = 'none'; wrap.style.display = ''; nm.value = ''; setTimeout(() => nm.focus(), 0); }; chainBtn.onclick = () => { saveBtn.style.display = 'none'; chainBtn.style.display = 'none'; cwrap.style.display = ''; cnm.value = ''; setTimeout(() => cnm.focus(), 0); }; // #409 redesign: Import/Export button, pushed to the right edge of the header. const ioBtn = document.createElement('button'); ioBtn.type = 'button'; ioBtn.className = 'tc-srtpl-iobtn'; ioBtn.textContent = 'Import/Export'; ioBtn.title = 'Import or export your saved patterns & chains as JSON (history is not included)'; ioBtn.onclick = () => showImportExport(); wrap.append(nm, ok); cwrap.append(cnm, cok); hd.append(saveBtn, chainBtn, wrap, cwrap, ioBtn); pop.appendChild(hd); // #409: Chains first — click to run all their member patterns in order; ✕ removes the chain const chains = srTemplates().filter(srIsChain).sort((a, b) => a.name.localeCompare(b.name)); if (chains.length) { const sec = document.createElement('div'); sec.className = 'tc-srtpl-sec'; sec.textContent = 'Chains'; pop.appendChild(sec); chains.forEach(c => { const mem = (c.members || []); const summary = mem.length ? mem.join(' → ') : '(empty — add patterns with ⛓)'; pop.appendChild(mkRow( [{ cls: 'tc-srtpl-nm tc-srtpl-chnm', txt: '⛓ ' + c.name }, { cls: 'tc-srtpl-f tc-srtpl-chm', txt: summary }, { cls: 'tc-srtpl-r', txt: '' }], () => { if (!mem.length) { toast('Empty chain — add patterns to it first (⛓ on a saved row)'); return; } srApplyChain(c); srShowChain(c.name); closeSrTemplates(); }, { isDefault: srDefaultName() === c.name, onDefault: () => { srSetDefault(c.name); render(); }, onRename: (v) => srRenameTemplate(c.name, v), renameValue: c.name, onRemove: () => { srRemoveTemplate(c.name); render(); } })); }); } // saved (named, non-chain) templates — internal "_"-prefixed names never show here const saved = srTemplates().filter(t => !srIsChain(t) && t.name && t.name[0] !== '_').sort((a, b) => a.name.localeCompare(b.name)); if (saved.length) { const sec = document.createElement('div'); sec.className = 'tc-srtpl-sec'; sec.textContent = 'Saved'; pop.appendChild(sec); saved.forEach(t => pop.appendChild(mkRow( [{ cls: 'tc-srtpl-nm', txt: t.name }, { cls: 'tc-srtpl-f', txt: t.find }, { cls: 'tc-srtpl-r', txt: t.replace }], () => applyEntry(t.find, t.replace, t.re), { re: t.re, isDefault: srDefaultName() === t.name, onDefault: () => { srSetDefault(t.name); render(); }, onAddChain: (btn) => openChainPicker(btn, t.name), onRename: (v) => srRenameTemplate(t.name, v), renameValue: t.name, onRemove: () => { srRemoveTemplate(t.name); render(); } }))); } else if (!chains.length) { const e = document.createElement('div'); e.className = 'tc-srtpl-empty'; e.textContent = 'No saved patterns yet — use + Save current.'; pop.appendChild(e); } // history — the 5 most-recent, COLLAPSED by default (#409): the header is a toggle; rows render // only when expanded (so they stay out of keyboard nav while collapsed). const hist = srHistoryList(); if (hist.length) { const open = SETTINGS.srHistoryOpen === true; const div = document.createElement('div'); div.className = 'tc-srtpl-sec tc-srtpl-sectog'; div.style.cursor = 'pointer'; div.innerHTML = `<span class="tc-srtpl-caret">${open ? '▾' : '▸'}</span> History`; div.onclick = () => { SETTINGS.srHistoryOpen = !open; saveSettings(); render(); }; pop.appendChild(div); if (open) hist.slice(0, 5).forEach(h => pop.appendChild(mkRow( [{ cls: 'tc-srtpl-nm tc-srtpl-hnm', txt: '' }, { cls: 'tc-srtpl-f', txt: h.find }, { cls: 'tc-srtpl-r', txt: h.replace }], () => applyEntry(h.find, h.replace, h.re), { re: h.re }))); } }; render(); document.body.appendChild(pop); const r = anchor.getBoundingClientRect(); pop.style.left = Math.max(8, Math.min(r.left, window.innerWidth - pop.offsetWidth - 8)) + 'px'; pop.style.top = (r.bottom + 4) + 'px'; _srPopOff = e => { if (!pop.contains(e.target) && e.target !== anchor) closeSrTemplates(); }; setTimeout(() => document.addEventListener('mousedown', _srPopOff, true), 0); _srPopKey = e => { // ↑/↓ navigate, Enter loads, Esc closes — unless typing in the name field if (document.activeElement && document.activeElement.className === 'tc-srtpl-name') return; if (e.key === 'ArrowDown') { e.preventDefault(); if (navRows.length) { sel = Math.min(navRows.length - 1, sel + 1); highlight(); } } else if (e.key === 'ArrowUp') { e.preventDefault(); if (navRows.length) { sel = Math.max(0, sel - 1); highlight(); } } else if (e.key === 'Enter') { if (sel >= 0 && navRows[sel]) { e.preventDefault(); navRows[sel].click(); } } else if (e.key === 'Escape') { e.preventDefault(); closeSrTemplates(); findEl.focus(); } }; document.addEventListener('keydown', _srPopKey, true); } const BAR = `<div class="tc-tools"><span class="tc-toolslabel" data-act="toolsmenu" title="more tools & customize">Tools ▾</span><span class="tc-toolbtns"></span></div>` + `<span class="tc-discstat"></span><span class="tc-globalstat"></span><span class="tc-am-lbl"><b>Change:</b> ${amRadios()}</span><span class="tc-tbsep"></span><button class="tc-btn primary" data-act="match" title="search MusicBrainz for the unmatched artists">⚡ Match</button>` + `<button class="tc-btn tc-caret" data-act="revertmenu" title="revert / clear all">▾</button>`; // gear moved to the Apollo launcher /* ── floating window (kept for tests; the in-page table is the real UI) ── */ function openPanel() { style(); const ex = document.getElementById('tc-panel'); if (ex) ex.remove(); const l = document.getElementById('tc-launch'); if (l) { l.remove(); mbRestackCorner('br'); } const p = document.createElement('div'); p.id = 'tc-panel'; p.innerHTML = `<div id="tc-hdr">${ICON}<b>Apollo Editor</b><span class="sp"></span>${BAR}<button class="tc-icon" data-act="close" title="close">✕</button></div> <div id="tc-body"></div>`; document.body.appendChild(p); const tbody = mountTable(p.querySelector('#tc-body')); ACTIVE = { mode: 'float', tbody, statusEl: p.querySelector('.tc-hstatus') }; const hdr = p.querySelector('#tc-hdr'); hdr.onmousedown = e => { if (e.target.closest('button')) return; const r = p.getBoundingClientRect(); const ox = e.clientX - r.left, oy = e.clientY - r.top; p.style.right = 'auto'; const mm = ev => { p.style.left = Math.max(0, ev.clientX - ox) + 'px'; p.style.top = Math.max(0, ev.clientY - oy) + 'px'; }; const mu = () => { document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu); }; document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu); }; bindActions(p); initTools(); wireApplyMode(p); loadAndRender((d, n) => updateStatus(`matching ${d}/${n}…`)); } /* ── in-page replacement (the only mode) ── */ let _showOriginal = false; function nativeTrackTables(root) { return [...(root || document).querySelectorAll('table')].filter(t => t.querySelector('tr.track')); } // the native tracklist = track tables + the #tracklist-tools row + the Guess-case fieldset; hide/show // together (the format header is lifted out, not hidden). MB's medium WARNINGS are NOT hidden — every // one (capitalization, Digital-Media/packaging, …) stays visible above the Apollo table. function nativeBits() { // SCOPE to the Tracklist tab only — the Recordings tab has its own track table (recording associations) // that we must NOT hide (issue #114). every medium has its own tools row (MB reuses id "tracklist-tools"). const tl = document.getElementById('tracklist'); if (!tl) return []; return [...nativeTrackTables(tl), ...tl.querySelectorAll('table.medium, [id="tracklist-tools"], fieldset.guesscase, .guesscase')]; } function setNativeHidden(hidden) { nativeBits().forEach(el => { el.style.display = hidden ? 'none' : ''; }); // keep ALL real medium warnings visible even in Apollo (force them back on in case a prior version or MB left one hidden) document.querySelectorAll('fieldset.advanced-medium .warning').forEach(w => { w.style.display = ''; }); } // mount one Apollo section (its own table header + Add footer) per medium, placed right before that // medium's native track table — so MB's own format header stays naturally above it. Reconciled on // every render so adding/removing a medium just works. Native collapse toggle hides our section too. function mountMediums() { document.querySelectorAll('#tc-mirror-wrap-sec, .tc-medsec').forEach(s => s.remove()); ACTIVE.sections = []; const fsList = document.querySelectorAll('fieldset.advanced-medium'); mediums().forEach((med, mi) => { const fs = fsList[mi]; if (!fs) return; const trackTbl = nativeTrackTables().find(t => fs.contains(t)) || fs.querySelector('table.medium'); const sec = document.createElement('div'); sec.className = 'tc-medsec'; sec.dataset.mi = mi; const tbody = mountTable(sec, mi); if (trackTbl && trackTbl.parentElement) trackTbl.parentElement.insertBefore(sec, trackTbl); else fs.appendChild(sec); ACTIVE.sections.push({ mi, tbody, sec }); // native ▼ collapse hides our section too — subscribe once per medium; the fresh section reads current state if (med.collapsed) { if (med.collapsed.subscribe && !med._tcColSub) { med._tcColSub = true; med.collapsed.subscribe(() => { const s = document.querySelector(`.tc-medsec[data-mi="${mi}"]`); if (s) s.style.display = u(med.collapsed) ? 'none' : ''; }); } sec.style.display = u(med.collapsed) ? 'none' : ''; } // #185: right-click the native expand arrow to expand ALL collapsed media at once. // Tooltip says so; the contextmenu listener is guarded so it's added once per button. const expBtn = fs.querySelector('button.icon.expand-medium'); if (expBtn) { expBtn.title = 'Left click: expand this medium · Right click: expand all media'; if (!expBtn._tcExpAll) { expBtn._tcExpAll = true; expBtn.addEventListener('contextmenu', e => { e.preventDefault(); expandAllTracklistMediums(); }); } } }); anchorBar(); // #247 a re-render (e.g. Add medium) can have shoved the toolbar to the footer — re-pin it on top } // #185: expand every collapsed medium on the Tracklist side — click each native // per-medium expand arrow (it un-collapses + loads tracks just like a single click). function expandAllTracklistMediums() { const btns = document.querySelectorAll('fieldset.advanced-medium button.icon.expand-medium'); if (!btns.length) return; Log.cat('System').info('expand all media (tracklist):', btns.length, 'collapsed'); btns.forEach(b => { try { b.click(); } catch (e) {} }); } // tidy the format header to a minimal look — but ONLY once a format is chosen. With no format the // full native header stays (Format: label, real combo, I don't know, help, error) so the user is // still prompted to pick one. Move-up/down/remove buttons stay visible either way. function setFmtTidy(tbl, on) { const fmt = tbl.querySelector('[id^="medium-format"]'); if (!fmt) return; fmt.classList.toggle('tc-fmt-flat', on); const lbl = tbl.querySelector('td.format > label[for^="medium-format"]'); if (lbl) lbl.style.display = on ? 'none' : ''; const help = tbl.querySelector('td.format a'); if (help) { help.style.display = on ? 'none' : ''; [help.previousSibling, help.nextSibling].forEach(n => { if (!n || n.nodeType !== 3) return; if (on) { if (!('_tcv' in n) && /[()]/.test(n.nodeValue)) n._tcv = n.nodeValue; if ('_tcv' in n) n.nodeValue = ''; } else if ('_tcv' in n) { n.nodeValue = n._tcv; delete n._tcv; } }); // the "( )" around (help) } const idk = tbl.querySelector('td.format input[type=checkbox]'); const idkLbl = idk ? idk.closest('label') : null; if (idkLbl) idkLbl.style.display = on ? 'none' : ''; } function tidyFmt(tbl) { const fmt = tbl.querySelector('[id^="medium-format"]'); if (!fmt) return; const apply = () => setFmtTidy(tbl, fmt.value !== ''); // minimise only when a format is selected if (!fmt._tcApply) { fmt._tcApply = apply; fmt.addEventListener('change', apply); } apply(); } function tidyMediums() { document.querySelectorAll('table.advanced-format').forEach(tidyFmt); } function untidyMediums() { document.querySelectorAll('table.advanced-format').forEach(t => setFmtTidy(t, false)); } function syncNative() { setNativeHidden(!_showOriginal); if (_showOriginal) untidyMediums(); else tidyMediums(); } // watch the live tracklist so Track parser (or any native structural change) refreshes our table let _subscribed = false, _syncTimer = null, _syncDeferred = false; /* #580: a resync rebuilds the whole mirrored tracklist, which replaces the very <input> the caret is in. Typing one character into a matched artist field was enough to trigger it — the edit un-links the artist, that writes the credit back to Knockout, and MB echoes a second notification of its own AFTER our _selfEdit guard has been dropped, so the watcher read it as an external change. The rebuild then dropped the keystroke, took the focus, and re-queued the slot for auto-match, which completed the name straight back to the artist you were editing away from — so + (create) never got a chance to appear. Fixing the echo alone would not be enough: any background resync lands the same blow. So the resync waits for the field to be left, which is also what was asked for — "auto match should run after focus is lost", keeping the split-artist flow intact. */ function scheduleSync() { clearTimeout(_syncTimer); _syncTimer = setTimeout(runSync, 400); } function runSync() { // Logged on the FIRST deferral of an episode, not on every re-check. The // match branch below re-arms twice a second, and logging each tick buried a // whole session's log under itself — "spamming now like crazy" (majkinetor). if (isEditingNow()) { if (!_syncDeferred) Log.cat('System').debug('tracklist resync deferred — a field is being edited'); _syncDeferred = true; return; } // #580 /* #575 (majkinetor: "'auto-match off - click match' shows while match is running. It switches between 'matched N/M' and it periodically"). Every committed track writes the credit back to Knockout, MusicBrainz echoes a notification of its own after our _selfEdit guard has dropped (the #580 mechanism), and the watcher read each of those as an external change — so a running pass was scheduling a full loadAndRender per matched track. That is what flipped the status line, and it was re-entering the whole load path dozens of times a run for nothing: a match is OUR write, and the model is already up to date when it lands. Wait for the pass. */ // #616: a recording auto-match that is still only WAITING for media to load has // written nothing — holding the resync for it is what kept a just-expanded // medium's tracks out of the table for the rest of that wait. if (_matching || (_autoMatching && !_autoMatchWaiting)) { if (!_syncDeferred) Log.cat('System').debug('tracklist resync deferred until the match pass finishes'); // #575 _syncDeferred = true; clearTimeout(_syncTimer); _syncTimer = setTimeout(runSync, 500); return; } if (_syncDeferred) Log.cat('System').debug('match pass finished — running the deferred tracklist resync'); // #575 _syncDeferred = false; if (document.getElementById('tc-mirror-wrap')) loadAndRender(); } // Released when focus actually leaves our fields. Deferred by a tick because // focusout fires while activeElement is still in flight, and tabbing from one // of our fields to the next must not count as leaving. document.addEventListener('focusout', () => { if (!_syncDeferred) return; setTimeout(() => { if (_syncDeferred && !isEditingNow()) { Log.cat('System').debug('field left — running the deferred tracklist resync'); runSync(); } }, 0); // #580 }, true); // #472: med.tracks.subscribe below only fires on STRUCTURAL changes to that // array (added/removed/reordered) — a track edited IN PLACE (a bookmarklet // calling tr.name(newName) or tr.artistCredit({...}) directly, or driving // MB's own inputs which round-trip to those same setters) never touches the // array itself, so it went unnoticed until a full off/on toggle forced a // reload. Subscribe each track's own name/artistCredit observables too. const subTrack = tr => { if (!tr || tr._tcTrackSub) return; if (tr.name && tr.name.subscribe) tr.name.subscribe(() => { if (!_selfEdit) scheduleSync(); }); if (tr.artistCredit && tr.artistCredit.subscribe) tr.artistCredit.subscribe(() => { if (!_selfEdit) scheduleSync(); }); tr._tcTrackSub = true; }; function subscribeTracks() { const rel = release(); if (!rel) return; const subMed = med => { if (!med || med._tcSub) return; if (med.tracks && med.tracks.subscribe) { med.tracks.subscribe(() => { if (!_selfEdit) { (u(med.tracks) || []).forEach(subTrack); scheduleSync(); } }); med._tcSub = true; } (u(med.tracks) || []).forEach(subTrack); }; try { (u(rel.mediums) || []).forEach(subMed); // watch the mediums list itself so adding/removing a medium re-renders + re-hides the new native bits if (!_subscribed && rel.mediums && rel.mediums.subscribe) { rel.mediums.subscribe(() => { if (!_selfEdit) { (u(rel.mediums) || []).forEach(subMed); scheduleSync(); } }); } _subscribed = true; Log.cat('System').info('watching tracklist + mediums for external changes'); } catch (e) { Log.cat('System').warn('subscribe failed', e.message); } } // Keep the global toolbar pinned at the TOP of the tracklist (right before the // first medium). A native knockout re-render — notably clicking "Add medium" — // re-orders the tracklist and pushes our toolbar down to the footer, so re-anchor // it on every render and whenever the mirror is (re)shown. #247 function anchorBar() { const wrap = document.getElementById('tc-mirror-wrap'); if (!wrap) return; const firstFs = document.querySelector('fieldset.advanced-medium'); if (firstFs && firstFs.parentElement && firstFs.previousElementSibling !== wrap) firstFs.parentElement.insertBefore(wrap, firstFs); } async function showMirror() { _apolloUsed = true; document.body.classList.add('tc-tl-on'); // #154: theme the native medium headers to match Apollo while the takeover is on style(); let wrap = document.getElementById('tc-mirror-wrap'); if (wrap) { anchorBar(); syncNative(); return; } // the global toolbar sits once at the very top of the Tracklist panel; per-medium tables mount below wrap = document.createElement('div'); wrap.id = 'tc-mirror-wrap'; const firstFs = document.querySelector('fieldset.advanced-medium'); if (firstFs && firstFs.parentElement) firstFs.parentElement.insertBefore(wrap, firstFs); else (document.querySelector('#tracklist, .tracklist, #content') || document.body).prepend(wrap); wrap.innerHTML = `<div id="tc-bar">${BAR}</div>`; ACTIVE = { mode: 'mirror', sections: [] }; syncNative(); // hide the native tracklist NOW (before the async match/render) so a fresh mount shows no native flash #145 bindActions(wrap); initTools(); wireApplyMode(wrap); subscribeTracks(); await loadAndRender((d, n) => updateStatus(`matching ${d}/${n}…`)); } function hideMirror() { document.body.classList.remove('tc-tl-on'); untidyMediums(); document.querySelectorAll('.tc-medsec').forEach(s => s.remove()); const w = document.getElementById('tc-mirror-wrap'); if (w) w.remove(); setNativeHidden(false); if (ACTIVE.mode === 'mirror') ACTIVE = {}; } /* ── entry points: ONE Original/Apollo toggle, applied to whichever editor tab you're on (#119) ── */ // each managed tab tracks whether its Apollo mirror is shown. It INITIALISES from the persisted // "Replace … on start" setting; the Original/Apollo launcher then toggles it transiently, per tab — // so the launcher always works even when a "replace on start" option is off. #119 // #135: the Apollo/Original switch is GLOBAL — one persisted flag toggles every feature on all tabs and stays // until switched back. Each replace* setting still chooses which features Apollo takes over when it's enabled. function apolloEnabled() { return SETTINGS.apolloEnabled !== false; } function tlWant() { return apolloEnabled() && SETTINGS.replaceTracklist !== false; } function recWant() { return apolloEnabled() && SETTINGS.replaceRecordings !== false; } function riWant() { return apolloEnabled() && SETTINGS.replaceReleaseInfo !== false; } function releaseInfoVisible() { const p = document.getElementById('information'); return !!(p && p.offsetParent !== null); } function apolloOn() { return apolloEnabled(); } // #695: the launcher is an icon, in colour while Apollo is on and monochrome while off; the tooltip says what a click does function relabelLauncher() { const b = document.getElementById('tc-launch'); if (!b) return; const on = apolloEnabled(); mbuCls(b, 'on', on); mbuAttr(b, 'title', (on ? 'Apollo Editor is on · click: show the original editor' : 'Apollo Editor is off · click: turn it on') + ' (every tab, stays until you switch back)\nRight-click: settings'); } // show/hide each visible managed tab's mirror per its want function applyView() { recStyle(); // make sure the recordings CSS (incl. the native-table hide rule) exists up front if (tracklistVisible()) { if (tlWant()) { if (!document.getElementById('tc-mirror-wrap')) showMirror(); } else hideMirror(); } if (recordingsVisible()) { if (recWant()) showRecMirror(); else hideRecMirror(); } if (releaseInfoVisible()) applyReleaseInfo(); applyDuplicates(); // #187 relabelLauncher(); } function ensureLauncher() { if (document.getElementById('tc-launch')) { relabelLauncher(); return; } style(); const b = document.createElement('button'); b.type = 'button'; b.id = 'tc-launch'; b.dataset.mbCorner = 'br'; b.dataset.mbCornerOrder = '10'; const img = document.createElement('img'); img.src = ICON_URL; img.alt = ''; b.appendChild(img); b.onclick = () => { // GLOBAL toggle — flips Apollo for every tab/feature and persists across pages SETTINGS.apolloEnabled = !apolloEnabled(); saveSettings(); applyView(); applyNav(); applyAnnotationPage(); }; b.oncontextmenu = e => { e.preventDefault(); openSettings(b); }; // #695: the one settings entry point (right-click on every corner launcher) document.body.appendChild(b); relabelLauncher(); mbRestackCorner('br'); } function tracklistVisible() { const p = document.getElementById('tracklist'); return !!(p && p.offsetParent !== null); } // the Tracklist tab panel is shown let _tlPrev = false, _recPrev = false, _tlRefreshed = false; // #145: the 500ms watcher means switching tab (or toggling Apollo, then visiting a tab) paints the // stale/native table for up to half a second before the takeover is (re)applied. Catch the tab-nav // clicks — the compact nav routes through these same links — and re-apply on the next frame: after // jQuery-UI has shown the target panel but BEFORE the browser paints, so the switch is flash-free. function wireTabFlush() { const ed = editorEl(); if (!ed || ed._tcTabFlush) return; ed._tcTabFlush = true; ed.addEventListener('click', e => { if (e.target.closest('ul.ui-tabs-nav a')) requestAnimationFrame(() => { applyView(); syncNav(); }); }); } /* ── #703: language and script, detected from the titles when none is given ────────── Rules after Harmony's (harmonizer/language_script.ts): letters only; the main script must hold over 70% of them; no language guess from one or two titles; a language only at 80% confidence or more, from lande, the model Harmony uses (inlined below, 50 languages). On 136 real tracklists in 17 languages it was wrong twice and gave no answer 39 times; franc-min was wrong 36 times, Finnish every time. When lande has no answer, a script that implies its language (kana → Japanese, Hangul → Korean, Greek, Hebrew, Thai, Armenian, Georgian) names one. Apollo owns a field only while it holds the value Apollo put there: a field given by a seed or the user, or changed after Apollo filled it, is never touched again. */ // our script → MusicBrainz's option text const LS_SCRIPTS = { Latin: 'Latin', Cyrillic: 'Cyrillic', Greek: 'Greek', Arabic: 'Arabic', Hebrew: 'Hebrew', Thai: 'Thai', Armenian: 'Armenian', Georgian: 'Georgian', Devanagari: 'Devanagari', Bengali: 'Bengali', Tamil: 'Tamil', Telugu: 'Telugu', Kannada: 'Kannada', Malayalam: 'Malayalam', Gujarati: 'Gujarati', Gurmukhi: 'Gurmukhi', Ethiopic: 'Ethiopic', Khmer: 'Khmer', Lao: 'Lao', Myanmar: 'Myanmar', Sinhala: 'Sinhala', Tibetan: 'Tibetan', Han: 'Han (Hanzi, Kanji, Hanja)', Japanese: 'Japanese', Korean: 'Korean', Multiple: '[Multiple scripts]' }; const LS_SCRIPT_LANG = { Japanese: 'Japanese', Korean: 'Korean', Greek: 'Greek', Hebrew: 'Hebrew', Thai: 'Thai', Armenian: 'Armenian', Georgian: 'Georgian' }; // lande's languages (ISO 639-3) → MusicBrainz's option text const LS_LANDE = { afr: 'Afrikaans', ara: 'Arabic', aze: 'Azerbaijani', bel: 'Belarusian', ben: 'Bengali', bul: 'Bulgarian', cat: 'Catalan', ces: 'Czech', ckb: 'Kurdish', cmn: 'Chinese', dan: 'Danish', deu: 'German', ell: 'Greek', eng: 'English', est: 'Estonian', eus: 'Basque', fin: 'Finnish', fra: 'French', hau: 'Hausa', heb: 'Hebrew', hin: 'Hindi', hrv: 'Croatian', hun: 'Hungarian', hye: 'Armenian', ind: 'Indonesian', isl: 'Icelandic', ita: 'Italian', jpn: 'Japanese', kat: 'Georgian', kaz: 'Kazakh', kor: 'Korean', lit: 'Lithuanian', mar: 'Marathi', mkd: 'Macedonian', nld: 'Dutch', nob: 'Norwegian Bokmål', pes: 'Persian', pol: 'Polish', por: 'Portuguese', ron: 'Romanian', run: 'Rundi', rus: 'Russian', slk: 'Slovak', spa: 'Spanish', srp: 'Serbian', swe: 'Swedish', tgl: 'Tagalog', tur: 'Turkish', ukr: 'Ukrainian', vie: 'Vietnamese' }; const LS_RE = Object.fromEntries(['Latin', 'Cyrillic', 'Greek', 'Arabic', 'Hebrew', 'Thai', 'Armenian', 'Georgian', 'Devanagari', 'Bengali', 'Tamil', 'Telugu', 'Kannada', 'Malayalam', 'Gujarati', 'Gurmukhi', 'Ethiopic', 'Khmer', 'Lao', 'Myanmar', 'Sinhala', 'Tibetan', 'Han', 'Hiragana', 'Katakana', 'Hangul'] .map(s => [s, new RegExp(`\\p{Script=${s}}`, 'gu')])); // the titles, without what is bracketed ("(Live)", "[2011 Remaster]" are English on any release) const lsClean = t => String(t || '').replace(/\([^)]*\)|\[[^\]]*\]/g, ' ').replace(/\b(feat|ft)\.\s.*$/i, ' ').trim(); // { script, freq, counts } of the letters in the text, or null function lsDetectScript(text) { const letters = text.replace(/\P{Letter}/gu, ''); if (letters.length < 4) return null; const c = {}; for (const [s, re] of Object.entries(LS_RE)) c[s] = (letters.match(re) || []).length; const kana = c.Hiragana + c.Katakana, n = letters.length; let script = null, freq = 0; if (kana && (kana + c.Han) / n > 0.7) { script = 'Japanese'; freq = (kana + c.Han) / n; } else if (c.Hangul && (c.Hangul + c.Han) / n > 0.7) { script = 'Korean'; freq = (c.Hangul + c.Han) / n; } else { const top = Object.entries(c).filter(([s]) => LS_SCRIPTS[s]).sort((a, b) => b[1] - a[1])[0]; if (top && top[1] / n > 0.7) { script = top[0]; freq = top[1] / n; } // no script holds 70%: parallel titles ("Laula mulle laulu = Обійми") or a real mix (#703) else if (top) { script = 'Multiple'; freq = top[1] / n; } } return { script, freq, letters: n, counts: Object.fromEntries(Object.entries(c).filter(([, v]) => v)) }; } // <LANDE> — lande 1.0.10 (MIT, github.com/fabiospampinato/lande), generated by dev/build-lande.mjs — DO NOT EDIT // eslint-disable-next-line const landeLoad = () => { var __lande=(()=>{var S=Object.defineProperty;var Ke=Object.getOwnPropertyDescriptor;var re=Object.getOwnPropertyNames;var ke=Object.prototype.hasOwnProperty;var ue=(n,e)=>{for(var i in e)S(n,i,{get:e[i],enumerable:!0})},be=(n,e,i,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let h of re(e))!ke.call(n,h)&&h!==i&&S(n,h,{get:()=>e[h],enumerable:!(o=Ke(e,h))||o.enumerable});return n};var Ve=n=>be(S({},"__esModule",{value:!0}),n);var Le={};ue(Le,{default:()=>Ee});var F=class extends Float32Array{},X=F;var he="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()[]{}<>*+-:;=@^_~!",ye=Object.fromEntries(he.split("").map((n,e)=>[n,e])),Ce={encode:(n,e)=>{let i=new Uint8Array(n.buffer);if(e==="f32"){let o="",h="",a="",m="";for(let l=0,d=i.length;l<d;l+=4)o+=String.fromCharCode(i[l+0]),h+=String.fromCharCode(i[l+1]),a+=String.fromCharCode(i[l+2]),m+=String.fromCharCode(i[l+3]);return`4${btoa(`${o}${h}${a}${m}`)}`}else if(e==="f16"){let o="",h="";for(let a=0,m=i.length;a<m;a+=4)o+=String.fromCharCode(i[a+2]),h+=String.fromCharCode(i[a+3]);return`2${btoa(`${o}${h}`)}`}else if(e==="f8"){let o=Math.max(...Array.from(n).map(Math.abs)),h=Number((126/o).toFixed(1));if(o>127||h<1)throw new Error("Unsupported encoding, max value out of range");let a="";for(let m=0,l=n.length;m<l;m+=1)a+=String.fromCharCode(Math.trunc(n[m]*h+127));return`1${h}|${btoa(a)}`}else if(e==="f6"){let o=Math.max(...Array.from(n).map(Math.abs)),h=o===0?1:Number((126/o).toFixed(1));if(o>127||h<1)throw new Error("Unsupported encoding, max value out of range");let a="";for(let m=0,l=n.length;m<l;m+=1)a+=he[Math.round(Math.trunc(n[m]*h+127)/3)];return`1-${h}|${a}`}else throw new Error("Unsupported precision")},decode:n=>{let e=Number(n[0]),i=n.indexOf("|"),o=n[1]==="-",h=i>=0?i+1:1,a=n.slice(h),m=o?a:atob(a),l=m.length/e,d=new X(m.length/e),p=new Uint8Array(d.buffer);if(e===4){let Z=m.slice(l*0,l*1),Y=m.slice(l*1,l*2),t=m.slice(l*2,l*3),I=m.slice(l*3,l*4);for(let g=0,J=0,q=l;J<q;g+=4,J+=1)p[g+0]=Z[J].charCodeAt(0),p[g+1]=Y[J].charCodeAt(0),p[g+2]=t[J].charCodeAt(0),p[g+3]=I[J].charCodeAt(0)}else if(e===2){let Z=m.slice(l*0,l*1),Y=m.slice(l*1,l*2);for(let t=0,I=0,g=l;I<g;t+=4,I+=1)p[t+0]=0,p[t+1]=0,p[t+2]=Z[I].charCodeAt(0),p[t+3]=Y[I].charCodeAt(0)}else if(e===1&&!o){let Z=Number(n.slice(1,i));for(let Y=0,t=l;Y<t;Y+=1)d[Y]=(m[Y].charCodeAt(0)-127)/Z}else if(e===1&&o){let Z=Number(n.slice(2,i));for(let Y=0,t=l;Y<t;Y+=1)d[Y]=(ye[m[Y]]*3-127)/Z}else throw new Error("Unsupported precision");return d}},j=Ce;var O=class{constructor(e,i){var o,h,a;this.options=e,this.isx=(o=i==null?void 0:i.osx)!=null?o:-1,this.isy=(h=i==null?void 0:i.osy)!=null?h:-1,this.isz=(a=i==null?void 0:i.osz)!=null?a:-1,this.il=this.isx*this.isy*this.isz,this.osx=this.isx,this.osy=this.isy,this.osz=this.isz}forward(e,i){throw new Error("Not implemented")}backward(e){throw new Error("Not implemented")}getAsOptions(e){return this.options}getParamsAndGrads(){return[]}},K=O;var x=class extends K{},G=x;var N=class extends G{},b=N;var R=class extends K{},u=R;var B=class extends K{},r=B;var ne=n=>n instanceof Float32Array,ae=n=>typeof n=="number";var me=n=>typeof n=="undefined",le=(n,e)=>{let i=1-Math.random(),o=Math.random();return Math.sqrt(-2*Math.log(i))*Math.cos(2*Math.PI*o)*e+n},V=(n,e)=>{let i=e-n;return Array.from({length:i},(o,h)=>h+n)};var U=class n{constructor(e,i,o,h,a){if(this.sx=e,this.sy=i,this.sz=o,this.length=e*i*o,me(h)){let m=Math.sqrt(1/this.length),l=()=>le(0,m);this.w=new X(this.length).map(l)}else ae(h)?(this.w=new X(this.length),h!==0&&this.w.fill(h)):ne(h)?this.w=h:this.w=new X(h);this.dw=a||new X(this.length)}index(e,i,o){return(this.sx*i+e)*this.sz+o}get(e,i,o){return this.w[this.index(e,i,o)]}set(e,i,o,h){return this.w[this.index(e,i,o)]=h}add(e,i,o,h){return this.w[this.index(e,i,o)]+=h}getGrad(e,i,o){return this.dw[this.index(e,i,o)]}setGrad(e,i,o,h){return this.dw[this.index(e,i,o)]=h}addGrad(e,i,o,h){return this.dw[this.index(e,i,o)]+=h}clone(){let e=new n(this.sx,this.sy,this.sz,this.w);return e.w=e.w.slice(),e}cloneWithZeros(){return new n(this.sx,this.sy,this.sz,0)}},f=U;var T=class extends G{constructor(e,i){var o,h,a,m;super(e,i),this.sx=e.sx,this.sy=(o=e.sy)!=null?o:this.sx,this.stride=e.stride,this.pad=e.pad,this.osx=Math.floor((this.isx+this.pad*2-this.sx)/this.stride+1),this.osy=Math.floor((this.isy+this.pad*2-this.sy)/this.stride+1),this.osz=e.filters,this.bias=(h=e.bias)!=null?h:0,this.l1decay=(a=e.l1decay)!=null?a:0,this.l2decay=(m=e.l2decay)!=null?m:1,this.biased=this.bias!==-1,this.biases=this.biased?e._biases?new f(1,1,this.osz,j.decode(e._biases)):new f(1,1,this.osz,this.bias):new f(1,1,this.osz,0),this.filters=e._filters?e._filters.map(l=>new f(this.sx,this.sy,this.isz,j.decode(l))):V(0,this.osz).map(()=>new f(this.sx,this.sy,this.isz))}forward(e,i){this.it=e;let o=new f(this.osx,this.osy,this.osz,0),h=e.sx,a=e.sy,m=this.stride;for(let l=0;l<this.osz;l++){let d=this.filters[l],p=-this.pad,Z=-this.pad;for(let Y=0;Y<this.osy;Z+=m,Y++){p=-this.pad;for(let t=0;t<this.osx;p+=m,t++){let I=0;for(let g=0;g<d.sy;g++){let J=Z+g;for(let q=0;q<d.sx;q++){let s=p+q;if(J>=0&&J<a&&s>=0&&s<h)for(let W=0;W<d.sz;W++)I+=d.w[(d.sx*g+q)*d.sz+W]*e.w[(h*J+s)*e.sz+W]}}I+=this.biases.w[l],o.set(t,Y,l,I)}}}return this.ot=o,this.ot}backward(){let e=this.it,i=this.biased;e.dw=new X(e.length);let o=e.sx,h=e.sy,a=this.stride;for(let m=0;m<this.osz;m++){let l=this.filters[m],d=-this.pad,p=-this.pad;for(let Z=0;Z<this.osy;p+=a,Z++){d=-this.pad;for(let Y=0;Y<this.osx;d+=a,Y++){let t=this.ot.getGrad(Y,Z,m);for(let I=0;I<l.sy;I++){let g=p+I;for(let J=0;J<l.sx;J++){let q=d+J;if(g>=0&&g<h&&q>=0&&q<o)for(let s=0;s<l.sz;s++){let W=(o*g+q)*e.sz+s,H=(l.sx*I+J)*l.sz+s;l.dw[H]+=e.w[W]*t,e.dw[W]+=l.w[H]*t}}}i&&(this.biases.dw[m]+=t)}}}}getAsOptions(e){return{...this.options,_biases:this.biased?j.encode(this.biases.w,e):void 0,_filters:this.filters.map(i=>j.encode(i.w,e))}}getParamsAndGrads(){let e=this.filters.map(o=>({params:o.w,grads:o.dw,l1decay:this.l1decay,l2decay:this.l2decay}));if(!this.biased)return e;let i={params:this.biases.w,grads:this.biases.dw,l1decay:0,l2decay:0};return[...e,i]}},de=T;var w=class extends G{constructor(e,i){var o,h,a;super(e,i),this.osx=1,this.osy=1,this.osz=e.filters,this.bias=(o=e.bias)!=null?o:0,this.l1decay=(h=e.l1decay)!=null?h:0,this.l2decay=(a=e.l2decay)!=null?a:1,this.biased=this.bias!==-1,this.biases=this.biased?e._biases?new f(1,1,this.osz,j.decode(e._biases)):new f(1,1,this.osz,this.bias):new f(1,1,this.osz,0),this.filters=e._filters?e._filters.map(m=>new f(1,1,this.il,j.decode(m))):V(0,this.osz).map(()=>new f(1,1,this.il))}forward(e,i){this.it=e;let o=new f(1,1,this.osz,0);for(let h=0,a=this.osz;h<a;h++){let m=0,l=this.filters[h].w;for(let d=0;d<this.il;d++)m+=e.w[d]*l[d];m+=this.biases.w[h],o.w[h]=m}return this.ot=o,this.ot}backward(){let e=this.it,i=this.biased;e.dw=new X(e.length);for(let o=0,h=this.osz;o<h;o++){let a=this.filters[o],m=this.ot.dw[o];for(let l=0;l<this.il;l++)e.dw[l]+=a.w[l]*m,a.dw[l]+=e.w[l]*m;i&&(this.biases.dw[o]+=m)}}getAsOptions(e){return{...this.options,_biases:this.biased?j.encode(this.biases.w,e):void 0,_filters:this.filters.map(i=>j.encode(i.w,e))}}getParamsAndGrads(){let e=this.filters.map(o=>({params:o.w,grads:o.dw,l1decay:this.l1decay,l2decay:this.l2decay}));if(!this.biased)return e;let i={params:this.biases.w,grads:this.biases.dw,l1decay:0,l2decay:0};return[...e,i]}},pe=w;var E=class extends G{constructor(e,i){var o;super(e,i),this.probability=(o=e.probability)!=null?o:.5,this.dropped=new X(this.osx*this.osy*this.osz)}forward(e,i){this.it=e;let o=e.clone();if(i)for(let h=0;h<e.length;h++)Math.random()<this.probability?(o.w[h]=0,this.dropped[h]=1):this.dropped[h]=0;else for(let h=0;h<e.length;h++)o.w[h]*=this.probability;return this.ot=o,this.ot}backward(){let e=this.it,i=this.ot;e.dw=new X(e.length);for(let o=0,h=e.length;o<h;o++)this.dropped[o]||(e.dw[o]=i.dw[o])}},Ze=E;var L=class extends u{constructor(e,i){super(e,i),this.osx=e.sx,this.osy=e.sy,this.osz=e.sz}forward(e,i){return this.it=e,this.ot=e,this.ot}},Ye=L;var z=class extends b{activationForward(e){throw new Error("Unimplemented")}activationBackward(e,i){throw new Error("Unimplemented")}forward(e,i){this.it=e;let o=e.clone();for(let h=0,a=e.length;h<a;h++)o.w[h]=this.activationForward(o.w[h]);return this.ot=o,this.ot}backward(){let e=this.it,i=this.ot;e.dw=new X(e.length);for(let o=0,h=e.length;o<h;o++)e.dw[o]=this.activationBackward(i.w[o],i.dw[o])}},k=z;var D=class extends k{activationForward(e){return Math.max(.01*e,e)}activationBackward(e,i){return e<=0?0:i}},te=D;var A=class extends G{constructor(e,i){var o;super(e,i),this.sx=(o=e.sx)!=null?o:2,this.osz=Math.floor(this.isz/this.sx),this.switches=new X(this.osx*this.osy*this.osz)}forward(e,i){this.it=e;let o=this.osz,h=new f(this.osx,this.osy,this.osz,0);if(this.osx===1&&this.osy===1)for(let a=0;a<o;a++){let m=a*this.sx,l=e.w[m],d=0;for(let p=1;p<this.sx;p++){let Z=e.w[m+p];Z>l&&(l=Z,d=p)}h.w[a]=l,this.switches[a]=m+d}else{let a=0;for(let m=0;m<e.sx;m++)for(let l=0;l<e.sy;l++)for(let d=0;d<o;d++){let p=d*this.sx,Z=e.get(m,l,p),Y=0;for(let t=1;t<this.sx;t++){let I=e.get(m,l,p+t);I>Z&&(Z=I,Y=t)}h.set(m,l,d,Z),this.switches[a]=p+Y,a++}}return this.ot=h,this.ot}backward(){let e=this.it,i=this.ot;if(e.dw=new X(e.length),this.osx===1&&this.osy===1)for(let o=0;o<this.osz;o++){let h=i.dw[o];e.dw[this.switches[o]]=h}else{let o=0;for(let h=0;h<i.sx;h++)for(let a=0;a<i.sy;a++)for(let m=0;m<this.osz;m++){let l=i.getGrad(h,a,m);e.setGrad(h,a,this.switches[o],l),o++}}}},Xe=A;var M=class extends G{constructor(e,i){var o,h,a;super(e,i),this.sx=e.sx,this.sy=(o=e.sy)!=null?o:this.sx,this.stride=(h=e.stride)!=null?h:2,this.pad=(a=e.pad)!=null?a:0,this.osx=Math.floor((this.isx+this.pad*2-this.sx)/this.stride+1),this.osy=Math.floor((this.isy+this.pad*2-this.sy)/this.stride+1),this.osz=this.isz,this.switchX=new X(this.osx*this.osy*this.osz),this.switchY=new X(this.osx*this.osy*this.osz)}forward(e,i){this.it=e;let o=new f(this.osx,this.osy,this.osz,0),h=0;for(let a=0;a<this.osz;a++){let m=-this.pad,l=-this.pad;for(let d=0;d<this.osx;m+=this.stride,d++){l=-this.pad;for(let p=0;p<this.osy;l+=this.stride,p++){let Z=-99999,Y=-1,t=-1;for(let I=0;I<this.sx;I++)for(let g=0;g<this.sy;g++){let J=l+g,q=m+I;if(J>=0&&J<e.sy&&q>=0&&q<e.sx){let s=e.get(q,J,a);s>Z&&(Z=s,Y=q,t=J)}}this.switchX[h]=Y,this.switchY[h]=t,h++,o.set(d,p,a,Z)}}}return this.ot=o,this.ot}backward(){let e=this.it;e.dw=new X(e.length);let i=0;for(let o=0;o<this.osz;o++){let h=-this.pad,a=-this.pad;for(let m=0;m<this.osx;h+=this.stride,m++){a=-this.pad;for(let l=0;l<this.osy;a+=this.stride,l++){let d=this.ot.getGrad(m,l,o);e.addGrad(this.switchX[i],this.switchY[i],o,d),i++}}}}},Ie=M;var Q=class extends r{constructor(e,i){super(e,i),this.osx=1,this.osy=1,this.osz=this.il}forward(e,i){return this.it=e,this.ot=e,e}backward(e){let i=this.it;i.dw=new X(i.length);let o=0;for(let h=0,a=this.osz;h<a;h++){let m=i.w[h]-e[h];i.dw[h]=m,o+=.5*m*m}return o}},ge=Q;var P=class extends k{activationForward(e){return e<=0?0:e}activationBackward(e,i){return e<=0?0:i}},Je=P;var v=class extends k{activationForward(e){return 1/(1+Math.exp(-e))}activationBackward(e,i){return e*(1-e)*i}},fe=v;var _=class extends r{constructor(e,i){super(e,i),this.osx=1,this.osy=1,this.osz=this.il}forward(e,i){this.it=e;let o=new f(1,1,this.osz,0),h=e.w,a=e.w[0];for(let d=1,p=this.osz;d<p;d++)h[d]>a&&(a=h[d]);let m=new X(this.osz),l=0;for(let d=0,p=this.osz;d<p;d++){let Z=Math.exp(h[d]-a);l+=Z,m[d]=Z}for(let d=0,p=this.osz;d<p;d++)m[d]/=l,o.w[d]=m[d];return this.es=m,this.ot=o,this.ot}backward(e){let i=this.it;i.dw=new X(i.length);for(let o=0,h=this.osz;o<h;o++){let m=-((o===e?1:0)-this.es[o]);i.dw[o]=m}return-Math.log(this.es[e])}},We=_;var $=class extends k{activationForward(e){return Math.tanh(e)}activationBackward(e,i){return(1-e**2)*i}},He=$;var Se={Abstract:K,AbstractActivation:b,AbstractHidden:G,AbstractInput:u,AbstractOutput:r,Input:Ye,Conv:de,Dense:pe,Dropout:Ze,Pool:Ie,LeakyRelu:te,Maxout:Xe,Relu:Je,Sigmoid:fe,Tanh:He,Regression:ge,Softmax:We},c=Se;var ee=class{constructor(e){if(this.layers=this.resolve(e.layers),this.options=e,this.layers.length<2)throw new Error("At least one input layer and one output layer are required");if(!this.layers.slice(0,1).every(i=>i instanceof u))throw new Error("The first layer must be an input layer");if(!this.layers.slice(-1).every(i=>i instanceof r))throw new Error("The last layer must be an output layer");if(!this.layers.slice(1,-1).every(i=>i instanceof G))throw new Error("The layers between the first and the last must be hidden layers")}cost(e,i){return this.forward(e,!1),this.layers[this.layers.length-1].backward(i)}forward(e,i){let o=this.layers[0].forward(e,i);for(let h=1,a=this.layers.length;h<a;h++)o=this.layers[h].forward(o,i);return o}backward(e){let i=this.layers[this.layers.length-1].backward(e);for(let o=this.layers.length-2;o>=1;o--)this.layers[o].backward(e);return i}resolve(e){let i=(h,a)=>{if(h instanceof K)return h;switch(h.type){case"input":return new c.Input(h,a);case"conv":return new c.Conv(h,a);case"dense":return new c.Dense(h,a);case"dropout":return new c.Dropout(h,a);case"pool":return new c.Pool(h,a);case"leakyrelu":return new c.LeakyRelu(h,a);case"maxout":return new c.Maxout(h,a);case"relu":return new c.Relu(h,a);case"sigmoid":return new c.Sigmoid(h,a);case"tanh":return new c.Tanh(h,a);case"regression":return new c.Regression(h,a);case"softmax":return new c.Softmax(h,a);default:throw new Error("Unknown layer type")}},o;return e.map(h=>o=i(h,o))}getAsOptions(e="f32"){return{layers:this.layers.map(i=>i.getAsOptions(e))}}getParamsAndGrads(){return this.layers.flatMap(e=>e.getParamsAndGrads())}},ie=ee;var qe=["afr","ara","aze","bel","ben","bul","cat","ces","ckb","cmn","dan","deu","ell","eng","est","eus","fin","fra","hau","heb","hin","hrv","hun","hye","ind","isl","ita","jpn","kat","kaz","kor","lit","mar","mkd","nld","nob","pes","pol","por","ron","run","rus","slk","spa","srp","swe","tgl","tur","ukr","vie"];var Ge={unigrams:[" ","\u0627","a","\u0430","\u09BE","\u0435","e","o","\u06D5","\u6211","r","n","\u03B1","t","i","u","s","l","k","\u05D9","\u093E","j","m","\u0561","d","\xF0","c","\u3044","\u10D0","\u044B","\uB2E4","p","\u0924","\u043E","h","g","\u06CC","z","v","\u0103","b","\u0442","\xE1","q","\u0438","\xE4","y","\u0131","\u043D","\u0111","w","\u0644","\u0259","\u0456","\u09C7","\u0441","f","\xED","\u0648","\u7684","\xE5","\xFC","\u03B5","x","\xF5","\xE9","\xF6","\xE0","\u0199","\u05D4","\u0939","\u0161","\xF3","\u0565","\xEA","\xFE","\xE8","\u306E","\u10D8","\u0440","\uC774","\u0117","\u0947","\u043C","\xEB","\xF8","\u062F","\u0142","\xE3","\xEE","\u043B","\u010D","\xF1","\u0443","\xE2","\u015F","\u0432","\xF4","\xEF","\u064A","\xE7","\u044F","\u09B0","\u0434","\xF2","\u011B","\u0646","\u4E86","\xE6","\xDF","\u03BF","\u017E","\u014D","\u0153","\u0257","\u05D5","\u0915","\u0107","\u0151","\u0576"],bigrams:["e ","\u0627\u0644","n ","\u0430 ","\u09C7 ","\u0435 ","a "," n","\u06D5 "," \u6211","r ","ch","\u03B1 "," t"," m"," e","\xE4 ","s ","an"," \u05D4","\u0947 ","i "," a","\u0578\u0582","ng","\xF0 ","o ","\u305F ","\u10D8 ","\u043D ","\uB2E4 ","u ","\u093E ","\u0438 ","en","er"," \u0627","ie"," d","\u0103 ","ra","\u043E "," s","es"," \u0441","t ","g "," b"," \u0432"," c"," h"," \u0623","\u0259n","\u044F "," \u0986"," \u043D"," l"," j","\u06CC "," \u4ED6","de","ei","\u03B9 ","th"," k","ar","in"," p","ya","\u05D4 "," \u0915","je","k ","\u0574 ","ka","a\xF0","re","\u306A\u3044","\u10D0 ","\u044B ","\uB294 ","ai","\u0940 ","\u0442\u043E"," i","et","\u062F ","ni","m ","te"," u"," \u043F","to","la"," \u0458"," v","na","ir","\u043D\u0430"," \u0111","di","\u0627 ","\u0259 ","\u0440\u0430","\u09BF "," \u0442","el","ne"," \u0628","\u4E86 ","ge","ic"," \u03C4","he","ma","at","ta","le","da"," \u05D0"," \u0939","ti","sz"," \u0565","ak"," \xFE","on","\u3063\u3066","\u10E1 "," \u0431","\uC740 ","as"," \u0924"," \u0434","ee","eg","\u0647 "," w","qu","st","ur","\u044C ","om","l ","\u0458\u0435","\xE4r","sa"," o","\u0443 ","nh","y ","\u0646 ","m\u0259","\u0456 ","\u09BE "," \u043C","ll","se","\u0645 "," \u4F60","or","h ","\u03C2 ","ou","d "," z","si","nt","wa"," \u05DC","\u0902 ","li","gy","\u0561\u0576","me"],trigrams:["ie "," \u0627\u0644","\u0259n ","\u043D\u0435 "," \u0986\u09AE","\u0434\u0430 ","es "," ne"," \u062F\u06D5"," \u6211\u4EEC","er ","en "," \u03C4\u03BF"," th","ma ","an ","in "," de"," da","\u05D9\u05DD "," \u0939\u0948","je "," a ","\u0578\u0582\u0574"," me","a\xF0 ","to ","\u3063\u305F "," \u10DB\u10D4","\u0435\u043D "," \uD1B0\uC740","as ","\u094D\u092F\u093E"," \u0434\u0430","et ","eg ","\u0633\u062A ","nie","de ","te ","ra "," \u043F\u043E","om "," es","\u0458\u0435 ","\xE4r ","ng "," bi","\u0442\u0438 "," ch"," di"," \u0623\u0646"," m\u0259","\u0446\u044C ","\u09BE\u09B0 "," \u0442\u043E"," el"," je"," \u0644\u06D5"," \u6211\u4E0D"," ha","ich","\u03B1\u03B9 ","the"," ma"," du","\xE4n ","re ","da "," \u05D0\u05EA","\u0939\u0948 ","ti "," az","\u0582\u0574 ","ang"," a\xF0","la ","\u3066\u3044\u308B","\u10D8\u10E1 ","\u0434\u044B ","\uD1B0\uC740 "," pa","\u0932\u093E ","\u043D\u0430 "," he","jeg"," \u0645\u06CC"," po"," qu"," \xEEn"," ku"," \u043D\u0435"," to","os "," \u0458\u0435","ag "," na","ir "," \u043D\u0430","\xF4i ","ek "," \u0645\u0646","m\u0259n"," \u043F\u0430","\u09AE\u09BF ","\u0442\u0430 "," la"," se"," \u0626\u06D5"," \u6211\u60F3"," er","ch ","\u03C4\u03BF ","he ","on "," ez","h\xE4n","is "," ya","\u05D0\u05EA ","\u0948\u0902 ","ije","em ","\u0565\u057D ","ya ","nn ","no ","\u307E\u3059 ","\u10DB\u10D4 "," \u049B\u0430","\uB098\uB294 ","ai "," \u0906\u0939"," \u0441\u0435","ik ","det","\u06CC\u062F "," ni","eu ","s\u0103 ","nda","\u0442\u044C "," pr","est","\u0442\u0435 "," ja"," an","bir"," \u0432\u0456","nh ","die","\u0645\u0646 ","dir"," \u044F ","\u0986\u09AE\u09BF","\u0442\u043E ","el "," js","\u0648\u06D5 "," \u6211\u5011"," en","ein","\u03BF\u03C5 "," yo"," on"," ba"," h\xE4","le ","na ","\u05D5\u05EA ","\u0928\u0947 ","am ","gy "," \u0567 ","kan"],quadgrams:["nie "," \u0645\u0646 "," m\u0259n"," \u043D\u0435 "," \u0986\u09AE\u09BF"," \u0434\u0430 "," la "," se "," \u0626\u06D5\u0648","\u6211\u4E0D\u77E5\u9053"," er ","ich "," \u03C4\u03BF "," the"," on ","zen "," h\xE4n"," de "," da "," \u05D0\u05EA "," \u0939\u0948 "," je "," az ","\u0578\u0582\u0574 ","ang "," a\xF0 "," di ","\u3066\u3044\u308B "," \u10DB\u10D4 ","\u043C\u0435\u043D "," \uD1B0\uC740 "," a\u0161 "," \u0906\u0939\u0947"," \u0441\u0435 "," ik "," jeg"," \u0631\u0627 "," nie"," que"," s\u0103 ","a ku"," \u043E\u043D "," sa "," est"," \u0458\u0435 "," jag"," ang"," bir","\u043E\u0433\u043E ","t\xF4i ","die "," \u0627\u0644\u0645","m\u0259n "," \u0433\u044D\u0442","\u0986\u09AE\u09BF "," \u043D\u0430 "," el ","sem "," \u0644\u06D5 "," \u6211\u4E0D\u77E5","jeg "," ich","\u03BD\u03B1\u03B9 ","the "," ma "," ez ","h\xE4n ","est ","a da","\u05D0\u05E0\u05D9 "," \u092E\u0948\u0902","sam "," meg"," \u0565\u057D ","kan "," \xE9g "," il ","\u307E\u3057\u305F "," \u10E2\u10DD\u10DB"," \u043C\u0435\u043D"," \uB098\uB294 "," man","\u0906\u0939\u0947 ","\u0430\u0442\u0430 ","een "," det"," \u0627\u0648 "," jes"," eu "," nu "," nda"," \u0447\u0442\u043E","som ","que ","\u0430 \u0458\u0435","jag ","ng m","bir "," \u0432\u0456\u043D"," t\xF4i"," ek "," \u0641\u064A "," tom","\u0433\u044D\u0442\u0430"," \u0986\u09AE\u09BE"," \u0441\u0438 "," no ","jsem","\u06D5\u0648\u06D5 "," \u5982\u679C\u4F60","det ","eine","\u03B5\u03AF\u03BD\u03B1"," you"," ei ","tzen","aan ","ous "," ya "," \u05D0\u05E0\u05D9","\u092E\u0947\u0902 "," sam","nem "," \u0569\u0578\u0574"," men","inn "," non","\u3063\u3066\u3044\u308B"," \u10D0\u10E0 ","\u043D\u044B\u04A3 "," \uC788\uC5B4 ","yra ","\u094D\u092F\u093E ","\u0442\u043E\u043C "," het","ikke"," \u0628\u0647 "," si\u0119","n\xE3o ","este","ira ","\u0430\u0442\u044C "," som"," es "," \u0458\u0430 "," \xE4r "," ng ","iyor","\u0432\u0456\u043D ","\xF4ng "]};var ce={layers:[{type:"input",sx:1,sy:1,sz:620},{type:"dense",filters:120,bias:.1,_biases:"154.6|yJFeZ6rIc4iLuoN/dWiYnKWRU5uFlluhTpann9h4qXPTs9KcmsCFW4OAhvyN1oahz92dj220g7e9kLegbZB7q3StxISGfX15eNRmllK/eXZm1IhnmFqRkNV1gZJ0lceGhMyZkr1W27aEc8bbTa95d3CehKm9m3uh",_filters:["130.6|npSaspqhn3u5jq65k7ahk6+0vXCdja5YoaN0bVU4caiconGadViXz5utX3ygdL+npYpbnLq0lp5+f5ORlqaQn3+JlZ9xdpCkdmBnj21sWaZrwJGlf5+BglmlnYBtjZqnuXaCpYrTlaV/wJOQuIiPmH9/cHGPdY1hgoifsJC2gYaYhq9liqWVgVyxjnmDp6RneJY2cGqLdbR/tH5+b62StnqxlYKn/ayPmW6FlZKYjZ+Bv4GLiK2JZ6iHipmIfYV1nHOPi3d8bmVvhpO1bpBvi6uIZ6OMe2Ggeo6ljpOFkZGPwIKTioOsgYhqiJJ3mJN3hoaec2CPenVplHBxhbNVhGxrYJGJbpiYRIt9ml90dYWPlomvfZGHi3eaiXyISpthZXmFg5d0i8+Qi4yGjZqUi3+RhIZreIZpf4h8enpud4yKUHhzm3ptiaimi256dHFxiJNqUIdpfJNrlYWIkYOneoqIf4CQg2yQd3Nuh3x9fot0g4dqe3ZueoeSkHqXc3Vgn2iXok5VhVeomYObgIiPhqyHkoSAkomDXWVwc4KCfXtpknh/l293dW58lZKeaY92Z3OQeGqLdpaJcn6LiaSAhH2DoIidhn+Oo4Jmhmh5gXp+doqIdpSUf4iVhKaMfIR/pIuDa35vdmtofnpwjnZ8iGl/dXt5mpKhWZd0d2OLdFhXeF2Ca4CMhJ+AhouDkI2ThIGVjHxtjHV3i2x+d3yEep2LaX58eX6Xj65aiHZ8aJZ4aHN7dINzgYaHnIOXhoGkjI+Bfo6TgWB9c2+Nb313fot0holhfnd4eZR9lnqVd4JtlXJtb3mBhmqAhIc=","141.1|d4d3W7phY2HDupB9q2NxeXBplqagf4hkeriOOrsSWpSRc4HNUvxX36VzcTuUQIy3fHDhlWahtXPPW3/FuZqmQVglhDh9nKpFlWpVnIk3tXZOQaRreshvwVmnaGaIXZusg3R6q4bdrm93Foe024ylTX9/fKOhtHxogY+VdJ5xjHuamJSkllp9aWoCqJKnqpFvqqOgVJxnYJKsfI7OY6GuxpFxxIZja8Oqe3mlmoCwnHdzZXSlsXSSToulnYDMkKC9nHavk5lwllFTKJZt6aVot722oW2cpEN1pod/arJ8fGefkYyZfZKmk5Nxj7OjjpyXmpuidrefh2ige1+OjXs5qlyTRKV8cISNTHaxe1RhaniAdZaGdIN2l8OzkQBxn49/qpedlWx4jaWehHaQd2FpioKeqYVUZKSnkKWDlnxXdZmQnG+RinVuf3WYl2GVe8qifpR8W4OKqZSBsI2EWIuHT7CJgcWahmh3onGRhoOXhH93rIyDd4h6dWhugnKubpFfp4Zyd4ZggZqAfXR1f4NcjYR8coiAyY2FdXmIcU6bg5SOf3i6oYFtkH14b21uhpxyolGXqYuPfnaAhY6cc357gHCIb3pVf35vpYhqYJxxg5+Cl7d1eayIe4OFiIVxdIR/vZWDaFSKc0qYg5J0eXiyi7d8i355Ym9wgKtohG2PiGKGaGd0h4qqcn2FgmiHf21uhICklYVnXoVzb46Ej4aCeqSVlXmFinl1cIdulW6ZcaaMbopReXSGipB3ZYOFb4aVSWeAgZmXgmJrh25tfoWRkod2lYukgYV/cmmKZmSlcJdwpol2gZlnhYOLknc=","141.1|hHinhWGse688qaCgtZq6p6a5gLSkzaZemcR+XnpQqMSnro++pDD2MIutgLnTx1M+nH5DXXcBZKKesniydUW0rIPkiKZtq4iznWJ2optgfJ6ttpOefXmONmpeqoyWmV5JtWGBTEyUZ5mAj3mjzXevlICBca2KcmtljW+yqGyemY1llbKBn6mxra39pZmBhahvt6jMbHePnF+Dt6+1oHJxSJm9so2vncWJsY7AY3aebq7ViY6SlpiZop+XurldmnqNbHCbmJ14eGupyJm9mJCghlN2s7Kasm6pro+odqSCdqJvoHaAgYybjJqlqqOzlqCed94+dXKiqHR4jpqEkK/Db6Nuuo+elJ6gQYupgmxzdIN3R3Gjzp+Bi26GmrfhfamuYZx2nm50pTVpd4F2p6qNboOfnIqXdalyoHiIaphXdZyOiXp6nomskq2iV5h5wYGIt3WfUoqvfGd5onN5XnakmG51gZBqio+Oa5SAgIdxrHF7j4qsenxtiMiln6t/lXGjX4Kkf69ZloF7qXhSfHd4dqy1q3aEjXCKk6x4kK1HiW+TX3iJe7h3eWiHeKOuqHmOk31ucp92fqWTh2d7eWeDgHV6mJR8eX/Kb4qcdmGMiIGHbn1xcolcg3abeLGtq3p/iXKKkI53jr5UiHCacHWYjZR7eXaJl6KuqX2UipdwcIKOkWCQjGmAemN7eW14oqKBeYOCdIaNc3yIg3iFcYxmeoqUj358fIKYm8GpepBmdG50iGOsdI+Hbnx9XXl3cn+vrIB7gINrhJOMep+XbodvgXN1jox6fXt1i4yCqH96kGB7W3iUb5GGiJFtfHo=","135.2|YpuClWuJg3VYb5eZapiDhqKuy2iNppltr4RtsqOYiKF/rqCNi0ehTpekWYp+/KvGlohfk4bTboqElIdw87Bui3eH0aWAa5q9VW5thYiymJSLvJaWf5eWXV5oqpp3vWyirnGBjqp/bZyA7ZF1Zo1tt36BfGiXcWprfYfDiHJ9h3VudtSmcaCQi6e7o3CYiYB1koNmlIx9iryWhLKmmZSfWpSnl4eOzaGwsWmgiIaocpyGvIV6n8p0Xayal5OZcZhwjni1g3CNio6JsYWhip6Ri46Of62JoHaho56hh5eQhJV0jYGqh3ywjHSCnIWSo61ynaFTeHeDk46QmoiLfZlwapl/iYyR1J15W6Czppl3XoyGmHaXsLGEenzEeY6HjbVpkXKbnm93nqSIh6Z2ZKqkdXytxndgjJqPjpx3l4RNd52EZIeGc3+Jd3Crmolyi4d4nI19XJxliZ5/j4OCjnmCeHJ4fpSceGiEpZOHoneRjXF5i4KIhoJxf5ltooiEiX2DZnOqkmBjm3i2m4WBhoSVeYiLpnp/kaN6bIlqjX6QeJBxfHuYm4iHhGuBkWqGaaKIcW1xeaCAd36ng4iPgpmDiqB5loaufX2qqnlljX6Qjpp7kXRxeZOBhYSeeWCKkH2Amo17aYNuj2x0fJJ/c3p/gnSAhXSAmm15a4WGe4J0c4VeemeiZYyOg5OBgot5k4SVe4KFi35pfnGHg3x5jIR8e5eAf4CCiIKgcIpyo4JraW9ykX6SeKJ1j4iDi4OQhXp1kZ19fqOYflyNc4uNdXqNiHN7kYFpgYV1gZODnXaRhHNxdnGFeIqHl2mOioU=","139.5|YKKer2fxio3YjZCsvcmdopSzrW6QTJWgg8OOwFu7XKScy6yHbT6oSYLw1orn/Kxo0JJAqDUfbN2q1ZyLuIy2fIWG86aCcHClg52Do7/AXdZZzHrKd2WBOJZb5oVnhnJsq4WAtIqqas6p6qCOTnqxq315gG10M46WaYaD03K+3JekiodSn66Ilq6Vl3Vq676RvqjGpW3YaJBxyqqcYhyOVpfFfZ3YgJGZrH6Un2ujdbKKlYGLldidu6O0ypx5d2c0jYytl5WOcJJcwI7ldY5uh4WQb6+bm1WVn5q8nJqFaa10yKejjINZh5mSl7C5lYd0VLJ6iqigmJNsv2eTlMBNk2dkpYyYi5eML6WogUyedoZuVnPC1sCHiZWgmoxoYKynbXdZkqODsUmRcZR4z323kX2Hh42wd5qOiXp7S2ethqqSp411x3h6o7FiiWhTZmymx4lXP56sYGOImIhzSHi6mj+KgZlvip+ck5a2hXxXo42HhYughniYdpS+sG9xa2Khan+qj3dHmnaZqY10gHVmecCAvYh+f4eJrp6Lj2NefVlhp4GYq7WPdqB5nLjAZJtyXp10hqdteq6Zunhwi3J9hHN8soishYB9dY6hdpuTgpt8XVeNin9nfXagfLx7e4N9dHOHnZOGjzxvflpUjoeNjLGDd5F4obavUnNtXZF3eo+SXFGsj36IimqCeV17n4Cfhn53dYeXboeIVIJ7XWqRhXuUjYR6kXqgqcNim3JngGx3mIZycKqLfXaJcoGUaXuvkZqEf5RyhKeNhYFkdntcaIqMlpGhgXqNdpaCmVN6c2SFcHSXjWZ+l4+IdYU=","128|kXSrb6JzycVZn62vYceeirO8ul2SmqJZw1F/Y1f8dY6aXrR+kb2aPIx/pI9W9pmxg3ihbap2nYR1nHakZ6tjnIGZ1GN4ZINlnGBsY0tgXIFuUId3gkGEi3ZbjoKNWGWVYHSAZYvwmnt0pXSeVYJjb4F9e1+EepZijnnEb5JvloRvjrCicI2PrqqggWx8SnxoYGN/aGyZeXF8ZJF4kqiLUoFjen14vIeHeF2jb461jnFtoYOXlJdun46DkIJVb3tzqXGJb3h4cbRybo6Rko6PjJSUcmyVi0mmj4J4eHp9jn+RiYR/foiSiHCcgXmTiYRrclqqcnZkmnNqgnOLh3TEfpK+eZt5oZ10Rp19zE9+q36MioqIq4h/jXZrca6zopF5cWp0fJZ1iJRyh4KJhJF7eIJtwXmYe5uIbYV4cY6VdWtxh3dzj32HjXaEZY2Vj4B1eJSKU5aDgWFmbnaEiYWKjot7f3yTepN2f5NdlHhyVJJ2YnWGfXmNfXiZeqNwipVzY393ooBZpZSEgH2df4aBg45qeXuBXJ15goiLjpBseW5qmnlcZHV2dY59UJt1mXGGmXpwaYqDcXOdfoNygI5+h5GEkouRe3x8nXifoHyRcYd5b2uRdXSJgIeDhXhzkX98VIp9lImDjIeTenKPkXZpdVV9dYp+bJdvmG+Lk3V1e4eHcl+ghoaAfpF8goWEf4mFfH9shHyRgISHdn97cHuAfGxwV3t7gH1pkoCab4l9fXF1knmPdKB+iXd9hX2Bh4GImYV8f3CQepqOgplyfHlxepB0bHFkfnqFeW97dJReh4V7aXuLfJhrjoCFd4I=","136.9|dZRxp3Kxin89vIaIcXOOmG5wa6mJbImyZqrCaXFKaZ1612yvpXWivo+ucmvqAbe8wpqEeTKddcPAconCgopzamikSqZppKaEtKt/lpppdLFosZiper6Zemyln2WarZyp4Jp/YqH3dKN7Qoyy2Xtzcn1+bqibe22ntHpos3XQkYhhnHtvdJ2IkGuNmJWhqbuZlJa2c3hhb4ieyKenqaJ2q5HAipqsgYWJtt6FcWy4eLm6aY2dmFN0soCUfZKHkp+Fe5J5kcN5d29pj4Scp5CheHCNrsSUfoV5cpyxnY6MZKF4nWqaiZCSf3iAjJaCn4+ZsL1mjHiVl3d4fXGLe7NOh6mAk5Cmo3WtplR8a6OJiI5sgXioeYaIlMiEdpOWPJKhZJitg26LqWVudpt6lJh1cYGXeHyscWZrm3OHr4N9i4WKl3p7cXuSb5qVgpp+io+cpV+jn111l6iOjXpzgHiRfop4gqtpepV5dHCcjIeiinSGhoiVfXxneaZYpHuak3iTmIeWhKieVUyXp4ppiHR8e4u2d3l/lGN7lYNtdIaHhKFoVoh5h5B6eWJ6mliBdIuNb2OLo5WGjo9ue6SQi22GgIR5m4Joen+jZnqkcHBvlHaFoIZ0j4dqhXOTfaC+eXt/mXB6knB2dIl0hqJ1dYp0iot9fHR6lFqBgJqSfYeJhINbm5dcXp+ki2V9eoR9kpd1en+adH6OeHF6kmyEn3h3g46MnH19fXqdYXJ3g4x6dJSNgHmGiFhymIiKanxpfnyJf3V8f4ZsfptqeGyYbYSbd3aMd4iKfn1xeI+FZF+ejnx2jn99gZBzaGuWiIs=","129.8|ZI5xfKx7b4y+ZHFmoHdzhYN3i6OWpnpsc1p2u6r9boaZfXl9o5xjTn6DwHZpiq+DiHDAdU2rp4iOnZRfh5CcYmjkgXWQnZJ1w3Cpalm8poZsqJKEgY2MvJ9vk2CWhHF5fGx8X4qgo4tksY5oUYibgYGCjZ+TgZhugHOQcJhwdZCacKWjkJKQmqW1jpGIXZtzhWmpoJGObn6AdaWKevWSYJN0eJKAeIR/k4eIbm+ilYCOrJByhr2OYZeakI2XkYd8nnancWuNjrRtz5J/d3+AkG6lfoR/lnycmJ6CeHt6b4OZfJ+Rk3eAdo2FmF+gmJSUjX3MeJVsj5WUh3OwgX6PoX2si3t5uXZwiG2heJCWpIhzm5KCiIqSdoxtjKdnmqakr5OLiK53katudJaNd6iUi3yFqoNgf5unmaaEf3queZ1ybouKc3y9g2+Fo4ackpaRgmK6hnWLgIxxfHh3l4p0kICIfo2ThGuYhY5tioOBe5F6k3WKhIiJesCMhIeAe5GLbIaGa9WHf7uagnqMfneLiG6JiIV/hoSHa4t6irOOhXtqpHmVh2uNh4R7sY9wi358jop1dpKFiXCYjoV5epeAeaKEgXuWhX2JoYVmeqeFm5SFeHeSeoqLgHmFh2R+n4J+iomBbGx1i6qVhIFxkHiMeXKBh4F6kI5omIaBlo53fJh7mIhtlIaBeYx7e42HfHuLg3yAiIBtcHeHl4qDe2+We3lwdYOFhXuSil+PfoSVlXB2k3qhgmyEiX16fXpsi4JtfIx/f3iVhF99dZuNk4N6eZR6gXlkgISAfId8c5KLgJWReICMeaF6eIqHeng=","144.9|r1mIzk6ec3hHRWxWsEt9f4KGX61wkWq7iXoywUgzp4dix2ZILAqhEmuScOGxWTM6jVgigdilVJFGwFE8PDiqrZ+mAH6Nppq4cbyEfnm/ToqvxI2ngJdXNINRhJGZ5lFG21F/nU4vVpt+u1pMxXOoq32AjrCSWlqtb4mFrV7CaFNmYpVqnq+ai4/skJScTV2iY3iEpmNgoX6Utox4aIJtOXnHgHmyO1Jzo4CFjplSaKydiFpbcneYWIaOfaOYlpxYZpRseXCPZ2Wry3mKWHNqai1le72IgtqCgoGdWJl1mqJlgnpzWmp7a5Z/oGichLucq5sckFZ9bpVie6KHc7DNa2lbwIhtdZGoy2iUd9ltSmOVlmuKeJRZZF6DlJiTlnN8mJqmiFWRkEuakZFxfpmPcnxqcIZaqZeImpyKrYFQjqd5W4xuYom0ZKWiZGeIomJ0hXSvuFpPcqZ4noaOrXB7r3R0fH1nhWWcWoNnxIeghV6Ng4CJhnhkiKxMipuDd32pWX2firqwVWt1jG5SfIypdH6xuHh9cliHcIZ1hL6qh6ZiXY6jYJuScmCEqU6eqWN3c5dmXlxrgZBjV1qJanV6iYR1gZqQfH2tVohmj22BhoKJnWJkkJFgfI6Ld5adoHt+fGmEa554hcymhKWGX4t+fIiDdGqKmVGrq4ZsmaJqcmpam6VZW1tqcneAhp92gKKDeHuIdIVvlXmHhH+KnHxjiKN+doJ5aIWcXYKhYm9gh2hwY269ilptaINxb4GHkHlvsI59e2ZjhV6HfJCNj4OhfmaQm31rgXduiZaCmpOFcWSKWHNnao2lZWdngHU=","126.7|kXXVYoZuw9JRZdHaj8rQ2sHDyYmHs8B1t5Wxo1pZb66PYN6Zk6zIXLNcmZ1xpqmRWo1xaIJThmCZoXVhp4CNmnWemLCFiXCVcXWDi7OkYGRxa3plgYyAY3txXKZ7Y3WHTqB/XJQBhmOMeHJpYX+NnH+AhIl1enN4wHa0ZodtzbhucZeki6+glZdxsYZtyH53jpCul3CIc7twcqGTjYa7Y7Jsvo5meY64VZqqa4IvhWOhpYZxg42Ie5Gmm5iQgm2nnHyWjKOIcmJwsYVil6CNnLCdo1acp6O5qaRijqt9gmOBZ5KvenZsh4iFjoefmKSEZZ5afqKKqo9venWAiGB3gY99ooyhLqORs4esn3qGkX2DaoVdq6B8cpWahpBYq46IlINpom57nItvgl6Eg3yZdX17hYaAhYSfhpSBYJpqfYSIkod2hnxlj3l5kI5/bJePa6JvpIt7h3CZl3mAaIN1bJF5fX+DhHh5momxkIBplHd9hYSng3uOe3mcZ4R8i4ybcn9okl+hhYuRYoONfYFygWWLknt/gYiEhJKHhnWUgWmVe36MlJKIeJF9hpp+coiHiXt2iWGQg4Cdg3t3hYiAhluAaH57fH6CfYR8hpWFj6KBbHp3e4OEfoBlgoOSfn59eoSDeHeFhGuFgmmld35/hZCEeIZ6eJuCeX2Jgo54fXeAc5uKdH5nhYt4f2+Cbnx9en6AgYF5e4aCgZKBa4B/fomEnoN7fH1zlHR4ioh4enJ+bZWFh4p5gnqFinuSdIJ2g36AgId8gXWAhYeMhH9riHR7ioOQgXqGfXh9i32FinR6coJ2lXmJiHWFeYE=","143|UItlqkyfg6jVtl9CtUhyc21tabKg0oWTsEizfrOpXJesc25ytIKaLZSAjH1pCL7Sg3LTbEJtVImNt5q+R4mxWHSbRFONqXrVl5CtYz17poxYt4uOfY+Oq69diambcmijXlN7UM1ZV4xa+JCvsHauzYB/i7GGsGaJtGl5nGKjVHWkmaaLnciMfmTZpZd8V4iIlFncdZa1YqiDgKaZcYV9RqWEmJaSW2ShZLB1Y2tLZoSGmZyfmkCZrKiZja6ql3m7kISpY3aDj5NempWTrHh6e4uMm22PqLNhn4N7ZYJ3a4dimIGQl5CkiZabs4+nn7acW8ZehZNik3aYkGSzmXiyanq/m4iCLGeNxV6QvIWNmIxwjGd9SpuVmZWilvzFzYGzqp1luGCClEJjeHdtnq+7k4KLkIucqqKdjImKVZechI1pqX+NmXfbn6Gbc4eRunSQZke+tGaCoGV4bnR1lnKZnZ+OgYloiKGKXXA4u4pepWSCkXCRfoWYd7uyc6CLea+UYYJ7nPGmX7SRenNufnaUc4KMxIaCcG2JgIZjdtO7hl+bgIKbV4F6iZt4oamJm2x9b49rgT+ViImDh5ljcGB9eGp1iHCthoCmWYm2m4FvnKuHZqBpiYpufHZwdp5124V/gWyKmn5tc72RhF2RaoiDcWh+iYl4kKiUroyHjZZtfHI/rKBiaphXdGN7foZ2eXqTh3yLaISYeGl6l4GGZ49pgZVxXH6Cc3qdnnmpa4eLm2xzZF+uiFx4nXFwYnleinaDhpyEgW9kiKtub319kodkkGuKiHRgg4WKd5CCno2AgY2YYnpwao2Nbm2XbnU=","132.8|jZRwcrF8lHVCV3JxZ2tzhn1+YWt1VoCyfD2OxKhbmK9tqISQey1+uYuOT32wLIttmJSfkj56qpGVYoBVjYNoYot0I6lsbJehS6y8XanBooqgt4l/ga6SdLGminl5iJl1uX98ln7yp3d31IpcbIVpmoGCcGyPc2ipvoZcf5yBboRia3CabqqQalK/knGXi6uetlWJp49akJ6PiZ20m7mfpY2Sl5l2kXeKqbZfj2zOlZ6gmXpijq1zoIqEhLF9c5tcjZBuZZOPi3aen32IoneWY3m0nqiShkl4YoGWl6OTZoyahKmQfnGbl3N/iZBupJtxppZYjIJbi5OTcpafcIZrd5mimYZzu4a1ZWdzeXupq4dqdZKQbNN8a6vGdPymSImjdnChi3GPrMGHcZCKa5uIcXyxXHqelmptno56qneMi7BsnY6Jc4a6ZmmKmYVsbZuCnHKTamOSgo2YwoNzdYqBb3JyfZWTeJKKdWumg3qfjW2MnHSJiIJshbZSl1qBjHqalm2Gk5ZwX2SPm4mJg3VziISeu3l/fIh4ioxlcbF4ep1qfYiHfHqOhWSEpFZfXIqJYJaMjaqJkHF9kISSiamEdouHiHqlen2EmniPgm1wvmR7mJFwjJCHhHaShm6annp/iIp8j4Vtc2hte6Byc4mEcaSBhnOFlldcWoGGcY+JfYNSmnVcY4GqiqN+eX6HjmGKeICCiX2PjWx2lGV6m31/hZRnrIOFgIOlX3ZihoRxmIqDjHB+flx1f4aIo4OCeYSKVJV7eoeUe5Z5dXKZZ3qbd3OOlHSUgYNyhpB/YnqWh32TkHuJcoBgbGZ7iYg=","141.5|hZefcWKdepxmon6MXoOXkJiutLOTe6hji5toVGLkn6yFhGpoQpSR3JSjxZ2WDcPfoZQXstQAZqdWXnusjLJlnIyJZoh3qaW/jmO2jGFTZKGiw6GJf7F6kcijtqViW6DJarSAys/QZpGBCIufcnFipYB8eK6eh5loUaJeiWp0q7Ftj2gbbKeIumptgpen1JNripKDZ2/Cl9CndmRtgZqNvn13op+HU4CwgZKEq5i1b3t0fWaRbpFuUH+Ih6iam6OetXN3jJR0baabsoydMJxxv/SeW3WlnzqOjZaClbKKm4Nwi7+TbYpOWmxPsHGDm6CbpJuvdKyPc29vk5iCinhUsnQqe395lJGDPFjJyEiziXyUVXCRJmdvjKNwcDojWZVkVZqirqdymLuoj3hznV2aeYFeN3denI1ggnmHo4GTdbKLaXd2s4Z1enR/s2iYbIOJoMJ8TFyLgEyQhZGNTneUpIB1flZad2Cob3R/joaXoJV0lYdoeHikhZtlgm2Kd5d4o1N3k45VS5iihoungotcd5FojXyAhVB5UUmMe4JohpmVpHicm3VydqWEnmR5ZIN5fsOTea6lnYBSg39piIeAjHN6jmtNe4JrT3hSoZd0iXeHnLCPdmqugIyBd4BhUHt+i216YIuAenphg5l9k3ichnp7d5iFnGZrSo5wjnyOapzEmV1OjoiLioKGhV93iHZsen6RdHpniYZ8fYiKmXuceXuGZHt5jIWJaodZiHOGpJNvpq6CdVGKiG+Knoewa36afmR8f31geVGAgHNwa4iXh414l4tzfnuRh5GHcHyedYOcpnaeoE1mYo2HcoQ=","134.7|eoliy13QX4VNiHl3oG1tfXh+jJKIr3Rcd4yomVwXlK973I1qYVHCRpXEj6H8dtuCuW5lm0XHYbeexHeHu2Och2q3Zax5iZvMYGFegsuWW8SVqpXDfrR/VkdrqbqEwmh2vHiDqYA5ZciWwYGEhHaax4F+eIyZb3RipJNqvWrIkJthhoCLkqaTZZe0q4SYs65rm4WmimupkH+T2LOskKGGVq3En6Cvf5Gcla6llGtYb6mupXN/j2+MkICShp6Thpyyh3CVg62GcEeSs4KFnZ2DjVeNsaKgncGeoYejbqSNaoZrqYe8dYGFjY+LgZ2PlIWJqM1bcpmFooRtuo1+dal6fIl3uImZN5zAunaXZK1aln5tkHKewZ50faOVjLl8gaqbe4WesXhytHaXc4NynaO2cH+Zf4WIb3+DlW+Ao62JdJ6DsYF2mIN6cpmVonmFgoeRnIeErHl5eqySnIl1XHSNfZZ0f5pvh42LhojJcH6ZrXh3lYSygHmNhJphk46MhIeqan2amW6hdHeRhnl0gXZyd4Cxl3Z8jHKFmZBuhI+EgJWQZ3qUicWEdZeCj2WjcZGBemZxjmualaSJiISPdn+Bf193dJiZen2rZoeQcXeCj6CBl3J0do19gXaGeJuwiXuAlHKDinlxhoNTf5qofXaAg5+CdoeEj2OmfY19ip5zempzhZ95boOEeHuFdmd6hJmHeH6QdIKKeXSBi3OAlH6FeZV+p4J9joOaaYuGk35yZG6BY5iYiHd6hYx3e4WDb3h9qIt7gYxsg4x8c4eXdYCRgnR3mIGPf3iLhY2AmnOifnJocXZqnX2cgXOCiXk=","128.7|fXGBo2+ganWgkqeabZiYh5GecpeUZ5ami5bJa2f8aJeam5CikWeSrIqWT6+Kk8a+koecZbhfb5r3YH6XwpRsomuDsKuHlIBuWaKKjb1qa5RkjIiaepR7ZYaaoGVqrZOsjGt+Ya5ec5iLq3WReYJuc4CAh5mDfnqeoG+flnaQjXWFhdiIcoqEeIiWtIl6xtWWqY1wbnKPbpt8i5CudkGln5WRnJGiy6e5mbuJbJGBd5mje4yRo6F1n4O7tIHAiXdzdoqTiaN7dK5kuI+PxIp+haiSk5uHpqyIgKCPhJt3k6F6gJmihISTmXSUmbCWpJeOcahzin2Mq3Z1hWyHipRTaHuanX+ksIuRqo2XlLKDaH2Og3mJsLR/jZuxdrmQaYWsmox2nnuKnnBziJp5j4meg3+ueniWfYi1qZeCa2iriJuHkHt6bXl4j4l1i4JljpKIi3x2oJGTl3yHlXqHlnl7dWCAf6KFe5CTlYC/hYFwmHuHkYaafHh9eJKcjW17e2qakniZiWabim6aioBmfIeWe3uWnn5/koh6mZhyf4eihW+Sg4WhlJB1eX96opqNa45/ZIiJlJF5joebnIRpfnZ+iJh8jHiQfX+DjHuYapWCqYmAbpV8i45ne4eKen2bhn9/joJ6j211fX52gHJsfYeHhJx/enx6hJuGdXqBbY6Hf6FQh5WReIdqgnZ8hIx8hnOIgYJ8gnyNcHZ+i4eCb4KChY+Jpnt7eXqKlXJwj4B3fIqAl21/hpCBhHR/cHyHjHxqd4t/gI+Ge5V5d3SRgoFuhoSLiISUfnx9d4J+jlyKf3d9jnqOc2yRjnyEc30=","131.4|gZNwX6pugHm/k3ttoHZoZ3uCbp2PoXdhk1iPlGzCbZiXcaOWZo97P5SM4JdvFi1QjoxZozbaoYd/nI2PkmickJL8MZ9tmnOCvGOvZmGSb4hlYX5zgap5b7NjjZeJZG1WcK2BrXvYoIV4QJSObnibhX+Bb513eJhkWY94aZRbeYCbh4qHkq2YsEjVhpBwWpNtrmR+jHaRcplyaKSla5OLTYljg5Zoj42QjZaSmmnJjnmhbnWIfnmNnml9eopmi2yeuHVVbqaFeZVoiIl+io9rgJygknKJjz2YkWmDkIKBZnCUibSDhIRweo96hm13hpeQYaKcc6BpfId1k2uEjHOesWd4tYR1vIuoVGd+f2amhoNtmI2CZY2ChqenjbCemHaZWI9klKF0rluWcJKJe5h/joGTkoSYk4xQrGeEXZGkdcJxhoR8iHqdlWCIo2uUpIaDjJanW2iRbI2FholwXoaBvJGEgIV0hI+IiGaEeYNndY94kHSFgX2XeYinjIaBbZejZFWBlqJkZIh4hIVwf3Zjg3qMo4aAh22Fcm2Naa5pgmSQpnmDeXqIe5h7bKNonoFygqVxbrWJjGp3eG+QiGd8eJ6Ef4pnhH+YaoSKfnRqeXODZ4iRdn11fXWOhmWNdYCAiHyFkH6CbMtbgmSkkHeEdHZ+fox6dKBYpH9ulJZxaZCKm2hgY3Gkh2uEe2uGgotyhYGEeIOJkYZziXmDan6UeY9tZoB8n3x9mHCThHJzlGlxmJ2ueV93eoqFaIJ/dIN+nnGAfn5zgot3g3mIbIVlf4x2oHpngXyKdnh9aISXc3yUcnyQon9kb2x+i4Q=","132.7|kHiKjK2Cf5K/XIl5ooKlt4J1YWiYbnduaa6YbawAhHCMrXSCU8J1sXOMLoS6VH6siXa8i5uMpna8OXhSKJSfhGZQXWuIap1pf3CUmItwo4aKip+Jen5nnpqYe118pZKhs2R7oKRhpX55XXhdwIucXICAiGqamWluiomJfJiHeGaZbYmFkHFkrKVeinOflq51W528e5RyhJOknYiGXHWQoX6bdpd0elCGjp5tkoh5kIyReWxkgrSNnm6VmICIcZaMY3eEkKB7jkqJxo9ylGlfiIR4g5aPg45Of62FcXV3iXmWgoGBc3GMjI2Xh6mZlJB0lZZsdm6Zjn2ViYaci4JefFmzfqWxZ11fbFWPmI2IV3SJc5CCdoBybJt9jY2Knn6Fj3OVg191hnOXhXaKWYB2i3qLkISXd3S8dLt8lHeUeWmMmnyKgoCPgGtVYmJ2bnd3jEaYcGFin5Z4foeIaIh0eouHfpeTg5J7h4eOoXuOk215eYyTfIdzgIt0gnSFaoaFlIWKgqF1V36PgndsfIV8iXqQeYZ6qH2Hn4aKh3iseo5+b3l1jH2BiW9/mnKAh2J0f4WJfXiBhIJ0g4WQeXZ5iXGHbmxug35wrIafhJR/lYV3j5RxeXxseIWBhm58iYV+ko2CkH2OiGjFeo90bXluioJ/iXeAf2+HgH9nd4WJhWVGh3ZaaYSFd3WEgXSCgWp6hX+AjYGQZ4qFg5R7jIhre3KRk36DfYCMcZd8W26Njo1+a1ltfVdzhI15YoRrdoGCWneCfWOXgZt7jHh/j3uNjHZ7cImXfoN5g36Hl2R4b4qLjINxXIWKZ2+Ii3g=","145.1|SGmwpL2QxsDqe8fNTPzNopvo6V+WgLSvuauLxkGAUteksLCXma7Tq4eil02IzaHKopkpU3TKsJeFnoR30J1QcWMv3opvZmyqTadTmbXISJNMmHqZgYN0VViMp5ZpqZiy0YaAQKHzsJeWyXN8UXpWr318c2R1YIGjbWe1kqCbpYKzgnKcYYuJc7UnjXBo4pKWvqCLqmB8WZxylafHY13VoYeonnSZp8jbrGa3WIDHmKJ2eoqCvpJnfYmiopN7bmU+1I6QlY+OZZFLbo2lobpppbieabmsoVq+jGSjnLZlgJmfqLqNin5gcWSAf7aDnYJuSaBuja+Zi5VfjFxmmayRimVXVp59xqWdNpGWvX56bHyDopSmnZKOgaC3ZE1ZsIdeV29SnYqHsWhjhJ2MYUiXloB5qnKFWZ+VoZ55Q3hPiK6SiY5yhXQ/pYiJm3+JfZp5iaM+S5WphZWOnW2Eoo+ET1+SfIZ7cHhtoZWlgHZRlX+KnY6Ih3VodGa/kZt0aldnkmiilD1Pl3KGmYZ3dYOdjZZxiY+AooR2e3CRjnxweU9mfISQm5OTcGB5brmCl5x3imCKgox9c3yKk4aPjGl6i5+KmYefiX9lcXR8go+MaHp5WIGBi35oeYGeiHlqd4Z+j3h2enWLi3uReVB+hYqjjbGAcXJ1ird/n3R/lWiIb3N/TVqbeYmPi2d6f5iGjXSRjH5zdXd4XZGJVYd5W3mKhIKRmoR5enZ8qZiann9mZ5Jye49CcJqEkIaIZHWmkYWKXJGEfJR1fHeEg5lraXtYeH+KmYipgHl0c4mCj3yFemtmlHV7iWJ8jX+SiYQ=","139.8|L4l74G/ygqnJiHeKa5aQnoWhqK6vvZ1xf1WIvGrpcras0JmWmMPEPI/ZsYnChZnXzIAzekRFctqWqJGEk8RtZWvym5xzpZbaYHGlasW1cNFznZ/RfJqDVJJr3bF6rG+lq2R8bsSdcs59/YiEXYhux36Ec6mXjJhxonaP0nDHoJ+niH26c5CNg7KIspuM26p1oWTHmnHIc7OPuc20ZFrBUcK5oqnPo4S5p5x+dWuXeLqmwo6ClsBzep+jo7CjlYbAz3upbq+Ndapz6KDJsrFvpLe0vq2kv5OXrLSmdbh2a691stDbi4R6nHWYsKe1x8SZfMWaecFpy5B0rneykahQqGtiqZGNeY6FjGG/s5mHbIRvfHirkPaOgarLddhvnbCYW5h/uql40Wl1cJJ4tJHikICkn3t2iJSfrryFc8CzfLhuoIt7oHyyk6mLr3xjYZeInXWLhGlysnCdqnx2inidbbCKgZuceHx+ipXBpYh0zpR6oXK2hXiWfL+ajW2SdJmnZnahhZd/X5S1nXqMfHSPepWU3It8mIx5jrWBjZqOiXSRonqZiKCIepd9k5yheZB2b31wjXmMjJeal5pleIV9hop5nmy2iICIpXd/koyIupKHd6KTe4hmfHaOeqSLyoZ8jox9eYd9jmyUhni9k3qKdrOGe4t9jpqfco17cJxyd4tNgnpjaZxjeX57eYJ5j1qfhXyNiXt7c36HkH6Gc5SgfohzvoV9fn6blI90jHpvjGtwgXCBfmNznm55cn+BhHuHVp+CfImZe3R9e4qgbod1jJN5n3WkfX2JfIuCpmqSdnOGcXiHc36Oa3CSb3k=","136.7|dV+fvZ66mXtmoIWgmpWjynh8baR4bZ6iX6qyklD9WYlqtZl+R6Joz4Sub1mD4zx3np+BdipSmaiUYmGkhY6WR3VJyWaLm5lSpZ+PknKLVqFSOoy2ekxkhJycvWaHyqGCl4t7kXw5lax4QWacQ4aVSX97iaSPnXKbm4KEpY2a54Jnj2qRjHB4lqYrepCW4XGPvpW6gWmiY66RlJyQbUeNtZ+vnpm8WnWEn5JihmdojqGiXGiWl6SKgHyVpY+okJyRXoyYjZiGbbNYWXqujWdzlKWhpK+ApK9glJ6YmHt3Y7CNiKOPZolWnYqNjYOpioqWppFxh7KQhX9llWOwdaBGiGxyeJWGp29biJWvd7mfUGpsgomal6xkkZbEiyVZhH2riJSjfGiInWWMdJqHlmxteYJ2iIaDiHmfooyHqYZ+h6aIfIJxfXWQZplDeG5jgamYlnWnhJCZqW6BdIR0noeFi3t3fpqMg3lql4+Hl4SgiXeFjIOjgnd9dmVUjGpteZSHnpSnaa18i3+Jkohle3WfiJJ4rXh/iY6GlaqIh0CghaOLcoOaqXR8dX16d1Wna4Z7gYGNmIZ8mJSWmJBpinB8eZOBoXdmeX9yoYV0e5mNhpWDm592iodveXOLgoh8mXyAeIaBeYqOi3OrgaB6dYaRhZaBdn12YFifamVyYYiLkptwlHacf4xch2yBeJKFiXx1eIFnh4N5Z4eHcoOEm45/g36Rhn16enlnYY1oi3WKipCLknhyfJyGhXGLYH+BjoNyYnN5goWUgHKBi3JqjYSakXeJkIuRgHqBdWuBplhpdn+FnouPdZaiioOFcYQ=","137.7|aZySfG5tgZlHZqGJtaOVhXusfbadnpu6xIyhwGypcKajo2p8a+5ry4yR1XxqUGmzhIFCquXEcHJR5YRjPX2unXKveU5nrHpC27JpgzO+an1xL4eGfoOLsnahnXCcvZewsmOAt4GXcYJm6Y9pvnewYH98bLKAnZivSZSedHBhlFhkcnhwmomQtYNCb5pzhXeeZYhuo3NrcY95c2R0lmSDs3+ZeWV/km6HuXqYoZrLcp51b3xzfDCWWmqAZJVylW9trJFnhliOd5lvPpSZZGmMmZSAfLiJf22AhWWig4CWopdzgHZSfXZ8aZhfaZeWbXmgW3i9jXCEV5N0j3Zhk5OdkJNcU5xS/V2XQ25Vv6KBeIaZl3aUN3l8dHp3l2VzpWxjSp1ncZOPbVyZj657TF5tcXxEg4hfaIpZdkqKWFh9i32FT4t3aHtjn1tzZnuQtXtvoFSZUXdcjJljdYmPmHZ3i3R1fW1qimJXaYA2eolhaZKKdYGJhndof1u0qZiCippZnmexjJhXcHNto3t6g4ydeYpqkniAfWuKUTmUemt5hWCCi4hwYmyLeWV8Q7B+s1uFgm+Pbbdre3eCVH2TgVKEhbd6lIKueHt5XopsfGV4an2IY5SQj313hIune0t4wnl/fG+FaGqOfcBhhGBnkIx5hGmCeW55XKlzqIV8h2KKa44vkF1xXn2RgWWDhpF6mJicd3+BboRpcI2BcHSEZIN8hI59UIJ9i3tcqIybV3+Mf5xymll9cnNxhIh8XoaEiXtviJp6fF9ohVuAhI10aoVjgY6Oh4Bnf3lxfWiBnoR7gI98kHuOV4+KfWd+iYE=","128.5|cZGCmWqPe29Gd4CQaIOJoIyKhp+QYpCleLFzrmMAZnGZnaVxb2SNWnWdnni3qsCwkZSqm22dbo2ItH11aq1oYIablZNvmHedd6GHmaCsZZVhnICUfXGMeXBxh5eFhniXs4t6o5mabpV1yYl3XoppoH+Dc6B7dHCdkYyEqHGrenZkfIK1bX1/fKeSio9xe4GTpJt9nG56bG94u4CLmLhlYI2rj4WTgH5zlHxwlX+Gc5qGinZ9bdBzqJNxlXVckG98XoyGk3aKcU1jjYqdgnOQf2Vzhpp4immAcKeelIiTfIl0r3eRfHyPjnGZjWV4pHmSZYheiH+Zno9vkmqniqd+b5WigJWqWGyGTnaIi2eIpIV+e3WfoKd5e3J+ctGHbbp3dJJqf2yIiaiLgIR3jZ6QcICScHijhVSJcG6DYIt3hmWPool2l3iulaJpjH97U3iEkVp8WHKkfp2WboOAbnaiWnl1foGbeZeAYI+WXINmn3aIi4iYhnhzeaejlXlyiXqKcY+Qg4ZeeYCvl4iGg394fKOIgXh/kJh5mqxojJBdiGaGY4ZnrXSIeW97d6CgXYOEel92l2+BfJ2Ioo+UipqEem18jnegeoB9rnadj3CKqniDaHBxiW6IgX+ReqaJjXp9jpJ5lZBtjFtFgmaIeoV8iYyBeXx5jKCianeCeIZ2iFubdWNxkouXiJGBf3V7kGiReIB1jnqRjW2Hk2uDaX2Fg16SpIJ6gnuSl6xyhIF/em2JZ4V3dnGFi4yGkoJ5eXuiVJB7foCbfZ+Pco2VcIVrc3OKZ4yQfXp6d4mAinx7gn10cn1viI99dIeOjok=","145|jYZyqJGQna0/bYKbWZlxmpy6i1d4tJq0SJq9XbxEpKFsdZFpSrubznR1snOBQaSLcZWboFxAjnikr2pvc5lejVn8cbOQXI+Eiq+jjXRctHmqboWHh5NxeaCxa4lsfKOSiXCAuqd7knF77nlySYJjkHyBkFiMmJqsf5unu4W5h7pYcIWeaJOQtLG0nmqSsbuijZM6bZxznL2HmKWNiqrMtoaYlqSa5JWfbL2PonRghoyonmRyaM1sZ3ydloWqZZiLypCNjJd2mWWl5HanY496oPGmkIl+m5u3mqKRj5SKeJqInu7EaXeGimuZknmJspNppXmlkKONenCbZ5qNb5OnoX+vhYmMRoGZgE6mt3Cbknl1cIWIeLtueYahb9VBym2Pu2eemKeRq6+hd36GjYHGb3uAmnVpc6S7s7p1on7gibWKS3aPhYdtaa2NunB9foVjc4uOfl1+iGl9jIt7noSjnX9zf2KTdmqOcoqKinibipWNnoabeopth35bhKaCgH97oHSFg2GAXZiqkIOjf3mff62Nw3d6mHp3X2N7jrand5qdsImao3J1i2uFq1qZiXJ/cZuUcUx7pI50fntxgJeCgWOClY2td32ApHRge5p+n455lreUjop7fn2Bf6yYwHt/lYh5amhxhtORd5mPkYyJh2t+jHWHd2Kkm4F1g3eLd2k5qYNTcn9sh52EfpKEin+bdoGAiHlqY3yJj4R5lJSah4eDfHyEZIZtZq+Vb3d2ho5vZWuPf1F6f3KAlIiKioGYkJh5gnKMe1l9dqOZj3qTmZKNm4NjfIR0h3+BoqeRfHuIonlqamyMZG+FdoI=","137|jmmnZ1SMm6Q1bpGBl5Gopp1UvKGRw5ZQkm+wjlGPlI2bdbytPeSVkKR7rzmXsoE+fWL9kjYcXIDCZVxshmGTXWSOoUp4mXJyklmgdT6PWYOVc3pzgnlpoauMfIV8XoRaRox+qW1PXXhhKmZwqoeTc39+ep11iotano9iaGhqjq5fd5TFjWCTmoiQjpBtSLtngXaeimylkYdzdI6ggNqRj5tejHBll6SFaHWJmGtibmtXjltxvpGMeqKJrnZjkGqlh3OseH6DcXmRfopkz4t2mqexm1CLfJqhknFgZF9+aGFpboZrYHm3nYqIc6SSbmuWXXuncpl2goZogY2GjFuQcHu9V4SVRoVsu5aMe3mMk2xtVW1laVBkdYl9ioqjsZC8oZBjdItyhZmZc2NznXpfcX2WtIZ5b5V8hHmFWo2Scnp6kINzkYGRlnmMYnGqxLSefpKArZyLemd+i4l2WHh6jpRzf2+XhIZ4l4BIWINhZox1gHx0gHmcgISmZYGCgIZajJdmdqOoqrhTaHV1fHVfd2NcSHd9gJmEc2yGfJldhGCKpXh6Y0yHdKGCZ6hrloF7vZmEjHuQY3iffHdocn19cmR1ZWhYeH55mYWCb3x/dX2EY5OHcpKLfnVqdoZYcXp+gouDhWaFfZhrhmCKhHGGe2l/d4+De6Ryo4Jzn2qDio2ofZ+YjnZvdXqDe2V2b2Nud32Bh4KHaYR7dYWDaHp4eIN1YH58goN4nGSSfnaMmo2Lf5R2hpqDdHV1coRlbnx7Zmp9fIWNgoh5gY13e4Jme4h0f3lsf3iOgnZ+cIR1eoiUf4yJlH59k4x6cnY=","132.5|YY6OpnOUoYBaoIuZdIKXtpmjoXSMdrF0lKmGbqc2krKAqMmno2GoPZGnhY+5j4K3nnhldkLHdZLBmoyfobxxcZ20fpptdJhYh3akk61voqWOWJmfe72YZJxll2uFn2yTwnh9Z7ahdp1+lImXy4l3YYCEbXOXb212r3SGpHmns2lskKCadpyao4CAm3eWoYt4mJifeJGIjpWSwbXPmFiAUJ+1p5CYh4ONppSTcGmXeqScmZCLrbh4iq2NnqSRepiGeHuIj5x9kWSMfIibso6ZeJucs6iTknKRfqerd6CIaJB6uJifioedlXeLoIae1Ll3pLNXeouRtnuToIigfKtXj5xyspyNbIqmUHabt3iRaolrl3ulpf2GhbW4ebOtWK+VXXegk196tXVvcY14fJtsdYLGjHuFrGd/nYB8m39jepuLnXqKlYCZd5GLkI5leoSRlHORV21wmqaIp3t0eXyVgZZ3f7SXfId2hHuipX2VgnJ7kIaafoZ3hJprnGSejoamZIedjZFeZl+omXtngXSKfZuN0Xx/roF7kqxxfmqDfJRwi36Ml4d7iHWBZ22daZeLbYpvoHJ3g5WQg5SYfHmCgXh7iHiNfH2gpnmQl3d9yX98kntwe5hxhXWYe5CVk3uAsIx6g593flB4fpSBc3qFhaWAh3uDcG6bZ6GPbplzgmNdgmRpX5GTe3l9eXp8j26JfH2hi36FmXWAjXB9jn15fZuIqH+BhYF/caxij4p1jm2Ia3eOdWlzlYt9cHt6e36VdYp6fpKVfoeJd3KSaoCOenh5k4iSfYV8gXCCoVCjjXaMZ4JxepGLc2eRjoA=","150.2|icZ7m1lZfpSysXqar3uSn5OYg1eAM6Rccd1xPLYHZH5sd5JWq36bxGZ0q25oTvHWdaLXp6P5YV6ttLO8XbGmQ4pVUmhuWqGdjF6xsXw4rnJbdZN6fY6ltrmTaZxzfqTAuIR5kLzkYHVq/bWtYIaknICBc1eVkmhhw49Lk2atn5OUmXCCmH9/foZHgmmisYBpjLrDTJVxbZCfqGpolII8qo2ynJyBRGhlmpRkiJGlbJxqbJ2iZ+OQw4N2n45Sap2JM3BoqIpsjFNcOn2yfFyNiZ+IjZ+LkF1QbbKgpH6Qio1pxJeSoY5ui5WYnVqJvm1nqIlwcX6xiGSUhGW1e55XgY2vZ8qgXWBsLFx94V+ytpyLtW2gZsGgm5dNkNV4Rrl8bGmlklpwgb+GipNzXpWAh4KJNonApySKYWx2rYKedFaZ0W6GmHOqaaJLqohrN3mNdkV1PVOjiKefOoSJtnaqWIaIf3eHiKuOQoOKfXijjG53dpShd4Zed5VWk16DfZ+BmZGbhI9KSY6ynY2MhYetdqtyhoaAo4OMnZFVhXlgeqJqe3hK1Vptilh6aFSUMIGAa4KGpE6fkpZUrJ+WkZyFeox3qlS4hX5nqYWxqXWDyop2ootodmKbhYWedpZ6q4KDo4KDoqBihTV8eZ9ndHZrknN3hXZ4eFWfPoCDY4KElzmjhFBLm5aNjZ6Ah595j0Cgg319hYafmWOEll12n4KNeEadlHqEcHuNW7FXgH+SnYKWVHxhb0eKi4mPnIFnkH2jLZ2DgXeTgrWIanl0cXedfGp1RJqTeoJtc3yEpm2LgJOToIdbfX2BXJKRjY4=","141.2|bomKrLaUZX+EWHFyrI6RkF2M0GGS7Gm0dJUxs7aaZZudrUZYvOdysXGUOVeIjGh4j3RTbNFrqowubpRTnVGnbnghZ1OPZGzhNbBPiIyzsIxeqnuagImZtFKOj65psoSHuUN8VltWpYx3/IdfS3Khxn5+iGV2u3amWmuAm5+eoHyIbDJwlId5TrYXjmto8EyZiI+GopZlacJuqox8kEeVpJywbXKma2ugtFFrZZl4la12oZpkYXaQS4OWhp6hcWbCrJKqinWJk5BkpY2cZpWWs2aUbrebq79VqHmhYoaCl6ubh7qmlHJJZ5N7kKKjkaNwV5aMjMaKdJSbamp5ipuysJNQNZeKpoBTxHeYvcFbHouZhJWEWqWRbXOijUhJ3HJZgnBeuH2IlV1pjqOIfkXDgXpViohTiaykfLR2U7VLi7SEdoqMVHhTmpCEo5OKXX1uiW9EtXpVlp6GaXeProx/MqeBe2VmhWN8jZSLqHhew3uIioN/hYdnemywnpiGiJRPmH2gilKvZKFkmXJtf4upjHt9o4J+nmqIa3+PioykfF2UY4GbmoWNjGB6m66KqY2Ge0uJdHOOi4WGaHVwdFuCj5eInWS0g35gWIhJlpiMgZ16YaJ6kH9tf4uQiHxkr4KAkWqGZo2QkIW7el6geYmAh5qBinB3kK2JpoGPi2GDeJw7WKRzYXdvdGV5hp+GjVWdf31/cYRmf5CKjZZ7YJaOg4uLm4OEbnqGn2+nj4uVaIhyjW5MjHVufnlzVnyjlINnP59/fIdghE6TiZNyhXxgpW+QpImXgYVxeI5+lJCLjZBqoHl9Z2KWeWp3eHc=","141.9|oJN9VlJtg3zLiJWgZJRyToWNx62dfpJ4xF2SR8BrloOQdaiLvoqbOrRzrpiCbf3IgW7TZd4eWn6flp6RxJNmroS3xY1ro59sWXmEbGBHtnqVPKJyhoqWcoRdf55qb2OljYV9Ub7sXWZyxZSHpG5qjX6BcKyhZ5t1nG+Jd2JpbrGfgcBsb4aVUjujkJShZYF6im1DWZ1okGmrbnl+f1ZsUIV1eWN139OplIisZJzLaIVjgKGEoA9vsJaDhmqJkZp8n3txd4hxl3aZP5CHxrKFiKGBjI9/kD2Mg2uLd4x+n4hlbVl2noHBenCTgIpohF+Yk3egeYdsrmqhYY9XiX2zWoGpdWSitqePPKdotl51xJCcVG+NeGiahXRYcPzLYpGSj5mZj5x5bYZojnpze5R8kX6olHSoh4xiaVaIlY+OfVh8j26QfIdogXfdc42StnB+nLVQSaO1d1iLaXKPZHGAepSMfWZdd6GJdmdxY4ONSZV6fXxweItohnRwiquNfXtvYop8nzlTsKRwkn6cgYtrc5JqS4R+XH95c3xsarBWiJJqnXhvU1lyimCEQGtvgZOAkn9vka9/aniPh4qCf3Z+fnt2imuig4CkQ3yomWlygoGGk2WUf32dfIyHd3hvbYR+d2h5nXlraIFPgpGNlHuFe1J7jnSDYXBohJKJiXFwhZPJelumpouWf4h7h3h4j2aKhX6PaHublnFxlmWGkXCEe3xuXnuEfoVqdImSloZ1eGqQnJWQcqiRhoF+lXt8b3iTkpCCgZtZeqt2cn2OZ4aOcI5/anNTf4VxgG+HZZqHgniBaoiYmk9lnJqJgno=","138.7|mZtlSlVScZTAcm5rom5NKWeBqKV5VWteo1NMpK1yb26GNmpLcneYLmlly39GtPzgaGR7ns3zWWJvtpZstX6eUGRFxid0nWKfbWJzZFajpWlmbWxSfXSBcn1OXK6EFmGsKmZ7raTUX2ly0ZdzT26dqH96dKFmb49jlIpaRWZbemibeI5bkV1VVpZAcZBjSGJtbWa1mZWUdTxjVXR4bEZJSH1DaWVJBnl4SkCRl5eMa0YvZHx8kC2Ql2N2eW1wkWBrcHSGbGaKj3BpEHpol4hwdGR6bC6QjDpdbFhTZYaFl0NokWtyi3tnd4+BW2uSa06RXHaOcYdqdI6Wh211f1F2X2h3Y2hXUH1eXpt1rCdxtYaUqm9riXyHeYdIjZF0VoZ3ZZFdg5R0c2qTkGZycnKAjX59boeVYn1nSkiDWoeRdl93uYeMmXuEi2eEfmx3g36NWHtdaJ2Ic4V/UomPc3WKdGiHfn5fh5xVcI1eboViao51aHmJgYiVfXKeY35vb4hzXolQhK5tnoZwXnSZgopjdHFNa4V+ZICGj3F2h2xohWJmeHVlbm+JiZp6R5xhU5B0c2puh2p+WmRtk4+Kb3WBeXV3dl6khn2GQ4emim6JfIyEZWOOdm6RfoxreXNQooOBb2mCm3NtikR6g2V4jXZ0eHWCiI56d55nXHBvd4FviV6pV22nmIyXdYJ+hXR6bmCMg39yboGWZHeHUG2GZnqMd2Nub3+EiX9+mHB+k3d4hGmEbo5neaiJj4h0lIJxe3iJWJKBfIxfg6l+c4peeYRodH54Y3aIf4SMdn15ZIF4dnd/aYNsjXlhnJKKhXY=","133.3|hHqIp6PCpYB7WHqGWoF2fJKHjVt4SYGqdLZsr1F4k3VspsSmq4phcImenlq8NGlJlYb8YzMqnZ2ikIVSO3dfRH44UF2GYpJmeaWSm3uuWZ2XUomofmWOl6hunHtwk35Vg4J7UWc0naSCh35diohicX98hFyLhXuihnFvqZWseY2GbWSYa3GDb1NolGqTioyWqaGmmmm4jpKOrnWoi2eXcHSdnYioeJaKfXh9Y2hQkYlraZJneaNsnISDn3GfbZVkkI+AmYiMb3KXPHuPom6QfKqPe3yOg8Foe3qDiZt+Z4iUk5pqi3Z2k2uViJ9+hoZronaBiq2bcpBonI+UdZJzi5LHbKF/P3aGo3OMboqUq4RtYY6JXJiIboizb4d4iHF8uWqZfW+JeKxpcW+JrnRtgHt+XHabj5ObZ596oW6YiIOMxIx1noWJbKJgbZB5T3h8jHtlnXC2gXKNrHF1boiManyBfIOXd5iFb2qBg3uXd3yHk4xqhneQgmdce292iX57eYCFf3yWdJp7eYOYf3R6hoxuioJ+ipF2m32CcX1+eJlvi4Ohmm6LdpaAcV2pcXWHg4x6k2p+c51imn5tgpyAdmuEe3eHgH9BpnaQfJlze4J4loV6jI+UgnR3hqRUeIB/eY94ln6HcUajd5pue4mWiaSAc4SFZ16oZ3aOdn95i3yfY5Fpkn1jhJx5eG6EfliLgIFzjHqSg4J0aYB6lX57gISQhIJ7e4RyZ5hpc4qIknWJc31dg2eKdnWDnHp1d4WFT4KAfW+Ue59yhXZhg3qTg3iMho2XgnqJhW6AooVpiIaNgot9fXqbdYp7coU=","141.5|k39ohoF7gYYxVXx3p4GCVHOfWVl3ioxSmTa8r0q8lIWGp1tki/ujynKIYpaTP1bPmJqtedF8g5ZxV3lSFbKjgXd6RZBuYV55VFeSWKeuUImaMmiHg22KuoCpiZ54tJ+8vod6d6jUfYVwz3pYjYqhgn+BdF9mr4BetoaJgX18YF9eb1r8lGR6P2k9c25fgIJnUlRNn2hSkmpdi2BqpKWMtXqKm3yFtFl4wak+eJjBfqKDhn5hlmGRj3xTVo1ZbmOwdXBYZJiIbaGYS3p+r3WYiamGlrOFi4CFaGGXlXSJmKB7an6GeXK6lJOGeo1qpGhpWGORcV1YnJJlYJNugJCgYZy0c7dn3HGbrWZUrM2Li3+Vh3+HTH96aXpwk9Cut2VhaWxbiXNzbql4jKN/fH2IbH6Ig4iNkUaKi412WqOmdD5qrIpwR4R9jnl+ZpSZjZtRlV98pGRrnZqtZ3uMmYBvTspyfV6hhYZ+VHaMeXtlhoJ0c3KghXlpg3WgmHx7j6GEnneOi2Gda413mYeIg4mKfXSFYnd/eZOIfIxqeKOIeWCFfHZHVFqLdGKAap9qklmJeWaSe56XlG98f42Fi36Cf6yBiHyieHxgoYiClFR6sXZ8YqF8c3N8hImPgHCOo3h8bZCGgnl0e6SQeWGvgXpccXiCdXCGXaBmrHmQioGNgahkhpVgb4qKioJ8hZJ8ileIdXx2iYOAnG5/s296aI18dmxmtIJ6eoNqmFSXU4uSf5l7mmtph11zjoOIdX9miYJnNZF5fViZgnx5dISYeXxng31zXWyafndyhml3WpRtipR2i3qTbn5ybWmGhIs=","137.9|d6Bjn7uFd5G+WnV9uah1cI7EcWGGgKC0hoT9yV7Fob55b5lL3U/UxG5+dYwf54m4jI9fak6rr4XYqqpZ+Ziwi39tz7iMYpx0R69ShNbHYIerOJSSfJ6qTE+cj6dujZmrfHyFTKWdrYik3Z5hV3uuin5+i16XcnusxGuaj6CQwISXa4SEnKagbK9odGye57efk4NzomqFnHedYoaFjS6Fsol8zY+Lx7GGfcOhZHCIl4ikc7RqrXWXsI6cpaqHa6KfVJCRgcaSbaiidYGUl5aWlq+XnZScnYazoluIjYSGcZigjJjLqHNvnJeRmKqbq2Fpqq1ejpGEnZVre5pwfIV/a5SCoK5zkKXGfJqbvI1yhphxrZWL2rKgcKp4mbGhlo+LgWuntXaMok9fd5GRdXyqjH6ZhompfXnAp2x4rpC/iX6AtI9zgYZCcI2GoJNmmp1pZ6RXf5ikeHOSa3N1uI+MeoiIfqdriZeeiZTacnagln2IdYHEhXh3hmRYgn6AiIWbnomGoDB7l4uFiYJbg3ani42Pl4Z9e4SKrK1skV19eKGdeoeGh6mKdnSFf1t9ZqiFcHWNf2SRoXaUqY11g1uDeo+JlpqogH2XXoyngJGNorF5o4N9jnJjg3iHin+0joN8d3CHl4lqjYlpe5+jgYt4gpWCeICIY12CfoiOcYuMeneken2ekpFzgGB5fqCKfpaRgXyIcoaUem6Jfod6nXyHhm59ooV8codoZX+CpIxwZph0dJd2gJ2KknaFXXqPlYV+kJSAfqNrg6KUcYWIiXudhHSOd32dgHx5iWmChlmYh2xpknJ1l2eGk4+SeH8=","129.6|oW/ff51hq8hAo7bMoOHn28a812Vpxspby2K0XaIqbL5kY8rDmfyvXc9lg694d891e4q8XMTGl3iXfHGmWISbtH2ybrODZ4SDb16Hb7xen3hrinxzgIyEkYF0XnpxaHd2kHmAT3j4lm+NyGufoIOcin+AgWN/i5Bjq3G1iZKZvsFhkG2skMLGh5uSxnCI399qkWyzao9TcKqFg6Wkk2uqarp7opN04oS6gayLY5KtjYuivISUjZqPhculobCmcoqqqXCscY92i2Jq2293upmUqJaZnoWoo2KqrJ6GjZl7lIKRjaaqeIumiI6Kp9S+rK5umJWndqFvsm+RaG6IcYaWkJWclpufcpOPh2ufn32Gf3yNmIuEi8V8kICjjaygtqWPoW+ToZ93l3ZrioWHfXykb4GCp4WBh6Gke595nY+eeI93k3iGcn5yaY2LdpCCf3aNcHZohHCRj5iToXWIl4aGbJd1gnyNhXyci4aokHuWmo12mXiUeoZpeZhYg3yHjIeTcJJ0mF+Eca1tioZ0fYeGhoKXm3h/jIGGhZWLh5mOepaSonqTY5N0h2Z8l1twpm+LlHx4immAhn2Uh3WPgXeAi4OFf4GheH9wjoZ5f5F/oZJ8lY2MdIhvgImFhJOJkHl+goWEe3uIhXyBepWHinaUeZ18hXZ5hF19mYePhoR5f2JfaYFpe3aYg3h5go2GgHGPen+IgIB6fouDkY97k36Deoh0onyCenuAY3OUaYmIeHmBZGJ4f2iAdoiEa3mRiIOJd5B5gHGKg3uGg4uQgHuUhIpzlXKWgIRyeoN9X3yCjIZ9b4JrZnFsdHp0iIE=","130.5|inxRe5OMVle5nV5kW1NciWJMWluQmXCaYb+IapxudmCIjXJ7nIp0VIGH336bLZ5qiW6MZjTbkYaf9IqgNn5dbXv8XnuJY5GwxZaWoGlolYtyupCDe3mBlJJvf5ySiW9nbG1+VYQ1kY9t0oOZtoJgrn+BiV+SgXuTc29WZYxvbnubi4B9aZV7i2Ggf2yPepaObKSqbI6MeYmThYltcKBcYZZxcpZvZVJadJpmZ2leiW2IrpCSZI5siIZ8g4SEb4yrUIeKl3R7imxzqopYRWB1eV9vjWtyb5pojKdwbGF0aWCJd1qPjImCgG2EgHWFhn9pjZSDiHGje3SNlnWUgm1DiHOBnIGhcF6Hr2VvSJuBgIFtl4hyZpKLjXZgbrFziY2XgG2NbIeFe3xqdH2Gb66ki39+cniBkVxycW14jpashWGRg3qJdX+wf15kbISDe2mYd0CypmhXdaJ8Ynd0YIVllpqJf4V8eYeBbHBndnqIln+Heo2MfIOSfKl1fHN7eJSSboV1e7+eVYWcandzfXNng1OPl4V+hXZ1i4d2dH2deoiMi4R1k4V4hph+pXZna219cI10iYSFgnaFfYSQdnx6d3WDYneghX6Vi3mEcXt0iJ15hnF9h3yHfHVwgV2KpIF9inh5iH99dIBse4mHgIZsjGV8hYd9lnViZ32CfI94iIddm5pgeIOJeHR7d22DcYSVgoN8fnqFhXp5lnp7hXt6gnOTeXyClX6ceVRmbYKGj3CFgHCmiF93fIZ3cnlodIJcjZOCgm+AeoNtemKNj3qHfXmHbY1sfoKJepKAdWV4fIeLaIN3dpiDbXSBiH0=","130.1|mJ3FTVNczK6ldJqkmbrUzaWailyVhqugl0eqolNrg56adoeoi5V2x454DEB52p+ufXDtlViRWXN3HpdwWaWXU5ABnFOLXoJST6BOYZCiWXJ8doZggU6Qq0mhc1aIZZSkhnB9kYTSXmh/LJl1bYGWVH5/iVuDnGuatILAYGRamGWSeXWNjp6bfqCDomt7sJ+Rk110lmhXhId6b5exfb2Ss39vnIpepmeEkIp3infCa3+Tg4t1mnWMmauarJWlbXaCk4mgZoqHbnR8do97yp58l3icdIKZiDOBd3yBbJaHc3JodYyLkHqoi4uqlr+dl5VndYl4i5JhtItpanuth3LNcHu9TpaTxYqNUJCRrWxZj4p6d2x2qYqLdZ6oiWS2h4qRq2t2kF+ImZuGeIx0ZnNuh3+ImoOTjqPFiYN3b25wh5BvlYhzan6CjV2SXnyIgppzjIl0XYeEmJB6qoF+a3R6WXODgZCDhJWCfY6KhHpwlnGFg3Jzg3dtgHCai5x7foFhmqSAmHxji219iHmRhXptdXONeYB9aISEn66Ai5Ctem+TTYaRV4qJdG2Ah5lbh3N8gFOPlLGEhWiRhZSSeYiCepZ0f5VcgoBxhISgp42NlYB7cI1yiZKOgH2PeGV7aoKBZICEkZqDiH1+enJpcId+c5uBdnZ/e5lWhXZ6g22NjIxibGqPbZKleIiCfnZ4iYVmg392fYORjX+Fe4Z4coBsg45ukYR6jIF1k2uUd3yTZ5mIl3BYeJF5jol6jYCSd3p9Z22BgHiFgpyVgZF3i3pxfneJjm2agHl3e3Z7YZ5lfo5qk32Nc5JahHOPino=","137|sWCyTK5Cta03rdXAmc+rhbnBdFNwb55MuLnKR0zKlYZmTKCzl3DJv39W8qowpiVEWXE4VKZcpFjopWa0kVKVv5X9tYZ2WpNSr1Gtl15HVFKXQIVUfWl8QrulZ3l3X5VmdJiARGvTpFB3L2KltHGUWIB/elWKX5pYqGuiVpVRr4Vel9RrjIOa5VS7fmmSb8dfcZ9fXWpUkHuQU3R4llKCr39bh4RpvKV0j6y0W4q4lXeaX4SasmiIqmSarXtAapZWbm5hlMFubKqUh3KHjYiUhKWXmoCDcEewfmh6cW15hYiXbIBucouRjYiUgpSJfWljqYuRb1+dkWtkUo54bXKagphwl5KZ1ZuwU6lgoGyWeHmIeZB0kFd3mZJHib/KYVTDTmaidJxxik5mhZCKaIVvcIKUg4KfjoFmo253rHblc3WNgXJxX4J9Y3F/XpB67KmNdZmcXKJ7Z3h+bHSCo4p0xnF0f4djhZiTgXGCf3agX5J0YYmNd3Zogm9QfpB3j4GHm3h9iolhqW9eiH1re4KOi4F+Xnh9fnWEf4WMdJFdeJ5yqXhQemdvdGKDbFVuloKNh7iNa5eBimqSfGJ2e2J/fKeFjoBjeYGnXIKddW18hmp5noGYbnVsfoCJhGmXbnp+dnGAlYSDdNGIdaOQlHNniVR6c3OFZFZkk3aPgYGNd5ahqmSnimOCeWp3gZiGgYtzeH54cYOWi4R4dIN2mnqDeXWKY3p6dYBwXHOMg45loZx3mY+Xd6iCanp6ZHh1kIN4m218gIdkgaJ7gHOFfXqcdJByZotofHdxg3F9bG12i2uci4OUkWVsmYNren8=","134.3|kXVo0FO6b2FCpoWBoWBsalxdaZeNX3NbdWGUUl0yiF2WxnhfRH6GPoizjJnMpILAqZpknJ78WaR7gmeqtpScY2pUrn6LkWyki1+Ab4tSYLJ/gne/eHRsZ3pfoKiX0W6br7R8tJkXXMF/KXOeRoSckX59i5dzcGZjkZF5rmK2kHhjkZ6gjmlgX2w6eIpshkppgm+9YG6khn1wvJ54fqaBTZmvkYOtE2R9i3Vtno5GaJB4aV+YnGaNaXCEaXSVi2yTSm+NcI10blSCUIV/hId4f3OQoJltg8V3f5iTnIKDiIRon2CXZYlyhZF/dU6Ib4OMXI5QcXlvfm1suH+LiqY1dHliknRwQ3tgpZ2QPbaVanOKom6dnGNpk4BtjiQ6f4iTjoxggVx0in2Zh4BxiYh+c4GNkYZxemF8koeEWJZfd251gnN4kH6Tko10jXB9eaqcjISVm6Jwkq2RW4eJbnSBf45wgpiIhWtnkXqLloNlgnJ3aHaOeHWOgYCnjHJ/fouYY4+UWaqTmG2XfYh2f4h9dH5xaXd/fZGHjZhxd0OfgmFuYHh7kIxydZeAjKaoWp57enBwj2OKeZ2Mjo+UjYp8gG94dHtcen+HfoRtaY2CdJyDZWNscoKDfIaBeI5/c3p/goiEamx5eFSqgmKQb3lpdYN8dIiCe6OmZn5yd5BykWaieoypj457i32DgnN5fIFqeoB9iINuand8bYGAZn6BeXR+jnp6jYGNnJBmpHV4gGiJYqx9haiAi4uJeIRzdnl3c257gJiGgmVtfmVxl4Jmf213ZHySfHiJfn57mXB/e3J6b4Noq5+imYWKioc=","129.1|r46viK2Sv5xRbbC4pruoua3DpXeJkK2vsqKnX66knrKTja26i3qPtJ2XhoCOm2qWmo5qiFcBpJq4n4JumKmhpIu/vbCKdm1jkap2knZeqJWjcHeTf4GNTWuanmGEfpKVlnKCgI/ao4uFuoRxtYSiaoCBh3ZzYoGotIHfmJmKr5prdsuAlKWvxV7Ql3dppb6bjpOAZJR7lLZzi52umnbHpZCPo5WV9q+hlp2uf3CxlJCoroF0vo6Scq2zraWsdmdnrI6bkI52k5Ces4WglI2Rj56khZiTnFWzn5eRj6GIcpiYiJWVgHmuj4+Cq6Chqbh5W455i4SSqHCYcJWfjpJ2d5eBkZGVqp68RaGgnl2DcYd1VZGYma6AeJWwkIqqlImWnXhgiH6KmoR6eIaKkZ6Qdn+ProhzkqacuLl9WICIipyLWniOhIaDmYyTZ4tzwZ+Pl4ynUJZ+i2FssH11gYiMmXx3f5qOhm2eonKFuXxha4KKg4iOeoZxhI2niIiCjHuEkHKGl3JYmoCZlIR/gnp/iZyYrXp9iYuHbZKJdIicfWBqlImofoh0iWyFnaaMlIeHi4GMfJV4jIemeIBuh4WAiIOGlqGZen+Um4Z4gZx6jJV9ZYGFjZltgnqLhomZjnp9hoaEbnmHcqWvfWB7g4mIh3Z8iniEe6WFgoCLfX+Jdo1mm12Ya4KChoWAfIKDjKKRen+DiIFtg4h1fpV+Y3pzhKCIa32CdIN8nZVwg4ZscZJ0l2eYdpl5hHaHdX+UfoKSro59fouQgmh/hm+PhHxmgY2NkIRmfoV0iHeAdm19hm55jnqUZ5BzknWLdoQ=","147.4|s65vpErgQpokdlBrwHx5PoiWek1ZknTCfjNvSW+UsYdS0V5JeaWYM16+csjqeW5Ys6K3rf0JT7hgWIZ/YGu2jpqnOcWbVYKTWLm6WtNJara7aXKtg4ygxatktotrlm9jkIh9tmlCVrB/8p11RXW0kHyAlk18pIq1hZWcoVype5ZRcUGooKypNqJzh2WJq2mkgUt9YXLHpUaGuWxBrnd+QoetknWikXR6j7FpoqpmYp2fk3Z1VnCdfrNpeKOFZ5CQZJKIXYZ0cXy03GWYZI+bkWF3fISCgb9+lGiJpG2jqoVil6Srh3pvUZ2esWmerHldoVSolYdWqGx1pKaOaYrZoqmemYtqQYJnzWSlnJSsooykWWuFeIx+fFM8ne2Zs4N2r2OZpJKSc2uUk3Jvs3CtaoGHeIqAwXvMd710qZTBjGJlpXZ7oIuYXZqkqIWWXmxam2RYvly6dXC9TIiXYXCPWrNxfVRlim/OZI2rnXWgm4eQj2mSeXiZiKhFe42HjpekX56JcmKwZNNdgotxiJRwdHGGbXV+dmOKe8Rsi9KLeaFwhI59ZXBweKWFtUqTmnONbIFuanaDopN2n2NmlG6KeWhyf261dX97W4hYnJaFyJF6nYOIk2t4hpJ3cKJ7nXp+dWuJb6JrjJXJe5yjho2IapN6eoqMdkuUrYeLjZFsgoupeatUpWlei3yFiWl1gkKUdn9/bIdutm6JwIZ4nIebilxmzXt/e4d3WIOpeoWWkGNxeXZxileLanSPcoNycXiAMpp5gG1phWOSbp+ll3iejnmSYm6ne3uRioCAoKWOiJWGa3mIcViRYpdrcpE=","137.1|ZYSHkGKiiZxql3typ3uGhImMmqeLtZVuqVeyWGDLk6F9q6ahXOKWSa+k/H+dLJzGmHCdkz8zZqCSrnecZKGja3XAeFNvn52Uwm+ha0ZeYZiRZZmcfpR6krhor6qPq3KhoI99mr54Zp9rTICTsHqgkX58c6SYkZlygpF7jWt+iaV1ipGSlJiarWmkmZSaZKp2nGTMaXGSj42Xg6mpgYyEVqCUlKKcc4uno5ydjW2ibpqDbGyJpV+TjpKXk5iOkZrCv3iObpxxbp6Ta4aYtJZ4oraeqZmTuXuLsoSRb5mCaJduf56Gc4WVkZKBo5mgl5KYp72udb9qj3FrkoqcepeUj3WOmY57xXCPZGeio42Uon9uYnGPT4Z0hquJkIiasZ+qd5aeppx8mV6XcpBzg5OHd4CIooiIg6txhGiGoZumeZZxoXd1fYGscnqTdnObtZGgoHuxam6mk2+BkIdybXeDsqR6fod1iImEe3JagIeYipZ8kXSZeXh+hZ1kkpGJeaOUanWeic9sdMKCj3l+gHN9eIZ6knx/gHqKcmuNc5lmhpidrnqUZHZ6eH2BXGWRqHF6oJ9uhriYg4RrkJR2eGyAgZh7iX5seoCPaYWWioZuh5KCmJ6VeoJ8fXWPdnRzj3x8fHqFinOHdKVlgpifk3uYdHN8dXuDcWOCpYZ4k5R0eZ+vm29mk5Z3eHGEenR5j390fYGNd4KJdIl3cXqFkoODfIFwUn16gIB9a4uacHaCnHV8po2ie2eLmHl4YH9veX13i3F9gHN1g5J1goRqcYGTepB7jnVsfnyAgXeEmoOCeIeacoakjoSDdJGTeXc=","128.2|k5Zzl3uUgXCzcn9oqnlwhGh8hKaaqWmBcV96b4S0noqOpHloxn6Onnuhn2KZfmKGqG0ybz+PfqlyjZ5vp36kf23Ie3pxnpmyd39zbWlvgpqlmpmaf56dV2+VmqSLmYWDtYd+V3/8fZpwYpdyZHyko3+DdqKXcY58b3B4nXief3CXeHmSlnp6nY6XgpSZbpd/iWxecH5ol56fmKWKiL6NlouZcnmbh3mLt4KFZ2vQfKuQgKdzgYiRbGWLh3l4kZSHr35/cpl8f56euY6dd4uRh3WjgqyFk2aRhoKia4iEaKV8kpmboHtxgZVtfXl8gI+WjaGCfZdueneBdpl/iaSTjY1jfXKG1IiMhnuKfZ9ucJFuinubfniaeJuVlJFxmYONX5ORiJl8s39qdKl8k3yaiX90mIZocJVxlHmHjpeGfK92ZXt+ZIZ9g5CEnpCOk6WFk46cgoJtfJh7iXV1nnyIhIGHfnmBiHJ8lIeGeYaKiYx7iHiRe35uh4p3m4iChIF1i2mVioaCe3CHnnZ9gHWOfoSUgIV9g3uFY2+Ng5d+houMf32CgIF6fmaDh3N7kYGGh3iEdJqLgoSLaYCBd36CgKp/nn9wg36Td4dydXeAeHKDjYyJfYZ0gHWcfouafoN+g4KEdHGDgrFigoqph36AeHB9gHOEjXJ2m4KKkHyGcJhrl396ZYCUd3t6eJR/j45zg3+Hf4B1eYiDg3yDiYGMfpNwcnx9fYSIdWiRg4tveId0lpaVf3pvhYF4cnqAjX19l3aBf4B2hG6BgpCTcoKKgomBl3lmfX50h4mHZoOShXN7i3qNkoNvhmqGgXs=","153.7|t5ZNnMhpPFH1nn+Jb25dUIuJcr5aUXdZfb28L50drIpMdIdnWzjf3n1ytvVkcSNqaaMNtPt8vHHR2Zyn211xqnZETbRzsIr8Tl+4ncAxmG2xqXh1d+FzPdCfZeJfa6aArc5+x4XgtG2ej5mc8I1u7H95dMB+VlRagJFtq6i8f4y0iLXXcnaIlEVjdJ6TmYVpepvFSIRonZqKl450TomLvZuqtaWWWbaGc56Iq6iFn5p/mXiQzu51dJhpi6hInJacLG6Fl7drgGGub2bLdINVeaagpJ+Wd1ifhWObo4J+qJiktXu9iIWrtXJwjJOWgnGksLYbbVWcUmOKZKZ3ZqQIf1FTyudlRJaqLYt/aUDNUn+fjZmbd3SHiJpoeV1piI+DRKGim1BwlJedlXySi5CUmX7bmXl8j0ZvuJuIuKRqdG6OwmyDnIh+XMqJoV1ulMifcJOUPYqGa2ipgY2UwY/BqMSRgaO1dXCpj2ipioqql1tvgIyueX9fi5U5hUy0XpHAo3WGgHtIiHV2npJOfZCuitaFbY990Kl5hFpmbjhXiatZpXNdpalpgVmGoj6rXKxoas+RdDmslJl4r1x8lJN4hm2GvIKRh3+PtnlhXW90l5WKpYlgcZNwe5KShcqab4Z+zaJ7bXdvb1SQhKipX3N7in94fXWHkUHAXLdhaqWReTvEmE+AlV5+knSEg6SJj3WMin2/nnhue251VXSLpXt4doqRl3d9VIWwTddOpm1ZqZl4UbmDbYCJY3yRbn94lofBgI6EfJ6hd2Vpd1x0cIqjeGN2ZY2he31vi5V5nT67amCfnHZbuWx+hotrfZA=","144.1|eYlWb7l5Vl/Tvnl0aGViXXyFil+aiIfBeqW9LMBNZ7uofXyZV2KBNohzbaKH3JvWdqCqmJQptWiejoPJv4hrY1o/24eOX3VW2rdAkqkutXRcQIR7e4ZwyEFOeHCdimmll6SArpB2s2qXIIi0z3twXn16i2N5n2i1cY/EeqN2lEmdnKKec5yLWao7mm1tq2Skq5u1S5t6bnZyfJi4XJGIRIqKypR4Mr6eh5m8nJCCmoqjYnOmwTJ3cKSReKjUb2iSZpOyj41qlWheJZKCsJlhpHqKl5CVp3uBtV6CoqR5i4eheWyMfpCGf3d4hI69g41sWrODj4qWkWSgeWZ8kYFkaFiGpKNTdoCBUJzEaG13g3aIXpSA1W11nJK+dAB1gq1/p2lgp1CTjnCfh26PbJZqkYONpnd4Z7aqko53UWxLiZGNr2+Njnd4nn+kXFyglJx+jYeBWqSnnl6KqI2Gb4uDh6KKgNR3empxgpKAn3Vdg2uNiIiadYprenG3e5STaY6mV4qGaphhrYGGho1fgIeEi5OQdIR/gYV6k6JniESfeFtlZ4rLa7Vtj2h7arSQfZpvf0hujImOiYiUlImAj2d6hHKLdp5ahoGyZnp6dZuLb8B5X3tpkKaHeYmDiYWUaYV/g318b3JliWGiflt3cY6gicJ5kHN2b7CUkY9kkppwem6jZmWphIxuj2eGg3eJjqluh32JfX5tZWqIa4Z3YoBpiKaNm3WFeHxxpayMmG6TWHV0eZF9c6mKkXuLYYFrd4OYjW2AgZhye2h6cIhwjHxih3KPmISke4V2dmR+pnGAbJVeaHSHjaWHmYySfos=","133.3|wGvgVIdE1Z5dh8vZl8yirdHHrFh7p+dTwbLSooe2bKyEVbqnPLqLybpK2J0atVtSRo57kLMviT6H02CBXY6SlZOfw3uLXmVpzFibmkakikRpRGxQe0BipLWZYYd0dpltY32BpXBRikaB1GuChIuQeYF7iVhthZRZd4mWUYhB5J5zg7CFjK+5vE+SiWhno5Zjfp50loZsbthmRHKGdGyys5los5pku4uEeZJ1k453hmx4vFmGnbKKXKydmKysbGR5jnCZlmCIhJ5qPX2DV2Rrl/2ff3mNi4K5p39okop6j3+FVbRtYYCGq4pyjYalfKhoXVOycnebYo6FW2+QgV1WjG95h7FipKicW6aNmHymfWyIbohgbqZngWTDhU9omnOJk2pgbpN1fLCXh3iGUHuCd39pc4Viq5lplIx6Xny9dKSPVYeCa3l9imZsXm91pIaQca+bYplnhVVnmYiJnoZsxpJ6f2uahWKNb28+r3hlUI12eYp7g4VvfVudbHN1d6J8mXKAm4FolYiLeomOeoecg4FozH6AcKGEYmaAc1WgeGds13qckW+KgGp6gJx5eXR4d7qPd4OLkmWcdXBripB7g5KDh5ylf4BqpoRWgH1+bKt3ZJaOcp+XeYR4gVpxn3x+cpGDZYOBdpGdfGV3j3d+i2x+gHV5ZZp7YnVwboCNeotjnWukbG9giJKBgpWCfJadgIFykoNoh3x2boJ7aX5+eJ+NSIKAaXxslYhbc3GImJd6j22QeaB2bnOIj4Sjj4FzoZJ/fniZg1l+f2RgindnhpJymoldgYF2eW16moBfdIiUlH6Ma3+KkWxucYg=","130.9|gZZuq6Whg3ZFVHV6n3d3dmJ0fJx6gHiiertxr6JRd15wj22Wea2FTXOD/WSUUlunh29NljQ1oZKL5IBQXYuaYnfzpGN+k5R+wZ+qnlaun4p5bYiPgG2MbK1pdYSMhm+On2h8mquWnYJlz4lcs3iYgX+Af5mNdJKbgox5tpTHh3dkaYZxj2pzmmFle4uTcnKUX6OYnIxrdnyQno97nFV6XoygZXuhZmRvgniOj2pnkJlzhHdlk1OMaHyNhm1/i5d0kYmKmHqLjW13jX2fbouSgH1/ipRtfpZzhI6NbX+QZZ6Tjnlqe3KJcIyJgoKNlIORoYaUhYCdjJKRXX6Dd5J+jJVYfnKTTGh2roN4eImOZIdqbY+KWIB7an1Qi5RzhGiZcY6ecJeFfViIdHqKoYWOcX1+kYVliol3fImCo4Sch26PaYmIfHyTbrOOZoR8sYGUdl2dpoZLi2x9boNyoYaYmYF4e3duhG9thHNemISZepGFaoyMhIJrfIpcfZh3iIeCbYB6bLedh4KJj3iMhHKWh5Rqj3h9gm2GZYCPd4yRhJxsk4RygWSMh2h/fV6JlX+FfJN0hk5sc42Ob5Bud26Efm2GmXGie3ycZYVtgIh4iYmClXSQjH1yhXN8hLZjsXp9fG6DcXKMdpqmgpeFj4VjjGKBhnd9dl2XinaDf4R0g3Jjl5mJeI5veXWBeJWFgXOYeX94cYJydop4g4OEloJ/gHuSdoSBYn2AZYSGfIJ5mXR+aWuThol2kHV5aoBtjISTdpB7fIBrgWdth3J+ioKTfouLa4tygIJzfnqBdYhxg3eZaYdzbX98g3eQdns=","130.9|iJKPeZKQeIxDh5akYpJ+mI1/gJSSg5edb3S2kY3DZquXlcVnf3OcQZeP1c+YyaFIi4GElpl8joyzvX2Hz2Zmo3b8qZ11k3tuvZmad3WOjItgcISIeIWKZaFjk3uIjmpXdYmCkmpAjZOFk4eEToFneH+Ad5OBYJOXf49sb4lwy5FkhKV5cpmTkaGuoot3hqSQvXaoiYmabHx6ibOQn3FyUqZ6epx/fpF9k6iHioR4jH2WhHmGkq9xmXmhnKCbiXZ/YIl1eZWDhZFiio1uloCSh3yos3mOip+shZ15hXSPgnmIc6SXeoFemXOJeVqdh5CKcYuYiY92g4aHjWqNhndelZmBx4aIj56DsY6Td6abcYSBfIdzxLh9hZWscaxrWKXPdol0eJuIvl2JgYqGgaGFcn6LinmVdHhcuV6EbZu5hr94iYGFdHmIj2p1moh0kIydipKlpo2McpR1eYOAaoVrxYR0fqh9eZBvhY+RgYBwZo6HhnmPgoKUd5ObgnF8iJOwZYeFlKKeiW6LeIBUgoFthF+As3eAeoV6lIR0i3SAg21orIOUlI6FhZd6dJtybZ2FeqFwiJOCmXqch3N+gHGDfo2Fc4qJeH+fhniQZG2KgX6CbWuPiI1vhH96hGWUiXuAfn96jHxxiJlpgnGLj4WEeoR+god5e5lrd3+HdqB0hKFznZmVbnF6gW6Cf3ODeJyBeYF8gH6LbXiEgG6DcHSVgYh4b4B/jnt7lF90n4R1jWuJlJGwhJV7b3uBXYB+eoBjtIJ6f5iEfI+Cd36GeIFydI6FnYJrfoGKdHZ8gVqHhXeMc4OOlI+EiXNvf4A=","136.1|l5hwVmpwa3XAqY6VYp92Zam7faB5lIlXj7uGU1d9h6ZtaFh8iQDZzGl0i6d/vnhIfZUak6QhbIGnqZys2kxnsZTEiMB8nJbDcluWnXtPWniFyYlugLSKRZise51tXJ1xe4qBkHLRcmyEyZmlw3VotX6EfKKOYnZeiXube3RvgHybk8tCb7SXs3rfeZKWjKdoaKNNX2p1iI2TbnhmcIB3uHVwoox41MR3ipmhjYe0dYaWrYqZladyiIyIn6FUjZh4V25/mI51cH2EvXyHQJZ4fomIcIKGcE6vimGEkH+BhoV1gYedk4yXeHRxjXyJmXaWpKNfbzyfem9pboJzdIJ9cnRVk5mBo6WlWZ+Eql6beIeFWnaEnXmNlHFUdMCqUoyJa5KfjItyjY+ChXp5l52ejIKtcXeFkIh3lHOCpn+Rd4KRXXdzhYCBbIa9inZys5J0lpx+Y5dxWGCEeoGEYXyKoXiKgnVgeXujeYOBe4Wch3t0Zo6He3Z2gZxYhoKSeHCFnnF9m2hol2x+j4dxf4NrepGHhoeAkHF1ZoJmhJZWhpx7hXZulYJzdXWBpVh4ZZx6XqWQXqyPk3WPfF17jIJ7enl5hn+gg4C9YnhuhmuHpX6EmoF7dX2EgISHepCZioJ/o2x5epplgZ9ahJ6EgHJojl17dnmBnlp3Ypd3d36PcJCKp2mYfWGNiIV/g256joyRgn+bc3p7lG6CjnSDmYF/eIeQcXp6fIKfYIhynXxkhJZuloGdeph5aH+IjH+Pcn6RopOBfZxnenGMboKYdYOYf3R0co9efHl4fZWCcnCjfGuIlHCYh1ltknxvfYk=","128.2|pZXFeaCUvKibi7LQnb/R7q2htGOZh6hTj5uFTltjlJ6cjrOyi4SNpbGUPo+0gNCTi42kkIyqnIObY5GRa5CasHGFfauAZ4ZlgVltjn5OYJCWjo2Nf5GIZFmSkFqDfI2Qb3OBiohjmZCDg5OKroaZZH6AgGKGd3ddvITgdZV9pL6OhZtkj6GXuJ63rXB+t7Nlko6NYW+ukKF+jaGUhIanmpGCjZt5/KCqcJ23hIJ2kHWspYWFk5GOkqemopKkcHt5l3KljJ9zcm2WypB6kZV/jH+JjWyYmnqNlbx6j6WFhWuOlX2Ti4KdhpCZlLOUlp1rd5BudJKNnGxssJGQiX15fn6VjYS5XJyOX2+mjGV1lomEi4yJnJqJhJCPkJGNdKiQoW15fXJzjo6Cg3KIfaOAhX6EpoaPaKeqkq15cIFqdYCGZnJ3p4N7jXOUdoFohHeLh4qcZ3OXloFrnn+DW4aHg3uFgJiGh4eQl4eYlHlycXl2jYaJdniThJCafY6MfXmEjJCEpnFpeYGXdoWRgYJrhYCiiIJ9jnaFjYKIhXyFd3J5bnebhI5ydpmCjJmMfnd9lnCIhoB2ioiGhIaLhoqAh2+Gb498goCTl4SPhJ+ChYZ6coF7c412gIF/hH2VhIF+jYKEhXqIg4SWe3F4d3WMh3N7doyBg5mIbox/bYCGhGZ6lG5zg4OFh4qAgGmEf5iAgX6NhYOFa4iEhYx6cn92e4SJeHt7k4GDkJh0dX52Z4mFcGiRe3KGfoaFj4CYcISLpX6BfXyMgomBhoeRgHtzhYJxh4FtfXuKg319l3qIfnZtiod4aouEd4aCh4Q=","130.9|rm++b61vkLFQUoqeZaikn56WnGqGgI5leL2Hsa9inY16hYi+nml9OH2CrZeMunYYkIVwYDqgpoejmHZLbE9ohYqrvIyPbJlpyWeYnZiyqIekk5J3e0qHbrBTgFZ3d2U3omiAT2H6pXWajHBYh3VqZIF6jWeUd5dshnB8e5p//a1tbHQzcp6NqISUj3SauZpxg6P6oJFZl4CYgIR4kw97S6aDcqVxY3x5lZiSY2u+lJCdmopkbotzpo2prpqBdJpdhXa0lpGMkHifrIOAfHyQi3Z+kZmWmUZvtZiRhpJ6aYaWh5d6f29XiXOUlK/Rip5xoZOpd5mbp5KZcpadfoFmo5hotHOWqpBoZ52odnSggH5vh5KIs6h9aIR4dHZ3ToC9g3ScgKF4hkVpco6Lb4KQdnxjYnqjlqGCZJd6nmzUeJKPnYuOdIWKcnFiXpJceGm7j3mHa5yFdpZ0oHJ0g4mDvFl4foJgfJSHjIKUs3qYipR5bouihYdnh5djkHd6jYalYpSBf6hylJFrlIKHfXZ8iH2IsXl8aXN5qKSXgGl6e5ZzonuIha+Ki2OFjWVmeXeIgKtvg5R8hnaalG+VgW2Af36FhoGCe36FY3mVeqiGf658mXOQdYN0f3iLhniCj3x/XHB6lY+OgV67eplSjXl8iZODi3WHh2NnW3mQY5xyhnp6f3KmiW6mhXF6eYKGjY6Ien54dHyQbox+aZZ7lH2CeYCSgIKGgIJ9anBpeIyCkm2DgV2Qe6aJa4qEgHt+foKIn4CAfXhnfJ+CiXJojXuTgo92hoyPgYRyiH2FZGxoi36VaYyAYHBhlI10jIE=","129.7|n5HJloWSuKueVpCRaqy2l6aQ5mOQsZNcp1lps1m4lJGYoaPWvc2CUK+im0uSbtGYrHHqajjOh6pwVZVRTphsRolTj0V+ZnqgimGAaXC0XZ+WvYOag1WZrYZio4d9nnOBrol9VYv8haR4L5FaeHluk316emF+iZdjhXWLl4GXiJ+KbXOEcqSbg2mxsW92fLBpqWeom2tvjo93jpHBir6TX5mXh5OXgIyusm6VaWjWgaJ2j6Nli3h3lbqktoWecXOH6XO1b4WLcJ+VhYuXyLeRo4ycaaehnV+Om4OecqWCaZyHlaqCl3GdeHWepbydi6htZ5K7dcxqpJNrg42ui6DKm4u4ZIOb2o6JfIakjZt2s4xsn4KeanaTbJ2OdXmil5KSpXFsmaR3lZttcqqBi3SQhnuBhnqVlsSVaYx4YYWcd51yrop2aIWnlIigZo2qe4iklJJsen+ui56Jr3hzm4CGeHmDfXRufJSXenh5h3lpm5J3lHdthHhyhJ6knJx2hoRycIuVj6d8iqp2m3uzgXSLgn+AcoN/cXV5ioqPesR+eWmGhXmZXXuKdmyCjKB3pneEoXJ0nKGTcoJ8kpaJeI+Bfa1/moNegX1ibnmWnZ18fIl2ao+PdYaZgHOcgIRYYIB9Z3V8k4SJe3d7fWl2kHeXdJqCd3eFmJ9yk3WLon10gZKhXnqBipSce4l+eJGAjmZsgX5ydX2PjIt5cox5bXmDeoZzfYR7hYGSmWWaeIiOh3aEkYlkfoCFj4h6mHqHjIB4V2yAfXh0fZl+ho9nd3ppeox0k3eTf31zhY98ZrBjiY2CdIeHiXdvgIiKhHg=","143.8|fLOMT1ZfeHmptJGWm8GOf3B50ViavnZahF9gM71ibqeigUlWhcmU6GttbUee0ndXX29YrIRVW2VTYai8/DiZbGx1mm9iXX+iL2JxaXg5sGFld4hufpeasGW2baJlfZ92imh8s09uXmtp5a2uPnWXooJ/ZVaDkZBfhY5TbGNPjXmOmSyLj2Z5QaM4dmV0oHVrfWgeUpplcolujIFOgIt7yZl7YF5yvY52kVxamnqWZnZXlYmkjWWMWWBxdohXbHKTeW+Gc3ttkmhphZFufZWDilqhgoSCbGV4bU9/bkaOenVpZ4eOlo1sd41WX4VuflJrcHO2dIlrZWeadmpliIefkH1MPoiLqpZoSZ1SonFpS496XGx5ZW+Rl3JojaFxgm5/RmhxdJhykoeTf4Fydl2Ih35ijIhddmlNgmV0aJlmd4d2XW+OdH1UkGydr3aXnJp9k4tSV6BWcIOLQIt8ZXFtOaKEfl9rhWdpjo+CWXdsa492enpxdYZyd2ahjHyGepBQrIaLkz9cmnVHgnVqgXtwdIFeU4GBen6FUGp9jJZod2tofXZbhUVujHR6baKBhqd9gV6Xh6WOgnydXHaKe2aDeYl0bVKqgn98WoJRc1WOjGR2ao6NdYB7g3yId2prjoF9em+CaXt4ip5ue26ajXlsemJ7h3V3e6Nymod5jGKRiJFifGahZnODeXWDfW13iEqSgX+DcIJoiH2KqG16aoKbeIJriHiGjXp+mo+YqnqOdJyEl5ZcdqRtcoV4YIODc3h9QJaBf59lgV6QeZOQXnZsg4F5fX13eoV6doZ8hn+PfZFzoIWPj1N8l2hviX4=","140.1|THFxfIlwXJbLp19iUm51e3NXgqh9j2CabT92nIlTY4WHmrKEmfGCMn+TeYqflFlDc3LDXD7Uhm2LPIauplxUam2jRYFmoWVzb5enXbSbjH1fPG17gK92rZxje6B7j2dMhpR6UGYthYpzI3uhcYVXdH6AaKdpsIGYlWZqZIZgkpiikkT8ZUJ7e6tYjpNjYHmQrVvKkIGCaENrjbaaY7R6R6yDhJZvd5d+foyLYW1lh22XU5CZn5trk3ZkeodPkmPZboiCYsqIhmRkiX5u2qJsjWip1W+Xpn1wjYN9bXRqaGCDfZSci4mWlmqdioKIoG2YVpSSiLJfsYeFmWeThXWZoWilnqCYcoFnV26Ve3CEmHxskoRzl4yNlKJlZ7musrOmZZVam36HlH5oc3iCXpljkX7H0XSNhG6IlH6GWbN7iGxq14SEg3SfjFuGoX+fkbCVgn+LYXawipqvcnV2XYN2a8CLfZOfc4+GqZDGYYVfjn6GoHCwhIONeJ6eg32Ec6WlZJiCd49ngcVXd3RjenZsgnSSTYmBrJF1kq6HiYs/iWKehIVne3aDg456R5yFko56s4FtjIiZk36FoHiTdXl6dHmCa2tXhH6TnHWHgHqGsXSDZZOChnd7enR8gVyHdoZ+npN2jIOKi5FxhWPAf4eOcZSBhX95YpyApIB7kaBui3HGfGpqoHmFd3Z7e2iEfFZlhn+DiXqLiYiHq3eFaZKLg2RptoCCjnt2lY6hiX6UlGiLeqGKd2qMd453XndmcIF4SmmCgYSUepGDg5ebZ4ZoiXyFcXShgoCKeWt7kX2VfIyMbZB4pIiIdpJ2j3Y=","137.7|X6Wdnpp0mqOdVneGT4KimpuWrlttkJSGdWM0va8xpoV7fF+gj2jQMYSFrF94s4SVhWAAnjfilnNzd5ZRzYpTXHm9pF9zW2G3l4ZvdHe7qoSrwWWLfMKUN2pcgYmXgGJ/p3R/k4XNkYd+Fpxcr3ZXlH1+c1lkYJqCrIWCkpGcs5iRaYRJY3yDsamMhmtikZmAZXPEopiFnF5knauJiH9ZSIqbjoqCMY5wkFe0jGefjJKJhIdnm2hoS5p9jZBxbGSPlH6ddKOQk2GkpnSfer+BkmKdmJWbkmaVkpCbXqiJZ4ePtXy0jnFoZ2lVlH2smItnYLuKfpFvlZWakJ2ZfZlWpYUeeVhgZ5Z1QorDb2VoaY5tqImenoePaqtKaitwSrOJXmdfi7V7rFyIboqGb4qOh3yIknNOaaRuc3V1Y5Z1fZh3dImOl4mdhJOqk4Nvh5qafZeVUo9jg5ZtkoFzm4ael3yIfKxtdWF4opCMj3hqlpl+eXqBh4Vwiq2VimuVfnVzXo2RaMFYi3qAj3KLg3eYhqCBeYN8gGR1c5CSjV9bemh1fH+BZpiNimqEepqPcJ9+jHVudGF8aYp2e4yOb32EgIaGk3lQhH2sVnVmgIaJhXZ6aXOWgnV2gnOTgot9d4J9iHJ4ZIOFiWKOeWmKl3+JeIt/iHuJm5WRY5CAeX1wfVKufF6KkYiGdHN/eZCFjZplgX6TdXdpa42KYX92bX+Nfn5wfICCeYaWkKVtmH1ieGZ4Y5WKdIqFjodweH5+jIOak2eBfZhod1uLhIZ8dXdpdpGCfHN7foJ6ipN3pWWlf2d+a39ojJOFi5CIh3M=","132.6|pJiLenB1oXZZVJqaoZ5/i4yKZaWGYph4oFLyvl9wipt7irSmdZtjtKuJwICKhrQ7i4jynmyocom0rIdNYHafeojLhZmRnpour3p1Zqi+YIePQ5WDfliPumSeh16UhZJcnZl7pTzYcoN5opBYg4ibV4B/jqKTcZZ4qIq/gnKAfIJya57Ekai2dlGioJSXhdh4tGJ5pHBchWqWg5G5kOKjpIOBqZh4zI6HlduIlXa0dYi4mnxipFiPsKeVpqCsj5ZqinqPbY+NcIKLhYOEo4CKiWegiZaPlUmjhnuTl5GQdYl3iHeNgHGulo+djZ+IjoWUnWHCd4xjsJNvdoqifImBeZDKhZWPwJDaRZV8PXV8/YV4hXeTrIWAaYWljcW4fpzEtpaeb5Z4h8KNd5J5b6uDeX6lkYWlh6WumIuEnW3xfIRwtY50coKbc3KMYH6PnauDjIOTUovIfZWTt4Z5fXqBcYd4fqSOhJmuem+qZ4WWW5B8mHKVhnlogZdhknR2g46Nk4eImYVanaBykYeKhHx8eYywdHp+c5WGo8x1c5CHhJVqiXugZX6NeGR9nmJzgXyDg1uMjqSPkHqmpnuWiZeBc4d5iKiUfH2Kk4SdZ5t5oo2El3aRfpadhHqTfXOnfXp/cYmElHZ5dZJ1gpV3jXyYdaWBdnaAZWRqdXp/fYWHhH+rimOTnHikiJKBfYR8io+IfHxzhIGTmHZ2oYSFk21+fottqIR7hoB0aIN3fH+YcpGEjH90c5OPcYyIlYGGf32HcIp9fn+Sgp91eHaSlIOSbo9+h3OXgXt0hG2FaYpngpJwh36DhaJrh5FujIU=","132.3|gpaLg2d9em+8X3+FdG2EcIuLppqFfYxvrLNPb67igW15coujbGqfQKJ//JRGPcG2eYZlo7teaHVz15JcV4l4cYqHeF1rk5q9oHFznWlxqHiFl4+Df4aARXVbk6+Kg2iXdZd9rIqAaoB8a5hkyHV2sX58cJqVaJNriox9e25zgoqZb35ee4SHfVKEiImYhYByeKKnfpKQf3KYZmiDb2RGT4aAin6CRIiFim+fl5KMb4FgaHtndE12kpJ1gW+EipaMgHh2lHZ6kLCDMISfjYZwgW1seYqHjHlpfH6BhrGEko50gGFsiHOCdXx5iImSdX6PoaaAdnybfH6WeoKHfYJ4dmplfXh/qXOSXH2Ee356oIeMh3KJXH6FbH1gemmPTI5qaY2ehpl1aHaSi4h2domHjX1zUn2UhYdbSUuDnoh7e2SOpH2LgX+ccIOFaWxxcHWcf3R9Y4GNe2eJhYiJoXaDfniIf3RjepJzW2pla4GYoJF7dot6fIZ4gINgfoCBcXiFZoCMeqhid3eCh4OUf4aeeY92cYZ+e2t8hXN9b4VegpiJc3x8f3mBiHZ7X2GBbnJ0hG5smIOCZHJWgYxrg4V+gpl5lHl7g35+XX2UhmxudXaClXqMenOFfIWHeXFrfIOAg259jXp5clRNgZZ+jXiHiYh9iX99hmJ8YYBxe4ltgYy1cmhxlIZpg4iDg5d3hHt+hH2EcXuNiXxzX2yBlH5+fHWRcX6DdH+JaY1ucHZufWyLjYp4eHGLhnGBlYR4jnt7anuCfHBlfY17enJqboSXdoV/bo2AfYV7gYaEoH58dWl6boKKhoGMg5KCcoE=","128.8|nW6DY6Vbj52+dGF9pYKPjpCZeJpoeoxigmOio6eWl5Bjb36kkoh/ZIV2eopdTISmg5GpXznSoXikf4FwUbKgf5GvdqmIlIBGomdscK2joHKZX3hnfIV4bGB2dlGRbHuQhnmATZH8mmyKmnR1poSeToCEiJh6a4pn0m+uZpNmpJ6de2aIk5qegV6ffYuEnrhtcG60mI9ckV2CZm6AbXJtbH5uk5JmlH5xjbGIY2XLkICmkYhwfmCPraiGgJeGiYh1hnODc5yHi46XhG2BnGt1gFtzioeKhzqHnoODmYtsZ32Rf3KShHuLhI+JqIyeo4+NlnqSdHxxwY6UbpKbb3aDdW6sqn94zHi1VnWOgnF7qXpsn45/n5eEcphmj6DHe5aGmo6Pfox1e3FpcJaIX6KAjoB3eIWjlHWjaZ2Bm4Wxdl50oYeJZYWSaWdraYJ1dniGfVaRYHSiiIyBj3Rymod8goWKfpWFhJKke26voIOScIt4eXufhYVrg5pYi2uEdn2qcYaBfYRmeJaEj4qDenKHiXqge4h9dHmHoLaAcYOBgJVsfXmAX6OKh2iBiVtehmp7kWR7hZt2f2eBmZKPi3t7fJ6FjKOMg4B+hoShiZlznpmDkm2MdnVxeXOQh2SlhIOBdICEkoR9dGyogpZ8i3h/eI6AhneEdV1biIGBfJZ8gIOSeWpvkJCdiH96d5KGiZmDhn+DgoOMkYB3g46CknZ8e3Jzm4ODg4JzY2d4ZX54aYB9i1+MeW+Lk4mIdHtriYJ6k4aBfWmHg5l/fnOLjIKReI15ZHSUf4Jzh29+ZmeAf3ttaX6AZ5teeJCQioc=","128.7|jIWguFOrt3xiipePXaSdm5eUrZJ4mo9ll5Z0mlc4ioKDwXuylaxng5GkIFnOlXWilWi5gme0W5t5V4SGYKBeZYMYoFeJkGmKXmdZjHycWqOIpW2uf1mLomJ8kW9vyn6Sq1B9c5IBXLCIooKKlYVjgH5+iZFskm9sd4GbpGaidW15hZGDbISEhW+UlItohHtxeZBxj22IiqJou5ysknKnhIOpeJCcuYCNjlx6en5AbI51l4WEop9vaKObl4WhiWd4oHerinKHcVeJcnx3mpGKiY6FaZV+hbx1jIiNZYuCeohni550goGsjG6ZkrWJi5+JYX96eI2LfYhsooWIf6GUgY6bYaCYYYB7po15jbZdP4R8inCOd5CCg3mebU2OrHNun4dgimt5gYd5foNzjXOFeYGRrXdqiKedjqWBYntafZWHbYR1gIB2ioyRY4yGiHJ/jnhrmIdmk6Vsln1+aXV6dn1+f4WMd3aIinlyn4NnjHN6hoRsgnmGgXSYjJl5h3drgoeUiGSTiIGMfnaHgH16eHmDln2AjYp4gXSEfZOogWp2e3uTgIeGd4uDkZqfmXyEgnN/hnp9hpmIeIuMdIiBhW93bZqJfn9smXl1kIuCaJKCaoVzdpF4gX6EeI5tfH2AhIV5eIeLfn+ng2lsdnh+hIJ9dn+CjpmcjHeIhnV/hXNMZ5CJbot9dIB+f294e4CJfH93hHp7goV4fIiBaYJye4+MdYJ5iYKOk4qGeoeNcoKAcWFpgod0iYd1d4GKcnt2eYd/gH2OfHd+hnRniYBqh3h4j4GGf3iDgYl6kpBphIpyi4FzX3edf3KLiHg=","134.1|k31Wk7GnZGRBdWxsZGhdY2iDep5vi3NxhaycYLDFnIRloH1yWImDU5Wj3YKqCKe/rXR7mXpaqrCd/XB0UphnhmzMb2qRm453xXRKk1pfraOeZIGfgJJ4WE9lnoaZi2yfo2Z/q5jeqKJ96Hp0y3dqjH5/jqCHYJFweo6TpZyodGlid4xgbJiFhWuEhY6Riad3fZZ6bJV1lHeIpYOMh1llYnyYgX6jhoGBr46XmIOwlqOHeml2dj1woIaRfnnBjpF/mHWBj315kJ+bbnOge3aBiWVrbp5+iXV3loKgdoOJhKCakmeLbXuGdG+Be4aSfImToJqLdZOTbnWWcZR7caGHgISTiYWDvWSvnV6JaJRif3eEdJKcc4RueYuYc4+GoXF5npSce5p4fkmPgqCLp5iecX9nhnaVcp2Tb3+Do3yze4SLe3aLaYR0aJqFdHd+gGB6kVeSlWd6gZBofYSFkYuQoodxfZtreJB1X3Nrl4OacZB8gYqOfIluh2tTnJiGg319aXaQkZGUYHiPnXxvfoWEiYiNlXh+g2l3iWh9cn64hpx2gHqneIx0iGyEhVqBknCCdmR1eZRqcoZtc4p3e2WBg5mHn5mren2TZ3akb4duZp+Dl3GNe4hsgYGUiJeOmniAi3B8jmh2c4qPgZ6Cj3uAiHZ7ineFdFp7lYZ7h353eZ5VgZJjZYqLemqDgJCGkK+YeH6FcXqPbXx5a4mDlXZ/epeNW3uGe4F2YWiTaXluZ3V2mW6Rg2N4jnp5YIBsioV/qZd8fm9ofJ1zeHd2kIKYfod+jolufYR3hnOAZXiGfHZnbnuQcZlwb22QfH8=","142.7|lXmziat1m4ZiW7PPd7m5rbKgqG2CodBZqcS6sa1qjK95SIGboLSp3Ll1xqxGHOWNZZyiYc+LpWeTsntUAbB7rIPzb7lncZnQrVu8oWK2pnGP2ZR9eWSKs72zeJhkQ6eZXIp/VKuTo3ly+3dcvIx6xoKDaXOShplfhHNGjJqXo85rb46JfaGdmEa+iHWavNVqdqaApJOYjsybdHRdj5+Bwqp8hKaM93KIVLAwZ5Zhk3B85Y5nh9x8j7p7jLx5d5akaG+FnoKNj4GLr3+sW1+UnsuHh2qDf1uFjrt+oIJ/mX2YuIqRg3Otm4BynIt9pJ55mXjFcWSihJKUioaEg485f5OGiaO1O25+KmV5kyOyhYGTl5CMLpaFaGt9fNt6VrqLYHmZbbJyiMhwjHKKjaaye350SH6Nl0c5kIqBl6fDdWyXbYyHroCddbFoXpF4gmOXWU+cOWWBg1yGbnaHpoeynbR7fUOgfYKjQGhmhn6VfZR3hpB1hYR9gcBicWF9h6CDpXZ+l4FAXZa1gY+Jf4ahhrV8kXt8gpV/bGFobpVTfJhlxnVnmmOMiHmDn2KfZWCHeLaRjkOgkY2Ei4FyjpiCiGaEnG23en1rvX5ziFtxn4J9lZyTc2+TgYmAha2Fln5+ipZ8gYZrb4ZffZSKmHhzjlWAjIiEpmOsUX+NboGNhUeKsExakYBoi4p+gpeFhW+ne4B6koCDmmp1t2J+kXyBd2KUa3+FaoKfar1YW4qPnZeQW12gbFiBd3WMi3pwkYCxh6F+fWKlfnhybl+ZZ3yTcpJ4ZI1PgoF9hJuEuGxwiZGVlIRlYWqHbH56dIo=","145.2|k6SgbUZjnMuNXJSOqrHHvaWip6ZPu9FOqKvotVi1oO9XNZzHdt23xpdblmYsMIGxYpi2qTuiUmy6f5lUNJ+nYGJoZ5psnWCJfVPCm622XWindVpbgYqQusmkYIxkS6emIJl9qMh3VG1/qJ5iz4yklYCCcKNgn29TjI1IZGFnobeKbmTBlZCjl4g9lZJt1cZkoZzgonCYmb94TX3Xh5vIr6pDyJZkp6OvRrVnmmdZZlhvt3plx8aVcrGPltRpk2zKjm6gkoSQcJGlimJpsHd+q/y8oDeuopi5sHZhmKKPbF9heLJzinWzlJRtqNmhxo+VaqOAcpeamJJsd5t6flBlcoOTgc19SW53vVegwHO8hopumWlfMqOGa6aalZpos6WLcJRkrGZyk6aPc3JvjVN9hH6fbYl3fWV8lY2Ge5Fmc3WMvIh2g4d4eHpmY3mVn6eMVGV4rWaujHWet4l1h3KBZ7aDelmZhHGKaHl5a4V8p290ooygh3edhqlqbGqOf6CNm3NgcYCsc8yHZ4pwgHR5dGtilIF9p4GGbVl4fIZVhYGcr3eMgnmNdqOHfGlojWN7kqWNmFegj22LoIZwinuBenR3fGWUgH5OrIZteH9vu5uEgrBxcYN8hXVqdXxnj4F9lIuHc154enlggX2TcnChi6t/do+DfGtxmoZ5i4KNc3uMgKVelY1vjXh9dn5zb0eQgX2Gh4NxbHh+iImIg4Buen2Mp4R8f4N6cF2BXXqYo5d7eWhjiFyMkXaKZoN2g3d3QYiBe2aciGl4eol8aYWEfnZugYChg3ySh3Z/aXF/f5KZkn9yaGd3bI+Rd4Y=","130.2|YIhhn3CgY4BljWhsmXlvhHWPhWi2kXlviVytlWeHhZiznnuIoIiWtIidqW6herW6n46ac1f9c5+xr4iNspCUV212hGeQa5WckXBhbomVaaCErqKcfq+SaV2UlYKUlpOph3h7ZYhgcqd/3IWLenuToH99kWqZcoxvqnS9jnWRjpN4iomWjKOdjJ+HkHKSrMV0nWfAi3GVhm6ZmKupjJSMp5mMmJyPfKiaiKyub295d4uceY+En3GJnKaxp5ehcYuElXaybZ2FdH2Fk6R4pZmRmWSdjoibopyFpYSOjJOEbX53k5zMh4SIhIyWp5+2nIdxdbGMd6Jxl4Z0oIGunoyljpOef5h3eYKwsXKliaF7l4Z0qHmKnqOEgbRpiYawpJ+Vq3J8oqJ3o2JtdZV3h426e4CRtIOYeqrPkJd6aXvEd5JxrYR4eYCVmHmhoYyElaqVhmaYpnmpjqaIfHl4cXx9gGl8gL9vhIejg4yginttp4x6h3Sqg3qWgJupkJeFh3mLlJaMgK6hfZ5+gIZ7fnl2e2ychX9+lXeCmJ94iZWVem+Ge3iVdqmEeJuBsaJ7jYuIh16JiH6Ii39vp5uQg2+Bf4J5d5uofn+WX4SceKqDlqp6d4mKenltgHeCfXeTqn19knGBh3lwiZCRfGx2jXqEdpF/eIaBkaB3mYiKj42JhYWFhJpzlZeHhmt8enV8gZiTfX+HdYGEbHqGZo57cYCSenFykX97l36XmGegiol3dIx6f3t9iHWMmIeEbXx4e35pd5V+foZsgJCCcpB3lHpyg4N6cXmXgHuMfYyBf4KchHhulXt1f5CDfI+Oi4Q=","143.7|kYJNo1OTcGFDr3hpY5BXaG6Ug1Zr83xqd1PEjbE3pp9dmz1qQp6c13F8eUyZ62VGkn55rktmXJ2PpW63m1xjZlbGwHOcXI2gkmpIZ02KqYevb32Cf3Ryki+zc5agk5lqu1SB0j3lYHlc/H+orYJnnH6ClFaInpJoqpzZo2fEZ1lglo/SaZqfVrJ2cGaSmZ5yXmQ/e41cn4qOm51qg6uXv4mZpnaV7olrip1xr3GPaqO0x2Ofr0JsY6h8daeSa5q2bne4bXKGjWyno2ySbYV7hlSIfqV1iaCEnVSLekiKcppmhmG4Zo+xjmxzbZSZgGliqoahc2FpbX+XVZ5+co2UXX2SVpBsaHKKzZ57V6BQYnV1f22ElWdollurcIi83GiErWShfJ1zi2qocoJwmJ23b4J+7HReYLa8oKl5s6ezeYR1S36Jb4iHYau3YWt52aVleViovJ9YiXhyZI10qHOQbrtxgJmVdmp3kphXh3ake411eXKWgoRkh2xIfY2Fholhn4x2d46zoYthk31efniSc4aYdXZ9dp50Y6Zyi3u3eKWFdHmPUHF6iFqEqkp5nYl/bzuUXlyDgoG2YW95enGBc3l3lI20dn2wknZkZXSQgJ13o46PeJpZgXqBeLSfzniAeIh4aXJvjtSzeqS0i3V2dVp9hnSKdE6Osn9xemuPfm4xnKulZm6AenOHe5d5gp6aen+BgnprZ3WHlYd6oYx9e6Jsc3+EYIeCWHSjg3R1XKFyZVqIjadwdn54UohiinuLgZx6gI2Ne1l/cJSenXmikox6hW5gfoNviYaBYoB2doBakXdwWah4mGh6eXw=","153.8|oXGchFSKdn8hxZytV6qs6VeCp1CoRYDJsSp1S1cAsKS4hLwssa2M3dFxnKGjgMpfeIsKU/sqWHFZ1XTYwmxZwFx3PHZnV37AYcJPUlVLXXu4wJF/d+qSK0+ndKagjpt/j4l8MkBLWW1t/HDBwYtbyn5+aVOHLna2nWV9kWKLqZNOoj6RYpyAmbY6eGZ1vtSlnErUX21/pqd4iMKGqq2GvJ6QcYGBYlejbIByUaFcaIGNnZCjmJhqnY58g5yMaG+EaZWAX5hzbUW1v5p5gX+meAu6gnzBhaNVXK6CkZF/pH5jgWbIfZZ2kmhOUK+TdpVhYbdOlodXSWtleKeWk38Uh6tEb6KKLIu4a1xxVHN5bYCfX2p5esZ9mZ2+bX5wfdCPOWFkaIePv2xZlVdzd6mHZ4Fvu3GcLnRgmHV0V4xkkZhldnNxmoqKo4l8m6FeaaWahWTBcWlyelNsn22RXHKOfY5ygsyedI9ktI2VdXpeb4CRimyeeXV0i4i7ejesnXFtpIB+wqt3WEdlgIhLfo9xdZmrmnWDnI51i3CVjDR4eF9ZWImDZ49vcHOFhrmdeayVh1mPdGR0Z5J3eXCAiIaBiVt2c3y2doOhmHWSTnGBiFN7XmF2kpddgI14d5KponiCp497inOTjFRieWB7eYyAbH58cXONm7SnVrGgZHGLgF5SknpiYGxkimh4iGNyg7CPdH24knmLXZKKXXx4YGSDh5dkb350coalp8RZnJlbaJiOYZSKemF0aIGHWnaqbXuhm518g6CTe4t/kG6GZ3ldZW6Pj25vd3V8iJF7sSnCmV5rnYJ5k7eXcmRmgIc=","132.2|dmGMrHG2gYNBh5uUbHFyfHWAgWxoi3xbd69Jnl+ahmdzrF5Mb5KBSYKY3ou0buuPtWZQbME2dL9D8GCGXHVsgV67gliAcWOlwmBUmGKhZqqEiGSjfnRvlU9lo6OVmmWEtm5+bW79dZx8lmSGc3tvo31+gG1mgItgUoFwt3vFdWRig4Jed2pgfJxJf3hkblNqS5x6lnN8iWhgq3lbjadXXHOncnWwNk1dplODd5K5erJpl3GDQV14V1ZkVHCSe2uKe3Rzk2GGdJKEUHKnMHeGkF9ZfrJ2coJqeZ+gZYF7l693klh+aIJMaHlsT09xX2l0bnGbdnmVU41xbIJkdaVLj4lUaGx5mm1xqWxyWKFSonKQcHuegWVwgFldeEchaZNUcHNpZ5N0bIx+i5eAr36JcYBGYH5cU3hfVmN7bZB2eGmNYoR2dIFsfa1xf4SERFBtl3aAoHdBeHVyWn6JnXyUiJB0fYF0e29RY4ledH5xe4t1dIt3g3lvgGWGkYN1iIppa3yLfZKabFuioXabf4mPfZB0end/dHJ8eV97iEycfG9teXt3mYSJd2yBcIiFX4CEcmF1eHx7cY50Z4JwcYp+hot/pISIe32LcXtxdnCGU4t8cGOIc3iWfYaLf6x7jXuAenx9bnV6h1qEfnSLiXdyim6CeXiAgYmJYHmEe3J2iJRuc5d1eX+AdIuAg5CAj56GeX50fH5yaH2FdXF7cn2KfHeNbYJ8bYCCh3Rpf4CEamuChYuChnZ3f3V1lH18iYCMlYJ8fXxzfGd2foBzin5zgn91eolwgXp4f4R3a394goJtbX2IhJl2dnh7dHQ=","150|P7aNmGW7lsLbo4BhYX2HZJWckWvW7IqvszSmwVyta8LSv46CcqDIEou5on7RpSRprW9wwCsAaKllmKWaXE5mWXBeVkNqbZ6ulqlnWnDBYqlqKbCigIiNjH1FxN2cqVdbpIN+xGR0aJt/M6+WsHFkpX1+bWmou3OngJuBj299n2qtjmewcJKheqprm3aWcmybhFHbomzIbH6lmLWtbJCILqOgwpGaO36SooCio2OgdJ2JXH6Ppi9xj394e7Jkd4nKlZCJYLWRcI1rILyayJ5qo4OVzJeho4WOtkSYYpWNX5RwiIuAlIZ5mHSBfqS3d4t0YtRtjqVZdZFrrHBzrofDeGSMorBDjIWKUYOfo4BwkZJkU3OOjICMhKifb1q4y4KGXHJytW2QoDygbnp0goVxjoGFrnqHf69pf2R1UaZvh5VmyY92o3qBsnCaYmqlrJKBsI6aWYh1lFaAo41wSnaArLiQgI1wepVeb45ok3pfp3aKfmyjhnWVe2DJiKCEa62cWHufhbNkip5VkXRogWxqeY16lIqBbYN5kXJ9i3NmeV2deo2ASa6OeJd8ML6apWp0nohocKedd4aBenlvbleAe4B6fYxfg4ChU3iclV+FYaB6bph9iox9f2uHe3J+qomBa3R7lIqAh5+Efl69e4eCbZqCdoZ6X7aOuYBpmZlqcJKQeWaJaH9qb2CGdlt7jZZmiYKEcnqWdH6GWHR7aJ9sh5RpcIN9hn1srLGyZXN1jGlxlJadeYV1hHRuW4VaZ3yMmGmEf2lle5WCfZ5iaXtok3OMl2uGg36OfGuDspKAcoOEV3yclJ6QjGuAdnE=","128.8|anNrcp5hcXaphm5qb3xqdWBuiJ2Ajm5zfluDpn9iZoyHW4dza5NkspdxsT5dVIh9fZB6fEf6mXaBoXmGq3ZyWWJub1iAl22ajXNpbTymgHZhm3Nvf6FtX2uVb4uVVpWCdYV+g2b8mXVlr3iGcn90mn98e55xZ4pxfn2TapB8b4eLhYCZdouCe3SDbpBrZp14vWmil317aopvcJqSY7KVoXdnhHlneKqhcG6MgG6rjG9zaneJjHZ4kISJi3eHi2tyqHqNcW2He3hkcX17kXpteWidZWmLi1Jrg2dylHx2bmiRkoOOeYF3fHdxe3uAYn2SYpl/eKNsXI1/fmuRgXB/eGaQTnlrbnSTYH6AYV+FmnZyrYyDYW13hJKneHCOsX+JhZBmfph4knSFdXuFcImKiH5xsXmPXaWKiYqFX3qFfKB3cYmDiHmZjXiDkXCEjqKDZnSYZoW6fYtqgYF5kYWHdWeDgJqOeYZ8lnhVeoNqbod7k3Z4hH9+e4Gcc355c2xalGlrhKRsh5dveYZxfHeAhYF5d4KAjYl+b2qIeYt0hGZ7cXuZemeLgH59hplrloV2k1yIcWhzZXR8iH6Ih357eIWDf3yTgX58inqQYpJ2YnGCaouIfoJse3p9hH5sloF8j4V6hWOEd5GAgmh2h3mcd3iCgYJ4iZh1m3pximiHc2GDgm5+gX+Uh3V+e4mDe4iEgoB9gnyFYoV6YYeFanqGfYZyWoF/fnyKk3uPhXdxdpB2aYdyeX+HhYeGZYCEiH+NdIh/fYOGfol1hIJxfIJsf4d8iXRqgIB9eId9bX6NdnN0lHlth5lrhYWEhoU=","134|fGhomF2RPlbJlm9mo2twand1UKmMbHZUdlKHpKykWoWZoTF0lACBmGebCOWW5mhTo3wmXdj8YqReN4CVpUiftoJ/h76OoW2RWFhjZ86ko5xVyniVeoBxWU2SlGeGmoBonWWCTVatY6GWbHGQh3ucen2BjqR0cmdffGyJjGqNhGiijYRak4iBep+fc5FrlIlma2KOmJN2YVtwjYNmantvkoKIf5+QbnllmqNvYJ6jcJOrg4uOg3KPYX19daSGkGmANXGVaYSIkIxZ2IiCYopyeDh9hZOBgX55hmuOeH9snopuhWCUhoR+f5Fzk4yceHmUWJhQckhnjo6UhGJ+jJBkZWpCxWturpV5p6OYRKlqS3uarXCRv3iFiF5gjlJ7TpyXepdeg250gWRrjp90hqKJj39/eIZleX2Bm3uCU2RmdXxvaoqJYHOJmHuScoF0mJuDhnikn59uaaJ8iHSPj3h4iWiMgYBrhmifjpG0fINeoG52c3WjhYN9drOqlHaScW23hYiJa4qam2ZriXlXeo2Dd3KzgoSAaW+GibV0imp4hl6RTnmPW8KLiHp5uKlxdYl3c3SGaoh3oneXh2CQe294gpx5h492hn2qX4VpY4CNmIqGYnhvdIFqfI2KeXe7foJ9anCBaIV2i49pgl1ob3SDcpaCh351naVqcpF/dqKFeI2LmZKplGeWeWp7hox6hKVwh4OVeYJufXmHkISDZYBxd4VzqoKCiHqen1dyjH5zaIxzi22ghquHaIl7bXaNiXtrqXmDfo5vhF+Oe3qbjINifXJ1enGPgIN/cZWAX2SOfHNtfW56b4d1l49viHg=","138.4|l5t9dFFvmYs5s45nrGqMt42Bmatr746fh7B7OVXxj7xeh3Jze4Wic5yCqXJkAHKHkFpFlCHGVYdn8IG3jXykgZPcL5Bso4ngkaBomns3W4GOvYCCfrqUjGCGj8qIlXSAjG+AnI/KXYd+tYmsinWi3n9/cKiChmucd4tDYGJkhJdfmHJwlK+qm1DchZeOkXqUf5yEVWtsj8aOZaubodyCe6VvhZ5ygm57p4aJj2LHaIWTsn2dao+QU32abbhwk5LOool5lZdubbeOlXFzUouZmYCWpo+NiG6gq5ODWZKTXodmZKihgI9zf5JslXZxmqGanbpeiaOYcGZpdYh+cHqXpKFUmqCd4o/Bk1hxja9ZaIVloWuAXZ1/mJ+Cj4Z2sJlzXpeap3OJq4OIb55xZJWkcISDeoRUlmZBoGKGo71Chq+NcG5xVoZ+Z2GQp4mmh3WKgYqPjldNbpZ6fIJsoXJhlK9ygHl0iGOPeGuAf4SbpG+GjouLeHd3gp1ViJyHjaiJfGyGoXOIUoOignFthXCOdGKgtnV9lGWGXVh1cKh9hpyYh4KBoaRudW5/gVRYj4KHhoeAgZ6qnmR0Y4ODbXeGhrZ4f5mXeH+faYRiiGJpepyFmn5yiXuChXGHeFSoiXh9m3SEZoZ4cr5FhZq6c4R+i3B7dXqDmVhWm4mGoYeAdaFnkoRMY4SOb3OAdJZ1faGKdn6MdoVpm3h2onmElYCBgoiNc3h8hYGWYUqKhYZ/cnd9mJmogVJuioFwa4KTjntZuYt6f4Fohl5+fXiSboOYgWqJl4tme3x3gpOAVYWihYJ2enmPl3xuXmaIf3E=","130.7|e6Kyi3WMldFuWJWrhqi6xrmam4qrpqqtc4qohZGmbJaZlaWmfubHi6eSrH+Vf61WpJS9pG14dp/JgZFQg36Dc2ieen+Gh6aprqh4h5GJj5dmk6eMeZSVhHeElJKMjYRlq5F9qFzkeImEPZ1bZ4SEkH5+hoyimpClvouZl3eNxtR+a2iifpWti6N+kIGhv8uciof9ioRmb3SsiKN1j6qCiJ6KrqOIeYqOp6mYmXzAeqCwcH9jdKyCnpaRoZV4gpmydZGZg8J/g5ZomZyRooSMopCSsKWdiliZqZSejXSTgKB9hJ7MiHBji32QloSmjnuIkZ6diqeGj4SJb22wjpBmsI+kiZaMvYaNcGWqcox/pIt8gXuTr4mHaalqgGmSqaKZl4WXkZ6KnWyMfpZ8h4igf35/mX6WboGkfZ6Bj6PCiH6Cq4GDbXmQhYNcnH54UZWTkXiPdm2ShYCIaIZ+hXuIf5F9fauLe5CRl4umgYCLnI2JfYOSgIBzeY97l2uChoyQg5uKd6N4baNxnIFxgn6GfYqQbIB8lIV/oZ6Him14gYyJj4Z4l5qFhG57jHd2eX+BmnCGg5qXgXpzmHmDf4CDeZN8kYZgfn54ln6XbZyFfY6Aj5iNjXRugX6Re4SHhn+AiIaAin2FimalgI2aiYl6g4WBgnp6h3dxgH5+fYaDjpWadXhrj3WSg3SAfYV+kIl0fn1+hX+LbIeGc4WBjIuPhWV9j4F/gX2HdnV/fn5+d3+ElIh1e2qHdYGDZoNxgn+Cdm5+fXmMe5d7g4J9jIKMjo2Ob4GQfIB3d4KJZ2WLgn15foWNiJlsc415gnw=","137.6|eqGOnVaff6HFjHWAXpia3HpmkHBwx5BXeJb1V5D3laZnnsiLeP11sqyhulmWYrllnob4pDCrWZ2u1pqTJXRgilvlY4x/dIRmylpziGBViZ6XT3+afn6HsHKgpoSUoJJzg3N9p2lPXaRs1J2KvIZme36AgHCCmolem4ydhGaJn8GdgljDbJ2orqqHkHmMouJnmY20ZIOokZuLkaGTbK2UpKqMi5+W4oWRntaSl2SAbZOuqISIhYJwpKCSoqKPe4zAinKlh5N0hayXs3SKjmZyn4eOrYySpKl9vJ2OhXCEY49qjISKjoKopnGYhMulkXhzkY6jcq+LjW6Gm4+MeIaUgWnEfKu4nWWZvVmKgbB6woZqnXCBeJSKiYiZcMqc2aiuoXSKe4N3hn2Qc511iKGIjYB4vneWX6GsgYx6lZO6dHOEjnh+coJ9b3ZvZHCKpnyOiU+zrWSjkJt7ioR0inV3kqqJgIKQeJCKgI10aHyOd4R2lIa2eoKahIpikI6HcqB6lJWUmY6mbMV8goFzgnaHd2ulhYZ+lI92i4OLiYZ8fZKbmnmVbn9zfpuCdGJ9oFt2qXaPg4mMmH2Sin90fXZ+fp55iI+mg4CMoneXeZCCiKh5lJWGc4Rzf3OFdmufsoJ+iYl6kHiJi7l1fZKSgnWQhHF8gYaGbmJ0o4Fvg3mKiKR1np1ii351gnWBeYN6g5qVg36Dgn2NaoiFn4l4jYF4e3l/cHqBh4VvaV+aVXWXe5CIkmKUh2CGgHuCX4N0hntfmJODf1+Qe5N8gpaMhHuPiYhzhX1nfH6Lg3GAjHl8dYx7iIeKZZuHbYd/dn4=","133|Z56IfF9wkq+ccIuIm3aTl3N2SF6gkYqif1mfmqQAZnSekFGPm9mTlnh+WaumTmWleHd/jY2eZGhtbpptIY+Yd3CzTIVyYpB/hqCqa52Zm3hhYJN7f2uTpJqSb4eNhoiZp2J8gqKGZ3V0tZdzjoaXdYCAdGGPsXCZvYKAfm6Ej46NeXqdjWRyn4Nrh26DfHeTTmfFkI5van2AoY18iJCOkqSdeZt1cz1liqxcf4iDb4mZro95jnSNYGx3Y5pbbYTAcIxybZOFjEhlhJaBiYGGgoORvI6GkHh6gaKJb5CDgnNtj4h8kXydlYyDgI2Io4FshIF5h2NopYmShGuGh4dqf4Z1q5J8XXV/W2BokXGJf4yDenKFbKqQeIpViq1ph4OUY2eIf2eIe5V/hnl1Y5GEhHyJhoRhiF90l4p5eqeKhVR0ioSJi3ifiXp/aoN2lZWJiFW6YWdLp5KJgn6Dc3eFibeCfnmKhGtrcoCFiXt8mXSHb3i9gYVweZ+Wh3uAgaeoiYeJebFma4aUh3qcgoN/eYidm4J/eHiDeol5gnmDeXmTeIRUao+IhW97dpOHf2CBfYuFiHOQm4iHeIySeI2BhXZ6fIKUgoCJk4RtjW1/so54epF1iXuCgYOHeXylwIF+dIeDbXaEgp+Tenyoc4Vkdnl9iHl5cJGOf3qAb6ODiGFKo2tmdIuLd4t+gXZ5hniJgn95hYVxf3uBoHN5dZF3hG92pIJ/fnp8kZ95XYORk4WGalaXemVwiod5j4F4d3yObYt/f2OPgmNzhICWent2inaKZXWNf4J6eXR9k5Fuf4qNeoduWZiGa3CIin8=","133.1|qoKcgnhxnIlNn5avZaiotqqL9KFz1piwlqdWU2epoKBmjr79WUOjPKqQLWJXltZ8gZXdoC74enGncXOmnoBldXxcma+Amo+1RatplKhTan2kuYaBhIl4XEtjloV1lG1th26Atm51eYqJ0oCZsIJop36Cf5+Lf2mkxpTEYnZhdr5mjKySbrGpVHXZvo+Tq4WdxZmOYXGIlayMZq65hG+jT6mHpIJ0+Oi8j3e1omWPeYKc5XGOjZRyotuls5CpjZmjpY7ZjX9zcZWhsXaS0ql+cWqBkoqNn3CPppyIkamIZXt7h2yecInPi2+soraUpaSRp6pNjJyYtW5xmZiScX6YY3++f33ArKGCSJabgXpvoXlnsHqGuL5ujHK7c87UjJ6Cu5OgnGmLjouecZV7Up6dcYC4pXeef62mZrGFqao9ioOLjnR2fYeSZWK+c3NpdHaVfoJdU5W7jZKDsIxumnp7RJl2fo6LeJKWkYCXi4WimWqJnoZ/eHl/hZ9RipyLgml/Y5WYl1RZopOYhoeSgHWSe4Sbk3h9i5N3kah2e7aEg6GIYYenhpZzeYCEl1V8gYyAh1ZwnYl+aHKjkoeJiZyBfpx8jHujen+QmXiUi49+kJmFm3xwj6NzgXKPfFV+knmAjYR5kX16fE6Cgp+VcImdi5B7d32FmVV0e4pzd35xhXZ5X2GSg4WAiZmFd5B5hHCReYCNgX2OfHp7in+FnIZshpuIlnp8iIOcXoyAhnh0YW6GhmN0c5SKgYaJnYWIh3x1fZJ7gIuLe5Z/eoeZe4Kbi3CLjYWAfXh8iZB+oJKDeXNmaIB9Zn2Jj4iChIU=","137.8|Z1iVhlCIprDJgm9tTZV4eZu0pqSKeJRRdJhVqUprgpGDfmB9eGyRm2F+pUKGW4CicmdRWi1fVnJqYXR8gJxQRZhnglaDnZbNZFWdiXqpU3t6tpZ9fodejbWJerFmjoqVaYF/X7UBW4CDNGZ8UXJUsX55gqSWmo1aZ2tRfmRvl4akgF9IY4p4oXV8eZKXmHpne5KKl2echamdd4d5THOWkXJynJd7XnaSYG1ybGZAamdsc359bphncXSEo5R4kpWHt26HiqSMam97mohnT6RemN2gZWSRmaFvj3prYJ1gZGpocc2geX5KaWl5roKArZeXlZmWcMCIipBkj3uSg2yAsFVgZoF/R4p8gXe6yn6FgWxrcW1vU4h5fZltampIcnl8cpSUr5twoY9ucl5ydVStkH9xTnV1ppeKeY+Jl3WZdqKIjolwkYSEd3KAn3CLWoZ7eKVmgHN9emmMgnpxXnV0iXKNflpldIOVdn2Hl4STs4x2h4ZxhXePfY9sbXeAbYxpj2xufIiBZZ6OZ3KodHRtdXxmnoZ/fWp0fH2KfI9thJCXlneLm3aMcJiAkWqNaolwcK2Id3WYkYFmj4h8bYN0e2h5ZHBdh3tmVXVzr59+k4KElKWIcWebc3JoeHVYa4aAdXN3gp+JgGyHgpN6i3SJhYmBcoiAlGqJXHptgnaIcXe4foFulItncoB8eWd2cmVwin99cnuEj4h9eIuFj4eUd3KIeYR5hn6Jbpdxi3WHnn1veJtkgG2Gj3xwlXyUcHd9W2yFe4VjenmDhXlpfYONh4JwioiNgXaJe5SIlIWHcYGYknd9l1ORd5CNfnM=","129.8|zo7SoqKrw7Fchru9bMS3sMG3hJtycMRcu1X8V2RqnrxoyaO2k6aEq766sbrVpo5Frourg5OFnqyxdISMc3tukJSjoYSMmIxNx1+WZ4hWaaqjTYKvfFqQo6aRrGaKs49h15mAfFu4m6qMMIOFeoJwU399jZqHiJJktoShr5Sp55h1gJWJdLq5llGbl4+RvNRroGPDYnVzl5iNw5Oal4ajm6C6ubinknyTuN54foG1jqy1lYN/pmt3qJ6erMuljZJ+gnSSa7d3dnmecnahpXyPmqOxnsOsnGeNoISyjZ6IgqSSn5aSg4CCpHWKo6iykaWRon25dpZorHBwj5Wyc7lcmpOVs514v6etTKOlc4ycwIWAd4ysqpWFgqCrd3SsaZXRlJGafpd4m5B6gKCMiZp8eH+GfHqjo5mOnZCEpXv9eJ9xs3N4fYWjaZJtY4x5m6akqZ6xWZmji52Nsnx+d4iJuJN8gJ6AfJamim+bqYObZZN4gnKVfHtsh5BVpmF9i6OukYSknK9gl5JtoYWBgoCDhZuVl31/ZY56p7mHdGWJg5xoqnmhZZVzeGmEj1iVdoOFg5+Khqifn5KanXCPhoOBfZGEj5lffn+BfnqcgJx8lZ+El46Rc46Vgn+hhIyVdH6AY4N+k46IcXmphZ16j3iNdJp+dnWFaFuIWH2KcJ2IhYapnmWhkXKahX5/fXyGl5lufX54g36TjYZ1eYaEl3yAeYxvgXt7h4J2YZlfgoeIkI6Cj4CReKGLbYeFgX+Oe4WMkW1+gYKGfp56hWBxjoCXfpBzjHSQfnpzhm1+gmptho2Nh4SIiJR/kYxuioQ=","139.5|aqFrq7WRXmfNroOJX49sZYqgrWKMw4qyka+sUrewYbR/h7CPcGyrP5iKy5ZKlJGOj5t3qzWlsIqq3Ju21H5ioob9qM15Y6KDmatolI1TrolgQ5mYfrSBWl9jlZx4nmx9mYmFs42lqY6L75+qhXJjmH6Cd2KZb5yobJCynJ2em4uml56BaLiriYTLoW6crqOdzJhXapp0Z5OacKufbEyCSp6OmY2T/OGqiqK7nWSUmJGqh3+XjXBuuJqZno2nbaGRppGLk6tzlqFjlIWbo5psmoyKoqJ9moOntISRl4yFYZ6akZinjY+QjGyelY2Vo5hqqLWZjLGWmnKcdmxzepC3lWSKro2nmJOvbZKdr5SDiIdnp5CVtLKJjpPBbdi7l6CXhWujpKWLr0uTcJWNdKSskoGjr3eujLCFm3l3pZWiiraMhHeNeHpkcZSqr2uDp25+cIx0cZCpgHaGeYhvvYyNnI6KgZJkdJaOgYKtiXWbepOJnYeNdopyeXtigbOLb4mZYneRoERxio+SkYpmgXOxjJ2Qrod/oHp3hZJ1faOFdpePl4iqnpR3imZ1aWCAlpRygHxvhmuDlXSfhIF2iFZ9gY6HmJeyh4C+X3eYjHyAh5d5mX6Wjo1nfnOLiYeakISAqGx1mJNtgrRZfZuqlYiLin59jHl4aGR/pYlti49vdXl4jHCJZoZviWWCeaaIgpicgoKRanmQjXaAl3p6lICZhaOGe32GbHhvapShk3Nrbmp3c4uieoh6hnmJWoF7mYN9spqBgI1kepiHb4qbbXqVioyLqI1ifIZ1d3OClXugdHZudXd3kGqNjHCIeok=","145.5|07DnYbpcqt0wfOuzTIHgy6+wUUljqqKlcMChRLdZrtBVTTl1eTHnq51XTuBUTrVhQmGKs9giskmPh4yDhTFNnmB9QImCUofRgaFJoMNFsE+3zXtYg4iiVDiXWp2XSoV3PXmAtVEHrVKXF5x+UXJNr4F/hkuCdl2duJVtY6BXqbpfeal2X4ibiKTaoWKOmKmUk6nWWZqIqFaMWJSUp/yKopteqrpcSZmEOradmpYunEvKV3x5d4lkeES5lLuKaJOJWYtlm9FykmOwjGtlho6aqUt/wkabsI2xoJhVWLGfnVWbZZibhX5ZiGSsmmSIb4hco7IwjHulg2qccqeBbU++gqF6w6GmJ6nIX2iaWUNAgY+TYJdaz0uDgJxAaGlbc4umnWGeolmLpHaWikiOZpOWbH12UHF3VnF/yYt3rbViiaSWlHGNl4qCXmiGnoOdiJ+RZKGiZHiNeU+Id42LY4p5knlzfn5lc4WfVIu/cXifqGaGhZC1eYSSirBJWbx3jYW2k4tdeJlmfXSWWG5liIZxjojQYXp+j2NzoXZmibZ8daCDO4WCp9ZtjZiDpEmFgHqGlGSNiV+pu3pXinJpboaIh1aJYr5TdX+gX3OIYnuCYpR4noFohm1vhodai3LIb3Z/g3F4hXRrhuJyeZ+vY4eEjpB8ioGLnk+IpnaFo6aHh2i1mGtulXJhcn6EgmiLa7pieH55b3iFY26He3l4nYN7hG2UpHqEdoeWV6WihoBtXYmGbp2Wd3GIcnZxgYSEdIOIr2d8gHpgeYN2cp2GiHadhWCMfo+ZfYKFjJqBnZyIiGpkj3p3mo+Pd5Nqc3E=","128.7|VHV2np6lcpSFdZ2QnYyKoJOScm2inpZwcKfap578ga+io5OVc4WagIGlVJOOzbyrm4bpfk1vm6DqgnR2wpKcemmav6ONbYljqXNvlbWjm5mEhpKegYRzqV+FsFmPpoKTlHh9gZF6mKGIf3V4YIaYZH+AimyNkGxzpoPIkpOMmJCDetfGkZqckaizoXOKkKh2p5e6kY6hhGiSh761d8usg6GYprCYtbmWnLeygm2VjpbFjXV6tryPmJTDs6OMc4OKbXetjayIibeCnpiitpN2m4SitZiIq4K3s6GTiIJ6bZqPkJezdnyZmou7qYGrp4l0cqF1eIuTv4uNnIKkl5SZiXTHrrGnsJWebYqkkJJ3n3hzg42R85p3falwjp+forGzqnN4pGl6pIuDdZSJiKuNfn7C04aVdYrJyaF5bYqFeYmLnoeGin+QmIOQl3qToK6MlniXcIuqk3WEdIB0hoeKfYOBfsCRhYmmmJK2gntxinN6iYe2g4KKgK+ejamBe42sfpSbfItzoZ+qjIFnf3aMhYutgH19pJOFobhtjZCJe3SEe3mFrKeGhot/k5iNgpV8jGuDj5yNqoCnq5F1gH9/fpmEj6F/f36qpYWYfJ+Om7J9eXxyeod2fHaShX2uin99nIeEiHpzjbCGfHCCdHmCh4mBhYJ/f5eCnYB6j5uBg5KGhHOUlZF6gXp9eoCGiZSAf35/hYKHcnKJnoR8d4CBfHSLpn+BgICFkImYmnqQbYN7lH2OepSIj3iBaoFxgYR6in5/fpaRhI5+d5KSjnp3iHZ6cY2SgoKEf3+Gmn6HeolygXuRgaKJiZCNeX4=","125.3|joruoVyq/e1hasHCaNXhzs3Comt4ptNYulbSrKO9Z8xxk6WxZoSnramhY2+Do6qgoI/KmWw6YZ+HT39lXo1rb4RPkG2Jco9+kl6ZaVKsnJ5hjIabgViBo6WRrnR/jZWWnJR+o6DFY5V5OoVudXpsd397hG2LjXRhvIy1pm2frpx3dqx8c8mwuZHIqHWQlLNpqGanmZCUa7GLiaO8hbK3oImfq6Smo5Gqm66QlHmrcqKdm3B2wX50laiws7+fdpB/qnOVbZWJjKFki3q0saKBmMi0faCwpWqsinygj5+JfKdumbR5enibinWeqKmtoKVznY19dZFrn4+RiGurdpjBeIWsepuDnKSOVoayynadr4B8cnKbf4J5eKqpc4Cbh4qupnSamW11qpiPfpB2kYZ5eX+Qj3yVkrSgrJR7noSJd6tziYiKknmgbZihZXmUtZt+kKyUYIOrjmR/p4V+lneUo4V7gHl0eoqQdoVhoHyVd3R2i3Z8g4N1e5Vgj6F4f5p8kYWVp61ljI6HnYWZgX2YeaCCkX19Zn16hYJ7hK+Ye5d1h3mdXW6IiHJ5k2COjnh/hZiJfoaajoiLjIpsiIeAfY57oo5hfX6Cc3uQpJKFjIl9kph2c4ySgn2TeZNufH9+aXl8iYp+g7GcfJd8d3eSdnyBiHp5g2KLinZ5jnuHfYOIkGmKfol2hYmCf416j4ZufXxzeHuHeX+BdoJ9k4BzeJF1YYKBc3iAaZiYeHuKko59h32Ad4qCinWHlIGXiHuWeW9+fXp4eo+IfpBuiHuUgHpzlnJzf4N6eH2Aj6RofYmPj36LfXp7gX+JdYE=","126.9|iZZsoKSaaX9Np292pXF7cYJ5pm5604lwgKZkbHKskZpwrJ2Idly9TH2Oi36MWItGkJ60mIM0n5OZh4KsgUKgfIiGMJx6cJPFanK5k3ttcIqSe4eUgq2Qk7VrlcBxqXNVro58n215nYhvuYuewXWds36AenGMqGdzkYt5n5OVaJNnkYt5krKdbJCSp3eRj2x1rZR3dndzjaGPlYaMl3JzWJipq3Skk7OfqpOSkX6JjauEpnqUfXyRqLB8mJmAd5PbeXeVjoh8eJuQm3ujlJ2Qip+HmbCNhaGDiXaZlYaRfauSeo2Mfoifbo6bmoiKoJJ1paphepaSgXp4cI2CdJnCfpSifZeUqJBvo2aaxq6snIR9bY2HZZl7j3eVkf29mYKCknSfrmF8i3WLf5aGin+XdIGqdoeipZSTeJN8osJleYmIoX16ZYCTaZK8hIGRhnN3kotpmWWygnSZfYV9nIWBZ9F4fnBnhJOPZYZvjHubn2l7loZyfXpxhZFbjZyQipV+apCal3yVaq10m4h0goCbhoNyjXl/lmuEeYpohcd1e5yUgXqQh116e2qCglyKjYSGfJNxhImSf4N5fm9uinqDd5GEm1ybeYGXZ4eNnHWAoYN7mJxre5F8g3+LhYZho3p+oW6Dkpxxg4OFfJzFbXqPh4B/e3OCfF2NmI+CjHxygZiNgo9beW9qiIKBgZKEjE2Oen+OcYKPkG+EnnR8l5F1epGKhH59boKJZX6ggoB/mnB/kH+BhF1/b3WGhIF1iX5zVY18gINogpiFc5mYdHyWlGx8i4p0fH52goSBk5mWg4KUcH+NhF6NbntudYU=","135.4|dZtcmVxvX112nouEooprd3mPil99cHmskLCvcq6Ahp5xgpORoYWAzYR7WIhgTIPHjpRri2z9XYDJkY+j1JWfm2dhfbR1YpR2S6dpnLxzqIKEXIyDedOWYGqlgY1wl6Kzmn9+fqyrYIqO6pGZzoWfiX6AdWCQf2OehoSmfWmKhHeBjZyfkYZ+hWd9m2yUuKSWqJ5wdZR0iLORg6OylVm9t5KMpJZ81ru/jaWafX6YboiilYuTnn6QpHqflYuPcJqhjouQkbB+jo+Don18r6GMkqaXi5SYrIJ5nIqHlJqHf4ZthYKqiIqqmZB/ksaAoohro7VKiZKZmnmXfYGGd4dBhJOMjqSsjoypin6Ok5l/aY2BrnGJlaiLkabKi7WTpIeKbGmfoVyHnoN9f5FzYZmWfoK8sIigdY+nlpd3nXZ4hIyOon2LcoJsb3eYo4xvfpOIdnx8iISOkp2SoX2BpXd4Z5iDgLCPhJOQsHHOfniXhWqGlIuzfoh4gnRchXOuhXOQnmWEpVKGd3iXhYppg4GTd3qploB+wZaHj4l/cXOGeJmTcoWQm5V4iG+Bg19vc6OFbnCQiG+Ai3GWkYmOjHaFjJR3iIOxgIGXlIWabo5vo5p5mpNtiZFugYCJdW+qjYGAuomEk2iDdGFueZWCb4aCiph+iXyBbF91ca+JcIaNdHRTfIV7Y4+Lh3OBf5Z5f3+Yf32yhn+KdIR2jYd3k4B9hZ6QpH+Cd4GHZm9on4Z5bZl4cHl6gnx4j4eJaH+UkHp4fJyAf6CIg5R1g2aQcHuSiW+LjoqTfYN6f3l+dlKxiXdxmHpzeXp9hm6Sh4c=","141|a7yjpb6NnoazYYx6uXujrq197neG2Ipqj3U4yW5uo2Fzh4XWltJuI5+QnB58aMO4kVybqyaas5Nzdq5bvbqwSpOIhzqFdqKbwWxceUrJbY6sy5WRhMahuk9VinCbhV2Tp296qZnhs4lpE7Zihnyug3+AhHeaoZVvhImpraO2g4SRb5+JnJqdqHaqqXmfaWlzZnmToHZ/n4+YmcCRhPxnPZyefIqcSG2Ff0zBl2GVnJmDf5Vta5CXRa2elmiXdaKm0njAfHSQdoWlfIO1lL2JnWWfiqR/fmODp6SWWYuUY5qerZeqn3WFa5p/q2qtmqt5urDBeKx6l5V3e5u9dpicz4J8O3mIbH5/UniSiWxTgZdnl5mhfnWTc6ddmxiTmLdinHixnah5jKCMboiRi32FiX2AyoZHoaSPXXp6un1TeZB7Wop3kYipaK6osnqqbpKhfH6KWXI/k3Jub4dvtY6pcHeGfrCGilqDs3pslXinsZN5foBZiHtwiY1ShJ2Uf4NIXZqLZ65dcYqMlm2ihXGojLOAa4R7k3eHZYSXfY+ve6iad31/hn6Od2iHjVKRmJV+o09oi1WIc4d7X5p3bpOFgHqIo4VNgIKPeYpioZR8epN6o3uReoOQhnCPh61fZ4J+nYWGXY6QfG6Deqxsi3lzfmeBenqLoFackpJ5plluimFYZGRsapl9bYx/eJyLi4ljgX2Pg4NjmZN/iol+noSWe4Z4ZoN7Y4eaXa2GkXySbmGCXpFudWlvln1xeYN/k4OmfmGCfI96g1ODi3x4jXyejpF3dnxuhXx1h5d/h4yqfo9wdIdrjJ95eGyVeXA=","135|pVqaeKNvjn5GdpKopIirroJ9q4l0lm6bUbhMok/Hg2WDkmeMjk+fsH6DAW11O5uijpeHUZGfoIt9IWV1j2aekGpMYKuLiGWvR5VlnZGiWX+CwWh/g453TVCWi3dom5CZpIuASI7TnYSEPl16anWeiH+CjIlmdm6SnWyZcpNwfKhlfoRLk4RwdoedooRhvIeMjKNHlmlbg65he3dtin56o3eCY26Bp6GbtX2PXIfIj5WSeX96Z46OfHqXilekgmJzi4aGlZaIcKR+w3mAhJeMhW1lcZ53f2SDdaCOkoVzhpGUbHuXcX6DaI+Wh31qfo2FXo5Shn2chYtpbH9kfYy2dZJ9aFnJ34Jmg3CUg6x2SHOIkY6Fg110en92j3laeV1htIJihHCDgollgaGKdniZcYBtb4aBeIihe7N/X5dZhIiPVoZxVYBTiHWfno5/a295iJFOf3l+iJmHZHSCmodvX2V2fWFuiHWLfYCfkX9ojXCBh4txgnZwgHqZj7B+ilZtkI6NfDt9cmOZjoiTe4OJh3GfVXmClF+Ic2t5frSkgWiATYCEo3aKdWl+t5xsfn2KfmeIkJ96dXBscoqFi4l9hrKFi35ien99a4Zuhox7bnqBZ4RxiX13f4ORhmuRW3t/k3GCdXB8fZOjfmSKdoN7jWmBdHWCl5hmkHqOhm2IjJxrbHxwcYeRh4l7gJODhHtxeYB9dIN3dHt/h4R+Z4R/hH+TioN4fX6Nk1mUgYlnYoSJmX1sfHB+g4OKl3eEi4Frc3F9e3prf3KCfZCQiYFpkHaIgZF2gHZyf5R5YKqKiGxnlHyQeV1ye3iGgYU=","145.3|Xq7JbcWBwcXQRJyVua662qafeVqohMFHtCeg0kl8adCwzXuKbF2KMqywAWzH5verimLRuSmfu3tGYqU9eLK0WIlpmJNtY5G4N05gTtDSTo5iypeIf0mKtz1kpYqItmCLrkp9xZE9t5WI3a1ODISwsn+DbFiPoWRMs5GNZqZUkIieX3e2n62iW6/ZsGyBiINfy0y7sWeYbHF9kM7Cb9rGTbiuort5qV+XsoBaqmeanpGwv4FglauYlKSQo9mDa3yElGu4XZKSaXNmxZ6Ks5Vvh2Spsp2gy3iGlqqWX7+OZXSmgJegk2uYl5mkr7GQuaZpfJlnbrdP4J9jtGeyjYmTbGupj42jv52iQoycg5pE041ndJqH1u6NZYWsmdmXavOzdGt9rlxtvqucc5OUUrCoj3qsnI2Ne5GLoXV2bY9BcZdmpo9vhHu7mUCDfmp9X3p9u3t6T4WwkKaPsopxPpVxVXGLe4eLi5mVf5i5WXhwvGJzs2q0iXmSetimooJib5SVYpiwon1Wj4qzjW6ahHRfj3ayz4h9WJaPsttukKNdeW+YVXWZcLSVbZZ6gaeKa4F2eVRuodGWmn60s5udbp+BfZuKe5ekg36Pm46gp4GUs5R2bHZlboOSfnKZijugqoJ8V4aHlqV8kWcoem97Z3ega7WCcXt4naZvZWVreI5wiJGKaF2QmpqabpqEfFqJkISLhX1khIKUlXSGt2V4a4N3dnVkwIV5on2hmpZ7h3KbZWOFnnR5c46OlY9vn4qjZ4RoZ5GCfIWRhZiSfpugbHxqdmtuiWiig3iGdpV/o49jdZZnZHiYeJ2OgY6NkHE=","132.9|eZCRhX6SlplJapSkca2Op6OctamYn5qBhaGibbhxpbKgoqiQg7RydpaZhnGdc7iNlZTyjC43fIuqkYVkl5ZyhXe/katuoXpBtX5djn5wtJWqNIWWgJiRuEB8oGSGmH2Bq3yAjHd+eo94t4lqZ4NzVH9/c6d/kZx8sYS7iX2CtatmdWaldpC0hpOXipJ2oKB9rJKCdZyAmmt/lZeEn56De5ygkJqQ/ciApKWqhmWcepeqiHtveYV3s5KOhZNzk3KJknuIj7R4k3mkfI+Um3aUkmCMoaaJmGaIq4qZl3SPYpN7g4KWf3qAinmbk3uGoneXX3bLfK6Qqnmbh5yLkpWrn5zBiKKzo4SbPnKOj3RvwodoYHuUr5N+dJJ4eMe5iK+KgJZpfpx8koiAb4Z5eKNxcX+CrXaodn6MhYeHXI2Zfn+KknuQhYhwmXt+o4mQfXyHnW2NSXjBgW6AWoBycH17c5l3foyMeZeZiIGseYJlU5V8kIaRfIhthoKpkJiGi5CIc5aZk1VWgaqKkop7hXGDfI6UZXh/nYp8iZd7gJFWiGRqiH13nIZ7j22GU6WThn+IolJ5kZqMooiOlY+CinKDfIN9g5aXeX+PmHqdhYd7h5eEaHeVfXmBg3OQfHiXg3l9k4J5lYR2gJx6hGWSknyJhWp+jXaIYaKIn4mGiYN5h4eji1tsnY18iHp+eHh9jZWPeoGEgX2Oint/sHiDZ3yWe2mBfn+DgYhim6CbfYaQW3eHi3qPdHCPhoKKa4BsenyHnop7fnmHfJyLd5WecINphJGCdYdme4V3imqAmoCTiJNdjIWPf5CHfZSKgYY=","124.1|hYR1dJ5raXhfmX9siWlzf2pwWJd3ZXGJmGHObZ9van1vZIlokbxZn4Z1hKVpSIqNfXS2fb79mXycnYCbSnyJoHaMSnSHko5XjIefbXBpm3xoYoVygIKHoqSTcHCGYY2PcXN8dZPLlXtvxX+TnYaJcH6AhZaIg22GgX2IdI6AdGp0jJKvhIJ9k25+jYqNbJ+FfGyUb4+EbomKdH2NkJuWloBveYlwf2ePdK5ueZKUjHGEg4ORjn+FlXmKiYSTiI59goF9cX97i3hnn3p5jGmLgoGIgm6KjWNsg4t5eH6DlG2Ojnx2gYaYjISQiJiOiI2NmH1zgndviHiNhG6Pd3OCdZC7jJuJaW+XdGl1jWmTjYOPrYqBZomDjoiMgsiPs36ek42WgGWAfYZ3in6Feph+d4CIjoGPhniekI6DmnSggHZzkXqGinqSb3xvcYuKnIV7cV+ld3GBiI1/jH2IiIaHjo16foSMgoyJfnV6ioGTb3CAiHaWfYSKeophe4GGh5SMjXNxlJ54cZR7e31ygYZ7hoKLj3x+hYmBhH5/d5CfgZJ6i4CDbXR4hox9kWJxlGqEgZOFdm6Bh3qQhHmLf3iCgoCEgXudfoB+koCWdo52oI6BkoJyhId7gYh+hICIoH1/goaBi26FeKOMgpV5cX97d3p+hYd5cWV5k4eJhYeFfGdcmHtwb3uQe3h+g4SCfXuNfH+FhoCLeYF4joqCkHd1fYV2fXyDh3t9a3mKaIWMlYt6bGuQfGx6foh8b4F8hICJe499gG6Nf5N1hHmDiYGRfXaDenZ8f4KDeXOAcHyAhouOgoJtcYxwc3WAhoA=","137.9|eIl+kWqRfn1WaoyLaYR/m5mSl6KnhH5rfMKJtGlYn4ekiqV5o1i0xXORkoiXYmlnh3lrbVFZbInQsYVsuGpqjJafUJt7oI2nUHCzoY+wa4qdapeMfrqTWrCph7hpgZp1jYF/XYxzb4V2zIRsj35rr4CCfKWPf2htv2+AlXGUnJppdJiKb5qYsWiikZKFsJd0fqRjlnSCmL6Nk4twknyXs6KQqJKSnIyUgaubam10coqUj4xzhr9xlIGerI9pj4OfbniRmq2OcWqewJmXb4aVgaibl4aOf3uJjZSOdH6Ea4V2k6CthnmOlW+Qo4iLrYKTe6ZEdoiijox1gpaglIt1iZiMmLGrYZ6rYnSAtmiYjodrcXiIgKiCeKJrdf2kkYCPdpJ8o192j6VpdXh0h46ddXuvd3eQs2KZjomId42ReHaPk4V5kIaRjZCHrpJyi6CRhZeIZ3FogHGXd3h0eXmUhJJ3fIiAeY2slHuqgYd3kml3f4+hhXp7hoiYgnSRjpKSn4GGpmlra4yOiXmChHR+eZaMlnmCpYd7f5J1epOJhnWch3phsnCIeH2ClpqXZo2KeK6PgHGZmIqJhH1/fJGBfXF6hGqgeH6XhHmDjoJ3v5GEdY5sd4OEgXWAeo6SmnmAp4N7jJZ5eYd1gnahbHhoi2OEen2HdpWZZo6OdZCLgHJ5pG1rbX54epB6eXd6hmOWeXyUgXiOn3V8nn+EdoWBeoORjIN6eoOKj6VnjYtypI17c5KQeWxyfH98k3mRd32NcZB8f4t7eY2LfnibfYN5jW96b5F8gXx9hX5/nXGli3KUl4B7kl+Kd296gH8=","128.5|eotpi3CCcICtWmpZi2d2hWBxcZeImWVmb2qCrZKuj3l/ilZqnp9xkneBeF6AbI2winF7eEn0coRggZBXeoGJUG9baV2MlJe2hmtSc42tj4aOupKIfYeGokiJg6GVk4Whgot8bIhqcot5QYpeaHmIp358i5aQjW5peXmSd3R+cXaUbmqdhYaAf5Zyg4uVfYRwfW+sm4WJi3CZhJuSev2ViomCjYt+RmeHhYSOc296dIiTcY9nf26FZnmOi4GDipScjXWZc5OKhpSQoIV3cZ97oVWVeYORlZ97mn+Eb4t4bH93goKljXJ6coWGk4mThYmNlZ1udZlyi5CHjIyZg32niXt8c5eGioCRtG2aZ6xeiIdysnl9g16NbJdbhDxmx5aCkoyYnG50lIZ1eJZ4cnudi3x1loFoeJGwiZeDlpJ2eH10koiDbIGOe2+QioSwe6aHeHyPqXJyhp6JeXx4knh0dZCFfpB0gm6RioiUhYSQqXB5g3adgoCLgpFsh5uDe4t9i4WIeJ2jeJuGf3d/f3uMemagaIR9hnCCgIyBipGZgpCLX3uBeJuKg4qAqmxwk4J9kU2DfXWZj3Z3h5KFeXyAgZF6fJNlg36Ib4B0hJ2Dg6GCkYJzeXWDf3eDfWWXeIF+g3eBb3WAh52TgJGWcHh7eoqChIGEmGtspoB/poKDfIWUeptyh5OEd3N9fIl8fJJwhH+Ed4BxeoCEg5aBj4B+eXN6kYKChoKRb2Cgg4CLaYh3hJt0h3J8lIN5cH+BhXxic3GAfX5ygWp/fpF0lIGPhHR6dX2SgH+DgJGDgJKFf4lniHtzl5WEdICQg3k=","132.8|iJNwh2eeSIdPm5h4X4x2WoBzV554fWpkhVHcda9fZIhsf2mHapZ5RnOGIPqhhHZggJqnnuFRaIawG36h2Dxi3WSIc9iTmZOBemm6ZsVzqIhfs4qIgISItrtnfmdpbHNlXZaAqYYSa4uTSYmZOXZneICCkp+OoWtoYIyGhG6Tfotrir2YbJt3hHetjY2TdoVwjWKqc5WvaX+QlqhykILDV5iEd6SDjo6nXMSFl6EpcnS1b3CTlJRxoVeugYxwjpiHd3aOaqx8kmhi/Xt7mpmLhoifnV98kq+MmH1ql2uPoWd0h56Qeol4iG+ZiYSTfoGRpot8dXhmoXiVkmpzdHaRlIyh14CQCZNXtJV8dHO1Zn+Za3Nzu3R9j4NscLVznXLKk5Ofpm51mWCRkF91oI+Pc4Ker3aZgoaVxKCDpnifd5hwj3uKonl8aJKNl32crZySdoWCqJyKeHCbb4SPYXiLnnZ2gIlud4edt3/AiYOdkG55bnKdf4aZeatWbZSEhYrBZnpseoKfnpRraYxigY9ueHWee3p/doF3jouBeqyGhJ2UkXlueaF4jaN5v1iQlpmEfLVzbE+Xp5Oml2ByjGiAiVN4c3hqe4CaZHeDa518i5uDmIZveXx4go9ne5uZcXyBcXJ4h2qDe8aWgJx7b3Zlc3uBiY95k1mZqIN9lad0em9fjJylf2djiGWEiGV5c3FweH+BcH2DcoB9qZWEmIOCen1vn3yEfnmXYoKfoHyVmW91aIWgi6R6aniJYoV3dXuFjXR9gJlueolvfoKTkYWWjW96d3WPfoOPeI+Al4iIgJGZc4Fyi1uMlXxvdYg=","137.7|oY21n5qxm59lqaCem4Gggb5//J104qOaerU+U0o0WIFnsrLHp5GlKJGnTHztw3eAsnrsgpWalrmNPIywtHiVaXAkoGGJmpG4e5aNnnBTUbBUnImghH6WqJ1MkppmkltypXuBcY+ulKmHG4qnWoaXkn96h5qJwHKXYoCNrJK1i7GBktGWj32Ol6aivY2WdD6OmaGsYGeGX6mQtKazlp6WQqSXoIuWdM6uij2zdoWLjpdkf46dk/SOVrSjq3aaj5i0novMlHxya1JWVXd3s62Ur+KMmoWHmXiztp2Tbp+Jh4eOlMl+iI2Yko2oq3ephreSp5p7iq2ccmximWCTcpm/mZm1e558V6c0nojMyouLWIeEeoyTmIeLmIeYjTprsY5uwo+gwGaMjah0gH6Mpm+BfoGurIVckrefirWFpqo2hbGTgXFvjnaKbJWmnpGid4GQlL5dmouLnJZ6inuDU4aIiZJ8f3iXh2eDhY9fqoOep3CHjoxke3SKc39UibCAiJV3Wpx7dYCWn7eYhHqOgIJfhHthjoKBqZKHcVt2jaGJhJ6cm4mNsYpzcZF5cVWCj42ErJtrjISefYt+hYGRdaGEg2mDcHlSf311rYZem5ONZqWEl6lxiZKRg4KIhKhNc4CApZCEan94jHmnhZ+YcoWQioF7cYF0i1qInHaMo4FsiW6XXo+Kjn2cep9/gGKDgmBrgX55hoRrbHWFcIWCm5p+hYKRZ3t3i3WGXnmckYiLjWOFapB3hYqEe4p5kH2HboKQeWiCgIuQgWOGeaZig4CXpXmDlpB7gHWFdoeDYqmCiY6Nb4Z0iGtxiYl8jXU=","125.9|eIy8joR317xro9O1YriturikuGFmk5pss2ulVo5ia7ZudKW4asJ2db97lXNoSepkfnfEl2PAgnKbhoGmlHlmdWmbaXd+ZWmVlHBrdI9YjntolWaCgI6Bm2B8fYaMeHtqg4R/nmOagn15XIWdYYNpj32AfWNqhIVzsIqZfoSGj6J8kYyrcZ2cgGykp29riLZ2oHOxZ4iNb5xmeLK2hPzDepuDqah6h46wd5qckHaIhYGjnnWTgHFxiXmaiqGOcG+Cv3qMea90h3hrkHCKqZKAkHaxl32innWAl49/d6KFeXuBlIWfeol4hHKKgIh8ipFvcouYeq11mm+GiXGgdX9snYCvgIuVapisX3qQWGti2n15nIOEk3x7jaOacoaCkqCWmm5wgI16mbOMenyAbZuFe3+JlXeHbJKIfaJ4dIqRepF8n3eCkHmYe4F9knmTWp6TdJCPY4GTgn2KpYV6kIOKaZGAgKGKeouJoXatgnh1e4N6mnqKeIN8eZSBeXd8f4mFe3KDlIpqgI6FgHqbgnuPgY2jb4CBhYl6i5OLeI6CenVse3uTf4x1gXt6jIOCe4R+lF2AfmeWgn6Ljn2FeJeBfn+Aholvf357jniOcpV2eop6dXqFeoyUgH2BgX+Sb399gIh5iWmLeGOaeneHgnuRfJF9gX96h4OIeHp7jIJ+f2aPdmx7gnl7fI6CfIqCf4N3foB4hnuHeIl6hYd7dXqJfod3jnqAeHyJg5V3gnuGcHh/bI58eHuCe4N5lISHg4GIeHSAgH2Keoxthn2GgXp3hYJ7hXqMfYF9eYR5mYiGfohvf4FykZWEgIB7g3o=","135.7|k3tiuqq6a36ynF1irXVpeGRuoKhnpmR0SnR7RGwooJBe65loX8GIVH26smr8a2GCoo2SgUOboqSVjnmhhoOnZ2SXaouMnoS8Y3ezeHhFba2ptHmxfJZsWsNql5Jt1npx0Hp+jI8VoLN14XuWV4eltYB+iaV+a490bIZjq5Ssd5ePimS8l317caiFkpGOipF6pHWjWnV1mqKKz7KWY4KkXqG/aIyofYOmp4Njhmtakqh6i3SMieKTe5CcpHmmkY9+oHufdY9ycVWj6GyAjYNqiZOggK2HoLOAjKKelnR4apCXjM2qdYh+jJaTmoWdlKeXoJGGeLR2g2l2oZqacK6SnGGfgo6Nf3dgol+phr+0SHRteJCRbqV2ipG8kq9h3HqNppSZm556r2qNdY6JhX6yioGHrIp6g5i2ms6FpY6lerd6enF4d4eOYol4qmeHc4aJnWaHmmeCj6+Nf4N1ZIZ5dn+Cf3WdiHWKi4+Ko4aZiox8l3qAeHl6iZxOl4WEcXiEb3yic5KTZJ2KiYZnenV4g3V5ooKBoYeGcnmDjKikhZxrpX2gkWZweXuEtVOenH91gpxyf493fJV+iIWQh4F4hHuDdWiqgX9iqYZsd5uDhX6CmoqIeIxfe3eLgohinn9/m4qEd3WEiJK3gpyBiHuJe4d8eXmHllWYq4RtkIV1eXpNgI5dbIiDh3eCd26HhE+Xgn+GhYZ2Y4OIeJGFm4CUe4Z0g3p7i4abXoihfXRsoWt0d3l+hFt0hYmBX39xcoBxVJaAfniWhXJ5gZR+i4GZiYd/oH18fnt8iI9/k4mQcHCbf4F8eXOabnB8iYM=","139.2|iaGvgr62dYVATpiTtIqfk5qJqnNweK10mHN5r7+lbJpjmWLGjniNra63Q665YZHTnp0ymeIotKOJKYtDz52zs4I2mIyLcpmieXSteky0uqZtyYahebOba8+Iu21fbJK6g5OBjtKKsqiME5JUvnmsfn96h3GLgHh1c4xemqOOr7dmacgYmqWAmEJ7i3aXvpJ4jXmpoKDLbeCRon1xqgCbo5OWipGhZ7DFjnZ4iZ2OnIxlh4FdrHSaZKu7jaDqdpyOjXque4eHkoRukHixhpiYkvSSdYKVjWSBl6CRmZqToIShq5mBhXB2dplLsp6odcd3sZ15eox7aY6gr3SDcJUZiqMtjHRqbZtOP5y7qEi7M4+ZYpeaQX2HaKalmAVYXIGFjHinnnt2h2CDjnaQq2qBcnqFdIxsmKVylKh7tHdfe61/VIqPuX18XpuVg5BinH2rnriIUZ2LpWNpj4CNWpCfuIN2fIBnhmaYqWhb1HuqeXt7jn9Zh4eWe45Hg1SmkICAin2Qh5ZXjIWQjotwg4xsjqJcnHl+dGqHZESNbVatfKd0s3rCaX6Mjph7rEykeqGJd9KLg4+JiZl6got3i2uGlXKKjmdOeH15VoZhf6hxZo15oqt6fph3hYmLh6NQXHp7g3GDZ3aKa264fapge3ikfGyDjY16mkubVaSNZoKIeX+Eil2egYp2iWSCiWaJi31pfHyjeoRrboxyYZB9ooN1eqV/R4OFhXqcWrJRnYyBoYl+h3+JdZ2IiXqKbX+gb4egnGF5fqBuhF9uiVJihH2hioR/lnZhgYWJfpSApVKfinmelYSPe12ClYaHeYU=","146.2|aqxPfmGhP0jEU3mKuH5zo4CRg1eFjZO/g9KpzU/eortzq32q8CatxZG0aqu4u42ErKAxaUO/Zbi7yLhPzHWwmnqxt+ZzW5qjabddru3LU7Clv5KheKG0SjmpqYCBkpeJlX1/Qnm1arib/apa44CwqX+BdleZYGCynGmrkWuHmJydZ5+tm56pj4C0hGeb3LGjp7GapGeRm4SYmpyyjn2TsquErKeX3NmNtMKlW2y7b5XHwr9rsqSXa7uZm7Vta6GIUJSxn5GQZ5mjwH6Ceoidh1SOn5OUqFymrYyYn7CJZYtxjGW6snK0pJpwobqlqoBoq7w6k2Wuu5hopJqJdaE1ZJk+vqOozp7MjpyWT5Nub6FvinKbzcSpcIOYm5GgbsibXmiml16Rm4JecqN1rqqkjHu2jYlvc2t+rYF3qYNUiniYj45vd4eLan+WdJdkpbaoqW+kh52eeKmUt3FzSXh6d4aJfqCOjGWsl37FZnmhpWuMkY7LhnWahbpWomyai3a5l4CThGqGpXiZj5BVhXRTdm/BoYWAr5SLgbNqe2JMdp2JZoyQmdKOcZ2Enlt+ap2JgFqRk8eAqYOwon2TjYGEgJ15gJ66g3y2lIhnWnqArqR3nnlqkpBkgnaUfIPTl4WApIOKZ3dteHlBe55/a46YkamAc4aGnF1oZ5+SbqWMdayLoISinn+kind3eGV6iKCghn+iiIZqem1+knh3lntuh5WZvIR2rISgZlJel4xrWJd8pGqZgJ+NgYyQbnuWb3lsqKCDe6CFh1+Jb3WhbXqVdm2UfZOagXiNg5CAVkeYi2tbjnGebI1mmZyDjIg=","138.3|rXSpiEdboK42m62aomumo7OAg2CKj6aMj6qEXbIBmnOXeo2NPza7iZNnPatvNYWScXF9nMfOUVfdM2al0meipKFLQnuVZmiwRYqvk1ZaqmydhHN0fMZtP8KHY6NmjHqOqXt/s6ykUWR5CXCXnXKfhn+AkmFwl1yJ5pNgeVyHqqJdjO4cka2MlzqzlXFoonWGZJaIZZZfledvhWxYhBdjipCdsYJqRYueho2On5eOZItxd12Og3ORn4Oio36qcGbCXICBkL12kFGbe4aDpIp4fNeBp5SXcnR1gZmMY4eElXphkoKWZIl3hI+PsW+Ni6ttUbgpgX2Shm+ZepaOjoNvdX1+knaXZrFjSYqg4nWQWXOOmWqFaYVljLRjkaC1XVmOpW5Zr1CDimObiYFxQn6BbYCkRYedx3CLfY57Ubp9f4+LiXaMh4N+lnKWoHNflHiHecdqUYRQl5dvUomPmnGCoZtzgo1aipqUiGJ5y3pdj2WDYYZgd4hkhHCuhXaTfo2Bg5WLpHJcbnaFh3J7f4yccY9hjXiCgWGFiXduaYe3fF6lfoB8lGx1i1+Glap9VpJ7ccaBjWKZgoFvbH+VdHV+gH91hVpLeICYTIWjn4JqjZB5YZpkgICAfoeJdG5raHmAmW+EmZd3aXW7fFqxZ4NZjU19inKGeKaIWIt0aYCCkk+DlWR6an6Icn6EhI15h21jeH+RcoOXinRxeYN6YJd5f4uOU3uFdoWDnaRimHZso3iOXJWVcntzeIlzkYSPh3mJlmB8gJFfg6l8el1+lXlhoWmDcItcf4Rwh3l9n2+md2ihgoZkmlCGh3N4jHI=","132.7|jmDEYYR3zMKge5CigcO5r8KYfHdwc6iHdFPZnYrmeqZreHCbjYqUu4t4dWlyvctweWbbXDVwhHudRXR2VZl+ZXuMrXmGfoVvhoiIZYicjHl1tYB0fkBwoZCfiFZyd5Z+YXyAUYpmg32LO2t5QYN9cX5+g3mFcZWKpXByV4dUr6ySfHJ+goygpJTnnHuJhvyGg2aflIeKemaIYpaPbNyQq4dZdbdpvHBpgqxhZmmDh2qHjYR9rbmDjp2esZyGf4hTg4SdbpeHiqF3s3dVioBylYumdmiSfIizgINmbnFrZm+AW7aLe4COlIKYl6aak3d7kmqyhoRloomDf3aheGKSdXCvi4WJoYSTqYmWd5Z5pHlqe4NmpWmBfqRqgYeGWp+8n32LdqCFprBrdYCDeYmJin94fIGIdJKOnoB8lHHQg5lwi4OBan6OcllpYIWMoJKOeIaRoYWNcoR8jHVxaoVZqFmGfXOEgI2pb4iNa32NdY+EgnV8g4OSfaNicn1beoCCmZNzfJibkIiEaXmSenRrfVaHbIZ9VYeAmIV8hZx9fY9yoIKBbpOHgJR+q2VbcGt7gpuNiZqTm2mLlol1dZB7eIqDZJ1jhH5pi36MgYyJhIJ+j36OfnyQe3Vvg1eGV4R/UIOCiYF4haVvfpBnjYWBdX2BgYZ8lmVWd1t/goiNiKWWj5WNkYl7d4Z8eniCbJFxg31YgX6LeHqCgIB+jW2Bgm9ufYJ+iX2Sa0qAa3+KhY+FnXl/hYyEgnx1jXp2e4FbkXCCfmyKgZSFeol4iHuPbY5+fnKCfoSJeY+AY4tYfY2Kk3+Sfnh8hYyEe3Y=","138.9|YqOR2WbHgaMxaXpyXoOXimGBf2Sdr3RohUGNwmF0aHGmu19Qef2gRGC1SJ2wyDexu3lmpafbabRcPIZjjHhnf4CNjoiRZHqGTGunYLW+ZLlrRIe3f2CZpJ1wqpt5q3CZr3N+rbKLabd1eZVtOXdmeH+Cj2eCr2dqsI+aum3HrHJcdmutbHJyebNDj210nlVsXVqCqG+qb4h+sJdqpFKqVqOye3itVjiVjJZjm5B0b6uUhnx3nlBvVXSVe46GdGykhnCPYZ6Nb31q2Jahi6ealX6Zn52Gr7OEjYqdbnCdkJJ0tKCagHl7a2+UmYSkpp1wWnpid5BgvJduqHKOlpvCfKFtmIOUSoxdwIyitaWGQ46Nr3KXhod9doRWcaVo52iRinFjo1d1k0SQiYp1lm2cbH6FpnVdsYa8or14UbKHdnNtjZF1lHuCoJySgIOVr6R6gIN6r4xHoo+vVYeHl3efcK5ygW1qdWJ/opmvuXhdmnB4eG/AhniHeYm3hZt2kaqgb5mRcoCnjalxkXZ8iIeTeYKKfXSBd253cr+Kjpu0e1qLY3htaG+OeI55k7GNqYaIgH5xgVaPlo6qdouCdlWHhXd8kG13eHyKWHZcppmPtJ56YoZxe3Bvh4qIeJqLnnuAZmp5Zo6Njbrcely/bXZgcISBdoV5ZbCTuHCCj55yh2poiJ+SbIpzdl6Eg4h8hFd3dX9tbXppk4uHrJ19Y5F8eW90vYF9dnluo4GxjIKLlm51YIqFhpJvjIJ5Y4OAiX2CP3l8foZofFiQh5Sdlnpflm54cnGif3aFe2+CnZ1rhYuNdIFpi2yLg2yJgHw=","133.8|ant3uWidnXHRlXiArGd6qHCCa2mUuIlqeb2aorM9Z2GdqYGHhsSViYaDnJOVDxqvhmpSbT1rao2Z24eXMqSnjpD9VYWPbHWBn2qaoGWhqodmSYOgf4F3SpePhI6PuYSbqFeAa7w8bJJu6H+Qw4CjhHyCjWx9f29sonGWqHG1kZCljpiLlYKKxVaRgXFukoJyYKKGk5dxa6t5nKZ+YDqPi5Sseoylo11tgpqEcmxOcZSan4SRmHuSU5Saf3ykdGylgXd9mJWJkmxlm5CCaHpqhJZ8pp9yktGMn66IZnlwbZpyfpmSh4eckpSPnoeOpatyXJFIeX+ghIuWdmyKkZyTiWNmr42bWWubv15+kq+AKXtygXKFVquDiIxqlJOB3mOFlXJjgmV5h0V1c312iJyfkoCHs4ZWp36ZsbF5VqyZd3SOU4WNcnuOm6GGZnRpwnSFeFbJsFtQo35yaHt3oHmAt62JfYKIhWSEfmlrwHpieXN5hYuvh4hsen6wfJ56b5WchoWKfaKnV5KkhHhyenmfe4WZvYeAn26HaX6Jcoe8eGKFjHqHinuKjGl5ia6drGB3gZuDgFRukJB9aJF3cmp4iHJ6gZKrhn+djIhhjIxsj555ZYRzeHlVenZ6epmjwISCl3+EZX+Rc+TCfmO1dHl3i1aCinR5ZKilqnZ3c5mEhG1HtqJTZpJldGt+fJR8e5SYhX16fIFjg4h4jo19ZIpve32SboGFZHpyo4uTXXtwjoB8Y1S6iFRxk3t2WX58i3t6o5qDgWSDhVhwi26RlH1ki3Z4dotgfoRzeG+AmIZ3dm2NdoVqVo2iZ2qQd3w=","132.8|mG6uclSEn6akmZGlZr6rg668fIlobI6bk1Gpj5eShJxii5KsW8NenJOeMLCaonJWoZLeer/AWZyFNnSfQXxopX9McY+PiX8YophBZ6aOlJx+OnmIfG9orkOAoTaFcIRshG2BiUXjXZKfaHaVmIdqI398i4V6lJSVeX/9emh6pouNjmqWdJKsnKaIloaFnbeUeWWWhIqTh0KDg36dYZCblHV9lqZ66J17i5+uh5O3bIG2XnKVjYZ0k5mThpSLhotSj4mGbJ6DjXx+YWyJpIdpo2aAfoCfkkKQpmyLlI92kndml6J1doidknaakcGqg4uDkGHCiH1nlIOKmn6GcoC4nV/Fmq9mn42mQ3aZgV5un3WOjG+XxIdzjYh8dTy7pYx4sIONg5iId42Mhot0e4ldhYB8uniPbMXAkbOBlUq2h4VwjYKGlH9lbGqLaGyOfnt1lYKWU3amipBupIKOYnWKmm6CgaGVeoOhh46onYGRZpKEfnSQgISKgmtckpKEb4GWhoyDkG5ZjJlkiIh+e4lkdomrd4CAkoN9n5GRiXGAgpBth4eJWbd9hZGBdV9xp3FynGKFaaN0oH5xkGyNi397h4Z5hcZzfn91nHiJg5uHbpiAkYiRiYR9eYeOeHGbeH5/fot8hYOHiJy6gZBWjYWJdZWChYiAYl9oo4BuiYyFfoCDel52kG6lh4GAg254ibF3g398hH2AdYiJc5mBkn2Ag3hsh36Cl4FlZoGcbXWPXo13i1t8dHmIboiJdICJcnmLpnp+gnKSf4iNhJxsjoCRhJKGiHGXfoOEfmiAa41wdZBfjX+FXZtld45vh4Y=","131.5|fZfXfWlt4+O1lrqtY73y7dKuuW6Hw9Ct01XqmH6/WMd6Vq3RweO9ptBrWnwsMK6ZcXz9ck+mbHK2X6CYOqFmfohrQZaHcZm4Sqixa7GXfHJStZFwhIGZqq6YeJtta5SRUoR+WbKAbHOCm5iVn4RqtX+BiHCWmGak9XCKd29/p9mUio3UcMPHlmHNvnSZyuCbkWXVjn2VYtuXW629ht3inMJqxK92uXTFYc1ybHFzdHKhzKWQva5zg8+zscySdZy8qo21a7GGe59W44KB05iPp8HCrG+0o5uyrqV6eKqCcXpzhbOxnYbWoXKgzN+vw7p3oadri51ov4l+f1+wfm6ae4zNlrqwcpuzpWWgrH2Qn5Jxn3N3WK6ajK+SdNix2JqusXigu2aHn7hqeYJ1eoSwioCsk3qBrn+ym7t7oJ2Uin5wtIV/hHWocYCLZ46eqa2dYoCHommLnHWgu3l2oHeDd6uGf3OUd4bCjnCpmnuWsm2Io3KZg32Pdrpie4KEhKGVj5V6momebcGHeHmPhHmWe36Ym4WAhYl6i5J+ctKefJmYlYeCY4uJfZV4wF95nGWDmKKJm1+rmnSTmpBweJiDgoh6in6Kgn9nmnl/l5tvw6Z8l6JtjIqAgnd6e3t+ioR/f4p6g3+FdJiQe5iXbYqKcZKAfYZ1lWF7mIGJlo6Ih3hziJtegZFzeId8eZB7eVuCgoCBhnuDkX94oJJ9lINthoNyq4F/fHiaaHGJX4ePmo6EdG94iF+Bi3p7gniKjHp6WoWAfWiSe4R2gX2Qh3qWhXGQfnGYfH+GdZCDgJB9g4uXiIZ0c2x/a3uIdns=","138|pl1qw2S0bHs9T2VorYFpR4iKfVGeeXethUKdr7IAobCmzVl6TZB6zXKZg3XmbRUlnI9fflVbaqFoNl5GgVGoj4ScWKlwVYF7babDW2uxrKKlSY24fZNpsMaqkJFk3aFU1rSClWw3ap5yImFTqHyndYCAdFWFkYincohUwG3HfZhiaEihlmuPc0tnVGZ5b4aacFVnn5RumHx9yl9Me5GbuHzMmX2znp17oZNhi25ZcbJzhmhdoViVY4NVbq1ManOSc5B3ZZiIkkqiipaFZGV9jKyYhL9/dt18lT2gj2iBbatziXduYm98fZNJiYNte2NjbmCsjX9feI6aiJtgjbJbcX1jg4laV4RvvJKGgcWvkm5vZnOZLj9haHNpkIWMhHqEXWRygI2La6KSdIB1k3Zgb317cYhkjnBbg4h2ZIOKiF1pd4iOeIZ2lqpyaXWhu5pznJuGsJGfb4ubfYR1dHiAkbp0fUWJiGWig25sdHdrUoWLinF4hIdmh4WmkVqOhZp+nmqacG6llrxIlIdue3iOeotqQ3d+aY2IT3OCcHw7d2djt4ZyWlaLiWOEcKariXZ/jrqRXXGakKiJhGiJiHd+dWF6el9ad4F4cohOfHZ0jHV3a5+GjnyReXSFeax+XHx9coqEaYF9b6GKd2ubiIaXcmaCiXKHaqe4hJB4kXqPbG7lpqCNqm55hHaCeXp8iGdqe36KhIJslXx4o3t2bIB4gn1kc4OBc4Vwm5t2cneLqJlwYpeQio2RbYWIbIBleXuBcmp/fnN8hVtzgHOTb3ZqfYeLbHBpgIZ0h3d+mWmKe46emIFzk1epjZ1xhYE=","128.6|mYJ9V51JkG+3iHKCmoRlfICElYmJgH9jeoSSmGBYZ26US4ppjYRyioBazF5IqYGiZ5JhejLKmGJg2ImDnZmYZZB9xm6PhG9xpGRQgleVYV9kSHtWfmN9Z093W5mKSIGSaJV+b4L8lVqFnoaFUHWXiX17jIl5c5Fmj3eOXY9yrpGYhW9hi4mMhmB3ZoJwm2Rtf4NujW5hbIJyYnRvcGN0hYJjgIlcaX5lb3aTdmirim58aIqFgkmMbIqJfW+Sgm1ygXWRgX6EcnNmMoh4Wnlzh4B+c2t0gkWNmm9xk3p1Y2mOhISQioFghIl3e3CMdG2GYnGSdJx/dIhvZGuFimyDf3BXeHp2eYWWW6F/d1RukYBqm4p4ln+KgX5lij1dj3R0joRmepFzdXd4cnuEZ4aNjH5kfoF4hpOHeluBX3uWd3yAaYR2eHlqj3d2gH9zkoaNZZmBZJmBeIpyYX10n4aFl2WGf3xshXCAhHV4gYBmcZB2a36LgnpveVKidIpueYd4gI1of3dql4uFe4aXfXOGhYJ8hIR/cICEdYCDdlmPgWaHgniFioqHeG16a59kaod5gmaAhWWAh2+Cf5SMh3h9eoaEgqSQhH+DXIVwhZJ9Zad+aGyNeXSJfHF9gnmHhoF/cnaCdYWBeZB+f2R7kHZ6fGeAeHp3Z5xuaW57cXx/iWOlfG2ij5GTiIF9eJCCfaqHhYJxdIJziIJ5ZYeAaH6LeHd/Wn96dnthmXd2jHp9ZICCbpaFeqSDjYaGiX2UioCKpIeBf4ZsgW2HfXJkkIBqgYl4eIRsf3x2em99aoZze3hmkH5qj4piko2PhIY=","129.9|WI54l3erdYu5dYKQeJF/ho+fpHellpahia6Zrah5dbOkoaeUnpm5UJGn/ZyvXLSUnI54flgfd6GjzZJyh490fW/7hb92eI+3npywlr6oo595s5Sbf5iNi6xupKJvkHeAoaR9bqWOeJmIo45zfYF1u36CeXeRbpicc3p5pXiekaafeYKseJiajZOOpHiJrbCSo5ixlJWYd4yOo6ejfL6lWp6inqGkj6Skla2YdHSSe56gpI16ls55b6CPmqeBeYWMo4uYkp+Jkn13y5mpgJ19o6aloZuSqXa3oZ2YlaR+cZZ8nqypjnyChHqLo4uar5p5eJ+miqGXrI6VlHqIk59ppXt1r5iahIWWVWO2gWyonYlzXXuVhZ2OepeCerFVgLqZdnl+oaeKqa11eYR6l4SvkHqZfX1wgYeDl5x7cJSnhpiMqYeNm3yPk5qIpIKWdpSQnIR7XGubfW2umnt3bnqYg5qKfn+FfHGdfoeyfH90nI2GnYmghIZ9e8GcjoCFd4qobneWdYZjbaSkloiHgnl7fp2RkYh/oH58e452iJpgenVrsYSPp4uHiX5+mpekeYl6e55vjJCYl5d/p4VxiJOAf318j3mShX+HjHthfo+Co498dXuRiIKRgXaQeZyNeoV9mYN9b3d0hHpvfHaVkIORh6GEiH59k5ageYiBj5ZyeHuxhGdkmoh4h4h9fHR8kWOOhH+HgX9xfXmEkXx8dHOWg3eOqIOEenybkap+h4N8lm55f5aGdmWMh3eHf3x6dX6bZYiBfoOAfGV4d4+Ocn52douIiY2UgoJ9fY6BpIKOfoCQeXqLlWaKcJGGeoQ=","125.4|vYiqg4qFhZmLg7yzZKWju5OtnpVtmauGwZnCYHMAlq5ok7mRpOt/pNCCyIqqOtqiioqVebiUiYm9zoyHlZNmpmzEdomFkn6BsoNzjmpgdoiZbXl/e6GOk2+Vb5KPiIyamox9bIaOiYFyp4qEjYVnj36AhJR6hIGHk3nFj4mYppaKf7ugcbCinG11m4yEsL6CrJCma3lmkLmCoJ6airK0mZ2NvZCBm5rFiaKaco97hpCom4+Bh4NxmI6joqSvioerl4GSipt3ekaYnnNzi3WFpZqjjYuWtJJ+sKaJi5aAkXuDhHmxjH97hXJ7moiVkKeLkJ+NgqiMk3N2dZOcdIdKkImTcaSiWYSysGyZh5R/rYeLdYaBe5CJgZakdZOKu6qLi4yMloOEkYd0h3+EjpyXhH+IoHiTeY+el5qElJCZgoWIi3h6eISIcItjjouDfJeIh3ebqXWlk51/fHqHbIKDc5+CfrCOeYuYoXWRi4KOe4OBlIabent3g41giW6AhJdzi2l8oIWjcamXhIRygYZugXeXjoKAnIl4gZKKdWqPgpGJh4CimHl3eniEjWF7f4WAimiHgHGRgIiVj3yOhHyAiW2Cd36QgoCLmHmVb6hziZ6Dj4uBf4R9g4iDhJeSjoB/m4d9iW2Hd3uAgY+VgIKOhn19enuEeWOBd4GHeHSGd3KMi6BvhX2UhXZ7hnWBf4uJgoCAiHyGd4Z7jI6CjHyDf4GGfHx9gIJ9aHRtgYaHdoV6a4mGiWyFgIiFaXyPd4GDg4mAfoOPe4tzhGyNhYKNhoB/f4Rzfn17hXd9Z1+RhYN4in1wi5p6eYSAiIE=","129.5|unOinZuvdJ9dpYOBWYGNeYBzaJeCj31pfkq0SVg3lKWN1npInTeTRXmvS8L80nlGp4eoZZ2JmKZYVXupfDBeoHxrcLSJkmmpRWemYaNIXKaXfHafgUuIiJ9ml6hstm1UwqOCU2Zxl6KKXnWdMXRinn2CipVygmRujXRomJKmn5R0kXCObJ6EQ56pio1rfWFznF5+X21+kH5xw3ZkjoSLVZS6gYWZXnCLpa5hZ4WHj6+QjYiVgGFsnnSAiayKimlyYnmNaYpycF2Su4OFfXWRhXR9kaSHlph/i2udhIR9h4yOknlugIprh26VhIOMaYaNYHpVeIxknGxomI5riJ68Q5KYuWuKfJtloZyggamVooGHcoiOoWaDkGGTbdN3hXqvnYxhkVl7iX1qgY6IiY+EeIFrXHmcjY+MjJSFXZ5+eolumnJzfoOAkYh5aY6VnHh4oqNplp2od6CkhHaBZ4eBjId9gFJod5CWbI+WioJlfGZ7hnKQd3d8g4uflZRji5GmaI2ck2yQoJpqkIF5f4NyhnSJcX5/Q4N2kqRoial+g2Vyd3qRZIJzdn2BkaGEe3WHfZ9zeo2bjo+ZkWiNhoGAfXuEfHpvfICJVXeJkIOLhJKDanZreIWNf4GPhIyHbn+ASnJ5kJZziKWWgmanaXqIc4V6dnuEfJ2IiGONi5dyg3u9hYymkW2Lgn97fm2EjXNxfX1kcXuPgnOFl3+AZ4JwfoNujnt4hYN7l3udfoh9kGx/dZKWhKKHaoiFjnyDc4F8hnl/gHVle5OHdpqLi4Nogmp2jHN+fXh+hH5+hp9nh3+LaYB+ll2Ik45sh4M=","129.1|hHJqgad1cnG+YXiCaHpvYnmEfG+Tg4pnjp+olqaQkWmZb4aIiLudrIZ0/ZBXbnWMd5Zya69loHijxIJZdXluc3mSmW2RcHyPpmqhjXeYpHWVUoZ6fnJ2iriQfa16epORjqd7ZZDBnHF6gHljqXhvmX9+jXKAkZNsjXKEhpWMloWbb45+cn+DgGE7gXJ3qYJya5GUk5FzjJd5dG51Zlp8m42AnIGHX3SDg52Mb4ySkYZxb4NnmlNzdYyDjn2YeHOianSOjYaCjI6VTY6VfI5tm7iLiZKEfWyIhXB/k4JwjY+UgYSThXaHgnR9hJSciXh3bIKgdoCNcomTbo9+h4Fhf2prfZBviXaGY46Di3OogXuLho2JYXSBbYBhdmFol2mKkXdxjZFzb3tziHyIfHKejXuCc3p3mYqXdX57ZobFemaFmIeLgIFykZKKZXqFoZuOdYR8aYp0imedbnqEp4aJjZKKf4Zud3OAc3RtjHtujJB4b4ONgYNthG2fcnh8dZqDlI18bpVtiZVuiIeCeoOXiJxpeIR/e355eH16d2OZfGyPpXl8fW+Gh2eCiZ+DdoR3dJ2Gh26Vf3l7g4Ryim55foSFkHWEg31/ZXlsdZB4ip99a5CNeXyOe4SAhY5wlIN/fXZ4dXJ4d3ibe2yMjHl0g3mAiHqDapuNb3t6fYSGi3iThG+Oin9wiXN+gpeFgG6GhH98d3t0f3p3boZ8bYR/e32IgIGDaYFvlJZ4hHqEnIuBc41yeY2CfneKeXt3j4GTXYKAf4Ruem5zfG1tk3luh4l5bYePfIVyg3B+f3pzeoKTi4SAiHR9h4aAdYc=","128.7|hJKBfmqDiJSVbJiNnoOBi3l9ap6cX2eSkUppaKnOkmSfeI9/uMJpboeFf59vO4hyioSGa6FAa454fJVvg3SdkH6fMpJ+m4Bik4uKYpJop4aZXouDgpuYo3l+mXiSfHp0kJd9WGb9bXt6b45xY4Oab4CBgaCFiHmKlnC4iHCDdImHdZCdjouCnmxyj459b3iGZV+XcpR4kHl/d5+Bit+bdIKEfYaOc16DloilaYXKc5OhfZ13c3COc2aMcHhtkHp9j4FvaJ13j6qWkJCddZSQflSclZmEelqHfJKLhoaBiZ5yhnmQl3iEcIyHfmt2iImRbHJ/fXphkHmXco+TjoOAoZGMkYt1vYOYYWBsaXaGu4+DeXKSiX6TephQjIx3lIp/f5FwgHiAeq1pgo92hpJrg36droN3gHqTh5WFanqBfmRtgHiKeYWOkoiQmo2OaJx8jn6UaGZfhGiJfnWCnHmKcpKCfKSJg3OJoHaph4NwdXqAbnKPe4RthYiehoJ/hpKReHiGgo1qa4h+lYSegIORe5iYaYB/ineFfI+MeHuKhmt2cH5lfYJ5iWuBhJ17hYOHhm98c5GChX59fHhvhZWBfpV4nIV9gHyKjodwgJJ2k4GCbm58hH6XgoGMeYSbgYF9hIODdXKOeoOZgm6KeX9bdHt7iXmCcpt1fYCMhYh+gZF1gWtjeHh8gpV8f5J4jH96f35/g4J1kYh8loiCcH2Gf3JsmnuDcoGAlIZ9gomKf3Z8k4uHeWF0eXeDl3qHinyNen5+f3yLgW11h4GPiIRygXmFZHOMf4R1g3V/cpiOhot8d4CRiZhxanV1dYI=","126.1|mJmnYqJmdqCRX52Xl5CWkoaZkZaWoJGQlqZ+nVz9i56ZZXx/f4WYVJFlwLlSl5psa3Q1mb5umnBg2JJZkFyWlnSPlXyGlIPDoY1xlJycYWiOo4pofm+KhnhmeLN/dGxwX36AmWx+mGiWlZdhYnWVvX17hZeHiX+KXYaCWpBXxp6HcItejqGLnJttmYt8yHmGh5jek210iaB+T46NhHiRYahal5pnT3OOfn+djZSPj22UkoBqeYOLXHqgkrGPin2dhoCdj4aFc7SNkY5fV5aBqY+OkWymrIKRu5JocK+IlHmQV5eWinRYgo1yjJSxfpmOeKh9f5+TgIlubIl/hmFjnIFCo417tJiBnIKufZN5goaPhYtnnJaIb4J7i0U0iJ+Jco55qIZ/kXqJioWHaHetgn1waoRjd6B6g3mFc4+Gf6GLlIR3YYJzjFp6hXt/eIabcZ2Bl4Vxe3aIlIaIkIZamYCCfYNphGqGhYqTiYJ3tIGBg4iqg3l9gJKXcIR5fqSdcHh0jpeRf5eKb3qHhIeFhluRr4F8e3WHfX2Hh2F/gnOUin+Tlb6Hd36BjZRcc4x+e4t1e5OdmGKEhnFue36AiZeCb46IgX+OYoNjepSIdLiCc32Ag32OgYd1hFSOlYF/dHOCan6Dhnd1gnOGgIGJh5mBeHyCj5VXYXZ8eZd4eZ+Td42Li3V0e3yBhIuEcpeJgX53doJxcYaEb4mBdYCHf36LiIJ9e4KKkE5vj32GfnF4lo6LhYqBd3R6iIGmh4FcnIV/fYprgmKBgoJrhIFyh3qEkYqQfXp+gYt9ZX18f399d3uSiXt5goh1dXw=","123|joCHZWtfZoZplIqYkX2LgoCJUp1yaJmYcKiol5yCcJV9cot0XHqAjIVvc69phIiDe4+glLqMbnOOkXaXa4KQl257gZuFmGpSoJVolZuXl3BxWWtrfnZ2gmt/eWGAa4OGj3WAp3n8bGiWoX+QbYePW4B8hJxtgHqTX4+RbHBzkohvjI6GhoSIe512iI1sm4aMmpegjoxsc2drc3mKfGV/h319hIVul5h+j46JlZC6dISPZ3GMd6WGhnuFfYuVjG5oZIdyj3uGiIZxW3eOfl94jZRkk4mAglGSkoqFkYiCk390goh8c4RwjYaOhHWEdISRa3eBhXuVdIeOa3VwfHZ6fniTm5FvqHV+WWuIfWmDeHmNh3aCo5F0iXmPhmNufotzkpBpfXOFe26Sh4h4aXh8eH97e4KGZ3+LmI2Ga3h6hIiJhYWFeXtnhHRih3J1a2N1gHF+YG+eh4VyeYeJknqFh3Z6f5WMgH2CZ4qIhYNydXuDhYiFg4RxfHiMgol5fH6Vf3x8fG1neX6Vi4ZlgIWEeoaAlnt/l4CBlHdwiGV9hG91i4GRnqKGiGx9do1ofnd8gHeEgYhxjXJzjnyGh3l/hY95j5mNen13oIGHcYaEaI6Db4J8hXxufoeHenONhXx+j4aAe3l1h4mJg3F5fYSJiJOAh3l7b4xrj3h0eYiCf353d2twiHyShXiCgo19hZSKeX53hoB8anaFdH+EcX6BgXOOhoCAd3txjHuJeneDcIB8gWyAeXGHe4KGaoR9h3yGnId8gHeQgX99eYp4goJxgYKGhYmMf4F1e3J6bnd8eX90fXuBbY1rc4l7gYM=","139.1|UICmr2uxxLS5hbOtaaKysZaanmmke5Rvjq+hr2uxlJiiormultu1tqKyoYGRAV+opXF4dDcub63Yfo1+poVqmXvFTpdqboNjaXK0mYupbqqVRpKmfcF+Urygt4JmmJmWvXmBba/obaaBboaEkH9sbX9/amyHdoZwtHNmyHTCtLukgouOcWeK01aEoXKBra5yk56Qk3GQk7aMqbSsaVKoraW+gJLIvJuksJytdWW6c8GSUoqDrqRwjWailnGJcXt2uXdllNuLdJ6RhZjkx5RwmM+uyMCMjWmlhqa5bJZ3ZcZ1tciMi4GUoXOei555nJNyZ5xxdq2XjI10jY94mLuGq26DpJ+uopOwQFl7uGuZV4FnbHe0X6KJgNl9dcWJnmyla3FtlIB6qnRzcpx4p4NxkoGsqHmGjXRuvJJ8Yo2hep+NgoV5lYFkmcCJrnyCr5ibopqLUGNvm2mHiXpxoHu1qIGPfpGPd4d9o2utjnhob4N5kImXhXluhXGlnZKKdoiYl3uvjGdUYXyStnV8fG+jesiIkYaAtX53dliRcJyEeG12sHhzp3CGfGiCYqC1lY14nL+MmH+AjqFydJVsd3F9iZV4wXt7hoCMi3uEgYNohmx7cZiDeYJrfXCje7WVc4Z/qYp6inKMcsiUfWqUf3h5iXOEe3mGZaCznYh6h5GKhXtqnlhaY5R1dnd8epN8nnGBhYCNgXeHfot1g3x6cIGPeoKLgYJ8a4N1m8GPiXtrpY6If42YcF50jXF0bHx6i3qqhHqEf4h/fIltiXmOaX1whYZ8hot9gXt2gW6EqICkfHOcmZCLjVyFaWeLc3s=","140.5|mGjnMURmvKXLlcK8na3n0aiip4ONnrFknL+Ef0v8lJyXQIy8nXSXmrJwSYtVeISNb4B1VZkPTnZlVX+YgHSbgJ8Xh4SNh2mvbGZgnqt8U2+Vu3VYgFd4gGmBfZZqH4WJM5iCRn2zU2ajDnaSUW6Zm4B9jYRujWhtgWiVVV4/zpqiiYRNkLSikGCMtoJm9H1ysKWcdmeik8hsSYyrY3yYkpxFo5RgWIapaH+WXIScZmCeZI6LhouMaZS8sZi5hWaFkXacmaJ8bqqYeomDgp9tsKCMhUWVmjeXo5tqgbhlhWdjd6qFi4RxgIyXr6SceLd/UrJeeauejXtjeY6Hi1mnj2ZegpGjkKGVR46qiCptV3mFWGlrhm6Hi5eSjBFVe3x+m4FasWN5koNngnFxmlKRkX9wX4dzpJ6NmIKBUIRRe7KSgH5vloNlnXGBfICLhoqZfLxOU4OLlUuMlnKCW3CIh2GMgHBiiHGkg3OblH9Zw3B6kY96fXechXW0daNoc4aFiotoi2BafXSTd4KMeIJbcoaYkIl+e2WJiX+CeY6pf1ibY3ycnKp5daKGqa5tg4F4gICGmpycnWp3hYpmgIB4jX10gKFRhX5xWIhmmJx+YaCCXY1udoCLeYJ5cn2FRoZ9c3CBdJKHdpN3flh5b3mWjqJ9cY+ClaljdWd/h36Fg52WZ16NfIp/g319gGp2gI5oiH5nb390l4V4dox/XYN4foqYiHx5hIKMoXyGi32PcYaEoZRpd4mGgHGCkXi1b3uIg2eGfYJhgmeIhnZfi39fi2x2nI6ZfXuMgo59a6FsfX9uk36ak11gf4B+bYM=","130.8|fYl1hWl9i3J0mm5rZ21xe5OHkp+XkItogUiaf2dtjIKNgp96qoF5OIuLg3WG/YuwhWejdDX4a4R6fYmhp8JoX5N5wm2Ul5ZQrmxJX4V8aImJTZiEf4OStEVbgHGUgGKQe4SAYZFybIyLMIeXhIhrXIN8kp6SiYhuiHS9d3B9mFh9iZOecKGZfpuBkY+ShnJwbl5peHKMinGYf5qikc50TYR8p4p4a3VpfnucbGh+cnmNgpOOmHxzW7CLgIawjY6PfXiiZ4aCc3aHUo10maCQl36OhHaFgnmmmHmAaoCCZGpxio2ijYeeiXJ/jXalmZaSiYeldohhlH1wloOTh36Qg5GUk49acZOqc5ynhH1cjIZpoHWB1oeNjJWYdRqRmKltqpGNknt3kaFxcIR4e5RzfoKVx3dbk6Cvh3eEiG6Ee5VtgH94ioR6hHSifI6ei451haGDdpNYjpZxmXZ0YXh8pJJ+friUeGeGjo6KloWFf4V7gXKHf3qVhG57hoeQh5OJYZiCeoR1lX2KeHZ1gXVpeHaOhn2Ac5F4gaRyiVu+hIaCh3qdY594eJuBfXeEfY+Hg2JuhISKiYGdfpWadoqDfIJ7d7pZgX6ekXduloaQg6KCiXCDeJ+SgnaBfHWXX3+Be456aJRuiHaBgoh2hXl9cpCCd4l/cXl9iJOMkYhzgm19cHmabJWLd4Z9d297frRmfn6Oj3ppl3WDdoqBhXl+e59ren58mX9tent8jYiSYnF5eJ+Nfph2lo95bn17dHt4q2V+fpKTemCSdIFul4OFgIp4l3CCgniOgG2FgXqDhZFpa3V3ma58im+Sj3g=","147.7|f5FxpVF4dGrcloKETXdypW1+dLp6X3i+eD+YuFCPh4xqe70pzZdx5Ih5Zq1JhC8+dapCXuiQVXOBaqGUrVtTpYRhLbOWtpiNCrW8YLS6U3CCMI+KfqCXQ7K5erxwpqprjaN6OF1qVnl13ZGNQolXnH6EkLySfl+wyGShjmGSm4Snjm7JYpWDX2gyj6Cf2GSenlVtpGhmg/2beYJ6fYXrwJ+PzH+JaV+8d69NWZ5rZ4idrKmNeY1lZXmEg5qvmaSje5aJZKCOaI5/1HeWeniJhLG1i5qmoqNodoiDopd5opRlhJTFo4RwlGRyjIl+jrWjp3knkaZhfZVkaYGoeoY4dIZ0iaJygq6YeIald5Wrd5GcnG2AX7iehHnjar5D7GGGpZ6jlkyPjrtckoxranOhlH+MfnNpt3LMmN6Lp4+EjYtsiYlvbYSIa49nsJFmW8BuaK2IfIR2l3Otp2+SuHSHP6yOfn2ic2GasnbGrYmdhWKLnG+th3Zsf2NRekWJfJWTpmSNql55bZZph458gI6sdJaHroiCipJzareIdGe2h52QcoqoiVmOcmOAs1OFboh+dY6TcVuKk3eliV11kZZ+gop3jleqioFdo3RUaa11xIGKp6dlk5aAfo2FdIWHpYR/jJR1Z3aUdVvBh5+wZIyEcp+CcnKAWFSKV4yIaoOQc3Vnm3l7YWRnkZh5iKR5f0KPh3+NkHdnkIh6l5KHm5F9h6NpuYN8aYJqXqBZiIlqmJZxb5doeHt1ZHiMlXnClnp/M5SEgIGceFZ7jWihkIWgnGaSlHGcf3hzgWiDnG6WhWeOnn1ykHCYiGJoeY0=","137|a6WnrVCld5q/c4B9Vlipe6KYnF1yq8xS2GOmr07KopJ7ua6ngufALseX1KaUOYjUlZyipcENV5hpoaBqOcJbbJF/YnVwZGHHkVifcoivUo+na2SmfoiMkKlUrcmF2WqiuKl6qLZBWJd3b6FxroVduoOAbltlnX9akYlVpWKXlpSceG7taY6tTVtpgmpiiG1lgW/9mmeAmodkm22Kb31aRquzuoS2K2uasJFZlpV4a69uZIZylXJtfspZWJWKbGW/dG96dXKLbKqiEXajxW1xkrt0tb6GnLmMknmglJ+IlbtndIB3knmemG11qnGUjaFnXZ6DcYdwjJFmepqSgaKCa22MlJZUumCHlXCQksmhhIuPY2yVPZOPcn2abWCnsZpad2dgpntzeHGSiZt0ioR+j359b3eBrlhYaXF6YKpudGp5w4pvaoa4h5FvaW+WhoaOm2GGj2qRoW+qgYeJnXN8ebaLfmmddHqAVmtck3hmqYR1g3iGh3dsiJCVlXCFcp2YX4urXquKbap7nYZqhYSkc41shoV/fZN5doJvcIBqeWiOnXeBXnyLdWSGV5iclWh2loNslZGVa4tjjY5oiXx/hpd4mXZ7hH5jkXV8l2VukIl5aJt9cnZ6gIiVdIFmjYGAfpN5eoV0cmp5eGavgXWGfJaCdnKGdZaVnYNzko5rhqO3cYhijItjh3SBhZF3jV56gn6Dhnh2k3B2dnB5aJF0eHN1k4J4bYZ+k46JY3iBlm2GloV3f1+CjXCJYoRhjHp0WIGBfWOQe3h2eXVubnpqfHtvdHCRg3p0h3l8oHx+doCQYYOWgoqdcIaHb4Y=","135.2|n5KEplGbdcKHVmpoXWebcYxuTpKEkHSrmDCRlVPEm4uQqoaEnLaeNY6td72SPUVJtYrChKPQWrNXZI1QGVZjioqLBJFrj2a3YqW5VamYWqSgZ3GhgGqOnLBeqsCAn2lRrqp7dmLhWKh4U45bg4BiqH6BbJFwoGmkqoNelWaVXL2Ja038b3iOXF2sd4poS3CZhU7zkm53kldqlHJ0kd2ASZeQkJSbXnh6raJdeYvAa6GShYlleHtyoYBMa6g2iWa+aZJWX5aEb6Gbc4OVu3SGhGaEuaedk2mFhWuXho6Fh5tmlHRyiXOdkHCRkINvgHGKXIRdj3pUqYdqgpOQg5u/Z420rp51xoGKi2RweKaZwoyGqG6cXHGLZ3ZicOa7rpGOaYpjm2GLb5Z6galxiqF4gXuOdnidmE9qd4SBXMhtikhn5IRwZoXAkYl4XoysgZN5i22QiGO8gaC6jnqEnHWEbM6BgVaYfJKaYXSOaoBknGuJj2ybgHp3h7GjloJ3hKuxYYmRhJuFdcFcmYR+gIKLdH+TW4OAbZF4kaF1d8tCgWKFgIZJToaHdnOBYZ5xm12DrIpveZOoh3pwn3GFgo+CeaZ3lnhtg39zknmPlWV0o3Z/ZYRri2SJgIGSdoGIhIB7Z496kYx/dZGAhGTUaYiMbIuAdXyCdKBwp2+IoaRvf5LThIFepGuThol7f493jFpvgn11hXqPnnV7oG6BaIpwhlRmsIB7g4R8mGCbVoSFmW2EkIeNgF6PbYOEgH5ljnhvVXd/fluPe5h6fouPaoJqeXCLXWqaf3h6hnd5Zp5vhYeUXomDiYR0apdvg4M=","130|Y5R3i6xvfnpRY2R3aHh7mGp9c6WcbIptclaLvKhqbaCigZtleViGe4J+2nRyfq2mgpJfmEP9pHxyu4Bhd59rbIfpf5VvnYCPm266alu8o39th4uCfIqLXLiEeY6FjoiXi5J7o7OMoIRp0ItnW3prmX2Dc6GFY5BviY1tfZeCnIZocHBjcKGGk6KaiZF8maFywmaGoY9zb4mDhp2Vk3Z0eYyEgY55f4iIhqJylGuOkYCJiH11g4Byn5GNmKKaknd8jneDbpGNjntvuJZ8eImPf4KYiYuKnXh6eJaFmJKTanqVjKGRgHdveHOAkHeNqJSWZJSJeKtrmZaUhnOglIh7i5Bqj2mKinKGcW+knomvpoRqp42Kbrl+dJiict9xV62zZ5Nuipx4rmmMdY2LZ56XdHyAZXmchYZ7lGGHXoeoeqZzfY+JdnurnHSFnH9whXWAeXSYdnLCgpqKiIVykop9nnl1fXdud5F9ZI56eoRmeIx3p3WQhIZ7eq+uinWAh4eNf4OKi7J2Z4mdgomChnWOiHt6uXh/bG14fpZ3iJtyg2d4j3qdelyLiXR9fah6coaFbpp8jXh4gXuJmZOUi3KEfI6HgmqkeXqbYHqQkIWHsGiEa4OOe394hHaKhG+Apnh+eHB5jpJ0iI9SgmiBj3mndYCCiXx7haV6bIR/coh8fXKZoHVth5WKiHiDeouFhHWOeX+Gd3qMhHeGi26CZ3eSeoZ0dYSEiH19nXt4h4F2mnWAdISbfW6Mk4mGfIKEhoR4d5J5fYNte4qPeIeQbIRqbYV8mXhmgIZ9fIGDgXWIgXSVd3xxhXuEdIiRioU=","131.7|lIl7eGx4gWVTX52bmJt5bYG4dJ2TQYWsq62AqHGobYCZZY2MYXhyvYpxpaBYMImseoRJm8wzbnuKtXtZXaWXy4zTdbdsmHlCfqdwlYipb3ltSYB5gZV9XGChhWR1aI+neYKCrJjqcHOH1IRgw4GWYH6BcJt8WZOhWY6ohHGGcWdocZhmjH9/qU+RfopzgKOZbZsZnHV7dIB0bFyAhl+KqGJ8enaJ/ZSLfIeempavdHqBhHRpbnOLmG57dHB7jnRGoo5OlIaJd5dugIyUYoCEfZdsY4Z6b1SNbod9jZ6LmZB1gGVvdHScdYt2cI5dkX6QamaRjFyXiI95aHJYioRqgYZ9goaOloPGQ3FbmFp3ln2Tb3WLcohybHh+jLyWXH1YXZJvYpWLc56TjHl2hZJ2dH16boOTdHFpcHCCZFORh2eOX4l6iHtWk5OIdXhscmFhfX13TGx5dFl1moeMo3iJfXB4fH2Fg4mBcWuUc4JqVI+Igop0g3ttemOjdn6Dgml4l1R5nz1TZ1udhoSWgomWeqCDinZ9jXqFdWiDcXRwhWtuj4Z7iXOLfmh7Z5+BemuAa36PcHljd4B9cHpqhYqBjY16lYykfH6IiIKNgm9vjGOFa2+NjH2EgYuCeY+Wd3t+joOBiXZ8cHZagmlvkYl8inGCenp8aJyHZYh4bXKLbH9ujFtpboF4hZWBhJh5gJKUeYCIg36JkYF1j3mEbXKDg4qQe4F9aXtumJdkZ3hwbZBxfm6Ic2Z4gHKGjoGRjXqWmpd6fmyDg4WCf2+VaoFrbpGMeohufX1yenB8dXaGfnNxjXaHcXdzc26DcYY=","137.3|t5tckVdwYG+/nF1mVXhPT3iMr599d1yxaKxVSFcvjH9xc2VbdmRweGtxtHF1YdWyh5BVopn4WopIr5ifjIFca2hGfnCDl5i4jKw/lohHWoCJl4qBgZyGkTxraK14eX2dnpyApHr9Xn2YcJyYdnJgr397gJ+RjJakSImHmWeqcXmZi0NKbIVseKhbeoubr3CdfpmNXG5Mi2SUjG18bYlie22FgZCEV5F/iG+nkYqrbJNxY4GQXGhte292b3eJjZqIjI+AlIlzb2GKTX17Q5Z0oXNzU5OThWZmj2iTkJ6Gh4xoloufj4hPa25rcIWHdWaRppWqjZ+XWmtraYZudJlypGxZbJNqfm+Gk2WmiIhso4iHr3CRiHKJkIdubysxbpZBeJGgnaONfJSQhJR1iW6fjX5YXHR/WqyLV22Bp2+FiIuQmnJ1a4NQapyLpXGMR2tzbo5hjXKRbKSCd4SHq3aMdHCJgXxidXl5aI2ZboOfpI+Jf4p4eHZlgWNVjoaHcoB0fXB6hGeHY4OMjYqeg4WOcoWEdIR/k2l2hF+Di2Jsgp+Kc4SPn6ludWGDeVp0Xo92blV9dGGSioJOioGYjIF/hox4jZSAgn1yVXN3iZCDX5eEnIGOkWqhfoWLeJx0c4R/lG93fXl7iVlpgp9ykImHiZF8dHKCi1l+WohyiHZ9c1+tYYdiloaciIR+g5Z6hJl/hYGHcHl8cn+HYH6CmoKahW6Jdn15dYJ/YWp1kHOGWXV1YZxkgGWKhYyJm4WMjnmHjX+BgYZke3eJeZJdfISaiIaMio6Nfnpxf4l/XYSVd4VhlnBkk3VqdJCJjIg=","133.3|h6pvcp1UdoWWrIOEpJuCaY6kiG14V5KVg7SXRl95ZG2De5VuhcOuwFhw/XZOlWvBaJZnoZ+5llyit52wPr2hanqWnnN0bmZ+lpSgmYxEYWNeVWtuenmXnq6ZdJ9yjZ6ykqx7nq2LkGx1g6OnqIiejX98eGtpio+RYomDbIxoelqKk3vBkml2oaYuenNklZSLUZtyX21caX5mbWN2gqByqnCLjod0f29rjoiBlouXiYRoc4mbiMmRgopzi3hCdGd+bIZ7k4hucINkZ3uMb46Hm9KQapeMcnSeaH+HlW2Qh4GMeo6TkY2ShJB/fIaOtWdzXnmshGedgWpscWmFfoZnj4KAbMB1tmp1UGh6x4GplI2Lk4l9a3uLlolbjoRniopiVnNhgJOEhKyNiJOETXOHhYGHcYeFhWSPZ395Ym6zhVaMrnF1Z3p8iWtteXyKZZVtem52W2plgJCnaYOEoIF1bZaDgXybiHp3X4x9cXtoko+DZ42JeXhmd3WcjGuEfpR9ln2PeYFicX6Th4aNhIKYhYVoZoOAnJGGe3p3iV9temV7roJLlWJyd2J8b5x3bnV/cI2LfISUfHJ0kIiJiYGBfpqDi2SGgH9ipoZ7jW+GtnR8aJOMhXmVg4SMgFtshX6AjJCEe4N0jVl/fGeBkIFfh4B6enN5bZdycoR8fnyKgHqFemdqhIuCiH2BgpOChVSJgH6Dj4J7hnSGe319aX+JgW6Plnp8e3p2l4d5a3yLmJB4holidWh2i4aFg4BvjoF2Q4OBfnCWg36NeIlye3toeouEYYuYfXtzd3Z3k3p7f4aQjnl7inmIcXqKhoo=","138.3|bI+GibaKnZFJcHl6Wmt/f4SHhKmhdXVabk1/t7NZanOmjFyCTa+jxYp+rVWKIXl+c1+XqzUUsXWwbHdtuHVcSIm0RFmEoHx6elleY4+6qnZrVo19gMF/X0mpd5CYipF7ioR7u1lQqnFqKIl0c3JdcX2BhaaFbphf/Jd+jZ6Kop1feFiKaG6edFRthpN7jblodGF9pZd0blWBiYRpibxesIiLmYCJXYNrdqaUoWNnl4KbPmZ3iyNuhX2Kg3SCkXeDgnBvadGNkmtrWJSKqI6AjUmeq4aKfKB+f2eCYHqNY4KagIS4cHtwc2yDmWx6iGaUYY6WcKNkl5WacG+jln2eooGFcn9+YovOdm6DaXdLpH5pWpJ2fFpwe783bX/Cfm2amZVpg6FyiXecbW6NdY+AdH+Fg3N+iXmXk4eFWo/Vd3BxoouNgHyPm4OXsnCUl8GPg4iUeXJ8h2eOXIhxhI2GaIh1f6Zrdoqmi2q1f4Ngcpd1hXaVhYh1eoGrfHqMf3uAm5h/a495dodagXF9f3KKi5apNHSBimN1iah+bpqJhWZqX3Rxd3aNinR8gqaUiZJ/m06Nj3GPioRmhJFybHCCc3OGfJBVfH2VSnSNd45tj3qEbYSSc3Z5gnF7iISxaXx6kXF5iXl5b7KjhGGfkHN5cHyCiXl5bKOTnYx2lIKPkHnJlntrmYpqb3ODd3yIf4RleX2PbniJmHt3hH6FaH+ZeXBqnoCGcnl0nKeQknNocJGJdax4emiGhnRxboZqfYGLaGt5fIhgeYh4fXuOj4VogIx0XXWNfoR6e2+BoYCjfHBsoYd9qouVepCAdHM=","138.2|sk/gb3dE4qY0e9nWl7u4/LywplRiiMV4rq2mXn5GnJFeWMR7f46qrr5G+o85qpBURnVGTo5WfkGS61aAkXmWf5TFnFqJW33LtniYlUNhgUaip3dZeGhsUqSSSbaMcotoc5F9TGtafEByaFOAWYGVu398iFR5YX+BuW6HZX9o56die7d9kbWpzmaQh2mDuZt+gqCtaIJkl96EW7GEi5uToqp2p6tkR02KV5WEXn5egmqXnHR/nqqNZYytra+eaoeHb4CNlZp4gmmgimt3Tn6KipuumHabjZCUjbZmcIJugHN6Zp2vZ39nkIqJlWOhj6Zkj45nfpaYanF8VZm+cmJOlopXhpucWp6eb5Wddm+JcW1+dn9ifJpsf5OKiVJghI2xgmSLd4iBqIZsfWiAUpSfc356h4dppnRtpoZ0maOmgKOObnh/eIeza3dkgo9pmp+oYZ27b4lGgl+CbnR+nn54vIZ0f55+h3CLkXlso3eRf4N+d4eFd39uhodbY3NoipyCkYpvlcR1e3CWdHd/eX2YfIh/rXZ9eoqCfISEeVmrdZJqkH+BpHN1fmaEolx5ZZKHeKCLglaWkHKLeHx0d459gnN+e4Z5enuUg4VngIuCfpB2kXWAe4GAeXxxf3F/iHp/eIKEc5GIfIiieZCYfYFxiWB+e3SJil2KVWyJa4mKjWGRp3ePc3ZmeIJ9fJN/dpN7eX5rgoB0iYV8bXt7j3uBf3+QYHp+ZIOQZJ1gkoduloSKY5uYeZN0cnt8hnydioGCjXp8gImAg2t7iGNwkXmTgXZ6hIxkfX9ziYuAm21zhm6NjYhxnI+OhnFyeHw="]},{type:"relu"},{type:"dense",filters:50,_biases:"119.2|CBIJGQ4TBgwTFw8KDAcBBwoMAQ0cDAgWAgsJFxEbEAodFw4QFggJBwcSDQkWBwIKDgk=",_filters:["111.8|OzYzOxg9JEg7ODlCPUYyOCs1Jw0tNUI6Jy8fPzc8NyY1ITIxEDc+OjwbJDIdLjM5ID40MDI2PTgwMyg3JS8pOi81ADkzNjI/NyYxNzY5Pyk9OTQ8NUQ7Kj06MCAtMzcdFzg6OSxIMzUdQCkpNTA9OzQ7ITo4Mjkv","19.2|S1IxY1RIV1FAMVFcVlEtXD4BM0FgXU5CTFtTTlFJTEMtUElAX0dbS0ljVkFlS1VBP0FaRU9aYTFIXExKX1czP2BBQk9CQlRIMUNkSTpBXWlZO01RYVJiZEI6RVpRSzZgNDYyRlQzOz5TS05PPDtdTUVIZVpOVl4w","112.2|Oj0fQi04KB45MkUAPzw5HzkzLi5BOzQsJDw3QUAlOjAxMTUnSS9HQgpHSjEwMCYzHiwpLSsSQSw2TjgwMB82PSktQSkmKjgnMB8qLyozCkkoNy0fTho8TzMtGkIxNy4/NTIsDDAyNSZBNTU8GDMyOzkoLUE4ORYt","19.6|TyEmUTdPUVdQIFJEJDNDSj5KVFNRKj0tNlRRME9JT1Q2QTlKT0BMH1VZPklTQRo4QVBDOE1BWlFGS1E3SVEAUSVXTkxCQTtNJUVgIU9JU19ATydAVRNSKjlFR0dERkNVSUctP1YvJEVeVUdSUThCWEVSX05PTTk/","111|OmMwUjJJQ29EGkxkR0Q7W1I6SU5HR0sxRkxYQ0BNQl4qTy1KSUBFVD9kZCteNk0uTjpKT0JGU0wsW081XD5BPT9VRDwAMUhAPi9oOmQPZlNlQFFGYVZcTycgWlpOMypJQCskSVNJHjZqTEI6TTZPUC4xbko9TmAt","17|W19cTVxaXDpXRFddXk1CUVpYW1dQXl5NYVtRXVpbVyhOS1JOV1tbTlViXVVeS2FVW09ZTltXVVNXUl09QVxYW1thSklWSFtRTlpSTk9aWURhVFNbXmhBUFdVWFxOWE5cV1kBW19kRVplZENbYlU4UFRYRl5WT1dR","112.8|KhM5JDglOCw7MCEtL0EvOSwrIycyETgsMwYAGhYVGzcvOCI1KTYmKTU4NjAnOTczOBURITQuDDYgSCQtHC4yMzMaPjgwMSwnKzZENTkrACkhJS4jMioWPSQvFzQ8LjIYOjAtNCU8MTEXMDU9NC1AISgoLzchHigy","113.5|KAgmODUsCTExLUMxKUAkLxktSCM8PDgpFTg3ODowNTctQxInMxQ/PCw/FTVELzAgHCQ3LRsyOBkkUjwtUzw2ODY4FTMmMSsoLSBHJQ8ePkYSHS0nQUA1HSArRAA5DycxORssMTpJLzFCJzEnHCMPPDIyRDQyNSYw","112.9|Nz8jVks1QGkMC15AAi0uTykiXR41PiIWQk1MTj1DM0sKTjMsOipAFFNgThhKLEonNDZJRz1CWD0xPj4wXBkpIFg2UTEhLUYBNCVlIhwWQ19NKTg5VFBeTycUOEVYHyA7WxcpGU9iIStpUTgcVTNcVjQ0Pjs8QjUL","19.5|S1EvN0lOYD9CO1ouPk89TTszWk0bSzxDVE9JSE1DRyQ/UkhLVTRGVxtMU0pJSk06OUBRVj8/RkRMSEg/Y0hDTFBLVUcBPEc1Rj9VOTc8SFMeSTIfUUFQT0k5TDdDSEhGUUszRVAwQEpCOUJJR0lXSzpDPEVLTUZB","114.5|Kz4sNRIhRywrKkErJzwuMSgoJwYzBiQuQhEQMycaIS8sHg4qETUZSzQ9PxYhJDIsERQpMjQEPTUVRxAsRDMwGTgzLC4nKhwxGxM7IikSM0Y0JwkyNkAWJCcqJzg9MiAAJiYqPA4rLQsiKhEeOhY0Ki4bAjMkIDoe","112.4|NUMuQiA5K0onMyo8Fi42MT0xSC4rQTs5RCwOIRw5Nyw1MysoLDsoNzVCODUNMyI2FScqPSk0DjguLTQ4Kjo3Li87FC81NzI5IScnK0EpGT43Ky08QzonLjc2SiE9IjIaMDk4NxFFOBgHOBI8OB5CRTo1ADQcNykr","112.5|LWJVVFUyOGhIDmI4REwzV0sDN0VlQDwyRFJTRUMxPFwjPjhFUjlQbmBTTyFYI0YiKShNOhtTWUocPkY5aDU+KDhET0cAJEwvLgpLFmIDYWJkOEw8WlNgSRcBVFNZPhY7XyAgVFVEAQ9VXz09TD5KRhoiUVE0T0Mo","112.4|ISU1HTc5QisuL0w9IEQ3NzguOyUpQTUzOTwBQDk6MTwwOjE4FSc7TC0sLhwVNSwyMzQrOiI+OTwkUCkwTS01Lz86Eio0Nik4LzdDLzwvREIdKzI+Q0MkPCUzOxQvNjIeEDEyLxczNDUdMRYyNyUdMTI4PTQoLjkp","112.5|LQQ2OTY1AiU9Ly4yMygsLikvLC8sNyssQS4zKysrKjUwKTQvBS9BIC4lGDAiMTYwMi8wLTQkMTIuESgtLC8uJzIVODMvLCkyNi85LyIxNgA0LzQuEUUUBjEtJSk3MjE2MDAtMzMdKjMaKC4sHjNDNDMvOzAvLzIz","111.5|JEEyJTQ4Mzo4OSsbOTs0O0E7STtFF0A6MhlBKC84LyU6LTczLDktS0ERKTJGMQ07PD02QTslLDY7OhY3NjQ8OT1BJTc5Ozg2OzIlNDE4AENCNzEzQjpNQDc8RzwwLjVGQzs5NTIvOSkqRjYuSDgtQzUzGjQzMDk3","113.3|IQw2Pjw9BDwxLx8QKTguAjA0SDMuPTgsNyk5Ey4ZITsxGTIeFCA5GjAHNR8bNC4yLiYzOjcJFiMnGRQrQzgyNDohOyQzMCguLxUxLkEuLQotLTMuBzEzHDIoNxY0DTA7GzMkECY3JTEBIy81HSg/KyUgPxkxMSEq","112.7|LRQ6MSglQEA7NDk/KUQ2OTIsUCUpODsxPB0TKxMPNDU0QSQ2KzZIGz8yBiM3Nzs1IhM2OjI4BTgcNzQxID80Mx8mPzoyNTk1Ki1BNTwlQDkpLjU5OkUuPx8zNxRDLjI9PSU2PgFINjQKPCUwNiUnQyw3PjkQJzkx","113.2|FTQdIS0TEEYyKzwJKjUxATEmHDQIFjspEBoyHyIyFC0sNDATOitBHgoSPy0sJwgtNi0OEyk1IC0yTREjVD0hLxAsMxwtLBgvLTEXKzwxHigvMyQfODBBPC8qPjYqNS03ODEtCDgdLC9BJy0vBDJFPicRGAkqFDEu","112.1|KmNbVCMyWmpLFF8+akdIVjoQXkprYUA6SkVHWkQzMjcZPys5Tio2b2BkSRdZL1sNMiZGUSRWZEYwTkU+UzBGKT5QUUwRLEJQUChQHlAKQFJINithXHBhXx4BUzVANhk4QCsrO1BEGipaZUFGT0FtZyAtbFZDRl0h","110.6|T187UBJJaGJLMSBNVlE6QlIBX08zPiw9VlJGVUNMOxslVkZGTCU4Z1dGTTtbSUtBUD9LKBNTWik3Tk4pViJGNjkVRTg9K1I0ST5fJyU2TFEtOD8rVltkVUIJPEI1PEI3JzwzVUpaMTpWQ0oPLj1kNQs8NUhOMhU0","113.4|FioyJR8RJAsyMkEmMjgwLDEtQjUwKjguGi06JiwsNjIuNColNgE8QjExLCYyIisoIysxOS0NMhMtGiIvMT0mMkAsLzQwMTYuNyA1MR8sRDczLTIYGkI9NzMsNygmMipAOyI1NCsiKzU/KDQnKzMUOTY3Hw8xLTAu","114|JyIeIi80Ik0QIhoTNTIkORInVh48HjooGx4kNzEoJykpNw0vECE3TzYJGC9KLyYfLS4zLzExOyoZTTUjSzQnNDA0DSUoKSEaLSlJFxcZDB4vGxU2HiEOGx0mQBckASkfKCkYNzlUJg5IKSUiOBsyDykZRDcxMjwk","111.8|LUUlUUc3YVBQFV1fOztJSDktO0xkXU4wQ0tXTENKOVQbUD46SUNSWTlPaCJfN0YKLidMUT1EVjkwSUIzVT5YN11KQEggLFFPPA5nMUwBOWMwPhs/XVBYTyYaSTlbPyk9PTU/VEhiGCZQWjxGSj1PXyk6TFlKTUkt","112.1|LEMyOjoSMjY4MkNDPEY4OS02UzowIT0xRhY+KCQ7Fi02JTQQHjkcSTkBSDQNNRY1NDkkKTM0MSo3SCI0NDE2MTs/MzQtMxY7NzRCMCc5MEgxMzUpMR4hHzU0RkE8KjEgLDEyJStFNDINDzU2LTg6RDIaTygzEhgx","113.5|MD81OzchRjE0KCoAOD4wKDUrODI+PjkoRD8vLis5HS0hUw8xMS8fPi1FRy1YLDorNxguKSoLSSYnUzgrLCwpPisvKDAmKyUvLxRIKT4UOEovMw81MVAkJC0iPS9LLiEEQCAXITJbKRFJIS04PSwcJykKKDwzNiUo","111.4|F0I9AEIfRh85Oy9BMkk+LD4/Uz0yODM2RjIuCzI6OEM6Nj01RCtGJilDUSsxOSo0QDIrQj1CMUIuLBU9UTE7OUMwQzQ+Nz4yOkEKPkY2IEAwKz4hPDdMQi87MSlELjtAMDs+NSVMODk+Qz1BOzo/QT1ALy8wOkE4","19.8|Rz0AV1FFLmhJPFNNJVU4TDs7Z0pNVTxBP0VOQExMSUQ3TUk+Sy5ASg8/O0lSSEMsRklFUUlQSUZKRExAZUMOTFtOVjYGN0sTSjJBMVVEFU5CSUciSF5aSkIvO0NdQUdcT0cyS09cOTtPOkVIVkVMU0JEaEtCP1Q8","112.3|KGEqZDAwO2c7Cl1YRUEzSUwVST1CQksraFFXV0Q5NTgjSycxRzxHTzpXYSJXL0ELSyZUSUE4ZkYnV0kwUk82Nj5LUzUAMUotNw1lLFoqQGE6MChjWlBiWxQaSVhGPyJCXS8tU0NwDhxlXy5ESDJHSyYqaEc3QFck","110.2|SSYaTUZPSllKJVcuRz1DVTdEWlJIHTo7LkpIHlRPSUslTTE+VDdTLElAM0JKRQtBKUhES0gqWEZGSU5AXEgBTT1XTUg6RDpLOzo+L1pCSFgfSTE6VylUUjpASE1QN0RWQkgwM0w5HUI7QEVIR0RbQzxOX0RCQUA1","110.3|QjNITA5APjZONDpKSlInQD8INDwmMktAV0VCSUZLRzoySUFIUD9RW05SQEFNPkc+S0FFNUVIS0NGT0c+XkZRRh8vPUIBOkBCQUk9QUArTFA/QyNLRl5WRTgsUjlNSEE4KEISUkU0QkhVUEI5UUVUSkFGN0NHPj9B","113|MxY1OzgXPEg+LEI6Nz4uJjEvPCUTNC0tEiw3HiwmIBctQiceNRk0MDY5BCUxLDkuLTcxKDYQNRoyKzksRjMzNyQ3RjUvKyo2NyEVNSovNBo4NzYIEytQPTEwQTc7KzMmJR4yKhtKKTIMKDMkDTMBHy8uMRUxJSgy","110.2|U15GTjJPVmhGOD1BVEZDTlEZY0dOTydFTE9QVkhGQQAgVks/VTkwXVc4XEFSTkdGTUlRLBpQViA+RE4reA1SOVomUzM/KVcoSjNcIkQwOT8UPkdDTWdgTUAOKzEnRkJSRDovXVRoL0JhV08YQkdlIBNDX1BKOjQ0","18|Vk5YWVdQVTtXQFRTUEpFPVhUWVdVV11GXVdRUVFZUjA+RkRBWFNYYEZdWlRYR0VRTENUWFZHXkFWR1kzQlZWVFpPQUFPRVhXWFVhVV9VWElVTVRZRWhBTVBRVlhCVUhZWlIBW0ZgO1ZcUT1SW0k8ZFRSTlFJUmZV","112.3|Jzk5NBA8Lkc9NkksGTYyQTI3KhhDIT49JxIvOSotOS85Gi40ADlBUEIVNCguMSg1FTg5Py0oIjkwQhY3QkEpJUo/ETk0Oi46KRdENkEzPS48NidBPy1LQTs2ShsmKTNDPD06PCEzNC0VNxo0Qik8Jjk7BzInLUAn","113.7|L0EsPiElQzspMyIxOUQ0OCQxSRUpFTowQyErNS0fIjovFxErJDQkNEA/OxoWMTcvFRg5OjkBLjEYOBIuRzI0KDI5LzgwKhw0LBVDLDcVNEg8MBM9HUwlNi8xRDo7OCYQIycqORxEMhUYMxwmRB43PCgcHTIvJUQl","110.4|QiE3XD5GZ1oYL1VRE0IjQwsBOB5KTzAtWFZYT0hSSk4uUkI/TkRXHFlOJjNhSUc9SUhQP0hPTDFKUUg9ai8+O0gjQ0Q0MkwaHUVKPg80UmpMPjNIYVhiW0YrJVhgNjVYIiUuL01EP0ZKVUcuMzZiVDpIUkZFTDwm","112.6|Jz4TFxUvMFQgKkIwNy8mLjMpKDFANDUzFzo6MzQ4OyIuQDIdOQFIQxweSzQ0LRMmJDg8PRZCIyA0Nzg0NDklPTwwHTIuNjclNTEqKkAzOi84IzQ7RTBLRjIyNB0kGjI9IjM0RTFIMDRAPDAvHS5HLTU4LiU1ODEs","111.9|GCQ6ADcsPk8/NUk+OycyQjkwQjc6Jzo6QS0IHC8zLCwzQyZAPC83UzZAHzhNPzQvPyodOTo1KjMlLjA5DyI0ND42Mjg4NjwjPT8UNjU4PQkuHzMFRkIdQy81SjscPjg2QCU6RRxKNThEOTg2QjAjNzo9SCsbNTQ7","111.8|PEIvAC8aRR4sMipAPzA5KTs2Kz1CJkM2KDEvEx8tOT4yNjw7KjgyLDxBGTkuMD02JSBBLi48CzQrMzg5TEI8MShBOzY2ODknNzsUODg2GUQiMjAnQUlGMyE1MzM/MjsvOTM4RykeNy5BOC04PjMcRisuODsoOUI4","111|LEYuADUlTSkqOUE2M0g7HD83M0MiPjc+PkM5PjxDMyk7RD0pSCcsSBkkNTdCOwQ9PD86RTk4QzY+Ojs5KR88QC04JTc8PT9ANToEOzo7MSU+Pzw6NTotLj07Pz86Pzw8PzU9FTwuNzlANjY6RT4tNT85Oxw0LUQ9","17.6|U1FUW1hYWF1SOU1ZW0JQW1JUWFdbR0hGSFBTWFRUWB5AS1VWUVhWb1tMVlNTTlhNVVdWUFBWWlhTUk81SVRBV1lUT1VTSk5TSFc2PGVUWEAsWFVYSkRLPk9LVFdYSlJUTlYAUFFZOVVYYFRXWVYuSVNXS1RMU1FG","112.9|JSApHi0uFiYxMzUqOUAfOhwwJSktODAzADUqOzwzNzUsPh0wTAs7QzdJMDQ4MzAjHTA3Oik2PRkkRTMxKDM2PD84GDcuMSkhNis3MA0qPCkjIy8jMTxJJCwyOxAgJC4zKSAwQj5PMjpJMTYjIjA4Pjg7PCw4Ozg2","112.7|EiI7Dy8qP1A+MUcyJzwnPC8qKzA+Ii8xORYAECgbIiwuQyc1PjIiTTgwRS1DOiswNyANLzs6MDoeUhcuTDg4PkFAOTc1NDYiKzolNj0vBgM8GTIgPRxTMiM0Eh81KDImPC4uKyc0NS4qQDU3KypJIDM0PSkfIzo2","111.6|OTE8MS8xQRpIK0k/LDYyHkU9S0Y3LzwyLj9HMz1GRRsnNyosSDBLPDVDRT5INyw5OC5AOTwbLCZFLjcpOE44Q0kuLy87NUE2OTwjNRo+TSEqPDk4HDhBOj47RkIpPTZPUDofTCg0Lkc+Lyw8OyoBQEJCHho3Mz86","113.9|KTIsPDEwCioeLA8RFzUwHhcsFSQoDh4uJwcaNjAUGjYnAxoxNzQPJzA1RCcaLjIpMBgyJi8SIikbIQomNjUoHTAvPxwmJRAxHSg6JDUcEzomJhctGyYCPSooHjY9LCYcBSUeOC4XKS0iEiAjGx83LCQUASYiHiAk","113.8|DjgvGzULEEAcIyQaNTUqGBkpLS0HJyspGh4gNyo1BS0oHCANJCM5UwIcQhwTJQ8rMS8NOTE4OzAeKhgrUhEhIyo5NxYqKgoyMC8tKj8rKCgfJSw3DRIzJCsnLjVHGSoHAS4kRytVKS0jCysyCSoTGSsqRxMvCywh","111.7|OUMgRzQ6RzQkN1oaQTs8Jj06UzdAQjgyQUA1QEMuOz4yOT4iTDcgWCciTi1KLx03JDkqJzYUQT09VDQ5TykuPS43KTMsMTsyPB0xLz06AVI8OzcuRCBVLjYyL0IzNjVCQi82DEA+Nx0/KjE4MzdKJzwkSz88PSow","18.8|T1VFWUJKU05OLT9ULkA5VVBOSUpXUkc1WVNLSk9IUB5CSE9LUkVLOFNNS0ZKQ0BHVFNOUFBXPldOR0ktUk82TD9YTU1KQFJSP1AzLjZES2A3UE5OWD1NLUlMUEpOTkVQSVAAS1c9N1BLR0BRRjdqREVQa0BSUUZD","112.6|MDMtPDspUUIjJDFEQjkuPywpRT42RDkjPEQuOjE8NTYlPSApWCxOSiNPVS9DMTorNCY1MxhCSDwZS0AvVDY9LCJHOxgkLSg2Hi9NGjknT04ANRxBQkJDTyEeUj5KHiQuFTIcSUVSKjFTQCk+LiskTDIxVDs7OxUj"]},{type:"softmax"}]};var Fe=class extends Float32Array{},y=Fe,Oe=n=>n instanceof Float32Array,xe=n=>typeof n=="number",Ne=n=>typeof n=="undefined",Re=(n,e)=>{let i=1-Math.random(),o=Math.random();return Math.sqrt(-2*Math.log(i))*Math.cos(2*Math.PI*o)*e+n},oe=class{constructor(n,e,i,o,h){if(this.sx=n,this.sy=e,this.sz=i,this.length=n*e*i,Ne(o)){let a=Math.sqrt(1/this.length),m=()=>Re(0,a);this.w=new y(this.length).map(m)}else xe(o)?(this.w=new y(this.length),o!==0&&this.w.fill(o)):Oe(o)?this.w=o:this.w=new y(o);this.dw=h||new y(this.length)}index(n,e,i){return(this.sx*e+n)*this.sz+i}get(n,e,i){return this.w[this.index(n,e,i)]}set(n,e,i,o){return this.w[this.index(n,e,i)]=o}add(n,e,i,o){return this.w[this.index(n,e,i)]+=o}getGrad(n,e,i){return this.dw[this.index(n,e,i)]}setGrad(n,e,i,o){return this.dw[this.index(n,e,i)]=o}addGrad(n,e,i,o){return this.dw[this.index(n,e,i)]+=o}clone(){let n=new oe(this.sx,this.sy,this.sz,this.w);return n.w=n.w.slice(),n}cloneWithZeros(){return new oe(this.sx,this.sy,this.sz,0)}},Be=oe;var C=(n,e)=>{let i={},o=0;for(let h=0,a=n.length-e;h<=a;h++){let m=n.slice(h,h+e);i[m]||(i[m]={value:m,count:0,frequency:0}),i[m].count+=1,o+=1}for(let h in i)i[h].frequency=i[h].count/o;return i},Ue=n=>{let e=/-+/g,i=/[^\p{L}\p{M}\s]/gu,o=/\s{2,}/g;return` ${n.replace(e," ").replace(i,"").replace(o," ").toLowerCase().trim()} `};var se=(n,e,i,o)=>{let h=Ue(n),a=C(h,1),m=C(h,2),l=C(h,3),d=C(h,4),p=i.unigrams.map(W=>{var H;return((H=a[W])==null?void 0:H.frequency)||0}),Z=i.bigrams.map(W=>{var H;return((H=m[W])==null?void 0:H.frequency)||0}),Y=i.trigrams.map(W=>{var H;return((H=l[W])==null?void 0:H.frequency)||0}),t=i.quadgrams.map(W=>{var H;return((H=d[W])==null?void 0:H.frequency)||0}),I=[...p,...Z,...Y,...t],g=new Be(1,1,I.length,new Float32Array(I)),J=o.forward(g,!1).w;return Array.from(J).map((W,H)=>[e[H],W]).sort((W,H)=>H[1]-W[1])};var Te=new ie(ce),we=n=>se(n,qe,Ge,Te),je=we;var Ee=je;return Ve(Le);})(); return __lande.default; }; // </LANDE> let _lande = null; // { lang, conf, top } from lande, or null; the model is built on the first call (~100 ms) function lsDetectLanguage(text) { try { if (!_lande) { const t0 = performance.now(); _lande = landeLoad(); Log.cat('Language').debug('lande ready in', Math.round(performance.now() - t0), 'ms'); } const res = _lande(text) || [], best = res[0]; const top = res.slice(0, 4).map(([c, p]) => c + ' ' + Math.round(p * 100) + '%').join(', '); return best ? { lang: best[0], conf: best[1], top, all: res } : null; } catch (e) { Log.cat('Language').warn('detection failed', e && e.message); return null; } } // a word's script group: kana and Han read together (lande tells Japanese from Chinese) function lsWordGroup(w) { let best = null, bn = 0; for (const [sc, re] of Object.entries(LS_RE)) { const k = (w.match(re) || []).length; if (k > bn) { bn = k; best = sc; } } return best === 'Hiragana' || best === 'Katakana' || best === 'Han' ? 'CJK' : best === 'Hangul' ? 'Korean' : best; } // the titles split by script, word by word: { group: { text, titles, letters } }. lande is // only ever given one script: mixed Latin and Cyrillic read as Serbian, which is written in // both ("Laula Mulle Laulu = Обійми" came out Serbian 95%; each half alone is Finnish 98% // and Ukrainian 100%) (#703) function lsByScript(titles) { const g = {}; titles.forEach(t => { const per = {}; for (const w of t.split(/\s+/)) { if (!/\p{Letter}/u.test(w)) continue; const k = lsWordGroup(w); if (k) (per[k] = per[k] || []).push(w); } for (const [k, ws] of Object.entries(per)) { const e = g[k] = g[k] || { text: [], titles: 0, letters: 0 }; e.text.push(ws.join(' ')); e.titles++; e.letters += ws.join('').replace(/\P{Letter}/gu, '').length; } }); for (const e of Object.values(g)) e.text = e.text.join('\n'); return g; } const LS_GROUP_OF = { Japanese: 'CJK', Han: 'CJK', Korean: 'Korean' }; // the "auto" badge inside a field Apollo filled, after the Label field's (#703 majkinetor: // "It would also be good to mark those fields as auto set") function lsBadges() { for (const k of ['language', 'script']) { const sel = document.querySelector('select#' + k); if (!sel) continue; const host = sel.parentElement, st = _ls[k]; let b = host.querySelector(':scope > .tc-ls-auto'); const want = st.state === 'auto' && sel.value === st.value && apolloEnabled(); if (!want) { if (b) b.remove(); continue; } if (!b) { b = document.createElement('span'); b.className = 'tc-ls-auto mbu-ui'; b.innerHTML = '<span class="tc-badge auto">auto</span>'; host.appendChild(b); if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; } b.firstChild.title = st.why || ''; // level with the field's right end, wherever the cell's edge is const pin = document.documentElement.querySelector('.mmthf-pin') ? 48 : 28; b.style.right = Math.max(0, host.clientWidth - (sel.offsetLeft + sel.offsetWidth)) + pin + 'px'; } } // #722 MB's ids for the names above (its language and script tables), so the option is found // whatever language MB shows its names in ("Deutsch" is still 145 = German) const LS_MB_IDS = { language: { Afrikaans: 9, Arabic: 18, Armenian: 21, Azerbaijani: 34, Basque: 41, Belarusian: 45, Bengali: 47, Bulgarian: 62, Catalan: 68, Chinese: 76, Croatian: 366, Czech: 98, Danish: 100, Dutch: 113, English: 120, Estonian: 123, Finnish: 131, French: 134, Georgian: 144, German: 145, Greek: 159, Hausa: 165, Hebrew: 167, Hindi: 171, Hungarian: 176, Icelandic: 180, Indonesian: 189, Italian: 195, Japanese: 198, Kazakh: 211, Korean: 224, Kurdish: 232, Lithuanian: 243, Macedonian: 254, Marathi: 264, 'Norwegian Bokmål': 306, Persian: 334, Polish: 338, Portuguese: 340, Romanian: 351, Rundi: 352, Russian: 353, Serbian: 363, Slovak: 377, Spanish: 393, Swedish: 403, Tagalog: 414, Thai: 415, Turkish: 433, Ukrainian: 441, Vietnamese: 448 }, script: { Hebrew: 11, Arabic: 18, Greek: 22, Latin: 28, Cyrillic: 31, Armenian: 35, Georgian: 36, Korean: 43, Gurmukhi: 49, Devanagari: 50, Gujarati: 52, Bengali: 53, Tibetan: 55, Telugu: 59, Kannada: 60, Tamil: 61, Malayalam: 62, Sinhala: 63, Myanmar: 64, Thai: 65, Khmer: 68, Lao: 69, Japanese: 85, Ethiopic: 86, 'Han (Hanzi, Kanji, Hanja)': 92, '[Multiple scripts]': 160 } }; // the option for a name, by its MB id, else by its English text (MB lists frequent ones twice: the first will do) const lsOption = (sel, name) => { if (!name) return null; const id = (LS_MB_IDS[sel.id] || {})[name]; return (id && [...sel.options].find(o => o.value === String(id))) || [...sel.options].find(o => o.value && o.textContent.trim() === name) || null; }; const _ls = { language: { state: 'idle', value: '' }, script: { state: 'idle', value: '' } }; // idle → auto (we filled it) → off (given, or changed) let _lsSig = '', _lsSigAt = 0, _lsDone = '', _lsBusy = false; // which fields Apollo may still fill; marks the given ones off for good function lsOpen() { const out = []; for (const k of ['language', 'script']) { const sel = document.querySelector('select#' + k), st = _ls[k]; if (!sel || st.state === 'off') continue; if (st.state === 'idle' && sel.value) { st.state = 'off'; Log.cat('Language').debug(k, 'is given (' + (sel.selectedOptions[0] || {}).textContent + '), left alone'); continue; } if (st.state === 'auto' && sel.value !== st.value) { st.state = 'off'; Log.cat('Language').info(k, 'changed by hand, left alone'); continue; } out.push(k); } return out; } // every tick: once the titles have been still for a moment, guess what is still open function applyLangScript() { lsBadges(); if (_lsBusy) return; // off: the Detected lists go, and come back from scratch once it is on again if (!apolloEnabled() || SETTINGS.autoLangScript === false) { if (lsOffer(null)) { Log.cat('Language').info(apolloEnabled() ? 'the setting is off' : 'Apollo is off', '— the Detected lists are taken away'); } _lsDone = ''; _lsSig = ''; return; } let titles; try { const rel = release(); titles = [u(rel.name) || ''].concat(readTracklist().map(t => t.title)).map(lsClean).filter(Boolean); } catch (e) { return; } const sig = titles.join('\n'); if (sig !== _lsSig) { _lsSig = sig; _lsSigAt = Date.now(); return; } if (!sig || sig === _lsDone || Date.now() - _lsSigAt < 1500) return; _lsDone = sig; _lsBusy = true; detectLangScript(titles).catch(e => Log.cat('Language').warn('detection failed:', e && e.message)).finally(() => { _lsBusy = false; }); } // everything the titles suggest, most likely first: { language: [{ name, conf, why }], script: [...] }, // and the one each field is filled with when nothing is given (best: { language, script }) function lsCandidates(titles) { const text = titles.join('\n'), sc = lsDetectScript(text); const out = { language: [], script: [], best: {} }; Log.cat('Language').debug(titles.length, 'titles · scripts', JSON.stringify(sc && sc.counts)); if (!sc) return out; const add = (list, name, conf, why) => { if (!name) return; const e = list.find(x => x.name === name); if (!e) list.push({ name, conf, why }); else if (conf > e.conf) Object.assign(e, { conf, why }); }; // scripts: each with 5% of the letters or more const sCount = {}; for (const [s, n] of Object.entries(sc.counts)) { const k = s === 'Hiragana' || s === 'Katakana' ? (sc.script === 'Japanese' ? 'Japanese' : 'Han') : s === 'Hangul' ? 'Korean' : s; sCount[k] = (sCount[k] || 0) + n; } if (sc.script === 'Japanese' || sc.script === 'Korean') { sCount[sc.script] = (sCount[sc.script] || 0) + (sCount.Han || 0); delete sCount.Han; } for (const [s, n] of Object.entries(sCount).sort((a, b) => b[1] - a[1])) if (LS_SCRIPTS[s] && n / sc.letters >= 0.05) add(out.script, LS_SCRIPTS[s], n / sc.letters, Math.round(n / sc.letters * 100) + '% of the letters'); if (out.script.length > 1) add(out.script, LS_SCRIPTS.Multiple, 0, 'all of them'); if (sc.script) out.best.script = { name: LS_SCRIPTS[sc.script], why: sc.script === 'Multiple' ? 'no script holds 70% of the letters' : Math.round(sc.freq * 100) + '% of the letters' }; // languages: lande on each script's words alone (guesses from one or two titles are wrong // more often than not, Harmony), and the language a script implies const groups = lsByScript(titles), dets = {}; Log.cat('Language').debug('by script', JSON.stringify(Object.fromEntries(Object.entries(groups).map(([k, e]) => [k, e.letters])))); for (const [g, e] of Object.entries(groups)) { if (e.letters / sc.letters < 0.05) continue; const implied = g === 'CJK' ? (sc.counts.Hiragana || sc.counts.Katakana ? 'Japanese' : null) : LS_SCRIPT_LANG[g]; const det = e.titles > 2 ? lsDetectLanguage(e.text) : null; dets[g] = det; if (det) { Log.cat('Language').debug('lande says', det.top, 'for the', g, 'words'); for (const [c, p] of det.all) if (p >= 0.1 && LS_LANDE[c]) add(out.language, LS_LANDE[c], p, Math.round(p * 100) + '% (' + c + '), the ' + g + ' words'); } if (implied && !(det && det.conf >= 0.8)) add(out.language, implied, 0.5, 'from the ' + (g === 'CJK' ? 'kana' : g) + ' script'); } out.language.sort((a, b) => b.conf - a.conf); if (out.language.length > 1) add(out.language, '[Multiple languages]', 0, 'all of them'); // the one to fill in: the main script's words; mixed, the non-Latin script's, as the Latin // is mostly its translation or transliteration (#703 majkinetor: "ukrainian is right") const pick = sc.script === 'Multiple' ? Object.keys(groups).filter(k => k !== 'Latin').sort((a, b) => groups[b].letters - groups[a].letters)[0] : (LS_GROUP_OF[sc.script] || sc.script); const det = pick && dets[pick]; const implied = pick === 'CJK' ? (sc.counts.Hiragana || sc.counts.Katakana ? 'Japanese' : null) : LS_SCRIPT_LANG[pick]; if (det && det.conf >= 0.8 && LS_LANDE[det.lang]) out.best.language = { name: LS_LANDE[det.lang], why: Math.round(det.conf * 100) + '% (' + det.lang + ')' + (sc.script === 'Multiple' ? ', from the ' + pick + ' words' : '') }; else if (implied) out.best.language = { name: implied, why: 'from the ' + (pick === 'CJK' ? 'kana' : pick) + ' script' }; else if (!pick || !groups[pick] || groups[pick].titles <= 2) Log.cat('Language').debug('too few titles for a language'); return out; } // "Detected by Apollo" at the top of each field's list: one of them, or all of them as // [Multiple …] (#703 majkinetor: "User should be able to select one or all of the found // languages by lande (the same for script)"). null takes the groups away. function lsOffer(cands) { let removed = 0; for (const k of ['language', 'script']) { const sel = document.querySelector('select#' + k); if (!sel) continue; const list = cands ? cands[k] : [], old = sel.querySelector(':scope > optgroup.tc-ls-found'); if (!old && !list.length) continue; const v = sel.value; // removing the chosen option would move the field to the first one if (old) { old.remove(); removed++; } if (list.length) { const og = document.createElement('optgroup'); og.className = 'tc-ls-found'; og.label = 'Detected by Apollo'; for (const c of list) { const src = lsOption(sel, c.name); if (!src) { Log.cat('Language').debug('MusicBrainz has no', k, JSON.stringify(c.name)); continue; } const o = document.createElement('option'); o.value = src.value; o.textContent = src.textContent.trim(); o.title = c.why; og.appendChild(o); // MB's own name for it, in its language } if (og.children.length) sel.insertBefore(og, sel.options[0] && !sel.options[0].value ? sel.options[0].nextSibling : sel.firstChild); } if (sel.value !== v) sel.value = v; if (cands) Log.cat('Language').debug('offered for', k, list.map(c => c.name + ' ' + Math.round(c.conf * 100) + '%').join(', ') || 'nothing found'); } return removed; } async function detectLangScript(titles) { const cands = lsCandidates(titles); lsOffer(cands); const open = lsOpen(), done = []; for (const k of open) { const want = cands.best[k]; if (!want) continue; const sel = document.querySelector('select#' + k), opt = sel && lsOption(sel, want.name); if (!opt) { Log.cat('Language').warn('MusicBrainz has no', k, JSON.stringify(want.name)); continue; } if (lsOpen().indexOf(k) < 0 || sel.value === opt.value) continue; // the user got there first sel.value = opt.value; sel.dispatchEvent(new Event('change', { bubbles: true })); _ls[k] = { state: 'auto', value: sel.value, why: 'Detected by Apollo from the titles: ' + want.why }; Log.cat('Language').info(k, '→', want.name, '·', want.why); done.push(want.name); } lsBadges(); if (done.length) toast('Detected ' + done.join(' · ')); } // single watcher for both managed tabs; the one launcher persists across them and is removed elsewhere function watchTabs() { const tick = () => { const tl = tracklistVisible(), rec = recordingsVisible(); if (document.getElementById('tc-mirror-wrap')) syncNative(); // keep native tracklist bits in their chosen state if MB re-renders if (tl && !_tlPrev) { _tlPrev = true; Log.cat('System').info('entered Tracklist tab'); // #638: this tick lands up to half a second after the tab opened, so a Match clicked at // once is already running: rebuilding the table then left the pass filling in the old one, // and every badge it set was gone ("set") in the table shown. Wait for the pass (#575). if (tlWant()) { if (!document.getElementById('tc-mirror-wrap')) showMirror(); else if (!_tlRefreshed) { _tlRefreshed = true; if (_matching || _autoMatching) runSync(); else loadAndRender(); } } else hideMirror(); } else if (!tl && _tlPrev) { _tlPrev = false; } if (tl && document.getElementById('tc-mirror-wrap')) syncTlVideoMarks(); // #655 if (rec && !_recPrev) { _recPrev = true; Log.cat('System').info('entered Recordings tab'); } else if (!rec && _recPrev) { _recPrev = false; } // mount as soon as the (lazily-built) native table exists — retry each tick so there's no native flash if (rec) { if (recWant()) { if (!document.getElementById('tc-recwrap')) showRecMirror(); else if (recSig() !== _lastRecSig) rerenderRec(); } else hideRecMirror(); } // re-render when MB mutates a recording externally (e.g. cleared on a title edit) if (releaseInfoVisible()) applyReleaseInfo(); applyLangScript(); // #703: language and script from the titles, while none is given applyDuplicates(); // #187: score the Add-release Duplicates tab when "Modify Duplicates" is on if (editorEl()) { ensureLauncher(); wireTabFlush(); } else { const l = document.getElementById('tc-launch'); if (l) { l.remove(); mbRestackCorner('br'); } } // #135: the switch shows on every tab; #145: flush the takeover on tab clicks if (navOn() && editorEl()) { if (!document.getElementById('tc-nav-steps')) applyNav(); else syncNav(); relocateAddMedium(); } // keep compact nav alive + synced applyZen(); // #141: keep zen state applied (and fill the nav title once the release model is ready) }; tick(); setInterval(tick, 500); } /* ═══════════════════════════════════════════════════════════════════════ DUPLICATES (#187) — Add-release "Duplicates" tab. Adds a Similarity column scoring how closely each existing "similar release" matches the one being entered, so the editor can pick the right one to base their release on. We proxy the native table (KO `foreach: similarReleases`): read each rendered row's cells, insert a score cell, and re-apply via a MutationObserver when KO re-renders. Off by default (the "Modify Duplicates" setting). ═══════════════════════════════════════════════════════════════════════ */ function dupWant() { return apolloEnabled() && !!SETTINGS.modifyDuplicates; } // similarity of an existing release (name / artist / track count, all we can read // from the native row) to the release being entered. Multiplicative penalties, // in the spirit of MB Release Seeding Helper: a steep title base, softened by an // artist mismatch and a track-count gap (our proxy for its per-track length check). // #187/#298: similarity = how many tracks line up AT THE SAME POSITION between the // candidate release and the one being entered, over the larger tracklist — the very // pairing the comparison view (buildDupDetail) reds row by row. Greedy any-position // matching read 100% for a release with the SAME songs in a DIFFERENT order, while // the position-by-position comparison showed those rows red (#298). A position // matches when its TITLE matches (fold-equal or fuzzy ≥0.85) AND its ARTIST agrees // (folded; empty on either side — single-artist releases — falls back to title). // Release-level metadata (name/count) is still deliberately ignored. const _simRatio = (a, b) => a === b ? 1 : 1 - recLev(a, b) / Math.max(a.length, b.length, 1); const _titleOk = (a, b) => a === b || _simRatio(a, b) >= 0.85; // #332: artist is a soft FACTOR, not an exact gate. A fuller credit ("Roy Ayers" vs // "Roy Ayers Ubiquity", "Horace Tapscott" vs "Horace Tapscott with …") or a near-variant // ("Phil"/"Philip", "&"/"and") is the SAME track — exact-only matching scored those as misses // and tanked an otherwise-identical release (62% when it should read ~90%). const _artistPrefix = (a, b) => { const s = a.length <= b.length ? a : b, l = a.length <= b.length ? b : a; return s.length >= 3 && l.startsWith(s) && (l.length === s.length || l[s.length] === ' '); }; const _artistFactor = (a, b) => (!a || !b || a === b) ? 1 : (_artistPrefix(a, b) || _simRatio(a, b) >= 0.7) ? 0.85 : 0.5; function dupTrackScore(media, entered) { const cand = []; (media || []).forEach(m => (m.tracks || []).forEach(t => cand.push({ t: fold(t.title || ''), a: fold(t.artist || '') }))); const ent = (entered || []).map(t => ({ t: fold(t.title || ''), a: fold(t.artist || '') })); // keep positions if (!ent.some(x => x.t) || !cand.length) return null; // can't judge overlap → no confident score let matched = 0; const n = Math.min(ent.length, cand.length); for (let i = 0; i < n; i++) { const e = ent[i], c = cand[i]; if (e.t && c.t && _titleOk(e.t, c.t)) matched += _artistFactor(e.a, c.a); } // title gates; artist scales return matched / Math.max(ent.length, cand.length); } // one WS fetch per existing release, shared between the score and the expand view const _dupTlCache = new Map(); // #626: the same responses, raw — the artist position source reads each listed duplicate's // track-by-track credits from here instead of asking MusicBrainz again const _dupRawCache = new Map(); // release gid → ws/2 release JSON async function dupTracklist(gid) { if (_dupTlCache.has(gid)) return _dupTlCache.get(gid); const m = await fetchDupTracklist(gid); _dupTlCache.set(gid, m); return m; } // throttled scorer queue — fetch each candidate's tracklist one at a time (the // Duplicates tab lists only a handful), staying under MB's WS rate limit. const _dupQ = []; let _dupBusy = false; function enqueueDupScore(gid, td, tr) { _dupQ.push({ gid, td, tr }); pumpDupScores(); } async function pumpDupScores() { if (_dupBusy) return; _dupBusy = true; while (_dupQ.length) { const { gid, td, tr } = _dupQ.shift(); if (!td.isConnected) continue; const media = await dupTracklist(gid); if (!td.isConnected) continue; const candTracks = media ? media.reduce((n, m) => n + ((m.tracks || []).length || 0), 0) : NaN; // #187: candidate's actual track count paintDupCell(td, tr, gid, media ? dupTrackScore(media, enteredTracklist()) : null, candTracks); await new Promise(z => setTimeout(z, 1100)); } _dupBusy = false; } function paintDupCell(td, tr, gid, sim, candTracks) { if (sim == null) { td.textContent = '?'; td.style.cssText = 'text-align:center;color:var(--mbu-text-weak)' + (gid ? ';cursor:pointer' : ''); td.title = 'no track overlap could be computed (tracklist unavailable, or no tracks entered yet)'; if (gid) td.onclick = () => toggleDupDetail(tr, gid, td); return; } const pct = Math.round(sim * 100); // #187: flag a track-count mismatch (still scored — the % is useful when a // platform just has a few extra/fewer tracks) so the number isn't read as a // full match. Only when we actually have a tracklist entered to compare. const myTracks = mediums().reduce((n, m) => n + ((u(m.tracks) || []).length || 0), 0); const trackMiss = myTracks > 0 && !isNaN(candTracks) && candTracks !== myTracks; td.innerHTML = pct + '%' + (trackMiss ? ' <span class="tc-dup-tm">⚠ tracks</span>' : ''); td.style.cssText = 'background:' + dupColor(pct) + ';color:#fff;font-weight:700;text-align:center;border-radius:3px;padding:1px 6px' + (gid ? ';cursor:pointer' : ''); td.title = 'track-overlap similarity to the release you are entering' + (gid ? ' — click for a track-by-track comparison' : ''); if (trackMiss) td.title += ` — track count differs: you entered ${myTracks}, this release has ${candTracks}`; if (gid) td.onclick = () => toggleDupDetail(tr, gid, td); } // 0% → Apollo red, 50% → amber, 100% → Apollo's match green (#1f8a4c) — uses the // same palette as the confidence dots so the score reads as "our" green. (#187) function dupColor(pct) { const p = Math.max(0, Math.min(100, pct)) / 100; const stops = [[0xd3, 0x2f, 0x2f], [0xff, 0xb7, 0x4d], [0x1f, 0x8a, 0x4c]]; // d32f2f · ffb74d · 1f8a4c const seg = p < 0.5 ? 0 : 1, t = p < 0.5 ? p / 0.5 : (p - 0.5) / 0.5; const c = stops[seg].map((v, i) => Math.round(v + (stops[seg + 1][i] - v) * t)); return `rgb(${c[0]},${c[1]},${c[2]})`; } // #723 the Similarity column goes right after the Release column: the cell that links the // release, not a fixed index, so a column MB adds or drops doesn't move it (2: the old place) function dupSimIndex(tr) { const cells = tr ? [...tr.children].filter(c => !c.classList.contains('tc-dup-sim')) : []; const i = cells.findIndex(c => c.querySelector('a[href*="/release/"]')); return i >= 0 ? i + 1 : 2; } function augmentDupRows(tbody) { tbody.querySelectorAll(':scope > tr').forEach(tr => { if (tr.classList.contains('tc-dup-detail')) return; // our own expanded-detail rows if (tr.querySelector('.tc-dup-sim')) return; // already scored this (KO-rendered) row const gid = (tr.querySelector('input[name="base-release"]') || {}).value || null; const td = document.createElement('td'); td.className = 'tc-dup-sim'; td.textContent = '…'; td.style.cssText = 'text-align:center;color:var(--mbu-text-weak)'; td.title = 'computing track-by-track similarity…'; tr.insertBefore(td, tr.children[dupSimIndex(tr)] || null); // right after the Release column if (gid) enqueueDupScore(gid, td, tr); else td.textContent = '—'; // neutral until the tracklist is fetched (never a metadata-only guess) }); } // expand/collapse a per-track comparison of the existing release vs the one being // entered, beneath the clicked row (mirrors MB Release Seeding Helper). #187 async function toggleDupDetail(tr, gid, cell) { const nxt = tr.nextElementSibling; if (nxt && nxt.classList.contains('tc-dup-detail')) { nxt.remove(); cell.classList.remove('tc-dd-open'); return; } cell.classList.add('tc-dd-open'); const dr = document.createElement('tr'); dr.className = 'tc-dup-detail'; const td = document.createElement('td'); td.colSpan = tr.children.length; td.innerHTML = '<div class="tc-dd-wrap">loading track comparison…</div>'; dr.appendChild(td); tr.parentNode.insertBefore(dr, tr.nextSibling); const media = await dupTracklist(gid); if (!dr.isConnected) return; // collapsed again before the fetch returned td.querySelector('.tc-dd-wrap').innerHTML = media ? buildDupDetail(media, enteredTracklist()) : '<div class="tc-dd-wrap">could not load the existing release tracklist</div>'; } function enteredTracklist() { const out = []; mediums().forEach(med => (u(med.tracks) || []).forEach(t => out.push({ title: u(t.name) || '', artist: acText(u(t.artistCredit)), len: u(t.length) || null, // #626: each credited name with its artist, when matched — the comparison links those names: liveNames(t).map(n => { const a = u(n.artist) || null; return { name: u(n.name) || (a && u(a.name)) || '', join: u(n.joinPhrase) || '', gid: (a && u(a.gid)) || null }; }), }))); return out; } function acJoinJson(ac) { return (ac || []).map(n => (n.name || (n.artist && n.artist.name) || '') + (n.joinphrase || '')).join(''); } // #358: render a WS2 artist credit as per-artist links (each credited name → /artist/<gid>), // keeping the join phrases as plain text. Used for the existing release's artists in the // Duplicates comparison so you can click through to each artist. function acLinksJson(ac) { return (ac || []).map(n => { const gid = n.artist && n.artist.id, nm = n.name || (n.artist && n.artist.name) || ''; const cell = gid ? `<a href="${ORIGIN}/artist/${gid}" target="_blank" rel="noopener">${esc(nm)}</a>` : esc(nm); return cell + esc(n.joinphrase || ''); }).join(''); } async function fetchDupTracklist(gid) { try { const res = await wsJson(`${ORIGIN}/ws/2/release/${gid}?inc=artist-credits+recordings&fmt=json`, { label: 'dup tracklist' }); const j = res.json; if (!j) return null; _dupRawCache.set(gid, j); return (j.media || []).map((m, mi) => ({ position: m.position || mi + 1, format: m.format || '', tracks: (m.tracks || []).map(t => { const ac = t['artist-credit'] || (t.recording && t.recording['artist-credit']) || []; return { pos: t.number || t.position || '', title: t.title || (t.recording && t.recording.title) || '', artist: acJoinJson(ac), artistAc: ac, // #358: raw credit → per-artist links on the existing (left) side recGid: (t.recording && t.recording.id) || '', // existing (left) title → link to the MB recording len: t.length || (t.recording && t.recording.length) || null, }; }), })); } catch (e) { Log.cat('Recording').warn('dup tracklist fetch failed', e.message); return null; } } const dupFmtLen = ms => ms ? (Math.floor(ms / 60000) + ':' + String(Math.round(ms / 1000) % 60).padStart(2, '0')) : ''; // char-level LCS diff (same as the recordings detailed highlight) — only the // differing characters of each title light up. #186/#187 function dupCharDiff(a, b) { a = a || ''; b = b || ''; if (a.length > 200 || b.length > 200) return null; const n = a.length, m = b.length; const dp = []; for (let i = 0; i <= n; i++) dp.push(new Uint16Array(m + 1)); for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); const out = []; let i = 0, j = 0; const push = (t, ch) => { const l = out[out.length - 1]; if (l && l.t === t) l.s += ch; else out.push({ t, s: ch }); }; while (i < n && j < m) { if (a[i] === b[j]) { push(0, b[j]); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { push(-1, a[i]); i++; } else { push(1, b[j]); j++; } } while (i < n) push(-1, a[i++]); while (j < m) push(1, b[j++]); return out; } function dupDiffSide(segs, want) { return segs.map(s => s.t === 0 ? esc(s.s) : s.t === want ? '<span class="tc-dh">' + dhRun(s.s) + '</span>' : '').join(''); } // Char-diff with a cutoff (#187): when the two strings share too little, a // scattered per-character highlight reads as "almost the same" — misleading // when the titles are nothing alike. Below the cutoff we mark the WHOLE side // instead (like the original MB Release Seeding Helper), so a real mismatch is // obvious and clearly outweighs a small length gap. `want` = -1 left/existing, 1 right/seeded. const DUP_DIFF_CUTOFF = 0.4; function dupDiffOrWhole(a, b, want) { a = a || ''; b = b || ''; const self = want === -1 ? a : b; if (!self) return ''; const segs = dupCharDiff(a, b); if (!segs) return esc(self); const common = segs.reduce((n, s) => n + (s.t === 0 ? s.s.length : 0), 0); if (common / Math.max(a.length, b.length, 1) < DUP_DIFF_CUTOFF) return '<span class="tc-dh">' + esc(self) + '</span>'; // too different → whole line return dupDiffSide(segs, want); } // #358: do two strings differ "wholly" (below the char-diff cutoff)? Used to decide // whether the linked left-artist cell gets a whole-cell mismatch highlight. function dupWholeDiff(a, b) { a = a || ''; b = b || ''; if (a === b) return false; const segs = dupCharDiff(a, b); if (!segs) return true; const common = segs.reduce((n, s) => n + (s.t === 0 ? s.s.length : 0), 0); return common / Math.max(a.length, b.length, 1) < DUP_DIFF_CUTOFF; } // #480 (majkinetor): "we don't need subsecond comparison as that is not a // thing. Up to 3s difference should be very mild color and should go darker // from there" — then, on the ramp's span: "change it so it ramps up from 3 // to 30s". Shared alpha curve for both the recordings detailed-highlight // shade (lenShade) and this duplicates-panel one (dupLenShade) — they're // deliberately kept as mirrors of each other (#186). Under 1s: no shade at // all. 1-3s: a flat, mild tint — a gap this small is common and not worth // alarming over. 3-30s: ramps up toward full strength. 30s+: solid/full. function lenShadeAlpha(gapMs) { const g = Math.abs(gapMs || 0); if (g < 1000) return null; if (g >= 30000) return 1; if (g < 3000) return 0.12; return 0.12 + 0.88 * ((g - 3000) / 27000); } // #564: which text colour survives ON that tint. Below 0.55 alpha the shade is // translucent, so what the eye actually sees is the tint composited over the // page's own surface: pale pink on a light page, dark maroon on a dark one. // Both callers below only knew the light case and always went dark-red there, // which on a dark userstyle is dark red on dark maroon — the cell reads as // EMPTY until you drag-select it (majkinetor's screenshot on #564). These are // inline styles, so neither a userstyle nor our own [style*=background] sweep // can reach them; the theme has to be read right here. data-mbu-theme is kept // current by mbuTheme(), so this is an attribute read, not a layout read. function shadeIsDark() { try { return document.documentElement.getAttribute('data-mbu-theme') === 'dark'; } catch (e) { return false; } } // Mirror images of each other: 42% toward black on a light surface, 65% toward // white on a dark one. 65 rather than 42 because the worst case is the last // step before white takes over at 0.55, where the composite is already a // strong red — 65% is what keeps that pair above 4.5:1. function shadeSoftFg(r, g, b) { const f = shadeIsDark() ? (c => Math.round(c + (255 - c) * 0.65)) : (c => Math.round(c * 0.42)); return 'rgb(' + f(r) + ',' + f(g) + ',' + f(b) + ')'; } // graded length-gap shade — same as the recordings detailed highlight (#186). null under 1s. function dupLenShade(gapMs) { const a = lenShadeAlpha(gapMs); if (a === null) return null; if (a >= 1) return { bg: '#d32f2f', fg: '#fff' }; // light keeps its hand-picked #7a0000; only the dark branch is new (#564) return { bg: 'rgba(211,47,47,' + a.toFixed(2) + ')', fg: a >= 0.55 ? '#fff' : shadeIsDark() ? shadeSoftFg(211, 47, 47) : '#7a0000' }; } function seededAcHtml(names, whole) { const h = names.map(n => (n.gid ? `<a href="${ORIGIN}/artist/${n.gid}" target="_blank" rel="noopener">${esc(n.name)}</a>` : `<span class="tc-dd-unres" title="not matched to a MusicBrainz artist yet">${esc(n.name)}</span>`) + esc(n.join)).join(''); return whole ? `<span class="tc-dh">${h}</span>` : h; } function buildDupDetail(media, entered) { let gi = 0, rows = ''; // #626: decided for the whole tracklist, so every unmatched name is marked the same way const anyLinked = (entered || []).some(s => (s.names || []).some(n => n.gid)); media.forEach((med, mi) => { rows += `<tr class="tc-dd-medhdr"><td colspan="7">#${med.position || mi + 1}${med.format ? ' ' + esc(med.format) : ''}</td></tr>`; med.tracks.forEach(t => { const s = entered[gi]; gi++; const has = !!s; // a seeded counterpart at this position? // title + artist: per-character diff with a cutoff (#187 — a wholly // different title is marked whole, not as scattered characters). const titleExInner = has ? dupDiffOrWhole(t.title || '', s.title || '', -1) : esc(t.title || ''); // link the existing (left) title to its MB recording, keeping the char-diff highlight inside const titleEx = t.recGid ? `<a href="${ORIGIN}/recording/${t.recGid}" target="_blank" rel="noopener">${titleExInner}</a>` : titleExInner; const titleSe = has ? dupDiffOrWhole(t.title || '', s.title || '', 1) : ''; // #358: existing (left) artists as per-artist links; keep the whole-cell "differs" // cue when they diverge below the cutoff (partial diffs just show the links). const artExLinks = acLinksJson(t.artistAc); const artEx = has && dupWholeDiff(t.artist || '', s.artist || '') ? `<span class="tc-dh">${artExLinks}</span>` : artExLinks; // #626: once any seeded artist is matched, the seeded credits are links too — a matched // name links to its artist and an unmatched one stands out. Before that, the char diff. const artSe = !has ? '' : anyLinked && (s.names || []).length ? seededAcHtml(s.names, dupWholeDiff(t.artist || '', s.artist || '')) : dupDiffOrWhole(t.artist || '', s.artist || '', 1); // length: graded shade on both Len cells when the gap is real (#186) const sh = (has && t.len && s.len) ? dupLenShade(t.len - s.len) : null; const lenSt = sh ? ` style="background:${sh.bg};color:${sh.fg}"` : ''; // title-then-artist column order, to match the rest of the UI (#187) rows += `<tr class="tc-dd-row"><td class="tc-dd-pos">${esc(String(t.pos || gi))}</td>` + `<td>${titleEx}</td><td>${artEx}</td><td class="tc-dd-len"${lenSt}>${dupFmtLen(t.len)}</td>` + `<td>${titleSe}</td><td>${artSe}</td><td class="tc-dd-len"${lenSt}>${has ? dupFmtLen(s.len) : ''}</td></tr>`; }); }); return `<table class="tc-dd-tbl"><thead><tr><th>Pos</th><th>Release title</th><th>Release artist</th><th>Len</th><th>Seeded title</th><th>Seeded artist</th><th>Len</th></tr></thead><tbody>${rows}</tbody></table>`; } function dupStyle() { if (document.getElementById('tc-dup-style')) return; const s = document.createElement('style'); s.id = 'tc-dup-style'; s.textContent = ` #duplicates-tab .tc-dup-sim.tc-dd-open { outline: 2px solid #2a8f2a; outline-offset: -2px; } #duplicates-tab tr.tc-dup-detail > td { padding: 0; background: var(--mbu-bg-raised); border-bottom: 2px solid var(--mbu-border); } .tc-dd-tbl { width: 100%; border-collapse: collapse; font: 12px Arial, sans-serif; } .tc-dd-tbl th { text-align: left; font-weight: 600; color: var(--mbu-ok); border-bottom: 1px solid var(--mbu-border); padding: 3px 10px; } .tc-dd-tbl td { padding: 2px 10px; vertical-align: top; } .tc-dd-unres { color: var(--mbu-warn); text-decoration: underline dotted; text-underline-offset: 3px; } .tc-dd-tbl .tc-dd-medhdr td { font-weight: 700; background: var(--mbu-ok-bg); color: var(--mbu-ok); } .tc-dd-tbl .tc-dd-pos { color: var(--mbu-text-weak); text-align: right; width: 34px; } .tc-dd-tbl .tc-dd-len { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; width: 48px; } .tc-dd-tbl .tc-dd-x { color: var(--mbu-error); } .tc-dd-tbl .tc-dh { background: var(--mbu-error-bg); color: var(--mbu-error); border-radius: 2px; } .tc-dd-tbl a { color: inherit; text-decoration: underline; text-decoration-color: rgba(0,0,0,.28); text-underline-offset: 2px; } /* #358: left-artist links, readable on normal + mismatch cells */ .tc-dd-tbl a:hover { text-decoration-color: currentColor; } .tc-dd-tbl .tc-cf { display: inline-block; padding: 0 .5px; } #duplicates-tab .tc-dup-sim .tc-dup-tm { display: inline-block; margin-left: 4px; font-weight: 600; font-size: 10px; opacity: 0.92; white-space: nowrap; }`; document.head.appendChild(s); } function applyDuplicates() { const panel = document.getElementById('duplicates-tab'); if (!panel) return; const table = panel.querySelector('table.tbl'); if (!table) return; const thead = table.querySelector('thead tr'), tbody = table.querySelector('tbody'); if (!dupWant()) { // teardown panel.querySelectorAll('.tc-dup-sim, .tc-dup-th, .tc-dup-detail').forEach(e => e.remove()); if (tbody && tbody._tcDupObs) { tbody._tcDupObs.disconnect(); tbody._tcDupObs = null; } return; } dupStyle(); if (thead && !thead.querySelector('.tc-dup-th')) { const th = document.createElement('th'); th.className = 'tc-dup-th'; th.textContent = 'Similarity'; th.title = 'How closely each existing release matches the one you are entering — click a score for a track-by-track comparison.'; const row0 = tbody && [...tbody.querySelectorAll(':scope > tr')].find(tr => !tr.classList.contains('tc-dup-detail')); thead.insertBefore(th, thead.children[dupSimIndex(row0)] || null); } if (!tbody) return; augmentDupRows(tbody); if (!tbody._tcDupObs) { const obs = new MutationObserver(() => augmentDupRows(tbody)); obs.observe(tbody, { childList: true }); tbody._tcDupObs = obs; } } /* ═══════════════════════════════════════════════════════════════════════ RECORDING MATCHER (#119) — Recordings tab. WIP — Phase 1: in-page comparison view. Takes over the native recording-assignment table (toggle via the shared Original/Apollo button) and shows track vs recording (title / artist / length) with a confidence colour and per-field diff highlight. Drives MB's recording association (setRecordingValue / suggestedRecordings / updateRecordingTitle|Artist); row actions land in P2. ═══════════════════════════════════════════════════════════════════════ */ // artist-credit display text (the recording AC has no .text() helper, so build it from names) function acText(ac) { if (!ac) return ''; try { if (typeof ac.text === 'function') { const t = ac.text(); if (t) return t; } } catch (e) {} const names = u(ac.names) || []; return names.map(n => (u(n.name) || (n.artist && u(u(n.artist).name)) || '') + (u(n.joinPhrase) || '')).join(''); } // the ordered artist-entity gids behind an artist credit (ignores the credited-as display names) function acArtistGids(ac) { const names = u(ac && ac.names) || []; return names.map(n => n.artist && u(u(n.artist).gid)).filter(Boolean); } // true when a track and recording credit the SAME artist entities (same gids, same order) — i.e. any // text difference between them is only a "credited as" name, not a real different artist. #119 function sameArtistEntities(track, rec) { const a = acArtistGids(u(track.artistCredit)), b = acArtistGids(rec ? u(rec.artistCredit) : null); return a.length > 0 && a.length === b.length && a.every((g, i) => g === b[i]); } // a length gap up to SETTINGS.recLenTol seconds (default 5) is treated as identical (MB lengths jitter). // Measured in WHOLE on-screen seconds so rows that LOOK the same get the same verdict. #119 function recLenTolMs() { return (SETTINGS.recLenTol != null ? SETTINGS.recLenTol : 5) * 1000; } function recLenGap(a, b) { if (!a || !b) return 0; const gap = Math.abs(Math.round(a / 1000) - Math.round(b / 1000)) * 1000; // gap in whole displayed seconds return gap <= recLenTolMs() ? 0 : gap; } // shared text normalisation for matching: case/accents (Ignore casing) + punctuation/symbols (Ignore punctuation) function recFold(s) { let t = SETTINGS.recIgnoreCase !== false ? fold(s) : String(s || ''); if (SETTINGS.recIgnorePunct) t = t.replace(/&/g, ' and ').replace(/[^\p{L}\p{N}]+/gu, ' ').replace(/\s+/g, ' ').trim(); return t; } function recNameEq(a, b) { return recFold(a) === recFold(b); } // exact after normalisation (used for artist) // Levenshtein edit distance — backs the "Title tolerance" (allow up to N differing characters) function recLev(a, b) { if (a === b) return 0; if (!a) return b.length; if (!b) return a.length; const n = b.length; let prev = []; for (let j = 0; j <= n; j++) prev[j] = j; for (let i = 1; i <= a.length; i++) { const cur = [i]; for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = cur; } return prev[n]; } // title equality: normalised, then within the "Title tolerance" edit distance (0 = exact) function recTitleEq(a, b) { const x = recFold(a), y = recFold(b); if (x === y) return true; const tol = SETTINGS.recTitleTol || 0; return tol > 0 && recLev(x, y) <= tol; } // an IDEAL match: title/length/artist match WITHOUT any relaxation option (ignore-punctuation, title or // length tolerance) — only case/accent folding. Preferred by auto-match and shown distinctly. #119 // artist-entity equality for the flattened {artistGids} carried on ctx / candidate data: // same gids in order means any text difference is only a "credited as" name, not a real // artist mismatch — so it must NOT drag the match confidence down (a *Credited as* value // doesn't influence matching). This keeps auto-match consistent with the table's exact dot. #190 function sameAcEntities(a, b) { const x = a && a.artistGids, y = b && b.artistGids; return !!(x && y && x.length > 0 && x.length === y.length && x.every((g, i) => g === y[i])); } // EXACT means the displayed strings are identical — only Unicode-normalisation // (NFC) is folded, never case/accents/spacing. A casing-only (or accent-only) // difference is NOT exact: it's a real visible difference that native offers to // copy, so it belongs in the tolerance band, not the blue "exact" one. #197 const literalEq = (a, b) => String(a == null ? '' : a).normalize('NFC') === String(b == null ? '' : b).normalize('NFC'); function recExactMatch(data, ctx) { if (!ctx) return false; const titleEq = !data.name || !ctx.title || literalEq(data.name, ctx.title); // a same-entity credited-as is still exact (#190) — only the artist *text* // is no longer case/accent-folded into exactness (#197) const artistEq = !data.artist || !ctx.artist || sameAcEntities(data, ctx) || literalEq(data.artist, ctx.artist); const lenEq = !data.length || !ctx.length || Math.round(data.length / 1000) === Math.round(ctx.length / 1000); return titleEq && artistEq && lenEq; } // per-field diff between a track and its recording: title/artist via MB's own flags, length in ms function recFieldDiffs(track, rec) { let title = false, artist = false; try { if (typeof track.titleDiffersFromRecording === 'function') title = !!track.titleDiffersFromRecording(); } catch (e) {} try { if (typeof track.artistDiffersFromRecording === 'function') artist = !!track.artistDiffersFromRecording(); } catch (e) {} // a "credited as" (same artist entity, different credit name) is NOT a real artist mismatch — don't // let it drag the match confidence down; it's still the right recording. #119 if (artist && sameArtistEntities(track, rec)) artist = false; // a casing-only (cosmetic) difference is not a real mismatch when "ignore casing" is on if (title && recTitleEq(u(track.name), rec ? u(rec.name) : '')) title = false; if (artist && recNameEq(acText(u(track.artistCredit)), acText(rec ? u(rec.artistCredit) : null))) artist = false; const lenDiff = recLenGap(u(track.length), rec ? u(rec.length) : null); return { title, artist, lenDiff, len: lenDiff > 0 }; } // Native MB's raw per-field "differs from recording" flag, UNfiltered by // Apollo's tolerance / ignore-casing settings. This is what drives the // native update checkboxes, so proxying it (rather than the cosmetically // filtered recFieldDiffs) is the only way to be sure the copy is offered // exactly when native offers it — e.g. casing-only title diffs. #146 function nativeDiffFlag(track, which) { try { const fn = which === 'title' ? track.titleDiffersFromRecording : track.artistDiffersFromRecording; return typeof fn === 'function' ? !!fn.call(track) : false; } catch (e) { return false; } } // Confidence ported from "Quick Recording Match": null = perfect (green), else low / vlow / xlow // (yellow / orange / red), graded by how many fields differ and by how far the length is off. // single source of truth for the confidence colors — used by the row dots AND the Cutoff picker const CONF_COLOR = { exact: '#2f6fd6', tolerance: '#86c686', near: '#fff176', low: '#ffb74d', vlow: '#d32f2f' }; const REC_CONF = { // the colored bands below exact/tolerance (keys = display names) near: { c: CONF_COLOR.near, label: 'near' }, low: { c: CONF_COLOR.low, label: 'low' }, vlow: { c: CONF_COLOR.vlow, label: 'very low' }, }; function recConfidence(track, rec, d) { if (!rec || !u(rec.gid)) return null; d = d || recFieldDiffs(track, rec); const diffs = []; if (d.title) diffs.push('title'); if (d.artist) diffs.push('artist'); if (d.lenDiff > 0) diffs.push('length ' + Math.round(d.lenDiff / 1000) + 's'); let level = null; if (diffs.length >= 3 && d.lenDiff > 10000) level = 'vlow'; else if (d.lenDiff > 15000) level = 'low'; else if (diffs.length >= 2 && d.lenDiff <= 15000) level = 'low'; else if (diffs.length === 1 || d.lenDiff > 3000) level = 'near'; if (!level) return null; return { level, color: REC_CONF[level].c, label: REC_CONF[level].label, diffs }; } const fmtMs = ms => (ms || ms === 0) ? (Math.floor(Math.round(ms / 1000) / 60) + ':' + String(Math.round(ms / 1000) % 60).padStart(2, '0')) : ''; // artist-credit rendered as links to each artist's page (joined by their join // phrases). `withDisamb` appends each artist's disambiguation (when the page // model carries it) — used in the picker header so a wrong artist is obvious. #195 function acLinks(ac, withDisamb) { const names = u(ac && ac.names) || []; if (!names.length) return ''; return names.map((n, i) => { const art = n.artist ? u(n.artist) : null; const nm = u(n.name) || (art && u(art.name)) || ''; const gid = art && u(art.gid); const cmt = (art && u(art.comment)) || getDisamb(gid); // KO model lacks it for fresh picks → cache fallback #195 const dis = withDisamb && cmt ? ' <span class="tc-rpk-adis">(' + esc(cmt) + ')</span>' : ''; const nx = names[i + 1]; const nxa = nx && (nx.artist ? u(nx.artist) : null); const nxNm = nx ? (u(nx.name) || (nxa && u(nxa.name)) || '') : ''; return (gid ? '<a href="' + ORIGIN + '/artist/' + esc(gid) + '" target="_blank" rel="noopener">' + dhRun(nm) + '</a>' : dhRun(nm)) + dis + joinMark(u(n.joinPhrase) || '', i === names.length - 1, nm, nxNm); }).join(''); } // flat {name, gid, comment, join} per artist of a credit — lets the table diff // artists by ENTITY (not raw text) while keeping links + disambiguations. #186 #195 function acData(ac) { return (u(ac && ac.names) || []).map(n => { const art = n.artist ? u(n.artist) : null; const gid = (art && u(art.gid)) || null; return { name: u(n.name) || (art && u(art.name)) || '', gid, comment: (art && u(art.comment)) || getDisamb(gid), join: u(n.joinPhrase) || '' }; }); } // render one credit as links (+ disambiguations), boxing the artists whose // ENTITY isn't present on the other side — the detailed-highlight equivalent // for artists that keeps links instead of char-diffing plain text (#186: the // original highlighted the differing artist; links used to drop here). A // credited-as (same entity, different text) is NOT boxed — it's the same // artist, just a different credit. function acLinksDiff(self, other, withDisamb) { const key = a => a.gid || ('name:' + a.name.toLowerCase().trim()); const otherArr = other || []; const otherSet = new Set(otherArr.map(key)); // key → the other side's credited-as text, so a SAME-entity credit whose *text* // differs (a credited-as change — same artist, different phrasing) still gets the // per-character highlight (#444). It doesn't affect matching (credited-as never // does), it just makes the difference visible like a title diff does. const otherName = new Map(); otherArr.forEach(a => { const k = key(a); if (!otherName.has(k)) otherName.set(k, a.name || ''); }); return (self || []).map((a, i) => { const sameEntity = otherSet.has(key(a)); let nm; if (sameEntity) { const on = otherName.get(key(a)); const segs = (on != null && (a.name || '') !== on) ? charDiff(a.name || '', on) : null; // #444 credited-as text diff nm = segs ? diffSide(segs, -1) : dhRun(a.name); // -1 = this side's unique chars (self is always "this" column) } else { nm = dhRun(a.name); } const inner = a.gid ? '<a href="' + ORIGIN + '/artist/' + esc(a.gid) + '" target="_blank" rel="noopener">' + nm + '</a>' : nm; const boxed = sameEntity ? inner : '<span class="tc-dh">' + inner + '</span>'; // a DIFFERENT entity is boxed whole; a same-entity credited-as is char-diffed above const dis = withDisamb && a.comment ? ' <span class="tc-rec-disamb">(' + esc(a.comment) + ')</span>' : ''; return boxed + dis + joinMark(a.join, i === self.length - 1, a.name, self[i + 1] && self[i + 1].name); }).join(''); } // #186 detailed highlighting — char-level LCS diff of two short strings. // Returns segments [{t,s}] with t: 0 common · -1 only-in-a · 1 only-in-b, or null // when either string is too long (caller falls back to the flat highlight). function charDiff(a, b) { a = a || ''; b = b || ''; if (a.length > 200 || b.length > 200) return null; const n = a.length, m = b.length; const dp = []; for (let i = 0; i <= n; i++) dp.push(new Uint16Array(m + 1)); for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); const out = []; let i = 0, j = 0; const push = (t, ch) => { const l = out[out.length - 1]; if (l && l.t === t) l.s += ch; else out.push({ t, s: ch }); }; while (i < n && j < m) { if (a[i] === b[j]) { push(0, b[j]); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { push(-1, a[i]); i++; } else { push(1, b[j]); j++; } } while (i < n) push(-1, a[i++]); while (j < m) push(1, b[j++]); return out; } // render one side of a diff: common runs stay plain, this side's unique chars // (want = -1 track/left, 1 recording/right) are wrapped as .tc-dh. // #203: confusable / invisible characters. These are marked with their Unicode // name + codepoint (tooltip) and enlarged, with invisibles drawn as a visible // glyph — so a curly quote, an en-dash, or a no-break space is obvious wherever a // title/artist is shown (not just inside a detailed-highlight diff), not a guess. const CONFUSABLE = {}; // Built from codepoints (NOT literal chars) so invisible keys can't be stripped/ // normalised in source. vis glyphs: ␣ ␣, ∅ ∅, ⇥ ⇥, · ·. [ [0x0027, 'apostrophe (straight)'], [0x2019, 'right single quote / curly apostrophe'], [0x2018, 'left single quote'], [0x0060, 'grave accent / backtick'], [0x00B4, 'acute accent'], [0x02BC, 'modifier letter apostrophe'], [0x0022, 'quotation mark (straight)'], [0x201C, 'left double quote'], [0x201D, 'right double quote'], [0x2032, 'prime'], [0x2033, 'double prime'], // #369 NOT U+002D hyphen-minus — it's the correct, standard hyphen in MB titles ("Cat-O-Nine", // "Afro-Jamaican"), so enlarging+bolding every one of them just mangled normal text. Its LOOK-ALIKES // (real hyphen, non-breaking hyphen, dashes, minus) stay flagged so an odd one still stands out. [0x2010, 'hyphen'], [0x2011, 'non-breaking hyphen'], [0x2012, 'figure dash'], [0x2013, 'en dash'], [0x2014, 'em dash'], [0x2015, 'horizontal bar'], [0x2212, 'minus sign'], [0x00AD, 'soft hyphen', '·'], [0x2026, 'horizontal ellipsis'], [0x00A0, 'no-break space', '␣'], [0x202F, 'narrow no-break space', '␣'], [0x2009, 'thin space', '␣'], [0x2002, 'en space', '␣'], [0x2003, 'em space', '␣'], [0x3000, 'ideographic space', '␣'], [0x200B, 'zero-width space', '∅'], [0x200C, 'zero-width non-joiner', '∅'], [0x200D, 'zero-width joiner', '∅'], [0xFEFF, 'byte-order mark', '∅'], [0x0009, 'tab', '⇥'], ].forEach(([cp, n, vis]) => { CONFUSABLE[String.fromCodePoint(cp)] = { n, c: cp.toString(16).toUpperCase().padStart(4, '0'), vis }; }); // Mark EVERY confusable / invisible character, wherever a title/artist is shown // (not just inside a detailed-highlight diff): each gets a tooltip naming it + its // codepoint, invisibles draw a visible glyph, and all are enlarged by the Appearance // "punctuation size" setting (px). Straight ' " are marked too (MB style prefers curly), but a plain // hyphen-minus is NOT — it's the correct char (#369). px = 0 disables it (master switch). // #443: the confusable / invisible marking (and the missing-space markers) belong to // **detailed highlighting** — they must show whenever it's on, so an invisible char (a // no-break space, a missing space) can never hide. "Enlarge punctuation" (px) is only // the enlargement amount, NOT a master switch: at 0px the markers are still drawn // (invisibles as their glyph, all named on hover), just not enlarged. Before, 0px // silently disabled everything — which surprised (a missing space vanished, #443). const dhMark = () => (SETTINGS.recPunctSize | 0) > 0 || !!SETTINGS.recDetailedHl; function dhRun(str) { if (!dhMark()) return esc(String(str)); const px = SETTINGS.recPunctSize | 0; const st = px > 0 ? ' style="font-size:calc(1em + ' + px + 'px);font-weight:700;line-height:1"' : ''; // px enlarges only let out = ''; for (const ch of String(str)) { const cf = CONFUSABLE[ch]; if (!cf) { out += esc(ch); continue; } // invisibles carry a persistent highlight (like the join ␣) so they're obvious at any size const cls = 'tc-cf' + (cf.vis ? ' tc-cf-inv' : ''); out += '<span class="' + cls + '"' + st + ' title="' + esc(cf.n + ' (U+' + cf.c + ')') + '">' + esc(cf.vis || ch) + '</span>'; } return out; } /* #601 (majkinetor): "When clicking on a track, cursor should always be on the place that is clicked. Current behavior is that it rarely does that and mostly moves to start/end… requires one to need 2 clicks to position the cursor instead 1." The #203 rich display is a <span> occupying the input's slot; while resting the real input is 1x1 with pointer-events:none, so a click can only ever land on the span. The handler did `e.preventDefault(); tin.focus()` — and preventDefault is exactly what stops the browser placing a caret, so focus() dropped it wherever the input last had it (start, or end). The second click then worked, because by then the input was the thing under the pointer. preventDefault has to stay (without it the span keeps its own text selection), so the caret is placed by hand at the character actually clicked. This is the inverse of dhRun: the span's text is the input's text unit for unit, because dhRun emits each source character either as escaped text (identical characters) or as a one-character <span> stand-in, and every entry in CONFUSABLE is a single UTF-16 unit with a single-char `vis`. So walking text nodes gives the offset with no bookkeeping. */ function dispCaretIndex(disp, input, x, y) { const val = input.value || ''; // empty title: the span shows its own "title…" placeholder, whose text is // not the value — anywhere in it means offset 0. if (!val) return 0; let node = null, offset = 0; if (document.caretPositionFromPoint) { // Firefox const p = document.caretPositionFromPoint(x, y); if (p) { node = p.offsetNode; offset = p.offset; } } else if (document.caretRangeFromPoint) { // Chromium / WebKit const r = document.caretRangeFromPoint(x, y); if (r) { node = r.startContainer; offset = r.startOffset; } } // clicked past the end of the text (the span is flex:1, so there is usually // empty space to its right) — that means the end, which is what a text field // does for a click past its content. if (!node || !disp.contains(node)) return val.length; let idx = 0, done = false; const walk = (n) => { if (done) return; if (n.nodeType === 3) { if (n === node) { idx += Math.min(offset, n.nodeValue.length); done = true; return; } idx += n.nodeValue.length; return; } // a caret resolved to an ELEMENT: its offset counts child nodes, not chars if (n === node) { for (let i = 0; i < offset && i < n.childNodes.length; i++) walk(n.childNodes[i]); done = true; return; } for (const c of n.childNodes) { walk(c); if (done) return; } }; walk(disp); // clamped because a guess-case PREVIEW can be on screen while the input is // about to be restored to the real title (see `restore` on focus), and the // two can differ in length. return Math.max(0, Math.min(idx, val.length)); } // #208 join-phrase spacing visibility (Recording view). A join phrase between // two artists should have a space on BOTH sides (" & ", " feat. ", …). Mark // where one is MISSING with a highlighted ␣ — so `Gandhabba &Render` reads as // `Gandhabba &␣Render`. An empty phrase between two artists (no join at all) // becomes `␣?␣`. Uses the same px>0 master switch as the #203 marking. `isLast` // = the final artist of a credit, whose empty join is correct (not flagged). // A join needs a TRAILING space (the next artist mustn't be glued on) and, // for most phrases, a LEADING one too (" & ", " feat. "). The exception: // a comma/semicolon attaches to the PREVIOUS name, so ", " is correct with // no leading space — don't flag it (chaban, #208). function joinNeed(j) { return { lead: !/^\s/.test(j) && !/^\s*[,;]/.test(j), trail: !/\s$/.test(j) }; } // CJK scripts (Japanese, Chinese, Korean) don't space their joins — e.g. と / // 、 in Japanese — so a no-space join is correct there. Detects Hiragana, // Katakana, Kanji, Hangul, CJK punctuation and full-width forms (chaban, #208). const CJK_RE = /[ -〿぀-ヿ㐀-䶿一-鿿豈-﫿︰-﹏＀-￯가-힯]/; const isCjk = s => CJK_RE.test(String(s == null ? '' : s)); function joinMark(join, isLast, prevName, nextName) { const j = String(join == null ? '' : join); if (!dhMark()) return esc(j); // #443: gated by detailed highlighting, not the px size if (isLast) return dhRun(j); // trailing artist — empty join is fine if (j === '') return '<span class="tc-jp" title="missing join phrase — no separator between these artists">␣?␣</span>'; const mk = side => '<span class="tc-jp" title="missing ' + side + ' space around the join phrase">␣</span>'; const need = joinNeed(j); if (isCjk(j) || isCjk(prevName) || isCjk(nextName)) { need.lead = false; need.trail = false; } // CJK: no space convention return (need.lead ? mk('leading') : '') + dhRun(j) + (need.trail ? mk('trailing') : ''); } function diffSide(segs, want) { return segs.map(s => s.t === 0 ? esc(s.s) : s.t === want ? '<span class="tc-dh">' + dhRun(s.s) + '</span>' : '').join(''); } // graded length-gap shade (#186): null under 1s, scaling red 1–5s, solid red ≥5s. // the configurable detailed-highlight colour as {r,g,b} (#186 — Matching → highlight colour) function hlRgb() { const h = String(SETTINGS.recHlColor || '#e53935').replace('#', ''); const n = h.length === 3 ? h.split('').map(c => c + c).join('') : h; const v = parseInt(n, 16); return (n.length === 6 && Number.isFinite(v)) ? { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 } : { r: 229, g: 57, b: 53 }; } function applyHlColor() { try { document.documentElement.style.setProperty('--tc-hl', SETTINGS.recHlColor || '#e53935'); } catch (e) {} } function lenShade(gapMs) { const a = lenShadeAlpha(gapMs); if (a === null) return null; // #480: shared curve with dupLenShade const { r, g: gg, b } = hlRgb(); if (a >= 1) return { bg: `rgb(${r},${gg},${b})`, fg: '#fff' }; return { bg: `rgba(${r},${gg},${b},${a.toFixed(2)})`, fg: a >= 0.55 ? '#fff' : shadeSoftFg(r, gg, b) }; // #564: theme-aware, was light-only } // read each track's recording association + the data needed to compare them side by side function readRecordings() { const out = []; mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const rec = u(t.recording); const sugg = (typeof t.suggestedRecordings === 'function' ? (u(t.suggestedRecordings) || []) : []); const diffs = rec ? recFieldDiffs(t, rec) : null; // ideal/exact match: title + length match with NO relaxation option used (fold only, no punctuation/ // edit-distance/length tolerance). A credited-as artist (same entity) still counts as exact. #119 // EXACT = displayed title/artist identical (literal, not case/accent-folded — // a casing-only diff is tolerance, #197) and length displays the same. A // credited-as artist (same entity) still counts as exact. #119 #190 const exact = !!(rec && literalEq(u(t.name), u(rec.name)) && (!u(t.length) || !u(rec.length) || Math.round(u(t.length) / 1000) === Math.round(u(rec.length) / 1000)) && (sameArtistEntities(t, rec) || literalEq(acText(u(t.artistCredit)), acText(u(rec.artistCredit))))); // which fields a green (within-tolerance) match still differs on — for the dot tooltip const tolDiffs = []; if (rec) { if (fold(u(t.name)) !== fold(u(rec.name))) tolDiffs.push('title'); if (u(t.length) && u(rec.length) && Math.round(u(t.length) / 1000) !== Math.round(u(rec.length) / 1000)) tolDiffs.push('length ' + Math.round(Math.abs(u(t.length) - u(rec.length)) / 1000) + 's'); if (!(sameArtistEntities(t, rec) || fold(acText(u(t.artistCredit))) === fold(acText(u(rec.artistCredit))))) tolDiffs.push('artist'); } const isNew = typeof t.hasNewRecording === 'function' ? !!u(t.hasNewRecording) : false; // raw native diff flags (proxy the native update checkboxes), #146 const rawTitleDiff = !!(rec && !isNew && nativeDiffFlag(t, 'title')); const rawArtistDiff = !!(rec && !isNew && nativeDiffFlag(t, 'artist')); out.push({ exact, tolDiffs, mi, ti, number: u(t.number), title: u(t.name), trackArtist: acText(u(t.artistCredit)), trackArtistHtml: acLinks(u(t.artistCredit), true), trackAc: acData(u(t.artistCredit)), trackLen: u(t.length), isNew, recGid: rec ? u(rec.gid) : null, recName: rec ? u(rec.name) : null, recComment: rec ? (u(rec.comment) || '') : '', recArtist: rec ? acText(u(rec.artistCredit)) : null, recArtistHtml: rec ? acLinks(u(rec.artistCredit), true) : '', recAc: rec ? acData(u(rec.artistCredit)) : [], recLen: rec ? u(rec.length) : null, recVideo: rec && !isNew ? !!u(rec.video) : false, // #655: a track set to a new recording has no video // #303 — native recordings table shows a video marker; mirror it here // submit-flags: when on, the recording's title/artist will be overwritten with the track's on submit copyTitle: typeof t.updateRecordingTitle === 'function' ? !!u(t.updateRecordingTitle) : false, copyArtist: typeof t.updateRecordingArtist === 'function' ? !!u(t.updateRecordingArtist) : false, rawTitleDiff, rawArtistDiff, suggCount: sugg.length, diffs, conf: rec ? recConfidence(t, rec, diffs) : null, }); })); return out; } let _recStyled = false; function recStyle() { if (_recStyled) return; _recStyled = true; const s = document.createElement('style'); s.textContent = [ '#tc-recwrap{margin:4px 0 12px;font:13px/1.4 Arial}', // #369 match the Tracklist font (was system-ui) so curly quotes/apostrophes render the same in both tables, not as system-ui prime-like glyphs '#tc-recwrap .tc-recbar{display:flex;align-items:center;gap:8px;padding:2px 2px 8px;font-weight:600}', '#tc-recwrap .tc-recbar .tc-ico{vertical-align:-5px}', '#tc-recwrap .tc-recwarn{color:var(--mbu-error);font-weight:600}#tc-recwrap .tc-recwarn:hover{text-decoration:underline;cursor:pointer}', // consistent with the Tracklist tab toolbar (#tc-bar / .tc-btn): same bar spacing, button look, inputs. // sticky at the top while the table scrolls (mirrors #tc-mirror-wrap) so it stays reachable on big releases. '#tc-recwrap .tc-rec-tb{display:flex;align-items:center;gap:8px;padding:6px 4px;flex-wrap:wrap;position:sticky;top:0;z-index:50;background:var(--mbu-bg);border-bottom:1px solid var(--mbu-accent);box-shadow:0 3px 8px rgba(40,20,80,.07)}', '#tc-recwrap .tc-rec-tb .sp{flex:1}', // flex spacer: Clear hugs the left, the Match cluster hugs the right (mirrors #tc-bar) // flat buttons that match the Tracklist tab's .tc-btn (transparent until hover); Match keeps the bold-purple primary look '#tc-recwrap .tc-rec-tb button{padding:4px 11px;border:1px solid transparent;border-radius:3px;background:transparent;cursor:pointer;font:13px Arial;color:var(--mbu-text)}#tc-recwrap .tc-rec-tb button:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-border)}', '#tc-recwrap .tc-rec-split{display:inline-flex}#tc-recwrap .tc-rec-revcaret{padding:4px 6px;color:var(--mbu-accent-text)}', '#tc-recwrap .tc-rec-tb button.primary{color:var(--mbu-accent-text);font-weight:bold}#tc-recwrap .tc-rec-tb button.primary:hover{background:linear-gradient(#7a52df,#5f3ec0);color:#fff;border-color:var(--mbu-accent)}', '#tc-recwrap .tc-rec-tbl{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text-dim);font-family:"Bitstream Vera Sans",Verdana,Arial,sans-serif}', '#tc-recwrap .tc-rec-tbl b{color:var(--mbu-accent-deep-text)}', // bold label word in MB purple, matching #tc-bar b on the Tracklist toolbar '#tc-recwrap .tc-cutoff{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--mbu-border);border-radius:14px;padding:2px 9px;cursor:pointer;font:12px Arial;background:var(--mbu-bg);user-select:none}', '#tc-recwrap .tc-cutoff:hover{border-color:var(--mbu-border)}', '.tc-cutoff-dot,.tc-cutoff-menu .dot{width:12px;height:12px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,.18);flex:none}', '#tc-recwrap .tc-cutoff-caret{color:var(--mbu-text-weak);font-size:10px}', '.tc-cutoff-menu{position:fixed;z-index:100002;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:7px;box-shadow:0 8px 24px rgba(40,20,80,.22);padding:4px;font:13px Arial}', '.tc-cutoff-menu .mi{display:flex;align-items:center;gap:9px;padding:5px 11px 5px 8px;border-radius:5px;cursor:pointer;white-space:nowrap;color:var(--mbu-text)}', '.tc-cutoff-menu .mi:hover,.tc-cutoff-menu .mi.sel{background:var(--mbu-bg-hover)}', '#tc-recwrap .tc-rec-amstatus{color:var(--mbu-accent-text);font-size:12px;flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;padding-right:4px}', '.tc-rectbl .tc-recname{position:relative}', '.tc-rectbl .tc-rec-rev{position:absolute;right:28px;top:50%;transform:translateY(-50%);border:none;background:var(--mbu-bg);cursor:pointer;color:var(--mbu-accent-text);font-size:15px;line-height:1;visibility:hidden;padding:1px 4px;border-radius:3px}', '.tc-rectbl tr.tc-recrow:hover .tc-rec-rev{visibility:visible}.tc-rectbl .tc-rec-rev:hover{color:var(--mbu-accent-text);background:var(--mbu-bg-hover)}', 'table.tc-rectbl{border-collapse:collapse;width:100%;background:var(--mbu-bg);table-layout:fixed}', '.tc-rectbl td{overflow-wrap:anywhere}', '.tc-rectbl th{text-align:left;font-size:11px;color:var(--mbu-text-dim);border-bottom:1px solid var(--mbu-border);padding:4px 7px;white-space:nowrap}', '.tc-rectbl td{padding:4px 7px;vertical-align:top}', '.tc-rectbl td.tc-rdrag{box-shadow:inset 0 0 0 2px var(--mbu-accent)}', // #635 cells a right-drag touched '.tc-rectbl.gridrows td{border-bottom:1px solid var(--mbu-border)}', // density layouts (same names as the Tracklist tab): compact tighter, cozy airier, normal = default '.tc-rectbl.compact th{padding:2px 7px}.tc-rectbl.compact td{padding:1px 7px}', '.tc-rectbl.cozy th{padding:7px 7px}.tc-rectbl.cozy td{padding:8px 7px}', // grid option: column separators on both tables '.tc-rectbl.gridcols td,.tc-rectbl.gridcols th{border-right:1px solid var(--mbu-border)}.tc-rectbl.gridcols td:last-child,.tc-rectbl.gridcols th:last-child{border-right:none}', '.tc-rectbl.alt tbody tr.tc-recrow:nth-of-type(even) td:not(.tc-diff):not(.tc-copy){background:var(--mbu-bg-raised)}', // zebra skips highlighted cells so their colour always shows', // #376 pending-edit recordings — gold tint + gold title (!important to beat the higher-specificity zebra) /* #564 (majkinetor): "Apollo pending changes on Recordings could be more readable (currently blue over redish)". We painted the background and left the text to MusicBrainz — whose artist links are its own bright blue. On a light page a pale cream tint under that blue is fine; on a dark one the tint is a muddy amber and the blue sits on top of it unreadably. Own both halves: the cell takes the theme's text colour and so do the links inside it, keeping their underline so they still read as links. Contrast is then text-on-background by construction, in either theme, whatever colour MusicBrainz would have used. */ '.tc-rectbl tr.tc-recrow.tc-rec-pending td:nth-child(2){background:var(--mbu-warn-bg)!important;border-radius:3px;color:var(--mbu-text)}', // #376 title pending '.tc-rectbl tr.tc-recrow.tc-art-pending td:nth-child(3){background:var(--mbu-warn-bg)!important;border-radius:3px;color:var(--mbu-text)}', // #376 artist pending '.tc-rectbl tr.tc-recrow.tc-rec-pending td:nth-child(2) a,.tc-rectbl tr.tc-recrow.tc-art-pending td:nth-child(3) a{color:var(--mbu-text);text-decoration:underline;text-decoration-color:var(--mbu-warn)}', '.tc-rectbl tr.tc-recmed td{background:var(--mbu-bg-raised);font-weight:600;color:var(--mbu-accent-deep-text)}', // collapsed-medium expand control (#149) '.tc-rectbl tr.tc-recmed-coll td{padding:0}', '.tc-rectbl .tc-recmed-exp{width:100%;text-align:left;border:none;background:var(--mbu-bg-raised);color:var(--mbu-accent-deep-text);font:600 13px Arial;padding:7px 10px;cursor:pointer}', '.tc-rectbl .tc-recmed-exp:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)}', '.tc-rectbl .tc-recmed-exp.loading{cursor:default;opacity:.7}', '.tc-rectbl tr.tc-recchanged td:first-child{box-shadow:inset 4px 0 0 var(--mbu-accent-text)}', // changed-row marker, like the Tracklist tab', '.tc-rectbl .c-n{color:var(--mbu-text-weak);text-align:right;width:34px;min-width:34px;white-space:nowrap}', '.tc-rectbl .c-sep{width:20px;text-align:center}', // group header (Track | Recording) + a vertical divider down the middle so the two halves read clearly '.tc-rectbl .tc-grouphd th{padding:5px 7px 3px;border-bottom:none;font-size:11px;font-weight:700;letter-spacing:.04em}', '.tc-rectbl .tc-grp{text-align:center;border-radius:5px 5px 0 0}', '.tc-rectbl .tc-grp-l{background:var(--mbu-bg-raised);color:var(--mbu-info)}', '.tc-rectbl .tc-grp-r{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)}', '.tc-rectbl th.c-sep,.tc-rectbl td.c-sep{border-left:1px solid var(--mbu-accent);border-right:1px solid var(--mbu-accent)}', '.tc-rectbl .c-len{color:var(--mbu-text-dim);white-space:nowrap;font-variant-numeric:tabular-nums;text-align:right;width:48px}', '.tc-rectbl .c-sugg{color:var(--mbu-accent-text);text-align:center;width:34px}', '.tc-rectbl .tc-tkt{font-weight:600}', '.tc-rectbl .tc-rec-none{color:var(--mbu-error)}.tc-rectbl .tc-rec-new{color:var(--mbu-ok)}', '.tc-rectbl td.tc-diff{background:var(--mbu-bg-raised);color:var(--mbu-error)}', '.tc-rectbl .tc-dh{background:var(--tc-hl,#e53935);color:#fff;border-radius:2px;padding:0 1px}', // #186 a differing character run — colour configurable (Matching → highlight colour) /* #564 (majkinetor): "Apollo pending changes on Recordings could be more readable (currently blue over redish)" — and, decisively, a DevTools capture of `<a … style="color: yellow">` inside `span.tc-dh` looking right. It was never the pending tint: it is the DIFF highlight. The span sets its own white foreground, but the run being highlighted is usually an artist LINK, and `.tc-rectbl td a` (0,1,2) matches that <a> directly where `.tc-rectbl .tc-dh` only reaches it by inheritance — so the link kept its --mbu-info blue and sat on the red wash. A link inside the highlight takes the highlight's own colour. !important because the pending rule below is deliberately more specific, and a row that is both pending AND diff-highlighted must not end up with body text on a red background. */ '.tc-rectbl .tc-dh a{color:inherit!important;text-decoration:underline;text-underline-offset:2px}', '.tc-cf{display:inline-block;padding:0 .5px}', // #203 confusable/invisible changed char — enlarged inline (Appearance) + hover tooltip names the codepoint '.tc-cf-inv{background:var(--tc-hl,#e53935);color:#fff;border-radius:2px;padding:0 1px}', // #443 invisible/whitespace char — always visible (glyph + wash), even at 0px enlargement '.tc-jp{display:inline-block;background:var(--tc-hl,#e53935);color:#fff;border-radius:2px;padding:0 1px;font-weight:700}', // #208 missing space (␣) / missing join phrase (␣?␣) around a join phrase', '.tc-rectbl td.tc-dh-len{font-weight:600;border-radius:2px}', // #186 graded length-gap shade (inline bg)', '.tc-rectbl td.tc-copy{background:var(--mbu-ok-bg);color:var(--mbu-ok);font-style:italic}', // flagged to copy the track value on submit '.tc-rectbl .tc-rec-orig{text-decoration:line-through;opacity:.55;font-style:normal;font-weight:400}', // recording original kept beside the → preview #146 '.tc-rectbl .tc-rec-disamb{color:var(--mbu-text-weak);font-weight:400}', // recording disambiguation, grey like native #144 '.tc-rec-video{display:inline-flex;vertical-align:-2px;color:var(--mbu-accent-text);margin-left:1px}', // recording video marker (table + picker) #303 '.tc-rec-video.lead{margin-left:0;margin-right:4px}', // #584 — marker leads the title instead of trailing it '.tc-rec-video svg{display:block}', '.tc-rectbl .tc-rpk-adis{color:var(--mbu-text-weak);font-weight:400}', // artist disambiguation in the table — grey like native, not black (tc-rpk-adis base style is picker-scoped) #186 '.tc-rectbl td.tc-clickable{cursor:pointer}', '.tc-rectbl td.tc-clickable:hover{outline:1px solid #9cc6ab;outline-offset:-1px}', '.tc-rectbl td a{color:var(--mbu-info);text-decoration:none}.tc-rectbl td a:hover{text-decoration:underline}', '.tc-rectbl .tc-recname{font-weight:600}', '.tc-recpop .tc-rpk-copy{padding:5px 10px;border-bottom:1px solid var(--mbu-border);display:flex;flex-direction:column;gap:3px;background:var(--mbu-bg-raised)}', '.tc-recpop .tc-rpk-copy label{cursor:pointer;color:var(--mbu-text);font-size:11px;display:flex;align-items:center;gap:5px}', '.tc-recpop .tc-rpk-row{border-left:3px solid transparent}', '.tc-recpop .tc-rpk-isrcm{font:bold 9.5px Arial;border-radius:8px;padding:0 6px;background:#0a7a8c;color:#fff;vertical-align:1px}', // #662 '.tc-recpop .tc-rpk-row.tc-conf-exact{border-left-color:var(--mbu-info)}', '.tc-recpop .tc-rpk-row.tc-conf-tolerance{border-left-color:var(--mbu-ok)}', '.tc-recpop .tc-rpk-row.tc-conf-near{border-left-color:var(--mbu-warn)}', '.tc-recpop .tc-rpk-row.tc-conf-low{border-left-color:var(--mbu-warn)}', '.tc-recpop .tc-rpk-row.tc-conf-vlow{border-left-color:var(--mbu-error)}', '.tc-rectbl .tc-dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid rgba(0,0,0,.15)}', '.tc-rectbl tr.tc-recrow:hover td:not(.tc-diff):not(.tc-copy){background:var(--mbu-bg)}', /* #583 — the + row action sits in the FIRST position on the right and the ↺ revert moves in beside it, so + is at the same x on every row whether or not that row has something to revert. */ '.tc-rectbl .tc-rec-new-btn{position:absolute;right:3px;top:50%;transform:translateY(-50%);border:none;background:var(--mbu-bg);cursor:pointer;color:var(--mbu-accent-text);font-size:15px;line-height:1;visibility:hidden;padding:1px 4px;border-radius:3px}', '.tc-rectbl tr.tc-recrow:hover .tc-rec-new-btn{visibility:visible}.tc-rectbl .tc-rec-new-btn:hover{background:var(--mbu-bg-hover)}', '.tc-rectbl .tc-recpick{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);border-radius:4px;padding:1px 6px;font:11px Arial;white-space:nowrap}', '.tc-rectbl .tc-recpick:hover{background:var(--mbu-bg-hover)}', '.tc-recpop{position:fixed;z-index:100003;width:410px;overflow:auto;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:6px;box-shadow:0 8px 28px rgba(40,20,80,.28);font:12px Arial}', '.tc-recpop .tc-rpk-hd{position:sticky;top:0;z-index:1}', '.tc-recpop .tc-rpk-hd{padding:7px 10px;background:var(--mbu-bg-raised);border-bottom:1px solid var(--mbu-accent);border-radius:6px 6px 0 0;display:flex;align-items:baseline;gap:8px}', // #144: title - artist … sec (sec right) '.tc-recpop .tc-rpk-hdmain{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.tc-recpop .tc-rpk-curwrap{border-bottom:1px solid var(--mbu-border)}', // one separator under the whole current-recording group '.tc-recpop .tc-rpk-cur{padding:6px 10px 3px;color:var(--mbu-text);display:flex;align-items:baseline;gap:6px}', '.tc-recpop .tc-rpk-curmain{flex:1;min-width:0}', // #189 title + artist on the left; length pinned right '.tc-recpop .tc-rpk-curactions{display:flex;padding:2px 10px 7px}', // "+ new recording" sits below appears-on, right-aligned '.tc-recpop .tc-rpk-curlbl{color:var(--mbu-text-weak);font-size:11px}.tc-recpop .tc-rpk-curlen{color:var(--mbu-text-weak);font-variant-numeric:tabular-nums;margin-left:auto;flex:none}', '.tc-recpop .tc-rpk-curnone{color:var(--mbu-error)}.tc-recpop .tc-rpk-newcur{color:var(--mbu-ok)}', '.tc-recpop .tc-rpk-newbtn{margin-left:auto;cursor:pointer;border:1px solid var(--mbu-border);background:var(--mbu-bg-raised);color:var(--mbu-ok);border-radius:4px;padding:2px 7px;font:11px Arial}.tc-recpop .tc-rpk-newbtn:hover{background:var(--mbu-ok-bg)}', '.tc-recpop .tc-rpk-qwrap{display:flex;align-items:stretch;gap:6px;margin:8px}', '.tc-recpop .tc-rpk-q{flex:1;min-width:0;padding:5px 7px;border:1px solid var(--mbu-border);border-radius:4px;font:12px Arial;box-sizing:border-box}', '.tc-recpop .tc-rpk-qnew{flex:none;cursor:pointer;border:1px solid var(--mbu-border);background:var(--mbu-bg-raised);color:var(--mbu-ok);border-radius:4px;font:bold 16px Arial;line-height:1;padding:0 10px}.tc-recpop .tc-rpk-qnew:hover{background:var(--mbu-ok-bg)}', '.tc-recpop .tc-rpk-hdby a{color:var(--mbu-info);text-decoration:none}.tc-recpop .tc-rpk-hdby a:hover{text-decoration:underline}', '.tc-recpop .tc-rpk-sec{display:flex;align-items:center;justify-content:space-between;padding:3px 10px;font-size:10px;text-transform:uppercase;letter-spacing:.03em;color:var(--mbu-text-weak);background:var(--mbu-bg-raised)}', '.tc-recpop .tc-rpk-suggsec{cursor:pointer;user-select:none}.tc-recpop .tc-rpk-suggsec:hover{color:var(--mbu-accent-text)}', '.tc-recpop .tc-rpk-suggn{color:var(--mbu-accent-text);font-weight:bold}', // #555 — how many are folded away '.tc-recpop .tc-rpk-caret{display:inline-block;font-size:9px;transition:transform .1s}', '.tc-recpop.tc-sugg-collapsed .tc-rpk-sugg{display:none}.tc-recpop.tc-sugg-collapsed .tc-rpk-caret{transform:rotate(-90deg)}', '.tc-recpop .tc-rpk-relax{text-transform:none;letter-spacing:0;border:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);border-radius:4px;padding:1px 7px;font:10px Arial;cursor:pointer}', '.tc-recpop .tc-rpk-relax:hover{background:var(--mbu-bg-hover)}.tc-recpop .tc-rpk-relax.on{background:#6f42c1;color:#fff;border-color:var(--mbu-accent)}', '.tc-recpop .tc-rpk-row{padding:5px 10px;cursor:pointer;border-bottom:1px solid var(--mbu-accent)}', '.tc-recpop .tc-rpk-row:hover{background:var(--mbu-bg-hover)}', '.tc-recpop .tc-rpk-main{display:flex;align-items:baseline;gap:6px}', '.tc-recpop .tc-rpk-name{font-weight:600;color:var(--mbu-text)}', '.tc-recpop .tc-rpk-cmt{color:var(--mbu-text-weak);font-size:11px}', '.tc-recpop .tc-rpk-curisrc{padding:0 10px 4px;color:var(--mbu-text-dim);font-size:11px}.tc-recpop .tc-rpk-curisrc-list{font-family:Consolas,monospace;color:var(--mbu-text-dim)}', '.tc-recpop .tc-rpk-len{margin-left:auto;color:var(--mbu-text-dim);font-variant-numeric:tabular-nums;white-space:nowrap}', '.tc-recpop .tc-rpk-by{color:var(--mbu-text-dim);font-size:11px}', '.tc-recpop .tc-rpk-on{color:var(--mbu-text-dim);font-size:11px}', '.tc-recpop .tc-rpk-on a,.tc-recpop .tc-rpk-curon a,.tc-recpop .tc-rpk-name a,.tc-recpop .tc-rpk-by a{color:var(--mbu-info);text-decoration:none}.tc-recpop .tc-rpk-on a:hover,.tc-recpop .tc-rpk-curon a:hover,.tc-recpop .tc-rpk-name a:hover,.tc-recpop .tc-rpk-by a:hover{text-decoration:underline}', '.tc-recpop .tc-rpk-name a{color:inherit}', // title link keeps the result's strong colour; underline on hover only '.tc-recpop .tc-rpk-adis{color:var(--mbu-accent-text);font-size:10px}', // artist disambiguation in the by-line / header #195', '.tc-recpop .tc-rpk-more{color:var(--mbu-text-weak);font-style:italic}', '.tc-recpop .tc-rpk-rgx{color:var(--mbu-text-weak);font-size:10px;font-weight:600}', // header subtitle = the song (track) artist + length; current-recording artist + its full appears-on '.tc-recpop .tc-rpk-hdby{color:var(--mbu-text-dim);font-weight:normal}.tc-recpop .tc-rpk-hdlen{color:var(--mbu-text-weak);font-weight:normal;font-variant-numeric:tabular-nums;margin-left:auto;flex:none}', // #144: push sec to the right edge (aligns with the rows' length column) '.tc-recpop .tc-rpk-curby{color:var(--mbu-text-dim);font-size:12px}', '.tc-recpop .tc-rpk-curon{padding:0 10px 4px;color:var(--mbu-text-dim);font-size:11px}', '.tc-recpop .tc-rpk-isrc{color:var(--mbu-accent-text);font-size:11px;font-family:Consolas,monospace}', '.tc-recpop .tc-rpk-fdiff{background:var(--mbu-bg-raised);color:var(--mbu-error);border-radius:2px;padding:0 2px}', '.tc-recpop .tc-rpk-empty{padding:8px 10px;color:var(--mbu-text-weak);font-style:italic}', // #555 — an IN-PROGRESS placeholder pulses its background, so "still working" // is distinguishable from "finished, nothing found" at a glance instead of // having to read the sentence. Terminal states never get this class. '.tc-recpop .tc-rpk-busy{animation:tc-rpk-pulse 1.15s ease-in-out infinite}', '@keyframes tc-rpk-pulse{0%,100%{background:var(--mbu-bg)}50%{background:var(--mbu-bg-hover)}}', '.tc-rpk-dots{animation:tc-rpk-dots 1.15s ease-in-out infinite;border-radius:3px;padding:0 3px}', '@keyframes tc-rpk-dots{0%,100%{background:transparent}50%{background:var(--mbu-bg-hover)}}', '@media (prefers-reduced-motion:reduce){.tc-recpop .tc-rpk-busy,.tc-rpk-dots{animation:none;background:var(--mbu-bg-raised)}}', // hide the native recording table from the first paint (no flash) and let our table use the // full width instead of MB's .half-width column (#119) 'body.tc-rec-on #track-recording-assignation{display:none!important}', 'body.tc-rec-on #recordings .half-width{max-width:none!important;width:auto!important}', // the recordings tab has one native <fieldset> per medium (legend "Medium N" + its own assignation // table) plus an Options fieldset. Our Apollo table is inserted INTO the first medium's fieldset, so // we can't hide that one wholesale: hide every OTHER native fieldset (other mediums' tables + Options), // and in the host fieldset strip the box + hide the native legend and table. Leaves only our table. #119 'body.tc-rec-on #recordings fieldset:not(:has(#tc-recwrap)){display:none!important}', 'body.tc-rec-on #recordings fieldset:has(#tc-recwrap){border:none!important;margin:0!important;padding:0!important}', 'body.tc-rec-on #recordings fieldset:has(#tc-recwrap) > legend{display:none!important}', 'body.tc-rec-on #recordings fieldset:has(#tc-recwrap) > table{display:none!important}', // #149: hide every native per-medium "Edit" (load-tracks) button — Apollo // renders all mediums collapsed and triggers the load itself on expand. 'body.tc-rec-on #recordings button[data-click="loadTracks"]{display:none!important}', // MOBILE: the side-by-side Track|Recording table (8 fixed-% columns) // can't fit a phone — stack each comparison into a card: the track line // (#, title, artist, length) on top, then the matched recording line // beneath, marked by the confidence dot. #issue '@media (max-width:820px){', ' #tc-recwrap .tc-rec-tb{gap:5px 8px}', ' .tc-rectbl{display:block}', ' .tc-rectbl > colgroup,.tc-rectbl > thead{display:none}', ' .tc-rectbl > tbody{display:block}', ' .tc-rectbl > tbody > tr.tc-recrow{display:grid;grid-template-columns:20px 1fr auto;column-gap:7px;row-gap:1px;padding:8px 8px}', ' .tc-rectbl.gridrows > tbody > tr.tc-recrow{border-bottom:1px solid var(--mbu-border)}', ' .tc-rectbl > tbody > tr.tc-recrow > td{display:block;padding:0;border:none!important;background:transparent}', ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(1){grid-column:1;grid-row:1;text-align:left}', // # ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(2){grid-column:2;grid-row:1;font-weight:600}', // track title ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(3){grid-column:2;grid-row:2;color:var(--mbu-text-dim);font-size:12px}', // track artist ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(4){grid-column:3;grid-row:1;width:auto}', // track length ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(5){grid-column:1;grid-row:3;text-align:center;padding-top:4px!important}', // dot ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(6){grid-column:2;grid-row:3}', // recording title ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(7){grid-column:2;grid-row:4;color:var(--mbu-text-dim);font-size:12px}', // recording artist ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(8){grid-column:3;grid-row:3;width:auto}', // recording length ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(5),.tc-rectbl > tbody > tr.tc-recrow > td:nth-child(6),.tc-rectbl > tbody > tr.tc-recrow > td:nth-child(8){margin-top:3px;padding-top:4px;border-top:1px solid var(--mbu-border)!important}', ' .tc-rectbl td.c-sep{border-left:none;border-right:none}', ' .tc-rectbl tr.tc-recmed td,.tc-rectbl tr.tc-recmed-coll td{display:block}', ' .tc-rectbl .tc-rec-rev,.tc-rectbl .tc-rec-new-btn{visibility:visible}', // touch: no hover '}', ].join('\n'); document.head.appendChild(s); } // build the toolbar + table shell once; the body is (re)rendered separately so a re-render during // auto-match updates rows live without rebuilding/resetting the toolbar. #119 function renderRecMirror(wrap) { wrap.innerHTML = '<div class="tc-rec-tb">' + '<span class="tc-rec-amstatus"></span>' + // flexible filler: its text changes absorb here, never reflowing the bar '<label class="tc-rec-tbl" title="Auto-match only links a recording when its confidence is at or above this level; anything lower is left unmatched."><b>Cutoff</b> <span class="tc-cutoff" tabindex="0"><span class="tc-cutoff-dot"></span><span class="tc-cutoff-lbl"></span><span class="tc-cutoff-caret">▾</span></span></label>' + '<span class="tc-recwarn"></span>' + '<span class="tc-tbsep"></span>' + '<button class="tc-rec-am tc-btn primary" type="button" title="auto-match unset recordings to MusicBrainz suggestions"><span class="tc-spin"></span><span class="tc-rec-am-lbl">⚡ Match</span></button>' + '<button class="tc-rec-revcaret" type="button" title="revert / clear all">▾</button>' + '' + /* gear moved to the Apollo launcher */ '</div>' + '<table class="tc-rectbl ' + (SETTINGS.layout || 'normal') + (SETTINGS.altRows ? ' alt' : '') + (SETTINGS.gridCols ? ' gridcols' : '') + (SETTINGS.gridRows !== false ? ' gridrows' : '') + '">' + '<colgroup><col style="width:2.5%"><col style="width:25.5%"><col style="width:18%"><col style="width:4%"><col style="width:2%"><col style="width:26%"><col style="width:18%"><col style="width:4%"></colgroup>' + '<thead>' + '<tr class="tc-grouphd"><th colspan="4" class="tc-grp tc-grp-l">Track</th><th class="c-sep"></th><th colspan="3" class="tc-grp tc-grp-r">Recording</th></tr>' + '<tr><th class="c-n">#</th><th>Title</th><th>Artist</th><th class="c-len">Length</th>' + '<th class="c-sep"></th><th>Title</th><th>Artist</th><th class="c-len">Length</th></tr></thead><tbody></tbody></table>'; // wire the toolbar (once) wireCutoff(wrap); const amBtn = wrap.querySelector('.tc-rec-am'); if (amBtn) { amBtn.onclick = () => { if (_autoMatching) stopMatching(); else autoMatchRecordings(); }; // #577 /* #575 round 4: the shell is rebuilt when the Recordings tab is entered, and it is built from static markup reading "⚡ Match" — so walking into the tab during a running pass produced a button that offered to start something already running. Clicking it then went to stopMatching(), and nothing about the button said so: the literal "100% unresponsive to clicks". Re-adopt the running state when the shell is (re)built. */ if (_autoMatching) { amBtn.classList.add('busy'); const lbl = amBtn.querySelector('.tc-rec-am-lbl'); if (lbl) lbl.textContent = _matchStop ? '⏳ Stopping…' : '■ Stop'; amBtn.title = 'Stop matching — the recordings already linked are kept'; } } // #582 — restore the last pass's verdict; the shell above was rebuilt from // static markup and would otherwise come back blank. const amStatus = wrap.querySelector('.tc-rec-amstatus'); if (amStatus && _recLastStatus && !_autoMatching) amStatus.textContent = _recLastStatus; const revCaret = wrap.querySelector('.tc-rec-revcaret'); if (revCaret) revCaret.onclick = () => openMiniMenu(revCaret, [ { label: '↺ Revert all', title: 'revert every recording to its page-load state', onClick: revertAllRecordings }, { label: '✕ Clear all', title: 'set every track to a new recording', onClick: clearAllRecordings }, ]); wireRecCellContextMenu(wrap); renderRecBody(wrap); } // Right-click a recording title/artist cell to toggle its "copy track value to // the recording (on submit)" flag — the same flag the picker checkbox sets, but // without opening the picker. Eligibility proxies the NATIVE update checkbox // (nativeDiffFlag), so it works for casing-only diffs too. #146 // plain → just the clicked field on that row // Ctrl → both fields (that have a diff) on the clicked row // Alt → the clicked field down the whole column (every diffing row) // Ctrl+Alt → both fields on every row = the whole side of the table (#443). The two // sides copy in opposite directions, so the full table is two gestures. function wireRecCellContextMenu(wrap) { const tbl = wrap.querySelector('.tc-rectbl'); if (!tbl) return; const copyOn = (t, f) => f === 'title' ? !!u(t.updateRecordingTitle) : !!u(t.updateRecordingArtist); const eligible = (t, f) => !!t && (nativeDiffFlag(t, f) || copyOn(t, f)); wireRecCellDrag(wrap, tbl, { copyOn, eligible }); tbl.addEventListener('contextmenu', e => { // #635: a right-button drag already did its work on every cell it touched if (_recDragSwallow) { e.preventDefault(); _recDragSwallow = false; Log.cat('Recording').debug('contextmenu after a right-drag — swallowed'); return; } const tr = e.target.closest('tr.tc-recrow'); if (!tr) return; const recRows = () => wrap.querySelectorAll('tbody tr.tc-recrow'); const wholeSide = e.ctrlKey && e.altKey; // #443: both fields, every row = the whole side // #443 whole-side sweep for the track side (recording -> track): copy a recording's // title AND artist onto its track for one row, reused across every row in the gesture. // Mirrors the per-field setters below; the shared cache fetches each artist entity once. const _sideEntCache = new Map(); const copyRecToTrack = async (m, i) => { const t = koTrack(m, i); if (!t) return; const rec = u(t.recording); const rn = rec ? u(rec.name) : null; if (rn != null && rn !== '' && u(t.name) !== rn) { _selfEdit = true; try { t.name(rn); } catch (x) {} finally { _selfEdit = false; } } if (u(t.updateRecordingTitle)) setCopy('title', { mi: m, ti: i }, false); // now equal -> clear the moot flag const recAc = rec && u(rec.artistCredit), recNames = recAc && (u(recAc.names) || []); if (recNames && recNames.length) { const nk = n => (n.artist ? (u(u(n.artist).gid) || '') : '') + '|' + (u(n.name) || '') + '|' + (u(n.joinPhrase) || ''); const tNames = u(u(t.artistCredit).names) || []; const same = tNames.length === recNames.length && tNames.every((n, k) => nk(n) === nk(recNames[k])); if (!same) { const names = []; for (const n of recNames) { const a = u(n.artist), gid = a && u(a.gid); let full = null; if (gid) { if (!_sideEntCache.has(gid)) _sideEntCache.set(gid, await fetchEntity(gid)); full = _sideEntCache.get(gid); } names.push({ artist: full || a, name: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '' }); // full entity persists the match (#348) } _selfEdit = true; try { t.artistCredit({ names }); } catch (x) { Log.cat('Recording').warn(`side copy: track ${m}.${i} artistCredit setter threw: ${x && x.message}`); } finally { _selfEdit = false; } // #443 } if (u(t.updateRecordingArtist)) setCopy('artist', { mi: m, ti: i }, false); } }; // #348: right-click a TRACK title cell → set the track NAME to its recording's name, IMMEDIATELY // (the mirror of copying track→recording). It's a real title edit, so the Tracklist tab shows the // row changed and its ↺ reverts it. Alt = do the whole column. if (e.target.closest('td.tc-tkt')) { e.preventDefault(); if (wholeSide) { (async () => { for (const row of recRows()) await copyRecToTrack(+row.dataset.mi, +row.dataset.ti); _tlRefreshed = false; scheduleSync(); rerenderRec(); })(); return; } // #443 whole track side const setFromRec = (m, i) => { const t = koTrack(m, i); const rec = t && u(t.recording); const rn = rec ? u(rec.name) : null; if (rn != null && rn !== '' && u(t.name) !== rn) { _selfEdit = true; try { t.name(rn); } catch (x) {} finally { _selfEdit = false; } } // #420: the track title now equals the recording's, so a pending "rename recording" // flag is a no-op — clear it (and its green indicator) instead of leaving it stale. if (t && u(t.updateRecordingTitle)) { setCopy('title', { mi: m, ti: i }, false); Log.cat('Recording').info(`track ${m}.${i}: title copied from recording — cleared the now-moot rename-recording flag`); } }; if (e.altKey) wrap.querySelectorAll('tbody tr.tc-recrow').forEach(row => setFromRec(+row.dataset.mi, +row.dataset.ti)); else setFromRec(+tr.dataset.mi, +tr.dataset.ti); _tlRefreshed = false; scheduleSync(); // #348: title changed here (a self-edit the watcher ignores) — re-sync the Tracklist mirror so the new title + changed-row marker show reliably, not on a racy tab-switch rerenderRec(); return; } // #348: right-click a TRACK artist cell → set the track's ARTIST CREDIT to its recording's, immediately. Alt = whole column. if (e.target.closest('td.tc-tka')) { e.preventDefault(); if (wholeSide) { (async () => { for (const row of recRows()) await copyRecToTrack(+row.dataset.mi, +row.dataset.ti); _tlRefreshed = false; scheduleSync(); rerenderRec(); })(); return; } // #443 whole track side // entity-aware equality: same artist GID + credited-as + join phrase per name. NOT acText — two // different artists sharing a credited name ("Mariz" ≠ "Mariz") must still copy the recording's entity. const nameKey = n => (n.artist ? (u(u(n.artist).gid) || '') : '') + '\x01' + (u(n.name) || '') + '\x01' + (u(n.joinPhrase) || ''); const _entCache = new Map(); // gid → full entity, so a whole-column copy fetches each artist once const fullEntity = async gid => { if (!_entCache.has(gid)) _entCache.set(gid, await fetchEntity(gid)); return _entCache.get(gid); }; const setArtistFromRec = async (m, i) => { const t = koTrack(m, i), rec = t && u(t.recording), recAc = rec && u(rec.artistCredit); const recNames = recAc && (u(recAc.names) || []); if (!recNames || !recNames.length) { Log.cat('Recording').warn(`artist copy: track ${m}.${i} — recording has no artist credit (recording ${rec ? 'present' : 'null'}) — nothing to copy`); return; } // #420 (artist twin): once track and recording agree, a pending "update recording // artist" flag is a no-op — clear it so its indicator doesn't linger. const clearMootArtistFlag = () => { if (u(t.updateRecordingArtist)) { setCopy('artist', { mi: m, ti: i }, false); Log.cat('Recording').info(`track ${m}.${i}: artist copied from recording — cleared the now-moot update-recording-artist flag`); } }; const tNames = u(u(t.artistCredit).names) || []; if (tNames.length === recNames.length && tNames.every((n, k) => nameKey(n) === nameKey(recNames[k]))) { Log.cat('Recording').info(`artist copy: track ${m}.${i} — track artist already identical to the recording's — skipped`); clearMootArtistFlag(); return; } const gids = recNames.map(n => (n.artist ? (u(u(n.artist).gid) || '∅') : '∅')).join(', '); // Fetch the FULL artist entity for each credit (same as the paste-MBID resolve → pickArtist // path). Verified live: writing the recording's own LEAN artist — or W.MB.entity(gid,name) — // gets DROPPED by MB on its next re-derive; only the full entity, with its integer id, persists // the match. Keep the recording's credited-as + join phrase. #348 const names = []; for (const n of recNames) { const a = u(n.artist), gid = a && u(a.gid); const full = gid ? await fullEntity(gid) : null; names.push({ artist: full || a, name: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '' }); } try { _selfEdit = true; try { t.artistCredit({ names }); } finally { _selfEdit = false; } // read back the WRITTEN track credit — ∅ here (vs a real gid in the recording) means // the entity link was dropped and only the credited text stuck (the "set without match" bug). const wrote = (u(u(t.artistCredit).names) || []).map(n => (n.artist ? (u(u(n.artist).gid) || '∅') : '∅')).join(', '); Log.cat('Recording').info(`artist copy: track ${m}.${i} ← recording "${acText(u(t.artistCredit))}" (recording gid(s): ${gids} · written track gid(s): ${wrote})`); clearMootArtistFlag(); } catch (x) { Log.cat('Recording').warn(`artist copy: track ${m}.${i} — artistCredit setter threw: ${x && x.message}`); } }; (async () => { if (e.altKey) { for (const row of wrap.querySelectorAll('tbody tr.tc-recrow')) await setArtistFromRec(+row.dataset.mi, +row.dataset.ti); } else await setArtistFromRec(+tr.dataset.mi, +tr.dataset.ti); // #348: the copy is a SELF-edit, which the tracklist change-watcher ignores — so the // Tracklist mirror only refreshes via the lazy _tlRefreshed flag on the next tab-switch, // and that races (the copy lands in the model but the tab paints the stale unmatched slot). // Re-sync the mirror ourselves so the resolved artist shows immediately/reliably. _tlRefreshed = false; scheduleSync(); rerenderRec(); })(); return; } const inTitle = !!e.target.closest('td.tc-recname'); const field = inTitle ? 'title' : (e.target.closest('td.tc-recartist') ? 'artist' : null); if (!field) return; // not a recording cell → leave the native menu // On a recording title/artist cell ALWAYS suppress the native menu, even // when there's no difference — toggling on some cells but popping the OS // menu on others was confusing; just do nothing (no native menu) when // there's no copy to offer. #146 (maintainer) e.preventDefault(); const mi = +tr.dataset.mi, ti = +tr.dataset.ti, t0 = koTrack(mi, ti); if (!eligible(t0, field)) return; // no copy available here → do nothing const target = !copyOn(t0, field); // toggle based on the clicked cell's current state const apply = (m, i, f) => { const t = koTrack(m, i); if (eligible(t, f)) setCopy(f, { mi: m, ti: i }, target); }; if (wholeSide) recRows().forEach(row => ['title', 'artist'].forEach(f => apply(+row.dataset.mi, +row.dataset.ti, f))); // #443 whole recording side (both fields, every row) else if (e.ctrlKey) ['title', 'artist'].forEach(f => apply(mi, ti, f)); // whole row else if (e.altKey) recRows().forEach(row => apply(+row.dataset.mi, +row.dataset.ti, field)); // whole column else apply(mi, ti, field); rerenderRec(); }); } // #635: right-button DRAG over the cells = the right-click on each cell it touches, so // several rows or columns are done with the mouse alone (the modifiers still work). // recording side → every touched cell's copy is set to what the FIRST cell toggles to, // so a drag either marks or unmarks, never a mix // track side → every touched cell copies from its recording, immediately // A drag stays on the side it started on (the two sides copy in opposite directions). // A press that never reaches a second cell is an ordinary right-click and is left to the // contextmenu handler; once a drag has reached one, that trailing contextmenu is swallowed. let _recDragSwallow = false; const REC_DRAG_CELLS = { 'td.tc-recname': ['rec', 'title'], 'td.tc-recartist': ['rec', 'artist'], 'td.tc-tkt': ['track', 'title'], 'td.tc-tka': ['track', 'artist'] }; function recDragCell(el) { for (const [sel, [side, field]] of Object.entries(REC_DRAG_CELLS)) { const td = el && el.closest && el.closest(sel), tr = td && td.closest('tr.tc-recrow'); if (td && tr) return { td, side, field, mi: +tr.dataset.mi, ti: +tr.dataset.ti, key: tr.dataset.mi + '.' + tr.dataset.ti + '.' + field }; } return null; } function wireRecCellDrag(wrap, tbl, { copyOn, eligible }) { if (tbl._tcRecDrag) return; tbl._tcRecDrag = true; tbl.addEventListener('mousedown', e => { if (e.button !== 2 || e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return; // modifiers keep their click meaning const start = recDragCell(e.target); if (!start) return; _recDragSwallow = false; const t0 = koTrack(start.mi, start.ti); const drag = { side: start.side, start, touched: new Map(), moved: false, target: start.side === 'rec' ? !copyOn(t0, start.field) : null, // the rec side's mark/unmark, decided by the first cell menuSeen: false }; // Linux and macOS fire the contextmenu on the PRESS, Windows on the release: note whether the // browser's own one has come already (the track side's dispatched ones are not the browser's) const sawMenu = ev => { if (ev.isTrusted) drag.menuSeen = true; }; window.addEventListener('contextmenu', sawMenu, true); const mark = () => wrap.querySelectorAll('tbody tr.tc-recrow').forEach(tr => { // rerenderRec rebuilds rows: re-mark by key for (const [sel, [side, field]] of Object.entries(REC_DRAG_CELLS)) { if (side !== drag.side) continue; const td = tr.querySelector(sel); if (td) td.classList.toggle('tc-rdrag', drag.touched.has(tr.dataset.mi + '.' + tr.dataset.ti + '.' + field)); } }); const touch = c => { if (drag.touched.has(c.key)) return; drag.touched.set(c.key, c); if (drag.side === 'rec') { const t = koTrack(c.mi, c.ti); if (eligible(t, c.field)) setCopy(c.field, { mi: c.mi, ti: c.ti }, drag.target); else Log.cat('Recording').debug(`right-drag: ${c.key} has no copy to offer — skipped`); rerenderRec(); } else { // the right-click's own copy for this one cell (the handler's plain, no-modifier path), // on the cell as it is NOW: the previous copy's rerenderRec may have replaced the row const sel = Object.keys(REC_DRAG_CELLS).find(s => REC_DRAG_CELLS[s][0] === 'track' && REC_DRAG_CELLS[s][1] === c.field); const td = wrap.querySelector(`tbody tr.tc-recrow[data-mi="${c.mi}"][data-ti="${c.ti}"] ${sel}`); if (td) td.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); else Log.cat('Recording').warn(`right-drag: row ${c.key} is gone from the table — not copied`); } mark(); }; const move = ev => { if (!(ev.buttons & 2)) return end(ev); const c = recDragCell(document.elementFromPoint(ev.clientX, ev.clientY)); if (!c || c.side !== drag.side || (!drag.moved && c.key === start.key)) return; if (!drag.moved) { drag.moved = true; Log.cat('Recording').info(`right-drag on the ${drag.side === 'rec' ? 'recording' : 'track'} side from ${start.key}` + (drag.side === 'rec' ? ` — ${drag.target ? 'marking' : 'unmarking'} copies` : ' — copying from the recordings')); touch(start); } touch(c); }; const end = () => { window.removeEventListener('mousemove', move, true); window.removeEventListener('mouseup', end, true); setTimeout(() => window.removeEventListener('contextmenu', sawMenu, true), 0); // after the release's own contextmenu, if it has one if (!drag.moved) return; // one cell: the contextmenu handler does it // The release's contextmenu (Windows fires it after mouseup) lands on whatever is under // the pointer, which may be outside the table (majkinetor, #635): swallow it page-wide. // Where it fired on the press already (Linux, macOS), nothing comes: waiting for one would // swallow the next right-click instead (a CI run on Linux caught that). if (drag.menuSeen) Log.cat('Recording').debug('the contextmenu came with the press — nothing to swallow on release'); else { _recDragSwallow = true; const swallow = ev => { ev.preventDefault(); ev.stopPropagation(); _recDragSwallow = false; window.removeEventListener('contextmenu', swallow, true); Log.cat('Recording').debug('contextmenu after a right-drag — swallowed (' + ((ev.target && ev.target.tagName) || '?') + ')'); }; window.addEventListener('contextmenu', swallow, true); setTimeout(() => { _recDragSwallow = false; window.removeEventListener('contextmenu', swallow, true); }, 400); } wrap.querySelectorAll('.tc-rdrag').forEach(td => td.classList.remove('tc-rdrag')); if (drag.side === 'track') { _tlRefreshed = false; scheduleSync(); } rerenderRec(); Log.cat('Recording').info(`right-drag done: ${drag.touched.size} cell(s) — ${[...drag.touched.keys()].join(', ')}`); }; window.addEventListener('mousemove', move, true); window.addEventListener('mouseup', end, true); }); } // custom Cutoff picker — a colored-dot dropdown that uses the SAME hex palette as the row dots // (a native <select> can't show the exact colors, only emoji approximations) function wireCutoff(wrap) { const host = wrap.querySelector('.tc-cutoff'); if (!host) return; const dotEl = host.querySelector('.tc-cutoff-dot'), lblEl = host.querySelector('.tc-cutoff-lbl'); const OPTS = [['exact', 'exact'], ['tolerance', 'tolerance'], ['near', 'near'], ['low', 'low'], ['vlow', 'very low']]; const paint = () => { const v = SETTINGS.recCutoff || 'near'; const o = OPTS.find(x => x[0] === v) || OPTS[2]; dotEl.style.background = CONF_COLOR[v]; lblEl.textContent = o[1]; }; paint(); host.onclick = () => { document.querySelectorAll('.tc-cutoff-menu').forEach(m => m.remove()); const cur = SETTINGS.recCutoff || 'near'; const m = document.createElement('div'); m.className = 'tc-cutoff-menu'; m.innerHTML = OPTS.map(([v, l]) => `<div class="mi${v === cur ? ' sel' : ''}" data-v="${v}"><span class="dot" style="background:${CONF_COLOR[v]}"></span>${l}</div>`).join(''); document.body.appendChild(m); const r = host.getBoundingClientRect(); m.style.left = r.left + 'px'; m.style.top = (r.bottom + 3) + 'px'; m.querySelectorAll('.mi').forEach(it => it.onmousedown = e => { e.preventDefault(); SETTINGS.recCutoff = it.dataset.v; saveSettings(); paint(); m.remove(); }); const off = e => { if (!m.contains(e.target) && !host.contains(e.target)) { m.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); }; } // lightweight fingerprint of the live recording state. The tab watcher compares // it each tick so the mirror re-renders when MB changes a recording externally — // e.g. it clears a track's recording after the track title is edited — instead of // only updating on our own picker actions (stale-row bug). let _lastRecSig = ''; function recSig() { let s = ''; mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const rec = u(t.recording); const gid = rec ? (u(rec.gid) || '') : ''; const isNew = (typeof t.hasNewRecording === 'function' && u(t.hasNewRecording)) ? 'N' : ''; s += mi + ':' + ti + ':' + gid + ':' + isNew + ':' + (u(t.name) || '') + '|'; })); return s; } // jump from the toolbar "N without a recording" to the first such track's picker function openFirstUnsetPicker(row) { const wrap = document.getElementById('tc-recwrap'); if (!wrap || !row) return; const tr = wrap.querySelector('tbody tr.tc-recrow[data-mi="' + row.mi + '"][data-ti="' + row.ti + '"]'); if (tr) tr.scrollIntoView({ behavior: 'smooth', block: 'center' }); openRecPicker(row, (tr && tr.querySelector('.tc-recname')) || wrap.querySelector('.tc-recwarn')); } // #303 — recording video marker (mirrors the native recordings table's <span class="video">) const VIDEO_MARK = '<span class="tc-rec-video" title="This recording is a video"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></span>'; /* #584 (majkinetor): "In recordings editor the video icons are displayed after the titles. While in the original editor they shown before titles. The latter makes it easier to scan and notice videos in the list (because titles rarely have same lengths)." A trailing marker lands at a different x on every row; a leading one lines up in a column the eye can run down. Same glyph, different spacing (.lead flips the margin), used everywhere the marker appears. */ const VIDEO_MARK_PRE = VIDEO_MARK.replace('class="tc-rec-video"', 'class="tc-rec-video lead"'); // (re)render just the rows + the unset-count — leaves the toolbar (status/inputs) untouched // #685: and the scroll, which the emptied body would otherwise send to the top function renderRecBody(wrap) { const restore = keepScroll(); try { renderRecRows(wrap); } finally { restore(); } } function renderRecRows(wrap) { wrap = wrap || document.getElementById('tc-recwrap'); if (!wrap) return; const tb = wrap.querySelector('tbody'); if (!tb) return; const rows = readRecordings(); const multi = mediums().length > 1; const unset = rows.filter(r => !r.recGid && !r.isNew).length; const firstUnset = rows.find(r => !r.recGid && !r.isNew); const warn = wrap.querySelector('.tc-recwarn'); if (warn) { warn.textContent = unset ? '⚠ ' + unset + ' without a recording' : ''; warn.onclick = (unset && firstUnset) ? () => openFirstUnsetPicker(firstUnset) : null; // #139 follow-up: jump to the first unset track's picker warn.title = unset ? 'jump to the first track without a recording' : ''; } tb.innerHTML = ''; // #149: iterate ALL mediums so collapsed ones still appear (with an expand // control) instead of being silently dropped — previously only the medium MB // had loaded showed up. mediums().forEach((med, mi) => { const collapsed = !mediumLoadedRec(med); if (multi || collapsed) { const mr = document.createElement('tr'); mr.className = 'tc-recmed' + (collapsed ? ' tc-recmed-coll' : ''); mr.dataset.mi = String(mi); if (collapsed) { const td = document.createElement('td'); td.colSpan = 8; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tc-recmed-exp'; btn.textContent = '▸ Medium ' + (mi + 1) + ' — left click to expand, right click to expand all'; btn.title = 'Left click: load this medium · Right click: load all collapsed media'; btn.onclick = () => expandRecMedium(mi, btn); btn.oncontextmenu = (e) => { e.preventDefault(); expandAllRecMediums(btn); }; td.appendChild(btn); mr.appendChild(td); } else { mr.innerHTML = '<td colspan="8">Medium ' + (mi + 1) + '</td>'; } tb.appendChild(mr); } if (collapsed) return; // no track rows until the user expands it rows.forEach(r => { if (r.mi === mi) tb.appendChild(renderRecRow(r)); }); }); _lastRecSig = recSig(); // mark the state this render reflects, so the tick only re-renders on real changes } function mediumLoadedRec(med) { try { return typeof med.loaded === 'function' ? !!u(med.loaded) : true; } catch (e) { return true; } } // #149: load a collapsed medium's recordings on demand — the native per-medium // "Edit" button's loadTracks action (those buttons are hidden). loadTracks fetches // async, so poll the model until the medium reports loaded, then re-render. function expandRecMedium(mi, btn) { const med = mediums()[mi]; if (!med) return; if (btn) { btn.classList.add('loading'); btn.textContent = '⏳ Medium ' + (mi + 1) + ' — loading…'; } try { if (typeof med.loadTracks === 'function') { Log.cat('Recording').info('loading recordings for medium ' + (mi + 1)); med.loadTracks(); } } catch (e) { Log.cat('Recording').warn('loadTracks failed', e.message); } let n = 0; // loadTracks makes MB re-render the recordings panel, which can wipe our // mounted table — so re-mount via showRecMirror (re-anchors to the now-loaded // medium's table + re-renders) once the medium reports loaded. #149 const poll = () => { n++; if (mediumLoadedRec(med) || n > 75) { snapshotRecOriginals(); showRecMirror(); } else setTimeout(poll, 200); }; setTimeout(poll, 200); } // #185: expand EVERY still-collapsed medium at once (right-click an expand arrow). // Fires loadTracks on all collapsed media, then re-renders once they've all loaded // (one poll for the whole set, vs one per medium). function expandAllRecMediums(btn) { const pending = mediums().filter(med => !mediumLoadedRec(med)); if (!pending.length) return; Log.cat('System').info('expand all media (recordings):', pending.length, 'collapsed'); if (btn) { btn.classList.add('loading'); btn.textContent = '⏳ expanding all media…'; } pending.forEach(med => { try { if (typeof med.loadTracks === 'function') med.loadTracks(); } catch (e) { Log.cat('System').warn('loadTracks failed', e.message); } }); let n = 0; const poll = () => { n++; if (pending.every(mediumLoadedRec) || n > 100) { snapshotRecOriginals(); showRecMirror(); } else setTimeout(poll, 200); }; setTimeout(poll, 200); } function renderRecRow(r) { const d = r.diffs || {}; // recording name: click to open the picker. Artists are links. When a copy-to-match flag is set // (from the picker), the cell previews the track value the recording will become (green). #119 // when a copy flag is on, preview the track value AND keep the recording's // original alongside it, struck through: "→ New (O̶r̶i̶g̶i̶n̶a̶l̶)". #146 // recording disambiguation shown in grey after the name, like the native UI. #144 const disamb = r.recComment ? ' <span class="tc-rec-disamb">(' + esc(r.recComment) + ')</span>' : ''; const titleCell = r.copyTitle ? '→ ' + dhRun(r.title || '') + (r.recName ? ' <s class="tc-rec-orig">' + esc(r.recName) + disamb + '</s>' : '') : r.isNew ? '<span class="tc-rec-new">+ new recording</span>' : r.recName ? (dhRun(r.recName) + disamb) : '<span class="tc-rec-none">— none —</span>'; const artistCell = r.copyArtist ? '→ ' + dhRun(r.trackArtist || '') + (r.recArtist ? ' <s class="tc-rec-orig">' + esc(r.recArtist) + '</s>' : '') : (r.recArtistHtml || ''); const tolHas = f => (r.tolDiffs || []).some(x => x === f || x.startsWith(f)); // within-tolerance diffs highlight the cells too // No more tc-updavail blue underline (#197): a native-offers-copy row is now // always tolerance (never exact), so it's already coloured by its tolerance // state — the extra blue line was redundant. Right-click copy still works // (driven by tElig below), it just isn't drawn as a separate cue. const tCls = r.copyTitle ? 'tc-copy' : (d.title || tolHas('title') ? 'tc-diff' : ''); const aCls = r.copyArtist ? 'tc-copy' : (d.artist || tolHas('artist') ? 'tc-diff' : ''); const tElig = r.rawTitleDiff || r.copyTitle, aElig = r.rawArtistDiff || r.copyArtist; const changed = recChangedFromOrig(r.mi, r.ti); // differs from the page-load recording // #186 detailed highlighting (opt-in): per-character title + artist diff + graded length shade. // Falls back to the flat tc-diff highlight when off, in copy-preview, or for long titles. // Diffs on the LITERAL strings, so a casing/punctuation-only difference still shows the exact // changed characters even though the tolerance/ignore-casing settings treat the row as a match. // The cell keeps its base background (the match/mismatch colour, like the length cells); // the per-character highlight is drawn on TOP — so the diff classes are left unchanged. #186 const dh = SETTINGS.recDetailedHl && r.recGid && !r.isNew; let trackTitleHtml = dhRun(r.title || ''), recTitleHtml = titleCell; if (dh && !r.copyTitle && r.recName != null && (r.title || '') !== (r.recName || '')) { const segs = charDiff(r.title || '', r.recName || ''); if (segs) { trackTitleHtml = diffSide(segs, -1); recTitleHtml = diffSide(segs, 1) + disamb; } } // artists: ENTITY-level diff that KEEPS the per-artist links + disambiguations // (#186: links used to drop here because we char-diffed plain text; #195: // disambiguations now show). The artist whose entity isn't on the other side // is boxed; a credited-as (same entity) isn't. Trigger on an ENTITY difference, // not just text — a track artist swapped to a SAME-NAME different artist reads // identically but must still be boxed so it's not missed (#186, chaban). const acKeys = ac => (ac || []).map(a => a.gid || ('name:' + (a.name || '').toLowerCase().trim())).join('\x01'); const artistEntitiesDiffer = acKeys(r.trackAc) !== acKeys(r.recAc); let trackArtistHtml2 = r.trackArtistHtml || '', recArtistCell = artistCell; if (dh && !r.copyArtist && r.recArtist != null && ((r.trackArtist || '') !== (r.recArtist || '') || artistEntitiesDiffer)) { trackArtistHtml2 = acLinksDiff(r.trackAc, r.recAc, true); recArtistCell = acLinksDiff(r.recAc, r.trackAc, true); } let recLenCls = (d.len || tolHas('length')) ? 'tc-diff' : '', recLenStyle = ''; if (dh && (d.len || tolHas('length'))) { const sh = lenShade((r.recLen || 0) - (r.trackLen || 0)); if (sh) { recLenCls = 'tc-dh-len'; recLenStyle = ' style="background:' + sh.bg + ';color:' + sh.fg + '"'; } } const tr = document.createElement('tr'); tr.className = 'tc-recrow' + (changed ? ' tc-recchanged' : ''); tr.dataset.mi = r.mi; tr.dataset.ti = r.ti; { const ps = recPendingState(r.mi, r.ti); if (ps && (ps.rec || ps.art)) { if (ps.rec) tr.classList.add('tc-rec-pending'); if (ps.art) tr.classList.add('tc-art-pending'); tr.title = ps.rec && ps.art ? 'Recording and artist have pending edits' : ps.rec ? 'This recording has pending edits' : 'This recording’s artist has pending edits'; } } // #376 tr.innerHTML = '<td class="c-n">' + esc(String(r.number == null ? '' : r.number)) + '</td>' + '<td class="tc-tkt">' + trackTitleHtml + '</td>' + '<td class="tc-tka">' + trackArtistHtml2 + '</td>' + '<td class="c-len">' + fmtMs(r.trackLen) + '</td>' + '<td class="c-sep"><span class="tc-dot"></span></td>' + '<td class="tc-recname ' + tCls + '">' + (r.recVideo ? VIDEO_MARK_PRE : '') + recTitleHtml + '</td>' + // #584 — marker LEADS the title, so it's scannable down a column of ragged titles '<td class="tc-recartist ' + aCls + '">' + recArtistCell + '</td>' + '<td class="c-len ' + recLenCls + '"' + recLenStyle + '>' + fmtMs(r.recLen) + '</td>'; const dot = tr.querySelector('.tc-dot'); if (r.conf) { dot.style.background = r.conf.color; dot.title = r.conf.label + ' — differs: ' + r.conf.diffs.join(', '); } else if (r.recGid) { dot.style.background = r.exact ? CONF_COLOR.exact : CONF_COLOR.tolerance; dot.title = r.exact ? 'Exact match' : 'Tolerance match' + (r.tolDiffs && r.tolDiffs.length ? ' (' + r.tolDiffs.join(', ') + ')' : ''); } else dot.style.visibility = 'hidden'; const nameCell = tr.querySelector('.tc-recname'); nameCell.classList.add('tc-clickable'); nameCell.title = 'change recording — suggestions / search' + (tElig ? ' · right-click: copy track title to recording (Ctrl: row · Alt: column · Ctrl+Alt: whole recording side)' : ''); nameCell.onclick = () => openRecPicker(r, nameCell); if (aElig) { const artCell = tr.querySelector('.tc-recartist'); if (artCell) artCell.title = 'right-click: copy track artist to recording (Ctrl: row · Alt: column · Ctrl+Alt: whole recording side)'; } // #583 — + "new recording", the picker's action lifted onto the row. It // holds the first position on the right so it sits at the same x on every // row; ↺ (which only appears when the row changed) tucks in beside it. if (!r.isNew) { const nw = document.createElement('button'); nw.className = 'tc-rec-new-btn'; nw.textContent = '+'; nw.title = 'unset this recording — the track will create a NEW recording on submit'; nw.onclick = e => { e.stopPropagation(); newRecordingFor(r); }; nameCell.appendChild(nw); } if (changed) { // per-row revert ↺ (single), shown on hover when changed const rev = document.createElement('button'); rev.className = 'tc-rec-rev'; rev.textContent = '↺'; rev.title = 'revert to the original recording'; rev.onclick = e => { e.stopPropagation(); revertRecording(r); }; nameCell.appendChild(rev); } // #348: hint the track-title / track-artist cells' right-click when the recording's value differs const tkt = tr.querySelector('.tc-tkt'); if (tkt && !r.isNew && r.recName != null && r.recName !== '' && (r.title || '') !== r.recName) { tkt.title = 'right-click: set track title to the recording title “' + r.recName + '” (Alt: whole column · Ctrl+Alt: whole track side)'; } const tka = tr.querySelector('.tc-tka'); if (tka && !r.isNew && r.recArtist != null && r.recArtist !== '' && (r.trackArtist || '') !== r.recArtist) { tka.title = 'right-click: set track artist to the recording artist “' + r.recArtist + '” (Alt: whole column · Ctrl+Alt: whole track side)'; } return tr; } // confidence level of a candidate vs the track: 0 tolerance(green) · 1 near · 2 low · 3 very low // "Cutoff" = the LOWEST confidence still auto-linked; anything below it is left unmatched. Levels run // best→worst: exact(blue) 0 · tolerance(green) 1 · near 2 · low 3 · very low 4. A candidate links // when its combined level ≤ the chosen cutoff. #119 const CUTOFF = { exact: 0, tolerance: 1, near: 2, low: 3, vlow: 4 }; function recComboLevel(d, ctx) { return recExactMatch(d, ctx) ? 0 : recConfLevel(d, ctx) + 1; } // fold exact/green into one ladder with the lower bands /* #540 — a TIE must not be settled by whichever candidate was seen first. majkinetor's release group has two distinct recordings both called "Toubaka", and its editions disagree about which sits at track 7 and which at track 10: 092b3f5e "Discotheque 71" Toubaka at 7 → f8df5b35 ebd5c67c Toubaka at 10 → f8df5b35 216a51c7 (the one edited) Toubaka at 7 → 53cb0616 Both candidates therefore score identically on title (and on length, being the same performance released twice), so the old `lvl < bestLevel` kept whichever the pool happened to yield first — and the position tier, which exists to disambiguate, offers the *other* one from a different edition. The result links the wrong recording and looks perfectly plausible, which is how it survived a visual check and only surfaced later as a wrong ISRC. So: collect every candidate at the best level, break the tie on evidence (an exactly-equal length, then a position the RG agrees on), and if it is STILL ambiguous leave the slot unset. An unmatched slot is visible and costs a click; a confidently wrong one is neither. */ /* #541 — "Matching improvements when same recording is on different positions". The position tie-break above has a hole: it trusts the slot even when the release group is precisely what disagrees. In majkinetor's group f8df5b35 sits at track 7 on one edition and track 10 on another, so if only the first edition happens to cover slot 7, "the group agrees" is an illusion built from a single contradictory source — and it would pick confidently, and wrongly. So a position only counts as evidence when it is STABLE: every tied candidate the index knows about must sit at exactly one position across the editions. A recording that moves between editions tells us nothing about which slot it belongs in here. `taken` additionally prefers a candidate no other slot has claimed this run: two slots grabbing the same recording is nearly always the greedy matcher double-spending one candidate rather than a genuine repeat. If the only candidate is already taken it is still used (a release CAN carry the same recording twice) — just noted by the caller. */ function recPositionsOf(posIndex, gid) { const at = new Set(); if (!posIndex || !gid) return at; posIndex.forEach((list, pk) => { if ((list || []).some(c => c && c.gid === gid)) at.add(pk); }); return at; } function recPickBest(cands, ctx, posGids, opts) { let bestLevel = Infinity; let tied = []; (cands || []).forEach(c => { if (!c) return; const lvl = (typeof c._level === 'number') ? c._level : recComboLevel(c, ctx); if (lvl < bestLevel) { bestLevel = lvl; tied = [c]; } else if (lvl === bestLevel) tied.push(c); }); if (!tied.length) return { best: null, level: Infinity, ambiguous: false, tied: [] }; // the same recording offered by several tiers is not a tie const byGid = new Map(); tied.forEach(c => { if (c.gid && !byGid.has(c.gid)) byGid.set(c.gid, c); }); let distinct = [...byGid.values()]; if (distinct.length > 1) { // 1. an exactly-equal length is real evidence; a mere "within tolerance" is not const exactLen = distinct.filter(c => c.length && ctx && ctx.length && recLenGap(c.length, ctx.length) === 0); if (exactLen.length === 1) distinct = exactLen; } if (distinct.length > 1 && posGids && posGids.length) { // 2. the release group's own editions: keep candidates that actually sit // at THIS slot — but only when none of the tied candidates wanders // between positions across those editions (#541). const idx = opts && opts.posIndex; const wanders = idx ? distinct.some(c => recPositionsOf(idx, c.gid).size > 1) : false; if (!wanders) { const atSlot = distinct.filter(c => c.gid && posGids.includes(c.gid)); if (atSlot.length === 1) distinct = atSlot; } } // 3. prefer one no other slot has already claimed in this run (#541) let reused = false; if (distinct.length > 1 && opts && opts.taken && opts.taken.size) { const free = distinct.filter(c => !opts.taken.has(c.gid)); if (free.length === 1) distinct = free; else if (free.length) distinct = free; } if (distinct.length === 1 && opts && opts.taken && opts.taken.has(distinct[0].gid)) reused = true; return { best: distinct[0], level: bestLevel, ambiguous: distinct.length > 1, tied: distinct, reused }; } function recConfLevel(data, ctx) { if (!ctx) return 0; let n = 0; const lenDiff = recLenGap(data.length, ctx.length); if (data.name && ctx.title && !recTitleEq(data.name, ctx.title)) n++; if (data.artist && ctx.artist && !sameAcEntities(data, ctx) && !recNameEq(data.artist, ctx.artist)) n++; // #190 same entity = credited-as only, not a diff if (lenDiff > 0) n++; if (n >= 3 && lenDiff > 10000) return 3; if (lenDiff > 15000) return 2; if (n >= 2 && lenDiff <= 15000) return 2; if (n === 1 || lenDiff > 3000) return 1; return 0; } /* #585 (chaban-mb) — a music video auto-linked onto a regular audio track. His examples are the expensive kind to undo: new recordings had to be made, lengths restored, wrong links and ISRCs stripped. The rule is not ours to invent — MusicBrainz already defines it, in code, as a database cleanup report (Report/VideosInNonVideoMediums.pm): r.video IS TRUE AND t.is_data_track IS FALSE AND m.format IN ($NON_VIDEO_FORMATS) so when Apollo links a video recording to a non-data track on one of those formats it is manufacturing new rows for that report. Position plays no part in it, which matters: the "videos come last" heuristic from the issue needs a format gate to be safe at all (a DVD's tracks are ALL videos), and it breaks on a trailing RUN of videos, where the earlier ones are not last — majkinetor raised exactly that. Format + data-track is exact, needs no ordering, and is free: both are already in the Knockout model. The list is MB's, transcribed. Formats not in it (DVD, Blu-ray, VHS, digital media…) can hold video, so nothing is refused there. Only the AUTO-matcher applies this — picking a video by hand in the picker is untouched, and #584's icon is right there while you do it. */ const NON_VIDEO_FORMAT_IDS = new Set([ 1, // CD 3, // SACD 6, // MiniDisc 7, // Vinyl 8, // Cassette 10, // Reel-to-reel 14, // Wax Cylinder 15, // Piano Roll 16, // DCC 25, // HDCD 29, // 7" Vinyl 30, // 10" Vinyl 31, // 12" Vinyl 34, // 8cm CD 36, // SHM-CD 37, // HQCD 38, // Hybrid SACD 42, // Enhanced CD 44, // DTS CD 50, // Edison Diamond Disc 51, // Flexi-disc 52, // 7" Flexi-disc 53, // Shellac 54, // 10" Shellac 55, // 12" Shellac 56, // 7" Shellac 57, // SHM-SACD 58, // Pathe disc 61, // Copy Control CD 63, // Hybrid SACD (CD layer) 64, // Hybrid SACD (SACD layer) 67, // DualDisc (CD side) 70, // DVDplus (CD side) 73, // Phonograph record 74, // PlayTape 75, // HiPac 81, // VinylDisc (Vinyl side) 82, // VinylDisc (CD side) 83, // Microcassette 84, // SACD (2 channels) 85, // SACD (multichannel) 86, // Hybrid SACD (SACD layer, multichannel) 87, // Hybrid SACD (SACD layer, 2 channels) 88, // SHM-SACD (multichannel) 89, // SHM-SACD (2 channels) 90, // Tefifon 128, // DataPlay 129, // Mixed Mode CD ]); // Would linking a VIDEO recording to this track put a row in MB's cleanup // report? Unknown format (null/0, "(unknown)") is left alone — refusing on // missing data would block legitimate matches on half-entered releases. function videoBlockedHere(mi, ti) { try { const med = mediums()[mi]; if (!med) return false; const fmt = Number(u(med.formatID)); if (!fmt || !NON_VIDEO_FORMAT_IDS.has(fmt)) return false; const ko = koTrack(mi, ti); if (ko && typeof ko.isDataTrack === 'function' && u(ko.isDataTrack())) return false; // data section can hold it return true; } catch (e) { return false; } } // Auto-match: for each UNSET track, load MB's suggestions and link the BEST-confidence one (not just // MB's first) when it clears the "ignore below" threshold. Already-linked tracks are left untouched. #119 let _autoMatching = false; let _autoMatchWaiting = false; // #616: the recording auto-match is only waiting for media to load (no writes yet) /* #582 — the pass's verdict, kept so a shell rebuild can put it back. The recordings toolbar is regenerated from static markup every time the tab is entered, and the auto-match usually finishes BEFORE that (it fires from the first showRecMirror), so the one line explaining why nothing happened was being wiped by the very click that went to look at it. Same shape as the #575 fix that re-adopts the running Match/Stop state on rebuild. */ let _recLastStatus = ''; async function autoMatchRecordings() { if (_autoMatching) return; _autoMatching = true; // #577 if (!_matching) _matchStop = false; // #575: don't clear a stop the tracklist pass has not acted on yet // #545: the status text alone was easy to miss while MusicBrainz was slow — // and the button stayed enabled and unchanged, so it read as "nothing // happened". Re-queried on each use rather than captured: the recordings // pane re-renders during the run (renderRecBody per link) and a captured // node would be stale by the second track. const amBtn = () => document.querySelector('#tc-recwrap .tc-rec-am'); const setBusy = (on) => { const b = amBtn(); if (!b) return; b.classList.toggle('busy', on); // #577: it used to go disabled here, which is exactly what made a long // pass unstoppable. It stays live and becomes the Stop button instead. b.disabled = false; const lbl = b.querySelector('.tc-rec-am-lbl'); if (lbl) lbl.textContent = on ? '■ Stop' : '⚡ Match'; b.title = on ? 'Stop matching — the recordings already linked are kept' : 'auto-match unset recordings to MusicBrainz suggestions'; const st = document.querySelector('#tc-recwrap .tc-rec-amstatus'); if (st) st.classList.toggle('busy', on); }; const setStatus = t => { const e = document.querySelector('#tc-recwrap .tc-rec-amstatus'); if (e) e.textContent = t; }; setBusy(true); const maxLevel = CUTOFF[SETTINGS.recCutoff || 'near']; let linked = 0, considered = 0, ambiguous = 0; // #540 let vidBlocked = 0; // #585 — video candidates refused on audio-only mediums let stopped = false; // #577 let noWork = ''; // #582: set to the reason when the pass has nothing to match const _takenGids = new Set(); // #541: recordings this run has already linked try { /* #582 (majkinetor): "When I open an existing release for editing the recording auto-matching runs even though all recordings are linked already." The pass fetched the release-group recording pool AND the position index BEFORE it ever looked at what needed matching — two /ws/2 round-trips (his took 26s and a 503 retry) only to report "linked 0 of 0 unset tracks". The read can't just move up unconditionally: auto-match fires from showRecMirror, and on a release with collapsed media MB is still loading tracks at that moment — an early read would see zero tracks and skipping then would be wrong. Nor is "wait until the list is non-empty" enough. On a multi-medium release the first medium can arrive with everything already linked while the others are still loading, and skipping on THAT would decide the whole release from a fraction of it — a narrowing against the old code, which read its to-do list after ~26s of lookups and so happened to catch the later media. Wait for every medium to report loaded as well, bounded, so a medium that is never going to load can't stall the pass. */ setStatus('reading tracklist…'); const allLoaded = () => { try { return mediums().every(mediumLoadedRec); } catch (e) { return true; } }; /* #616 (majkinetor: "Tracks not visible 10s after expanding medium"): the wait below used to run until EVERY medium reported loaded. A collapsed medium nobody opened is loaded:false and never loads on its own (MB loads it on expand), so on a release with many collapsed media — 14 on his — the pass always sat out the whole 15s budget. Wait only while MB is actually LOADING a medium (the #582 case: media still arriving at page open). */ const anyLoading = () => { try { return mediums().some(m => !mediumLoadedRec(m) && !!u(m.loading)); } catch (e) { return false; } }; let rows = readRecordings(); _autoMatchWaiting = true; // #616: reading only — the tracklist may resync meanwhile (see runSync) try { for (let t = 0; t < 60 && (!rows.length || anyLoading()); t++) { if (_matchStop) { stopped = true; break; } await new Promise(z => setTimeout(z, 250)); rows = readRecordings(); } } finally { _autoMatchWaiting = false; } if (!allLoaded()) Log.cat('Recording').debug('auto-match: ' + mediums().filter(m => !mediumLoadedRec(m)).length + ' medium(s) collapsed and not loading — deciding on the ' + rows.length + ' track(s) that are loaded'); if (!stopped && !rows.filter(r => !r.recGid).length) { noWork = rows.length ? 'all ' + rows.length + ' recording' + (rows.length === 1 ? '' : 's') + ' already linked' : 'no tracks loaded — nothing to match'; Log.cat('Recording').info('auto-match: skipped — ' + noWork + ' (no release-group lookup needed)'); return; // the finally below still restores the button and writes the status } // ONE request: pull the whole release group's recordings, index by normalised title, match locally let byTitle = new Map(), pool = []; let posIndex = new Map(), rgGidForDup = null, relTitleForDup = '', relArtistGidForDup = null, dupFetched = false; // #440 try { const rel = release(); const rg = rel && u(rel.releaseGroup); const rgGid = rg && u(rg.gid); rgGidForDup = rgGid || null; relTitleForDup = rel ? (u(rel.name) || '') : ''; relArtistGidForDup = (acArtistGids(u(rel && rel.artistCredit)) || [])[0] || null; if (rgGid) { setStatus('loading release-group recordings…'); pool = await fetchRgRecordings(rgGid); pool.forEach(p => { const k = recFold(p.name); if (!byTitle.has(k)) byTitle.set(k, []); byTitle.get(k).push(p); }); posIndex = await fetchRgPositionIndex(rgGid); // #440 position index from the RG's editions } } catch (e) { Log.cat('Recording').warn('RG pool load failed', e.message); } Log.cat('Recording').debug('rg=' + (rgGidForDup || 'none') + ' pool=' + pool.length + ' posIndex=' + posIndex.size + ' relTitle="' + relTitleForDup + '" relArtist=' + (relArtistGidForDup || 'none') + ' cutoff=' + (SETTINGS.recCutoff || 'near') + '(' + maxLevel + ')'); // #440 diag // #440 — the edit track's "<medium>.<track>" position, to look up sibling/duplicate recordings placed there. const posKeyOf = (r, ko) => { try { return (u(mediums()[r.mi].position) || (r.mi + 1)) + '.' + (u(ko.position) || r.number || (r.ti + 1)); } catch (e) { return null; } }; const todo = readRecordings().filter(r => !r.recGid); for (let i = 0; i < todo.length; i++) { if (_matchStop) { stopped = true; break; } // #577 const r = todo[i]; considered++; setStatus('auto-matching ' + (i + 1) + '/' + todo.length + '…'); const ko = koTrack(r.mi, r.ti); const ctx = { title: r.title, artist: r.trackArtist, length: r.trackLen, artistGids: acArtistGids(u(ko.artistCredit)) }; // best candidate from the local RG pool (normalised-title match; fuzzy scan if a Title tolerance is set). // lower confidence level wins; within the same level, an EXACT (no-tolerance) match is preferred. #119 // #540: every candidate is kept, with the level it scored, so a tie can be // recognised at the end instead of being decided by arrival order. let best = null, bestLevel = Infinity; const seen = []; /* #585 — drop video candidates this track cannot legitimately hold, before they are scored. Done here rather than inside recConfLevel because that function also colours the picker: penalising video-ness there would mark a DELIBERATE pick as low confidence, and would tar every candidate on a DVD, where they are all videos. */ const videoBlocked = videoBlockedHere(r.mi, r.ti); let vidSkipped = 0; const note = (d, lvl) => { if (!d) return; if (videoBlocked && d.video) { vidSkipped++; return; } d._level = lvl; seen.push(d); if (lvl < bestLevel) { bestLevel = lvl; best = d; } }; const consider = d => note(d, recComboLevel(d, ctx)); // lower combined level (exact < tolerance < near < …) wins // #440 — a POSITION candidate that passed the similarity gate AND matches on LENGTH is // strong evidence it's the same recording, even when the recording's canonical title or // artist differ from the imported track (punctuation "Part"/"Pt.", or an extra backing // act like "… & Orchestre Afrisa"). Those diffs otherwise drag recComboLevel to 'low' and // it never links. Floor such a candidate to 'near' so it clears the default cutoff; a // DIFFERING length keeps its (worse) computed level, so this can't over-link. const considerPos = d => { let lvl = recComboLevel(d, ctx); if (d.length && ctx.length && recLenGap(d.length, ctx.length) === 0) lvl = Math.min(lvl, CUTOFF.near); note(d, lvl); }; // #662: the track's ISRC from First Contact, ahead of every other tier const isrc = fcIsrcFor(r.mi, r.ti, r.title); if (!isrc && fcHandoff()) Log.cat('Recording').debug('track ' + (r.number || (r.ti + 1)) + ' no ISRC in the First Contact handoff for this track'); if (isrc) { setStatus('auto-matching ' + (i + 1) + '/' + todo.length + '… (ISRC)'); const held = (await isrcRecordings(isrc)).filter(d => !(videoBlocked && d.video)); const agree = held.filter(d => isrcAgrees(d, ctx)); Log.cat('Recording').info('track ' + (r.number || (r.ti + 1)) + ' ISRC ' + isrc + ' → ' + held.length + ' recording(s)' + (held.length ? ': ' + held.map(d => '"' + d.name + '" [' + (d.gid || '').slice(0, 8) + ']' + (isrcAgrees(d, ctx) ? ' agrees' : ' differs')).join(', ') : '')); if (held.length === 1 && agree.length === 1) { note(agree[0], -1); // unique and agreeing: ranks above even an exact title match, so it can never tie with one Log.cat('Recording').info('track ' + (r.number || (r.ti + 1)) + ' ISRC: "' + agree[0].name + '" is the only recording with ' + isrc + ' and agrees on title and artist, ranked first'); } else { // several holders, or one that disagrees: offered on their own merits, never forced held.forEach(consider); if (held.length) Log.cat('Recording').info('track ' + (r.number || (r.ti + 1)) + ' ISRC: ' + (held.length > 1 ? held.length + ' recordings share ' + isrc : 'the recording with ' + isrc + ' differs from the track') + ', not linked on the ISRC alone'); } } let cands = byTitle.get(recFold(r.title)) || []; if (!cands.length && (SETTINGS.recTitleTol || 0) > 0 && pool.length) cands = pool.filter(p => recTitleEq(p.name, r.title)); cands.forEach(consider); const _pkDiag = posKeyOf(r, ko); // #440 diag Log.cat('Recording').debug('track ' + (r.number || (r.ti + 1)) + ' "' + r.title + '" pos=' + _pkDiag + ' len=' + (r.trackLen || '?') + ' → titleCands=' + cands.length + ' bestLevel=' + (best ? bestLevel : '∞')); // #440 — position + similarity from the RG's editions (and possible duplicates // outside the RG), when the title match didn't already clear the cutoff. The // same slot in a duplicate holds the right recording even when its title is // worded differently ("Salongo Part 1" ↔ "Salongo, Pt. 1"); the similarity gate // keeps a divergent edition from mislinking an unrelated song at that position. if (!best || bestLevel > maxLevel) { const pk = posKeyOf(r, ko); const tryPos = (tag) => { const at = (posIndex.get(pk) || []); const sim = at.filter(c => c.gid && posTitleAgrees(c, r)); Log.cat('Recording').debug('track ' + (r.number || (r.ti + 1)) + ' posTier[' + tag + '] pos=' + pk + ' atSlot=' + at.length + ' similar=' + sim.length + (at.length ? ' [' + at.slice(0, 4).map(c => '"' + c.name + '"' + (recSimilar(c.name, r.title) ? '✓' : posTitleAgrees(c, r) ? '✓script' : '✗') + (c.length && r.trackLen && recLenGap(c.length, r.trackLen) === 0 ? '=len' : '')).join(', ') + ']' : '')); sim.forEach(considerPos); }; // #440 diag if (pk) { tryPos('rg'); if ((!best || bestLevel > maxLevel) && !dupFetched && relTitleForDup) { // widen to possible duplicates once (works even on a fresh import with no RG yet, #440) dupFetched = true; setStatus('scanning duplicates…'); const before = posIndex.size; await fetchDuplicatePositionIndex(relTitleForDup, relArtistGidForDup, rgGidForDup, posIndex); Log.cat('Recording').debug('duplicate search for "' + relTitleForDup + '" (artist=' + (relArtistGidForDup || 'none') + ') → posIndex ' + before + '→' + posIndex.size); /* #575 round 4: "it often shows 'scanning duplicates', although there either aren't any or all are 0% similar". The status was written when the scan STARTED and never again until the next track began, so a search that found nothing — his took 23s and four throttled retries before giving up — left the pane claiming to be scanning long after it had stopped. Say what came back. */ setStatus(posIndex.size > before ? `auto-matching ${i + 1}/${todo.length}…` : `auto-matching ${i + 1}/${todo.length}… (no duplicate editions found)`); tryPos('dup'); } } } // only fall back to MB's per-track lookup (network) when the pool had nothing good enough if (!best || bestLevel > maxLevel) { let sugg = (typeof ko.suggestedRecordings === 'function' ? (u(ko.suggestedRecordings) || []) : []); if (!sugg.length) { try { getEditor().recordingAssociation.findRecordingSuggestions(ko); } catch (e) {} for (let t = 0; t < 28; t++) { await new Promise(z => setTimeout(z, 250)); const loading = typeof ko.loadingSuggestedRecordings === 'function' ? u(ko.loadingSuggestedRecordings) : false; sugg = u(ko.suggestedRecordings) || []; if (!loading && sugg.length) break; if (!loading && t >= 3) break; } } for (let s = 0; s < sugg.length; s++) { consider(suggData(sugg[s])); if (bestLevel === 0) break; } // 0 = exact, can't do better } // #540: settle the winner on evidence, and refuse to guess between equals. const pk2 = posKeyOf(r, ko); const posGids = pk2 ? (posIndex.get(pk2) || []).map(c => c.gid).filter(Boolean) : []; const pick = recPickBest(seen, ctx, posGids, { posIndex, taken: _takenGids }); best = pick.best; bestLevel = pick.level; if (pick.ambiguous && bestLevel <= maxLevel) { ambiguous++; Log.cat('Recording').warn('track ' + (r.number || (r.ti + 1)) + ' AMBIGUOUS — ' + pick.tied.length + ' recordings tie at level ' + bestLevel + ': ' + pick.tied.map(c => '"' + c.name + '" [' + (c.gid || '').slice(0, 8) + ']' + (c.length ? ' ' + Math.round(c.length / 1000) + 's' : '')).join(', ') + ' — left unset, pick one by hand (the release group disagrees about which belongs here)'); } else if (best && bestLevel <= maxLevel) { Log.cat('Recording').debug('track ' + (r.number || (r.ti + 1)) + ' LINK → "' + best.name + '" [' + (best.gid || '').slice(0, 8) + '] level=' + bestLevel + ' (≤' + maxLevel + ')'); // #541: a release can legitimately carry the same recording twice, so // this still links — but say so, because the usual cause is the // matcher spending one candidate on two slots. if (pick.reused) Log.cat('Recording').warn('track ' + (r.number || (r.ti + 1)) + ' reuses "' + best.name + '" [' + (best.gid || '').slice(0, 8) + '], already linked to an earlier track — correct only if this release really repeats it'); try { ko.setRecordingValue(recEntityFrom(best)); linked++; if (best.gid) _takenGids.add(best.gid); renderRecBody(); } catch (e) { Log.cat('Recording').warn('auto-match set failed', e.message); } } else Log.cat('Recording').debug('track ' + (r.number || (r.ti + 1)) + ' NO LINK — best=' + (best ? '"' + best.name + '" level=' + bestLevel + ' > cutoff ' + maxLevel : 'none')); // #440 diag if (vidSkipped) { // #585 vidBlocked += vidSkipped; Log.cat('Recording').info('track ' + (r.number || (r.ti + 1)) + ': skipped ' + vidSkipped + ' video recording(s) — this medium’s format cannot hold video and the track is not a data track (MusicBrainz would list it under "Videos in non-video mediums")'); } } } finally { _autoMatching = false; rerenderRec(); setBusy(false); // #545 — after rerenderRec, which replaces the button node const w = document.getElementById('tc-recwrap'); const e = w && w.querySelector('.tc-rec-amstatus'); // #540: an ambiguous slot is a result, not a silence — say so where the count is. // #577: a stop has to name itself, or "linked 6 of 42" reads as a result // rather than as an interruption. What was linked stays linked. _recLastStatus = noWork ? noWork // #582 — say why nothing happened, not "linked 0 of 0" : (stopped ? 'stopped · ' : '') + 'linked ' + linked + ' of ' + considered + ' unset track' + (considered === 1 ? '' : 's') + (ambiguous ? ' · ' + ambiguous + ' ambiguous, left for you' : '') + (vidBlocked ? ' · ' + vidBlocked + ' video skipped' : ''); // #585 if (e) e.textContent = _recLastStatus; // #575: named for its pass. Plain "auto-match:" read as the whole match // finishing, while the tracklist's artist pass was still going. if (!noWork) Log.cat('Recording').info('auto-match:' + (stopped ? ' STOPPED —' : ''), 'linked', linked, 'of', considered, 'unset tracks' + (ambiguous ? ', ' + ambiguous + ' left unset as ambiguous' : '') + (vidBlocked ? ', ' + vidBlocked + ' video candidate(s) refused on an audio-only medium' : '') + (stopped ? ' — the rest are untouched' : '')); // #585 if (!_matching) _matchStop = false; // #575: leave it set while the tracklist pass is still winding down } } // submit-flag setters (per track / all tracks) + a light re-render of the recordings table function setCopy(field, entry, on) { try { const t = koTrack(entry.mi, entry.ti); if (field === 'title') t.updateRecordingTitle(on); else t.updateRecordingArtist(on); } catch (e) { Log.cat('Recording').warn('set copy ' + field + ' failed', e.message); } } function rerenderRec() { renderRecBody(); } // body only — keeps the toolbar (status / inputs) intact /* ── original-recording snapshot for revert + clear-all (#119) ── */ const _recOrig = new Map(); // Additive: snapshots tracks not yet captured, keeping the earliest (page-load) // baseline. Re-run safely after a collapsed medium is expanded (#149) so its // newly-loaded tracks also get a revert baseline. function snapshotRecOriginals() { mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const key = mi + ':' + ti; if (_recOrig.has(key)) return; const r = u(t.recording); _recOrig.set(key, { entity: r, gid: r ? u(r.gid) : null, isNew: typeof t.hasNewRecording === 'function' ? !!u(t.hasNewRecording) : false }); })); } function _restoreRec(entry, o) { const ko = koTrack(entry.mi, entry.ti); try { if (o.isNew) ko.hasNewRecording(true); else if (o.entity) ko.setRecordingValue(o.entity); } catch (e) { Log.cat('Recording').warn('revert recording failed', e.message); } } function recChangedFromOrig(mi, ti) { const o = _recOrig.get(mi + ':' + ti); if (!o) return false; const ko = koTrack(mi, ti), r = u(ko.recording); const curGid = r ? u(r.gid) : null, curNew = typeof ko.hasNewRecording === 'function' ? !!u(ko.hasNewRecording) : false; return curGid !== o.gid || curNew !== o.isNew; } function revertRecording(entry) { const o = _recOrig.get(entry.mi + ':' + entry.ti); if (o) { _restoreRec(entry, o); rerenderRec(); Log.cat('Recording').info('reverted recording for track', entry.ti + 1); } } function revertAllRecordings() { _recOrig.forEach((o, key) => { const p = key.split(':'); _restoreRec({ mi: +p[0], ti: +p[1] }, o); }); rerenderRec(); Log.cat('Recording').info('reverted all recordings to the page-load state'); } function clearAllRecordings() { if (!W.confirm('Set every track to a NEW recording (clear all existing recording links)?')) return; mediums().forEach(med => (u(med.tracks) || []).forEach(t => { try { t.hasNewRecording(true); } catch (e) {} })); rerenderRec(); Log.cat('Recording').info('cleared all recording links → new recordings'); } /* ── #583 — unset a recording link from the row ───────────────────── majkinetor: "When having to create new recordings because the links are wrong Apollo currently only allows unsetting all links or one-by-one via recording search dialog. The former can be too broad while the latter can take many clicks." Then, precisely: "[+] is now available when you open recording search, for quicker access without opening search popup, make it available as row hover action" — the picker's "add a new recording" action, lifted out of the popup and onto the row. (MBS-11635, MBS-10589.) Unsetting means what MB's editor and Apollo's own Clear all mean by it: the track is flagged to CREATE A NEW recording on submit. ↺ still reverts the row to its page-load link, so a mis-aimed + is one click to walk back. */ function newRecordingFor(entry) { try { koTrack(entry.mi, entry.ti).hasNewRecording(true); Log.cat('Recording').info('unset the recording on track ' + (entry.number || (entry.ti + 1)) + ' — it will create a NEW recording on submit (↺ puts the original back)'); } catch (e) { Log.cat('Recording').warn('unset recording failed for track ' + (entry.number || (entry.ti + 1)), e.message); } rerenderRec(); } /* ── recording picker (#119 P2.2): suggestions + search-by-name → setRecordingValue ── */ let _recPop = null, _recPopAnchor = null, _recPopPos = null; // _recPopPos persists a dragged location across reopens function closeRecPop() { if (!_recPop) return; _recPop.remove(); _recPop = null; _recPopAnchor = null; // keep _recPopPos — a moved panel stays put on the next row click document.removeEventListener('mousedown', _recPopOutside, true); document.removeEventListener('keydown', _recPopKey, true); window.removeEventListener('scroll', _recPopReposition, true); window.removeEventListener('resize', _recPopReposition); } function _recPopOutside(e) { if (_recPop && !_recPop.contains(e.target)) closeRecPop(); } function _recPopKey(e) { if (e.key === 'Escape') closeRecPop(); } // dock the picker as a tall panel whose RIGHT edge aligns with the status-circle column (so it sits // over the Track half, not far off to the side), using the full viewport height. // once the user drags it (header), leave its position alone. #119 function _recPopReposition() { if (!_recPop) return; const M = 10, W = _recPop.offsetWidth || 410; if (_recPopPos) { // user dragged it once — keep that spot (clamped into view), only refresh the height const left = Math.round(Math.max(M, Math.min(_recPopPos.left, window.innerWidth - W - M))); const top = Math.round(Math.max(M, Math.min(_recPopPos.top, window.innerHeight - 60))); _recPop.style.left = left + 'px'; _recPop.style.top = top + 'px'; _recPop.style.maxHeight = (window.innerHeight - top - M) + 'px'; return; } const wrap = document.getElementById('tc-recwrap'); const wr = wrap ? wrap.getBoundingClientRect() : null; const sep = wrap && wrap.querySelector('td.c-sep, th.c-sep'); // the status-circle column // align the popup's right edge with the status circles; fall back to the wrap's left edge let left = sep ? sep.getBoundingClientRect().left - W : (wr ? wr.left : M); _recPop.style.left = Math.round(Math.max(M, Math.min(left, window.innerWidth - W - M))) + 'px'; const top = Math.round(Math.max(M, Math.min(wr ? wr.top : 60, window.innerHeight - 240))); _recPop.style.top = top + 'px'; _recPop.style.maxHeight = (window.innerHeight - top - M) + 'px'; } // drag the picker by its header function _recPopDrag(hd) { hd.style.cursor = 'move'; hd.addEventListener('mousedown', e => { if (e.target.closest('button, a, input')) return; e.preventDefault(); const r = _recPop.getBoundingClientRect(), ox = e.clientX - r.left, oy = e.clientY - r.top; const mm = ev => { const left = Math.max(0, Math.min(ev.clientX - ox, window.innerWidth - 40)), top = Math.max(0, Math.min(ev.clientY - oy, window.innerHeight - 40)); _recPopPos = { left, top }; _recPop.style.left = left + 'px'; _recPop.style.top = top + 'px'; }; const mu = () => { document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu); }; document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu); }); } // a WS2 recording → the flat shape used by the picker / matcher (gid, name, length, artist text + raw ac, …) function mapWsRec(r) { (r['artist-credit'] || []).forEach(a => a.artist && noteDisamb(a.artist.id, a.artist.disambiguation)); // cache disambiguations (#195) return { gid: r.id, name: r.title, length: r.length || null, artist: (r['artist-credit'] || []).map(a => (a.name || (a.artist && a.artist.name) || '') + (a.joinphrase || '')).join(''), artistGids: (r['artist-credit'] || []).map(a => a.artist && a.artist.id).filter(Boolean), // #190 entity-aware artist match ac: r['artist-credit'] || [], // raw credit, so the linked recording keeps its artist on screen releases: (() => { const seen = new Set(), out = []; (r.releases || []).forEach(rl => { const k = rl.id || rl.title; if (rl.title && !seen.has(k)) { seen.add(k); const rg = rl['release-group']; out.push({ name: rl.title, gid: rl.id, rgGid: rg ? rg.id : null, rgName: rg ? rg.title : null }); } }); return out; })(), isrcs: r.isrcs || [], comment: r.disambiguation || '', video: !!r.video, // #303 — surface the video flag in the picker results }; } // every recording in a release group, fetched ONCE (paginated) — the pool auto-match matches against // locally, so a full release matches with ~one request instead of a per-track lookup each. #119 async function fetchRgRecordings(rgGid) { const out = []; let offset = 0; for (let page = 0; page < 12; page++) { const res = await wsJson(`${ORIGIN}/ws/2/recording?query=rgid:${encodeURIComponent(rgGid)}&fmt=json&limit=100&offset=${offset}&inc=artist-credits+releases+release-groups+isrcs`, { label: 'RG recordings' }); const j = res.json; if (!j) { Log.cat('Recording').warn('RG recordings: page', page + 1, 'failed — pool may be incomplete'); break; } (j.recordings || []).forEach(r => out.push(mapWsRec(r))); offset += 100; if (!(j.recordings || []).length || offset >= (j.count || 0)) break; } return out; } // #440 — POSITION index from a release's editions / duplicates. Keyed by // "<medium>.<track>" position → the recordings other releases place there // (with title/length/artist, so the caller can confirm by similarity). This is // what makes "Salongo Part 1" ↔ "Salongo, Pt. 1" resolvable: the title differs // too much for the title matcher, but the same position in a duplicate holds // the right recording, and the titles are *similar enough* to trust it. function addReleaseToPosIndex(rel, idx, rgGidOfEdit) { (rel.media || []).forEach(med => { const medLens = mediumLensOf(med); (med.tracks || []).forEach(t => { const rec = t.recording; if (!rec || !rec.id) return; const key = (med.position || 1) + '.' + (t.position || 0); const ac = (t['artist-credit'] && t['artist-credit'].length) ? t['artist-credit'] : (rec['artist-credit'] || []); const cand = { gid: rec.id, name: rec.title || t.title || '', length: rec.length || t.length || null, artist: ac.map(a => (a.name || (a.artist && a.artist.name) || '') + (a.joinphrase || '')).join(''), artistGids: ac.map(a => a.artist && a.artist.id).filter(Boolean), ac, isrcs: rec.isrcs || [], comment: rec.disambiguation || '', video: !!rec.video, relTitle: rel.title || '', sameRg: !rgGidOfEdit || (rel['release-group'] && rel['release-group'].id === rgGidOfEdit), medLens, }; if (!idx.has(key)) idx.set(key, []); const arr = idx.get(key); if (!arr.some(c => c.gid === cand.gid)) arr.push(cand); }); }); } // RG editions: every edition's tracklist by position — from the shared release-group // lookup (#626), so this pass and the artist pass make that request once between them. async function fetchRgPositionIndex(rgGid) { const idx = new Map(); const rels = await rgReleases(rgGid); if (rels) rels.forEach(rel => addReleaseToPosIndex(rel, idx, rgGid)); Log.cat('Recording').debug('RG position index:', idx.size, 'position(s) from', (rels || []).length, 'edition(s)'); return idx; } // Cross-RG duplicates (#440, majkinetor: "go outside RG too, but consider Similarity"). // Find releases that share this release's title + artist but sit in a DIFFERENT RG, // and fold their tracklists into the position index. Gated later by per-track title // similarity, so a same-named-but-different album can't silently mislink. Bounded to // a handful of releases; only worth running when the RG editions came up short. async function fetchDuplicatePositionIndex(title, artistGid, rgGid, into) { const rels = await duplicateReleases(title, artistGid, rgGid); rels.forEach(rel => addReleaseToPosIndex(rel, into, rgGid)); Log.cat('Recording').debug('duplicate position index:', rels.length, 'outside-RG edition(s) folded in →', into.size, 'position(s)'); return into; } // #626: the search behind it, shared with the artist position source — the Tracklist and // Recordings passes run it once between them per release. The search is cached as a // promise, so parallel callers share one run; a run in which any lookup failed (#555) is // dropped from the cache afterwards, so a throttled one is retried rather than // remembered as "no duplicates". const _dupRelCache = new Map(); // title|artist|rg → Promise<releases[]> function duplicateReleases(title, artistGid, rgGid) { if (!title) return Promise.resolve([]); const key = [fold(title), artistGid || '', rgGid || ''].join('|'); if (_dupRelCache.has(key)) return _dupRelCache.get(key); let failed = false; const p = (async () => { const titleQ = `release:"${String(title).replace(/["\\]/g, '\\$&')}"`; const runQuery = async (q) => { const res = await wsJson(`${ORIGIN}/ws/2/release?query=${encodeURIComponent(q)}&fmt=json&limit=12`, { label: 'duplicate release search' }); if (!res.json) failed = true; return (res.json && res.json.releases) || []; }; const out = []; try { // Try title + release artist first, but fall back to TITLE-ONLY when that's empty: // a Various-Artists compilation credits the release to the VA placeholder, and // `arid:<VA>` matches nothing in the search index — so `title AND arid:VA` = 0 hits // and the editions were never found. The per-track similarity + length gate is the // real guard against a same-titled different album, so title-only is safe. #440 let releases = artistGid ? await runQuery(`${titleQ} AND arid:${artistGid}`) : []; if (!releases.length) releases = await runQuery(titleQ); const others = releases.filter(rl => !rl['release-group'] || rl['release-group'].id !== rgGid).slice(0, 6); for (const rl of others) { const res = await wsJson(`${ORIGIN}/ws/2/release/${rl.id}?inc=recordings+artist-credits+release-groups&fmt=json`, { label: 'duplicate release detail' }); if (res.json) out.push(res.json); else failed = true; } Log.cat('Recording').debug('duplicate releases for "' + title + '":', out.length, 'outside the release group' + (failed ? ' (a lookup failed — not cached)' : '')); } catch (e) { failed = true; Log.cat('Recording').warn('duplicate release search failed', e.message); } if (failed) _dupRelCache.delete(key); return out; })(); _dupRelCache.set(key, p); return p; } // similarity gate for a position candidate: titles must be close (≥60% by edit // distance) or one contained in the other — enough to bridge "Pt 1"/"Part 1" but // not to link an unrelated song that merely shares a slot in a divergent edition. function recSimilar(a, b) { const x = recFold(a), y = recFold(b); if (!x || !y) return false; if (x === y || x.includes(y) || y.includes(x)) return true; const d = recLev(x, y), m = Math.max(x.length, y.length); return m > 0 && (1 - d / m) >= 0.6; } // #626 (majkinetor): "Another case for this is localization … seed is on english and RG is // japanese". A title in another script shares no letters to compare ("Kalimba Night" and // "カリンバナイト"), so it is neither similar nor dissimilar. The position is trusted then on // the rest: the edition's medium has as many tracks as this one, and every length both know // agrees within the tolerance (three quarters of them known, at least two). A different album // that merely shares a slot doesn't line up track for track. function titleScript(s) { const n = {}; for (const ch of String(s || '')) { const k = /\p{Script=Latin}/u.test(ch) ? 'Latin' : /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(ch) ? 'CJK' : /\p{Script=Hangul}/u.test(ch) ? 'Hangul' : /\p{Script=Cyrillic}/u.test(ch) ? 'Cyrillic' : /\p{Script=Greek}/u.test(ch) ? 'Greek' : /\p{Script=Arabic}/u.test(ch) ? 'Arabic' : /\p{Script=Hebrew}/u.test(ch) ? 'Hebrew' : /\p{Script=Thai}/u.test(ch) ? 'Thai' : /\p{L}/u.test(ch) ? 'Other' : null; if (k) n[k] = (n[k] || 0) + 1; } let best = null; for (const k in n) if (!best || n[k] > n[best]) best = k; return best; } function scriptsDiffer(a, b) { const x = titleScript(a), y = titleScript(b); return !!(x && y && x !== y); } function mediumLensOf(med) { return (med.tracks || []).map(t => t.length || (t.recording && t.recording.length) || null); } function mediumAligned(lens, mi) { if (!lens || !lens.length) return false; let mine; try { mine = u(mediums()[mi].tracks).map(t => u(t.length) || null); } catch (e) { return false; } if (mine.length !== lens.length) return false; let known = 0; for (let k = 0; k < mine.length; k++) { if (!mine[k] || !lens[k]) continue; known++; if (recLenGap(mine[k], lens[k]) !== 0) return false; } return known >= Math.max(2, Math.ceil(mine.length * 0.75)); } // a position candidate's title agrees: similar, or in another script on a medium that lines up const posTitleAgrees = (c, r) => recSimilar(c.name, r.title) || (scriptsDiffer(c.name, r.title) && mediumAligned(c.medLens, r.mi)); // Returns an array of hits, or NULL when the lookup itself failed (throttled / // network / superseded). A failure must never masquerade as "0 results" — that // was #555: an intermittent 503 rendered a silent "no matches". async function searchRecordings(q, opts) { q = (q || '').trim(); if (!q) return []; Log.cat('Recording').debug('recording search:', JSON.stringify(q)); const url = `${ORIGIN}/ws/2/recording?query=${encodeURIComponent(q)}&fmt=json&limit=15&inc=artist-credits+releases+release-groups+isrcs`; const res = await wsJson(url, Object.assign({ label: 'recording search' }, opts || {})); if (!res.json) { Log.cat('Recording').warn('recording search:', JSON.stringify(q), '→ no result (' + (res.stale ? 'superseded' : 'lookup failed') + ')'); return null; } const out = (res.json.recordings || []).map(mapWsRec); Log.cat('Recording').debug('recording search:', JSON.stringify(q), '→', out.length, 'hit(s) of', res.json.count != null ? res.json.count : out.length); return out; } // direct lookup of one recording by MBID — backs pasting a recording MBID / URL // into the picker's search field (mirrors the artist picker). #189 async function fetchRecordingById(gid) { const res = await wsJson(`${ORIGIN}/ws/2/recording/${gid}?fmt=json&inc=artist-credits+releases+release-groups+isrcs`, { label: 'recording lookup' }); const j = res.json; Log.cat('Recording').debug('recording lookup:', gid, '→', j && j.id ? JSON.stringify(j.title || '') : 'none'); return j && j.id ? mapWsRec(j) : null; } // recordings sharing an ISRC — backs pasting an ISRC into the picker's search // field (one ISRC can map to several recordings). #196 async function fetchRecordingsByIsrc(isrc) { // NB: the `isrc` resource rejects `release-groups` as an inc param (unlike // the `recording` resource) — including it returns an error object with no // recordings, which is why an existing ISRC came back "not found". #196 const res = await wsJson(`${ORIGIN}/ws/2/isrc/${encodeURIComponent(isrc)}?fmt=json&inc=artist-credits+releases+isrcs`, { label: 'ISRC lookup' }); const out = ((res.json && res.json.recordings) || []).map(mapWsRec); Log.cat('Recording').debug('ISRC lookup:', isrc, '→', out.length, 'recording(s)'); return out; } /* #662: First Contact hands over each track's ISRC (Deezer, Apple Music, Tidal, …). A recording that already carries it on MusicBrainz is the strongest sign it is this track's recording, so the matcher tries it first and the picker lists it on top. The release's ISRCs are searched together, shared by both, and only on a page First Contact seeded. */ const _isrcRecCache = new Map(); // isrc → Promise<recordings> function fcIsrcFor(mi, ti, title) { const ht = fcTrackFor({ mi, ti, title }); const v = ht && ht.isrc ? String(ht.isrc).toUpperCase().replace(/[^A-Z0-9]/g, '') : ''; return /^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/.test(v) ? v : null; } // #662 (chaban-mb): every ISRC the handoff carries is looked up together, in as few recording // searches as possible (isrc:A OR isrc:B …), instead of one /isrc/ lookup each. The search index // trails the database a little; an ISRC added minutes ago is missed, which is acceptable here. const ISRC_BATCH = 40; // ISRCs per search, to keep the query url short function fcAllIsrcs() { const h = fcHandoff(); if (!h) return []; const out = []; (h.mediums || []).forEach(m => (m.tracks || []).forEach(t => { const v = t && t.isrc ? String(t.isrc).toUpperCase().replace(/[^A-Z0-9]/g, '') : ''; if (/^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/.test(v) && !out.includes(v)) out.push(v); })); return out; } async function searchRecordingsByIsrcs(isrcs) { const q = isrcs.map(i => 'isrc:' + i).join(' OR '); const recs = []; for (let offset = 0; ; offset += 100) { const res = await wsJson(`${ORIGIN}/ws/2/recording?query=${encodeURIComponent(q)}&fmt=json&limit=100&offset=${offset}`, { label: 'ISRC batch search' }); if (!res.json) throw new Error('ISRC batch search failed'); const page = (res.json.recordings || []).map(mapWsRec); recs.push(...page); if (!page.length || recs.length >= (res.json.count || 0)) break; // past 100 hits: the next page } Log.cat('Recording').debug('ISRC batch search:', isrcs.length, 'ISRC(s) →', recs.length, 'recording(s)'); return recs; } function isrcBatch(isrcs) { const want = isrcs.filter(i => !_isrcRecCache.has(i)); for (let k = 0; k < want.length; k += ISRC_BATCH) { const chunk = want.slice(k, k + ISRC_BATCH); const all = searchRecordingsByIsrcs(chunk); chunk.forEach(isrc => _isrcRecCache.set(isrc, all .then(recs => recs.filter(r => (r.isrcs || []).some(x => String(x).toUpperCase() === isrc)).map(r => Object.assign({}, r, { _isrc: isrc }))) .then(recs => { Log.cat('Recording').debug('ISRC', isrc, '→', recs.length, 'recording(s)'); return recs; }) .catch(e => { Log.cat('Recording').warn('ISRC batch search failed for', isrc, '—', e.message); _isrcRecCache.delete(isrc); return []; }))); } } function isrcRecordings(isrc) { if (!_isrcRecCache.has(isrc)) { const all = fcAllIsrcs(); isrcBatch(all.includes(isrc) ? all : [isrc, ...all]); // the first ask fetches the whole release's ISRCs } return _isrcRecCache.get(isrc); } // the ISRC holder agrees with the track on title and artist: the only kind auto-linked on the ISRC alone const isrcAgrees = (d, ctx) => !!(d && ctx && d.name && recTitleEq(d.name, ctx.title) && (!ctx.artist || !d.artist || sameAcEntities(d, ctx) || recNameEq(d.artist, ctx.artist))); function recEntityFrom(data) { if (data.entity) return data.entity; // suggestions are already full MB entities try { const spec = { entityType: 'recording', gid: data.gid, name: data.name, length: data.length || null }; // build the artist credit from the WS2 result so the recording shows its artist (not blank). #119 if (data.ac && data.ac.length) { spec.artistCredit = { names: data.ac.map(a => ({ name: a.name || (a.artist && a.artist.name) || '', joinPhrase: a.joinphrase || '', artist: W.MB.entity({ entityType: 'artist', gid: a.artist && a.artist.id, name: a.artist && a.artist.name }, 'artist'), })) }; } return W.MB.entity(spec, 'recording'); } catch (e) { Log.cat('Recording').warn('build recording entity failed', e.message); return null; } } function pickRecording(entry, data) { if (!data) return; const ent = recEntityFrom(data); if (!ent) return; try { koTrack(entry.mi, entry.ti).setRecordingValue(ent); Log.cat('Recording').info('linked recording', JSON.stringify(data.name), '→ track', entry.number); } catch (e) { Log.cat('Recording').warn('setRecordingValue failed', e.message); } closeRecPop(); rerenderRec(); } // "Add a new recording" — native binds this to the per-track hasNewRecording observable (#119) function pickNewRecording(entry) { try { koTrack(entry.mi, entry.ti).hasNewRecording(true); Log.cat('Recording').info('new recording for track', entry.number); } catch (e) { Log.cat('Recording').warn('hasNewRecording failed', e.message); } closeRecPop(); rerenderRec(); } // pull display data off a suggestion entity (releases live in appearsOn.results; isrcs may be objects) function suggData(s) { const e = u(s); const ap = u(e.appearsOn); const rels = []; const seen = new Set(); if (ap && ap.results) ap.results.forEach(r => { const rr = u(r); const name = u(rr.name), gid = u(rr.gid); const rg = u(rr.releaseGroup); const k = gid || name; if (name && !seen.has(k)) { seen.add(k); rels.push({ name, gid, rgGid: rg ? u(rg.gid) : null, rgName: rg ? u(rg.name) : null }); } }); const isrcs = (u(e.isrcs) || []).map(x => typeof x === 'string' ? x : (x && (x.isrc || u(x.isrc)))).filter(Boolean); return { entity: e, gid: u(e.gid), name: u(e.name), length: u(e.length), artist: acText(u(e.artistCredit)), artistGids: acArtistGids(u(e.artistCredit)), releases: rels, isrcs, video: !!u(e.video) }; // #303 video flag } // confidence of a picker result vs the track that opened the picker (same scheme as the table dot) function resultConfClass(data, ctx) { if (!ctx) return ''; const lvl = recConfLevel(data, ctx); if (lvl === 0 && recExactMatch(data, ctx)) return ' tc-conf-exact'; // ideal: blue left border return ' tc-conf-' + ['tolerance', 'near', 'low', 'vlow'][lvl]; } // render "appears on" releases as links (each {name,gid,rgGid?,rgName?}); plain strings tolerated for safety. // Releases of the SAME release group collapse to a single "RG ×N" link, like MB's own suggestions (#136). // cap = max entries shown before a "+N more" tail (0 = show all). #119 function relLinksHtml(relsArr, cap) { const arr = relsArr || []; // group by release group; releases with no RG info each stay their own entry (graceful fallback) const groups = [], byRg = new Map(); arr.forEach(rl => { const o = rl && typeof rl === 'object' ? rl : { name: rl }; const rgKey = o.rgGid || (o.rgName ? 'n:' + o.rgName : null); if (rgKey && byRg.has(rgKey)) { byRg.get(rgKey).count++; return; } const g = { rgGid: o.rgGid, rgName: o.rgName, name: o.name, gid: o.gid, count: 1 }; if (rgKey) byRg.set(rgKey, g); groups.push(g); }); const shown = cap ? groups.slice(0, cap) : groups; const html = shown.map(g => { if (g.count > 1) { // multiple releases in this RG → link the RG title with a count const label = esc(g.rgName || g.name); const a = g.rgGid ? '<a href="' + ORIGIN + '/release-group/' + esc(g.rgGid) + '" target="_blank" rel="noopener">' + label + '</a>' : label; return a + ' <span class="tc-rpk-rgx" title="' + g.count + ' releases in this release group">×' + g.count + '</span>'; } return g.gid ? '<a href="' + ORIGIN + '/release/' + esc(g.gid) + '" target="_blank" rel="noopener">' + esc(g.name) + '</a>' : esc(g.name); }).join(', '); const extra = groups.length - shown.length; return html + (extra > 0 ? ' <span class="tc-rpk-more">+' + extra + ' more</span>' : ''); } // render a WS2 artist-credit (the raw `artist-credit` array kept on mapped // recordings) as links + optional disambiguations. Network-free: the gids and // disambiguations come from the search response itself. #199 #195 function acLinksWs(ac, withDisamb) { const names = ac || []; if (!names.length) return ''; return names.map(n => { const a = n.artist || {}; noteDisamb(a.id, a.disambiguation); // feed the cache so the table shows it after a pick (#195) const nm = n.name || a.name || ''; const dis = withDisamb && a.disambiguation ? ' <span class="tc-rpk-adis">(' + esc(a.disambiguation) + ')</span>' : ''; const link = a.id ? '<a href="' + ORIGIN + '/artist/' + esc(a.id) + '" target="_blank" rel="noopener">' + esc(nm) + '</a>' : esc(nm); return link + dis + esc(n.joinphrase || ''); }).join(''); } // a picker result row — mirrors the native list: title + length, by artist, appears on, ISRCs; // left-border colour = confidence vs the track. Title links to the recording and the // artist credit links to each artist (with disambiguations), so an odd-looking result // can be inspected without first selecting it (#199 #195). Clicks on these links are // ignored by the row's pick handler (it bails on `closest('a')`). function recRowHtml(data, ctx) { const rels = relLinksHtml(data.releases, 6); const isrcs = (data.isrcs || []).slice(0, 4).join(', '); // highlight the fields that differ from the track, like the table does const dT = ctx && data.name && ctx.title && !recTitleEq(data.name, ctx.title); const dA = ctx && data.artist && ctx.artist && !recNameEq(data.artist, ctx.artist); const dL = !!(ctx && recLenGap(data.length, ctx.length) > 0); const titleHtml = data.gid ? '<a href="' + ORIGIN + '/recording/' + esc(data.gid) + '" target="_blank" rel="noopener">' + esc(data.name || '') + '</a>' : esc(data.name || ''); const artistHtml = (data.ac && data.ac.length) ? acLinksWs(data.ac, true) : esc(data.artist || ''); return '<div class="tc-rpk-row' + resultConfClass(data, ctx) + '" data-gid="' + esc(data.gid) + '">' + '<div class="tc-rpk-main">' + (data._isrc ? '<span class="tc-badge isrc tc-rpk-isrcm" title="has the ISRC First Contact read for this track, ' + esc(data._isrc) + '">ISRC</span> ' : '') + (data.video ? VIDEO_MARK_PRE : '') + // #584 — leading, same as the table '<span class="tc-rpk-name' + (dT ? ' tc-rpk-fdiff' : '') + '">' + titleHtml + '</span>' + (data.comment ? ' <span class="tc-rpk-cmt">(' + esc(data.comment) + ')</span>' : '') + '<span class="tc-rpk-len' + (dL ? ' tc-rpk-fdiff' : '') + '">' + (data.length ? fmtMs(data.length) : '') + '</span></div>' + (artistHtml ? '<div class="tc-rpk-by' + (dA ? ' tc-rpk-fdiff' : '') + '">by ' + artistHtml + '</div>' : '') + (rels ? '<div class="tc-rpk-on">appears on: ' + rels + '</div>' : '') + (isrcs ? '<div class="tc-rpk-isrc">ISRCs: ' + esc(isrcs) + '</div>' : '') + '</div>'; } function openRecPicker(entry, anchor) { recStyle(); closeRecPop(); const pop = document.createElement('div'); pop.className = 'tc-recpop'; _recPop = pop; _recPopAnchor = anchor; document.body.appendChild(pop); const ko = koTrack(entry.mi, entry.ti); const data = {}; const ctx = { title: u(ko.name), artist: acText(u(ko.artistCredit)), length: u(ko.length), artistGids: acArtistGids(u(ko.artistCredit)) }; // for result confidence colouring // the currently-linked recording (or "new recording" if that's flagged) const curRec = u(ko.recording); const isNew = typeof ko.hasNewRecording === 'function' && !!u(ko.hasNewRecording); const curGid = !isNew && curRec ? u(curRec.gid) : null; const curArtist = curRec ? acText(u(curRec.artistCredit)) : ''; // Title - Artist … Len, same shape as the header (length pushed to the right // edge); the artist links to the RECORDING's artist (acLinks), not plain text. #189 const curHtml = isNew ? '<span class="tc-rpk-newcur">+ new recording (created on submit)</span>' : curGid ? '<span class="tc-rpk-curmain">' + (u(curRec.video) ? VIDEO_MARK_PRE : '') // #303 video flag on the linked recording · #584 leads the title + '<a href="' + ORIGIN + '/recording/' + esc(curGid) + '" target="_blank" rel="noopener">' + esc(u(curRec.name) || '') + '</a>' + (u(curRec.comment) ? ' <span class="tc-rpk-cmt">(' + esc(u(curRec.comment)) + ')</span>' : '') // disambiguation on selection #144 + (curArtist ? ' <span class="tc-rpk-curby">- <span class="tc-rpk-curby-ac">' + acLinks(u(curRec.artistCredit), true) + '</span></span>' : '') + '</span>' + (u(curRec.length) ? '<span class="tc-rpk-curlen">' + fmtMs(u(curRec.length)) + '</span>' : '') : '<span class="tc-rpk-curnone">— none —</span>'; const trackArtist = ctx.artist, trackLen = u(ko.length); // Show the copy checkboxes whenever NATIVE MB offers them (rawTitleDiff / // rawArtistDiff proxy the native update checkboxes), not just when Apollo's // tolerance/casing-filtered diff fires — so casing-only diffs still offer it. #146 const showCopyT = !isNew && (entry.rawTitleDiff || entry.copyTitle), showCopyA = !isNew && (entry.rawArtistDiff || entry.copyArtist); pop.innerHTML = '<div class="tc-rpk-hd">' + '<span class="tc-rpk-hdmain"><b>' + esc(u(ko.name) || '') + '</b>' + (trackArtist ? '<span class="tc-rpk-hdby"> - ' + acLinks(u(ko.artistCredit)) + '</span>' : '') + '</span>' + (trackLen ? '<span class="tc-rpk-hdlen">' + fmtMs(trackLen) + '</span>' : '') + '</div>' + '<div class="tc-rpk-curwrap">' + '<div class="tc-rpk-cur">' + curHtml + '</div>' + // ISRC of the linked recording — makes it obvious when an ISRC drove the // selection (#196). Hidden until the async fill finds at least one. (curGid ? '<div class="tc-rpk-curisrc" style="display:none">ISRC: <span class="tc-rpk-curisrc-list"></span></div>' : '') + (curGid ? '<div class="tc-rpk-curon">appears on: <span class="tc-rpk-curon-list tc-rpk-dots">…</span></div>' : '') + '</div>' + (showCopyT || showCopyA ? '<div class="tc-rpk-copy">' + (showCopyT ? '<label><input type="checkbox" class="tc-rpk-ct"' + (entry.copyTitle ? ' checked' : '') + '> copy track <b>title</b> to the recording (on submit)</label>' : '') + (showCopyA ? '<label><input type="checkbox" class="tc-rpk-ca"' + (entry.copyArtist ? ' checked' : '') + '> copy track <b>artist</b> to the recording (on submit)</label>' : '') + '</div>' : '') + '<div class="tc-rpk-qwrap"><input class="tc-rpk-q" type="text" ' + NOPW_ATTRS + ' placeholder="search by name, or paste a recording MBID / URL / ISRC…"><button class="tc-rpk-qnew" type="button" title="+ new recording — create a brand-new recording for this track">+</button></div>' + '<div class="tc-rpk-sec tc-rpk-suggsec" title="click to collapse / expand"><span>suggestions<span class="tc-rpk-suggn"></span> <span class="tc-rpk-caret">▾</span></span></div><div class="tc-rpk-list tc-rpk-sugg"><div class="tc-rpk-empty tc-rpk-busy">finding suggestions…</div></div>' + '<div class="tc-rpk-sec">search results<button class="tc-rpk-relax" type="button" title="relaxed search — show all recordings with this title, ignoring artist & length">show all</button></div><div class="tc-rpk-list tc-rpk-res"><div class="tc-rpk-empty">type to search…</div></div>'; const newBtn = pop.querySelector('.tc-rpk-qnew'); if (newBtn) newBtn.onclick = () => pickNewRecording(entry); const ctEl = pop.querySelector('.tc-rpk-ct'); if (ctEl) ctEl.onchange = () => { setCopy('title', entry, ctEl.checked); rerenderRec(); }; const caEl = pop.querySelector('.tc-rpk-ca'); if (caEl) caEl.onchange = () => { setCopy('artist', entry, caEl.checked); rerenderRec(); }; // fill the current recording's full "appears on" (all releases, linkable) — not in the page model, so fetch it if (curGid) { wsJson(ORIGIN + '/ws/2/recording/' + curGid + '?fmt=json&inc=artist-credits+releases+release-groups+isrcs', { label: 'linked recording detail', stale: () => !_recPop }) .then(res => { const j = res.json; if (!_recPop) return; // a failed lookup must not render as "appears on: —" (that reads as "nowhere") — #555 if (!j) { const f = pop.querySelector('.tc-rpk-curon-list'); if (f && !res.stale) { f.classList.remove('tc-rpk-dots'); f.textContent = '(lookup failed)'; } return; } const el = pop.querySelector('.tc-rpk-curon-list'); if (el) { const seen = new Set(), rels = []; (j.releases || []).forEach(rl => { const k = rl.id || rl.title; if (rl.title && !seen.has(k)) { seen.add(k); const rg = rl['release-group']; rels.push({ name: rl.title, gid: rl.id, rgGid: rg ? rg.id : null, rgName: rg ? rg.title : null }); } }); el.classList.remove('tc-rpk-dots'); // stop the pulse — this is now a settled answer (#555) el.innerHTML = rels.length ? relLinksHtml(rels, 0) : '—'; } // Artist disambiguations in the header: the page (KO) model doesn't carry // them, so backfill from the WS2 artist-credit once it arrives (#195). const acEl = pop.querySelector('.tc-rpk-curby-ac'); if (acEl && j['artist-credit']) { const h = acLinksWs(j['artist-credit'], true); if (h) acEl.innerHTML = h; } // ISRC line — reveal only when the recording actually has ISRC(s). #196 const isrcWrap = pop.querySelector('.tc-rpk-curisrc'), isrcEl = pop.querySelector('.tc-rpk-curisrc-list'); const isrcs = (j.isrcs || []).filter(Boolean); if (isrcWrap && isrcEl && isrcs.length) { isrcEl.textContent = isrcs.join(', '); isrcWrap.style.display = ''; } }).catch(() => {}); } _recPopDrag(pop.querySelector('.tc-rpk-hd')); // header is the drag handle _recPopReposition(); // dock it right + tall now the content (and height) exist const q = pop.querySelector('.tc-rpk-q'), suggBox = pop.querySelector('.tc-rpk-sugg'), resBox = pop.querySelector('.tc-rpk-res'); // collapsible suggestions — click the header to fold it away and see search results immediately (remembered) const suggSec = pop.querySelector('.tc-rpk-suggsec'); const applySuggCollapse = () => pop.classList.toggle('tc-sugg-collapsed', !!SETTINGS.recSuggCollapsed); if (suggSec) { applySuggCollapse(); suggSec.onclick = () => { SETTINGS.recSuggCollapsed = !SETTINGS.recSuggCollapsed; saveSettings(); applySuggCollapse(); _recPopReposition(); }; } // click a row to pick it — but a click on a link (release / artist) inside the row just follows the // link (new tab) and leaves the picker open; it must NOT pick + close the window. #119 const wire = box => box.querySelectorAll('.tc-rpk-row').forEach(row => { row.onclick = e => { if (e.target.closest('a')) return; pickRecording(entry, data[row.dataset.gid]); }; }); // de-dupe: a recording already shown under SUGGESTIONS is not repeated in SEARCH RESULTS below (#119) const suggGids = new Set(); let lastResults = []; const paintResults = () => { const filtered = lastResults.filter(r => !suggGids.has(r.gid)); resBox.innerHTML = filtered.length ? filtered.map(d => recRowHtml(d, ctx)).join('') : '<div class="tc-rpk-empty">' + (lastResults.length ? 'all matches are shown in suggestions' : 'no matches') + '</div>'; wire(resBox); }; // the count next to the section label — the whole point when collapsed, since the // rows themselves are hidden and there's otherwise no hint anything is in there. #555 const suggN = pop.querySelector('.tc-rpk-suggn'); const setSuggCount = n => { if (suggN) suggN.textContent = n > 0 ? ' (' + n + ')' : ''; }; // suggestions are lazy in MB — render what's there, else trigger findRecordingSuggestions and poll let isrcList = []; // #662: recordings holding the track's ISRC, listed first const renderSugg = () => { const mbList = (typeof ko.suggestedRecordings === 'function' ? (u(ko.suggestedRecordings) || []) : []).map(suggData); const isrcGids = new Set(isrcList.map(d => d.gid)); const list = isrcList.concat(mbList.filter(d => !isrcGids.has(d.gid))); list.forEach(s => { data[s.gid] = s; }); if (!list.length) return false; suggGids.clear(); list.forEach(s => suggGids.add(s.gid)); suggBox.innerHTML = list.map(d => recRowHtml(d, ctx)).join(''); wire(suggBox); setSuggCount(list.length); Log.cat('Recording').debug('picker suggestions for track', entry.number, '→', list.length); if (lastResults.length) paintResults(); // suggestions arrived after a search → drop any now-duplicate rows return mbList.length > 0; // keep waiting for MB's own when only the ISRC holders are in }; { const isrc = fcIsrcFor(entry.mi, entry.ti, u(ko.name)); if (isrc) isrcRecordings(isrc).then(recs => { if (!_recPop || !recs.length) return; isrcList = recs; Log.cat('Recording').debug('picker: ISRC', isrc, '→', recs.length, 'recording(s) on top'); renderSugg(); }); } if (!renderSugg()) { try { getEditor().recordingAssociation.findRecordingSuggestions(ko); } catch (e) { Log.cat('Recording').warn('findRecordingSuggestions failed', e.message); } let tries = 0; const poll = () => { if (!_recPop) return; const loading = typeof ko.loadingSuggestedRecordings === 'function' ? u(ko.loadingSuggestedRecordings) : false; if (!loading && renderSugg()) { rerenderRec(); return; } // also refresh the ⊕ count on the row if (!loading && tries > 3) { suggBox.innerHTML = '<div class="tc-rpk-empty">no suggestions</div>'; return; } if (++tries < 40) setTimeout(poll, 250); }; setTimeout(poll, 250); } q.value = u(ko.name) || ''; q.title = 'free-form — raw MB query, e.g. isrc:USXXX… or artist:"…"'; // auto-query for THIS track. Narrow = title + artist + a ±10s length window (precise). Relaxed // ("show all") = title only, ignoring artist & length — for classical, covers, re-recordings. #119 // the relaxed/narrow choice is remembered (SETTINGS) so it carries across picker opens and reloads. let relax = !!SETTINGS.recRelax; const esq = s => String(s || '').replace(/(["\\])/g, '\\$1'); const autoQuery = () => { const title = u(ko.name), artist = acText(u(ko.artistCredit)), len = u(ko.length); if (relax) return title; // broad title search (covers / loose matches), identical to typing the title — NOT an exact phrase let qy = 'recording:"' + esq(title) + '"'; if (artist) qy += ' AND artist:"' + esq(artist) + '"'; if (len) qy += ' AND dur:[' + Math.max(0, len - 10000) + ' TO ' + (len + 10000) + ']'; return qy; }; let seq = 0, tmr = null; const runSearch = async (query, fallbackTitle) => { const my = ++seq; // paste a recording MBID or a /recording/<mbid> URL → resolve and LINK it // immediately (same as pasting an MBID in the artist picker). #189 const gid = mbidFrom(query); if (gid) { resBox.innerHTML = '<div class="tc-rpk-empty tc-rpk-busy">resolving recording…</div>'; const rec = await fetchRecordingById(gid); if (my !== seq || !_recPop) return; if (rec) pickRecording(entry, rec); // links the recording + closes the picker else resBox.innerHTML = '<div class="tc-rpk-empty">recording MBID not found</div>'; return; } // paste an ISRC → resolve to its recording(s). Exactly one → link // immediately (same as an MBID); several → list them to choose from. #196 const isrc = isrcFrom(query); if (isrc) { resBox.innerHTML = '<div class="tc-rpk-empty tc-rpk-busy">resolving ISRC ' + esc(isrc) + '…</div>'; const recs = await fetchRecordingsByIsrc(isrc); if (my !== seq || !_recPop) return; if (recs.length === 1) { pickRecording(entry, recs[0]); return; } // one hit → link + close if (recs.length > 1) { recs.forEach(rr => { data[rr.gid] = rr; }); lastResults = recs; paintResults(); return; } resBox.innerHTML = '<div class="tc-rpk-empty">no recording with ISRC ' + esc(isrc) + '</div>'; return; } resBox.innerHTML = '<div class="tc-rpk-empty tc-rpk-busy">searching…</div>'; // superseded queries are dropped before they cost a request (typing fires one // per pause and the WS2 gate is ~1 req/s); a throttled search says so instead // of rendering an empty list. #555 const sOpts = { stale: () => my !== seq || !_recPop, onThrottle: () => { if (my === seq && _recPop) resBox.innerHTML = '<div class="tc-rpk-empty tc-rpk-busy">MusicBrainz is throttling requests — retrying…</div>'; } }; let results = await searchRecordings(query, sOpts); if (fallbackTitle && results && !results.length) results = await searchRecordings(u(ko.name) || '', sOpts); // smart query too tight → broaden if (my !== seq || !_recPop) return; if (!results) { resBox.innerHTML = '<div class="tc-rpk-empty">search failed — MusicBrainz is busy, try again (see the log)</div>'; return; } results.forEach(rr => { data[rr.gid] = rr; }); lastResults = results; paintResults(); // paintResults hides any recording already listed under suggestions }; // "show all" toggles relaxed mode and re-runs the track-derived search (independent of any manual edit). // the button is painted from the remembered state on open, and the toggle persists it. #119 const relaxBtn = pop.querySelector('.tc-rpk-relax'); const paintRelax = () => { if (!relaxBtn) return; relaxBtn.classList.toggle('on', relax); relaxBtn.textContent = relax ? 'narrow' : 'show all'; relaxBtn.title = relax ? 'back to a precise search (title + artist + ±10s length)' : 'relaxed search — show all recordings with this title, ignoring artist & length'; }; paintRelax(); if (relaxBtn) relaxBtn.onclick = () => { relax = !relax; SETTINGS.recRelax = relax; saveSettings(); paintRelax(); runSearch(autoQuery(), !relax); }; // once the user edits the box, search their raw text (free Lucene); the initial run is the auto query q.oninput = () => { clearTimeout(tmr); tmr = setTimeout(() => runSearch(q.value, false), 300); }; q.focus(); q.select(); runSearch(autoQuery(), !relax); _recPopReposition(); setTimeout(() => { document.addEventListener('mousedown', _recPopOutside, true); document.addEventListener('keydown', _recPopKey, true); window.addEventListener('scroll', _recPopReposition, true); window.addEventListener('resize', _recPopReposition); }, 0); } // the Recordings tab panel (#recordings) — check the PANEL not the inner table (we hide the table) function recordingsVisible() { const p = document.getElementById('recordings'); return !!(p && p.offsetParent !== null); } // hide the native recording-assignment table and render the Apollo comparison table in its place. // Both read/write the same MB model, so toggling Original/Apollo lets you work in either view (#119). function showRecMirror() { _apolloUsed = true; recStyle(); applyHlColor(); snapshotRecOriginals(); // capture the page-load recording associations, for revert // Anchor on a loaded medium's assignation table when present; otherwise (every // medium collapsed, #149) mount into the recordings panel itself so we still // render — each collapsed medium then shows an expand control. const tbl = document.getElementById('track-recording-assignation'); const host = tbl ? tbl.parentElement : document.getElementById('recordings'); if (!host) return; let wrap = document.getElementById('tc-recwrap'); if (!wrap) { wrap = document.createElement('div'); wrap.id = 'tc-recwrap'; host.insertBefore(wrap, tbl || host.firstChild); } document.body.classList.add('tc-rec-on'); // CSS hides the native table + widens the column (no flash) renderRecMirror(wrap); // optional: auto-match the Recordings tab on load (settings), once per page session if (SETTINGS.autoMatchRec && !_recAutoMatchedOnce) { _recAutoMatchedOnce = true; setTimeout(() => autoMatchRecordings(), 0); } } let _recAutoMatchedOnce = false; function hideRecMirror() { document.body.classList.remove('tc-rec-on'); closeRecPop(); _recPopPos = null; // drop any dragged location so the next visit docks fresh const w = document.getElementById('tc-recwrap'); if (w) w.remove(); } /* ═══════════════════════════════════════════════════════════════════════ COMPACT NAVIGATION — hide the native editor's step-tab row + footer and relocate them compactly: a segmented step switcher (Release | Tracklist | Recordings | ⊟ Edit note) on the right of the entity-tab row, the wizard buttons (Cancel / Prev / Next / Finish) top-right by the title, and "Add medium" at the right of the tracklist table. Everything is a proxy to the still-present (visually-hidden) native control, so MB's wizard is untouched and the feature reverts cleanly when toggled off. ═══════════════════════════════════════════════════════════════════════ */ function navOn() { return SETTINGS.apolloEnabled !== false && SETTINGS.compactNav !== false; } // compact nav is part of Apollo — off with the global switch const DIFF_ICON = '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.1"/><line x1="5" y1="6" x2="11" y2="6" stroke="currentColor" stroke-width="1.1"/><line x1="5" y1="8.6" x2="11" y2="8.6" stroke="currentColor" stroke-width="1.1"/><line x1="5" y1="11.2" x2="8.5" y2="11.2" stroke="currentColor" stroke-width="1.1"/></svg>'; const STEP_DEFS = [ { key: 'information', label: 'Release', title: 'Release information' }, { key: 'duplicates-tab', label: 'Duplicates', title: 'Release duplicates' }, // Add-release only; hidden when its native tab is absent { key: 'tracklist', label: 'Tracklist', title: 'Tracklist' }, { key: 'recordings', label: 'Recordings', title: 'Recordings' }, { key: 'edit-note', diff: true, title: 'Edit note — review changes & add an edit note' } ]; // only the submit button is kept (Cancel/Prev/Next are reachable via the entity tabs / step switcher); // it shows ONLY when there are pending changes. Both forms are mirrored — whichever MB renders. const WIZ_DEFS = [ { id: 'enter', label: '✓ Enter edit', cls: 'tc-wiz-enter', find: f => f.querySelector('#enter-edit') || [...f.querySelectorAll('button')].find(b => /enter edit/i.test(b.textContent)) }, { id: 'finish', label: '✓ Finish', cls: 'tc-wiz-finish', find: f => f.querySelector('button[data-click="lastTab"]') || [...f.querySelectorAll('button')].find(b => /^\s*finish\s*$/i.test(b.textContent)) } ]; // Right-side paginators (#140) — proxy MB's native footer Cancel / Previous / Next. // MB data-binds their visibility (Prev hidden on the first step, Next on the last), // mirrored in syncNav via `vis(nat)`. Cancel carries the native `.negative` class. // #722 each by its data-click handler first (MB translates the labels); the text only for // a form without one. const PAGE_DEFS = [ { id: 'cancel', label: '✕ Cancel', cls: 'tc-wiz-cancel', find: f => f.querySelector('button[data-click="cancelPage"]') || f.querySelector('button.negative') || [...f.querySelectorAll('button')].find(b => /^\s*cancel\s*$/i.test(b.textContent)) }, { id: 'prev', label: '‹ Prev', cls: 'tc-wiz-prev', find: f => f.querySelector('button[data-click="previousTab"]') || [...f.querySelectorAll('button')].find(b => /previous/i.test(b.textContent)) }, { id: 'next', label: 'Next ›', cls: 'tc-wiz-next', find: f => f.querySelector('button[data-click="nextTab"]') || [...f.querySelectorAll('button')].find(b => /^\s*next\s*»?\s*$/i.test(b.textContent)) } ]; function hasChanges() { try { const re = W.MB && W.MB.releaseEditor; return !!(re && typeof re.allowsSubmission === 'function' && re.allowsSubmission()); } catch (e) { return false; } } function editorEl() { return document.getElementById('release-editor'); } function stepNavEl() { const e = editorEl(); return e && e.querySelector(':scope > ul.ui-tabs-nav'); } function navFooterEl() { const e = editorEl(); return e && e.querySelector(':scope > div.buttons'); } function stepLink(key) { const n = stepNavEl(); return n && n.querySelector('a[href="#' + key + '"]'); } function activeStepKey() { const n = stepNavEl(); const a = n && n.querySelector('li.ui-tabs-active a'); return a ? (a.getAttribute('href') || '').slice(1) : ''; } function vis(el) { return !!(el && el.style.display !== 'none' && !el.disabled); } // native inline display reflects MB's per-step show/hide let _navStyled = false; function navStyle() { if (_navStyled) return; _navStyled = true; const css = MBU_TOKENS + MBU_UI_CSS + ` .tc-nav-vh{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0!important} .tc-nav-steps{display:inline-flex;align-items:stretch;border:1px solid var(--mbu-accent);border-radius:7px;overflow:hidden;background:var(--mbu-bg);font-family:Arial,Helvetica,sans-serif} .tc-nav-step{font:600 13px Arial;padding:5px 14px;border:none;border-right:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-deep-text);cursor:pointer;line-height:1.5;white-space:nowrap} .tc-nav-step:last-child{border-right:none} .tc-nav-step:hover{background:var(--mbu-bg-raised)} .tc-nav-step.active{background:#6a4d9a;color:var(--mbu-text-on-accent)} .tc-nav-step.tc-nav-diff{display:flex;align-items:center;padding:5px 10px} .tc-nav-step.tc-nav-diff svg{width:15px;height:15px;display:block} /* mirror MB's native tab states: disabled (e.g. Recordings until the tracklist is complete) + error-tab (validation warnings) */ .tc-nav-step:disabled,.tc-nav-step.tc-nav-disabled{opacity:.45;cursor:not-allowed;color:var(--mbu-accent-text);background:var(--mbu-bg)} .tc-nav-step.tc-nav-warn{background:var(--mbu-error-bg);color:var(--mbu-error)} .tc-nav-step.tc-nav-warn.active{background:var(--mbu-error-bg);color:var(--mbu-error)} .tc-nav-step.tc-nav-warn::before{content:"⚠";margin-right:5px} .tabs.tc-nav-sticky{position:sticky;top:0;background:var(--mbu-bg);z-index:20} /* freeze the nav row when scrolling */ /* MB's green TAGGER badge (Picard) sits at the header's top-right and collides with the frozen nav — lift it clear above the nav row, and keep the sticky nav above it so it's never covered */ body:has(.tabs.tc-nav-sticky) .releaseheader span.tagger-icon{position:relative;z-index:1;transform:translateY(-16px)} /* with the step-tab row hidden, strip the editor's jQuery-UI frame + the panel's top padding so no empty box is left */ #release-editor.tc-nav-on{margin-top:0;padding:0;border:none;background:none;box-shadow:none} #release-editor.tc-nav-on > .ui-tabs-panel{padding-top:0;border:none} #tc-nav-steps-wrap{position:absolute;right:0;bottom:6px;z-index:5} #tc-nav-right{display:flex;align-items:center;gap:10px} #tc-nav-wiz{display:inline-flex;align-items:center;gap:2px} .tc-nav-wbtn{font:13px Arial;padding:3px 9px;border:1px solid transparent;background:none;border-radius:5px;cursor:pointer;color:var(--mbu-text-dim);display:inline-flex;align-items:center;gap:4px;white-space:nowrap} .tc-nav-wbtn:not(:disabled):hover{background:var(--mbu-bg-raised);border-color:var(--mbu-border)} .tc-nav-wbtn:disabled{opacity:.4;cursor:default;font-weight:normal;color:var(--mbu-text-weak);background:none;border-color:transparent} .tc-nav-wbtn.tc-wiz-finish,.tc-nav-wbtn.tc-wiz-enter{color:var(--mbu-ok);font-weight:600} .tc-nav-wbtn.tc-wiz-finish:hover,.tc-nav-wbtn.tc-wiz-enter:hover{background:var(--mbu-ok-bg);border-color:var(--mbu-ok)} .tc-addmed{font:13px Arial;padding:4px 12px;border:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);border-radius:5px;cursor:pointer;margin-left:auto} .tc-addmed:hover{background:var(--mbu-bg-raised)} /* #140 — full-width nav bar: step switcher + Finish on the left, Cancel/Prev/Next paginators on the right */ #tc-nav-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;position:sticky;top:0;z-index:20;background:var(--mbu-bg);padding:5px 2px 6px;border-bottom:1px solid var(--mbu-accent);margin-bottom:6px} #tc-nav-left{display:flex;align-items:center;gap:10px;min-width:0;flex-wrap:wrap} #tc-nav-pager{display:inline-flex;align-items:center;gap:4px;flex-shrink:0} .tc-nav-wbtn.tc-wiz-cancel{color:var(--mbu-error)} .tc-nav-wbtn.tc-wiz-cancel:not(:disabled):hover{background:var(--mbu-error-bg);border-color:var(--mbu-error)} .tc-nav-wbtn.tc-wiz-prev,.tc-nav-wbtn.tc-wiz-next{color:var(--mbu-accent-deep-text);font-weight:600;border-color:var(--mbu-accent);background:var(--mbu-bg-raised)} .tc-nav-wbtn.tc-wiz-prev:not(:disabled):hover,.tc-nav-wbtn.tc-wiz-next:not(:disabled):hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} /* #141 Zen editing — the nav-bar release title (shown only in zen) + hiding the page chrome */ #tc-nav-title{display:none} /* two lines (album / artist + versions), centred, tight so they fit the nav button height (#141) */ /* contain:inline-size — MB's #page is display:table, which grows to its content's max-content width, so an unwrapped long release title widened the whole page (medium warnings ended up off-screen); now it ellipsizes */ body.tc-zen-on #tc-nav-title{display:flex;flex-direction:column;justify-content:center;flex:1 1 0;min-width:0;contain:inline-size;text-align:center;line-height:1.15;padding:0 14px} #tc-nav-title a{color:inherit;text-decoration:none}#tc-nav-title a:hover{text-decoration:underline} #tc-nav-title .tc-nav-title-album{font:600 14px Arial;color:var(--mbu-accent-deep-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* #290: mirror MB's native "modification pending" mark (span.mp, #ffdd99) on the release name — the native header that carries it is hidden by zen. Inline span so it hugs the text; same gold as native. */ #tc-nav-title .tc-nav-title-name{border-radius:3px;padding:0 1px} #tc-nav-title.tc-nav-title-pending .tc-nav-title-name{background:var(--mbu-warn-bg);padding:0 5px} #tc-nav-title.tc-nav-title-pending .tc-nav-title-name a{color:var(--mbu-warn)} #tc-nav-title .tc-nav-title-artist{font:12px Arial;color:var(--mbu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #tc-nav-title .tc-nav-title-ver{color:var(--mbu-text-weak)} body.tc-zen-on .header,body.tc-zen-on .releaseheader,body.tc-zen-on #page > .tabs,body.tc-zen-on #footer{display:none!important} /* zen: drop the page's top spacing so the sticky nav bar pins flush to the top and doesn't drift on scroll (#141) */ body.tc-zen-on #page{padding-top:0!important;margin-top:0!important} body.tc-zen-on #page.fullwidth{padding-top:0!important;margin-top:0!important} /* MOBILE: the top nav bar wraps — step switcher + Finish on line 1, the Cancel/Prev/Next paginators stay grouped, and (in zen) the release title drops to its own full-width line so nothing gets squeezed or clipped. */ @media (max-width: 820px){ #tc-nav-bar{flex-wrap:wrap;gap:5px 8px;padding:5px 2px} #tc-nav-left{flex:1 1 auto} #tc-nav-pager{order:2} .tc-nav-step,.tc-nav-wbtn{padding:3px 8px;font-size:12px} body.tc-zen-on #tc-nav-title{order:3;flex:1 1 100%;text-align:left;padding:1px 2px 0} body.tc-zen-on #tc-nav-title .tc-nav-title-album, body.tc-zen-on #tc-nav-title .tc-nav-title-artist{white-space:normal} }`; const s = document.createElement('style'); s.id = 'tc-nav-style'; s.textContent = css; document.head.appendChild(s); } // build (once) the full-width nav bar at the top of the editor (#140): // left → step switcher (Release | Tracklist | Recordings | ⊟) + Finish/Enter // right → Cancel / Prev / Next paginators // Everything proxies the still-present (visually-hidden) native control. function buildNav() { navStyle(); if (document.getElementById('tc-nav-bar')) return; // already built const ed = editorEl(); if (!ed) return; // step switcher const steps = document.createElement('div'); steps.className = 'tc-nav-steps'; steps.id = 'tc-nav-steps'; STEP_DEFS.forEach(d => { const b = document.createElement('button'); b.className = 'tc-nav-step' + (d.diff ? ' tc-nav-diff' : ''); b.dataset.step = d.key; b.dataset.baseTitle = d.title; b.innerHTML = d.diff ? DIFF_ICON : d.label; b.title = d.title; b.onclick = () => { const l = stepLink(d.key); if (l) l.click(); setTimeout(syncNav, 40); }; steps.appendChild(b); }); // submit button (Finish / Enter edit) — sits to the right of the switcher const wiz = document.createElement('div'); wiz.id = 'tc-nav-wiz'; WIZ_DEFS.forEach(d => { const b = document.createElement('button'); b.className = 'tc-nav-wbtn ' + d.cls; b.dataset.wiz = d.id; b.textContent = d.label; b.onclick = () => { ensureApolloEditNote(); const f = navFooterEl(); const nat = f && d.find(f); if (nat) nat.click(); setTimeout(syncNav, 40); }; // #130: set the edit note on the compact-nav submit path too wiz.appendChild(b); }); // right-side paginators (Cancel / Prev / Next) const pager = document.createElement('div'); pager.id = 'tc-nav-pager'; PAGE_DEFS.forEach(d => { const b = document.createElement('button'); b.className = 'tc-nav-wbtn ' + d.cls; b.dataset.page = d.id; b.textContent = d.label; b.onclick = () => { const f = navFooterEl(); const nat = f && d.find(f); if (nat) nat.click(); setTimeout(syncNav, 40); }; pager.appendChild(b); }); const left = document.createElement('div'); left.id = 'tc-nav-left'; left.append(steps, wiz); const title = document.createElement('div'); title.id = 'tc-nav-title'; // #141 Zen: release title · artist · versions (shown only in zen) const bar = document.createElement('div'); bar.id = 'tc-nav-bar'; bar.append(left, title, pager); ed.insertBefore(bar, ed.firstChild); // a dedicated full-width row at the top of the editor (frozen on scroll) fillNavTitle(); } // Zen editing (#141): hide the site header, release header, entity tabs + footer, // leaving just the Apollo nav bar (which gains the release title) and the editor. function applyZen() { const on = !!(SETTINGS.zenMode && navOn() && editorEl()); document.body.classList.toggle('tc-zen-on', on); if (on) fillNavTitle(); } // populate the nav-bar title: "<album> by <artist> (N versions)", all links — // mirrors the native release header that zen hides. Cheap; only fills once. function fillNavTitle() { const el = document.getElementById('tc-nav-title'); if (!el) return; let rel; try { rel = release(); } catch (e) { return; } if (!rel) return; const gid = u(rel.gid), name = u(rel.name) || ''; if (!name) return; const artist = acLinks(u(rel.artistCredit)) || ''; // live-update: rebuild only when the title or artist actually changed (not every tick) (#141) const sig = name + '\x01' + artist; if (el.dataset.sig === sig) return; el.dataset.sig = sig; const album = gid ? '<a href="' + ORIGIN + '/release/' + esc(gid) + '" target="_blank" rel="noopener">' + esc(name) + '</a>' : esc(name); // version count — reuse the native release header's "see all versions" link let ver = ''; const rh = document.querySelector('.releaseheader'); const va = rh && (rh.querySelector('.subheader .small a[href*="/release-group/"]') || [...rh.querySelectorAll('a')].find(a => /version/i.test(a.textContent || ''))); // #722 by its address if (va) { const m = (va.textContent.match(/(\d+)/) || [])[1]; ver = ' <a href="' + esc(va.href) + '" target="_blank" rel="noopener" class="tc-nav-title-ver">(' + (m ? m + ' versions' : 'all versions') + ')</a>'; } el.innerHTML = '<div class="tc-nav-title-album"><span class="tc-nav-title-name">' + album + '</span></div>' + '<div class="tc-nav-title-artist">' + artist + ver + '</div>'; el.querySelectorAll('.tc-nav-title-album, .tc-nav-title-artist').forEach(d => { d.title = d.textContent.trim(); }); // the whole text, when it's cut to … } // keep the proxies in sync with the native state each tick function syncNav() { if (!navOn()) return; const active = activeStepKey(); document.querySelectorAll('#tc-nav-steps .tc-nav-step').forEach(b => { mbuCls(b, 'active', b.dataset.step === active); const link = stepLink(b.dataset.step), li = link && link.closest('li'); // mirror MB's native tab state if (!link) { mbuProp(b.style, 'display', 'none'); return; } // step's native tab not present on this page (e.g. Duplicates exists only on Add) mbuProp(b.style, 'display', ''); const dis = !!(li && li.classList.contains('ui-state-disabled')); const panel = document.getElementById(b.dataset.step); // MB sets error-tab for some errors but not link errors — also scan the panel for a visible field-error const err = !!((li && li.classList.contains('error-tab')) || (panel && panel.querySelector('.field-error[data-visible="1"]'))); mbuProp(b, 'disabled', dis); mbuCls(b, 'tc-nav-disabled', dis); mbuCls(b, 'tc-nav-warn', err); mbuProp(b, 'title', (dis && link && link.title) ? link.title : (b.dataset.baseTitle || b.title)); // disabled → MB's "enter all track info…" hint }); const f = navFooterEl(); const changed = hasChanges(); // the submit button appears only when there are pending changes // #290: MIRROR MB's native "modification pending" mark. MB wraps the release // title in <span class="mp"> (gold #ffdd99) when the entity has open edits — // it's there on entrance. Zen hides the native header, so reflect that exact // state onto the release name in our toolbar — read straight from the DOM, no // logic of our own. The .mp node stays in the DOM even while the header is hidden. const navTitle = document.getElementById('tc-nav-title'); if (navTitle) mbuCls(navTitle, 'tc-nav-title-pending', !!document.querySelector('.releaseheader h1 .mp')); WIZ_DEFS.forEach(d => { const proxy = document.querySelector('#tc-nav-wiz [data-wiz="' + d.id + '"]'); if (!proxy) return; const nat = f && d.find(f); mbuProp(proxy.style, 'display', (vis(nat) && changed) ? '' : 'none'); }); // paginators stay in a fixed position — Prev/Next are never hidden, just disabled // when MB's native button isn't applicable (Prev on the first step, Next on the last) (#140) PAGE_DEFS.forEach(d => { const proxy = document.querySelector('#tc-nav-pager [data-page="' + d.id + '"]'); if (!proxy) return; const nat = f && d.find(f); mbuProp(proxy, 'disabled', !vis(nat)); }); updateStickyOffsets(); } // stack the sticky Apollo toolbars BELOW the frozen entity-tab row (both default to top:0 and would // otherwise overlap, hiding the pinned tab row). The row height is dynamic, so measure it each sync. function updateStickyOffsets() { const tabs = document.getElementById('tc-nav-bar'); // #140: the full-width nav bar is the frozen header now const h = (navOn() && tabs) ? tabs.offsetHeight : 0; const w = document.getElementById('tc-mirror-wrap'); if (w) w.style.top = h ? h + 'px' : ''; const r = document.querySelector('#tc-recwrap .tc-rec-tb'); if (r) r.style.top = h ? h + 'px' : ''; } // the native "Add medium" OPENER — it lives in the editor footer (present on the Tracklist step) and // opens MB's add-medium parser dialog. (NB: the button inside #add-medium-dialog is the dialog's commit // button, disabled until tracks are parsed — proxying that one does nothing.) function nativeAddMediumBtn() { // #722 by its data-click (MB translates "Add medium"), the text for older markup const f = navFooterEl(); let b = f && (f.querySelector('button[data-click="open"]') || [...f.querySelectorAll('button')].find(x => /add medium/i.test(x.textContent))); if (b) return b; const tl = document.getElementById('tracklist'); return tl ? [...tl.querySelectorAll('button')].find(x => /add medium/i.test(x.textContent) && !x.closest('#add-medium-dialog')) : null; } // move "Add medium" to the right end of the Apollo tracklist (opposite "Add tracks"), proxying the native opener function relocateAddMedium() { const nat = nativeAddMediumBtn(); const want = navOn() && document.getElementById('tc-mirror-wrap') && nat; if (!want) { const p = document.getElementById('tc-addmed'); if (p) p.remove(); return; } if (document.getElementById('tc-addmed')) return; // proxy already placed (the add-row may rebuild → re-add then) const btn = document.createElement('button'); btn.id = 'tc-addmed'; btn.className = 'tc-addmed'; btn.title = 'add a new medium'; btn.textContent = '+ Add medium'; btn.onclick = () => { const n = nativeAddMediumBtn(); if (n) n.click(); }; const rows = document.querySelectorAll('.tc-medsec .tc-addrow'); const host = rows[rows.length - 1]; if (host) host.appendChild(btn); // far right of the last medium's add-tracks row else { const secs = document.querySelectorAll('.tc-medsec'); const last = secs[secs.length - 1]; if (last) { const row = document.createElement('div'); row.className = 'tc-addrow'; row.appendChild(btn); last.appendChild(row); } } // locked last medium → own right-aligned row } function applyNav() { if (!editorEl()) return; if (navOn()) { buildNav(); editorEl().classList.add('tc-nav-on'); const sn = stepNavEl(); if (sn) sn.classList.add('tc-nav-vh'); const f = navFooterEl(); if (f) f.classList.add('tc-nav-vh'); syncNav(); } else { editorEl().classList.remove('tc-nav-on'); const sn = stepNavEl(); if (sn) sn.classList.remove('tc-nav-vh'); const f = navFooterEl(); if (f) f.classList.remove('tc-nav-vh'); const tabs = document.querySelector('#page > .tabs, .tabs'); if (tabs) tabs.classList.remove('tc-nav-sticky'); ['tc-nav-bar', 'tc-nav-steps-wrap', 'tc-nav-addbar'].forEach(id => { const e = document.getElementById(id); if (e) e.remove(); }); } relocateAddMedium(); updateStickyOffsets(); } /* ── edit note: credit Apollo when it's used, appended to the bottom (keep import-script notes) ── */ let _apolloUsed = false; // set true once a tracklist/recordings mirror is shown — i.e. Apollo is in use function ensureApolloEditNote() { if (!_apolloUsed) return; const ta = document.getElementById('edit-note-text'); if (!ta) return; // MB's plain edit-note textarea (name=edit_note) const cur = ta.value || ''; if (/Apollo Editor/i.test(cur)) return; // already credited — don't duplicate const s = (typeof GM_info !== 'undefined' && GM_info.script) || {}; // same shape as the other scripts' edit notes const note = (s.name || 'Apollo Editor') + ' v' + scriptVersion() + ' by ' + (s.author || 'majkinetor') + ' - ' + (s.homepageURL || s.homepage || HELP_URL); const kept = cur.replace(/\s+$/, ''); // keep any existing note (import scripts etc.), append below const val = kept ? kept + '\n\n' + note : note; setEditNote(ta, val); } function setEditNote(ta, val) { // use the native setter so a React-controlled textarea actually picks up the change (plain .value can be ignored — #130) try { const set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; set.call(ta, val); } catch (e) { ta.value = val; } ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); } // append the note the moment the user submits — by then any import-script note is already present function watchSubmit() { document.addEventListener('click', e => { const b = e.target && e.target.closest && e.target.closest('button'); if (!b) return; // #722 Finish / Enter edit by their data-click, as MB translates the labels const f = navFooterEl(); if (f && f.contains(b) && (b.matches('[data-click="lastTab"], [data-click="submitEdits"]') || /^\s*(finish|enter edit)\s*$/i.test(b.textContent || ''))) { if (b.matches('[data-click="submitEdits"]')) { try { archiveOnSubmit(); } catch (x) { Log.cat('Archive').warn('archive: skipped, it failed:', x && x.message || x); } } // #736 ensureApolloEditNote(); } }, true); // capture phase: runs before MB reads the textarea for submission } /* ── #736: archive the release's links on the Wayback Machine ── */ // At Enter edit every save is fired at once and nothing waits for it: the page leaves seconds later, // and Save Page Now finishes on archive.org's side. #739 (majkinetor): only the release's own links, // not the links an edit note mentions (script repos, Harmony…), and nothing is written in the edit // note: the archive menu by each link on the entity pages finds the snapshots. What First Contact // archives for its import (the page as the user had it, its API data) is left to it; the imported // link itself is saved here, as the menu looks it up by that address (#739). const _arSent = new Set(); // a second Enter edit (MB refused the first) doesn't save again const arNorm = u => mbaShort(u).replace(/\/+$/, '').toLowerCase(); // the addresses to save for the release's links (Discogs's as its API: its site refuses archive.org) function archiveUrls(releaseLinks, skip) { const ok = u => /^https?:\/\//i.test(u) && !/^https?:\/\/([^/]*\.)?(archive\.org|musicbrainz\.org|metabrainz\.org)(\/|$)/i.test(u) && !skip.has(arNorm(u)) && !skip.has(arNorm(mbaSavable(u))); return [...new Map(releaseLinks.filter(ok).map(u => [arNorm(u), u])).values()].map(mbaSavable); } function archiveOnSubmit() { if (SETTINGS.archive === false) return; const log = Log.cat('Archive'); const h = fcHandoff(); const fc = new Set(((h && h.archive && h.archive.urls) || []).map(a => a.url).filter(Boolean).map(arNorm)); const send = archiveUrls(linkRows().map(r => r.url), fc).filter(u => !_arSent.has(u)), keys = !!mbaKeys(); if (!send.length) { log.debug('archive: no links to save'); return; } log.info(`archive: sending ${send.length} URL(s) to the Internet Archive (${keys ? 'Save Page Now 2 with your keys' : 'anonymous'}): ${send.join(', ')}`); if (!keys && send.length > 3) log.info('archive: anonymous saves are limited per connection, so some of these may be refused; archive.org keys in the settings raise the limit'); for (const u of send) { _arSent.add(u); mbaSave(u, log, false); } } /* ── #736: the Internet Archive menu by each external link in an entity page's sidebar ── */ // One faint history clock per link (majkinetor: "one icon that shows menu on click with full words"). // Plain links to the Wayback Machine: nothing is sent to archive.org until the user picks one. const WB_CLOCK = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.6 8a5.4 5.4 0 1 0 1.6-3.8"/><path d="M2.4 1.9v2.6H5"/><path d="M8 4.8V8l2.2 1.4"/></svg>'; function archiveMenuItems(url) { const save = mbaSavable(url), wb = 'https://web.archive.org/'; if (save !== url) return { head: 'Internet Archive · this site refuses archiving', items: [['Open the latest API data snapshot', wb + 'web/' + save], ['All API data snapshots (calendar)', wb + 'web/*/' + save], null, ['Save the API data now', wb + 'save/' + save]] }; return { head: 'Internet Archive', items: [['Open the latest snapshot', wb + 'web/' + url], ['Open the latest screenshot', wb + 'web/http://web.archive.org/screenshot/' + url], ['All snapshots (calendar)', wb + 'web/*/' + url], null, ['Save it now', wb + 'save/' + url]] }; } let _wbMenu = null; function openArchiveMenu(btn, url) { if (_wbMenu) { const same = _wbMenu.btn === btn; _wbMenu.close(); if (same) return; } const { head, items } = archiveMenuItems(url); const m = document.createElement('div'); m.className = 'tc-wbmenu mbu-ui'; m.innerHTML = `<div class="tc-wbmenu-h">${esc(head)}</div>` + items.map(i => i ? `<a href="${esc(i[1])}" target="_blank" rel="noopener">${esc(i[0])}</a>` : '<div class="tc-wbmenu-sep"></div>').join(''); document.body.appendChild(m); const r = btn.getBoundingClientRect(); m.style.top = (r.bottom + 4) + 'px'; m.style.left = Math.max(8, Math.min(r.right - m.offsetWidth, window.innerWidth - m.offsetWidth - 8)) + 'px'; btn.classList.add('on'); const close = () => { m.remove(); btn.classList.remove('on'); _wbMenu = null; off(); }; const off = mbuDismissOn(m, close, { ignore: '.tc-wb' }); m.addEventListener('click', e => { if (e.target.closest('a')) close(); }); _wbMenu = { btn, close }; } function mountArchiveMenus() { if (SETTINGS.archiveMenu === false) return; const mark = () => { const lis = document.querySelectorAll('#sidebar ul.external_links > li'); if (!lis.length) return; style(); lis.forEach(li => { if (li.querySelector('.tc-wb')) return; const a = li.querySelector('a[href^="http"]'); if (!a || /(^|\.)(musicbrainz|metabrainz|archive)\.org$/i.test(a.hostname)) return; const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-wb'; b.title = 'Internet Archive'; b.innerHTML = WB_CLOCK; b.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); openArchiveMenu(b, a.href); }); li.appendChild(b); }); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mark, { once: true }); else mark(); } // #412: pulse the pinned compact-nav bar while MB is submitting the edit — a big edit can take a // while. Drive it purely off MB's OWN indicator: the release editor inserts a // `Submitting edits…` node (a Knockout `if`-bound `.loading-message`) the moment submission // starts, and removes it if the submit fails. So we don't watch clicks at all — we watch for that // node appearing (→ pulse) and disappearing (→ stop). It can't fire while editing because the node // simply isn't in the DOM until MB actually submits; a successful submit navigates away. const _isSubmitNode = n => n && n.nodeType === 1 && /submitting edits/i.test(n.textContent || ''); function watchSubmitFlash() { const obs = new MutationObserver(muts => { for (const m of muts) { for (const n of m.addedNodes) if (_isSubmitNode(n)) document.body.classList.add('tc-saving'); for (const n of m.removedNodes) if (_isSubmitNode(n)) document.body.classList.remove('tc-saving'); } }); obs.observe(document.body, { childList: true, subtree: true }); } /* ── Release information takeover (#129): tidy the first tab — hide the help bubble, clean the external links, and add an Apollo gear. Toggled by Apollo's corner icon. ── */ let _riStyled = false; let _riExtFs = null; // the External-links <fieldset>, remembered so we can move it back function riStyle() { if (_riStyled) return; _riStyled = true; const css = MBU_TOKENS + MBU_UI_CSS + ` body.tc-ri-on .tc-ri-helphidden{display:none!important} /* #652: the release artist's and labels' badges and offers, by their fields (the Tracklist's colours) */ .tc-ri-am{display:flex;flex-wrap:wrap;gap:4px 10px;margin:4px 0 0;font:12px var(--mbu-font)} /* #652: credited-as in the label column, a row per artist line, lined up with it */ body.tc-ri-on #information .tc-ri-artlbl > :not(.tc-ri-lead){display:none!important} #information .tc-ri-artlbl{vertical-align:top;padding-top:0} .tc-ri-art .tc-aslot{position:relative} .tc-ri-leadrow{position:absolute;top:0;bottom:0;right:calc(100% + 10px);width:var(--tc-ri-lead-w,160px);display:flex;align-items:center;justify-content:flex-end;gap:6px;box-sizing:border-box} .tc-ri-leadrow .tc-credwrap{flex:1 1 auto;min-width:0;display:flex;align-items:center} #information .tc-ri-leadrow input.tc-cred{width:100%!important;max-width:none!important} /* otherwise the Tracklist's credited-as box, as is */ .tc-ri-art .tc-aslot{margin:0 0 4px} .tc-ri-art .tc-mirror td.c-art{box-shadow:none!important} .tc-ri-art .tc-search{flex:1 1 auto!important;min-width:0;border-radius:var(--mbu-radius)!important} #information .tc-ri-art .tc-search:not(.tc-has-nm) input.nm,#information .tc-ri-art .tc-search input.nm:focus{flex:1 1 auto!important;min-width:120px!important} /* a filled, unfocused name hugs its text like the Tracklist (× then disambiguation right after it) */ #release-editor #information .tc-ri-art .tc-search:not(:has(.tc-joinwrap)) .mk{margin-left:auto} /* #742: + at the right edge, as in the Tracklist; MusicBrainz's "#release-editor #information div.half-width button{margin:0}" beat the class-only rule */ #information .tc-ri-art .tc-joinwrap{flex:0 0 64px;justify-content:flex-end} /* narrow: the name and its disambiguation need the room */ #information .tc-ri-art input.tc-join{width:auto!important;flex:1 1 auto;min-width:0!important;text-align:right} .tc-ri-rb{flex:0 0 auto;min-width:44px;display:inline-flex;justify-content:flex-start;font:12px var(--mbu-font)} .tc-ri-chip{display:inline-flex;align-items:center;gap:5px} .tc-ri-nm{color:var(--mbu-text-dim)} /* #652: the Tracklist's artist cell in place of MusicBrainz's artist field */ body.tc-ri-on #information td.release-artist.tc-ri-artcell > :not(.tc-ri-art):not(.tc-ri-am){display:none!important} .tc-ri-art .tc-mirror{width:100%;border-collapse:collapse;table-layout:fixed} .tc-ri-art .tc-mirror td.c-art{padding:0;overflow:visible!important} .tc-ri-art,.tc-ri-art .tc-mirror{overflow:visible!important} /* MusicBrainz pins the form's inputs to 354px with !important: give the cell's fields back their Tracklist sizes */ #information .tc-ri-art :is(input.nm,input.tc-join){width:auto!important;max-width:none!important;min-width:0!important} #information .tc-ri-art .tc-search:not(.matched):not(.tc-has-nm) input.nm{flex:1 1 auto} .tc-ri-art .tc-acts{width:auto;min-width:22px} /* inside the label field, left of its search icon: the cell is too narrow for it beside the field */ #information span.autocomplete:has(> .tc-ri-lab),#information .tc-ri-labhost{position:relative} /* the Label field's search is Apollo's: + takes the search icon's place */ #information .tc-ri-labhost > img.search{display:none!important} .tc-ri-labmk{all:unset;position:absolute;right:4px;top:50%;transform:translateY(-50%);width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--mbu-ok);font:bold 15px/1 var(--mbu-font)} .tc-ri-labmk:hover{color:var(--mbu-accent-text)} /* #729 the name stops short of + (and of Mammoth's pin beside it), as on the artist field */ #information .tc-ri-labhost > input{padding-right:24px!important;box-sizing:border-box} html:has(.mmthf-pin) #information .tc-ri-labhost > input{padding-right:44px!important} .tc-ri-labhost.tc-lab-matched:not(.tc-lab-focus) > .tc-ri-labmk,.tc-ri-labhost.tc-lab-empty > .tc-ri-labmk{display:none} .tc-ri-labdis{position:absolute;top:50%;transform:translateY(-50%);color:var(--mbu-text-weak);font:italic 11px var(--mbu-font);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none} .tc-ri-labhost.tc-lab-focus > .tc-ri-labdis{display:none} .tc-labpop .tc-acmeta{margin-left:auto;padding-left:10px;font-size:10px;color:var(--mbu-text-weak);white-space:nowrap} .tc-ri-lab{position:absolute;right:28px;top:50%;transform:translateY(-50%);line-height:1;pointer-events:auto} /* still being matched: a spinner where the match badge will land */ .tc-ri-spin{display:inline-block;width:9px;height:9px;margin:0 4px;border:2px solid var(--mbu-accent-soft);border-top-color:var(--mbu-accent);border-radius:50%;animation:tc-ri-spin .7s linear infinite;vertical-align:middle;cursor:progress} @keyframes tc-ri-spin{to{transform:rotate(360deg)}} html:has(.mmthf-pin) .tc-ri-lab{right:48px} /* Mammoth pins its icon inside the field, just left of the search icon: sit clear of it */ :is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge{font-size:10px;font-weight:bold;border-radius:9px;padding:1px 7px;color:var(--mbu-text-on-accent);white-space:nowrap;cursor:help;text-transform:none} :is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.rg{background:#1f8a4c}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.set{background:#6c757d}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.high{background:#2f6fd6}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.disc{background:#0a7a8c} :is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.low{background:#e0a800}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.user{background:var(--mbu-accent)}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.cred{background:#b5179e}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.alias{background:#1f8a7a}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.pos{background:#a0522d}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.plat{background:#0b6e99} .tc-ls-auto{position:absolute;top:50%;transform:translateY(-50%);line-height:1;pointer-events:auto} .tc-ls-auto .tc-badge{font-size:10px;font-weight:bold;border-radius:9px;padding:1px 7px;color:var(--mbu-text-on-accent);white-space:nowrap;cursor:help;text-transform:none;background:#6f42c1} :is(.tc-ri-am,.tc-ri-rb) .tc-badge.tc-ri-pick{cursor:pointer} .tc-ri-am .tc-tic{display:inline-flex;align-items:center;justify-content:center;min-width:22px;min-height:22px;color:#0a7a8c;text-decoration:none;cursor:pointer} .tc-ri-am .tc-tic.discogs-conflict{color:#c0392b}.tc-ri-am .tc-tic.discogs-mismatch{color:#b4791f} .tc-ri-am .tc-ri-mk{all:unset;cursor:pointer;min-width:22px;min-height:22px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);color:var(--mbu-accent-text);font-size:13px} .tc-ri-am .tc-ri-mk:hover{background:var(--mbu-bg-hover)} /* hide the inline "?" help / info icons + guidance across the whole Release-information panel */ body.tc-ri-on #information .tooltip-wrapper, body.tc-ri-on #information .icon.help, body.tc-ri-on #information span.img.help, body.tc-ri-on #information .inline-help, body.tc-ri-on #information a.help, body.tc-ri-on #information .guidance, body.tc-ri-on #information .guidance-popover, /* hide help bubbles only — never a functional editor bubble (URL cleanup, add/edit link) */ body.tc-ri-on #information .bubble:not(:has(input,button,select,textarea)){display:none!important} /* MB's link-editor popups must clear our sticky compact-nav (z-index 20), else the first link's popup opens under the nav bar and looks cut off */ body.tc-ri-on .dialog.popover, body.tc-ri-on .bubble:has(input,button,select,textarea){z-index:50} /* #461: "add a new artist/label/…" opens an iframe inside a .modal-backdrop that makes its OWN stacking context at z-index:auto(0) — so the iframe's internal z-index can't escape to clear the artist-credit bubble we lift to 50 above. Elevate the whole backdrop context so the creation form sits on top of the credit editor, not trapped underneath it. */ body.tc-ri-on .modal-backdrop{z-index:210} /* ---- two-column layout: form on the left, external links lifted into the (now-used) right column ---- */ body.tc-ri-on #information{display:flex;flex-wrap:wrap;gap:14px 30px;align-items:flex-start;max-width:100%;box-sizing:border-box} /* wrap → links stack below the form on a narrow window instead of overlapping it */ body.tc-ri-on #information > div.half-width{flex:0 1 620px;min-width:0;width:auto} /* form keeps its natural width */ /* ---- #143: compact, low-noise Release-info form, matching Apollo's purple ---- */ body.tc-ri-on #information > div.half-width fieldset{border:none;margin:0 0 12px;padding:0} /* drop the boxy fieldset frames */ body.tc-ri-on #information > div.half-width legend{font:600 11px Arial;letter-spacing:.06em;text-transform:uppercase;color:var(--mbu-accent-text);padding:0 0 5px;margin:0 0 4px;border-bottom:1px solid var(--mbu-accent);width:100%;box-sizing:border-box} body.tc-ri-on #information table.row-form{border-collapse:collapse;width:100%} body.tc-ri-on #information table.row-form > tbody > tr > td{padding:2px 6px;vertical-align:middle} /* tighter rows */ body.tc-ri-on #information table.row-form label{font-size:12px;color:var(--mbu-text-dim);font-weight:normal} /* the original bolds only the Title + Artist labels among all the field captions — match that, keeping the rest (and the checkbox labels) light so they aren't intrusive (#143) */ body.tc-ri-on #information table.row-form label[for="title"], body.tc-ri-on #information table.row-form label[for="release-artist"]{font-weight:600;color:var(--mbu-text-dim)} body.tc-ri-on #information input[type=text], body.tc-ri-on #information select, body.tc-ri-on #information textarea{font-size:12px;padding:2px 6px;border:1px solid var(--mbu-accent);border-radius:4px;box-shadow:none} /* no background → MB's green auto-fill tint survives */ body.tc-ri-on #information input[type=text]:focus, body.tc-ri-on #information select:focus, body.tc-ri-on #information textarea:focus{border-color:var(--mbu-accent);outline:none} /* "Additional information": the native annotation textarea renders narrower than the disambiguation input, and this fieldset's wider "Disambiguation:" caption pushed both fields ~23px right of the fields above. Pin its label column to the same 150px the main form uses so the fields left-align with Title/Barcode, and make both fields fill the column so they're the same width and right-align with the rest. */ /* :not(#external-links-editor) — MB's external-links editor is ALSO a table.row-form inside the information fieldset; without the exclusion this 150px label-column width hit its favicon cell (normally 30px) and shoved every link URL ~150px to the right, misaligning the icons. */ body.tc-ri-on #information fieldset.information table.row-form:not(#external-links-editor) > tbody > tr > td:first-child{width:150px} body.tc-ri-on #information fieldset.information textarea#annotation, body.tc-ri-on #information fieldset.information input#comment{width:100%!important;max-width:none!important;box-sizing:border-box} /* MB pins the annotation textarea to 354px via an !important rule in its (cross-origin) stylesheet — override it so the field fills the column like the input */ body.tc-ri-on #information .buttons button,body.tc-ri-on #information button.styled-button{font-size:12px} body.tc-ri-on #information .lookup-performed{background-color:var(--mbu-ok-bg)!important;color:var(--mbu-text)!important} /* soften MB's bright auto-fill green to a pale tint (#143) */ body.tc-ri-on #information > div.documentation{display:none} /* the contextual help text — replaced by the links column */ /* #143: on-demand help popover. The native field bubbles still carry the clickable link to the *selected* entity ("You selected <a>…</a>"); we surface that next to the focused field instead of the removed help column — without bringing back the verbose style-guide noise. (unscoped: the element lives on <body>; visibility is gated by the .on class, only added while Apollo is on.) */ body.tc-ri-on #tc-ri-rightcol{flex:1 1 340px;min-width:300px;max-width:100%;box-sizing:border-box} /* links take the remaining width, but wrap below the form when there isn't room for both; never wider than the row */ /* External links matches the form sections: no boxy border, same compact purple header (#143) */ body.tc-ri-on #tc-ri-rightcol > fieldset{margin-top:0;max-width:100%;min-width:0;box-sizing:border-box;border:none;padding:0} body.tc-ri-on #tc-ri-rightcol > fieldset > legend{font:600 11px Arial;letter-spacing:.06em;text-transform:uppercase;color:var(--mbu-accent-text);padding:0 0 5px;margin:0 0 4px;border-bottom:1px solid var(--mbu-accent);width:100%;box-sizing:border-box} body.tc-ri-on #tc-ri-rightcol #external-links-editor{max-width:100%;box-sizing:border-box} /* #297: front cover thumbnail under the external-links section (display only) */ #tc-ri-cover{margin:14px 0 0} #tc-ri-cover .tc-ri-cover-h{font:600 11px Arial;letter-spacing:.06em;text-transform:uppercase;color:var(--mbu-accent-text);padding:0 0 5px;margin:0 0 6px;border-bottom:1px solid var(--mbu-accent)} #tc-ri-cover a{display:inline-block;line-height:0} /* shrink-wrap the image so only the cover itself is the link, not the empty space beside it */ #tc-ri-cover img{display:block;width:180px;max-width:100%;height:auto;border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 1px 5px rgba(40,20,80,.14)} /* ---- external links as a grid: the URL row spans every column, the link's type combos flow into aligned columns beneath it, and "Add another relationship" (the [+]) lands in the last cell. ---- */ body.tc-ri-on #external-links-editor{display:block} body.tc-ri-on #external-links-editor > tbody{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(185px,100%),1fr));column-gap:14px;row-gap:2px;padding-left:45px;align-items:center;box-sizing:border-box} /* URL line spans all columns; pulled back so the favicon sits at the column's left edge */ body.tc-ri-on #external-links-editor tr.external-link-item{grid-column:1 / -1;display:flex;align-items:center;gap:9px;padding:7px 6px 1px;margin-left:-45px;border-radius:var(--mbu-radius);position:relative} body.tc-ri-on #external-links-editor tr.external-link-item:hover{background:var(--mbu-bg-raised)} body.tc-ri-on #external-links-editor tr.external-link-item > td{padding:0;border:none} body.tc-ri-on #external-links-editor tr.external-link-item > td:first-child{flex:none;width:30px;height:30px;display:flex;align-items:center;justify-content:center;cursor:context-menu;position:relative;top:4px} /* larger favicon → right-click to edit URL */ body.tc-ri-on #external-links-editor .favicon{transform:scale(1.45);transform-origin:center;margin-right:0} /* master's size; drop MB's 4px margin-right that pushed the scaled icon off-centre and clipped it (#143) */ body.tc-ri-on #external-links-editor tr.external-link-item > td:last-child{flex:1;min-width:0} body.tc-ri-on #external-links-editor a.url{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px} /* hover edit/remove icons removed. link-actions -> display:contents; the edit pencils are kept in the DOM (positioned, invisible) so our JS proxy clicks still anchor MB's editor popup near the link instead of 0,0 */ body.tc-ri-on #external-links-editor td.link-actions{display:contents} body.tc-ri-on #external-links-editor td.link-actions > button.edit-item{position:absolute;left:6px;top:8px;width:18px;height:18px;opacity:0;pointer-events:none;margin:0;padding:0} /* whole-link remove ("Remove link") — revealed on URL-row hover at the right end (no layout shift) */ body.tc-ri-on #external-links-editor tr.external-link-item td.link-actions > button.remove-item{display:inline-flex;align-items:center;order:9;margin:0 2px 0 8px;transform:scale(.85);opacity:0;transition:opacity .12s} body.tc-ri-on #external-links-editor tr.external-link-item:hover td.link-actions > button.remove-item{opacity:.5} body.tc-ri-on #external-links-editor tr.external-link-item td.link-actions > button.remove-item:hover{opacity:1} /* each type combo is one grid cell: [x] [type fills the cell] [video] [!]. A fixed left slot is reserved for the per-type [x] so the type text lines up whether or not a remove button is present (single vs multi type) */ body.tc-ri-on #external-links-editor tr.relationship-item{display:flex;align-items:center;gap:5px;min-width:0;padding:0 0 1px 22px;position:relative} body.tc-ri-on #external-links-editor tr.relationship-item > td{padding:0;border:none} body.tc-ri-on #external-links-editor tr.relationship-item > td:first-child{display:none} /* the cell content is a 3-track grid [ type (1fr) | video | ! ] so the select always ends at the same x and the carets line up across rows, whether or not a video checkbox / error badge is present */ body.tc-ri-on #external-links-editor tr.relationship-item > td:last-child{display:grid;grid-template-columns:minmax(0,1fr) 18px 16px;align-items:center;column-gap:2px;flex:1;min-width:0} body.tc-ri-on #external-links-editor tr.relationship-item .relationship-content{grid-column:1;display:flex;align-items:center;min-width:0} body.tc-ri-on #external-links-editor tr.relationship-item .relationship-content > label:first-child{display:none} /* the "Type:" caption */ /* a relationship with a date period ("stream for free (1111-11-11 – 1112-11-11)") needs more room than one 185px type cell, but not the whole row — span two cells so several dated types still share a wide row. The type stays on one line; only a very long date wraps to a second line *inside* the cell, so it never overflows into the neighbouring type (overlapping its remove ✗) (#143). */ body.tc-ri-on #external-links-editor tr.relationship-item:has(.date-period){grid-column:span 2} body.tc-ri-on #external-links-editor tr.relationship-item:has(.date-period) .relationship-name{flex-wrap:wrap;white-space:nowrap;overflow:visible} /* let a very long date wrap to a 2nd line inside the cell (not clipped) */ /* per-type remove [x] sits in the reserved left slot (absolute), so the type text starts at the same x with or without it. left:6px centres MB's native ✗ sprite (≈13.6px) on the same x as the "add another relationship" [+] that wraps directly below it when the types stack — at left:0 the native sprite landed ~6px too far left of the [+] (the old #154 flat glyph was wider, so it happened to line up). (#160) */ body.tc-ri-on #external-links-editor tr.relationship-item td.link-actions > button[class*="remove"]{display:inline-flex;align-items:center;position:absolute;left:6px;top:50%;transform:translateY(-50%) scale(.85);margin:0;opacity:.6;transition:opacity .12s} body.tc-ri-on #external-links-editor tr.relationship-item td.link-actions > button[class*="remove"]:hover{opacity:1} /* type text (committed) / select (editable) fills the cell */ body.tc-ri-on #external-links-editor tr.relationship-item .relationship-name{display:flex;align-items:center;flex:1;min-width:0;font-size:12px;color:var(--mbu-accent-deep-text);background:transparent;border:none;border-radius:0;padding:0;font-weight:normal;cursor:context-menu;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* keep the type on one line — never wrap it one-word-per-line as the column resizes (#143) */ body.tc-ri-on #external-links-editor tr.relationship-item .relationship-name:hover{color:var(--mbu-accent-deep-text)} /* editable type dropdowns — appearance:none so the text starts flush at the cell edge; a custom caret keeps the affordance. padding-right reserves room for the 10px caret with a small gap; keep it tight (not 15px) so the type label has a few px of slack and never clips its last glyph under sub-pixel rounding at 100% browser zoom on a scaled display (#143). */ body.tc-ri-on #external-links-editor select{-webkit-appearance:none;-moz-appearance:none;appearance:none;font-size:12px;color:var(--mbu-accent-deep-text);background-color:transparent;background-image:url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='10'%20height='7'%20viewBox='0%200%2010%207'%3E%3Cpath%20d='M1%201l4%204%204-4'%20fill='none'%20stroke='%235a3e94'%20stroke-width='1.5'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 5px center;border:1px solid var(--mbu-border-strong);border-radius:var(--mbu-radius);box-shadow:none;padding:1px 18px 1px 5px;height:auto;margin:0;flex:1;min-width:0;width:100%;cursor:pointer} /* #609: bordered, so an empty (no type yet) combo reads as a field to click, not a stray chevron */ body.tc-ri-on #external-links-editor select:hover{color:var(--mbu-accent-deep-text);border-color:var(--mbu-accent)} body.tc-ri-on #external-links-editor select:focus-visible{border-color:var(--mbu-accent);outline:none} /* video attribute → a compact glyph toggle (no "video" caption) */ body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container{grid-column:2;justify-self:start;display:inline-flex;align-items:center;margin:0} body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container label{font-size:0;display:inline-flex;align-items:center;cursor:pointer} /* error → a compact "!" badge (full text on hover via title) so it doesn't reflow the combos when it appears */ body.tc-ri-on #external-links-editor tr.relationship-item .error.field-error{grid-column:3;justify-self:start;font-size:0;display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;background:var(--mbu-error-bg);border:1px solid var(--mbu-error);margin:0;cursor:help} body.tc-ri-on #external-links-editor tr.relationship-item .error.field-error::before{content:"!";font:bold 11px/1 Arial;color:var(--mbu-error)} /* #169: MB flags a relationship/URL with pending edits via a small <img class="info" alt="This relationship has open edits."> that the compact type cell clips away. Rather than re-fit the icon (alignment is fragile here), surface it with COLOUR — an amber type label + a left accent bar on the row. Pure :has(), no layout shift (inset box-shadow). */ body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]){box-shadow:inset 2px 0 0 0 #e8920c} body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) .relationship-name, body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) select{color:var(--mbu-warn)} body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) .relationship-name:hover, body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) select:hover{color:var(--mbu-warn)} /* same cue when the open edit is on the URL itself (pending add/remove of the link) */ body.tc-ri-on #external-links-editor tr.external-link-item:has(img.info[alt*="open edits" i]){box-shadow:inset 2px 0 0 0 #e8920c} body.tc-ri-on #external-links-editor tr.external-link-item:has(img.info[alt*="open edits" i]) a.url{color:var(--mbu-warn)} /* "Add another relationship" (the [+]) — flows into the last grid cell; padding-left matches the per-type [x] inset so they line up */ body.tc-ri-on #external-links-editor tr.add-relationship{display:flex;align-items:center;margin:0;padding:0 0 0 6px} body.tc-ri-on #external-links-editor tr.add-relationship > td{padding:0;border:none} body.tc-ri-on #external-links-editor tr.add-relationship > td:empty{display:none} body.tc-ri-on #external-links-editor tr.add-relationship td.add-item{display:inline-grid} /* size the cell to the [+] button */ /* the [+] is a touch smaller than the per-type [x] remove */ body.tc-ri-on #external-links-editor tr.add-relationship button.add-item{font-size:0;width:13px;height:13px;border-radius:50%;border:1px solid var(--mbu-accent);background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;margin:0;line-height:1} body.tc-ri-on #external-links-editor tr.add-relationship button.add-item::before{content:"+";font:bold 9px/1 Arial;color:var(--mbu-accent-text)} body.tc-ri-on #external-links-editor tr.add-relationship button.add-item:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} body.tc-ri-on #external-links-editor tr.add-relationship button.add-item:hover::before{color:var(--mbu-accent-text)} /* the "add another link" input row */ body.tc-ri-on #external-links-editor tr.external-link-item .value.with-button input{width:100%} /* collapse the "Add another link" field into a [+] button that expands to a full input on click/focus */ /* #543: keyed on :placeholder-shown (= no value yet), NOT on the placeholder TEXT as it once was. Changing that text to "Paste one or more links" silently un-matched every rule below: the row stopped collapsing to [+] and drew over the Check-links button. */ body.tc-ri-on #external-links-editor input[type=url]:placeholder-shown{box-sizing:border-box;width:22px;min-width:0;height:22px;padding:0;margin:2px 0;border:1px solid var(--mbu-accent);border-radius:50%;background-color:transparent;color:transparent;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='11'%20height='11'%20viewBox='0%200%2011%2011'%3E%3Cpath%20d='M5.5%201v9M1%205.5h9'%20stroke='%239a8fc0'%20stroke-width='1.6'%20stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:center;transition:width .12s ease} body.tc-ri-on #external-links-editor input[type=url]:placeholder-shown:hover{background-color:var(--mbu-bg-hover);border-color:var(--mbu-accent)} body.tc-ri-on #external-links-editor input[type=url]:placeholder-shown::placeholder{color:transparent} /* hide the hint text inside the collapsed [+] — it is shown on focus */ body.tc-ri-on #external-links-editor input[type=url]:placeholder-shown:focus{width:100%;height:auto;padding:4px 7px;border:1px solid var(--mbu-border);border-radius:4px;background-color:var(--mbu-bg);background-image:none;color:var(--mbu-text);cursor:text} body.tc-ri-on #external-links-editor input[type=url]:placeholder-shown:focus::placeholder{color:var(--mbu-text-weak)} /* ---- dead-link checker ---- */ #tc-ri-toolbar{position:absolute;right:10px;bottom:8px;display:flex;align-items:center;gap:8px;z-index:3} /* the add-link field expands to full width on focus and would sit under the Check-links button — hide it while editing */ body.tc-ri-on #tc-ri-rightcol > fieldset:has(#external-links-editor input[type=url]:placeholder-shown:focus) > #tc-ri-toolbar{display:none} #tc-ri-check{font:12px Arial;display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);background:var(--mbu-bg-raised);color:var(--mbu-accent-deep-text);cursor:pointer} #tc-ri-check:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} #tc-ri-check:disabled{opacity:.6;cursor:default} /* #545 (majkinetor): "Auto match should be visible while working, currently it isn't so slow MB response make it look like nothing is happening." Same busy idiom as the link checker below — spinner in the button, the button disabled so a second run cannot be started on top of the first (the _autoMatching guard silently swallowed that click). */ #tc-recwrap .tc-rec-am .tc-spin{width:11px;height:11px;border:2px solid var(--mbu-accent-soft);border-top-color:var(--mbu-accent);border-radius:50%;animation:tc-spin .7s linear infinite;display:none;margin-right:5px;vertical-align:-1px} #tc-recwrap .tc-rec-am.busy .tc-spin{display:inline-block} #tc-recwrap .tc-rec-am[disabled]{opacity:.6;cursor:progress} #tc-recwrap .tc-rec-amstatus.busy{color:var(--mbu-accent-text);font-weight:600} #tc-ri-check .tc-spin{width:12px;height:12px;border:2px solid var(--mbu-accent-soft);border-top-color:var(--mbu-accent);border-radius:50%;animation:tc-spin .7s linear infinite;display:none} #tc-ri-check.busy .tc-spin{display:inline-block} @keyframes tc-spin{to{transform:rotate(360deg)}} #tc-ri-check-status{font:12px Arial;color:var(--mbu-text-dim)} /* a dead link (4xx/5xx/unreachable): faded favicon + struck URL, like Platform Check's not-found state */ body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-dead .favicon{filter:grayscale(1);opacity:.45} body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-dead a.url{text-decoration:line-through;opacity:.55} body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-dead a.url::after{content:" ✖ " attr(data-tc-deadcode);color:var(--mbu-error);font-size:11px;text-decoration:none;opacity:.9} body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-ok a.url::after{content:" ✓";color:var(--mbu-ok);font-size:11px;opacity:.7} /* annotation editor: a bordered box wrapping the toolbar + (bigger) textarea + in-place preview */ body.tc-ri-on #tc-anno-wrap{border:1px solid var(--mbu-accent);border-radius:7px;background:var(--mbu-bg);overflow:hidden;box-sizing:border-box} #tc-anno-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px 8px;background:var(--mbu-bg-raised);border-bottom:1px solid var(--mbu-accent)} #tc-anno-bar button{font:12px Arial;display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);background:var(--mbu-bg);color:var(--mbu-accent-deep-text);cursor:pointer} #tc-anno-bar button:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} #tc-anno-bar button:disabled{opacity:.6;cursor:default} #tc-anno-bar button.tc-anno-icon{padding:4px 8px;font-size:13px;line-height:1;font-weight:700} #tc-anno-bar .tc-mk-ico{width:22px;height:14px;display:block} #tc-anno-bar .tc-mk-mb{width:17px;height:17px;border-radius:3px} #tc-anno-bar .tc-mk-sq{width:15px;height:15px} /* maximize: the editor fills the viewport over a dimmed backdrop (Esc or the button restores it) */ body.tc-anno-max-open{overflow:hidden} body.tc-anno-max-open::before{content:'';position:fixed;inset:0;background:rgba(30,20,55,.42);z-index:10000} body.tc-ri-on #tc-anno-wrap.tc-anno-max{position:fixed;inset:14px;z-index:10001;display:flex;flex-direction:column;max-width:none;box-shadow:0 14px 50px rgba(35,20,70,.45)} #tc-anno-wrap.tc-anno-max #tc-anno-body{flex:1 1 auto;min-height:0} body.tc-ri-on #tc-anno-wrap.tc-anno-max textarea,#tc-anno-wrap.tc-anno-max #tc-anno-preview{min-height:0;height:auto} #tc-anno-wrap.tc-anno-max #tc-anno-history{flex:1 1 auto;min-height:0;max-height:none} #tc-anno-bar #tc-anno-help{width:25px;justify-content:center;color:var(--mbu-accent-text)} #tc-anno-bar.tc-anno-prev-on #tc-anno-preview-btn{background:var(--mbu-accent);color:var(--mbu-text-on-accent);border-color:var(--mbu-accent)} #tc-anno-bar.tc-anno-hist-on #tc-anno-history-btn{background:var(--mbu-accent);color:var(--mbu-text-on-accent);border-color:var(--mbu-accent)} #tc-anno-bar.tc-anno-hist-on button:not(#tc-anno-history-btn):not(#tc-anno-max){opacity:.4;pointer-events:none} /* History active → only History + maximize stay usable */ #tc-anno-status{font:italic 11px var(--mbu-font);font-weight:normal;color:var(--mbu-accent-text);letter-spacing:0;text-transform:none} /* shown next to the Annotation: label */ /* three toolbar groups: [Preview Clear] [markup ?] [History] (the 1:3 spacers place markup/? left-of-centre) */ #tc-anno-bar .tc-anno-sp1{flex:1 1 0;min-width:14px} #tc-anno-bar .tc-anno-sp2{flex:3 1 0;min-width:14px} /* editor body: the active textarea on the left; the live preview splits in on the right when toggled */ #tc-anno-body{display:flex;align-items:stretch;min-height:240px} #tc-anno-edit{flex:1 1 0;min-width:0;display:flex;flex-direction:column} body.tc-ri-on #tc-anno-wrap textarea{display:block;width:100%!important;max-width:none!important;min-height:240px;flex:1 1 auto;border:none!important;border-radius:0;padding:9px 11px;resize:vertical;box-shadow:none;box-sizing:border-box;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:12px;line-height:1.55;tab-size:4} /* standalone Edit annotation page: Changelog + Annotation rows stacked full-width with the label above */ body.tc-anno-page #content > form > .row:not(.no-label){display:block;margin:0 0 16px;max-width:1100px} body.tc-anno-page #content > form > .row:not(.no-label) > label{display:block;width:auto;text-align:left;float:none;font:600 12px Arial;letter-spacing:.02em;color:var(--mbu-text-dim);margin:0 0 5px} body.tc-anno-page #content > form > .row > input[type=text]{width:100%;max-width:680px;box-sizing:border-box;padding:5px 8px;border:1px solid var(--mbu-accent);border-radius:5px} body.tc-anno-page #content > form > fieldset.editnote{max-width:1100px} /* fill the remaining viewport height (height is set per-resize in JS) */ body.tc-anno-page #tc-anno-wrap{display:flex;flex-direction:column} body.tc-anno-page #tc-anno-wrap > #tc-anno-body,body.tc-anno-page #tc-anno-wrap > #tc-anno-history{flex:1 1 auto;min-height:0;max-height:none} body.tc-anno-page #tc-anno-wrap textarea,body.tc-anno-page #tc-anno-wrap #tc-anno-preview{min-height:0} /* Changelog row becomes [label above] then [input + Enter edit] side by side */ body.tc-anno-page #content > form > .row.tc-cl-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px} body.tc-anno-page #content > form > .row.tc-cl-row > label{flex:1 1 100%;margin-bottom:4px} body.tc-anno-page #content > form > .row.tc-cl-row > input[type=text]{flex:1 1 auto;width:auto;max-width:600px} body.tc-anno-page #tc-anno-submit{flex:0 0 auto;font:600 13px Arial;padding:7px 18px;border:1px solid var(--mbu-ok);border-radius:var(--mbu-radius);background:#3aa55f;color:var(--mbu-text-on-accent);cursor:pointer} body.tc-anno-page #tc-anno-submit:hover{background:#2c8a4d} #tc-anno-preview{flex:1 1 0;min-width:0;min-height:240px;padding:10px 13px;background:var(--mbu-bg);border-left:1px solid var(--mbu-accent);font-size:13px;line-height:1.5;color:var(--mbu-text);overflow:auto;word-break:break-word;box-sizing:border-box} #tc-anno-preview .tc-anno-empty{color:var(--mbu-text-weak);font-style:italic} #tc-anno-preview p{margin:0 0 8px} #tc-anno-preview .tc-anno-h{margin:10px 0 6px;color:var(--mbu-accent-deep-text);font-weight:700;line-height:1.25} #tc-anno-preview h1.tc-anno-h{font-size:18px} #tc-anno-preview h2.tc-anno-h{font-size:16px} #tc-anno-preview h3.tc-anno-h,#tc-anno-preview h4.tc-anno-h,#tc-anno-preview h5.tc-anno-h,#tc-anno-preview h6.tc-anno-h{font-size:14px} #tc-anno-preview .tc-anno-ul{margin:0 0 8px;padding-left:22px} #tc-anno-preview .tc-anno-pre{margin:0 0 8px;padding:8px 10px;background:var(--mbu-bg-hover);border-radius:4px;font-family:Consolas,monospace;font-size:12px;white-space:pre-wrap} #tc-anno-preview hr{border:none;border-top:1px solid var(--mbu-accent);margin:10px 0} #tc-anno-preview a{color:var(--mbu-accent-text);text-decoration:none} #tc-anno-preview a:hover{text-decoration:underline} /* syntax help popover (hover the ? button) */ #tc-anno-help-pop{position:fixed;z-index:10000;display:none;width:370px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow-y:auto;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:8px;box-shadow:0 8px 26px rgba(60,40,110,.22);padding:10px 13px;font:12px Arial;color:var(--mbu-text);line-height:1.5} #tc-anno-help-pop.on{display:block} #tc-anno-help-pop table{border-collapse:collapse;margin:6px 0;width:100%} #tc-anno-help-pop td{padding:2px 7px 2px 0;vertical-align:top} #tc-anno-help-pop td:last-child{color:var(--mbu-text-dim)} #tc-anno-help-pop code{background:var(--mbu-bg-hover);border-radius:3px;padding:0 4px;font-family:Consolas,monospace;color:var(--mbu-accent-deep-text)} #tc-anno-help-pop .tc-help-dim{color:var(--mbu-text-weak)} /* Disambiguation + Annotation span the full column with their label stacked ABOVE (not the 150px label column). :has targets exactly those two rows, so the relocated External-links table is untouched. */ body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment), body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation){display:block;width:100%;margin:0 0 14px} body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment) > td, body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation) > td{display:block;width:100%!important;padding:0} body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment) > td:first-child, body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation) > td:first-child{text-align:left!important;padding:0 0 4px} body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment) > td:first-child label, body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation) > td:first-child label{display:block;width:auto;float:none;text-align:left!important;font:600 12px Arial;letter-spacing:.02em;color:var(--mbu-text-dim)} body.tc-ri-on #information fieldset.information input#comment{width:100%!important;max-width:none!important;box-sizing:border-box} /* annotation History: the selected version rendered on the LEFT, user cards on the RIGHT */ #tc-anno-history{display:flex;min-height:240px;max-height:520px;overflow:hidden;box-sizing:border-box} #tc-anno-history .tc-hist-view{flex:1 1 auto;order:1;overflow:auto;padding:11px 14px;font-size:13px;line-height:1.5;color:var(--mbu-text);word-break:break-word} #tc-anno-history .tc-hist-list{flex:0 0 250px;order:2;overflow-y:auto;overflow-x:hidden;background:var(--mbu-bg-raised);border-left:1px solid var(--mbu-accent)} #tc-anno-history .tc-hist-card{display:flex;gap:9px;align-items:flex-start;width:100%;box-sizing:border-box;text-align:left;border:none;border-bottom:1px solid var(--mbu-accent);background:none;cursor:pointer;padding:9px 11px} #tc-anno-history .tc-hist-card:hover{background:var(--mbu-bg-hover)} #tc-anno-history .tc-hist-card.on{background:var(--mbu-bg-hover);box-shadow:inset -3px 0 0 #5f3ec0} #tc-anno-history .tc-hist-av{width:30px;height:30px;border-radius:50%;flex:0 0 auto;object-fit:cover;background:var(--mbu-bg-hover);border:1px solid var(--mbu-border)} #tc-anno-history .tc-hist-meta{display:flex;flex-direction:column;min-width:0;flex:1 1 auto;font:12px Arial} #tc-anno-history .tc-hist-editor{font-weight:600;color:var(--mbu-accent-deep-text)} #tc-anno-history .tc-hist-date{color:var(--mbu-text-dim);font-size:11px} #tc-anno-history .tc-hist-cl{color:var(--mbu-text-weak);font-style:italic;font-size:11px;margin-top:2px} #tc-anno-history .tc-hist-cur{color:var(--mbu-ok);font-style:normal} #tc-anno-history .tc-hist-clmsg{color:var(--mbu-accent-deep-text);font-style:italic;font-size:11px;margin-top:2px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #tc-anno-history .tc-hist-revert{align-self:center;flex:0 0 auto;width:26px;height:26px;border:1px solid var(--mbu-accent);border-radius:5px;background:var(--mbu-bg);color:var(--mbu-accent-deep-text);font-size:15px;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s} #tc-anno-history .tc-hist-card.on .tc-hist-revert,#tc-anno-history .tc-hist-card:hover .tc-hist-revert{opacity:1} /* visible on the selected card, or any card on hover */ #tc-anno-history .tc-hist-revert:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} #tc-anno-history .tc-hist-revert:disabled{opacity:.5;cursor:default} #tc-anno-history .tc-hist-msg{color:var(--mbu-text-weak);font-style:italic;font-size:12px;padding:6px 2px} #tc-anno-history .tc-hist-bar{display:flex;align-items:center;gap:10px;margin:0 0 10px;flex-wrap:wrap} #tc-anno-history .tc-hist-use{font:12px Arial;padding:4px 11px;border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);background:var(--mbu-bg-raised);color:var(--mbu-accent-deep-text);cursor:pointer} #tc-anno-history .tc-hist-use:hover{background:var(--mbu-bg-hover)} #tc-anno-history .tc-hist-vmeta{font:11px Arial;color:var(--mbu-text-weak)} #tc-anno-history .tc-anno-rendered h1{font-size:18px;margin:8px 0 6px;color:var(--mbu-accent-deep-text)} #tc-anno-history .tc-anno-rendered h2{font-size:16px;margin:8px 0 6px;color:var(--mbu-accent-deep-text)} #tc-anno-history .tc-anno-rendered h3{font-size:14px;margin:8px 0 6px;color:var(--mbu-accent-deep-text)} #tc-anno-history .tc-anno-rendered p{margin:0 0 8px} #tc-anno-history .tc-anno-rendered ul{margin:0 0 8px;padding-left:22px} #tc-anno-history .tc-anno-rendered a{color:var(--mbu-accent-text);text-decoration:none} #tc-anno-history .tc-anno-rendered a:hover{text-decoration:underline} #tc-anno-history .tc-anno-rendered .annotation-details,#tc-anno-history .tc-anno-rendered h2.annotation{display:none}`; const s = document.createElement('style'); s.id = 'tc-ri-style'; s.textContent = css; document.head.appendChild(s); } // move the External-links fieldset into a dedicated right column (or back home when Apollo is off). // Only the server-rendered <fieldset> wrapper is moved — React's editor root inside it is untouched. function relocateLinks(on) { const panel = document.getElementById('information'); if (!panel) return; const half = panel.querySelector(':scope > div.half-width'); if (!half) return; if (!_riExtFs || !_riExtFs.isConnected) { // find the External-links fieldset by its editor table, or — while that's still loading — by its legend, // so the section moves to the right column immediately instead of showing "Loading…" at the bottom const ext = document.getElementById('external-links-editor'); _riExtFs = ext ? ext.closest('fieldset') : [...half.querySelectorAll('fieldset')].find(f => /external links/i.test(f.querySelector('legend')?.textContent || '')); } const fs = _riExtFs; if (!fs) return; if (on) { let col = panel.querySelector(':scope > #tc-ri-rightcol'); if (!col) { col = document.createElement('div'); col.id = 'tc-ri-rightcol'; panel.appendChild(col); } if (fs.parentElement !== col) { if (!fs._tcHome) fs._tcHome = { parent: fs.parentElement, next: fs.nextElementSibling }; col.appendChild(fs); } ensureCheckToolbar(col); riCover(col); // #297: front cover thumbnail below the external-links section } else if (fs._tcHome && fs.parentElement !== fs._tcHome.parent) { // the Check-links toolbar is appended *inside* the fieldset, so it would travel home with it and orphan // onto the native Release-information tab (#160) — drop it before moving the fieldset back fs.querySelector(':scope > #tc-ri-toolbar')?.remove(); fs._tcHome.parent.insertBefore(fs, fs._tcHome.next && fs._tcHome.next.isConnected ? fs._tcHome.next : null); const col = panel.querySelector(':scope > #tc-ri-rightcol'); col?.querySelector(':scope > #tc-ri-cover')?.remove(); if (col && !col.children.length) col.remove(); } } // #297: the current release MBID (live editor gid, else from the URL); '' on /release/add function currentMbid() { try { const r = u(getEditor().rootField.release); const g = r && u(r.gid); if (g) return g; } catch (e) {} return (location.pathname.match(MBID_RE) || [''])[0]; } // Append/refresh a front-cover thumbnail (Cover Art Archive) at the bottom of the // right column, below external links. Display only; hides itself when there's no art. function riCover(col) { const mbid = currentMbid(); let box = col.querySelector(':scope > #tc-ri-cover'); if (!mbid) { box?.remove(); return; } if (box && box.dataset.mbid === mbid) { col.appendChild(box); return; } // keep, just ensure it's last box?.remove(); box = document.createElement('div'); box.id = 'tc-ri-cover'; box.dataset.mbid = mbid; box.style.display = 'none'; // hidden until the cover actually loads — no "FRONT COVER" flash on coverless releases box.innerHTML = '<div class="tc-ri-cover-h">Front cover</div>'; const a = document.createElement('a'); a.href = ORIGIN + '/release/' + mbid + '/cover-art'; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Cover art (Cover Art Archive)'; const img = document.createElement('img'); img.alt = 'Front cover'; img.referrerPolicy = 'no-referrer'; // not lazy: a hidden (display:none) box would never load a lazy image // reveal only once the image loads. On error the box stays hidden — but is KEPT // (marked for this mbid) so riCover doesn't rebuild + re-request it every relayout // tick (#297: front-250 request loop / OpaqueResponseBlocking on coverless releases). img.onload = () => { box.style.display = ''; }; img.src = 'https://coverartarchive.org/release/' + mbid + '/front-250'; a.appendChild(img); box.appendChild(a); col.appendChild(box); } // ---- dead-link checker (#138): check each external link's HTTP status, fade the dead ones, and turn on // "This relationship has ended" for each of a dead link's relationship types ---- const _deadLinks = new Map(); // url -> { dead, code } — kept so marks survive React re-renders of the editor const GMX = (typeof GM_xmlhttpRequest !== 'undefined' && GM_xmlhttpRequest) || (typeof GM !== 'undefined' && GM && GM.xmlHttpRequest) || null; // HEAD (then GET on 405/403/0) → { status, dead }. dead = 4xx/5xx or unreachable. function checkUrl(url) { return new Promise(resolve => { if (!GMX) { resolve({ status: null, dead: null }); return; } // no GM (e.g. page-context) → unknown let done = false; const fin = r => { if (!done) { done = true; resolve(r); } }; const req = method => { try { GMX({ method, url, timeout: 15000, onload: r => { const s = r.status; if (method === 'HEAD' && (s === 405 || s === 501 || s === 403 || s === 0)) return req('GET'); fin({ status: s, dead: !s || s >= 400 }); }, onerror: () => method === 'HEAD' ? req('GET') : fin({ status: 0, dead: true }), ontimeout: () => fin({ status: 0, dead: true }) }); } catch (e) { fin({ status: -1, dead: true }); } }; req('HEAD'); }); } // each external-link row with a real URL, paired with its <a> and the relationship-item rows beneath it function linkRows() { const ext = document.getElementById('external-links-editor'); if (!ext) return []; const rows = [...ext.querySelectorAll('tr')]; const out = []; rows.forEach((r, i) => { if (!r.classList.contains('external-link-item')) return; const a = r.querySelector('a.url'); if (!a) return; // skip the "add another link" input row const rels = []; for (let k = i + 1; k < rows.length; k++) { const n = rows[k]; if (n.classList.contains('external-link-item')) break; if (n.classList.contains('relationship-item')) rels.push(n); } out.push({ row: r, url: a.href, rels }); }); return out; } function markLinkRow(row, dead, code) { row.classList.toggle('tc-link-dead', dead === true); row.classList.toggle('tc-link-ok', dead === false); const a = row.querySelector('a.url'); if (a) { if (dead && code) a.setAttribute('data-tc-deadcode', code); else a.removeAttribute('data-tc-deadcode'); } } function remarkDeadLinks() { // re-apply marks after the React editor re-renders (called from the observer) if (!_deadLinks.size) return; linkRows().forEach(({ row, url }) => { const v = _deadLinks.get(url); if (v) markLinkRow(row, v.dead, v.code); }); } // open a relationship's edit dialog, tick "This relationship has ended", click Done function setRelEnded(relRow) { return new Promise(resolve => { const edit = relRow.querySelector('button.edit-item'); if (!edit) { resolve(false); return; } edit.click(); setTimeout(() => { // #722 the dialog, its ended box and Done by class and field name (MB translates their text) const dlg = [...document.querySelectorAll('form.external-link-relationship-dialog')].find(d => d.offsetParent !== null) || [...document.querySelectorAll('.dialog.popover,[role="dialog"],.bubble')].find(d => d.offsetParent !== null && /relationship has ended|has ended/i.test(d.textContent)); if (!dlg) { resolve(false); return; } const cb = dlg.querySelector('input[type=checkbox][name="period.ended"]') || [...dlg.querySelectorAll('input[type=checkbox]')].find(c => /ended/i.test((c.closest('label') || c.parentElement || {}).textContent || '')); if (cb && !cb.checked) cb.click(); const done = dlg.querySelector('button.positive[type=submit]') || [...dlg.querySelectorAll('button')].find(b => /^\s*done\s*$/i.test(b.textContent)); setTimeout(() => { if (done) done.click(); resolve(!!cb); }, 70); }, 240); }); } /* -- #543 paste several external links at once ---------------------------- majkinetor: "When multiple URLs are pasted, in (+) line, Apollo should add them all at once ... Find out only links, no matter where they are, so they could be intermingled with text", e.g. Bandcamp: https://digthiswayrecords.bandcamp.com/album/musical-breed Spotify: https://open.spotify.com/album/3ibDwnUIydFebHj3pNW9WR or `Available on [Bandcamp](url) & [Spotify](url)`. So the paste is MINED for urls rather than parsed as a list: anything that is not a url is ignored, which covers both shapes without a format to learn. (ROpdebee's mb_multi_external_links does this today, but it stopped matching MB's markup and looks unmaintained -- he had to patch its selector by hand.) */ const AL_URL_RE = /\bhttps?:\/\/[^\s<>"'`\)\]}]+/gi; function alExtractUrls(text) { const out = []; const seen = new Set(); for (const raw of String(text || '').match(AL_URL_RE) || []) { const url = raw.replace(/[.,;:!?]+$/, ''); let ok = false; try { const u = new URL(url); ok = !!u.hostname && u.hostname.includes('.'); } catch (e) { ok = false; } if (!ok) continue; const key = url.toLowerCase(); if (seen.has(key)) continue; // the same link twice in one paste is one link seen.add(key); out.push(url); } return out; } /* MB leaves the link type blank for hosts its classifier cannot decide, and a blank REQUIRED select blocks the whole release editor's submit -- not just that row. Platform Check already solved this; the table is duplicated here rather than shared, the same way the pattern engine is (single-file userscripts). KEEP IN SYNC with platform_check.user.js's TYPE_FORCE. Order matters: the first id MB actually offers for that row wins, because MB only lists types applicable to the host. */ const AL_TYPE_FORCE = [ { test: u => /music\.apple\.com\/.*\/album\//i.test(u), ids: ['980', '85'] }, { test: u => /[a-z0-9-]+\.bandcamp\.com\/album\//i.test(u), ids: ['85', '980'] }, { test: u => /hdtracks\.com\//i.test(u), ids: ['74'] }, { test: u => /volumo\.com\/album\//i.test(u), ids: ['74'] }, { test: u => /qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\//i.test(u), ids: ['74', '980'] }, { test: u => /^https?:\/\/(?:www\.|m\.)?soundcloud\.com\//i.test(u), ids: ['85', '980'] }, { test: u => /^https?:\/\/(?:www\.)?audiomack\.com\//i.test(u), ids: ['85', '980'] }, ]; const alWaitFor = (fn, ms) => new Promise(resolve => { const t0 = Date.now(); const tick = () => { let v = null; try { v = fn(); } catch (e) {} if (v) return resolve(v); if (Date.now() - t0 > (ms || 3000)) return resolve(null); setTimeout(tick, 60); }; tick(); }); const alAddInput = () => [...document.querySelectorAll('#external-links-editor input[type=url]')].find(i => !i.value); // Fill ONE url into the editor's empty (+) input and wait for MB to accept it // (it renders the row and hands us a fresh empty input for the next one). async function alAddUrl(url) { const input = alAddInput(); if (!input) return { url, ok: false, why: 'no empty link input' }; const setVal = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setVal.call(input, url); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); // The row keeps its url in the <input> while the editor is open — an <a.url> // only appears once MB renders it as a committed link — so don't wait for // one (that burned 4s per url waiting for something that never comes). const row = input.closest('tr'); const force = AL_TYPE_FORCE.find(f => f.test(url)); let typed = null; if (force && row) { const select = await alWaitFor(() => { const sib = row.nextElementSibling; return (sib && sib.classList.contains('relationship-item')) ? sib.querySelector('select.link-type') : null; }, 3000); // only fill a select MB left empty -- never override a type it did resolve if (select && !select.value) { const opt = force.ids.map(id => [...select.options].find(o => o.value === id)).find(Boolean); if (opt) { const setSel = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, 'value').set; setSel.call(select, opt.value); select.dispatchEvent(new Event('change', { bubbles: true })); typed = opt.textContent.trim(); } } } // MB gives us a NEW empty input once the url is accepted; if it never does, // the url was rejected (duplicate/invalid) and the next one would overwrite it. const fresh = await alWaitFor(() => { const n = alAddInput(); return (n && n !== input) ? n : null; }, 4000); return { url, ok: !!fresh, typed, why: fresh ? '' : 'MusicBrainz did not accept it (duplicate or unsupported?)' }; } let _alBusy = false; async function alAddUrls(urls) { if (_alBusy) return []; _alBusy = true; const results = []; try { for (const u of urls) results.push(await alAddUrl(u)); } finally { _alBusy = false; } const ok = results.filter(r => r.ok).length; const typed = results.filter(r => r.typed).length; Log.cat('Links').info('multi-link paste: added ' + ok + '/' + results.length + ' url(s)' + (typed ? ', set ' + typed + ' link type(s) MusicBrainz left blank' : '')); results.filter(r => !r.ok).forEach(r => Log.cat('Links').warn('multi-link paste: ' + r.url + ' -- ' + r.why)); return results; } /* majkinetor: "Make a hint show that it can be multiple links." MB's own placeholder is "Add link", which gives no reason to try pasting a block — the feature is invisible until someone happens to attempt it. React rewrites the input on re-render, so the hint is re-applied by the same observer that watches the editor rather than set once. */ const AL_HINT = 'Paste one or more links'; function alApplyHint() { document.querySelectorAll('#external-links-editor input[type=url]').forEach(i => { if (!i.value && i.placeholder !== AL_HINT) i.placeholder = AL_HINT; }); } let _alHintObs = null; function installLinkHint() { if (_alHintObs) return; const ed = document.getElementById('external-links-editor'); if (!ed) return; // retried by the caller's own observer tick _alHintObs = new MutationObserver(alApplyHint); _alHintObs.observe(ed, { childList: true, subtree: true }); alApplyHint(); } // One delegated listener: the editor re-renders its rows constantly, so // binding to the input itself would not survive. let _alPasteHooked = false; function installMultiLinkPaste() { if (_alPasteHooked) return; _alPasteHooked = true; // the editor mounts a moment after the release editor is ready installLinkHint(); if (!_alHintObs) { const t = setInterval(() => { installLinkHint(); if (_alHintObs) clearInterval(t); }, 300); setTimeout(() => clearInterval(t), 15000); } document.addEventListener('paste', ev => { const input = ev.target; if (!input || input.tagName !== 'INPUT' || input.type !== 'url') return; if (!input.closest('#external-links-editor')) return; const cd = ev.clipboardData || window.clipboardData; const text = cd && cd.getData ? cd.getData('text') : ''; if (!text) return; const urls = alExtractUrls(text); // Exactly one url and nothing else pasted: that is MB's own case, so leave // it alone -- no reason to take over ordinary single-link pasting. if (urls.length === 1 && urls[0] === text.trim()) return; if (!urls.length) return; ev.preventDefault(); alAddUrls(urls); }, true); } let _checking = false; async function checkAllLinks(setEnded) { if (_checking) return; _checking = true; const btn = document.getElementById('tc-ri-check'), stat = document.getElementById('tc-ri-check-status'); const links = linkRows(); if (btn) { btn.classList.add('busy'); btn.disabled = true; } if (stat) stat.textContent = 'checking ' + links.length + ' link(s)…'; let dead = 0, done = 0; const queue = links.slice(); const worker = async () => { while (queue.length) { const L = queue.shift(); const r = await checkUrl(L.url); _deadLinks.set(L.url, { dead: r.dead, code: r.status }); markLinkRow(L.row, r.dead, r.status); if (r.dead && setEnded) { for (const rel of L.rels) await setRelEnded(rel); } if (r.dead) dead++; done++; if (stat) stat.textContent = 'checked ' + done + '/' + links.length + (dead ? ' · ' + dead + ' dead' : ''); } }; await Promise.all([worker(), worker(), worker()]); if (btn) { btn.classList.remove('busy'); btn.disabled = false; } if (stat) stat.textContent = links.length ? (dead ? dead + ' dead link(s)' + (setEnded ? ' — marked “ended”' : '') : 'all ' + links.length + ' OK') : 'no links'; _checking = false; } // the "Check links" button + status, pinned at the bottom-right of the External-links box (across from the // add-link [+]). Hidden when the release has no links yet. Lives in the fieldset wrapper, not the React editor. function ensureCheckToolbar(col) { const fs = _riExtFs && _riExtFs.isConnected ? _riExtFs : (col && col.querySelector('fieldset')); if (!fs) return; let bar = fs.querySelector(':scope > #tc-ri-toolbar'); if (!bar) { fs.style.position = fs.style.position || 'relative'; bar = document.createElement('div'); bar.id = 'tc-ri-toolbar'; bar.innerHTML = '<span id="tc-ri-check-status"></span><button id="tc-ri-check" type="button" title="Check every external link for a dead/404 status. Dead links are faded and each of their relationship types is marked “This relationship has ended”."><span class="tc-spin"></span>⟳ Check links</button>'; bar.querySelector('#tc-ri-check').onclick = () => checkAllLinks(true); fs.appendChild(bar); } bar.style.display = linkRows().length ? '' : 'none'; // no links → no button } // ── Annotation editor: a small toolbar above the release annotation textarea, with a live // Preview (MB markup → HTML), Clear, and — inspired by kellnerd's annotationConverter — a // Markdown→MB converter and a WS2 "resolve names" action that labels bare MB entity URLs. ── const ANNO_NAME_FIELD = { artist:'name', label:'name', area:'name', place:'name', instrument:'name', series:'name', event:'name', genre:'name', 'release-group':'title', release:'title', recording:'title', work:'title' }; // release-group MUST precede release in the alternation (else "release" matches the prefix of "release-group/…") const ANNO_ENTITY_RE = /https?:\/\/(?:beta\.)?musicbrainz\.org\/(artist|label|area|place|instrument|series|event|genre|release-group|release|recording|work)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i; const _annoName = new Map(); // entity url → resolved display name (shared by Resolve-names + Preview) const _annoEsc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); // build nested <ul>/<ol> from a flat list of {level>=1, html, ordered} (MB nests by 4-space indentation; // "a." marks an auto-numbered list, "*" a bullet list) function bulletsToHtml(items) { let out = '', depth = 0, openLi = false; const stack = []; const open = ord => { const t = ord ? 'ol' : 'ul', c = ord ? 'tc-anno-ol' : 'tc-anno-ul'; out += `<${t} class="${c}">`; stack.push(t); }; const close = () => { out += `</${stack.pop()}>`; }; for (const it of items) { const lvl = Math.max(1, it.level); if (lvl > depth) { while (depth < lvl) { open(it.ordered); depth++; openLi = false; } } else { if (openLi) { out += '</li>'; openLi = false; } while (depth > lvl) { close(); depth--; if (depth > 0) out += '</li>'; } } out += '<li>' + it.html; openLi = true; } if (openLi) out += '</li>'; while (depth > 0) { close(); depth--; if (depth > 0) out += '</li>'; } return out; } // MB annotation markup → HTML, replicating the documented subset (musicbrainz.org/doc/Annotation): // ''italic'' '''bold''', = h1 = .. === h3 ===, [url] / [url|text] / bare-url links, ---- rule, // (4n)-space "*" nested bullets, 8-space code, [/] literal brackets. Pure + sync (no network). function annoToHtml(src) { if (!src || !src.trim()) return '<span class="tc-anno-empty">(nothing to preview)</span>'; src = String(src).replace(/[/g, '\x01').replace(/]/g, '\x02'); // protect literal brackets const inline = txt => { let s = _annoEsc(txt); const links = []; // pull links out first so '' / ''' never touch a URL const stash = html => '\x03' + (links.push(html) - 1) + '\x04'; // only http(s)/ftp are linkified (as MB does) — anything else (e.g. javascript:) renders as plain text const anchor = (url, label) => { url = url.trim(); if (!/^(?:https?|ftp):\/\//i.test(url)) return null; const name = _annoName.get(url); return stash(`<a href="${_annoEsc(url)}" target="_blank" rel="noopener">${label != null ? label : (name ? _annoEsc(name) : _annoEsc(url))}</a>`); }; s = s.replace(/\[([^\]|]+)\|([^\]]*)\]/g, (_m, url, text) => anchor(url, text ? _annoEsc(text) : null) ?? _annoEsc(_m)); s = s.replace(/\[([^\]|]+)\]/g, (_m, url) => anchor(url, null) ?? _annoEsc(_m)); s = s.replace(/(^|[\s(])((?:https?|ftp):\/\/[^\s<>]+[^\s<>.,;:!?)])/g, (_m, pre, url) => pre + (anchor(url, null) ?? _annoEsc(url))); s = s.replace(/'''''(.+?)'''''/g, '<b><i>$1</i></b>').replace(/'''(.+?)'''/g, '<b>$1</b>').replace(/''(.+?)''/g, '<i>$1</i>'); s = s.replace(/\x03(\d+)\x04/g, (_m, i) => links[+i]); // restore links return s; }; const lines = src.split(/\r?\n/), out = []; let i = 0; while (i < lines.length) { const ln = lines[i]; let m; if (/^\s*$/.test(ln)) { i++; continue; } if (/^-{4,}\s*$/.test(ln)) { out.push('<hr>'); i++; continue; } if ((m = ln.match(/^(={1,6})\s*(.*?)\s*=*\s*$/)) && m[2]) { const n = Math.min(m[1].length, 6); out.push(`<h${n} class="tc-anno-h">${inline(m[2])}</h${n}>`); i++; continue; } // lists BEFORE code: a "(4n)-space * " line is a level-n bullet, "(4n)-space a. " an auto-numbered item // (MB nests by indentation); only 8-space lines that are NOT a list item are code. if (/^ {4,}(?:\*|[a-z]\.)[ \t]+/i.test(ln)) { const items = []; let bm; while (i < lines.length && (bm = lines[i].match(/^( +)(\*|[a-z]\.)[ \t]+(.*)$/i))) { items.push({ level: Math.max(1, Math.floor(bm[1].length / 4)), ordered: bm[2] !== '*', html: inline(bm[3]) }); i++; } out.push(bulletsToHtml(items)); continue; } if (/^ {8}/.test(ln)) { const buf = []; while (i < lines.length && /^ {8}/.test(lines[i]) && !/^ {4,}(?:\*|[a-z]\.)[ \t]/i.test(lines[i])) { buf.push(_annoEsc(lines[i].slice(8))); i++; } out.push('<pre class="tc-anno-pre">' + buf.join('\n') + '</pre>'); continue; } const buf = []; // a paragraph: consume the CURRENT line first (do-while → i ALWAYS advances, so an do { buf.push(inline(lines[i])); i++; } // empty-title "= =" heading can't spin forever), while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^-{4,}\s*$/.test(lines[i]) && !/^={1,6}\s/.test(lines[i]) && !/^ {4,}(?:\*|[a-z]\.)[ \t]+/i.test(lines[i]) && !/^ {8}/.test(lines[i])); // then following non-blank, non-block lines out.push('<p>' + buf.join('<br>') + '</p>'); } return out.join('').replace(/\x01/g, '[').replace(/\x02/g, ']'); } // Markdown → MB annotation markup (kellnerd-inspired). Pure + sync. function mdToAnno(src) { if (!src) return src; const urls = [], blocks = []; // protect URLs (* / _) and code blocks from the inline passes const stashU = u => '\x05' + (urls.push(u) - 1) + '\x06'; const stashB = b => '\x07' + (blocks.push(b) - 1) + '\x08'; src = src.replace(/^```[^\n]*\n([\s\S]*?)\n```[ \t]*$/gm, (_m, code) => stashB(code.split('\n').map(l => ' ' + l).join('\n'))); // ```fenced``` → MB 8-space block src = src.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => `[${stashU(url)}|${text}]`); // [text](url) → [url|text] src = src.replace(/(^|[\s(])((?:https?|ftp):\/\/[^\s<>]+)/g, (_m, pre, url) => pre + stashU(url)); // bare urls src = src.replace(/\[([^\[\]]*)\]/g, (m, inner) => inner.includes('\x05') ? m : '[' + inner + ']'); // a non-link [x] → encoded brackets, so MB doesn't read it as a (broken) link src = src.replace(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/gm, (_m, h, t) => { const n = Math.min(h.length, 3); const e = '='.repeat(n); return `${e} ${t} ${e}`; }); src = src.replace(/(\*\*|__)(.+?)\1/g, "'''$2'''"); // **bold** / __bold__ src = src.replace(/(?<![*\w])\*(?!\s)(.+?)(?<!\s)\*(?![*\w])/g, "''$1''"); // *italic* src = src.replace(/(?<![_\w])_(?!\s)(.+?)(?<!\s)_(?![_\w])/g, "''$1''"); // _italic_ src = src.replace(/^[ \t]*(?:-{3,}|\*{3,}|_{3,})[ \t]*$/gm, '----'); // hr (before list rules, so --- isn't eaten) src = src.replace(/^([ \t]*)\d+\.[ \t]+/gm, (_m, ind) => { const sp = ind.replace(/\t/g, ' ').length; return ' '.repeat((Math.floor(sp / 2) + 1) * 4) + 'a. '; }); // markdown numbered list → MB "a." (MB auto-numbers a., not 1.) src = src.replace(/^([ \t]*)[-*+][ \t]+/gm, (_m, ind) => { const sp = ind.replace(/\t/g, ' ').length; return ' '.repeat((Math.floor(sp / 2) + 1) * 4) + '* '; }); // markdown bullet (2-space-per-level indent) → MB (4n)-space bullet src = src.replace(/\x07(\d+)\x08/g, (_m, i) => blocks[+i]); return src.replace(/\x05(\d+)\x06/g, (_m, i) => urls[+i]); } // MB annotation markup → Markdown (reverse of mdToAnno; powers the Markdown toggle's "back" direction) function annoToMd(src) { if (!src) return src; const blocks = []; // pull MB code blocks out first so '''/'' inside them aren't touched const stashB = b => '\x07' + (blocks.push(b) - 1) + '\x08'; src = src.replace(/(?:^ {8}(?! *(?:\*|[a-z]\.)[ \t]).*(?:\n|$))+/gim, m => { const trail = m.endsWith('\n') ? '\n' : ''; const code = m.replace(/\n$/, '').split('\n').map(l => l.slice(8)).join('\n'); return stashB('```\n' + code + '\n```') + trail; }); // MB 8-space block (not a nested list item) → ```fenced``` src = src.replace(/\[([^\]|]+)\|([^\]]*)\]/g, (_m, url, text) => text ? `[${text}](${url})` : url); // [url|text] → [text](url); [url|] (empty label) → bare url src = src.replace(/\[((?:https?|ftp):\/\/[^\]|]+)\]/g, (_m, url) => url); // [url] → bare url src = src.replace(/'''''(.+?)'''''/g, '***$1***').replace(/'''(.+?)'''/g, '**$1**').replace(/''(.+?)''/g, '*$1*'); src = src.replace(/^(={1,6})[ \t]*(.*?)[ \t]*=*[ \t]*$/gm, (_m, e, t) => '#'.repeat(e.length) + ' ' + t); // = H = → # H src = src.replace(/^( {4,})[a-z]\.[ \t]+/gim, (_m, ind) => ' '.repeat(Math.max(0, Math.floor(ind.length / 4) - 1)) + '1. '); // MB "a." auto-numbered → markdown "1." numbered list src = src.replace(/^( {4,})\*[ \t]+/gm, (_m, ind) => ' '.repeat(Math.max(0, Math.floor(ind.length / 4) - 1)) + '- '); // MB (4n)-space bullet → markdown (2-space-per-level) bullet src = src.replace(/^-{4,}[ \t]*$/gm, '---'); // ---- → --- src = src.replace(/[/g, '[').replace(/]/g, ']'); // decode literal brackets back to plain [ ] (literal in Markdown) return src.replace(/\x07(\d+)\x08/g, (_m, i) => blocks[+i]); } function annoReplaceAsync(str, re, fn) { // async String.replace (kellnerd's replaceAsync) const parts = []; let last = 0, m; re.lastIndex = 0; while ((m = re.exec(str)) !== null) { parts.push(str.slice(last, m.index), fn(...m, m.index, str)); last = m.index + m[0].length; if (!re.global) break; } parts.push(str.slice(last)); return Promise.all(parts).then(p => p.join('')); } async function annoLookupName(type, mbid, full) { // WS2 entity name, cached type = type.toLowerCase(); if (_annoName.has(full)) return _annoName.get(full); try { const res = await wsJson(`${location.origin}/ws/2/${type}/${mbid}?fmt=json`, { label: 'annotation entity name' }); const j = res.json; if (!j) return null; const name = j[ANNO_NAME_FIELD[type] || 'name']; if (name) { _annoName.set(full, name); return name; } return null; } catch { return null; } } // is this URL an MB entity URL? (tolerates a trailing path like /release/<mbid>/annotations) → {type,mbid} function annoEntity(url) { const m = ANNO_ENTITY_RE.exec(url); ANNO_ENTITY_RE.lastIndex = 0; return m ? { type: m[1].toLowerCase(), mbid: m[2] } : null; } // add the entity name to every MB entity link that doesn't already have one — handles MB [url] / [url|] // and Markdown []()/bare URLs, in either editing mode. Links that already carry a label are left alone. // Captures the URL first, THEN tests it for an entity, so trailing path segments don't break the match. async function annoResolveNames(src, md) { // md=true → emit Markdown links [Name](url); else MB links [url|Name] const lbl = async (url) => { const e = annoEntity(url); return e ? await annoLookupName(e.type, e.mbid, url) : null; }; if (!md) src = await annoReplaceAsync(src, /\[([^\]|]+)\|?\]/g, async (m, url) => { url = url.trim(); const n = await lbl(url); return n ? `[${url}|${n}]` : m; }); // MB [url] / [url|] src = await annoReplaceAsync(src, /\[\]\(([^)\s]+)\)/g, async (m, url) => { const n = await lbl(url); return n ? `[${n}](${url})` : m; }); // Markdown [](url) // a bare URL (not already inside a [..] or (..) link) → named, in the active markup src = await annoReplaceAsync(src, /(?<![\[(|])((?:https?|ftp):\/\/[^\s<>\]]+)/g, async (m, url) => { const n = await lbl(url); return n ? (md ? `[${n}](${url})` : `[${url}|${n}]`) : m; }); return src; } // annotation History: parse the /annotations page into a version list, and pull a single version's // rendered annotation HTML from its "View this version" page (musicbrainz.org, same-origin fetch). async function annoFetchHistory(mbid) { const r = await fetch(`${location.origin}/release/${mbid}/annotations`, { credentials: 'same-origin' }); if (!r.ok) throw new Error('history ' + r.status); const doc = new DOMParser().parseFromString(await r.text(), 'text/html'); const out = []; doc.querySelectorAll('table tr').forEach(tr => { // #722 the "View this version" link by its address (MB translates the text) const view = tr.querySelector('a[href*="/annotation/"]') || [...tr.querySelectorAll('a')].find(a => /this version/i.test(a.textContent || '')); if (!view) return; const ua = tr.querySelector('a[href^="/user/"]'); const editor = ua?.textContent.trim() || ''; const avatar = ua?.querySelector('img')?.getAttribute('src') || ''; const date = [...tr.querySelectorAll('td')].map(c => c.textContent.trim()).find(t => /\d{4}-\d{2}-\d{2}/.test(t)) || ''; const cl = (view.parentElement.textContent.match(/\(([^)]*)\)/) || ['', ''])[1]; // MB puts its "no changelog specified" in <em> (in any language); a real changelog is plain text const none = !!view.parentElement.querySelector('em') || /no changelog/i.test(cl); out.push({ editor, avatar, date, changelog: none ? '' : cl, url: view.getAttribute('href') }); }); return out; } async function annoFetchVersion(url) { const r = await fetch(new URL(url, location.origin).href, { credentials: 'same-origin' }); if (!r.ok) throw new Error('version ' + r.status); const doc = new DOMParser().parseFromString(await r.text(), 'text/html'); const body = doc.querySelector('.annotation-body'); return body ? body.innerHTML : '<em>(this version is empty)</em>'; } // reconstruct MB markup from a rendered annotation's HTML (MB exposes no raw text per revision) — used to // load a past version back into the editor. Lossy on exotic markup, faithful for the common elements. function annoHtmlToMb(html) { if (/this annotation is empty/i.test(html)) return ''; const root = new DOMParser().parseFromString('<div>' + html + '</div>', 'text/html').body.firstChild; const inline = node => { let s = ''; node.childNodes.forEach(n => { if (n.nodeType === 3) s += n.textContent; else if (n.nodeType === 1) { const t = n.tagName.toLowerCase(), inner = inline(n); if (t === 'strong' || t === 'b') s += "'''" + inner + "'''"; else if (t === 'em' || t === 'i') s += "''" + inner + "''"; else if (t === 'a') { const href = n.getAttribute('href') || ''; s += href ? `[${/^https?:\/\//i.test(href) ? href : location.origin + href}|${inner}]` : inner; } else if (t === 'br') s += '\n'; else s += inner; } }); return s; }; const lines = []; const listWalk = (listNode, ordered, level) => { [...listNode.children].forEach(li => { if (li.tagName.toLowerCase() !== 'li') return; const clone = li.cloneNode(true); clone.querySelectorAll(':scope > ul, :scope > ol').forEach(s => s.remove()); lines.push(' '.repeat(level * 4) + (ordered ? 'a. ' : '* ') + inline(clone).trim()); [...li.children].forEach(c => { const ct = c.tagName.toLowerCase(); if (ct === 'ul' || ct === 'ol') listWalk(c, ct === 'ol', level + 1); }); }); }; const walk = node => node.childNodes.forEach(n => { if (n.nodeType === 3) { if (n.textContent.trim()) lines.push(n.textContent.trim(), ''); return; } if (n.nodeType !== 1) return; const t = n.tagName.toLowerCase(); if (/^h[1-6]$/.test(t)) { const e = '='.repeat(Math.min(+t[1], 3)); lines.push(`${e} ${inline(n).trim()} ${e}`, ''); } else if (t === 'p') lines.push(inline(n).replace(/\n+$/, '').trim(), ''); else if (t === 'ul' || t === 'ol') { listWalk(n, t === 'ol', 1); lines.push(''); } else if (t === 'pre') { inline(n).replace(/\n$/, '').split('\n').forEach(l => lines.push(' ' + l)); lines.push(''); } else if (t === 'hr') lines.push('----', ''); else if (t === 'div' || t === 'blockquote') walk(n); else { const x = inline(n).trim(); if (x) lines.push(x, ''); } }); walk(root); return lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, ''); } // write into the annotation textarea so MB's model (knockout 'change' / React 'input') picks it up + dirties function annoSet(ta, value) { const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(ta, value); ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); } // Enter on a list line continues it (same indent+marker — bullet "*"/"-" or numbered "a."/"1."); an empty // item ends the list. Pure → testable. function annoContinueBullet(value, pos) { const lineStart = value.lastIndexOf('\n', pos - 1) + 1; let lineEnd = value.indexOf('\n', pos); if (lineEnd < 0) lineEnd = value.length; const m = value.slice(lineStart, lineEnd).match(/^([ \t]*)([*+-]|\d+\.|[a-z]\.)([ \t]+)(.*)$/i); if (!m) return null; if (m[4].trim() === '') return { value: value.slice(0, lineStart) + value.slice(lineEnd), caret: lineStart }; // empty item → end list const prefix = m[1] + m[2] + m[3]; return { value: value.slice(0, pos) + '\n' + prefix + value.slice(pos), caret: pos + 1 + prefix.length }; } // Ctrl/Cmd+B/I: wrap the selection with `marker`, or — with no selection — surround the word under the cursor. Pure. function annoWrap(value, selStart, selEnd, marker) { let a = selStart, b = selEnd; if (a === b) { while (a > 0 && /\w/.test(value[a - 1])) a--; while (b < value.length && /\w/.test(value[b])) b++; } return { value: value.slice(0, a) + marker + value.slice(a, b) + marker + value.slice(b), selStart: a + marker.length, selEnd: b + marker.length }; } // Tab on a selection cycles the lines through plain → bullet → numbered → bullet…; Shift+Tab (strip=true) // removes any list marker. Pure → testable. function annoListSelection(value, selStart, selEnd, raw, strip) { let s = value.lastIndexOf('\n', selStart - 1) + 1; let e = selEnd; if (e > s && value[e - 1] === '\n') e--; let lineEnd = value.indexOf('\n', e); if (lineEnd < 0) lineEnd = value.length; const block = value.slice(s, lineEnd); const first = (block.split('\n').find(l => l.trim() !== '') || '').match(/^[ \t]*([-*+]|\d+\.|[a-z]\.)/i); const ordered = !!(first && /[-*+]/.test(first[1])); // currently bullets → switch to numbered; else → bullets const repl = block.split('\n').map(ln => { if (ln.trim() === '') return ln; if (strip) return ln.replace(/^[ \t]*(?:[-*+]|\d+\.|[a-z]\.)[ \t]+/i, ''); // remove the list marker, keep the text const txt = ln.replace(/^[ \t]*(?:[-*+]|\d+\.|[a-z]\.)?[ \t]*/i, ''); // drop leading ws + any existing marker return raw ? (ordered ? ' a. ' : ' * ') + txt : (ordered ? '1. ' : '- ') + txt; }).join('\n'); return { value: value.slice(0, s) + repl + value.slice(lineEnd), selStart: s, selEnd: s + repl.length }; } // "Join lines": collapse the selected lines — or, with no selection, the paragraph at the caret — into a // single line, turning every interior newline into one space so hard-wrapped text (e.g. Bandcamp credits) // reflows. Leaves blank-line paragraph boundaries as the natural edge of an empty selection. Pure → testable. function annoJoinBlock(value, selStart, selEnd) { let a, b; if (selStart === selEnd) { // no selection → expand to the run of non-blank lines around the caret a = value.lastIndexOf('\n', selStart - 1) + 1; while (a > 0) { const ps = value.lastIndexOf('\n', a - 2) + 1; if (!value.slice(ps, a - 1).trim()) break; a = ps; } b = value.indexOf('\n', selEnd); if (b < 0) b = value.length; while (b < value.length) { let ne = value.indexOf('\n', b + 1); if (ne < 0) ne = value.length; if (!value.slice(b + 1, ne).trim()) break; b = ne; } } else { // selection → expand to whole lines a = value.lastIndexOf('\n', selStart - 1) + 1; let e = selEnd; if (e > a && value[e - 1] === '\n') e--; b = value.indexOf('\n', e); if (b < 0) b = value.length; } const joined = value.slice(a, b).replace(/[ \t]*\r?\n[ \t]*/g, ' ').replace(/[ \t]{2,}/g, ' ').replace(/^[ \t]+|[ \t]+$/g, ''); return { value: value.slice(0, a) + joined + value.slice(b), selStart: a, selEnd: a + joined.length }; } // Wrap #annotation in a bordered editor box (toolbar + a Markdown editing surface + the raw MB field + an // in-place preview). Markdown is the DEFAULT surface; the real #annotation field always holds MB markup (so // saving is always correct) — Markdown edits are converted into it live. Mounted ONCE per #annotation node // (the 500ms applyReleaseInfo poll must not rebuild it — that was the flicker). const ANNO_MD_LOGO = '<svg class="tc-mk-ico" viewBox="0 0 208 128" aria-hidden="true"><rect width="198" height="118" x="5" y="5" rx="10" fill="none" stroke="currentColor" stroke-width="10"/><path fill="currentColor" d="M30 98V30h20l20 25 20-25h20v68H110V59L90 84 70 59v39zm125 0l-30-33h20V30h20v35h20z"/></svg>'; const ANNO_MAX_ICON = '<svg class="tc-mk-ico tc-mk-sq" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'; // Join lines: two lines pulled together toward one (a downward merge between a top and bottom rule). const ANNO_JOIN_ICON = '<svg class="tc-mk-ico tc-mk-sq" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16"/><path d="M4 19h16"/><path d="M8 9l4 4 4-4"/></svg>'; const ANNO_MIN_ICON = '<svg class="tc-mk-ico tc-mk-sq" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'; const ANNO_MB_LOGO = '<img class="tc-mk-ico tc-mk-mb" alt="MB" src="https://images.dwncdn.net/images/t_app-icon-s/p/c5c33b93-7347-46e3-a512-3decccb33d78/1678792153/2170_4-166444-imgingest-4209519771082272608.png">'; const ANNO_HELP_HTML = '<b>Markdown</b> <span class="tc-help-dim">(converted to MusicBrainz markup on save)</span>' + '<table>' + '<tr><td><code>**bold**</code> <code>*italic*</code></td><td>bold / italic</td></tr>' + '<tr><td><code># H1</code> <code>## H2</code> <code>### H3</code></td><td>headings</td></tr>' + '<tr><td><code>- item</code></td><td>bullet list (indent 2 spaces to nest)</td></tr>' + '<tr><td><code>1. item</code></td><td>numbered list (MB auto-numbers)</td></tr>' + '<tr><td><code>[text](url)</code> · bare URL</td><td>link</td></tr>' + '<tr><td><code>```code```</code></td><td>code block</td></tr>' + '<tr><td><code>---</code></td><td>horizontal rule</td></tr>' + '<tr><td><code>[x]</code></td><td>shown literally (auto-encoded)</td></tr>' + '</table>' + '<b>Shortcuts</b>' + '<table>' + '<tr><td><code>Ctrl/Cmd+B</code> / <code>+I</code></td><td>bold / italic (selection or word)</td></tr>' + '<tr><td><code>Enter</code></td><td>continue the current list</td></tr>' + '<tr><td><code>Tab</code></td><td>indent · on a selection → bullet list (Tab again → numbered, again → bullet…)</td></tr>' + '<tr><td><code>Shift+Tab</code></td><td>on a selection → remove the list marker</td></tr>' + '</table>' + '<div class="tc-help-dim">A MusicBrainz entity URL (bare or <code>[]()</code>) gets its name added automatically.</div>'; function ensureAnnotationToolbar(taArg) { const ta = taArg || document.getElementById('annotation'); if (!ta) return; // the MB annotation field — always holds MB markup if (ta._tcAnnoMounted && ta._tcAnnoMounted.isConnected) return; const mbid = (location.pathname.match(/\/release\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/|$)/) || [])[1]; // present on /release/<mbid>/edit, absent on /release/add const wrap = document.createElement('div'); wrap.id = 'tc-anno-wrap'; const bar = document.createElement('div'); bar.id = 'tc-anno-bar'; bar.innerHTML = '<button type="button" id="tc-anno-preview-btn" title="Toggle a live split preview — editor on the left, rendered annotation on the right">👁 Preview</button>' + '<button type="button" id="tc-anno-clear" title="Clear the annotation">✕ Clear</button>' + '<span class="tc-anno-sp tc-anno-sp1"></span>' + '<button type="button" id="tc-anno-join" class="tc-anno-icon" title="Join lines — merge the selected lines into one, dropping the hard line breaks so the text reflows. With nothing selected, joins the paragraph at the cursor." aria-label="Join lines">' + ANNO_JOIN_ICON + '</button>' + '<button type="button" id="tc-anno-md" class="tc-anno-icon" title="">' + ANNO_MD_LOGO + '</button>' + '<button type="button" id="tc-anno-help" class="tc-anno-icon" title="Annotation syntax help" aria-label="Syntax help">?</button>' + '<span class="tc-anno-sp tc-anno-sp2"></span>' + '<button type="button" id="tc-anno-max" class="tc-anno-icon" title="Maximize the editor (Esc to restore)" aria-label="Maximize">' + ANNO_MAX_ICON + '</button>' + (mbid ? '<button type="button" id="tc-anno-history-btn" title="Browse this annotation\'s previous versions and display any one">🕘 History</button>' : ''); const body = document.createElement('div'); body.id = 'tc-anno-body'; const editPane = document.createElement('div'); editPane.id = 'tc-anno-edit'; const md = document.createElement('textarea'); md.id = 'tc-anno-mdinput'; md.spellcheck = false; // the Markdown editing surface const prev = document.createElement('div'); prev.id = 'tc-anno-preview'; prev.style.display = 'none'; const hist = document.createElement('div'); hist.id = 'tc-anno-history'; hist.style.display = 'none'; const helpPop = document.createElement('div'); helpPop.id = 'tc-anno-help-pop'; helpPop.innerHTML = ANNO_HELP_HTML; ta.parentNode.insertBefore(wrap, ta); editPane.append(md, ta); body.append(editPane, prev); wrap.append(bar, body, hist, helpPop); ta._tcAnnoMounted = wrap; // the field's row — a <tr> in the release editor, a div.row on the standalone Edit annotation page const annoRow = wrap.closest('tr, .row'); // release editor: put Disambiguation above Annotation (both already span the full column via CSS) const commRow = document.getElementById('comment')?.closest('tr'); if (annoRow && commRow && commRow !== annoRow && annoRow.previousElementSibling !== commRow) annoRow.parentNode.insertBefore(commRow, annoRow); // status messages appear next to the "Annotation:" label (not in the toolbar) const statusEl = document.createElement('span'); statusEl.id = 'tc-anno-status'; const labelCell = annoRow?.querySelector('td:first-child label') || annoRow?.querySelector('td:first-child') || annoRow?.querySelector('label'); if (labelCell) labelCell.appendChild(statusEl); const $ = id => bar.querySelector('#' + id); const status = (msg, ms) => { statusEl.textContent = msg ? ' — ' + msg : ''; if (ms) setTimeout(() => { if (statusEl.textContent === ' — ' + msg) statusEl.textContent = ''; }, ms); }; // apply a new value as a minimal range edit via execCommand, so it joins the textarea's NATIVE undo stack // (Ctrl+Z undoes Ctrl+B/I, Tab lists, Enter continuation, …). Falls back to the native setter if unsupported. const editTa = (el, val, s, e2) => { const old = el.value; if (old !== val) { let p = 0; const lim = Math.min(old.length, val.length); while (p < lim && old[p] === val[p]) p++; let so = old.length, sn = val.length; while (so > p && sn > p && old[so - 1] === val[sn - 1]) { so--; sn--; } const ins = val.slice(p, sn); el.focus(); el.setSelectionRange(p, so); let ok = false; try { ok = ins ? document.execCommand('insertText', false, ins) : (so > p ? document.execCommand('delete') : true); } catch { ok = false; } if (!ok || el.value !== val) { const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(el, val); el.dispatchEvent(new Event('input', { bubbles: true })); } // fallback: no native undo, but correct } el.setSelectionRange(s, e2 == null ? s : e2); el.dispatchEvent(new Event('change', { bubbles: true })); }; const syncMdToField = () => annoSet(ta, mdToAnno(md.value)); // Markdown surface → MB field (keeps the model correct) const activeEl = () => surface === 'raw' ? ta : md; let surface = 'md', previewing = false, view = 'edit'; // surface: md|raw · previewing: split preview · view: edit|history const renderPreview = () => { if (previewing && view === 'edit') prev.innerHTML = annoToHtml(ta.value); }; const apply = () => { body.style.display = view === 'edit' ? 'flex' : 'none'; hist.style.display = view === 'history' ? '' : 'none'; md.style.display = surface === 'md' ? '' : 'none'; ta.style.display = surface === 'raw' ? '' : 'none'; prev.style.display = previewing && view === 'edit' ? '' : 'none'; bar.classList.toggle('tc-anno-prev-on', previewing); bar.classList.toggle('tc-anno-hist-on', view === 'history'); const mdBtn = $('tc-anno-md'); mdBtn.innerHTML = surface === 'md' ? ANNO_MD_LOGO : ANNO_MB_LOGO; mdBtn.title = surface === 'md' ? 'Editing as Markdown — click to edit the raw MusicBrainz markup' : 'Editing raw MusicBrainz markup — click to edit as Markdown'; renderPreview(); }; let previewT; md.addEventListener('input', () => { if (surface === 'md') syncMdToField(); clearTimeout(previewT); previewT = setTimeout(renderPreview, 120); }); ta.addEventListener('input', () => { if (surface === 'raw') { clearTimeout(previewT); previewT = setTimeout(renderPreview, 120); } }); const wireKeys = el => el.addEventListener('keydown', e => { const raw = el === ta; if (e.key === 'Enter' && !e.shiftKey && el.selectionStart === el.selectionEnd) { const r = annoContinueBullet(el.value, el.selectionStart); if (r) { e.preventDefault(); editTa(el, r.value, r.caret); } } else if (e.key === 'Tab') { e.preventDefault(); if (el.selectionStart !== el.selectionEnd) { // Tab → bullet → numbered → bullet…; Shift+Tab → remove the list marker const r = annoListSelection(el.value, el.selectionStart, el.selectionEnd, raw, e.shiftKey); editTa(el, r.value, r.selStart, r.selEnd); } else if (!e.shiftKey) { const p = el.selectionStart, v = el.value; editTa(el, v.slice(0, p) + '\t' + v.slice(p), p + 1); } // plain Tab → insert a tab } else if ((e.ctrlKey || e.metaKey) && !e.altKey && /^[biBI]$/.test(e.key)) { e.preventDefault(); const bold = e.key.toLowerCase() === 'b'; const marker = raw ? (bold ? "'''" : "''") : (bold ? '**' : '*'); const r = annoWrap(el.value, el.selectionStart, el.selectionEnd, marker); editTa(el, r.value, r.selStart, r.selEnd); } }); wireKeys(md); wireKeys(ta); // auto-resolve unnamed MB entity links (no button) — on blur of the editing surface, and once on mount let resolving = false; const autoResolve = async (el) => { if (resolving || !ANNO_ENTITY_RE.test(el.value)) return; resolving = true; const before = el.value; try { const after = await annoResolveNames(before, el === md); if (after !== before && el.value === before) { editTa(el, after, Math.min(el.selectionStart, after.length)); status('named entity links', 2000); } } finally { resolving = false; } }; md.addEventListener('blur', () => autoResolve(md)); ta.addEventListener('blur', () => autoResolve(ta)); $('tc-anno-preview-btn').onclick = () => { previewing = !previewing; apply(); }; $('tc-anno-md').onclick = () => { if (surface === 'md') syncMdToField(); surface = surface === 'md' ? 'raw' : 'md'; if (surface === 'md') md.value = annoToMd(ta.value); apply(); activeEl().focus(); }; $('tc-anno-clear').onclick = () => { md.value = ''; annoSet(ta, ''); renderPreview(); }; // Join lines: reflow the selected lines (or the caret's paragraph) into one — works on whichever surface is active $('tc-anno-join').onclick = () => { const el = activeEl(); const r = annoJoinBlock(el.value, el.selectionStart, el.selectionEnd); editTa(el, r.value, r.selStart, r.selEnd); el.focus(); }; // maximize / restore the editor (fills the viewport) const setMax = on => { wrap.classList.toggle('tc-anno-max', on); document.body.classList.toggle('tc-anno-max-open', on); if (on) wrap.style.height = ''; else if (wrap._tcFill) wrap._tcFill(); const b = $('tc-anno-max'); b.innerHTML = on ? ANNO_MIN_ICON : ANNO_MAX_ICON; b.title = on ? 'Restore the editor (Esc)' : 'Maximize the editor (Esc to restore)'; renderPreview(); }; $('tc-anno-max').onclick = () => setMax(!wrap.classList.contains('tc-anno-max')); wrap.addEventListener('keydown', e => { if (e.key === 'Escape' && wrap.classList.contains('tc-anno-max')) { setMax(false); activeEl().focus(); } }); // click-to-show help popover (#521, majkinetor: "it pops out when you // casually move mouse" — hover was too eager). Toggles on click, closes // on a click anywhere else or Escape. const help = $('tc-anno-help'); const showHelp = () => { helpPop.classList.add('on'); const r = help.getBoundingClientRect(), ph = helpPop.offsetHeight, pw = helpPop.offsetWidth; const left = Math.max(8, Math.min(r.left, window.innerWidth - pw - 8)); let top = r.bottom + 6; if (top + ph > window.innerHeight - 8) top = Math.max(8, r.top - ph - 6); // flip above the button if it would overflow the bottom if (top < 8) top = 8; helpPop.style.left = Math.round(left) + 'px'; helpPop.style.top = Math.round(top) + 'px'; }; const hideHelp = () => helpPop.classList.remove('on'); help.addEventListener('click', e => { e.stopPropagation(); if (helpPop.classList.contains('on')) hideHelp(); else showHelp(); }); const outsideHideHelp = e => { if (helpPop.classList.contains('on') && !helpPop.contains(e.target) && e.target !== help) hideHelp(); }; document.addEventListener('mousedown', outsideHideHelp, true); // removed in unmountAnnotation() wrap._tcOutsideHideHelp = outsideHideHelp; wrap.addEventListener('keydown', e => { if (e.key === 'Escape' && helpPop.classList.contains('on')) { e.stopPropagation(); hideHelp(); } }); // History — version list as user cards on the RIGHT, the selected version rendered on the LEFT const renderHistory = async () => { hist.innerHTML = '<div class="tc-hist-msg">Loading history…</div>'; let versions; try { versions = await annoFetchHistory(mbid); } catch { hist.innerHTML = '<div class="tc-hist-msg">Failed to load history.</div>'; return; } if (!versions.length) { hist.innerHTML = '<div class="tc-hist-msg">No annotation history yet.</div>'; return; } hist.innerHTML = '<div class="tc-hist-view"><div class="tc-hist-msg">Select a version to display it.</div></div><div class="tc-hist-list"></div>'; const list = hist.querySelector('.tc-hist-list'), vw = hist.querySelector('.tc-hist-view'); versions.forEach((v, idx) => { const card = document.createElement('div'); card.className = 'tc-hist-card'; card.tabIndex = 0; card.innerHTML = (v.avatar ? `<img class="tc-hist-av" src="${_annoEsc(v.avatar)}" alt="">` : '<span class="tc-hist-av tc-hist-av0"></span>') + `<span class="tc-hist-meta"><span class="tc-hist-editor">${_annoEsc(v.editor)}</span><span class="tc-hist-date">${_annoEsc(v.date)}</span>` + (idx === 0 ? '<span class="tc-hist-cl tc-hist-cur">current</span>' : '') + (v.changelog ? `<span class="tc-hist-cl tc-hist-clmsg" title="${_annoEsc(v.changelog)}">“${_annoEsc(v.changelog)}”</span>` : '') + '</span>' + `<button type="button" class="tc-hist-revert" title="${idx === 0 ? 'Revert the editor to the saved version (discard unsaved changes made this session)' : 'Revert the editor to this version (reconstructed from the rendered version — review before submitting)'}">↶</button>`; card.onclick = async (e) => { if (e.target.closest('.tc-hist-revert')) return; list.querySelectorAll('.tc-hist-card').forEach(c => c.classList.remove('on')); card.classList.add('on'); vw.innerHTML = '<div class="tc-hist-msg">Loading…</div>'; try { vw.innerHTML = '<div class="tc-anno-rendered">' + await annoFetchVersion(v.url) + '</div>'; } catch { vw.innerHTML = '<div class="tc-hist-msg">Failed to load this version.</div>'; } }; const revert = card.querySelector('.tc-hist-revert'); if (revert) revert.onclick = async (e) => { e.stopPropagation(); revert.disabled = true; try { const mb = annoHtmlToMb(await annoFetchVersion(v.url)); annoSet(ta, mb); md.value = annoToMd(ta.value); view = 'edit'; apply(); activeEl().focus(); status('reverted to ' + v.date + ' — review before submitting', 4000); } catch { status('failed to load that version', 3000); } finally { revert.disabled = false; } }; list.appendChild(card); }); }; if ($('tc-anno-history-btn')) $('tc-anno-history-btn').onclick = () => { view = view === 'history' ? 'edit' : 'history'; apply(); if (view === 'history') renderHistory(); }; md.value = annoToMd(ta.value); // seed the Markdown surface from existing MB markup (no annoSet → no spurious dirty) apply(); // reflect EXTERNAL changes to the annotation (Scribe, scripts, revert) in the Markdown surface. // ko's `value` binding sets the textarea WITHOUT firing input, so a DOM listener can't see it — // subscribe to the observable. Our own md→field writes round-trip equal, so they're ignored (no loop). try { const annoObs = getEditor().rootField.release().annotation; if (annoObs && typeof annoObs.subscribe === 'function' && !ta._tcAnnoSub) { ta._tcAnnoSub = annoObs.subscribe(v => { const nv = v || ''; if (mdToAnno(md.value) === nv) return; // our own write / already in sync md.value = annoToMd(nv); // render the preview from nv directly — ta.value may not be updated yet (ko subscriber // order isn't guaranteed), which would leave the split preview showing the OLD annotation. if (previewing && view === 'edit') prev.innerHTML = annoToHtml(nv); }); } } catch (e) {} setTimeout(() => autoResolve(activeEl()), 400); // name any unnamed links already in the annotation } function annoWant() { return apolloEnabled() && SETTINGS.modifyAnnotation !== false; } // global Apollo toggle + the "Modify annotations" setting // tear the editor down and put the native textarea back (when the setting is turned off) function unmountAnnotation(taArg) { const ta = taArg || document.getElementById('annotation'); const wrap = ta && ta._tcAnnoMounted; if (!wrap || !wrap.isConnected) { if (ta) ta._tcAnnoMounted = null; return; } if (wrap._tcOutsideHideHelp) document.removeEventListener('mousedown', wrap._tcOutsideHideHelp, true); // #521 — a document-level listener, not cleaned up by wrap.remove() ta.style.display = ''; wrap.parentNode.insertBefore(ta, wrap); wrap.remove(); ta._tcAnnoMounted = null; document.getElementById('tc-anno-status')?.remove(); } // standalone /release/<mbid>/edit_annotation page: mount our editor on the annotation field, move the // Changelog above it (like Disambiguation in /edit). Gated by the same "Modify annotations" setting. function applyAnnotationPage() { if (!ANNO_PAGE_RE.test(location.pathname)) return; const ta = document.querySelector('textarea[name="edit-annotation.text"]'); if (!ta) return; ensureLauncher(); // the floating Original / Apollo switcher + ⚙ settings, same as the release editor const form = ta.closest('form'), hide = annoWant(); if (hide) { document.body.classList.add('tc-ri-on', 'tc-anno-page'); if (!document.getElementById('tc-ri-style')) riStyle(); ensureAnnotationToolbar(ta); const annoRow = (ta._tcAnnoMounted || ta).closest('.row'), clRow = document.querySelector('input[name="edit-annotation.changelog"]')?.closest('.row'); if (annoRow && clRow && clRow !== annoRow && annoRow.previousElementSibling !== clRow) annoRow.parentNode.insertBefore(clRow, annoRow); } else { unmountAnnotation(ta); document.body.classList.remove('tc-anno-page'); } // hide everything below the editor — the Edit note (the Change note already serves that role), the "Make all // edits votable" row, the native Preview button, and MB's formatting guide — keeping only "Enter edit". const annoRowEl = (ta._tcAnnoMounted || ta).closest('.row'), els = new Set(); // #722 #723 the guide is the first h3 after the form (MB translates its "Annotation formatting") const fh = (form && [...document.querySelectorAll('#content > h3')].find(h => form.compareDocumentPosition(h) & Node.DOCUMENT_POSITION_FOLLOWING)) || [...document.querySelectorAll('#content h3')].find(h => /annotation formatting/i.test(h.textContent || '')); if (fh) { els.add(fh); for (let n = fh.nextElementSibling; n; n = n.nextElementSibling) els.add(n); } // the guide is the h3 + everything after it (NOT its parent, which is #content!) [...document.querySelectorAll('#content h2')].forEach(h => { if (/^\s*edit note\s*$/i.test((h.textContent || '').trim())) { els.add(h); let n = h.nextElementSibling; while (n && n.tagName === 'P') { els.add(n); n = n.nextElementSibling; } } }); if (form && annoRowEl) { const kids = [...form.children], ai = kids.indexOf(annoRowEl); kids.forEach((ch, i) => { if (i > ai) els.add(ch); }); } // everything below the editor, incl. the native buttons els.forEach(el => { el.style.display = hide ? 'none' : ''; }); // our own "Enter edit" (the native one is hidden) — placed to the right of the Changelog input; just clicks // the native submit so MB's flow runs const clInput = document.querySelector('input[name="edit-annotation.changelog"]'), clRow = clInput?.closest('.row'); let sub = document.getElementById('tc-anno-submit'); if (hide && !sub && clInput) { sub = document.createElement('button'); sub.id = 'tc-anno-submit'; sub.type = 'button'; sub.textContent = '✓ Enter edit'; sub.onclick = () => { const b = form && (form.querySelector('button.submit.positive') || [...form.querySelectorAll('button, input[type=submit]')].find(x => x.id !== 'tc-anno-submit' && /enter edit/i.test((x.textContent || x.value || '').trim()))); if (b) b.click(); }; // #722 by its class, as MB translates "Enter edit" // the NATIVE submit (not ourselves) clInput.after(sub); } if (clRow) clRow.classList.toggle('tc-cl-row', hide); if (sub) sub.style.display = hide ? '' : 'none'; document.getElementById('tc-anno-fouc')?.remove(); // reveal the (now transformed) form — no native flash // make the editor fill the remaining viewport height (down to just above the footer) const w = ta._tcAnnoMounted; if (hide && w) { if (!w._tcFill) { w._tcFill = () => { if (!w.isConnected || w.classList.contains('tc-anno-max')) { w.style.height = ''; return; } w.style.height = Math.max(300, window.innerHeight - w.getBoundingClientRect().top - 18) + 'px'; }; window.addEventListener('resize', w._tcFill); } requestAnimationFrame(w._tcFill); } else if (w && w._tcFill) w.style.height = ''; } // MB's contextual guidance box(es) — anything outside #information that's just the style-guidelines help // (the in-panel ones are hidden by CSS via #information .bubble/.guidance) function nativeHelpBubbles() { const out = new Set(); const isHelp = e => !e.querySelector('input,button,select,textarea'); // a functional editor bubble (URL cleanup, add/edit link) has controls — never hide it document.querySelectorAll('#release-editor .bubble, #release-editor .guidance, #release-editor .guidance-popover, #page .bubble').forEach(e => { if (isHelp(e)) out.add(e); }); [...document.querySelectorAll('#page div')].forEach(e => { if (e.offsetParent === null || document.getElementById('information')?.contains(e)) return; if (e.querySelector('a[href*="style"]') && (e.textContent || '').length < 400 && !e.querySelector('input,button,select,textarea,table,fieldset,h2')) out.add(e); }); return [...out]; } // clicking the favicon edits the URL (edit1); clicking the type chip edits the relationship type (edit2). // Both proxy to MB's own (hover-hidden) pencil buttons so the native editor bubble does the actual work. let _riClicksWired = false; function wireLinkClicks() { if (_riClicksWired) return; _riClicksWired = true; // right-click the favicon → edit URL; right-click a type → edit type. Both proxy to MB's own pencil button. document.addEventListener('contextmenu', e => { if (!document.body.classList.contains('tc-ri-on')) return; const ext = document.getElementById('external-links-editor'); if (!ext || !ext.contains(e.target)) return; const type = e.target.closest('.relationship-name, .relationship-content, select.link-type'); if (type) { const btn = type.closest('tr.relationship-item')?.querySelector('button.edit-item'); if (btn) { e.preventDefault(); btn.click(); } return; } const linkRow = e.target.closest('tr.external-link-item'); if (linkRow && e.target.closest('td:first-child')) { // the favicon cell const btn = linkRow.querySelector('button.edit-item'); if (btn) { e.preventDefault(); btn.click(); } } }); } // #391 right-click a release-event or label ✕ → a GT-style "Remove all but this one" batch cleanup. // Keeps the row you right-clicked and removes every OTHER entry straight off the KO observableArray. let _riBatchWired = false; function wireReleaseInfoBatch() { if (_riBatchWired) return; _riBatchWired = true; document.addEventListener('contextmenu', e => { const btn = e.target.closest('button.remove-item.remove-release-event, button.remove-item.remove-release-label'); if (!btn) return; const isEvent = btn.classList.contains('remove-release-event'); const rel = release(); if (!rel) return; const arr = isEvent ? rel.events : rel.labels; if (typeof arr !== 'function' || typeof arr.remove !== 'function') return; const list = arr() || []; const n = list.length - 1; // everything except the clicked row if (n < 1) return; // 0–1 rows → nothing to batch-remove; let the native menu through e.preventDefault(); const sel = isEvent ? 'button.remove-item.remove-release-event' : 'button.remove-item.remove-release-label'; const idx = [...document.querySelectorAll(sel)].indexOf(btn); // the clicked row's position = its array index const keep = idx >= 0 ? list[idx] : list[0]; const noun = isEvent ? 'release event' : 'label'; openMiniMenu(btn, [{ label: `Remove all but this one (${n})`, title: `Remove the other ${n} ${noun}${n > 1 ? 's' : ''}, keeping this row`, onClick: () => { for (const it of (arr() || []).slice()) { if (it !== keep) { try { arr.remove(it); } catch (x) {} } } } }]); }, true); } // Tell the user the favicon + type are right-click-editable (the affordance isn't obvious). // Re-applied each tick via applyReleaseInfo, so React re-renders that drop the title get it back. function annotateLinkEditHints() { const ext = document.getElementById('external-links-editor'); if (!ext) return; const URL_HINT = 'Right-click to edit the URL', TYPE_HINT = 'Right-click to edit the relationship type'; ext.querySelectorAll('tr.external-link-item > td:first-child').forEach(td => { if (td.title !== URL_HINT) td.title = URL_HINT; }); ext.querySelectorAll('tr.relationship-item .relationship-name, tr.relationship-item select.link-type').forEach(el => { if (el.title !== TYPE_HINT) el.title = TYPE_HINT; }); } // MB indents hierarchical link-type options with leading spaces (" purchase for download"); the <select> // shows the selected option *with* that indent, pushing the text right of the URL. Trim it (display only — // MB keys on option.value, not text). A self-guarded observer re-trims when MB re-renders the options. let _riOptObs = null; function tidyLinkTypeOptions() { const ext = document.getElementById('external-links-editor'); if (!ext) return; const apply = () => { // trim the leading-space indent MB puts on hierarchical link-type options so combos align with the URL ext.querySelectorAll('select.link-type option').forEach(o => { const t = o.textContent, tr = t.replace(/^\s+/, '').replace(/\s+$/, ''); if (tr !== t) o.textContent = tr; }); // the attribute checkbox (e.g. "video") shows compactly with its caption hidden — surface the caption as a tooltip ext.querySelectorAll('tr.relationship-item .attribute-container label').forEach(l => { const cap = (l.textContent || '').trim(); if (!cap) return; if (l.title !== cap) l.title = cap; const cb = l.querySelector('input'); if (cb && cb.title !== cap) cb.title = cap; }); // the error text is collapsed to a "!" badge — surface the full message as its tooltip ext.querySelectorAll('tr.relationship-item .error.field-error').forEach(e => { const t = (e.textContent || '').trim(); if (t && e.title !== t) e.title = t; }); remarkDeadLinks(); // re-apply dead-link fading after a re-render ensureCheckToolbar(); // keep the Check-links button present + hidden when there are no links }; _riOptObs?.disconnect(); apply(); if (!_riOptObs) _riOptObs = new MutationObserver(() => { _riOptObs.disconnect(); apply(); _riOptObs.observe(ext, { childList: true, subtree: true }); }); _riOptObs.observe(ext, { childList: true, subtree: true }); } let _riPrevOn = false; function applyReleaseInfo() { riStyle(); wireLinkClicks(); wireReleaseInfoBatch(); autoSelectReleaseGroup(); // #716 // #652 (majkinetor): "no bubbles anywhere" — the #143 selection popover is retired; the badges say what was picked if (riWant()) { _apolloUsed = true; mbuCls(document.body, 'tc-ri-on', true); // #569: guarded — re-added 2x/s otherwise relocateLinks(true); tidyLinkTypeOptions(); renderRiArtist(); renderRiLabels(); renderRiReleaseGroup(); // #652: badges and offers by the artist and label fields annotateLinkEditHints(); if (annoWant()) ensureAnnotationToolbar(); else unmountAnnotation(); // #569: 170 redundant class writes in a 5s idle window — the single // largest source of the DevTools blinking chaban reported nativeHelpBubbles().forEach(b => mbuCls(b, 'tc-ri-helphidden', true)); _riPrevOn = true; } else { relocateLinks(false); renderRiArtist(); renderRiLabels(); renderRiReleaseGroup(); // #652: removed in the Original view unmountAnnotation(); // Apollo off → tear the annotation editor down too, so the field reverts to native (the toolbar must not linger) mbuCls(document.body, 'tc-ri-on', false); document.querySelectorAll('.tc-ri-helphidden').forEach(e => e.classList.remove('tc-ri-helphidden')); if (_riPrevOn) { _riPrevOn = false; resetDocBubbles(); } // one-shot on switch → drop Apollo-era bubble geometry watchDocBubbles(); } } // On switching Apollo→Original, any documentation bubble still flagged display:block carries the position // MB computed while the column was hidden (left ~0), so it flashes mispositioned on the left until the user // focuses a field. Hide the leftover bubbles inline (MB's own toggle); MB re-shows + repositions on next focus. (#143) function resetDocBubbles() { const doc = document.querySelector('#information > div.documentation'); if (!doc) return; doc.querySelectorAll('.bubble').forEach(b => { if (/display:\s*block/.test(b.getAttribute('style') || '')) b.style.display = 'none'; }); } // MB sizes its contextual help bubble to the documentation column's width once, and caches it. While Apollo // hid that column (display:none → width 0) it caches width:0, so in the Original view the bubble renders ~24px // wide and the text wraps one word per line ("scrambled"). Override the stale width to the real column width. let _docBubObs = null; function watchDocBubbles() { const doc = document.querySelector('#information > div.documentation'); if (!doc) return; const fix = () => { if (document.body.classList.contains('tc-ri-on')) return; // only the Original view is affected const w = doc.clientWidth; if (w < 80) return; doc.querySelectorAll('.bubble').forEach(b => { if (b.offsetParent === null) return; const cur = parseFloat(b.style.width) || b.getBoundingClientRect().width; if (cur < w - 24) b.style.width = w + 'px'; // self-guarded: once set to w, no further change }); }; if (!_docBubObs) { _docBubObs = new MutationObserver(fix); _docBubObs.observe(doc, { attributes: true, attributeFilter: ['style'], childList: true, subtree: true }); } fix(); } W.__apolloEditor = { lsDetectScript, lsByScript, lsCandidates, lsDetectLanguage, LS_LANDE, lsClean, detectLangScript, matchCardHtml, readTracklist, buildModel, commitTrack, resetTrack, revertTrack, trackChanged, removeTrack, moveTrack, addTracks, searchArtist, fetchEntity, createArtist, openPanel, showMirror, hideMirror, revertAll, revertSlot, pickArtist, addSlot, removeSlot, splitSlot, matchSlot, snapshotOriginals, readRecordings, showRecMirror, hideRecMirror, recordingsVisible, recConfidence, applyView, applyNav, applyReleaseInfo, releaseInfoVisible, ensureApolloEditNote, checkAllLinks, checkUrl, linkRows, alExtractUrls, alAddUrls, installMultiLinkPaste, alApplyHint, AL_HINT, discogsReleaseUrlFromPage, loadDiscogsMap, resolveByDiscogsUrl, discogsFeatUrlFor, tagDiscogsAddable, tagDiscogsForAll, addOrCreateDiscogsLink, reTagAfterDiscogsLink, artistDiscogsUrls, platformOf, platformUrlForms, resolvePlatformUrls, resolveByPlatformUrl, fcHandoff, fcPlatformUrl, tagPlatformAddable, tagPlatformForAll, addOrCreatePlatformLink, matchReleaseArtist, matchReleaseLabels, searchLabel, createLabel, riPickLabel, riEditionArtists, riPick, get riArt() { return _riArt; }, get rgAuto() { return _rgAuto; }, autoSelectReleaseGroup, dhRun, acLinksDiff, fetchRgPositionIndex, fetchDuplicatePositionIndex, recSimilar, recComboLevel, recPickBest, pickSibArtist, loadSiblingMap, autoMatchRecordings, setDataBoundary, videoBlockedHere, NON_VIDEO_FORMAT_IDS, trackRecIsVideo, newRecordingFor, logMarkdown, openLengthParser, lpParse, lpValid, lpExtractFromHtml, lpNoteSource, openTrackPatternParser, tpCompile, resolveByExactAlias, wsJson, stopMatching, lenShadeAlpha, lenShade, dupLenShade, mergeMediums, splitMedium, pickTool, runAction, slotContextGids, releaseArtistGids, positionArtists, posNameMatch, tallyPosArtists, artistPosRgIndex, artistPosDupIndex, rgReleases, duplicateReleases, enteredTracklist, buildDupDetail, get apolloOn() { return apolloOn(); }, get model() { return MODEL; }, get settings() { return SETTINGS; } }; // #267 auto-confirm a seeded Add/Edit-release submission. When another site seeds the editor, // MusicBrainz shows a `.confirm-seed` interstitial with a single submit button; clicking it // proceeds into the pre-filled editor. Behind an option (on by default), Apollo clicks it for // you — integrating chaban's "Auto click confirm form submission" script (greasyfork 536999). // Inert on a normal editor load (no `.confirm-seed`); `?skip_confirmation` bypasses it once. const whenDomReady = () => new Promise(r => { if (document.readyState !== 'loading') r(); else document.addEventListener('DOMContentLoaded', () => r(), { once: true }); }); async function autoConfirmSeed() { await whenDomReady(); const btn = document.querySelector('.confirm-seed button[type="submit"], form.confirm-seed button[type="submit"]'); if (!btn) return false; // not the seed interstitial → let the normal flow run if (SETTINGS.autoConfirmSeed === false) { Log.cat('Create').info('seed interstitial — auto-confirm off'); return true; } if (new URLSearchParams(location.search).has('skip_confirmation')) { Log.cat('Create').info('seed interstitial — skip_confirmation set'); return true; } Log.cat('Create').info('auto-confirming seeded submission'); // #267 btn.click(); return true; // clicked → page navigates into the editor } (async function main() { if (handleAutoCommit()) { Log.cat('Create').info('auto-commit (background create/link) — submitting the seeded form'); return; } if (handleArtistPageCallback()) { Log.cat('Create').info('artist/label-create callback — posting MBID back and closing'); return; } if (handleEditLinkClose()) { Log.cat('Create').info('Discogs-link edit committed — closing tab'); return; } if (await autoConfirmSeed()) return; // handled the seed-confirmation interstitial (clicked, or option off) — no editor here if (ANNO_PAGE_RE.test(location.pathname)) { // #394 standalone Edit annotation page for ANY entity (no releaseEditor) const tryMount = () => { if (document.querySelector('textarea[name="edit-annotation.text"]')) { applyAnnotationPage(); return true; } return false; }; if (!tryMount()) { const t = setInterval(() => { if (tryMount()) clearInterval(t); }, 200); setTimeout(() => clearInterval(t), 8000); } return; } if (!/^\/release\/(add|.+\/edit)/.test(location.pathname)) { mountArchiveMenus(); return; } // /artist/* and /label/* (non-callback) just load the channel listener; entity pages get the archive menu (#736) const ed = await waitFor(() => { const e = getEditor(); try { return e && u(e.rootField.release) && u(u(e.rootField.release).mediums) ? e : null; } catch (x) { return null; } }); if (!ed) { Log.cat('System').err('MB.releaseEditor never became ready'); return; } try { // line 2: the MB release, as a full link (real title now the editor is up) const rel = u(ed.rootField.release); const nm = rel ? (u(rel.name) || '') : ''; const gid = rel ? (u(rel.gid) || '') : ''; const mbid = gid || (location.pathname.match(/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}/i) || [''])[0]; Log.cat('System').info('Release:', (nm ? nm + ' — ' : '') + (mbid ? ORIGIN + '/release/' + mbid : location.href)); } catch (e) {} Log.cat('System').info('editor ready'); // #543: the multi-url paste hook belongs to the editor, not to Apollo's // release-info panel — the (+) link row is MB's and is there whether or not // that panel is showing. One delegated listener, self-guarded by the // #external-links-editor check inside it. installMultiLinkPaste(); setTimeout(() => { try { Log.reopen(); } catch (e) {} }, 1200); // #283 reopen the log if it was left open snapshotOriginals(); const tl = readTracklist(); Log.cat('System').info('tracklist:', tl.length, 'tracks ·', tl.reduce((n, t) => n + t.names.filter(x => !x.artistGid).length, 0), 'unresolved slots'); if (apolloEnabled()) matchReleaseInfo(); // the first page's matching first, whether or not the Tracklist is taken over if (tlWant()) showMirror(); // pre-build the tracklist takeover inside the (possibly hidden) #tracklist panel // pre-hide the native recordings table right after edit is entered (recStyle's `body.tc-rec-on` rule // applies even before MB lazily builds the table), and pre-mount the Apollo table if it already exists — // so switching to Recordings shows the Apollo view with no flash of the native assignment table. #119 if (recWant()) { recStyle(); document.body.classList.add('tc-rec-on'); showRecMirror(); } applyView(); // apply the chosen view to whichever tab is initially visible (tracklist and/or recordings) if (tracklistVisible() || recordingsVisible()) ensureLauncher(); // one toggle, present on both managed tabs applyNav(); // compact navigation — hide native step-tabs + footer, relocate compactly watchSubmit(); // append an Apollo credit to the edit note on submit (keeps existing notes) watchSubmitFlash(); // #412 — pulse the toolbar while MB is saving the edit watchTabs(); // #119 — single watcher drives the tracklist + recordings takeovers + the shared toggle })(); })(); })(); } catch (e) { try { console.error('[String Theory] apollo_editor failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== art_station (@run-at document-start) ======================================= if (__stOff.indexOf("art_station") < 0 && __stRuns("art_station", {"inc":["^https?:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*$","^https?:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*/cover-art.*$","^https?:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*/add-cover-art.*$","^https?:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/event/.*/event-art.*$","^https?:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/event/.*/add-event-art.*$"],"exc":[],"noframes":false})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Art Station*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Cover/event-art editor for MusicBrainz — one gallery to view, group, sort, reorder, retype, comment, remove, download and source (MH Covers) a release's cover art (or an event's event art), staged and applied on Enter edit. PoC (discussion #230).","author":"majkinetor","homepage":null,"homepageURL":null,"supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5BcnQgU3RhdGlvbjwvdGl0bGU+CjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjM4IiBmaWxsPSIjMWIwZjNiIiBzdHJva2U9IiNiOTY3ZmYiIHN0cm9rZS13aWR0aD0iMyIvPgo8Y2lyY2xlIGN4PSI4NiIgY3k9IjY0IiByPSIyOC44OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNGEzNDgwIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjIwLjkwMDAwMDAwMDAwMDAwMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNGEzNDgwIiBzdHJva2Utd2lkdGg9IjEuNSIvPgo8Y2lyY2xlIGN4PSI4NiIgY3k9IjY0IiByPSIxMS4wMiIgZmlsbD0iI2ZmNzFjZSIvPjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjIuNTA4IiBmaWxsPSIjMWIwZjNiIi8+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhcy1za3ljIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwLjAwIiBzdG9wLWNvbG9yPSIjN2ZlN2ZmIi8+PHN0b3Agb2Zmc2V0PSIwLjUwIiBzdG9wLWNvbG9yPSIjZDlhNmZmIi8+PHN0b3Agb2Zmc2V0PSIxLjAwIiBzdG9wLWNvbG9yPSIjZmY3MWNlIi8+PC9saW5lYXJHcmFkaWVudD48Y2xpcFBhdGggaWQ9ImFzLWNjIj48cmVjdCB4PSIxMCIgeT0iMjIiIHdpZHRoPSI3OCIgaGVpZ2h0PSI4NCIgcng9IjgiLz48L2NsaXBQYXRoPjwvZGVmcz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2FzLWNjKSI+PHJlY3QgeD0iMTAiIHk9IjIyIiB3aWR0aD0iNzgiIGhlaWdodD0iODQiIGZpbGw9InVybCgjYXMtc2t5YykiLz4KPGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhcy1nczIiIHgxPSIwIiB5MT0iMCIgeDI9IjAiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmY2YTgiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNmZjhhNWMiLz48L2xpbmVhckdyYWRpZW50Pgo8bWFzayBpZD0iYXMtbXMyIj48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iI2ZmZiIvPjxyZWN0IHg9IjI1LjYiIHk9IjY3LjIiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuMiIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjY5LjgiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuNSIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjczLjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuOCIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijc3LjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjIuMyIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjgyLjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjIuOSIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijg4LjMiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjMuNiIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijk2LjIiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjQuNSIgZmlsbD0iIzAwMCIvPjwvbWFzaz48L2RlZnM+CjxjaXJjbGUgY3g9IjQ5IiBjeT0iNzYuMDgiIHI9IjIzLjQiIGZpbGw9InVybCgjYXMtZ3MyKSIgbWFzaz0idXJsKCNhcy1tczIpIi8+PHJlY3QgeD0iMTAiIHk9Ijc0LjA4IiB3aWR0aD0iNzgiIGhlaWdodD0iMzEuOTIiIGZpbGw9IiNiOTY3ZmYiLz48cGF0aCBkPSJNMTAgNzQuMDhIODhNMTAgNzcuOUg4OE0xMCA4My4ySDg4TTEwIDkxLjRIODhNMTAgMTA0LjJIODhNMzQuOCA3NC4wOEwtNDguNSAxMDZNMzguNCA3NC4wOEwtMjQuMSAxMDZNNDEuOSA3NC4wOEwwLjMgMTA2TTQ1LjUgNzQuMDhMMjQuNiAxMDZNNDkuMCA3NC4wOEw0OS4wIDEwNk01Mi41IDc0LjA4TDczLjQgMTA2TTU2LjEgNzQuMDhMOTcuOCAxMDZNNTkuNiA3NC4wOEwxMjIuMSAxMDZNNjMuMiA3NC4wOEwxNDYuNSAxMDYiIHN0cm9rZT0iI2ZmZmI5NiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvZz48cmVjdCB4PSIxMCIgeT0iMjIiIHdpZHRoPSI3OCIgaGVpZ2h0PSI4NCIgcng9IjgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzJhMTQ1OCIgc3Ryb2tlLXdpZHRoPSIzIi8+Cjwvc3ZnPgo="}) }) : { script: {"name":"Art Station*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Cover/event-art editor for MusicBrainz — one gallery to view, group, sort, reorder, retype, comment, remove, download and source (MH Covers) a release's cover art (or an event's event art), staged and applied on Enter edit. PoC (discussion #230).","author":"majkinetor","homepage":null,"homepageURL":null,"supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5BcnQgU3RhdGlvbjwvdGl0bGU+CjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjM4IiBmaWxsPSIjMWIwZjNiIiBzdHJva2U9IiNiOTY3ZmYiIHN0cm9rZS13aWR0aD0iMyIvPgo8Y2lyY2xlIGN4PSI4NiIgY3k9IjY0IiByPSIyOC44OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNGEzNDgwIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjIwLjkwMDAwMDAwMDAwMDAwMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNGEzNDgwIiBzdHJva2Utd2lkdGg9IjEuNSIvPgo8Y2lyY2xlIGN4PSI4NiIgY3k9IjY0IiByPSIxMS4wMiIgZmlsbD0iI2ZmNzFjZSIvPjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjIuNTA4IiBmaWxsPSIjMWIwZjNiIi8+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhcy1za3ljIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwLjAwIiBzdG9wLWNvbG9yPSIjN2ZlN2ZmIi8+PHN0b3Agb2Zmc2V0PSIwLjUwIiBzdG9wLWNvbG9yPSIjZDlhNmZmIi8+PHN0b3Agb2Zmc2V0PSIxLjAwIiBzdG9wLWNvbG9yPSIjZmY3MWNlIi8+PC9saW5lYXJHcmFkaWVudD48Y2xpcFBhdGggaWQ9ImFzLWNjIj48cmVjdCB4PSIxMCIgeT0iMjIiIHdpZHRoPSI3OCIgaGVpZ2h0PSI4NCIgcng9IjgiLz48L2NsaXBQYXRoPjwvZGVmcz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2FzLWNjKSI+PHJlY3QgeD0iMTAiIHk9IjIyIiB3aWR0aD0iNzgiIGhlaWdodD0iODQiIGZpbGw9InVybCgjYXMtc2t5YykiLz4KPGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhcy1nczIiIHgxPSIwIiB5MT0iMCIgeDI9IjAiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmY2YTgiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNmZjhhNWMiLz48L2xpbmVhckdyYWRpZW50Pgo8bWFzayBpZD0iYXMtbXMyIj48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iI2ZmZiIvPjxyZWN0IHg9IjI1LjYiIHk9IjY3LjIiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuMiIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjY5LjgiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuNSIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjczLjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuOCIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijc3LjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjIuMyIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjgyLjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjIuOSIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijg4LjMiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjMuNiIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijk2LjIiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjQuNSIgZmlsbD0iIzAwMCIvPjwvbWFzaz48L2RlZnM+CjxjaXJjbGUgY3g9IjQ5IiBjeT0iNzYuMDgiIHI9IjIzLjQiIGZpbGw9InVybCgjYXMtZ3MyKSIgbWFzaz0idXJsKCNhcy1tczIpIi8+PHJlY3QgeD0iMTAiIHk9Ijc0LjA4IiB3aWR0aD0iNzgiIGhlaWdodD0iMzEuOTIiIGZpbGw9IiNiOTY3ZmYiLz48cGF0aCBkPSJNMTAgNzQuMDhIODhNMTAgNzcuOUg4OE0xMCA4My4ySDg4TTEwIDkxLjRIODhNMTAgMTA0LjJIODhNMzQuOCA3NC4wOEwtNDguNSAxMDZNMzguNCA3NC4wOEwtMjQuMSAxMDZNNDEuOSA3NC4wOEwwLjMgMTA2TTQ1LjUgNzQuMDhMMjQuNiAxMDZNNDkuMCA3NC4wOEw0OS4wIDEwNk01Mi41IDc0LjA4TDczLjQgMTA2TTU2LjEgNzQuMDhMOTcuOCAxMDZNNTkuNiA3NC4wOEwxMjIuMSAxMDZNNjMuMiA3NC4wOEwxNDYuNSAxMDYiIHN0cm9rZT0iI2ZmZmI5NiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvZz48cmVjdCB4PSIxMCIgeT0iMjIiIHdpZHRoPSI3OCIgaGVpZ2h0PSI4NCIgcng9IjgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzJhMTQ1OCIgc3Ryb2tlLXdpZHRoPSIzIi8+Cjwvc3ZnPgo="} }; (f=>f())(function(){ try { (function(){ // // Phase-1 PoC. Principle: "you get what you see" — the gallery is the staged // state; Enter edit makes MB match it. Reads live cover art (CAA JSON + the // page), no uploads yet (Add/Enter-edit submission land next). (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('art_station', 'Art Station')) return; // the shared MusicBrainz request gate (#633) — first, so nothing can call it before its consts exist // <ST-NET> — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT const MBN_GAP = 1000; const MBN_BURST = 3; const MBN_KEY = "mbu:mb-gate"; const MBN_LOCK = "mbu-mb-gate"; const MBN_MAX_HOLD = 60000; function mbnGated(url) { const s = String(url || ''); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null'); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; // no storage: this page's copy only else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) {} return out; }; // The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is // stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang // every request behind it. let timer = 0; try { if (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === 'function' ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3000); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState(s => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? 'MusicBrainz asked everyone to wait' : 'pacing, one request a second' }; }); let told = false; for (;;) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); // a hold that appeared while we queued if (left <= 0) break; if (!told && until - t0 > 1000 && o.log) { told = true; o.log('info', 'MusicBrainz gate: ' + (o.label || 'request') + ' waits ' + Math.round((until - t0) / 100) / 10 + 's (' + (cool > booked.at ? 'MusicBrainz asked everyone to wait' : booked.why) + ')'); } await new Promise(r => setTimeout(r, Math.min(left, 250))); // in slices, so a cancel lands promptly } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header('Retry-After') : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState(s => { const now = Date.now(); // One throttle, one step: the requests already in flight when it began all answer // 503 too, and each must not stretch the hold again. A throttle long gone doesn't // count at all: `hot` is shared and persists, and would otherwise slow every later // page for good. if (now > (s.cool || 0) + 30000) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1000, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log('warn', 'MusicBrainz gate: HTTP ' + status + (raw != null ? ' (Retry-After: ' + raw + ')' : '') + ' — every script holds ' + Math.round(hold / 100) / 10 + 's'); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState(s => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 30000 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) { const r = await fetch(url, init); if (!mbnWebsite(url) || !(await mbnIsCheck(r))) return r; // #733: MusicBrainz's browser check answered instead: nothing was done (a POST is safe // to repeat), so pass the check and ask again return (await mbnReverify(url, init, o)) ? fetch(url, init) : r; } const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, n => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnHot() { let s; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; } return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over } function mbnRawHeader(raw, name) { const want = String(name).toLowerCase(); for (const line of String(raw || '').split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim(); } return null; } function mbnWebsite(url) { const s = String(url || ''); if (mbnGated(s)) return false; if (/^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org(\/|$)/i.test(s)) return true; return /^\/(?!\/)/.test(s) && typeof location !== 'undefined' && /(^|\.)musicbrainz\.org$/i.test(location.hostname); } async function mbnIsCheck(r) { try { if (!r || !/text\/html/i.test((r.headers && r.headers.get('content-type')) || '')) return false; const head = (await r.clone().text()).slice(0, 3000); return /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(head); } catch (e) { return false; } } function mbnReverify(url, init, o) { o = o || {}; const log = (lvl, m) => { try { if (o.log) o.log(lvl, m); else (lvl === 'warn' ? console.warn : console.info)('[MusicBrainz gate] ' + m); } catch (e) {} }; const g = globalThis.__mbnCheck || (globalThis.__mbnCheck = { busy: null, failedAt: 0 }); if (g.busy) return g.busy; let target; try { target = new URL(String(url), location.href); } catch (e) { return Promise.resolve(false); } if (target.host !== location.host || typeof document === 'undefined') { log('warn', 'MusicBrainz asked to verify the browser — open any MusicBrainz page, then retry'); return Promise.resolve(false); } if (Date.now() - g.failedAt < 60000) return Promise.resolve(false); // just failed: don't loop // a POST's URL can't be loaded: the check is per browser, so any light GET passes it const method = String((init && init.method) || 'GET').toUpperCase(); const src = method === 'GET' ? target.href : location.origin + '/ws/js/entity/89ad4ac3-39f7-470e-963a-56509c546377'; const t0 = Date.now(); log('warn', 'MusicBrainz asked to verify the browser again ("Verifying your browser") — passing it in a hidden frame'); g.busy = new Promise(resolve => { const fr = document.createElement('iframe'); // fresh each time, never re-navigated fr.setAttribute('aria-hidden', 'true'); fr.setAttribute('data-mbn-check', ''); fr.tabIndex = -1; fr.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;top:0;border:0;opacity:0;pointer-events:none'; let loads = 0, done = false, timer = 0; const finish = ok => { if (done) return; done = true; clearTimeout(timer); fr.remove(); g.busy = null; if (ok) log('info', 'browser check passed in ' + (Date.now() - t0) + ' ms — retrying'); else { g.failedAt = Date.now(); const m = 'MusicBrainz wants to verify your browser — open any MusicBrainz page in another tab, then retry'; log('warn', m + ' (the hidden frame did not clear it in 20 s)'); try { if (o.notify) o.notify(m); } catch (e) {} } resolve(ok); }; // the first load is the check; it posts its answer and is redirected to the real URL fr.onload = () => { loads++; let still; try { const d = fr.contentDocument; still = !d || /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(d.documentElement ? d.documentElement.outerHTML.slice(0, 3000) : ''); } catch (e) { still = loads < 2; } if (!still) finish(true); }; timer = setTimeout(() => finish(false), 20000); fr.src = src; (document.body || document.documentElement).appendChild(fr); }); return g.busy; } // </ST-NET> // <ST-MBPAGE> — generated by dev/mbpage/sync-mbpage.mjs from dev/mbpage/mb-page.mjs — DO NOT EDIT const MBP_COVER_TYPES = {"1":"Front","2":"Back","3":"Booklet","4":"Medium","5":"Obi","6":"Spine","7":"Track","8":"Other","9":"Tray","10":"Sticker","11":"Poster","12":"Liner","13":"Watermark","14":"Raw/Unedited","15":"Matrix/Runout","48":"Top","49":"Bottom"}; function mbpCoverTypeId(name) { const want = String(name || '').trim().toLowerCase(); for (const [id, n] of Object.entries(MBP_COVER_TYPES)) if (n.toLowerCase() === want) return id; return null; } function mbpReleaseLinkList(doc) { doc = doc || document; const lists = [...doc.querySelectorAll('ul.external_links')].map(ul => { let h = ul.previousElementSibling; while (h && !/^H\d$/.test(h.tagName)) h = h.previousElementSibling; return { ul, head: h && h.classList.contains('external-links') ? (h.textContent || '').trim() : null }; }).filter(l => l.head !== null); const lang = (doc.documentElement && doc.documentElement.lang) || 'en'; const key = 'mbu.extLinksHeading.' + lang; // shared storage: every script learns it once if (lists.length >= 2) { // the release group's is the last; the release's is the one headed differently from it const rgHead = lists[lists.length - 1].head; const own = lists.find(l => l.head !== rgHead); if (!own) return null; if (!/^en\b/i.test(lang)) { try { localStorage.setItem(key, JSON.stringify(own.head)); } catch (e) { /* storage blocked: nothing learnt */ } } return own.ul; } if (lists.length !== 1) return null; if (/^external links$/i.test(lists[0].head)) return lists[0].ul; if (/^en\b/i.test(lang)) return null; // English needs nothing learnt: any other heading is the release group's let learnt = null; try { learnt = JSON.parse(localStorage.getItem(key)); } catch (e) { /* storage blocked */ } return learnt && learnt === lists[0].head ? lists[0].ul : null; } function mbpReleaseJsonLinks(doc) { doc = doc || document; const mbid = ((doc.location && doc.location.pathname) || '').match(/\/release\/([0-9a-f-]{36})/i); for (const s of doc.querySelectorAll('script[type="application/json"]')) { const t = s.textContent || ''; if (t.indexOf('"relationships"') < 0) continue; let rel = null; try { rel = JSON.parse(t).release; } catch (e) { continue; } if (!rel || rel.entityType !== 'release' || !Array.isArray(rel.relationships)) continue; if (mbid && rel.gid && rel.gid.toLowerCase() !== mbid[1].toLowerCase()) continue; // MB writes some protocol-relative (//x.bandcamp.com), as in its sidebar: resolve them const abs = h => { try { return new URL(h, (doc.location && doc.location.href) || 'https://musicbrainz.org/').href; } catch (e) { return ''; } }; return rel.relationships.map(r => r && r.target).filter(u => u && u.entityType === 'url' && u.href_url).map(u => abs(u.href_url)).filter(u => /^https?:/.test(u)); } return null; } function mbpReleaseLinks(doc) { const data = mbpReleaseJsonLinks(doc); if (data) return [...new Set(data)]; const ul = mbpReleaseLinkList(doc); // MB writes some hrefs protocol-relative (//x.bandcamp.com): a.href resolves them return ul ? [...ul.querySelectorAll('a[href]')].map(a => a.href).filter(u => /^https?:/.test(u)) : []; } // </ST-MBPAGE> // Works on BOTH a release's cover art and an event's event art — same gallery, // same flow, only the entity differs (archive host, the */-art endpoint suffix, // and the type vocabulary). Everything downstream goes through ENT. (#241) // the platforms Art Station can source art from (providerOf) — above the page check, // because the Mission Control adapter on the release page needs it too (#680) const ART_PROVIDERS = [ { re: /(^|\.)discogs\.com$/i, name: 'Discogs', domain: 'discogs.com' }, { re: /(^|\.)bandcamp\.com$/i, name: 'Bandcamp', domain: 'bandcamp.com' }, { re: /(^|\.)music\.apple\.com$|(^|\.)itunes\.apple\.com$/i, name: 'Apple Music', domain: 'music.apple.com' }, { re: /(^|\.)open\.spotify\.com$|(^|\.)spotify\.com$/i, name: 'Spotify', domain: 'spotify.com' }, { re: /(^|\.)amazon\./i, name: 'Amazon', domain: 'amazon.com' }, { re: /(^|\.)deezer\.com$/i, name: 'Deezer', domain: 'deezer.com' }, { re: /(^|\.)tidal\.com$/i, name: 'Tidal', domain: 'tidal.com' }, { re: /(^|\.)qobuz\.com$/i, name: 'Qobuz', domain: 'qobuz.com' }, { re: /(^|\.)vgmdb\.net$/i, name: 'VGMdb', domain: 'vgmdb.net' }, { re: /7digital\./i, name: '7digital', domain: '7digital.com' }, { re: /(^|\.)beatport\.com$/i, name: 'Beatport', domain: 'beatport.com' }, { re: /(^|\.)junodownload\.com$|(^|\.)juno\.co\.uk$/i, name: 'Juno', domain: 'junodownload.com' }, ]; /* ── Mission Control adapter (#680) ───────────────────────────────────────── On the release page itself Art Station has no UI: it only answers Mission Control (document events, JSON-string details — see userscripts/mission_control/DEVELOP.md). A probe reads the Cover Art Archive's listing for the release and the release's own external links (the sidebar's "External links" block, not the release group's), and offers each linked platform Art Station can source from. Ticked by default only when the release has no front cover yet. Apply opens this release's cover-art page seeded with the ticked links: there Art Station imports from them and keeps the best cover (as middle-click on its URL button does), for you to review and enter. */ const MC_OV = location.pathname.match(/^\/release\/([0-9a-f-]{36})\/?$/i); if (MC_OV) { const rel = MC_OV[1].toLowerCase(); const log = mbuLog({ name: 'Art Station', version: () => (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) || '?', key: 'artstation:logwin' }); const send = (type, detail) => document.dispatchEvent(new CustomEvent(type, { detail: JSON.stringify(detail) })); const ver = (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) || '?'; const hello = () => send('mc:provider', { id: 'as', name: 'Art Station', version: ver, release: rel, capabilities: ['probe', 'apply'] }); let last = [], mcPlan = { replace: false }; // mcPlan: what the last probe decided for Enter const releaseLinks = () => mbpReleaseLinks(); // #722 the release's own list, in any language const caa = () => new Promise(res => { try { GM_xmlhttpRequest({ method: 'GET', url: 'https://coverartarchive.org/release/' + rel, timeout: 20000, onload: r => { try { res(r.status === 200 ? JSON.parse(r.responseText) : { images: [] }); } catch (e) { res(null); } }, onerror: () => res(null), ontimeout: () => res(null) }); } catch (e) { res(null); } }); // the best cover against the fronts already there: a larger one replaces the one front there, // with several fronts it's only added. A front still 0×0 couldn't be measured (no cover is // 0×0: the Cover Art Archive hasn't processed it yet, or it's a PDF), so nothing is compared // and nothing replaced (#715) const vsFronts = (best, existing) => { const px = x => (x.w || 0) * (x.h || 0); const top = existing.slice().sort((a, b) => px(b) - px(a))[0]; const unknown = existing.some(x => !x.w); const larger = !!(best && !unknown && (!top || px(best) > px(top))); return { top, larger, unknown, replace: !!(best && top && larger && existing.length === 1) }; }; document.addEventListener('mc:discover', () => { log.info('Mission Control asked — answering as provider as'); hello(); }); document.addEventListener('mc:probe', async e => { let d = {}; try { d = JSON.parse(e.detail) || {}; } catch (x) { return; } if ((d.release && d.release !== rel) || (d.only && !d.only.includes('as'))) return; send('mc:progress', { id: 'as', run: d.run, state: 'busy', note: 'reading the Cover Art Archive' }); const j = await caa(); const imgs = (j && j.images) || []; // the page's own "Cover art (N)" tab as well: the archive's listing can lag behind (#680) const tabN = +((([...document.querySelectorAll('.tabs a, ul.tabs a')].find(a => /\/cover-art$/.test(a.getAttribute('href') || '')) || {}).textContent || '').match(/\((\d+)\)/) || [])[1] || 0; let front = imgs.some(i => i.front || (i.types || []).includes('Front')); const seen = new Set(); // #724: the album links taken in Platform Check's card count too: Execute adds them before // the cover is entered. The release's own link wins a platform both have. const own = releaseLinks(), pend = (Array.isArray(d.links) ? d.links : []).filter(u => /^https?:/.test(u) && !own.includes(u)); last = own.concat(pend).map(u => ({ u, p: providerOf(u), pend: pend.includes(u) })).filter(x => x.p && !seen.has(x.p.name) && seen.add(x.p.name)); if (pend.length) log.info('Mission Control probe ' + d.run + ': ' + pend.length + ' album link(s) taken in Platform Check, not on the release yet: ' + pend.join(' ')); const icon = name => (Object.entries({ 'Apple Music': 'apple', Spotify: 'spotify', Deezer: 'deezer', Tidal: 'tidal', Qobuz: 'qobuz', Bandcamp: 'bandcamp', Discogs: 'discogs', Beatport: 'beatport', '7digital': 'sevendigital', Amazon: 'amazonmusic' }).find(([n]) => n === name) || [])[1] || null; // the best cover, sourced headless, compared with the fronts already there (the frame measures them) let got = null; if (last.length) { send('mc:progress', { id: 'as', run: d.run, state: 'busy', note: 'finding the best cover' }); got = await mcSource(last.map(x => x.u), note => send('mc:progress', { id: 'as', run: d.run, state: 'busy', note })); } const best = got && got.best, existing = (got && got.existing) || []; if (existing.length) front = true; const { top, larger, replace, unknown } = vsFronts(best, existing); mcPlan = { replace }; const why = !front ? null : !best ? 'the release already has a front cover' : unknown ? "the current front isn't processed by the Cover Art Archive yet, so its size is unknown; probe again later" : larger ? (replace ? `larger than the current front (${top.w}×${top.h}), which it replaces` : `larger than the current fronts (up to ${top.w}×${top.h}); added beside them`) : `not larger than the current front (${top.w}×${top.h})`; const state = !front || larger ? 'new' : 'unsure'; // the rows are the sources searched, not covers to enter: only the best cover's row is entered // (role 'best'), the others only took part in the search (role 'searched') const bp = String((best && best.provider) || '').toLowerCase(); const won = bp && (last.find(x => bp === x.p.name.toLowerCase() || bp.includes(x.p.name.toLowerCase())) || {}).p; // #751: each platform's own cover, the largest it gave, as `cover`: MC shows its size in a column of its own, // a link to it; a platform the search found nothing on comes as w 0 ("no cover") const coverOf = p => ((best && best.found) || []).filter(c => { const n = String(c.provider || '').toLowerCase(), m = p.name.toLowerCase(); return n === m || n.includes(m); }) .sort((a, b) => b.w * b.h - a.w * a.h)[0] || null; const findings = last.map(x => { const c = coverOf(x.p) || (x.p === won ? { w: best.w, h: best.h, bytes: best.bytes } : best && best.found ? { w: 0, h: 0 } : null); const f = { key: x.p.name, name: x.p.name, icon: icon(x.p.name), url: x.u, state, pending: x.pend || undefined, cover: c ? { w: c.w, h: c.h, bytes: c.bytes || 0, url: c.url || '' } : undefined }; if (!won) return Object.assign(f, why ? { why } : {}); if (x.p === won) return Object.assign(f, { role: 'best', why: 'the best cover' + (why ? '; ' + why : ': entered as the front cover') }); return Object.assign(f, { role: 'searched', why: larger ? 'only ' + won.name + '\'s larger cover is entered' : won.name + '\'s was the largest found' }); }).sort((a, b) => (b.role === 'best') - (a.role === 'best')); // the one entered leads const n = Math.max(imgs.length, tabN, existing.length); const summary = j == null && !n ? 'Cover Art Archive: could not be read' : n ? 'Cover art: ' + n + ' image' + (n === 1 ? '' : 's') + (front ? ', front cover ✓' : ', no front cover') : 'Cover art: none yet'; log.info('Mission Control probe ' + d.run + ': ' + summary + ' (archive ' + imgs.length + ', tab ' + tabN + ', frame ' + existing.length + ') · ' + findings.length + ' source(s): ' + findings.map(x => x.name).join(', ') + (why ? ' · ' + why : '')); send('mc:findings', { id: 'as', run: d.run, release: rel, summary, findings, best: best ? Object.assign({ larger, replace, unknown, current: top || null }, best) : null }); }); // #680: covers are sourced and entered in a hidden frame of the cover-art page, where Art // Station runs headless (mc_frame) and talks back by postMessage. One frame at a time. let frame = null; // { el, token, urls, best } const frameMsgs = new Set(); window.addEventListener('message', e => { if (e.origin !== location.origin || !e.data || e.data.mcAs !== 1 || !frame || e.data.token !== frame.token) return; frameMsgs.forEach(f => f(e.data)); }); // the ticks are a self-ending setTimeout chain, not setInterval: in Violentmonkey on Firefox the // interval outlived its clearInterval and kept reporting "finding the best cover" after the answer (#680) const waitFrame = (type, ms, onTick) => new Promise(res => { const t0 = Date.now(); let over = false; const f = m => { if (m.type === type) done(m); }; const done = m => { if (over) return; over = true; frameMsgs.delete(f); res(m); }; const tick = () => { if (over) return; if (Date.now() - t0 > ms) { done(null); return; } if (onTick) onTick(Math.round((Date.now() - t0) / 1000)); setTimeout(tick, 3000); }; frameMsgs.add(f); setTimeout(tick, 3000); }); async function mcSource(urls, onTick) { if (frame) { frame.el.remove(); frame = null; } const token = Math.random().toString(36).slice(2); const el = document.createElement('iframe'); el.setAttribute('aria-hidden', 'true'); el.tabIndex = -1; el.style.cssText = 'position:fixed;left:-20000px;top:0;width:1200px;height:900px;border:0;opacity:0;pointer-events:none'; el.src = location.origin + '/release/' + rel + '/cover-art?mc_frame=' + token + '&mc_source=' + encodeURIComponent(JSON.stringify(urls)); frame = { el, token, urls: urls.slice().sort().join(' '), best: null }; log.info('Mission Control: sourcing the best cover in a hidden frame from ' + urls.join(' ')); const got = waitFrame('best', 300000, s => onTick && onTick('finding the best cover (' + s + ' s)')); document.body.appendChild(el); const m = await got; if (!frame || frame.token !== token) return null; frame.best = m ? m.best : null; frame.existing = (m && m.existing) || []; log.info('Mission Control: best cover ' + (frame.best ? frame.best.provider + ' ' + frame.best.w + '×' + frame.best.h + ' of ' + frame.best.of : m ? 'none could be imported' : 'timed out')); return { best: frame.best, existing: frame.existing }; } document.addEventListener('mc:apply', async e => { let d = {}; try { d = JSON.parse(e.detail) || {}; } catch (x) { return; } if (d.id !== 'as' || (d.release && d.release !== rel)) return; const urls = last.filter(x => (d.keys || []).includes(x.p.name)).map(x => x.u); const reply = o => send('mc:applied', Object.assign({ id: 'as', run: d.run, release: rel }, o)); const busy = note => send('mc:progress', { id: 'as', run: d.run, state: 'busy', note }); if (!urls.length) { reply({ ok: true, sent: 0, note: 'nothing to source' }); return; } if (d.dry) { reply({ ok: true, sent: 0, note: 'dry run: would enter the best cover of ' + urls.length + ' source' + (urls.length === 1 ? '' : 's') + (frame && frame.best ? ' (' + frame.best.provider + ' ' + frame.best.w + '×' + frame.best.h + ')' : '') }); return; } // the probe's best is reused when it came from a ticked source and every ticked source was in // that probe: the best of more sources is also the best of the ticked ones. Otherwise source again. const probed = frame ? frame.urls.split(' ') : []; const bp = String((frame && frame.best && frame.best.provider) || '').toLowerCase(); const bestUrl = bp && (last.find(x => bp === x.p.name.toLowerCase() || bp.includes(x.p.name.toLowerCase())) || {}).u; const same = !!frame && probed.slice().sort().join(' ') === urls.slice().sort().join(' '); const reuse = !!(frame && frame.best && urls.every(u => probed.includes(u)) && (same || (bestUrl && urls.includes(bestUrl)))); log.info('Mission Control apply: ' + (reuse ? 'reusing the best cover from the probe (' + frame.best.provider + ')' : 'sourcing again for ' + urls.join(' '))); if (!reuse) { busy('finding the best cover'); const r = await mcSource(urls, busy); if (!r || !r.best) { reply({ ok: false, sent: 0, note: 'no cover could be imported' }); return; } // the same comparison as the probe's, for the new pick const { top, larger, replace, unknown } = vsFronts(r.best, r.existing); mcPlan = { replace }; send('mc:progress', { id: 'as', run: d.run, state: 'busy', note: 'entering the cover', best: Object.assign({ larger, replace, unknown, current: top || null }, r.best) }); } busy('entering the cover'); // #741: the frame repeats a failed upload (auto-repeat), which can take longer than ten minutes: it is // waited for while it says what it does, and its word is the note let word = 'entering the cover', heard = Date.now(); const onWord = m => { if (m.type === 'progress') { word = m.note || word; heard = Date.now(); busy(word); } }; frameMsgs.add(onWord); const got = waitFrame('entered', 6 * 3600000, s => { if (Date.now() - heard > 600000) frameMsgs.forEach(f => f({ type: 'entered', ok: false, error: 'no word from the cover-art frame for 10 minutes' })); else busy(word + ' (' + s + ' s)'); }); frame.el.contentWindow.postMessage({ mcAs: 1, token: frame.token, type: 'enter', replace: mcPlan.replace, note: 'Via Mission Control' + (d.mc ? ' v' + d.mc : '') + ': ' + location.origin + '/release/' + rel }, location.origin); const m = await got; frameMsgs.delete(onWord); log.info('Mission Control apply: ' + JSON.stringify(m)); const b = frame.best; if (!m) reply({ ok: false, sent: 0, note: 'Art Station did not finish within 10 minutes' }); else if (m.ok) reply({ ok: true, sent: 1, note: 'entered ' + b.provider + ' ' + b.w + '×' + b.h + ' as the front cover' + (mcPlan.replace ? ', and removed the smaller one' : '') }); else reply({ ok: false, sent: 0, retry: !m.cancelled && !!m.retry, note: m.cancelled ? 'cancelled' : 'the edit failed' + (m.error ? ': ' + m.error : '') + (m.gaveUp ? ' (repeated for ' + m.gaveUp + ')' : '') }); }); hello(); return; } const M = location.pathname.match(/\/(release|event)\/([0-9a-f-]{36})\/(add-)?(?:cover|event)-art/i); if (!M) return; // mbRestackCorner, the #468 corner slots, is in the ST-UI block (dev/ui/ui-components.mjs). const IS_EVENT = M[1].toLowerCase() === 'event'; const MBID = M[2]; // #248 the native "add cover art" uploader page (also where integrations like // Harmony land, sometimes pre-seeded with images). Art Station fully takes it // over: same gallery, plus it harvests any seeded images as staged new covers. const IS_ADD = !!M[3]; const ENT = IS_EVENT ? { kind: 'event', base: `/event/${MBID}`, art: 'event-art', archive: `https://eventartarchive.org/event/${MBID}`, noun: 'event art', Noun: 'Event art' } : { kind: 'release', base: `/release/${MBID}`, art: 'cover-art', archive: `https://coverartarchive.org/release/${MBID}`, noun: 'cover art', Noun: 'Cover art' }; // Deep-link to the current user's Cover/Event Art edit history — the MB edit // search pre-filtered to the cover-art + event-art edit types, scoped to "me". // Shown as a button in the Enter-edit dialog header. location.origin so it // follows to beta.musicbrainz.org too. const ART_EDITS_URL = location.origin + '/search/edits?auto_edit_filter=&order=desc&negation=0&combinator=and&conditions.0.field=type&conditions.0.operator=%3D&conditions.0.args=314&conditions.0.args=158&conditions.0.args=316&conditions.0.args=1510&conditions.0.args=315&conditions.0.args=159&conditions.0.args=317&conditions.0.args=1511&conditions.1.field=editor&conditions.1.operator=me&conditions.1.name=&conditions.1.args.0='; // append a node to <head>/<html>, deferring if neither exists yet (document-start) function appendEl(el) { const t = document.head || document.documentElement; if (t) { t.appendChild(el); return; } new MutationObserver((_, obs) => { const t2 = document.head || document.documentElement; if (t2) { obs.disconnect(); t2.appendChild(el); } }).observe(document, { childList: true }); } // Hide the native cover-art UI BEFORE it paints (we run at document-start), so the // tab never flashes MB's gallery before ours mounts. Our gallery uses .as-* only. const earlyHide = document.createElement('style'); earlyHide.textContent = '.artwork-cont,#content>h2,#content>p{display:none!important}'; appendEl(earlyHide); // Hide the native button row (Add / Reorder / Import…) before paint too — a JS hide // at render time flashes them on entry AND misses buttons other scripts add late; // a document-start !important style avoids both. Toggled by the setting + Original. const footerStyle = document.createElement('style'); footerStyle.textContent = '#content div.buttons.ui-helper-clearfix{display:none!important}'; appendEl(footerStyle); // #501: settings live in GM storage (backed up and synced by the script manager). // The one-time adoption of an older localStorage copy is retired (#623). const gmLoad = (key) => { try { return GM_getValue(key, undefined); } catch (e) { return undefined; } }; const gmSave = (key, raw) => { try { GM_setValue(key, raw); } catch (e) {} }; // saved prefs read directly here (the SETTINGS object is built later) so the initial // Original/footer state is applied flash-free, before first paint. let _savedPrefs = {}; try { _savedPrefs = JSON.parse(gmLoad('artstation:settings') || '{}'); } catch (e) {} earlyHide.disabled = !!_savedPrefs.showOrig; footerStyle.disabled = !!_savedPrefs.showOrig || _savedPrefs.hideMbFooter === false; // #248 the add page: take the whole thing over. Move the native uploader form // OFF-SCREEN (not display:none) so it stays functional — integrations still // seed it and we harvest the resulting preview rows — while every // bit of its UI (and any plugin UI inside it) is invisible. mount() hides the // rest of #content. Disabled in "Show original". let addHide = null; if (IS_ADD) { addHide = document.createElement('style'); addHide.textContent = 'form#add-cover-art,form#add-event-art{position:fixed!important;left:-99999px!important;top:0!important;width:1000px!important;opacity:0!important;pointer-events:none!important}'; appendEl(addHide); addHide.disabled = !!_savedPrefs.showOrig; } // Proper edit-note attribution, like the other scripts: "Name vX by author - url". // GM_info is exposed even under @grant none on the common managers; fall back to // the hard-coded repo URL so the note never reads "v undefined". const _gm = (typeof GM_info !== 'undefined' && GM_info.script) ? GM_info.script : null; const SCRIPT_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/tree/main/userscripts/art_station'; const ICON_URL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5BcnQgU3RhdGlvbjwvdGl0bGU+CjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjM4IiBmaWxsPSIjMWIwZjNiIiBzdHJva2U9IiNiOTY3ZmYiIHN0cm9rZS13aWR0aD0iMyIvPgo8Y2lyY2xlIGN4PSI4NiIgY3k9IjY0IiByPSIyOC44OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNGEzNDgwIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjIwLjkwMDAwMDAwMDAwMDAwMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNGEzNDgwIiBzdHJva2Utd2lkdGg9IjEuNSIvPgo8Y2lyY2xlIGN4PSI4NiIgY3k9IjY0IiByPSIxMS4wMiIgZmlsbD0iI2ZmNzFjZSIvPjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjIuNTA4IiBmaWxsPSIjMWIwZjNiIi8+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhcy1za3ljIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwLjAwIiBzdG9wLWNvbG9yPSIjN2ZlN2ZmIi8+PHN0b3Agb2Zmc2V0PSIwLjUwIiBzdG9wLWNvbG9yPSIjZDlhNmZmIi8+PHN0b3Agb2Zmc2V0PSIxLjAwIiBzdG9wLWNvbG9yPSIjZmY3MWNlIi8+PC9saW5lYXJHcmFkaWVudD48Y2xpcFBhdGggaWQ9ImFzLWNjIj48cmVjdCB4PSIxMCIgeT0iMjIiIHdpZHRoPSI3OCIgaGVpZ2h0PSI4NCIgcng9IjgiLz48L2NsaXBQYXRoPjwvZGVmcz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2FzLWNjKSI+PHJlY3QgeD0iMTAiIHk9IjIyIiB3aWR0aD0iNzgiIGhlaWdodD0iODQiIGZpbGw9InVybCgjYXMtc2t5YykiLz4KPGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhcy1nczIiIHgxPSIwIiB5MT0iMCIgeDI9IjAiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmY2YTgiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNmZjhhNWMiLz48L2xpbmVhckdyYWRpZW50Pgo8bWFzayBpZD0iYXMtbXMyIj48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iI2ZmZiIvPjxyZWN0IHg9IjI1LjYiIHk9IjY3LjIiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuMiIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjY5LjgiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuNSIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjczLjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuOCIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijc3LjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjIuMyIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjgyLjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjIuOSIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijg4LjMiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjMuNiIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijk2LjIiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjQuNSIgZmlsbD0iIzAwMCIvPjwvbWFzaz48L2RlZnM+CjxjaXJjbGUgY3g9IjQ5IiBjeT0iNzYuMDgiIHI9IjIzLjQiIGZpbGw9InVybCgjYXMtZ3MyKSIgbWFzaz0idXJsKCNhcy1tczIpIi8+PHJlY3QgeD0iMTAiIHk9Ijc0LjA4IiB3aWR0aD0iNzgiIGhlaWdodD0iMzEuOTIiIGZpbGw9IiNiOTY3ZmYiLz48cGF0aCBkPSJNMTAgNzQuMDhIODhNMTAgNzcuOUg4OE0xMCA4My4ySDg4TTEwIDkxLjRIODhNMTAgMTA0LjJIODhNMzQuOCA3NC4wOEwtNDguNSAxMDZNMzguNCA3NC4wOEwtMjQuMSAxMDZNNDEuOSA3NC4wOEwwLjMgMTA2TTQ1LjUgNzQuMDhMMjQuNiAxMDZNNDkuMCA3NC4wOEw0OS4wIDEwNk01Mi41IDc0LjA4TDczLjQgMTA2TTU2LjEgNzQuMDhMOTcuOCAxMDZNNTkuNiA3NC4wOEwxMjIuMSAxMDZNNjMuMiA3NC4wOEwxNDYuNSAxMDYiIHN0cm9rZT0iI2ZmZmI5NiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvZz48cmVjdCB4PSIxMCIgeT0iMjIiIHdpZHRoPSI3OCIgaGVpZ2h0PSI4NCIgcng9IjgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzJhMTQ1OCIgc3Ryb2tlLXdpZHRoPSIzIi8+Cjwvc3ZnPgo='; // as @icon: the icon itself, so a branch build shows its own const ATTRIBUTION = _gm ? `${_gm.name} v${_gm.version} by ${_gm.author} - ${_gm.homepageURL || _gm.homepage || SCRIPT_URL}` : `Art Station by majkinetor - ${SCRIPT_URL}`; const CAA = ENT.archive; // CAA for releases, EAA for events const imgUrl = id => `${CAA}/${id}.jpg`; // original // the public image URL we can hand to a reverse-image engine: CAA for a published // cover, or — for a sourced new cover — its provider direct-image URL (_provImageUrl, // e.g. i.discogs.com) or its source URL when that itself points at an image file (a // picker round-trip / pasted image URL, e.g. an Apple mzstatic .jpg). A purely local // (dropped) file, or one sourced only via a provider *page*, has no image URL to search. const isImgUrl = u => /^https?:\/\/\S+\.(jpe?g|png|gif|webp|bmp|tiff?)(\?|#|$)/i.test(u || ''); const searchUrlFor = it => it._del ? '' : (it._new ? (it._provImageUrl || (isImgUrl(it._provUrl) ? it._provUrl : '')) : imgUrl(it.id)); const thumb = (id, n) => `${CAA}/${id}-${n}.jpg`; // 250 / 500 / 1200 // reverse-image-search engines (find a higher-resolution copy of a cover) — each // opens pre-loaded with the cover's public URL, so there's no download + drop. const IMG_SEARCH_ENGINES = [ { name: 'Yandex', u: url => `https://yandex.com/images/search?rpt=imageview&url=${encodeURIComponent(url)}` }, { name: 'Google Lens', u: url => `https://lens.google.com/uploadbyurl?url=${encodeURIComponent(url)}` }, { name: 'TinEye', u: url => `https://tineye.com/search?url=${encodeURIComponent(url)}&sort=size&order=desc` }, // biggest copy first { name: 'Bing', u: url => `https://www.bing.com/images/searchbyimage?cbir=sbi&imgurl=${encodeURIComponent(url)}` }, ]; // canonical MB art types per entity, in a sensible display order; "(none)" is virtual. // Event art has its own vocabulary (Poster/Flyer/Setlist/…) — wholly distinct from cover art. const COVER_ORDER = ['Front', 'Back', 'Booklet', 'Medium', 'Tray', 'Obi', 'Spine', 'Track', 'Liner', 'Sticker', 'Poster', 'Watermark', 'Matrix/Runout', 'Top', 'Bottom', 'Other']; const COVER_TYPES = ['Front', 'Back', 'Booklet', 'Medium', 'Tray', 'Obi', 'Spine', 'Track', 'Liner', 'Sticker', 'Poster', 'Watermark', 'Raw/Unedited', 'Matrix/Runout', 'Top', 'Bottom', 'Panel', 'Other']; const EVENT_TYPES = ['Poster', 'Flyer', 'Banner', 'Program', 'Setlist', 'Schedule', 'Ticket', 'Map', 'Logo', 'Merchandise', 'Raw/Unedited', 'Watermark']; const TYPE_ORDER = IS_EVENT ? EVENT_TYPES : COVER_ORDER; const ALL_TYPES = IS_EVENT ? EVENT_TYPES : COVER_TYPES; // #721 MB's type ids (its art_type tables), so a form's checkbox maps to the English name // whatever language MB shows its labels in ("Vorderseite" is still 1 = Front). Event art // ids other than Poster aren't known; those still match by an English label only. const TYPE_BY_ID = IS_EVENT ? { 1: 'Poster' } : MBP_COVER_TYPES; const NO_TYPE = '(no type)'; // neutral noun for a single artwork piece in UI labels: "cover" for releases, // "image" for events (an untyped event piece isn't a "cover"). const ITEM = IS_EVENT ? 'image' : 'cover'; const ITEMS = ITEM + 's'; // #243 guess a cover type from the file name — "Folder"/"Cover" are the de-facto names // for the front; otherwise the type word appears in the name (back, booklet, obi, …). // First match wins, so order specific → general. Only types valid for this entity are used. const TYPE_FROM_NAME = [ [/\b(front|folder|frontal|recto)\b|cover art|albumart/, 'Front'], [/\b(back|rear|verso|trasera)\b/, 'Back'], [/\b(booklet|inlay|libretto|insert)\b/, 'Booklet'], [/\btray\b/, 'Tray'], [/\bobi\b/, 'Obi'], [/\bspine\b/, 'Spine'], [/\bsticker\b/, 'Sticker'], [/\b(matrix|runout)\b/, 'Matrix/Runout'], [/\bliner\b/, 'Liner'], [/\bposter\b/, 'Poster'], [/\bcd\d*\b|\bdiscs?\b|\bdisk\b|\bvinyl\b|\bmedium\b|\blabel\b|\bside\s*[a-d0-9]/, 'Medium'], [/\btrack\b/, 'Track'], [/\btop\b/, 'Top'], [/\bbottom\b/, 'Bottom'], [/\b(raw|unedited)\b/, 'Raw/Unedited'], [/\bwatermark\b/, 'Watermark'], [/\bflyer\b/, 'Flyer'], [/\bticket\b/, 'Ticket'], [/\bsetlist\b/, 'Setlist'], [/\bbanner\b/, 'Banner'], [/\bprogram\b/, 'Program'], [/\bschedule\b/, 'Schedule'], [/\bmap\b/, 'Map'], [/\blogo\b/, 'Logo'], [/\bmerch/, 'Merchandise'], [/\bcover\b/, 'Front'], // generic fallback (after Back etc.) so "cover.jpg" → Front but "back cover" → Back ]; // exact download tokens → canonical type ("front"→Front, "raw_unedited"→Raw/Unedited), for round-tripping #244 names const TYPE_BY_TOKEN = {}; [...COVER_TYPES, ...EVENT_TYPES].forEach(t => { TYPE_BY_TOKEN[t.toLowerCase().replace(/[\\/]/g, '_')] = t; }); // #243/#244 parse a file name into { types[], comment }: // "02 front,sticker some comment.jpg" → [Front,Sticker], "some comment" (our download format) // "booklet page 12" → [Booklet], "page 12" · "page 12 booklet" → [Booklet], "" (keyword + after) function parseName(name) { let base = String(name || '').replace(/\.[a-z0-9]+$/i, '').trim(); base = base.replace(/^\s*\d+\s*[-_.)]*\s*/, ''); // strip a leading position number if (!base) return { types: [], comment: '' }; // case A — leading comma-joined exact type tokens (no spaces), then the comment const sp = base.search(/\s/); const head = (sp < 0 ? base : base.slice(0, sp)).trim(), tail = sp < 0 ? '' : base.slice(sp + 1).trim(); const tokens = head.split(',').map(t => t.trim()).filter(Boolean); const headTypes = tokens.map(t => TYPE_BY_TOKEN[t.toLowerCase()]); if (tokens.length && headTypes.every(Boolean)) return { types: headTypes.filter(t => ALL_TYPES.includes(t)), comment: tail }; // case B — a fuzzy type keyword anywhere; the comment is whatever follows it const spaced = base.replace(/[^a-z0-9,]+/gi, ' ').replace(/\s+/g, ' ').trim(), lower = spaced.toLowerCase(); for (const [re, t] of TYPE_FROM_NAME) { if (!ALL_TYPES.includes(t)) continue; const m = lower.match(re); if (m) return { types: [t], comment: spaced.slice(m.index + m[0].length).replace(/^[\s,]+/, '').trim() }; } return { types: [], comment: '' }; } let MODEL = []; // [{ id, types:[], comment, order, w, h, bytes, fmt, _del, _new, _file }] let _booted = false; // #283 emit the name+version / release log lines once const SIZES = new Map(); // CAA image id -> original file size in bytes (from archive.org metadata) const FORMATS = new Map(); // CAA image id -> the original's format, 'PNG' (same metadata, #630) const fmtSize = b => b >= 1048576 ? (b / 1048576).toFixed(1) + 'Mb' : Math.max(1, Math.round(b / 1024)) + 'Kb'; // #630 (chaban-mb, upgrading covers from JPEG to PNG): the file's format, as the native // uploader shows it — from a MIME type ("image/png") or a name or URL ("…-123.png"). const FMT_NAMES = { jpg: 'JPEG', jpeg: 'JPEG', png: 'PNG', gif: 'GIF', webp: 'WEBP', avif: 'AVIF', bmp: 'BMP', tif: 'TIFF', tiff: 'TIFF', pdf: 'PDF' }; function fileFormat(v) { const t = String(v || '').toLowerCase(); const m = t.match(/^(?:image|application)\/(?:x-)?([a-z0-9+.-]+)$/) || t.match(/\.([a-z0-9]+)(?:[?#]|$)/); return m ? (FMT_NAMES[m[1]] || '') : ''; } // the size, followed by the format when the option is on and it is known const sizeText = it => [it.bytes ? fmtSize(it.bytes) : '', SETTINGS.showFileType && it.fmt ? it.fmt : ''].filter(Boolean).join(' '); // footer line under the image: "1.2Mb 600 × 600" ("1.2Mb PNG 600 × 600" with #630's // option) — size first, then resolution, each half shown once known. function dimText(it) { const parts = []; if (sizeText(it)) parts.push(sizeText(it)); if (it.w && it.h) parts.push(`${it.w} × ${it.h}`); return parts.length ? parts.join(' ') : '…'; } // card-foot version: size + resolution as separate spans so they WRAP (stack) on a // narrow card instead of overflowing the tile. function dimHtml(it) { const parts = []; if (sizeText(it)) parts.push(`<span class="as-dim-sz">${sizeText(it)}</span>`); if (it.w && it.h) parts.push(`<span class="as-dim-px">${it.w} × ${it.h}</span>`); return parts.join('') || '<span class="as-dim-sz">…</span>'; } function refreshDim(it) { const el = document.querySelector(`.as-card[data-id="${CSS.escape(String(it.id))}"] .as-dim`); if (el) el.innerHTML = dimHtml(it); } // one request per release: archive.org item metadata carries every original's byte size — and the // #368 `is_dark` flag for a darkened item (read from this same request, no extra fetch) async function loadSizes() { try { const j = await fetch(`https://archive.org/metadata/mbid-${MBID}`, { headers: { Accept: 'application/json' } }).then(r => r.ok ? r.json() : null); if (j && j.is_dark === true && !_caaDarkened) { _caaDarkened = true; detectIaNotice(); } // #368 surface the darkened notice if (!j || !j.files) return; for (const f of j.files) { if (f.source !== 'original' || !f.size) continue; const m = String(f.name).match(/-(\d+)\.[a-z0-9]+$/i); if (m) { SIZES.set(m[1], +f.size); if (fileFormat(f.name)) FORMATS.set(m[1], fileFormat(f.name)); } // #630 the original's format } MODEL.forEach(it => { const b = SIZES.get(String(it.id)), t = FORMATS.get(String(it.id)); if (t) it.fmt = t; if (b) it.bytes = b; if (b || t) refreshDim(it); }); asLog.debug(`archive.org: loaded original file sizes (${SIZES.size})`); } catch (e) { asLog.debug('archive.org: metadata unavailable — ' + ((e && e.message) || e)); } // size is a nicety — never block the gallery } // Declared here, not next to the other #566 constants further down: load() runs // on the very next line and reads it, and a `const` below that point is still // in its temporal dead zone — a ReferenceError that takes the whole script out // before anything renders (node --check does not see it; a browser does). const AR_SETTINGS_V = 1; let SETTINGS = load(); // #560 (majkinetor): "Uploading PDF booklet of 50-100MB fails in AS due to // timeout (currently visible as IA is slow) while it passes in native uploader. // Let's make timeout configurable in minutes, and make default double the // current value." Was a hardcoded 5 minutes; the default is 10 now. function load() { const d = { tile: 200, group: false, sort: 'type', detailed: false, hideMbFooter: true, showOrig: false, autoRepeat: true, autoRepeatMin: 10, autoRepeatTimes: 10, arV: AR_SETTINGS_V, autoType: true, autoComment: true, autoFront: true, autoFrontMode: 'whenNone', clearSelAfterOp: true, followPan: true, uploadTimeoutMin: 10, showFileType: false }; try { const stored = JSON.parse(gmLoad('artstation:settings') || '{}'); return arMigrate(Object.assign(d, stored), stored); } catch (e) { return d; } } /* #566 follow-up (majkinetor): "change defaults to 10/10 and enable the option by default". Raising the defaults alone reaches nobody who already has settings stored — save() persists every key, so a stored 20 shadows the new default (the same trap called out for uploadTimeoutMin just below). Hence a one-shot migration, which only moves values still sitting on the OLD defaults: a deliberate 30 minutes survives untouched. A deliberate opt-OUT cannot be told apart from the old default of false once saved, so it is turned on — which is what was asked for, and the checkbox is right there. AR_SETTINGS_V is declared above `let SETTINGS = load()`, not here. */ // The version must be read off the STORED blob, not the merged one: arV is a // default too, so Object.assign hands every old install a current version // number and the migration skips itself. (It did exactly that, until the test // caught it.) function arMigrate(s, stored) { if (!stored || !Object.keys(stored).length) return s; // fresh install — nothing to move if ((stored.arV || 0) >= AR_SETTINGS_V) return s; if (s.autoRepeat === false) s.autoRepeat = true; if (s.autoRepeatMin === 20) s.autoRepeatMin = 10; if (s.autoRepeatTimes === 20) s.autoRepeatTimes = 10; s.arV = AR_SETTINGS_V; setTimeout(() => { save(); asLog.info('Auto-repeat defaults migrated to on / 10 min / 10 tries (#566)'); }, 0); return s; } function save() { try { gmSave('artstation:settings', JSON.stringify(SETTINGS)); } catch (e) {} } // ⚠ Raising the default alone does nothing for anyone who already has settings // stored — Object.assign above lets a persisted 5 shadow it. There is no stored // value to migrate here (the old timeout was hardcoded, never a setting), so an // existing install simply has no `uploadTimeoutMin` key and picks up the 10. // Clamped rather than trusted: a 0 would abort every upload instantly, and the // value is written straight into xhr.timeout. const UPLOAD_TIMEOUT_MIN_DEFAULT = 10, UPLOAD_TIMEOUT_MIN_MAX = 120; // #566 (majkinetor): "It could happen that covers fail to upload to Internet // Archive (IA), especially in latest weeks. Repeating failed until commit // passes is one option to deal with it." On by default, N = M = 10 (his // follow-up), and it stops at whichever of the two limits is reached first. // // Deliberately bounded on BOTH axes: a count alone would hammer a struggling // server as fast as it can refuse, and a duration alone would keep a tab busy // for hours if each attempt fails instantly. The gap between attempts is the // budget spread over the allowance (10 min / 10 tries = 1 min), clamped so it // can be neither a hot loop nor a wait longer than the whole budget. const AR_MIN_DEFAULT = 10, AR_TIMES_DEFAULT = 10, AR_MIN_MAX = 240, AR_TIMES_MAX = 200; const arNum = (v, def, max) => { const n = Math.round(Number(v)); return (isFinite(n) && n > 0) ? Math.min(n, max) : def; }; const arMinutes = () => arNum(SETTINGS.autoRepeatMin, AR_MIN_DEFAULT, AR_MIN_MAX); const arTimes = () => arNum(SETTINGS.autoRepeatTimes, AR_TIMES_DEFAULT, AR_TIMES_MAX); // The gap grows as attempts pile up — a server that has refused ten times in a // row is not helped by an eleventh at the same cadence. Same shape as // Anakunda's MB Auto-retry on upload to CAA error (x2 from the 10th attempt, // x3 from the 100th), which chaban-mb pointed at on #566; it serves // majkinetor's own "not good idea to spam overworked server" better than the // flat interval I had. Still bounded by the same two limits, so backing off // cannot make a run outlast its window. function arDelayMs(attempt) { const spread = (arMinutes() * 60000) / Math.max(1, arTimes()); const n = Math.max(1, attempt || 1); const step = 1 + Math.floor(Math.log10(n)); // 1 for 1-9, 2 for 10-99, 3 for 100+ return Math.round(Math.min(Math.max(spread * step, 10000), arMinutes() * 60000)); } const fmtDur = ms => { const s2 = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s2 / 60); return m ? `${m}m${String(s2 % 60).padStart(2, '0')}s` : `${s2}s`; }; function uploadTimeoutMs() { const n = Number(SETTINGS.uploadTimeoutMin); const min = (isFinite(n) && n > 0) ? Math.min(n, UPLOAD_TIMEOUT_MIN_MAX) : UPLOAD_TIMEOUT_MIN_DEFAULT; return Math.round(min * 60000); } // ── data ─────────────────────────────────────────────────────────────────── // MB's page (its DB) is the source of truth for the cover list — it includes images // that aren't on the Cover Art Archive yet (just added). CAA only enriches comments. function parsePageArt() { if (IS_ADD) return null; // #248 the add page has no native gallery to parse — use CAA only const blocks = [...document.querySelectorAll('.artwork-cont')]; if (!blocks.length) return null; return blocks.map((b, i) => { const ed = b.querySelector(`a[href*="/edit-${ART}/"]`); const m = ed && ed.getAttribute('href').match(new RegExp(`/edit-${ART}/(\\d+)`)); if (!m) return null; // each piece is its own <p> — parse types from the "Types:" <p> ONLY (the comment // is a separate <p>, so reading the whole block grabbed it, e.g. "Types: -test"). // #721 MB translates "Types:" ("Typen:", "Types :", "类型:") and the sizes line, so // go by place instead: the types <p> comes first, the sizes one is p.small. const ps = [...b.querySelectorAll('p')]; const typeP = ps.find(p => /^\s*Types:/.test(p.textContent)) || ps.find(p => !p.classList.contains('small') && /^[^::]+[::]/.test(p.textContent.trim()) && p === ps[0]); const raw = typeP ? typeP.textContent.trim().replace(/^[^::]*[::]\s*/, '').trim() : ''; const types = (raw && raw !== '-') ? raw.split(/\s*[,,、]\s*/).map(s => s.trim()).filter(s => s && s !== '-') : []; const cmtP = ps.find(p => p !== typeP && !p.classList.contains('small') && p.textContent.trim() && !/All sizes:/i.test(p.textContent) && !/Dimensions:/i.test(p.textContent)); const comment = cmtP ? cmtP.textContent.trim() : ''; // the sizes line's unsized link; #721 by its URL (/<id>.jpg, not -250.jpg), as MB translates "original" const orig = [...b.querySelectorAll('a')].find(a => a.textContent.trim().toLowerCase() === 'original') || [...b.querySelectorAll('p.small a')].find(a => new RegExp(`/${m[1]}\\.\\w+$`).test(a.getAttribute('href') || '')); const img = orig ? new URL(orig.getAttribute('href'), location.href).href : ''; const pdf = /\.pdf(\?|$)/i.test(img); return { id: m[1], types, comment, pending: b.classList.contains('mp'), pdf, img, order: i }; }).filter(Boolean); } // #721 the page names types in MB's language ("Vorderseite"). The English names come from // what MB's own forms taught us in this language (typeMapOf learns them), else from the Cover // Art Archive (always English, but it lags behind a new cover). A name neither knows stays // as the page shows it; the edit form still maps it by its label (typeMapOf / buildEdit). function englishTypes(types, caaImage) { const known = loadTypeNames(); const en = types.map(t => known[t] || t); if (en.every(t => ALL_TYPES.includes(t)) || !caaImage || !Array.isArray(caaImage.types)) return en; return caaImage.types.slice(); } async function loadArt() { if (!_booted) { // first two log lines: the script + version, then the MB entity _booted = true; asLog.info(mbuStartupInfo('Art Station')); try { const ri = releaseInfo(); const t = (ri.title || '').trim(); asLog.info('Release: ' + (t ? t + ' — ' : '') + (ri.url || (location.origin + '/' + ENT.kind + '/' + MBID))); } catch (e) { asLog.info('Release: ' + location.origin + '/release/' + MBID); } setTimeout(() => { try { LOG.reopen(); } catch (e) {} }, 600); // #283 reopen the log if it was left open } const pageArt = parsePageArt(); let caa = []; try { const j = await fetch(CAA, { headers: { Accept: 'application/json' } }).then(r => r.ok ? r.json() : null); if (j) caa = j.images || []; } catch (e) { asLog.debug('CAA: cover-art metadata fetch failed — ' + ((e && e.message) || e)); } // not propagated / none yet const byId = new Map(caa.map(im => [String(im.id), im])); const source = (pageArt && pageArt.length) ? pageArt.map(p => ({ id: p.id, types: englishTypes(p.types, byId.get(String(p.id))), comment: p.comment || (byId.get(String(p.id)) || {}).comment || '', pending: p.pending, img: p.img || (byId.get(String(p.id)) || {}).image || imgUrl(p.id), pdf: p.pdf })) : caa.map(im => ({ id: im.id, types: (im.types || []).slice(), comment: im.comment || '', pending: false, img: im.image || imgUrl(im.id), pdf: /\.pdf(\?|$)/i.test(im.image || '') })); // a partial page parse (e.g. a block without an edit link) must not DROP a cover the // CAA knows about — append a CAA image missing from the parsed list. BUT only if the // page actually references that id somewhere (a block we failed to parse): a CAA image // absent from the page ENTIRELY is a stale CAA entry for a just-removed cover (MB's DB // drops it immediately while coverartarchive.org lags), and resurrecting it shows a // phantom that 404s when removed again. MB's page is authoritative for what exists. #264 if (pageArt && pageArt.length && caa.length) { const have = new Set(source.map(s => String(s.id))); const pageRefs = (document.getElementById('content') || document.body).innerHTML; for (const im of caa) if (!have.has(String(im.id)) && pageRefs.includes(String(im.id))) source.push({ id: im.id, types: (im.types || []).slice(), comment: im.comment || '', pending: false, img: im.image || imgUrl(im.id), pdf: /\.pdf(\?|$)/i.test(im.image || '') }); } MODEL = source.map((s, i) => ({ id: s.id, types: s.types.slice(), comment: s.comment, order: i, w: 0, h: 0, fmt: fileFormat((byId.get(String(s.id)) || {}).image) || fileFormat(s.img), _del: false, _new: false, _pending: !!s.pending, _pdf: !!s.pdf || /\.pdf(\?|$)/i.test(s.img || ''), _img: s.img, _origTypes: s.types.slice(), _origComment: s.comment, _origOrder: i, })); render(); watchIaNotice(); // #367/#368 surface IA "difficulties" warnings + darkened-item notices asLog.info(`Loaded ${MODEL.length} ${MODEL.length === 1 ? ITEM : ITEMS}`); MODEL.forEach(measure); // lazy-fill dimensions loadSizes(); // lazy-fill file sizes (single archive.org request) } function measure(it) { if (it.w || (it._new && it._pdf)) return; // measured already, or a PDF (no pixel dims) const src = it._new ? it._file : imgUrl(it.id); // new covers measure from the local object URL if (!src) return; const img = new Image(); img.onload = () => { it.w = img.naturalWidth; it.h = img.naturalHeight; refreshDim(it); // dimension sort can't place a cover until its size is known — re-sort once it is if (SETTINGS.sort === 'dim') scheduleResort(); }; img.onerror = () => { it._noDim = true; }; // not processed by the Cover Art Archive yet: stays 0×0, its size unknown (#715) img.src = src; } // debounce: several new covers measure near-simultaneously; re-render the grid once let _resortT = null; function scheduleResort() { if (_resortT) return; _resortT = setTimeout(() => { _resortT = null; render(); }, 120); } const selectable = () => MODEL.filter(it => !it._del); // reorder (drag) only in the canonical Position view — ungrouped + sorted by position. // Grouping is view-only; other sorts don't map to the committed order. const canReorder = () => !SETTINGS.group && !SETTINGS.detailed && SETTINGS.sort === 'type'; // ── render ─────────────────────────────────────────────────────────────────── const root = document.createElement('div'); root.id = 'as-root'; let _mounted = false; let _showOrig = SETTINGS.showOrig; // "Show original" — reveal MB's native UI; remembered across loads const _native = []; // the native cover-art elements mount() hid, so we can show them again function mount() { if (_mounted) return; _mounted = true; const anchor = document.querySelector('#content') || document.body; // #230: sit BELOW the MB header + the entity tabs. ul.tabs is nested in a // div.tabs child of #content, so climb to that #content-level ancestor. const childOf = (el) => { if (!el) return null; let n = el; while (n.parentElement && n.parentElement !== anchor) n = n.parentElement; return n.parentElement === anchor ? n : null; }; const afterTabs = childOf(anchor.querySelector('ul.tabs')); const afterH1 = childOf(anchor.querySelector('h1')); if (afterTabs) afterTabs.insertAdjacentElement('afterend', root); else if (afterH1) afterH1.insertAdjacentElement('afterend', root); else anchor.insertBefore(root, anchor.firstChild); // hide the native cover-art UI between the tabs and the page footer: the type // <h2>s, the .artwork-cont blocks and the trailing "These images…" note. const hide = el => { el.style.display = 'none'; _native.push(el); }; if (IS_ADD) { // #248 full takeover: hide every #content child except the header, the tabs, // the title and our gallery. The uploader form is left alone — it's already // off-screen (addHide) and must stay live so seeds populate it for harvest. [...anchor.children].forEach(ch => { if (ch === root || ch === afterTabs || ch === afterH1) return; if (/^(SCRIPT|NOSCRIPT|STYLE|LINK)$/.test(ch.tagName)) return; if (ch.id === 'add-cover-art' || ch.id === 'add-event-art') return; // the off-screen uploader (harvest source) if (ch.classList && ch.classList.contains('releaseheader')) return; hide(ch); }); } else { [...anchor.children].forEach(ch => { if (ch === root || ch === afterTabs || ch === afterH1) return; if (ch.tagName === 'H2' || ch.tagName === 'P') hide(ch); else if (ch.querySelector && ch.querySelector('.artwork-cont')) hide(ch); else if (ch.classList && ch.classList.contains('artwork-cont')) hide(ch); }); document.querySelectorAll('.artwork-cont').forEach(hide); } } // "Show original" (View): un-hide MB's native cover-art UI and collapse our // gallery to just the toolbar — like Apollo's native/script switcher. #234 function applyOriginal() { earlyHide.disabled = _showOrig; // the document-start hiding style if (addHide) addHide.disabled = _showOrig; // #248 reveal the native uploader in Original _native.forEach(el => { el.style.display = _showOrig ? '' : 'none'; }); root.classList.toggle('as-orig', _showOrig); // hides the whole Art Station UI ensureSwitch(); applyHideFooter(); } // #367/#368 surface the Internet Archive notices MB shows on the cover-art page that our takeover would // otherwise hide: the "IA is having difficulties — adding images unlikely to work" upload warning, and // darkened-item notices ("Cannot show cover art" / "hidden … takedown request"). A darkened item can't be // added to / removed / reordered, so while such a notice is up we disable editing. let _iaDark = '', _iaDown = '', _caaDarkened = false; const iaVisible = node => { for (let n = node; n && n.nodeType === 1 && n !== document.body; n = n.parentElement) { const s = getComputedStyle(n); if (s.display === 'none' || s.visibility === 'hidden') return false; } return true; }; // MB renders the darkened notice as a bare <h2> (+ <p>) directly under #content, in the current UI // language — grab it for display, but detection itself keys on the language-independent CAA 403. function nativeDarkMsg() { const c = document.getElementById('content'); if (!c) return ''; const h2 = [...c.children].find(n => n.tagName === 'H2' && !n.closest('#as-root')); if (!h2) return ''; let m = (h2.textContent || '').replace(/\s+/g, ' ').trim(); const p = h2.nextElementSibling; if (p && p.tagName === 'P') m += ' — ' + (p.textContent || '').replace(/\s+/g, ' ').trim(); return m; } function detectIaNotice() { const scope = document.getElementById('content') || document.body; // #367/#487: MB's `.caa-warning` is ALWAYS in the DOM (on add-cover-art/add-event-art only — // it's not rendered at all on the plain view page), wrapped in display:none until MB's own // async archive.org rate-limit check (fired from its inline <script>) reveals it — so trust // its LIVE MB-visibility, re-read on every call rather than snapshotted once at mount (#487: // a one-time snapshot at mount ran before that async check could ever resolve, so it read // "not shown yet" and never looked again). const w = scope.querySelector('.warning.caa-warning, .caa-warning'); let down = (w && iaVisible(w)) ? (w.textContent || '').replace(/\s+/g, ' ').trim() : ''; // #487 follow-up (majkinetor, live: MB's own warning genuinely showed, but // Art Station's never did): traced it — MB reveals `.caa-warning` via // jQuery within ~1-2s of the async check resolving (fast, confirmed live), // but a React re-render of that same region moments later replaces the // node wholesale, resetting it back to its template-default hidden state // — jQuery's one-shot toggle never gets reapplied. So a purely "live // snapshot" read (the #487 fix) flickers true then false within the same // second, and Art Station's own render() calls just aren't guaranteed to // land inside that narrow window. Latch it instead: once genuinely seen // revealed, keep showing it even if a later read finds the node reset — // the real-world condition (archive.org overloaded) doesn't actually // resolve that fast, so a stale-but-true warning is a far safer failure // mode than silently dropping a real one. if (!down && _iaDown) down = _iaDown; // #368: detected via the CAA 403 (darkened archive.org item) — language-independent, unlike the on-page // notice. Show MB's own localized wording when present, else a default. const dark = _caaDarkened ? (nativeDarkMsg() || 'This item is darkened at the Internet Archive — its cover art can’t be shown, added, removed or reordered.') : ''; if (dark === _iaDark && down === _iaDown) return; _iaDark = dark; _iaDown = down; if (_mounted) render(); } const iaNoticeHtml = () => (!_iaDark && !_iaDown) ? '' : `<div class="as-ia ${_iaDark ? 'as-ia-dark' : 'as-ia-warn'}">${_iaDark ? '⛔' : '⚠'} ${esc(_iaDark || _iaDown)}</div>`; // the darkened/warning notice can appear after load (the artwork area fills from CAA asynchronously) let _iaObs = null; function watchIaNotice() { detectIaNotice(); if (_iaObs) return; const scope = document.getElementById('content') || document.body; let t = null; _iaObs = new MutationObserver(() => { clearTimeout(t); t = setTimeout(detectIaNotice, 200); }); // #487: attributes too — MB reveals .caa-warning via jQuery .parent().toggle(), which flips an // inline style attribute, not childList/characterData (which is all this used to watch for). _iaObs.observe(scope, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['style', 'class'] }); // #487 follow-up (majkinetor, live: "still doesn't work frequently. If I // switch to native then go back it shows always" — the manual toggle // forces a fresh check later, once the race below has settled, which is // exactly what this backstop automates): a MutationObserver only fires // once per coalesced batch of DOM changes — if MB's jQuery reveal and its // OWN later React re-render (which resets `.caa-warning` back to the // template's display:none, see detectIaNotice) land in the SAME batch, // the observer can deliver only the batch's final (hidden-again) state // and never dispatch a callback for the fleeting true moment in between — // so even the #487-follow-up latch has nothing to latch onto. A plain // interval poll doesn't have that blind spot: it just samples current // state on a fixed cadence, so it can't miss a moment squeezed inside one // mutation batch. 2 minutes comfortably covers even a slow archive.org // response (measured live: up to 20s+); a 1s poll on one small subtree // for that long costs nothing. const iv = setInterval(detectIaNotice, 1000); setTimeout(() => { if (_iaObs) { _iaObs.disconnect(); _iaObs = null; } clearInterval(iv); }, 120000); // stop once the page settles } // optional (setup): hide MB's native button row (Add / Reorder / Import from …) // under the gallery — redundant with Art Station's own toolbar. Revealed in Original. function applyHideFooter() { footerStyle.disabled = !(SETTINGS.hideMbFooter && !_showOrig); } // #234: a fixed switcher (bottom-right) toggling Original ⇄ Art Station — always visible. // #695: the script's round icon like every other corner launcher, in colour while Art // Station shows and monochrome while off; a right-click opens the setup. function ensureSwitch() { let sw = document.getElementById('as-switch'); if (!sw) { sw = document.createElement('button'); sw.type = 'button'; sw.id = 'as-switch'; sw.dataset.mbCorner = 'br'; sw.dataset.mbCornerOrder = '10'; sw.innerHTML = `<img src="${ICON_URL}" alt="">`; sw.onclick = () => { _showOrig = !_showOrig; SETTINGS.showOrig = _showOrig; save(); render(); }; sw.oncontextmenu = e => { e.preventDefault(); if (document.getElementById('as-setup')) document.getElementById('as-setup').remove(); else openSetup(); }; document.body.appendChild(sw); mbRestackCorner('br'); } mbuCls(sw, 'on', !_showOrig); mbuAttr(sw, 'title', (_showOrig ? 'Art Station is off · click: back to the Art Station gallery' : 'Art Station is on · click: show the original MusicBrainz cover-art page') + '\nRight-click: settings'); } // setup panel (Apollo-style): script info + help + toggles function openSetup() { document.getElementById('as-setup')?.remove(); const ver = (_gm && _gm.version) || ''; const panel = document.createElement('div'); panel.id = 'as-setup'; panel.innerHTML = mbuCfgHeader({ script: 'art_station', name: 'Art Station', version: ver, icon: `<img src="${ICON_URL}" alt="">`, log: true, logClass: 'as-setup-logbtn' }) + `<div class="as-setup-body">` + `<label class="as-setup-opt"><input type="checkbox" class="as-setup-hidefoot"${SETTINGS.hideMbFooter ? ' checked' : ''}> Hide MB native buttons (Add / Reorder / Import…)</label>` + `<label class="as-setup-opt"><input type="checkbox" class="as-setup-autotype"${SETTINGS.autoType ? ' checked' : ''}> Set ${ITEM} type from the file name (Front, Back, Booklet…)</label>` + `<label class="as-setup-opt"><input type="checkbox" class="as-setup-autocomment"${SETTINGS.autoComment ? ' checked' : ''}> Set comment from the file name (text after the type)</label>` + `<div class="as-setup-opt"><label class="as-setup-optlbl"><input type="checkbox" class="as-setup-autofront"${SETTINGS.autoFront ? ' checked' : ''}> Set type to “Front” on first import</label>` + ` <select class="as-setup-autofront-mode"><option value="whenNone"${SETTINGS.autoFrontMode !== 'always' ? ' selected' : ''}>when none exists</option><option value="always"${SETTINGS.autoFrontMode === 'always' ? ' selected' : ''}>always</option></select></div>` + `<label class="as-setup-opt"><input type="checkbox" class="as-setup-clearsel"${SETTINGS.clearSelAfterOp ? ' checked' : ''}> Clear the selection after a batch action (type, comment, download, report)</label>` + `<label class="as-setup-opt"><input type="checkbox" class="as-setup-filetype"${SETTINGS.showFileType ? ' checked' : ''}> Show each ${ITEM}'s file type next to its size (PNG, JPEG…)</label>` + `<label class="as-setup-opt"><input type="checkbox" class="as-setup-followpan"${SETTINGS.followPan ? ' checked' : ''}> Full-screen: pan a zoomed image by moving the mouse (no dragging)</label>` // #560 — a big PDF booklet against a slow Internet Archive needs more than // the old fixed 5 minutes; the native uploader sets no timeout at all. + `<div class="as-setup-opt as-setup-num" title="How long a single file may take to upload to the Internet Archive before Art Station gives up. Raise it for large PDF booklets, or when the Archive is slow. Max ${UPLOAD_TIMEOUT_MIN_MAX}.">` + `<label class="as-setup-optlbl">Upload timeout <input type="number" class="as-setup-uptimeout" min="1" max="${UPLOAD_TIMEOUT_MIN_MAX}" step="1" value="${esc(String(Math.round(uploadTimeoutMs() / 60000)))}"> minutes</label></div>` + `<div class="as-setup-opt as-setup-num" title="When a commit finishes with failures, re-run just the failed operations by itself until they succeed or the allowance runs out. Off by default. Stops at whichever limit comes first; progress is shown in the commit window's footer.">` + `<label class="as-setup-optlbl"><input type="checkbox" class="as-setup-autorepeat"${SETTINGS.autoRepeat ? ' checked' : ''}> Automatically repeat failures up to` + ` <input type="number" class="as-setup-ar-min" min="1" max="${AR_MIN_MAX}" step="1" value="${esc(String(arMinutes()))}"> minutes or` + ` <input type="number" class="as-setup-ar-times" min="1" max="${AR_TIMES_MAX}" step="1" value="${esc(String(arTimes()))}"> times</label></div>` + `</div>`; document.body.appendChild(panel); panel.querySelector('.as-setup-hidefoot').onchange = e => { SETTINGS.hideMbFooter = e.target.checked; save(); applyHideFooter(); }; panel.querySelector('.as-setup-filetype').onchange = e => { SETTINGS.showFileType = e.target.checked; save(); render(); asLog.info(`File type next to the size: ${SETTINGS.showFileType ? 'on' : 'off'}`); }; // #630 panel.querySelector('.as-setup-autotype').onchange = e => { SETTINGS.autoType = e.target.checked; save(); }; panel.querySelector('.as-setup-autocomment').onchange = e => { SETTINGS.autoComment = e.target.checked; save(); }; panel.querySelector('.as-setup-autofront').onchange = e => { SETTINGS.autoFront = e.target.checked; save(); }; panel.querySelector('.as-setup-autofront-mode').onchange = e => { SETTINGS.autoFrontMode = e.target.value; save(); }; panel.querySelector('.as-setup-clearsel').onchange = e => { SETTINGS.clearSelAfterOp = e.target.checked; save(); }; panel.querySelector('.as-setup-followpan').onchange = e => { SETTINGS.followPan = e.target.checked; save(); const img = document.querySelector('.as-lb-img'); if (img) applyZoom(img); }; // #560 — clamp on the way in as well as on the way out: an empty or silly box // must not persist a value that would abort every upload instantly. panel.querySelector('.as-setup-uptimeout').onchange = e => { const n = Math.round(Number(e.target.value)); SETTINGS.uploadTimeoutMin = (isFinite(n) && n > 0) ? Math.min(n, UPLOAD_TIMEOUT_MIN_MAX) : UPLOAD_TIMEOUT_MIN_DEFAULT; save(); e.target.value = String(SETTINGS.uploadTimeoutMin); asLog.info(`Upload timeout set to ${SETTINGS.uploadTimeoutMin} min`); }; // #566 — same clamping discipline as the timeout above: an empty or zero box // must not persist a value that turns the feature into a hot loop. panel.querySelector('.as-setup-autorepeat').onchange = e => { SETTINGS.autoRepeat = e.target.checked; save(); asLog.info(`Auto-repeat failures ${SETTINGS.autoRepeat ? `ON — up to ${arMinutes()} min or ${arTimes()} times` : 'OFF'}`); }; panel.querySelector('.as-setup-ar-min').onchange = e => { SETTINGS.autoRepeatMin = arNum(e.target.value, AR_MIN_DEFAULT, AR_MIN_MAX); save(); e.target.value = String(arMinutes()); asLog.info(`Auto-repeat window set to ${arMinutes()} min (first retry after ${Math.round(arDelayMs(1) / 1000)}s, backing off as attempts pile up)`); }; panel.querySelector('.as-setup-ar-times').onchange = e => { SETTINGS.autoRepeatTimes = arNum(e.target.value, AR_TIMES_DEFAULT, AR_TIMES_MAX); save(); e.target.value = String(arTimes()); asLog.info(`Auto-repeat limit set to ${arTimes()} attempts (first retry after ${Math.round(arDelayMs(1) / 1000)}s, backing off as attempts pile up)`); }; const off = e => { if (!panel.contains(e.target) && !e.target.closest('#as-switch')) { panel.remove(); document.removeEventListener('mousedown', off); } }; panel.querySelector('.as-setup-logbtn').onclick = () => { panel.remove(); document.removeEventListener('mousedown', off); openLog(); }; setTimeout(() => document.addEventListener('mousedown', off), 0); } const openLog = () => LOG.open(); // #576 (majkinetor, "Selection icon lost when sizing cards"): there used to be // an applyZoomClass() here that put an `as-zoomed` class on the root at // tile >= 280, whose only job was to hide the per-card ✓ badge — the theory // being that at big sizes the selection outline alone is obvious enough and // the artwork stays uncluttered. In practice one step on the size slider makes // the tick vanish from every selected card while the toolbar still reads // "5 selected", which looks like a bug rather than like restraint. The badge // now shows at every tile size, and the class, its rule and its three call // sites are gone rather than left as dead scaffolding. function render() { mount(); const y = window.scrollY; // keep the viewport put — rebuilding innerHTML must not jump the page const n = opsCount(); const groups = grouped(); // #238 Detailed view: a flat list, image left + all type checkboxes & the full // comment beside it (read long comments / see every type without the popover). const body = SETTINGS.detailed ? `<div class="as-dlist">${MODEL.filter(it => !it._del).slice().sort(sortFn).map(detailRow).join('')}</div>` : SETTINGS.group ? groups.map(g => groupRow(g.type, g.items)).join('') // compact: label column + cards beside it : groups.map(g => section(g.type, g.items)).join(''); root.innerHTML = iaNoticeHtml() + bar(n) + commentPresets() + dropZone() + newSection() + body + deletedSection(); root.classList.toggle('as-darkened', !!_iaDark); // #368 disable editing while a darkened notice is up wire(); hydrateImgs(); // re-attach cached <img> for new/pending covers so they don't reload applyOriginal(); // keep the native/script view state across re-renders fitTypePills(); // show as many types as the pill width allows fitFooters(); // hide a comment that can't fit even a few chars (no ugly sliver) fitToolbar(); // icon-only buttons if the toolbar would otherwise wrap if (window.scrollY !== y) window.scrollTo(0, y); } // #234: a grid card's type pill shows as many of its types as fit on one line; // a trailing "+" appears only when some types are hidden for lack of space. function fitTypePills() { root.querySelectorAll('.as-foot-type .as-type').forEach(pill => { if (pill.classList.contains('as-type-add')) return; // untyped placeholder const it = byId(cardId(pill)); if (!it || !it.types.length) return; const types = it.types; pill.textContent = types.join(', '); let n = types.length; while (n > 1 && pill.scrollWidth > pill.clientWidth) { n--; pill.textContent = types.slice(0, n).join(', ') + ' +'; } }); } // the comment shares the footer row with the dimensions; on a narrow card the // dimensions win and the comment can be squeezed to a 1-2px sliver of a glyph. // Hide it entirely below a readable width rather than show that sliver. function fitFooters() { root.querySelectorAll('.as-card .as-foot-cmt').forEach(cmt => { cmt.classList.remove('as-cmt-collapsed'); const txt = cmt.querySelector('.as-cmt-text'); if (!txt) return; // empty comment (hover pencil) // hide ONLY a comment clipped to an unreadable sliver — a SHORT comment that fully // fits (e.g. "A") keeps a narrow column but must still show (and stay clickable). if (txt.scrollWidth > txt.clientWidth + 1 && cmt.clientWidth < 24) cmt.classList.add('as-cmt-collapsed'); }); } // shared autocomplete of the comments already used on this release (#238 presets) function commentPresets() { const seen = [...new Set(MODEL.map(it => (it.comment || '').trim()).filter(Boolean))]; return `<datalist id="as-cmt-presets">${seen.map(c => `<option value="${esc(c)}"></option>`).join('')}</datalist>`; } function grouped() { if (!SETTINGS.group) { // Position view (committed order): new uploads sit INLINE, positioned among covers const items = MODEL.filter(it => !it._del).slice().sort(sortFn); return [{ type: null, items }]; } // group mode is view-only; new uploads get their own section on top (see newSection) let items = MODEL.filter(it => !it._del && !it._new); // group by primary type; untyped → NO_TYPE; order groups by TYPE_ORDER then alpha const map = new Map(); for (const it of items) { const t = (it.types[0] || NO_TYPE); if (!map.has(t)) map.set(t, []); map.get(t).push(it); } const keys = [...map.keys()].sort((a, b) => { const ia = TYPE_ORDER.indexOf(a), ib = TYPE_ORDER.indexOf(b); if (a === NO_TYPE) return 1; if (b === NO_TYPE) return -1; return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b); }); for (const k of keys) map.get(k).sort(sortFn); return keys.map(k => ({ type: k, items: map.get(k) })); } const typeRank = t => { const i = TYPE_ORDER.indexOf(t); return i < 0 ? 99 : i; }; function sortFn(a, b) { // the still-sourcing placeholder (no image/dims yet) always leads so its progress // shows; everything else — INCLUDING real new covers — takes part in the chosen sort. if (!!a._sourcing !== !!b._sourcing) return a._sourcing ? -1 : 1; if (SETTINGS.sort === 'newest') { // staged new covers ARE the newest → they lead (insertion order); existing by CAA id desc if (!!a._new !== !!b._new) return a._new ? -1 : 1; if (a._new) return a.order - b.order; return b.id - a.id; } if (SETTINGS.sort === 'bytype') return typeRank(a.types[0] || '') - typeRank(b.types[0] || '') || a.order - b.order; if (SETTINGS.sort === 'dim') return (b.w * b.h) - (a.w * a.h) || a.order - b.order; return a.order - b.order; // position (committed order) — new covers have low order, so still lead } const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); function bar(n) { return `<div class="as-bar"> <button class="as-btn as-add" title="Add ${ENT.noun} — file drop zone (goes first)"><span class="as-bi">+</span><span class="mbu-bt">Add image</span></button> ${IS_EVENT ? '' : `<button class="as-btn as-mh" title="MH Covers — source a cover from covers.musichoarders.xyz (#235)"><img class="as-mh-ic" src="https://covers.musichoarders.xyz/favicon.svg" alt="MH" width="18" height="18"></button>`} <button class="as-btn as-src" title="Source ${ENT.noun} from a linked platform, a registered provider, or any URL"><span class="as-bi">🔗</span><span class="mbu-bt">URL</span><span class="as-src-n"></span></button> <span class="as-ctl"><span class="mbu-bt">Size</span> <input class="as-size" type="range" min="120" max="340" value="${SETTINGS.tile}" title="Thumbnail size — scroll the wheel over the slider, or hold right-click and scroll the wheel anywhere in the gallery"></span> <button class="as-btn as-view" title="Sort & grouping">View ▾</button> ${!canReorder() ? '<span class="as-dragwarn" title="Drag-to-reorder is off — it works only with Sort = Position and Grid view. Click to set view.">⚠</span>' : ''} <span class="as-selbox">${selBox()}</span> <button class="as-btn as-commit" title="Review & apply staged changes as MusicBrainz edits — right-click to skip the "Run" click and start immediately"${n?'':' disabled'}>${commitInner(n)}</button> </div>`; } const commitInner = n => `<span class="as-bi">✓</span><span class="mbu-bt">Enter edit</span>${n ? ` <span class="as-cnt2">(${n})</span>` : ''}`; // #234: the selection cluster lives in the center of the main toolbar (the old // bottom bulk bar is gone). syncSel() rebuilds just this span in place so // right-click paint-select never reflows the grid. function selBox() { const sel = MODEL.filter(it => it._sel && !it._del); return `${sel.length ? `<b class="as-selcnt">${sel.length} selected</b>` : '<span class="as-selcnt none">none selected</span>'} <button class="as-ic as-selall" title="Select all ${ITEMS}">✳</button> <button class="as-ic as-selclr" title="Clear selection"${sel.length ? '' : ' disabled'}>✕</button> ${sel.length ? `<button class="as-btn as-bk-type" title="Set type on the selection">Type ▾</button> <button class="as-btn as-bk-cmt" title="Set a comment on the selection"><span class="as-bi">✎</span><span class="mbu-bt">Comment ▾</span></button> <button class="as-btn as-bk-dl" title="Download the selected ${ITEMS}"><span class="as-bi">⬇</span><span class="mbu-bt">Download</span></button> <button class="as-btn as-bk-report" title="Postable Markdown / HTML report of the selection"><span class="as-bi">📋</span><span class="mbu-bt">Report</span></button> <button class="as-btn as-bk-rm" title="Mark the selected ${ITEMS} for removal"><span class="as-bi">🗑️</span><span class="mbu-bt">Delete</span></button>` : ''}`; } function section(type, items) { const label = type === null ? ('All ' + ITEMS) : type; return `<div class="as-sec"><h3>${esc(label)}</h3><span class="as-cnt">${items.length}</span><span class="as-line"></span></div> <div class="as-grid" data-group="${esc(type||'')}">${items.map(card).join('')}</div>`; } // compact group: type label in a left column, cards flow beside it (no full-width waste) function groupRow(type, items) { const label = type === NO_TYPE ? 'No type' : type; return `<div class="as-grow"><div class="as-glabel"><span class="as-gl-name">${esc(label)}</span><span class="as-gl-cnt">${items.length}</span></div> <div class="as-grid" data-group="${esc(type||'')}">${items.map(card).join('')}</div></div>`; } function dropZone() { if (!_dropZone) return ''; return `<div class="as-dropzone" tabindex="0" title="Drop image / PDF files, a folder or a .zip, or click to browse. Folders and zips upload one level of subfolders deep, up to ${DIR_MAX_FILES} files. Shift-click to browse a folder."> <div class="as-dz-in">⬇ Drop ${ENT.noun} files, a folder or a .zip here<span>or click to browse · Shift-click for a folder · new ${ITEMS} go first</span></div></div>`; } function newSection() { if (!SETTINGS.group) return ''; // Position view shows new uploads inline, positioned among covers // group view excludes _new covers from the type groups, so the New uploads section // is the ONLY place they show — include in-progress sourcing placeholders too, or // a URL/provider/MH import would add nothing visible while it works. const news = MODEL.filter(it => it._new && !it._del).sort((a, b) => a.order - b.order); if (!news.length) return ''; return `<div class="as-sec as-sec-new"><h3>New uploads</h3><span class="as-cnt">${news.length}</span><span class="as-line"></span></div> <div class="as-grid">${news.map(card).join('')}</div>`; } function deletedSection() { const dels = MODEL.filter(it => it._del); if (!dels.length) return ''; const body = SETTINGS.detailed // match the current view — detail rows, not grid cards ? `<div class="as-dlist">${dels.map(detailRow).join('')}</div>` : `<div class="as-grid">${dels.map(card).join('')}</div>`; return `<div class="as-sec as-sec-del"><h3>Marked for removal</h3><span class="as-cnt">${dels.length}</span><span class="as-line"></span></div>${body}`; } // Stable CAA thumbnails are HTTP-cached, so recreating their <img> on each // render is cheap and flicker-free. NEW (blob) and PENDING (no CAA thumb yet → // the thumb 404s and we fall back to the full original) covers, though, visibly // RELOAD on every render — type change, resize, anything. So we keep ONE live // <img> per such cover in _imgCache and re-attach the already-decoded node into // a host slot after each render instead of building a fresh one. (covers "reload // in place" — #235) const _imgCache = new Map(); function thumbImg(it, size) { if (it._new || it._pending) return `<span class="as-imghost" data-host="${esc(it.id)}" data-size="${size}"></span>`; return `<img loading="lazy" draggable="false" src="${esc(thumb(it.id, size))}" alt="">`; } function hydrateImgs() { root.querySelectorAll('.as-imghost[data-host]').forEach(host => { const id = host.dataset.host, size = +host.dataset.size, it = byId(id); if (!it) return; let img = _imgCache.get(String(id)); if (!img) { // first sighting — build + load it once img = new Image(); img.loading = 'lazy'; img.draggable = false; img.alt = ''; img.onerror = () => { // pending thumb not ready → show the original if (it._new) { img.closest('.as-thumb, .as-dthumb')?.classList.add('na', 'as-na-new'); return; } // #250 a staged blob that won't decode — no CAA fallback exists const orig = !it._pdf ? (it._img || imgUrl(it.id)) : null; if (orig && img.getAttribute('src') !== orig) img.src = orig; else img.closest('.as-thumb, .as-dthumb')?.classList.add('na'); }; img.src = it._new ? it._file : thumb(it.id, size); _imgCache.set(String(id), img); } host.replaceWith(img); // re-attach the cached (decoded) node — no reload }); } // #249 a small favicon chip in the cover's bottom-left corner naming where a // newly-sourced image came from (a store / MH Covers), shown until commit. function provBadge(it) { if (!(it._new && it._provIcon)) return ''; const tip = `Sourced from ${it._provider || 'provider'}` + (it._provUrl ? `\n${it._provUrl}` : ''); // #249 URL on a second line return `<span class="as-prov" title="${esc(tip)}"><img src="${esc(it._provIcon)}" alt=""></span>`; } // #248 (vzell) tooltip for a locally-uploaded cover — its original file name. const uploadTip = it => (it._new && it._uploadName) ? ` title="${esc(it._uploadName)}"` : ''; // #660 (majkinetor): "When importing all images from streaming provider the point is to keep the // best one. Currently, one must select other images and delete them … Lets have an option on new // images only to keep just one." On a NEW cover, while there are others: mark every other new // cover for removal (undoable with ↺ keep). Covers already on the release, and sources still // being read, are left alone. const otherNews = it => MODEL.filter(x => x !== it && x._new && !x._del && !x._sourcing); function onlyBtn(it) { if (!it._new || it._del || it._sourcing || !otherNews(it).length) return ''; return `<button class="as-only" title="Keep only this one: the other ${otherNews(it).length} new ${otherNews(it).length === 1 ? ITEM : ITEMS} are marked for removal (↺ keep undoes it)">✓ only this</button>`; } function keepOnly(it) { if (!it || !it._new) return; const drop = otherNews(it); drop.forEach(x => { x._del = true; x._sel = false; }); asLog.info(`Keep only ${it._provider || it._uploadName || 'this'} ${ITEM} (${it.w}×${it.h}): marked the other ${drop.length} new ${drop.length === 1 ? ITEM : ITEMS} for removal${drop.length ? ` (${drop.map(x => `${x._provider || x._uploadName || x.id} ${x.w}×${x.h}`).join(', ')})` : ''}`); toast(`Kept only this one — ${drop.length} other new ${drop.length === 1 ? ITEM : ITEMS} marked for removal`); render(); } function card(it) { if (it._sourcing) return `<div class="as-card new as-sourcing" data-id="${esc(it.id)}" title="${esc(it._srcLabel || 'Sourcing…')}">` + `<div class="as-srcing-thumb"><div class="as-spinner"></div><div class="as-srcing-lbl">${esc(it._srcLabel || 'Sourcing…')}</div></div></div>`; return `<div class="as-card${it._del?' del':''}${it._new?' new':''}${it._sel?' sel':''}${it._pending?' pending':''}" data-id="${esc(it.id)}" ${(!it._del && canReorder())?'draggable="true"':''}> <div class="as-thumb"${uploadTip(it)}>${thumbImg(it, SETTINGS.tile > 260 ? 500 : 250)} ${it._new ? '<span class="as-newban">NEW</span>' : ''} ${it._pdf ? '<span class="as-pdfban" title="PDF — opens in a new tab">PDF</span>' : ''} ${provBadge(it)} ${it._del ? '<button class="as-tbtn as-undo" title="keep this image">↺ keep</button>' : ''} ${onlyBtn(it)} </div> ${foot(it)} <span class="as-selmark">✓</span> </div>`; } // #234: footer below the image (mockup-driven). Row 1 = the comment on the // left + "dimensions · size" on the right — sharing one row means an empty // comment costs no extra height. Row 2 = the type as a centered pill on a // divider line at the card's bottom (first type only, "+" when there are // more). Empty comment → a hover-only ✎; untyped → a faint + pill. function foot(it) { const firstType = it.types[0] || ''; // seed with the full list; fitTypePills() trims to what fits and adds "+" if needed const typePill = firstType ? `<span class="as-type" title="${esc(it.types.join(', '))}">${esc(it.types.join(', '))}</span>` : `<span class="as-type as-type-add" title="set type">+ type</span>`; /* #592: no flanking rules. The type pill straddles the card's own bottom border, which already IS the line these drew — so they doubled it, 1px above it, in a different colour, and stopped at x=9 where the corner starts to curve. That mismatch at both bottom corners is the "bottom border" looking broken. The pill has its own background, so it masks the border behind it exactly as before. */ const typeRow = `<div class="as-foot-type">${typePill}</div>`; const dim = `<span class="as-dim">${dimHtml(it)}</span>`; if (it._del) return `<div class="as-foot"><div class="as-foot-row"><span class="as-foot-cmt"></span>${dim}</div>${typeRow}</div>`; const cmt = it._editcmt ? `<input class="as-cmt" value="${esc(it.comment)}" placeholder="comment…" list="as-cmt-presets">` : (it.comment ? `<span class="as-cmt-text" title="edit comment">${esc(it.comment)}</span>` : `<button class="as-pencil" title="add a comment">✎</button>`); // reverse-image search this cover — flat magnifier, hover-revealed, in the comment // row. Shown for any cover with a public URL: a published cover, or a new cover // sourced from a URL (a purely local dropped file has no URL to search by). const search = searchUrlFor(it) ? `<button class="as-fsearch" title="Search the web for a higher-resolution copy of this image"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="20" y1="20" x2="16.65" y2="16.65"/></svg></button>` : ''; return `<div class="as-foot"> <div class="as-foot-row"><span class="as-foot-cmt">${cmt}</span>${search}${dim}</div> ${typeRow} </div>`; } // #238 Detailed view row: image + id on the left, all type checkboxes and the // full comment field beside it. No per-row toolbar actions (selection / delete // live on the main toolbar). function detailRow(it) { if (it._sourcing) return `<div class="as-drow new as-sourcing" data-id="${esc(it.id)}" title="${esc(it._srcLabel || 'Sourcing…')}"> <div class="as-dthumb as-srcing-thumb"><div class="as-spinner"></div></div> <div class="as-dmeta"><div class="as-srcing-lbl">${esc(it._srcLabel || 'Sourcing…')}</div></div></div>`; if (it._del) return `<div class="as-drow del" data-id="${esc(it.id)}"> <span class="as-dsel-x">✕</span> <div class="as-dleft"> <div class="as-dthumb">${it._new ? '<span class="as-newban">NEW</span>' : ''}${thumbImg(it, 250)}${it._pdf ? '<span class="as-pdfban">PDF</span>' : ''}</div> <div class="as-dcap"><span class="as-dim">${esc(dimText(it))}</span></div> ${it._new ? '' : `<div class="as-did">#${esc(it.id)}</div>`} </div> <div class="as-dmeta as-dmeta-del"> <div class="as-ddel-lbl">${esc(it.types.join(', ') || 'no type')}${it.comment ? ` — “${esc(it.comment)}”` : ''}</div> <button class="as-tbtn as-undo" title="keep this image">↺ keep</button> </div> </div>`; const types = ALL_TYPES.map(t => `<label><input type="checkbox" value="${esc(t)}"${it.types.includes(t) ? ' checked' : ''}> ${esc(t)}</label>`).join(''); return `<div class="as-drow${it._new ? ' new' : ''}${it._pending ? ' pending' : ''}${it._sel ? ' sel' : ''}" data-id="${esc(it.id)}"> <input type="checkbox" class="as-dsel" title="select"${it._sel ? ' checked' : ''}> <div class="as-dleft"> <div class="as-dthumb"${uploadTip(it)}>${it._new ? '<span class="as-newban">NEW</span>' : ''}${thumbImg(it, 250)}${provBadge(it)}${it._pdf ? '<span class="as-pdfban" title="PDF — opens in a new tab">PDF</span>' : ''}</div> <div class="as-dcap"><span class="as-dim">${esc(dimText(it))}</span></div> ${it._new ? '' : `<div class="as-did">#${esc(it.id)}</div>`} </div> <div class="as-dmeta"> <div class="as-dlbl">Types</div> <div class="as-dtypes">${types}</div> <input class="as-dcmt mmth-pin" data-mmth-key="art-station-comment" data-mmth-label="Comment" value="${esc(it.comment)}" placeholder="comment…" list="as-cmt-presets" spellcheck="false"> </div> </div>`; } // ── interaction ─────────────────────────────────────────────────────────────── function byId(id) { return MODEL.find(it => String(it.id) === String(id)); } function cardId(el) { const c = el.closest('.as-card, .as-drow'); return c ? c.dataset.id : null; } // Wire the comment controls. #234 split the footer (pencil on row 1, comment // on row 2 which only exists when there's a comment), so entering/leaving edit // re-renders — render() keeps the viewport put, so the page still doesn't jump. // focus a comment input with the caret at the END — re-rendering then plain .focus() // drops the cursor at position 0, which is jarring when editing an existing comment. const focusCmtEnd = inp => { if (!inp) return; inp.focus(); const n = inp.value.length; try { inp.setSelectionRange(n, n); } catch (e) {} }; function wireComments() { root.querySelectorAll('.as-fsearch').forEach(b => b.onclick = e => { e.stopPropagation(); openImageSearchPop(b, byId(cardId(b))); }); root.querySelectorAll('.as-pencil, .as-cmt-text').forEach(el => el.onclick = e => { e.stopPropagation(); const it = byId(cardId(el)); if (!it) return; it._editcmt = true; render(); focusCmtEnd(root.querySelector(`.as-card[data-id="${CSS.escape(String(it.id))}"] .as-cmt`)); }); root.querySelectorAll('.as-cmt').forEach(inp => { inp.oninput = () => { const it = byId(cardId(inp)); if (it) { it.comment = inp.value; refreshStaged(); } }; inp.onblur = () => { const it = byId(cardId(inp)); if (it) { it._editcmt = false; render(); } }; // Enter saves and jumps to the NEXT card's comment (Escape just bails out). inp.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); inp.blur(); return; } if (e.key !== 'Enter') return; e.preventDefault(); const it = byId(cardId(inp)); if (!it) return; it.comment = inp.value; it._editcmt = false; inp.onblur = null; // we drive the transition — don't let the stale blur double-render const cards = [...root.querySelectorAll('.as-card:not(.del)')]; const idx = cards.findIndex(c => c.dataset.id === String(it.id)); const nextIt = (idx >= 0 && cards[idx + 1]) ? byId(cards[idx + 1].dataset.id) : null; if (nextIt) nextIt._editcmt = true; refreshStaged(); render(); if (nextIt) focusCmtEnd(root.querySelector(`.as-card[data-id="${CSS.escape(String(nextIt.id))}"] .as-cmt`)); }; }); } // #238 wire the detailed-view rows: thumbnail → lightbox, inline type checkboxes, // and the comment field — all editing the model in place (no re-render, no jump). function wireDetail() { root.querySelectorAll('.as-drow').forEach(row => { const it = byId(row.dataset.id); if (!it) return; const th = row.querySelector('.as-dthumb'); if (th) { th.onclick = e => { if (e.target.closest('button')) return; if (it._pdf) window.open(it._img, '_blank', 'noopener'); else openLightbox(it.id); }; const img = th.querySelector(':scope > img'); // #250 gallery img only — never the .as-prov favicon (see wire()) if (img) { img.onerror = () => { const orig = !it._pdf ? (it._img || imgUrl(it.id)) : null; if (orig && img.getAttribute('src') !== orig) img.src = orig; else th.classList.add('na'); }; if (img.complete && !img.naturalWidth && img.getAttribute('src')) img.onerror(); } } row.querySelectorAll('.as-dtypes input').forEach(cb => cb.onchange = () => { it.types = ALL_TYPES.filter(t => row.querySelector(`.as-dtypes input[value="${CSS.escape(t)}"]`).checked); refreshStaged(); }); const cmt = row.querySelector('.as-dcmt'); if (cmt) { cmt.oninput = () => { it.comment = cmt.value; refreshStaged(); }; // Enter jumps to the NEXT row's comment (Escape bails) — matches the grid. // Detailed view edits in place, so just move focus (no re-render). cmt.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); cmt.blur(); return; } if (e.key !== 'Enter') return; e.preventDefault(); const rows = [...root.querySelectorAll('.as-drow:not(.del)')]; const idx = rows.findIndex(r => r.dataset.id === String(it.id)); const nc = (idx >= 0 && rows[idx + 1]) ? rows[idx + 1].querySelector('.as-dcmt') : null; if (nc) { nc.focus(); nc.select(); } else cmt.blur(); }; } // selection: the checkbox is the certain indicator; right-click also paints const sel = row.querySelector('.as-dsel'); if (sel) sel.onchange = () => { it._sel = sel.checked; row.classList.toggle('sel', it._sel); syncSel(); }; row.onmousedown = e => { if (e.button !== 2) return; e.preventDefault(); _paint = { value: !it._sel, cards: [] }; paintCard(row); }; }); } // bump the thumbnail size by one notch (dir +1 bigger / -1 smaller), update the live // CSS var + slider, and persist/re-fit once changes settle. Shared by the size slider's // wheel and the right-click+wheel gallery shortcut (#259). let _szT = null; function resizeTile(dir) { SETTINGS.tile = Math.max(120, Math.min(340, SETTINGS.tile + (dir > 0 ? 25 : -25))); // #259 bigger step → less scrolling const sizeEl = root.querySelector('.as-size'); if (sizeEl) sizeEl.value = SETTINGS.tile; document.documentElement.style.setProperty('--as-tile', SETTINGS.tile + 'px'); fitTypePills(); fitFooters(); clearTimeout(_szT); _szT = setTimeout(() => { save(); render(); }, 250); // persist + re-fit once scrolling settles } function wire() { const sizeEl = root.querySelector('.as-size'); sizeEl.oninput = e => { SETTINGS.tile = +e.target.value; document.documentElement.style.setProperty('--as-tile', SETTINGS.tile + 'px'); fitTypePills(); }; sizeEl.onchange = () => { save(); render(); }; // scroll the wheel over the slider to resize (no need to drag it) sizeEl.onwheel = e => { e.preventDefault(); e.stopPropagation(); resizeTile(e.deltaY < 0 ? 1 : -1); }; // stopProp: don't also trigger the RMB+wheel root handler (#259) const view = root.querySelector('.as-view'); if (view) view.onclick = e => { e.stopPropagation(); openViewPop(view); }; const dw = root.querySelector('.as-dragwarn'); if (dw) dw.onclick = () => { SETTINGS.detailed = false; SETTINGS.group = false; SETTINGS.sort = 'type'; save(); render(); }; root.querySelector('.as-add').onclick = toggleDropZone; const mh = root.querySelector('.as-mh'); if (mh) mh.onclick = openMHCovers; const src = root.querySelector('.as-src'); if (src) { src.onclick = e => { e.stopPropagation(); openSourcePop(src); }; // #558: right-click imports from every source at once, skipping the popover // — the common case ("I almost always use import all"). preventDefault so // the browser's own context menu doesn't cover the sourcing slots it starts. src.oncontextmenu = e => { e.preventDefault(); e.stopPropagation(); sourceAllFromButton(src); }; // #667: middle click imports from all, then keeps only the best cover (see sourceBestFromButton) src.onmousedown = e => { if (e.button === 1) e.preventDefault(); }; // no autoscroll cursor src.onauxclick = e => { if (e.button !== 1) return; e.preventDefault(); e.stopPropagation(); sourceBestFromButton(src); }; refreshSrcCount(); // show how many import sources are available on the button: "URL (3)" } const mhIc = root.querySelector('.as-mh-ic'); if (mhIc) mhIc.onerror = () => mhIc.replaceWith(document.createTextNode('🔍')); root.querySelectorAll('.as-prov img').forEach(img => img.onerror = () => { const s = img.closest('.as-prov'); if (s) s.style.display = 'none'; }); // #249 hide a missing provider favicon // #503 (majkinetor, live: a plain left click "immediately entered edit" — // same as right-click, un-distinguishable in his own log): `onclick` // always calls its handler with the click Event as the first argument. // Assigning `enterEdit` directly meant every left click invoked // `enterEdit(clickEvent)` — and a MouseEvent object is truthy, so // `if (immediate)` read true on EVERY click, not just the real // right-click path below. A destructive "Remove Front" batch nearly // auto-committed on a routine click. Wrapping strips the event so a // left click genuinely calls enterEdit() with no argument (falsy). const commit = root.querySelector('.as-commit'); if (commit && !commit.disabled) { commit.onclick = () => enterEdit(); commit.oncontextmenu = e => { e.preventDefault(); enterEdit(true); }; } // #493: right-click — skip the review dialog root.querySelectorAll('.as-undo').forEach(b => b.onclick = e => { e.stopPropagation(); const it = byId(cardId(e.target)); if (it) { it._del = false; render(); } }); root.querySelectorAll('.as-only').forEach(b => b.onclick = e => { e.stopPropagation(); keepOnly(byId(cardId(b))); }); wireComments(); wireDetail(); const dz = root.querySelector('.as-dropzone'); if (dz) { dz.onclick = e => (e.shiftKey ? pickFolder : pickFiles)(); // #359: Shift-click browses a folder dz.ondragover = e => { e.preventDefault(); dz.classList.add('over'); }; dz.ondragleave = () => dz.classList.remove('over'); dz.ondrop = async e => { e.preventDefault(); dz.classList.remove('over'); await addFromDrop(e.dataTransfer); }; } // type pill → popover root.querySelectorAll('.as-type').forEach(ch => ch.onclick = e => { e.stopPropagation(); openTypePop(ch); }); // click the THUMB (not just the <img>, which is display:none on a not-yet-propagated // cover) → lightbox; PDFs open in a new tab. right-click card → toggle selection root.querySelectorAll('.as-thumb').forEach(th => { th.onclick = e => { if (e.target.closest('button') || _lpSwallow) return; const it = byId(cardId(e.target)); if (!it) return; if (it._pdf) window.open(it._img, '_blank', 'noopener'); else openLightbox(it.id); }; // #250 (vzell) only wire the GALLERY image, which is a direct child of the thumb. // A new cover's gallery <img> isn't here yet (it's an .as-imghost placeholder until // hydrateImgs runs), and the only <img> present is the provider-badge favicon nested // in .as-prov — a plain querySelector('img') grabbed THAT, so a 404 favicon fired // this onerror and added .na, whose CSS hid the real JPEG. `:scope > img` never // matches the badge; new covers are left to hydrateImgs, which owns their onerror. const img = th.querySelector(':scope > img'); if (!img) return; // A freshly-added cover has its original uploaded but the CAA thumbnails // (250/500) aren't generated yet — so the thumb URL 404s and native MB // shows a placeholder. We can do better: fall back to the full original // (the same URL the lightbox uses), so the image shows in the gallery. // Only show the "not on CAA" placeholder if the original 404s too. PDFs // can't render as <img>, so they keep the placeholder. img.onerror = () => { const it = byId(cardId(img)); const orig = it && !it._pdf ? (it._img || imgUrl(it.id)) : null; if (orig && img.getAttribute('src') !== orig) img.src = orig; else th.classList.add('na'); }; if (img.complete && !img.naturalWidth && img.getAttribute('src')) img.onerror(); }); // right-button paint-select IN PLACE — no render(), so the page never jumps. // down toggles the start card; holding right + moving paints the same state on hovered cards. root.querySelectorAll('.as-card').forEach(c => { c.onmousedown = e => { if (e.button !== 2 || c.classList.contains('del')) return; e.preventDefault(); const it = byId(c.dataset.id); if (!it) return; _paint = { value: !it._sel, cards: [] }; paintCard(c); }; wireCardTouch(c); // #251 long-press to select, drag-handle to reorder (mobile) }); wireSel(); wireDrag(); markCursor(); } function wireSel() { const q = s => root.querySelector(s); q('.as-selall') && (q('.as-selall').onclick = () => { selectable().forEach(it => it._sel = true); root.querySelectorAll('.as-card:not(.del), .as-drow').forEach(c => c.classList.add('sel')); root.querySelectorAll('.as-dsel').forEach(cb => cb.checked = true); syncSel(); }); q('.as-selclr') && (q('.as-selclr').onclick = () => clearSel()); q('.as-bk-rm') && (q('.as-bk-rm').onclick = () => { let n = 0; MODEL.forEach(it => { if (it._sel) { it._del = true; it._sel = false; n++; } }); asLog.info(`Batch: marked ${n} ${n === 1 ? ITEM : ITEMS} for removal`); render(); }); q('.as-bk-dl') && (q('.as-bk-dl').onclick = async e => { const sel = MODEL.filter(it => it._sel && !it._del && !it._sourcing); if (!sel.length) return; // include NEW covers (download their local blob) if (sel.length === 1) { dlOne(sel[0]); maybeClearSel(); return; } // single → save the image directly const b = e.currentTarget, lbl = b.querySelector('.mbu-bt'), old = lbl ? lbl.textContent : ''; b.disabled = true; if (lbl) lbl.style.display = 'inline'; // show progress even in compact mode const prog = (d, t) => { if (lbl) lbl.textContent = `Zipping ${d}/${t}…`; }; prog(0, sel.length); try { await dlZip(sel, prog); } finally { b.disabled = false; if (lbl) { lbl.textContent = old; lbl.style.display = ''; } } // multiple → one .zip (#240) maybeClearSel(); // #277 }); q('.as-bk-type') && (q('.as-bk-type').onclick = e => { e.stopPropagation(); openBulkTypePop(q('.as-bk-type')); }); q('.as-bk-cmt') && (q('.as-bk-cmt').onclick = e => { e.stopPropagation(); openBulkCommentPop(q('.as-bk-cmt')); }); q('.as-bk-report') && (q('.as-bk-report').onclick = e => { e.stopPropagation(); openReport(); }); } // right-button paint selection (held + move) let _paint = null; function paintCard(c) { if (!c || !_paint || c.classList.contains('del')) return; const it = byId(c.dataset.id); if (!it || it._sel === _paint.value) return; it._sel = _paint.value; c.classList.toggle('sel', it._sel); const cb = c.querySelector('.as-dsel'); if (cb) cb.checked = it._sel; // keep the row checkbox in sync _paint.cards.push(c); syncSel(); } // #259 the right-click+wheel resize shares the RMB-down with paint-select; if the user // wheels, the gesture was a resize, so undo any cards toggled on the way in. function cancelPaint() { if (!_paint) return; const prev = !_paint.value; for (const c of _paint.cards) { const it = byId(c.dataset.id); if (!it) continue; it._sel = prev; c.classList.toggle('sel', it._sel); const cb = c.querySelector('.as-dsel'); if (cb) cb.checked = it._sel; } _paint = null; syncSel(); } document.addEventListener('mousemove', e => { if (!_paint || !e.buttons) return; // e.buttons falls to 0 if the button was released off-window const c = e.target.closest && e.target.closest('.as-card, .as-drow'); if (c && root.contains(c)) paintCard(c); }); document.addEventListener('mouseup', e => { _paint = null; if (e.button === 2) _rmb = false; }); // #259 hold the right mouse button and scroll the wheel anywhere in the gallery to set // thumbnail size. RMB is also paint-select, so a wheel cancels the in-flight select. let _rmb = false; root.addEventListener('mousedown', e => { if (e.button === 2) _rmb = true; }); window.addEventListener('blur', () => { _rmb = false; }); root.addEventListener('wheel', e => { if (!_rmb) return; e.preventDefault(); // don't scroll the page while resizing cancelPaint(); // the RMB-down was the start of a resize, not a select resizeTile(e.deltaY < 0 ? 1 : -1); }, { passive: false }); // ── #251 touch support: long-press to select, long-press-then-drag to reorder ─── // A tap opens the viewer; _lpSwallow eats the click synthesised after a long-press // so it doesn't ALSO open the viewer. let _lpSwallow = false; const swallowTap = () => { _lpSwallow = true; setTimeout(() => { _lpSwallow = false; }, 450); }; function toggleSel(c) { const it = byId(c.dataset.id); if (!it || it._del) return; it._sel = !it._sel; c.classList.toggle('sel', it._sel); const cb = c.querySelector('.as-dsel'); if (cb) cb.checked = it._sel; syncSel(); } let _tdrag = null; // active touch reorder: { block, ghost, tgt } function wireCardTouch(c) { if (c.classList.contains('del')) return; let timer = null, start = null, engaged = false, moved = false; c.addEventListener('touchstart', e => { if (e.touches.length !== 1) return; const t = e.touches[0]; start = { x: t.clientX, y: t.clientY }; engaged = false; moved = false; timer = setTimeout(() => { engaged = true; try { navigator.vibrate && navigator.vibrate(15); } catch (x) {} const it = byId(c.dataset.id); if (canReorder() && it && !it._del) startTouchDrag(c, it, start); // pick up to reorder else toggleSel(c); // otherwise select }, 420); }, { passive: true }); c.addEventListener('touchmove', e => { if (!start) return; const t = e.touches[0], far = Math.hypot(t.clientX - start.x, t.clientY - start.y) > 12; if (!engaged) { if (far) { clearTimeout(timer); timer = null; start = null; } return; } // pre-engage move = page scroll e.preventDefault(); moved = true; if (_tdrag) moveTouchDrag(t); }, { passive: false }); c.addEventListener('touchend', e => { clearTimeout(timer); timer = null; if (_tdrag) { e.preventDefault(); const dropped = endTouchDrag(); if (!dropped && !moved) toggleSel(c); swallowTap(); start = null; return; } if (engaged) { e.preventDefault(); swallowTap(); } // long-press select already done start = null; }, { passive: false }); c.addEventListener('touchcancel', () => { clearTimeout(timer); timer = null; if (_tdrag) endTouchDrag(); start = null; }); } function startTouchDrag(c, it, start) { _drag = it; const block = dragBlock(); block.forEach(g => cardEl(g)?.classList.add('as-dragging')); const r = c.getBoundingClientRect(); const ghost = c.cloneNode(true); ghost.className = 'as-card as-ghost'; ghost.style.cssText = `position:fixed;left:0;top:0;width:${r.width}px;z-index:100050;pointer-events:none;opacity:.92;transform:translate(${r.left}px,${r.top}px) scale(1.04);box-shadow:0 10px 30px rgba(0,0,0,.4)`; document.body.appendChild(ghost); _tdrag = { block, ghost, off: { x: start.x - r.left, y: start.y - r.top }, tgt: null }; } function moveTouchDrag(t) { const g = _tdrag.ghost; g.style.transform = `translate(${t.clientX - _tdrag.off.x}px,${t.clientY - _tdrag.off.y}px) scale(1.04)`; g.style.visibility = 'hidden'; const under = document.elementFromPoint(t.clientX, t.clientY); g.style.visibility = ''; const card = under && under.closest && under.closest('.as-card[draggable="true"]'); root.querySelectorAll('.as-drop').forEach(c => c.classList.remove('as-drop')); const tgt = card && byId(card.dataset.id); _tdrag.tgt = (tgt && !_tdrag.block.includes(tgt)) ? tgt : null; if (_tdrag.tgt) card.classList.add('as-drop'); } function endTouchDrag() { const { block, ghost, tgt } = _tdrag; _tdrag = null; ghost.remove(); root.querySelectorAll('.as-dragging').forEach(c => c.classList.remove('as-dragging')); root.querySelectorAll('.as-drop').forEach(c => c.classList.remove('as-drop')); _drag = null; if (tgt) { reorder(block, tgt); render(); return true; } return false; } window.addEventListener('resize', () => { if (root.isConnected) fitToolbar(); }); // right-click is our selection gesture across the gallery — suppress the native menu there document.addEventListener('contextmenu', e => { if (root.contains(e.target)) e.preventDefault(); }); // refresh just the toolbar's selection cluster in place — no grid reflow, so // right-click paint-select never makes the page jump. function syncSel() { const box = root.querySelector('.as-selbox'); if (box) { box.innerHTML = selBox(); wireSel(); fitToolbar(); } } // drop the whole selection (model flags + on-screen cards/checkboxes + toolbar). function clearSel() { MODEL.forEach(it => it._sel = false); root.querySelectorAll('.as-card.sel, .as-drow.sel').forEach(c => c.classList.remove('sel')); root.querySelectorAll('.as-dsel').forEach(cb => cb.checked = false); syncSel(); } // #277: after a batch op (type / comment / download / report), drop the // selection — on by default, opt-out in the gear panel. const maybeClearSel = () => { if (SETTINGS.clearSelAfterOp) clearSel(); }; function refreshStaged() { const n = opsCount(); const c = root.querySelector('.as-commit'); if (c) { c.innerHTML = commitInner(n); c.disabled = !n; if (!c.disabled) { c.onclick = () => enterEdit(); c.oncontextmenu = e => { e.preventDefault(); enterEdit(true); }; } fitToolbar(); } // #493 (#503: onclick must not pass the click Event through as `immediate`) } // #234: when the toolbar's real items + gaps can't fit one row (the flex // spacers would have to collapse and it'd wrap), collapse the labelled buttons // to icon-only — the icons + tooltips carry the meaning. Measured by summing // widths (the flex:1 spacers defeat scrollWidth/offsetTop-based detection). function fitToolbar() { const bar = root.querySelector('.as-bar'); if (!bar) return; // #563: the shared collapse. Art Station's own measurement was the reference, // so this is the same arithmetic, now named once for every script. mbuFitToolbar(bar, { spacer: '.as-sp' }); } // the count shown on "Enter edit (N)" = the number of real MB edits we'll submit // (buildPlan merges a cover's type+comment change into one edit), so it matches // the panel's operation list exactly. #234 const opsCount = () => buildPlan().length; // #234: the "View ▾" dropdown — Sort options + Group toggle, moved off the // main toolbar to free its center for the selection controls. function openViewPop(btn) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); const pop = document.createElement('div'); pop.className = 'as-pop as-view-pop'; const sorts = [['type', 'Position'], ['bytype', 'Type'], ['dim', 'Dimensions'], ['newest', 'Newest']]; const vmode = SETTINGS.detailed ? 'detailed' : SETTINGS.group ? 'group' : 'grid'; pop.innerHTML = `<div class="as-pop-h">Sort</div>` + sorts.map(([v, l]) => `<label><input type="radio" name="as-vsort" value="${v}"${SETTINGS.sort === v ? ' checked' : ''}> ${l}${v === 'type' ? ' <span class="as-pop-note">(needed to drag-reorder)</span>' : ''}</label>`).join('') + `<div class="as-pop-h">View</div>` + [['grid', 'Grid', ''], ['detailed', 'Detailed view', '(list + all types & comment)'], ['group', 'Group by type', '(view-only)']] .map(([v, l, note]) => `<label><input type="radio" name="as-vmode" value="${v}"${vmode === v ? ' checked' : ''}> ${l}${note ? ` <span class="as-pop-note">${note}</span>` : ''}</label>`).join(''); document.body.appendChild(pop); placePop(pop, btn.getBoundingClientRect()); pop.querySelectorAll('input[name="as-vsort"]').forEach(r => r.onchange = () => { SETTINGS.sort = r.value; save(); render(); }); // #234: the view modes are mutually exclusive — Grid / Detailed / Group. pop.querySelectorAll('input[name="as-vmode"]').forEach(r => r.onchange = () => { SETTINGS.detailed = r.value === 'detailed'; SETTINGS.group = r.value === 'group'; save(); render(); }); const off = e => { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } // position a popover next to an anchor, flipping up / clamping so it stays on-screen function placePop(pop, r) { const ph = pop.offsetHeight, pw = pop.offsetWidth, vh = innerHeight, vw = innerWidth, M = 8; let top = r.bottom + 3; if (top + ph > vh - M && r.top - ph - 3 >= M) top = r.top - ph - 3; // flip above the anchor top = Math.max(M, Math.min(top, vh - ph - M)); let left = Math.max(M, Math.min(r.left, vw - pw - M)); pop.style.top = (top + scrollY) + 'px'; pop.style.left = (left + scrollX) + 'px'; } function openTypePop(chip) { const it = byId(cardId(chip)); if (!it) return; openTypePopFor(it, chip, () => render()); } // shared single-cover type picker — anchored to `anchor`, mutating `it.types`, // calling `onChange` after each toggle. Used by the grid pills AND the lightbox. function openTypePopFor(it, anchor, onChange) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); const pop = document.createElement('div'); pop.className = 'as-pop'; pop.innerHTML = `<div class="as-type-grid">${ALL_TYPES.map(t => `<label title="Right-click: set only this type"><input type="checkbox" value="${esc(t)}"${it.types.includes(t)?' checked':''}> ${esc(t)}</label>`).join('')}</div>`; document.body.appendChild(pop); placePop(pop, anchor.getBoundingClientRect()); const close = () => { pop.remove(); document.removeEventListener('mousedown', off); _popJustClosed = true; setTimeout(() => { _popJustClosed = false; }, 0); }; pop.querySelectorAll('input').forEach(cb => cb.onchange = () => { it.types = ALL_TYPES.filter(t => pop.querySelector(`input[value="${CSS.escape(t)}"]`).checked); onChange && onChange(); }); // right-click a type → set ONLY that type and close (quick single-type set) pop.querySelectorAll('.as-type-grid label').forEach(lab => lab.oncontextmenu = e => { e.preventDefault(); e.stopPropagation(); it.types = [lab.querySelector('input').value]; onChange && onChange(); close(); }); const off = e => { if (!pop.contains(e.target)) close(); }; setTimeout(() => document.addEventListener('mousedown', off), 0); return pop; } let _popJustClosed = false; // bridges the mousedown-dismiss → click gap so a pop dismissal doesn't also close the lightbox let _drag = null; // the block being dragged: the whole selection if the grabbed card is selected, else just it const dragBlock = () => (_drag && _drag._sel) ? MODEL.filter(it => it._sel && !it._del).sort((a, b) => a.order - b.order) : (_drag ? [_drag] : []); function wireDrag() { root.querySelectorAll('.as-card[draggable="true"]').forEach(card => { card.ondragstart = e => { _drag = byId(card.dataset.id); dragBlock().forEach(g => cardEl(g)?.classList.add('as-dragging')); e.dataTransfer.effectAllowed = 'move'; }; card.ondragend = () => { root.querySelectorAll('.as-dragging').forEach(c => c.classList.remove('as-dragging')); _drag = null; root.querySelectorAll('.as-drop').forEach(c => c.classList.remove('as-drop')); }; card.ondragover = e => { const tgt = byId(card.dataset.id); if (!_drag || dragBlock().includes(tgt)) return; // not onto a member of the moving block e.preventDefault(); root.querySelectorAll('.as-drop').forEach(c => c.classList.remove('as-drop')); card.classList.add('as-drop'); }; card.ondrop = e => { e.preventDefault(); const tgt = byId(card.dataset.id); const block = dragBlock(); if (!_drag || !tgt || block.includes(tgt)) return; reorder(block, tgt); render(); }; }); } const cardEl = it => root.querySelector(`.as-card[data-id="${CSS.escape(String(it.id))}"]`); function reorder(block, tgt) { // move the block (one card, or the whole selection) next to tgt, preserving the // block's relative order. Drop on the side you came from: forward → after tgt. const seq = MODEL.filter(it => !it._del).slice().sort((a, b) => a.order - b.order); const set = new Set(block); const fromFirst = Math.min(...block.map(b => seq.indexOf(b))); const forward = fromFirst < seq.indexOf(tgt); const rest = seq.filter(it => !set.has(it)); const to = rest.indexOf(tgt) + (forward ? 1 : 0); rest.splice(to, 0, ...block); rest.forEach((it, i) => it.order = i); } // ── actions ─────────────────────────────────────────────────────────────────── // #244 a download name that round-trips the type back via #243: // "<NN> <type1>,<type2> <comment>.<ext>" — types lowercased, "/" → "_", // no type → "none". e.g. "09 front,sticker Front cover with the sticker.jpg" function downloadName(it, pos, ext, pad = 2) { const nn = String(pos).padStart(pad, '0'); const types = (it.types && it.types.length) ? it.types.map(t => t.toLowerCase().replace(/[\\/]/g, '_')).join(',') : 'none'; const comment = (it.comment || '').trim().slice(0, 100); const base = (nn + ' ' + types + (comment ? ' ' + comment : '')).replace(/[<>:"|?*]/g, '_').replace(/[\\/]/g, '_').replace(/\s+/g, ' ').trim(); return `${base}.${ext}`; } // download URL + extension for a cover — NEW covers use their local blob, not a CAA URL const dlUrl = it => it._new ? it._file : (it._img || imgUrl(it.id)); function dlExt(it) { if (it._new) { const n = (it._fileObj && it._fileObj.name) || ''; const m = n.match(/\.([a-z0-9]+)$/i); return (m ? m[1] : ((it._fileObj && it._fileObj.type || '').split('/')[1] || 'jpg')).toLowerCase().replace('jpeg', 'jpg'); } return ((it._img || imgUrl(it.id)).match(/\.(jpg|jpeg|png|gif|pdf|webp)(?:$|\?)/i) || ['', 'jpg'])[1].toLowerCase(); } async function dlOne(it, size) { const orig = !size || size === 'original' || it._new; // new covers only have their local blob const url = orig ? dlUrl(it) : thumb(it.id, size), ext = dlExt(it); let name = downloadName(it, it.order + 1, ext); if (!orig) name = name.replace(/\.(\w+)$/, ` ${size}.$1`); // note the thumbnail size try { // cross-origin <a download> is ignored by browsers — fetch the blob (CAA // sends CORS) and download via a same-origin object URL so it actually saves const blob = await fetch(url).then(r => { if (!r.ok) throw new Error(r.status); return r.blob(); }); const obj = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = obj; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(obj), 8000); asLog.ok(`Download: saved ${name}`); } catch (e) { logErr(`Download: ${name} (opened in a tab instead)`, e); window.open(url, '_blank'); } // fallback: just open it } // #240: multiple selected covers → one ZIP. Triggering N separate downloads // from timeouts trips the browser's "downloading multiple files" block (only the // first saves); a single zip download sidesteps it entirely. function crc32(bytes) { let t = crc32._t; if (!t) { t = crc32._t = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } } let crc = 0xFFFFFFFF; for (let i = 0; i < bytes.length; i++) crc = (crc >>> 8) ^ t[(crc ^ bytes[i]) & 0xFF]; return (crc ^ 0xFFFFFFFF) >>> 0; } // store-only ZIP record builders (no compression — covers are already JPEG/PNG) const _zdv = (len, fill) => { const a = new Uint8Array(len); fill(new DataView(a.buffer)); return a; }; const zipLocal = (crc, size, nameLen) => _zdv(30, v => { v.setUint32(0, 0x04034b50, true); v.setUint16(4, 20, true); v.setUint32(14, crc, true); v.setUint32(18, size, true); v.setUint32(22, size, true); v.setUint16(26, nameLen, true); }); const zipCentral = (crc, size, nameLen, off) => _zdv(46, v => { v.setUint32(0, 0x02014b50, true); v.setUint16(4, 20, true); v.setUint16(6, 20, true); v.setUint32(16, crc, true); v.setUint32(20, size, true); v.setUint32(24, size, true); v.setUint16(28, nameLen, true); v.setUint32(42, off, true); }); const zipEOCD = (count, cdSize, cdOff) => _zdv(22, v => { v.setUint32(0, 0x06054b50, true); v.setUint16(8, count, true); v.setUint16(10, count, true); v.setUint32(12, cdSize, true); v.setUint32(16, cdOff, true); }); function makeZip(files) { const enc = new TextEncoder(); const parts = [], central = []; let offset = 0; for (const f of files) { const name = enc.encode(f.name), crc = crc32(f.data), size = f.data.length; parts.push(zipLocal(crc, size, name.length), name, f.data); central.push(zipCentral(crc, size, name.length, offset), name); offset += 30 + name.length + size; } const cdSize = central.reduce((s, c) => s + c.length, 0); return new Blob([...parts, ...central, zipEOCD(files.length, cdSize, offset)], { type: 'application/zip' }); } // member names, disambiguating same-type covers (front.jpg, booklet.jpg, booklet-2.jpg …) function zipNames(sel) { const used = new Set(); const pad = Math.max(2, String(Math.max(0, ...sel.map(it => it.order + 1))).length); return sel.map(it => { const url = dlUrl(it), ext = dlExt(it); let name = downloadName(it, it.order + 1, ext, pad); // #244 "NN types comment.ext" const b = name.replace(/\.[^.]+$/, ''); let n = 2; while (used.has(name.toLowerCase())) name = `${b} (${n++}).${ext}`; used.add(name.toLowerCase()); return { url, name, it }; }); } // #274: covers (esp. big ones from coverartarchive.org → archive.org) fail // intermittently — a transient 5xx / network hiccup / slow large transfer. // Without a retry those covers were silently dropped from the archive. Retry // with backoff, and abort a stalled attempt so it retries instead of hanging. async function fetchBytes(url, attempts = 4) { let lastErr; for (let i = 0; i < attempts; i++) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 120000); // 2 min per attempt try { const r = await fetch(url, { cache: 'no-store', signal: ctrl.signal }); clearTimeout(timer); if (!r.ok) throw new Error('HTTP ' + r.status); return new Uint8Array(await r.arrayBuffer()); } catch (e) { clearTimeout(timer); lastErr = e; } // #274: plain fetch() is CORS-bound — a PDF booklet on archive.org carries // no Access-Control-Allow-Origin, so fetch() ALWAYS fails it even though the // browser views it fine. GM_xmlhttpRequest is not CORS-bound; use it as the // fallback (it also recovers transient image failures). try { const blob = await gmFetch(url); if (blob) return new Uint8Array(await blob.arrayBuffer()); } catch (e) { lastErr = e; } if (i < attempts - 1) await new Promise(res => setTimeout(res, 800 * (i + 1))); } throw lastErr; } // #274: warn the user (clearly, and in the archive's own README) when covers // couldn't be downloaded, instead of silently shipping an incomplete zip. function warnDropped(failed, total) { const n = failed.length; const list = failed.slice(0, 4).join(', ') + (n > 4 ? `, +${n - 4} more` : ''); toast(`⚠ ${n}/${total} file${n === 1 ? '' : 's'} failed to download — missing from the archive: ${list}. See README.md inside.`, 14000); } // capture the original's resolution from its bytes (no extra request) so the manifest table has it async function measureBytes(it, data) { if (it.w || it._pdf || !data) return; try { const bmp = await createImageBitmap(new Blob([data])); it.w = bmp.width; it.h = bmp.height; bmp.close && bmp.close(); } catch (e) {} } async function dlZip(sel, onProgress) { const items = zipNames(sel), enc = new TextEncoder(); // unique, sortable archive name: "<MBID> <YYYY-MM-DDThh-mm-ss> <N> <covers>.zip" — the // export timestamp + file count make every download distinct (no overwrite / same-name reuse). const _p = n => String(n).padStart(2, '0'), _d = new Date(); const zipName = `${MBID} ${_d.getFullYear()}-${_p(_d.getMonth() + 1)}-${_p(_d.getDate())}T${_p(_d.getHours())}-${_p(_d.getMinutes())}-${_p(_d.getSeconds())} ${items.length} ${ITEMS}.zip`; asLog.info(`Download: zipping ${items.length} ${items.length === 1 ? ITEM : ITEMS}`); // #240: stream the zip straight to disk when the browser supports it — the // download starts immediately (first cover written as soon as it arrives) and // the whole archive is never buffered in memory. // Grab the save handle FIRST, while we still hold the user gesture. // showSaveFilePicker needs transient user activation; awaiting loadSizes (an // archive.org fetch) before it lets that activation expire, so the picker threw // SecurityError. We also used to swallow EVERY failure as "cancelled" → the zip // download silently did nothing. Now: pick the file first, and on any non-cancel // failure (gesture lost, blocked by the userscript manager, API unavailable) log // the reason and fall through to the blob download so the zip still saves. let handle = null; // Call showSaveFilePicker on the REAL window (unsafeWindow): on the userscript-sandboxed // `window` proxy some managers (e.g. Tampermonkey) throw TypeError "illegal invocation", // which forced the blob fallback — and Chromium then silently blocks the 2nd+ of those // repeat downloads. The native Save dialog isn't subject to that gate. #282 const _win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; if (_win.showSaveFilePicker) { try { handle = await _win.showSaveFilePicker({ suggestedName: zipName, types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }] }); } catch (e) { if (e && e.name === 'AbortError') return; // genuine user cancel — respect it asLog.warn(`Save dialog unavailable (${(e && e.name) || e}) — saving via direct download instead`); handle = null; // fall through to the blob fallback below } } await loadSizes(); // byte sizes for the manifest; resolutions are captured during the fetch below if (handle) { const w = await handle.createWritable(); const central = []; let offset = 0, done = 0; const failed = []; const writeEntry = async (nameStr, data) => { const name = enc.encode(nameStr), crc = crc32(data); await w.write(zipLocal(crc, data.length, name.length)); await w.write(name); await w.write(data); central.push({ crc, size: data.length, name, offset }); offset += 30 + name.length + data.length; }; for (const o of items) { asLog.debug(`Zip: fetching ${o.name} ← ${o.url}`); let data; try { data = await fetchBytes(o.url); } catch (e) { failed.push(o.name); logErr(`Zip: ${o.name}`, e); onProgress && onProgress(++done, items.length); continue; } // #274: record, don't silently drop await measureBytes(o.it, data); await writeEntry(o.name, data); asLog.info(`Zip + ${o.name} — ${fmtBytes(data.length)}${o.it && o.it.w ? `, ${o.it.w}×${o.it.h}` : ''}${o.it && o.it._pdf ? ', PDF' : ''}`); onProgress && onProgress(++done, items.length); } await writeEntry('README.md', enc.encode(manifestMd(sel, failed))); // manifest last — now has resolutions + any drops asLog.debug('Zip + README.md (manifest)'); let cdSize = 0; for (const c of central) { await w.write(zipCentral(c.crc, c.size, c.name.length, c.offset)); await w.write(c.name); cdSize += 46 + c.name.length; } await w.write(zipEOCD(central.length, cdSize, offset)); await w.close(); asLog.ok(`Download: saved ${zipName} — ${central.length} file${central.length === 1 ? '' : 's'} · ${fmtBytes(offset)} (streamed)${failed.length ? `, ${failed.length} dropped` : ''}`); if (failed.length) warnDropped(failed, items.length); return; } // fallback: fetch with a small concurrency pool (NOT all at once — flooding // the network with many big covers is what made them fail/drop, #274), keep // original order, then one blob download. let done = 0; const out = new Array(items.length).fill(null); const failed = []; let idx = 0; const worker = async () => { while (true) { const i = idx++; if (i >= items.length) break; const o = items[i]; asLog.debug(`Zip: fetching ${o.name} ← ${o.url}`); try { const data = await fetchBytes(o.url); await measureBytes(o.it, data); out[i] = { name: o.name, data }; asLog.info(`Zip + ${o.name} — ${fmtBytes(data.length)}${o.it && o.it.w ? `, ${o.it.w}×${o.it.h}` : ''}${o.it && o.it._pdf ? ', PDF' : ''}`); } catch (e) { failed.push(o.name); logErr(`Zip: ${o.name}`, e); } onProgress && onProgress(++done, items.length); } }; await Promise.all(Array.from({ length: Math.min(4, items.length) }, worker)); const covers = out.filter(Boolean); if (!covers.length) { toast('⚠ Download failed — could not fetch any cover. Try again.', 10000); return; } const entries = [...covers, { name: 'README.md', data: enc.encode(manifestMd(sel, failed)) }]; // manifest last — now has resolutions const total = entries.reduce((s, e) => s + e.data.length, 0); const obj = URL.createObjectURL(makeZip(entries)); const a = document.createElement('a'); a.href = obj; a.download = zipName; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(obj), 8000); asLog.ok(`Download: saved ${zipName} — ${covers.length} file${covers.length === 1 ? '' : 's'} · ${fmtBytes(total)}${failed.length ? `, ${failed.length} dropped` : ''}`); if (failed.length) warnDropped(failed, items.length); } // ── #235 source covers from covers.musichoarders.xyz (the sanctioned MH Covers // integration — same window.open + postMessage protocol the "Ame" script uses; // no internal MH API). A chosen cover is fetched and dropped into the gallery // as a staged NEW cover, so it rides the normal Enter-edit upload flow. ───── const MH_ORIGIN = 'https://covers.musichoarders.xyz'; // cross-origin GET → Blob (covers can be on any provider host → needs GM xhr) function gmFetch(url, onProgress) { return new Promise((resolve, reject) => { const gx = (typeof GM !== 'undefined' && GM.xmlHttpRequest && GM.xmlHttpRequest.bind(GM)) || (typeof GM_xmlhttpRequest !== 'undefined' && GM_xmlhttpRequest) || null; if (!gx) { fetch(url).then(r => r.ok ? r.blob() : Promise.reject(new Error('HTTP ' + r.status))).then(resolve, reject); return; } gx({ method: 'GET', url, responseType: 'blob', timeout: 180000, onprogress: e => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); }, onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error('HTTP ' + r.status)), onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timed out')) }); }); } // ── session log (#283) ──────────────────────────────────────────────────── // A troubleshooting log of everything the script does this session — downloads, // zipping, provider/MH-Covers integrations, archive.org sign/upload, MB edits, // reorders, and every warning/error (incl. the ones that used to die silently // in a catch). Reviewable + copy/pastable as a Markdown <details> block for a // GitHub issue, mirroring Credit Hoarder. `toast()` feeds it; operations log // explicitly, and verbose diagnostics go in at `debug`. // The buffer and window are the shared mbuLog (ST-UI block), since X12 of #623. const LOG = mbuLog({ name: 'Art Station', version: () => _gm && _gm.version, key: 'artstation:logwin', subtitle: () => (releaseInfo().title || '').trim(), // the release, after the script name load: k => gmLoad(k), save: (k, v) => gmSave(k, v), }); function asLog(sev, msg) { if (msg != null) LOG.add(sev, String(msg)); } asLog.info = m => asLog('info', m); asLog.ok = m => asLog('ok', m); asLog.warn = m => asLog('warn', m); asLog.error = m => asLog('error', m); asLog.debug = m => asLog('debug', m); // standard shape for a caught error: "<context> — <message>" const logErr = (ctx, e) => asLog('error', ctx + ' — ' + ((e && e.message) || e || 'unknown error')); const fmtBytes = n => (n == null) ? '?' : n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(2) + ' MB'; let _toastT; // #563: the shared toast. Art Station's log-mirroring was the behaviour worth // keeping, so it becomes the standard sink rather than being reimplemented. mbuToast.log = (level, message) => asLog(level, message); function toast(msg, ms = 2800) { return mbuToast(msg, { ms }); } function openMHCovers() { const info = releaseInfo(); const artist = info.artists.map(a => a.name).join(' ').trim(); const album = (info.title || '').trim(); if (!album) { toast('Could not read the release title'); return; } const p = new URLSearchParams(); // remote.* puts MH Covers into integration ("pick") mode — picking a cover posts // it back over the browser channel instead of just opening the image. #235 p.set('remote.port', 'browser'); p.set('remote.agent', 'Art Station - MusicBrainz'); p.set('remote.text', 'Pick a cover for this MusicBrainz release.'); if (artist) p.set('artist', artist); p.set('album', album); const win = window.open(`${MH_ORIGIN}?${p}`, '_blank'); if (!win) { toast('Pop-up blocked — allow pop-ups for MH Covers'); return; } toast('Pick a cover in the MH Covers tab…', 6000); const onMsg = async e => { if (e.source !== win) return; let host = ''; try { host = new URL(e.origin).hostname; } catch (err) {} if (!/(^|\.)musichoarders\.xyz$/.test(host)) return; let o; try { o = JSON.parse(e.data); } catch (err) { return; } if (o.action !== 'primary' && o.action !== 'secondary') return; cleanup(); try { win.close(); } catch (err) {} await addCoverFromMH(o); }; const onUnload = () => { try { win.close(); } catch (err) {} }; const cleanup = () => { window.removeEventListener('message', onMsg); window.removeEventListener('beforeunload', onUnload); }; window.addEventListener('message', onMsg); window.addEventListener('beforeunload', onUnload); } // MH reports the cover's underlying source (e.g. "itunes", "bandcamp") — map the // known ones to a recognisable name + favicon so the staged cover shows where it // came from, the same as store sources do (#249). Unknown → labelled as MH Covers. const MH_SOURCE = { itunes: ['Apple Music', 'music.apple.com'], applemusic: ['Apple Music', 'music.apple.com'], deezer: ['Deezer', 'deezer.com'], spotify: ['Spotify', 'spotify.com'], tidal: ['Tidal', 'tidal.com'], qobuz: ['Qobuz', 'qobuz.com'], bandcamp: ['Bandcamp', 'bandcamp.com'], discogs: ['Discogs', 'discogs.com'], amazonmusic: ['Amazon', 'amazon.com'], amazon: ['Amazon', 'amazon.com'], vgmdb: ['VGMdb', 'vgmdb.net'], junodownload: ['Juno', 'junodownload.com'], beatport: ['Beatport', 'beatport.com'], sevendigital: ['7digital', '7digital.com'], }; function mhProvider(o) { const key = String(o.source || o.sourceName || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const m = MH_SOURCE[key]; if (m) return { name: `${m[0]} (via MH)`, icon: provIconUrl(m[1]) }; return { name: 'MH Covers', icon: `${MH_ORIGIN}/favicon.svg` }; } async function addCoverFromMH(o) { const url = o.bigCoverUrl || o.smallCoverUrl; if (!url) return; const prov = mhProvider(o); const slot = addSourcingSlot(`Sourcing ${prov.name}…`); // show the in-grid spinner placeholder, same as URL/provider sourcing try { const blob = await gmFetch(url, (l, t) => setSourcingLabel(slot, `Fetching ${prov.name}… ${Math.round(l / t * 100)}%`)); const ext = (String(url).match(/\.(jpe?g|png|gif|webp)(?:$|\?)/i) || ['', 'jpg'])[1].toLowerCase().replace('jpeg', 'jpg'); const type = (blob.type && blob.type.startsWith('image/')) ? blob.type : 'image/jpeg'; const file = new File([blob], `mh-${Date.now()}.${ext}`, { type }); dropSourcingSlot(slot); const added = await addFilesDeduped([file], [{ provider: prov.name, provIcon: prov.icon, provUrl: url }]); // #253 if (added) toast('Added cover from MH Covers ✓'); else { render(); toast('That cover is already added'); } } catch (e) { dropSourcingSlot(slot); render(); toast('Could not fetch the cover — ' + e.message, 5000); } } // ── #717 Source from a store page or an image URL. ── // The shared ImageMaximizer (dev/imgmax, its API in dev/imgmax/README.md) reads the // store's page or API for its cover(s), turns each into its largest version and // downloads it; Art Station stages the results as NEW covers, riding the normal // "you get what you see" Enter-edit flow. Its requests go through the manager // (GM xhr: no CORS), to the stores only, never to MusicBrainz. It replaced the // hidden-iframe seeding of Enhanced Cover Art Uploads (#242), so no other script // is needed. // <ST-IMGMAX> — generated by dev/imgmax/sync-imgmax.mjs from dev/imgmax/image-maximizer.js — DO NOT EDIT class ImageMaximizer { // ── URL rules ──────────────────────────────────────────────────────────────── // host → (URL) → bigger URLs, largest first. Measured on the live CDNs (2026-10-11). static RULES = [ // Apple: the uploaded original (a1.mzstatic.com/us/r1000/0/<path>), then the largest // rendition the resizer gives. 100000x100000-999 answers 400 now. { host: /(^|\.)mzstatic\.com$/, max: u => { const m = u.pathname.match(/^\/image\/thumb\/(.+)\/[^/]+$/); return m ? [`https://a1.mzstatic.com/us/r1000/0/${m[1]}`, `https://${u.host}/image/thumb/${m[1]}/9999x9999bb.jpg`] : []; } }, // Deezer: 4000x0 at quality 100 gives the stored size (1900 too; 0x0 and 3000x3000 are 403) { host: /(^|\.)dzcdn\.net$/, max: u => { const m = u.pathname.match(/^(\/images\/\w+\/[0-9a-f]{32})\/\d+x\d+-[^/]+$/); return m ? [`https://${u.host}${m[1]}/4000x0-000000-100-0-0.jpg`, `https://${u.host}${m[1]}/1200x1200-000000-80-0-0.jpg`] : []; } }, // Spotify: the size is a code inside the id; 82c1 is the upload (1500 px and more), b273 640 { host: /(^|\.)(scdn\.co|spotifycdn\.com)$/, max: u => { const m = u.pathname.match(/\/image\/ab67616d[0-9a-f]{8}([0-9a-f]+)$/); return m ? ['000082c1', '0000b273'].map(c => `https://i.scdn.co/image/ab67616d${c}${m[1]}`) : []; } }, // Bandcamp: _0 is the original upload, _10 the largest rendition { host: /(^|\.)bcbits\.com$/, max: u => { const m = u.pathname.match(/^\/img\/(a?\d+)_\d+\.(jpg|png|gif)$/); return m ? [`https://f4.bcbits.com/img/${m[1]}_0.${m[2]}`, `https://f4.bcbits.com/img/${m[1]}_10.jpg`] : []; } }, // Tidal: origin.jpg is the upload; 1280 the largest rendition (3000 is 403) { host: /^resources\.tidal\.com$/, max: u => { const m = u.pathname.match(/^(\/images\/(?:[0-9a-f]+\/){4}[0-9a-f]+)\/[^/]+$/); return m ? [`https://resources.tidal.com${m[1]}/origin.jpg`, `https://resources.tidal.com${m[1]}/1280x1280.jpg`] : []; } }, // Qobuz: _org is the upload, _max the largest rendition { host: /^static\.qobuz\.com$/, max: u => { const m = u.pathname.match(/^(\/images\/covers\/.+?)_(?:\d+|max|org)\.jpg$/); return m ? [`https://static.qobuz.com${m[1]}_org.jpg`, `https://static.qobuz.com${m[1]}_max.jpg`] : []; } }, // Beatport: any size is rendered on demand (3000 is an upscale); 0x0 is the stored size { host: /(^|\.)beatport\.com$/, max: u => { const m = u.pathname.match(/^\/image_size\/\d+x\d+\/(.+)$/); return m ? [`https://${u.host}/image_size/0x0/${m[1]}`] : []; } }, // Amazon: drop the ._SL500_-style modifiers for the stored image { host: /(^|\.)(media-amazon\.com|ssl-images-amazon\.com|images-amazon\.com)$/, max: u => { const m = u.pathname.match(/^\/images\/I\/([^./]+)(?:\.[^/]*)?\.(jpg|png|gif)$/); return m ? [`https://m.media-amazon.com/images/I/${m[1]}.${m[2]}`] : []; } }, // 7digital: 800 is the largest rendition { host: /(^|\.)7static\.com$/, max: u => { const m = u.pathname.match(/^(.+)_\d+\.jpg$/); return m ? [`https://${u.host}${m[1]}_800.jpg`] : []; } }, // Juno: /full/…-BIG { host: /(^|\.)(juno\.co\.uk|junostatic\.com)$/, max: u => { const m = u.pathname.match(/^\/(?:\d+|full)\/(.+?)(?:-(?:TN|MED|BIG))?\.(jpg|png)$/i); return m ? [`https://${u.host}/full/${m[1]}-BIG.${m[2]}`] : []; } }, // VGMdb: thumbnails and medium copies live on their own hosts; the full image on media.vgm.io { host: /(^|\.)vgm\.io$/, max: u => /^(thumb|medium)-media\./.test(u.host) ? [`https://media.vgm.io${u.pathname}`] : [] }, // Google (YouTube Music, Play): =s0 is the original { host: /^(lh\d|yt\d)\.(googleusercontent|ggpht)\.com$/, max: u => { const m = u.href.match(/^([^=]+)=[^/]*$/); return m ? [`${m[1]}=s0`] : []; } }, // YouTube thumbnails { host: /^i\d?\.ytimg\.com$/, max: u => { const m = u.pathname.match(/^\/vi(?:_webp)?\/([^/]+)\/[a-z]*default\.(?:jpg|webp)$/); return m ? [`https://i.ytimg.com/vi/${m[1]}/maxresdefault.jpg`, `https://i.ytimg.com/vi/${m[1]}/hqdefault.jpg`] : []; } }, // SoundCloud: -original, else the 500 px rendition { host: /(^|\.)sndcdn\.com$/, max: u => { const m = u.pathname.match(/^(.+)-(?:t\d+x\d+|large|crop|small|badge|tiny|mini|original)\.(jpg|png)$/); return m ? [`https://${u.host}${m[1]}-original.${m[2]}`, `https://${u.host}${m[1]}-t500x500.${m[2]}`] : []; } }, // Cover Art Archive thumbnails → the original (its extension is unknown, so both) { host: /^(coverartarchive\.org|archive\.org|ia\d+\.us\.archive\.org)$/, max: u => { const m = u.href.match(/^(.+\/\d+)-(?:250|500|1200)\.jpg$/); return m ? [`${m[1]}.jpg`, `${m[1]}.png`] : []; } }, ]; // Images a store shows when it has none. static PLACEHOLDERS = [ /dzcdn\.net\/images\/cover\/d41d8cd98f00b204e9800998ecf8427e\//, // Deezer's empty md5 /7static\.com\/.*\/0{8}16_\d+\.jpg$/, // 7digital /\/images\/I\/(01RmK\+J4pJL|01QFb8SNuTL|01PkLIhTX3L|01MKUOLsA5L|31CTP6oiIBL)\./, // Amazon /vgmdb\.net\/db\/img\/album-(nocover|nsfw)-medium\.gif$/, ]; // ── platform resolvers ──────────────────────────────────────────────────────── // A resolver turns a store page into its cover image(s): [{ url, types?, comment? }]. // `this` is the ImageMaximizer, so a resolver uses this._text / this._json / this._log. static PLATFORMS = [ { id: 'apple', name: 'Apple Music', host: /^(music|itunes)\.apple\.com$/, async find(u) { const id = (u.pathname.match(/\/album\/(?:[^/]+\/)?(?:id)?(\d+)/) || [])[1]; if (!id) throw new Error('no album id in the URL'); const cc = (u.pathname.match(/^\/([a-z]{2})\//) || [])[1] || 'us'; const j = await this._json(`https://itunes.apple.com/lookup?id=${id}&country=${cc}&entity=album`); const art = j && j.results && j.results[0] && j.results[0].artworkUrl100; if (art) return [{ url: art, types: ['Front'] }]; this._log('debug', `Apple: lookup had no artwork for ${id} in ${cc}, reading the page`); return this._metaImage(u.href, ['Front']); } }, { id: 'deezer', name: 'Deezer', host: /(^|\.)deezer\.com$/, async find(u) { const id = (u.pathname.match(/\/album\/(\d+)/) || [])[1]; if (!id) throw new Error('no album id in the URL'); const j = await this._json(`https://api.deezer.com/album/${id}`); if (j && j.error) throw new Error(j.error.message || 'album not found'); return j && j.cover_xl ? [{ url: j.cover_xl, types: ['Front'] }] : []; } }, { id: 'spotify', name: 'Spotify', host: /^open\.spotify\.com$/, async find(u) { const id = (u.pathname.match(/\/album\/(\w+)/) || [])[1]; if (!id) throw new Error('no album id in the URL'); const j = await this._json(`https://open.spotify.com/oembed?url=${encodeURIComponent('https://open.spotify.com/album/' + id)}`); return j && j.thumbnail_url ? [{ url: j.thumbnail_url, types: ['Front'] }] : []; } }, { id: 'bandcamp', name: 'Bandcamp', host: /(^|\.)bandcamp\.com$/, async find(u) { const html = await this._text(u.href); // data-tralbum's art_id where the page has it, else the og:image const art = (html.match(/"art_id":(\d+)/) || html.match(/"art_id":(\d+)/) || [])[1]; if (art) return [{ url: `https://f4.bcbits.com/img/a${art}_10.jpg`, types: ['Front'] }]; const og = ImageMaximizer.metaImage(html, u.href); return og ? [{ url: og, types: ['Front'] }] : []; } }, { id: 'tidal', name: 'Tidal', host: /^(listen\.|store\.|www\.)?tidal\.com$/, async find(u) { const id = (u.pathname.match(/\/album\/(\d+)/) || [])[1]; if (!id) throw new Error('no album id in the URL'); const headers = { 'x-tidal-token': ImageMaximizer.TIDAL_TOKEN }; const countries = []; try { const c = await this._json('https://listen.tidal.com/v1/country/context?countryCode=WW&locale=en_US&deviceType=BROWSER', headers); if (c && c.countryCode) countries.push(c.countryCode); } catch (e) { this._log('debug', 'Tidal: country lookup failed — ' + e.message); } for (const c of ['US', 'GB', 'DE', 'NL', 'CA', 'NZ', 'JP', 'IT']) if (!countries.includes(c)) countries.push(c); let last; for (const cc of countries) { try { const j = await this._json(`https://api.tidal.com/v1/albums/${id}?countryCode=${cc}`, headers); if (j && j.cover) return [{ url: `https://resources.tidal.com/images/${j.cover.replace(/-/g, '/')}/1280x1280.jpg`, types: ['Front'] }]; } catch (e) { last = e; this._log('debug', `Tidal: album ${id} not in ${cc} — ${e.message}`); } } throw last || new Error('album not found'); } }, { id: 'qobuz', name: 'Qobuz', host: /(^|\.)qobuz\.com$/, async find(u) { const id = (u.pathname.match(/\/album\/(?:[^/]+\/)?([A-Za-z\d]+)\/?$/) || [])[1]; if (!id) throw new Error('no album id in the URL'); // the store's covers are addressed by the album id (…/covers/<last 2>/<the 2 before>/<id>); // the API, when it answers, adds the booklets const out = [{ url: `https://static.qobuz.com/images/covers/${id.slice(-2)}/${id.slice(-4, -2)}/${id}_600.jpg`, types: ['Front'] }]; try { const j = await this._json(`https://www.qobuz.com/api.json/0.2/album/get?album_id=${id}&offset=0&limit=20`, { 'x-app-id': ImageMaximizer.QOBUZ_APP }); if (j && j.image && j.image.large) out[0].url = j.image.large; for (const g of (j && j.goodies) || []) { if (!g.original_url) continue; const booklet = /livret|booklet/i.test(g.name || ''); out.push({ url: g.original_url, types: booklet ? ['Booklet'] : [], comment: booklet ? '' : (g.name || '') }); } } catch (e) { this._log('debug', `Qobuz: API gave nothing for ${id} (${e.message}); the cover alone, from its URL`); } return out; } }, { id: 'beatport', name: 'Beatport', host: /(^|\.)beatport\.com$/, async find(u) { const html = await this._text(u.href); const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/); if (m) { try { const d = JSON.parse(m[1]), r = d.props && d.props.pageProps && d.props.pageProps.release; if (r && r.image && r.image.uri) return [{ url: r.image.uri, types: ['Front'] }]; } catch (e) { this._log('debug', 'Beatport: page data unreadable — ' + e.message); } } const og = ImageMaximizer.metaImage(html, u.href); return og ? [{ url: og, types: ['Front'] }] : []; } }, { id: 'discogs', name: 'Discogs', host: /(^|\.)discogs\.com$/, async find(u) { const rel = (u.pathname.match(/\/release\/(\d+)/) || [])[1], master = (u.pathname.match(/\/master\/(\d+)/) || [])[1]; if (!rel && !master) throw new Error('no release id in the URL'); if (rel) { // the site's own API has the full-size images (Cloudflare may stop it until the // site has been opened once); the public API caps them at 600 px try { const q = new URLSearchParams({ operationName: 'ReleaseAllImages', variables: JSON.stringify({ discogsId: +rel, count: 500 }), extensions: JSON.stringify({ persistedQuery: { version: 1, sha256Hash: ImageMaximizer.DISCOGS_QUERY } }) }); const j = await this._json('https://www.discogs.com/internal/release-page/api/graphql?' + q); const edges = j && j.data && j.data.release && j.data.release.images && j.data.release.images.edges; if (edges && edges.length) return edges.map(e => ({ url: e.node.fullsize.sourceUrl })); } catch (e) { this._log('debug', 'Discogs: full-size images unavailable (' + e.message + '), using the public API (600 px)'); } } const j = await this._json(`https://api.discogs.com/${rel ? 'releases/' + rel : 'masters/' + master}`); return ((j && j.images) || []).map(i => ({ url: i.uri })).filter(i => i.url); } }, { id: 'amazon', name: 'Amazon', host: /(^|\.)amazon\.(com|ca|de|fr|it|es|nl|pl|se|in|sg|ae|sa|eg|cn|jp|co\.jp|co\.uk|com\.au|com\.br|com\.mx|com\.tr|com\.be)$/, async find(u) { const asin = (u.pathname.match(/\/(?:gp\/product|dp|albums|hz\/audible\/mlp\/mfpdp)\/([A-Z\d]{10})(?:[/?]|$)/i) || [])[1]; if (!asin) throw new Error('no product id in the URL'); const tld = u.host.replace(/^.*?amazon\./, ''); const html = await this._text(`https://www.amazon.${tld}/dp/${asin}`); if (/validateCaptcha|\/errors\/validateCaptcha/.test(html)) throw new Error('Amazon answered with a captcha; open the page once, then retry'); const m = html.match(/'colorImages':\s*\{\s*'initial':\s*(\[[\s\S]*?\])\s*\},?\s*\n/); if (m) { try { const variant = { MAIN: 'Front', FRNT: 'Front', BACK: 'Back', SIDE: 'Spine' }; return JSON.parse(m[1]).map(i => ({ url: i.hiRes || i.large, types: variant[i.variant] ? [variant[i.variant]] : [] })).filter(i => i.url); } catch (e) { this._log('debug', 'Amazon: image data unreadable — ' + e.message); } } const hi = (html.match(/data-old-hires="([^"]+)"/) || [])[1]; if (hi) return [{ url: hi, types: ['Front'] }]; const og = ImageMaximizer.metaImage(html, u.href); return og ? [{ url: og }] : []; } }, { id: 'vgmdb', name: 'VGMdb', host: /(^|\.)vgmdb\.net$/, async find(u) { const html = await this._text(u.href); const out = [], seen = new Set(); const gallery = (html.match(/id="cover_gallery"[\s\S]*?(?=<\/table>|<div id="(?!cover)|$)/) || [''])[0]; for (const m of gallery.matchAll(/<a[^>]*id="thumb_\d+"[^>]*href="([^"]+)"[^>]*>[\s\S]*?class="label"[^>]*>([^<]*)</g)) { const url = ImageMaximizer.decode(m[1]); if (seen.has(url)) continue; seen.add(url); out.push(Object.assign({ url }, ImageMaximizer.captionType(ImageMaximizer.decode(m[2]).trim()))); } const main = (html.match(/id="coverart"[^>]*background-image:\s*url\(['"]?([^'")]+)/) || [])[1]; const base = n => String(n).split('/').pop(); if (main && !/album-(nocover|nsfw)-medium\.gif$/.test(main) && !out.some(i => base(i.url) === base(main))) out.unshift({ url: new URL(ImageMaximizer.decode(main), u.origin).href, types: ['Front'] }); return out; } }, { id: '7digital', name: '7digital', host: /(^|\.)7digital\.com$/, async find(u) { return this._metaImage(u.href, ['Front']); } }, { id: 'juno', name: 'Juno', host: /(^|\.)(junodownload\.com|juno\.co\.uk)$/, async find(u) { return this._metaImage(u.href, ['Front']); } }, ]; static TIDAL_TOKEN = 'CzET4vdadNUFQ5JU'; // the web player's public token static QOBUZ_APP = '712109809'; static DISCOGS_QUERY = 'c7033a9fd1facb3e69fa50074b47e8aa0076857a968e6ed086153840e02b988a'; // ReleaseAllImages // VGMdb-style captions ("Front", "Back", "Disc 2", "Booklet p. 3–4") → a type + the rest as comment static captionType(caption) { const map = { front: 'Front', cover: 'Front', back: 'Back', booklet: 'Booklet', disc: 'Medium', disk: 'Medium', cd: 'Medium', medium: 'Medium', vinyl: 'Medium', obi: 'Obi', spine: 'Spine', tray: 'Tray', inside: 'Tray', sticker: 'Sticker', poster: 'Poster', liner: 'Liner', inlay: 'Liner', insert: 'Liner', jacket: 'Front', matrix: 'Matrix/Runout' }; const word = (String(caption).match(/^[A-Za-z]+/) || [''])[0].toLowerCase(); const type = map[word]; return type ? { types: [type], comment: caption.toLowerCase() === word ? '' : caption } : { types: [], comment: caption }; } // ── instance ───────────────────────────────────────────────────────────────── // request(url, { headers, type: 'text' | 'blob' }) → { status, url, contentType, text | blob } // log(level, message), level = 'debug' | 'info' | 'warn' constructor(opts) { opts = opts || {}; this.request = opts.request || ImageMaximizer.fetchRequest; this.log = opts.log || null; } _log(level, msg) { try { if (this.log) this.log(level, msg); } catch (e) { /* a broken logger must not break a fetch */ }} async _get(url, type, headers) { const r = await this.request(url, { type, headers: headers || {} }); if (!r || r.status < 200 || r.status >= 300) { const body = r && typeof r.text === 'string' ? r.text : ''; if (r && r.status === 403 && /<title>Just a moment\.\.\.<\/title>/.test(body)) throw new Error('Cloudflare check; open the page once in this browser, then retry'); throw new Error('HTTP ' + (r ? r.status : '?')); } return r; } async _text(url, headers) { return (await this._get(url, 'text', headers)).text; } async _json(url, headers) { const t = await this._text(url, headers); try { return JSON.parse(t); } catch (e) { throw new Error('not JSON'); } } async _metaImage(url, types) { const og = ImageMaximizer.metaImage(await this._text(url), url); return og ? [{ url: og, types: types || [] }] : []; } /** The platform resolver for a page URL, or null. */ platform(url) { let u; try { u = new URL(url); } catch (e) { return null; } const p = ImageMaximizer.PLATFORMS.find(x => x.host.test(u.hostname)); return p ? { id: p.id, name: p.name } : null; } /** Bigger versions of an image URL, largest first, ending with the URL itself. Synchronous. */ candidates(url) { let u; try { u = new URL(url); } catch (e) { return [String(url)]; } const out = []; for (const r of ImageMaximizer.RULES) { if (!r.host.test(u.hostname)) continue; try { out.push(...r.max(u)); } catch (e) { this._log('debug', `rule for ${u.hostname} failed — ${e.message}`); } break; } out.push(u.href); return [...new Set(out)]; } /** Images behind a URL, unmaximised: [{ url, types, comment, page }]. A store page goes to its * resolver; any other page to its og:image; an image URL is itself. */ async find(url) { const u = new URL(url); const p = ImageMaximizer.PLATFORMS.find(x => x.host.test(u.hostname)); // an image URL is an image even on a store's host (Beatport's geo-media, say), not a page if (ImageMaximizer.looksLikeImage(u.href)) { return [{ url: u.href, types: [], comment: '', page: '' }]; } let list; if (p) { this._log('debug', `${p.name}: reading ${u.href}`); list = await p.find.call(this, u); } else { // unknown host: an image answers as one, a page by its og:image const r = await this._get(u.href, 'text'); if (/^image\//i.test(r.contentType || '')) return [{ url: u.href, types: [], comment: '', page: '' }]; const og = ImageMaximizer.metaImage(r.text || '', u.href); list = og ? [{ url: og }] : []; } list = (list || []).filter(i => i && i.url && !ImageMaximizer.PLACEHOLDERS.some(re => re.test(i.url))); this._log('debug', `${p ? p.name : u.hostname}: ${list.length} image(s)${list.length ? ' — ' + list.map(i => i.url).join(', ') : ''}`); return list.map(i => ({ url: i.url, types: i.types || [], comment: i.comment || '', page: u.href })); } /** Download the largest version of each image behind a URL. * → [{ blob, url, original, page, types, comment, width, height }]; throws when there is none. * opts.minSize drops images whose longer side is smaller (a store's logo). */ async fetchImages(url, opts) { opts = opts || {}; const found = await this.find(url); if (!found.length) throw new Error('no image found'); const got = []; for (const img of found) { const r = await this.fetchLargest(img.url); if (!r) { this._log('warn', `no candidate of ${img.url} answered with an image`); continue; } if (opts.minSize && r.width && Math.max(r.width, r.height) < opts.minSize) { this._log('debug', `skipped ${r.url}: ${r.width}×${r.height} is under ${opts.minSize} px`); continue; } got.push(Object.assign(r, { original: img.url, page: img.page, types: img.types, comment: img.comment })); } if (!got.length) throw new Error('no image could be downloaded'); return got; } /** The first candidate of one image URL that answers with an image: { blob, url, width, height } or null. */ async fetchLargest(url) { for (const c of this.candidates(url)) { try { const r = await this.request(c, { type: 'blob', headers: {} }); const type = (r && (r.contentType || (r.blob && r.blob.type))) || ''; if (!r || r.status < 200 || r.status >= 300 || !r.blob || !r.blob.size) { this._log('debug', `candidate ${c}: HTTP ${r ? r.status : '?'}`); continue; } if (!/^image\/|^application\/pdf/i.test(type) && !/^(application\/octet-stream|binary\/octet-stream)?$/i.test(type)) { this._log('debug', `candidate ${c}: not an image (${type})`); continue; } let d = {}; try { d = ImageMaximizer.dimensions(await r.blob.slice(0, 262144).arrayBuffer()) || {}; } catch (e) { /* size unknown */ } if (!d.type && !/^image\/|pdf/i.test(type)) { this._log('debug', `candidate ${c}: unrecognised bytes`); continue; } this._log('debug', `candidate ${c}: ${d.width ? d.width + '×' + d.height : 'size unknown'}, ${r.blob.size} bytes`); return { blob: r.blob, url: c, width: d.width || 0, height: d.height || 0 }; } catch (e) { this._log('debug', `candidate ${c}: ${e.message}`); } } return null; } // ── helpers (static, pure) ──────────────────────────────────────────────────── /** True when the URL's path ends in an image extension. */ static looksLikeImage(url) { try { return /\.(jpe?g|png|gif|webp|avif|bmp|tiff?)$/i.test(new URL(url).pathname); } catch (e) { return false; } } /** og:image / twitter:image / image_src of an HTML page, resolved against its URL. */ static metaImage(html, base) { const tags = String(html).match(/<(?:meta|link)\b[^>]*>/gi) || []; const want = [/^og:image(:secure_url|:url)?$/i, /^twitter:image(:src)?$/i, /^image_src$/i]; for (const re of want) { for (const t of tags) { const key = (t.match(/\b(?:property|name|itemprop|rel)\s*=\s*["']([^"']+)["']/i) || [])[1]; const val = (t.match(/\b(?:content|href)\s*=\s*["']([^"']+)["']/i) || [])[1]; if (key && val && re.test(key.trim())) { try { return new URL(ImageMaximizer.decode(val), base).href; } catch (e) { /* bad URL */ } } } } return null; } static decode(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>'); } /** { type, width, height } from the first bytes of a JPEG, PNG, GIF or WebP; null otherwise. */ static dimensions(buf) { const b = new Uint8Array(buf); if (b.length > 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47) return { type: 'png', width: (b[16] << 24 | b[17] << 16 | b[18] << 8 | b[19]) >>> 0, height: (b[20] << 24 | b[21] << 16 | b[22] << 8 | b[23]) >>> 0 }; if (b.length > 10 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return { type: 'gif', width: b[6] | b[7] << 8, height: b[8] | b[9] << 8 }; if (b.length > 30 && b[0] === 0x52 && b[1] === 0x49 && b[8] === 0x57 && b[9] === 0x45) { const f = String.fromCharCode(b[12], b[13], b[14], b[15]); if (f === 'VP8X') return { type: 'webp', width: 1 + (b[24] | b[25] << 8 | b[26] << 16), height: 1 + (b[27] | b[28] << 8 | b[29] << 16) }; if (f === 'VP8 ') return { type: 'webp', width: (b[26] | b[27] << 8) & 0x3fff, height: (b[28] | b[29] << 8) & 0x3fff }; if (f === 'VP8L') return { type: 'webp', width: 1 + ((b[22] << 8 | b[21]) & 0x3fff), height: 1 + ((b[24] << 4 | b[23] >> 4) & 0x3fff) }; return { type: 'webp', width: 0, height: 0 }; } if (b.length > 4 && b[0] === 0xFF && b[1] === 0xD8) { let i = 2; while (i + 9 < b.length) { if (b[i] !== 0xFF) { i++; continue; } const m = b[i + 1]; if (m === 0xD8 || m === 0x01 || (m >= 0xD0 && m <= 0xD7) || m === 0xFF) { i++; continue; } const len = b[i + 2] << 8 | b[i + 3]; if (m >= 0xC0 && m <= 0xCF && m !== 0xC4 && m !== 0xC8 && m !== 0xCC) return { type: 'jpeg', width: b[i + 7] << 8 | b[i + 8], height: b[i + 5] << 8 | b[i + 6] }; i += 2 + len; } return { type: 'jpeg', width: 0, height: 0 }; } if (b.length > 4 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46) return { type: 'pdf', width: 0, height: 0 }; return null; } // ── request adapters ────────────────────────────────────────────────────────── /** The default: global fetch (Node 18+, a page; CORS applies in a page). */ static async fetchRequest(url, o) { const r = await fetch(url, { headers: (o && o.headers) || {} }); const out = { status: r.status, url: r.url || url, contentType: r.headers.get('content-type') || '' }; if (o && o.type === 'blob') out.blob = await r.blob(); else out.text = await r.text(); return out; } /** An adapter for a userscript manager's GM_xmlhttpRequest / GM.xmlHttpRequest (no CORS). */ static gmRequest(gmXhr, timeout) { return (url, o) => new Promise((resolve, reject) => { gmXhr({ method: 'GET', url, headers: (o && o.headers) || {}, responseType: o && o.type === 'blob' ? 'blob' : 'text', timeout: timeout || 60000, onload: r => { const ct = (String(r.responseHeaders || '').match(/^content-type:\s*([^\r\n]+)/im) || [])[1] || ''; const out = { status: r.status, url: r.finalUrl || url, contentType: ct.trim() }; if (o && o.type === 'blob') out.blob = r.response; else out.text = typeof r.responseText === 'string' ? r.responseText : String(r.response || ''); resolve(out); }, onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timed out')) }); }); } } // </ST-IMGMAX> // Some stores (notably Amazon) list their site logo alongside the real covers. // Those are tiny; real cover art is never this small. #242 const MIN_ART_PX = 200; // the image types the Cover Art Archive takes; anything else (WebP, AVIF) is skipped const CAA_MIME = { jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', pdf: 'application/pdf' }; const IMGMAX = new ImageMaximizer({ request: (url, o) => { const gx = (typeof GM !== 'undefined' && GM.xmlHttpRequest && GM.xmlHttpRequest.bind(GM)) || (typeof GM_xmlhttpRequest !== 'undefined' && GM_xmlhttpRequest) || null; return gx ? ImageMaximizer.gmRequest(gx, 60000)(url, o) : ImageMaximizer.fetchRequest(url, o); }, log: (level, msg) => asLog(level === 'warn' ? 'warn' : 'debug', 'Maximizer: ' + msg), }); if (mbuTestHooks()) (window.__asTest || (window.__asTest = {})).imgmax = IMGMAX; // test hook only (#717): specs stub its request // #566 test hook — the auto-repeat state machine, so its semantics can be // driven without provoking real archive.org failures. No behaviour change. if (mbuTestHooks()) window.__asAutoRepeat = { get schedule() { return arSchedule; }, get stop() { return arStop; }, get cancelPending() { return arCancelPending; }, get delayMs() { return arDelayMs; }, state: ov => ov && ov._ar, }; // a placeholder card (spinner + label) shown at the front of the gallery while a // source is fetched, so the (sometimes slow) provider fetch has visible in-grid progress. It's // replaced by the real cover on success, removed on failure. function addSourcingSlot(label) { const ph = { id: 'srcing-' + Math.random().toString(36).slice(2, 7), types: [], comment: '', order: -1, w: 0, h: 0, _new: true, _sourcing: true, _srcLabel: label, _origTypes: [], _origComment: '', _origOrder: -1 }; const rest = MODEL.slice().sort((a, b) => a.order - b.order); MODEL = [ph, ...rest]; MODEL.forEach((it, i) => it.order = i); render(); return ph.id; } function setSourcingLabel(id, text) { const it = MODEL.find(x => x.id === id); if (it) it._srcLabel = text; const el = document.querySelector(`.as-card[data-id="${CSS.escape(id)}"] .as-srcing-lbl`); if (el) el.textContent = text; } function dropSourcingSlot(id) { MODEL = MODEL.filter(it => it.id !== id); MODEL.forEach((it, i) => it.order = i); } // ── #250 Plugin API ───────────────────────────────────────────────────────── // Third-party userscripts register a custom cover provider — it shows as its own // "Import from <name>" button in the Source popover. The provider's run(ctx) // returns the images it fetched ITSELF (a Blob — so the provider's own // authenticated, e.g. CloudFlare-cleared, session does the fetch) OR plain URLs // (Art Station fetches those via GM xhr). Each cover keeps the provider's badge. const _customProviders = []; let _srcBtn = null; // the button that opened the Source popover (to re-open on late registration) const hostIcon = u => { try { return provIconUrl(new URL(u).hostname.replace(/^www\./, '')); } catch (e) { return ''; } }; // #250 (vzell) a provider may declare `match` — host string / array / RegExp / predicate — // so its "Import from …" button only appears when the release actually links that site, // and the matched external link(s) are handed to run() (ctx.link / ctx.links). Normalise // any of those forms to a (url)=>bool. No match → button always shows (legacy). function normMatch(m) { if (!m) return null; if (typeof m === 'function') return m; if (m instanceof RegExp) return u => { try { return m.test(u); } catch (e) { return false; } }; const needles = (Array.isArray(m) ? m : [m]).map(s => String(s).toLowerCase()).filter(Boolean); return u => { const lu = String(u || '').toLowerCase(); let h = ''; try { h = new URL(u).hostname.toLowerCase(); } catch (e) {} return needles.some(s => h === s || h.endsWith('.' + s) || lu.includes(s)); }; } function registerProvider(p) { if (!p || typeof p.run !== 'function' || !p.name) return false; const id = p.id || p.name; if (_customProviders.some(x => x.id === id)) return false; // de-dupe _customProviders.push({ id, name: String(p.name), icon: p.icon || '', run: p.run, match: normMatch(p.match) }); refreshSrcCount(); // #270 keep the button count right when a provider registers after the toolbar built if (document.querySelector('.as-src-pop') && _srcBtn) openSourcePop(_srcBtn); // reflect in an open popover return true; } // does a blob decode as a real image in OUR realm? (the true test of a usable cover) async function decodesImg(blob) { try { const bmp = await createImageBitmap(blob); bmp.close && bmp.close(); return true; } catch (e) { return false; } } async function providerBlob(it) { // one provider result → a Blob in OUR realm if (it == null) return null; if (it.dataUrl) { try { return await fetch(it.dataUrl).then(r => r.blob()); } catch (e) {} } // A provider fetches images in ITS OWN userscript sandbox, so the Blob/File it hands back // belongs to a different realm. On some managers (notably Firefox's Xray wrappers) those // bytes don't survive the boundary — the blob reports the right size but never decodes or // renders (#250, vzell's Jungleland). So whenever the provider also gave us a URL, we fetch // the image OURSELVES, in our own realm, and never touch the foreign object at all. This is // the robust default; the raw Blob is only used when no URL is available (e.g. a session- // locked image the provider could fetch but we can't). const directUrl = it.url || it.source || ''; if (directUrl) { try { const b = await gmFetch(directUrl); if (b && (b.type === 'application/pdf' || await decodesImg(b))) return b; } catch (e) {} } // No usable URL — launder the provider's own bytes into a fresh same-realm Blob, and verify // it actually decodes (a cross-realm copy can have the right length but unreadable content). const raw = it.blob || it.file; if (raw && typeof raw.arrayBuffer === 'function') { try { const buf = await raw.arrayBuffer(); const u8 = new Uint8Array(buf), copy = new Uint8Array(u8.length); copy.set(u8); // explicit same-realm byte copy const b = new Blob([copy], { type: raw.type || 'image/jpeg' }); if ((raw.type === 'application/pdf') || await decodesImg(b)) return b; // PDFs can't be decode-checked; trust them } catch (e) {} } return null; } function sourceFromProvider(prov, links) { const slot = addSourcingSlot(`Sourcing ${prov.name}…`); asLog.info(`Integration: sourcing covers from ${prov.name}`); const info = releaseInfo(); // #250 (vzell) ctx.link/links = the release's external link(s) this provider matched, // so run() can key off them instead of guessing the source page. ctx.url stays the MB page. const ctx = { mbid: MBID, entity: ENT.kind, artist: info.artists.map(a => a.name).join(', '), title: info.title, url: info.url, link: (links && links[0]) || '', links: links || [] }; let done = false; const finish = () => { done = true; dropSourcingSlot(slot); }; const killer = setTimeout(() => { if (done) return; finish(); render(); asLog.warn(`Integration: ${prov.name} timed out`); toast(`${prov.name} timed out`, 6000); }, 90000); Promise.resolve().then(() => prov.run(ctx)).then(async list => { if (done) return; clearTimeout(killer); const items = Array.isArray(list) ? list : (list ? [list] : []); const files = [], metas = []; for (const it of items) { let blob; try { blob = await providerBlob(it); } catch (e) { blob = null; asLog.debug(`Integration: ${prov.name} image fetch failed — ${(e && e.message) || e}`); } if (!blob) continue; const mime = (blob.type && blob.type.startsWith('image/')) ? blob.type : 'image/jpeg'; const ext = (mime.split('/')[1] || 'jpg').replace('jpeg', 'jpg'); const types = Array.isArray(it.types) ? it.types.filter(t => ALL_TYPES.includes(t)) : []; const srcUrl = it.source || it.url || ''; files.push(new File([blob], `prov-${Date.now()}-${files.length}.${ext}`, { type: mime })); metas.push({ types, comment: it.comment || '', provider: prov.name, provIcon: prov.icon || hostIcon(srcUrl), provUrl: srcUrl }); } finish(); if (files.length) { addFiles(files, metas); toast(`Added ${files.length} image${files.length > 1 ? 's' : ''} from ${prov.name} ✓`); } else { render(); toast(`${prov.name} returned no image`, 5000); } }).catch(e => { if (done) return; clearTimeout(killer); finish(); render(); toast(`${prov.name} failed — ${(e && e.message) || e}`, 8000); }); } // expose the registry on the page (and a CustomEvent fallback for managers that // isolate `window` from other userscripts). Either way is fine to call repeatedly. (function exposeApi() { // addImageUrl: bring an image in by URL — the bridge the reverse-image-search // picker companion (as_picker.user.js) calls when it lands on the MB page with // images the user picked elsewhere. Returns true so the companion knows AS is here. const api = { apiVersion: 1, registerProvider, addImageUrl: (url, prov) => { sourceFromUrl(url, prov); return true; } }; // no prov → sourceFromUrl derives name+favicon from the host try { (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).ArtStation = api; } catch (e) { try { window.ArtStation = api; } catch (x) {} } try { document.addEventListener('artstation:register-provider', e => { try { registerProvider(e.detail); } catch (x) {} }); } catch (e) {} // also accept picks via a DOM event (the companion may prefer this over the API object) try { document.addEventListener('artstation:add-image', e => { try { if (e.detail && e.detail.url) api.addImageUrl(e.detail.url, e.detail.prov); } catch (x) {} }); } catch (e) {} })(); // prov (optional) = { name, icon } the cover is being sourced from — passed by the // "Import from <provider>" buttons, else derived from the URL. Stamped on each new // cover so the gallery shows where it came from until commit (#249). function sourceFromUrl(rawUrl, prov, seed) { const url = (rawUrl || '').trim(); // test breadcrumb, same idea as window.__asAutoRepeat: an import leaves no // other trace a test can read (asLog is in-memory, nothing reaches console), // and "did one actually start" is the check #554 turns on. if (mbuTestHooks()) try { (window.__asTest || (window.__asTest = {})).lastSource = url; } catch (e) {} if (!/^https?:\/\//i.test(url)) { toast('Enter a provider or image URL (https://…)', 4000); return Promise.resolve(0); } // known provider → its name+icon; otherwise fall back to the URL's host so a // pasted link from anywhere (e.g. nugs.net) still gets a favicon badge. #249 if (!prov) { const pf = providerOf(url); if (pf) prov = { name: pf.name, icon: provIconUrl(pf.domain) }; else { try { const h = new URL(url).hostname.replace(/^www\./, ''); if (h) prov = { name: h, icon: provIconUrl(h) }; } catch (e) {} } } const who = (prov && prov.name) || 'provider'; asLog.info(`Source: ${(prov && prov.name) || 'URL'} — ${url}`); // name the source attempted (mirrors tooltip) const slot = addSourcingSlot(prov ? `Sourcing ${prov.name}…` : 'Sourcing…'); // #717 the maximizer reads the page, picks each image's largest version and downloads it return IMGMAX.fetchImages(url, { minSize: MIN_ART_PX }).then(async got => { const files = [], metas = []; for (const g of got) { let kind = ''; try { kind = (ImageMaximizer.dimensions(await g.blob.slice(0, 262144).arrayBuffer()) || {}).type || ''; } catch (e) {} const mime = CAA_MIME[kind]; if (!mime) { asLog.warn(`Source: ${who} — skipped ${g.url}: ${kind ? kind.toUpperCase() : 'not an image'} isn't a Cover Art Archive type (JPEG/PNG/GIF/PDF)`); continue; } const ext = kind === 'jpeg' ? 'jpg' : kind; files.push(new File([g.blob], `src-${Date.now()}-${files.length}.${ext}`, { type: mime })); const types = ((seed && seed.types) || g.types || []).filter(t => ALL_TYPES.includes(t)); const comment = (seed && seed.comment != null) ? seed.comment : (g.comment || ''); metas.push({ types, comment, provider: prov && prov.name, provIcon: prov && prov.icon, provUrl: g.page || url, provImageUrl: g.url, provMaxFrom: g.original !== g.url ? g.original : '' }); asLog.info(`Source: ${who} — ${g.width ? g.width + '×' + g.height : 'size unknown'} ${fmtBytes(g.blob.size)} from ${g.url}${g.original !== g.url ? ` (maximised from ${g.original})` : ''}`); } dropSourcingSlot(slot); const added = files.length ? await addFilesDeduped(files, metas) : 0; // #253 skip an image already staged if (added) toast(`Added ${added} image${added > 1 ? 's' : ''} from ${who} ✓`); // name the source in the log/toast else { render(); toast(files.length ? 'That image is already added' : `${who} returned no usable image`, 5000); } return added; }).catch(e => { dropSourcingSlot(slot); render(); toast('⚠ ' + (prov && prov.name ? prov.name + ': ' : '') + ((e && e.message) || e), 13000); // #286 say why (mirrors to the log) return 0; }); } // the art platforms we recognise on a release's external links, so the popover // can offer "Import from <provider>" for each. // domain = the provider's CANONICAL site (not the linked subdomain, e.g. // analogafrica.bandcamp.com → bandcamp.com) — favicons come from there. // ART_PROVIDERS is defined above the page check: the Mission Control adapter on the release page uses it too (#680) // Shared platform icons (#404) — stIcon(name, size) / stColor(name). Source of truth is // dev/ui/platform-icons.mjs; the block below is generated by dev/ui/sync-icons.mjs (pre-commit hook). // <ST-ICONS> — generated by dev/ui/sync-icons.mjs from dev/ui/platform-icons.mjs — DO NOT EDIT const ST_ICONS = {"musicbrainz":{"color":"#eb743b","svg":"<svg viewBox=\"0.75 0.75 28.5 28.5\" xmlns=\"http://www.w3.org/2000/svg\"><g transform=\"translate(1.5)\"><path d=\"m13 1-12 7v14l12 7z\" fill=\"#ba478f\"/><path d=\"m14 1 12 7v14l-12 7z\" fill=\"#eb743b\"/></g></svg>"},"discogs":{"color":"#333333","svg":"<svg viewBox=\"71 71 882 882\" xmlns=\"http://www.w3.org/2000/svg\"><g transform=\"translate(512 512) scale(0.86) translate(-512 -512)\"><circle cx=\"512\" cy=\"512\" r=\"496\" fill=\"#333\" stroke=\"#9a9a9a\" stroke-width=\"32\"/><path fill=\"#fff\" d=\"M439.84 511.58A72.58 72.58 0 0 1 512.41 439 72.54 72.54 0 0 1 585 511.58a72.56 72.56 0 0 1-72.57 72.56 72.56 72.56 0 0 1-72.57-72.56zm3.18 0A69.48 69.48 0 0 0 512.41 581a69.4 69.4 0 0 0 69.4-69.38 69.49 69.49 0 0 0-69.4-69.43A69.44 69.44 0 0 0 443 511.58zm69.42-11.44a11.43 11.43 0 1 0 11.47 11.45 11.45 11.45 0 0 0-11.48-11.45zm-131.08 11.43a130.68 130.68 0 0 0 40.3 94.43l24.68-26.69.33.3a94.59 94.59 0 0 1 113.08-149.95l17.51-31.95a130.23 130.23 0 0 0-64.82-17.22c-72.27.01-131.08 58.81-131.08 131.08zm225.73 0a94.6 94.6 0 0 1-138.64 83.79l-17.83 31.74a130.26 130.26 0 0 0 61.82 15.53c72.28 0 131.08-58.8 131.08-131.08a130.63 130.63 0 0 0-37.73-91.9L581 446.39a94.3 94.3 0 0 1 26.1 65.2zm-267.34 0a172.17 172.17 0 0 0 53.68 125l25-27.07a135.38 135.38 0 0 1-41.82-97.89c0-74.88 60.92-135.8 135.8-135.8a134.92 134.92 0 0 1 67.08 17.8l17.73-32.34a171.57 171.57 0 0 0-84.81-22.35c-95.19-.03-172.66 77.43-172.66 172.65zm308.49 0c0 74.88-60.92 135.8-135.8 135.8a135 135 0 0 1-64.14-16.14l-18.07 32.17a171.62 171.62 0 0 0 82.21 20.86c95.22 0 172.69-77.47 172.69-172.69a172.15 172.15 0 0 0-51-122.4l-25.12 27a135.35 135.35 0 0 1 39.23 95.4zm41.61 0c0 97.83-79.58 177.43-177.41 177.43a176.32 176.32 0 0 1-84.52-21.46l-18.18 32.36a213.21 213.21 0 0 0 102.7 26.23C630.74 726.11 727 629.87 727 511.57a213.87 213.87 0 0 0-64.38-153l-25.26 27.18a176.85 176.85 0 0 1 52.49 125.82zm-392 0A213.9 213.9 0 0 0 365 667.24L390.23 640A176.88 176.88 0 0 1 335 511.57c0-97.82 79.59-177.41 177.41-177.41a176.26 176.26 0 0 1 87.08 22.93l17.84-32.55A213.14 213.14 0 0 0 512.44 297c-118.3 0-214.54 96.28-214.54 214.57zm392.55-183-24.64 26.49a218.57 218.57 0 0 1 65.94 156.51c0 120.9-98.36 219.26-219.26 219.26a217.9 217.9 0 0 1-105-26.84l-18.24 32.47A255.43 255.43 0 0 0 512 768c141.39 0 256-114.64 256-256a255.23 255.23 0 0 0-77.55-183.41zm-397.27 183c0-120.9 98.36-219.26 219.26-219.26a217.84 217.84 0 0 1 107.19 28.09L637 288.65A254.46 254.46 0 0 0 516.12 256H512c-140.54.22-254.42 113.26-256 253.5v2.5a255.69 255.69 0 0 0 80.51 186.08l25.31-27.36a218.61 218.61 0 0 1-68.64-159.15z\"/></g></svg>"},"spotify":{"color":"#1DB954","svg":"<svg viewBox=\"1.25 1.375 21.25 21.25\" fill=\"#1DB954\"><path transform=\"translate(12 12) scale(.875) translate(-12 -12)\" d=\"M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.42 1.56-.299.421-1.02.599-1.559.3z\"/></svg>"},"apple":{"color":"#FA243C","svg":"<svg viewBox=\"1.625 1.25 20 20\" fill=\"#FA243C\"><path d=\"M17.05 12.04c-.03-2.5 2.04-3.7 2.13-3.76-1.16-1.7-2.97-1.93-3.61-1.96-1.54-.16-3 .9-3.78.9-.78 0-1.97-.88-3.24-.86-1.67.03-3.21.97-4.07 2.46-1.73 3.01-.44 7.47 1.24 9.92.82 1.2 1.8 2.54 3.08 2.49 1.24-.05 1.71-.8 3.21-.8 1.5 0 1.92.8 3.23.77 1.33-.02 2.18-1.22 3-2.42.94-1.39 1.33-2.73 1.35-2.8-.03-.01-2.59-.99-2.62-3.93zM14.6 4.59c.68-.83 1.14-1.97 1.01-3.11-.98.04-2.17.65-2.87 1.47-.63.73-1.18 1.9-1.03 3.02 1.09.08 2.21-.55 2.89-1.38z\"/></svg>"},"deezer":{"color":"#A238FF","svg":"<svg viewBox=\"3 3 18 18\"><path transform=\"translate(12 12) scale(.74) translate(-12 -12)\" d=\"M4 2h6v2h-6zM14 2h6v2h-6zM2 4h20v2h-20zM0 6h24v2h-24zM0 8h24v2h-24zM0 10h24v2h-24zM2 12h20v2h-20zM4 14h16v2h-16zM6 16h12v2h-12zM8 18h8v2h-8zM10 20h4v2h-4z\" fill=\"#A238FF\"/></svg>"},"tidal":{"color":"#000000","svg":"<svg viewBox=\"3 3 18 18\"><path d=\"M6 6l3 3-3 3-3-3zM12 6l3 3-3 3-3-3zM18 6l3 3-3 3-3-3zM12 12l3 3-3 3-3-3z\" style=\"fill:var(--mbu-text,currentColor)\"/></svg>"},"qobuz":{"color":"#0070ef","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#0070ef\"/><circle cx=\"12\" cy=\"12\" r=\"5\" fill=\"none\" stroke=\"#fff\" stroke-width=\"2.2\"/><path d=\"M14.5 14.5 19 19\" stroke=\"#fff\" stroke-width=\"2.2\" stroke-linecap=\"round\"/></svg>"},"beatport":{"color":"#01FF95","svg":"<svg viewBox=\"0.75 0.75 22.5 22.5\"><circle cx=\"12\" cy=\"12\" r=\"11\" fill=\"#000\"/><g transform=\"translate(12 12) scale(0.84) translate(-12 -12)\" fill=\"none\" stroke=\"#01FF95\" stroke-width=\"2.5\"><path d=\"M10.9 3V8.3c0 1.2-.4 1.9-1.1 2.6L5.6 15.1\"/><circle cx=\"13.9\" cy=\"15.8\" r=\"4.05\" stroke-width=\"2.35\"/></g></svg>"},"bandcamp":{"color":"#629AA9","svg":"<svg viewBox=\"2.25 2.25 19.5 19.5\" fill=\"#629AA9\"><path transform=\"translate(12 12) scale(.8) translate(-12 -12)\" d=\"M0 18.75l7.437-13.5H24l-7.438 13.5z\"/></svg>"},"volumo":{"color":"#7c4dff","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#7c4dff\"/><path d=\"M7 8h2.2l2.8 6 2.8-6H17l-4 9h-2z\" fill=\"#fff\"/></svg>"},"hdtracks":{"color":"#e63329","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#e63329\"/><path d=\"M5 7.5h1.7v3.1h2.6V7.5H11v8H9.3v-3.2H6.7v3.2H5zm7.2 0h2.9c2 0 3.4 1.6 3.4 4s-1.4 4-3.4 4h-2.9zm1.7 1.5v5h1.1c1.1 0 1.8-1 1.8-2.5s-.7-2.5-1.8-2.5z\" fill=\"#fff\"/></svg>"},"soundcloud":{"color":"#ff5500","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#ff5500\"/><g fill=\"#fff\"><rect x=\"6\" y=\"12\" width=\"1.4\" height=\"4\" rx=\".6\"/><rect x=\"8.5\" y=\"10\" width=\"1.4\" height=\"6\" rx=\".6\"/><rect x=\"11\" y=\"8.5\" width=\"1.4\" height=\"7.5\" rx=\".6\"/><rect x=\"13.5\" y=\"10.5\" width=\"1.4\" height=\"5.5\" rx=\".6\"/><rect x=\"16\" y=\"11.5\" width=\"1.4\" height=\"4.5\" rx=\".6\"/></g></svg>"},"audiomack":{"color":"#FFA200","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#FFA200\"/><path d=\"M5 13.5l2-2 1.6 2.4 2.2-5.4 2.4 6.6 2.2-4 1.6 2.4H19\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.6\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/></svg>"},"sevendigital":{"color":"#07606E","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#07606E\"/><path d=\"M7.8 6.8h8.4v1.9l-4.5 8.9H9.4l4.4-8.7h-6z\" fill=\"#fff\"/></svg>"},"ytmusic":{"color":"#FF0000","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#FF0000\"/><circle cx=\"12\" cy=\"12\" r=\"5.6\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.4\"/><path d=\"M10.4 9.5v5l4.2-2.5z\" fill=\"#fff\"/></svg>"},"amazonmusic":{"color":"#25D1DA","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#25D1DA\"/><path d=\"M5.8 11.2c3.5 3.2 8.9 3.5 12.4.9\" fill=\"none\" stroke=\"#0F1111\" stroke-width=\"1.9\" stroke-linecap=\"round\"/><path d=\"M15.5 10.7l3 1.3-.9 3.1\" fill=\"none\" stroke=\"#0F1111\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>"},"soundexchange":{"color":"#6f42c1","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#6f42c1\"/><path d=\"M6.5 12h1.3l1-3 1.6 6 1.6-9 1.6 12 1.4-6h1.5\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.4\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/></svg>"},"globe":{"color":"#6f7d75","svg":"<svg viewBox=\"2 2 20 20\" fill=\"none\" stroke=\"#6f7d75\" stroke-width=\"1.8\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18\"/></svg>"}}; function stIcon(name, size) { var i = ST_ICONS[name]; if (!i) return ''; size = size || 16; return i.svg.replace(/<svg\b([^>]*)>/, function (m, a) { a = a.replace(/\s(?:width|height)="[^"]*"/g, ''); var ns = /\bxmlns=/.test(a) ? '' : ' xmlns="http://www.w3.org/2000/svg"'; return '<svg' + a + ns + ' width="' + size + '" height="' + size + '">'; }); } function stColor(name) { return (ST_ICONS[name] && ST_ICONS[name].color) || ''; } // </ST-ICONS> // Provider badge icon for a domain. Known music platforms use the SHARED inline icon // (as a data-URI, so it drops straight into the existing <img src>) — consistent with // Platform Check / ISRC Scout / Credit Hoarder. Everything else falls back to Google's // favicon service (a real icon for any domain, unlike a 404-prone /favicon.ico guess). const ICON_DOMAIN = { 'music.apple.com': 'apple', 'itunes.apple.com': 'apple', 'apple.com': 'apple', 'deezer.com': 'deezer', 'spotify.com': 'spotify', 'tidal.com': 'tidal', 'qobuz.com': 'qobuz', 'bandcamp.com': 'bandcamp', 'discogs.com': 'discogs', 'beatport.com': 'beatport', 'volumo.com': 'volumo', 'hdtracks.com': 'hdtracks' }; // our theme's text colour, resolved — an icon drawn in it (Tidal's) has no CSS inside an <img> const provIconInk = () => { try { const p = document.createElement('span'); p.style.color = 'var(--mbu-text, #222)'; document.body.appendChild(p); const c = getComputedStyle(p).color; p.remove(); return c || '#222'; } catch (e) { return '#222'; } }; const provIconUrl = d => { const host = String(d || '').toLowerCase().replace(/^www\./, ''); // an icon drawn in the text colour (Tidal's) has no text colour inside an <img>: give it the page's for (const dom in ICON_DOMAIN) if (host === dom || host.endsWith('.' + dom)) return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(stIcon(ICON_DOMAIN[dom], 32).replace(/var\(--mbu-text,currentColor\)|currentColor/g, provIconInk())); return `https://www.google.com/s2/favicons?sz=64&domain=${d}`; }; function providerOf(url) { let h = ''; try { h = new URL(url).hostname; } catch (e) { return null; } return ART_PROVIDERS.find(x => x.re.test(h)) || null; } // ALL of the release/event's external link URLs (one WS2 fetch, cached) — used both by // the recognised-provider list and by #250 custom-provider link matching. // #530 (majkinetor): "Cover art URLs not available randomly" — the popover said // "No supported platforms linked on this release" while MB's own tab offered // "Import from Discogs" at the same moment, so the link was certainly there. // The old code cached [] whenever the request FAILED (503, offline, throw), // and [] is truthy, so that "this release has no links" verdict stuck for the // rest of the page. MusicBrainz 503s often enough that one unlucky request // silently disabled sourcing — hence "switching back & forth usually fixes it" // (a reload retries) "but not always" (it can fail again). // // Now: transient failures retry with backoff, a failure is NEVER cached as an // answer, and concurrent callers share one in-flight request instead of // racing each other into MB's rate limiter. let _urlRels = null, _urlRelsInflight = null; async function releaseUrlsRaw() { for (let attempt = 1; ; attempt++) { let r = null; try { // through the shared request gate (#633): its slot, and a hold after a 429/503 that the retry waits out await mbnSlot({ label: `/ws/2/${ENT.kind}/${MBID}?inc=url-rels`, log: (lv, m) => asLog[lv === 'warn' ? 'warn' : 'info'](m) }); r = await fetch(`${location.origin}/ws/2/${ENT.kind}/${MBID}?inc=url-rels&fmt=json`, { headers: { Accept: 'application/json' } }); if ((await mbnAnswer(r.status, n => r.headers.get(n), { log: (lv, m) => asLog[lv === 'warn' ? 'warn' : 'info'](m) })).throttled && attempt < 4) { asLog.debug(`Links: got ${r.status}, retrying (${attempt}/3) after the hold`); continue; } } catch (e) { if (attempt >= 4) { asLog.warn(`Links: could not reach MusicBrainz (${e.message}) — sourcing left unknown, not "no links"`); return null; } } if (r && r.ok) { const j = await r.json(); const urls = [...new Set(((j && j.relations) || []).map(rel => rel.url && rel.url.resource).filter(Boolean))]; // #530 (majkinetor): "Please make detailed log about this." Log what MB // actually returned, so a report never again comes down to guessing // whether the fetch failed, returned nothing, or returned links whose // domain simply isn't a recognised art provider. asLog.info(`Links: MusicBrainz returned ${((j && j.relations) || []).length} relationship(s), ${urls.length} URL(s)` + (attempt > 1 ? ` (after ${attempt} attempts)` : '')); urls.forEach(u => { const pr = providerOf(u); asLog.debug(` link ${u} → ${pr ? pr.name : 'no art provider for this domain'}`); }); return urls; } // 404 means the entity genuinely isn't there; anything else transient is worth a retry if (r && r.status === 404) return []; if (attempt >= 4) { asLog.warn(`Links: MusicBrainz returned ${r ? r.status : 'no response'} — sourcing left unknown, not "no links"`); return null; } const wait = Number(r && r.headers.get('Retry-After')) * 1000 || (500 * attempt + Math.floor(Math.random() * 400)); asLog.debug(`Links: got ${r ? r.status : 'network error'}, retrying (${attempt}/3) in ${Math.round(wait)}ms`); await new Promise(res => setTimeout(res, Math.min(wait, 8000))); } } // #530 follow-up (majkinetor: "it actually showed now, but it took 10+ // seconds"). MusicBrainz's WS2 is intermittently very slow — 28s and 43s // responses show up in his logs — and the links are ALREADY on the page: // MB renders them in `ul.external_links`, which measures at 0ms against ~200ms // for the API on a good day and far worse on a bad one. So read the page // first and treat the network as enrichment rather than the source of truth. // // Careful: a release page renders MORE than one of these. The sidebar has // "External links" (the release's own) AND "Release group external links", // and both use `ul.external_links`. Scooping every block attributed the // release group's Discogs *master* to the release and offered "Import from // Discogs" on a release that has no Discogs link at all (majkinetor, #530: // "Discogs is mistakenly added here"). Only the block under the entity's own // heading counts — the prefixed ones belong to a different entity. #722 Which list // is the entity's own is told by structure, not by the heading's English text // (mbpReleaseLinkList; an event page has one list, its own). When that can't be // told, the page gives nothing and the links come from WS2. function pageLinks() { try { const own = mbpReleaseLinkList(); const skipped = [...document.querySelectorAll('ul.external_links')].filter(ul => ul !== own).flatMap(ul => [...ul.querySelectorAll('li a[href]')].map(a => a.href)); if (skipped.length) asLog.debug(`Links: ignoring ${skipped.length} link(s) of another entity, or of a list not told apart (${skipped.join(', ')})`); return [...new Set(mbpReleaseLinks())]; } catch (e) { return []; } } async function releaseUrls() { if (_urlRels) return _urlRels; const dom = pageLinks(); if (dom.length) { _urlRels = dom; asLog.info(`Links: ${dom.length} link(s) read from the page (no request needed)`); dom.forEach(u => { const pr = providerOf(u); asLog.debug(` link ${u} → ${pr ? pr.name : 'no art provider for this domain'}`); }); // still ask MB in the background — the sidebar can omit links, and the // answer refreshes the count without anyone waiting on it. if (!_urlRelsInflight) { _urlRelsInflight = releaseUrlsRaw().finally(() => { _urlRelsInflight = null; }); _urlRelsInflight.then(extra => { if (!extra) return; const merged = [...new Set([..._urlRels, ...extra])]; if (merged.length !== _urlRels.length) { asLog.info(`Links: MusicBrainz added ${merged.length - _urlRels.length} link(s) the page did not list`); _urlRels = merged; _provLinks = null; refreshSrcCount(); } }).catch(() => {}); } return _urlRels; } if (!_urlRelsInflight) { asLog.debug(`Links: reading ${ENT.kind} relationships from /ws/2/${ENT.kind}/${MBID}?inc=url-rels`); _urlRelsInflight = releaseUrlsRaw().finally(() => { _urlRelsInflight = null; }); } const got = await _urlRelsInflight; if (got) _urlRels = got; // only a real answer is remembered return got || []; } // Did we actually manage to read the links? The popover needs to tell "MB says // none" apart from "we could not ask", which is the whole point of #530. function urlRelsKnown() { return _urlRels !== null; } // the release/event's external links → the recognised art providers, deduped async function artProviderLinks() { const urls = await releaseUrls(); // One entry per provider. Reading links off the page (see releaseUrls) picks // up sibling links the API did not return — a Discogs *master* alongside the // *release*, say — and two identically-labelled "Import from Discogs" // buttons is worse than one. Prefer the release-level URL when both exist. const byProv = new Map(); for (const u of urls) { const prov = providerOf(u); if (!prov) continue; const cur = byProv.get(prov.name); const isMaster = /\/master\//i.test(u); if (!cur || (/\/master\//i.test(cur.url) && !isMaster)) byProv.set(prov.name, { name: prov.name, url: u, icon: provIconUrl(prov.domain) }); } const out = [...byProv.values()]; // Say which of the three cases this is, every time — "0 providers" reads // very differently depending on whether the links could be read at all. if (!urlRelsKnown()) asLog.warn(`Links: could not read this ${ENT.kind}'s links — sourcing unavailable (NOT "no links")`); else if (!urls.length) asLog.info(`Links: this ${ENT.kind} has no external links at all`); else asLog.info(`Links: ${out.length} art provider(s) matched from ${urls.length} link(s)` + (out.length ? ' — ' + out.map(x => x.name).join(', ') : ' — none of the linked domains is a supported provider')); return out; } // #250 (vzell) custom providers whose declared `match` hits a link on THIS release, // each with the matched URL(s). A provider that declared no match is always offered. async function matchedCustomProviders() { const links = await releaseUrls(); return _customProviders .map(p => ({ p, urls: p.match ? links.filter(u => { try { return p.match(u); } catch (e) { return false; } }) : [] })) .filter(x => !x.p.match || x.urls.length); } let _provLinks = null; // fetched once per page; reused by the button count + the popover function getProvLinks() { return _provLinks ? Promise.resolve(_provLinks) : artProviderLinks().then(l => (_provLinks = l)); } // the count on the Source button = built-in linked platforms + matched custom providers // (#270 — it was only counting the platforms, so "URL (1)" showed beside a 3-button popover). // Re-run on render and whenever a provider registers (which may happen after the toolbar built). function refreshSrcCount() { const src = document.querySelector('.as-src'); const n = src && src.querySelector('.as-src-n'); if (!n) return; Promise.all([getProvLinks(), matchedCustomProviders()]).then(([l, m]) => { const total = l.length + m.length; n.textContent = total ? ` (${total})` : ''; // #558: the tooltip has to advertise the right-click shortcut — an invisible // one nobody is told about is one nobody uses. src.title = total ? `Source ${ENT.noun} — ${total} source${total > 1 ? 's' : ''} (linked platform${l.length === 1 && !m.length ? '' : 's'}, registered providers, or any URL)` + `\nRight-click: import from all ${total} at once, without opening this panel` + `\nMiddle-click: import from all and keep only the best cover (highest resolution, then smallest file)` : `Source ${ENT.noun} from a linked platform, a registered provider, or any URL`; }).catch(() => {}); } // #558 (majkinetor): "Currently I have to open popup and click import all (or // provider). I almost always use import all so it could be run faster with // right click on the button." // // "All" means every source the popover offers — the release's linked platforms // AND the custom providers registered by other scripts (#250) whose match hits // this release. That is also what the button's own "(N)" count has always meant // (refreshSrcCount sums both), whereas the popover's "Import all N sources" // button counted only the linked platforms and quietly skipped the providers. // Both go through here now, so the count, the button and the right click agree. function allSources() { return Promise.all([getProvLinks(), matchedCustomProviders()]) .then(([provs, custom]) => ({ provs: provs || [], custom: custom || [], total: (provs || []).length + (custom || []).length })); } function sourceFromAll(all) { all.provs.forEach(p => sourceFromUrl(p.url, { name: p.name, icon: p.icon })); // one sourcing slot per provider all.custom.forEach(x => sourceFromProvider(x.p, x.urls)); asLog.info(`Sourcing from all ${all.total} source(s): ${[...all.provs.map(p => p.name), ...all.custom.map(x => x.p.name)].join(', ')}`); } // right-click the toolbar's URL button — import from everything without opening // the popover. With nothing to import there is nothing to shortcut, so fall back // to opening the popover: "By URL" is still in there, and silently doing nothing // to a deliberate click reads as a broken button. #558 function sourceAllFromButton(btn) { allSources().then(all => { if (!all.total) { toast(`No sources found on this ${ENT.kind} — opening the panel`, 3500); openSourcePop(btn); return; } toast(`⬇ Importing from ${all.total} source${all.total > 1 ? 's' : ''}…`); sourceFromAll(all); }).catch(e => { asLog.warn('right-click import all failed: ' + (e && e.message)); openSourcePop(btn); }); } // #667 (majkinetor): "Implement middle click on URL button so that it automatically chooses // the best image - highest resolution lowest size. Only one cover is added as a result." // Import from all (#558), wait until every sourcing slot is gone and the new covers have // their dimensions, then pick max pixels (ties → fewest bytes) and drop the other imported covers outright. // The pick carries _bestOf so its edit note says Art Station chose it. // each source's first image to arrive: its front, as the sources give no types (#702) function frontsOf(items) { const firstOf = new Map(); items.slice().sort((a, b) => (a._seq || 0) - (b._seq || 0)).forEach(x => { const k = x._provUrl || x._provider || x.id; if (!firstOf.has(k)) firstOf.set(k, x); }); return [...firstOf.values()]; } if (mbuTestHooks()) (window.__asTest || (window.__asTest = {})).best = { frontsOf, pickBest: c => pickBest(c) }; function pickBest(cands) { return cands.slice().sort((a, b) => (b.w * b.h - a.w * a.h) || ((a.bytes || Infinity) - (b.bytes || Infinity)))[0]; } function sourceBestFromButton(btn) { allSources().then(all => { if (!all.total) { toast(`No sources found on this ${ENT.kind} — opening the panel`, 3500); openSourcePop(btn); return; } sourceBest(all); }).catch(e => { asLog.warn('middle-click best cover failed: ' + (e && e.message)); openSourcePop(btn); }); } function sourceBest(all, onDone) { // onDone(best or null), #680 Mission Control's frame onDone = onDone || (() => {}); { const before = new Set(MODEL.map(x => x.id)); toast(`⬇ Importing from ${all.total} source${all.total > 1 ? 's' : ''}, keeping the best…`); asLog.info(`Best cover: importing from ${all.total} source(s), will keep the best one`); sourceFromAll(all); const t0 = Date.now(); let idleSince = 0; const tick = setInterval(() => { const busy = MODEL.some(x => x._sourcing); const fresh = MODEL.filter(x => !before.has(x.id) && x._new && !x._sourcing && !x._del && !x._pdf); const sized = fresh.every(x => x.w > 0); if (busy || !sized) { idleSince = 0; if (Date.now() - t0 < 240000) return; } else if (!idleSince) { idleSince = Date.now(); return; } // one more beat for late arrivals else if (Date.now() - idleSince < 1000) return; clearInterval(tick); // majkinetor: "discogs doesn't have types, 1st one to come is front". A source's other // images are its back, inserts, a tracklist scan…, so only each source's first competes. const cands = frontsOf(fresh.filter(x => x.w > 0)); const notFront = fresh.filter(x => x.w > 0 && !cands.includes(x)); if (notFront.length) asLog.info(`Best cover: only each source's first image counts as its front; left out ${notFront.length} more (${notFront.map(x => `${x._provider || x.id} ${x.w}×${x.h}`).join(', ')})`); asLog.debug(`Best cover: candidates ${cands.map(x => `${x._provider || x.id} ${x.w}×${x.h} ${x.bytes || '?'}b`).join(', ') || '(none)'}${busy ? ' (timed out while still sourcing)' : ''}`); if (!cands.length) { toast('No cover could be imported', 4000); onDone(null); return; } const best = pickBest(cands); best._bestOf = cands.length; best._found = cands.map(x => ({ provider: x._provider || '', w: x.w || 0, h: x.h || 0, bytes: x.bytes || 0, url: x._provImageUrl || '' })); // #751 asLog.info(`Best cover: ${best._provider || best.id} ${best.w}×${best.h} (${best.bytes ? fmtBytes(best.bytes) : '?'}) of ${cands.length}`); // the losers are removed outright, not marked for removal — they never show up at all const drop = MODEL.filter(x => !before.has(x.id) && x !== best && !x._sourcing); drop.forEach(x => { try { if (x._file) URL.revokeObjectURL(x._file); } catch (e) {} }); MODEL = MODEL.filter(x => !drop.includes(x)); MODEL.forEach((x, i) => x.order = i); asLog.info(`Best cover: removed the other ${drop.length} imported ${drop.length === 1 ? ITEM : ITEMS}${drop.length ? ` (${drop.map(x => `${x._provider || x.id} ${x.w}×${x.h}`).join(', ')})` : ''}`); toast(cands.length > 1 ? `Kept the best of ${cands.length}: ${best._provider || ''} ${best.w}×${best.h}` : `Only one cover found — ${best._provider || 'kept'}`); render(); onDone(best); }, 500); } } // #680: Mission Control opened this page with ?mc_source=[links] — import from those // links and keep the best cover, as a middle-click on the URL button does function mcSeeded() { let urls = []; try { urls = JSON.parse(new URLSearchParams(location.search).get('mc_source') || '[]'); } catch (e) { asLog.warn('Mission Control: unreadable mc_source'); } urls = (Array.isArray(urls) ? urls : []).filter(u => providerOf(u)); if (!urls.length) return; const provs = urls.map(u => { const p = providerOf(u); let host = ''; try { host = new URL(u).hostname; } catch (e) {} return { name: p.name, url: u, icon: provIconUrl(host) }; }); asLog.info('Mission Control: sourcing the best cover from ' + provs.map(p => p.name).join(', ')); toast(`⬇ Mission Control: importing from ${provs.length} source${provs.length > 1 ? 's' : ''}, keeping the best…`); sourceBest({ provs, custom: [], total: provs.length }, best => { if (!MC_FRAME) return; // the preview goes to Mission Control as a data URL: a blob URL dies with this frame // #751: found, every cover the search kept for a moment: its platform, size and (when the platform gives it) address const out = best ? { provider: best._provider || '', w: best.w, h: best.h, bytes: best.bytes || 0, of: best._bestOf || 1, found: best._found || [] } : null; _mcBest = best; // the covers already there and typed Front, measured (the card compares the best one with them) const fronts = () => MODEL.filter(x => !x._new && !x._del && (x.types || []).includes('Front')); const measured = new Promise(res => { const t0 = Date.now(); const iv = setInterval(() => { if (fronts().every(x => x.w > 0 || x._noDim || x._pdf) || Date.now() - t0 > 15000) { clearInterval(iv); res(); } }, 300); }); const post = () => measured.then(() => { // full: the archive's own image, for Mission Control's full-screen view const existing = fronts().map(x => ({ id: x.id, w: x.w || 0, h: x.h || 0, bytes: x.bytes || 0, thumb: x.id ? thumb(x.id, 250) : '', full: x._img || (x.id ? thumb(x.id, 1200) : '') })); asLog.info(`Mission Control: existing front cover(s): ${existing.map(x => `#${x.id} ${x.w ? x.w + '×' + x.h : 'size unknown (not processed yet?)'}`).join(', ') || 'none'}`); mcFramePost({ type: 'best', best: out, existing }); }); if (!best || !best._file) { post(); return; } // the preview (500 px) and the full image for Mission Control's full-screen view: the file // itself up to 12 MB, or 2400 px across above that const scaled = (bmp, n, q) => { const k = Math.min(1, n / Math.max(bmp.width, bmp.height)), c = document.createElement('canvas'); c.width = Math.round(bmp.width * k); c.height = Math.round(bmp.height * k); c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height); return c.toDataURL('image/jpeg', q); }; const asData = b => new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => rej(fr.error); fr.readAsDataURL(b); }); fetch(best._file).then(r => r.blob()).then(async b => { const bmp = await createImageBitmap(b); out.thumb = scaled(bmp, 500, 0.85); out.full = b.size <= 12 * 1048576 ? await asData(b) : scaled(bmp, 2400, 0.92); }).catch(e => asLog.warn('Mission Control: preview failed: ' + (e && e.message))).then(post); }); } // #680: Mission Control's hidden frame (…/cover-art?mc_source=[…]&mc_frame=<token>): the best // cover goes back to the release page as { mcAs, token, type: 'best' }; on { type: 'enter' } // the frame runs Enter edit and answers { type: 'entered', ok, errs, total }. const MC_FRAME = window.top !== window ? new URLSearchParams(location.search).get('mc_frame') : null; function mcFramePost(o) { try { window.parent.postMessage(Object.assign({ mcAs: 1, token: MC_FRAME }, o), location.origin); } catch (e) { asLog.warn('Mission Control: post failed: ' + (e && e.message)); } } if (MC_FRAME) window.addEventListener('message', e => { const d = e.data; if (e.origin !== location.origin || !d || d.mcAs !== 1 || d.token !== MC_FRAME || d.type !== 'enter') return; asLog.info('Mission Control: Enter edit' + (d.note ? ' with its note' : '')); // #741: the frame's own note plus Mission Control's line, once: a second Execute on the same frame // appended the line again if (_mcNoteBase === null) _mcNoteBase = _seedNote; _seedNote = [_mcNoteBase, d.note].filter(Boolean).join('\n\n'); // replace: the one existing front is smaller, so it goes and the best becomes the front if (d.replace && _mcBest) { const old = MODEL.filter(x => !x._new && !x._del && (x.types || []).includes('Front')); if (old.length === 1) { old[0]._del = true; old[0]._sel = false; asLog.info(`Mission Control: removing the smaller front #${old[0].id} ${old[0].w}×${old[0].h}`); } if (!(_mcBest.types || []).includes('Front')) _mcBest.types = ['Front'].concat(_mcBest.types || []); } _mcCommitDone = r => mcFramePost(Object.assign({ type: 'entered' }, r)); enterEdit(true); }); let _mcCommitDone = null, _mcBest = null, _mcNoteBase = null; function openSourcePop(btn) { _srcBtn = btn; // #250 remembered so a late provider registration can re-open this popover document.querySelectorAll('.as-pop').forEach(p => p.remove()); const pop = document.createElement('div'); pop.className = 'as-pop as-src-pop'; pop.innerHTML = `<div class="as-pop-h as-src-hd"><span class="as-src-htxt">Source ${ENT.noun}</span>` + `<span class="as-src-urlwrap"><button class="as-src-url-btn" type="button" title="Import by URL — paste a provider page or direct image URL">By URL</button>` + `<input class="as-src-url-inp" type="text" placeholder="https://… provider page or image URL" autocomplete="off" spellcheck="false"></span></div>` + `<div class="as-src-prov as-pop-note">Looking for linked platforms…</div>` + `<div class="as-src-custom"></div>` + `<div class="as-src-allwrap"></div>`; // #558: "Import all" now spans platforms AND registered providers, so it lives below both document.body.appendChild(pop); placePop(pop, btn.getBoundingClientRect()); // #250 custom providers registered by other userscripts — one stacked "Import from …" // button each, but only for providers whose declared `match` hits a link on this release. const cbox = pop.querySelector('.as-src-custom'); if (cbox && _customProviders.length) { matchedCustomProviders().then(matched => { if (!cbox.isConnected || !matched.length) return; cbox.innerHTML = matched.map((x, i) => `<button class="as-btn as-src-prov-b" data-ci="${i}">${x.p.icon ? `<img class="as-src-ic" src="${esc(x.p.icon)}" alt="">` : '🧩 '}⬇ Import from ${esc(x.p.name)}</button>`).join(''); cbox.querySelectorAll('.as-src-prov-b').forEach(b => b.onclick = () => { const x = matched[+b.dataset.ci]; pop.remove(); sourceFromProvider(x.p, x.urls); }); cbox.querySelectorAll('.as-src-ic').forEach(img => img.onerror = () => { img.style.visibility = 'hidden'; }); placePop(pop, btn.getBoundingClientRect()); }); } // #507: "By URL" is a title-bar toggle that unrolls into an input filling the title // (apollo/isrc_scout-style unroll — see #180), replacing the old always-visible // "or paste any URL" row + Fetch button. Pasting a URL fetches immediately; no // button needed either way. const srcHd = pop.querySelector('.as-src-hd'); const urlBtn = pop.querySelector('.as-src-url-btn'); const urlInp = pop.querySelector('.as-src-url-inp'); const openUrlAdd = () => { srcHd.classList.add('open'); setTimeout(() => urlInp.focus(), 0); }; const closeUrlAdd = () => { srcHd.classList.remove('open'); urlInp.value = ''; }; const go = () => { const v = urlInp.value; closeUrlAdd(); pop.remove(); sourceFromUrl(v); }; urlBtn.onclick = () => srcHd.classList.contains('open') ? closeUrlAdd() : openUrlAdd(); urlInp.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); go(); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeUrlAdd(); } }; // paste a URL → fetch immediately (no need to press Enter). Read after the paste // lands; only auto-go when the whole field is a URL (typing-then-pasting won't fire). urlInp.onpaste = () => setTimeout(() => { if (/^https?:\/\//i.test(urlInp.value.trim())) go(); }, 0); // populate "Import from <provider>" buttons from the release's linked platforms // populate "Import from <provider>" buttons from the release's linked platforms getProvLinks().then(provs => { const box = pop.querySelector('.as-src-prov'); if (!box) return; if (!provs.length) { // #530: never claim "no platforms" when the lookup itself failed — that // is what made a transient MusicBrainz 503 look like a release with no // links, with no way to tell and no way to retry. if (!urlRelsKnown()) { box.classList.remove('as-pop-note'); box.innerHTML = `<div class="as-pop-note">Could not read this ${ENT.kind}'s links from MusicBrainz.</div>` + `<button class="as-btn as-src-retry">↻ Retry</button>`; const rb = box.querySelector('.as-src-retry'); if (rb) rb.onclick = () => { _urlRels = null; _provLinks = null; pop.remove(); openSourcePop(btn); }; } else { box.textContent = `No supported platforms linked on this ${ENT.kind}.`; } placePop(pop, btn.getBoundingClientRect()); return; } box.classList.remove('as-pop-note'); box.innerHTML = provs.map((p, i) => `<button class="as-btn as-src-prov-b" data-i="${i}"><img class="as-src-ic" src="${esc(p.icon)}" alt="">⬇ Import from ${esc(p.name)}</button>`).join(''); box.querySelectorAll('.as-src-prov-b').forEach(b => b.onclick = () => { const p = provs[+b.dataset.i]; pop.remove(); sourceFromUrl(p.url, { name: p.name, icon: p.icon }); }); box.querySelectorAll('.as-src-ic').forEach(img => img.onerror = () => { img.style.visibility = 'hidden'; }); // hide a missing favicon (no inline handler — CSP) placePop(pop, btn.getBoundingClientRect()); }); // #558: ONE "Import all" covering both lists. It used to live inside the // platforms box and count only those — so a release with 1 platform and 2 // registered providers offered no "all" at all, and one with 2 platforms and // a provider offered an "all" that silently skipped the provider. It now // agrees with the toolbar button's "(N)", which always counted both. const allWrap = pop.querySelector('.as-src-allwrap'); if (allWrap) allSources().then(all => { if (!allWrap.isConnected || all.total < 2) return; allWrap.innerHTML = `<button class="as-btn as-src-all">⬇ Import all ${all.total} sources</button>`; allWrap.querySelector('.as-src-all').onclick = () => { pop.remove(); sourceFromAll(all); }; placePop(pop, btn.getBoundingClientRect()); }); const off = e => { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } let _dropZone = IS_ADD; // #361: on the direct /add-(cover|event)-art page, open the drop zone straight away function toggleDropZone() { _dropZone = !_dropZone; render(); if (_dropZone) root.querySelector('.as-dropzone')?.scrollIntoView({ block: 'nearest' }); } // Reveal the drop zone automatically when files are dragged onto the page, so you don't // have to click "Add image" first; the whole gallery then accepts the drop (the browser // won't navigate to the file). Internal reorder drags carry no Files, so they're ignored; // the zone auto-hides after a drop or when the drag leaves the window. let _autoDz = false; // #396 a drag that STARTS inside this page — MB's full-screen cover viewer, our own gallery // thumbnails, a reorder — is not an upload; dropping it (even a tiny nudge) must NOT re-add the // image. `dragstart` only fires for in-page drags (OS-file drags and an image dragged from // ANOTHER tab don't fire it here), so it's a reliable "this drag is internal" flag. dragend // always fires at the end of a drag (success or cancel), so it self-clears. let _internalDrag = false; window.addEventListener('dragstart', () => { _internalDrag = true; }, true); window.addEventListener('dragend', () => { _internalDrag = false; }, true); // a drag we accept: local files, OR an image dragged from another tab/page (a URL — #331) const isFileDrag = e => { try { const t = [...((e.dataTransfer && e.dataTransfer.types) || [])]; return t.includes('Files') || t.includes('text/uri-list'); } catch (x) { return false; } }; window.addEventListener('dragover', e => { if (_internalDrag || !isFileDrag(e)) return; e.preventDefault(); if (!_dropZone) { _dropZone = true; _autoDz = true; render(); root.querySelector('.as-dropzone')?.scrollIntoView({ block: 'nearest' }); } }); window.addEventListener('drop', async e => { if (_internalDrag || !isFileDrag(e)) return; e.preventDefault(); e.stopPropagation(); // stage every file drop here (a near-miss outside the zone still works); also stops the zone's own ondrop double-adding _autoDz = false; root.querySelector('.as-dropzone')?.classList.remove('over'); const added = await addFromDrop(e.dataTransfer); if (!added) { _dropZone = false; render(); } }, true); window.addEventListener('dragleave', e => { if (_autoDz && !e.relatedTarget) { _dropZone = false; _autoDz = false; render(); } }); let _newSeq = 0; function newItem(f, meta) { let types = (meta && meta.types && meta.types.length) ? meta.types.slice() : []; let comment = (meta && meta.comment) || ''; // #604: a cover unpacked from an Art Station archive already carries its exact types + // comment (read back from its #244 name) — "none" means untyped, so don't re-guess it if (!types.length && !(meta && meta.exactName) && (SETTINGS.autoType || SETTINGS.autoComment)) { // #243/#244 guess type + comment from the file name const p = parseName(f.name); if (SETTINGS.autoType) types = p.types; if (SETTINGS.autoComment && !comment && p.comment) comment = p.comment; } return { id: 'new-' + Math.random().toString(36).slice(2, 8), types, comment, order: 0, w: 0, h: 0, bytes: f.size, fmt: fileFormat(f.type) || fileFormat(f.name), _del: false, _new: true, _pdf: f.type === 'application/pdf', _file: URL.createObjectURL(f), _fileObj: f, _provider: (meta && meta.provider) || '', _provIcon: (meta && meta.provIcon) || '', _provUrl: (meta && meta.provUrl) || '', // #249 where this image was sourced (shown until committed) _seq: ++_newSeq, // arrival order: a source's first image is its front (sourceBest) _provImageUrl: (meta && meta.provImageUrl) || '', // #260 direct image URL when the provider exposes one (e.g. Discogs) _provMaxFrom: (meta && meta.provMaxFrom) || '', // #717 the image URL the store gave, when a bigger version of it was taken _seedSrc: (meta && meta.seedSrc) || '', _seedTypes: (meta && meta.seedTypes) ? meta.seedTypes.slice() : null, // #248 native-uploader row + last types synced from it _seedBlobSrc: (meta && meta.seedBlobSrc) || '', // #253 the row's current blob URL (changes when an integration swaps in a bigger image) _contentKey: (meta && meta.contentKey) || '', // #253 image-content fingerprint, to drop duplicate sourced/seeded covers // #248 (vzell) original file name for a locally-picked/dropped upload — shown in the // thumb tooltip. Sourced/seeded covers carry a synthetic File name, so skip those // (they show their provider/source via provBadge instead). Disk path isn't recoverable. _uploadName: (meta && (meta.provider || meta.seedSrc)) ? '' : ((f && f.name) || ''), _origTypes: [], _origComment: '', _origOrder: -1 }; } // #262 AS doesn't import a source's per-cover types (dropped as unreliable, #253), so // imports arrive untyped. Front is by far the most common, so optionally type the FIRST // imported cover Front. Only touches a cover with no type yet (a file-name type wins), and // in "when none exists" mode only when no Front is already present (existing or staged) — // which is the safe default: it can't create a duplicate Front. "always" can (see #262). function maybeAutoFront(news) { if (!SETTINGS.autoFront || !news.length) return; const first = news.find(it => !it.types.length); // first untyped cover of this import if (!first) return; if (SETTINGS.autoFrontMode !== 'always' && MODEL.some(it => !it._del && it.types.includes('Front'))) return; // a Front already exists first.types = ['Front']; } // metas (optional) carries per-file { types, comment } — used when sourcing covers // that already know their type/comment (e.g. a store import, #242) function addFiles(files, metas) { const news = [...files].map((f, i) => ({ f, meta: metas && metas[i] })) .filter(x => x.f.type.startsWith('image/') || x.f.type === 'application/pdf').map(x => newItem(x.f, x.meta)); if (!news.length) return; maybeAutoFront(news); // #262 type the first untyped imported cover Front (per setting), before they're inserted // New covers go before the existing/published ones (majkinetor: they were landing last). WHERE among // the staged-new they land depends on the source (#370): // • a batch harvested from the native add rows (other integrations) can arrive across several passes (an // image not decodable yet is deferred to a later pass), so it APPENDS after covers already staged — // keeping the import's source order instead of reversing. // • a manually-added cover (drop, URL, MH / other provider) is the NEWEST deliberate add, so it LEADS. const rest = MODEL.slice().sort((a, b) => a.order - b.order); const stagedNew = rest.filter(m => m._new), published = rest.filter(m => !m._new); const isSeedBatch = news.some(n => n._seedSrc); MODEL = isSeedBatch ? [...stagedNew, ...news, ...published] : [...news, ...stagedNew, ...published]; MODEL.forEach((it, i) => it.order = i); news.forEach(measure); // fill in each new cover's resolution from its local file _dropZone = false; render(); } // #253 a content fingerprint so the same image isn't staged twice. Sourcing // (stores/MH/providers) and the add-page harvest can surface the SAME cover more // than once — an integration re-fires as it maximises, a provider may return dups, etc. async function fileKey(file) { try { const buf = await file.arrayBuffer(); const h = await crypto.subtle.digest('SHA-1', buf); return file.size + ':' + [...new Uint8Array(h)].map(b => b.toString(16).padStart(2, '0')).join(''); } catch (e) { return 'sz' + file.size; } // fallback: byte length only } // stage files, skipping any whose content already exists as a staged NEW cover // (or repeats within this batch). Returns how many were actually added. async function addFilesDeduped(files, metas) { const have = new Set(MODEL.filter(m => m._new && !m._del && m._contentKey).map(m => m._contentKey)); const outF = [], outM = []; for (let i = 0; i < files.length; i++) { const f = files[i]; if (!(f.type.startsWith('image/') || f.type === 'application/pdf')) continue; const k = await fileKey(f); if (have.has(k)) continue; have.add(k); outF.push(f); outM.push(Object.assign({}, metas && metas[i], { contentKey: k })); } if (outF.length) addFiles(outF, outM); return outF.length; } // #243 a drop can include whole FOLDERS — recurse the directory entries to collect every // file. webkitGetAsEntry() must be read synchronously while the drop event is live. // #359: dropped/browsed folders upload recursively, but BOUNDED — one level of // subdirectories deep (the given dir + its immediate subfolders) and at most // DIR_MAX_FILES image/PDF files, so a stray huge tree can't stage thousands. Loose // files dropped directly are kept as-is (type-filtered downstream by addFiles). const DIR_MAX_DEPTH = 1, DIR_MAX_FILES = 100; const DIR_ACCEPT_RE = /\.(jpe?g|png|gif|pdf)$/i; let _dropTruncated = false; function filesFromDrop(dt) { const entries = [...(dt.items || [])].map(i => i.webkitGetAsEntry && i.webkitGetAsEntry()).filter(Boolean); if (!entries.some(e => e.isDirectory)) return Promise.resolve([...(dt.files || [])]); const out = []; _dropTruncated = false; // walk a directory: collect its image/PDF files, descend into subdirs only while // within DIR_MAX_DEPTH, and stop once DIR_MAX_FILES is reached. const walk = (entry, depth) => new Promise(res => { if (out.length >= DIR_MAX_FILES) { _dropTruncated = true; return res(); } if (entry.isFile) { if (!DIR_ACCEPT_RE.test(entry.name)) return res(); // inside a folder → only CAA image/PDF types entry.file(f => { if (out.length < DIR_MAX_FILES) out.push(f); else _dropTruncated = true; res(); }, () => res()); return; } if (!entry.isDirectory) return res(); const rd = entry.createReader(); const readBatch = () => rd.readEntries(async ents => { if (!ents.length || out.length >= DIR_MAX_FILES) return res(); if (depth >= DIR_MAX_DEPTH && ents.some(e => e.isDirectory)) _dropTruncated = true; // deeper levels skipped const next = ents.filter(e => e.isFile || depth < DIR_MAX_DEPTH); await Promise.all(next.map(e => walk(e, e.isDirectory ? depth + 1 : depth))); readBatch(); }, () => res()); readBatch(); }); // top-level dropped FILES kept as-is; dropped DIRECTORIES walked with the bounds above. return Promise.all(entries.map(e => e.isFile ? new Promise(r => e.file(f => { out.push(f); r(); }, () => r())) : walk(e, 0) )).then(() => out); } // #331: an image dragged from another tab/page arrives as a URL, not a File. Pull it out // of the drop (uri-list → <img src> in the HTML → a plain-text URL). function urlFromDrop(dt) { try { const uri = (dt.getData('text/uri-list') || '').split(/\r?\n/).map(s => s.trim()).find(s => s && !s.startsWith('#')); if (uri) return uri; const m = (dt.getData('text/html') || '').match(/<img[^>]+src\s*=\s*["']([^"']+)["']/i); if (m) return m[1]; const txt = (dt.getData('text/plain') || '').trim(); if (/^https?:\/\//i.test(txt)) return txt; } catch (e) {} return null; } // Fetch a dropped image URL (CORS-free via fetchBytes/GM) and wrap it as a File. Sniffs the // bytes for a CAA-accepted type (JPEG/PNG/GIF/PDF); warns + skips anything else (e.g. webp). async function fileFromUrl(url) { asLog.info('Fetching dropped image…'); const bytes = await fetchBytes(url); const hex = [...bytes.slice(0, 4)].map(b => b.toString(16).padStart(2, '0')).join(''); let type, ext; if (hex.startsWith('ffd8ff')) { type = 'image/jpeg'; ext = 'jpg'; } else if (hex.startsWith('89504e47')) { type = 'image/png'; ext = 'png'; } else if (hex.startsWith('47494638')) { type = 'image/gif'; ext = 'gif'; } else if (hex.startsWith('25504446')) { type = 'application/pdf'; ext = 'pdf'; } else { asLog.warn(`Dropped image isn't a Cover Art Archive type (JPEG/PNG/GIF/PDF) — skipped: ${url.slice(0, 90)}`); return null; } let name = ''; try { name = decodeURIComponent(new URL(url).pathname.split('/').pop() || ''); } catch (e) {} if (!/\.(jpe?g|png|gif|pdf)$/i.test(name)) name = (name.replace(/\.[^./]*$/, '') || 'dropped-image') + '.' + ext; return new File([bytes], name, { type }); } // #604: a dropped / picked .zip is unpacked in the browser and its covers staged as if // its folder had been dropped — so an Art Station download (#240) can be dragged // straight from the browser's download bar onto another release, no unpacking. // Reads the central directory (found via the end-of-central-directory record), and // supports the two methods that occur in practice: stored (0 — what #240 writes; // covers are already compressed) and deflate (8 — OS / zip tools, via the built-in // DecompressionStream). Same bounds as a folder drop: CAA types only, one level of // subfolders deep, at most DIR_MAX_FILES. Encrypted, ZIP64 and other methods are // skipped and reported rather than guessed at. const isZipFile = f => !!f && (/\.zip$/i.test(f.name || '') || /^application\/(?:x-)?zip(?:-compressed)?$/i.test(f.type || '')); const MIME_BY_EXT = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', pdf: 'application/pdf' }; const AS_MANIFEST_RE = /Report created with \[Art Station\]/; // exact types + comment from a #244 download name ("03 front,sticker a comment.jpg"). // Unlike parseName, the "none" placeholder means untyped and KEEPS the comment after it. function parseDownloadName(name) { const base = String(name || '').replace(/\.[a-z0-9]+$/i, '').replace(/^\s*\d+\s+/, '').trim(); const sp = base.search(/\s/); const head = sp < 0 ? base : base.slice(0, sp), tail = sp < 0 ? '' : base.slice(sp + 1).trim(); if (head.toLowerCase() === 'none') return { types: [], comment: tail }; const p = parseName(name); return { types: p.types, comment: p.comment }; } async function inflateRaw(bytes) { if (typeof DecompressionStream !== 'function') throw new Error('this browser cannot decompress ZIP entries (no DecompressionStream)'); const out = await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'))).arrayBuffer(); return new Uint8Array(out); } async function filesFromZip(zip) { const t0 = Date.now(); const buf = new Uint8Array(await zip.arrayBuffer()), dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); const res = { files: [], metas: [], skipped: [], truncated: false, fromAS: false }; let eocd = -1; for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xFFFF); i--) if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; } if (eocd < 0) throw new Error('not a ZIP archive (no end-of-central-directory record)'); const count = dv.getUint16(eocd + 10, true), cdOff = dv.getUint32(eocd + 16, true); if (count === 0xFFFF || cdOff === 0xFFFFFFFF) throw new Error('ZIP64 archives are not supported'); const dec = new TextDecoder(); // #240 writes UTF-8 names; so do current OS/zip tools const ents = []; for (let n = 0, p = cdOff; n < count; n++) { if (p + 46 > buf.length || dv.getUint32(p, true) !== 0x02014b50) throw new Error(`corrupt central directory at entry ${n + 1}/${count}`); const nl = dv.getUint16(p + 28, true), xl = dv.getUint16(p + 30, true), cl = dv.getUint16(p + 32, true); ents.push({ name: dec.decode(buf.subarray(p + 46, p + 46 + nl)), flags: dv.getUint16(p + 8, true), method: dv.getUint16(p + 10, true), csize: dv.getUint32(p + 20, true), usize: dv.getUint32(p + 24, true), lho: dv.getUint32(p + 42, true) }); p += 46 + nl + xl + cl; } asLog.debug(`Zip ${zip.name}: ${ents.length} entr${ents.length === 1 ? 'y' : 'ies'} — ${ents.map(e => `${e.name} (m${e.method}, ${e.usize}b)`).join(', ').slice(0, 1500)}`); const dataOf = async e => { if (e.flags & 1) throw new Error('encrypted'); if (e.csize === 0xFFFFFFFF || e.usize === 0xFFFFFFFF) throw new Error('ZIP64 entry'); if (dv.getUint32(e.lho, true) !== 0x04034b50) throw new Error('bad local header'); const start = e.lho + 30 + dv.getUint16(e.lho + 26, true) + dv.getUint16(e.lho + 28, true); const raw = buf.subarray(start, start + e.csize); if (e.method === 0) return raw; if (e.method === 8) return inflateRaw(raw); throw new Error('unsupported compression method ' + e.method); }; // Art Station's own archive → its README.md manifest says so; its names are then exact const readme = ents.find(e => /^readme\.md$/i.test(e.name)); if (readme) { try { res.fromAS = AS_MANIFEST_RE.test(dec.decode(await dataOf(readme))); } catch (e) { asLog.debug(`Zip: README.md unreadable — ${e.message || e}`); } } const cand = ents.filter(e => !e.name.endsWith('/') && !/(^|\/)__MACOSX\//.test(e.name) && !/(^|\/)\./.test(e.name)) .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); // #244 "NN …" names → cover order // zipping a folder wraps every entry in that folder's name ("Album/cover.jpg"); the zip // then stands for that folder, so measure depth from inside it — exactly as a drop of // the "Album" folder itself would (its "scans/" is one level deep, not two) const tops = new Set(cand.map(e => e.name.includes('/') ? e.name.split('/')[0] : '')); const wrap = (tops.size === 1 && !tops.has('')) ? 1 : 0; if (wrap) asLog.debug(`Zip: everything is inside "${[...tops][0]}/" — depth counted from there`); for (const e of cand) { const base = e.name.split('/').pop(), depth = e.name.split('/').length - 1 - wrap; if (e === readme) continue; if (!DIR_ACCEPT_RE.test(base)) { res.skipped.push(`${e.name} (not JPEG/PNG/GIF/PDF)`); continue; } if (depth > DIR_MAX_DEPTH) { res.truncated = true; asLog.debug(`Zip: ${e.name} is ${depth} folders deep — skipped`); continue; } if (res.files.length >= DIR_MAX_FILES) { res.truncated = true; continue; } let data; try { data = await dataOf(e); } catch (err) { res.skipped.push(`${e.name} (${err.message || err})`); continue; } const ext = base.split('.').pop().toLowerCase(); res.files.push(new File([data], base, { type: MIME_BY_EXT[ext] })); res.metas.push(res.fromAS ? Object.assign({ exactName: true }, parseDownloadName(base)) : undefined); } asLog.info(`Zip ${zip.name}: ${res.files.length} cover${res.files.length === 1 ? '' : 's'} unpacked in ${Date.now() - t0}ms${res.fromAS ? ' — Art Station archive, types + comments from file names' : ''}${res.skipped.length ? `, ${res.skipped.length} skipped` : ''}${res.truncated ? ', capped' : ''}`); res.skipped.forEach(x => asLog.warn('Zip: skipped ' + x)); if (res.fromAS) res.files.forEach((f, i) => asLog.debug(`Zip: ${f.name} → types=[${res.metas[i].types.join(', ')}] comment="${res.metas[i].comment}"`)); return res; } // expand any .zip among the files into its covers (loose files pass through unchanged) async function expandZips(files) { const out = { files: [], metas: [], zips: 0 }; for (const f of files) { if (!isZipFile(f)) { out.files.push(f); out.metas.push(undefined); continue; } out.zips++; try { const z = await filesFromZip(f); out.files.push(...z.files); out.metas.push(...z.metas); const notes = []; if (z.skipped.length) notes.push(`${z.skipped.length} file${z.skipped.length === 1 ? '' : 's'} skipped (see Log)`); if (z.truncated) notes.push(`capped: first ${DIR_MAX_FILES} images, one level of subfolders deep`); toast(z.files.length ? `Unpacked ${z.files.length} ${z.files.length === 1 ? ITEM : ITEMS} from ${f.name}${notes.length ? ' — ' + notes.join('; ') : ''}` : `No JPEG / PNG / GIF / PDF images in ${f.name}${notes.length ? ' — ' + notes.join('; ') : ''}`, notes.length || !z.files.length ? 8000 : 4000); } catch (e) { logErr(`Zip: ${f.name}`, e); toast(`Couldn't read ${f.name}: ${e.message || e}`, 8000); } } return out; } // Unified drop ingest: local files if present, else fetch a dropped image URL. Returns true // when something was staged. async function addFromDrop(dt) { const dropped = await filesFromDrop(dt); if (dropped && dropped.length) { const { files, metas, zips } = await expandZips(dropped); // #604 a .zip → its covers if (files.length) addFiles(files, metas); if (_dropTruncated) toast(`Folder upload capped: first ${DIR_MAX_FILES} images, one level of subfolders deep`, 6000); return files.length > 0 || !zips; } const url = urlFromDrop(dt); if (!url) return false; try { const f = await fileFromUrl(url); if (f) { addFiles([f], [{ provImageUrl: url, provUrl: url }]); return true; } } catch (e) { asLog.err('Drop fetch failed: ' + (e.message || e)); } return false; } function pickFiles() { // Only the types MusicBrainz / the Cover Art Archive accept — `image/*` was too // broad (it offered webp, bmp, … which CAA rejects). JPEG · PNG · GIF · PDF. // #604 plus a .zip of them (e.g. an Art Station download), unpacked in the browser const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/jpeg,image/png,image/gif,application/pdf,.zip,application/zip'; inp.multiple = true; inp.onchange = async () => { const { files, metas } = await expandZips([...inp.files]); if (files.length) addFiles(files, metas); }; inp.click(); } // #359: pick a FOLDER → upload its image/PDF files one level of subfolders deep, capped // at DIR_MAX_FILES (webkitRelativePath is "folder/[sub/]file", so depth = segments − 2). function pickFolder() { const inp = document.createElement('input'); inp.type = 'file'; inp.webkitdirectory = true; inp.multiple = true; inp.onchange = () => { const imgs = [...inp.files].filter(f => DIR_ACCEPT_RE.test(f.name)); const inDepth = imgs.filter(f => (f.webkitRelativePath.split('/').length - 2) <= DIR_MAX_DEPTH); let files = inDepth, truncated = imgs.length > inDepth.length; // deeper files skipped if (inDepth.length > DIR_MAX_FILES) { files = inDepth.slice(0, DIR_MAX_FILES); truncated = true; } if (files.length) addFiles(files); if (truncated) toast(`Folder upload capped: first ${DIR_MAX_FILES} images, one level of subfolders deep`, 6000); else if (!files.length) toast('No JPEG / PNG / GIF / PDF images in that folder (first level)', 5000); }; inp.click(); } // ── Phase 2a: apply staged changes as real MB edits (form-replay) ───────────── const R = ENT.base; // /release/<mbid> | /event/<gid> const ART = ENT.art; // cover-art | event-art — the MB endpoint + form-field suffix // credit the tool in every edit note (user's note first, then the attribution) const editNote = m => [m.note && m.note.trim(), ATTRIBUTION].filter(Boolean).join('\n\n'); // #260 a sourced cover records where it came from in ITS OWN add edit note. The commit // note is shared across all ops, so this per-cover provenance is appended only to that // upload (sourced covers carry _provider/_provUrl; local uploads have neither → nothing added). const sourceLine = it => { if (!it) return ''; const who = (it._provider && String(it._provider).trim()) || ''; const page = (it._provUrl && String(it._provUrl).trim()) || ''; const img = (it._provImageUrl && String(it._provImageUrl).trim()) || ''; const main = page || img; if (!who && !main) return ''; let s = `Cover art sourced from ${who || 'an external provider'}`; if (main) s += ` — ${main}`; if (img && img !== main) s += `\nImage: ${img}`; // #260 the direct image URL, when distinct from the page const from = (it._provMaxFrom && String(it._provMaxFrom).trim()) || ''; if (from && from !== img) s += `\nMaximised from: ${from}`; // #717 what the store offered return s; }; // #667 the best-cover pick says so in its note const bestLine = it => (it && it._bestOf > 1) ? `Art Station chose this as the best of ${it._bestOf} imported covers (highest resolution, then smallest file): ${it.w}×${it.h}${it.bytes ? ', ' + fmtBytes(it.bytes) : ''}` : ''; // #678 a cover rotated from an existing one records, in its own add edit note, that it // replaces the original (the angle stays accurate through repeated rotations — see _rotDeg) const rotLine = it => (it && it._rotatedFrom) ? `Rotated ${it._rotDeg || 90}° from this release's existing image ${it._rotatedFrom} (which this edit replaces)` : ''; const editNoteFor = (m, it) => [m.note && m.note.trim(), sourceLine(it), bestLine(it), rotLine(it), ATTRIBUTION].filter(Boolean).join('\n\n'); // test hook only (#717): the staged sourced covers, with the source lines their edit notes get if (mbuTestHooks()) (window.__asTest || (window.__asTest = {})).staged = () => MODEL.filter(x => x._new && !x._sourcing).map(x => ({ provider: x._provider || '', provUrl: x._provUrl || '', provImageUrl: x._provImageUrl || '', provMaxFrom: x._provMaxFrom || '', types: x.types, comment: x.comment, w: x.w, h: x.h, note: sourceLine(x), seedNote: _seedNote })); async function getPostForm(url) { const html = await fetch(url, { credentials: 'same-origin' }).then(r => { if (!r.ok) throw new Error('GET ' + r.status); return r.text(); }); const doc = new DOMParser().parseFromString(html, 'text/html'); const form = [...doc.querySelectorAll('form')].find(f => (f.getAttribute('method') || '').toUpperCase() === 'POST'); if (!form) throw new Error('no POST form at ' + url); form._action = new URL(form.getAttribute('action') || url, location.origin + url).href; return form; } // carry every hidden field (csrf/nonce/etc.) verbatim — that's the point of form-replay function copyHidden(form, params, skip) { form.querySelectorAll('input[type=hidden]').forEach(h => { if (h.name && !(skip && skip.test(h.name))) params.append(h.name, h.value); }); } function typeMapOf(form, prefix) { const m = {}; // the label as MB shows it, then (#721) the English name by type id, so a translated // label ("Vorderseite") still maps, and an id → name lookup ends on the English one const learnt = {}; form.querySelectorAll(`input[name="${prefix}.type_id"]`).forEach(cb => { const l = cb.closest('label'); const n = (l ? l.textContent : '').trim(); if (n) m[n] = cb.value; const en = TYPE_BY_ID[cb.value]; if (en) { m[en] = cb.value; if (n && n !== en) learnt[n] = en; } }); learnTypeNames(learnt); return m; } // #721 translated type name → English, per MB interface language, as MB's forms show them // (the add page's uploader, the edit forms a commit reads); no request of its own. const typeNamesKey = () => 'artstation:typeNames:' + (document.documentElement.lang || 'en'); let _typeNames = null; function loadTypeNames() { if (!_typeNames) { try { _typeNames = JSON.parse(gmLoad(typeNamesKey()) || '{}'); } catch (e) { _typeNames = {}; } } return _typeNames; } function learnTypeNames(learnt) { const known = loadTypeNames(); if (!Object.keys(learnt).some(k => known[k] !== learnt[k])) return; Object.assign(known, learnt); gmSave(typeNamesKey(), JSON.stringify(known)); } async function buildEdit(it, meta) { // retype / comment on an existing cover const form = await getPostForm(`${R}/edit-${ART}/${it.id}`); const p = new URLSearchParams(); copyHidden(form, p); const tm = typeMapOf(form, `edit-${ART}`); // #721 types untouched (only the comment changed): send the ones the form has checked, // so a type Art Station couldn't name is never dropped by a comment edit if (it.types.join('|') === it._origTypes.join('|')) form.querySelectorAll(`input[name="edit-${ART}.type_id"]:checked`).forEach(cb => p.append(`edit-${ART}.type_id`, cb.value)); else it.types.forEach(t => { if (tm[t]) p.append(`edit-${ART}.type_id`, tm[t]); }); p.append(`edit-${ART}.comment`, it.comment); p.append(`edit-${ART}.edit_note`, editNote(meta)); if (meta.votable) p.append(`edit-${ART}.make_votable`, '1'); return { method: 'POST', url: form._action, body: p }; } async function buildRemove(it, meta) { // #264 if the cover is already gone (e.g. removed in a prior edit but lingering in a // stale CAA listing), the remove form 404s — treat that as "already removed", not an error. let form; try { form = await getPostForm(`${R}/remove-${ART}/${it.id}`); } catch (e) { if (/\b404\b/.test(String((e && e.message) || e))) return { noop: true, note: 'already removed (not on the release)' }; throw e; } const p = new URLSearchParams(); copyHidden(form, p); p.append('confirm.edit_note', editNote(meta)); if (meta.votable) p.append('confirm.make_votable', '1'); return { method: 'POST', url: form._action, body: p }; } // Phase 2b: upload a new image. (1) sign via MB, (2) POST file to archive.org, (3) register. // The sign endpoint reserves an image_id/nonce per call and fetches an S3 policy from the // Internet Archive, so concurrent calls for the same release RACE and 500 — committing 6 // covers, only the 1st succeeded and the rest failed "sign 500". So serialise signing // through a gate (the slow S3 PUT still overlaps) and retry transient 5xx/429 (IA flakes). let _signGate = Promise.resolve(); async function signUploadRaw(mime, ctl) { for (let attempt = 1; ; attempt++) { if (ctl && ctl.aborted) throw new Error('cancelled'); const r = await mbnFetch(`/ws/js/${ART}-upload/${MBID}?mime_type=${encodeURIComponent(mime || 'image/jpeg')}`, { credentials: 'same-origin', signal: ctl && ctl.ac.signal }); if (r.ok) return r.json(); // { action, image_id, formdata, nonce } if (attempt >= 4 || ![429, 500, 502, 503, 504].includes(r.status)) throw new Error('sign ' + r.status); asLog.debug(`Upload: sign got ${r.status}, retrying (${attempt}/3)`); await new Promise(res => setTimeout(res, 500 * attempt + Math.floor(Math.random() * 400))); // backoff + jitter } } function signUpload(mime, ctl) { const run = () => signUploadRaw(mime, ctl); const p = _signGate.then(run, run); // one sign at a time, regardless of prior failures _signGate = p.catch(() => {}); return p; } let _addForm = null; const addForm = () => (_addForm = _addForm || getPostForm(`${R}/add-${ART}`)); // step 1: sign (serialised by the gate above) then PUT the file to archive.org. Stores // the signed upload on the item; the slow PUT is the part that overlaps across items. async function uploadStep(it, onProgress, ctl) { if (ctl && ctl.aborted) throw new Error('cancelled'); const mime = (it._fileObj && it._fileObj.type) || 'image/jpeg'; const signed = await signUpload(mime, ctl); if (ctl && ctl.aborted) throw new Error('cancelled'); const fd = new FormData(); Object.entries(signed.formdata).forEach(([k, v]) => fd.append(k, v)); fd.append('file', it._fileObj, (it._fileObj && it._fileObj.name) || String(signed.image_id)); // XHR (not fetch) so we get upload progress + a timeout — a big cover used to // sit silently with no feedback, and a stalled POST would hang forever. #240/#235 // The live xhr is registered on ctl so Cancel can abort an in-flight upload. await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', signed.action); // #560: configurable, because a 50–100MB PDF booklet against a slow // Internet Archive outlasted the old hardcoded 5 minutes and failed here // while the native uploader (which sets no timeout at all) got through. xhr.timeout = uploadTimeoutMs(); if (ctl) ctl.xhrs.add(xhr); const done = () => { if (ctl) ctl.xhrs.delete(xhr); }; xhr.upload.onprogress = e => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); }; xhr.onload = () => { done(); (xhr.status >= 200 && xhr.status < 300) ? resolve() : reject(new Error('IA upload ' + xhr.status)); }; xhr.onerror = () => { done(); reject(new Error('IA upload network error')); }; // #560: name the limit and where to change it — "timed out" alone gave no // hint that this is a setting, and the fix (a bigger number) is one click away. xhr.ontimeout = () => { done(); reject(new Error(`IA upload timed out after ${Math.round(uploadTimeoutMs() / 60000)} min — raise "Upload timeout" in setup (⚙︎) if the Internet Archive is slow`)); }; xhr.onabort = () => { done(); reject(new Error('cancelled')); }; xhr.send(fd); }); it._signed = signed; } // step 2: register on MB. Runs in PARALLEL (#362) — the per-upload `position` only groups the batch, so // the trailing reorder edit is what sets the final order (see buildReorder / runAdds). async function registerStep(it, meta, ctl) { const form = await addForm(); const tm = typeMapOf(form, `add-${ART}`); const typeIds = it.types.map(t => tm[t]).filter(Boolean); const mime = (it._fileObj && it._fileObj.type) || 'image/jpeg'; const p = new URLSearchParams(); copyHidden(form, p, /\.(nonce|position|id|type_id|comment|mime_type)$/); p.append(`add-${ART}.id`, it._signed.image_id); p.append(`add-${ART}.position`, String(it.order + 1)); p.append(`add-${ART}.nonce`, it._signed.nonce); p.append(`add-${ART}.mime_type`, mime); // required Select (MB Form::Role::AddArt) typeIds.forEach(id => p.append(`add-${ART}.type_id`, id)); p.append(`add-${ART}.comment`, it.comment); p.append(`add-${ART}.edit_note`, editNoteFor(meta, it)); // #260 include this cover's source, if any if (meta.votable) p.append(`add-${ART}.make_votable`, '1'); const add = await fetch(`${R}/add-${ART}`, { method: 'POST', body: p, credentials: 'same-origin', signal: ctl && ctl.ac.signal }); if (!add.ok) throw new Error('add ' + add.status); } async function runAdd(it, meta, dry, report) { // dry-run summary only (live uses runAdds) const mime = (it._fileObj && it._fileObj.type) || 'image/jpeg'; const form = await addForm(); const typeIds = it.types.map(t => typeMapOf(form, `add-${ART}`)[t]).filter(Boolean); report(`1. GET /ws/js/${ART}-upload/${MBID}?mime_type=${mime} → {action,image_id,formdata,nonce}\n` + `2. POST ‹signed archive.org action› multipart: ‹policy,signature,key,AWSAccessKeyId…› + file (${(it._fileObj && it._fileObj.name) || 'file'}, ${(it._fileObj && it._fileObj.size) || '?'}b)\n` + `3. POST ${R}/add-${ART}\n add-${ART}.id=‹image_id›\n add-${ART}.position=${it.order + 1}\n add-${ART}.nonce=‹nonce›\n add-${ART}.mime_type=${mime}\n` + ` add-${ART}.type_id=${typeIds.join(',') || '(none)'}\n add-${ART}.comment=${it.comment}\n add-${ART}.edit_note=${editNoteFor(meta, it).replace(/\n+/g, ' / ')}` + (meta.votable ? `\n add-${ART}.make_votable=1` : '')); } async function buildReorder(meta) { // single edit: full ordered artwork list const form = await getPostForm(`${R}/reorder-${ART}`); const p = new URLSearchParams(); copyHidden(form, p, /\.artwork\./); // #261 the full final order — NEW covers included by their post-upload image_id (runs // after the uploads register). MB's add `position` only places the whole upload as a // group, so without this the relative order of multiple new covers (or a new cover // slotted among existing ones) isn't preserved. const seq = MODEL.filter(it => !it._del && !it._sourcing).sort((a, b) => a.order - b.order); let n = 0; for (const it of seq) { const id = it._new ? (it._signed && it._signed.image_id) : it.id; if (!id) continue; // a new cover whose upload failed — leave it out of the ordering p.append(`reorder-${ART}.artwork.${n}.id`, id); p.append(`reorder-${ART}.artwork.${n}.position`, String(n + 1)); n++; } p.append(`reorder-${ART}.edit_note`, editNote(meta)); if (meta.votable) p.append(`reorder-${ART}.make_votable`, '1'); return { method: 'POST', url: form._action, body: p }; } // ordered work list (uploads are Phase 2b): remove → retype/comment → reorder function buildPlan() { const plan = []; MODEL.filter(it => it._new && !it._del && !it._sourcing).forEach(it => plan.push({ label: `Add ${it.types[0] || 'new image'}${it.comment ? ` “${it.comment}”` : ''} (upload)`, kind: 'add', it, run: (m, dry, report) => runAdd(it, m, dry, report) })); MODEL.filter(it => it._del && !it._new).forEach(it => plan.push({ label: `Remove ${it.types[0] || ITEM}`, id: it.id, kind: 'remove', build: m => buildRemove(it, m) })); MODEL.filter(it => !it._del && !it._new && (it.comment !== it._origComment || it.types.join('|') !== it._origTypes.join('|'))) .forEach(it => { // readable description of what changed on this cover (the panel list now // doubles as the "pending operations" view — #234) const ch = []; if (it.types.join('|') !== it._origTypes.join('|')) ch.push(`type → ${it.types.join(', ') || '(none)'}`); if (it.comment !== it._origComment) ch.push(`comment → ${it.comment ? `“${it.comment}”` : '(cleared)'}`); plan.push({ label: `Edit ${it._origTypes[0] || ITEM}: ${ch.join(', ')}`, id: it.id, kind: 'edit', build: m => buildEdit(it, m) }); }); const ex = MODEL.filter(it => !it._del && !it._new); const now = ex.slice().sort((a, b) => a.order - b.order).map(it => it.id).join(','); const orig = ex.slice().sort((a, b) => a._origOrder - b._origOrder).map(it => it.id).join(','); // #261 reorder when the existing order changed OR new covers were uploaded among // others (their order isn't honoured by the per-upload position alone). Needs ≥2 covers. const all = MODEL.filter(it => !it._del && !it._sourcing); const reorderNeeded = all.length >= 2 && (now !== orig || all.some(it => it._new)); if (reorderNeeded) plan.push({ label: 'Reorder ' + ITEMS, kind: 'reorder', build: m => buildReorder(m) }); return plan; } // #493: right-click "Enter edit" still OPENS the review dialog (so the plan is visible, // same as a normal left-click) but starts the run automatically instead of waiting for a // "Run" click — for a single-image digital-provider upload there's nothing to actually // decide, just cut the extra click. `immediate` defers the auto-run past the next paint // (double rAF) so the dialog genuinely renders open first, rather than firing in the same // synchronous tick and never visibly appearing before the run's own DOM updates take over. /* #595 (vzell): "by dragging and releasing the resize handler with the mouse the dialog closes immediately as soon as releasing the mouse." A `click` whose mousedown and mouseup have DIFFERENT targets is dispatched on their nearest COMMON ANCESTOR. Drag the box's resize corner and the pointer ends up over the backdrop, so the pair is (box, overlay) and the click is dispatched on the overlay — `e.target === ov` was true for a gesture that began inside the dialog. The same thing happens selecting text in the edit note and releasing past the edge, which threw the note away without a word. Requiring the gesture to have STARTED on the backdrop is what separates "clicked outside" from "finished a drag outside". `extra` is an additional guard evaluated at click time (a run in flight, an open popover, …). */ /* #595, second half (vzell): "When then clicking the 'Enter edit' button again, it starts with the original size again." Every open rebuilds the dialog, so a resize lasted exactly as long as that one dialog — and for someone who makes it large every time ("personal habit"), that is a resize per open forever. Remembered per dialog kind, the same way Fusion persists its own window (ResizeObserver → GM storage). Sizes are clamped to the viewport on restore, so a size saved on a big monitor cannot leave the dialog larger than the screen it is reopened on. */ const AS_DLG_SIZE_KEY = 'artstation:dialogSize'; function asDlgSizes() { try { return JSON.parse(gmLoad(AS_DLG_SIZE_KEY) || '{}') || {}; } catch (e) { return {}; } } /* #668 the commit dialog's operations went to two columns, which need its new wider default. A size saved before that (#595) would keep reopening it in one cramped column, so ("ignore any saved size", majkinetor) a saved size without the `m` mark is ignored and the default applies; resizes from now on are remembered. */ const DLG_MIG = 668; function rememberDialogSize(box, kind) { let saved = asDlgSizes()[kind]; if (saved && saved.m !== DLG_MIG) { asLog.debug(`Dialog ${kind}: ignoring a size saved before #668 (${saved.w}x${saved.h})`); saved = null; } if (saved && saved.w > 0 && saved.h > 0) { box.style.width = Math.min(saved.w, Math.round(window.innerWidth * 0.94)) + 'px'; box.style.height = Math.min(saved.h, Math.round(window.innerHeight * 0.88)) + 'px'; } // The observer fires once on observe() with the CURRENT size; that would // write back whatever was just restored, which is harmless, but it would // also persist the default size for a dialog nobody ever resized. Skip the // first callback so only a real resize is recorded. let first = true; try { new ResizeObserver(() => { if (first) { first = false; return; } if (!box.isConnected) return; // ⚠ what the UA resizer writes to style.width is a CONTENT-box value — // .as-cm-box is content-box and carries 18px/20px padding. Storing // offsetWidth (which includes that padding) and assigning it back to // style.width grew the dialog by 40x36px on every reopen. Measured. // Reading back the resizer's own inline values needs no box-model // arithmetic and cannot drift if the padding or box-sizing changes. const w = parseFloat(box.style.width), h = parseFloat(box.style.height); if (!(w > 0) || !(h > 0)) return; const all = asDlgSizes(); all[kind] = { w: Math.round(w), h: Math.round(h), m: DLG_MIG }; gmSave(AS_DLG_SIZE_KEY, JSON.stringify(all)); }).observe(box); } catch (e) { /* no ResizeObserver — the dialog just won't remember its size */ } } function onBackdropClick(ov, run, extra) { ov.addEventListener('mousedown', e => { ov._downOnBackdrop = (e.target === ov); }); ov.addEventListener('click', e => { const fromBackdrop = ov._downOnBackdrop; ov._downOnBackdrop = false; if (e.target !== ov || !fromBackdrop) return; if (extra && !extra()) return; run(); }); } function enterEdit(immediate) { document.getElementById('as-commit')?.remove(); const plan = buildPlan(); const ov = document.createElement('div'); ov.id = 'as-commit'; /* #668 (majkinetor): "Edit note dominates enter edit so we can't really see most of the individual progress." The note is rarely changed but often holds a long block from a sourcing script, so it now opens COLLAPSED to a one-line summary (line count + first line) and expands on click. Each operation row carries the cover it acts on and its progress bar under the label, and Dry run moved off the footer into the Submit button's menu. */ const opThumb = o => o.kind === 'reorder' ? '<span class="as-cm-thi">⇅</span>' : o.it && o.it._file ? `<img alt="" data-alt="" src="${esc(o.it._file)}">` : o.id ? `<img alt="" data-alt="${esc(imgUrl(o.id))}" src="${esc(thumb(o.id, 250))}">` : '<span class="as-cm-thi">🖼</span>'; ov.innerHTML = `<div class="as-cm-box"> <div class="as-cm-h"><span class="as-cm-h-t" title="Each change is applied as a MusicBrainz edit">Apply ${plan.length} change${plan.length===1?'':'s'}</span><a class="as-cm-hist" href="${ART_EDITS_URL}" target="_blank" rel="noopener noreferrer" title="Your ${ENT.noun} edits on MusicBrainz">🕓 My edits</a><button type="button" class="as-cm-x" title="Close — during a run, cancels it">✕</button></div> <div class="as-cm-prog" hidden><div class="as-cm-prog-track"><div class="as-cm-prog-fill"></div></div><span class="as-cm-prog-txt"></span></div> <div class="as-cm-list">${plan.map((o, i) => `<div class="as-cm-op" data-i="${i}"><div class="as-cm-line"><span class="as-cm-st">○</span><span class="as-cm-th">${opThumb(o)}</span><div class="as-cm-main"><div class="as-cm-ttl"><span class="as-cm-lb">${esc(o.label)}</span>${o.id ? ` <span class="as-cm-id">#${esc(o.id)}</span>` : ''}${o.skip ? `<span class="as-cm-skip">${esc(o.skip)}</span>` : ''}</div><span class="as-cm-bar"><span class="as-cm-bfill"></span></span></div></div><div class="as-cm-payload"></div></div>`).join('')}</div> <div class="as-cm-nb"> <div class="as-cm-ns" role="button" tabindex="0" title="Edit note added to each edit — click to show or hide it"><span class="as-cm-ni">📝</span><b>Note</b><span class="as-cm-nl"></span><span class="as-cm-np"></span><span class="as-cm-nc">▸</span></div> <div class="as-cm-nbody"><textarea class="as-cm-note edit-note" rows="2" placeholder="optional edit note shown on each edit"></textarea></div> </div> <div class="as-cm-f"><label class="as-cm-chk"><input type="checkbox" class="as-cm-vote"> Make votable</label><span class="as-sp"></span><button class="as-btn as-cm-cancel">Cancel</button><span class="as-cm-split"><button class="as-btn as-cm-go">Submit edits</button><button type="button" class="as-btn as-cm-more" title="More ways to run">▾</button><div class="as-cm-menu" hidden><button type="button" class="as-cm-mi as-cm-dryrun" title="Show what each edit would send, without submitting anything">👁 Dry run</button></div></span></div> <div class="as-cm-ar"></div> </div>`; document.body.appendChild(ov); // a cover whose CAA thumbnail isn't generated yet falls back to the original, then to nothing ov.querySelectorAll('.as-cm-th img').forEach(img => img.addEventListener('error', () => { const alt = img.dataset.alt; img.dataset.alt = ''; if (alt) img.src = alt; else img.replaceWith(Object.assign(document.createElement('span'), { className: 'as-cm-thi', textContent: '🖼' })); })); const noteEl = ov.querySelector('.as-cm-note'), nb = ov.querySelector('.as-cm-nb'); if (_seedNote) noteEl.value = _seedNote; // #248/#364 carry over a seeded edit note (the native add page's) const paintNote = () => { const v = noteEl.value.trim(), lines = v ? v.split('\n').length : 0; ov.querySelector('.as-cm-nl').textContent = lines ? `${lines} line${lines === 1 ? '' : 's'}` : 'empty'; ov.querySelector('.as-cm-np').textContent = v ? v.split('\n').find(l => l.trim()).trim().replace(/^https?:\/\/(www\.)?/, '') : 'click to add one'; }; const toggleNote = open => { open = open == null ? !nb.classList.contains('open') : open; nb.classList.toggle('open', open); ov.querySelector('.as-cm-nc').textContent = open ? '▾' : '▸'; if (open) noteEl.focus(); asLog.debug(`Commit: edit note ${open ? 'expanded' : 'collapsed'} (${noteEl.value.length} chars)`); }; noteEl.addEventListener('input', paintNote); paintNote(); const ns = ov.querySelector('.as-cm-ns'); ns.onclick = () => toggleNote(); ns.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleNote(); } }; const closeMenu = installCommitMenu(ov); // backdrop click closes — but NOT while a live run is in flight (#269): that // path bypassed the abort, orphaning the in-flight edits. During a run the only // exits are Cancel (aborts) or Close (after it finishes). onBackdropClick(ov, () => { arStop(ov); ov.remove(); }, () => !ov._running); // #595 rememberDialogSize(ov.querySelector('.as-cm-box'), 'commit'); // #595 ov.querySelector('.as-cm-cancel').onclick = () => { arStop(ov); ov.remove(); }; // #668 the ✕ is the Cancel/Close button's twin: mid-run it aborts (the dialog stays // up to show what was cancelled), otherwise it closes. A dry run has nothing to abort. ov.querySelector('.as-cm-x').onclick = () => { const c = ov.querySelector('.as-cm-cancel'); if (ov._running && !c.disabled) c.click(); else if (!ov._running) { arStop(ov); ov.remove(); } }; const goBtn = ov.querySelector('.as-cm-go'); const go = dry => { closeMenu(); asLog.debug(`Commit: ${dry ? 'dry run' : 'submit'} of ${plan.length} operation(s), note ${noteEl.value.length} chars`); runPlan(ov, plan, { note: noteEl.value, votable: ov.querySelector('.as-cm-vote').checked, dry }); }; goBtn.onclick = () => go(false); ov.querySelector('.as-cm-dryrun').onclick = () => go(true); if (immediate) requestAnimationFrame(() => requestAnimationFrame(() => go(false))); // let the dialog actually paint first } // #668 the Submit button's ▾ menu (Dry run). Dismissed by an outside press, and // that press's trailing click is swallowed so it can't also hit what's underneath // (#305). Returns closeMenu(), which reports whether a menu was actually open. function installCommitMenu(ov) { const more = ov.querySelector('.as-cm-more'), menu = ov.querySelector('.as-cm-menu'); const close = () => { if (menu.hidden) return false; menu.hidden = true; more.classList.remove('on'); return true; }; more.onclick = e => { e.stopPropagation(); if (!close()) { menu.hidden = false; more.classList.add('on'); asLog.debug('Commit: run menu opened'); } }; ov.addEventListener('pointerdown', e => { if (menu.hidden || e.target.closest('.as-cm-menu, .as-cm-more')) return; close(); const swallow = ev => { ev.stopPropagation(); ev.preventDefault(); }; ov.addEventListener('click', swallow, { capture: true, once: true }); setTimeout(() => ov.removeEventListener('click', swallow, { capture: true }), 400); }, true); ov.addEventListener('keydown', e => { if (e.key === 'Escape' && close()) e.stopPropagation(); }); return close; } // #278: per-row progress bar pinned on the right of each op row. `pct` null → // leave the width; `state` colours it (busy=indeterminate sweep, ''=in-progress // accent, done=green, dry=muted, err=red, cancel=grey). function setRowBar(row, pct, state) { if (!row) return; const bar = row.querySelector('.as-cm-bar'); if (!bar) return; bar.classList.add('on'); bar.classList.remove('busy', 'done', 'dry', 'err', 'cancel'); if (state) bar.classList.add(state); if (pct != null) { const f = bar.querySelector('.as-cm-bfill'); if (f) f.style.width = Math.max(0, Math.min(100, pct)) + '%'; } } async function runOp(ov, op, meta, ctl) { const row = ov.querySelector(`.as-cm-op[data-i="${op._i}"]`); const st = row.querySelector('.as-cm-st'), pay = row.querySelector('.as-cm-payload'); if (op.skip) { st.textContent = '⏭'; return; } if (ctl && ctl.aborted) { st.textContent = '⛔'; setRowBar(row, 100, 'cancel'); return; } st.textContent = '⏳'; setRowBar(row, null, 'busy'); if (!meta.dry && !op.run) asLog.info(`${op.label} — applying…`); // make each commit operation visible as it runs try { if (op.run) { // multi-step op (uploads) reports its own payload await op.run(meta, meta.dry, txt => { pay.textContent = txt; }); st.textContent = meta.dry ? '👁' : '✅'; if (meta.dry) row.classList.add('dry'); setRowBar(row, 100, meta.dry ? 'dry' : 'done'); } else { const req = await op.build(meta); if (req && req.noop) { st.textContent = '✅'; pay.textContent = req.note || 'nothing to do'; setRowBar(row, 100, 'done'); return; } // #264 already-done op (e.g. removing a cover that's already gone) if (meta.dry) { st.textContent = '👁'; row.classList.add('dry'); pay.textContent = `${req.method} ${req.url}\n${decodeURIComponent(req.body.toString()).replace(/\+/g, ' ').replace(/&/g, '\n ')}`; setRowBar(row, 100, 'dry'); } else { const r = await fetch(req.url, { method: 'POST', body: req.body, credentials: 'same-origin', signal: ctl && ctl.ac.signal }); if (!r.ok) throw new Error('HTTP ' + r.status); st.textContent = '✅'; setRowBar(row, 100, 'done'); asLog.ok(`Edit: ${op.label}`); } } } catch (e) { const cancelled = ctl && ctl.aborted; st.textContent = cancelled ? '⛔' : '❌'; pay.textContent = String(e && e.message || e); setRowBar(row, 100, cancelled ? 'cancel' : 'err'); if (!cancelled) { row.classList.add('err'); op._err = true; logErr(`Edit: ${op.label}`, e); } // #275: flag for the Repeat retry } } // bounded-concurrency map async function pool(items, conc, fn) { let i = 0; const worker = async () => { while (i < items.length) { const k = i++; await fn(items[k]); } }; await Promise.all(Array.from({ length: Math.min(conc, items.length || 1) }, worker)); } const runPool = (ops, conc, ov, meta, ctl) => pool(ops, conc, op => runOp(ov, op, meta, ctl)); // adds: upload to archive.org AND register on MB in PARALLEL, per image. Position is not relied on here // (MB's add `position` only places the whole upload as one group) — the trailing reorder edit is what // establishes the final order, so register order no longer matters. (#362) async function runAdds(ov, addOps, meta, ctl) { if (meta.dry || !addOps.length) return runPool(addOps, meta.dry ? 8 : 1, ov, meta, ctl); const rowOf = op => ov.querySelector(`.as-cm-op[data-i="${op._i}"]`); const setSt = (op, s) => { rowOf(op).querySelector('.as-cm-st').textContent = s; }; const fail = (op, e) => { const row = rowOf(op); row.querySelector('.as-cm-st').textContent = '❌'; row.querySelector('.as-cm-payload').textContent = String(e && e.message || e); row.classList.add('err'); op._err = true; setRowBar(row, 100, 'err'); logErr(`Upload: ${op.label}`, e); }; const stop = (op) => { setSt(op, '⛔'); op._err = true; setRowBar(rowOf(op), 100, 'cancel'); }; addOps.forEach(op => { setSt(op, '⏳'); setRowBar(rowOf(op), null, 'busy'); }); // #698: bytes per upload, for the upload rate on the overall bar (see upRateText) const up = ctl.up = { t0: performance.now(), tEnd: 0, over: false, size: {}, sent: {}, samples: [] }; addOps.forEach(op => { up.size[op._i] = (op.it && op.it._fileObj && op.it._fileObj.size) || 0; }); await pool(addOps, 4, async op => { if (ctl && ctl.aborted) return stop(op); // #278: the live upload % drives the per-row bar (was a cramped inline "⏫94%") const sz = (op.it && op.it._fileObj && op.it._fileObj.size) ? ` (${fmtBytes(op.it._fileObj.size)})` : ''; asLog.info(`Upload: ${op.label}${sz} — uploading to archive.org…`); try { const t0 = performance.now(); await uploadStep(op.it, (l, t) => { up.sent[op._i] = l; up.size[op._i] = t; setSt(op, '⏫'); setRowBar(rowOf(op), l / t * 100, ''); }, ctl); up.sent[op._i] = up.size[op._i]; up.tEnd = performance.now(); const sec = (up.tEnd - t0) / 1000; setSt(op, '⏫'); setRowBar(rowOf(op), 100, ''); asLog.debug(`Upload: ${op.label} — uploaded ${fmtBytes(up.size[op._i])} in ${sec.toFixed(1)} s (${fmtBytes(Math.round(up.size[op._i] / Math.max(sec, 0.001)))}/s), registering`); if (ctl && ctl.aborted) return stop(op); await registerStep(op.it, meta, ctl); setSt(op, '✅'); setRowBar(rowOf(op), 100, 'done'); asLog.ok(`Upload: ${op.label} — registered on MusicBrainz ✓`); } catch (e) { (ctl && ctl.aborted) ? stop(op) : fail(op, e); } }); // parallel upload+register w/ progress (abortable via ctl); order fixed by the reorder edit below up.over = true; } /* #698 (majkinetor): "Show real time upload rate in Enter Edit screen." The bytes the uploads have sent and their rate, on the overall bar: live over the last 3 seconds while any upload runs (all of them together, up to 4 in parallel), then the average from the first byte to the last upload's end. Read on the bar's 150 ms tick; nothing when the run has no uploads. */ function upRateText(up) { if (!up) return ''; let sent = 0, size = 0; for (const k in up.size) { size += up.size[k]; sent += up.sent[k] || 0; } const now = performance.now(); const rate = b => fmtBytes(Math.round(b)) + '/s'; if (up.over || (sent >= size && up.tEnd)) return sent && up.tEnd ? ` · ↑ ${fmtBytes(sent)} at ${rate(sent / Math.max((up.tEnd - up.t0) / 1000, 0.001))}` : ''; up.samples.push([now, sent]); while (up.samples.length > 2 && now - up.samples[0][0] > 3000) up.samples.shift(); const [t0, b0] = up.samples[0]; return ` · ↑ ${fmtBytes(sent)} / ${fmtBytes(size)}` + (now - t0 >= 500 ? ` · ${rate((sent - b0) / ((now - t0) / 1000))}` : ''); } async function runPlan(ov, plan, meta, opsToRun) { const goBtn = ov.querySelector('.as-cm-go'), cancelBtn = ov.querySelector('.as-cm-cancel'); goBtn.disabled = true; // #668 the ▾ (Dry run) is off while anything runs, and gone for good once a live // run has started — from then on the main button is Repeat / Done, not a choice. const moreBtn = ov.querySelector('.as-cm-more'); if (moreBtn) { moreBtn.disabled = true; if (!meta.dry) ov.querySelector('.as-cm-split').classList.add('live'); } // #275: `opsToRun` set → Repeat run (just the failed ops). Keep the original // `_i` row mapping; reset each retried row's ❌/error back to pending first. const isRepeat = !!opsToRun; let ops = opsToRun || plan; // #362 register runs in parallel, so ORDER is set by the trailing reorder edit — a repeat that retries // any upload must re-run the reorder too (it re-reads the now-succeeded image ids). Pull the plan's // reorder op into this run if a retried add isn't already accompanied by it. if (isRepeat && ops.some(o => o.kind === 'add')) { const ro = plan.find(o => o.kind === 'reorder'); if (ro && !ops.includes(ro)) ops = ops.concat(ro); } if (!isRepeat) plan.forEach((op, i) => { op._i = i; }); else ops.forEach(op => { op._err = false; const row = ov.querySelector(`.as-cm-op[data-i="${op._i}"]`); if (row) { row.classList.remove('err'); const st = row.querySelector('.as-cm-st'); if (st) st.textContent = '○'; const pl = row.querySelector('.as-cm-payload'); if (pl) pl.textContent = ''; } }); // ctl carries the abort flag + the live xhrs/fetch-signal so Cancel works mid-run const ctl = { aborted: false, xhrs: new Set(), ac: new AbortController() }; // #269 while a live run is in flight: block accidental backdrop dismissal (see // enterEdit) AND warn before the page is unloaded, so edits can't be silently // orphaned by clicking out or navigating away. Both are cleared the moment the // run finishes — the unload guard BEFORE the clean-run auto-reload below, or it // would block its own reload. let unloadGuard = null; if (!meta.dry) { ov._running = true; unloadGuard = e => { e.preventDefault(); e.returnValue = ''; return ''; }; window.addEventListener('beforeunload', unloadGuard); } const endRun = () => { ov._running = false; if (unloadGuard) { window.removeEventListener('beforeunload', unloadGuard); unloadGuard = null; } }; if (meta.dry) { cancelBtn.disabled = true; } else { cancelBtn.disabled = false; cancelBtn.textContent = 'Cancel'; cancelBtn.onclick = () => { // abort in-flight uploads + skip the rest if (ctl.aborted) { ov.remove(); return; } ctl.aborted = true; try { ctl.ac.abort(); } catch (e) {} ctl.xhrs.forEach(x => { try { x.abort(); } catch (e) {} }); cancelBtn.textContent = 'Cancelling…'; cancelBtn.disabled = true; }; } const CONC = meta.dry ? 8 : 4; // modest concurrency live to stay friendly to MB // #278: overall progress — a big batch buries "how much is left" in a long scroll, // so show a header bar + "done / total" that ticks as each op reaches a terminal // state. Polled (not threaded through every status-set site) so the count is robust. const prog = ov.querySelector('.as-cm-prog'); const progFill = prog.querySelector('.as-cm-prog-fill'), progTxt = prog.querySelector('.as-cm-prog-txt'); /* #566 (majkinetor, "progress bar was glitchy"): this counted over `ops`, which on a REPEAT is only the failed subset — his shot reads "2 / 2 · 100%" above a list of five rows, three of them already green. The bar belongs to the commit, so count the whole plan; a retried row is reset to pending first (see above), so it climbs back through the same numbers. */ const tickOverall = () => { let done = 0; for (const op of plan) { const r = ov.querySelector(`.as-cm-op[data-i="${op._i}"]`); const s = r ? r.querySelector('.as-cm-st').textContent : ''; if ('✅👁❌⛔⏭'.includes(s)) done++; } const total = plan.length, pct = total ? Math.round(done / total * 100) : 100; progFill.style.width = pct + '%'; progTxt.textContent = `${done} / ${total} · ${pct}%${upRateText(ctl.up)}`; }; prog.hidden = false; tickOverall(); if (ov._ar) ov._ar.running = true; // #566 — no schedule may fire while a run is in flight const progTimer = setInterval(tickOverall, 150); if (!meta.dry) asLog.info(`Commit: ${isRepeat ? 'retrying' : 'applying'} ${ops.length} edit${ops.length === 1 ? '' : 's'}${meta.votable ? ' (votable)' : ''}`); // uploads + register run in parallel; edits/removes parallel; the reorder edit runs LAST and sets order. try { await runAdds(ov, ops.filter(o => o.kind === 'add'), meta, ctl); if (!ctl.aborted) await runPool(ops.filter(o => o.kind === 'edit' || o.kind === 'remove'), CONC, ov, meta, ctl); if (!ctl.aborted) await runPool(ops.filter(o => o.kind === 'reorder'), 1, ov, meta, ctl); } finally { clearInterval(progTimer); tickOverall(); // settle the overall bar on the final tally if (ov._ar) ov._ar.running = false; // #566 endRun(); // run finished/failed — re-allow backdrop close and drop the unload guard (before any auto-reload) } cancelBtn.disabled = false; cancelBtn.textContent = 'Close'; cancelBtn.onclick = () => { arStop(ov); ov.remove(); }; if (ctl.aborted) { // mark any not-yet-started ops as cancelled, leave the modal up ov.querySelectorAll('.as-cm-op').forEach(r => { const s = r.querySelector('.as-cm-st'); if (s.textContent === '○' || s.textContent === '⏳') { s.textContent = '⛔'; setRowBar(r, 100, 'cancel'); } }); tickOverall(); asLog.warn('Commit: cancelled by user'); goBtn.textContent = 'Cancelled'; goBtn.disabled = true; if (_mcCommitDone) { _mcCommitDone({ ok: false, errs: 0, total: ops.length, cancelled: true }); _mcCommitDone = null; } return; } if (!meta.dry) { const b = ov.querySelector('.as-cm-go'); const errs = ov.querySelectorAll('.as-cm-op.err').length; asLog[errs ? 'warn' : 'ok'](`Commit: finished — ${errs ? `${errs} failed of ${ops.length}` : `all ${ops.length} succeeded`}`); if (_mcCommitDone) { // #680: Mission Control's frame reports back, and doesn't reload const report = extra => { if (!_mcCommitDone) return; const n = ov.querySelectorAll('.as-cm-op.err').length; _mcCommitDone(Object.assign({ ok: !n, errs: n, total: ops.length, error: n ? [...ov.querySelectorAll('.as-cm-op.err .as-cm-payload')].map(x => x.textContent.trim()).filter(Boolean).join(' | ').slice(0, 300) : '' }, extra || {})); _mcCommitDone = null; }; // #741: a failed operation is repeated as on the cover-art page (auto-repeat, its window and attempts), // each attempt told to Mission Control; it hears the outcome once it is clean or auto-repeat gives up, // with retry: true, so it can offer to try again if (!errs || !SETTINGS.autoRepeat) { report(errs ? { retry: true } : null); return; } ov._arGiveUp = why => report({ retry: true, gaveUp: why }); mcFramePost({ type: 'progress', note: `${errs} failed: repeating` }); arSchedule(ov, () => runPlan(ov, plan, meta, plan.filter(o => o._err))); return; } if (!errs) { // #234: clean run → reload automatically so the gallery shows the new // state (brief pause so the ✅s are visible first). // #248 on the add page there's nothing to reload INTO — land on the cover-art // tab so the freshly-uploaded covers show in the normal gallery. b.textContent = IS_ADD ? 'Done — opening cover art…' : 'Done — reloading…'; b.disabled = true; b.classList.remove('as-cm-repeat'); // clear the error styling if a repeat just went clean setTimeout(() => { if (IS_ADD) location.href = `${ENT.base}/${ENT.art}`; else location.reload(); }, 900); } else { // #275: something failed — offer to RE-RUN just the failed ops in place // (uploads / comment / type changes are expensive to redo by hand, so // don't force a full reload that throws the staged work away). Reddish to // signal the error state. b.textContent = `Repeat (${errs} failed)`; b.disabled = false; b.classList.add('as-cm-repeat'); const again = () => runPlan(ov, plan, meta, plan.filter(o => o._err)); /* #566: a manual press cancels the WAIT and goes now — it used to call arStop, which marks the schedule abandoned, so one manual Repeat killed auto-repeat for the rest of the window ("it just stopped after that retry"). The budget and the attempt count carry on from where they were. */ b.onclick = () => { arCancelPending(ov); again(); }; // #566: do it by itself when asked to. State lives on the overlay so it // survives the recursion through runPlan, and the countdown is torn down // by arStop from Cancel/Close as well as from a manual Repeat. if (SETTINGS.autoRepeat) arSchedule(ov, again); } } else { ov.querySelector('.as-cm-go').disabled = false; if (moreBtn) moreBtn.disabled = false; } } // ── #566 auto-repeat of failed operations ──────────────────────────────────── // The state hangs off the overlay because runPlan recurses into itself for a // repeat: a module-level counter would be shared by two commit windows and // would not reset when one is closed. function arTeardown(ov) { const st = ov && ov._ar; if (!st) return; clearTimeout(st.timer); clearInterval(st.tick); st.timer = st.tick = null; st.gen++; // anything still holding an older gen is now inert } // Cancel / Close: give up on auto-repeat entirely. function arStop(ov) { if (!ov || !ov._ar) return; arTeardown(ov); ov._ar.abandoned = true; const el = ov.querySelector('.as-cm-ar'); // #566 follow-up: emptied and made invisible, never removed from the layout — // its row is reserved so the buttons above it cannot move. if (el) { el.className = 'as-cm-ar'; el.textContent = ''; } } /* #566 — a manual Repeat: drop the pending wait, keep the schedule. Distinct from arStop, and that distinction is the bug he reported: the button used to abandon the schedule, so pressing it once meant no further attempts and no countdown for the rest of the window. */ function arCancelPending(ov) { if (!ov || !ov._ar) return; arTeardown(ov); const el = ov.querySelector('.as-cm-ar'); if (el) { el.className = 'as-cm-ar'; el.textContent = ''; } } function arNote(ov, text, cls) { const el = ov.querySelector('.as-cm-ar'); if (!el) return; el.className = 'as-cm-ar on' + (cls ? ' ' + cls : ''); el.textContent = text; } /* How many ops are failing RIGHT NOW. Read off the rows, never captured: the old countdown closed over the count from when it was scheduled, which is why his shot shows "3 failing" beside a button reading "Repeat (1 failed)". */ const arErrs = ov => ov.querySelectorAll('.as-cm-op.err').length; function arSchedule(ov, again) { const st = ov._ar || (ov._ar = { n: 0, t0: Date.now(), abandoned: false, gen: 0, timer: null, tick: null, running: false }); if (st.abandoned) return; arTeardown(ov); // one owner: drop whatever was pending const gen = st.gen; // …and let only this generation act const budgetMs = arMinutes() * 60000, maxTries = arTimes(); const usedMs = Date.now() - st.t0; // Both limits, whichever comes first — and the NEXT attempt has to fit inside // the window, otherwise it is scheduled only to be cut off mid-flight. if (st.n >= maxTries || usedMs >= budgetMs) { const why = st.n >= maxTries ? `${st.n} attempt${st.n === 1 ? '' : 's'}` : `${fmtDur(usedMs)} of ${arMinutes()}m`; arNote(ov, `Auto-repeat gave up after ${why} — ${arErrs(ov)} still failing. Press Repeat to keep trying.`, 'as-cm-ar-done'); asLog.warn(`Commit: auto-repeat gave up after ${why} — ${arErrs(ov)} still failing`); if (ov._arGiveUp) { const f = ov._arGiveUp; ov._arGiveUp = null; f(why); } // #741: Mission Control's frame reports now return; } st.n++; const delay = arDelayMs(st.n); if (MC_FRAME) mcFramePost({ type: 'progress', note: `${arErrs(ov)} failed: repeating in ${Math.round(delay / 1000)} s (attempt ${st.n} of ${maxTries})` }); // #741 const at = Date.now() + delay; const fire = () => { if (st.gen !== gen || st.abandoned || !document.body.contains(ov)) return; /* Never start on top of a run. The old code could not express this, and a stale timer firing into a live run is the shape of both the glitchy bar and a countdown that outlives its own attempt. Re-check shortly instead. */ if (st.running) { clearTimeout(st.timer); st.timer = setTimeout(fire, 1500); return; } arTeardown(ov); arNote(ov, `Auto-repeat: attempt ${st.n}/${maxTries} running…`); asLog.info(`Commit: auto-repeat attempt ${st.n}/${maxTries} (${fmtDur(Date.now() - st.t0)} of ${arMinutes()}m used) — retrying ${arErrs(ov)} failed op(s)`); again(); }; const render = () => { if (st.gen !== gen) return; // a ticker from an older schedule says nothing const left = at - Date.now(); /* He saw the countdown stop at "in 0s" once, with no give-up message and no log — a state the old code could sit in forever, since a frozen ticker and a timer that never fires both leave the last text on screen. Past due it says so, and if it STAYS past due (a throttled background tab will do it) the tick starts the attempt itself rather than waiting for a timer that may never arrive. */ if (left <= 0) { arNote(ov, `Auto-repeat: attempt ${st.n}/${maxTries} due${st.running ? ' — waiting for the current run' : ' — starting…'} · ${fmtDur(Date.now() - st.t0)} of ${arMinutes()}m used · ${arErrs(ov)} failing`); if (!st.running && Date.now() - at > 3000) fire(); return; } arNote(ov, `Auto-repeat: attempt ${st.n}/${maxTries} in ${fmtDur(left)} · ${fmtDur(Date.now() - st.t0)} of ${arMinutes()}m used · ${arErrs(ov)} failing`); }; render(); st.tick = setInterval(render, 1000); st.timer = setTimeout(fire, delay); } // ── lightbox (#230: click image → popup, ←→↑↓ navigate) ─────────────────────── let _lb = null; // current lightbox image id let _z = { s: 1, x: 0, y: 0 }; // wheel-zoom state (scale + translate) let _pinch = null, _pan = null; // #251 active touch pinch-zoom / one-finger pan function applyZoom(img) { img.style.transform = `translate(${_z.x}px,${_z.y}px) scale(${_z.s})`; img.style.cursor = _z.s > 1 ? (SETTINGS.followPan ? 'crosshair' : 'grab') : ''; } function resetZoom() { _z = { s: 1, x: 0, y: 0 }; const img = document.querySelector('.as-lb-img'); if (img) applyZoom(img); } // keyboard zoom (↑/↓ in the lightbox) — anchored on the image centre, same step as the wheel function zoomKey(dir) { const img = document.querySelector('.as-lb-img'); if (!img) return; const ns = Math.min(8, Math.max(1, _z.s * (dir > 0 ? 1.2 : 1 / 1.2))); if (ns === _z.s) return; const r = ns / _z.s; _z.x *= r; _z.y *= r; _z.s = ns; if (ns === 1) { _z.x = 0; _z.y = 0; } applyZoom(img); } const visible = () => grouped().flatMap(g => g.items); // flat, in displayed order function openLightbox(id) { const it0 = byId(id); if (it0 && it0._pdf) { window.open(it0._img, '_blank', 'noopener'); return; } // PDFs render in a new tab _lb = id; _cursorId = id; _lbEditCmt = false; let ov = document.getElementById('as-lb'); if (!ov) { ov = document.createElement('div'); ov.id = 'as-lb'; ov.innerHTML = `<button class="as-lb-del" title="Mark for removal (Del)">🗑️</button> <div class="as-lb-top"><button class="as-lb-play" title="slideshow (P)">▶ Play</button><button class="as-lb-x" title="close (Esc)">✕</button></div> <button class="as-lb-nav as-lb-prev" title="previous (←)">‹</button> <img class="as-lb-img" alt=""> <button class="as-lb-nav as-lb-next" title="next (→)">›</button> <div class="as-lb-bar"><div class="as-lb-caprow"><div class="as-lb-cap"></div> <div class="as-lb-dlwrap"><button class="as-lb-dl" title="Download original">⬇ Download</button><button class="as-lb-dlcaret" title="Other sizes">▾</button> <div class="as-lb-dlmenu"><button data-sz="original">Original</button><button data-sz="1200">1200 px</button><button data-sz="500">500 px</button><button data-sz="250">250 px</button></div></div> <div class="as-lb-rotwrap"><button class="as-lb-rot" data-deg="-90" title="Rotate left 90°">↶</button><button class="as-lb-rot" data-deg="90" title="Rotate right 90°">↷</button></div></div> <div class="as-lb-cmtarea"></div></div>`; document.body.appendChild(ov); ov.querySelector('.as-lb-x').onclick = closeLightbox; ov.querySelector('.as-lb-del').onclick = e => { e.stopPropagation(); deleteLbCover(); }; const dlMenu = ov.querySelector('.as-lb-dlmenu'); let _dlJustClosed = false; ov.querySelector('.as-lb-dl').onclick = e => { e.stopPropagation(); dlMenu.classList.remove('open'); const it = byId(_lb); if (it) dlOne(it); }; ov.querySelector('.as-lb-dlcaret').onclick = e => { e.stopPropagation(); dlMenu.classList.toggle('open'); }; dlMenu.querySelectorAll('button').forEach(b => b.onclick = e => { e.stopPropagation(); dlMenu.classList.remove('open'); const it = byId(_lb); if (it) dlOne(it, b.dataset.sz); }); ov.querySelectorAll('.as-lb-rot').forEach(b => b.onclick = e => { e.stopPropagation(); rotateLbCover(+b.dataset.deg); }); // click anywhere outside the Download control closes its size menu (capture so it // fires regardless of stopPropagation). _dlJustClosed bridges the mousedown→click // gap so a backdrop click that dismisses the menu doesn't also close the viewer. document.addEventListener('mousedown', e => { if (dlMenu.classList.contains('open') && !(e.target.closest && e.target.closest('.as-lb-dlwrap'))) { dlMenu.classList.remove('open'); _dlJustClosed = true; setTimeout(() => { _dlJustClosed = false; }, 0); } }, true); ov.querySelector('.as-lb-play').onclick = e => { e.stopPropagation(); togglePlay(); }; ov.querySelector('.as-lb-prev').onclick = e => { e.stopPropagation(); lbNav(-1); }; ov.querySelector('.as-lb-next').onclick = e => { e.stopPropagation(); lbNav(1); }; // a backdrop click while a type popover is open OR the comment is focused // should dismiss THAT (handled by their own outside-click/blur), not close // the whole viewer — _popJustClosed / _lbJustBlurred bridge the mousedown→click gap // #595: onBackdropClick, so a drag that ENDS on the backdrop (panning a // zoomed image, dragging a selection out of the comment box) is not a close. onBackdropClick(ov, closeLightbox, () => !_popJustClosed && !_lbJustBlurred && !_dlJustClosed && !document.querySelector('.as-pop')); // wheel zooms the image toward the cursor (instead of scrolling the page behind) ov.addEventListener('wheel', e => { e.preventDefault(); const img = ov.querySelector('.as-lb-img'); const r = img.getBoundingClientRect(); const cx = r.left + r.width / 2 - _z.x, cy = r.top + r.height / 2 - _z.y; // untransformed centre const relx = e.clientX - cx, rely = e.clientY - cy; const ns = Math.min(8, Math.max(1, _z.s * (e.deltaY < 0 ? 1.2 : 1 / 1.2))); if (ns === _z.s) return; _z.x = relx - ns * (relx - _z.x) / _z.s; _z.y = rely - ns * (rely - _z.y) / _z.s; _z.s = ns; if (ns === 1) { _z.x = 0; _z.y = 0; } applyZoom(img); }, { passive: false }); // drag to pan when zoomed (skipped when follow-pan is on — mousemove handles it) ov.querySelector('.as-lb-img').addEventListener('mousedown', e => { if (SETTINGS.followPan || _z.s <= 1) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY, ox = _z.x, oy = _z.y, img = e.currentTarget; const mv = ev => { _z.x = ox + (ev.clientX - sx); _z.y = oy + (ev.clientY - sy); applyZoom(img); }; const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); // #323 follow-pan: when zoomed, moving the mouse over the image pans the view // (cursor position maps across the image's extent). Default on; only over the // image itself, so the bars/buttons stay usable. ov.addEventListener('mousemove', e => { if (!SETTINGS.followPan || _z.s <= 1) return; const img = ov.querySelector('.as-lb-img'); if (!img || e.target !== img) return; const r = img.getBoundingClientRect(); const w0 = r.width / _z.s, h0 = r.height / _z.s; if (!w0 || !h0) return; const cx = r.left + r.width / 2 - _z.x, cy = r.top + r.height / 2 - _z.y; // pan-invariant centre const clamp = v => v < -1 ? -1 : v > 1 ? 1 : v; const fx = clamp((e.clientX - cx) / (w0 / 2)), fy = clamp((e.clientY - cy) / (h0 / 2)); _z.x = -fx * (_z.s - 1) * (w0 / 2); _z.y = -fy * (_z.s - 1) * (h0 / 2); applyZoom(img); }); // #251 mobile: just the full image — swipe left/right to navigate, swipe down // to close, tap toggles the controls; pinch to zoom, one finger to pan, tap to // reset. (hidden chrome by default on a touch screen.) ov.classList.toggle('as-lb-touch', matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window); let tsx = 0, tsy = 0, tmoved = false, tmulti = false; ov.addEventListener('touchstart', e => { tmulti = e.touches.length > 1; if (tmulti) return; tsx = e.touches[0].clientX; tsy = e.touches[0].clientY; tmoved = false; }, { passive: true }); ov.addEventListener('touchmove', e => { if (tmulti || e.touches.length > 1) { tmulti = true; return; } if (Math.hypot(e.touches[0].clientX - tsx, e.touches[0].clientY - tsy) > 8) tmoved = true; }, { passive: true }); ov.addEventListener('touchend', e => { if (tmulti) return; // a pinch/2-finger gesture, not a swipe if (_z.s > 1) { if (!tmoved && !_pinch) resetZoom(); return; } // zoomed: tap → fit, else pan handled it const t = e.changedTouches[0], dx = t.clientX - tsx, dy = t.clientY - tsy; if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.3) { lbNav(dx < 0 ? 1 : -1); return; } if (dy > 80 && dy > Math.abs(dx) * 1.3) { closeLightbox(); return; } if (!tmoved) ov.classList.toggle('as-lb-chrome'); // tap toggles the controls }, { passive: true }); // pinch-zoom toward the pinch midpoint (mirrors the wheel zoom), one-finger pan when zoomed const limg = ov.querySelector('.as-lb-img'); const tdist = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); limg.addEventListener('touchstart', e => { if (e.touches.length === 2) { e.preventDefault(); const m = { x: (e.touches[0].clientX + e.touches[1].clientX) / 2, y: (e.touches[0].clientY + e.touches[1].clientY) / 2 }; _pinch = { d0: tdist(e.touches[0], e.touches[1]), s0: _z.s, x0: _z.x, y0: _z.y, m }; _pan = null; } else if (e.touches.length === 1 && _z.s > 1) { _pan = { x: e.touches[0].clientX, y: e.touches[0].clientY, x0: _z.x, y0: _z.y }; } }, { passive: false }); limg.addEventListener('touchmove', e => { if (_pinch && e.touches.length === 2) { e.preventDefault(); const ns = Math.min(8, Math.max(1, _pinch.s0 * tdist(e.touches[0], e.touches[1]) / _pinch.d0)); const r = limg.getBoundingClientRect(), cx = r.left + r.width / 2 - _z.x, cy = r.top + r.height / 2 - _z.y; const relx = _pinch.m.x - cx, rely = _pinch.m.y - cy; _z.x = relx - ns * (relx - _pinch.x0) / _pinch.s0; _z.y = rely - ns * (rely - _pinch.y0) / _pinch.s0; _z.s = ns; if (ns === 1) { _z.x = 0; _z.y = 0; } applyZoom(limg); } else if (_pan && e.touches.length === 1 && _z.s > 1) { e.preventDefault(); _z.x = _pan.x0 + (e.touches[0].clientX - _pan.x); _z.y = _pan.y0 + (e.touches[0].clientY - _pan.y); applyZoom(limg); } }, { passive: false }); limg.addEventListener('touchend', e => { if (e.touches.length < 2) _pinch = null; if (e.touches.length === 0) _pan = null; }, { passive: false }); } resetZoom(); // a fresh open starts un-zoomed; ←/→ navigation keeps the zoom ov.classList.remove('as-lb-chrome'); // #251 touch: start as just-the-image, tap to reveal controls paintLightbox(); preloadNeighbors(); ov.style.display = 'flex'; document.body.classList.add('as-lb-open'); // #678 shift toasts clear of the viewer's footer bar } // prefetch the adjacent covers' 1200px so arrow-nav is instant const _preloaded = new Set(); function preloadNeighbors() { const seq = visible().filter(it => !it._pdf); const i = seq.findIndex(it => String(it.id) === String(_lb)); if (i < 0 || !seq.length) return; [1, 2, -1, -2].forEach(d => { // prefetch 2 covers each way so nav stays instant const it = seq[(i + d + seq.length) % seq.length]; if (it && !it._new && !_preloaded.has(it.id)) { _preloaded.add(it.id); const im = new Image(); im.src = thumb(it.id, 1200); } }); } function paintLightbox() { const ov = document.getElementById('as-lb'); if (!ov) return; const it = byId(_lb); if (!it) return; const img = ov.querySelector('.as-lb-img'); const src = it._new ? it._file : thumb(it.id, 1200); // NOTE: no resetZoom here — the zoom level is kept while navigating ←/→ (#234); // it's reset only on a fresh open (openLightbox) and on close. ov.classList.remove('na'); // hide until the NEW src has decoded — otherwise the previous image lingers // visibly while the 1200px loads ("original shows shortly") img.classList.add('loading'); img.onload = () => { img.classList.remove('loading'); ov.classList.remove('na'); }; img.onerror = () => { // thumbnails aren't generated yet (pending / just uploaded) — show the original, // which MB serves before the thumbs exist (its "original" link). #230 if (!it._new && it._img && img.src !== it._img) { img.src = it._img; return; } img.classList.remove('loading'); ov.classList.add('na'); }; img.src = src; if (img.complete && img.naturalWidth) img.classList.remove('loading'); const dims = [it.bytes ? fmtSize(it.bytes) : '', it.w && it.h ? `${it.w} × ${it.h}` : ''].filter(Boolean).join(' · '); // #380-followup show file size in full screen too, like the card const cap = ov.querySelector('.as-lb-cap'); // type is a clickable chip (same picker as the grid pills) so it can be set full-screen cap.innerHTML = `<button class="as-lb-type${it.types.length ? '' : ' as-type-add'}" title="set ${ITEM} type">${it.types.length ? esc(it.types.join(', ')) : '+ type'}</button>${dims ? `<span class="as-lb-dim">${esc(dims)}</span>` : ''}`; cap.querySelector('.as-lb-type').onclick = e => { e.stopPropagation(); openTypePopFor(byId(_lb), e.currentTarget, () => { _lbDirty = true; paintLightbox(); }); }; paintCmtArea(ov, it); } let _lbEditCmt = false; let _lbJustBlurred = false; // bridges the mousedown-blur → click gap so defocusing the comment doesn't also close the viewer function paintCmtArea(ov, it) { const area = ov.querySelector('.as-lb-cmtarea'); if (!area) return; if (_lbEditCmt) { area.innerHTML = `<input class="as-lb-cmt" placeholder="comment…" spellcheck="false" list="as-cmt-presets">`; const inp = area.querySelector('.as-lb-cmt'); inp.value = it.comment || ''; inp.oninput = () => { const cur = byId(_lb); if (cur) { cur.comment = inp.value; _lbDirty = true; } }; inp.onblur = () => { _lbEditCmt = false; _lbJustBlurred = true; setTimeout(() => { _lbJustBlurred = false; }, 0); paintCmtArea(ov, byId(_lb)); }; // Enter saves and advances to the next image, keeping its comment open for editing. inp.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); inp.blur(); return; } if (e.key !== 'Enter') return; e.preventDefault(); const cur = byId(_lb); if (cur) { cur.comment = inp.value; _lbDirty = true; } inp.onblur = null; // we drive the transition — don't let the stale blur cancel edit mode lbNav(1, true); }; inp.focus(); } else if (it.comment) { // not editing: show the comment as plain centered text (no input box), like the gallery area.innerHTML = `<div class="as-lb-cmt-text" title="edit comment">${esc(it.comment)}</div>`; area.querySelector('.as-lb-cmt-text').onclick = () => { _lbEditCmt = true; paintCmtArea(ov, byId(_lb)); }; } else { area.innerHTML = `<button class="as-lb-cmtadd" title="add a comment">✎ comment</button>`; area.querySelector('.as-lb-cmtadd').onclick = () => { _lbEditCmt = true; paintCmtArea(ov, byId(_lb)); }; } } let _lbDirty = false; function closeLightbox() { stopPlay(); resetZoom(); _lb = null; const ov = document.getElementById('as-lb'); if (ov) ov.style.display = 'none'; document.body.classList.remove('as-lb-open'); const dm = ov && ov.querySelector('.as-lb-dlmenu'); if (dm) dm.classList.remove('open'); // don't reopen with the menu still showing if (_lbDirty) { _lbDirty = false; render(); } // reflect comment edits in the grid } let _play = null; function updatePlayBtn() { const b = document.querySelector('.as-lb-play'); if (b) b.textContent = _play ? '⏸ Pause' : '▶ Play'; } function stopPlay() { if (_play) { clearInterval(_play); _play = null; updatePlayBtn(); } } function togglePlay() { if (_play) stopPlay(); else { _play = setInterval(() => lbNav(1), 3000); updatePlayBtn(); } } function lbNav(d, keepEdit) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); // a dangling type pop must not survive a cover change (incl. slideshow) const seq = visible().filter(it => !it._pdf); // PDFs open in a tab, not the lightbox if (!seq.length) return; let i = seq.findIndex(it => String(it.id) === String(_lb)); if (i < 0) i = 0; i = (i + d + seq.length) % seq.length; // keepEdit (Enter in the comment field) carries edit-mode to the next image _lb = seq[i].id; _cursorId = _lb; _lbEditCmt = !!keepEdit; paintLightbox(); markCursor(true); preloadNeighbors(); } // Del in full-screen view → mark the current cover for removal (same as the grid's // delete: _del moves it to "Marked for removal", undoable there), then advance to // the next cover — or close the lightbox if that was the last one. function deleteLbCover() { const it = byId(_lb); if (!it) return; const seq = visible().filter(x => !x._pdf); const i = seq.findIndex(x => String(x.id) === String(_lb)); it._del = true; it._sel = false; _lbDirty = true; toast(`“${(it.types && it.types[0]) || ITEM}” marked for removal — undo in the grid`); const rest = visible().filter(x => !x._pdf); // recomputed without the just-deleted cover if (!rest.length) { closeLightbox(); return; } const nx = rest[Math.min(i, rest.length - 1)]; resetZoom(); _lb = nx.id; _cursorId = nx.id; _lbEditCmt = false; paintLightbox(); markCursor(true); preloadNeighbors(); } // ── rotate (#678) ───────────────────────────────────────────────────────────── // Rotate an image blob by a right angle (CW degrees) on a canvas and re-encode. // Anything that isn't JPEG re-encodes losslessly as PNG; JPEG is recompressed at // high quality — a truly lossless right-angle JPEG rotation would need a block-level // transform, out of scope here. Returns { blob, type, swap } (swap = dims transposed). async function rotateImageBlob(blob, deg) { const rot = ((deg % 360) + 360) % 360; const bmp = await createImageBitmap(blob); try { const swap = rot === 90 || rot === 270, w = bmp.width, h = bmp.height; const cv = document.createElement('canvas'); cv.width = swap ? h : w; cv.height = swap ? w : h; const ctx = cv.getContext('2d'); ctx.translate(cv.width / 2, cv.height / 2); ctx.rotate(rot * Math.PI / 180); ctx.drawImage(bmp, -w / 2, -h / 2); const type = blob.type === 'image/jpeg' ? 'image/jpeg' : 'image/png'; const out = await new Promise(res => cv.toBlob(res, type, type === 'image/jpeg' ? 0.95 : undefined)); return out ? { blob: out, type: out.type || type, swap } : null; } finally { bmp.close && bmp.close(); } } let _rotBusy = false; async function rotateLbCover(deg) { if (_rotBusy) return; const it = byId(_lb); if (!it || it._pdf || it._sourcing) return; const ov = document.getElementById('as-lb'); _rotBusy = true; if (ov) ov.classList.add('as-lb-rotbusy'); asLog.info(`Rotate: ${it._new ? 'new' : 'existing #' + it.id} by ${deg}° (${it._new ? 'in place' : 'replace'})`); try { // source pixels: a staged new cover already holds its blob; an existing cover is // fetched full-resolution from CAA (same CORS path Download uses) // upgrade http→https so fetching an existing original isn't blocked as mixed content const srcUrl = (it._new ? it._file : (it._img || imgUrl(it.id))).replace(/^http:\/\//i, 'https://'); const srcBlob = (it._new && it._fileObj) ? it._fileObj : await fetch(srcUrl).then(r => { if (!r.ok) throw new Error('fetch ' + r.status); return r.blob(); }); const r = await rotateImageBlob(srcBlob, deg); if (!r) { toast('Rotate failed — the image could not be re-encoded'); return; } const ow = it.w, oh = it.h, ext = r.type === 'image/png' ? 'png' : 'jpg'; if (it._new) { // #678 rotate the staged blob in place — nothing was ever on the release, so no replace try { URL.revokeObjectURL(it._file); } catch (e) {} const name = (it._fileObj && it._fileObj.name) || ('image.' + ext); it._file = URL.createObjectURL(r.blob); it._fileObj = new File([r.blob], name, { type: r.type }); it.bytes = r.blob.size; it.fmt = fileFormat(r.type) || it.fmt; it._contentKey = await fileKey(it._fileObj); if (it._rotatedFrom) it._rotDeg = (((it._rotDeg || 0) + deg) % 360 + 360) % 360; // #678 further rotations of a staged replacement keep the note's angle accurate if (ow && oh) { it.w = r.swap ? oh : ow; it.h = r.swap ? ow : oh; } else { it.w = 0; it.h = 0; measure(it); } _imgCache.delete(String(it.id)); resetZoom(); paintLightbox(); } else { // #678 existing published cover: CAA images are immutable, so stage the rotated copy // as a NEW cover in the same slot (keeping its types + comment) and mark the original removed const file = new File([r.blob], `${it.id}-rot.${ext}`, { type: r.type }); const nu = newItem(file, { types: it.types.slice(), comment: it.comment, exactName: true }); nu._contentKey = await fileKey(file); nu._rotatedFrom = it.id; nu._rotDeg = ((deg % 360) + 360) % 360; if (ow && oh) { nu.w = r.swap ? oh : ow; nu.h = r.swap ? ow : oh; } it._del = true; it._sel = false; const rest = MODEL.slice().sort((a, b) => a.order - b.order); rest.splice(rest.indexOf(it) + 1, 0, nu); MODEL = rest; MODEL.forEach((m, i) => m.order = i); if (!(ow && oh)) measure(nu); _lb = nu.id; _cursorId = nu.id; resetZoom(); paintLightbox(); toast('Rotated — staged as a replacement; the original is marked for removal'); } render(); } catch (e) { logErr('Rotate failed', e); toast('Rotate failed — see the log'); } finally { _rotBusy = false; if (ov) ov.classList.remove('as-lb-rotbusy'); } } // ── keyboard cursor (arrows select / move; Enter opens lightbox) ────────────── let _cursorId = null; function markCursor(scroll) { root.querySelectorAll('.as-card.as-cursor').forEach(c => c.classList.remove('as-cursor')); if (!_cursorId) return; const c = root.querySelector(`.as-card[data-id="${CSS.escape(String(_cursorId))}"]`); if (c) { c.classList.add('as-cursor'); if (scroll) c.scrollIntoView({ block: 'nearest' }); } } function moveCursor(dx, dy) { const cards = [...root.querySelectorAll('.as-card:not(.del)')]; if (!cards.length) return; let cur = cards.find(c => c.dataset.id === String(_cursorId)) || cards[0]; if (!_cursorId) { _cursorId = cur.dataset.id; markCursor(true); return; } const r0 = cur.getBoundingClientRect(); let best = null, bestD = Infinity; for (const c of cards) { if (c === cur) continue; const r = c.getBoundingClientRect(); const ddx = (r.left + r.width / 2) - (r0.left + r0.width / 2); const ddy = (r.top + r.height / 2) - (r0.top + r0.height / 2); if (dx > 0 && ddx <= 4) continue; if (dx < 0 && ddx >= -4) continue; if (dy > 0 && ddy <= 4) continue; if (dy < 0 && ddy >= -4) continue; // penalise off-axis drift so motion stays mostly in the requested direction const d = (dx ? Math.abs(ddx) + Math.abs(ddy) * 3 : Math.abs(ddy) + Math.abs(ddx) * 3); if (d < bestD) { bestD = d; best = c; } } if (best) { _cursorId = best.dataset.id; markCursor(true); } } /* #554 (majkinetor: "there is a second click again" / "That '&Paste' button should go away"). A real paste GESTURE carries its data with no permission and therefore no Chrome chip — so copy a URL, press Ctrl+V anywhere on the gallery, and it imports. That is one keystroke and no button at all, which is fewer steps than the flow he was objecting to rather than more. Ignored while typing somewhere, and while a lightbox or a popover has the page, so it can never eat a paste meant for a comment box or the URL input (which has its own handler). */ document.addEventListener('paste', e => { try { const t = e.target; if (t && (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable)) return; if (_lb) return; /* A popover no longer swallows the paste: the URL box that used to catch it inside the source panel is gone, so Ctrl+V has to work there too. */ if (!document.getElementById('as-root')) return; // not on a gallery page const txt = ((e.clipboardData && e.clipboardData.getData('text')) || '').trim(); if (!/^https?:\/\/\S+$/i.test(txt)) return; e.preventDefault(); document.querySelectorAll('.as-pop').forEach(p => p.remove()); asLog.info('Pasted a URL onto the gallery — importing ' + txt); sourceFromUrl(txt); } catch (err) { asLog.debug('paste handler: ' + (err && err.message)); } }); document.addEventListener('keydown', e => { const t = e.target; // a popover (type picker / bulk pop) is open → Escape dismisses IT first // (wherever focus is), and other keys are swallowed so navigation/zoom/delete // don't act behind it. Mirrors the backdrop-click behaviour. if (document.querySelector('.as-pop')) { if (e.key === 'Escape') { e.preventDefault(); document.querySelectorAll('.as-pop').forEach(p => p.remove()); } else if (!(t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) e.preventDefault(); return; } if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return; if (_lb) { if (e.key === 'Escape') { e.preventDefault(); closeLightbox(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); lbNav(-1); } else if (e.key === 'ArrowRight') { e.preventDefault(); lbNav(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); zoomKey(1); } // ↑ zoom in else if (e.key === 'ArrowDown') { e.preventDefault(); zoomKey(-1); } // ↓ zoom out else if (e.key === 'p' || e.key === 'P') { e.preventDefault(); togglePlay(); } else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteLbCover(); } else if (e.key === 'Enter') { e.preventDefault(); _lbEditCmt = true; paintCmtArea(document.getElementById('as-lb'), byId(_lb)); } // start editing the comment else if (e.key === 'd' || e.key === 'D') { e.preventDefault(); const it = byId(_lb); if (it) dlOne(it); } // download original return; } if (!root.isConnected || !root.querySelector('.as-card')) return; const map = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; if (map[e.key]) { e.preventDefault(); moveCursor(map[e.key][0], map[e.key][1]); } else if (e.key === 'Enter' && _cursorId) { e.preventDefault(); openLightbox(_cursorId); } else if (e.key === ' ' && _cursorId) { e.preventDefault(); const it = byId(_cursorId); if (it && !it._del) { it._sel = !it._sel; render(); } } else if ((e.key === 'Delete' || e.key === 'Backspace') && _cursorId) { // mark the focused cover for removal (mirrors the viewer's Del) e.preventDefault(); const it = byId(_cursorId); if (!it || it._del) return; const cards = [...root.querySelectorAll('.as-card:not(.del)')]; const i = cards.findIndex(c => c.dataset.id === String(_cursorId)); it._del = true; it._sel = false; // advance the cursor to the next remaining cover so Del can be pressed repeatedly const rest = cards.filter(c => c.dataset.id !== String(_cursorId)); _cursorId = rest.length ? rest[Math.min(i, rest.length - 1)].dataset.id : null; render(); } }); function openBulkTypePop(btn) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); const sel = MODEL.filter(it => it._sel && !it._del); if (!sel.length) return; const pop = document.createElement('div'); pop.className = 'as-pop'; pop.innerHTML = `<div class="as-pop-h">Set type on ${sel.length} ${ITEM}${sel.length===1?'':'s'}</div>` + `<div class="as-type-grid">${ALL_TYPES.map(t => `<label title="Right-click: replace with only this type"><input type="checkbox" value="${esc(t)}"> ${esc(t)}</label>`).join('')}</div>` + `<div class="as-pop-f"><button class="as-btn as-pop-apply">Apply (replace)</button><button class="as-btn as-pop-add">Add</button></div>`; document.body.appendChild(pop); placePop(pop, btn.getBoundingClientRect()); const picked = () => ALL_TYPES.filter(t => pop.querySelector(`input[value="${CSS.escape(t)}"]`).checked); const lbl = n => `${n} ${n === 1 ? ITEM : ITEMS}`; const replace = ts => { sel.forEach(it => it.types = ts.slice()); asLog.info(`Batch: set type [${ts.join(', ') || 'none'}] on ${lbl(sel.length)}`); if (SETTINGS.clearSelAfterOp) sel.forEach(it => it._sel = false); pop.remove(); render(); }; // #277 pop.querySelector('.as-pop-apply').onclick = () => replace(picked()); pop.querySelector('.as-pop-add').onclick = () => { const ts = picked(); sel.forEach(it => it.types = [...new Set([...it.types, ...ts])]); asLog.info(`Batch: added type [${ts.join(', ') || 'none'}] to ${lbl(sel.length)}`); if (SETTINGS.clearSelAfterOp) sel.forEach(it => it._sel = false); pop.remove(); render(); }; // #277 // right-click a type → replace all selected with ONLY that type and close pop.querySelectorAll('.as-type-grid label').forEach(lab => lab.oncontextmenu = e => { e.preventDefault(); e.stopPropagation(); document.removeEventListener('mousedown', off); replace([lab.querySelector('input').value]); }); const off = e => { if (!pop.contains(e.target)) { pop.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } // #236: set one comment on every selected cover at once. Pre-fills the shared // comment if they already agree; Apply writes it, Clear blanks them all. // Reverse-image search one cover for a higher-res copy. Per-card (the 🔍 on each // tile) so it's unambiguously one image; a still-local NEW file has no public URL // to search by. Each engine opens pre-loaded with that URL — no download + drop. function openImageSearchPop(btn, it) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); const url = it && searchUrlFor(it); // CAA original, or a sourced new cover's provider URL if (!url) { toast(it && it._new ? 'Reverse search needs a public image URL — this one is a local file (source it from a URL instead)' : 'Reverse search needs a published image'); return; } const pop = document.createElement('div'); pop.className = 'as-pop as-search-pop'; // latest (main-branch) install link for the companion — never a feature branch const COMPANION_URL = 'https://raw.githubusercontent.com/majkinetor/musicbrainz-userscripts/main/userscripts/art_station/as_picker/as_picker.user.js'; pop.innerHTML = `<div class="as-pop-h">Search for a higher-res copy<button class="as-search-all" type="button" title="Open every engine in its own tab">Open all</button></div>` + IMG_SEARCH_ENGINES.map((e, i) => `<button class="as-btn as-search-eng" data-i="${i}">${esc(e.name)}</button>`).join('') + `<div class="as-search-foot">Requires the <a href="${COMPANION_URL}" target="_blank" rel="noopener">Art Station Picker</a> script for click-capture.</div>`; document.body.appendChild(pop); placePop(pop, btn.getBoundingClientRect()); // mb_as_pick=<MBID> activates the picker companion (as_picker.user.js, if installed): it // lets you click the higher-res image anywhere and sends it back here. The value is THIS // release's MBID so the companion tags each pick with it — a pick stays destined for this // release even if this tab is closed, so it can't leak onto some other release's cover-art // page. Harmless if the companion isn't installed (engines ignore the unknown query param). // NB: a *query* param, not a #hash — Yandex's SPA blanks on an unexpected hash, not a param. const openEng = eng => { asLog.info(`Search: ${eng.name} ← ${it.types && it.types.length ? it.types.join('/') : ITEM} ${it.id}`); window.open(eng.u(url) + '&mb_as_pick=' + MBID, '_blank', 'noopener,noreferrer'); }; pop.querySelectorAll('.as-search-eng').forEach(b => b.onclick = () => { openEng(IMG_SEARCH_ENGINES[+b.dataset.i]); pop.remove(); }); const allBtn = pop.querySelector('.as-search-all'); if (allBtn) allBtn.onclick = () => { IMG_SEARCH_ENGINES.forEach(openEng); pop.remove(); }; // each in its own tab (the browser may ask to allow multiple popups the first time) const off = e => { if (!pop.contains(e.target) && e.target !== btn && !btn.contains(e.target)) { pop.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } function openBulkCommentPop(btn) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); const sel = MODEL.filter(it => it._sel && !it._del); if (!sel.length) return; const common = sel.every(it => it.comment === sel[0].comment) ? sel[0].comment : ''; const pop = document.createElement('div'); pop.className = 'as-pop as-cmt-pop'; pop.innerHTML = `<div class="as-pop-h">Comment on ${sel.length} ${ITEM}${sel.length===1?'':'s'}</div>` + `<input class="as-bulk-cmt" placeholder="comment…" spellcheck="false" list="as-cmt-presets" value="${esc(common)}">` + `<div class="as-pop-f"><button class="as-btn as-pop-apply">Apply</button><button class="as-btn as-bulk-cmt-clr">Clear</button></div>`; document.body.appendChild(pop); placePop(pop, btn.getBoundingClientRect()); const inp = pop.querySelector('.as-bulk-cmt'); inp.focus(); inp.select(); const apply = v => { sel.forEach(it => it.comment = v); asLog.info(`Batch: ${v ? `set comment “${v}”` : 'cleared comment'} on ${sel.length} ${sel.length === 1 ? ITEM : ITEMS}`); if (SETTINGS.clearSelAfterOp) sel.forEach(it => it._sel = false); pop.remove(); render(); }; // #277 pop.querySelector('.as-pop-apply').onclick = () => apply(inp.value); pop.querySelector('.as-bulk-cmt-clr').onclick = () => apply(''); inp.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); apply(inp.value); } else if (e.key === 'Escape') { e.preventDefault(); pop.remove(); } }; const off = e => { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } // ── #239 postable report (Markdown / HTML) of the selected covers ───────────── // release artist(s) + title, linked, parsed from the page header function releaseInfo() { const title = (document.querySelector('h1 bdi') || document.querySelector('h1'))?.textContent?.trim() || 'release'; const sub = document.querySelector('p.subheader') || document.querySelector('.subheader'); const artists = sub ? [...sub.querySelectorAll('a[href*="/artist/"]')] .filter(a => !/\/create(\?|$)/.test(a.getAttribute('href'))) .map(a => ({ name: a.textContent.trim(), url: location.origin + a.getAttribute('href').split(/[?#]/)[0] })) : []; return { title, url: `${location.origin}${ENT.base}`, artists }; // #623 X2: this server, not always production } const pad2 = n => String(n).padStart(2, '0'); // the logged-in MB user, for the export manifest ("Exported by"). Read the name from // the /user/<name> href — the link text may be a label like "Profile". function mbUser() { for (const a of document.querySelectorAll('a[href^="/user/"]')) { const m = (a.getAttribute('href') || '').match(/^\/user\/([^/?#]+)\/?$/); if (m) return decodeURIComponent(m[1]); } return ''; } // #244 a README.md / manifest for the download archive (and a Report type): release // header, export metadata, and the artwork list linking each type-named file to its original. // shared rows for the "Detailed table" layout / archive manifest function manifestRows(sel) { const ord = sel.slice().sort((a, b) => a.order - b.order); const pad = Math.max(2, String(Math.max(0, ...ord.map(it => it.order + 1))).length); return ord.map(it => ({ pos: pad2(it.order + 1), name: downloadName(it, it.order + 1, dlExt(it), pad).replace(/^\d+\s+/, ''), // drop the position — the Position column already has it orig: dlUrl(it), res: (it.w && it.h) ? `${it.w} × ${it.h}` : '', size: it.bytes ? fmtSize(it.bytes) : '', })); } function manifestHead() { const info = releaseInfo(); const d = new Date(), date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; return { info, date, by: mbUser() }; } // #244 markdown manifest (README.md in the archive + the Markdown "Detailed table" report) function manifestMd(sel, failed) { const { info, date, by } = manifestHead(); const artists = info.artists.length ? info.artists.map(a => `[${a.name}](${a.url})`).join(', ') : 'Unknown artist'; const out = [`# ${artists} - [${info.title}](${info.url})`, '', `- **Export date:** ${date}`]; if (by) out.push(`- **Exported by:** ${by}`); // #274: if any cover couldn't be downloaded, flag it loudly at the top of the // manifest so an incomplete archive is never mistaken for a complete one. if (failed && failed.length) { out.push('', `> ⚠ **${failed.length} file${failed.length === 1 ? '' : 's'} could not be downloaded** and ${failed.length === 1 ? 'is' : 'are'} **missing** from this archive — re-download to get ${failed.length === 1 ? 'it' : 'them'}:`, '', ...failed.map(f => `> - ${f}`)); } out.push('', '## Artwork', '', '| Position | Cover | Resolution | Size |', '| --- | --- | --- | --- |'); manifestRows(sel).forEach(r => out.push(`| ${r.pos} | [${r.name}](${r.orig}) | ${r.res} | ${r.size} |`)); out.push('', `*Report created with [Art Station](${SCRIPT_URL})${_gm ? ' v' + _gm.version : ''}*`); return out.join('\n') + '\n'; } function manifestHtml(sel) { const { info, date, by } = manifestHead(); const artists = info.artists.length ? info.artists.map(a => `<a href="${a.url}">${esc(a.name)}</a>`).join(', ') : 'Unknown artist'; const out = [`<h3>${artists} - <a href="${info.url}">${esc(info.title)}</a></h3>`, `<p><strong>Export date:</strong> ${date}${by ? `<br><strong>Exported by:</strong> ${esc(by)}` : ''}</p>`, '<table><thead><tr><th>Position</th><th>Cover</th><th>Resolution</th><th>Size</th></tr></thead><tbody>']; manifestRows(sel).forEach(r => out.push(`<tr><td>${r.pos}</td><td><a href="${r.orig}">${esc(r.name)}</a></td><td>${esc(r.res)}</td><td>${esc(r.size)}</td></tr>`)); out.push('</tbody></table>', `<p><em>Report created with <a href="${SCRIPT_URL}">Art Station</a>${_gm ? ' v' + esc(_gm.version) : ''}</em></p>`); return out.join('\n'); } // ensure resolution (loads originals) + byte sizes are known before a manifest is built async function ensureMeasured(sel) { await loadSizes(); await pool(sel.filter(it => !it.w && !it._pdf), 4, it => new Promise(res => { const src = it._new ? it._file : imgUrl(it.id); if (!src) return res(); const img = new Image(); img.onload = () => { it.w = img.naturalWidth; it.h = img.naturalHeight; res(); }; img.onerror = () => res(); img.src = src; })); } function buildReport(opts) { const info = releaseInfo(); const sel = MODEL.filter(it => it._sel && !it._del && !it._new).slice().sort(sortFn); if (opts.layout === 'detailed') return opts.format === 'html' ? manifestHtml(sel) : manifestMd(sel); // #244 table w/ position, type-name, resolution, size const sz = opts.size, W = sz === 'original' ? null : sz; const url = it => `${CAA}/${it.id}${sz === 'original' ? '' : '-' + sz}.jpg`; const alt = it => (it.types[0] || (IS_EVENT ? 'event art' : ITEM)).toLowerCase(); const cap = it => [it.types.join(', ') || 'no type', it.comment].filter(Boolean).join(' — '); const out = []; if (opts.format === 'html') { const artists = info.artists.length ? info.artists.map(a => `<a href="${a.url}">${esc(a.name)}</a>`).join(', ') : 'Unknown artist'; out.push(`<h3>${artists} - <a href="${info.url}">${esc(info.title)}</a></h3>`, ''); if (opts.layout === 'captioned') sel.forEach(it => out.push(`<p><img src="${url(it)}" alt="${esc(alt(it))}"${W ? ` width="${W}"` : ''}><br><em>${esc(cap(it))}</em></p>`)); else out.push(sel.map(it => `<img src="${url(it)}" alt="${esc(alt(it))}"${W ? ` width="${W}"` : ''}>`).join(' ')); } else { const artists = info.artists.length ? info.artists.map(a => `[${a.name}](${a.url})`).join(', ') : 'Unknown artist'; out.push(`### ${artists} - [${info.title}](${info.url})`, ''); if (opts.layout === 'captioned') sel.forEach(it => out.push(`**${cap(it)}** `, `![${alt(it)}](${url(it)})`, '')); else out.push(sel.map(it => `![${alt(it)}](${url(it)})`).join(' ')); } return out.join('\n'); } function openReport() { const sel = MODEL.filter(it => it._sel && !it._del && !it._new); const omitted = MODEL.filter(it => it._sel && it._new && !it._del && !it._sourcing).length; document.getElementById('as-report')?.remove(); const ov = document.createElement('div'); ov.id = 'as-report'; ov.innerHTML = `<div class="as-cm-box as-rp-box"> <div class="as-cm-h">Report — ${sel.length} ${ITEM}${sel.length===1?'':'s'}</div> <div class="as-rp-opts"> <label>Format <select class="as-rp-fmt"><option value="markdown">Markdown</option><option value="html">HTML</option></select></label> <label>Image size <select class="as-rp-size"><option>250</option><option>500</option><option>1200</option><option value="original">original</option></select></label> <label>Layout <select class="as-rp-layout"><option value="inline">Inline images</option><option value="captioned">With types & comments</option><option value="detailed">Detailed table</option></select></label> </div> <textarea class="as-rp-out" rows="9" spellcheck="false" readonly></textarea> <div class="as-cm-f"><span class="as-rp-note">${omitted ? `${omitted} unsaved upload${omitted===1?'':'s'} omitted (no CAA URL yet)` : ''}</span><span class="as-sp"></span><button class="as-btn as-rp-copy">📋 Copy</button><button class="as-btn as-cm-cancel">Close</button></div> </div>`; document.body.appendChild(ov); const close = () => { ov.remove(); maybeClearSel(); }; // #277: report counts as "using" the selection → clear on close // #595: same box (.as-cm-box is resize:both) and a textarea people select // out of — the report dialog had the identical closes-on-drag-release bug. onBackdropClick(ov, close); rememberDialogSize(ov.querySelector('.as-cm-box'), 'report'); // #595 ov.querySelector('.as-cm-cancel').onclick = close; const ta = ov.querySelector('.as-rp-out'); const regen = async () => { const opts = { format: ov.querySelector('.as-rp-fmt').value, size: ov.querySelector('.as-rp-size').value, layout: ov.querySelector('.as-rp-layout').value }; if (opts.layout === 'detailed') { ta.value = 'Measuring resolutions…'; await ensureMeasured(sel); } // load dimensions for the table ta.value = buildReport(opts); }; ov.querySelectorAll('select').forEach(s => s.onchange = regen); ov.querySelector('.as-rp-copy').onclick = async () => { ta.select(); try { await navigator.clipboard.writeText(ta.value); } catch (e) { document.execCommand('copy'); } asLog.info(`Batch: copied ${ov.querySelector('.as-rp-fmt').value} report — ${sel.length} ${sel.length === 1 ? ITEM : ITEMS} (${ov.querySelector('.as-rp-layout').value})`); const b = ov.querySelector('.as-rp-copy'); b.textContent = '✓ Copied'; setTimeout(() => { b.textContent = '📋 Copy'; }, 1200); }; regen(); } // ── styles ─────────────────────────────────────────────────────────────────── // The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and are // inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never in this block. // Every script that adopts them declares the same :root rule, so when several // run on one page (or all of them do, via String Theory) the duplicates are // identical and the last one simply wins. // <ST-TOKENS> — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // </ST-TOKENS> // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here. // <ST-UI> — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on <html>, which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'<svg…>' | '<img…>', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // o.history opt-in history of past sessions (#705), below // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // viewer appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) : '--:--:--.---'; }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + (e.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button, input, select') || pop.classList.contains('full')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); views = views.filter(function (v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in <body>: a permanent stray node under <html>, outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No <body> yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and <head> and <body> don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } // </ST-UI> const css = MBU_TOKENS + MBU_UI_CSS + ` /* --as-acc / --as-warn are kept as aliases so the ~34 call sites that already used them don't all have to churn; both now resolve through the shared set. Note --as-warn was never a warning colour — it is the destructive red, which is precisely the drift #562 is about, so it points at --mbu-error. */ :root{ --as-tile:${SETTINGS.tile}px; --as-acc:var(--mbu-accent); --as-warn:var(--mbu-error); } /* the sticky toolbar grows a line when a selection adds the bulk-action buttons; scroll-anchoring would then nudge the page on every right-click select. Disable anchoring so the scrollbar stays put (Art Station owns this page's scroll). */ html{overflow-anchor:none} #as-root{font:14px/1.4 var(--mbu-font);color:var(--mbu-text);margin:0 0 18px} .as-ia{margin:0 0 8px;padding:9px 13px;border-radius:8px;font-size:13px;line-height:1.45;border:1px solid} .as-ia-warn{background:var(--mbu-warn-bg);border-color:var(--mbu-warn-border);color:var(--mbu-warn)} .as-ia-dark{background:var(--mbu-error-bg);border-color:var(--mbu-error-border);color:var(--mbu-error);font-weight:600} .as-darkened .as-add,.as-darkened .as-mh,.as-darkened .as-src,.as-darkened .as-commit,.as-darkened .as-tbtn,.as-darkened .as-bk-rm{opacity:.4;pointer-events:none} .as-bar{position:sticky;top:0;z-index:var(--mbu-z-panel);display:flex;align-items:center;gap:8px 11px;padding:8px 12px;background:var(--mbu-bg);border:1px solid var(--mbu-border-soft);border-radius:9px;box-shadow:var(--mbu-shadow);flex-wrap:wrap;margin-bottom:6px} .as-bar>*{flex:0 0 auto} /* "Original" (Apollo-style switch): hide the whole Art Station UI, MB's native page shows through */ #as-root.as-orig{display:none} /* #695: a round icon like every other corner launcher (Falcon, Fusion); in colour while Art Station shows, monochrome while off */ #as-switch{position:fixed;bottom:14px;right:14px;z-index:var(--mbu-z-pop);width:40px;height:40px;border-radius:50%;border:none;padding:0;display:flex;align-items:center;justify-content:center;cursor:pointer;background:transparent;box-shadow:none;opacity:.85;transition:background .15s,transform .1s,opacity .15s} #as-switch:hover{opacity:1;transform:scale(1.08)} #as-switch.on{opacity:1} #as-switch:not(.on) img{filter:grayscale(1);opacity:.55} #as-switch img{width:34px;height:34px;display:block;pointer-events:none} #as-setup{position:fixed;bottom:58px;right:14px;z-index:2147483001;width:max-content;min-width:320px;max-width:92vw;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius-lg);box-shadow:0 8px 28px rgba(40,20,80,.32);font:13px Arial;color:var(--mbu-text)} /* above the corner launchers (2147483000), like Fusion's settings: Falcon's icon showed through */ #as-setup>.mbu-cfg-h{margin:0;padding:10px 12px 9px} /* the panel has no padding of its own, so the shared header needs it */ /* #283 activity-log popup */ /* floating, movable, NON-modal window (no backdrop) */ .as-setup-x{border:none;background:none;color:var(--mbu-text-weak);font-size:14px;cursor:pointer;padding:0 2px} .as-setup-x:hover{color:var(--mbu-text-dim)} .as-setup-body{padding:11px 12px;display:flex;flex-direction:column;gap:11px} /* #262 a bit more breathing room between options */ .as-setup-info{margin:0 0 10px;color:var(--mbu-text-dim);font-size:12px;line-height:1.45} .as-setup-opt{display:flex;gap:8px;align-items:center;cursor:pointer;white-space:nowrap;line-height:1.4} .as-setup-opt input{margin:0} .as-setup-optlbl{display:inline-flex;gap:8px;align-items:center;cursor:pointer} /* #262 label wraps only checkbox+text so the mode select stays independent */ .as-setup-autofront-mode{font-size:12px;padding:1px 3px} .as-setup-num{cursor:default} /* #560 a number box, not a clickable checkbox row */ .as-setup-uptimeout{width:52px;font-size:13px;font-family:inherit;border:1px solid var(--mbu-border);border-radius:5px;padding:2px 5px;text-align:right} .as-ctl{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--mbu-text-dim);white-space:nowrap} .as-size{accent-color:var(--as-acc);flex:0 1 130px;min-width:54px} #as-root select,.as-btn{font-size:13px;font-family:inherit;border:1px solid var(--mbu-border);background:var(--mbu-bg);border-radius:var(--mbu-radius);padding:4px 9px;color:var(--mbu-text);cursor:pointer;white-space:nowrap} .as-btn{display:inline-flex;align-items:center;gap:5px} /* #234: compact toolbar — hide button labels (keep icons + tooltips) when it would otherwise wrap */ /* #493: :not(:disabled) — a disabled .as-commit (white text, unconditional) hovered with the plain rule below got a pale lavender background under its own white text, unreadable */ .as-btn:hover:not(:disabled){background:var(--mbu-bg-raised)} /* accent (white-on-purple) buttons must darken on hover, not lighten — else the white text vanishes */ .as-commit:hover:not(:disabled),.as-pop-apply:hover:not(:disabled),.as-cm-go:hover:not(:disabled){background:var(--mbu-accent-hover);color:var(--mbu-text-on-accent);border-color:var(--mbu-accent-hover)} /* #275: the Repeat (failed) button is red to signal the error state */ .as-cm-go.as-cm-repeat{background:#d9463f;border-color:var(--mbu-error);color:var(--mbu-text-on-accent)} .as-cm-go.as-cm-repeat:hover:not(:disabled){background:#c43c36;border-color:var(--mbu-error)} .as-add{font-weight:600;color:var(--mbu-accent-text)} .as-mh{padding:3px 7px} .as-mh-ic{display:block;background:#80a32b;padding:2px;border-radius:5px;width:14px;height:14px} /* green chip so the white MH icon shows; sized so it doesn't out-tall the text buttons */ .as-asback{font-weight:700;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .as-dl{border-color:var(--mbu-border);color:var(--mbu-ok)} .as-sp{flex:1 1 auto} .as-commit{margin-left:auto} /* push Enter edit to the far right of the toolbar */ .as-staged{font-size:12px;color:var(--mbu-warn);background:var(--mbu-warn-bg);border-color:var(--mbu-warn);white-space:nowrap} .as-staged:hover{background:var(--mbu-warn-bg)} .as-op{padding:4px 8px;border-radius:5px;font-size:12.5px;color:var(--mbu-text);white-space:nowrap} .as-op:hover{background:var(--mbu-bg-hover)} .as-staged-pop{min-width:230px;max-width:420px} .as-dropzone{border:2px dashed var(--mbu-accent);border-radius:11px;background:var(--mbu-bg-raised);padding:24px;text-align:center;cursor:pointer;margin-bottom:12px;transition:.1s} .as-dropzone.over{background:var(--mbu-accent-soft);border-color:var(--as-acc)} .as-dz-in{font-weight:600;font-size:15px;color:var(--mbu-accent-text);display:flex;flex-direction:column;gap:4px} .as-dz-in span{font-weight:400;font-size:12px;color:var(--mbu-accent-text)} .as-commit{background:var(--as-acc);color:var(--mbu-text-on-accent);border-color:var(--as-acc);font-weight:600} .as-commit:disabled{opacity:.45;cursor:default} .as-sec{margin:14px 0 4px;display:flex;align-items:center;gap:8px} .as-sec h3{margin:0;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:var(--mbu-accent-text)} .as-sec-del h3{color:var(--as-warn)} .as-cnt{font-size:12px;color:var(--mbu-accent-text)} .as-line{flex:1;height:1px;background:var(--mbu-bg-hover)} .as-grid{display:flex;flex-wrap:wrap;gap:24px 14px} /* #238 Detailed view: list rows — image + id on the left, all types & full comment on the right */ .as-dlist{display:flex;flex-direction:column;gap:10px} .as-drow{display:flex;gap:16px;align-items:flex-start;border:1px solid var(--mbu-border-soft);border-radius:9px;padding:10px 12px;background:var(--mbu-bg);position:relative} .as-drow.new{background:repeating-linear-gradient(45deg,var(--mbu-ok-bg),var(--mbu-ok-bg) 11px,var(--mbu-bg-sunken) 11px,var(--mbu-bg-sunken) 22px);border-color:var(--mbu-ok-border);border-style:dashed} .as-drow.pending{background:var(--mbu-warn-bg);border-color:var(--mbu-warn)} .as-drow.sel{outline:2px solid var(--as-acc);outline-offset:-1px;background:var(--mbu-bg-raised);box-shadow:inset 4px 0 0 var(--as-acc)} .as-dsel{flex:0 0 auto;width:18px;height:18px;margin:4px 2px 0 2px;accent-color:var(--as-acc);cursor:pointer} .as-dleft{flex:0 0 auto;width:128px;text-align:center} .as-dthumb{position:relative;width:128px;height:128px;border-radius:7px;overflow:hidden;background:var(--mbu-bg-sunken);cursor:zoom-in;display:block} .as-dthumb img{width:100%;height:100%;object-fit:contain;display:block} .as-dthumb.na img{display:none} .as-dthumb.na::after{content:'not on CAA yet';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--mbu-accent-text);font-size:11px;font-weight:600;text-align:center;padding:0 8px} .as-dthumb.na.as-na-new::after{content:'preview unavailable'} .as-dcap{font-size:11px;font-weight:600;color:var(--mbu-accent-text);margin-top:5px;white-space:nowrap} .as-did{font-size:11px;color:var(--mbu-accent-text);font-variant-numeric:tabular-nums;word-break:break-all;line-height:1.3;margin-top:1px} .as-dmeta{flex:1 1 auto;min-width:0} .as-dlbl{font-weight:700;color:var(--mbu-accent-text);font-size:12px;margin:0 0 4px} /* a marked-for-removal detail row: greyed image + a "keep" (undo) button, no editing */ .as-drow.del{opacity:.75;background:var(--mbu-bg-raised);border-color:var(--mbu-error)} .as-drow.del .as-dthumb img{filter:grayscale(1) brightness(.85)} .as-dsel-x{flex:0 0 auto;width:18px;text-align:center;color:var(--mbu-error);font-weight:700;margin-top:2px} .as-dmeta-del{display:flex;align-items:center;justify-content:space-between;gap:12px} .as-ddel-lbl{color:var(--mbu-error);font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .as-dtypes{display:grid;grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:3px 12px;margin:0 0 10px} .as-dtypes label{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--mbu-text);cursor:pointer} .as-dtypes input{accent-color:var(--as-acc)} .as-dcmt{width:100%;box-sizing:border-box;font-size:13px;font-family:inherit;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:6px 9px;background:var(--mbu-bg-raised);color:var(--mbu-text)} .as-dcmt:focus{outline:2px solid var(--as-acc);outline-offset:-1px;background:var(--mbu-bg)} /* compact group rows: label column + cards beside it */ .as-grow{display:flex;align-items:flex-start;gap:16px;padding:12px 0;border-top:1px solid var(--mbu-accent)} .as-grow:first-of-type{border-top:none} .as-glabel{flex:0 0 104px;position:sticky;top:58px;padding-top:2px} .as-gl-name{display:block;font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--mbu-accent-text);word-break:break-word} .as-gl-cnt{font-size:11px;color:var(--mbu-accent-text)} .as-grow .as-grid{flex:1} .as-card{width:var(--as-tile);background:var(--mbu-bg);border:1px solid var(--mbu-border-soft);border-radius:9px;overflow:visible;position:relative;transition:.1s} .as-card[draggable=true]{cursor:grab} .as-card:hover{box-shadow:0 3px 12px rgba(60,40,110,.15);border-color:var(--mbu-accent)} .as-card.as-dragging{opacity:.4} .as-card.as-drop{outline:2px dashed var(--as-acc);outline-offset:-2px} .as-card.del .as-thumb img{filter:grayscale(1) brightness(.82)} .as-card.del{opacity:.7} .as-sec-new h3{color:var(--mbu-ok)} .as-card.new{background:repeating-linear-gradient(45deg,var(--mbu-ok-bg),var(--mbu-ok-bg) 11px,var(--mbu-bg-sunken) 11px,var(--mbu-bg-sunken) 22px);border-color:var(--mbu-ok-border);border-style:dashed} .as-card.pending{background:var(--mbu-warn-bg);border-color:var(--mbu-warn)} .as-card.as-sourcing{border-style:dashed} .as-srcing-thumb{width:100%;aspect-ratio:1;border-radius:9px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:11px;color:var(--mbu-ok)} .as-spinner{width:30px;height:30px;border:3px solid var(--mbu-ok-bg);border-top-color:var(--mbu-ok);border-radius:50%;animation:as-spin .8s linear infinite} @keyframes as-spin{to{transform:rotate(360deg)}} .as-srcing-lbl{font:600 12px Arial;color:var(--mbu-ok)} .as-pdfban{position:absolute;right:6px;top:6px;z-index:4;background:#7a3a8f;color:var(--mbu-text-on-accent);font:700 10px/1 Arial;letter-spacing:.5px;padding:3px 7px;border-radius:var(--mbu-radius);box-shadow:0 1px 3px rgba(0,0,0,.25);pointer-events:none} .as-newban{position:absolute;top:8px;right:-26px;transform:rotate(45deg);background:#1f9d6b;color:var(--mbu-text-on-accent);font:700 10px Arial;letter-spacing:1px;padding:2px 26px;z-index:5;box-shadow:0 1px 3px rgba(0,0,0,.3);pointer-events:none} /* #249 provider favicon chip, bottom-left of a newly-sourced cover */ .as-prov{position:absolute;left:6px;bottom:6px;z-index:5;width:25px;height:25px;border-radius:var(--mbu-radius);background:rgba(255,255,255,.93);box-shadow:0 1px 3px rgba(0,0,0,.32);display:flex;align-items:center;justify-content:center;overflow:hidden;cursor:zoom-in} .as-prov img{width:18px;height:18px;display:block;object-fit:contain} .as-dthumb .as-prov{left:4px;bottom:4px;width:22px;height:22px} .as-dthumb .as-prov img{width:16px;height:16px} /* majkinetor, again: "it still happens that borders are junky", "mostly visible on smaller sizes". The selection ring was fixed earlier (c5cf0aed) but the base geometry never was: the card is border-radius 9px over a 1px border, so the INSIDE of that border curves at 9 − 1 = 8px, while this sat in it at 9px. A corner rounded harder than the hole it sits in leaves a sliver of card background showing at each top corner — a fixed ~1px, so the smaller the tile the larger a share of the corner it is, which is exactly where he sees it. Concentric radii: inner = outer − border width. */ .as-thumb{position:relative;display:block;width:100%;aspect-ratio:1;background:var(--mbu-bg-sunken);cursor:zoom-in;border-radius:8px 8px 0 0;overflow:hidden} .as-thumb img{width:100%;height:100%;object-fit:contain;display:block} .as-thumb.na{display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--mbu-bg),var(--mbu-bg-raised))} .as-thumb.na img{display:none} .as-thumb.na::after{content:'Not on the Cover Art Archive yet';text-align:center;color:var(--mbu-accent-text);font-size:12px;font-weight:600;line-height:1.45;padding:0 16px} .as-thumb.na.as-na-new::after{content:'Preview unavailable — the image couldn’t be decoded'} /* #250 a staged blob that won't render (no CAA fallback) */ /* #592 follow-up (majkinetor: "still happens", at small tile sizes). This was 'flex-wrap:nowrap;white-space:nowrap' to stop the two halves wrapping to a second line — but that only moved the problem: on a small tile "329Kb 1245 × 1121" is wider than the card, so instead of wrapping it ran PAST the right border and under the next card, which is the border looking broken. Measured at --as-tile:120px: .as-dim overflowed the card's inner edge by 11.7px (scrollWidth 132 vs clientWidth 120); at 175px it fitted, which is why it only showed on smaller sizes. So: the two spans may wrap between themselves again — which is what dimHtml's separate spans were always for — while each stays unbroken internally, so a resolution never splits mid-value. The grid row is align-items:stretch, so a card that grows a line lifts its whole row instead of being clipped. */ .as-dim{font-size:12px;font-weight:600;color:var(--mbu-accent-text);flex:0 1 auto;min-width:0;margin-left:auto;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:0 7px} .as-dim-sz,.as-dim-px{white-space:nowrap} .as-tbtn{position:absolute;top:6px;right:6px;border:none;border-radius:var(--mbu-radius);background:rgba(255,255,255,.92);cursor:pointer;font-size:14px;line-height:1;padding:4px 7px;color:var(--mbu-text-dim);box-shadow:0 1px 3px rgba(0,0,0,.2);opacity:0;transition:.1s} .as-card:hover .as-tbtn{opacity:1} .as-only{position:absolute;top:6px;left:6px;z-index:6;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);background:var(--mbu-bg);color:var(--mbu-accent-text);cursor:pointer;font:600 12px/1 var(--mbu-font);padding:5px 8px;box-shadow:0 1px 3px rgba(0,0,0,.25);opacity:0;transition:opacity .1s} .as-card:hover .as-only,.as-only:focus-visible{opacity:1} .as-only:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .as-rm:hover{background:var(--as-warn);color:var(--mbu-text-on-accent)} .as-undo{opacity:1;background:var(--mbu-bg);color:var(--mbu-accent-text);font-size:12px;font-weight:600} /* #234: footer (mockup) — row 1: comment (left) · dimensions+size (right); row 2: centered type pill on a divider */ /* majkinetor: "AS border still looks junky, especially with selection". This line separates the artwork from the footer, and it was drawn in the ACCENT at full strength — a saturated purple rule across every card, cutting through the orange of a pending one and competing with the selection ring it sits inside. A divider should read as a divider; --mbu-divider is the token for exactly that and follows the theme. */ .as-foot{padding:5px 8px 0;display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--mbu-divider)} .as-foot-row{display:flex;align-items:center;gap:6px;min-height:17px} .as-foot-cmt{flex:0 1 auto;min-width:0;display:flex;align-items:center;overflow:hidden} .as-foot-cmt.as-cmt-collapsed{display:none} .as-cmt-text{font-size:11px;font-family:inherit;color:var(--mbu-accent-deep-text);line-height:1.3;cursor:text;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} .as-cmt-text:hover{color:var(--mbu-accent-text)} .as-foot-type{display:flex;align-items:center;justify-content:center;gap:7px;transform:translateY(50%);position:relative;z-index:1} .as-card.sel .as-foot-type{padding-right:20px} .as-type{font-size:11px;font-weight:700;color:var(--mbu-accent-deep-text);background:var(--mbu-bg-sunken);border:1px solid var(--mbu-accent);border-radius:20px;padding:2px 13px;cursor:pointer;max-width:90%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .as-type:hover{background:var(--mbu-bg-hover)} .as-type-add{color:var(--mbu-accent-text);background:var(--mbu-bg);border-style:dashed;font-weight:600;opacity:.5} .as-card:hover .as-type-add{opacity:1} .as-cmt{font-size:11px;font-family:inherit;border:1px solid var(--mbu-border-soft);border-radius:var(--mbu-radius);padding:2px 6px;color:var(--mbu-text);background:var(--mbu-bg-raised);width:100%} .as-pencil{font-size:11px;font-family:inherit;border:1px dashed var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);border-radius:var(--mbu-radius);padding:0 7px;cursor:pointer;opacity:0;transition:.1s} .as-card:hover .as-pencil{opacity:1} .as-pencil:hover{background:var(--mbu-bg-raised);color:var(--mbu-accent-text)} /* flat reverse-image-search magnifier — in the comment row, after the comment/pencil, revealed on card hover. The dim's nowrap (above) keeps the resolution from wrapping. */ .as-fsearch{display:inline-flex;align-items:center;color:var(--mbu-accent-text);background:none;border:none;cursor:pointer;padding:0 2px;flex:none;line-height:0;opacity:0;transition:opacity .1s} .as-card:hover .as-fsearch{opacity:.85} .as-fsearch:hover{opacity:1;color:var(--mbu-accent-text)} /* selection + keyboard cursor */ /* Selection used to be a 3px outline at offset -1px sitting ON TOP of the card's own 1px border: two rounded edges of different radii, a hairline of the old border still showing through at the corners, and the ring biting into the artwork. A box-shadow spread is concentric with the border-radius by definition, so the ring is one clean rounded edge OUTSIDE the card rather than a second one drawn over it. The border simply takes the accent colour. */ .as-card.sel{border-color:var(--as-acc);box-shadow:0 0 0 2px var(--as-acc),0 3px 14px rgba(95,62,192,.3)} .as-selmark{position:absolute;right:7px;bottom:7px;width:21px;height:21px;line-height:21px;text-align:center;background:var(--as-acc);color:var(--mbu-text-on-accent);border-radius:50%;font-size:12px;font-weight:700;box-shadow:0 1px 4px rgba(0,0,0,.35);z-index:6;display:none} .as-card.sel .as-selmark{display:block} .as-card.sel .as-cmt{padding-right:28px} .as-card.as-cursor{box-shadow:0 0 0 2px #2a6,0 3px 14px rgba(40,160,100,.28)} /* bulk bar */ /* #234: center selection cluster in the main toolbar */ .as-selbox{display:flex;align-items:center;gap:8px;flex:0 1 auto;justify-content:center} .as-selcnt{font-size:13px;font-weight:700;color:var(--mbu-accent-text);white-space:nowrap} .as-selcnt.none{font-weight:400;color:var(--mbu-accent-text)} .as-ic{font-size:14px;line-height:1;font-family:inherit;border:1px solid var(--mbu-border);background:var(--mbu-bg);border-radius:var(--mbu-radius);padding:3px 9px;color:var(--mbu-accent-deep-text);cursor:pointer} .as-ic:hover{background:var(--mbu-bg-raised)} .as-ic:disabled{opacity:.4;cursor:default} .as-selall{color:var(--mbu-ok)} .as-bk-rm{border-color:var(--mbu-error);color:var(--as-warn)} .as-view{font-weight:600} .as-dragwarn{font-size:13px;color:var(--mbu-warn);background:var(--mbu-warn-bg);border:1px solid var(--mbu-warn);border-radius:var(--mbu-radius);padding:3px 7px;line-height:1;cursor:help} .as-pop-note{color:var(--mbu-accent-text);font-size:11px} .as-pop{position:absolute;z-index:10001;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:8px;box-shadow:0 6px 22px rgba(60,40,110,.22);padding:6px;min-width:150px;max-height:340px;overflow:auto;font-size:13px} /* z above the lightbox (9999) so the type picker shows over it */ .as-type-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 14px} .as-pop label{display:flex;align-items:center;gap:7px;padding:3px 6px;border-radius:5px;cursor:pointer} .as-pop label:hover{background:var(--mbu-bg-hover)}.as-pop input{accent-color:var(--as-acc)} .as-pop-h{font-weight:600;color:var(--mbu-accent-text);padding:3px 6px 6px;border-bottom:1px solid var(--mbu-divider);margin-bottom:4px} .as-pop-f{display:flex;gap:6px;padding:6px 4px 2px;border-top:1px solid var(--mbu-divider);margin-top:4px;position:sticky;bottom:0;background:var(--mbu-bg)} .as-pop-apply{background:var(--as-acc);color:var(--mbu-text-on-accent);border-color:var(--as-acc)} .as-cmt-pop{min-width:220px} .as-search-pop{display:flex;flex-direction:column;gap:2px;min-width:160px} .as-search-pop .as-search-eng{display:block;width:100%;text-align:left;border:1px solid transparent;background:none} /* borderless until hover (transparent border keeps it from shifting) */ .as-search-pop .as-search-eng:hover{border-color:var(--mbu-border)} .as-search-pop .as-pop-h{display:flex;align-items:baseline;justify-content:space-between;gap:14px} /* title left, "Open all" right (#open-all-in-title) */ .as-search-pop .as-search-all{flex:none;font-weight:600;font-size:13px;font-family:inherit;color:var(--mbu-accent-text);background:none;border:none;cursor:pointer;padding:0} .as-search-pop .as-search-all:hover{text-decoration:underline} .as-search-pop .as-search-foot{margin-top:6px;padding:7px 6px 2px;border-top:1px solid var(--mbu-divider);font-size:11px;line-height:1.45;color:var(--mbu-accent-text)} .as-search-pop .as-search-foot a{color:var(--mbu-accent-text);font-weight:600;text-decoration:none} .as-search-pop .as-search-foot a:hover{text-decoration:underline} /* grow to fit all providers when the screen allows; no horizontal bar, and hide the vertical scrollbar chrome (still wheel-scrollable on a very short screen) */ .as-src-pop{min-width:340px;max-width:90vw;width:max-content;max-height:calc(100vh - 20px);overflow-x:hidden;scrollbar-width:none} .as-src-pop::-webkit-scrollbar{display:none} .as-src-prov{display:flex;flex-direction:column;gap:5px;margin:6px 0 2px} .as-src-custom{display:flex;flex-direction:column;gap:5px} /* #250 stacked custom-provider buttons */ .as-src-allwrap{display:flex;flex-direction:column;gap:5px} /* #558 "Import all" moved out of .as-src-prov so it can span both lists */ .as-src-custom:not(:empty){margin:6px 0 2px} .as-src-prov-b{justify-content:flex-start;font-weight:600;color:var(--mbu-accent-deep-text);gap:8px} .as-src-all{justify-content:center;font-weight:700;color:var(--mbu-text-on-accent);background:var(--as-acc);border-color:var(--as-acc);margin-top:3px} /* #502 (chaban-mb): the generic .as-btn:hover:not(:disabled) rule (#493) has the SAME specificity (one class + two pseudo-classes) as a bare .as-src-all:hover — a tie that source order alone happened to resolve correctly for .as-commit's own :hover:not(:disabled) rule (see below) but NOT for this one, since it never had the :not(:disabled) to match. Its pale lavender hover background then won outright, leaving this button's white text unreadable on it — same failure mode as #493, different button. Matching :not(:disabled) restores the tie (already positioned after the generic rule, so it wins it) instead of just losing outright. */ .as-src-all:hover:not(:disabled){background:var(--mbu-accent-hover);border-color:var(--mbu-accent-hover)} .as-src-ic{width:16px;height:16px;object-fit:contain;flex:0 0 auto} .as-src-n{opacity:.85} .as-src-hd{display:flex;align-items:center;justify-content:space-between;gap:8px} .as-src-hd.open .as-src-htxt{display:none} /* input fills the whole title when unrolled */ .as-src-urlwrap{display:inline-flex;align-items:center;flex:none;min-width:0} .as-src-hd.open .as-src-urlwrap{flex:1 1 auto} .as-src-url-btn{font-weight:600;font-size:13px;font-family:inherit;color:var(--mbu-accent-text);background:none;border:none;cursor:pointer;padding:0;white-space:nowrap} .as-src-url-btn:hover{text-decoration:underline} .as-src-hd.open .as-src-url-btn{display:none} .as-src-url-inp{display:none} .as-src-hd.open .as-src-url-inp{display:inline-block;width:100%;box-sizing:border-box;padding:4px 7px;border:1px solid var(--mbu-border);border-radius:5px;font-size:12px;font-family:inherit} .as-bulk-cmt{width:100%;box-sizing:border-box;font-size:13px;font-family:inherit;border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);padding:5px 8px;margin:2px 0 2px;background:var(--mbu-bg-raised);color:var(--mbu-text)} /* lightbox */ #as-lb{display:none;position:fixed;inset:0;z-index:9999;background:rgba(15,12,28,.92);align-items:center;justify-content:center;flex-direction:column;padding:30px} /* #678 (majkinetor): the viewer's footer bar sits where a bottom toast lands — while the viewer is open, raise the toast to the top so it doesn't cover Download/Rotate. */ body.as-lb-open #mbu-toast{bottom:auto;top:20px} /* #564 (majkinetor: "AS dark gallery mode buttons top right not visible … image type (front) hardly visible … and download btn"). kellnerd's dark userstyle applies filter:var(--invert-value) to every <button> on the page, and the lightbox chrome is nothing BUT buttons — Play, ✕, the type chip, Download and its caret — all authored light-on-dark for this always-dark overlay. Inverted, they come out near-black on a near-black backdrop, which is invisible and which no computed-style check can see (the filter is applied at paint time). #as-lb is deliberately NOT in the shared ROOTS list that neutralises this elsewhere: those rules also hand the container the THEME's text colour, and this surface is dark in both themes, so it would go dark-on-dark in the light theme instead. It cancels the filter itself, and nothing else. */ #as-lb{--invert-value:none;--invert:none} .as-lb-img{max-width:92vw;max-height:84vh;object-fit:contain;box-shadow:0 8px 40px rgba(0,0,0,.6);border-radius:4px;background:var(--mbu-bg)} .as-lb-img.loading{visibility:hidden} #as-lb.na .as-lb-img{display:none} #as-lb.na::after{content:'Image not available, please try again later';color:var(--mbu-error);font-style:italic;font-size:16px} /* z-index keeps BOTH arrows above the image: applyZoom always sets a transform on .as-lb-img (a stacking context), so without this the prev arrow — a DOM sibling BEFORE the image — gets painted over while next (after it) stays on top. */ .as-lb-nav{position:fixed;top:50%;transform:translateY(-50%);z-index:2;font-size:42px;line-height:1;color:var(--mbu-text-on-accent);background:transparent;border:none;border-radius:50%;width:54px;height:54px;cursor:pointer} .as-lb-nav:hover{background:rgba(255,255,255,.25)} .as-lb-prev{left:18px}.as-lb-next{right:18px} .as-lb-top{position:fixed;top:16px;right:20px;display:flex;gap:10px;align-items:center} .as-lb-x,.as-lb-play{font-size:15px;color:var(--mbu-text-on-accent);background:transparent;border:none;border-radius:8px;height:42px;cursor:pointer;font-weight:600} .as-lb-x{width:42px;font-size:24px}.as-lb-play{padding:0 14px} .as-lb-x:hover,.as-lb-play:hover{background:rgba(255,255,255,.25)} /* lightbox actions: Delete top-left, Download (size menu) top-centre. No z-index, so a ZOOMED image paints over them — same as Play/✕. The download wrapper lifts only while its menu is open so the menu stays usable. */ .as-lb-del{position:fixed;top:16px;left:20px;font-size:18px;line-height:1;color:var(--mbu-text-on-accent);background:transparent;border:none;border-radius:8px;height:42px;width:46px;cursor:pointer} .as-lb-del:hover{background:var(--as-warn)} /* Download moved into the bottom bar (right of the resolution): at the top it was painted over by the image even at 0 zoom. In-flow now; its size menu opens UPWARD so it doesn't fall off the bottom edge. */ .as-lb-dlwrap{position:relative;display:inline-flex;align-items:center} .as-lb-dlwrap:has(.as-lb-dlmenu.open){z-index:3} .as-lb-dl,.as-lb-dlcaret{font:600 13px Arial;color:var(--mbu-text-on-accent);background:rgba(255,255,255,.08);border:1px solid transparent;height:34px;cursor:pointer} .as-lb-dl{padding:0 14px;border-radius:8px 0 0 8px;border-right:none} .as-lb-dlcaret{padding:0 11px;border-radius:0 8px 8px 0;font-size:12px} .as-lb-dl:hover,.as-lb-dlcaret:hover{background:rgba(255,255,255,.25)} .as-lb-dlwrap:hover .as-lb-dl,.as-lb-dlwrap:hover .as-lb-dlcaret{border-color:rgba(255,255,255,.28)} /* border only while hovering the Download control */ .as-lb-dlmenu{position:absolute;bottom:40px;left:0;min-width:130px;background:var(--mbu-bg);border-radius:8px;box-shadow:0 6px 22px rgba(0,0,0,.4);padding:5px;display:none;flex-direction:column} .as-lb-dlmenu.open{display:flex} .as-lb-dlmenu button{text-align:left;background:none;border:none;color:var(--mbu-text);font:13px Arial;padding:7px 10px;border-radius:var(--mbu-radius);cursor:pointer} .as-lb-dlmenu button:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)} .as-lb-rotwrap{display:inline-flex;align-items:center;gap:1px} .as-lb-rot{font:600 17px Arial;line-height:1;color:var(--mbu-text-on-accent);background:rgba(255,255,255,.08);border:1px solid transparent;height:34px;min-width:40px;cursor:pointer;border-radius:0} .as-lb-rotwrap .as-lb-rot:first-child{border-radius:8px 0 0 8px} .as-lb-rotwrap .as-lb-rot:last-child{border-radius:0 8px 8px 0} .as-lb-rot:hover{background:rgba(255,255,255,.25)} .as-lb-rotwrap:hover .as-lb-rot{border-color:rgba(255,255,255,.28)} .as-lb-rotbusy .as-lb-rot{pointer-events:none;opacity:.45} /* z-index:2 keeps the footer above a ZOOMED image — the image's transform makes a stacking context that would otherwise paint over the bar (it sits below in flow). */ .as-lb-bar{margin-top:14px;display:flex;flex-direction:column;align-items:center;gap:8px;width:min(560px,84vw);position:relative;z-index:2} /* while editing (focused), give the bar a readable dark backdrop — otherwise the comment field / type chip are invisible over a light image */ .as-lb-bar:focus-within{background:rgba(15,12,28,.88);padding:11px 16px;border-radius:13px;box-shadow:0 6px 24px rgba(0,0,0,.5)} .as-lb-bar:focus-within .as-lb-cmt{background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.5)} .as-lb-caprow{display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap} .as-lb-cap{color:#eee;font-size:13px;text-align:center;display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap} .as-lb-type{font-weight:700;font-size:12px;font-family:inherit;color:#e7dffb;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.28);border-radius:20px;padding:3px 14px;cursor:pointer} .as-lb-type:hover{background:rgba(255,255,255,.2);color:var(--mbu-text-on-accent)} .as-lb-type.as-type-add{font-weight:600;border-style:dashed;color:rgba(255,255,255,.6)} .as-lb-dim{color:var(--mbu-text-weak)} .as-lb-cmtarea{width:100%;display:flex;justify-content:center} .as-lb-cmt{width:100%;font-size:13px;font-family:inherit;border:1px solid rgba(255,255,255,.25);background:rgba(255,255,255,.08);color:var(--mbu-text-on-accent);border-radius:7px;padding:7px 11px;text-align:center} .as-lb-cmt-text{font-size:14px;font-family:inherit;color:var(--mbu-text-on-accent);text-align:center;line-height:1.4;padding:4px 8px;cursor:text;max-width:100%;word-break:break-word} .as-lb-cmt-text:hover{color:#e7dffb} .as-lb-cmt::placeholder{color:rgba(255,255,255,.45)} .as-lb-cmt:focus{outline:none;border-color:rgba(255,255,255,.55);background:rgba(255,255,255,.14)} .as-lb-cmtadd{font-size:12px;font-family:inherit;color:rgba(255,255,255,.6);background:transparent;border:1px solid rgba(255,255,255,.2);border-radius:14px;padding:4px 13px;cursor:pointer} .as-lb-cmtadd:hover{color:var(--mbu-text-on-accent);border-color:rgba(255,255,255,.5);background:rgba(255,255,255,.1)} /* #251 touch viewer: show only the full image; tap reveals the controls, swipe navigates */ #as-lb.as-lb-touch .as-lb-img{max-width:100vw;max-height:100vh;border-radius:0} #as-lb.as-lb-touch .as-lb-nav{display:none} #as-lb.as-lb-touch .as-lb-del,#as-lb.as-lb-touch .as-lb-dlwrap,#as-lb.as-lb-touch .as-lb-top,#as-lb.as-lb-touch .as-lb-bar{opacity:0;pointer-events:none;transition:opacity .15s} #as-lb.as-lb-touch.as-lb-chrome .as-lb-del,#as-lb.as-lb-touch.as-lb-chrome .as-lb-dlwrap,#as-lb.as-lb-touch.as-lb-chrome .as-lb-top,#as-lb.as-lb-touch.as-lb-chrome .as-lb-bar{opacity:1;pointer-events:auto} .as-ghost{border-radius:9px;background:var(--mbu-bg)} /* #251 bigger tap targets on a touch screen (≈44px), incl. the full-screen controls */ @media (pointer: coarse) { #as-root .as-btn,#as-root .as-ic,#as-root select{min-height:40px;padding-top:8px;padding-bottom:8px} #as-root .as-type,#as-root .as-type-add{padding-top:7px;padding-bottom:7px} #as-root .as-pencil{min-height:34px;padding:0 12px} #as-root .as-tbtn{opacity:1;padding:8px 11px} #as-root .as-only{opacity:1;padding:8px 11px} .as-lb-x,.as-lb-play,.as-lb-del,.as-lb-dl,.as-lb-dlcaret,.as-lb-rot{min-width:46px;min-height:46px;font-size:18px} .as-lb-cmtadd,.as-lb-type{min-height:40px;padding:9px 16px} .as-lb-dlmenu button{padding:12px 14px} } /* commit panel */ #as-commit,#as-report{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px} .as-rp-opts{display:flex;flex-wrap:wrap;gap:8px 18px;margin-bottom:10px} .as-rp-opts label{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--mbu-text-dim)} .as-rp-out{font:12px/1.45 ui-monospace,Consolas,monospace;border:1px solid var(--mbu-border);border-radius:7px;padding:9px 11px;resize:vertical;background:var(--mbu-bg-raised);color:var(--mbu-text);white-space:pre;overflow:auto} .as-rp-note{font-size:12px;color:var(--mbu-warn)} .as-rp-copy{font-weight:600;color:var(--mbu-accent-text)} /* #473 (vzell, big screens): the box is a flex column with .as-cm-list already flex:1 1 auto + min-height:0 + overflow:auto (below), so it was already set up to grow into extra room cleanly — just needed resize turned on. overflow:hidden here is what CSS resize requires (any value other than visible), and min-* keeps it from being dragged down to something unusable. */ .as-cm-box{background:var(--mbu-bg);border-radius:12px;box-shadow:0 12px 50px rgba(0,0,0,.4);width:min(680px,94vw);container-type:inline-size;max-width:94vw;min-width:360px;max-height:88vh;min-height:220px;display:flex;flex-direction:column;padding:18px 20px;font:14px/1.4 var(--mbu-font);color:var(--mbu-text);resize:both;overflow:hidden} .as-cm-h{font-size:16px;font-weight:700;color:var(--mbu-accent-deep-text);margin-bottom:12px;display:flex;align-items:center;gap:12px} .as-cm-h-t{flex:1;min-width:0} .as-cm-hist{flex:none;font-size:13px;font-weight:600;color:var(--mbu-accent-text);text-decoration:none;white-space:nowrap;padding:4px 12px;border:1px solid var(--mbu-accent);border-radius:8px;background:var(--mbu-bg)} .as-cm-hist:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-accent)} .as-cm-row{display:flex;flex-direction:column;gap:5px;margin-bottom:10px;font-size:13px;color:var(--mbu-text-dim)} .as-cm-hint{font-size:11px;color:var(--mbu-accent-text);font-weight:400} .as-cm-note{font-size:13px;font-family:inherit;border:1px solid var(--mbu-border);border-radius:7px;padding:6px 9px;resize:vertical;width:100%;box-sizing:border-box;display:block;margin:0} /* #668 the note opens collapsed to a one-line summary; .open shows the field */ .as-cm-nb{flex:none;border:1px solid var(--mbu-divider);border-radius:8px;background:var(--mbu-bg-raised);margin-bottom:12px} .as-cm-ns{display:flex;align-items:center;gap:8px;padding:6px 8px 6px 12px;cursor:pointer;user-select:none;min-height:30px} .as-cm-ns:hover{background:var(--mbu-bg-hover);border-radius:8px} .as-cm-ns:focus-visible{outline:2px solid var(--as-acc);outline-offset:-2px} .as-cm-ni{flex:none} .as-cm-ns b{flex:none;color:var(--mbu-text)} .as-cm-nl{flex:none;font-size:11.5px;color:var(--mbu-accent-text);background:var(--mbu-bg-sunken);border-radius:var(--mbu-radius-lg);padding:1px 8px;font-variant-numeric:tabular-nums} .as-cm-np{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--mbu-text-dim);font-size:13px} .as-cm-nc{flex:none;color:var(--mbu-text-dim);padding:2px 8px;font-size:15px;line-height:1} .as-cm-nbody{display:none;padding:0 8px 8px} .as-cm-nb.open .as-cm-nbody{display:block} .as-cm-nb.open .as-cm-np{visibility:hidden} /* #263 if Mammoth is installed it auto-enhances the .edit-note field (saved notes / history panel), which sits below the operations list. It wraps the textarea in .mmth-wrap + a 300px side panel; the 680px modal fits that — give the wrap the note's bottom margin and full width. Hide Mammoth's WIDTH splitter in the modal: it overhangs a shorter panel, and stretching the panel to size-match the field feeds Mammoth's height observer into a runaway (infinite growth, #245). The field stays HEIGHT-resizable via its own corner grip. */ #as-commit .mmth-wrap{margin:0;max-width:none} #as-commit .mmth-vsep{display:none} .as-cm-chk{display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--mbu-text-dim)} .as-cm-opts{flex-direction:row;gap:18px;flex-wrap:wrap} .as-cm-opts label{display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--mbu-text)} .as-cm-split{position:relative;display:inline-flex} .as-cm-split .as-cm-go{border-top-right-radius:0;border-bottom-right-radius:0} /* #668 ▾ gets a real hit area (no tiny carets, #419) */ .as-cm-more{min-width:30px;padding:0 9px;border-top-left-radius:0;border-bottom-left-radius:0;border-left:1px solid rgba(0,0,0,.25);background:var(--as-acc);color:var(--mbu-text-on-accent);border-color:var(--as-acc);font-size:15px;line-height:1} .as-cm-more:hover:not(:disabled),.as-cm-more.on{background:var(--mbu-accent-hover);border-color:var(--mbu-accent-hover)} .as-cm-more:disabled{opacity:.5} .as-cm-split.live .as-cm-more{display:none} .as-cm-split.live .as-cm-go{border-radius:var(--mbu-radius)} .as-cm-menu{position:absolute;right:0;bottom:calc(100% + 6px);z-index:5;min-width:170px;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:8px;box-shadow:0 6px 22px rgba(0,0,0,.3);padding:4px} .as-cm-mi{display:block;width:100%;text-align:left;background:none;border:0;border-radius:6px;padding:7px 10px;font:inherit;font-size:13px;color:var(--mbu-warn);cursor:pointer;white-space:nowrap} .as-cm-mi:hover{background:var(--mbu-bg-hover)} .as-cm-x{flex:none;background:none;border:0;color:var(--mbu-text-dim);font-size:18px;line-height:1;cursor:pointer;padding:4px 6px;border-radius:6px} .as-cm-x:hover{color:var(--mbu-text);background:var(--mbu-bg-hover)} .as-cm-list{overflow:auto;border:1px solid var(--mbu-divider);border-radius:8px;padding:6px;margin:4px 0 12px;background:var(--mbu-bg-raised);flex:1 1 auto;min-height:0} /* #263 flex so the note + buttons below stay pinned and the list scrolls */ .as-cm-op{padding:5px 6px;border-radius:var(--mbu-radius);font-size:13px;min-width:0} /* #668 second variant (majkinetor): two columns of rows in a wider dialog, so a batch shows twice as many covers and a failed row's error has room. Narrowed below ~640px (a resize, or a small screen) it is one column again. */ #as-commit .as-cm-box{width:min(980px,94vw)} /* #668 room for two columns; the report dialog keeps 680 */ .as-cm-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;column-gap:8px} @container (max-width:640px){.as-cm-list{grid-template-columns:minmax(0,1fr)}} .as-cm-op.dry{background:var(--mbu-bg-hover)}.as-cm-op.err{background:var(--mbu-bg-hover)} .as-cm-line{display:flex;align-items:center;gap:10px} /* #668 cover thumbnail per row, label above its own full-width progress bar */ .as-cm-th{flex:none;width:44px;height:44px;border:1px solid var(--mbu-border);border-radius:6px;overflow:hidden;display:flex;align-items:center;justify-content:center;background:var(--mbu-bg-sunken)} .as-cm-th img{width:100%;height:100%;object-fit:cover;display:block} .as-cm-thi{font-size:18px;color:var(--mbu-text-dim)} .as-cm-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:5px} .as-cm-ttl{display:flex;align-items:center;gap:6px;min-width:0} .as-cm-st{display:inline-block;min-width:18px;white-space:nowrap;text-align:center;flex:none} .as-cm-lb{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .as-cm-skip{font-size:11px;color:var(--mbu-text-weak);margin-left:6px;background:var(--mbu-bg-sunken);border-radius:var(--mbu-radius-lg);padding:1px 7px;flex:none} .as-cm-payload{white-space:pre-wrap;font:11px/1.4 ui-monospace,Consolas,monospace;color:var(--mbu-text-dim);margin:4px 0 2px 82px;display:none} .as-cm-op.dry .as-cm-payload,.as-cm-op.err .as-cm-payload{display:block} /* #278 per-row progress bar (right side, was empty) */ .as-cm-bar{display:block;width:100%;height:5px;border-radius:5px;background:var(--mbu-bg-hover);overflow:hidden;position:relative;visibility:hidden} /* #668 reserved, so rows don't jump when it appears */ .as-cm-bar.on{visibility:visible} .as-cm-bfill{display:block;height:100%;width:0;background:var(--as-acc);border-radius:5px;transition:width .15s linear} .as-cm-bar.done .as-cm-bfill{background:#2e9b57} .as-cm-bar.dry .as-cm-bfill{background:var(--mbu-bg-hover)} .as-cm-bar.err .as-cm-bfill{background:var(--as-warn)} .as-cm-bar.cancel .as-cm-bfill{background:var(--mbu-bg-sunken)} .as-cm-bar.busy .as-cm-bfill{position:absolute;width:40%;left:0;animation:as-cm-ind 1.1s ease-in-out infinite} @keyframes as-cm-ind{0%{left:-40%}100%{left:100%}} /* #278 overall progress (header) */ .as-cm-prog{display:flex;align-items:center;gap:10px;margin:-2px 0 12px} .as-cm-prog-track{flex:1 1 auto;height:8px;border-radius:var(--mbu-radius);background:var(--mbu-bg-hover);overflow:hidden} .as-cm-prog-fill{height:100%;width:0;background:var(--as-acc);border-radius:var(--mbu-radius);transition:width .2s linear} .as-cm-prog-txt{flex:none;font-size:12px;font-weight:600;color:var(--mbu-accent-text);font-variant-numeric:tabular-nums;white-space:nowrap} .as-cm-f{display:flex;align-items:center;gap:8px} .as-cm-id{color:var(--mbu-accent-text);font-size:12px;font-variant-numeric:tabular-nums;flex:none} .as-cm-go{background:var(--as-acc);color:var(--mbu-text-on-accent);border-color:var(--as-acc);font-weight:600} .as-cm-go:disabled{opacity:.5} /* #566 follow-up: this used to sit inside .as-cm-f, where it competed with the buttons for width — it was clipped to "1 fail…" and squeezed the checkbox labels onto two lines, moving the buttons under the pointer. Its own row below them instead: full width, free to wrap, and no longer part of any layout the buttons depend on. Its row is ALWAYS in the layout — hidden by visibility, not by display — because appearing at all would otherwise grow the box, and a centred dialog that grows moves its buttons up by half the difference. Which is the very complaint. So one line is reserved and the buttons never shift. */ .as-cm-ar{visibility:hidden;margin-top:8px;min-height:17px;font-size:11.5px;line-height:1.45;color:var(--mbu-warn)} .as-cm-ar.on{visibility:visible;padding-top:0} .as-cm-ar-done{color:var(--mbu-error)} .as-cm-note2{font-size:11px;color:var(--mbu-accent-text);margin-top:8px;text-align:center} `; const st = document.createElement('style'); st.textContent = css; appendEl(st); // ── #248 add page: harvest images seeded into the native uploader ─────────────── // Another integration may drop images into MB's add-cover-art uploader. We read each // preview row's blob and stage it as a NEW cover. Runs on load and via an observer // because such a seed is async (the integration fetches after the page settles). // NOTE: we deliberately do NOT scrape the row's type/comment checkboxes — an // integration may set them via Knockout and re-render rows, so the checked state is // only momentarily readable and racing it was unreliable (#253). Types are set // instead from the file-name option or in AS itself. // harvest is idempotent + re-runnable: a NEW row is staged; a row we've already // staged has its type/comment SYNCED if the integration set them after the image // appeared — but only while the user hasn't edited that cover. let _seedNote = ''; // #248 an edit note an integration pre-filled on the native add page → moved to our commit panel // #253 harvest keys on the uploader ROW (tagged once), NOT the blob URL: an // integration may maximise the preview in place — the blob URL changes — so blob-keying re-staged // the SAME image on every pass (multiple identical covers from one seed). Reentrancy // is guarded with a trailing re-run so overlapping passes can't double-add either. let _harvesting = false, _harvestPending = false, _rowSeq = 0; async function harvestSeeds() { if (_harvesting) { _harvestPending = true; return; } _harvesting = true; _harvestPending = false; try { const form = document.getElementById('add-' + ART); if (!form) return; const en = form.querySelector('textarea.edit-note, textarea[name*="edit_note"]'); // capture a seeded edit note if (en && en.value && en.value.trim()) _seedNote = en.value.trim(); const rows = [...form.querySelectorAll('tr')].filter(tr => tr.querySelector('img.uploader-preview-image, img[src^="blob:"]')); const files = [], metas = []; let dirty = false; for (const tr of rows) { const img = tr.querySelector('img.uploader-preview-image, img[src^="blob:"]'); const src = img && (img.src || img.getAttribute('src')); if (!src || !/^blob:/i.test(src)) continue; const rowId = tr.dataset.asRow; if (rowId) { // this row is already staged — swap in a maximised image, never re-add const existing = MODEL.find(m => m._seedSrc === rowId); if (!existing || existing._del) continue; if (src !== existing._seedBlobSrc) { // maximised in place → replace the staged blob with the bigger one let blob; try { blob = await fetch(src).then(r => r.ok ? r.blob() : null); } catch (e) { blob = null; } if (blob) { try { URL.revokeObjectURL(existing._file); } catch (e) {} const m = (blob.type && blob.type.startsWith('image/')) ? blob.type : 'image/jpeg'; existing._file = URL.createObjectURL(blob); existing._fileObj = new File([blob], (existing._fileObj && existing._fileObj.name) || 'seed.jpg', { type: m }); existing._seedBlobSrc = src; existing.bytes = blob.size; existing.w = 0; existing.h = 0; existing._contentKey = await fileKey(existing._fileObj); _imgCache.delete(String(existing.id)); measure(existing); dirty = true; } } continue; } let blob; try { blob = await fetch(src).then(r => r.ok ? r.blob() : null); } catch (e) { blob = null; } if (!blob) continue; // not decodable yet — a later pass will pick it up const id = 'srow' + (++_rowSeq); tr.dataset.asRow = id; // tag BEFORE staging so a racing pass sees it taken const mime = (blob.type && blob.type.startsWith('image/')) ? blob.type : 'image/jpeg'; const ext = (mime.split('/')[1] || 'jpg').replace('jpeg', 'jpg'); files.push(new File([blob], `seed-${Date.now()}-${files.length}.${ext}`, { type: mime })); metas.push({ seedSrc: id, seedBlobSrc: src }); // #253 image only — types are set via the file-name option or in AS, not scraped from the rows' transient checkboxes } const added = files.length ? await addFilesDeduped(files, metas) : 0; if (added) toast(`Imported ${added} pre-added ${added > 1 ? ITEMS : ITEM} ✓`); else if (dirty) { refreshStaged(); render(); } } finally { _harvesting = false; if (_harvestPending) { _harvestPending = false; harvestSeeds(); } } } // #717 seeding links: other tools (Harmony, a-tisket, …) open the add page with // x_seed.image.<n>.url / .types / .comment (and x_seed.origin) — the parameters // Enhanced Cover Art Uploads defined. MusicBrainz itself ignores them, so Art Station // reads them and sources each URL like a pasted one. The types are MB type ids, // mapped to names through the add form's own checkboxes. function seedFromParams() { const q = new URLSearchParams(location.search); const byN = new Map(); for (const [k, v] of q) { const m = k.match(/^x_seed\.image\.(\d+)\.(url|types|comment)$/); if (!m) continue; const e = byN.get(+m[1]) || { url: '', types: [], comment: null }; if (m[2] === 'url') e.url = v.trim(); else if (m[2] === 'types') e.types.push(...v.split(',')); else e.comment = v; byN.set(+m[1], e); } const seeds = [...byN.entries()].sort((x, y) => x[0] - y[0]).map(x => x[1]).filter(e => /^https?:\/\//i.test(e.url)); if (!seeds.length) return; const form = document.getElementById('add-' + ART); const idToName = {}; if (form) for (const [name, id] of Object.entries(typeMapOf(form, `add-${ART}`))) idToName[id] = name; const origin = q.get('x_seed.origin') || ''; asLog.info(`Seeded: ${seeds.length} image URL(s)${origin ? ' from ' + origin : ''}`); if (origin && !_seedNote) _seedNote = 'Seeded from ' + origin; // where the link came from, in the commit note seeds.forEach(e => { const types = e.types.map(t => String(t).trim()).filter(Boolean).map(t => idToName[t] || (ALL_TYPES.includes(t) ? t : '')).filter(Boolean); sourceFromUrl(e.url, null, { types: e.types.length ? types : null, comment: e.comment }); }); } function initAdd() { if (!IS_ADD) return; const form = document.getElementById('add-' + ART); if (!form) { // uploader not in the DOM yet — wait for it const o = new MutationObserver(() => { if (document.getElementById('add-' + ART)) { o.disconnect(); initAdd(); } }); o.observe(document.documentElement, { childList: true, subtree: true }); return; } let t; const soon = () => { clearTimeout(t); t = setTimeout(harvestSeeds, 250); }; typeMapOf(form, `add-${ART}`); // #721 learn this language's type names for the gallery seedFromParams(); harvestSeeds(); // a new row, a maximised image (src swap), or a type/comment change all re-harvest. new MutationObserver(soon).observe(form, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); form.addEventListener('change', soon); form.addEventListener('input', soon); // MB applies seeded types/comments by setting the checkbox `checked` PROPERTY (no // change event) a beat after the image, so neither the observer nor the listeners // fire — poll-resync for a while so the per-row type/comment still gets picked up. #253 let polls = 0; const poll = setInterval(() => { if (++polls > 24 || !document.getElementById('as-root')) { clearInterval(poll); return; } harvestSeeds(); }, 800); window.addEventListener('beforeunload', () => clearInterval(poll)); } // we run at document-start; wait for #content before mounting the gallery if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => loadArt().then(initAdd).then(mcSeeded), { once: true }); else loadArt().then(initAdd).then(mcSeeded); })(); })(); } catch (e) { try { console.error('[String Theory] art_station failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== credit_hoarder (@run-at document-end) ==================================== if (__stOff.indexOf("credit_hoarder") < 0 && __stRuns("credit_hoarder", {"inc":["^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*/edit-relationships$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/artist/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/label/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/place/.*$","^https:\\/\\/tidal\\.com(?::\\d+)?/album/.*$","^https:\\/\\/listen\\.tidal\\.com(?::\\d+)?/album/.*$","^https:\\/\\/www\\.metal-archives\\.com(?::\\d+)?/albums/.*$"],"exc":[],"noframes":false})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Credit Hoarder*","namespace":"majkinetor","version":"2026.10.16","description":"Import per-track release credits from streaming/database providers (Discogs, Tidal, Qobuz, Deezer) into MusicBrainz relationships, with a review phase","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md","supportURL":"https://github.com/majkinetor/musicbrainz-userscripts/issues","icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xNS41IC0xLjUgMTM2LjUgMTM2LjUiIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4Ij4KPHRpdGxlPkNyZWRpdCBIb2FyZGVyPC90aXRsZT4KPGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJjaGMwNC1nIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZjlhODI2Ii8+PHN0b3Agb2Zmc2V0PSIuNSIgc3RvcC1jb2xvcj0iI2YzNzIyYyIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2QwMDA3MCIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxnIG9wYWNpdHk9Ii43Ij48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2NiA3MCkgcm90YXRlKDApIHNjYWxlKDEuNTUpIHRyYW5zbGF0ZSgtNjYgLTcwKSI+PGcgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuOTM1NDgzODcwOTY3NzQxOCI+PGVsbGlwc2UgY3g9IjMwIiBjeT0iODAiIHJ4PSIxNCIgcnk9IjEwIi8+PGNpcmNsZSBjeD0iNTAiIGN5PSI3NiIgcj0iOCIvPjxjaXJjbGUgY3g9IjY2IiBjeT0iNzAiIHI9IjEwIi8+PC9nPjxnIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI1LjE2MTI5MDMyMjU4MDY0NSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNMjYgODYgbC02IDEyIE0zNCA4OCBsMiAxMiBNNDYgODIgbC00IDE2IE01MiA4NCBsOCAxNCIvPjwvZz48L2c+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjYuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNNzIgNjIgQzc4IDUyIDg2IDQ4IDkyIDQ4IE03MCA2MiBDNzIgNTIgNzAgNDQgNjQgNDAiLz48L2c+PHBhdGggZD0iTTExMy42IDQ1LjAgTDk4LjAgNTQuMCBMODIuNCA0NS4wIEw4Mi40IDI3LjAgTDk4LjAgMTguMCBMMTEzLjYgMjcuMFoiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI2IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB0cmFuc2Zvcm09InJvdGF0ZSgtMTAgOTggMzYpIi8+PC9nPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDY2IDcwKSByb3RhdGUoMCkgc2NhbGUoMS41NSkgdHJhbnNsYXRlKC02NiAtNzApIj48ZyBmaWxsPSIjMjIyMjNiIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMCI+PGVsbGlwc2UgY3g9IjMwIiBjeT0iODAiIHJ4PSIxNCIgcnk9IjEwIi8+PGNpcmNsZSBjeD0iNTAiIGN5PSI3NiIgcj0iOCIvPjxjaXJjbGUgY3g9IjY2IiBjeT0iNzAiIHI9IjEwIi8+PC9nPjxnIHN0cm9rZT0iIzIyMjIzYiIgc3Ryb2tlLXdpZHRoPSIzLjIyNTgwNjQ1MTYxMjkwMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNMjYgODYgbC02IDEyIE0zNCA4OCBsMiAxMiBNNDYgODIgbC00IDE2IE01MiA4NCBsOCAxNCIvPjwvZz48L2c+PGcgc3Ryb2tlPSIjMjIyMjNiIiBzdHJva2Utd2lkdGg9IjMuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNNzIgNjIgQzc4IDUyIDg2IDQ4IDkyIDQ4IE03MCA2MiBDNzIgNTIgNzAgNDQgNjQgNDAiLz48L2c+PHBhdGggZD0iTTExMy42IDQ1LjAgTDk4LjAgNTQuMCBMODIuNCA0NS4wIEw4Mi40IDI3LjAgTDk4LjAgMTguMCBMMTEzLjYgMjcuMFoiIGZpbGw9InVybCgjY2hjMDQtZykiIHN0cm9rZT0iIzIyMjIzYiIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB0cmFuc2Zvcm09InJvdGF0ZSgtMTAgOTggMzYpIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTggMzcpIHNjYWxlKDAuOSkiIGZpbGw9IiNmZmYiPjxjaXJjbGUgY3g9IjAiIGN5PSItNSIgcj0iNSIvPjxwYXRoIGQ9Ik0tOSA5IEMtOSAwIDkgMCA5IDlaIi8+PC9nPgo8L3N2Zz4K"}) }) : { script: {"name":"Credit Hoarder*","namespace":"majkinetor","version":"2026.10.16","description":"Import per-track release credits from streaming/database providers (Discogs, Tidal, Qobuz, Deezer) into MusicBrainz relationships, with a review phase","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md","supportURL":"https://github.com/majkinetor/musicbrainz-userscripts/issues","icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xNS41IC0xLjUgMTM2LjUgMTM2LjUiIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4Ij4KPHRpdGxlPkNyZWRpdCBIb2FyZGVyPC90aXRsZT4KPGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJjaGMwNC1nIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZjlhODI2Ii8+PHN0b3Agb2Zmc2V0PSIuNSIgc3RvcC1jb2xvcj0iI2YzNzIyYyIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2QwMDA3MCIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxnIG9wYWNpdHk9Ii43Ij48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2NiA3MCkgcm90YXRlKDApIHNjYWxlKDEuNTUpIHRyYW5zbGF0ZSgtNjYgLTcwKSI+PGcgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuOTM1NDgzODcwOTY3NzQxOCI+PGVsbGlwc2UgY3g9IjMwIiBjeT0iODAiIHJ4PSIxNCIgcnk9IjEwIi8+PGNpcmNsZSBjeD0iNTAiIGN5PSI3NiIgcj0iOCIvPjxjaXJjbGUgY3g9IjY2IiBjeT0iNzAiIHI9IjEwIi8+PC9nPjxnIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI1LjE2MTI5MDMyMjU4MDY0NSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNMjYgODYgbC02IDEyIE0zNCA4OCBsMiAxMiBNNDYgODIgbC00IDE2IE01MiA4NCBsOCAxNCIvPjwvZz48L2c+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjYuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNNzIgNjIgQzc4IDUyIDg2IDQ4IDkyIDQ4IE03MCA2MiBDNzIgNTIgNzAgNDQgNjQgNDAiLz48L2c+PHBhdGggZD0iTTExMy42IDQ1LjAgTDk4LjAgNTQuMCBMODIuNCA0NS4wIEw4Mi40IDI3LjAgTDk4LjAgMTguMCBMMTEzLjYgMjcuMFoiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI2IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB0cmFuc2Zvcm09InJvdGF0ZSgtMTAgOTggMzYpIi8+PC9nPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDY2IDcwKSByb3RhdGUoMCkgc2NhbGUoMS41NSkgdHJhbnNsYXRlKC02NiAtNzApIj48ZyBmaWxsPSIjMjIyMjNiIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMCI+PGVsbGlwc2UgY3g9IjMwIiBjeT0iODAiIHJ4PSIxNCIgcnk9IjEwIi8+PGNpcmNsZSBjeD0iNTAiIGN5PSI3NiIgcj0iOCIvPjxjaXJjbGUgY3g9IjY2IiBjeT0iNzAiIHI9IjEwIi8+PC9nPjxnIHN0cm9rZT0iIzIyMjIzYiIgc3Ryb2tlLXdpZHRoPSIzLjIyNTgwNjQ1MTYxMjkwMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNMjYgODYgbC02IDEyIE0zNCA4OCBsMiAxMiBNNDYgODIgbC00IDE2IE01MiA4NCBsOCAxNCIvPjwvZz48L2c+PGcgc3Ryb2tlPSIjMjIyMjNiIiBzdHJva2Utd2lkdGg9IjMuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNNzIgNjIgQzc4IDUyIDg2IDQ4IDkyIDQ4IE03MCA2MiBDNzIgNTIgNzAgNDQgNjQgNDAiLz48L2c+PHBhdGggZD0iTTExMy42IDQ1LjAgTDk4LjAgNTQuMCBMODIuNCA0NS4wIEw4Mi40IDI3LjAgTDk4LjAgMTguMCBMMTEzLjYgMjcuMFoiIGZpbGw9InVybCgjY2hjMDQtZykiIHN0cm9rZT0iIzIyMjIzYiIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB0cmFuc2Zvcm09InJvdGF0ZSgtMTAgOTggMzYpIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTggMzcpIHNjYWxlKDAuOSkiIGZpbGw9IiNmZmYiPjxjaXJjbGUgY3g9IjAiIGN5PSItNSIgcj0iNSIvPjxwYXRoIGQ9Ik0tOSA5IEMtOSAwIDkgMCA5IDlaIi8+PC9nPgo8L3N2Zz4K"} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ try { (function(){ (() => { // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } if (!mbuClaim('credit_hoarder', 'Credit Hoarder')) return; // src/constants.js var REL_TEMPLATE = { _lineage: [], _original: null, _status: 1, attributes: null, begin_date: null, editsPending: false, end_date: null, ended: false, entity0_credit: "", entity1_credit: "", id: null, linkOrder: 0, linkTypeID: null }; var SELECTORS = { MediumsInput: ".multiselect-input", MediumsInputOptions: ".multiselect-input + .menu a", InstrumentsInput: "#add-relationship-dialog .multiselect.instrument input[aria-autocomplete]", VocalsTypeInput: "#add-relationship-dialog .multiselect.vocal input[aria-autocomplete]", AddRelationshipsDialogEntityType: "#add-relationship-dialog .entity-type", AddRelationshipsDialogRelationshipType: "#add-relationship-dialog input.relationship-type", AddRelationshipsDialogRelationshipTarget: "#add-relationship-dialog input.relationship-target", AddRelationshipsDialogEntityCredit: "#add-relationship-dialog input.entity-credit", AddRelationshipsDialogDoneButton: "#add-relationship-dialog .buttons button.positive", AddRelationshipsDialogError: "#add-relationship-dialog .error", AddRelationshipsDialogCancelButton: "#add-relationship-dialog .buttons button.negative", AddReleaseRelationshipButton: "#release-rels button.add-relationship", EditNote: "#edit-note-text", TaskInput: "#add-relationship-dialog .attribute-container.task input" }; var EQUIVALENCE_SETS = [ ["writer", "composer"] ]; var MB = typeof location !== "undefined" && /(^|\.)musicbrainz\.org$/.test(location.hostname) ? "//" + location.hostname : "//musicbrainz.org"; var DISCOGS_CHANNEL = new BroadcastChannel("discogs-importer-artist"); DISCOGS_CHANNEL.unref?.(); var pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : typeof window !== "undefined" ? window : globalThis; // src/log.js var _logs = null; var _warn = 0; var _err = 0; var _countListener = null; function onLogCounts(fn) { _countListener = fn; } function resetLogCounts() { _warn = 0; _err = 0; _notifyCounts(); } function _notifyCounts() { if (_countListener) { try { _countListener(_warn, _err); } catch (e) { } } } function setLogContainer(el) { _logs = el; const pending = _pending.splice(0, _pending.length); pending.forEach((a) => _emit(...a)); } function getLogContainer() { return _logs; } var _review = null; function setReviewContainer(el) { _review = el; } function getReviewContainer() { return _review || _logs; } var _pending = []; var PENDING_MAX = 200; function _console(plainText, sev) { try { const line = "[credit_hoarder] " + String(plainText).replace(/<[^>]*>/g, "").trim(); if (sev === "error") console.error(line); else if (sev === "warn") console.warn(line); else console.log(line); } catch (e) { } } function _emit(html, plainText, sev) { _console(plainText, sev); if (!_logs) { if (_pending.length < PENDING_MAX) _pending.push([html, plainText, sev]); return; } const li = document.createElement("li"); if (sev) li.dataset.sev = sev; if (sev === "warn") { _warn++; _notifyCounts(); } else if (sev === "error") { _err++; _notifyCounts(); } const d = /* @__PURE__ */ new Date(); const pad = (n) => String(n).padStart(2, "0"); const stamp = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; li.innerHTML = `<span style="color:var(--mbu-text-weak);font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.82em;">${stamp}</span> ${html}`; _logs.insertAdjacentElement("beforeend", li); const bar = document.querySelector(".discogs-bar"); if (bar?._setProgress) { bar._setProgress(null, plainText.replace(/<[^>]*>/g, "").trim().substring(0, 120)); } } var log = { info: (msg) => _emit(msg, msg), warn: (msg) => _emit(`<span style="color:orange">WARN ${msg}</span>`, `WARN ${msg}`, "warn"), error: (msg) => _emit(`<span style="color:red">ERR ${msg}</span>`, `ERR ${msg}`, "error"), // Entity skipped because it wasn't matched on MB in the review (#118). Kept // OUT of the WARN tally — these are surfaced by the separate "N unresolved" // badge, and the maintainer asked not to lump them with real warnings. Still // rendered (muted amber) so the log shows exactly which roles were dropped. skip: (msg) => _emit(`<span style="color:var(--mbu-warn)">SKIP ${msg}</span>`, `SKIP ${msg}`, "skip") }; var _debugUl = null; var _debugStartT = null; function _ensureDebugUl() { if (_debugUl) return _debugUl; if (!_logs) return null; const details = document.createElement("details"); details.style.cssText = "margin:0.3rem 0;"; const summary = document.createElement("summary"); summary.textContent = "Preflight diagnostics"; summary.style.cssText = "cursor:pointer;font-size:0.8rem;color:var(--mbu-text-weak);user-select:none;"; details.appendChild(summary); const ul = document.createElement("ul"); ul.style.cssText = "list-style:none;margin:0.3rem 0;padding:0.4rem 0.6rem;background:var(--mbu-bg-raised);border-radius:0.25rem;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.72rem;color:var(--mbu-text);max-height:24rem;overflow-y:auto;"; details.appendChild(ul); const li = document.createElement("li"); li.style.listStyle = "none"; li.appendChild(details); _logs.appendChild(li); _debugUl = ul; _debugStartT = performance.now(); return _debugUl; } function logDebug(line) { const ul = _ensureDebugUl(); if (!ul) return; const t = Math.round(performance.now() - _debugStartT); const row = document.createElement("div"); row.textContent = `[+${t}ms] ${line}`; ul.appendChild(row); } // ../../dev/net/mb-gate.mjs var MBN_GAP = 1e3; var MBN_BURST = 3; var MBN_KEY = "mbu:mb-gate"; var MBN_LOCK = "mbu-mb-gate"; var MBN_MAX_HOLD = 6e4; function mbnGated(url) { const s = String(url || ""); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || "null"); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; else if (!s || typeof s !== "object") s = { tat: 0, cool: 0, hot: 0 }; const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) { } return out; }; let timer = 0; try { if (typeof navigator !== "undefined" && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === "function" ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3e3); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState((s) => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? "MusicBrainz asked everyone to wait" : "pacing, one request a second" }; }); let told = false; for (; ; ) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || "null") || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); if (left <= 0) break; if (!told && until - t0 > 1e3 && o.log) { told = true; o.log("info", "MusicBrainz gate: " + (o.label || "request") + " waits " + Math.round((until - t0) / 100) / 10 + "s (" + (cool > booked.at ? "MusicBrainz asked everyone to wait" : booked.why) + ")"); } await new Promise((r) => setTimeout(r, Math.min(left, 250))); } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header("Retry-After") : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1e3 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState((s) => { const now = Date.now(); if (now > (s.cool || 0) + 3e4) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1e3, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log("warn", "MusicBrainz gate: HTTP " + status + (raw != null ? " (Retry-After: " + raw + ")" : "") + " \u2014 every script holds " + Math.round(hold / 100) / 10 + "s"); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState((s) => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 3e4 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) { const r = await fetch(url, init); if (!mbnWebsite(url) || !await mbnIsCheck(r)) return r; return await mbnReverify(url, init, o) ? fetch(url, init) : r; } const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, (n) => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnWebsite(url) { const s = String(url || ""); if (mbnGated(s)) return false; if (/^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org(\/|$)/i.test(s)) return true; return /^\/(?!\/)/.test(s) && typeof location !== "undefined" && /(^|\.)musicbrainz\.org$/i.test(location.hostname); } async function mbnIsCheck(r) { try { if (!r || !/text\/html/i.test(r.headers && r.headers.get("content-type") || "")) return false; const head = (await r.clone().text()).slice(0, 3e3); return /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(head); } catch (e) { return false; } } function mbnReverify(url, init, o) { o = o || {}; const log2 = (lvl, m) => { try { if (o.log) o.log(lvl, m); else (lvl === "warn" ? console.warn : console.info)("[MusicBrainz gate] " + m); } catch (e) { } }; const g = globalThis.__mbnCheck || (globalThis.__mbnCheck = { busy: null, failedAt: 0 }); if (g.busy) return g.busy; let target; try { target = new URL(String(url), location.href); } catch (e) { return Promise.resolve(false); } if (target.host !== location.host || typeof document === "undefined") { log2("warn", "MusicBrainz asked to verify the browser \u2014 open any MusicBrainz page, then retry"); return Promise.resolve(false); } if (Date.now() - g.failedAt < 6e4) return Promise.resolve(false); const method = String(init && init.method || "GET").toUpperCase(); const src = method === "GET" ? target.href : location.origin + "/ws/js/entity/89ad4ac3-39f7-470e-963a-56509c546377"; const t0 = Date.now(); log2("warn", 'MusicBrainz asked to verify the browser again ("Verifying your browser") \u2014 passing it in a hidden frame'); g.busy = new Promise((resolve) => { const fr = document.createElement("iframe"); fr.setAttribute("aria-hidden", "true"); fr.setAttribute("data-mbn-check", ""); fr.tabIndex = -1; fr.style.cssText = "position:fixed;width:1px;height:1px;left:-9999px;top:0;border:0;opacity:0;pointer-events:none"; let loads = 0, done = false, timer = 0; const finish = (ok) => { if (done) return; done = true; clearTimeout(timer); fr.remove(); g.busy = null; if (ok) log2("info", "browser check passed in " + (Date.now() - t0) + " ms \u2014 retrying"); else { g.failedAt = Date.now(); const m = "MusicBrainz wants to verify your browser \u2014 open any MusicBrainz page in another tab, then retry"; log2("warn", m + " (the hidden frame did not clear it in 20 s)"); try { if (o.notify) o.notify(m); } catch (e) { } } resolve(ok); }; fr.onload = () => { loads++; let still; try { const d = fr.contentDocument; still = !d || /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(d.documentElement ? d.documentElement.outerHTML.slice(0, 3e3) : ""); } catch (e) { still = loads < 2; } if (!still) finish(true); }; timer = setTimeout(() => finish(false), 2e4); fr.src = src; (document.body || document.documentElement).appendChild(fr); }); return g.busy; } // src/api-mb.js async function fetchMBEntity(mbid) { const res = await mbnFetch(`/ws/js/entity/${mbid}`); if (!res.ok) throw new Error(`/ws/js/entity/${mbid} \u2192 ${res.status}`); return res.json(); } var mbThrottle = /* @__PURE__ */ (() => { const MAX_CONCURRENT = 4; let _running = 0; let _pauseUntil = 0; const _queue = []; let _totalRequests = 0; let _rateLimited = 0; async function _waitForPause() { let wait = _pauseUntil - Date.now(); if (wait <= 0) return; logDebug(`throttle: waiting ${wait}ms for shared pause`); while ((wait = _pauseUntil - Date.now()) > 0) { await new Promise((r) => setTimeout(r, wait)); } } function _drain() { while (_running < MAX_CONCURRENT && _queue.length > 0) { _running++; const item = _queue.shift(); _run(item).finally(() => { _running--; _drain(); }); } } let _diagReqSeq = 0; const REQUEST_TIMEOUT_MS = 1e4; async function _run(item) { const tag = `req#${++_diagReqSeq}`; const shortUrl = item.url.replace(MB, "").replace(/^https:/, ""); const gated = mbnGated(item.url); const gateLog = (lv, m) => lv === "warn" ? log.warn(m) : logDebug(m); for (let attempt = 0; attempt <= item.retries; attempt++) { await _waitForPause(); if (gated) await mbnSlot({ label: shortUrl, log: gateLog }); _totalRequests++; const attemptTag = attempt === 0 ? "" : ` (retry ${attempt})`; logDebug(`${tag} [running=${_running} queued=${_queue.length}] GET ${shortUrl}${attemptTag}`); const t0 = Date.now(); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS); try { const res = gated ? await fetch(item.url, { signal: ctrl.signal }) : await mbnFetch(item.url, { signal: ctrl.signal }, { log: gateLog }); const elapsed = Date.now() - t0; if (gated) { const a = await mbnAnswer(res.status, (n) => res.headers.get(n), { log: gateLog }); if (a.throttled) { _rateLimited++; logDebug(`${tag} <- ${res.status} in ${elapsed}ms; the shared gate holds ${a.hold}ms`); continue; } } if (res.status === 429 || res.status === 503) { _rateLimited++; const ra = parseInt(res.headers.get("Retry-After"), 10); const waitMs = ra > 0 ? ra * 1e3 : Math.min(1e3 * Math.pow(2, attempt), 3e4); _pauseUntil = Math.max(_pauseUntil, Date.now() + waitMs); logDebug(`${tag} <- ${res.status} in ${elapsed}ms; shared pause pushed to +${waitMs}ms`); continue; } if (!res.ok) { logDebug(`${tag} <- ${res.status} (give up) in ${elapsed}ms`); item.resolve(item.nf && res.status === 404 ? { notFound: true } : null); return; } const data = item.wantJson ? await res.json() : res; logDebug(`${tag} <- ${res.status} in ${elapsed}ms`); item.resolve(data); return; } catch (e) { const elapsed = Date.now() - t0; const isTimeout = e?.name === "AbortError"; const reason = isTimeout ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : `${e?.message || e}`; if (isTimeout) { _rateLimited++; const waitMs = Math.min(1e3 * Math.pow(2, attempt), 8e3); _pauseUntil = Math.max(_pauseUntil, Date.now() + waitMs); logDebug(`${tag} threw in ${elapsed}ms: ${reason}; shared pause pushed to +${waitMs}ms`); } else { logDebug(`${tag} threw in ${elapsed}ms: ${reason}`); } if (attempt === item.retries) { item.resolve(null); return; } await new Promise((r) => setTimeout(r, 500)); } finally { clearTimeout(timer); } } item.resolve(null); } function _enqueue(url, retries, wantJson, nf = false) { return new Promise((resolve) => { _queue.push({ url, retries, wantJson, nf, resolve }); _drain(); }); } return { fetchJson: (url, retries = 3) => _enqueue(url, retries, true), // Like fetchJson, but a 404 resolves `{ notFound: true }` instead of // null — so null unambiguously means "lookup FAILED" (#193 chip bug). fetchJson404: (url, retries = 3) => _enqueue(url, retries, true, true), fetchRaw: (url, retries = 3) => _enqueue(url, retries, false), stats: () => ({ total: _totalRequests, rateLimited: _rateLimited, inFlight: _running, queued: _queue.length }) }; })(); async function fetchWithRetry(url, retries = 4) { return mbThrottle.fetchJson(url, retries); } var _relTypeCache = /* @__PURE__ */ new Map(); async function fetchArtistRelTypes(mbid) { if (!mbid) return null; if (_relTypeCache.has(mbid)) return _relTypeCache.get(mbid); const json = await mbThrottle.fetchJson( `${MB}/ws/2/artist/${mbid}?inc=recording-rels+release-rels+release-group-rels+work-rels&fmt=json&limit=100` ); if (!json) return null; const types = relRoleLabels(json.relations); _relTypeCache.set(mbid, types); return types; } var MODIFIER_ATTRS = /* @__PURE__ */ new Set(["additional", "guest", "solo", "minor", "bonus"]); function relRoleLabels(relations) { const labels = /* @__PURE__ */ new Set(); for (const r of relations || []) { if (!r.type) continue; const attrs = (Array.isArray(r.attributes) ? r.attributes : []).filter((a) => a && !MODIFIER_ATTRS.has(String(a).toLowerCase())); if ((r.type === "instrument" || r.type === "vocal") && attrs.length) { attrs.forEach((a) => labels.add(a)); } else { labels.add(r.type); } } return [...labels].sort(); } async function getSourceUrlsForRelease(mbid) { const url = `/ws/js/release/${mbid}?fmt=json&inc=rels`; let json = null; for (let attempt = 1; ; attempt++) { let res = null; try { res = await mbnFetch(url, { headers: { Accept: "application/json" } }); } catch (e) { if (attempt >= 4) throw e; } if (res && res.ok) { json = await res.json(); if (attempt > 1) log.info(`Sources: MusicBrainz answered on attempt ${attempt}`); break; } if (res && res.status === 404) { log.warn(`Sources: MusicBrainz says this release does not exist (404)`); return {}; } if (attempt >= 4) throw new Error(`MB /ws/js/release returned ${res ? res.status : "no response"} after ${attempt} attempts`); const wait = Number(res && res.headers.get("Retry-After")) * 1e3 || 400 * attempt + Math.floor(Math.random() * 300); log.warn(`Sources: MusicBrainz returned ${res ? res.status : "no response"} \u2014 retrying (${attempt}/3) in ${Math.round(wait)}ms`); await new Promise((r) => setTimeout(r, Math.min(wait, 8e3))); } { const rels = json.relationships || []; const urlRels = rels.filter((r) => r.target?.href_url); log.info(`Sources: MusicBrainz returned ${rels.length} relationship(s) for this release, ${urlRels.length} of them url(s)`); _lastLinkCount = urlRels.length; const abs = (u) => u && u.startsWith("//") ? "https:" + u : u; const href = (pred) => abs(rels.find(pred)?.target?.href_url || null); return { discogs: href((rel) => rel.target?.sidebar_name === "Discogs"), tidal: href((rel) => /(^|\/\/)(www\.|listen\.)?tidal\.com\/(browse\/)?album\/\d+/i.test(rel.target?.href_url || "")), qobuz: href((rel) => /(^|\/\/)(www\.|play\.|open\.)?qobuz\.com\/([a-z]{2}-[a-z]{2}\/)?album\//i.test(rel.target?.href_url || "")), deezer: href((rel) => /(^|\/\/)(www\.)?deezer\.com\/([a-z]{2}\/)?album\/\d+/i.test(rel.target?.href_url || "")), apple: href((rel) => /(^|\/\/)(?:music|itunes)\.apple\.com\/(?:[a-z]{2}\/)?album\/(?:[^/?#]+\/)?(?:id)?\d+/i.test(rel.target?.href_url || "")), // #435; iTunes URLs #436 metalArchives: href((rel) => /(^|\/\/)(www\.)?metal-archives\.com\/albums\/[^/]+\/[^/]+\/\d+/i.test(rel.target?.href_url || "")), // #453 ytmusic: href((rel) => /(^|\/\/)((music|www|m)\.)?youtube\.com\/(?:playlist\?(?:[^#]*&)?list=OLAK5uy_|browse\/MPREb_)/i.test(rel.target?.href_url || "")) // #648; www.youtube.com #679 }; } } var _lastLinkCount = 0; function logSourceProbe(sources, urlCount) { const found = Object.entries(sources || {}).filter(([, v]) => v); const n = urlCount == null ? _lastLinkCount : urlCount; log.info(`Sources: ${found.length} import source(s) from ${n} link(s)` + (found.length ? " \u2014 " + found.map(([k]) => k).join(", ") : " \u2014 none of the links is a supported source")); found.forEach(([k, v]) => logDebug(` source ${k}: ${v}`)); } function resolveLinkTypeId(name, type0, type1) { const lt = pageWindow.MB?.linkedEntities?.link_type; if (!lt) { log.error("MB.linkedEntities.link_type not available"); return null; } const needle = name.toLowerCase().trim(); const stripAttrs = (s) => (s || "").toLowerCase().replace(/\{[^}]*\}/g, "").replace(/\s+/g, " ").trim(); const candidates = Object.values(lt).filter( (v) => v.type0 === type0 && v.type1 === type1 && !v.deprecated ); for (const v of candidates) { if ((v.name || "").toLowerCase() === needle) return v.id; } for (const v of candidates) { if (stripAttrs(v.link_phrase) === needle) return v.id; if (stripAttrs(v.reverse_link_phrase) === needle) return v.id; } const contains = candidates.filter((v) => { const blobs = [v.name, stripAttrs(v.link_phrase), stripAttrs(v.reverse_link_phrase)].filter(Boolean); return blobs.some((b) => b.includes(needle) || needle.includes(b)); }); if (contains.length > 0) { contains.sort((a, b) => ((a.name || "").length || 999) - ((b.name || "").length || 999)); const best = contains[0]; if ((best.name || "").toLowerCase() !== needle) { log.info(`Fuzzy match: "${name}" \u2192 "${best.name}" (${type0}\u2192${type1})`); } return best.id; } const availableNames = candidates.map((v) => v.name).filter(Boolean).sort().join(", "); const allByName = Object.values(lt).filter( (v) => (v.name || "").toLowerCase() === needle || stripAttrs(v.link_phrase) === needle || stripAttrs(v.reverse_link_phrase) === needle ); const deprecatedHit = allByName.find((v) => v.type0 === type0 && v.type1 === type1 && v.deprecated); const wrongPairHits = allByName.filter((v) => !(v.type0 === type0 && v.type1 === type1)); if (deprecatedHit) { const altPairs = [...new Set(allByName.filter((v) => !v.deprecated).map((v) => `${v.type0}\u2192${v.type1}`))].join(", "); log.error(`"${name}" (${type0}\u2192${type1}) is deprecated by MB and would block the commit \u2014 skipping${altPairs ? `. Valid alternative(s): ${altPairs}` : ""}.`); } else if (wrongPairHits.length > 0) { const hitDesc = wrongPairHits.map((v) => `${v.name}(${v.type0}\u2192${v.type1})`).join(", "); log.warn(`No "${name}" link type for (${type0}\u2192${type1}) \u2014 exists for other entity pairs: ${hitDesc} \u2014 skipping`); } else { log.warn(`Unknown link type "${name}" (${type0}\u2192${type1}). Available for this pair: ${availableNames || "none"}`); } return null; } // src/storage.js var DB_NAME = "mblink"; var DB_VERSION = 2; var STORE = "entity_cache"; var db = null; var _request = indexedDB.open(DB_NAME, DB_VERSION); _request.onerror = function() { console.error("Why didn't you allow my web app to use IndexedDB?!"); }; _request.onsuccess = function(event) { db = event.target.result; }; _request.onupgradeneeded = function(event) { const upgradeDb = event.target.result; if (!upgradeDb.objectStoreNames.contains(STORE)) { upgradeDb.createObjectStore(STORE, { keyPath: "discogs_id" }); } }; function mbUrlOf(entityType, mbid) { return `${MB}/${entityType}/${mbid}`; } function readIdbRecord(key) { return new Promise((resolve) => { if (!key || !db) return resolve(null); try { const tx = db.transaction([STORE], "readonly"); const req = tx.objectStore(STORE).get(key); req.onsuccess = () => resolve(req.result || null); req.onerror = () => resolve(null); } catch (e) { resolve(null); } }); } function writeIdbRecord(key, partial) { return new Promise((resolve) => { if (!key || !db) return resolve(null); try { const tx = db.transaction([STORE], "readwrite"); const store = tx.objectStore(STORE); const getReq = store.get(key); getReq.onsuccess = () => { const existing = getReq.result || {}; const merged = { ...existing, ...partial, discogs_id: key, resolvedAt: (/* @__PURE__ */ new Date()).toISOString() }; if (merged.mbid && merged.entityType && !partial.mbUrl) { merged.mbUrl = mbUrlOf(merged.entityType, merged.mbid); } const putReq = store.put(merged); putReq.onsuccess = () => resolve(merged); putReq.onerror = () => resolve(null); }; getReq.onerror = () => resolve(null); } catch (e) { resolve(null); } }); } function deleteIdbRecord(key) { return new Promise((resolve) => { if (!key || !db) return resolve(false); try { const tx = db.transaction([STORE], "readwrite"); const store = tx.objectStore(STORE); const req = store.delete(key); req.onsuccess = () => resolve(true); req.onerror = () => resolve(false); } catch (e) { resolve(false); } }); } // src/progress-bar.js var _pInterval = null; var _pPos = -40; function _showBar() { const row1 = document.querySelector(".discogs-bar-row1"); const row2 = document.querySelector(".discogs-bar-row2"); const r1h = row1 ? row1.getBoundingClientRect().height : 42; let pb = document.getElementById("discogs-pb"); if (!pb) { pb = document.createElement("div"); pb.id = "discogs-pb"; pb.style.cssText = "position:fixed;left:0;right:0;height:5px;z-index:99999;background:var(--mbu-bg-sunken);overflow:hidden;"; const fill = document.createElement("div"); fill.id = "discogs-pb-fill"; fill.style.cssText = "position:absolute;top:0;height:100%;width:40%;background:var(--mbu-warn);transition:width 0.2s linear;"; pb.appendChild(fill); document.body.appendChild(pb); } pb.style.top = r1h + "px"; pb.style.display = "block"; if (row2) row2.style.marginTop = r1h + 5 + "px"; _startMarquee(); } function _startMarquee() { clearInterval(_pInterval); const fill = document.getElementById("discogs-pb-fill"); if (fill) { fill.style.width = "40%"; fill.style.left = _pPos + "%"; fill.style.transition = ""; } _pPos = -40; _pInterval = setInterval(() => { _pPos += 1.5; if (_pPos > 100) _pPos = -40; const f = document.getElementById("discogs-pb-fill"); if (f) f.style.left = _pPos + "%"; }, 16); } function _setProgressPct(pct) { const p = Math.max(0, Math.min(100, Number(pct) || 0)); clearInterval(_pInterval); _pInterval = null; const pctEl = document.getElementById("discogs-progress-pct"); if (pctEl) pctEl.textContent = Math.round(p) + "%"; const fill = document.getElementById("discogs-pb-fill"); if (!fill) return; fill.style.left = "0"; fill.style.transition = "width 0.2s linear"; fill.style.width = p + "%"; } function _hideBar() { clearInterval(_pInterval); _pInterval = null; const pb = document.getElementById("discogs-pb"); if (pb) pb.style.display = "none"; const row2 = document.querySelector(".discogs-bar-row2"); if (row2) row2.style.marginTop = ""; } // src/api-discogs.js var DISCOGS_URL_RE = /^https?:\/\/(?:www|api)\.discogs\.com\/(?:(?:(?!sell).+|sell.+)\/)?(master|release|artist|label)s?\/(\d+)(?:[^?#]*)(?:\?noanv=1|\?anv=[^=]+)?$/i; function parseDiscogsUrl(url) { const m = DISCOGS_URL_RE.exec(url); if (!m) return null; const type = m[1]; const id = m[2]; return { type, id, key: `${type}/${id}`, cleanUrl: `https://www.discogs.com/${type}/${id}` }; } var _releaseDataCache = /* @__PURE__ */ new Map(); function stripCompanyNums(json) { for (const list of [json.companies, json.labels]) { if (!Array.isArray(list)) continue; for (const c of list) { if (c && typeof c.name === "string") c.name = c.name.replace(/\s+\(\d+\)$/, ""); } } return json; } function getDiscogsReleaseData(url) { if (_releaseDataCache.has(url)) return Promise.resolve(_releaseDataCache.get(url)); return fetch( `${url.replace( "https://www.discogs.com/release/", "https://api.discogs.com/releases/" )}?token=gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ` ).then((body) => body.json()).then((json) => { _releaseDataCache.set(url, stripCompanyNums(json)); return json; }); } var _entityDataCache = /* @__PURE__ */ new Map(); function getDiscogsEntityData(resourceUrl) { if (!resourceUrl) return Promise.resolve(null); if (_entityDataCache.has(resourceUrl)) return Promise.resolve(_entityDataCache.get(resourceUrl)); return fetch(`${resourceUrl}?token=gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ`).then((r) => r.ok ? r.json() : r.status === 404 ? null : Promise.reject(new Error("HTTP " + r.status))).then((json) => { if (!json) { _entityDataCache.set(resourceUrl, null); return null; } const slim = { profile: json.profile || "", name: json.name || "", namevariations: json.namevariations || [], realname: json.realname || "" }; _entityDataCache.set(resourceUrl, slim); return slim; }).catch(() => null); } // src/data/entity-map.js var ENTITY_TYPE_MAP = { // Places "Arranged At": { entityType: "place", linkType: "arranged at" }, "Engineered At": { entityType: "place", linkType: "engineered at" }, "Recorded At": { entityType: "place", linkType: "recorded at" }, "Mixed At": { entityType: "place", linkType: "mixed at" }, "Mastered At": { entityType: "place", linkType: "mastered at" }, "Lacquer Cut At": { entityType: "place", linkType: "lacquer cut at" }, "edited At": { entityType: "place", linkType: "edited at" }, "Remixed At": { entityType: "place", linkType: "remixed at" }, "Produced At": { entityType: "place", linkType: "produced at" }, "Overdubbed At": null, "manufactured At": { entityType: "place", linkType: "manufactured at" }, "Glass Mastered At": { entityType: "place", linkType: "glass mastered at" }, "Pressed At": { entityType: "place", linkType: "pressed at" }, "Designed At": null, "Filmed At": null, "Exclusive Retailer": null, // labels "Copyright (c)": { entityType: "label", linkType: "copyright" }, "Phonographic Copyright (p)": { entityType: "label", linkType: "phonographic copyright" }, "Copyright \xA9": { entityType: "label", linkType: "copyright" }, "Phonographic Copyright \u2117": { entityType: "label", linkType: "phonographic copyright" }, "Licensed From": { entityType: "label", linkType: "licensor" }, "Licensed To": { entityType: "label", linkType: "licensee" }, "Licensed Through": null, "Distributed By": { entityType: "label", linkType: "distributed" }, "Made By": { entityType: "label", linkType: "manufactured" }, "Manufactured By": { entityType: "label", linkType: "manufactured" }, "Glass Mastered By": { entityType: "label", linkType: "glass mastered" }, "Pressed By": { entityType: "label", linkType: "pressed" }, "Marketed By": { entityType: "label", linkType: "marketed" }, "Printed By": { entityType: "label", linkType: "printed" }, "Promoted By": { entityType: "label", linkType: "promoted" }, "Published By": { entityType: "label", linkType: "published" }, "Rights Society": { entityType: "label", linkType: "rights society" }, "Arranged For": { entityType: "label", linkType: "arranged for" }, "Manufactured For": { entityType: "label", linkType: "manufactured for" }, "Mixed For": { entityType: "label", linkType: "mixed for" }, "Produced For": { entityType: "label", linkType: "produced for" }, "Miscellaneous Support": { entityType: "label", linkType: "misc" }, "Exported By": null, // Artists Performer: { entityType: "artist", linkType: "performer" }, // Discogs "Ensemble" — a group/ensemble credited as performing (e.g. a // vocal quartet or jazz ensemble). MB has no dedicated "ensemble" link // type, so the conventional fit is the generic `performer` relationship // (same as Discogs "Performer"). Without this it fell through to the // INSTRUMENTS table (`Ensemble: null`) and was dropped as unmapped. Ensemble: { entityType: "artist", linkType: "performer" }, // Discogs role "Accompanied By" → MB has no dedicated link type or // attribute for this. Closest semantic fit is `performer` with the // `additional` attribute (used elsewhere in MB for non-primary // contributions). Previously this fell through to the INSTRUMENTS map // and was dispatched as a bare `instrument` rel with the bogus // attribute value "accompanied by" — which MB silently drops, leaving // a junk instrument rel with no instrument named. "Accompanied By": { entityType: "artist", linkType: "performer", attributes: ["additional"] }, Instruments: { entityType: "artist", linkType: "instrument" }, Vocals: { entityType: "artist", linkType: "vocal" }, // Discogs "Voice [Humming]" and the like: a vocal (it was dropped, unmapped) Voice: { entityType: "artist", linkType: "vocal" }, "Backing Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, Choir: { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "choir vocals" }] }, Chorus: { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "choir vocals" }] }, "Choir Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "choir vocals" }] }, "Lead Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "lead vocals" }] }, // #427: voice-type vocals — MB carries each of these as a vocal attribute (verified // against the live link_attribute_type table). Hyphenated ones get both Discogs // casing variants, since the role lookup is exact-case. "Soprano Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "soprano vocals" }] }, "Mezzo-soprano Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "mezzo-soprano vocals" }] }, "Mezzo-Soprano Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "mezzo-soprano vocals" }] }, "Alto Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "alto vocals" }] }, "Contralto Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "contralto vocals" }] }, "Countertenor Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "countertenor vocals" }] }, "Tenor Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "tenor vocals" }] }, "Baritone Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "baritone vocals" }] }, "Bass-baritone Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "bass-baritone vocals" }] }, "Bass-Baritone Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "bass-baritone vocals" }] }, "Bass Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "bass vocals" }] }, "Treble Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "treble vocals" }] }, Orchestra: { entityType: "artist", linkType: "orchestra" }, Conductor: { entityType: "artist", linkType: "conductor" }, "Chorus Master": { entityType: "artist", linkType: "chorus master" }, Concertmaster: { entityType: "artist", linkType: "concertmaster" }, Concertmistress: { entityType: "artist", linkType: "concertmaster" }, "Compiled By": { entityType: "artist", linkType: "compiler" }, "DJ Mix": { entityType: "artist", linkType: "DJ-mixer" }, Remix: { entityType: "artist", linkType: "remixer" }, "contains samples by": { entityType: "artist", linkType: "contains samples by" }, "Written-By": { entityType: "artist", linkType: "writer" }, "Written By": { entityType: "artist", linkType: "writer" }, "Composed By": { entityType: "artist", linkType: "composer" }, // #233: author of the source text (e.g. the novel a Hörspiel adapts). MB // 'writer' is a work-level rel, so this lands on the work CH creates. Author: { entityType: "artist", linkType: "writer" }, // #233: spoken-word performer (audio play / Hörspiel cast). MB convention is // the 'vocal' relationship with the 'spoken vocals' attribute; the character // in the Discogs bracket becomes the credited-as (handled in mappers.js). "Voice Actor": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "spoken vocals" }] }, "Words By": { entityType: "artist", linkType: "lyricist" }, "Lyrics By": { entityType: "artist", linkType: "lyricist" }, "Libretto By": { entityType: "artist", linkType: "librettist" }, "Translated By": { entityType: "artist", linkType: "translator" }, "Arranged By": { entityType: "artist", linkType: "arranger" }, "Instrumentation By": { entityType: "artist", linkType: "instruments arranger" }, "Orchestrated By": { entityType: "artist", linkType: "orchestrator" }, "vocals arranger": { entityType: "artist", linkType: "vocals arranger" }, Producer: { entityType: "artist", linkType: "producer" }, "Co-producer": { entityType: "artist", linkType: "producer", // MB has no `co` attribute. The convention for "Co-X" is `additional` // on the base role (issue #3). See also the matching remap in // `getArtistRoles` for the regex /Co /. attributes: ["additional"] }, "Executive-Producer": { entityType: "artist", linkType: "producer", attributes: ["executive"] }, "Post Production": { entityType: "artist", linkType: "producer" }, Engineer: { entityType: "artist", linkType: "engineer" }, "Audio Engineer": { entityType: "artist", linkType: "audio engineer" }, "Mastered By": { entityType: "artist", linkType: "mastering" }, "Remastered By": { entityType: "artist", linkType: "mastering", attributes: ["re"] }, "Lacquer Cut By": { entityType: "artist", linkType: "lacquer cut" }, "sound engineer": { entityType: "artist", linkType: "sound engineer" }, "Mixed By": { entityType: "artist", linkType: "mix" }, "Recorded By": { entityType: "artist", linkType: "recording" }, "Recording Engineer": { entityType: "artist", linkType: "recording" }, "Programmed By": { entityType: "artist", linkType: "programming" }, Editor: { entityType: "artist", linkType: "editor" }, "Edited By": { entityType: "artist", linkType: "editor" }, "balance engineer": { entityType: "artist", linkType: "engineer" }, "copyrighted by": { entityType: "artist", linkType: "copyright" }, "phonographic copyright by": { entityType: "artist", linkType: "phonographic copyright" }, Legal: { entityType: "artist", linkType: "legal representation" }, Booking: { entityType: "artist", linkType: "booking" }, "Art Direction": { entityType: "artist", linkType: "art direction" }, Artwork: { entityType: "artist", linkType: "artwork" }, "Artwork By": { entityType: "artist", linkType: "artwork" }, Cover: { entityType: "artist", linkType: "artwork" }, Design: { entityType: "artist", linkType: "design" }, "Graphic Design": { entityType: "artist", linkType: "graphic design" }, Illustration: { entityType: "artist", linkType: "illustration" }, "Booklet Editor": { entityType: "artist", linkType: "booklet editor" }, Photography: { entityType: "artist", linkType: "photography" }, "Photography By": { entityType: "artist", linkType: "photography" }, Technician: { entityType: "artist", // MB's link type is singular ("instrument technician"). The plural // form here missed the exact-name match in resolveLinkTypeId, so the // fuzzy contains-match collapsed "instruments technician" down to the // bare "instrument" performance link type — turning a piano tuner / // tech credit into an instrument performer. #155 linkType: "instrument technician" }, publisher: { entityType: "artist", linkType: "published" }, "Liner Notes": { entityType: "artist", linkType: "liner notes" }, "A&R": { entityType: "artist", linkType: "misc" }, // #291: no dedicated MB rel — the generic "misc" relationship carries the // role name as its `task` attribute (mappers.js), so this lands as misc + task "research". Research: { entityType: "artist", linkType: "misc" }, Advisor: { entityType: "artist", linkType: "misc" }, "Concept By": { entityType: "artist", linkType: "misc" }, Contractor: { entityType: "artist", linkType: "misc" }, Coordinator: { entityType: "artist", linkType: "misc" }, Management: { entityType: "artist", linkType: "misc" }, "Musical Assistance": { entityType: "artist", linkType: "misc" }, "Tour Manager": { entityType: "artist", linkType: "misc" }, Other: { entityType: "artist", linkType: "misc" }, "Public Relations": { entityType: "artist", linkType: "misc" }, Promotion: { entityType: "artist", linkType: "misc" }, Crew: { entityType: "artist", linkType: "misc" }, "Supervised By": { entityType: "artist", linkType: "misc" }, "Director Of Photography": { entityType: "artist", linkType: "photography", attributes: [{ _type: "task", value: "director of photography" }] } }; // src/data/instruments.js var INSTRUMENTS = { Afox\u00E9: "afox\xE9", Agog\u00F4: "agog\xF4", Ashiko: "ashiko", Atabal: null, Bapang: "bhapang", "Clarinet": "clarinet", "Percussion": "percussion", "Congas": "congas", "Conga": "congas", "Conga Drum": "congas", "Bongos": "bongos", "Bongo": "bongos", "Tambourine": "tambourine", "Cuica": "cu\xEDca", "Guiro": "g\xFCiro", "G\xFCiro": "g\xFCiro", "Udu": "udu", "La\xFAd": "la\xFAd", "Laud": "la\xFAd", "Mbira": "mbira", "Kalimba": "mbira", "Slide Guitar": "slide guitar", "Acoustic Guitar": "acoustic guitar", "Double Bass": "double bass", "Goblet Drum": "goblet drum", "Darbouka": "darbuka", // #392 goblet drum (Discogs spelling; MB instrument is "darbuka") "Darbuka": "darbuka", "Maracas": "maracas", "Steel Drum": "steelpan", "Steelpan": "steelpan", "Electronics": "electronics", "Electronic": "electronics", "Synth Bass": "bass synthesizer", "Vocoder": "vocoder", "Xylophone": "xylophone", "Bells": "bells", "Chimes": "chimes", // #392 MB has no "wind chimes" instrument; the generic MB name is "chimes" "Glockenspiel": "glockenspiel", "Shakers": "shaker", "Shaker": "shaker", "Cowbell": "cowbell", "Claves": "claves", "Timbales": "timbales", "Baritone Guitar": "baritone guitar", "Synth": "synthesizer", "Synthesizer": "synthesizer", "Synths": "synthesizer", "Keyboards": "keyboard", "Keyboard": "keyboard", "Organ": "organ", "Harmonium": "harmonium", "Piano": "piano", "Electric Piano": "electric piano", "Harpsichord": "harpsichord", "Celesta": "celesta", "Strings": "string instruments", "Flute": "flute", "Saxophone": "saxophone", "Vibraphone": "vibraphone", "Trumpet": "trumpet", "Trombone": "trombone", "Violin": "violin", "Cello": "cello", "Viola": "viola", "Harp": "harp", "Banjo": "banjo", "Mandolin": "mandolin", "Ukulele": "ukulele", "Harmonica": "harmonica", "Accordion": "accordion", "Oboe": "oboe", "Bassoon": "bassoon", "Tuba": "tuba", "French Horn": "French horn", "Marimba": "marimba", "Melodica": "melodica", "Sitar": "sitar", "Oud": "oud", "Kora": "kora", "Tabla": "tabla", "Didgeridoo": "didgeridoo", "Theremin": "theremin", "Flugelhorn": "flugelhorn", "Cornet": "cornet", "Alto Saxophone": "alto saxophone", "Tenor Saxophone": "tenor saxophone", "Soprano Saxophone": "soprano saxophone", "Baritone Saxophone": "baritone saxophone", "Bass Clarinet": "bass clarinet", "Bass Flute": "bass flute", "Piccolo": "piccolo", "Bass Drum": "bass drum", Bata: "bat\xE1 drum", "Bell Tree": "bell tree", Bendir: "bendir", Bodhr\u00E1n: "bodhr\xE1n", "Body Percussion": "body percussion", Bombo: null, Bones: "bones", Buhay: null, Buk: "buk", Cabasa: "cabasa", Caixa: "caixa", "Caja Vallenata": null, Caj\u00F3n: "caj\xF3n", Calabash: "calabash", Castanets: "castanets", Caxixi: "caxixi", "Chak'chas": null, Chinch\u00EDn: null, Ching: "ching", Cymbal: "cymbal", Daf: "daf", Davul: "davul", Dhol: "dhol", Dholak: "dholak", Djembe: "djembe", Doira: null, Doli: null, Drum: "drum set", "Drum Programming": null, Drums: "drum set", Dunun: "dunun", "Electronic Drums": null, "Finger Cymbals": "finger cymbals", "Finger Snaps": "finger snaps", "Frame Drum": "frame drum", "Friction Drum": "friction drum", Frottoir: "frottoir", Ganz\u00E1: "ganz\xE1", Ghatam: "ghatam", Ghungroo: null, Gong: "gong", Guacharaca: null, Handbell: "handbell", Handclaps: "handclaps", "Hang Drum": "handpan", Hihat: "hi-hat", Hosho: null, Hyoshigi: "hyoshigi", Idiophone: "idiophone", Jaggo: null, Janggu: "janggu", Jing: "jing", "K'kwaengwari": null, Ka: null, "Kagura Suzu": null, Kanjira: "kanjira", Karkabas: null, Khartal: null, Khurdak: "duggi", Kynggari: null, Lagerphone: "monkey stick", "Lion's Roar": null, Madal: "madal", Mallets: null, "Monkey stick": "monkey stick", Mridangam: "mridangam", Pakhavaj: "pakhawaj", Pandeiro: null, Rainstick: "rainstick", Ratchet: "ratchet", Rattle: "shaken idiophone", "Reco-reco": "reco-reco", Repinique: "repinique", Rototoms: null, Scraper: null, Shakubyoshi: null, Shekere: "shekere", Shuitar: null, "Singing Bowls": null, Skratjie: null, Slapstick: "slapstick", "Slit Drum": "slit drum", Snare: null, Spoons: "spoons", "Stomp Box": null, Surdo: "surdo", Surigane: "atarigane", Taiko: "taiko", "Talking Drum": "talking drum", "Tam-tam": "chau gong", Tambora: null, Tamboril: "tabor", Tamborim: "tamborim", "Tan-Tan": null, "Tap Dance": "tap dance", "Tar (Drum)": null, "Temple Bells": null, "Temple Block": null, Thavil: "thavil", Timpani: "timpani", "Tom Tom": "tom-tom", Triangle: "triangle", T\u00FCng\u00FCr: null, Vibraslap: "vibraslap", Washboard: "washboard", Waterphone: "waterphone", "Wood Block": "wood block", Zabumba: "zabumba", Amadinda: "amadinda", Angklung: "angklung", Balafon: "balafon", Boomwhacker: "boomwhacker", Carillon: null, Crotales: "crotales", Guitaret: "guitaret", Lamellophone: "lamellaphone", Marimbula: "mar\xEDmbula", Metallophone: "metallophone", "Musical Box": "musical box", Prempensua: null, Slagbordun: null, "Steel Drums": "steelpan", "Thumb Piano": "mbira", Tubaphone: null, "Tubular Bells": "tubular bells", Tun: null, Txalaparta: "txalaparta", "Baby Grand Piano": null, Chamberlin: "chamberlin", Claviorgan: "claviorganum", "Concert Grand Piano": null, Dulcitone: "dulcitone", "Electric Harmonium": null, "Electric Harpsichord": null, "Electric Organ": "electronic organ", Fortepiano: null, "Grand Piano": "grand piano", Mellotron: "mellotron", Omnichord: "omnichord", "Ondes Martenot": "ondes martenot", "Parlour Grand Piano": null, Pedalboard: null, "Player Piano": null, Regal: "regal", Stylophone: null, "Tangent Piano": "tangent piano", "Toy Piano": "toy piano", "Upright Piano": "upright piano", Virginal: "virginal", "12-String Acoustic Guitar": null, "12-String Bass": null, "5-String Banjo": null, "6-String Banjo": null, "6-String Bass": null, "Acoustic Bass": "acoustic bass guitar", // #209 — Discogs separates this from "Double Bass", which maps to upright "Arco Bass": null, Arpa: null, Autoharp: "autoharp", Baglama: null, "Bajo Quinto": null, "Bajo Sexto": "bajo sexto", Balalaika: "balalaika", Bandola: null, Bandura: "bandura", Bandurria: "bandurria", Banhu: "banhu", Banjolin: "banjolin", "Baroque Guitar": "baroque guitar", Baryton: "baryton", "Bass Guitar": "bass guitar", Berimbau: "berimbau", Bhapang: "bhapang", Biwa: "biwa", "Blaster Beam": "blaster beam", Bolon: "bolon", Bouzouki: "bouzouki", "Bulbul Tarang": "bulbul tarang", Byzaanchi: null, Cavaquinho: "cavaquinho", "Cello Banjo": null, Changi: null, Chanzy: "chanzy", "Chapman Stick": "chapman stick", Charango: "charango", Chitarrone: null, Chonguri: null, Chuniri: null, Cimbalom: "cimbalom", Citole: "citole", Cittern: "cittern", Cl\u00E0rsach: null, "Classical Guitar": "classical guitar", Clavichord: "clavichord", Clavinet: "clavinet", Cobza: null, Contrabass: "double bass", Cuatro: "cuatro", C\u00FCmb\u00FC\u015F: "c\xFCmb\xFC\u015F", Cura: null, Deaejeng: null, "Diddley Bow": "diddley bow", Dilruba: "dilruba", Dobro: "resonator guitar", Dojo: null, Dombra: "dombra", Domra: "domra", Doshpuluur: "doshpuluur", Dulcimer: null, Dutar: "dutar", "\u0110\xE0n b\u1EA7u": "\u0111\xE0n b\u1EA7u", Ektare: null, "Electric Bass": "bass guitar", // MB's "bass guitar" IS the electric bass (was falling through to generic "instrument") "Electric Bass Guitar": "electric bass guitar", // MB's specific instrument (distinct from generic "bass guitar"); metal default, OP #453 "Electric Guitar": "electric guitar", "Electric Upright Bass": "electric upright bass", "Electric Violin": "electric violin", "Epinette des Vosges": null, Erhu: "erhu", Esraj: "esraj", Fiddle: "fiddle", "Flamenco Guitar": "flamenco guitar", "Fretless Bass": "fretless bass", "Fretless Guitar": null, Gadulka: "gadulka", Gaohu: "gaohu", Gayageum: "gayageum", Geomungo: "geomungo", Giga: "\u0123\u012Bga", Gittern: "gittern", Gottuv\u00E2dyam: null, Guimbri: "gumbri", Guitalele: "guitalele", Guitar: "guitar", "Guitar Banjo": "banjitar", "Guitar Synthesizer": "guitar synthesizer", Guitarr\u00F3n: null, GuitarViol: null, Guqin: "guqin", Gusli: "gusli", Guzheng: "guzheng", Haegum: "haegeum", Halldorophone: null, Hardingfele: "hardingfele", "Harp Guitar": "harp guitar", Hummel: "hummel", Huqin: "huqin", "Hurdy Gurdy": "hurdy gurdy", Igil: "igil", Jarana: null, Jinghu: "jinghu", Jouhikko: "jouhikko", Kabosy: null, Kamancha: "kamancheh", Kankl\u0117s: "kankl\u0117s", Kantele: "kantele", Kanun: "kanun", Kemenche: "kemenche", Kirar: null, Kobyz: null, Kokyu: "kokyu", Koto: "koto", Krar: "krar", Langeleik: "langeleik", Laouto: "laouto", "Lap Steel Guitar": "lap steel guitar", Lavta: "lavta", "Lead Guitar": "guitar", // #209 — MB has no lead/rhythm-guitar instrument; both are just "guitar" Lira: null, "Lira da Braccio": "lira da braccio", Lirone: "lirone", Liuqin: "liuqin", Lute: "lute", Lyre: "lyre", Mandobass: null, Mandocello: "mandocello", Mandoguitar: "mandoguitar", Mandola: "mandola", "Mandolin Banjo": null, Mandolincello: null, Marxophone: "marxophone", Masinko: null, Monochord: null, Morinhoor: null, "Mountain Dulcimer": null, "Musical Bow": "musical bow", Ngoni: "ng\u0254ni", Nyckelharpa: "nyckelharpa", "Open-Back Banjo": null, Outi: "oud", Panduri: null, "Pedal Steel Guitar": "pedal steel guitar", "Piccolo Banjo": null, Pipa: "pipa", "Plectrum Banjo": null, "Portuguese Guitar": "portuguese guitar", Psalmodicon: null, Psaltery: "psaltery", Rabab: "rebab", Rabeca: "rebec", Rebab: "rebab", Rebec: "rebec", Reikin: null, "Requinto Guitar": null, "Resonator Banjo": null, "Resonator Guitar": "resonator guitar", "Rhythm Guitar": "guitar", // #209 — see Lead Guitar Ronroco: "ronroco", Ruan: "ruan", Sanshin: "sanshin", Santoor: "santoor", Sanxian: "sanxian", Sarangi: "sarangi", Sarod: "sarod", "Selmer-Maccaferri Guitar": null, "Semi-Acoustic Guitar": null, Seperewa: null, "Shahi Baaja": "bulbul tarang", Shamisen: "shamisen", Sintir: "gumbri", Spinet: "spinet", "Steel Guitar": "steel guitar", "Stroh Violin": "stroh violin", Strumstick: null, Surbahar: "surbahar", "Svara Mandala": null, Swarmandel: null, Sympitar: null, SynthAxe: null, Taish\u014Dgoto: "taishogoto", Talharpa: "talharpa", Tambura: "tambura", Tamburitza: null, Tapboard: null, "Tar (lute)": null, "Tenor Banjo": "tenor banjo", "Tenor Guitar": "tenor guitar", Theorbo: "theorbo", Timple: "tiple", Tiple: "tiple", Tipple: "tiple", Tonkori: "tonkori", Tres: "tres", "Tromba Marina": "tromba marina", "Twelve-String Guitar": null, Tzouras: "tzoura", "Ukulele Banjo": "banjo-ukulele", \u00DCt\u0151gardon: "\xFCt\u0151gardon", Valiha: "valiha", Veena: "saraswati veena", Vielle: "vielle", Vihuela: "vihuela", Viol: "viola da gamba", "Viola Caipira": "viola caipira", "Viola d'Amore": "viola d'amore", "Viola da Gamba": "viola da gamba", "Viola de Cocho": null, "Viola Kontra": null, "Viola Nordestina": null, "Violino Piccolo": "violino piccolo", Violoncello: "cello", Violone: "violone", "Washtub Bass": "washtub bass", Xalam: "xalam", "Yang T'Chin": "yangqin", Yanggeum: null, Zither: "zither", Zongora: null, Algoza: "algozey", Alphorn: "alphorn", "Alto Clarinet": "alto clarinet", "Alto Flute": "alto flute", "Alto Horn": null, "Alto Recorder": "treble recorder / alto recorder", Apito: null, Bagpipes: "bagpipe", Bandoneon: "bandone\xF3n", Bansuri: "bansuri", "Baritone Horn": "baritone horn", "Barrel Organ": "barrel organ", "Bass Harmonica": "bass harmonica", "Bass Saxophone": "bass saxophone", "Bass Trombone": "bass trombone", "Bass Trumpet": "bass trumpet", "Bass Tuba": null, "Basset Horn": "basset horn", Bawu: "bawu", Bayan: "bayan", Bellowphone: null, Beresta: null, "Blues Harp": "harmonica", "Bolivian Flute": null, Bombarde: "bombarde", Brass: "brass", "Brass Bass": null, Bucium: null, Bugle: null, Chalumeau: "chalumeau", Chanter: null, Charamel: null, Chirimia: "chirim\xEDa", Clarion: null, Claviola: "claviola", Comb: null, "Concert Flute": "concert flute", Concertina: null, Conch: "conch", "Contra-Alto Clarinet": null, "Contrabass Clarinet": "contrabass clarinet", "Contrabass Saxophone": "contrabass saxophone", Contrabassoon: "contrabassoon", "Cor Anglais": "cor anglais", Cornett: "cornett", Cromorne: "crumhorn", Crumhorn: "crumhorn", Daegeum: "daegeum", Danso: "danso", "Dili Tuiduk": null, Dizi: "dizi", Drone: null, Duduk: "duduk", Dulcian: "dulcian", Dulzaina: "dulzaina", "Electronic Valve Instrument": null, "Electronic Wind Instrument": "wind synthesizer", "English Horn": "cor anglais", Euphonium: "euphonium", Fife: "fife", Flageolet: "flageolet", Flugabone: null, Fluier: null, Flumpet: "flumpet", "Flute D'Amour": "fl\xFBte d'amour", Friscaletto: null, Fujara: "fujara", Galoubet: "three-hole pipe", Gemshorn: "gemshorn", Gudastviri: null, Harmet: null, Heckelphone: "heckelphone", Helicon: "helicon", Hichiriki: "hichiriki", "Highland Pipes": null, Horagai: null, Horn: "horn", Horns: null, Hotchiku: "hotchiku", "Hunting Horn": null, Jug: "jug", Kagurabue: "kagurabue", Kaval: "kaval", Kazoo: "kazoo", Khene: "khene", Kortholt: "kortholt", Launeddas: "launeddas", Limbe: "limbe", Liru: null, "Low Whistle": "low whistle", Lur: null, Lyricon: "lyricon", M\u00E4nkeri: null, Mellophone: "mellophone", Melodeon: null, Mey: null, Mizmar: null, Mizwad: "mezwed", Moce\u00F1o: null, "Mouth Organ": "mouth organ", Murli: null, Musette: null, Nadaswaram: "nadaswaram", Ney: "ney", "Northumbrian Pipes": "northumbrian pipes", "Nose Flute": "nose flute", "Oboe d'Amore": "oboe d'amore", "Oboe Da Caccia": "oboe da caccia", Ocarina: "ocarina", Ophicleide: "ophicleide", "Overtone Flute": null, Panpipes: "pan flute", "Piano Accordion": "piano accordion", "Piccolo Flute": null, "Piccolo Trumpet": "piccolo trumpet", Pipe: null, Piri: "piri", Pito: "three-hole pipe", Pixiephone: null, Quena: "quena", Quenacho: null, Quray: null, Rauschpfeife: "rauschpfeife", Recorder: "recorder", Reeds: "reeds", Rhaita: null, Rondador: "rondador", Rozhok: null, Ryuteki: "ryuteki", Sackbut: "sackbut", Salamuri: null, Sampona: "siku", Sarrusophone: "sarrusophone", Saxello: null, Saxhorn: null, Schwyzer\u00F6rgeli: "schwyzer\xF6rgeli", Serpent: "serpent", Shakuhachi: "shakuhachi", Shanai: "shehnai", Shawm: "shawm", Shenai: "shehnai", Sheng: "sheng", Shinobue: "shinobue", Sho: "sho", "Shruti Box": "shruti box", "Slide Whistle": "slide whistle", Smallpipes: null, Sodina: null, Sopilka: "sopilka", "Sopranino Saxophone": "sopranino saxophone", "Soprano Clarinet": "soprano clarinet", "Soprano Cornet": null, "Soprano Flute": "soprano flute", "Soprano Trombone": null, Souna: null, Sousaphone: "sousaphone", "Subcontrabass Saxophone": null, Suling: "suling", Suona: "suona", Taepyungso: null, T\u00E1rogat\u00F3: "taragot", "Tenor Horn": null, "Tenor Trombone": "tenor trombone", "Ti-tse": null, "Tin Whistle": "tin whistle", Tonette: "tonette", Txirula: "three-hole pipe", Txistu: "three-hole pipe", "Uilleann Pipes": "uilleann pipes", "Valve Trombone": "valve trombone", "Valve Trumpet": null, "Wagner Tuba": "wagner tuba", Whistle: "whistle", "Whistling Water Jar": "jug", Wind: null, Woodwind: "woodwind", Xiao: "xiao", Yorgaphone: null, Zhaleika: "zhaleika", Zukra: null, Zurna: "zurna", "Automatic Orchestra": null, Computer: null, "Drum Machine": "drum machine", "Rhythm Box": "drum machine", // #223 — Discogs "[Rhythm Box]" preset drum unit Effects: "effects", Groovebox: null, Loops: null, "MIDI Controller": null, Noises: null, Sampler: "sampler", Scratches: "turntable", Sequencer: null, "Software Instrument": null, Talkbox: "talkbox", // not a Discogs role; a bracket names it ("Soloist [Moog Solo]") and MB has it Moog: "Moog", Tannerin: null, Tape: "tape", Turntables: "turntable", // 'Accompanied By' deliberately NOT in INSTRUMENTS — it's a meta role, // not an instrument. Mapped in ENTITY_TYPE_MAP to performer + additional. "Audio Generator": null, "Backing Band": null, Band: null, // Discogs "Bass" is generic (could be bass guitar, double bass, …) but MB // has a generic "bass" instrument for exactly that case. Leaving it null // dispatched a bare instrument rel with no instrument → "Missing instrument" // on commit (#133). Map to MB's generic "bass". Bass: "bass", "Brass Band": null, Bullroarer: "bullroarer", "Concert Band": null, "E-Bow": "ebow", Ensemble: null, Gamelan: "gamelan", "Glass Harmonica": "glass harmonica", Guest: null, Homus: null, Instruments: null, "Jew's Harp": "mouth harp", Morchang: null, Musician: null, Orchestra: null, Performer: null, "Rhythm Section": null, Saw: null, Siren: null, Soloist: null, Sounds: null, Toy: null, Trautonium: "trautonium", "Wind Chimes": null, "Wobble Board": null }; // ../../dev/match/artist-match.mjs var MBM_EXACT_LIMIT = 100; var MBM_SPECIAL_PURPOSE = [ "125ec42a-7229-4250-afc5-e057484327fe", // [unknown] "f731ccc4-e22a-43af-a747-64213329e088", // [anonymous] "33cf029c-63b0-41a0-9855-be2a3665fb3b", // [data] "314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc", // [dialogue] "eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61", // [no artist] "9be7f096-97ec-4615-8957-8d40b5dcbc41", // [traditional] "89ad4ac3-39f7-470e-963a-56509c546377", // Various Artists "7e84f845-ac16-41fe-9ff8-df12eb32af55", // MusicBrainz Test Artist "66ea0139-149f-4a0c-8fbf-5ea9ec4a6e49", // [Disney] "a0ef7e1d-44ff-4039-9435-7d5fefdeecc9", // [theatre] "90068d37-bae7-4292-be4a-704c145bd616", // [church chimes] "80a8851f-444c-4539-892b-ad2a49292aa9" // [language instruction] ]; function mbmFold(s) { return String(s == null ? "" : s).normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/gi, "d").replace(/[‐‑‒–—―−]/g, "-").toLowerCase().replace(/\s+/g, " ").trim(); } function mbmFoldKeepCase(s) { return String(s == null ? "" : s).normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/gi, "d").replace(/[‐‑‒–—―−]/g, "-").replace(/\s+/g, " ").trim(); } function mbmSameName(a, b) { return mbmFold(a) === mbmFold(b) && mbmFold(a) !== ""; } function mbmSameNameCase(a, b) { return mbmFoldKeepCase(a) === mbmFoldKeepCase(b) && mbmFoldKeepCase(a) !== ""; } function mbmHolds(entity, name, caseExact) { if (!entity) return null; const same = caseExact ? mbmSameNameCase : mbmSameName; if (same(entity.name, name)) return "name"; if ((entity.aliases || []).some((al) => same(al && (al.name != null ? al.name : al), name))) return "alias"; return null; } function mbmIdentityQuery(name, field) { const q = String(name == null ? "" : name).replace(/["\\]/g, " ").replace(/\s+/g, " ").trim(); return q ? 'alias:"' + q + '" OR ' + (field || "artist") + ':"' + q + '"' : ""; } function mbmExactIdentity(json, name, opts) { const o = opts || {}; if (!json || typeof json !== "object") return { status: "failed", exact: [] }; const list = json.artists || json.labels || json.places || []; let exact = list.filter((e) => mbmHolds(e, name)); if (exact.length > 1) { const caseExact = exact.filter((e) => mbmHolds(e, name, true)); if (caseExact.length === 1) exact = caseExact; else if (o.scoreGap) { const scored = exact.filter((e) => typeof e.score === "number").sort((a, b) => b.score - a.score); if (scored.length >= 2 && scored[0].score - scored[1].score >= o.scoreGap) exact = [scored[0]]; } } const offset = typeof json.offset === "number" ? json.offset : 0; const complete = typeof json.count === "number" && json.count <= offset + list.length; if (exact.length === 1 && complete) return { status: "unique", hit: exact[0], via: mbmHolds(exact[0], name) === "name" ? "name" : "alias", exact, complete }; if (exact.length > 1) return { status: "ambiguous", exact, complete }; if (!complete) return { status: "incomplete", exact, complete }; return { status: "none", exact, complete }; } function mbmRelatedArtists(artistJson) { if (!artistJson || !artistJson.id) return []; const out = [{ gid: artistJson.id, name: artistJson.name || "", aliases: (artistJson.aliases || []).map((a) => a && a.name).filter(Boolean), rel: "self" }]; for (const r of artistJson.relations || []) { const a = r && r.artist; if (!a || !a.id || out.some((x) => x.gid === a.id)) continue; out.push({ gid: a.id, name: a.name || "", aliases: [], rel: r.type || "" }); } return out; } function mbmContextHolders(related, name, candidates) { const cand = new Map((candidates || []).map((c) => [c.id || c.gid, c])); const out = []; for (const r of related || []) { let via = mbmSameName(r.name, name) ? "name" : (r.aliases || []).some((a) => mbmSameName(a, name)) ? "alias" : null; if (!via) { const c = cand.get(r.gid); if (c && mbmHolds(c, name)) via = mbmHolds(c, name); } if (via && !out.some((x) => x.gid === r.gid)) out.push({ gid: r.gid, name: r.name, via, rel: r.rel }); } return out; } function mbmCoCreditHits(recordingsJson, ctxGid, name) { const out = []; for (const rec of recordingsJson && recordingsJson.recordings || []) { for (const c of rec["artist-credit"] || []) { const a = c && c.artist; if (!a || !a.id || a.id === ctxGid) continue; if ((mbmSameName(c.name, name) || mbmSameName(a.name, name)) && !out.some((x) => x.gid === a.id)) out.push({ gid: a.id, name: a.name }); } } return out; } function mbmGuessSortName(name) { if (!name || !name.trim()) return name; name = name.trim().replace(/\s+/g, " "); if (/[^\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]/u.test(name)) return name; const words = name.split(" "); if (words.length === 1) return name; const article = name.match(/^(the|a|an)\s+(.+)$/i); if (article) return article[2] + ", " + article[1].charAt(0).toUpperCase() + article[1].slice(1).toLowerCase(); let base = name, suffix = ""; const sfx = name.match(/^(.*?),?\s+(jr\.?|sr\.?|ii|iii|iv|v|esq\.?)$/i); if (sfx) { base = sfx[1].trim(); suffix = " " + sfx[2]; } const parts = base.split(" "); if (parts.length === 1) return name; return parts[parts.length - 1] + ", " + parts.slice(0, -1).join(" ") + suffix; } // src/mappers.js var INSTRUMENTS_CI = Object.fromEntries( Object.entries(INSTRUMENTS).map(([k, v]) => [k.toLowerCase(), v]) ); var BRACKET_REFINES = [ [/^(electronic organ|organ)$/, /\b(b-?3|hammond)\b/i, "Hammond organ"], [/^electric piano$/, /\brhodes\b/i, "Rhodes piano"], [/^(guitar|acoustic guitar)$/, /\b(12|twelve)[- ]string\b/i, "12 string guitar"], [/^(guitar|acoustic guitar)$/, /\bnylon\b/i, "classical guitar"], [/^lute$/, /\b(tanpura|tambura)\b/i, "tambura"], [/^synthesizer$/, /\bmoog\b/i, "Moog"] ]; var isBareInstrument = (r) => r.linkType === "instrument" && !(r.attributes || []).some((a) => a && a._type === "instrument"); function flattenTracklist(tracklist) { if (!Array.isArray(tracklist)) return []; return tracklist.flatMap((t) => { if (t?.type_ === "index" && Array.isArray(t.sub_tracks)) { const parentExtra = Array.isArray(t.extraartists) ? t.extraartists : []; if (!parentExtra.length) return t.sub_tracks; return t.sub_tracks.map((st) => Object.assign({}, st, { extraartists: [...Array.isArray(st.extraartists) ? st.extraartists : [], ...parentExtra] })); } return [t]; }); } function getAllArtistTracks(tracklist, artistTracks) { tracklist = flattenTracklist(tracklist); return artistTracks.split(",").reduce((trackArray, trackNumber) => { if (/ to /.test(trackNumber)) { const parts = trackNumber.split(" to "); const startTrack = parts[0].trim().replace(".", "-"); const lastTrack = parts[1].trim().replace(".", "-"); let hasFoundStart = false, hasFoundEnd = false; tracklist.forEach((track) => { const resolvedTrackPosition = track.position.replace(".", "-"); if (!hasFoundStart && resolvedTrackPosition === startTrack) { hasFoundStart = true; trackArray.push(track); } else if (hasFoundStart && !hasFoundEnd) { if (resolvedTrackPosition === lastTrack) { hasFoundEnd = true; trackArray.push(track); } else if (track.position === "") { hasFoundEnd = true; } else { trackArray.push(track); } } }); } else { const track = tracklist.find((track2) => { return track2.position === trackNumber.trim(); }); if (track) { trackArray.push(track); } } return trackArray; }, []); } function convertPotentialDJMixers(json) { const all = flattenTracklist(json.tracklist || []).filter((t) => t.type_ === "track"); const djmixers = (json.extraartists || []).filter((artist) => artist.role === "DJ Mix" && artist.tracks).map((artist) => { const covered = new Set(getAllArtistTracks(json.tracklist, artist.tracks).map((t) => t.position)); if (!all.length || !all.every((t) => covered.has(t.position))) return null; json.extraartists = json.extraartists.filter((a) => a !== artist); return Object.assign({}, ENTITY_TYPE_MAP["DJ Mix"], { artist }); }).filter((role) => role !== null); return djmixers; } function getArtistRoles(artist) { const roleStr = artist.role; const rawRoles = roleStr.split(","); if (/\([0-9]+\)/.test(artist.anv)) { artist.anv = artist.anv.replace(/\([0-9]+\)/, "").trim(); } if (/\([0-9]+\)/.test(artist.name)) { artist.name = artist.name.replace(/\([0-9]+\)/, "").trim(); } return rawRoles.map((role) => { let additionalAttributes = []; let creditedAs = null; let rolePart = role.trim().split("["); const actualRole = rolePart[0].trim(); if (/Recording Engineer/.test(rolePart[1]) && actualRole === "Engineer") { return Object.assign({}, ENTITY_TYPE_MAP["Recording Engineer"], { artist }); } if (/Mastering Engineer/.test(rolePart[1]) && actualRole === "Engineer") { return Object.assign({}, ENTITY_TYPE_MAP["Mastered By"], { artist }); } if (/Cover Design/.test(rolePart[1]) && actualRole === "Artwork") { return Object.assign({}, ENTITY_TYPE_MAP["Design"], { artist }); } if (/Design/.test(rolePart[1]) && actualRole === "Cover") { return Object.assign({}, ENTITY_TYPE_MAP["Design"], { artist }); } if (/Art/.test(rolePart[1]) && actualRole === "Cover") { return Object.assign({}, ENTITY_TYPE_MAP["Artwork"], { artist }); } if (/Additional/.test(rolePart[1])) { additionalAttributes.push("additional"); } if (/Assistant/.test(rolePart[1])) { additionalAttributes.push("assistant"); } if (/Co /.test(rolePart[1])) { additionalAttributes.push("additional"); } if (/Executive/.test(rolePart[1])) { additionalAttributes.push("executive"); } if (/Associate/.test(rolePart[1])) { additionalAttributes.push("associate"); } if (/Guest/.test(rolePart[1])) { additionalAttributes.push("guest"); } if (/Solo/.test(rolePart[1])) { additionalAttributes.push("solo"); } const mapping = ENTITY_TYPE_MAP[actualRole]; if (mapping && mapping.linkType == "misc") { const taskValue = rolePart[1] ? rolePart[1].replace("]", "").trim().toLowerCase() : actualRole.trim().toLowerCase(); additionalAttributes.push({ _type: "task", value: taskValue }); } if (mapping && mapping.linkType == "engineer" && rolePart[1]) { additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() }); } if (mapping && mapping.linkType == "mix" && rolePart[1]) { additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() }); } if (mapping && mapping.linkType == "photography" && rolePart[1]) { additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() }); } if (mapping && mapping.linkType == "artwork" && rolePart[1]) { additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() }); } if (mapping && mapping.linkType == "vocal" && rolePart[1]) { creditedAs = rolePart[1].replace(/]/g, "").trim() || null; } const actualRoleLc = actualRole.toLowerCase(); if (!mapping && Object.prototype.hasOwnProperty.call(INSTRUMENTS_CI, actualRoleLc)) { let instrumentName = INSTRUMENTS_CI[actualRoleLc]; let role2 = ENTITY_TYPE_MAP.Instruments; if (actualRoleLc === "drum programming") { role2 = ENTITY_TYPE_MAP["Programmed By"]; instrumentName = INSTRUMENTS_CI["drum machine"]; } const bracket = rolePart[1] ? rolePart[1].replace(/]/g, "").trim() : ""; if (instrumentName && bracket) { const ref = BRACKET_REFINES.find(([base, re]) => base.test(instrumentName.toLowerCase()) && re.test(bracket)); if (ref) instrumentName = ref[2]; } if (!instrumentName && bracket) { const noSolo = bracket.replace(/\s*\bsolos?\b\s*/ig, " ").trim(); for (const candidate of [bracket, bracket.split(",")[0].trim(), noSolo]) { const lc = candidate.toLowerCase(); if (lc && Object.prototype.hasOwnProperty.call(INSTRUMENTS_CI, lc) && INSTRUMENTS_CI[lc]) { instrumentName = INSTRUMENTS_CI[lc]; break; } } } const extra = additionalAttributes.filter((a) => a === "solo" || a === "guest" || a === "additional"); return Object.assign({}, role2, { artist, attributes: (instrumentName ? [{ _type: "instrument", value: instrumentName.toLowerCase() }] : []).concat(extra) }); } if (!mapping) { return null; } if (Array.isArray(mapping.attributes)) { let mapped = mapping.attributes; if (creditedAs && mapping.linkType === "vocal") { mapped = mapped.map((a) => a && a._type === "vocal" ? Object.assign({}, a, { creditedAs }) : a); } additionalAttributes = additionalAttributes.concat(mapped); } return Object.assign({}, mapping, { artist, attributes: additionalAttributes }); }).filter((resolvedRole) => { return !!resolvedRole; }).filter((r, i, all) => !isBareInstrument(r) || !all.some((o) => o.linkType === "vocal" || o.linkType === "instrument" && !isBareInstrument(o))); } var ARTWORK_LINK_TYPES = /* @__PURE__ */ new Set(["artwork", "design", "photography", "illustration", "graphic design"]); function hoistFullSpanArtworkRels(artistRoles, tracklistRels, tracklist) { const allPos = new Set((tracklist || []).map((t) => String(t && t.position != null ? t.position : "")).filter(Boolean)); if (!allPos.size) return { artistRoles, tracklistRels, hoisted: [] }; const keyOf = (r) => [ r.artist && (r.artist.resource_url || r.artist.name) || "", r.linkType, JSON.stringify((r.attributes || []).map((a) => a && typeof a === "object" && a._type ? a._type + ":" + a.value : String(a)).sort()) ].join("|"); const groups = /* @__PURE__ */ new Map(); for (const r of tracklistRels) { if (!ARTWORK_LINK_TYPES.has(r.linkType) || !r.artist) continue; const k = keyOf(r); if (!groups.has(k)) groups.set(k, { rels: [], pos: /* @__PURE__ */ new Set() }); const g = groups.get(k); g.rels.push(r); if (r.track && r.track.position != null) g.pos.add(String(r.track.position)); } const drop = /* @__PURE__ */ new Set(), hoisted = []; for (const g of groups.values()) { if (g.pos.size !== allPos.size || ![...allPos].every((p) => g.pos.has(p))) continue; g.rels.forEach((r) => drop.add(r)); const rel = Object.assign({}, g.rels[0]); delete rel.track; hoisted.push(rel); } if (!hoisted.length) return { artistRoles, tracklistRels, hoisted }; return { artistRoles: artistRoles.concat(hoisted), tracklistRels: tracklistRels.filter((r) => !drop.has(r)), hoisted }; } function rolesFromDiscogsArtists(artists) { return artists?.reduce((rolesArr, artist) => { const roles = getArtistRoles(artist); if (Array.isArray(roles) && roles.length > 0) { return rolesArr.concat(roles); } return rolesArr; }, []) || []; } // src/sources/split-names.js function splitCombinedNames(name) { if (!name || !/[,&]/.test(name)) return [name]; const parts = name.split(/\s*,\s*|\s*&\s*/).map((s) => s.trim()).filter(Boolean); if (parts.length < 2 || !parts.every((p) => /\s/.test(p))) return [name]; return parts; } // src/util.js function assignVolumePositions(items, getNum) { const nums = items.map((it) => String(getNum(it) || "").trim()); const multiVolume = nums.some((n, i) => n && nums.indexOf(n) < i); let vol = 1; const seenInVol = /* @__PURE__ */ new Set(); const positions = nums.map((num) => { if (multiVolume && num && seenInVol.has(num)) { vol++; seenInVol.clear(); } if (num) seenInVol.add(num); return multiVolume ? `${vol}-${num}` : num; }); return { positions, multiVolume }; } function noPasswordManagers(el) { if (!el) return el; el.autocomplete = "off"; el.setAttribute("data-lpignore", "true"); el.setAttribute("data-1p-ignore", "true"); el.setAttribute("data-bwignore", "true"); el.setAttribute("data-form-type", "other"); if (el.tagName === "INPUT" && (!el.type || el.type === "text")) el.type = "search"; el.classList.add("ch-nopw"); return el; } function hashKey(str) { let h = 2166136261; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(16).padStart(8, "0"); } // src/sources/tidal.js var TIDAL_PERTRACK_BRIDGE = { "Vocal": "Vocals", "Background Vocal": "Backing Vocals", "Performance Arranger": "Arranged By" }; var TIDAL_PERTRACK_SKIP = /* @__PURE__ */ new Set(["Mastering Engineer", "Associated Performer", "Studio Personnel"]); var TIDAL_ROLE_MAP = { "Producer": { target: "recording", rel: "producer" }, "Remixer": { target: "recording", rel: "remixer" }, // #257 artist→recording remixer (was unrecognized → dropped) "Mixing Engineer": { target: "recording", rel: "mix" }, "Recording Engineer": { target: "recording", rel: "recording" }, "Sound Engineer": { target: "recording", rel: "sound engineer" }, "Lead Vocalist": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "lead vocals" }] }, // #257 // #266 Tidal's per-track background-vocal roles → recording vocal[background vocals] // (same MB attribute as the release-level "Backing Vocals", see entity-map.js). "Group Background Vocalists": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, "Background Vocalist": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, "Background Vocalists": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, "Composer": { target: "work", rel: "composer" }, "Lyricist": { target: "work", rel: "lyricist" }, "Writer": { target: "work", rel: "writer" }, "Orchestrator": { target: "work", rel: "orchestrator" }, "Music Publisher": { target: "work", rel: "publisher" }, "Publisher": { target: "work", rel: "publisher" } // Not mapped (reported, not imported): Mastering Engineer (artist→recording mastering // is deprecated in MB — it's release-level), Sound Editor, the Assistant * Engineer // variants (need an MB "assistant" attribute), and Studio Personnel (too generic). }; var TIDAL_COPYRIGHT_CONTROL_ID = "15780"; var isCopyrightControl = (n) => n?.tidalId === TIDAL_COPYRIGHT_CONTROL_ID || /^copyright control$/i.test(n?.name || ""); var TIDAL_RELEASE_ROLE_MAP = { "Mixing": "Mixed By", "Mixing Engineer": "Mixed By", "Recording": "Recorded By", "Recording Engineer": "Recorded By", "Sound Engineer": "sound engineer", "Mastering": "Mastered By", "Mastering Engineer": "Mastered By", "Vocals (Background)": "Backing Vocals", "Background Vocals": "Backing Vocals", "Composer": "Composed By", "Lyricist": "Lyrics By", "Writer": "Written-By", "Orchestrator": "Orchestrated By", // #325 instrument-name variants Tidal uses that the INSTRUMENTS table doesn't // match verbatim (plurals / qualified forms) → canonical Discogs instrument names. "Guitars": "Guitar", "Bass Instrument": "Bass", "Bass Guitars": "Bass Guitar", "Sax (Alto)": "Alto Saxophone", "Sax (Tenor)": "Tenor Saxophone", "Sax (Baritone)": "Baritone Saxophone", "Sax (Soprano)": "Soprano Saxophone", "Keyboard": "Keyboards" }; var TIDAL_RELEASE_COMPANY_MAP = { "Current Distributor": "Distributed By", "Distributor": "Distributed By" }; var TIDAL_RELEASE_ROLE_SKIP = /* @__PURE__ */ new Set([ "Primary Artist", "Featured Artist", "Main Artist", "Artist", "Record Label", "Manufacturer", "Copyright", "Phonographic Copyright" ]); var TIDAL_ALBUM_RE = /^(?:https?:)?\/\/(?:www\.|listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/i; function parseTidalAlbumUrl(url) { const m = TIDAL_ALBUM_RE.exec(url || ""); if (!m) return null; return { id: m[1], creditsUrl: `https://tidal.com/album/${m[1]}/credits` }; } function extractTidalCreditsDom(doc) { const out = []; for (const item of doc.querySelectorAll('[data-test="album-info-item"]')) { const num = item.querySelector('[class*="_trackNumber"]')?.textContent?.trim() || ""; const titleEl = item.querySelector('[class*="_title_"]'); const credits = []; for (const cell of item.querySelectorAll('[class*="_creditsCell"]')) { const role = cell.querySelector("[data-uppercase]")?.textContent?.trim(); if (!role) continue; const names = [...cell.querySelectorAll('[data-test="grid-item-detail-text-title-artist"]')].map((el) => ({ name: el.getAttribute("title") || el.textContent.trim(), tidalId: (el.getAttribute("href") || "").match(/\/artist\/(\d+)/)?.[1] || null })).filter((n) => n.name); credits.push({ role, names }); } out.push({ num, title: titleEl?.getAttribute("title") || titleEl?.textContent?.trim() || "", tidalTrackId: titleEl?.getAttribute("data-test-id") || null, credits }); } return out; } function extractTidalReleaseCreditsDom(doc) { const out = []; for (const cell of doc.querySelectorAll('[class*="_creditsCell"]')) { if (cell.closest('[data-test="album-info-item"]')) continue; const role = cell.querySelector("[data-uppercase]")?.textContent?.trim(); if (!role) continue; const names = []; const seen = /* @__PURE__ */ new Set(); const textBox = cell.querySelector('[class*="_creditsCellText"]') || cell; for (const el of textBox.querySelectorAll("a, [title]")) { const name = (el.getAttribute("title") || el.textContent || "").trim(); if (!name || seen.has(name)) continue; seen.add(name); names.push({ name, tidalId: (el.getAttribute("href") || "").match(/\/artist\/(\d+)/)?.[1] || null }); } if (!names.length) { (textBox.textContent || "").split(/\s*,\s*/).map((s) => s.trim()).filter(Boolean).forEach((name) => { if (!seen.has(name)) { seen.add(name); names.push({ name, tidalId: null }); } }); } if (names.length) out.push({ role, names }); } return out; } var ASSISTANT_RE = /^Assistant\s+(.+)$/i; function tidalRoleBase(role) { const m = ASSISTANT_RE.exec(role || ""); return m ? m[1] : role; } function filterTidalCredits(tracks) { return tracks.map((t) => ({ ...t, credits: t.credits.map((c) => ({ ...c, names: c.names.filter((n) => !(/^(?:Music )?Publisher$/.test(c.role) && isCopyrightControl(n))) })).filter((c) => c.names.length) })); } var TIDAL_ARTIST_RE = /^(?:https?:)?\/\/(?:www\.|listen\.)?tidal\.com\/(?:browse\/)?artist\/(\d+)/i; function parseTidalArtistUrl(url) { const m = TIDAL_ARTIST_RE.exec(url || ""); if (!m) return null; return { id: m[1], key: `tidal-artist/${m[1]}`, cleanUrl: `https://tidal.com/artist/${m[1]}` }; } function tidalPublisherRole(name, track) { const role = { linkType: "publishing", entityType: "label", attributes: [], artist: { name, anv: "", entityType: "label", resource_url: "https://tidal.com/_publisher/" + encodeURIComponent(name) } }; if (track) role.track = track; return role; } function tidalCompany(name, entityTypeName) { return { entity_type_name: entityTypeName, name, resource_url: "https://tidal.com/_company/" + encodeURIComponent(entityTypeName) + "/" + encodeURIComponent(name) }; } function tidalToEngine(tracks) { const tracklistRels = []; const tracklist = []; const skipped = []; const filtered = filterTidalCredits(tracks); const { positions, multiVolume } = assignVolumePositions(filtered, (t) => t.num); filtered.forEach((t, i) => { const track = { position: positions[i], title: t.title || "", type_: "track" }; tracklist.push(track); for (const c of t.credits) { const base = tidalRoleBase(c.role); const assistant = base !== c.role; if (base === "Music Publisher" || base === "Publisher") { for (const n of c.names) { if (isCopyrightControl(n)) continue; tracklistRels.push(tidalPublisherRole(n.name, track)); } continue; } const names = c.names.flatMap((n) => { const parts = splitCombinedNames(n.name); return parts.length > 1 ? parts.map((nm) => ({ name: nm, tidalId: null })) : [n]; }); const mapping = TIDAL_ROLE_MAP[base]; if (mapping) { for (const n of names) { tracklistRels.push({ linkType: mapping.rel, entityType: "artist", attributes: [...mapping.attributes || [], ...assistant ? ["assistant"] : []], artist: { id: n.tidalId ? `tidal-${n.tidalId}` : void 0, name: n.name, anv: "", resource_url: n.tidalId ? `https://tidal.com/artist/${n.tidalId}` : "" }, track }); } continue; } if (TIDAL_PERTRACK_SKIP.has(base)) { for (const n of names) skipped.push(`track ${track.position} "${t.title}": ${c.role} \u2014 ${n.name}`); continue; } const discogsRole = TIDAL_PERTRACK_BRIDGE[base] || base; for (const n of names) { const artist = { id: n.tidalId ? `tidal-${n.tidalId}` : void 0, name: n.name, anv: "", role: discogsRole, resource_url: n.tidalId ? `https://tidal.com/artist/${n.tidalId}` : "" }; const rels = getArtistRoles(artist); if (!rels.length) { skipped.push(`track ${track.position} "${t.title}": ${c.role} \u2014 ${n.name}`); continue; } for (const r of rels) { tracklistRels.push({ linkType: r.linkType, entityType: "artist", attributes: [...r.attributes || [], ...assistant ? ["assistant"] : []], artist: r.artist, track }); } } } }); return { tracklistRels, tracklist, skipped, multiVolume }; } function tidalReleaseArtists(releaseCredits) { const artists = []; const publishers = []; const companies = []; const skipped = []; for (const c of releaseCredits || []) { const baseRole = tidalRoleBase(c.role); if (baseRole === "Music Publisher" || baseRole === "Publisher") { for (const n of c.names || []) { if (isCopyrightControl(n)) continue; publishers.push(tidalPublisherRole(n.name)); } continue; } if (TIDAL_RELEASE_COMPANY_MAP[c.role]) { for (const n of c.names || []) companies.push(tidalCompany(n.name, TIDAL_RELEASE_COMPANY_MAP[c.role])); continue; } if (TIDAL_RELEASE_ROLE_SKIP.has(c.role)) { (c.names || []).forEach((n) => skipped.push(`release: ${c.role} \u2014 ${n.name}`)); continue; } const base = tidalRoleBase(c.role); const assistant = base !== c.role; const discogsRole = TIDAL_RELEASE_ROLE_MAP[base] || base; const relNames = (c.names || []).flatMap((n) => { const parts = splitCombinedNames(n.name); return parts.length > 1 ? parts.map((nm) => ({ name: nm, tidalId: null })) : [n]; }); for (const n of relNames) { artists.push({ id: n.tidalId ? `tidal-${n.tidalId}` : void 0, name: n.name, anv: "", role: discogsRole, assistant, tidalRole: c.role, // for "not imported" reporting at the call site resource_url: n.tidalId ? `https://tidal.com/artist/${n.tidalId}` : "" }); } } return { artists, publishers, companies, skipped }; } var HARVEST_KEY = (reqId) => `ch-tidal-result:${reqId}`; var HARVEST_TIMEOUT_MS = 45e3; function runTidalHarvestPage() { const m = location.hash.match(/ch-req=([a-z0-9.-]+)/i); if (!m) return; const reqId = m[1]; const albumId = (location.pathname.match(/\/album\/(\d+)/) || [])[1] || null; const post = (payload) => { try { GM_setValue(HARVEST_KEY(reqId), { albumId, ts: Date.now(), ...payload }); } catch (e) { } }; const started = Date.now(); let lastCount = -1, stableSince = 0; const timer = setInterval(() => { const items = document.querySelectorAll('[data-test="album-info-item"]'); if (items.length > 0) { if (items.length !== lastCount) { lastCount = items.length; stableSince = Date.now(); } else if (Date.now() - stableSince > 1200) { clearInterval(timer); const tracks = extractTidalCreditsDom(document); harvestReleaseThenPost(tracks, post); return; } } if (Date.now() - started > HARVEST_TIMEOUT_MS - 5e3) { clearInterval(timer); post({ ok: false, error: lastCount > 0 ? "render never stabilised" : "credits never rendered (login wall? geo block?)" }); setTimeout(() => window.close(), 250); } }, 300); } function harvestReleaseThenPost(tracks, post) { const finish = (releaseCredits) => { post({ ok: true, tracks, releaseCredits }); setTimeout(() => window.close(), 250); }; const infoTab = document.querySelector('[data-test="album-info-tab-info"]'); if (!infoTab) { finish([]); return; } try { infoTab.click(); } catch (e) { finish([]); return; } const start = Date.now(); const t = setInterval(() => { const cells = [...document.querySelectorAll('[class*="_creditsCell"]')].filter((c) => !c.closest('[data-test="album-info-item"]')); if (cells.length > 0) { clearInterval(t); setTimeout(() => finish(extractTidalReleaseCreditsDom(document)), 600); } else if (Date.now() - start > 6e3) { clearInterval(t); finish([]); } }, 250); } function harvestTidalAlbum(albumUrl) { const parsed = parseTidalAlbumUrl(albumUrl); if (!parsed) return Promise.reject(new Error(`Not a Tidal album URL: ${albumUrl}`)); const reqId = `${parsed.id}.${Date.now().toString(36)}`; const key = HARVEST_KEY(reqId); const harvestUrl = `${parsed.creditsUrl}#ch-req=${reqId}`; if (typeof GM_openInTab === "function") { GM_openInTab(harvestUrl, { active: false, insert: true, setParent: true }); } else { const tab = window.open(harvestUrl, "_blank"); if (!tab) return Promise.reject(new Error("Popup blocked \u2014 allow popups for musicbrainz.org and retry")); } return new Promise((resolve, reject) => { let listenerId = null; let pollTimer = null; const done = (fn, arg) => { if (pollTimer) clearInterval(pollTimer); clearTimeout(deadline); try { if (listenerId !== null && typeof GM_removeValueChangeListener === "function") GM_removeValueChangeListener(listenerId); } catch (e) { } try { GM_deleteValue(key); } catch (e) { } fn(arg); }; const check = (value) => { if (value && typeof value === "object") done(resolve, value); }; if (typeof GM_addValueChangeListener === "function") { listenerId = GM_addValueChangeListener(key, (_n, _o, value) => check(value)); } pollTimer = setInterval(() => { try { check(GM_getValue(key)); } catch (e) { } }, 700); const deadline = setTimeout(() => done(reject, new Error("Tidal harvest timed out \u2014 is the credits tab open and loading?")), HARVEST_TIMEOUT_MS); }); } // src/sources/qobuz.js var QOBUZ_ROLE_MAP = { "Composer": { target: "work", rel: "composer" }, "Lyricist": { target: "work", rel: "lyricist" }, "Author": { target: "work", rel: "lyricist" }, "ComposerLyricist": { target: "work", rel: "writer" }, "Writer": { target: "work", rel: "writer" }, "Arranger": { target: "work", rel: "arranger" }, "Performance Arranger": { target: "recording", rel: "arranger" }, "Producer": { target: "recording", rel: "producer" }, "Co-Producer": { target: "recording", rel: "producer" }, "Assistant Producer": { target: "recording", rel: "producer", attributes: ["assistant"] }, "Mixer": { target: "recording", rel: "mix" }, "MixingEngineer": { target: "recording", rel: "mix" }, "Mixing Engineer": { target: "recording", rel: "mix" }, "Engineer": { target: "recording", rel: "engineer" }, "Assistant Engineer": { target: "recording", rel: "engineer", attributes: ["assistant"] }, "RecordingEngineer": { target: "recording", rel: "recording" }, "Recording Engineer": { target: "recording", rel: "recording" }, "MasteringEngineer": { target: "release", rel: "mastering" }, "Mastering Engineer": { target: "release", rel: "mastering" }, "Editor": { target: "recording", rel: "editor" }, "Remixer": { target: "recording", rel: "remixer" }, "Conductor": { target: "recording", rel: "conductor" }, "Vocals": { target: "recording", rel: "vocal" }, "Vocal": { target: "recording", rel: "vocal" }, "Background Vocal": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, "Background Vocals": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, "MusicPublisher": { target: "work", rel: "publisher" }, "Music Publisher": { target: "work", rel: "publisher" }, "MainArtist": null, "Main Artist": null, "FeaturedArtist": null, "Featured Artist": null, "AssociatedPerformer": null, "Associated Performer": null, "StudioPersonnel": null, "Studio Personnel": null }; var QOBUZ_INSTRUMENTS_CI = new Set(Object.keys(INSTRUMENTS).map((k) => k.toLowerCase())); function isQobuzRole(token) { return Object.prototype.hasOwnProperty.call(QOBUZ_ROLE_MAP, token) || QOBUZ_INSTRUMENTS_CI.has(token.toLowerCase()); } var QOBUZ_ALBUM_RE = /^(?:https?:)?\/\/(?:www\.|play\.|open\.)?qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/]+\/)?([a-z0-9]+)\/?(?:[?#]|$)/i; function parseQobuzAlbumUrl(url) { const m = QOBUZ_ALBUM_RE.exec(url || ""); if (!m) return null; const original = String(url).replace(/^\/\//, "https://"); const isStore = /^https?:\/\/(www\.)?qobuz\.com\/[a-z]{2}-[a-z]{2}\/album\//i.test(original); return { id: m[1], pageUrl: isStore ? original.split(/[?#]/)[0] : `https://www.qobuz.com/us-en/album/x/${m[1]}` }; } function decodeEntities(s) { return String(s).replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n)).replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&/g, "&").replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " "); } var QOBUZ_NOTICE_RE = /^\s*[(℗©]|\bcopyright\b/i; function parseQobuzCreditLine(line) { const out = []; for (const seg of String(line).split(" - ")) { const raw = seg.trim(); if (!raw || QOBUZ_NOTICE_RE.test(raw)) continue; const tokens = raw.split(",").map((t) => t.trim()).filter(Boolean); const firstRole = tokens.findIndex(isQobuzRole); if (firstRole === -1) { out.push({ name: tokens.join(", "), roles: [] }); continue; } const name = tokens.slice(0, firstRole).join(", "); const roles = tokens.slice(firstRole).filter(isQobuzRole); if (name) out.push({ name, roles }); else if (out.length) out[out.length - 1].roles.push(...roles); } return out; } function extractQobuzCredits(html) { const out = []; const re = /id="popinAddToCartBtnPlayerTrack(\d+)"|<p[^>]*class="[^"]*\btrack__info\b[^"]*"[^>]*>([\s\S]*?)<\/p>/gi; let m, current = 0, capturedFor = null; while ((m = re.exec(html)) !== null) { if (m[1] !== void 0) { current = parseInt(m[1], 10); capturedFor = null; continue; } const text = decodeEntities((m[2] || "").replace(/<[^>]+>/g, "").trim()); if (!text || !current || capturedFor === current) continue; out.push({ index: current, credits: parseQobuzCreditLine(text) }); capturedFor = current; } return out; } function extractQobuzAlbumInfo(html) { const og = html.match(/<meta property="og:title" content="([^"]*)"/)?.[1] || ""; return decodeEntities(og.replace(/ - Qobuz$/, "")); } function qobuzToEngine(parsedTracks) { const tracklistRels = []; const artistRoles = []; const tracklist = []; const skipped = []; const { positions, multiVolume } = assignVolumePositions(parsedTracks, (t) => t.index); parsedTracks.forEach((t, i) => { const track = { position: positions[i], title: "", type_: "track" }; tracklist.push(track); for (const credit of t.credits) { if (!credit.roles.length) { if (credit.name && !/^copyright control$/i.test(credit.name)) skipped.push(`track ${track.position}: (no role) \u2014 ${credit.name}`); continue; } const names = splitCombinedNames(credit.name); for (const role of credit.roles) { if (role === "MusicPublisher" || role === "Music Publisher") { if (!/^copyright control$/i.test(credit.name)) skipped.push(`track ${track.position}: Music Publisher \u2014 ${credit.name}`); continue; } for (const nm of names) { const url = names.length > 1 ? "" : credit.resource_url || ""; if (Object.prototype.hasOwnProperty.call(QOBUZ_ROLE_MAP, role)) { const plan = QOBUZ_ROLE_MAP[role]; if (!plan) continue; const rel = { linkType: plan.rel, entityType: "artist", attributes: [...plan.attributes || []], artist: { name: nm, anv: "", resource_url: url } // #353 Qobuz artist id (composer/performer) → exact link }; if (plan.target === "release") artistRoles.push(rel); else tracklistRels.push({ ...rel, track }); continue; } const rels = getArtistRoles({ name: nm, anv: "", role, resource_url: url }); if (!rels.length) { skipped.push(`track ${track.position}: ${role} \u2014 ${nm}`); continue; } for (const r of rels) { tracklistRels.push({ linkType: r.linkType, entityType: "artist", attributes: r.attributes || [], artist: r.artist, track }); } } } } }); return { tracklistRels, artistRoles, tracklist, skipped, multiVolume }; } function fetchQobuzAlbumPage(pageUrl) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("GM_xmlhttpRequest unavailable")); return; } GM_xmlhttpRequest({ method: "GET", url: pageUrl, headers: { "Accept": "text/html,application/xhtml+xml", "Accept-Language": "en-US,en;q=0.8" }, timeout: 2e4, onload: (r) => r.status >= 200 && r.status < 400 && r.responseText ? resolve(r.responseText) : reject(new Error(`Qobuz page returned ${r.status}`)), onerror: () => reject(new Error("Qobuz page fetch failed (network)")), ontimeout: () => reject(new Error("Qobuz page fetch timed out")) }); }); } var QOBUZ_API = "https://www.qobuz.com/api.json/0.2"; var QOBUZ_APP_ID = "712109809"; var QOBUZ_LS_KEY = "mbtools:qobuz"; function qobuzToken() { try { const t = JSON.parse(localStorage.getItem(QOBUZ_LS_KEY) || "null"); return t && t.token || null; } catch (e) { return null; } } function qobuzArtistUrl(id) { return `https://open.qobuz.com/artist/${id}`; } var QOBUZ_ARTIST_RE = /(?:https?:)?\/\/(?:www\.|play\.|open\.)?qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?(?:interpreter\/[^/]+\/|artist\/)(\d+)/i; function parseQobuzArtistUrl(url) { const m = QOBUZ_ARTIST_RE.exec(url || ""); return m ? { key: `qobuz-artist/${m[1]}`, cleanUrl: qobuzArtistUrl(m[1]), id: m[1] } : null; } function parseQobuzApiTracks(json) { const items = json && json.tracks && json.tracks.items || []; return items.map((t, i) => { const credits = t.performers ? parseQobuzCreditLine(decodeEntities(t.performers)) : []; const idByName = {}; if (t.composer && t.composer.id) idByName[t.composer.name] = t.composer.id; if (t.performer && t.performer.id) idByName[t.performer.name] = t.performer.id; for (const c of credits) { const id = idByName[c.name]; if (id) c.resource_url = qobuzArtistUrl(id); } return { index: t.track_number || i + 1, credits }; }).filter((t) => t.credits.length); } function qobuzApiAlbumInfo(json) { if (!json) return ""; return [json.title, json.artist && json.artist.name].filter(Boolean).join(", "); } function fetchQobuzApiAlbum(albumId, token) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("GM_xmlhttpRequest unavailable")); return; } GM_xmlhttpRequest({ method: "GET", url: `${QOBUZ_API}/album/get?album_id=${encodeURIComponent(albumId)}&app_id=${QOBUZ_APP_ID}`, headers: { "X-App-Id": QOBUZ_APP_ID, "X-User-Auth-Token": token, "Accept": "application/json" }, timeout: 2e4, onload: (r) => { if (r.status === 401) { reject(new Error("Qobuz session expired \u2014 re-login in Platform Check")); return; } if (r.status < 200 || r.status >= 300) { reject(new Error(`Qobuz album/get returned ${r.status}`)); return; } try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(new Error("Qobuz album/get: malformed JSON")); } }, onerror: () => reject(new Error("Qobuz album/get failed (network)")), ontimeout: () => reject(new Error("Qobuz album/get timed out")) }); }); } // src/sources/metal_archives.js var MA_ARTIST_RE = /^https?:\/\/(?:www\.)?metal-archives\.com\/artists\/([^/?#]+)\/(\d+)/i; function parseMetalArchivesArtistUrl(url) { const m = MA_ARTIST_RE.exec(url || ""); if (!m) return null; return { id: m[2], key: `metal-archives-artist/${m[2]}`, cleanUrl: `https://www.metal-archives.com/artists/${m[1]}/${m[2]}` }; } var MA_ALBUM_RE = /^https?:\/\/(?:www\.)?metal-archives\.com\/albums\/([^/?#]+)\/([^/?#]+)\/(\d+)/i; function parseMetalArchivesAlbumUrl(url) { const m = MA_ALBUM_RE.exec(url || ""); if (!m) return null; return { id: m[3], albumUrl: `https://www.metal-archives.com/albums/${m[1]}/${m[2]}/${m[3]}` }; } var MA_INSTRUMENT_MAP = { // Guitars / Bass are special-cased in bridgeToken (electric by default in metal, #453). "Drums": "Drums", "Drum programming": "Drum Programming", "Drum Programming": "Drum Programming", "Keyboards": "Keyboard", "Keyboard": "Keyboard", "Synthesizers": "Synthesizer", "Synthesizer": "Synthesizer", "Synth": "Synthesizer", "Synths": "Synthesizer", "Piano": "Piano", "Percussion": "Percussion", "Classical Percussion": "Percussion", "Bagpipes": "Bagpipe", "Bells": "Bell", "Round Bells": "Bell", "Kettledrums": "Kettledrum", "Contrabass": "Double Bass", "Citern": "Cittern", "Jew's Harp": "Mouth Harp", "Oak Stick": "Rhythm Sticks", "Western Concert Flute": "Concert Flute", "Woodchimes": "Chimes", "Wind instruments": "Wind Instruments", "Saxophone (alto)": "Alto Saxophone", "Saxophone (baritone)": "Baritone Saxophone", "Saxophone (tenor)": "Tenor Saxophone" // pass-through-friendly names (already in INSTRUMENTS): Accordion, Banjo, Bassoon, Bouzouki, Cello, // Clarinet, Concertina, Cowbell, Crumhorn, Domra, Fiddle, Flute, French Horn, Harp, Harpsichord, // Hurdy Gurdy, Mandolin, Oboe, Ocarina, Organ, Pan Flute, Saxophone, Shakuhachi, Sopilka, Strings, // Tambourine, Timpani, Tin Whistle, Trombone, Trumpet, Viola, Violin, Xylophone, Sitar, Ebow, Samples. }; var MA_VOCAL_SUBTYPE = { "lead": "Lead Vocals", "backing": "Backing Vocals", "back": "Backing Vocals", "additional": "Backing Vocals", "baritone": "Baritone Vocals", "soprano": "Soprano Vocals", "tenor": "Tenor Vocals", "alto": "Alto Vocals", "choirs": "Choir Vocals", "choir": "Choir Vocals", "spoken": "Spoken Vocals", "spoken word": "Spoken Vocals" }; var MA_STAFF_MAP = { "Songwriting": "Composed By", "Composition": "Composed By", "Lyrics": "Lyrics By", "Arrangements": "Arranged By", "Arrangement": "Arranged By", "Recording": "Recording Engineer", "Engineering": "Engineer", "Mixing": "Mixed By", "Remixing": "Remixer", "Mastering": "Mastered By", "Remastering": "Remastered By", "Producer": "Producer", "Executive Producer": "Executive-Producer", "Co-producer": "Co-producer", "Editing": "Edited By", "Technician": "Instruments", "Conductor": "Conductor", "Choirmaster": "Chorus Master", "Artwork": "Artwork By", "Illustrations": "Illustration", "Illustration": "Illustration", "Cover Art": "Artwork By", "Interior art": "Artwork By", "Art Direction": "Art Direction", "Photography": "Photography By", "Design": "Graphic Design", "Liner Notes": "Liner Notes", "Director": "Director" // design-with-a-task (#453): base rel + a `task` attribute (getArtistRoles doesn't add // it for the graphic-design link type, so bridgeToken attaches it explicitly). }; var MA_DESIGN_TASK = { "Layout": "layout", "Logo": "logo", "Photo manipulation": "photo manipulation" }; var MA_SKIP = /* @__PURE__ */ new Set([ "All Instruments", "Everything", "Unknown", "Production assistance", "Producer (pre-production)", "Authoring", "Orchestra leader", "Menu" ]); var MA_SPECIAL_SKIP = /* @__PURE__ */ new Set(["Ambience"]); function splitTopLevelCommas(str) { const out = []; let depth = 0, cur = ""; for (const ch of String(str || "")) { if (ch === "(") depth++; else if (ch === ")") depth = Math.max(0, depth - 1); if (ch === "," && depth === 0) { out.push(cur.trim()); cur = ""; } else cur += ch; } if (cur.trim()) out.push(cur.trim()); return out; } function parseTrackGroup(inner) { if (!/^tracks?\b/i.test(inner)) return null; const nums = []; inner.replace(/^tracks?\s*/i, "").split(",").forEach((part) => { const range = part.trim().match(/^(\d+)\s*[-–]\s*(\d+)$/); if (range) { for (let n = +range[1]; n <= +range[2]; n++) nums.push(n); } else { const one = part.trim().match(/^\d+$/); if (one) nums.push(+part.trim()); } }); return nums.length ? nums : null; } function parseMaRoleCell(cell) { return splitTopLevelCommas(cell).map((token) => { const details = []; let tracks = null; const base = token.replace(/\(([^()]*)\)/g, (_, inner) => { const tg = parseTrackGroup(inner.trim()); if (tg) tracks = (tracks || []).concat(tg); else if (inner.trim()) details.push(inner.trim()); return ""; }).replace(/\s+/g, " ").trim(); return { base, details, tracks }; }).filter((t) => t.base); } function bridgeToken(tok) { const base = tok.base; if (MA_SKIP.has(base) || MA_SPECIAL_SKIP.has(base)) return null; const detail = tok.details.map((d) => d.toLowerCase()).join(" "); if (/^guitars?$/i.test(base)) { const role = /\belectric\b/.test(detail) ? "Electric Guitar" : /\bacoustic\b/.test(detail) ? "Acoustic Guitar" : /\b(classical|nylon)\b/.test(detail) ? "Classical Guitar" : "Electric Guitar"; return { role, attrs: [] }; } if (/^bass(\s*guitar)?$/i.test(base)) { const role = /\bacoustic\b/.test(detail) ? "Acoustic Bass" : /\b(double|upright|contrabass)\b/.test(detail) ? "Double Bass" : /\bfretless\b/.test(detail) ? "Fretless Bass" : "Electric Bass Guitar"; return { role, attrs: [] }; } if (/^vocals?$/i.test(base) || /^voice$/i.test(base) || /^narration$/i.test(base)) { if (/^narration$/i.test(base)) return { role: "Spoken Vocals", attrs: [] }; const sub = tok.details.map((d) => d.toLowerCase()).find((d) => MA_VOCAL_SUBTYPE[d]); return { role: sub ? MA_VOCAL_SUBTYPE[sub] : "Vocals", attrs: [] }; } if (MA_DESIGN_TASK[base]) return { role: "Graphic Design", attrs: [{ _type: "task", value: MA_DESIGN_TASK[base] }] }; return { role: MA_INSTRUMENT_MAP[base] || MA_STAFF_MAP[base] || base, attrs: [] }; } function extractLineupTable(doc, id) { const table = doc.getElementById(id); const rows = []; if (!table) return rows; let band = null; for (const tr of table.querySelectorAll("tr")) { const a = tr.querySelector('a[href*="/artists/"]'); const tds = [...tr.querySelectorAll("td")]; if (!a) { const label = tds.map((td) => td.textContent.trim()).join(" ").replace(/^\[|\]$/g, "").trim(); if (label) band = label; continue; } const url = a.href; const nameText = a.textContent.trim(); const roleTd = tds.find((td) => !td.querySelector('a[href*="/artists/"]')); const roleCell = roleTd ? roleTd.textContent.trim().replace(/\s+/g, " ") : ""; rows.push({ name: nameText, url, roleCell, band }); } return rows; } function extractTracklist(doc) { const table = doc.querySelector("#album_tabs_tracklist table.table_lyrics, .album_tabs_tracklist table, table.table_lyrics"); const tracks = []; let disc = 0; if (!table) return { tracks, multiDisc: false }; const multiDisc = /\b(?:Disc|CD)\s*\d/i.test(table.textContent); for (const tr of table.querySelectorAll("tr")) { const tds = [...tr.querySelectorAll("td")]; if (/^(disc|cd|side)\s*\w+/i.test(tr.textContent.trim()) && !tr.querySelector("td.wrapWords")) { disc++; continue; } const m = tds[0] && tds[0].textContent.trim().match(/^(\d+)\.?$/); if (!m) continue; const n = +m[1]; const titleCell = tr.querySelector("td.wrapWords") || tds[1]; const title = titleCell ? titleCell.textContent.trim().replace(/\s+/g, " ") : ""; tracks.push({ position: multiDisc ? `${disc || 1}-${n}` : String(n), title, type_: "track" }); } return { tracks, multiDisc }; } function extractMaLineupDom(doc) { const typeEl = [...doc.querySelectorAll("#album_info dt, dl.float_left dt")].find((d) => /^type\b/i.test(d.textContent.trim())); const type = typeEl && typeEl.nextElementSibling ? typeEl.nextElementSibling.textContent.trim() : ""; const band = extractLineupTable(doc, "album_members_lineup"); const guest = extractLineupTable(doc, "album_members_guest"); const misc = extractLineupTable(doc, "album_members_misc"); const { tracks, multiDisc } = extractTracklist(doc); const multiBand = /split|collaboration/i.test(type) || [...band, ...guest].some((r) => r.band); if (/split/i.test(type)) { const bandNames = [...new Set([...band, ...guest, ...misc].map((r) => r.band).filter(Boolean))]; for (const t of tracks) { const b = bandNames.find((bn) => new RegExp("^" + bn.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*[-\u2013]\\s*", "i").test(t.title)); if (b) { t.band = b; t.title = t.title.replace(new RegExp("^" + b.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*[-\u2013]\\s*", "i"), ""); } } } return { type, multiBand, multiDisc, tracks, band, guest, misc }; } var HARVEST_KEY2 = (reqId) => `ch-ma-result:${reqId}`; var HARVEST_TIMEOUT_MS2 = 45e3; function runMetalArchivesHarvestPage() { const m = location.hash.match(/ch-req=([a-z0-9.-]+)/i); if (!m) return; const reqId = m[1]; const albumId = (location.pathname.match(/\/albums\/[^/]+\/[^/]+\/(\d+)/) || [])[1] || null; const post = (payload) => { try { GM_setValue(HARVEST_KEY2(reqId), { albumId, ts: Date.now(), ...payload }); } catch (e) { } }; const started = Date.now(); const timer = setInterval(() => { const challenged = /just a moment|attention required|checking your browser/i.test(document.title); const ready = document.querySelector("#album_info, .album_name, #album_tabs_tracklist, table.table_lyrics"); if (ready && !challenged) { clearInterval(timer); try { post({ ok: true, ...extractMaLineupDom(document) }); } catch (e) { post({ ok: false, error: "extract failed: " + (e && e.message) }); } setTimeout(() => window.close(), 250); return; } if (Date.now() - started > HARVEST_TIMEOUT_MS2 - 3e3) { clearInterval(timer); post({ ok: false, error: challenged ? "Cloudflare challenge did not clear in the tab" : "album page never rendered" }); setTimeout(() => window.close(), 250); } }, 300); } function harvestMetalArchivesAlbum(albumUrl) { const parsed = parseMetalArchivesAlbumUrl(albumUrl); if (!parsed) return Promise.reject(new Error(`Not a Metal Archives album URL: ${albumUrl}`)); const reqId = `${parsed.id}.${Date.now().toString(36)}`; const key = HARVEST_KEY2(reqId); const harvestUrl = `${parsed.albumUrl}#ch-req=${reqId}`; if (typeof GM_openInTab === "function") { GM_openInTab(harvestUrl, { active: false, insert: true, setParent: true }); } else { const tab = window.open(harvestUrl, "_blank"); if (!tab) return Promise.reject(new Error("Popup blocked \u2014 allow popups for musicbrainz.org and retry")); } return new Promise((resolve, reject) => { let listenerId = null, pollTimer = null; const done = (fn, arg) => { if (pollTimer) clearInterval(pollTimer); clearTimeout(deadline); try { if (listenerId !== null && typeof GM_removeValueChangeListener === "function") GM_removeValueChangeListener(listenerId); } catch (e) { } try { GM_deleteValue(key); } catch (e) { } fn(arg); }; const check = (value) => { if (value && typeof value === "object") done(resolve, value); }; if (typeof GM_addValueChangeListener === "function") listenerId = GM_addValueChangeListener(key, (_n, _o, value) => check(value)); pollTimer = setInterval(() => { try { check(GM_getValue(key)); } catch (e) { } }, 700); const deadline = setTimeout(() => done(reject, new Error("Metal Archives harvest timed out \u2014 is the album tab open and loading?")), HARVEST_TIMEOUT_MS2); }); } function maArtist(row, discogsRole) { const parsed = parseMetalArchivesArtistUrl(row.url); return { id: parsed ? `metal-archives-${parsed.id}` : void 0, name: row.name, anv: "", role: discogsRole, resource_url: parsed ? parsed.cleanUrl : "" }; } function rowToTrackRels(row, allTracks, byPosition, guest, skipped, sectionLabel, onlyQualified) { const rels = []; for (const tok of parseMaRoleCell(row.roleCell)) { if (onlyQualified && !tok.tracks) continue; const bridged = bridgeToken(tok); if (!bridged) { skipped.push(`${sectionLabel}: ${tok.base} \u2014 ${row.name}`); continue; } let resolved = getArtistRoles(maArtist(row, bridged.role)); if (!resolved.length && /s$/i.test(bridged.role)) resolved = getArtistRoles(maArtist(row, bridged.role.replace(/s$/i, ""))); if (!resolved.length) { skipped.push(`${sectionLabel}: ${tok.base} \u2014 ${row.name}`); continue; } const targets = tok.tracks ? tok.tracks.map((n) => byPosition.get(String(n))).filter(Boolean) : allTracks; for (const track of targets) { for (const r of resolved) { rels.push({ linkType: r.linkType, entityType: "artist", attributes: [...r.attributes || [], ...bridged.attrs, ...guest ? ["guest"] : []], artist: r.artist, track }); } } } return rels; } function metalArchivesToEngine(harvest) { const tracklist = (harvest.tracks || []).map((t) => ({ position: t.position, title: t.title, type_: "track", band: t.band })); const byPosition = new Map(tracklist.map((t) => [String(t.position).replace(/^\d+-/, ""), t])); tracklist.forEach((t) => byPosition.set(String(t.position), t)); const skipped = []; const tracklistRels = []; const isSplit = /split/i.test(harvest.type || ""); for (const [rows, guest, label] of [[harvest.band || [], false, "band"], [harvest.guest || [], true, "guest"]]) { for (const row of rows) { let scope = tracklist; if (isSplit && row.band) { scope = tracklist.filter((t) => t.band === row.band); if (!scope.length) { skipped.push(`${label} (split \u2014 no tracks matched band "${row.band}"): ${row.roleCell} \u2014 ${row.name}`); continue; } } tracklistRels.push(...rowToTrackRels(row, scope, byPosition, guest, skipped, label)); } } for (const row of harvest.misc || []) { tracklistRels.push(...rowToTrackRels(row, tracklist, byPosition, false, skipped, "misc", true)); } return { tracklistRels, tracklist, skipped, multiVolume: !!harvest.multiDisc }; } function metalArchivesReleaseArtists(harvest) { const artists = [], skipped = []; for (const row of harvest.misc || []) { for (const tok of parseMaRoleCell(row.roleCell)) { if (tok.tracks) continue; const bridged = bridgeToken(tok); if (!bridged) { skipped.push(`release: ${tok.base} \u2014 ${row.name}`); continue; } const a = maArtist(row, bridged.role); a.maRole = tok.base; a.maAttrs = bridged.attrs; artists.push(a); } } return { artists, publishers: [], companies: [], skipped }; } // src/sources/registry.js function parseSourceEntityUrl(url) { if (!url) return null; return parseDiscogsUrl(url) || parseTidalArtistUrl(url) || parseQobuzArtistUrl(url) || parseMetalArchivesArtistUrl(url); } function idbKeyForEntity(entity) { if (!entity) return null; return parseSourceEntityUrl(entity.resource_url)?.key || entity._cacheKey || null; } function sourceNameForUrl(url) { if (/tidal\.com\//i.test(url || "")) return "Tidal"; if (/qobuz\.com\//i.test(url || "")) return "Qobuz"; if (/deezer\.com\//i.test(url || "")) return "Deezer"; if (/(?:music|itunes)\.apple\.com\//i.test(url || "")) return "Apple"; if (/metal-archives\.com\//i.test(url || "")) return "Metal Archives"; if (/music\.youtube\.com\/|youtube\.com\/playlist\?(?:[^#]*&)?list=OLAK5uy_/i.test(url || "")) return "YouTube Music"; return "Discogs"; } var isSyntheticProviderUrl = (url) => /tidal\.com\/_(?:publisher|company)\//i.test(String(url || "")); function sourceUrlLinkTypeId(url, entityType) { if (isSyntheticProviderUrl(url)) return null; const src = sourceNameForUrl(url); if (src === "Tidal") return entityType === "artist" ? "978" : null; if (src === "Qobuz") return entityType === "artist" ? "978" : null; if (src === "Metal Archives") return entityType === "artist" ? "188" : null; return entityType === "label" ? "217" : entityType === "place" ? "705" : "180"; } // src/preflight.js var KIND_TABLE = { artist: { searchLimit: 100, resultKey: "artists", incRels: "artist-rels" }, label: { searchLimit: 8, resultKey: "labels", incRels: "label-rels" }, // Places also accept label-rels because MB editors often file a // facility as a label rather than a place (issue we've worked around // since the original company resolver). place: { searchLimit: 8, resultKey: "places", incRels: "place-rels+label-rels" } }; var toCandidate = (a) => ({ id: a.id, name: a.name, disambiguation: a.disambiguation || a["disambiguation-comment"] || "", score: a.score || 0, aliases: (a.aliases || []).map((al) => al && al.name).filter(Boolean) }); function contextHit(context, name, candidates) { if (!context || !context.related || !context.related.length) return null; const holders = mbmContextHolders(context.related, name, (candidates || []).map((c) => ({ id: c.id, name: c.name, aliases: (c.aliases || []).map((n) => ({ name: n })) }))); if (holders.length === 1) return holders[0]; if (holders.length > 1) logDebug(`context: "${name}" is carried by ${holders.length} related artists (${holders.map((h) => h.name).join(", ")}) \u2014 left to review`); return null; } async function coCreditHit(context, name) { if (!context || !context.coCredit || !context.seeds || !context.seeds.length) return null; const tally = /* @__PURE__ */ new Map(); for (const seed of context.seeds.slice(0, 4)) { const q = `arid:${seed} AND artistname:"${String(name).replace(/["\\]/g, " ")}"`; const json = await mbThrottle.fetchJson(`${MB}/ws/2/recording?query=${encodeURIComponent(q)}&inc=artist-credits&limit=25&fmt=json`); if (!json) continue; for (const h of mbmCoCreditHits(json, seed, name)) { const t = tally.get(h.gid) || { n: 0, name: h.name }; t.n++; tally.set(h.gid, t); } } const ranked = [...tally.entries()].sort((a, b) => b[1].n - a[1].n); if (ranked.length === 1 || ranked.length > 1 && ranked[0][1].n > ranked[1][1].n) return { gid: ranked[0][0], name: ranked[0][1].name }; if (ranked.length) logDebug(`co-credit: "${name}" \u2192 ${ranked.length} tied candidates \u2014 left to review`); return null; } async function resolveEntity(entity, kind, opts) { const { bypassIdb } = opts; const context = kind === "artist" ? opts.context || null : null; const { searchLimit, resultKey, incRels } = KIND_TABLE[kind]; const parsed = parseSourceEntityUrl(entity.resource_url); const key = parsed?.key || entity._cacheKey || null; const searchName = entity.name; const displayName = kind === "artist" ? entity.anv && entity.anv.trim() || entity.name : entity.name; const discogsHref = entity.resource_url.replace(/https:\/\/api\.discogs\.com\/(\w+?)s\/(\d+)/, "https://www.discogs.com/$1/$2"); function buildResolved(mbUrl, mbName, mbDisambig, via2, actualKind = kind, fromCache = false, urlLinkedIds2, creditOverride) { return { type: "resolved", entityType: actualKind, entity, displayName, discogsHref, mbUrl, mbName, mbDisambig, // User's saved "Credited as" override from a prior session // (IDB `creditOverride` field). Review-table reads this in // `pickPrefill` to populate the field. Undefined when no // prior override exists. #105. creditOverride, // `urlLinkedIds` — MBIDs that have a relation to this Discogs URL, // harvested from the URL lookup done during // preflight. The review-table uses this to render // the "Add Discogs link" / "already linked" / "linked // to different MB <type>" badge without issuing // another `/ws/2/url?…` query per row. `undefined` // means "preflight didn't ask MB" (IDB hit on a // legacy record that predates this field), in which // case review-table falls back to its own per-row // fetch. `[]` means "asked MB, got no relations" — // no fallback needed. urlLinkedIds: urlLinkedIds2, // `via` — the resolution mechanism (`name` / `url` / `both` / `user`, // or `cache` only when a legacy IDB record predates the // `resolvedVia` field and we genuinely can't recover it). // `fromCache`— whether THIS resolution came from IDB rather than a fresh // MB lookup. The two are orthogonal: a name-resolved entity // loaded from cache is `via='name'` + `fromCache=true`, and // the UI surfaces both as `name (cache)`. logEntry: { displayName, discogsHref, mbUrl, mbName, mbDisambig, via: via2, fromCache } }; } function buildAttention(nameMatches2, nameSearchFailed2, ambiguityReason, urlLinkedIds2, creditOverride) { return { type: "attention", entityType: kind, entity, displayName, discogsHref, nameMatches: nameMatches2 || [], // Saved "Credited as" override — see buildResolved. #105. creditOverride, // Same `urlLinkedIds` contract as on the resolved shape — review-table // uses it to skip the per-row URL fetch even for attention rows once // the user picks an MBID from the candidate list. urlLinkedIds: urlLinkedIds2, // Only artists track this — used by the review table to badge // entries that failed because of a rate-limited name search vs // entries that genuinely don't exist in MB. rateLimited: kind === "artist" && nameSearchFailed2 && !nameMatches2?.length, ambiguityReason: ambiguityReason || null }; } async function fetchMbEntityInfo(et, mbid) { const json = await mbThrottle.fetchJson(`${MB}/ws/2/${et}/${mbid}?fmt=json`); return json ? { name: json.name || null, disambiguation: json.disambiguation || "" } : { name: null, disambiguation: "" }; } if (bypassIdb && key) { await deleteIdbRecord(key); } if (!bypassIdb && key) { const cachedRec = await readIdbRecord(key); if (cachedRec?.mbid && cachedRec?.entityType) { const via2 = cachedRec.resolvedVia || "cache"; let cachedLinkedIds = cachedRec.urlLinkedIds; if (cachedLinkedIds === void 0 && (via2 === "url" || via2 === "both" || via2 === "both-alias")) { cachedLinkedIds = [cachedRec.mbid]; } if (Array.isArray(cachedLinkedIds) && cachedLinkedIds.length === 0) cachedLinkedIds = void 0; if (cachedRec.name) { return buildResolved( cachedRec.mbUrl, cachedRec.name, cachedRec.disambiguation || "", via2, cachedRec.entityType, true, cachedLinkedIds, cachedRec.creditOverride ); } const info = await fetchMbEntityInfo(cachedRec.entityType, cachedRec.mbid); if (info.name) { await writeIdbRecord(key, { name: info.name, disambiguation: info.disambiguation }); } return buildResolved( cachedRec.mbUrl, info.name, info.disambiguation, via2, cachedRec.entityType, true, cachedLinkedIds, cachedRec.creditOverride ); } if (cachedRec && Array.isArray(cachedRec.nameMatches)) { const attnLinkedIds = Array.isArray(cachedRec.urlLinkedIds) && cachedRec.urlLinkedIds.length === 0 ? void 0 : cachedRec.urlLinkedIds; const ctx = contextHit(context, searchName, cachedRec.nameMatches); if (ctx) { log.info(`Match: ${displayName} \u2192 ${ctx.name} \u2014 via release context (${ctx.rel || "related"}, ${ctx.via})`); const mbUrl = `${MB}/artist/${ctx.gid}`; await writeIdbRecord(key, { mbid: ctx.gid, entityType: "artist", name: ctx.name, disambiguation: "", resolvedVia: "ctx", nameMatches: null, mbUrl }); return buildResolved(mbUrl, ctx.name, "", "ctx", "artist", false, attnLinkedIds, cachedRec.creditOverride); } return buildAttention(cachedRec.nameMatches, false, null, attnLinkedIds, cachedRec.creditOverride); } } const [nameJson, urlJson] = await Promise.all([ mbThrottle.fetchJson( `${MB}/ws/2/${kind}?query=${encodeURIComponent(searchName)}&fmt=json&limit=${searchLimit}` ), parsed ? mbThrottle.fetchJson404( `${MB}/ws/2/url?resource=${encodeURIComponent(parsed.cleanUrl)}&inc=${incRels}&fmt=json` ) : Promise.resolve({ notFound: true }) ]); const nameSearchFailed = nameJson === null; const normalized = searchName.toLowerCase().trim(); const isArtist = kind === "artist"; const holdsName = (a) => isArtist ? !!mbmHolds(a, searchName) : a.name.toLowerCase().trim() === normalized; const found = nameJson?.[resultKey] || []; const nameMatches = [ ...found.filter((a) => holdsName(a)), ...found.filter((a) => !holdsName(a) && a.score != null && a.score >= 70).slice(0, 10) ].map(toCandidate); const exactNameMatches = nameMatches.filter(holdsName); const nameHit = exactNameMatches.length === 1 ? { kind, mbid: exactNameMatches[0].id, name: exactNameMatches[0].name, disambiguation: exactNameMatches[0].disambiguation || "", via: isArtist && !(exactNameMatches[0].name.toLowerCase().trim() === normalized) ? "alias" : "name" } : null; let urlHit = null; const urlLinkedIds = urlJson === null ? void 0 : (urlJson.relations || []).map((r) => kind === "place" ? r.place?.id || r.label?.id || null : r[kind]?.id || null).filter(Boolean); if (urlJson?.relations?.length > 0) { const rel = kind === "place" ? urlJson.relations.find((r) => r.place || r.label) : urlJson.relations.find((r) => r[kind]); if (rel) { const actualKind = rel[kind] ? kind : rel.label ? "label" : "place"; const a = rel[actualKind]; urlHit = { kind: actualKind, mbid: a.id, name: a.name || null, disambiguation: a.disambiguation || "" }; } } async function cacheAttention(matches) { if (key && !nameSearchFailed) { await writeIdbRecord(key, { mbid: null, entityType: null, name: null, mbUrl: null, disambiguation: "", resolvedVia: null, nameMatches: matches, // Omit when unknown (lookup failed) — never persist a guess. ...urlLinkedIds !== void 0 && { urlLinkedIds } }); } } let resolved = null; let via = null; const heldBy = (a) => isArtist ? mbmHolds(a, displayName) || mbmHolds(a, searchName) : holdsName(a) ? "name" : null; const urlHolder = urlHit && urlHit.kind === kind && !(nameHit && nameHit.mbid === urlHit.mbid) ? (nameJson?.[resultKey] || []).find((a) => a.id === urlHit.mbid && heldBy(a)) : null; if (urlHolder) { resolved = urlHit; via = heldBy(urlHolder) === "alias" ? "both-alias" : "both"; logDebug(`Match: ${displayName} \u2192 ${urlHolder.name} \u2014 the URL's artist holds the name${via === "both-alias" ? " as an alias" : ""} (${exactNameMatches.length} exact holder(s) in all)`); } else if (nameHit && urlHit) { if (nameHit.mbid === urlHit.mbid && nameHit.kind === urlHit.kind) { resolved = urlHit; via = nameHit.via === "alias" ? "both-alias" : "both"; } else { await cacheAttention(nameMatches); return buildAttention( nameMatches, false, `name \u2192 ${nameHit.kind}/${nameHit.mbid}, URL \u2192 ${urlHit.kind}/${urlHit.mbid}`, urlLinkedIds ); } } else if (urlHit) { resolved = urlHit; via = "url"; } else if (isArtist) { let reviewReason = null; const ctx = contextHit(context, searchName, nameMatches); if (ctx) { resolved = { kind: "artist", mbid: ctx.gid, name: ctx.name, disambiguation: "" }; via = "ctx"; log.info(`Match: ${displayName} \u2192 ${ctx.name} \u2014 via release context (${ctx.rel || "related"}, ${ctx.via})`); } else if (nameHit) { const idJson = await mbThrottle.fetchJson(`${MB}/ws/2/artist?query=${encodeURIComponent(mbmIdentityQuery(searchName, "artist"))}&fmt=json&limit=${MBM_EXACT_LIMIT}`); const idn = mbmExactIdentity(idJson, searchName); logDebug(`exact identity "${searchName}": ${idn.status} (${idJson ? (idJson.artists || []).length + " of " + idJson.count : "no response"})`); if (idn.status === "unique" && idn.hit.id === nameHit.mbid) { resolved = nameHit; via = idn.via; } else if (idn.status === "failed") { return buildAttention(nameMatches, true, null, urlLinkedIds); } else { reviewReason = idn.status === "incomplete" ? `not provably unique (${idJson.count} artists match)` : idn.status === "ambiguous" ? `${idn.exact.length} artists carry the name` : "the exact holder did not verify"; logDebug(`"${searchName}" not resolved by name \u2014 ${reviewReason}`); } } else if (exactNameMatches.length > 1) { reviewReason = `${exactNameMatches.length} artists carry the name`; logDebug(`"${searchName}" not resolved by name \u2014 ${reviewReason}`); } if (!resolved) { const cc = await coCreditHit(context, searchName); if (cc) { resolved = { kind: "artist", mbid: cc.gid, name: cc.name, disambiguation: "" }; via = "cred"; log.info(`Match: ${displayName} \u2192 ${cc.name} \u2014 via existing artist credits (co-credit search)`); } } if (!resolved && reviewReason) { logDebug(`"${searchName}" left to review \u2014 ${reviewReason}`); await cacheAttention(nameMatches); return buildAttention(nameMatches, false, reviewReason, urlLinkedIds); } } else if (nameHit) { resolved = nameHit; via = "name"; } if (resolved) { const mbUrl = `${MB}/${resolved.kind}/${resolved.mbid}`; let finalName = resolved.name; let finalDisam = resolved.disambiguation; let liveAliases = null; if (via === "url" && resolved.kind === "artist" && isArtist) { const live = await mbThrottle.fetchJson(`${MB}/ws/2/artist/${resolved.mbid}?inc=aliases&fmt=json`); if (live && live.id === resolved.mbid) { liveAliases = (live.aliases || []).map((al) => al && al.name).filter(Boolean); if (!finalName) { finalName = live.name || null; finalDisam = live.disambiguation || ""; } const held = heldBy({ name: live.name, aliases: live.aliases || [] }); if (held) via = held === "alias" ? "both-alias" : "both"; logDebug(`"${displayName}" \u2192 ${live.name}: found by URL; the artist ${held ? `holds the name as its ${held} \u2192 ${via}` : `doesn't carry the name (${liveAliases.length} alias(es))`}`); } else logDebug(`"${displayName}": the URL's artist ${resolved.mbid} couldn't be read \u2014 stays "url"`); } if (!finalName) { const info = await fetchMbEntityInfo(resolved.kind, resolved.mbid); finalName = info.name || null; finalDisam = info.disambiguation || ""; } if (key) { await writeIdbRecord(key, { mbid: resolved.mbid, entityType: resolved.kind, name: finalName, disambiguation: finalDisam || "", resolvedVia: via, // Omit when unknown (lookup failed) — never persist a guess. ...urlLinkedIds !== void 0 && { urlLinkedIds } }); } const out = buildResolved(mbUrl, finalName, finalDisam || "", via, resolved.kind, false, urlLinkedIds); if (resolved.kind === "artist") { const cand = nameMatches.find((c) => c.id === resolved.mbid); const self = !cand && via === "ctx" && context && context.related ? context.related.find((x) => x.gid === resolved.mbid && x.rel === "self") : null; if (cand && Array.isArray(cand.aliases)) out.mbAliases = cand.aliases; else if (liveAliases) out.mbAliases = liveAliases; else if (self && Array.isArray(self.aliases)) out.mbAliases = self.aliases; logDebug(`"${displayName}" \u2192 ${finalName}: aliases ${out.mbAliases ? `known (${out.mbAliases.length})` : "unknown"} for the "+ alias" check`); } return out; } await cacheAttention(nameMatches); return buildAttention(nameMatches, nameSearchFailed, null, urlLinkedIds); } async function resolveAll(entities, opts) { const { kindOf, progressLi, bypassIdb, progressLabel, context } = opts; const CONCURRENCY = 5; const MIN_GAP_MS = 50; let done = 0; const inFlightNames = /* @__PURE__ */ new Set(); function setProgress() { if (entities.length > 0) { try { _setProgressPct(done / entities.length * 100); } catch (_) { } } if (!progressLi) return; const remaining = entities.length - done; const checking = inFlightNames.size ? ` \u2014 checking <em>${[...inFlightNames].join(", ")}</em>` : ""; progressLi.innerHTML = `${progressLabel}\u2026 <strong>${done}/${entities.length}</strong> done${checking}` + (remaining === 0 ? " \u2714" : ` (${remaining} remaining)`); try { const plain = `${progressLabel}\u2026 ${done}/${entities.length} done` + (inFlightNames.size ? ` \u2014 checking ${[...inFlightNames].join(", ")}` : "") + (remaining === 0 ? " \u2714" : ` (${remaining} remaining)`); document.querySelector(".discogs-bar")?._setProgress?.(null, plain); } catch (_) { } } const delay = (ms) => new Promise((r) => setTimeout(r, ms)); const queue = entities.map((e, i) => ({ entity: e, index: i })); const results = new Array(entities.length); setProgress(); async function worker(slotIndex) { const tag = `worker#${slotIndex}`; logDebug(`${tag} starting (stagger ${slotIndex * MIN_GAP_MS}ms)`); await delay(slotIndex * MIN_GAP_MS); let processed = 0; while (queue.length > 0) { const { entity, index } = queue.shift(); const kind = kindOf(entity); if (!kind) { logDebug(`${tag} skip "${entity?.name || "?"}" \u2014 no resolvable kind`); done++; setProgress(); continue; } const displayName = kind === "artist" ? entity.anv && entity.anv.trim() || entity.name : entity.name; inFlightNames.add(displayName); setProgress(); const t0 = Date.now(); logDebug(`${tag} resolving "${displayName}" (${kind})`); results[index] = await resolveEntity(entity, kind, { bypassIdb, context }); const elapsed = Date.now() - t0; const r = results[index]; const outcome = r?.type === "resolved" ? `resolved via ${r.logEntry?.via || "?"}${r.logEntry?.fromCache ? " (cache)" : ""}` : r?.type === "attention" ? `unresolved (${r.nameMatches?.length || 0} candidates)` : "skipped"; logDebug(`${tag} "${displayName}" -> ${outcome} in ${elapsed}ms`); inFlightNames.delete(displayName); done++; processed++; setProgress(); } logDebug(`${tag} finished (${processed} entit${processed === 1 ? "y" : "ies"})`); } const slots = Math.min(CONCURRENCY, entities.length); logDebug(`resolveAll: ${entities.length} entit${entities.length === 1 ? "y" : "ies"}, ${slots} worker slot(s)`); if (slots > 0) await Promise.all(Array.from({ length: slots }, (_, i) => worker(i))); logDebug(`resolveAll: done`); return { allResults: results.filter(Boolean) }; } var ARTIST_KIND = () => "artist"; var ENTITY_KIND = (e) => e?.entityType || "artist"; var COMPANY_KIND = (c) => ENTITY_TYPE_MAP[c.entity_type_name]?.entityType ?? null; // src/derive/remix.js var STRONG = /* @__PURE__ */ new Set([ "extended", "original", "radio", "instrumental", "acapella", "acappella", "acoustic", "album", "single", "main", "long", "short", "full", "special", "bonus", "alternative", "alternate", "vip", "rmx", "redux", "dancefloor", "unplugged", "demo", "remastered", // remix-family words, in case a lead carries a second one // ("Extended Remix Edit"): strip them off the trailing edge too. "dub", "edit", "mix", "remix", "rework", "remodel", "reshuffle", "reprise", "version", "re-edit", "reedit" ]); var WEAK = /* @__PURE__ */ new Set([ "club", "deep", "tech", "soulful", "disco", "electro", "house", "techno", "progressive", "tribal", "vocal", "classic", "clean", "dirty", "censored", "uncensored", "studio", "live", "the", "a", "an" ]); var SEP_RE = /\s*(?:&|\+|,|\/|\bfeat\.?\b|\bft\.?\b|\bfeaturing\b)\s*/i; var isVinyl = (t) => /^\d+(?:"|''|”|inch|in)?$/.test(t); var norm = (t) => t.toLowerCase().replace(/[.''`]+$/, ""); var isStrong = (t) => { const l = norm(t); return STRONG.has(l) || isVinyl(l); }; var isDecorator = (t) => { const l = norm(t); return STRONG.has(l) || WEAK.has(l) || isVinyl(l); }; var TRAILING_RE = /^(.+?)\s+((?:re[-_ ]?)?(?:remix|rework|remodel|reshuffle|reprise|edit|dub|mix))(?:es|ed|s|d)?$/i; var BY_RE = /^(?:re[-_ ]?)?(?:remix|rework|remodel|reshuffle|rerub)(?:es|ed|s|d)?\s+by\s+(.+)$/i; function cleanName(raw) { let s = String(raw || "").replace(/\s*[([].*$/, "").replace(/[)\]]+\s*$/, ""); let tokens = s.trim().split(/\s+/).filter(Boolean); const pIdx = tokens.findIndex((t) => /['']s$/i.test(t)); if (pIdx !== -1) { tokens = tokens.slice(0, pIdx + 1); tokens[pIdx] = tokens[pIdx].replace(/['']s$/i, ""); } while (tokens.length && isStrong(tokens[tokens.length - 1])) tokens.pop(); if (!tokens.length) return null; if (tokens.every(isDecorator)) return null; const name = tokens.join(" ").replace(/['']s$/i, "").trim(); if (!/[A-Za-z0-9]/.test(name)) return null; return name; } function parseRemixTitle(title) { const result = { base: title || "", remixers: [], kind: null }; if (!title || typeof title !== "string") return result; const groups = title.match(/[([][^)\]]*[)\]]/g); if (!groups) return result; for (const g of groups) { const inner = g.slice(1, -1).trim(); let captured = null, kind = null; let m = BY_RE.exec(inner); if (m) { captured = m[1]; kind = "remix"; } else { m = TRAILING_RE.exec(inner); if (m) { captured = m[1]; kind = /mix$/i.test(m[2]) && !/remix$/i.test(m[2]) ? "mix" : /edit$/i.test(m[2]) ? "edit" : /dub$/i.test(m[2]) ? "dub" : "remix"; } } if (!captured) continue; const names = captured.split(SEP_RE).map(cleanName).filter(Boolean); if (!names.length) continue; result.kind = result.kind || kind; for (const n of names) if (!result.remixers.includes(n)) result.remixers.push(n); result.base = result.base.replace(g, "").replace(/\s{2,}/g, " ").trim(); } return result; } function deriveRemixRoles(tracklist, releaseMbid) { if (!Array.isArray(tracklist)) return []; const roles = []; for (const track of tracklist) { if (!track || track.type_ && track.type_ !== "track") continue; if (!track.title) continue; const { remixers } = parseRemixTitle(track.title); for (const name of remixers) { const artist = { name, anv: "", resource_url: "", _derived: true }; if (releaseMbid) artist._cacheKey = `titles-remix/${releaseMbid}/${name.toLowerCase().trim()}`; roles.push({ linkType: "remixer", artist, track, creditedAs: name, attributes: [], entityType: "artist" }); } } return roles; } // src/data/special-purpose.js var SPECIAL_PURPOSE_ARTISTS = /* @__PURE__ */ new Set([ "125ec42a-7229-4250-afc5-e057484327fe", // [unknown] "f731ccc4-e22a-43af-a747-64213329e088", // [anonymous] "33cf029c-63b0-41a0-9855-be2a3665fb3b", // [data] "314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc", // [dialogue] "eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61", // [no artist] "9be7f096-97ec-4615-8957-8d40b5dcbc41", // [traditional] "89ad4ac3-39f7-470e-963a-56509c546377", // Various Artists "7e84f845-ac16-41fe-9ff8-df12eb32af55", // MusicBrainz Test Artist "66ea0139-149f-4a0c-8fbf-5ea9ec4a6e49", // [Disney] "a0ef7e1d-44ff-4039-9435-7d5fefdeecc9", // [theatre] "90068d37-bae7-4292-be4a-704c145bd616", // [church chimes] "80a8851f-444c-4539-892b-ad2a49292aa9" // [language instruction] ]); // src/edit-note.js function buildEditNote(sourceUrl, opts, extraLines, sourceLabel) { const s = GM_info.script; const mbUrl = location.href.split(/[?#]/)[0].replace(/\/edit-relationships$/, ""); const homepage = s.homepageURL || s.homepage || "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md"; const header = s.name + " v" + s.version + " by " + s.author + " - " + homepage; const raw = String(sourceUrl || ""); const sourceName = sourceNameForUrl(raw); const cleanSource = sourceName === "YouTube Music" ? raw.split("#")[0] : raw.split(/[?#]/)[0]; const lines = [ header, "", "Release URL: " + mbUrl, // #408: a consolidated "Import all" run passes an explicit source label (it has no single // URL). Otherwise: the source URL's provider, or the title-derived "Titles" source (#271). sourceLabel ? "Source: " + sourceLabel : cleanSource ? sourceName + " URL: " + cleanSource : "Source: track titles" ]; if (opts) lines.push("Options: " + opts); if (extraLines) lines.push(...Array.isArray(extraLines) ? extraLines : [extraLines]); return lines.join("\n"); } function buildCreateNote(action = "Created the entity") { const s = GM_info.script; const homepage = s.homepageURL || s.homepage || "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md"; const header = s.name + " v" + s.version + " by " + s.author + " - " + homepage; const mbUrl = location.href.split(/[?#]/)[0].replace(/\/edit(-relationships)?$/, ""); return header + "\n\n" + action + " while importing credits onto " + mbUrl; } function splitOurNote(note) { const headerPrefix = GM_info.script.name + " v"; const lines = String(note || "").split("\n"); const idx = lines.findIndex((l) => l.startsWith(headerPrefix)); if (idx === -1) return null; const pre = lines.slice(0, idx).join("\n").replace(/\s+$/, ""); const our = lines.slice(idx); const relIdx = our.findIndex((l) => /^Release URL:/i.test(l)); return { pre, header: our[0], releaseLine: relIdx !== -1 ? our[relIdx] : "", body: (relIdx !== -1 ? our.slice(relIdx + 1) : our.slice(1)).join("\n").trim() }; } function blockSourceKey(block) { const first = (String(block).split("\n")[0] || "").trim(); const m = first.match(/^([A-Za-z][A-Za-z ]*?)\s+URL:/); if (m) return m[1].trim().toLowerCase(); if (/^Source:\s*track titles/i.test(first)) return "titles"; return first.toLowerCase(); } function combineEditNote(existingNote, ourNote) { const fresh = splitOurNote(ourNote); if (!fresh) return ourNote; const newKey = blockSourceKey(fresh.body); const prev = splitOurNote(existingNote); const keptBlocks = prev ? prev.body.split(/\n\n+/).map((b) => b.trim()).filter(Boolean).filter((b) => blockSourceKey(b) !== newKey) : []; const stacked = [fresh.body, ...keptBlocks].join("\n\n"); const ourBlock = `${fresh.header} ${fresh.releaseLine} ${stacked}`; const pre = prev ? prev.pre : String(existingNote || "").replace(/\s+$/, ""); return pre ? `${pre} ${ourBlock}` : ourBlock; } // src/alias-add.js var SPECIAL = new Set(MBM_SPECIAL_PURPOSE); function aliasHeldBy(artist, credit) { if (!artist || !credit) return true; const aliases = (artist.aliases || []).map((a) => typeof a === "string" ? { name: a } : a).filter(Boolean); return !!mbmHolds({ name: artist.name, aliases }, credit); } function wantsAliasButton(entityType, artist, credit) { if (entityType !== "artist" || !artist || !artist.id || SPECIAL.has(artist.id)) return false; if (!Array.isArray(artist.aliases)) return !aliasHeldBy({ name: artist.name, aliases: [] }, credit); return !aliasHeldBy(artist, credit); } var aliasFormUrl = (mbid) => `${location.origin}/artist/${mbid}/add-alias`; async function liveHolds(mbid, name) { const t0 = Date.now(); logDebug(`+ alias: checking ${mbid}'s live aliases for "${name}"`); let why = ""; const live = await Promise.race([ mbnFetch(`${location.origin}/ws/2/artist/${mbid}?inc=aliases&fmt=json`, { headers: { Accept: "application/json" } }, { background: false }).then((r) => { if (!r) { why = "cancelled"; return null; } if (!r.ok) { why = "HTTP " + r.status; return null; } return r.json(); }).catch((e) => { why = e && e.message || String(e); return null; }), new Promise((res) => setTimeout(() => { why = `no answer in ${LIVE_TIMEOUT / 1e3}s`; res(null); }, LIVE_TIMEOUT)) ]); const out = live && live.id ? { held: aliasHeldBy({ name: live.name, aliases: live.aliases || [] }, name), artistName: live.name } : null; if (out) logDebug(`+ alias: ${out.artistName} ${out.held ? "already carries" : "doesn't carry"} "${name}" (${Date.now() - t0} ms)`); else log.warn(`+ alias: couldn't read ${mbid}'s live aliases (${why || "no artist in the answer"}, ${Date.now() - t0} ms) \u2014 going ahead without the check`); return out; } var LIVE_TIMEOUT = 8e3; async function aliasNowHeld(mbid, name) { const live = await liveHolds(mbid, name); return live ? live.held : null; } async function openAddAliasForm(mbid, name, note) { const q = new URLSearchParams({ "edit-alias.name": name, "edit-alias.sort_name": name }); if (note) q.set("edit-alias.edit_note", note); const url = aliasFormUrl(mbid) + "?" + q.toString(); const tab = window.open("about:blank", "_blank"); const live = await liveHolds(mbid, name); if (live && live.held) { try { if (tab) tab.close(); } catch (e) { } log.info(`+ alias: "${name}" is already a name/alias of ${live.artistName} \u2014 form not opened`); return { already: true }; } log.info(`+ alias: opening MusicBrainz's add-alias form for "${name}" \u2014 ${url.split("?")[0]}`); let how = "the blank tab"; try { if (!tab || tab.closed) throw new Error(tab ? "the blank tab was closed" : "no tab handle (popup blocked?)"); tab.location.href = url; } catch (e) { how = "a new tab \u2014 " + (e && e.message || e); try { if (tab) tab.close(); } catch (e2) { } window.open(url, "_blank"); } logDebug(`+ alias: form opened in ${how}`); return { already: false }; } async function submitAliasBackground(mbid, name, note) { const url = aliasFormUrl(mbid); const live = await liveHolds(mbid, name); if (live && live.held) { log.info(`+ alias: "${name}" is already a name/alias of ${live.artistName} \u2014 nothing submitted`); return { already: true }; } const html = await fetch(url, { credentials: "same-origin" }).then((r) => { if (!r.ok) throw new Error(`GET add-alias HTTP ${r.status}`); return r.text(); }); const form = new DOMParser().parseFromString(html, "text/html").querySelector("form.edit-alias"); if (!form) throw new Error("MusicBrainz did not serve an alias form (still logged in?)"); const p = new URLSearchParams(); form.querySelectorAll("input, select, textarea").forEach((el) => { if (!el.name) return; if (el.type === "checkbox" || el.type === "radio") { if (el.checked) p.set(el.name, el.value || "1"); return; } p.set(el.name, el.value || ""); }); p.set("edit-alias.name", name); p.set("edit-alias.sort_name", name); p.set("edit-alias.type_id", ""); p.set("edit-alias.locale", ""); p.delete("edit-alias.primary_for_locale"); if (note) p.set("edit-alias.edit_note", note); log.info(`+ alias: submitting "${name}" as an alias of ${url.split("/artist/")[1].split("/")[0]} in the background (no type)`); const res = await fetch(url, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: p.toString() }); if (!res.ok) throw new Error(`add-alias submit HTTP ${res.status}`); if (!/\/add-alias\b/.test(res.url)) return res.url; const doc = new DOMParser().parseFromString(await res.text(), "text/html"); const msg = [...doc.querySelectorAll(".error, .errors li, p.error")].map((n) => (n.textContent || "").trim()).filter(Boolean)[0]; throw new Error(msg || "MusicBrainz rejected the alias without saying why"); } // src/split-credit.js var SEP_RE2 = /\s*(\bfeat\.?|\bft\.?|\bfeaturing|&|\band\b|\bvs\.?|\bwith\b|×|・|,|;)\s*/gi; var stripDiscogsNum = (s) => String(s || "").replace(/\s+\(\d+\)$/, ""); function splitCreditName(name) { const parts = stripDiscogsNum(name).split(SEP_RE2).filter((_, i) => i % 2 === 0).map((p) => p.trim()).filter(Boolean); if (parts.length < 2) return []; const words = (p) => p.split(/\s+/); const last = words(parts[parts.length - 1]); if (last.length > 1 && parts.slice(0, -1).every((p) => words(p).length === 1)) { const surname = last[last.length - 1]; return parts.map((p, i) => i === parts.length - 1 ? p : `${p} ${surname}`); } return parts; } var splitKey = (origKey, i) => `${origKey}#split${i}`; function expandSplitRoles(roles, splits, keyOfEntity) { if (!splits || !splits.size || !roles) return roles; const out = []; for (const role of roles) { const parts = role?.artist ? splits.get(keyOfEntity(role.artist)) : null; if (!parts) { out.push(role); continue; } for (const p of parts) { out.push({ ...role, creditedAs: "", artist: { name: p.name, anv: "", _syntheticKey: p.key, _splitOf: keyOfEntity(role.artist) } }); } } return out; } // src/review-table.js var _urlCheckSessionCache = /* @__PURE__ */ new Map(); function ensureCreatingStyle() { if (document.getElementById("ch-creating-style")) return; const st = document.createElement("style"); st.id = "ch-creating-style"; st.textContent = "@keyframes ch-creating-spin { to { transform: rotate(360deg); } }"; document.head.appendChild(st); } async function showReviewTable(allResults, rolesMap, companiesRolesMap, opts) { rolesMap = rolesMap || /* @__PURE__ */ new Map(); companiesRolesMap = companiesRolesMap || /* @__PURE__ */ new Map(); const onRefresh = opts?.onRefresh || null; const headerSlot = opts?.headerSlot || null; const importSourceName = opts?.sourceName || "Discogs"; const sourceIcon = opts?.sourceIcon || ""; const entitySources = opts?.entitySources || null; const sourceBadgeIcon = opts?.sourceBadgeIcon || (() => ""); const _preloadedNames = /* @__PURE__ */ new Map(); const _nullNames = allResults.filter((r) => r.type === "resolved" && r.mbUrl && !r.mbName); for (const r of _nullNames) { const rUrl = r.entity?.resource_url; try { const idbKey = idbKeyForEntity(r.entity); const rec = await readIdbRecord(idbKey); if (rec?.name) { _preloadedNames.set(rUrl, { name: rec.name, dis: rec.disambiguation || "" }); continue; } const mbid = (r.mbUrl || "").split("/").pop().replace(/[^a-f0-9-]/g, "").substring(0, 36); if (!mbid) continue; const et = r.entityType || "artist"; const data = await mbThrottle.fetchJson(`https:${MB}/ws/2/${et}/${mbid}?fmt=json`); if (data?.name) { _preloadedNames.set(rUrl, { name: data.name, dis: data.disambiguation || "" }); if (idbKey) { await writeIdbRecord(idbKey, { mbid, entityType: et, name: data.name, disambiguation: data.disambiguation || "" // No resolvedVia change — this is just a name-display // populate; whatever set the cached mbid stays the // source of truth for `resolvedVia`. }); } } } catch (e) { } } return new Promise((resolve) => { opts?.registerAbort?.(() => resolve(null)); const rowState = /* @__PURE__ */ new Map(); const rowSearchInputs = /* @__PURE__ */ new Map(); const linkState = /* @__PURE__ */ new Map(); const rowLinkChips = /* @__PURE__ */ new Map(); let linksNote = null; const splits = /* @__PURE__ */ new Map(); function updateLinksBadge() { if (!linksNote) return; const n = [...linkState.values()].filter((v) => v === "none").length; linksNote.textContent = n ? `\u{1F517} ${n} link${n === 1 ? "" : "s"}` : ""; linksNote.style.display = n ? "" : "none"; linksNote.classList.toggle("clickable", n > 0); } const keyOf = (r) => r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`; let _linkJumpIdx = -1; function jumpNextLink() { const n = allResults.length; let found = -1; for (let step = 1; step <= n; step++) { const i = (_linkJumpIdx + step) % n; if (linkState.get(keyOf(allResults[i])) === "none") { found = i; break; } } if (found === -1) return; _linkJumpIdx = found; const key = keyOf(allResults[found]); const chip = rowLinkChips.get(key); const target = chip || rowSearchInputs.get(key); if (!target) return; target.scrollIntoView({ behavior: "smooth", block: "center" }); if (chip) { const o = chip.style.boxShadow; chip.style.boxShadow = "0 0 0 3px rgba(232,119,29,0.6)"; setTimeout(() => { chip.style.boxShadow = o; }, 1200); } } const attentionCount = allResults.filter((r) => r.type === "attention").length; const mismatchCount = allResults.filter((r) => { if (r.type !== "resolved") return false; const e = r.logEntry; return e && e.mbName && e.displayName && e.mbName.toLowerCase().trim() !== e.displayName.toLowerCase().trim(); }).length; const URL_CHECK_CONCURRENCY = 5; const urlCheckPending = []; let urlCheckRunning = 0; let urlCheckStarted = false; function queuedUrlCheck(fn) { return new Promise((resolve2, reject) => { urlCheckPending.push({ fn, resolve: resolve2, reject }); if (urlCheckRunning < URL_CHECK_CONCURRENCY) { runUrlCheckWorker(); } }); } async function runUrlCheckWorker() { urlCheckRunning++; while (urlCheckPending.length > 0) { const { fn, resolve: resolve2, reject } = urlCheckPending.shift(); try { resolve2(await fn()); } catch (e) { reject(e); } } urlCheckRunning--; } const VIA_STYLES = { both: { text: "name+url", color: "var(--mbu-ok)" }, // high confidence "both-alias": { text: "alias+url", color: "var(--mbu-ok)" }, // #613 URL and an exact ALIAS agree url: { text: "url", color: "var(--mbu-accent-text)" }, name: { text: "name", color: "var(--mbu-accent-text)" }, alias: { text: "alias", color: "var(--mbu-accent-text)" }, // #613 exact alias of the MB artist (provably unique) ctx: { text: "context", color: "var(--mbu-ok)" }, // #612 a related artist of the release artist cred: { text: "co-credit", color: "var(--mbu-accent-text)" }, // #613 co-credit search (option) user: { text: "user", color: "var(--mbu-text-dim)" }, cache: { text: "cache", color: "var(--mbu-text-dim)" } // legacy: original mechanism unknown }; function viaCfg(via, fromCache) { const base = VIA_STYLES[via]; if (!base) return null; if (fromCache && via !== "cache") { return { text: `${base.text} (cache)`, color: base.color }; } return base; } function makeViaBadge(via, fromCache) { const cfg = viaCfg(via, fromCache); if (!cfg) return null; const span = document.createElement("span"); span.textContent = cfg.text; span.title = fromCache && via !== "cache" ? `Resolved via ${via}, served from cache` : `Resolved via ${via}`; span.style.cssText = `font-size:0.68rem;background:var(--mbu-bg-raised);color:${cfg.color};padding:0 0.35rem;border-radius:8px;border:1px solid var(--mbu-border);flex-shrink:0;`; return span; } const creditOverrides = /* @__PURE__ */ new Map(); const existingCreditByMbid = computeExistingCreditByMbid(); function computeExistingCreditByMbid() { const counts = /* @__PURE__ */ new Map(); const MB2 = pageWindow?.MB; const iterate = MB2?.tree?.iterate; if (!iterate) return counts; const valueOf = (yielded) => Array.isArray(yielded) ? yielded[1] : yielded; const isTree = (x) => x && typeof x === "object" && x.size != null && (x.left !== void 0 || x.right !== void 0 || x.value !== void 0); function tally(rel) { if (!rel || rel._status === 2) return; for (const side of [1, 0]) { const entity = rel[`entity${side}`]; const tgt = entity?.gid; if (!tgt) continue; const credit = rel[`entity${side}_credit`] || entity.name; if (!credit) continue; if (!counts.has(tgt)) counts.set(tgt, /* @__PURE__ */ new Map()); const m = counts.get(tgt); m.set(credit, (m.get(credit) || 0) + 1); } } function walkRels(rels) { if (!isTree(rels)) return; for (const e of iterate(rels)) tally(valueOf(e)); } function walkPhraseGroups(phraseGroups) { if (!isTree(phraseGroups)) return; for (const e of iterate(phraseGroups)) { const pg = valueOf(e); if (pg?.relationships) walkRels(pg.relationships); } } function walkTypeGroups(byTypeId) { if (!isTree(byTypeId)) return; for (const e of iterate(byTypeId)) { const tg = valueOf(e); if (tg?.phraseGroups) walkPhraseGroups(tg.phraseGroups); } } function walkPerSource(perSource) { if (!isTree(perSource)) return; for (const e of iterate(perSource)) { walkTypeGroups(valueOf(e)); } } function walkSource(root) { if (!isTree(root)) return; for (const e of iterate(root)) { walkPerSource(valueOf(e)); } } try { walkSource(MB2.relationshipEditor?.state?.existingRelationshipsBySource); } catch (e) { } try { walkSource(MB2.relationshipEditor?.state?.relationshipsBySource); } catch (e) { } const out = /* @__PURE__ */ new Map(); for (const [mbid, m] of counts) { let best = null, bestN = 0; for (const [credit, n] of m) { if (n > bestN) { best = credit; bestN = n; } } if (best) out.set(mbid, best); } return out; } const panel = document.createElement("div"); panel.style.cssText = "border:2px solid var(--mbu-warn);border-radius:0.5rem;background:var(--mbu-bg);padding:1rem 1.5rem;margin:0.5rem 0;"; { const _pb = document.getElementById("discogs-progress-bar"); if (_pb) _pb.style.display = "none"; } const _bar = document.querySelector(".discogs-bar"); if (_bar) { _hideBar(); const _r2 = _bar.querySelector(".discogs-bar-row2"); if (_r2) _r2.style.marginTop = ""; } const heading = document.createElement("div"); heading.style.cssText = "display:flex;align-items:center;gap:0.6rem;margin:0 0 0.5rem;padding:0.4rem 0.6rem;border-radius:0.3rem;background:var(--mbu-warn-bg);border:1px solid var(--mbu-warn);"; if (onRefresh) { const refreshBtn = document.createElement("button"); refreshBtn.textContent = "\u{1F504} Refresh from MB"; refreshBtn.title = "Re-resolve every entity via MusicBrainz API, ignoring the local IDB cache"; refreshBtn.style.cssText = "font-size:0.8rem;cursor:pointer;padding:0.2rem 0.5rem;border:1px solid var(--mbu-warn);border-radius:3px;background:var(--mbu-bg);color:var(--mbu-warn);flex-shrink:0;"; refreshBtn.addEventListener("click", () => { refreshBtn.disabled = true; refreshBtn.textContent = "\u{1F504} Refreshing\u2026"; (panelLi || panel).remove(); if (headerSlot) headerSlot.replaceChildren(); onRefresh().then((freshResults) => { showReviewTable(freshResults, rolesMap, companiesRolesMap, opts).then((confirmedMap) => resolve(confirmedMap)); }); }); heading.appendChild(refreshBtn); } const headingText = document.createElement("span"); headingText.style.cssText = "font-weight:bold;font-size:1rem;color:var(--mbu-warn);flex:1;"; headingText.textContent = `Review \u2014 ${allResults.length} entit${allResults.length === 1 ? "y" : "ies"}`; heading.appendChild(headingText); panel.appendChild(heading); const intro = document.createElement("p"); intro.style.cssText = "margin:0 0 0.75rem;font-size:0.85rem;color:var(--mbu-text-dim);"; intro.innerHTML = 'Review all artist matches before importing. <span style="background:var(--mbu-error-bg);color:var(--mbu-text);padding:0 0.3rem;border-radius:2px;">Red rows</span> need attention. <span style="background:var(--mbu-warn-bg);color:var(--mbu-text);padding:0 0.3rem;border-radius:2px;">Yellow rows</span> have a name mismatch \u2014 verify. Green rows are confirmed. Use the search or create buttons to resolve outstanding issues.'; panel.appendChild(intro); const table = document.createElement("table"); table.style.cssText = "border-collapse:collapse;width:100%;font-size:0.85rem;"; const thead = document.createElement("thead"); const hr = document.createElement("tr"); hr.style.background = "var(--mbu-warn-bg)"; [...entitySources ? ["Source"] : [], importSourceName + " entity", "MB match / search"].forEach((col) => { const th = document.createElement("th"); th.style.cssText = "text-align:left;padding:0.3rem 0.5rem;border:1px solid var(--mbu-warn);white-space:nowrap;color:var(--mbu-text);"; th.textContent = col; hr.appendChild(th); }); thead.appendChild(hr); table.appendChild(thead); const tbody = document.createElement("tbody"); async function splitRow(r, tr, parts, btn) { const origKey = keyOf(r); if (allResults.indexOf(r) < 0) return; btn.disabled = true; btn.textContent = "\u2026"; log.info(`#605 split "${r.displayName}" \u2192 ${parts.join(" \xB7 ")}`); const norm2 = (s) => String(s || "").toLowerCase().trim(); const subs = []; for (let i = 0; i < parts.length; i++) { const name = parts[i]; const entity = { name, anv: "", _syntheticKey: splitKey(origKey, i), _splitOf: origKey, _splitFrom: r.displayName }; let pool = (r.nameMatches || []).filter((a) => norm2(a.name) === norm2(name)); let via = "candidates"; if (!pool.length) { via = "search"; try { const json = await mbThrottle.fetchJson(`${MB}/ws/2/artist?query=${encodeURIComponent(name)}&fmt=json&limit=8`); const all = json?.artists || []; const exact2 = all.filter((a) => norm2(a.name) === norm2(name)); pool = exact2.length ? exact2 : all; } catch (e) { log.warn(`#605 split: search for "${name}" failed \u2014 ${e.message}`); pool = []; } } const exact = pool.filter((a) => norm2(a.name) === norm2(name)); log.info(`#605 split part "${name}": ${pool.length} candidate(s) via ${via}, ${exact.length} exact`); const base = { entityType: "artist", entity, displayName: name, discogsHref: "", _roles: r._roles }; if (exact.length === 1) { const a = exact[0], mbUrl = `${MB}/artist/${a.id}`; subs.push({ ...base, type: "resolved", mbUrl, mbName: a.name, mbDisambig: a.disambiguation || "", logEntry: { displayName: name, discogsHref: "", mbUrl, mbName: a.name, mbDisambig: a.disambiguation || "", via: "name", fromCache: false } }); } else { subs.push({ ...base, type: "attention", nameMatches: pool }); } } const idx = allResults.indexOf(r); if (idx < 0 || !tr.isConnected) return; if (r._credInput?._activeMbUrl) creditOverrides.delete(r._credInput._activeMbUrl); rowState.delete(origKey); rowSearchInputs.delete(origKey); linkState.delete(origKey); if (entitySources?.has(origKey)) subs.forEach((s) => entitySources.set(keyOf(s), entitySources.get(origKey))); allResults.splice(idx, 1, ...subs); subs.forEach((s) => buildRow(s, tr)); tr.remove(); splits.set(origKey, subs.map((s) => ({ key: keyOf(s), name: s.displayName }))); headingText.textContent = `Review \u2014 ${allResults.length} entit${allResults.length === 1 ? "y" : "ies"}`; updateLinksBadge(); updateImportBtn(); } allResults.forEach((r) => buildRow(r)); function buildRow(r, beforeEl) { const entityType = r.entityType || "artist"; const displayName = r.displayName || r.entity?.name || ""; const discogsHref = r.discogsHref || ""; const srcName = discogsHref ? sourceNameForUrl(discogsHref) : importSourceName; const e = r.logEntry || null; const artist = r.entity; const isResolved = r.type === "resolved"; const initMbUrl = isResolved ? r.mbUrl : null; const _entityKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`; const _pl = _preloadedNames.get(_entityKey) || _preloadedNames.get(r.entity?.resource_url); const initMbName = e && e.mbName ? e.mbName : _pl?.name || (isResolved ? r.mbName : null) || null; const initMbDisam = e && e.mbDisambig ? e.mbDisambig : _pl?.dis || r.mbDisambig || ""; const nameMismatch = isResolved && initMbName && initMbName.toLowerCase().trim() !== displayName.toLowerCase().trim(); const needsAttention = r.type === "attention"; const rowBg = needsAttention ? "var(--mbu-error-bg)" : nameMismatch ? "var(--mbu-warn-bg)" : "var(--mbu-bg)"; const borderColor = needsAttention ? "#cc6666" : "#d4d4d4"; const tr = document.createElement("tr"); tr.style.cssText = `vertical-align:top;background:${rowBg};`; tr.dataset.entityKey = _entityKey; rowState.set(_entityKey, { mbUrl: initMbUrl, mbName: initMbName, mbDisambig: initMbDisam, confirmed: isResolved && !needsAttention, via: isResolved ? r.logEntry?.via || null : null, fromCache: isResolved ? r.logEntry?.fromCache || false : false }); if (entitySources) { const tdSrc = document.createElement("td"); tdSrc.style.cssText = `padding:0.3rem 0.5rem;border:1px solid ${borderColor};white-space:nowrap;text-align:center;`; const names = entitySources.get(_entityKey) || []; const srcUrls = (r._mergeUrls || (discogsHref ? [discogsHref] : [])).filter((u) => !isSyntheticProviderUrl(u)); if (!names.length) { tdSrc.innerHTML = '<span style="color:var(--mbu-text-weak);">\u2014</span>'; } else names.forEach((nm) => { const url = srcUrls.find((u) => sourceNameForUrl(u) === nm) || null; const span = document.createElement("span"); span.className = "discogs-src-badge"; span.style.cssText = "display:inline-flex;vertical-align:middle;margin:0 2px;" + (url ? "cursor:pointer;" : "filter:grayscale(1);opacity:0.45;"); span.title = url ? `${nm} \u2014 click to open ${url}` : `${nm} \u2014 name-only credit (no link)`; span.innerHTML = sourceBadgeIcon(nm); if (url) span.addEventListener("click", () => window.open(url, "_blank", "noopener,noreferrer")); tdSrc.appendChild(span); }); tr.appendChild(tdSrc); } const tdDiscogs = document.createElement("td"); tdDiscogs.style.cssText = `padding:0.3rem 0.5rem;border:1px solid ${borderColor};white-space:nowrap;`; const nameRow = document.createElement("div"); nameRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:0.6rem;"; const nameWrap = document.createElement("span"); nameWrap.style.cssText = "min-width:0;overflow:hidden;text-overflow:ellipsis;"; if (entityType !== "artist") { const badge = document.createElement("span"); badge.textContent = entityType; badge.style.cssText = "font-size:0.7rem;background:var(--mbu-bg-sunken);border-radius:3px;padding:0 0.3rem;margin-right:0.3rem;color:var(--mbu-text-dim);vertical-align:middle;"; nameWrap.appendChild(badge); } const placeholderUrl = /\/_company\//.test(discogsHref) || /\/_company\//.test(r.entity?.resource_url || ""); const hasDiscogsUrl = !!r.entity?.resource_url && !placeholderUrl; const dlA = document.createElement(hasDiscogsUrl ? "a" : "span"); dlA.href = discogsHref; dlA.target = "_blank"; dlA.rel = "noopener noreferrer nofollow"; dlA.textContent = displayName; if (!hasDiscogsUrl) { dlA.className = "discogs-entity-name"; dlA.style.color = "var(--mbu-text)"; } const _srcTitles = [...new Set((r._roles || []).map((x) => x.trackTitle).filter(Boolean))]; if (_srcTitles.length) dlA.title = _srcTitles.join("\n"); nameWrap.appendChild(dlA); const splitParts = entityType === "artist" && !r.entity?._splitOf ? splitCreditName(displayName || r.entity?.name) : []; if (splitParts.length) { const sp = document.createElement("button"); sp.type = "button"; sp.className = "discogs-split-btn"; sp.textContent = "\u22D4"; sp.title = `Split into separate artists: ${splitParts.join(" \xB7 ")} Each gets this row's roles.`; sp.style.cssText = "margin-left:0.35rem;padding:0 0.35rem;min-width:1.4rem;cursor:pointer;border:1px solid var(--mbu-accent);border-radius:3px;background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font-size:16px;font-weight:bold;line-height:1.2;vertical-align:middle;"; sp.addEventListener("click", () => splitRow(r, tr, splitParts, sp)); nameWrap.appendChild(sp); } if (r.entity?._splitOf) { const sb = document.createElement("span"); sb.textContent = "split"; sb.title = `Split from "${r.entity._splitFrom}" \u2014 gets that credit's roles`; sb.style.cssText = "display:inline-flex;align-items:center;margin-left:0.35rem;padding:0.05rem 0.4rem;font-size:0.65rem;font-weight:600;border-radius:0.7rem;line-height:1.4;cursor:help;background:var(--mbu-bg-sunken);color:var(--mbu-text-dim);border:1px solid var(--mbu-border);"; nameWrap.appendChild(sb); } const BADGE_BASE = "display:inline-flex;align-items:center;margin-left:0.35rem;padding:0.05rem 0.4rem;font-size:0.65rem;font-weight:600;border-radius:0.7rem;letter-spacing:0.01em;cursor:help;text-transform:lowercase;line-height:1.4;"; if (!hasDiscogsUrl && !placeholderUrl && srcName !== "Titles" && !r.entity?._splitOf) { const noUrl = document.createElement("span"); noUrl.textContent = "no profile"; noUrl.title = `No ${srcName} artist page \u2014 name lookup unavailable, search MB manually`; noUrl.style.cssText = BADGE_BASE + "background:var(--mbu-error-bg);color:var(--mbu-error);border:1px solid var(--mbu-error);"; nameWrap.appendChild(noUrl); } if (nameMismatch) { const w = document.createElement("span"); w.textContent = "name differs"; w.title = "MB entity name differs from the Discogs display name \u2014 double-check this is the right match"; w.style.cssText = BADGE_BASE + "background:var(--mbu-warn-bg);color:var(--mbu-warn);border:1px solid var(--mbu-warn);"; nameWrap.appendChild(w); } nameRow.appendChild(nameWrap); const actionsLine = document.createElement("span"); actionsLine.style.cssText = "display:inline-flex;align-items:center;gap:0.3rem;flex-shrink:0;"; nameRow.appendChild(actionsLine); tdDiscogs.appendChild(nameRow); tr.appendChild(tdDiscogs); const rolesList = r._roles || []; if (rolesList.length > 0) { const byRole = /* @__PURE__ */ new Map(); rolesList.forEach(({ displayLabel, linkType, trackPos }) => { const key = displayLabel || linkType; if (!key) return; if (!byRole.has(key)) byRole.set(key, /* @__PURE__ */ new Set()); if (trackPos) byRole.get(key).add(String(trackPos)); }); const compressPositions = (posSet) => { const all = [...posSet]; if (!all.length) return ""; if (!all.every((p) => /^\d+$/.test(p))) return "[" + all.join(",") + "]"; const nums = all.map(Number).sort((a, b) => a - b); const parts = []; for (let i = 0; i < nums.length; ) { let j = i; while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++; parts.push(j > i ? nums[i] + "-" + nums[j] : String(nums[i])); i = j + 1; } return "[" + parts.join(",") + "]"; }; const chips = [...byRole.entries()].map(([roleKey, posSet]) => { const pos = compressPositions(posSet); return { roleKey, displayText: roleKey + (pos ? " " + pos : "") }; }); const rolesLine = document.createElement("div"); rolesLine.style.cssText = "font-size:0.75rem;color:var(--mbu-text-weak);margin-top:0.15rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;"; rolesLine.title = chips.map((c) => c.displayText).join(", "); chips.forEach((chip, i) => { if (i > 0) rolesLine.appendChild(document.createTextNode(", ")); const span = document.createElement("span"); span.className = "discogs-role-chip"; span.dataset.roleKey = chip.roleKey; span.textContent = chip.displayText; rolesLine.appendChild(span); }); tdDiscogs.appendChild(rolesLine); } const credLine = document.createElement("div"); credLine.style.cssText = "display:flex;align-items:center;gap:0.3rem;margin-top:1rem;padding-top:0.25rem;max-width:280px;"; const credLabel = document.createElement("label"); credLabel.textContent = "Credited as:"; credLabel.style.cssText = "font-size:0.72rem;color:var(--mbu-text-weak);flex-shrink:0;"; const credInput = noPasswordManagers(document.createElement("input")); const CRED_BG_SAME = "var(--mbu-bg-sunken)"; const CRED_BG_DIFFERENT = "var(--mbu-warn-bg)"; credInput.style.cssText = "flex:1;padding:0.15rem 0.35rem;font-size:0.78rem;border:1px solid var(--mbu-border);border-radius:3px;background:" + CRED_BG_SAME + ";"; credInput.placeholder = displayName; credInput.title = `Override the credited name dispatched with every rel for this entity. Leave empty to use the default (${srcName} name, or MB's most-frequent existing credit when known).`; function refreshCredBg() { const value = (credInput.value || "").trim(); const same = value === "" || value === displayName; credInput.style.background = same ? CRED_BG_SAME : CRED_BG_DIFFERENT; } function pickPrefill(mbUrl) { if (r.creditOverride !== void 0 && r.creditOverride !== null && r.creditOverride !== "") { return r.creditOverride; } if (mbUrl) { const mbid = (String(mbUrl).split("/").pop() || "").replace(/[^a-f0-9-]/gi, "").slice(0, 36); if (mbid && existingCreditByMbid.has(mbid)) return existingCreditByMbid.get(mbid); } if (srcName === "Titles") { const mbName = rowState.get(_entityKey)?.mbName || r.mbName; if (mbName) return mbName; } return displayName; } credInput.value = pickPrefill(r.mbUrl); credInput._userTouched = false; refreshCredBg(); let _credSaveTimer; credInput.addEventListener("input", () => { credInput._userTouched = true; const url = credInput._activeMbUrl; if (url) creditOverrides.set(url, credInput.value); refreshCredBg(); clearTimeout(_credSaveTimer); _credSaveTimer = setTimeout(() => { const idbKey = idbKeyForEntity(r.entity); if (idbKey) writeIdbRecord(idbKey, { creditOverride: credInput.value }); }, 500); }); credInput._activeMbUrl = r.mbUrl; if (r.mbUrl) creditOverrides.set(r.mbUrl, credInput.value); const CRED_BTN_STYLE = "flex-shrink:0;padding:0.05rem 0.35rem;font-size:0.7rem;line-height:1;cursor:pointer;border:1px solid var(--mbu-warn);border-radius:3px;background:var(--mbu-bg-raised);color:var(--mbu-warn);"; const mbBtn = document.createElement("button"); mbBtn.type = "button"; mbBtn.textContent = "MB"; mbBtn.title = "Set Credited as to the MB entity name"; mbBtn.style.cssText = CRED_BTN_STYLE; const dBtn = document.createElement("button"); dBtn.type = "button"; dBtn.textContent = srcName.charAt(0); dBtn.title = `Set Credited as to the ${srcName} name`; dBtn.style.cssText = CRED_BTN_STYLE; function currentMbName() { return rowState.get(_entityKey)?.mbName || r.mbName || null; } function refreshCredBtns() { const val = credInput.value; const mbName = currentMbName(); mbBtn.style.display = !mbName || val === mbName ? "none" : ""; dBtn.style.display = val === displayName ? "none" : ""; } function setCredViaButton(value) { credInput.value = value; credInput._userTouched = true; credInput.dispatchEvent(new Event("input", { bubbles: true })); } mbBtn.addEventListener("click", () => { const mbName = currentMbName(); if (mbName) setCredViaButton(mbName); }); dBtn.addEventListener("click", () => setCredViaButton(displayName)); credInput.addEventListener("input", refreshCredBtns); refreshCredBtns(); credLine.appendChild(credLabel); credLine.appendChild(credInput); credLine.appendChild(mbBtn); credLine.appendChild(dBtn); tdDiscogs.appendChild(credLine); r._credInput = credInput; r._refreshCredBtns = refreshCredBtns; const tdMb = document.createElement("td"); tdMb.style.cssText = `padding:0.3rem 0.5rem;border:1px solid ${borderColor};min-width:240px;`; const candidateList = document.createElement("div"); candidateList.style.cssText = "display:flex;flex-direction:column;gap:0.2rem;margin-bottom:0.3rem;"; const searchRow = document.createElement("div"); searchRow.style.cssText = "display:flex;gap:0.3rem;"; const searchInput = noPasswordManagers(document.createElement("input")); searchInput.value = displayName; searchInput.style.cssText = "flex:1;padding:0.15rem 0.35rem;font-size:0.82rem;border:1px solid var(--mbu-border);border-radius:3px;"; rowSearchInputs.set(_entityKey, searchInput); const searchBtn = document.createElement("button"); searchBtn.type = "button"; searchBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="20" y1="20" x2="16.65" y2="16.65"/></svg>'; searchBtn.title = "Search MusicBrainz"; searchBtn.style.cssText = "display:inline-flex;align-items:center;justify-content:center;padding:0 0.45rem;cursor:pointer;color:var(--mbu-text-dim);background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:3px;"; searchRow.appendChild(searchBtn); searchRow.appendChild(searchInput); tdMb.appendChild(candidateList); tdMb.appendChild(searchRow); tr.appendChild(tdMb); const tdAction = actionsLine; tbody.insertBefore(tr, beforeEl || null); function buildMbRolesEl(explicitMbid) { if (entityType !== "artist") return null; const wrap = document.createElement("span"); wrap.style.cssText = "display:inline-flex;align-items:center;gap:0.25rem;margin-left:0.5rem;min-width:0;overflow:hidden;font-size:0.72rem;"; const trigger = document.createElement("a"); trigger.href = "#"; trigger.textContent = "MB roles \u25BE"; trigger.style.cssText = "color:var(--mbu-info);text-decoration:none;cursor:pointer;white-space:nowrap;"; trigger.title = "Fetch this artist's existing MB relationship types to compare with the Discogs role"; trigger.addEventListener("click", async (ev) => { ev.preventDefault(); let mbid = (String(explicitMbid || "").match(/[a-f0-9-]{36}/i) || [])[0]; if (!mbid) { const st = rowState.get(_entityKey); const curUrl = st?.mbUrl || r.mbUrl; mbid = (String(curUrl || "").split("/").pop() || "").replace(/[^a-f0-9-]/gi, "").slice(0, 36); } if (!mbid) { trigger.textContent = "MB roles: (none selected)"; return; } trigger.textContent = "MB roles\u2026"; const types = await fetchArtistRelTypes(mbid); wrap.innerHTML = ""; const label = document.createElement("span"); label.style.color = "var(--mbu-text-dim)"; if (!types) { label.textContent = "MB roles: fetch failed"; label.style.color = "var(--mbu-error)"; wrap.appendChild(label); return; } if (!types.length) { label.textContent = "MB roles: none"; wrap.appendChild(label); return; } label.textContent = "MB roles: "; label.style.whiteSpace = "nowrap"; label.style.flex = "0 0 auto"; wrap.style.alignItems = "flex-start"; wrap.style.overflow = "visible"; wrap.appendChild(label); wrap.title = types.join(", "); const chipsBox = document.createElement("span"); chipsBox.style.cssText = "display:flex;flex-wrap:wrap;gap:0.25rem;min-width:0;"; wrap.appendChild(chipsBox); types.forEach((t) => { const c = document.createElement("span"); c.textContent = t; c.style.cssText = "background:var(--mbu-info-bg);border:1px solid var(--mbu-border);border-radius:0.7rem;padding:0 0.4rem;color:var(--mbu-info);white-space:nowrap;"; chipsBox.appendChild(c); }); }); wrap.appendChild(trigger); return wrap; } let _creatingEl = null; let _creatingTimer = null; let _creatingCancel = null; function setRowCreating(name, onCancel) { ensureCreatingStyle(); if (_creatingTimer) { clearTimeout(_creatingTimer); _creatingTimer = null; } if (_creatingEl) _creatingEl.remove(); _creatingCancel = onCancel || null; candidateList.style.display = "none"; tdAction.innerHTML = ""; searchInput.disabled = true; searchBtn.disabled = true; const ph = document.createElement("div"); ph.className = "ch-creating"; ph.style.cssText = "padding:0.15rem 0.4rem;border:1px dashed var(--mbu-info);border-radius:3px;background:var(--mbu-bg);display:flex;align-items:center;gap:0.4rem;font-size:0.85rem;color:var(--mbu-info);font-style:italic;margin-bottom:0.3rem;"; const spin = document.createElement("span"); spin.textContent = "\u27F3"; spin.style.cssText = "display:inline-block;animation:ch-creating-spin 0.9s linear infinite;"; ph.appendChild(spin); const txt = document.createElement("span"); txt.textContent = `Creating ${name} in the background\u2026`; ph.appendChild(txt); const x = document.createElement("button"); x.textContent = "\u2715"; x.title = "Cancel \u2014 stop waiting and restore the row"; x.style.cssText = "margin-left:auto;font-size:0.75rem;line-height:1;cursor:pointer;border:none;background:none;color:var(--mbu-info);padding:0 0.2rem;"; x.addEventListener("click", () => cancelCreating()); ph.appendChild(x); tdMb.insertBefore(ph, candidateList); _creatingEl = ph; tr.style.background = "var(--mbu-info-bg)"; _creatingTimer = setTimeout(() => { _creatingTimer = null; cancelCreating(); }, 9e4); } function cancelCreating() { if (_creatingCancel) { try { _creatingCancel(); } catch (e2) { } } clearRowCreating(true); } function clearRowCreating(restore) { if (_creatingTimer) { clearTimeout(_creatingTimer); _creatingTimer = null; } _creatingCancel = null; if (_creatingEl) { _creatingEl.remove(); _creatingEl = null; } candidateList.style.display = ""; if (restore) { searchInput.disabled = false; searchBtn.disabled = false; tr.style.background = ""; renderActions(null); } } let aliasPick = null; let rowViaBadge = null, rowMatchId = null; function aliasLanded(a) { if (r.logEntry?.via !== "url" || !rowViaBadge || rowMatchId !== a.id) return; const nb = makeViaBadge("both-alias", false); if (nb) { rowViaBadge.replaceWith(nb); rowViaBadge = nb; } r.logEntry.via = "both-alias"; r.logEntry.fromCache = false; const st = rowState.get(_entityKey); if (st) { st.via = "both-alias"; st.fromCache = false; } const k = idbKeyForEntity(r.entity); if (k) writeIdbRecord(k, { resolvedVia: "both-alias" }); log.info(`+ alias: "${displayName}" \u2192 ${a.name} is now an alias+url match (was url)${k ? ", cached" : ""}`); } function makeAddAliasBtn(a) { if (!wantsAliasButton(entityType, a, displayName)) return null; const ab = document.createElement("button"); ab.type = "button"; ab.className = "discogs-add-alias"; ab.textContent = "+ alias"; ab.title = `Add "${displayName}" as an alias of ${a.name} \u2022 click: open MusicBrainz's add-alias form, pre-filled (you submit it) \u2022 right-click: submit it in the background (no alias type)`; ab.style.cssText = "font-size:0.72rem;cursor:pointer;padding:0 0.4rem;border:1px solid var(--mbu-accent);border-radius:3px;background:var(--mbu-bg);color:var(--mbu-accent-text);white-space:nowrap;flex:0 0 auto;"; const note = buildCreateNote(`Added "${displayName}" as an alias \u2014 the ${srcName} credit${discogsHref ? " (" + discogsHref + ")" : ""} \u2014`); ab.addEventListener("click", async (ev) => { ev.preventDefault(); logDebug(`+ alias: click on "${displayName}" \u2192 ${a.name} (${a.id})`); let res; try { res = await openAddAliasForm(a.id, displayName, note); } catch (e2) { log.warn(`+ alias: opening the form for "${displayName}" failed \u2014 ${e2 && e2.message || e2}`); return; } if (res && res.already) { ab.textContent = "\u2713 has alias"; ab.disabled = true; ab.title = `${a.name} already carries "${displayName}" \u2014 nothing to add`; ab.style.color = "var(--mbu-ok)"; ab.style.borderColor = "var(--mbu-ok)"; aliasLanded(a); return; } if (ab._aliasWatch) return; let checking = false; const onReturn = async () => { if (document.visibilityState !== "visible" || checking || ab.disabled) return; checking = true; const held = await aliasNowHeld(a.id, displayName); checking = false; if (held !== true) { log.info(`+ alias: "${displayName}" isn't on ${a.name} yet${held === null ? " (lookup failed)" : ""} \u2014 checked on return to this tab`); return; } document.removeEventListener("visibilitychange", onReturn); window.removeEventListener("focus", onReturn); ab._aliasWatch = null; ab.textContent = "\u2713 alias"; ab.disabled = true; ab.title = `"${displayName}" is now an alias of ${a.name}`; ab.style.color = "var(--mbu-ok)"; ab.style.borderColor = "var(--mbu-ok)"; a.aliases = [...a.aliases || [], displayName]; log.info(`+ alias: "${displayName}" is now an alias of ${a.name} (added through the form)`); aliasLanded(a); }; ab._aliasWatch = onReturn; setTimeout(() => { document.addEventListener("visibilitychange", onReturn); window.addEventListener("focus", onReturn); }, 600); }); ab.addEventListener("contextmenu", async (ev) => { ev.preventDefault(); logDebug(`+ alias: right-click on "${displayName}" \u2192 ${a.name} (${a.id})${ab.disabled ? " \u2014 ignored, the button is busy or done" : ""}`); if (ab.disabled) return; ab.disabled = true; ab.textContent = "\u23F3 alias"; try { const res = await submitAliasBackground(a.id, displayName, note); ab.textContent = res && res.already ? "\u2713 has alias" : "\u2713 alias"; ab.title = res && res.already ? `${a.name} already carries "${displayName}" \u2014 nothing submitted` : `"${displayName}" submitted as an alias of ${a.name}`; ab.style.color = "var(--mbu-ok)"; ab.style.borderColor = "var(--mbu-ok)"; a.aliases = [...a.aliases || [], displayName]; aliasLanded(a); if (!(res && res.already)) log.info(`+ alias: "${displayName}" submitted as an alias of <a href="${location.origin}/artist/${a.id}/aliases" target="_blank" rel="noopener noreferrer nofollow">${a.name}</a>`); } catch (e2) { ab.disabled = false; ab.textContent = "\u2717 alias"; ab.title = `Adding the alias failed: ${e2.message} \u2014 right-click to retry, click to open the form`; ab.style.color = "var(--mbu-error)"; ab.style.borderColor = "var(--mbu-error)"; log.warn(`+ alias: "${displayName}" \u2192 ${a.name} failed \u2014 ${e2.message}`); } }); return ab; } function setRowResolved(a) { aliasPick = a; clearRowCreating(); const mbUrl = `${MB}/${entityType}/${a.id}`; rowState.set(_entityKey, { mbUrl, mbName: a.name, mbDisambig: a.disambiguation || "", confirmed: true, via: "user", fromCache: false }); if (r._credInput) { const oldUrl = r._credInput._activeMbUrl; if (oldUrl && oldUrl !== mbUrl) creditOverrides.delete(oldUrl); r._credInput._activeMbUrl = mbUrl; if (!r._credInput._userTouched) { const fresh = pickPrefill(mbUrl); r._credInput.value = fresh; } creditOverrides.set(mbUrl, r._credInput.value); refreshCredBg(); if (r._refreshCredBtns) r._refreshCredBtns(); } const _idbKey = idbKeyForEntity(r.entity); if (_idbKey) { writeIdbRecord(_idbKey, { mbid: a.id, entityType, name: a.name, disambiguation: a.disambiguation || "", resolvedVia: "user" // user picked this in the review table }); } tr.style.background = "var(--mbu-ok-bg)"; searchInput.disabled = true; searchBtn.disabled = true; candidateList.innerHTML = ""; const selRow = document.createElement("div"); selRow.style.cssText = "padding:0.15rem 0.4rem;border:1px solid var(--mbu-ok);border-radius:3px;background:var(--mbu-ok-bg);display:flex;flex-wrap:wrap;align-items:center;gap:0.4rem;font-size:0.85rem;"; const selA = document.createElement("a"); selA.href = "https:" + mbUrl; selA.target = "_blank"; selA.rel = "noopener noreferrer nofollow"; selA.textContent = "\u2713 " + a.name + (a.disambiguation ? ` (${a.disambiguation})` : ""); selA.style.fontWeight = "bold"; selA.style.whiteSpace = "nowrap"; selA.style.flex = "0 0 auto"; const undoBtn = document.createElement("button"); undoBtn.textContent = "\u2715"; undoBtn.title = "Clear selection"; undoBtn.style.cssText = "font-size:0.75rem;cursor:pointer;padding:0 0.3rem;margin-left:auto;"; undoBtn.addEventListener("click", () => setRowUnresolved()); selRow.appendChild(selA); const viaBadge = makeViaBadge("user", false); if (viaBadge) selRow.appendChild(viaBadge); const mbRolesEl = buildMbRolesEl(); if (mbRolesEl) selRow.appendChild(mbRolesEl); selRow.appendChild(undoBtn); candidateList.appendChild(selRow); renderActions(a); updateImportBtn(); } function setRowUnresolved() { aliasPick = null; clearRowCreating(); rowState.set(_entityKey, { mbUrl: null, mbName: null, mbDisambig: "", confirmed: false, via: null, fromCache: false }); if (r._credInput && r._credInput._activeMbUrl) { creditOverrides.delete(r._credInput._activeMbUrl); r._credInput._activeMbUrl = null; } tr.style.background = "var(--mbu-error-bg)"; searchInput.disabled = false; searchBtn.disabled = false; candidateList.innerHTML = ""; const none = document.createElement("div"); none.style.cssText = "font-size:0.82rem;color:var(--mbu-text-weak);"; none.textContent = "No selection \u2014 search or create"; candidateList.appendChild(none); renderActions(null); updateImportBtn(); } const ACTION_CHIP_STYLE = "display:inline-flex;align-items:center;justify-content:center;min-width:1.6rem;height:1.6rem;padding:0 0.35rem;font-size:0.95rem;line-height:1;cursor:pointer;border:1px solid var(--mbu-border);border-radius:0.3rem;background:var(--mbu-bg-raised);"; function renderActions(selected) { tdAction.innerHTML = ""; if (!selected) { linkState.delete(_entityKey); rowLinkChips.delete(_entityKey); updateLinksBadge(); } const noLinkUi = selected && (isSyntheticProviderUrl(discogsHref) || SPECIAL_PURPOSE_ARTISTS.has(selected.id)); if (noLinkUi) { linkState.delete(_entityKey); rowLinkChips.delete(_entityKey); updateLinksBadge(); } if (selected && !noLinkUi) { let recheckUrlBypassCache = function() { _urlCheckSessionCache.delete(urlCheckCacheKey); try { localStorage.removeItem(urlCheckLsKey); } catch (e2) { } queuedUrlCheck( () => fetchWithRetry(`${MB}/ws/2/url?resource=${encodeURIComponent(discogsHref)}&inc=${entityType}-rels&fmt=json`).then((json) => { const linkedIds = (json.relations || []).filter((r2) => r2[entityType]).map((r2) => r2[entityType].id); const result = linkedIds.includes(selected.id) ? "linked" : linkedIds.length > 0 ? "other" : "none"; _urlCheckSessionCache.set(urlCheckCacheKey, result); try { localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result })); } catch (e2) { } const healKey = parseSourceEntityUrl(r.entity?.resource_url)?.key; if (healKey) writeIdbRecord(healKey, { urlLinkedIds: linkedIds }); applyUrlCheckResult(result); }).catch(() => applyUrlCheckResult("none")) ); }, applyUrlCheckResult = function(result) { linkState.set(_entityKey, result); if (result !== "none") rowLinkChips.delete(_entityKey); updateLinksBadge(); if (result === "linked") { linkSlot.textContent = "\u2713"; linkSlot.title = srcName + " URL already linked to this MB " + entityType; linkSlot.style.color = "var(--mbu-ok)"; linkSlot.style.fontWeight = "bold"; } else if (result === "other") { linkSlot.textContent = "\u26A0\uFE0F"; linkSlot.title = `${srcName} URL is linked to a DIFFERENT MB ${entityType}`; linkSlot.style.color = "var(--mbu-warn)"; } else { linkSlot.textContent = ""; linkSlot.style.color = ""; const addLinkBtn = document.createElement("button"); const _addCount = r._mergeUrls && r._mergeUrls.length ? r._mergeUrls.filter((u) => sourceUrlLinkTypeId(u, entityType)).length : 0; addLinkBtn.textContent = "\u{1F517}" + (_addCount > 1 ? " " + _addCount : ""); addLinkBtn.title = (_addCount > 1 ? `Add ${_addCount} source links to MB ${entityType}` : `Add ${srcName} link to MB ${entityType}`) + ` \xB7 right-click: add ${_addCount > 1 ? "them" : "it"} silently in the background`; addLinkBtn.style.cssText = ACTION_CHIP_STYLE + "color:var(--mbu-warn);"; const openLinkEdit = (background) => { const urls = r._mergeUrls && r._mergeUrls.length ? r._mergeUrls : [discogsHref]; const p = new URLSearchParams(); let n = 0; for (const u of urls) { const lt = sourceUrlLinkTypeId(u, entityType); if (!lt) continue; p.set(`edit-${entityType}.url.${n}.text`, u); p.set(`edit-${entityType}.url.${n}.link_type_id`, lt); n++; } if (!n) return; p.set(`edit-${entityType}.edit_note`, buildCreateNote(n > 1 ? `Added ${n} source links` : `Added ${srcName} link`)); const mbid = selected.id.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36); const editUrl = `https:${MB}/${entityType}/${mbid}/edit?${p}`; if (background && typeof GM_openInTab === "function") { const editTab = GM_openInTab(`${editUrl}#ch-autocommit`, { active: false, insert: true }); const onCommitted = (evt) => { if (evt.data?.type !== "edit-committed" || evt.data.id !== mbid) return; DISCOGS_CHANNEL.removeEventListener("message", onCommitted); try { if (editTab && typeof editTab.close === "function") editTab.close(); } catch (e2) { } recheckUrlBypassCache(); }; DISCOGS_CHANNEL.addEventListener("message", onCommitted); linkSlot.innerHTML = ""; linkSlot.textContent = "\u2026"; linkSlot.title = `Adding ${srcName} link in the background\u2026`; linkSlot.style.color = "var(--mbu-text-dim)"; linkSlot.style.fontStyle = "italic"; return; } const linkTab = window.open(editUrl, "_blank"); if (linkTab) { const trySet = () => { try { linkTab.sessionStorage.setItem("discogs-importer-close-after-edit", "1"); } catch (e2) { setTimeout(trySet, 50); } }; trySet(); } linkSlot.innerHTML = ""; linkSlot.textContent = "\u2026"; linkSlot.title = `Verifying ${srcName} link on return to this tab\u2026`; linkSlot.style.color = "var(--mbu-text-dim)"; linkSlot.style.fontStyle = "italic"; const onReturn = () => { if (document.visibilityState !== "visible") return; document.removeEventListener("visibilitychange", onReturn); window.removeEventListener("focus", onReturn); recheckUrlBypassCache(); }; document.addEventListener("visibilitychange", onReturn); window.addEventListener("focus", onReturn); }; addLinkBtn.addEventListener("click", () => openLinkEdit(false)); addLinkBtn.addEventListener("contextmenu", (e2) => { e2.preventDefault(); openLinkEdit(true); }); linkSlot.appendChild(addLinkBtn); rowLinkChips.set(_entityKey, addLinkBtn); } }; const linkSlot = document.createElement("span"); linkSlot.style.cssText = "display:inline-flex;align-items:center;font-size:0.8rem;color:var(--mbu-text-weak);"; linkSlot.textContent = "\u2026"; linkSlot.title = `Checking whether MB already has this ${srcName} URL linked`; tdAction.appendChild(linkSlot); const urlCheckCacheKey = `${selected.id}|${discogsHref}`; const urlCheckLsKey = `discogs-urlcheck-${selected.id}-${hashKey(discogsHref)}`; const urlCheckToday = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10); const urlCheckExpiry = /* @__PURE__ */ new Date(); urlCheckExpiry.setDate(urlCheckExpiry.getDate() - 7); const urlCheckExpiryStr = urlCheckExpiry.toISOString().slice(0, 10); let urlCheckCached = _urlCheckSessionCache.get(urlCheckCacheKey) ?? null; if (urlCheckCached === null) { try { const s = JSON.parse(localStorage.getItem(urlCheckLsKey) || "null"); if (s?.date >= urlCheckExpiryStr) urlCheckCached = s.result; } catch (e2) { } if (urlCheckCached !== null) _urlCheckSessionCache.set(urlCheckCacheKey, urlCheckCached); } if (!discogsHref || placeholderUrl) { linkState.set(_entityKey, "na"); rowLinkChips.delete(_entityKey); updateLinksBadge(); if (srcName === "Titles" || placeholderUrl || r.entity?._splitOf) { linkSlot.remove(); } else { linkSlot.textContent = `\u26A0 No ${srcName} page`; linkSlot.style.color = "var(--mbu-warn)"; } } else if (urlCheckCached !== null) { applyUrlCheckResult(urlCheckCached); } else if (Array.isArray(r.urlLinkedIds)) { const result = r.urlLinkedIds.includes(selected.id) ? "linked" : r.urlLinkedIds.length > 0 ? "other" : "none"; _urlCheckSessionCache.set(urlCheckCacheKey, result); try { localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result })); } catch (e2) { } applyUrlCheckResult(result); } else { queuedUrlCheck( () => fetchWithRetry(`${MB}/ws/2/url?resource=${encodeURIComponent(discogsHref)}&inc=${entityType}-rels&fmt=json`).then((json) => { const linkedIds = (json.relations || []).filter((r2) => r2[entityType]).map((r2) => r2[entityType].id); const result = linkedIds.includes(selected.id) ? "linked" : linkedIds.length > 0 ? "other" : "none"; _urlCheckSessionCache.set(urlCheckCacheKey, result); try { localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result })); } catch (e2) { } const healKey = parseSourceEntityUrl(r.entity?.resource_url)?.key; if (healKey) writeIdbRecord(healKey, { urlLinkedIds: linkedIds }); applyUrlCheckResult(result); }).catch(() => applyUrlCheckResult("none")) ); } } function openCreateTab({ name, disambiguation, background } = {}) { const finalName = (name || displayName).trim(); const seedUrls = (params, et) => { const urls = r._mergeUrls && r._mergeUrls.length ? r._mergeUrls : [discogsHref]; let n = 0; for (const u of urls) { if (!u) continue; const lt = sourceUrlLinkTypeId(u, et); if (!lt) continue; params[`edit-${et}.url.${n}.text`] = u; params[`edit-${et}.url.${n}.link_type_id`] = lt; n++; } }; let createUrl; let createParams; if (entityType === "artist") { createParams = { "edit-artist.name": finalName, "edit-artist.sort_name": mbmGuessSortName(finalName), "edit-artist.type_id": "1" }; seedUrls(createParams, "artist"); if (disambiguation) createParams["edit-artist.comment"] = disambiguation; createParams["edit-artist.edit_note"] = buildCreateNote(); createUrl = `https:${MB}/artist/create`; } else { createParams = { [`edit-${entityType}.name`]: finalName }; seedUrls(createParams, entityType); if (disambiguation) createParams[`edit-${entityType}.comment`] = disambiguation; createParams[`edit-${entityType}.edit_note`] = buildCreateNote(); createUrl = `https:${MB}/${entityType}/create`; } const p = new URLSearchParams(createParams); const pendingKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || displayName}`; let bgTab = null; if (background && typeof GM_openInTab === "function") { const url = `${createUrl}?${p}#ch-autocommit=${encodeURIComponent(pendingKey)}`; bgTab = GM_openInTab(url, { active: false, insert: true }); } else { const newTab = window.open(`${createUrl}?${p}`, "_blank"); if (newTab) { const trySet = () => { try { newTab.sessionStorage.setItem("discogs-importer-pending-artist", pendingKey); } catch (e2) { setTimeout(trySet, 50); } }; trySet(); } } const onCreated = (evt) => { if (evt.data?.type !== "artist-created") return; if (evt.data.resourceUrl !== pendingKey) return; DISCOGS_CHANNEL.removeEventListener("message", onCreated); try { if (bgTab && typeof bgTab.close === "function") bgTab.close(); } catch (e2) { } _urlCheckSessionCache.set(`${evt.data.id}|${discogsHref}`, "linked"); if (evt.data.name) { setRowResolved({ id: evt.data.id, name: evt.data.name, disambiguation: evt.data.disambiguation }); } else { setRowResolved({ id: evt.data.id, name: finalName || displayName || "", disambiguation: "" }); fetchWithRetry(`${MB}/ws/2/${entityType}/${evt.data.id}?fmt=json`).then((json) => { if (json && json.name) setRowResolved({ id: evt.data.id, name: json.name, disambiguation: json.disambiguation || "" }); }).catch(() => { }); } }; DISCOGS_CHANNEL.addEventListener("message", onCreated); if (background && typeof GM_openInTab === "function") { setRowCreating(finalName, () => { DISCOGS_CHANNEL.removeEventListener("message", onCreated); try { if (bgTab && typeof bgTab.close === "function") bgTab.close(); } catch (e2) { } }); } } const createBtn = document.createElement("button"); createBtn.textContent = "+"; createBtn.title = (discogsHref ? `Create in MB with default ${srcName} name + URL` : "Create in MB with the credited name") + " \xB7 right-click: create silently in a background tab (auto-submitted)"; createBtn.style.cssText = ACTION_CHIP_STYLE + "color:var(--mbu-ok);font-size:1.15rem;font-weight:600;"; createBtn.addEventListener("click", () => openCreateTab()); createBtn.addEventListener("contextmenu", (e2) => { e2.preventDefault(); openCreateTab({ background: true }); }); const createAdvBtn = document.createElement("button"); createAdvBtn.textContent = "\u25BE"; createAdvBtn.title = "Create in MB with editable name + disambiguation" + (srcName === "Discogs" && discogsHref ? ", pre-filled from the Discogs profile" : ""); createAdvBtn.style.cssText = ACTION_CHIP_STYLE + "color:var(--mbu-text-dim);"; createAdvBtn.addEventListener("click", () => openAdvancedCreatePopup()); tdAction.appendChild(createBtn); tdAction.appendChild(createAdvBtn); async function openAdvancedCreatePopup() { const distinctRoles = []; const seen = /* @__PURE__ */ new Set(); for (const role of r._roles || []) { const label = (role.displayLabel || role.linkType || "").trim(); if (!label || seen.has(label)) continue; seen.add(label); distinctRoles.push(label); if (distinctRoles.length === 3) break; } const defaultDis = distinctRoles.join(", "); const overlay = document.createElement("div"); overlay.className = "mbu-ui"; overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;"; const modal = document.createElement("div"); modal.style.cssText = "background:var(--mbu-bg);border-radius:0.5rem;padding:1.1rem 1.35rem 1rem;max-width:600px;width:92%;max-height:82vh;display:flex;flex-direction:column;gap:0.55rem;box-shadow:0 12px 32px rgba(0,0,0,0.32);font-family:inherit;"; const heading2 = document.createElement("div"); heading2.style.cssText = "font-weight:bold;font-size:1.02rem;color:var(--mbu-text);margin-bottom:0.15rem;"; heading2.textContent = `Create ${entityType} in MusicBrainz`; modal.appendChild(heading2); const FIELD_LABEL = "font-size:0.78rem;color:var(--mbu-text-dim);font-weight:600;letter-spacing:0.02em;text-transform:uppercase;margin-top:0.25rem;"; const FIELD_INPUT = "padding:0.45rem 0.55rem;border:1px solid var(--mbu-border);border-radius:0.3rem;font-size:0.93rem;font-family:inherit;"; const nameLabel = document.createElement("label"); nameLabel.style.cssText = FIELD_LABEL; nameLabel.textContent = "Name"; modal.appendChild(nameLabel); const nameInput = noPasswordManagers(document.createElement("input")); nameInput.value = displayName; nameInput.style.cssText = FIELD_INPUT; modal.appendChild(nameInput); let nameUserTouched = false; nameInput.addEventListener("input", () => { nameUserTouched = true; }); const disLabel = document.createElement("label"); disLabel.style.cssText = FIELD_LABEL; disLabel.textContent = "Disambiguation"; modal.appendChild(disLabel); const disInput = noPasswordManagers(document.createElement("input")); disInput.value = defaultDis; disInput.style.cssText = FIELD_INPUT; modal.appendChild(disInput); let disUserTouched = false; disInput.addEventListener("input", () => { disUserTouched = true; }); const showProfile = srcName === "Discogs" && !!discogsHref; let profileBox = null; if (showProfile) { const profileLabel = document.createElement("div"); profileLabel.style.cssText = "font-size:0.78rem;color:var(--mbu-text-weak);margin-top:0.55rem;"; profileLabel.textContent = "Discogs profile \u2014 select text to copy into Disambiguation"; modal.appendChild(profileLabel); profileBox = document.createElement("div"); profileBox.style.cssText = "border:1px solid var(--mbu-border);border-radius:0.3rem;padding:0.5rem 0.6rem;background:var(--mbu-bg-raised);font-size:0.85rem;line-height:1.5;white-space:pre-wrap;overflow:auto;min-height:5rem;max-height:18rem;flex:1;color:var(--mbu-text);"; profileBox.textContent = "Loading profile from Discogs\u2026"; modal.appendChild(profileBox); const captureSelection = () => { const sel = window.getSelection(); if (!sel || sel.isCollapsed) return; if (!profileBox.contains(sel.anchorNode)) return; const text = sel.toString().trim(); if (!text) return; disInput.value = text; disUserTouched = true; }; profileBox.addEventListener("mouseup", captureSelection); profileBox.addEventListener("keyup", captureSelection); } const btnRow2 = document.createElement("div"); btnRow2.style.cssText = "display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.55rem;"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "Cancel"; cancelBtn.style.cssText = "padding:0.4rem 1rem;cursor:pointer;border:1px solid var(--mbu-border);border-radius:0.25rem;background:var(--mbu-bg-raised);color:var(--mbu-text);font-size:0.88rem;"; const submitBtn = document.createElement("button"); submitBtn.textContent = "Create \u2197"; submitBtn.title = "Open the create page, for you to review \xB7 right-click: create silently in a background tab (auto-submitted)"; submitBtn.style.cssText = "padding:0.4rem 1.1rem;cursor:pointer;font-weight:bold;background:var(--mbu-ok);color:var(--mbu-text-on-accent);border:none;border-radius:0.25rem;font-size:0.9rem;"; btnRow2.appendChild(cancelBtn); btnRow2.appendChild(submitBtn); modal.appendChild(btnRow2); overlay.appendChild(modal); document.body.appendChild(overlay); const close = () => { document.removeEventListener("keydown", onKey); overlay.remove(); }; const submit = (background) => { const name = nameInput.value.trim(); const dis = disInput.value.trim(); close(); openCreateTab({ name: name || displayName, disambiguation: dis || null, background: !!background }); }; const onKey = (ev) => { if (ev.key === "Escape") { close(); } else if (ev.key === "Enter" && (ev.target === disInput || ev.target === nameInput)) submit(false); }; document.addEventListener("keydown", onKey); overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); }); cancelBtn.addEventListener("click", close); submitBtn.addEventListener("click", () => submit(false)); submitBtn.addEventListener("contextmenu", (ev) => { ev.preventDefault(); submit(true); }); disInput.focus(); disInput.select(); if (showProfile) try { const data = await getDiscogsEntityData(r.entity?.resource_url); if (data?.realname && !nameUserTouched && data.realname.trim() !== displayName.trim()) { nameInput.value = data.realname.trim(); } const lines = []; if (data?.namevariations?.length) lines.push(`Also known as: ${data.namevariations.slice(0, 6).join(", ")}`); if (data?.profile) { if (lines.length) lines.push(""); lines.push(data.profile); } profileBox.textContent = lines.length ? lines.join("\n") : "(no Discogs profile)"; } catch (e2) { profileBox.textContent = "(failed to load Discogs profile)"; } } if (selected && aliasPick && aliasPick.id === selected.id) { const ab = makeAddAliasBtn(aliasPick); if (ab) tdAction.insertBefore(ab, tdAction.firstChild); } } function makeCandidateRow(a) { const row = document.createElement("div"); row.style.cssText = "display:flex;align-items:center;gap:0.35rem;padding:0.2rem 0.35rem;border:1px solid var(--mbu-border);border-radius:3px;background:var(--mbu-bg);font-size:0.82rem;"; const selBtn = document.createElement("button"); selBtn.textContent = "\u2713"; selBtn.title = "Select this candidate as the MB match"; selBtn.style.cssText = "font-size:0.95rem;line-height:1;cursor:pointer;padding:0.1rem 0.45rem;white-space:nowrap;border:1px solid var(--mbu-border);border-radius:0.25rem;background:var(--mbu-ok-bg);color:var(--mbu-ok);font-weight:600;flex-shrink:0;"; selBtn.addEventListener("click", () => setRowResolved(a)); row.appendChild(selBtn); const info = document.createElement("span"); info.style.flex = "1"; const nameA = document.createElement("a"); nameA.href = `https:${MB}/${entityType}/${a.id}`; nameA.target = "_blank"; nameA.rel = "noopener noreferrer nofollow"; nameA.style.fontWeight = "bold"; nameA.textContent = a.name; info.appendChild(nameA); if (a.disambiguation) { const d = document.createElement("span"); d.style.cssText = "color:var(--mbu-text-dim);margin-left:0.25rem;"; d.textContent = `(${a.disambiguation})`; info.appendChild(d); } row.appendChild(info); const rolesEl = buildMbRolesEl(a.id); if (rolesEl) { rolesEl.style.marginLeft = "auto"; row.appendChild(rolesEl); } return row; } function extractMbid(q) { const m = q.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i); return m ? m[0] : null; } function doSearch(q) { if (!q) return; const mbid = extractMbid(q); if (mbid) { candidateList.innerHTML = '<div style="font-size:0.82rem;color:var(--mbu-text-weak);">Looking up MBID\u2026</div>'; mbThrottle.fetchJson(`${MB}/ws/2/${entityType}/${mbid}?inc=aliases&fmt=json`).then((json) => { if (!json) return; candidateList.innerHTML = ""; if (json.id) { candidateList.appendChild(makeCandidateRow({ id: json.id, name: json.name, disambiguation: json.disambiguation || "", aliases: json.aliases || [] })); } else { candidateList.innerHTML = '<div style="font-size:0.82rem;color:var(--mbu-text-weak);">Not found</div>'; } }).catch(() => { candidateList.innerHTML = `<div style="font-size:0.82rem;color:var(--mbu-error);">MBID not found or wrong entity type</div>`; }); return; } candidateList.innerHTML = '<div style="font-size:0.82rem;color:var(--mbu-text-weak);font-style:italic;">Searching\u2026</div>'; mbThrottle.fetchJson(`${MB}/ws/2/${entityType}?query=${encodeURIComponent(q)}&fmt=json&limit=8`).then((json) => { if (!json) { candidateList.innerHTML = '<div style="font-size:0.82rem;color:var(--mbu-error);">Search failed \u2014 MB unavailable</div>'; return; } candidateList.innerHTML = ""; const resultKey = entityType === "label" ? "labels" : entityType === "place" ? "places" : "artists"; if (!json[resultKey] || json[resultKey].length === 0) { const none = document.createElement("div"); none.style.cssText = "font-size:0.82rem;color:var(--mbu-text-weak);"; none.textContent = "No results"; candidateList.appendChild(none); } else { json[resultKey].forEach((a) => candidateList.appendChild(makeCandidateRow(a))); } }).catch(() => { candidateList.innerHTML = '<div style="font-size:0.82rem;color:var(--mbu-error);">Search failed</div>'; }); } let searchTimer; searchInput.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => doSearch(searchInput.value.trim()), 300); }); searchInput.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.preventDefault(); doSearch(searchInput.value.trim()); } }); searchBtn.addEventListener("click", () => doSearch(searchInput.value.trim())); if (isResolved && initMbUrl) { const mbid = initMbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36); const correctedMbUrl = `${MB}/${entityType}/${mbid}`; const displayName2 = initMbName || mbid; if (!initMbName) { rowState.set(_entityKey, { mbUrl: initMbUrl, mbName: null, mbDisambig: "", confirmed: true, via: r.logEntry?.via || null, fromCache: r.logEntry?.fromCache || false }); tr.style.background = "var(--mbu-warn-bg)"; } const fakeA = { id: mbid, name: displayName2, disambiguation: initMbDisam }; if (entityType === "artist" && initMbName && Array.isArray(r.mbAliases)) { fakeA.aliases = r.mbAliases; aliasPick = fakeA; if (wantsAliasButton(entityType, fakeA, displayName)) logDebug(`+ alias: offered on the automatic match "${displayName}" \u2192 ${initMbName} (not among its ${r.mbAliases.length} alias(es))`); } candidateList.innerHTML = ""; const selRow = document.createElement("div"); selRow.style.cssText = "padding:0.15rem 0.4rem;border:1px solid var(--mbu-ok);border-radius:3px;background:var(--mbu-ok-bg);display:flex;flex-wrap:wrap;align-items:center;gap:0.4rem;font-size:0.85rem;"; const selA = document.createElement("a"); selA.href = "https:" + correctedMbUrl; selA.target = "_blank"; selA.rel = "noopener noreferrer nofollow"; selA.textContent = "\u2713 " + displayName2 + (initMbDisam ? ` (${initMbDisam})` : "") + (!initMbName ? " \u26A0 name unknown" : ""); selA.style.fontWeight = "bold"; selA.style.whiteSpace = "nowrap"; selA.style.flex = "0 0 auto"; const undoBtn = document.createElement("button"); undoBtn.textContent = "\u2715"; undoBtn.title = "Clear selection"; undoBtn.style.cssText = "font-size:0.75rem;cursor:pointer;padding:0 0.3rem;margin-left:auto;"; undoBtn.addEventListener("click", () => setRowUnresolved()); selRow.appendChild(selA); const viaBadge = makeViaBadge(r.logEntry?.via, r.logEntry?.fromCache); if (viaBadge) selRow.appendChild(viaBadge); rowViaBadge = viaBadge; rowMatchId = mbid; const mbRolesEl = buildMbRolesEl(); if (mbRolesEl) selRow.appendChild(mbRolesEl); selRow.appendChild(undoBtn); candidateList.appendChild(selRow); renderActions(fakeA); } else if (r.nameMatches && r.nameMatches.length > 0) { r.nameMatches.forEach((a) => candidateList.appendChild(makeCandidateRow(a))); renderActions(null); } else { const none = document.createElement("div"); none.style.cssText = "font-size:0.82rem;color:var(--mbu-text-weak);"; none.textContent = needsAttention ? "No suggestions \u2014 search or create" : ""; if (needsAttention) candidateList.appendChild(none); renderActions(null); } } table.appendChild(tbody); panel.appendChild(table); const btnRow = document.createElement("div"); btnRow.style.cssText = "display:flex;gap:0.75rem;align-items:center;margin-top:0.75rem;flex-wrap:wrap;"; const importBtn = document.createElement("button"); importBtn.style.cssText = "border:none;padding:0.4rem 1.1rem;border-radius:0.3rem;cursor:pointer;font-weight:bold;font-size:0.95rem;display:inline-flex;align-items:center;gap:5px;"; const issueNote = document.createElement("span"); issueNote.className = "discogs-issue-note"; issueNote.style.cssText = "font-size:0.85rem;color:var(--mbu-warn);"; linksNote = document.createElement("span"); linksNote.className = "discogs-issue-note discogs-links-note"; linksNote.style.cssText = "font-size:0.85rem;color:var(--mbu-warn);display:none;"; linksNote.title = `Confirmed matches whose ${importSourceName} URL isn't linked in MB yet \u2014 click to jump to the next one; use its \u{1F517} chip to add the link`; linksNote.addEventListener("click", jumpNextLink); updateLinksBadge(); let _jumpIdx = -1; function jumpNextUnresolved() { const n = allResults.length; let found = -1; for (let step = 1; step <= n; step++) { const i = (_jumpIdx + step) % n; if (!rowState.get(keyOf(allResults[i]))?.confirmed) { found = i; break; } } if (found === -1) return; _jumpIdx = found; const input = rowSearchInputs.get(keyOf(allResults[found])); if (!input) return; input.scrollIntoView({ behavior: "smooth", block: "center" }); try { input.focus({ preventScroll: true }); } catch { input.focus(); } input.select?.(); } issueNote.addEventListener("click", jumpNextUnresolved); function recSelectionCounts() { try { let total = 0, checked = 0; for (const tr of document.querySelectorAll("tr")) { if (!tr.querySelector('a[href*="/recording/"]')) continue; const cb = tr.querySelector('input[type="checkbox"]'); if (!cb) continue; total++; if (cb.checked) checked++; } return total ? { checked, total } : null; } catch (e) { return null; } } function updateImportBtn() { const unresolved = [...rowState.values()].filter((s) => !s.confirmed).length; const sel = recSelectionCounts(); const selLabel = sel && sel.checked > 0 && sel.checked < sel.total ? ` (${sel.checked}/${sel.total})` : ""; if (unresolved === 0) { importBtn.innerHTML = `Start import${selLabel} \u2192`; importBtn.style.background = "var(--mbu-ok)"; importBtn.style.color = "var(--mbu-text-on-accent)"; issueNote.textContent = ""; issueNote.classList.remove("clickable"); issueNote.removeAttribute("title"); } else { importBtn.innerHTML = `Start import anyway${selLabel} \u2192`; importBtn.style.background = "var(--mbu-warn)"; importBtn.style.color = "var(--mbu-text-on-accent)"; issueNote.textContent = `\u26A0 ${unresolved} unresolved`; issueNote.classList.add("clickable"); issueNote.title = "Jump to the next unresolved entity \u2014 click again to cycle through them; these will be skipped on import"; } } updateImportBtn(); let _lastSelKey = ""; const _recSelPoll = setInterval(() => { if (!importBtn.isConnected) { clearInterval(_recSelPoll); return; } const s = recSelectionCounts(); const k = s ? `${s.checked}/${s.total}` : ""; if (k !== _lastSelKey) { _lastSelKey = k; updateImportBtn(); } }, 1e3); function buildStaticTableLi() { const tbl = document.createElement("table"); tbl.style.cssText = "border-collapse:collapse;width:100%;font-size:0.78rem;margin:0.4rem 0;"; const thRow = document.createElement("tr"); thRow.style.background = "var(--mbu-bg-raised)"; [importSourceName + " entity", "Roles / Tracks", "MB match", "MBID", "Resolved via"].forEach((h) => { const th = document.createElement("th"); th.style.cssText = "text-align:left;padding:0.2rem 0.4rem;border:1px solid var(--mbu-border);white-space:nowrap;"; th.textContent = h; thRow.appendChild(th); }); tbl.appendChild(thRow); allResults.forEach((r) => { const _rKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`; const state = rowState.get(_rKey) || {}; const tr2 = document.createElement("tr"); const url = r.entity?.resource_url || r.entity?._syntheticKey || ""; const rolesList2 = url ? rolesMap.get(url) || companiesRolesMap.get(url) || [] : []; const grouped2 = /* @__PURE__ */ new Map(); rolesList2.forEach(({ displayLabel, linkType, trackPos }) => { const key = displayLabel || linkType; if (!grouped2.has(key)) grouped2.set(key, /* @__PURE__ */ new Set()); if (trackPos) grouped2.get(key).add(trackPos); }); const rolesText = [...grouped2.entries()].map(([label, tr]) => label + (tr.size ? " [" + [...tr].join(",") + "]" : "")).join("; "); const mbid = state.mbUrl ? state.mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36) : ""; const matchText = state.mbName || (state.mbUrl ? mbid : ""); const vCfg = state.via ? viaCfg(state.via, state.fromCache) : null; const viaText = vCfg ? vCfg.text : state.mbUrl ? "\u2014" : ""; [r.displayName || r.entity?.name, rolesText, matchText, mbid, viaText].forEach((val, ci) => { const td = document.createElement("td"); td.style.cssText = "padding:0.15rem 0.4rem;border:1px solid var(--mbu-border);" + (ci === 2 && !val ? "color:var(--mbu-text-weak);" : ci === 2 ? "color:var(--mbu-ok);" : ci === 4 && vCfg ? `color:${vCfg.color};` : ""); if (ci === 2 && mbid) { const a = document.createElement("a"); a.href = "https:" + state.mbUrl; a.target = "_blank"; a.rel = "noopener noreferrer nofollow"; a.textContent = val || mbid; td.appendChild(a); } else { td.textContent = val || (ci === 1 ? "" : ci === 2 ? "\u2014" : ""); } tr2.appendChild(td); }); tbl.appendChild(tr2); }); const tblLi = document.createElement("li"); tblLi.style.cssText = "list-style:none;margin:0;padding:0;"; tblLi.appendChild(tbl); return tblLi; } importBtn.addEventListener("click", () => { clearInterval(_recSelPoll); const confirmedMap = /* @__PURE__ */ new Map(); rowState.forEach((s, key) => { if (s.mbUrl) confirmedMap.set(key, s.mbUrl); }); getLogContainer().appendChild(buildStaticTableLi()); const unresolvedCount = allResults.filter((r) => { const _k = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`; return !rowState.get(_k)?.confirmed; }).length; if (unresolvedCount > 0) { const unresolvedLi = document.createElement("li"); unresolvedLi.style.cssText = "list-style:none;margin:0.2rem 0;font-size:0.82rem;color:var(--mbu-warn);"; unresolvedLi.textContent = `\u26A0 ${unresolvedCount} entity/entities unresolved \u2014 will be skipped`; getLogContainer().appendChild(unresolvedLi); } confirmedMap.unresolvedCount = unresolvedCount; confirmedMap.totalEntities = allResults.length; confirmedMap.creditOverrides = creditOverrides; confirmedMap.splits = splits; (panelLi || panel).remove(); if (headerSlot) headerSlot.replaceChildren(); resolve(confirmedMap); }); if (headerSlot) { headerSlot.replaceChildren(importBtn, issueNote, linksNote); } else { btnRow.appendChild(importBtn); btnRow.appendChild(issueNote); btnRow.appendChild(linksNote); panel.appendChild(btnRow); } updateLinksBadge(); const panelLi = document.createElement("li"); panelLi.style.cssText = "list-style:none;margin:0;padding:0;"; panelLi.classList.add("discogs-review-panel-li"); panelLi._buildStaticTableLi = buildStaticTableLi; panelLi.appendChild(panel); getReviewContainer().appendChild(panelLi); panelLi.scrollIntoView({ behavior: "smooth", block: "nearest" }); _hideBar(); }); } // src/match-context.js var SPECIAL2 = new Set(MBM_SPECIAL_PURPOSE); var _relatedCache = /* @__PURE__ */ new Map(); function releaseArtistMbids() { try { const names = pageWindow.MB?.relationshipEditor?.state?.entity?.artistCredit?.names || []; return [...new Set(names.map((n) => n?.artist?.gid).filter(Boolean))]; } catch (e) { return []; } } async function buildReleaseContext({ coCredit = false } = {}) { const all = releaseArtistMbids(); const seeds = all.filter((g) => !SPECIAL2.has(g)); if (all.length && !seeds.length) log.info("Matching context: the release artist is special-purpose (e.g. Various Artists) \u2014 no context lookup (#612)"); const related = []; for (const gid of seeds.slice(0, 4)) { let list = _relatedCache.get(gid); if (!list) { const json = await mbThrottle.fetchJson(`${MB}/ws/2/artist/${gid}?inc=aliases+artist-rels&fmt=json`); if (!json) { log.warn(`Matching context: could not load release artist ${gid} \u2014 continuing without it`); continue; } list = mbmRelatedArtists(json); _relatedCache.set(gid, list); } for (const r of list) if (!related.some((x) => x.gid === r.gid)) related.push(r); } if (seeds.length) { const kinds = related.filter((r) => r.rel !== "self").reduce((m, r) => (m[r.rel] = (m[r.rel] || 0) + 1, m), {}); log.info(`Matching context: ${seeds.length} release artist(s) \u2192 ${related.length - seeds.length} related artist(s)` + (Object.keys(kinds).length ? ` (${Object.entries(kinds).map(([k, n]) => `${n} ${k}`).join(", ")})` : "") + (coCredit ? " \xB7 co-credit search on" : "")); logDebug(`context related: ${related.map((r) => `${r.name}[${r.rel}]`).join(", ")}`); } return { seeds, related, coCredit: !!coCredit }; } // src/editor-state.js async function waitForMBEditor(timeoutMs = 15e3) { log.info("Waiting for MB relationship editor\u2026"); let waited = 0; while (waited < timeoutMs) { const MB2 = pageWindow.MB; const re = MB2?.relationshipEditor; const st = re?.state; if (st?.entity) { log.info(`Editor ready (${waited}ms). Release: "${st.entity.name}"`); return re; } if (waited % 2e3 === 0 && waited > 0) { const mbKeys = MB2 ? Object.keys(MB2).join(", ") : "undefined"; const reKeys = re ? Object.keys(re).join(", ") : "undefined"; const stKeys = st ? Object.keys(st).join(", ") : "undefined"; log.info(`[${waited}ms] MB={${mbKeys}} re={${reKeys}} state={${stKeys}}`); } await new Promise((r) => setTimeout(r, 200)); waited += 200; } log.error("MB editor not ready after 15s \u2014 aborting"); return null; } function dispatchRelationship(re, sourceEntity, targetEntity, linkTypeID, credit, attributes, trackPos) { if (credit && credit === (targetEntity.name || "")) credit = ""; const swapped = sourceEntity.entityType > targetEntity.entityType; const e0 = swapped ? targetEntity : sourceEntity; const e1 = swapped ? sourceEntity : targetEntity; const ltEntry = pageWindow.MB?.linkedEntities?.link_type?.[linkTypeID]; const ltName = ltEntry ? ltEntry.name : linkTypeID; let attrDesc = ""; if (attributes) { try { const parts = []; for (const a of pageWindow.MB.tree.iterate(attributes)) { const n = a.type?.name || a.typeID; const v = a.text_value ? `=${a.text_value}` : ""; if (n) parts.push(n + v); } if (parts.length) attrDesc = ` [${parts.join(", ")}]`; } catch (e) { } } const posLabel = trackPos != null && trackPos !== "" ? ` <span style="color:var(--mbu-text-weak);font-size:0.85em">#${trackPos}</span>` : ""; log.info(`\u2192 <strong>${ltName}</strong>${attrDesc}${posLabel}: ${sourceEntity.name || sourceEntity.gid} \u2194 ${targetEntity.name || targetEntity.gid}${credit && credit !== (targetEntity.name || targetEntity.gid) ? ` (credited: ${credit})` : ""}`); re.dispatch({ type: "update-relationship-state", sourceEntity, batchSelectionCount: null, creditsToChangeForSource: "", creditsToChangeForTarget: "", oldRelationshipState: null, newRelationshipState: { ...REL_TEMPLATE, entity0: e0, entity0_credit: swapped ? credit || "" : "", entity1: e1, entity1_credit: swapped ? "" : credit || "", id: re.getRelationshipStateId(), linkTypeID, attributes: attributes || null } }); } function buildAttributes(rawAttributes, linkTypeID) { if (!rawAttributes || rawAttributes.length === 0) return null; const MB2 = pageWindow.MB; const tree = MB2?.tree; const lat = MB2?.linkedEntities?.link_attribute_type; if (!tree || !lat) return null; const linkType = linkTypeID != null ? MB2?.linkedEntities?.link_type?.[linkTypeID] : null; const supportedRoots = linkType && linkType.attributes ? new Set(Object.keys(linkType.attributes)) : null; const attrSupported = (found) => { if (!supportedRoots) return true; const rootId = found.root_id != null ? found.root_id : found.id; return supportedRoots.has(String(rootId)); }; function findAttrByName(name) { const lower = name.toLowerCase().trim(); for (const v of Object.values(lat)) { if (v.name?.toLowerCase() === lower) return v; } if (lower.length >= 4) { for (const v of Object.values(lat)) { const vl = v.name?.toLowerCase() || ""; if (vl.length < 4) continue; if (vl.includes(lower) || lower.includes(vl)) return v; } } log.warn(`Attribute "${name}" not found in MB \u2014 dropping attribute but keeping the rel`); return null; } const attrObjs = []; const seen = /* @__PURE__ */ new Set(); for (const attr of rawAttributes) { let attrName = null; let textValue = ""; let creditedAs = ""; if (typeof attr === "string") { attrName = attr; } else if (attr && typeof attr === "object" && attr._type) { if (attr._type === "task") { attrName = "task"; textValue = attr.value; } else { attrName = attr.value; } if (attr.creditedAs) creditedAs = attr.creditedAs; } if (!attrName) continue; const found = findAttrByName(attrName); if (!found || seen.has(found.id)) continue; if (!attrSupported(found)) { log.warn(`Attribute "${found.name}" isn't supported by link type "${linkType.name || linkTypeID}" \u2014 dropping it (keeping the rel)`); continue; } seen.add(found.id); attrObjs.push({ type: found, typeID: found.id, credited_as: creditedAs, text_value: textValue }); } if (attrObjs.length === 0) return null; attrObjs.sort((a, b) => a.typeID - b.typeID); try { return tree.fromDistinctAscArray(attrObjs); } catch (e) { log.warn(`Attribute tree build failed (${e.message}) \u2014 importing without attributes`); return null; } } // src/data/work-only-rels.js var WORK_ONLY_ARTIST_RELS = [ "writer", "composer", "lyricist", "librettist", "revised by", // NOT 'translator' — a translator credit (liner notes / lyrics / libretto) is a // release-wide credit, so it dispatches at RELEASE level (artist↔release // "translator"), not duplicated onto every work. (MB has the artist-release rel.) "reconstructed by", // 'arranger', // 'instruments arranger', "orchestrator", // 'vocals arranger', "previously attributed to", "miscellaneous support", "dedicated to", "premiered by", "was commissioned by", "publisher", // MB's actual link-type name for the music-publisher rel (label→work). // Tidal/Qobuz "Music Publisher" credits resolve to a label and attach here. "publishing", "inspired the name of" ]; // src/dispatch.js var stripDiscogsNum2 = (s) => String(s || "").replace(/\s+\(\d+\)$/, ""); function makeIdentifyingClassifier(lat) { const identifyingRoots = /* @__PURE__ */ new Set(); if (lat) { for (const v of Object.values(lat)) { const isRoot = v.parent_id == null || v.parent_id === v.id; if (isRoot && /^(instrument|vocal)$/i.test(v.name || "")) identifyingRoots.add(v.id); } } const rootCache = /* @__PURE__ */ new Map(); const rootIdOf = (typeID) => { if (rootCache.has(typeID)) return rootCache.get(typeID); let node = lat ? lat[typeID] : null; let guard = 0; while (node && node.parent_id != null && node.parent_id !== node.id && lat[node.parent_id] && guard++ < 64) { node = lat[node.parent_id]; } const rootId = node ? node.id : typeID; rootCache.set(typeID, rootId); return rootId; }; return (typeID) => identifyingRoots.has(rootIdOf(typeID)); } async function dispatchAllRelationships(companies, artistRoles, tracklistRels, applyToTracks, createWorksMode, discogsTracklist, processTracklist, resolvedEntityTypes, confirmedMap, discogsUrl, dedupOpts) { resolvedEntityTypes = resolvedEntityTypes || /* @__PURE__ */ new Map(); confirmedMap = confirmedMap || /* @__PURE__ */ new Map(); dedupOpts = dedupOpts || {}; if (confirmedMap.splits?.size) { const keyOfEntity = (e) => e.resource_url || e._syntheticKey || `_nourl_${e.name}`; const before = (artistRoles?.length || 0) + (tracklistRels?.length || 0); artistRoles = expandSplitRoles(artistRoles, confirmedMap.splits, keyOfEntity); tracklistRels = expandSplitRoles(tracklistRels, confirmedMap.splits, keyOfEntity); const after = (artistRoles?.length || 0) + (tracklistRels?.length || 0); confirmedMap.splits.forEach((parts, k) => log.info(`#605 split ${k} \u2192 ${parts.map((p) => `${p.name} [${confirmedMap.get(p.key) || "unresolved"}]`).join(" \xB7 ")}`)); log.info(`#605 split: ${before} role(s) \u2192 ${after} after fan-out`); } const dedupeEquivalenceSets = dedupOpts.dedupeEquivalenceSets !== false; const dedupeDuplicateRoles = dedupOpts.dedupeDuplicateRoles !== false; const creditOverrides = dedupOpts.creditOverrides || /* @__PURE__ */ new Map(); const re = await waitForMBEditor(); if (!re) return; const MB2 = pageWindow.MB; const isIdentifyingAttr = makeIdentifyingClassifier(MB2?.linkedEntities?.link_attribute_type); const equivalenceLookup = (() => { const m = /* @__PURE__ */ new Map(); if (!dedupeEquivalenceSets || !MB2?.linkedEntities?.link_type) return m; for (const set of EQUIVALENCE_SETS) { const byPair = /* @__PURE__ */ new Map(); for (const [id, lt] of Object.entries(MB2.linkedEntities.link_type)) { if (!lt?.name) continue; if (!set.includes(String(lt.name).toLowerCase())) continue; const key = `${lt.type0}|${lt.type1}`; if (!byPair.has(key)) byPair.set(key, []); byPair.get(key).push(Number(id)); } for (const ids of byPair.values()) { if (ids.length < 2) continue; const sibSet = new Set(ids); for (const id of ids) m.set(id, sibSet); } } return m; })(); const releaseEntity = re.state.entity; let added = 0, existedInMb = 0, existedStaged = 0, dedupedThisSession = 0, skipped = 0, failed = 0; const dispatchedThisSession = /* @__PURE__ */ new Set(); const RECORDING_LINK_TYPES = /* @__PURE__ */ new Set([ "performer", "instrument", "vocal", "vocals", "orchestra", "conductor", "concertmaster", "chorus master", "producer", "engineer", "mix", "recording", "remixer", "DJ-mixer", "additional", "guest", "programming" // NOT 'mastering' — MB deprecated artist→recording mastering (link type 136). ]); const isExecProducer = (role) => role.linkType === "producer" && (role.attributes || []).some((a) => a === "executive" || a && a.value === "executive"); log.info(`Starting instant fill: ${companies.length} companies, ${artistRoles.length} release artist roles, ${tracklistRels.length} tracklist roles`); try { _showBar(); } catch (_) { } const bar = document.querySelector(".discogs-bar"); function tickProgress() { const done = added + skipped + failed; const est = Math.max(done + 1, companies.length + artistRoles.length + tracklistRels.length); const pct = Math.min(Math.round(done / est * 99), 99); try { _setProgressPct(pct); } catch (_) { } } const recordingByGid = /* @__PURE__ */ new Map(); const recordingByPosition = /* @__PURE__ */ new Map(); const editorWorkByRecGid = /* @__PURE__ */ new Map(); const positionByGid = /* @__PURE__ */ new Map(); let trackCount = 0; try { let mediumIndex = 0; for (const [mediumKey, medium] of MB2.tree.iterate(re.state.mediums)) { mediumIndex++; const tracks = medium?.tracks ?? medium; let trackIndex = 0; for (const rawTrack of MB2.tree.iterate(tracks)) { const trackObj = Array.isArray(rawTrack) ? rawTrack[1] : rawTrack; const trackKey = Array.isArray(rawTrack) ? rawTrack[0] : null; const rec = trackObj?.recording ?? trackObj; if (!rec) continue; trackCount++; if (rec.gid) { recordingByGid.set(rec.gid, rec); positionByGid.set(rec.gid, `${mediumIndex}-${trackIndex + 1}`); const rw = trackObj?.relatedWorks; if (rw && rw.size > 0) { try { for (const entry of MB2.tree.iterate(rw)) { const raw = Array.isArray(entry) ? entry[1] : entry; const work = raw?.work ?? raw; if (work?.gid || work?.id) { editorWorkByRecGid.set(rec.gid, work); break; } } } catch (e) { } } } const positions = new Set([ trackObj?.position, trackObj?.number, rec?.position, rec?.number, trackKey, trackIndex + 1, // Compound keys: "mediumIndex-trackPosition" `${mediumIndex}-${trackIndex + 1}`, trackObj?.position != null ? `${mediumIndex}-${trackObj.position}` : null, trackObj?.number != null ? `${mediumIndex}-${trackObj.number}` : null ].filter((x) => x != null).map(String)); for (const p of positions) recordingByPosition.set(p, rec); trackIndex++; } } log.info(`Found ${trackCount} track(s) in editor state (${recordingByGid.size} with GID, ${recordingByPosition.size} position entries: ${[...recordingByPosition.keys()].join(",")}). relatedWorks: ${editorWorkByRecGid.size} pre-linked`); } catch (e) { log.warn(`Iterating MB state: ${e.message}`); } const checkedRecGids = /* @__PURE__ */ new Set(); try { for (const raw of MB2.tree.iterate(re.state.selectedRecordings)) { const rec = Array.isArray(raw) ? raw[1] : raw; if (rec?.gid) checkedRecGids.add(rec.gid); } } catch (e) { } const recSelectionActive = checkedRecGids.size > 0 && checkedRecGids.size < recordingByGid.size; const applyToRec = (gid) => !recSelectionActive || checkedRecGids.has(gid); if (recSelectionActive) log.info(`Recording selection: applying only to ${checkedRecGids.size}/${recordingByGid.size} checked recording(s).`); const positionToGid = /* @__PURE__ */ new Map(); try { const relMbid = releaseEntity.gid; log.info(`WS2: fetching recordings for release ${relMbid}\u2026`); const wsJson = await fetchWithRetry(`/ws/2/release/${relMbid}?inc=recordings&fmt=json`); log.info(`WS2: response received`); if (wsJson) { const mediaCount = wsJson.media?.length ?? 0; log.info(`WS2: ${mediaCount} medium/media in response`); const mediaArr = wsJson.media || []; const isMultiMedium2 = mediaArr.length > 1; for (const medium of mediaArr) { const medPos = medium.position; for (const track of medium.tracks || []) { const gid = track.recording?.id; if (!gid) continue; if (medPos != null && track.position != null) { positionToGid.set(`${medPos}-${track.position}`, gid); } if (medPos != null && track.number != null) { positionToGid.set(`${medPos}-${track.number}`, gid); } if (!isMultiMedium2) { if (track.position != null) positionToGid.set(String(track.position), gid); if (track.number != null) positionToGid.set(String(track.number), gid); } } } log.info(`WS2 position map: ${positionToGid.size} entries (${[...positionToGid.keys()].sort().join(", ")})`); } } catch (e) { log.warn(`WS2 recording fetch failed: ${e.message} \u2014 using editor state positions only`); } const isMultiMedium = positionToGid.size > 0 && [...positionToGid.keys()].some((k) => /^[2-9]-/.test(k)); function inferDiscFromVinylSide(pos) { const m = String(pos || "").match(/^([A-Z])\d+$/i); if (!m) return null; return Math.floor((m[1].toUpperCase().charCodeAt(0) - 65) / 2) + 1; } function getRecordingEntity(track) { const stripPad = (s) => String(s).replace(/-0+(\d)/g, "-$1"); const pos = track.position != null ? String(track.position) : ""; const num = track.number != null ? String(track.number) : ""; const compounds = /* @__PURE__ */ new Set(); const plain = /* @__PURE__ */ new Set(); if (/^\d+-/.test(pos)) { compounds.add(pos); const unpadded = stripPad(pos); if (unpadded !== pos) compounds.add(unpadded); } else if (pos) { plain.add(pos); const inferredDisc = inferDiscFromVinylSide(pos); if (inferredDisc != null) compounds.add(`${inferredDisc}-${pos}`); for (let m = 1; m <= 10; m++) compounds.add(`${m}-${pos}`); } if (num && num !== pos) { plain.add(num); for (let m = 1; m <= 10; m++) compounds.add(`${m}-${num}`); } const tryKeys = isMultiMedium ? [...compounds] : [...plain, ...compounds]; for (const c of tryKeys) { const gid = positionToGid.get(c); if (gid) { const rec = recordingByGid.get(gid); if (rec) return rec; log.warn(`Recording ${gid} for track ${track.position} not in editor state`); return null; } } for (const c of tryKeys) { const rec = recordingByPosition.get(c); if (rec) return rec; } if (trackCount > 0) { const ws2Keys = positionToGid.size ? [...positionToGid.keys()].join(", ") : "(empty)"; const stateKeys = recordingByPosition.size ? [...recordingByPosition.keys()].join(", ") : "(empty)"; log.warn(`No recording for track ${track.position} "${track.title}". WS2 keys: ${ws2Keys} | State keys: ${stateKeys}`); } return null; } function confirmedMbUrl(entity) { if (!entity) return null; const direct = confirmedMap.get(entity.resource_url) || confirmedMap.get(entity._syntheticKey) || null; if (direct) return direct; if (entity.name) { return confirmedMap.get(`_nourl_${entity.name}`) || null; } return null; } function relAlreadyExists(sourceEntity, linkTypeID, targetGid, attrTree) { const rels = sourceEntity?.relationships; if (!Array.isArray(rels) || rels.length === 0) return null; const acceptableLinkTypes = equivalenceLookup.get(linkTypeID) || /* @__PURE__ */ new Set([linkTypeID]); const sigOf = (attrs) => attrs.map((a) => `${a.typeID}:${a.text_value || ""}:${a.credited_as || ""}`).sort().join(","); const idSigOf = (attrs) => attrs.filter((a) => isIdentifyingAttr(a.typeID)).map((a) => `${a.typeID}:${a.text_value || ""}:${a.credited_as || ""}`).sort().join(","); const candAttrs = (() => { if (!attrTree) return []; try { return [...pageWindow.MB.tree.iterate(attrTree)].map((a) => ({ typeID: a.typeID, text_value: a.text_value || "", credited_as: a.credited_as || "" })); } catch (e) { return []; } })(); const candSig = sigOf(candAttrs); const candIdSig = idSigOf(candAttrs); const lookupName = (id) => { try { return pageWindow.MB.linkedEntities.link_type[id]?.name || `#${id}`; } catch (e) { return `#${id}`; } }; let dupMatch = null; for (const r of rels) { if (!acceptableLinkTypes.has(r.linkTypeID)) continue; const tgt = r.target?.gid || r.entity0?.gid || r.entity1?.gid; if (tgt !== targetGid) continue; const isEquivalent = r.linkTypeID !== linkTypeID; const existingAttrs = (r.attributes || []).map((a) => ({ typeID: a.typeID, text_value: a.text_value || "", credited_as: a.credited_as || "" })); const exactMatch = sigOf(existingAttrs) === candSig; if (exactMatch) { return { kind: isEquivalent ? "equivalence" : "exact", existingLinkName: lookupName(r.linkTypeID), status: r._status }; } if (dedupeDuplicateRoles && !dupMatch && idSigOf(existingAttrs) === candIdSig) { dupMatch = { kind: isEquivalent ? "equivalence" : "duplicate-role", existingLinkName: lookupName(r.linkTypeID), status: r._status }; } } return dupMatch; } async function processOne(sourceEntity, entityType0, entityType1, linkTypeName, mbUrl, rawAttributes, credit, trackPos) { const overrideCredit = creditOverrides.get(mbUrl); if (overrideCredit && String(overrideCredit).trim()) { credit = String(overrideCredit).trim(); } const mbid = mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36); if (!mbid) { log.error(`Bad MBID URL: ${mbUrl}`); failed++; return; } const linkTypeID = resolveLinkTypeId(linkTypeName, entityType0, entityType1); if (!linkTypeID) { failed++; return; } const attrTree = buildAttributes(rawAttributes, linkTypeID); const attrSig = attrTree ? (() => { try { return [...pageWindow.MB.tree.iterate(attrTree)].map((a) => (a.typeID || "") + (a.credited_as ? "~" + a.credited_as : "")).join(","); } catch (e) { return ""; } })() : ""; const sessionKey = `${sourceEntity.gid}|${linkTypeID}|${mbid}|${attrSig}`; if (dispatchedThisSession.has(sessionKey)) { log.info(`Skipped duplicate dispatch of <strong>${linkTypeName}</strong>: ${sourceEntity.name} \u2194 ${credit || ""} \u2014 already queued earlier this run`); dedupedThisSession++; return; } dispatchedThisSession.add(sessionKey); let targetEntity; try { targetEntity = await fetchMBEntity(mbid); } catch (e) { log.error(`Entity fetch failed for ${mbid}: ${e.message}`); failed++; return; } const PLACE_TO_LABEL_LINK = { "glass mastered at": "glass mastered", "mastered at": "mastering", "pressed at": "pressed", "manufactured at": "manufactured", "recorded at": "engineer", "mixed at": "mix" }; const LABEL_TO_PLACE_LINK = Object.fromEntries( Object.entries(PLACE_TO_LABEL_LINK).map(([k, v]) => [v, k]) ); let resolvedLinkTypeID = linkTypeID; if (targetEntity.entityType !== entityType1 && targetEntity.entityType !== entityType0) { const at = targetEntity.entityType; const [rt0, rt1] = at < sourceEntity.entityType ? [at, sourceEntity.entityType] : [sourceEntity.entityType, at]; let reResolved = resolveLinkTypeId(linkTypeName, rt0, rt1); if (!reResolved) { const altName = at === "label" ? PLACE_TO_LABEL_LINK[linkTypeName] : LABEL_TO_PLACE_LINK[linkTypeName]; if (altName) reResolved = resolveLinkTypeId(altName, rt0, rt1); } if (reResolved) { resolvedLinkTypeID = reResolved; } else { log.warn(`Entity "${targetEntity.name}" is a ${targetEntity.entityType} but expected ${entityType0}/${entityType1} \u2014 link type "${linkTypeName}" may not apply`); } } const dedupHit = relAlreadyExists(sourceEntity, resolvedLinkTypeID, targetEntity.gid, attrTree); if (dedupHit) { const pair = `${sourceEntity.name} \u2194 ${targetEntity.name}${credit && credit !== targetEntity.name ? ` (credited: ${credit})` : ""}`; const existing = dedupHit.existingLinkName; const staged = dedupHit.status === 1; const where = staged ? "already added this session" : "already in MB"; if (dedupHit.kind === "equivalence") { log.info(`Deduplication (equivalence sets): <strong>${linkTypeName}</strong> not added \u2014 equivalent <strong>${existing}</strong> ${where} on ${pair}`); } else if (dedupHit.kind === "duplicate-role") { log.info(`Deduplication (duplicate roles): <strong>${linkTypeName}</strong> not added \u2014 same role ${where} with different attributes on ${pair}`); } else { log.info(`${staged ? "Already added this session" : "Already in MB"}: <strong>${linkTypeName}</strong>: ${pair}`); } if (staged) existedStaged++; else existedInMb++; return; } dispatchRelationship(re, sourceEntity, targetEntity, resolvedLinkTypeID, credit, attrTree, trackPos); added++; } async function dispatchCompanies() { for (const company of companies) { const details = ENTITY_TYPE_MAP[company.entity_type_name]; if (!details) continue; const resolvedEt = resolvedEntityTypes.get(company.resource_url) || details.entityType; if (resolvedEt !== details.entityType) { if (details.entityType === "place" && resolvedEt === "label") { log.warn(`Skipped ${company.name}: MB has no "${details.linkType}" relationship for labels (only places). Add manually if needed.`); skipped++; tickProgress(); continue; } } const mbUrl = confirmedMbUrl(company); if (!mbUrl) { log.skip(`Skipped ${company.name} \u2014 not resolved in review`); skipped++; tickProgress(); continue; } const et = resolvedEt; const [t0, t1] = et <= "release" ? [et, "release"] : ["release", et]; await processOne(releaseEntity, t0, t1, details.linkType, mbUrl, [], ""); tickProgress(); } } async function dispatchReleaseArtists() { for (const role of artistRoles) { if (applyToTracks && RECORDING_LINK_TYPES.has(role.linkType) && !isExecProducer(role)) continue; if (WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue; const mbUrl = confirmedMbUrl(role.artist); if (!mbUrl) { log.skip(`Skipped ${role.artist.name} (${role.linkType}) \u2014 not resolved in review`); skipped++; tickProgress(); continue; } const credit = role.creditedAs || stripDiscogsNum2(role.artist.anv?.trim() || role.artist.name); await processOne(releaseEntity, "artist", "release", role.linkType, mbUrl, role.attributes || [], credit); tickProgress(); } } async function dispatchTracklist() { if (applyToTracks && recordingByGid.size > 0) { const applicable = artistRoles.filter((role) => RECORDING_LINK_TYPES.has(role.linkType) && !WORK_ONLY_ARTIST_RELS.includes(role.linkType) && !isExecProducer(role)); if (applicable.length > 0) { log.info(`Applying ${applicable.length} release credit(s) to ${recordingByGid.size} recording(s)\u2026`); for (const role of applicable) { const mbUrl = confirmedMbUrl(role.artist); if (!mbUrl) { log.skip(`Skipped ${role.artist.name} (${role.linkType}) in applyToTracks \u2014 not resolved in review`); continue; } const credit = role.creditedAs || stripDiscogsNum2(role.artist.anv?.trim() || role.artist.name); for (const recEntity of recordingByGid.values()) { if (!applyToRec(recEntity.gid)) continue; await processOne(recEntity, "artist", "recording", role.linkType, mbUrl, role.attributes || [], credit, positionByGid.get(recEntity.gid) || "*"); } } } } } async function dispatchWorks() { if (createWorksMode === "off") { const workOnly = [...tracklistRels || [], ...artistRoles || []].filter((r) => WORK_ONLY_ARTIST_RELS.includes(r.linkType)); if (workOnly.length) log.skip(`"Use works" is off \u2014 skipped ${workOnly.length} work-level credit(s) (${[...new Set(workOnly.map((r) => r.linkType))].join(", ")})`); return; } const recordingOfLinkTypeId = resolveLinkTypeId("performance", "recording", "work"); const includeOnlyResolved = createWorksMode === "when-needed"; const workOnlyByGid = /* @__PURE__ */ new Map(); for (const role of tracklistRels) { if (!WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue; if (includeOnlyResolved && !confirmedMbUrl(role.artist)) continue; const recEntity = getRecordingEntity(role.track); if (!recEntity) { log.error(`Work-only rel for track ${role.track.position} "${role.track.title}" \u2014 no recording found, skipped`); failed++; continue; } if (!applyToRec(recEntity.gid)) continue; if (!workOnlyByGid.has(recEntity.gid)) workOnlyByGid.set(recEntity.gid, []); workOnlyByGid.get(recEntity.gid).push({ role, recEntity }); } for (const role of artistRoles) { if (!WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue; if (includeOnlyResolved && !confirmedMbUrl(role.artist)) continue; for (const recEntity of recordingByGid.values()) { if (!applyToRec(recEntity.gid)) continue; const syntheticRole = { ...role, track: { position: "", title: recEntity.name || "" } }; if (!workOnlyByGid.has(recEntity.gid)) workOnlyByGid.set(recEntity.gid, []); workOnlyByGid.get(recEntity.gid).push({ role: syntheticRole, recEntity }); } } if (createWorksMode === "when-missing" && recordingOfLinkTypeId) { for (const recEntity of recordingByGid.values()) { if (!applyToRec(recEntity.gid)) continue; if (!workOnlyByGid.has(recEntity.gid)) { workOnlyByGid.set(recEntity.gid, []); } } } if (workOnlyByGid.size === 0) return; if (!recordingOfLinkTypeId) { log.error('Could not resolve "performance" link type \u2014 work processing skipped'); return; } log.info(`Processing work relationships for ${workOnlyByGid.size} recording(s)\u2026`); const existingWorkByRecGid = editorWorkByRecGid; log.info(`Editor state: ${existingWorkByRecGid.size} recording(s) already have a linked work`); function getWorkFromEditorState(recEntity) { try { for (const rel of MB2.tree.iterate(recEntity.relationships)) { if (rel._status === 1 && rel.linkTypeID === recordingOfLinkTypeId) { return rel.entity0?.entityType === "work" ? rel.entity0 : rel.entity1; } } } catch (e) { } return null; } const createdWorkRecGids = /* @__PURE__ */ new Set(); for (const [recGid, entries] of workOnlyByGid) { const recEntity = entries[0]?.recEntity ?? recordingByGid.get(recGid); const trackTitle = entries[0]?.role.track.title || recEntity?.name || recGid; const trackPos = entries[0]?.role.track.position ?? ""; if (!recEntity) continue; const hasExistingWork = editorWorkByRecGid.has(recGid); let workEntity = null; if (hasExistingWork) { workEntity = editorWorkByRecGid.get(recGid); const wid = workEntity.gid || workEntity.id; log.info(`Track ${trackPos} "${trackTitle}": work already linked (${workEntity.name || wid || "existing"}) \u2014 skipping creation`); if (!workEntity.gid && !workEntity.id) continue; } if (!workEntity) workEntity = getWorkFromEditorState(recEntity); if (!workEntity && createWorksMode === "never") { for (const { role } of entries) { log.error(`Track ${trackPos} "${trackTitle}": no work exists for ${role.linkType} (${role.artist.name}) \u2014 "Create works" is set to "never". Add the work manually or change the mode.`); failed++; } continue; } if (!workEntity) { if (createdWorkRecGids.has(recGid)) { log.error(`Track ${trackPos} "${trackTitle}": a work was already created for this recording in this run \u2014 skipping to avoid a duplicate work`); failed++; continue; } const newWorkId = re.getRelationshipStateId(); workEntity = { _fromBatchCreateWorksDialog: true, attributes: [], comment: "", editsPending: false, entityType: "work", gid: null, id: newWorkId, iswcs: [], languages: [], name: trackTitle, typeID: null }; if (MB2.mergeLinkedEntities) { MB2.mergeLinkedEntities({ work: { [newWorkId]: workEntity } }); } re.dispatch({ type: "update-relationship-state", sourceEntity: recEntity, batchSelectionCount: null, creditsToChangeForSource: "", creditsToChangeForTarget: "", oldRelationshipState: null, newRelationshipState: { _lineage: ["batch-created work"], _original: null, _status: 1, attributes: null, begin_date: null, editsPending: false, end_date: null, ended: false, entity0: recEntity, entity0_credit: "", entity1: workEntity, entity1_credit: "", id: re.getRelationshipStateId(), linkOrder: 0, linkTypeID: recordingOfLinkTypeId } }); createdWorkRecGids.add(recGid); log.info(`Track ${trackPos} "${trackTitle}": created new work "${trackTitle}"`); added++; tickProgress(); workEntity = getWorkFromEditorState(recEntity) || workEntity; } for (const { role } of entries) { const mbUrl = confirmedMbUrl(role.artist); if (!mbUrl) { log.skip(`Skipped ${role.artist.name} \u2014 not resolved in review (${role.linkType})`); continue; } const credit = role.creditedAs || stripDiscogsNum2(role.artist.anv?.trim() || role.artist.name); const srcType = role.entityType || "artist"; const urlType = (mbUrl.match(/musicbrainz\.org\/(artist|label|place)\//i) || [])[1]; if (urlType && urlType !== srcType) { log.skip(`Skipped ${role.linkType} for "${credit}" \u2014 resolved to an MB ${urlType}, but this relationship needs a ${srcType} (#417)`); continue; } if (workEntity.gid) { await processOne(workEntity, srcType, "work", role.linkType, mbUrl, role.attributes || [], credit, trackPos || entries[0]?.role?.track?.position); } else { const linkTypeID = resolveLinkTypeId(role.linkType, srcType, "work"); if (linkTypeID) { const mbid = mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36); try { const artistEntity = await fetchMBEntity(mbid); dispatchRelationship(re, workEntity, artistEntity, linkTypeID, credit, buildAttributes(role.attributes || [], linkTypeID)); added++; } catch (e) { log.error(`Failed to add ${role.linkType} for new work: ${e.message}`); } } } } } } async function dispatchTracklistArtists() { const seenTrackRels = /* @__PURE__ */ new Set(); for (const role of tracklistRels) { if (WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue; const mbUrl = confirmedMbUrl(role.artist); if (!mbUrl) { log.skip(`Skipped ${role.artist.name} on track ${role.track.position} \u2014 not resolved in review`); continue; } const recEntity = getRecordingEntity(role.track); if (!recEntity) { log.warn(`No recording found for track ${role.track.position} "${role.track.title}" \u2014 skipped`); failed++; continue; } if (!applyToRec(recEntity.gid)) continue; const credit = role.creditedAs || stripDiscogsNum2(role.artist.anv?.trim() || role.artist.name); const attrKey = (role.attributes || []).map((a) => typeof a === "string" ? a : a.value || a._type || "").join(","); const trackRelKey = `${role.track.position}|${role.linkType}|${mbUrl}|${attrKey}`; if (seenTrackRels.has(trackRelKey)) continue; seenTrackRels.add(trackRelKey); log.info(`Track ${role.track.position} "${role.track.title}": adding <strong>${role.linkType}</strong> \u2014 ${credit}`); await processOne(recEntity, "artist", "recording", role.linkType, mbUrl, role.attributes || [], credit, role.track.position); tickProgress(); } } await dispatchCompanies(); await dispatchReleaseArtists(); await dispatchTracklist(); await dispatchWorks(); await dispatchTracklistArtists(); try { const opts = [ processTracklist !== void 0 ? `per-track:${processTracklist ? "on" : "off"}` : null, applyToTracks !== void 0 ? `move-to-tracks:${applyToTracks ? "on" : "off"}` : null, createWorksMode !== void 0 ? `create-works:${createWorksMode}` : null ].filter(Boolean).join(", "); const trackCount2 = Array.isArray(discogsTracklist) ? discogsTracklist.length : 0; const inputStats = `Input: ${companies?.length || 0} companies, ${artistRoles?.length || 0} release credits, ${tracklistRels?.length || 0} tracklist credits on ${trackCount2} track${trackCount2 === 1 ? "" : "s"}`; const unresolvedCount = confirmedMap?.unresolvedCount || 0; const totalEntities = confirmedMap?.totalEntities || 0; const unresolvedLine = unresolvedCount > 0 ? `Unresolved: ${unresolvedCount} of ${totalEntities} entit${totalEntities === 1 ? "y" : "ies"} skipped in review` : null; const editNoteDedupPart = dedupedThisSession > 0 ? `, ${dedupedThisSession} dispatch duplicate${dedupedThisSession === 1 ? "" : "s"}` : ""; const editNoteStagedPart = existedStaged > 0 ? `, ${existedStaged} already added this session` : ""; const resultStats = `Result: ${added} added, ${existedInMb} already in MB${editNoteStagedPart}${editNoteDedupPart}, ${skipped} skipped, ${failed} failed${recSelectionActive ? ` (applied to ${checkedRecGids.size} of ${recordingByGid.size} selected recordings)` : ""}`; const ourNote = buildEditNote(discogsUrl, opts, [inputStats, unresolvedLine, resultStats].filter(Boolean), dedupOpts.sourceLabel); const existingNote = document.querySelector(SELECTORS.EditNote)?.value || ""; const note = combineEditNote(existingNote, ourNote); re.dispatch({ type: "update-edit-note", editNote: note }); } catch (e) { } const dedupPart = dedupedThisSession > 0 ? `, ${dedupedThisSession} dispatch duplicate${dedupedThisSession === 1 ? "" : "s"}` : ""; const stagedPart = existedStaged > 0 ? `, ${existedStaged} already added this session` : ""; const selNote = recSelectionActive ? ` \u2014 applied to ${checkedRecGids.size} of ${recordingByGid.size} selected recording(s)` : ""; log.info(`<strong>Done: ${added} added, ${existedInMb} already in MB${stagedPart}${dedupPart}, ${skipped} skipped, ${failed} failed${selNote}</strong>`); } // src/sources/apple.js var APPLE_AMP = "https://amp-api.music.apple.com/v1/catalog"; var APPLE_ALBUM_RE = /(?:music|itunes)\.apple\.com\/(?:([a-z]{2})\/)?album\/(?:[^/?#]+\/)?(?:id)?(\d+)/i; function parseAppleAlbumUrl(url) { const m = APPLE_ALBUM_RE.exec(url || ""); return m ? { storefront: (m[1] || "us").toLowerCase(), id: m[2] } : null; } var APPLE_ROLE_BRIDGE = { "Songwriter": "Written-By", "Writer": "Written-By", "Composer": "Composed By", "Lyricist": "Lyrics By", "Producer": "Producer", "Mixing Engineer": "Mixed By", "Mixer": "Mixed By", "Recording Engineer": "Recording Engineer", "Engineer": "Engineer", "Arranger": "Arranged By", "Vocalist": "Vocals", "Vocal": "Vocals", "Background Vocals": "Backing Vocals", "Background Vocal": "Backing Vocals" }; var APPLE_SKIP = /* @__PURE__ */ new Set(["Performer", "Mastering Engineer", "Studio Personnel"]); function appleToEngine(parsedTracks) { const tracklistRels = []; const tracklist = []; const skipped = []; const tracks = parsedTracks || []; const { positions, multiVolume } = assignVolumePositions(tracks, (t) => t.index); tracks.forEach((t, i) => { const track = { position: positions[i], title: t.title || "", type_: "track" }; tracklist.push(track); for (const c of t.credits || []) { const role = String(c.role || "").trim(); const name = String(c.name || "").trim(); if (!role || !name) continue; if (APPLE_SKIP.has(role)) { skipped.push(`track ${track.position}: ${role} \u2014 ${name}`); continue; } const discogsRole = APPLE_ROLE_BRIDGE[role] || role; const rels = getArtistRoles({ name, anv: "", role: discogsRole, resource_url: "" }); if (!rels || !rels.length) { skipped.push(`track ${track.position}: ${role} \u2014 ${name} (unmapped)`); continue; } for (const rel of rels) tracklistRels.push({ ...rel, artist: rel.artist || { name, anv: "", resource_url: "" }, track }); } }); return { tracklistRels, tracklist, skipped, multiVolume }; } function appleGet(url, headers) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("GM_xmlhttpRequest unavailable")); return; } GM_xmlhttpRequest({ method: "GET", url, headers: headers || {}, timeout: 2e4, onload: (r) => resolve({ status: r.status, text: r.responseText || "" }), onerror: () => reject(new Error("Apple request failed (network)")), ontimeout: () => reject(new Error("Apple request timed out")) }); }); } var _appleToken = null; var APPLE_TOKEN_LS = "ch:apple-token"; async function appleToken() { if (_appleToken) return _appleToken; try { const raw = JSON.parse(localStorage.getItem(APPLE_TOKEN_LS) || "null"); if (raw && raw.t && raw.at && Date.now() - raw.at < 12 * 36e5) { _appleToken = raw.t; return _appleToken; } } catch (e) { } const home = await appleGet("https://music.apple.com/us/browse", { "Accept": "text/html" }); const asset = (home.text.match(/\/assets\/index-legacy~[a-z0-9]+\.js/i) || home.text.match(/\/assets\/index~[a-z0-9]+\.js/i) || [])[0]; if (!asset) throw new Error("Apple: could not locate the web-player JS asset"); const js = await appleGet("https://music.apple.com" + asset, {}); const tok = (js.text.match(/eyJ[A-Za-z0-9._\-]{80,}/) || [])[0]; if (!tok) throw new Error("Apple: no bearer token in the web-player JS"); _appleToken = tok; try { localStorage.setItem(APPLE_TOKEN_LS, JSON.stringify({ t: tok, at: Date.now() })); } catch (e) { } return tok; } function ampHeaders(tok) { return { Authorization: "Bearer " + tok, Origin: "https://music.apple.com", Accept: "application/json" }; } async function fetchAppleCredits(storefront, albumId, onProgress) { const sf = storefront || "us"; const tok = await appleToken(); const albRes = await appleGet(`${APPLE_AMP}/${sf}/albums/${encodeURIComponent(albumId)}?l=en-US`, ampHeaders(tok)); if (albRes.status !== 200) throw new Error(`Apple album ${albumId} \u2192 HTTP ${albRes.status}`); let albJson; try { albJson = JSON.parse(albRes.text); } catch (e) { throw new Error("Apple: malformed album JSON"); } const album = albJson.data && albJson.data[0]; const albumName = album && album.attributes && album.attributes.name || ""; let songs = album && album.relationships && album.relationships.tracks && album.relationships.tracks.data || []; let next = album && album.relationships && album.relationships.tracks && album.relationships.tracks.next; while (next) { const pg = await appleGet("https://amp-api.music.apple.com" + next + (next.includes("?") ? "&" : "?") + "l=en-US", ampHeaders(tok)); let pj; try { pj = JSON.parse(pg.text); } catch (e) { break; } songs = songs.concat(pj.data || []); next = pj.next; } const tracks = []; let done = 0; for (const s of songs) { const sid = s.id; const num = s.attributes && s.attributes.trackNumber || done + 1; const title = s.attributes && s.attributes.name || ""; const credits = []; try { const cr = await appleGet(`${APPLE_AMP}/${sf}/songs/${encodeURIComponent(sid)}/credits?l=en-US`, ampHeaders(tok)); if (cr.status === 200) { let cj; try { cj = JSON.parse(cr.text); } catch (e) { cj = null; } for (const grp of cj && cj.data || []) { const arts = grp.relationships && grp.relationships["credit-artists"] && grp.relationships["credit-artists"].data || []; for (const a of arts) { const name = a.attributes && a.attributes.name; for (const role of a.attributes && a.attributes.roleNames || []) { if (name && role) credits.push({ name, role }); } } } } } catch (e) { } if (credits.length) tracks.push({ index: +num, title, credits }); done++; if (onProgress) { try { onProgress(done, songs.length); } catch (e) { } } } return { album: albumName, tracks }; } // src/sources/ytmusic.js var YTM_API = "https://music.youtube.com/youtubei/v1/"; var YTM_CLIENT = { clientName: "WEB_REMIX", clientVersion: "1.20250101.01.00", hl: "en", gl: "US" }; var YTM_ALBUM_RE = /(?:^|\/\/)(?:(?:music|www|m)\.)?youtube\.com\/(?:playlist\?(?:[^#]*&)?list=(OLAK5uy_[\w-]+)|browse\/(MPREb_[\w-]+))/i; function parseYtmAlbumUrl(url) { const m = YTM_ALBUM_RE.exec(url || ""); return m ? m[1] ? { list: m[1] } : { album: m[2] } : null; } var YTM_ROLE_BRIDGE = { "Written by": "Written-By", "Produced by": "Producer" }; var YTM_SKIP = { "Performed by": "the track artist credit", "Music metadata provided by": "the label or distributor" }; function ytmPositions(count, mediumSizes) { const sizes = (mediumSizes || []).filter((n) => n > 0); const plain = Array.from({ length: count }, (_, i) => String(i + 1)); if (sizes.length <= 1) return { positions: plain, multiMedium: false, mismatch: false }; const total = sizes.reduce((a, b) => a + b, 0); if (total !== count) return { positions: plain, multiMedium: true, mismatch: true }; const positions = []; sizes.forEach((n, m) => { for (let t = 1; t <= n; t++) positions.push(`${m + 1}-${t}`); }); return { positions, multiMedium: true, mismatch: false }; } function ytmToEngine(songs, mediumSizes) { const tracklistRels = []; const tracklist = []; const skipped = []; const list = songs || []; const { positions, multiMedium, mismatch } = ytmPositions(list.length, mediumSizes); list.forEach((s, i) => { const track = { position: positions[i], title: s.title || "", type_: "track" }; tracklist.push(track); for (const [section, names] of Object.entries(s.sections || {})) { const named = (names || []).map((n) => String(n || "").trim()).filter(Boolean); if (!named.length) continue; if (YTM_SKIP[section]) { skipped.push(`track ${track.position}: ${section} \u2014 ${named.join(", ")} (${YTM_SKIP[section]})`); continue; } const role = YTM_ROLE_BRIDGE[section]; if (!role) { skipped.push(`track ${track.position}: ${section} \u2014 ${named.join(", ")} (unmapped section)`); continue; } for (const name of named) { const rels = getArtistRoles({ name, anv: "", role, resource_url: "" }); if (!rels || !rels.length) { skipped.push(`track ${track.position}: ${section} \u2014 ${name} (unmapped)`); continue; } for (const rel of rels) tracklistRels.push({ ...rel, artist: rel.artist || { name, anv: "", resource_url: "" }, track }); } } }); return { tracklistRels, tracklist, skipped, multiMedium, mismatch }; } function ytmPost(endpoint, body) { const what = body.browseId || endpoint; return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("GM_xmlhttpRequest unavailable")); return; } const t0 = Date.now(); GM_xmlhttpRequest({ method: "POST", url: `${YTM_API}${endpoint}?prettyPrint=false`, anonymous: true, timeout: 2e4, headers: { "Content-Type": "application/json" }, data: JSON.stringify({ context: { client: YTM_CLIENT }, ...body }), onload: (r) => { const text = r.responseText || ""; let json = null; try { json = JSON.parse(text); } catch (e) { } logDebug(`YouTube Music: ${endpoint} ${what} \u2192 HTTP ${r.status}, ${text.length}b in ${Date.now() - t0}ms`); if (r.status !== 200 || !json) { reject(new Error(`YouTube Music ${endpoint} ${what} \u2192 HTTP ${r.status}${r.status === 200 ? ", not JSON" : ""} \u2014 YouTube Music's API may have changed`)); return; } resolve(json); }, onerror: () => reject(new Error(`YouTube Music ${endpoint} ${what}: network error`)), ontimeout: () => reject(new Error(`YouTube Music ${endpoint} ${what}: timed out`)) }); }); } var ytmText = (t) => t && t.runs ? t.runs.map((x) => x.text).join("") : t && t.simpleText || ""; function ytmWalk(o, fn) { if (!o || typeof o !== "object") return; fn(o); for (const k in o) ytmWalk(o[k], fn); } var videoType = (w) => w && w.watchEndpointMusicSupportedConfigs && w.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig && w.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType; function ytmRows(json) { const rows = []; ytmWalk(json, (o) => { const r = o.musicResponsiveListItemRenderer; if (!r) return; let w = null; ytmWalk(r, (x) => { if (!w && x.watchEndpoint && x.watchEndpoint.videoId) w = x.watchEndpoint; }); const id = r.playlistItemData && r.playlistItemData.videoId || w && w.videoId; if (!id) return; const col = r.flexColumns && r.flexColumns[0] && r.flexColumns[0].musicResponsiveListItemFlexColumnRenderer; rows.push({ title: ytmText(col && col.text), videoId: id, type: videoType(w) || null }); }); return rows; } function ytmCreditSections(json) { const out = {}; ytmWalk(json, (o) => { const s = o.dismissableDialogContentSectionRenderer; if (s) out[ytmText(s.title)] = (s.subtitle && s.subtitle.runs || []).map((r) => r.text.trim()).filter(Boolean); }); return out; } async function fetchYtmCredits(url, onProgress) { const parsed = parseYtmAlbumUrl(url); if (!parsed) throw new Error(`Not a YouTube Music album link: ${url}`); let list = parsed.list, album = ""; if (!list) { const a = await ytmPost("browse", { browseId: parsed.album }); const canon = a.microformat && a.microformat.microformatDataRenderer && a.microformat.microformatDataRenderer.urlCanonical || ""; list = (canon.match(/[?&]list=(OLAK5uy_[\w-]+)/) || JSON.stringify(a).match(/"(OLAK5uy_[\w-]+)"/) || [])[1]; let h = null; ytmWalk(a, (o) => { if (!h && o.musicResponsiveHeaderRenderer) h = o.musicResponsiveHeaderRenderer; }); album = h ? ytmText(h.title) : ""; log.info(`YouTube Music: album ${parsed.album} "${album}" \u2192 playlist ${list || "(none found)"}`); if (!list) throw new Error(`YouTube Music: album ${parsed.album} names no playlist \u2014 its page may have changed`); } const pl = await ytmPost("browse", { browseId: "VL" + list }); if (!album) { let h = null; ytmWalk(pl, (o) => { if (!h && (o.musicResponsiveHeaderRenderer || o.musicDetailHeaderRenderer)) h = o.musicResponsiveHeaderRenderer || o.musicDetailHeaderRenderer; }); album = h ? ytmText(h.title) : ""; } if (/"continuations?"|"continuationItemRenderer"/.test(JSON.stringify(pl))) log.warn(`YouTube Music: playlist ${list} has more songs than its first page \u2014 only those are read`); const rows = ytmRows(pl); const types = rows.reduce((m, r) => { const k = (r.type || "?").replace("MUSIC_VIDEO_TYPE_", ""); m[k] = (m[k] || 0) + 1; return m; }, {}); log.info(`YouTube Music: playlist ${list} "${album}" \u2014 ${rows.length} row(s) (${Object.entries(types).map(([k, n]) => `${n} ${k}`).join(", ") || "none"})`); const songs = []; let done = 0; for (const r of rows) { let sections = {}; if (r.type && r.type !== "MUSIC_VIDEO_TYPE_ATV") logDebug(`YouTube Music: row ${done + 1} "${r.title}" is a ${r.type}, not a song \u2014 it has no credits`); else { try { sections = ytmCreditSections(await ytmPost("browse", { browseId: "MPTC" + r.videoId })); } catch (e) { log.warn(`YouTube Music: credits of "${r.title}" (${r.videoId}) failed \u2014 ${e.message}`); } } logDebug(`YouTube Music: ${done + 1}. "${r.title}" (${r.videoId}) \u2014 ${Object.keys(sections).map((k) => `${k}: ${sections[k].join(", ")}`).join(" \xB7 ") || "no credits"}`); songs.push({ title: r.title, videoId: r.videoId, sections }); done++; if (onProgress) { try { onProgress(done, rows.length); } catch (e) { } } } return { album, list, songs }; } // src/sources/deezer.js function decodeEntities2(s) { return String(s).replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n)).replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&/g, "&").replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ").replace(/'/g, "'").replace(/'/g, "'"); } var DEEZER_LABEL_MAP = { "Composers": { rel: "composer" }, "Composer": { rel: "composer" }, "Authors": { rel: "lyricist" }, "Author": { rel: "lyricist" }, "Writers": { rel: "writer" }, "Writer": { rel: "writer" }, "Producers": { rel: "producer" }, "Producer": { rel: "producer" } }; var DEEZER_ALBUM_RE = /^(?:https?:)?\/\/(?:www\.)?deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/i; function parseDeezerAlbumUrl(url) { const m = DEEZER_ALBUM_RE.exec(url || ""); return m ? { id: m[1], pageUrl: `https://www.deezer.com/us/album/${m[1]}` } : null; } var DEEZER_NO_INFO_RE = /^\s*no\s*info\s*$/i; function parseDeezerCreditLine(line) { const text = decodeEntities2(String(line)).trim(); const byName = /* @__PURE__ */ new Map(); for (const group of text.split(" / ")) { const m = /^([^:]+):\s*(.*)$/.exec(group.trim()); if (!m) continue; const plan = DEEZER_LABEL_MAP[m[1].trim()]; if (!plan) continue; for (const raw of m[2].split(/\s*,\s*|\s+-\s+/)) { const name = raw.trim(); if (!name || DEEZER_NO_INFO_RE.test(name)) continue; if (!byName.has(name)) byName.set(name, []); const roles = byName.get(name); if (!roles.includes(plan.rel)) roles.push(plan.rel); } } return [...byName].map(([name, roles]) => ({ name, roles })); } function extractDeezerCredits(html) { const posByTrack = /* @__PURE__ */ new Map(); const posRe = /itemid="\/[a-z]{2}\/track\/(\d+)"[\s\S]{0,600}?data-target="position">\s*(\d+)/gi; let pm; while ((pm = posRe.exec(html)) !== null) { const id = pm[1]; if (!posByTrack.has(id)) posByTrack.set(id, parseInt(pm[2], 10)); } const out = []; const credRe = /naboo_datagrid_contributors_(\d+)"[\s\S]{0,400}?data-target="contributors">([^<]*)</gi; let cm; while ((cm = credRe.exec(html)) !== null) { const id = cm[1]; const pos = posByTrack.get(id); if (!pos) continue; const credits = parseDeezerCreditLine(cm[2]); if (credits.length) out.push({ index: pos, credits }); } return out; } function extractDeezerAlbumInfo(html) { const og = html.match(/<meta property="og:title" content="([^"]*)"/)?.[1] || ""; return decodeEntities2(og); } function deezerToEngine(parsedTracks) { const tracklistRels = []; const tracklist = []; const skipped = []; const { positions, multiVolume } = assignVolumePositions(parsedTracks, (t) => t.index); parsedTracks.forEach((t, i) => { const track = { position: positions[i], title: "", type_: "track" }; tracklist.push(track); for (const credit of t.credits) { for (const rel of credit.roles) { tracklistRels.push({ linkType: rel, entityType: "artist", attributes: [], artist: { name: credit.name, anv: "", resource_url: "" }, track }); } } }); return { tracklistRels, tracklist, skipped, multiVolume }; } function fetchDeezerAlbumPage(pageUrl) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("GM_xmlhttpRequest unavailable")); return; } GM_xmlhttpRequest({ method: "GET", url: pageUrl, headers: { "Accept": "text/html,application/xhtml+xml", "Accept-Language": "en-US,en;q=0.8" }, timeout: 2e4, onload: (r) => r.status >= 200 && r.status < 400 && r.responseText ? resolve(r.responseText) : reject(new Error(`Deezer page returned ${r.status}`)), onerror: () => reject(new Error("Deezer page fetch failed (network)")), ontimeout: () => reject(new Error("Deezer page fetch timed out")) }); }); } // src/consolidate.js var fold = (s) => String(s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/\s+/g, " ").trim(); var entityKeyOf = (a) => a && a.resource_url ? a.resource_url : `_nourl_${a && (a.name || a.id) || ""}`; var attrsSig = (rel) => JSON.stringify( (rel.attributes || []).map((x) => x && typeof x === "object" && x._type ? `${x._type}:${x.value}` : String(x)).sort() ); var relKeyOf = (rel) => [ entityKeyOf(rel.artist), rel.linkType || "", attrsSig(rel), rel.track ? String(rel.track.position != null ? rel.track.position : "") : "" ].join("\x01"); function mergeHarvests(harvests) { const mkDedup = () => { const seen = /* @__PURE__ */ new Map(), order = []; return { add(rel, source) { const k = relKeyOf(rel); if (!seen.has(k)) { seen.set(k, { rel, sources: /* @__PURE__ */ new Set() }); order.push(k); } seen.get(k).sources.add(source); }, drain(relSrc2, entitySrc2) { const arr = []; for (const k of order) { const e = seen.get(k); arr.push(e.rel); relSrc2.set(k, [...e.sources]); const ek = entityKeyOf(e.rel.artist); if (!entitySrc2.has(ek)) entitySrc2.set(ek, /* @__PURE__ */ new Set()); e.sources.forEach((s) => entitySrc2.get(ek).add(s)); } return arr; } }; }; const relSrc = /* @__PURE__ */ new Map(), entitySrc = /* @__PURE__ */ new Map(); const arD = mkDedup(), trD = mkDedup(); const companies = [], seenCo = /* @__PURE__ */ new Set(); const tracklist = [], seenPos = /* @__PURE__ */ new Set(); let processTracklist = false; for (const h of harvests || []) { if (!h) continue; const src = h.sourceName || "Source"; if (h.processTracklist) processTracklist = true; (h.artistRoles || []).forEach((r) => arD.add(r, src)); (h.tracklistRels || []).forEach((r) => trD.add(r, src)); (h.companies || []).forEach((c) => { const ck = c.resource_url || `co:${fold(c.entity_type_name)}|${fold(c.name)}`; if (ck && !seenCo.has(ck)) { seenCo.add(ck); companies.push(c); } const ek = entityKeyOf(c); if (!entitySrc.has(ek)) entitySrc.set(ek, /* @__PURE__ */ new Set()); entitySrc.get(ek).add(src); }); (h.tracklist || []).forEach((t) => { const pos = String(t && t.position != null ? t.position : ""); const key = pos || `#${tracklist.length}`; if (!seenPos.has(key)) { seenPos.add(key); tracklist.push(t); } }); } const artistRoles = arD.drain(relSrc, entitySrc); const tracklistRels = trD.drain(relSrc, entitySrc); const entitySources = /* @__PURE__ */ new Map(); entitySrc.forEach((set, k) => entitySources.set(k, [...set])); return { companies, artistRoles, tracklistRels, tracklist, processTracklist, relSrc, entitySources }; } var _resultKey = (r) => r && r.entity && (r.entity.resource_url || r.entity._syntheticKey) || `_nourl_${r && (r.entity && r.entity.name || r.displayName) || ""}`; var _resultName = (r) => r && (r.entity && r.entity.name || r.displayName) || ""; var _resultKind = (r) => r && (r.entityType || r.entity && r.entity.entityType) || "artist"; var _roleKey = (ro) => [ro.linkType, ro.displayLabel, ro.trackPos, ro.trackTitle].join(""); var boundedLev = (a, b, max) => { if (Math.abs(a.length - b.length) > max) return -1; const dp = Array.from({ length: a.length + 1 }, (_, i) => i); for (let j = 1; j <= b.length; j++) { let prev = dp[0]; dp[0] = j; let rowMin = dp[0]; for (let i = 1; i <= a.length; i++) { const tmp = dp[i]; dp[i] = Math.min(dp[i] + 1, dp[i - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = tmp; if (dp[i] < rowMin) rowMin = dp[i]; } if (rowMin > max) return -1; } return dp[a.length] <= max ? dp[a.length] : -1; }; var fuzzyMax = (len) => len <= 6 ? 0 : len <= 12 ? 1 : 2; var stripInitials = (fn) => fn.split(" ").filter((t) => !/^[a-z]\.?$/.test(t)).join(" "); function mergeResolvedResults(allResults, entitySources) { const rows = (allResults || []).filter(Boolean); const nameMbids = /* @__PURE__ */ new Map(); const nameMbidsStripped = /* @__PURE__ */ new Map(); for (const r of rows) { if (r.type !== "resolved" || !r.mbUrl) continue; const fn = fold(_resultName(r)); if (!fn) continue; const kn = _resultKind(r) + "|" + fn; if (!nameMbids.has(kn)) nameMbids.set(kn, /* @__PURE__ */ new Set()); nameMbids.get(kn).add(r.mbUrl); const sn = _resultKind(r) + "|" + stripInitials(fn); if (!nameMbidsStripped.has(sn)) nameMbidsStripped.set(sn, /* @__PURE__ */ new Set()); nameMbidsStripped.get(sn).add(r.mbUrl); } const conflictNames = /* @__PURE__ */ new Set(); nameMbids.forEach((set, kn) => { if (set.size > 1) conflictNames.add(kn); }); const uniqResolved = []; nameMbids.forEach((set, kn) => { if (set.size === 1) uniqResolved.push([kn, [...set][0]]); }); const fuzzyResolvedMatch = (kfn) => { const [kind, fn] = [kfn.slice(0, kfn.indexOf("|")), kfn.slice(kfn.indexOf("|") + 1)]; let hit = null; for (const [kn, url] of uniqResolved) { if (!kn.startsWith(kind + "|")) continue; const nm = kn.slice(kind.length + 1); if (boundedLev(fn, nm, fuzzyMax(Math.max(fn.length, nm.length))) < 0) continue; if (hit && hit !== url) return null; hit = url; } return hit; }; const keyFor = (r) => { const fn = fold(_resultName(r)); const kn = fn ? _resultKind(r) + "|" + fn : ""; if (kn && conflictNames.has(kn)) return "cf:" + kn; if (r.type === "resolved" && r.mbUrl) return "mb:" + r.mbUrl; if (!fn) return null; const set = nameMbids.get(kn); if (set && set.size === 1) return "mb:" + [...set][0]; if (!set) { const stripped = nameMbidsStripped.get(_resultKind(r) + "|" + stripInitials(fn)); if (stripped && stripped.size === 1) return "mb:" + [...stripped][0]; } const fuzzy = fuzzyResolvedMatch(kn); if (fuzzy) return "mb:" + fuzzy; return "nm:" + kn; }; const byKey = /* @__PURE__ */ new Map(), mergeMap = /* @__PURE__ */ new Map(), out = []; for (const r of rows) { const gk = keyFor(r), rk = _resultKey(r); if (!gk || !byKey.has(gk)) { if (gk) { byKey.set(gk, r); mergeMap.set(rk, [rk]); if (gk.startsWith("cf:")) r._conflicts = [r.mbUrl ? { mbUrl: r.mbUrl, mbName: r.mbName, mbDisambig: r.mbDisambig } : null]; } out.push(r); continue; } const rep = byKey.get(gk), repKey = _resultKey(rep); if (gk.startsWith("cf:")) { if (r.mbUrl) (rep._conflicts = rep._conflicts || []).push({ mbUrl: r.mbUrl, mbName: r.mbName, mbDisambig: r.mbDisambig }); } else if ((rep.type !== "resolved" || !rep.mbUrl) && r.type === "resolved" && r.mbUrl) { rep.type = "resolved"; rep.mbUrl = r.mbUrl; rep.mbName = r.mbName; rep.mbDisambig = r.mbDisambig; rep.entityType = r.entityType || rep.entityType; rep.logEntry = r.logEntry || rep.logEntry; } const seen = new Set((rep._roles || []).map(_roleKey)); rep._roles = (rep._roles || []).concat((r._roles || []).filter((ro) => { const k = _roleKey(ro); if (seen.has(k)) return false; seen.add(k); return true; })); if (entitySources) { const u = new Set(entitySources.get(repKey) || []); (entitySources.get(rk) || []).forEach((s) => u.add(s)); entitySources.set(repKey, [...u]); } mergeMap.get(repKey).push(rk); } byKey.forEach((rep, gk) => { if (!gk.startsWith("cf:")) return; const cands = [], seenIds = /* @__PURE__ */ new Set(); for (const c of rep._conflicts || []) { const m = c && c.mbUrl && c.mbUrl.match(/\/(?:artist|label|place)\/([^/?#]+)/i); if (!m || seenIds.has(m[1])) continue; seenIds.add(m[1]); cands.push({ id: m[1], name: c.mbName || _resultName(rep), disambiguation: c.mbDisambig || "" }); } rep.nameMatches = cands.concat((rep.nameMatches || []).filter((nm) => nm && nm.id && !seenIds.has(nm.id))); rep.type = "attention"; rep.mbUrl = null; rep.mbName = null; rep.mbDisambig = ""; rep.ambiguityReason = "sources link this name to different MB artists"; delete rep._conflicts; }); for (const rep of out) { const keys = mergeMap.get(_resultKey(rep)); rep._mergeUrls = [...new Set((keys || []).filter((k) => /^https?:\/\//i.test(k) && !/tidal\.com\/_(?:publisher|company)\//i.test(k)).map((k) => k.replace(/^https?:\/\/api\.discogs\.com\/(\w+?)s\/(\d+).*$/i, "https://www.discogs.com/$1/$2")))]; } return { results: out, mergeMap }; } // src/ui-bar.js var _logs2; var _summary; var _discogsJson = null; var _tidalJson = null; var _maJson = null; var _qobuzJson = null; var _deezerJson = null; var _appleJson = null; var _ytmJson = null; var _consolidatedJson = null; var ST_ICONS = { "musicbrainz": { "color": "#eb743b", "svg": '<svg viewBox="0.75 0.75 28.5 28.5" xmlns="http://www.w3.org/2000/svg"><g transform="translate(1.5)"><path d="m13 1-12 7v14l12 7z" fill="#ba478f"/><path d="m14 1 12 7v14l-12 7z" fill="#eb743b"/></g></svg>' }, "discogs": { "color": "#333333", "svg": '<svg viewBox="71 71 882 882" xmlns="http://www.w3.org/2000/svg"><g transform="translate(512 512) scale(0.86) translate(-512 -512)"><circle cx="512" cy="512" r="496" fill="#333" stroke="#9a9a9a" stroke-width="32"/><path fill="#fff" d="M439.84 511.58A72.58 72.58 0 0 1 512.41 439 72.54 72.54 0 0 1 585 511.58a72.56 72.56 0 0 1-72.57 72.56 72.56 72.56 0 0 1-72.57-72.56zm3.18 0A69.48 69.48 0 0 0 512.41 581a69.4 69.4 0 0 0 69.4-69.38 69.49 69.49 0 0 0-69.4-69.43A69.44 69.44 0 0 0 443 511.58zm69.42-11.44a11.43 11.43 0 1 0 11.47 11.45 11.45 11.45 0 0 0-11.48-11.45zm-131.08 11.43a130.68 130.68 0 0 0 40.3 94.43l24.68-26.69.33.3a94.59 94.59 0 0 1 113.08-149.95l17.51-31.95a130.23 130.23 0 0 0-64.82-17.22c-72.27.01-131.08 58.81-131.08 131.08zm225.73 0a94.6 94.6 0 0 1-138.64 83.79l-17.83 31.74a130.26 130.26 0 0 0 61.82 15.53c72.28 0 131.08-58.8 131.08-131.08a130.63 130.63 0 0 0-37.73-91.9L581 446.39a94.3 94.3 0 0 1 26.1 65.2zm-267.34 0a172.17 172.17 0 0 0 53.68 125l25-27.07a135.38 135.38 0 0 1-41.82-97.89c0-74.88 60.92-135.8 135.8-135.8a134.92 134.92 0 0 1 67.08 17.8l17.73-32.34a171.57 171.57 0 0 0-84.81-22.35c-95.19-.03-172.66 77.43-172.66 172.65zm308.49 0c0 74.88-60.92 135.8-135.8 135.8a135 135 0 0 1-64.14-16.14l-18.07 32.17a171.62 171.62 0 0 0 82.21 20.86c95.22 0 172.69-77.47 172.69-172.69a172.15 172.15 0 0 0-51-122.4l-25.12 27a135.35 135.35 0 0 1 39.23 95.4zm41.61 0c0 97.83-79.58 177.43-177.41 177.43a176.32 176.32 0 0 1-84.52-21.46l-18.18 32.36a213.21 213.21 0 0 0 102.7 26.23C630.74 726.11 727 629.87 727 511.57a213.87 213.87 0 0 0-64.38-153l-25.26 27.18a176.85 176.85 0 0 1 52.49 125.82zm-392 0A213.9 213.9 0 0 0 365 667.24L390.23 640A176.88 176.88 0 0 1 335 511.57c0-97.82 79.59-177.41 177.41-177.41a176.26 176.26 0 0 1 87.08 22.93l17.84-32.55A213.14 213.14 0 0 0 512.44 297c-118.3 0-214.54 96.28-214.54 214.57zm392.55-183-24.64 26.49a218.57 218.57 0 0 1 65.94 156.51c0 120.9-98.36 219.26-219.26 219.26a217.9 217.9 0 0 1-105-26.84l-18.24 32.47A255.43 255.43 0 0 0 512 768c141.39 0 256-114.64 256-256a255.23 255.23 0 0 0-77.55-183.41zm-397.27 183c0-120.9 98.36-219.26 219.26-219.26a217.84 217.84 0 0 1 107.19 28.09L637 288.65A254.46 254.46 0 0 0 516.12 256H512c-140.54.22-254.42 113.26-256 253.5v2.5a255.69 255.69 0 0 0 80.51 186.08l25.31-27.36a218.61 218.61 0 0 1-68.64-159.15z"/></g></svg>' }, "spotify": { "color": "#1DB954", "svg": '<svg viewBox="1.25 1.375 21.25 21.25" fill="#1DB954"><path transform="translate(12 12) scale(.875) translate(-12 -12)" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.42 1.56-.299.421-1.02.599-1.559.3z"/></svg>' }, "apple": { "color": "#FA243C", "svg": '<svg viewBox="1.625 1.25 20 20" fill="#FA243C"><path d="M17.05 12.04c-.03-2.5 2.04-3.7 2.13-3.76-1.16-1.7-2.97-1.93-3.61-1.96-1.54-.16-3 .9-3.78.9-.78 0-1.97-.88-3.24-.86-1.67.03-3.21.97-4.07 2.46-1.73 3.01-.44 7.47 1.24 9.92.82 1.2 1.8 2.54 3.08 2.49 1.24-.05 1.71-.8 3.21-.8 1.5 0 1.92.8 3.23.77 1.33-.02 2.18-1.22 3-2.42.94-1.39 1.33-2.73 1.35-2.8-.03-.01-2.59-.99-2.62-3.93zM14.6 4.59c.68-.83 1.14-1.97 1.01-3.11-.98.04-2.17.65-2.87 1.47-.63.73-1.18 1.9-1.03 3.02 1.09.08 2.21-.55 2.89-1.38z"/></svg>' }, "deezer": { "color": "#A238FF", "svg": '<svg viewBox="3 3 18 18"><path transform="translate(12 12) scale(.74) translate(-12 -12)" d="M4 2h6v2h-6zM14 2h6v2h-6zM2 4h20v2h-20zM0 6h24v2h-24zM0 8h24v2h-24zM0 10h24v2h-24zM2 12h20v2h-20zM4 14h16v2h-16zM6 16h12v2h-12zM8 18h8v2h-8zM10 20h4v2h-4z" fill="#A238FF"/></svg>' }, "tidal": { "color": "#000000", "svg": '<svg viewBox="3 3 18 18"><path d="M6 6l3 3-3 3-3-3zM12 6l3 3-3 3-3-3zM18 6l3 3-3 3-3-3zM12 12l3 3-3 3-3-3z" style="fill:var(--mbu-text,currentColor)"/></svg>' }, "qobuz": { "color": "#0070ef", "svg": '<svg viewBox="1.75 1.75 20.5 20.5"><circle cx="12" cy="12" r="10" fill="#0070ef"/><circle cx="12" cy="12" r="5" fill="none" stroke="#fff" stroke-width="2.2"/><path d="M14.5 14.5 19 19" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/></svg>' }, "beatport": { "color": "#01FF95", "svg": '<svg viewBox="0.75 0.75 22.5 22.5"><circle cx="12" cy="12" r="11" fill="#000"/><g transform="translate(12 12) scale(0.84) translate(-12 -12)" fill="none" stroke="#01FF95" stroke-width="2.5"><path d="M10.9 3V8.3c0 1.2-.4 1.9-1.1 2.6L5.6 15.1"/><circle cx="13.9" cy="15.8" r="4.05" stroke-width="2.35"/></g></svg>' }, "bandcamp": { "color": "#629AA9", "svg": '<svg viewBox="2.25 2.25 19.5 19.5" fill="#629AA9"><path transform="translate(12 12) scale(.8) translate(-12 -12)" d="M0 18.75l7.437-13.5H24l-7.438 13.5z"/></svg>' }, "volumo": { "color": "#7c4dff", "svg": '<svg viewBox="1.75 1.75 20.5 20.5"><circle cx="12" cy="12" r="10" fill="#7c4dff"/><path d="M7 8h2.2l2.8 6 2.8-6H17l-4 9h-2z" fill="#fff"/></svg>' }, "hdtracks": { "color": "#e63329", "svg": '<svg viewBox="1.75 1.75 20.5 20.5"><circle cx="12" cy="12" r="10" fill="#e63329"/><path d="M5 7.5h1.7v3.1h2.6V7.5H11v8H9.3v-3.2H6.7v3.2H5zm7.2 0h2.9c2 0 3.4 1.6 3.4 4s-1.4 4-3.4 4h-2.9zm1.7 1.5v5h1.1c1.1 0 1.8-1 1.8-2.5s-.7-2.5-1.8-2.5z" fill="#fff"/></svg>' }, "soundcloud": { "color": "#ff5500", "svg": '<svg viewBox="1.75 1.75 20.5 20.5"><circle cx="12" cy="12" r="10" fill="#ff5500"/><g fill="#fff"><rect x="6" y="12" width="1.4" height="4" rx=".6"/><rect x="8.5" y="10" width="1.4" height="6" rx=".6"/><rect x="11" y="8.5" width="1.4" height="7.5" rx=".6"/><rect x="13.5" y="10.5" width="1.4" height="5.5" rx=".6"/><rect x="16" y="11.5" width="1.4" height="4.5" rx=".6"/></g></svg>' }, "audiomack": { "color": "#FFA200", "svg": '<svg viewBox="1.75 1.75 20.5 20.5"><circle cx="12" cy="12" r="10" fill="#FFA200"/><path d="M5 13.5l2-2 1.6 2.4 2.2-5.4 2.4 6.6 2.2-4 1.6 2.4H19" fill="none" stroke="#fff" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/></svg>' }, "sevendigital": { "color": "#07606E", "svg": '<svg viewBox="1.75 1.75 20.5 20.5"><circle cx="12" cy="12" r="10" fill="#07606E"/><path d="M7.8 6.8h8.4v1.9l-4.5 8.9H9.4l4.4-8.7h-6z" fill="#fff"/></svg>' }, "ytmusic": { "color": "#FF0000", "svg": '<svg viewBox="1.75 1.75 20.5 20.5"><circle cx="12" cy="12" r="10" fill="#FF0000"/><circle cx="12" cy="12" r="5.6" fill="none" stroke="#fff" stroke-width="1.4"/><path d="M10.4 9.5v5l4.2-2.5z" fill="#fff"/></svg>' }, "amazonmusic": { "color": "#25D1DA", "svg": '<svg viewBox="1.75 1.75 20.5 20.5"><circle cx="12" cy="12" r="10" fill="#25D1DA"/><path d="M5.8 11.2c3.5 3.2 8.9 3.5 12.4.9" fill="none" stroke="#0F1111" stroke-width="1.9" stroke-linecap="round"/><path d="M15.5 10.7l3 1.3-.9 3.1" fill="none" stroke="#0F1111" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>' }, "soundexchange": { "color": "#6f42c1", "svg": '<svg viewBox="1.75 1.75 20.5 20.5"><circle cx="12" cy="12" r="10" fill="#6f42c1"/><path d="M6.5 12h1.3l1-3 1.6 6 1.6-9 1.6 12 1.4-6h1.5" fill="none" stroke="#fff" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"/></svg>' }, "globe": { "color": "#6f7d75", "svg": '<svg viewBox="2 2 20 20" fill="none" stroke="#6f7d75" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>' } }; function stIcon(name, size) { var i = ST_ICONS[name]; if (!i) return ""; size = size || 16; return i.svg.replace(/<svg\b([^>]*)>/, function(m, a) { a = a.replace(/\s(?:width|height)="[^"]*"/g, ""); var ns = /\bxmlns=/.test(a) ? "" : ' xmlns="http://www.w3.org/2000/svg"'; return "<svg" + a + ns + ' width="' + size + '" height="' + size + '">'; }); } var SRC_ICON = { Discogs: stIcon("discogs", 16), Tidal: stIcon("tidal", 16), Qobuz: stIcon("qobuz", 16), Deezer: stIcon("deezer", 16), Apple: stIcon("apple", 16), "YouTube Music": stIcon("ytmusic", 16), // #648 — the All review's per-entity source badge "Metal Archives": '<img src="data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAQAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw+ODwAAbmwAAGe3AAByzAAAfswQEI+9DAyKeBgYlxUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2ZgAAeO0AAG3/AAAk/wAAAP8AAAD/AAAk/yUlpP8AADH7AAAA/wAAAP8AAACWAAAAAAAAAAAAAAAAAAB+jQAAaP8AAHH/AABr/wAAAP9wfpf/cH6X/wAAAP8AAHr/AAAA/3B+l/9wfpf/AAAA/wAAAAAAAAAAAAB+ZgAAWP8BAYD/DAyK/wAAev8AAAD/fYyo/32MqP8AAAD/AAAA/wAAAP99jKj/fYyo/wAAAP8AAAAAAQGAEhMTku0AAGz/AABk/wMDgv8AAGv/AAAt/wAAAP+LnLv/i5y7/4ucu/+LnLv/i5y7/4ucu/8AAAD/AAB/JAAAZWkICIf/BgaF/wAAef8AAEz/AABx/wEBgP8EBDj/AAAA/5qt0P8AAAD/AAAA/5qt0P+ardD/AAAA/wAAbocAAHKxAABe/wAAc/8REZD/AABl/wwMiv8VFZT/AABv/wAALf8AAAD/qL3j/wAAAP+oveP/qL3j/wAAAP8AAHHPAAAe7AAAAP8AAAD/AAAy/wAAev8NDUH/AAAA/wAAAP8BATb/AAAh/wAAAP+1y/T/tcv0/7XL9P8AAAD/AQGA+QAAAP9ygJr/coCa/wAAAP8CAoH/AAAA/3KAmv9ygJr/AAAA/wAAfP8GBjr/AAAA/73U//+91P//AAAA/wAAcvYAAAD/gZGu/4GRrv8AAAD/AAAA/wAAAP+Bka7/gZGu/wAAAP8AAHj/AABu/wAAM/8AAAD/AAAA/wEBNv8AAHPYAAAA/5Olxv+Tpcb/AAAA/5Olxv8AAAD/k6XG/5Olxv8AAAD/Dw+O/wAAaP8AAHn/AABe/wAAWv8AAGv/AAB0lgAAAP+kuN3/pLjd/wAAAP+kuN3/AAAA/6S43f+kuN3/AAAA/wAAW/8AAHL/AAB//wICgf8AAHH/AABy/AAAbTAAAAD/s8nx/7PJ8f+zyfH/AAAA/7PJ8f+zyfH/s8nx/wAAAP8AAH7/AAB//wAAe/8AAGv/AAB7/wAAepwAAAAAAAAA/73U//+91P//AAAA/wAAMP8AAAD/vdT//73U//8AAAD/BweG/wkJiP8AAHX/AABt/wAAfMMcHJsGAAAAAAAAAJYAAAD/AAAA/wAAFbsWFpXwAAAs/wAAAP8AAAD/CAg8/xcXlv8AAF7/AAB49gAAZHUREZADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABhGw8PjoQAAGrJAQGA+QkJiP8TE5LMAAB2jQ0NjCcAAAAAAAAAAAAAAAAAAAAA8A8AAOABAADAAQAAgAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAwAA8A8AAA==" width="16" height="16" alt="Metal Archives" style="display:inline-block;vertical-align:middle">', // #453 real MA favicon Titles: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 6h16M4 11h16M4 16h10"/></svg>' }; var srcIconByUrl = (url) => SRC_ICON[sourceNameForUrl(url)] || ""; function insertDiscogsBar(discogsUrl, sources = {}, meta = {}) { const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; function mbuHelpHref(name) { return "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/" + name + "/README.md"; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener" title="open the README in a new tab">' + (label || "? Help") + "</a>"; } function mbuHelpEl(name, label) { var a = document.createElement("a"); a.className = "mbu-help"; a.href = mbuHelpHref(name); a.target = "_blank"; a.rel = "noopener"; a.title = "open the README in a new tab"; a.textContent = label || "? Help"; return a; } var MBU_CFG_ICON = "\u2699\uFE0E"; var _mbuTT; function mbuHtml(s) { if (_mbuTT === void 0) { _mbuTT = null; try { var tt = typeof window !== "undefined" && window.trustedTypes || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy("mbu-" + Math.random().toString(36).slice(2, 8), { createHTML: function(x) { return x; } }); } catch (e) { } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } function mbuStartupInfo(name) { var g = null; try { g = typeof GM_info !== "undefined" && GM_info || null; } catch (e) { } var s = g && g.script || {}, p = g && g.platform || {}; var host = String(s.name || "").replace(/\*$/, ""), ver = s.version || "?"; var line = !name ? (host || "Script") + " v" + ver : host && host !== name ? name + " (" + host + " v" + ver + ")" : name + " v" + ver; if (g) line += " \xB7 " + (g.scriptHandler || "unknown manager") + (g.version ? " " + g.version : ""); if (p.browserName) line += " \xB7 " + p.browserName + (p.browserVersion ? " " + p.browserVersion : "") + (p.os ? " (" + p.os + ")" : ""); else { try { line += " \xB7 " + navigator.userAgent; } catch (e) { } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { var lang = document.documentElement && document.documentElement.lang || (document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1] || "en"; line += " \xB7 MusicBrainz in " + lang; } } catch (e) { } return line; } function mbuClaimVer(v) { return String(v || "").split(".").map(function(n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = typeof GM_info !== "undefined" && GM_info && GM_info.script || {}; var name = String(info.name || label || key), ver = String(info.version || "0"); var mine = (name.slice(-1) === "*" ? "String Theory" : "standalone") + " v" + ver; var root = document.documentElement, attr = "data-mbu-run-" + key, ev = "mbu-claim-" + key, noteKey = "mbu-newer-" + key; var log2 = function(msg) { try { if (typeof mbuLog !== "undefined" && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== "undefined" && typeof mbuToast.log === "function") mbuToast.log("info", msg); else console.info("[" + (label || key) + "] " + msg); } catch (e) { } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || "null"); } catch (e) { } var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function(why) { try { if (root) root.setAttribute(attr + "-off", JSON.stringify({ mine, why })); } catch (e) { } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine, ver, why }) })); } catch (e) { } return false; }; if (held) { var heldVer = root.getAttribute(attr + "-ver") || "0"; if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver, mine, at: Date.now() })); } catch (e) { } return off("newer, from the next page load"); } return off("older or the same"); } if (note) { var watch = function() { setTimeout(function() { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { } try { console.info("[" + (label || key) + "] the newer copy (" + note.mine + ") did not start: this copy runs again from the next page load"); } catch (e) { } }, 3e3); }; if (document.readyState === "complete") watch(); else window.addEventListener("load", watch, { once: true }); return off("older: a newer copy runs"); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + "-ver", ver); } var told = function(o) { log2((label || key) + " is installed twice: " + mine + " runs, " + (o.mine || "another copy") + " is switched off" + (o.why === "newer, from the next page load" ? " for this page (it is newer and runs from the next page load)" : "") + "."); }; document.addEventListener(ev, function(e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { } told(o); }); var before = root && root.getAttribute(attr + "-off"); if (before) setTimeout(function() { var o = {}; try { o = JSON.parse(before); } catch (x) { } told(o); }, 0); return true; } var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? "warn" : /[✓✅]/.test(s) ? "ok" : "info"); try { if (typeof mbuToast.log === "function") mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, "")); } catch (e) { } var el = document.getElementById("mbu-toast"); if (!el) { el = document.createElement("div"); el.id = "mbu-toast"; (document.body || document.documentElement).appendChild(el); } el.className = "mbu-toast-on" + (kind !== "info" ? " mbu-toast-" + kind : "") + (opts.action ? " mbu-toast-act" : ""); el.textContent = s; if (opts.action) { var b = document.createElement("button"); b.type = "button"; b.className = "mbu-toast-btn"; b.textContent = opts.action.label || "OK"; b.onclick = function() { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function() { el.className = ""; }, 900); }; el.appendChild(b); } if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + "px"; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + "px"; el.style.bottom = "auto"; el.style.transform = "none"; } else { el.style.left = ""; el.style.top = ""; el.style.bottom = ""; el.style.transform = ""; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function() { el.className = ""; }, opts.ms || (opts.action ? 12e3 : 2600)); return el; } function mbuCfgHeader(o) { o = o || {}; var esc = function(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, function(c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + "</span>"; html += '<span class="mbu-cfg-name">' + esc(o.name) + "</span>"; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + "</span>"; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? " " + esc(o.logClass) : "") + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : "") + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + "</div>"; } function mbuTestHooks() { try { return typeof window !== "undefined" && window.__mbuTest === true; } catch (e) { return false; } } function mbuShared(key, value) { var k = "mbu." + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? void 0 : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { } return value; } function mbRestackCorner(corner) { var bottom = corner[0] === "b", right = corner[1] === "r"; var row = mbuShared("cornerFlow") === "row"; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')).filter(function(el) { return getComputedStyle(el).display !== "none"; }).sort(function(a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function(el) { var r = el.getBoundingClientRect(); el.style[bottom ? "bottom" : "top"] = (row ? 14 : pos) + "px"; el.style[right ? "right" : "left"] = (row ? pos : 14) + "px"; pos += (row ? r.width : r.height) + 8; }); } function mbuLog(o) { o = o || {}; var max = o.max || 2e4, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ["error", "warn", "info", "debug"]; var group = function(sev) { return sev === "ok" ? "info" : sev; }; var pad = function(n, w) { return String(n).padStart(w || 2, "0"); }; var ts = function(d) { return d ? pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()) + "." + pad(d.getMilliseconds(), 3) : "--:--:--.---"; }; var str = function(v) { if (typeof v === "string") return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return "<" + (v.tagName || "node").toLowerCase() + ">"; try { return typeof v === "object" ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function(s) { return String(s).replace(/[&<>"]/g, function(c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); }; var linkify = function(s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function(m) { var t = (m.match(/[.,;:!?)\]]+$/) || [""])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + "</a>" + t; }); }; var load = o.load || function(k) { try { return GM_getValue(k, void 0); } catch (e) { return void 0; } }; var save = o.save || function(k, v) { try { GM_setValue(k, v); } catch (e) { } }; var state = function() { try { return JSON.parse(load(o.key) || "{}") || {}; } catch (e) { return {}; } }; var remember = function(patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { } }; var dbgOn = null; var debugOn = function() { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function(v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function(w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function(e, d) { if (e.sev === "warn") warn += d; else if (e.sev === "error") error += d; }; var PRE = { info: "", ok: "OK ", warn: "WARN ", error: "ERR ", debug: "DBG " }; var line = function(e) { return ts(e.t) + " " + (PRE[e.sev] || "") + (e.cat ? "[" + e.cat + "] " : "") + (e.pre ? "\n" : "") + e.msg; }; var countOf = function(list) { var c = { warn: 0, error: 0 }; list.forEach(function(e) { if (e.sev === "warn") c.warn++; else if (e.sev === "error") c.error++; }); return c; }; var H = o.history || null, hp = H && (H.prefix || (o.key || "mbu") + ":session:"); var ID_RE = /^\d{14}-\d+$/, sid = "", kept = false, sname = "", savedName = "", seq = 0, pTimer = 0, lastSaved = ""; var store = H && (H.store || { get: function(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }, del: function(k) { try { localStorage.removeItem(k); } catch (e) { } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function() { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { } return out; } }); var keepN = function() { var n = Number(typeof H.keep === "function" ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function() { return (/* @__PURE__ */ new Date()).toISOString().replace(/[^0-9]/g, "").slice(0, 14) + "-" + ++seq; }; var idDate = function(id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ""); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function(k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }).map(function(k) { return k.slice(hp.length); }).sort(); } function fromStored(x, id) { if (typeof x === "string") { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e2) { } return p ? { t: p.t || null, sev: p.sev || "info", cat: p.cat || "", msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: "info", msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || "info", msg: String(x[3] == null ? "" : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function(x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ":name"); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || "" : ""; } catch (e) { n = ""; } } return names[id] = n || ""; } function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function(e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack).concat([[null, "info", 0, "\u2014\u2014\u2014 " + cut + " line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full \u2014\u2014\u2014", 0]]).concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ":name", sname); savedName = sname; } } catch (e) { } } var persist = function() { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ":name"); delete names[id]; } } function prune() { var all = ids().filter(function(id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } function sessions() { return ids().filter(function(id) { return id !== sid; }).reverse().map(function(id) { return { id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function(s) { var d = s.date, when = d ? d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()) : s.id; return when + (s.name ? " \u2014 " + s.name : ""); }; function startSession() { flush(); sid = newId(); kept = false; sname = ""; savedName = ""; lastSaved = ""; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ":name") || ""; lastSaved = store.get(hp + id) || ""; store.set(hp + "current", sid); entries.forEach(function(e) { push(e); }); return true; } function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function(v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function(g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function(v) { return v.el.isConnected; }); views.forEach(function(v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(" "); msg = pre ? msg.replace(/\s+$/, "") : msg.replace(/\s+/g, " ").trim(); if (!msg) return; var e = { t: /* @__PURE__ */ new Date(), sev: sev === "err" ? "error" : sev, msg }; if (e.sev === "debug" && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === "function" ? (function() { try { return o.version(); } catch (e) { return ""; } })() : o.version; var t = (o.name || "Log") + (v ? " v" + v : ""); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += " \u2014 " + s; } catch (e) { } return t; } function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn, error }; var body = list.length ? list.map(line).join("\n") : "(no activity logged)"; if (!past && dropped) body = "(" + dropped + " earlier line" + (dropped === 1 ? "" : "s") + " not kept)\n" + body; var n = c.warn || c.error ? " (" + c.warn + " warning" + (c.warn === 1 ? "" : "s") + ", " + c.error + " error" + (c.error === 1 ? "" : "s") + ")" : ""; var what = past ? "log of " + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : "session log"; var fence = String.fromCharCode(96, 96, 96); return "<details><summary>" + title(!!past) + " \u2014 " + what + n + "</summary>\n\n" + fence + "log\n" + body + "\n" + fence + "\n\n</details>"; } function copy(btn, past) { var md = markdown(past); var done = function(ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? "Copied \u2713" : "Copy failed"; setTimeout(function() { btn.textContent = was; }, 1500); }; var fallback = function() { var ok = false; try { var ta = document.createElement("textarea"); ta.value = md; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); ok = document.execCommand("copy"); ta.remove(); } catch (x) { } done(ok); }; try { navigator.clipboard.writeText(md).then(function() { done(true); }, fallback); } catch (e) { fallback(); } } var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add("mbu-logpop"); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? "<b>" + esc(o.header || "Activity log") + "</b> " : "") + '<span class="mbu-log-badge"></span><input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : "") + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : "") + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label><button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button><button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">\u29C9 Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">\u26F6</button><button class="mbu-logpop-min" type="button" title="Minimize">\u2013</button><button class="mbu-logpop-x" type="button" title="Close">\u2715</button>' : "") + '</div><div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style><div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : "")); if (tools) root.querySelector(".mbu-logpop-clear").before(tools); var list = root.querySelector(".mbu-log-list"), badge = root.querySelector(".mbu-log-badge"); var qEl = root.querySelector(".mbu-log-q"), q = "", sel = root.querySelector(".mbu-log-ses"); var fRow = root.querySelector(".mbu-log-f"), fCss = root.querySelector(".mbu-log-fcss"); var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function(e) { return !!q && ((e.cat || "") + " " + e.msg).toLowerCase().indexOf(q) < 0; }; var note = function(e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function(e) { var d = document.createElement("div"); d._e = e; d.className = "mbu-log-li mbu-log-" + e.sev + (e.pre ? " mbu-log-pre" : ""); if (miss(e)) d.classList.add("mbu-log-nq"); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span><span class="mbu-log-c">' + esc(e.cat || "") + '</span><span class="mbu-log-m">' + linkify(e.msg) + "</span>"); return d; }; var filters = function() { var lv = LEVELS.filter(function(l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function(kind, v2, on) { return '<button type="button" class="mbu-log-fb' + (on ? " on" : "") + '" data-' + kind + '="' + esc(v2) + '">' + esc(v2) + "</button>"; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function(l) { return b("sev", l, fSev === l); }).join("") + "</span>" : "") + (showL && showC ? '<span class="mbu-log-fs"></span>' : "") + (showC ? '<span class="mbu-log-fg">' + cats.map(function(c) { return b("cat", c, fCat === c); }).join("") + "</span>" : "")); fRow.hidden = !showL && !showC; list.classList.toggle("mbu-log-cats", cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : "") + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ""); }; var showBadge = function() { var c = past ? countOf(src) : { warn, error }; badge.textContent = "(" + src.length + ")" + (c.warn || c.error ? " \xB7 " + c.warn + "\u26A0 " + c.error + "\u2716" : ""); }; var empty = function() { return '<div class="mbu-log-empty">' + (past ? "Nothing was kept of this session." : "No activity yet.") + "</div>"; }; var queued = false, follow = true; var paint = function() { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; var draw = function() { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function(e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? "" : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle("mbu-log-past", !!past); }; list.addEventListener("scroll", function() { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function() { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle("mbu-log-nq", miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener("input", function() { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener("click", function(ev) { var t = ev.target.closest(".mbu-log-fb"); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating, past: null, q: qEl, append: function(e) { var em = list.querySelector(".mbu-log-empty"); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function(id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: "warn", msg: "This session could not be read (it may have been deleted in another tab)." }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || "")) sel.value = past || ""; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function() { if (!sel) return; var all = sessions(), sig = all.map(function(x) { return x.id + "=" + x.name; }).join("|"); if (sig === v.sig && !(past && !all.some(function(x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function(s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function(s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + "</option>"; }).join("")); sel.value = past || ""; var hc = root.querySelector(".mbu-logpop-hclear"); if (hc) hc.disabled = !all.length; }, filters }; if (sel) { sel.addEventListener("change", function() { v.show(sel.value || null); }); root.querySelector(".mbu-logpop-hclear").onclick = function() { clearHistory(); }; } root.querySelector(".mbu-logpop-clear").onclick = function() { clear(); }; v.dbg = root.querySelector(".mbu-log-dbg input"); v.dbg.checked = debugOn(); v.dbg.onchange = function() { setDebug(v.dbg.checked); }; root.querySelector(".mbu-logpop-copy").onclick = function() { copy(root.querySelector(".mbu-logpop-copy"), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === "function") { try { o.before(); } catch (e) { } } remember({ open: true }); var st = state(); var pop = document.createElement("div"); pop.id = "mbu-logpop"; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = "auto"; pop.style.transform = "none"; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; var onKey = function(e) { if (e.key !== "Escape") return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ""; v.q.dispatchEvent(new Event("input")); return; } close(); }; win = { el: pop, v, off: function() { window.removeEventListener("keydown", onKey, true); } }; var minBtn = pop.querySelector(".mbu-logpop-min"); var setMin = function(m) { minBtn.textContent = m ? "\u25A2" : "\u2013"; minBtn.title = m ? "Restore" : "Minimize"; if (m) { pop.style.left = "14px"; pop.style.bottom = "14px"; pop.style.top = "auto"; pop.style.right = "auto"; pop.style.transform = "none"; } else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector(".mbu-logpop-full"); var setFull = function(f) { pop.classList.toggle("full", f); fullBtn.textContent = f ? "\u2750" : "\u26F6"; fullBtn.title = f ? "Restore the size (or double-click the title bar)" : "Full screen (or double-click the title bar)"; }; minBtn.onclick = function() { var m = pop.classList.toggle("min"); if (m && pop.classList.contains("full")) { setFull(false); remember({ full: false }); } setMin(m); remember({ min: m }); }; fullBtn.onclick = function() { var f = !pop.classList.contains("full"); setFull(f); remember({ full: f }); }; pop.querySelector(".mbu-logpop-h").addEventListener("dblclick", function(e) { if (!e.target.closest("button, input, select") && !pop.classList.contains("min")) fullBtn.click(); }); if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add("sized"); } if (st.min) { pop.classList.add("min"); setMin(true); } else if (st.full) setFull(true); pop.querySelector(".mbu-logpop-grip").addEventListener("mousedown", function(e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + "px"; pop.style.top = r.top + "px"; pop.style.right = "auto"; pop.style.bottom = ""; pop.style.transform = "none"; pop.classList.add("sized"); var x0 = e.clientX, y0 = e.clientY; var mv = function(ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + "px"; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + "px"; }; var up = function() { document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up); restore = { left: pop.style.left, top: pop.style.top, right: "auto", bottom: "", transform: "none" }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up); }); pop.querySelector(".mbu-logpop-x").onclick = function() { close(); }; pop.querySelector(".mbu-logpop-h").addEventListener("mousedown", function(e) { if (e.target.closest("button, input, select") || pop.classList.contains("full")) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + "px"; pop.style.top = r.top + "px"; pop.style.right = "auto"; pop.style.transform = "none"; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function(ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + "px"; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + "px"; }; var up = function() { document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up); if (!pop.classList.contains("min")) { restore = { left: pop.style.left, top: pop.style.top, right: "auto", bottom: "", transform: "none" }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up); }); window.addEventListener("keydown", onKey, true); return pop; } function mount(host, opts) { if (!host) return null; views = views.filter(function(v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement("div"); el.className = "mbu-logemb"; host.appendChild(el); return view(el, false, opts && opts.tools).el; } function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ""; } views.forEach(function(v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function(id) { if (id !== sid) forget(id); }); views.forEach(function(v) { v.sessions(); }); } function close(quiet) { var stray = document.getElementById("mbu-logpop"); if (win) { win.off(); win.el.remove(); views = views.filter(function(v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); } var api = { info: function() { add("info", arguments); }, warn: function() { add("warn", arguments); }, err: function() { add("error", arguments); }, error: function() { add("error", arguments); }, ok: function() { add("ok", arguments); }, debug: function() { add("debug", arguments); }, add: function(sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function() { return debugOn(); }, setDebug, pre: function() { add("info", arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function(name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function() { add("info", arguments, name); }, warn: function() { add("warn", arguments, name); }, err: function() { add("error", arguments, name); }, error: function() { add("error", arguments, name); }, ok: function() { add("ok", arguments, name); }, debug: function() { add("debug", arguments, name); }, add: function(sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function() { add("info", arguments, name, true); } }; return bound[name]; }, open, mount, close: function() { close(); }, reopen: function() { if (state().open) open(); }, isOpen: function() { return !!win; }, markdown: function() { return markdown(); }, copy: function(btn) { copy(btn); }, clear, lines: function() { return buf.map(line); }, entries: function() { return buf.slice(); }, messages: function() { return buf.map(function(e) { return e.msg; }); }, counts: function() { return { warn, error }; }, // history (#705); harmless without o.history session: function() { if (H) startSession(); return sid; }, resume: function(id) { return H ? resume(id) : false; }, keep: function() { if (H && sid && !kept) { kept = true; store.set(hp + "current", sid); flush(); prune(); views.forEach(function(v) { v.sessions(); }); } }, name: function(text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ":name", sname); } }, sessionId: function() { return sid; }, last: function() { return H ? store.get(hp + "current") : null; }, isKept: function() { return kept; }, sessions: function() { return H ? sessions() : []; }, load: function(id) { return loadSession(id); }, forget: function(id) { forget(id); views.forEach(function(v) { v.sessions(); }); }, clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function() { views.forEach(function(v) { v.sessions(); }); }, flush }; if (H) sid = newId(); mbuLog.active = api; return api; } function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function(e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); var eat = function(ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener("click", eat, true); }; document.addEventListener("click", eat, true); setTimeout(function() { document.removeEventListener("click", eat, true); }, 400); }; var onKey = function(e) { if (closed || e.key !== "Escape") return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener("mousedown", onDown, true); document.removeEventListener("keydown", onKey, true); try { close(); } catch (err) { } } document.addEventListener("mousedown", onDown, true); document.addEventListener("keydown", onKey, true); return finish; } function mbuFitToolbar(bar2, opts) { if (!bar2) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || ".mbu-sp"; bar2.classList.remove("mbu-compact"); var kids = [].slice.call(bar2.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar2.clientWidth - pad; bar2.classList.toggle("mbu-compact", compact); return compact; } function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ""); if (!m) return null; if (m[4] !== void 0 && +m[4] < 0.5) return null; var f = function(v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? "dark" : "light"; } function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === void 0 || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } function mbuProbe() { var p = document.getElementById("mbu-theme-probe"); if (p) return p; if (!document.body) return null; p = document.createElement("span"); p.id = "mbu-theme-probe"; p.setAttribute("aria-hidden", "true"); p.style.cssText = "position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)"; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue("--mbu-theme") || "").trim(); var t = forced === "dark" || forced === "light" ? forced : mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue("--mbu-bg")) || "light"; if (root.getAttribute("data-mbu-theme") !== t) root.setAttribute("data-mbu-theme", t); var seed = null; var raw = (cs.getPropertyValue("--background") || "").trim(); if (raw) { var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { var tmp = document.createElement("span"); tmp.style.cssText = "position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)"; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = "theme"; } if (seed) { if (root.getAttribute("data-mbu-seed") !== seed) root.setAttribute("data-mbu-seed", seed); } else if (root.hasAttribute("data-mbu-seed")) root.removeAttribute("data-mbu-seed"); return t; } catch (e) { return "light"; } } function mbuThemeStart() { try { mbuTheme(); var _mbuThemeT = 0; var _mbuThemeSoon = function() { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ["style", "class"] }); if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ["style", "class"] }); try { var _mbuMq = matchMedia("(prefers-color-scheme: dark)"); if (_mbuMq.addEventListener) _mbuMq.addEventListener("change", _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) { } setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2e3); } catch (e) { } } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { } log.info(mbuStartupInfo("Credit Hoarder")); const style = document.createElement("style"); style.innerText = MBU_TOKENS + MBU_UI_CSS + ` .discogs-bar { font-family: inherit; background: var(--mbu-bg); border: 1px solid var(--mbu-warn); border-left: 4px solid var(--mbu-warn); border-radius: 0.35rem; margin-bottom: 1rem; overflow: hidden; } .discogs-bar-row1 { display: flex; align-items: center; gap: 0.6rem; row-gap: 0.4rem; flex-wrap: wrap; padding: 0.5rem 0.75rem; background: var(--mbu-bg-raised); border-bottom: 1px solid var(--mbu-warn); } /* inline options strip in the single bar (#139) */ .discogs-bar-opts { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin-left: 0.9rem; } .discogs-bar-opts .discogs-opts-label { font-size: 0.75rem; color: var(--mbu-text-weak); text-transform: uppercase; letter-spacing: 0.05em; flex-shrink: 0; } .discogs-opts-btn { font-size: 0.8rem; color: var(--mbu-text-dim); background: var(--mbu-bg); border: 1px solid var(--mbu-warn); border-radius: 2rem; padding: 0.15rem 0.6rem; cursor: pointer; display: inline-flex; align-items: center; gap: 0.25rem; } .discogs-opts-btn:hover { border-color: var(--mbu-warn); color: var(--mbu-text); } .discogs-opts-caret { color: var(--mbu-text-weak); font-size: 0.7rem; } /* "Options \u25BE" popover (Dedup toggles) */ .discogs-opts-panel { position: fixed; z-index: 100002; display: none; flex-direction: column; gap: 0.4rem; background: var(--mbu-bg); border: 1px solid var(--mbu-warn); border-radius: 0.4rem; box-shadow: 0 6px 22px rgba(40,20,80,0.18); padding: 0.55rem 0.6rem; font-family: inherit; } .discogs-opts-panel.open { display: flex; } .discogs-opts-panel .discogs-opts-panel-hd { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mbu-text-weak); font-weight: 600; } /* "Log" header toggle \u2014 a split button (#142, #217): "Log" toggles, the \u25BE half (its own clickable target) opens the copy menu. */ .discogs-log-split { display: inline-flex; align-items: stretch; } .discogs-logtoggle-btn { font-size: 0.78rem; color: var(--mbu-text-dim); background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 0.25rem 0 0 0.25rem; border-right: none; padding: 0.15rem 0.55rem; cursor: pointer; display: inline-flex; align-items: center; white-space: nowrap; } .discogs-log-caret-btn { font-size: 0.78rem; color: var(--mbu-text-dim); background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 0 0.25rem 0.25rem 0; padding: 0.15rem 0.45rem; cursor: pointer; display: inline-flex; align-items: center; } .discogs-logtoggle-btn:hover, .discogs-log-caret-btn:hover { border-color: var(--mbu-border); } .discogs-log-caret-btn:hover { background: var(--mbu-bg-raised); } .discogs-log-split.active .discogs-logtoggle-btn, .discogs-log-split.active .discogs-log-caret-btn { background: var(--mbu-bg-hover); border-color: var(--mbu-accent); color: var(--mbu-accent-deep-text); } /* "Log \u25BE" dropdown menu (#118): show/hide + the three copy actions. */ .discogs-log-menu { position: fixed; z-index: 100002; display: none; flex-direction: column; min-width: 11rem; background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 0.4rem; box-shadow: 0 6px 22px rgba(40,20,80,0.18); padding: 0.3rem; font-family: inherit; } .discogs-log-menu.open { display: flex; } .discogs-log-menu button { text-align: left; font-size: 0.82rem; color: var(--mbu-text); background: none; border: none; border-radius: 0.25rem; padding: 0.3rem 0.5rem; cursor: pointer; white-space: nowrap; } .discogs-log-menu button:hover { background: var(--mbu-bg-hover); color: var(--mbu-text); } /* log panel toolbar: severity filter + copy buttons */ .discogs-log-toolbar { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; padding: 0.3rem 0 0.45rem; } .discogs-log-filter { display: inline-flex; border: 1px solid var(--mbu-border); border-radius: 0.3rem; overflow: hidden; } .discogs-log-filterbtn { font-size: 0.75rem; color: var(--mbu-text-dim); background: var(--mbu-bg); border: none; border-right: 1px solid var(--mbu-divider); padding: 0.15rem 0.55rem; cursor: pointer; } .discogs-log-filterbtn:last-child { border-right: none; } .discogs-log-filterbtn:hover { background: var(--mbu-bg-raised); } .discogs-log-filterbtn.active { background: var(--mbu-accent); color: var(--mbu-text-on-accent); } .discogs-log-copyslot { display: inline-flex; gap: 0.4rem; margin-left: auto; } .discogs-log-copybtn { font-size: 0.78rem; color: var(--mbu-text-dim); background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 0.25rem; padding: 0.15rem 0.5rem; cursor: pointer; white-space: nowrap; } .discogs-log-copybtn:hover { border-color: var(--mbu-border); } .discogs-bar img.discogs-logo { height: 20px; width: auto; flex-shrink: 0; opacity: 0.85; } .discogs-bar .discogs-source-icon { display: inline-flex; align-items: center; flex-shrink: 0; } .discogs-bar .discogs-source-icon:hover .discogs-logo { opacity: 1; } .discogs-bar .discogs-source { flex: 0 1 auto; max-width: 20rem; font-size: 0.82rem; color: var(--mbu-text-dim); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Slot in the always-visible header that hosts the review "Start import" button + unresolved message (#139). Content-sized; the right cluster's margin-left:auto does the pushing so the link/help stay right even when this slot is empty (initial state). */ .discogs-bar-action { flex: 0 1 auto; min-width: 0; display: flex; align-items: center; gap: 0.6rem; } .discogs-bar-action:empty { display: none; } /* Reserved message area (#118): badge + transient status, right-aligned just left of the Discogs/Help/Log cluster. margin-left:auto pushes it (and the right cluster after it) to the edge, leaving the gap up to the "Options" button free for these messages. Collapses to nothing when empty (hidden children don't count as flex items, so the gap contributes no width). */ /* #139: grow to fill the gap between "Options" and the right cluster so the status message can use that whole width (right-aligned next to the cluster). flex:1 also keeps the right cluster pinned to the edge whether this is empty or not \u2014 replacing the old margin-left:auto. Basis 0 (not auto) is essential: with an auto basis a very long message's content width is used for row1's wrap calculation and bumps the whole slot to a second row; basis 0 keeps it on the line and the status just ellipsises inside it. */ .discogs-bar-msgs { flex: 1 1 0; justify-content: flex-end; display: flex; align-items: center; gap: 0.4rem; min-width: 0; } /* #139: no fixed cap \u2014 the message takes the available space up to "Options" and only ellipsises when it genuinely doesn't fit (flex-shrink + min-width:0). */ .discogs-bar-status { font-size: 0.8rem; color: var(--mbu-text-weak); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } /* #216: persistent end-of-run message (e.g. "No importable credits found") */ .discogs-bar-status-final { color: var(--mbu-warn); font-weight: 600; } /* Count badges. Buttons so they can focus/open the log; styled as pills. Borderless + a touch larger per #139; the (deepened) fill carries them. */ .discogs-badge { flex-shrink: 0; font-size: 0.9rem; font-weight: 600; line-height: 1.2; padding: 0.2rem 0.7rem; border-radius: 2rem; border: none; cursor: pointer; white-space: nowrap; } .discogs-badge-warn { color: var(--mbu-warn); background: var(--mbu-warn-bg); } .discogs-badge-warn:hover{ background: var(--mbu-warn-bg); } .discogs-badge-err { color: var(--mbu-error); background: var(--mbu-error-bg); } .discogs-badge-err:hover { background: var(--mbu-error-bg); } .discogs-badge-unresolved{ color: var(--mbu-accent-deep-text); background: var(--mbu-bg-hover); } .discogs-badge-unresolved:hover { background: var(--mbu-bg-hover); } /* Discogs logo + Help + Log \u2014 pinned to the right edge (the msgs slot's margin-left:auto does the pushing). */ .discogs-bar-right { flex-shrink: 0; display: flex; align-items: center; gap: 0.6rem; min-width: 0; } /* During the review wait the import isn't running, so the "Importing\u2026" button + percentage are redundant \u2014 hide them; they reappear while a real import phase (preflight / dispatch) is active (#139). */ .discogs-bar.is-reviewing .discogs-import-btn, .discogs-bar.is-reviewing #discogs-progress-pct { display: none !important; } /* !important: the % span carries an inline display set by JS */ .discogs-bar-action .discogs-issue-note { font-size: 0.85rem; color: var(--mbu-warn); min-width: 7.5rem; /* reserve space so the bar doesn't reflow as the count appears / changes (#139) */ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .discogs-bar-action .discogs-issue-note.clickable { cursor: pointer; text-decoration: underline dotted; } .discogs-bar-action .discogs-issue-note.clickable:hover { color: var(--mbu-warn); } /* "N links" badge \u2014 orange, short, clickable to cycle through the rows whose source URL still needs linking. No wide min-width reservation. */ .discogs-bar-action .discogs-links-note { min-width: 0; color: var(--mbu-warn); } .discogs-bar-action .discogs-links-note.clickable:hover { color: var(--mbu-warn); } .discogs-bar .discogs-source a { color: var(--mbu-warn); text-decoration: none; font-weight: bold; } .discogs-bar .discogs-source a:hover { text-decoration: underline; } /* #272: "Import credits:" label + a row of clickable source icons */ .discogs-import-label { flex-shrink: 0; font-size: 0.88rem; font-weight: bold; color: var(--mbu-text); letter-spacing: 0.01em; } /* #272: drop the "Import credits:" label once a run is underway \u2014 only the active source icon + progress/Start-import matter then. */ .discogs-bar.is-importing .discogs-import-label, .discogs-bar.is-reviewing .discogs-import-label { display: none; } .discogs-src-icons { flex-shrink: 0; display: inline-flex; align-items: center; gap: 0.3rem; } .discogs-src-ico { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; padding: 0; cursor: pointer; border: 1px solid var(--mbu-border); border-radius: 0.3rem; background: var(--mbu-bg); color: var(--mbu-text-dim); } .discogs-src-ico:hover { background: var(--mbu-bg-raised); border-color: var(--mbu-warn); color: var(--mbu-warn); } .discogs-src-ico:disabled { opacity: 0.5; cursor: default; } .discogs-src-ico svg { width: 18px; height: 18px; } .discogs-src-ico img.discogs-logo { height: 18px; width: auto; opacity: 1; } .discogs-src-ico.importing { background: var(--mbu-bg-raised); border-color: var(--mbu-warn); animation: discogs-ico-pulse 1s ease-in-out infinite; } @keyframes discogs-ico-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(232,119,29,.5); } 50% { box-shadow: 0 0 0 4px rgba(232,119,29,0); } } /* #412: while MusicBrainz is submitting the staged edits (can be slow for hundreds), pulse the toolbar blue. Target ROW1 \u2014 that's the strip pinned (position:fixed) at the top of the viewport while the page is scrolled down to the submit button, i.e. the part actually on screen. The outer container is usually scrolled out of view. */ .discogs-bar.is-saving { border-left-color: var(--mbu-info); } .discogs-bar.is-saving .discogs-bar-row1 { animation: discogs-bar-saving 1.1s ease-in-out infinite; border-bottom-color: var(--mbu-info); } @keyframes discogs-bar-saving { 0%,100% { background: var(--mbu-bg-raised); } 50% { background: var(--mbu-info-bg); } } .discogs-bar.is-saving .discogs-bar-status-final { color: var(--mbu-info); font-weight: 600; } /* #412: a toolbar "Enter edit" that fires MB's native submit, so you don't have to scroll to the bottom after an import. Shown once an import finishes, removed on return-to-source. Green to read as the positive/submit action (matches MB). */ .discogs-enter-edit { flex-shrink: 0; font-size: 0.82rem; font-weight: 600; cursor: pointer; white-space: nowrap; color: var(--mbu-text-on-accent); background: #4b8f29; border: 1px solid var(--mbu-ok); border-radius: 0.25rem; padding: 0.14rem 0.6rem; } .discogs-enter-edit:hover { background: #57a230; } .discogs-bar.is-saving .discogs-enter-edit { opacity: 0.6; pointer-events: none; } /* #408 "Import all" \u2014 wider pill with a glyph + label, brand-orange so it reads as the primary action */ .discogs-src-all { width: auto; gap: 0.3rem; padding: 0 0.6rem; border-color: var(--mbu-warn); color: var(--mbu-warn); font-weight: 600; font-size: 0.85rem; margin-left: 0.35rem; } .discogs-src-all:hover { background: #e8771d; color: var(--mbu-text-on-accent); border-color: var(--mbu-warn); } .discogs-src-all .discogs-all-glyph { font-size: 1rem; line-height: 1; } .discogs-bar-badge, .discogs-src-badge svg { width: 15px; height: 15px; } .discogs-log-menu button svg { vertical-align: -2px; margin-right: 4px; } .discogs-bar-row2 { display: flex; align-items: center; gap: 0.4rem; padding: 0.35rem 0.75rem; flex-wrap: wrap; } .discogs-bar-row2 .discogs-opts-label { font-size: 0.75rem; color: var(--mbu-text-weak); text-transform: uppercase; letter-spacing: 0.05em; margin-right: 0.2rem; flex-shrink: 0; } /* Borderless toggles (#118): no pill outline/background \u2014 the dot alone signals on/off, matching the maintainer's mockup. */ .discogs-toggle { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.15rem 0.4rem 0.15rem 0.2rem; border: none; border-radius: 2rem; background: transparent; cursor: pointer; font-size: 0.8rem; color: var(--mbu-text-dim); user-select: none; transition: color 0.12s; } .discogs-toggle:hover { color: var(--mbu-text); } .discogs-toggle input[type=checkbox] { display: none; } .discogs-toggle .discogs-toggle-dot { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--mbu-border); background: var(--mbu-bg); flex-shrink: 0; transition: border-color 0.12s, background 0.12s; } .discogs-toggle.active { color: var(--mbu-text); } .discogs-toggle.active .discogs-toggle-dot { border-color: var(--mbu-warn); background: #e8771d; } .discogs-output { padding: 0.5rem 0.75rem 0.25rem; } .discogs-output.empty { display: none; } /* no log yet \u2192 hide the whole section (#142) */ .discogs-output .summary { margin: 0 0 0.25rem; font-size: 0.88rem; color: var(--mbu-text-dim); } .discogs-output .logs { margin: 0; padding-left: 1.2rem; font-size: 0.83rem; } /* log panel hides behind the header "Log \u25BE" button (#142); the review panel sits in .discogs-review-slot OUTSIDE the panel, always visible. */ .discogs-log-panel { display: none; } .discogs-output.log-open .discogs-log-panel { display: block; } .discogs-review-slot:not(:empty) { margin: 0.2rem 0; } /* severity filter: Warnings shows only warn lines, Errors only error lines \u2014 each view matches its header badge count. "skip" lines (unresolved-entity skips, #118) and info lines show only under "All"; errors are NOT lumped into the Warnings view. */ .discogs-output[data-logfilter="warn"] .discogs-log-body .logs > li:not([data-sev="warn"]) { display: none; } .discogs-output[data-logfilter="error"] .discogs-log-body .logs > li:not([data-sev="error"]) { display: none; } /* \u2500\u2500 Progress / sticky bar \u2500\u2500 */ /* Pinned during import (is-importing) AND kept pinned afterwards (is-pinned, #118) so the WARN/ERR badge stays visible on top while the user scrolls the staged edits below. overflow:hidden on .discogs-bar rules out position:sticky, so we use fixed + an in-flow spacer that reserves row1's height (see .discogs-sticky-spacer). */ .discogs-bar.is-importing .discogs-bar-row1, .discogs-bar.is-pinned .discogs-bar-row1 { position: fixed; top: 0; left: 0; right: 0; z-index: 9000; background: var(--mbu-bg-raised); border-bottom: 1px solid var(--mbu-warn); box-shadow: 0 2px 8px rgba(0,0,0,0.15); } /* Occupies row1's height in the flow while row1 is fixed, so the page content below the bar doesn't jump up under it. Height set by JS. */ .discogs-sticky-spacer { display: none; } .discogs-bar.is-importing .discogs-sticky-spacer, .discogs-bar.is-pinned .discogs-sticky-spacer { display: block; } .discogs-progress-track { height: 5px; background: var(--mbu-warn-bg); border-radius: 3px; overflow: hidden; } .discogs-progress-fill { height: 100%; width: 0%; background: #e8771d; border-radius: 3px; transition: width 0.3s ease; } .discogs-progress-fill.indeterminate { width: 40%; animation: discogs-slide 1.4s ease-in-out infinite; } @keyframes discogs-slide { 0% { margin-left: -40%; } 100% { margin-left: 100%; } } .discogs-progress-status { font-size: 0.8rem; color: var(--mbu-warn); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .discogs-recent-logs { font-size: 0.78rem; color: var(--mbu-text-weak); max-height: 3.2rem; overflow: hidden; line-height: 1.4; } .discogs-recent-logs span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .discogs-toggle { position: relative; } /* position:fixed so the tooltip escapes .discogs-bar's overflow:hidden (needed there to clip child backgrounds to the bar's rounded corners). Per-hover JS in makeCheckbox sets top/left from the toggle's viewport rect, so the tooltip renders outside any overflow-clipping ancestor. Issue #89. */ .discogs-tooltip { display: none; position: fixed; background: #333; color: var(--mbu-text-on-accent); font-size: 0.78rem; line-height: 1.45; padding: 0.45rem 0.65rem; border-radius: 0.3rem; white-space: normal; width: 220px; box-shadow: 0 2px 8px rgba(0,0,0,0.25); pointer-events: none; z-index: 9999; text-align: left; } .discogs-tooltip::after { content: ''; position: absolute; top: 100%; left: var(--arrow-x, 50%); transform: translateX(-50%); border: 5px solid transparent; border-top-color: #333; } /* When the tooltip flipped below the toggle (no room above), flip the arrow to point up from the tooltip's top edge. */ .discogs-tooltip.below::after { top: auto; bottom: 100%; border-top-color: transparent; border-bottom-color: #333; } /* Tooltip shown by JS adding .discogs-tooltip-visible after a hover-intent delay (see makeCheckbox). Native browser title= tooltips have a ~1s delay by convention; the custom tooltips used to fire instantly and felt jumpy when sweeping across toggles. */ .discogs-tooltip.discogs-tooltip-visible { display: block; } /* An input that password managers leave alone is typed "search"; these rules put back the look of a plain text box (Chrome draws a clear button and its own inner spacing otherwise). See noPasswordManagers. No backticks in here: this whole block is a JS template literal. */ input.ch-nopw { -webkit-appearance: textfield; appearance: textfield; } input.ch-nopw::-webkit-search-decoration, input.ch-nopw::-webkit-search-cancel-button, input.ch-nopw::-webkit-search-results-button, input.ch-nopw::-webkit-search-results-decoration { display: none; -webkit-appearance: none; } `; document.head.appendChild(style); const bar = document.createElement("div"); bar.className = "discogs-bar"; bar._runToken = 0; const row1 = document.createElement("div"); row1.className = "discogs-bar-row1"; const importSources = []; if (discogsUrl) importSources.push({ name: "Discogs", url: discogsUrl, run: (g, c, collect) => runImport(discogsUrl, g, c, collect) }); if (sources.tidal) importSources.push({ name: "Tidal", url: sources.tidal, run: (g, c, collect) => runTidalImport(sources.tidal, g, c, collect) }); if (sources.qobuz) importSources.push({ name: "Qobuz", url: sources.qobuz, run: (g, c, collect) => runQobuzImport(sources.qobuz, g, c, collect) }); if (sources.deezer) importSources.push({ name: "Deezer", url: sources.deezer, run: (g, c, collect) => runDeezerImport(sources.deezer, g, c, collect) }); if (sources.apple) importSources.push({ name: "Apple", url: sources.apple, run: (g, c, collect) => runAppleImport(sources.apple, g, c, collect) }); if (sources.metalArchives) importSources.push({ name: "Metal Archives", url: sources.metalArchives, run: (g, c, collect) => runMetalArchivesImport(sources.metalArchives, g, c, collect) }); if (sources.ytmusic) importSources.push({ name: "YouTube Music", url: sources.ytmusic, run: (g, c, collect) => runYtmImport(sources.ytmusic, g, c, collect) }); if ((meta.titlesRemixCount || 0) > 0) { importSources.push({ name: "Titles", url: "", run: (g, c, collect) => runTitlesImport(g, c, collect) }); } const importLabel = document.createElement("span"); importLabel.className = "discogs-import-label"; importLabel.textContent = "Import credits:"; const srcIcons = document.createElement("span"); srcIcons.className = "discogs-src-icons"; const ORIG_ICON = { Discogs: stIcon("discogs", 16), Tidal: stIcon("tidal", 16), Qobuz: stIcon("qobuz", 16), Deezer: stIcon("deezer", 16), Apple: stIcon("apple", 16), "YouTube Music": stIcon("ytmusic", 16), // #648 Titles: SRC_ICON.Titles }; const srcButtons = []; let importing = false; const makeSrcButton = (s) => { const b = document.createElement("button"); b.type = "button"; b.className = "discogs-src-ico"; b._icon = ORIG_ICON[s.name] || SRC_ICON[s.name] || s.name; b.innerHTML = b._icon; b.dataset.src = s.name; b.title = s.url ? `Import credits from ${s.name} \xB7 right-click to open the ${s.name} page` : "Import remixer credits derived from the track titles"; b.addEventListener("click", () => { if (importing) { if (b.classList.contains("importing")) cancelRun(); return; } startImport(b, s.url, s.run); }); if (s.url) b.addEventListener("contextmenu", (e) => { e.preventDefault(); window.open(s.url, "_blank", "noopener,noreferrer"); }); srcButtons.push(b); srcIcons.appendChild(b); return b; }; importSources.forEach(makeSrcButton); if (meta.sourceProbeFailed && !importSources.length) { const warn = document.createElement("span"); warn.className = "discogs-src-probe-failed"; warn.style.cssText = "font-size:0.8rem;color:var(--mbu-warn);"; warn.textContent = "could not read this release\u2019s links from MusicBrainz \u2014 reload to retry"; warn.title = "The /ws/js/release lookup failed (MusicBrainz was busy or unreachable), so the linked import sources are unknown."; srcIcons.appendChild(warn); } const makeAllButton = () => { if (importSources.length <= 1 || srcIcons.querySelector(".discogs-src-all")) return; const allBtn = document.createElement("button"); allBtn.type = "button"; allBtn.className = "discogs-src-ico discogs-src-all"; allBtn._icon = '<span class="discogs-all-glyph">\u269B</span><span class="discogs-all-lbl">All</span>'; allBtn.innerHTML = allBtn._icon; allBtn.dataset.src = "All"; allBtn.title = `Import from all ${importSources.length} sources at once \u2014 merged & de-duplicated into one review`; allBtn.addEventListener("click", () => { if (importing) { if (allBtn.classList.contains("importing")) cancelRun(); return; } startImport(allBtn, "", (g, c) => runConsolidatedImport(importSources, g, c), `Import all (${importSources.map((s) => s.name).join(", ")})`); }); srcButtons.push(allBtn); srcIcons.appendChild(allBtn); }; makeAllButton(); const addTitlesSource = (count) => { if (!(count > 0)) return "none"; if (importSources.some((x) => x.name === "Titles")) return "already"; const src = { name: "Titles", url: "", run: (g, c, collect) => runTitlesImport(g, c, collect) }; importSources.push(src); makeSrcButton(src); makeAllButton(); return "added"; }; bar._addTitlesSource = addTitlesSource; const progressPct = document.createElement("span"); progressPct.id = "discogs-progress-pct"; progressPct.style.cssText = "display:none; margin-left:0.5rem; font-size:0.85rem; color:var(--mbu-warn); font-weight:bold; min-width:3.5rem;"; row1.appendChild(importLabel); row1.appendChild(srcIcons); row1.appendChild(progressPct); const actionSlot = document.createElement("div"); actionSlot.className = "discogs-bar-action"; row1.appendChild(actionSlot); const optsWrap = document.createElement("div"); optsWrap.className = "discogs-bar-opts"; row1.appendChild(optsWrap); let _optsHost = optsWrap; const msgSlot = document.createElement("div"); msgSlot.className = "discogs-bar-msgs"; const statusEl = document.createElement("span"); statusEl.className = "discogs-bar-status"; statusEl.style.display = "none"; const warnPill = document.createElement("button"); warnPill.type = "button"; warnPill.className = "discogs-badge discogs-badge-warn"; warnPill.style.display = "none"; warnPill.title = "Show warnings in the log"; const errPill = document.createElement("button"); errPill.type = "button"; errPill.className = "discogs-badge discogs-badge-err"; errPill.style.display = "none"; errPill.title = "Show errors in the log"; const unresolvedPill = document.createElement("button"); unresolvedPill.type = "button"; unresolvedPill.className = "discogs-badge discogs-badge-unresolved"; unresolvedPill.style.display = "none"; unresolvedPill.title = "Entities not matched on MusicBrainz \u2014 skipped on import"; msgSlot.append(statusEl, warnPill, errPill, unresolvedPill); row1.appendChild(msgSlot); const rightGroup = document.createElement("div"); rightGroup.className = "discogs-bar-right"; const logSplit = document.createElement("span"); logSplit.className = "discogs-log-split"; logSplit.style.display = "none"; const logToggleBtn = document.createElement("button"); logToggleBtn.type = "button"; logToggleBtn.className = "discogs-logtoggle-btn"; logToggleBtn.textContent = "Log"; logToggleBtn.title = "Show / hide the import log"; const logCaretBtn = document.createElement("button"); logCaretBtn.type = "button"; logCaretBtn.className = "discogs-log-caret-btn"; logCaretBtn.textContent = "\u25BE"; logCaretBtn.title = "More log actions (copy)"; logSplit.append(logToggleBtn, logCaretBtn); const docsHref = typeof GM_info !== "undefined" && (GM_info?.script?.homepageURL || GM_info?.script?.homepage) || "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md"; const docsLink = document.createElement("a"); docsLink.href = docsHref; docsLink.target = "_blank"; docsLink.rel = "noopener noreferrer nofollow"; docsLink.textContent = "? Help"; docsLink.title = "Open the script's README in a new tab"; docsLink.style.cssText = "flex-shrink:0;font-size:0.82rem;color:var(--mbu-warn);text-decoration:none;padding:0.1rem 0.45rem;border:1px solid var(--mbu-warn);border-radius:0.25rem;background:var(--mbu-bg-raised);"; const enterEditBtn = document.createElement("button"); enterEditBtn.type = "button"; enterEditBtn.className = "discogs-enter-edit"; enterEditBtn.textContent = "Enter edit"; enterEditBtn.title = 'Submit the staged edits to MusicBrainz \u2014 clicks the native "Enter edit" button at the bottom of the page'; enterEditBtn.style.display = "none"; const findNativeSubmit = () => document.querySelector("button.submit.positive") || [...document.querySelectorAll('button[type="submit"], button.submit')].find((b) => /enter edit/i.test(b.textContent || "")); enterEditBtn.addEventListener("click", () => { const submit = findNativeSubmit(); if (!submit) { bar._setStopMessage(`Could not find MusicBrainz's "Enter edit" button \u2014 scroll down and submit manually.`); return; } submit.scrollIntoView({ behavior: "smooth", block: "center" }); submit.click(); }); bar._showEnterEdit = () => { if (findNativeSubmit()) enterEditBtn.style.display = ""; }; bar._hideEnterEdit = () => { enterEditBtn.style.display = "none"; }; rightGroup.append(enterEditBtn, logSplit, docsLink); row1.appendChild(rightGroup); bar.appendChild(row1); const stickySpacer = document.createElement("div"); stickySpacer.className = "discogs-sticky-spacer"; bar.appendChild(stickySpacer); bar._pin = () => { const h = row1.getBoundingClientRect().height; if (h) stickySpacer.style.height = h + "px"; }; window.addEventListener("resize", () => { if (bar.classList.contains("is-pinned")) bar._pin(); }); function makeCheckbox(labelText, checkedByDefault, tooltipText) { const lbl = document.createElement("label"); lbl.className = "discogs-toggle" + (checkedByDefault ? " active" : ""); const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = checkedByDefault; const dot = document.createElement("span"); dot.className = "discogs-toggle-dot"; lbl.appendChild(cb); lbl.appendChild(dot); lbl.appendChild(document.createTextNode(labelText)); if (tooltipText) { const tip = document.createElement("span"); tip.className = "discogs-tooltip"; tip.textContent = tooltipText; lbl.appendChild(tip); const TIP_W = 220, TIP_MARGIN = 6, EDGE_PAD = 8; const HOVER_DELAY_MS = 1e3; let _showTimer, _hideTimer; lbl.addEventListener("mouseenter", () => { clearTimeout(_showTimer); _showTimer = setTimeout(() => { const r = lbl.getBoundingClientRect(); const centerX = r.left + r.width / 2; let x = centerX - TIP_W / 2; x = Math.max(EDGE_PAD, Math.min(x, window.innerWidth - TIP_W - EDGE_PAD)); tip.style.left = `${x}px`; tip.style.top = "-9999px"; tip.classList.add("discogs-tooltip-visible"); const h = tip.offsetHeight; const above = r.top - TIP_MARGIN - h; const fitsAbove = above >= EDGE_PAD; tip.style.top = fitsAbove ? `${above}px` : `${r.bottom + TIP_MARGIN}px`; tip.classList.toggle("below", !fitsAbove); tip.style.setProperty("--arrow-x", `${centerX - x}px`); clearTimeout(_hideTimer); _hideTimer = setTimeout(() => tip.classList.remove("discogs-tooltip-visible"), 4e3); }, HOVER_DELAY_MS); }); lbl.addEventListener("mouseleave", () => { clearTimeout(_showTimer); clearTimeout(_hideTimer); tip.classList.remove("discogs-tooltip-visible"); }); } lbl.addEventListener("click", (e) => { e.preventDefault(); cb.checked = !cb.checked; lbl.classList.toggle("active", cb.checked); document.querySelectorAll(".discogs-tooltip-visible").forEach((t) => t.classList.remove("discogs-tooltip-visible")); }); _optsHost.appendChild(lbl); return cb; } function makeSelect(labelText, initialValue, options, tooltipText) { const wrap = document.createElement("span"); wrap.className = "discogs-select-wrap"; wrap.style.cssText = "display:inline-flex;align-items:center;gap:0.3rem;font-size:0.8rem;color:var(--mbu-text-dim);padding:0.15rem 0.2rem;border:none;background:transparent;"; const lbl = document.createElement("span"); lbl.textContent = labelText + ":"; wrap.appendChild(lbl); const sel = document.createElement("select"); sel.style.cssText = "font-size:0.8rem;padding:0.05rem 0.2rem;border:none;background:transparent;cursor:pointer;color:var(--mbu-text);font-weight:600;"; options.forEach((opt) => { const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; if (opt.value === initialValue) o.selected = true; sel.appendChild(o); }); if (tooltipText) wrap.title = tooltipText; wrap.appendChild(sel); _optsHost.appendChild(wrap); return sel; } const gmLoad = (key) => { try { return GM_getValue(key, void 0); } catch (e) { return void 0; } }; const gmSave = (key, raw) => { try { GM_setValue(key, raw); } catch (e) { } }; const OPTS_KEY = "discogs-importer-opts"; let savedOpts = {}; try { savedOpts = JSON.parse(gmLoad(OPTS_KEY) || "{}"); } catch (e) { } if (!savedOpts.createWorksReset421) { savedOpts.createWorksMode = "never"; savedOpts.createWorksReset421 = true; delete savedOpts.createWorks; try { gmSave(OPTS_KEY, JSON.stringify(savedOpts)); } catch (e) { } } if (!savedOpts.coCreditDefaultOn613) { savedOpts.coCredit = true; savedOpts.coCreditDefaultOn613 = true; try { gmSave(OPTS_KEY, JSON.stringify(savedOpts)); } catch (e) { } } const bv = (k, d) => k in savedOpts ? savedOpts[k] : d; const tracklistCb = makeCheckbox( "Per-track credits", bv("tracklist", true), "Import per-track artist credits." ); const applyTracksCb = makeCheckbox( "Move release credits to tracks", bv("applyTracks", false), "Move performance credits from the release down to every recording." ); const useWorksCb = makeCheckbox( "Use", bv("useWorks", true), "Import work-level credits (composer / lyricist / writer \u2026). Off: no work relationship is touched at all \u2014 nothing created, nothing attached to existing works." ); const _initialCreateWorksMode = bv("createWorksMode", "never") === "when-needed" ? "when-needed" : "never"; const createWorksMode = makeSelect("works", _initialCreateWorksMode, [ { value: "never", label: "create none" }, { value: "when-needed", label: "create needed" } ], "create none: use only existing works \u2014 work-only credits with no work are logged and skipped. create needed: also create a work when a composer/lyricist/writer credit needs one \u2014 match recordings to EXISTING works first (Group Therapy) or you will create duplicates."); const syncWorksUi = () => { createWorksMode.disabled = !useWorksCb.checked; const w = createWorksMode.closest(".discogs-select-wrap"); if (w) w.style.opacity = useWorksCb.checked ? "" : ".45"; }; syncWorksUi(); useWorksCb.closest("label").style.paddingRight = "0"; { const w = createWorksMode.closest(".discogs-select-wrap"); if (w) { w.style.paddingLeft = "0"; w.style.marginLeft = "-0.2rem"; } } function showCreateWorksWarning() { const ov = document.createElement("div"); ov.className = "discogs-cw-warn-ov mbu-ui"; ov.style.cssText = "position:fixed;inset:0;z-index:2147483000;background:rgba(20,10,10,.45);display:flex;align-items:center;justify-content:center;"; const box = document.createElement("div"); box.style.cssText = "max-width:460px;margin:16px;background:var(--mbu-bg);border-radius:8px;border-top:4px solid var(--mbu-error);padding:16px 20px 14px;box-shadow:0 14px 44px rgba(0,0,0,.4);font-size:13px;line-height:1.55;color:var(--mbu-text);"; box.innerHTML = '<div style="font-weight:800;color:var(--mbu-error);font-size:15px;margin-bottom:8px;">\u26A0\uFE0F WARNING: Avoid creating work duplicates!</div><p style="margin:0 0 8px;">Make sure that you <strong>matched works</strong> prior to using this option. You are responsible for matching recordings to existing works.</p><p style="margin:0 0 12px;"><a href="https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/group_therapy/README.md" target="_blank" rel="noopener noreferrer">Group Therapy</a> userscript makes work matching faster and can start it as soon as you enter the relationship editor so you don\u2019t forget.</p><div style="text-align:right;"><button type="button" style="padding:5px 18px;font-size:13px;font-weight:600;color:#fff;background:#c0392b;border:none;border-radius:5px;cursor:pointer;">I understand</button></div>'; const close = () => ov.remove(); box.querySelector("button").addEventListener("click", close); ov.addEventListener("mousedown", (e) => { if (e.target === ov) close(); }); ov.appendChild(box); document.body.appendChild(ov); box.querySelector("button").focus(); } createWorksMode.addEventListener("change", () => { if (createWorksMode.value === "when-needed") showCreateWorksWarning(); }); useWorksCb.closest("label").addEventListener("click", () => setTimeout(() => { syncWorksUi(); if (useWorksCb.checked && createWorksMode.value === "when-needed") showCreateWorksWarning(); }, 0)); const optsBtn = document.createElement("button"); optsBtn.type = "button"; optsBtn.className = "discogs-opts-btn"; optsBtn.innerHTML = 'Options <span class="discogs-opts-caret">\u25BE</span>'; optsBtn.title = "Deduplication and matching options"; const optsPanel = document.createElement("div"); optsPanel.className = "discogs-opts-panel mbu-ui"; const dedupHd = document.createElement("div"); dedupHd.className = "discogs-opts-panel-hd"; dedupHd.textContent = "Deduplication"; optsPanel.appendChild(dedupHd); _optsHost = optsPanel; const dedupeEqCb = makeCheckbox( "Equivalence sets", bv("dedupeEquivalenceSets", true), "Skip a role when an equivalent role already exists on the target (writer \u2261 composer)." ); const dedupeDupCb = makeCheckbox( "Duplicate roles", bv("dedupeDuplicateRoles", true), "Skip adding a role when the target already has the same role (regardless of task / dates / attributes)." ); const matchHd = document.createElement("div"); matchHd.className = "discogs-opts-panel-hd"; matchHd.textContent = "Matching"; optsPanel.appendChild(matchHd); const coCreditCb = makeCheckbox( "Co-credit search", bv("coCredit", true), "For a name still ambiguous after name, alias and release-context matching, search MusicBrainz for a recording that credits it ALONGSIDE the release artist (one extra request per ambiguous name and release artist; none on Various Artists releases). On by default." ); _optsHost = optsWrap; optsWrap.appendChild(optsBtn); document.body.appendChild(optsPanel); optsBtn.addEventListener("click", (e) => { e.stopPropagation(); const open = optsPanel.classList.toggle("open"); if (!open) return; const r = optsBtn.getBoundingClientRect(); optsPanel.style.left = Math.max(8, Math.min(r.left, window.innerWidth - optsPanel.offsetWidth - 8)) + "px"; optsPanel.style.top = r.bottom + 4 + "px"; const off = (ev) => { if (!optsPanel.contains(ev.target) && ev.target !== optsBtn && !optsBtn.contains(ev.target)) { optsPanel.classList.remove("open"); document.removeEventListener("mousedown", off); } }; setTimeout(() => document.addEventListener("mousedown", off), 0); }); const saveOpts = () => { try { gmSave(OPTS_KEY, JSON.stringify({ tracklist: tracklistCb.checked, applyTracks: applyTracksCb.checked, useWorks: useWorksCb.checked, // #424 master toggle createWorksMode: createWorksMode.value, createWorksReset421: true, // #421 one-time reset already applied — must survive every save dedupeEquivalenceSets: dedupeEqCb.checked, dedupeDuplicateRoles: dedupeDupCb.checked, coCredit: coCreditCb.checked, // #613 coCreditDefaultOn613: true // #613 one-time default-on already applied — must survive every save })); } catch (e) { } }; [tracklistCb, applyTracksCb, useWorksCb, dedupeEqCb, dedupeDupCb, coCreditCb].forEach((cb) => cb.closest("label").addEventListener("click", () => setTimeout(saveOpts, 0))); createWorksMode.addEventListener("change", saveOpts); const outputDiv = document.createElement("div"); outputDiv.className = "discogs-output empty"; const LOG_OPEN_KEY = "discogs-importer-log-open"; const reviewSlot = document.createElement("div"); reviewSlot.className = "discogs-review-slot"; setReviewContainer(reviewSlot); const logPanel = document.createElement("div"); logPanel.className = "discogs-log-panel"; const logToolbar = document.createElement("div"); logToolbar.className = "discogs-log-toolbar"; const logFilter = document.createElement("div"); logFilter.className = "discogs-log-filter"; [["all", "All"], ["warn", "\u26A0 Warnings"], ["error", "\u26D4 Errors"]].forEach(([f, label]) => { const b = document.createElement("button"); b.type = "button"; b.className = "discogs-log-filterbtn" + (f === "all" ? " active" : ""); b.dataset.f = f; b.textContent = label; b.addEventListener("click", () => { outputDiv.dataset.logfilter = f; logFilter.querySelectorAll("button").forEach((x) => x.classList.toggle("active", x.dataset.f === f)); }); logFilter.appendChild(b); }); logToolbar.append(logFilter); const logBody = document.createElement("div"); logBody.className = "discogs-log-body"; logPanel.append(logToolbar, logBody); outputDiv.append(reviewSlot, logPanel); outputDiv.dataset.logfilter = "all"; if (!_logs2) { _logs2 = document.createElement("ul"); _logs2.className = "logs"; logBody.appendChild(_logs2); setLogContainer(_logs2); if (_logs2.children.length) outputDiv.classList.remove("empty"); } const applyLogOpen = () => { const open = gmLoad(LOG_OPEN_KEY) === "1"; outputDiv.classList.toggle("log-open", open); logSplit.classList.toggle("active", open); }; try { applyLogOpen(); } catch (e) { } const setLogOpen = (open) => { outputDiv.classList.toggle("log-open", open); logSplit.classList.toggle("active", open); try { gmSave(LOG_OPEN_KEY, open ? "1" : "0"); } catch (e) { } }; const logMenu = document.createElement("div"); logMenu.className = "discogs-log-menu mbu-ui"; const mkMenuItem = (label, title, fn) => { const b = document.createElement("button"); b.type = "button"; b.textContent = label; b.title = title; b.addEventListener("click", () => fn(b, label)); return b; }; const copyLogItem = mkMenuItem("Copy log", "Copy the full import log (incl. the raw source data)", (b, l) => bar._copy?.log(b, l)); const copyNoJsonItem = mkMenuItem("Copy without JSON", "Copy the log without the raw source-data block \u2014 fits in a GitHub issue", (b, l) => bar._copy?.noJson(b, l)); logMenu.append(copyLogItem, copyNoJsonItem); if (discogsUrl) logMenu.appendChild(mkMenuItem("Copy Discogs", "Copy the raw Discogs JSON for this release", (b, l) => bar._copy?.discogs(b, l))); if (sources.tidal) logMenu.appendChild(mkMenuItem("Copy Tidal", "Copy the raw Tidal credits harvest for this release", (b, l) => bar._copy?.tidal(b, l))); if (sources.qobuz) logMenu.appendChild(mkMenuItem("Copy Qobuz", "Copy the parsed Qobuz credits for this release", (b, l) => bar._copy?.qobuz(b, l))); if (sources.deezer) logMenu.appendChild(mkMenuItem("Copy Deezer", "Copy the parsed Deezer credits for this release", (b, l) => bar._copy?.deezer(b, l))); if (sources.apple) logMenu.appendChild(mkMenuItem("Copy Apple", "Copy the parsed Apple credits for this release", (b, l) => bar._copy?.apple(b, l))); if (sources.ytmusic) logMenu.appendChild(mkMenuItem("Copy YouTube Music", "Copy the fetched YouTube Music credits for this release", (b, l) => bar._copy?.ytmusic(b, l))); if (importSources.length > 1) logMenu.appendChild(mkMenuItem("Copy all", 'Copy the combined JSON of an "Import all" run \u2014 every source plus the merged, de-duplicated result (#408)', (b, l) => bar._copy?.all(b, l))); document.body.appendChild(logMenu); function openLogMenu() { const open = logMenu.classList.toggle("open"); if (!open) return; const r = logSplit.getBoundingClientRect(); logMenu.style.left = Math.max(8, Math.min(r.left, window.innerWidth - logMenu.offsetWidth - 8)) + "px"; logMenu.style.top = r.bottom + 4 + "px"; const off = (ev) => { if (!logMenu.contains(ev.target) && !logSplit.contains(ev.target)) { logMenu.classList.remove("open"); document.removeEventListener("mousedown", off); } }; setTimeout(() => document.addEventListener("mousedown", off), 0); } logToggleBtn.addEventListener("click", (e) => { e.stopPropagation(); logMenu.classList.remove("open"); setLogOpen(!outputDiv.classList.contains("log-open")); }); logCaretBtn.addEventListener("click", (e) => { e.stopPropagation(); openLogMenu(); }); function openLog(filter, scrollSel) { const f = filter || "all"; outputDiv.classList.add("log-open"); logSplit.classList.add("active"); outputDiv.dataset.logfilter = f; logFilter.querySelectorAll("button").forEach((x) => x.classList.toggle("active", x.dataset.f === f)); requestAnimationFrame(() => { const target = scrollSel && logBody.querySelector(scrollSel) || logPanel; const headerH = bar.classList.contains("is-pinned") ? row1.getBoundingClientRect().height + 6 : 0; const top = target.getBoundingClientRect().top + window.scrollY - headerH - 10; window.scrollTo({ top: Math.max(0, top), behavior: "smooth" }); }); } warnPill.addEventListener("click", () => openLog("warn", 'li[data-sev="warn"]')); errPill.addEventListener("click", () => openLog("error", 'li[data-sev="error"]')); unresolvedPill.addEventListener("click", () => openLog("all", 'li[data-sev="skip"]')); onLogCounts((w, e) => { warnPill.textContent = `\u26A0 ${w}`; warnPill.style.display = w > 0 ? "" : "none"; errPill.textContent = `\u26D4 ${e}`; errPill.style.display = e > 0 ? "" : "none"; }); bar._setUnresolved = (n) => { unresolvedPill.textContent = `\u2298 ${n} unresolved`; unresolvedPill.style.display = n > 0 ? "" : "none"; }; bar._setStopMessage = (msg) => { bar._stopActive = !!msg; statusEl.textContent = msg || ""; statusEl.style.display = msg ? "" : "none"; statusEl.classList.toggle("discogs-bar-status-final", !!msg); }; function cancelRun() { bar._runToken++; importing = false; if (typeof bar._reviewAbort === "function") { const abort = bar._reviewAbort; bar._reviewAbort = null; abort(); } srcButtons.forEach((b) => { b.classList.remove("importing"); b.style.display = ""; b.title = b._restoreTitle || b.title; }); progressPct.style.display = "none"; progressPct.textContent = "0%"; bar.classList.remove("is-importing", "is-reviewing", "is-pinned"); _hideBar(); reviewSlot.replaceChildren(); actionSlot.replaceChildren(); setReviewContainer(reviewSlot); bar._hideEnterEdit(); bar._setStopMessage("Import cancelled \u2014 pick a source to start again."); bar._pin(); delete bar._setProgress; } function startImport(srcBtn, sourceUrl, runner, sourceLabel) { const myToken = ++bar._runToken; const cancelled = () => bar._runToken !== myToken; importing = true; srcButtons.forEach((b) => { const active = b === srcBtn; b.classList.toggle("importing", active); b.style.display = active ? "" : "none"; if (active) { b._restoreTitle = b.title; b.title = `Cancel this ${srcBtn.dataset.src} import and return to the source picker`; } }); progressPct.style.display = "inline"; progressPct.textContent = "0%"; bar._hideEnterEdit(); bar.classList.add("is-importing", "is-pinned"); bar._pin(); _showBar(); bar.scrollIntoView({ behavior: "smooth", block: "start" }); bar._showProgress = () => { _showBar(); }; requestAnimationFrame(bar._showProgress); resetLogCounts(); bar._setUnresolved(0); bar._setStopMessage(""); _logs2 = document.createElement("ul"); _logs2.className = "logs"; setLogContainer(_logs2); _summary = document.createElement("p"); _summary.className = "summary"; logBody.innerHTML = ""; logBody.appendChild(_summary); logBody.appendChild(_logs2); outputDiv.classList.remove("empty"); logSplit.style.display = ""; try { applyLogOpen(); } catch (e) { } function buildCopyText({ skipDiscogsJson }) { function htmlToMd(el) { function nodeToMd(node) { if (node.nodeType === Node.TEXT_NODE) return node.textContent; const tag = node.tagName?.toLowerCase(); const inner = [...node.childNodes].map(nodeToMd).join(""); if (tag === "strong" || tag === "b") return `**${inner}**`; if (tag === "em" || tag === "i") return `_${inner}_`; if (tag === "a") return `[${inner}](${node.href})`; if (tag === "br") return "\n"; if (tag === "pre") { return "\n```json\n" + node.textContent + "\n```\n"; } if (tag === "details") { const sum = node.querySelector("summary"); const sumText = sum ? [...sum.childNodes].map((n) => { if (n.nodeType === Node.TEXT_NODE) return n.textContent; const t = n.tagName?.toLowerCase(); if (t === "button" || t === "input") return ""; return n.textContent; }).join("").trim() : ""; if (skipDiscogsJson && /raw Discogs JSON/i.test(sumText)) { return ""; } const body = [...node.childNodes].filter((n) => n !== sum).map(nodeToMd).join(""); return "\n\n<details><summary>" + sumText + "</summary>\n\n" + body + "\n</details>\n\n"; } if (tag === "summary") return ""; if (tag === "span") return inner; if (tag === "div") return inner + "\n"; if (tag === "ul") return inner; if (tag === "li" && el !== node) return "- " + inner + "\n"; if (tag === "table") { const rows = [...node.querySelectorAll("tr")]; if (!rows.length) return ""; const cells = rows.map((r) => [...r.querySelectorAll("th,td")].map((c) => c.innerText.trim().replace(/\|/g, "\\|"))); const widths = cells[0]?.map((_, i) => Math.max(...cells.map((r) => (r[i] || "").length), 3)); const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length)); const mdRows = cells.map((row) => "| " + row.map((c, i) => pad(c, widths[i])).join(" | ") + " |"); if (mdRows.length > 1) mdRows.splice(1, 0, "| " + widths.map((w) => "-".repeat(w)).join(" | ") + " |"); return "\n\n" + mdRows.join("\n") + "\n\n"; } return inner; } const _md = nodeToMd(el); return _md.startsWith("\n\n") || _md.endsWith("\n\n") ? _md : _md.replace(/^\n/, "").replace(/\n$/, ""); } const _panel = document.querySelector(".discogs-review-slot .discogs-review-panel-li"); const lines = [..._panel ? [_panel] : [], ..._logs2.querySelectorAll("li")].map((li) => { if (li.classList?.contains("discogs-review-panel-li") && typeof li._buildStaticTableLi === "function") { return htmlToMd(li._buildStaticTableLi()); } const md = htmlToMd(li); if (!md) return ""; if (md.startsWith("\n\n|") || md.startsWith("<details>")) return md; return md + " "; }).filter(Boolean).join("\n"); const releaseName = pageWindow?.MB?.relationshipEditor?.state?.entity?.name || document.title.replace(/ - MusicBrainz.*/, "").trim() || "Import log"; return `<details><summary>${releaseName}</summary> ${lines} </details>`; } function copyToClipboard(text, btn, restoreText) { const restore = () => { btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = restoreText; }, 1500); }; const fallback = () => { const ta = Object.assign(document.createElement("textarea"), { value: text }); document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); restore(); }; if (navigator.clipboard?.writeText) { navigator.clipboard.writeText(text).then(restore, fallback); } else { fallback(); } } bar._copy = { log: (item, label) => copyToClipboard(buildCopyText({ skipDiscogsJson: false }), item, label), noJson: (item, label) => copyToClipboard(buildCopyText({ skipDiscogsJson: true }), item, label), discogs: (item, label) => { if (_discogsJson) copyToClipboard(JSON.stringify(_discogsJson, null, 2), item, label); }, tidal: (item, label) => { if (_tidalJson) copyToClipboard(JSON.stringify(_tidalJson, null, 2), item, label); }, qobuz: (item, label) => { if (_qobuzJson) copyToClipboard(JSON.stringify(_qobuzJson, null, 2), item, label); }, deezer: (item, label) => { if (_deezerJson) copyToClipboard(JSON.stringify(_deezerJson, null, 2), item, label); }, apple: (item, label) => { if (_appleJson) copyToClipboard(JSON.stringify(_appleJson, null, 2), item, label); }, // #435 ytmusic: (item, label) => { if (_ytmJson) copyToClipboard(JSON.stringify(_ytmJson, null, 2), item, label); }, // #648 all: (item, label) => { if (_consolidatedJson) copyToClipboard(JSON.stringify(_consolidatedJson, null, 2), item, label); } // #408 }; bar._setProgress = (pct, text) => { if (pct !== null && pct >= 100) _hideBar(); if (text && bar.classList.contains("is-importing") && !bar._stopActive) { statusEl.textContent = text; statusEl.style.display = ""; } }; requestAnimationFrame(_showBar); const getOpts = () => ({ processTracklist: tracklistCb.checked, applyToTracks: applyTracksCb.checked, // #424: "Use works" off → dispatch sees mode 'off' and touches no work rels createWorksMode: useWorksCb.checked ? createWorksMode.value : "off", dedupeEquivalenceSets: dedupeEqCb.checked, dedupeDuplicateRoles: dedupeDupCb.checked, coCredit: coCreditCb.checked // #613 co-credit search (on by default) }); const _click = getOpts(); const opts = `per-track:${_click.processTracklist ? "on" : "off"}, move-to-tracks:${_click.applyToTracks ? "on" : "off"}, create-works:${_click.createWorksMode}`; const editNote = buildEditNote(sourceUrl, opts, void 0, sourceLabel); editNote.split("\n").forEach((line) => { if (!line.trim()) return; const html = line.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer nofollow">$1</a>'); log.info(html); }); runner(getOpts, cancelled).finally(() => { if (cancelled()) { delete bar._setProgress; return; } importing = false; srcButtons.forEach((b) => { b.classList.remove("importing"); b.style.display = ""; b.title = b._restoreTitle || b.title; }); progressPct.textContent = "100%"; setTimeout(() => { progressPct.style.display = "none"; }, 2e3); bar.classList.remove("is-reviewing"); setTimeout(() => { bar.classList.remove("is-importing"); _hideBar(); bar._showEnterEdit(); bar._pin(); }, 2e3); delete bar._setProgress; }); } bar.appendChild(outputDiv); function insertBar() { const anchor = document.querySelector(".release-rel-editor") || // MB React wrapper document.querySelector("#content > div") || // generic first content div document.querySelector("#content"); if (!anchor) return setTimeout(insertBar, 300); anchor.insertBefore(bar, anchor.firstChild); } insertBar(); (function watchSubmit() { const findMsg = () => [...document.querySelectorAll(".loading-message, .submitting")].find((el) => /submit/i.test(el.textContent || "")); const set = (on) => { if (!!bar._saving === on) return; bar._saving = on; bar.classList.toggle("is-saving", on); if (on) { bar.classList.add("is-pinned"); bar._pin?.(); _showBar(); bar._setStopMessage("\u23F3 Submitting edits to MusicBrainz\u2026"); } else bar._setStopMessage(""); }; const obs = new MutationObserver(() => set(!!findMsg())); obs.observe(document.body, { childList: true, subtree: true }); set(!!findMsg()); })(); } (function pruneLocalStorage() { try { const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10); if (localStorage.getItem("ch:ls-pruned") === today) return; localStorage.setItem("ch:ls-pruned", today); const cutoff = new Date(Date.now() - 7 * 864e5).toISOString().slice(0, 10); const drop = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (!k) continue; if (k.startsWith("discogs-release-")) drop.push(k); else if (k.startsWith("discogs-urlcheck-")) { let date = null; try { date = JSON.parse(localStorage.getItem(k)).date; } catch (e) { } if (!date || date < cutoff) drop.push(k); } } drop.forEach((k) => localStorage.removeItem(k)); if (drop.length) logDebug(`localStorage prune: removed ${drop.length} expired URL-check entr${drop.length === 1 ? "y" : "ies"}`); } catch (e) { } })(); function runImport(discogsUrl, getOpts, cancelled, collect) { const initial = getOpts(); const { processTracklist } = initial; return getDiscogsReleaseData(discogsUrl).then((json) => { _discogsJson = json; let artistRoles = rolesFromDiscogsArtists(json.extraartists?.filter((artist) => !artist.tracks)); if (!_logs2._releaseInfoAdded) { _logs2._releaseInfoAdded = true; const trackCount = flattenTracklist(json.tracklist).filter((t) => t.type_ === "track").length; const summary = `${json.title || ""}${json.year ? " \xB7 " + json.year : ""} \xB7 ${trackCount} tracks`; const li = document.createElement("li"); const pre = document.createElement("pre"); pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:var(--mbu-bg-raised);padding:0.5rem;border:1px solid var(--mbu-border);border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;"; pre.textContent = JSON.stringify(json, null, 2); li.innerHTML = `<details><summary style="cursor:pointer;user-select:none;"><strong>${summary} \u2014 raw Discogs JSON</strong></summary></details>`; li.querySelector("details").appendChild(pre); _logs2.appendChild(li); } log.info(`Found ${json.companies.length + artistRoles.length} release relationships`); artistRoles = artistRoles.concat(convertPotentialDJMixers(json)); let tracklistRels = []; if (processTracklist) { tracklistRels = flattenTracklist(json.tracklist).filter((track) => track.type_ === "track").reduce((map, track) => { if (!track.extraartists || !Array.isArray(track.extraartists)) { return map; } return map.concat( rolesFromDiscogsArtists(track.extraartists).map((rel) => { return Object.assign({}, rel, { track }); }) ); }, []); const releaseLevelTracklistRels = json.extraartists?.filter((artist) => artist.tracks && artist.tracks !== "") || []; if (releaseLevelTracklistRels.length > 0) { tracklistRels = tracklistRels.concat( releaseLevelTracklistRels.reduce((array, artist) => { return array.concat( getAllArtistTracks(json.tracklist, artist.tracks).reduce((array2, track) => { return array2.concat( getArtistRoles(artist).map((rel) => { return Object.assign({}, rel, { artist, track }); }) ); }, []) ); }, []) ); } log.info(`Found ${tracklistRels.length} tracklist relationships`); } const parts = { companies: json.companies, artistRoles, tracklistRels, tracklist: json.tracklist, sourceUrl: discogsUrl, processTracklist }; return collect ? parts : runSourcePipeline({ ...parts, getOpts, cancelled }); }); } function runTidalImport(tidalUrl, getOpts, cancelled, collect) { log.info(`Opening the Tidal credits tab \u2014 it closes itself once harvested (a few seconds)\u2026`); return harvestTidalAlbum(tidalUrl).then((harvest) => { _tidalJson = harvest; if (!harvest.ok) throw new Error(`Tidal harvest failed: ${harvest.error || "unknown error"}`); const li = document.createElement("li"); const pre = document.createElement("pre"); pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:var(--mbu-bg-raised);padding:0.5rem;border:1px solid var(--mbu-border);border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;"; pre.textContent = JSON.stringify(harvest, null, 2); li.innerHTML = `<details><summary style="cursor:pointer;user-select:none;"><strong>${harvest.tracks.length} tracks \u2014 raw Tidal harvest</strong></summary></details>`; li.querySelector("details").appendChild(pre); _logs2.appendChild(li); const processTracklist = !!getOpts().processTracklist; const { tracklistRels: ptRels, tracklist, skipped, multiVolume } = tidalToEngine(harvest.tracks); const tracklistRels = processTracklist ? ptRels : []; const { artists: relArtists, publishers: relPublishers, companies: relCompanies, skipped: relSkipped } = tidalReleaseArtists(harvest.releaseCredits); const artistRoles = [...relPublishers]; for (const a of relArtists) { const roles = getArtistRoles(a); if (!roles.length) { relSkipped.push(`release: ${a.tidalRole} \u2014 ${a.name}`); continue; } if (a.assistant) roles.forEach((r) => { r.attributes = (r.attributes || []).concat("assistant"); }); artistRoles.push(...roles); } const companies = relCompanies || []; log.info(`Tidal credits: ${tracklistRels.length} per-track + ${artistRoles.length} release-level relationship(s)${companies.length ? ` + ${companies.length} label/company` : ""} across ${tracklist.length} track(s)`); if (!processTracklist) log.info(`Per-track credits disabled \u2014 importing release-level credits only${getOpts().applyToTracks ? " (applied to tracks)" : ""}.`); (processTracklist ? skipped.concat(relSkipped) : relSkipped).forEach((s) => log.info(`Not imported (v1 scope): ${s}`)); if (multiVolume) log.warn(`Multi-volume Tidal album \u2014 track numbers repeat per volume; positions may not all match this release's mediums. Review carefully.`); if (!tracklistRels.length && !artistRoles.length && !companies.length) { log.warn("No importable credits found on the Tidal credits page."); stopMsg(collect, "No importable credits found"); return; } const parts = { companies, artistRoles, tracklistRels, tracklist, sourceUrl: tidalUrl, processTracklist }; return collect ? parts : runSourcePipeline({ ...parts, getOpts, cancelled }); }).catch((err) => { log.error(err.message || String(err)); }); } function runMetalArchivesImport(maUrl, getOpts, cancelled, collect) { log.info(`Opening the Metal Archives tab \u2014 it closes itself once harvested (a few seconds)\u2026`); return harvestMetalArchivesAlbum(maUrl).then((harvest) => { _maJson = harvest; if (!harvest.ok) throw new Error(`Metal Archives harvest failed: ${harvest.error || "unknown error"}`); const li = document.createElement("li"); const pre = document.createElement("pre"); pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:var(--mbu-bg-raised);padding:0.5rem;border:1px solid var(--mbu-border);border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;"; pre.textContent = JSON.stringify(harvest, null, 2); const nCredited = (harvest.band?.length || 0) + (harvest.guest?.length || 0) + (harvest.misc?.length || 0); li.innerHTML = `<details><summary style="cursor:pointer;user-select:none;"><strong>${(harvest.tracks || []).length} tracks, ${nCredited} credited \u2014 raw Metal Archives harvest</strong></summary></details>`; li.querySelector("details").appendChild(pre); _logs2.appendChild(li); const processTracklist = !!getOpts().processTracklist; const { tracklistRels: ptRels, tracklist, skipped, multiVolume } = metalArchivesToEngine(harvest); const tracklistRels = processTracklist ? ptRels : []; const { artists: relArtists, skipped: relSkipped } = metalArchivesReleaseArtists(harvest); const artistRoles = []; for (const a of relArtists) { const roles = getArtistRoles(a); if (!roles.length) { relSkipped.push(`release: ${a.maRole} \u2014 ${a.name}`); continue; } if (a.maAttrs && a.maAttrs.length) roles.forEach((r) => { r.attributes = (r.attributes || []).concat(a.maAttrs); }); artistRoles.push(...roles); } const companies = []; log.info(`Metal Archives: ${tracklistRels.length} per-track + ${artistRoles.length} release-level relationship(s) across ${tracklist.length} track(s)`); if (!processTracklist) log.info(`Per-track credits disabled \u2014 importing release-level credits only${getOpts().applyToTracks ? " (applied to tracks)" : ""}.`); (processTracklist ? skipped.concat(relSkipped) : relSkipped).forEach((s) => log.info(`Not imported: ${s}`)); if (harvest.multiBand) log.warn(`Multi-artist release (${harvest.type}) \u2014 split/collaboration credits may need per-band track scoping; review carefully.`); if (multiVolume) log.warn(`Multi-disc release \u2014 positions are "disc-track"; verify they line up with this release's mediums.`); if (!tracklistRels.length && !artistRoles.length) { log.warn("No importable credits found on the Metal Archives page."); stopMsg(collect, "No importable credits found"); return; } const parts = { companies, artistRoles, tracklistRels, tracklist, sourceUrl: maUrl, processTracklist }; return collect ? parts : runSourcePipeline({ ...parts, getOpts, cancelled }); }).catch((err) => { log.error(err.message || String(err)); }); } function runQobuzImport(qobuzUrl, getOpts, cancelled, collect) { const parsed = parseQobuzAlbumUrl(qobuzUrl); if (!parsed) { log.error(`Not a Qobuz album URL: ${qobuzUrl}`); return Promise.resolve(); } const finish = (via, albumInfo, tracks) => { _qobuzJson = { via, source: via === "API" ? `album/get (${parsed.id})` : parsed.pageUrl, album: albumInfo, tracks }; const li = document.createElement("li"); const pre = document.createElement("pre"); pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:var(--mbu-bg-raised);padding:0.5rem;border:1px solid var(--mbu-border);border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;"; pre.textContent = JSON.stringify(_qobuzJson, null, 2); li.innerHTML = `<details><summary style="cursor:pointer;user-select:none;"><strong>${albumInfo || "Qobuz album"} \xB7 ${tracks.length} tracks \u2014 parsed Qobuz credits (${via === "API" ? "API" : "page"})</strong></summary></details>`; li.querySelector("details").appendChild(pre); _logs2.appendChild(li); if (!tracks.length) { log.warn("No Qobuz credits found \u2014 nothing to import."); stopMsg(collect, "No importable credits found"); return; } const { tracklistRels, artistRoles, tracklist, skipped, multiVolume } = qobuzToEngine(tracks); log.info(`Qobuz credits: ${tracklistRels.length} per-track relationship(s)${artistRoles.length ? ` + ${artistRoles.length} release-level relationship(s)` : ""} across ${tracklist.length} track(s)`); skipped.forEach((s) => log.info(`Not imported (v1 scope): ${s}`)); if (multiVolume) log.warn(`Multi-medium Qobuz album \u2014 track numbers repeat per medium; positions may not all match this release's mediums. Review carefully.`); if (!tracklistRels.length && !artistRoles.length) { log.warn("No importable Qobuz credits found."); stopMsg(collect, "No importable credits found"); return; } const parts = { companies: [], artistRoles, tracklistRels, tracklist, sourceUrl: qobuzUrl, processTracklist: true }; return collect ? parts : runSourcePipeline({ ...parts, getOpts, cancelled }); }; const scrape = () => { log.info(`Fetching Qobuz store page: ${parsed.pageUrl}`); return fetchQobuzAlbumPage(parsed.pageUrl).then((html) => finish("page", extractQobuzAlbumInfo(html), extractQobuzCredits(html))).catch((err) => { log.error(err.message || String(err)); }); }; const token = qobuzToken(); if (token) { log.info(`Fetching Qobuz credits via album/get (signed in): album ${parsed.id}`); return fetchQobuzApiAlbum(parsed.id, token).then((json) => finish("API", qobuzApiAlbumInfo(json), parseQobuzApiTracks(json))).catch((err) => { log.warn(`Qobuz API failed (${err.message || err}) \u2014 falling back to the store page`); return scrape(); }); } return scrape(); } function runDeezerImport(deezerUrl, getOpts, cancelled, collect) { const parsed = parseDeezerAlbumUrl(deezerUrl); if (!parsed) { log.error(`Not a Deezer album URL: ${deezerUrl}`); return Promise.resolve(); } log.info(`Fetching Deezer album page: ${parsed.pageUrl}`); return fetchDeezerAlbumPage(parsed.pageUrl).then((html) => { const albumInfo = extractDeezerAlbumInfo(html); const tracks = extractDeezerCredits(html); _deezerJson = { source: parsed.pageUrl, album: albumInfo, tracks }; const li = document.createElement("li"); const pre = document.createElement("pre"); pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:var(--mbu-bg-raised);padding:0.5rem;border:1px solid var(--mbu-border);border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;"; pre.textContent = JSON.stringify(_deezerJson, null, 2); li.innerHTML = `<details><summary style="cursor:pointer;user-select:none;"><strong>${albumInfo || "Deezer album"} \xB7 ${tracks.length} tracks \u2014 parsed Deezer credits (page)</strong></summary></details>`; li.querySelector("details").appendChild(pre); _logs2.appendChild(li); if (!tracks.length) { log.warn("No Deezer credits found \u2014 nothing to import."); stopMsg(collect, "No importable credits found"); return; } const { tracklistRels, tracklist, skipped, multiVolume } = deezerToEngine(tracks); log.info(`Deezer credits: ${tracklistRels.length} per-track relationship(s) across ${tracklist.length} track(s)`); skipped.forEach((s) => log.info(`Not imported (v1 scope): ${s}`)); if (multiVolume) log.warn(`Multi-medium Deezer album \u2014 track numbers repeat per medium; positions may not all match this release's mediums. Review carefully.`); if (!tracklistRels.length) { log.warn("No importable Deezer credits found."); stopMsg(collect, "No importable credits found"); return; } const parts = { companies: [], artistRoles: [], tracklistRels, tracklist, sourceUrl: deezerUrl, processTracklist: true }; return collect ? parts : runSourcePipeline({ ...parts, getOpts, cancelled }); }).catch((err) => { log.error(err.message || String(err)); }); } function runAppleImport(appleUrl, getOpts, cancelled, collect) { const parsed = parseAppleAlbumUrl(appleUrl); if (!parsed) { log.error(`Not an Apple Music album URL: ${appleUrl}`); return Promise.resolve(); } log.info(`Fetching Apple Music credits (anonymous): album ${parsed.id} (${parsed.storefront})`); return fetchAppleCredits(parsed.storefront, parsed.id, (d, n) => document.querySelector(".discogs-bar")?._setProgress?.(null, `Apple ${d}/${n}`)).then(({ album, tracks }) => { _appleJson = { source: appleUrl, album, tracks }; const li = document.createElement("li"); const pre = document.createElement("pre"); pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:var(--mbu-bg-raised);padding:0.5rem;border:1px solid var(--mbu-border);border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;"; pre.textContent = JSON.stringify(_appleJson, null, 2); li.innerHTML = `<details><summary style="cursor:pointer;user-select:none;"><strong>${album || "Apple album"} \xB7 ${tracks.length} tracks \u2014 parsed Apple credits (API)</strong></summary></details>`; li.querySelector("details").appendChild(pre); _logs2.appendChild(li); if (!tracks.length) { log.warn("No Apple credits found (Apple has no credit data for this album, or none of its tracks list credits) \u2014 nothing to import."); stopMsg(collect, "No importable credits found"); return; } const { tracklistRels, tracklist, skipped, multiVolume } = appleToEngine(tracks); log.info(`Apple credits: ${tracklistRels.length} per-track relationship(s) across ${tracklist.length} track(s)`); skipped.forEach((s) => log.info(`Not imported (v1 scope): ${s}`)); if (multiVolume) log.warn(`Multi-medium Apple album \u2014 track numbers repeat per medium; positions may not all match this release's mediums. Review carefully.`); if (!tracklistRels.length) { log.warn("No importable Apple credits found."); stopMsg(collect, "No importable credits found"); return; } const parts = { companies: [], artistRoles: [], tracklistRels, tracklist, sourceUrl: appleUrl, processTracklist: true }; return collect ? parts : runSourcePipeline({ ...parts, getOpts, cancelled }); }).catch((err) => { log.error(err.message || String(err)); }); } function editorMediumSizes() { try { const MB2 = pageWindow.MB, st = MB2?.relationshipEditor?.state; if (!st?.mediums || !MB2.tree?.iterate) return []; const sizes = []; for (const entry of MB2.tree.iterate(st.mediums)) { const medium = Array.isArray(entry) ? entry[1] : entry; const tracks = medium?.tracks ?? medium; let n = 0; for (const t of MB2.tree.iterate(tracks)) if (t) n++; sizes.push(n); } return sizes; } catch (e) { logDebug(`YouTube Music: couldn't read the release's mediums from the editor \u2014 ${e.message}`); return []; } } function runYtmImport(ytmUrl, getOpts, cancelled, collect) { log.info(`Fetching YouTube Music credits (anonymous): ${ytmUrl}`); return fetchYtmCredits(ytmUrl, (d, n) => document.querySelector(".discogs-bar")?._setProgress?.(null, `YouTube Music ${d}/${n}`)).then(({ album, list, songs }) => { _ytmJson = { source: ytmUrl, album, list, songs }; const credited = songs.filter((s) => Object.keys(s.sections || {}).length).length; const li = document.createElement("li"); const pre = document.createElement("pre"); pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:var(--mbu-bg-raised);padding:0.5rem;border:1px solid var(--mbu-border);border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;"; pre.textContent = JSON.stringify(_ytmJson, null, 2); li.innerHTML = `<details><summary style="cursor:pointer;user-select:none;"><strong>${album || "YouTube Music album"} \xB7 ${songs.length} songs, ${credited} with credits \u2014 YouTube Music credits (API)</strong></summary></details>`; li.querySelector("details").appendChild(pre); _logs2.appendChild(li); if (!credited) { log.warn("No YouTube Music credits found (the label sent none for this album) \u2014 nothing to import."); stopMsg(collect, "No importable credits found"); return; } const sizes = editorMediumSizes(); const { tracklistRels, tracklist, skipped, multiMedium, mismatch } = ytmToEngine(songs, sizes); log.info(`YouTube Music credits: ${tracklistRels.length} per-track relationship(s) across ${tracklist.length} song(s); release mediums: ${sizes.length ? sizes.join(" + ") : "unknown"}`); skipped.forEach((s) => log.info(`Not imported: ${s}`)); if (mismatch) log.warn(`Multi-medium release: YouTube Music has ${songs.length} songs, the release ${sizes.reduce((a, b) => a + b, 0)} tracks (${sizes.join(" + ")}) \u2014 songs can't be placed on the right mediums. Review carefully.`); else if (multiMedium) log.info(`Multi-medium release: YouTube Music's straight-through numbering mapped onto ${sizes.length} mediums (${sizes.join(" + ")}).`); if (!tracklistRels.length) { log.warn("No importable YouTube Music credits found."); stopMsg(collect, "No importable credits found"); return; } const parts = { companies: [], artistRoles: [], tracklistRels, tracklist, sourceUrl: ytmUrl, processTracklist: true }; return collect ? parts : runSourcePipeline({ ...parts, getOpts, cancelled }); }).catch((err) => { log.error(err.message || String(err)); }); } function buildTitlesTracklist(mbid) { return fetchWithRetry(`/ws/2/release/${mbid}?inc=recordings&fmt=json`).then((json) => { const media = json?.media || []; const multiMedium = media.length > 1; const tracklist = []; for (const medium of media) { const medPos = medium.position; for (const t of medium.tracks || []) { const pos = t.position != null ? t.position : t.number; tracklist.push({ position: multiMedium && medPos != null && pos != null ? `${medPos}-${pos}` : String(pos != null ? pos : ""), title: t.title || t.recording?.title || "", type_: "track" }); } } return tracklist; }); } function probeTitleRemixes(mbid) { if (!mbid) return Promise.resolve({ count: 0, tracklist: [] }); return buildTitlesTracklist(mbid).then((tracklist) => ({ count: deriveRemixRoles(tracklist).length, tracklist })).catch(() => ({ count: 0, tracklist: [] })); } function runTitlesImport(getOpts, cancelled, collect) { const m = location.pathname.match(/release\/([0-9a-f-]{36})/i); if (!m) { log.error("Not on a release page \u2014 cannot read track titles."); return Promise.resolve(); } log.info("Reading track titles from MusicBrainz to derive remixer credits\u2026"); return buildTitlesTracklist(m[1]).then((tracklist) => { const tracklistRels = deriveRemixRoles(tracklist, m[1]); log.info(`Derived <strong>${tracklistRels.length}</strong> remixer credit(s) from ${tracklist.length} track title(s)`); if (!tracklistRels.length) { log.warn("No named remixes found in the track titles \u2014 nothing to import."); stopMsg(collect, "No remixes found in titles"); return; } const parts = { companies: [], artistRoles: [], tracklistRels, tracklist, sourceUrl: "", processTracklist: true }; return collect ? parts : runSourcePipeline({ ...parts, getOpts, cancelled }); }).catch((err) => { log.error(err.message || String(err)); }); } var stopMsg = (collect, msg) => { if (!collect) document.querySelector(".discogs-bar")?._setStopMessage?.(msg); }; async function runConsolidatedImport(importSources, getOpts, cancelled) { const isCancelled = () => typeof cancelled === "function" && cancelled(); log.info(`Import all: harvesting ${importSources.length} sources\u2026`); const harvests = []; for (const s of importSources) { if (isCancelled()) return; try { const parts = await s.run(getOpts, cancelled, true); const n = (parts?.artistRoles?.length || 0) + (parts?.tracklistRels?.length || 0) + (parts?.companies?.length || 0); if (parts && n) { harvests.push({ ...parts, sourceName: s.name }); log.info(`${s.name}: ${n} credit(s) collected`); } else log.info(`${s.name}: no importable credits`); } catch (e) { log.warn(`${s.name} failed: ${e.message || e}`); } } if (isCancelled()) return; if (!harvests.length) { log.warn("Import all: no credits from any source."); document.querySelector(".discogs-bar")?._setStopMessage?.("No importable credits found"); return; } const merged = mergeHarvests(harvests); const total = merged.artistRoles.length + merged.tracklistRels.length; log.info(`Import all: <strong>${total}</strong> unique credit(s) from ${harvests.length} source(s) after de-duplication.`); _consolidatedJson = { via: "consolidated", sources: harvests.map((h) => h.sourceName), discogs: _discogsJson, tidal: _tidalJson, qobuz: _qobuzJson, deezer: _deezerJson, apple: _appleJson, ytmusic: _ytmJson, merged: { companies: merged.companies, artistRoles: merged.artistRoles, tracklistRels: merged.tracklistRels } }; return runSourcePipeline({ companies: merged.companies, artistRoles: merged.artistRoles, tracklistRels: merged.tracklistRels, tracklist: merged.tracklist, sourceUrl: "", processTracklist: merged.tracklistRels.length > 0 || merged.processTracklist, getOpts, cancelled, entitySources: merged.entitySources, sourceLabel: `Import all (${harvests.map((h) => h.sourceName).join(", ")})` // #408: edit note names the real sources }); } function runSourcePipeline({ companies, artistRoles, tracklistRels, tracklist, sourceUrl, processTracklist, getOpts, cancelled, entitySources, sourceLabel }) { const isCancelled = () => typeof cancelled === "function" && cancelled(); { const h = hoistFullSpanArtworkRels(artistRoles, tracklistRels, tracklist); if (h.hoisted.length) { artistRoles = h.artistRoles; tracklistRels = h.tracklistRels; h.hoisted.forEach((r) => log.info(`Moved to release level (#433): ${r.linkType} \u2014 ${r.artist.name} (was on every track; artist\u2013recording artwork is for videos)`)); } } const allArtistRoles = artistRoles.concat(tracklistRels); const uniqueArtists = []; const seenResourceUrls = /* @__PURE__ */ new Set(); const rolesMap = /* @__PURE__ */ new Map(); allArtistRoles.forEach((role) => { const url = role.artist?.resource_url || `_nourl_${role.artist?.name || role.artist?.id}`; if (!rolesMap.has(url)) rolesMap.set(url, []); let displayLabel = role.linkType; if (role.attributes && role.attributes.length > 0) { const attr = role.attributes[0]; if (attr._type === "instrument" && attr.value) displayLabel = attr.value; else if (attr._type === "vocal" && attr.value) displayLabel = attr.value; else if (typeof attr === "string") displayLabel = `${role.linkType} [${attr}]`; } rolesMap.get(url).push({ linkType: role.linkType, displayLabel, trackPos: role.track?.position || "", trackTitle: role.track?.title || "" }); if (!seenResourceUrls.has(url)) { seenResourceUrls.add(url); if (!role.artist?.resource_url && role.artist) role.artist._syntheticKey = url; uniqueArtists.push(role.artist); } }); const _relMbid = (location.pathname.match(/release\/([0-9a-f-]{36})/i) || [])[1]; if (_relMbid) { for (const a of uniqueArtists) { if (a && !a.resource_url && !a._cacheKey && a.name) { a._cacheKey = `nameonly/${_relMbid}/${a.name.toLowerCase().trim()}`; } } } const companiesRolesMap = /* @__PURE__ */ new Map(); companies.forEach((c) => { if (!c.resource_url) return; if (!companiesRolesMap.has(c.resource_url)) companiesRolesMap.set(c.resource_url, []); companiesRolesMap.get(c.resource_url).push({ linkType: c.entity_type_name || "" }); }); const uniqueCompanies = []; const seenCompanyUrls = /* @__PURE__ */ new Set(); companies.forEach((c) => { if (c.resource_url && !seenCompanyUrls.has(c.resource_url) && ENTITY_TYPE_MAP[c.entity_type_name]) { seenCompanyUrls.add(c.resource_url); uniqueCompanies.push(c); } }); function runPreflight(bypassIdb = false) { log.info(`Starting preflight: ${uniqueArtists.length} artist(s), ${uniqueCompanies.length} label(s)/place(s).`); const artistProgressLi = document.createElement("li"); artistProgressLi.textContent = `Checking ${uniqueArtists.length} artist(s) against MusicBrainz\u2026`; _logs2.appendChild(artistProgressLi); const companyProgressLi = document.createElement("li"); companyProgressLi.textContent = `Checking ${uniqueCompanies.length} label(s)/place(s) against MusicBrainz\u2026`; _logs2.appendChild(companyProgressLi); const t0 = performance.now(); return (async () => { let context = null; try { context = await buildReleaseContext({ coCredit: !!(getOpts && getOpts().coCredit) }); } catch (e) { log.warn(`Matching context unavailable: ${e.message}`); } const artistResults = await resolveAll(uniqueArtists, { progressLi: artistProgressLi, progressLabel: "Checking artists against MusicBrainz", kindOf: ENTITY_KIND, bypassIdb, context }); const companyResults = await resolveAll(uniqueCompanies, { progressLi: companyProgressLi, progressLabel: "Checking labels/places against MusicBrainz", kindOf: COMPANY_KIND, bypassIdb }); const elapsed = (performance.now() - t0) / 1e3; log.info(`Preflight done in ${elapsed.toFixed(1)}s.`); return [...artistResults.allResults, ...companyResults.allResults].filter(Boolean); })(); } function annotateRoles(allResults) { allResults.forEach((r) => { if (!r) return; const url = r.entity?.resource_url || r.entity?._syntheticKey; if (url) r._roles = rolesMap.get(url) || companiesRolesMap.get(url) || []; }); } let capturedResults = null; let capturedConfirmedMap = null; let _reviewMergeMap = null; const mergeForReview = (results) => { if (!entitySources) return results; const m = mergeResolvedResults(results, entitySources); _reviewMergeMap = m.mergeMap; return m.results; }; return runPreflight().then((allResults) => { if (isCancelled()) return; annotateRoles(allResults); capturedResults = allResults; if (!allResults.length) { log.warn("Nothing to import \u2014 no entities to review."); document.querySelector(".discogs-bar")?._setStopMessage?.("Nothing to import \u2014 no entities to review"); return; } document.querySelector(".discogs-bar")?.classList.add("is-reviewing"); return showReviewTable(mergeForReview(capturedResults), rolesMap, companiesRolesMap, { // Mount the Start-import button + unresolved message in the // always-visible header rather than below the table (#139). // Resolved via the DOM (there's one bar) — `runImport` is a // separate function from the bar builder that owns the slot. headerSlot: document.querySelector(".discogs-bar-action"), // Label fallback for URL-less credits (#193): a Qobuz row // must say "No Qobuz page", not "No Discogs page". The // URL-less Titles source (#271) reports as 'Titles' so the // review table drops Discogs-specific wording/elements. sourceName: entitySources ? "multiple sources" : sourceUrl ? sourceNameForUrl(sourceUrl) : "Titles", sourceIcon: sourceUrl ? srcIconByUrl(sourceUrl) : SRC_ICON.Titles || "", // #193 — shown on the "Start import" button // #408: per-entity source provenance → the review table renders a Source column // (brand-icon badges). Present only on an "Import all" run. entitySources, sourceBadgeIcon: (name) => SRC_ICON[name] || "", // "🔄 Refresh from MB" — bypass the IDB cache and re-resolve // every entity via MB API. Used when a cached MBID is stale. onRefresh: () => runPreflight(true).then((freshResults) => { annotateRoles(freshResults); capturedResults = freshResults; return mergeForReview(freshResults); }), // Let `cancelRun` unblock this review promise (resolve → null) // so the chain unwinds cleanly instead of leaking a pending // promise when the user cancels mid-review. registerAbort: (fn) => { const b = document.querySelector(".discogs-bar"); if (b) b._reviewAbort = fn; } }); }).then((confirmedMap) => { const _bar = document.querySelector(".discogs-bar"); if (_bar) _bar._reviewAbort = null; if (isCancelled()) return; if (!confirmedMap) return; if (_reviewMergeMap) _reviewMergeMap.forEach((members, repKey) => { const mb = confirmedMap.get(repKey); if (!mb) return; members.forEach((k) => { if (!confirmedMap.has(k)) confirmedMap.set(k, mb); }); }); if (_reviewMergeMap && confirmedMap.splits?.size) _reviewMergeMap.forEach((members, repKey) => { const parts = confirmedMap.splits.get(repKey); if (!parts) return; members.forEach((k) => { if (!confirmedMap.splits.has(k)) confirmedMap.splits.set(k, parts); }); }); capturedConfirmedMap = confirmedMap; document.querySelector(".discogs-bar")?.classList.remove("is-reviewing"); document.querySelector(".discogs-bar")?._setUnresolved?.(confirmedMap.unresolvedCount || 0); const cachePromises = []; confirmedMap.forEach((mbUrl, resourceUrl) => { const key = parseSourceEntityUrl(resourceUrl)?.key; if (!key) return; const m = mbUrl.match(/\/(artist|label|place)\/([a-f0-9-]+)/); if (!m) return; cachePromises.push(writeIdbRecord(key, { mbid: m[2], entityType: m[1] // No resolvedVia change — the inline write owns it. })); }); return Promise.all(cachePromises); }).then(() => { if (!capturedConfirmedMap) return; const resolvedEntityTypes = /* @__PURE__ */ new Map(); (capturedResults || []).forEach((r) => { if (r.entity?.resource_url && r.mbUrl && r.entityType) { resolvedEntityTypes.set(r.entity.resource_url, r.entityType); } }); const live = getOpts(); if (live.processTracklist !== processTracklist) { log.warn(`"Per-track credits" toggled during review (preflight ran with "${processTracklist ? "on" : "off"}", import will follow preflight). To change, restart the import.`); } const dedupOpts = { dedupeEquivalenceSets: live.dedupeEquivalenceSets, dedupeDuplicateRoles: live.dedupeDuplicateRoles, creditOverrides: capturedConfirmedMap?.creditOverrides, sourceLabel // #408: consolidated runs label the edit note "Import all (…)" }; return dispatchAllRelationships(companies, artistRoles, tracklistRels, live.applyToTracks, live.createWorksMode, tracklist, processTracklist, resolvedEntityTypes, capturedConfirmedMap, sourceUrl, dedupOpts); }); } // ../../dev/mbpage/mb-page.mjs function mbpReleaseLinkList(doc) { doc = doc || document; const lists = [...doc.querySelectorAll("ul.external_links")].map((ul) => { let h = ul.previousElementSibling; while (h && !/^H\d$/.test(h.tagName)) h = h.previousElementSibling; return { ul, head: h && h.classList.contains("external-links") ? (h.textContent || "").trim() : null }; }).filter((l) => l.head !== null); const lang = doc.documentElement && doc.documentElement.lang || "en"; const key = "mbu.extLinksHeading." + lang; if (lists.length >= 2) { const rgHead = lists[lists.length - 1].head; const own = lists.find((l) => l.head !== rgHead); if (!own) return null; if (!/^en\b/i.test(lang)) { try { localStorage.setItem(key, JSON.stringify(own.head)); } catch (e) { } } return own.ul; } if (lists.length !== 1) return null; if (/^external links$/i.test(lists[0].head)) return lists[0].ul; if (/^en\b/i.test(lang)) return null; let learnt = null; try { learnt = JSON.parse(localStorage.getItem(key)); } catch (e) { } return learnt && learnt === lists[0].head ? lists[0].ul : null; } function mbpReleaseJsonLinks(doc) { doc = doc || document; const mbid = (doc.location && doc.location.pathname || "").match(/\/release\/([0-9a-f-]{36})/i); for (const s of doc.querySelectorAll('script[type="application/json"]')) { const t = s.textContent || ""; if (t.indexOf('"relationships"') < 0) continue; let rel = null; try { rel = JSON.parse(t).release; } catch (e) { continue; } if (!rel || rel.entityType !== "release" || !Array.isArray(rel.relationships)) continue; if (mbid && rel.gid && rel.gid.toLowerCase() !== mbid[1].toLowerCase()) continue; const abs = (h) => { try { return new URL(h, doc.location && doc.location.href || "https://musicbrainz.org/").href; } catch (e) { return ""; } }; return rel.relationships.map((r) => r && r.target).filter((u) => u && u.entityType === "url" && u.href_url).map((u) => abs(u.href_url)).filter((u) => /^https?:/.test(u)); } return null; } function mbpReleaseLinks(doc) { const data = mbpReleaseJsonLinks(doc); if (data) return [...new Set(data)]; const ul = mbpReleaseLinkList(doc); return ul ? [...ul.querySelectorAll("a[href]")].map((a) => a.href).filter((u) => /^https?:/.test(u)) : []; } // src/mc-adapter.js var send = (type, detail) => document.dispatchEvent(new CustomEvent(type, { detail: JSON.stringify(detail) })); var version = () => typeof GM_info !== "undefined" && GM_info.script && GM_info.script.version || "?"; var releaseLinks = () => mbpReleaseLinks(); function pageTracks() { const out = []; let medium = 0; document.querySelectorAll("#content table.medium").forEach((tbl) => { medium++; let pos = 0; tbl.querySelectorAll("tbody tr").forEach((tr) => { const a = tr.querySelector('a[href*="/recording/"]'); if (!a) return; pos++; out.push({ medium, pos, rec: (a.getAttribute("href").match(/[0-9a-f-]{36}/) || [])[0] }); }); }); return out; } function withMedia(list) { let medium = 1, last = 0; return list.map((t) => { if (t.index <= last) medium++; last = t.index; return Object.assign({ medium }, t); }); } var flatRoles = (c) => (c.roles || [c.role]).filter(Boolean).join(", "); var IMPORT_SOURCES = [ ["Discogs", "discogs", /discogs\.com\/(?:[a-z-]+\/)?release\/\d+/i], ["Tidal", "tidal", /(^|\/\/)(www\.|listen\.)?tidal\.com\/(browse\/)?album\/\d+/i], ["Qobuz", "qobuz", /(^|\/\/)(www\.|play\.|open\.)?qobuz\.com\/([a-z]{2}-[a-z]{2}\/)?album\//i], ["Deezer", "deezer", /(^|\/\/)(www\.)?deezer\.com\/([a-z]{2}\/)?album\/\d+/i], ["Apple", "apple", /(^|\/\/)(?:music|itunes)\.apple\.com\/(?:[a-z]{2}\/)?album\/(?:[^/?#]+\/)?(?:id)?\d+/i], ["Metal Archives", "globe", /(^|\/\/)(www\.)?metal-archives\.com\/albums\/[^/]+\/[^/]+\/\d+/i], ["YouTube Music", "ytmusic", /(^|\/\/)((music|www|m)\.)?youtube\.com\/(?:playlist\?(?:[^#]*&)?list=OLAK5uy_|browse\/MPREb_)/i] ]; function openLinks(rel, links) { const names = IMPORT_SOURCES.filter(([, , re]) => links.some((u) => re.test(u))); const url = (name) => `/release/${rel}/edit-relationships#ch-import=${encodeURIComponent(name)}`; const out = names.map(([name, icon]) => ({ label: name, icon, url: url(name), title: `Import the credits from ${name} in Credit Hoarder` })); if (out.length > 1) out.push({ label: "All", icon: "", url: url("All"), title: `Import from all ${out.length} sources at once in Credit Hoarder` }); return out; } async function readSources(links) { const find = (re) => links.find((u) => re.test(u)); const jobs = []; const discogs = find(/discogs\.com\/(?:[a-z-]+\/)?release\/\d+/i); if (discogs) jobs.push(["Discogs", async () => { const url = discogs.replace(/^https?:\/\/(www\.)?discogs\.com\/(?:[a-z-]+\/)?release\//i, "https://www.discogs.com/release/").split(/[?#]/)[0]; const json = await getDiscogsReleaseData(url); const tracks = []; const walk = (arr) => (arr || []).forEach((t) => { if (t.sub_tracks) walk(t.sub_tracks); else if (t.type_ === "track" || !t.type_) tracks.push(t); }); walk(json.tracklist); const release = (json.extraartists || []).map((a) => ({ name: a.name, role: a.role + (a.tracks ? ` (tracks ${a.tracks})` : "") })); return { source: "Discogs", perTrack: tracks.map((t, i) => ({ medium: 0, index: i + 1, credits: (t.extraartists || []).map((a) => ({ name: a.name, role: a.role })) })), flat: true, release }; }]); const qobuz = find(/qobuz\.com\//i) && parseQobuzAlbumUrl(find(/qobuz\.com\//i)); if (qobuz) jobs.push(["Qobuz", async () => ({ source: "Qobuz", perTrack: withMedia(extractQobuzCredits(await fetchQobuzAlbumPage(qobuz.pageUrl))) })]); const deezer = find(/deezer\.com\//i) && parseDeezerAlbumUrl(find(/deezer\.com\//i)); if (deezer) jobs.push(["Deezer", async () => ({ source: "Deezer", perTrack: withMedia(extractDeezerCredits(await fetchDeezerAlbumPage(deezer.pageUrl))) })]); const apple = find(/(music|itunes)\.apple\.com\//i) && parseAppleAlbumUrl(find(/(music|itunes)\.apple\.com\//i)); if (apple) jobs.push(["Apple", async () => ({ source: "Apple", perTrack: withMedia((await fetchAppleCredits(apple.storefront, apple.id)).tracks) })]); log.info("Mission Control probe: credit sources linked: " + (jobs.map((j) => j[0]).join(", ") || "none")); const out = await Promise.all(jobs.map(([name, run]) => run().catch((x) => { log.warn(`Mission Control probe: ${name} failed: ${x && x.message || x}`); return null; }))); return out.filter(Boolean); } function startMcAdapter() { const m = location.pathname.match(/^\/release\/([0-9a-f-]{36})\/?$/i); if (!m) return false; const rel = m[1].toLowerCase(); const hello = () => send("mc:provider", { id: "ch", name: "Credit Hoarder", version: version(), release: rel, capabilities: ["probe"], open: openLinks(rel, releaseLinks()) }); document.addEventListener("mc:discover", () => { log.info("Mission Control asked \u2014 answering as provider ch"); hello(); }); document.addEventListener("mc:probe", async (e) => { let d = {}; try { d = JSON.parse(e.detail) || {}; } catch (x) { return; } if (d.release && d.release !== rel || d.only && !d.only.includes("ch")) return; const done = (findings, summary) => send("mc:findings", { id: "ch", run: d.run, release: rel, findings, summary, open: openLinks(rel, releaseLinks()) }); try { send("mc:progress", { id: "ch", run: d.run, state: "busy", note: "reading credits" }); const all = await readSources(releaseLinks()); if (!all.length) { log.info("Mission Control probe: no credit source linked or readable (Discogs, Qobuz, Deezer, Apple)"); done([], "No credit source linked (Discogs, Qobuz, Deezer, Apple)"); return; } const tracks = pageTracks(); const names = all.map((g) => g.source); const findings = tracks.map((t, i) => { const merged = /* @__PURE__ */ new Map(); for (const got of all) { const hit = got.flat ? got.perTrack[i] : got.perTrack.find((x) => x.medium === t.medium && x.index === t.pos); (hit ? hit.credits : []).forEach((c) => { const role = flatRoles(c), k = String(c.name || "").toLowerCase() + "|" + role.toLowerCase(); const mm = merged.get(k) || merged.set(k, { name: c.name, role, sources: [] }).get(k); if (!mm.sources.includes(got.source)) mm.sources.push(got.source); }); } const list = [...merged.values()].map((c) => ({ name: c.name, role: c.role, sources: c.sources })); return { key: t.rec, track: t.rec, state: list.length ? "info" : "none", credits: list.length, list, source: names.join(", ") }; }); const total = findings.reduce((n, f) => n + f.credits, 0); const withCredits = findings.filter((f) => f.track && f.credits).length; const relList = all.flatMap((g) => g.release || []); if (relList.length) findings.push({ key: "release", state: "info", credits: relList.length, list: relList, source: "Discogs", level: "release" }); all.forEach((g) => log.info(`Mission Control probe: ${g.source} gave credits for ${g.perTrack.filter((x) => x.credits && x.credits.length).length} track(s)`)); log.info(`Mission Control probe: ${names.join(" + ")} \u2014 ${total} track credit(s), merged, on ${withCredits} of ${tracks.length} track(s), ${relList.length} release credit(s)`); relList.forEach((c) => log.info(` release credit: ${c.name} \u2014 ${c.role}`)); done(findings, `${names.join(" + ")}: ${total} credit${total === 1 ? "" : "s"} on ${withCredits} of ${tracks.length} tracks` + (relList.length ? `, ${relList.length} on the release` : "")); } catch (x) { log.error("Mission Control probe failed: " + (x && x.message || x)); done([], "failed: " + (x && x.message || x)); } }); hello(); return true; } // src/credit_hoarder.user.js try { pageWindow.__creditHoarder = { resolveAll, ARTIST_KIND, buildReleaseContext, releaseArtistMbids, wantsAliasButton, submitAliasBackground, openAddAliasForm }; } catch (e) { } if (/(^|\.)tidal\.com$/i.test(location.hostname)) { runTidalHarvestPage(); } if (/(^|\.)metal-archives\.com$/i.test(location.hostname)) { runMetalArchivesHarvestPage(); } (function handleCreatePageAutoCommit() { const onCreate = /\/(artist|label|place)\/create\b/i.test(location.pathname); const onEdit = /\/(artist|label|place)\/[a-f0-9-]{36}\/edit\b/i.test(location.pathname); if (!onCreate && !onEdit) return; const m = location.hash.match(/ch-autocommit(?:=([^&]+))?/); if (!m) return; if (onCreate) { let identity = ""; try { identity = decodeURIComponent(m[1] || ""); } catch (e) { identity = m[1] || ""; } try { sessionStorage.setItem("discogs-importer-pending-artist", identity); } catch (e) { } } else { try { sessionStorage.setItem("discogs-importer-close-after-edit", "1"); } catch (e) { } } const et = (location.pathname.match(/\/(artist|label|place)\//) || [])[1] || "artist"; const seedUrl = new URLSearchParams(location.search).get(`edit-${et}.url.0.text`) || ""; const seedKey = seedUrl ? seedUrl.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/+$/, "").toLowerCase() : ""; let tries = 0; const submit = () => { const seedReady = !seedKey || document.body.innerHTML.toLowerCase().includes(seedKey); const btn = document.querySelector("button.submit.positive") || [...document.querySelectorAll('button[type="submit"]')].find((b) => /enter edit/i.test(b.textContent || "")); if (seedReady && btn && !btn.disabled) { btn.click(); return; } if (tries++ < 100) setTimeout(submit, 200); }; if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", submit); else submit(); })(); (function handleEntityPageIfNeeded() { const entityMatch = location.href.match( /musicbrainz\.org\/(artist|label|place)\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:[^/]|$)/i ); if (!entityMatch) return; const entityType = entityMatch[1]; const mbid = entityMatch[2]; try { if (sessionStorage.getItem("discogs-importer-close-after-edit")) { sessionStorage.removeItem("discogs-importer-close-after-edit"); try { DISCOGS_CHANNEL.postMessage({ type: "edit-committed", id: mbid }); } catch (e) { } setTimeout(() => window.close(), 50); return; } } catch (e) { } const pendingKey = "discogs-importer-pending-artist"; const pending = sessionStorage.getItem(pendingKey); if (!pending) return; sessionStorage.removeItem(pendingKey); const NAME_FETCH_TIMEOUT_MS = 1e3; const CLOSE_DELAY_MS = 50; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), NAME_FETCH_TIMEOUT_MS); mbnFetch(`${MB}/ws/2/${entityType}/${mbid}?fmt=json`, { signal: ctrl.signal }, { background: false }).then((r) => r.json()).then((json) => ({ name: json.name || "", disambiguation: json.disambiguation || "" })).catch(() => ({ name: "", disambiguation: "" })).then(({ name, disambiguation }) => { clearTimeout(timer); DISCOGS_CHANNEL.postMessage({ type: "artist-created", // keep same message type for compatibility id: mbid, name, disambiguation, resourceUrl: pending }); setTimeout(() => window.close(), CLOSE_DELAY_MS); }); })(); var T0 = typeof performance !== "undefined" ? performance.now() : Date.now(); var since = () => Math.round((typeof performance !== "undefined" ? performance.now() : Date.now()) - T0); if (/musicbrainz\.org$/i.test(location.hostname)) startMcAdapter(); if (/musicbrainz\.org$/i.test(location.hostname)) (function() { const re = /musicbrainz\.org\/release\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\/edit-relationships/i; const m = window.location.href.match(re); if (!m) return; log.info(`Boot: script running (document.readyState=${document.readyState}, ${Math.round(performance.now())}ms into the page)`); const tProbe = since(); const sourceProbe = getSourceUrlsForRelease(m[1]).then((s) => { log.info(`Boot: source probe done (+${since() - tProbe}ms)`); return { sources: s, failed: false }; }).catch((e) => { log.error(`Sources: could not read this release's links from MusicBrainz \u2014 ${e.message}. Showing the toolbar anyway; reload to retry.`); console.warn("[credit_hoarder] could not read release sources:", e); return { sources: {}, failed: true }; }); const remixProbe = probeTitleRemixes(m[1]).then((r) => { log.info(`Boot: title-remix probe done (+${since() - tProbe}ms)`); return r; }).catch((e) => { log.warn(`Titles: remix probe failed \u2014 ${e.message}`); return null; }); const domReady = new Promise((resolve) => { if (document.readyState === "interactive" || document.readyState === "complete") return resolve(); document.addEventListener("DOMContentLoaded", () => resolve(), { once: true }); }).then(() => log.info(`Boot: DOM ready (+${since()}ms)`)); Promise.all([sourceProbe, domReady]).then(([probe]) => { const known = !!(probe.sources.discogs || probe.sources.tidal || probe.sources.qobuz || probe.sources.deezer || probe.sources.apple || probe.sources.metalArchives || probe.sources.ytmusic); if (known || probe.failed) { bootstrapBar(probe, null, m[1]); remixProbe.then((remix) => { const n = remix?.count || 0; if (!n) return; const bar = document.querySelector(".discogs-bar"); const outcome = bar?._addTitlesSource ? bar._addTitlesSource(n) : "no bar"; log.info(`Titles: ${n} remixer(s) derivable from the track titles \u2014 ${outcome} (+${since()}ms)`); }); return; } log.info("Boot: no linked source \u2014 waiting for the title-remix probe before deciding whether to show anything"); remixProbe.then((remix) => bootstrapBar(probe, remix, m[1])); }); })(); function bootstrapBar(probe, remix, releaseMbid) { { const sources = probe.sources; const hasProvider = !!(sources.discogs || sources.tidal || sources.qobuz || sources.deezer || sources.apple || sources.metalArchives || sources.ytmusic); const remixCount = remix?.count || 0; if (!probe.failed) logSourceProbe(sources); log.info(`Toolbar: ${probe.failed ? "source probe FAILED" : hasProvider ? "linked source(s) found" : "no linked sources"}, ${remixCount} title-derived remixer(s) \u2014 ${probe.failed || hasProvider || remixCount ? "mounting" : "not mounting (nothing to import)"}`); if (!probe.failed && !hasProvider && remixCount === 0) return; insertDiscogsBar(sources.discogs, sources, { titlesRemixCount: remixCount, sourceProbeFailed: probe.failed }); log.info(`Boot: toolbar mounted (+${since()}ms from script start)`); pressRequestedSource(); } } function pressRequestedSource() { const m = location.hash.match(/ch-import=([^&]+)/); if (!m) return; let name = m[1]; try { name = decodeURIComponent(name); } catch (e) { } window.history.replaceState(null, "", location.pathname + location.search); let tries = 0; const press = () => { const b = [...document.querySelectorAll(".discogs-bar .discogs-src-ico")].find((x) => x.dataset.src === name); if (b) { log.info(`Mission Control asked to import from ${name}`); b.click(); return; } if (tries++ < 50) { setTimeout(press, 200); return; } log.warn(`Mission Control asked to import from ${name}, but the toolbar has no such source`); }; press(); } })(); })(); } catch (e) { try { console.error('[String Theory] credit_hoarder failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== falcon (@run-at document-end) ============================================ if (__stOff.indexOf("falcon") < 0 && __stRuns("falcon", {"inc":["^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/.*$","^https:\\/\\/harmony\\.pulsewidth\\.org\\.uk(?::\\d+)?/.*$"],"exc":[],"noframes":true})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Falcon*","namespace":"https://github.com/majkinetor/musicbrainz-userscripts","version":"2026.10.16","description":"Edit a BATCH of MusicBrainz artists/labels/recordings at once — add external links, ISRCs, names, aliases, disambiguations and cover art — no popup-per-entity, no tab churn. A small pool of persistent worker iframes churns through a queue, each submitting its own edit and moving straight to the next entity. Paste a list, hand it a queue via a `?falcon=` URL param, or click \"Send to Falcon\" on a Harmony actions page to import its suggestions directly.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/falcon/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5GYWxjb248L3RpdGxlPgo8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSI1OCIgZmlsbD0iI2Y2ZWZlMiIgc3Ryb2tlPSIjMWIyYTRhIiBzdHJva2Utd2lkdGg9IjYiLz48cGF0aCBkPSJNMTA1LjYgNDAuMCBBNDggNDggMCAwIDEgMTA1LjYgODguMCIgc3Ryb2tlPSIjZDYyODI4IiBzdHJva2Utd2lkdGg9IjciIGZpbGw9Im5vbmUiLz4KPGcgc3Ryb2tlPSIjMWIyYTRhIiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxwYXRoIGQ9Ik0yMC43IDg5LjAgTDI5LjQgODQuMCIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTE0LjMgNjkuMiBMMjQuMiA2OC4yIiBzdHJva2Utd2lkdGg9IjMiLz48cGF0aCBkPSJNMTYuNCA0OC41IEwyNi4wIDUxLjYiIHN0cm9rZS13aWR0aD0iMyIvPjxwYXRoIGQ9Ik0yNi44IDMwLjUgTDM0LjMgMzcuMiIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTQzLjcgMTguMyBMNDcuNyAyNy41IiBzdHJva2Utd2lkdGg9IjMiLz48cGF0aCBkPSJNNjQuMCAxNC4wIEw2NC4wIDI0LjAiIHN0cm9rZS13aWR0aD0iMyIvPjxwYXRoIGQ9Ik04NC4zIDE4LjMgTDgwLjMgMjcuNSIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTEwMS4yIDMwLjUgTDkzLjcgMzcuMiIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTExMS42IDQ4LjUgTDEwMi4wIDUxLjYiIHN0cm9rZS13aWR0aD0iMyIvPjxwYXRoIGQ9Ik0xMTMuNyA2OS4yIEwxMDMuOCA2OC4yIiBzdHJva2Utd2lkdGg9IjMiLz48cGF0aCBkPSJNMTA3LjMgODkuMCBMOTguNiA4NC4wIiBzdHJva2Utd2lkdGg9IjMiLz48L2c+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQgOTQpIHJvdGF0ZSgwKSBzY2FsZSgwLjE2KSB0cmFuc2xhdGUoLTY0IC02NCkiPjxwYXRoIGQ9Ik02NCAyNiBDNjcgMjYgNzAgMzAgNzAgMzYgQzcyIDQwIDc0IDQyIDc2IDQ0IEM5MiA0MiAxMDggMzYgMTI0IDM0IEMxMTIgNDQgOTYgNTQgNzggNjIgQzc2IDY4IDc0IDc0IDc0IDgwIEw4MCAxMDAgQzc0IDEwNCA2OCAxMDQgNjQgMTA0IFoiIGZpbGw9IiM0YTVhNzgiLz48cGF0aCBkPSJNNjQgMjYgQzY3IDI2IDcwIDMwIDcwIDM2IEM3MiA0MCA3NCA0MiA3NiA0NCBDOTIgNDIgMTA4IDM2IDEyNCAzNCBDMTEyIDQ0IDk2IDU0IDc4IDYyIEM3NiA2OCA3NCA3NCA3NCA4MCBMODAgMTAwIEM3NCAxMDQgNjggMTA0IDY0IDEwNCBaIiBmaWxsPSIjNGE1YTc4IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMjggMCkgc2NhbGUoLTEgMSkiLz48L2c+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQgNjQpIHJvdGF0ZSgxMTIpIj48cGF0aCBkPSJNMCAtNDYgTDQgMCBMLTQgMFoiIGZpbGw9IiMxYjJhNGEiLz48L2c+CjxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjciIGZpbGw9IiMxYjJhNGEiLz4KPC9zdmc+Cg=="}) }) : { script: {"name":"Falcon*","namespace":"https://github.com/majkinetor/musicbrainz-userscripts","version":"2026.10.16","description":"Edit a BATCH of MusicBrainz artists/labels/recordings at once — add external links, ISRCs, names, aliases, disambiguations and cover art — no popup-per-entity, no tab churn. A small pool of persistent worker iframes churns through a queue, each submitting its own edit and moving straight to the next entity. Paste a list, hand it a queue via a `?falcon=` URL param, or click \"Send to Falcon\" on a Harmony actions page to import its suggestions directly.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/falcon/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5GYWxjb248L3RpdGxlPgo8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSI1OCIgZmlsbD0iI2Y2ZWZlMiIgc3Ryb2tlPSIjMWIyYTRhIiBzdHJva2Utd2lkdGg9IjYiLz48cGF0aCBkPSJNMTA1LjYgNDAuMCBBNDggNDggMCAwIDEgMTA1LjYgODguMCIgc3Ryb2tlPSIjZDYyODI4IiBzdHJva2Utd2lkdGg9IjciIGZpbGw9Im5vbmUiLz4KPGcgc3Ryb2tlPSIjMWIyYTRhIiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxwYXRoIGQ9Ik0yMC43IDg5LjAgTDI5LjQgODQuMCIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTE0LjMgNjkuMiBMMjQuMiA2OC4yIiBzdHJva2Utd2lkdGg9IjMiLz48cGF0aCBkPSJNMTYuNCA0OC41IEwyNi4wIDUxLjYiIHN0cm9rZS13aWR0aD0iMyIvPjxwYXRoIGQ9Ik0yNi44IDMwLjUgTDM0LjMgMzcuMiIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTQzLjcgMTguMyBMNDcuNyAyNy41IiBzdHJva2Utd2lkdGg9IjMiLz48cGF0aCBkPSJNNjQuMCAxNC4wIEw2NC4wIDI0LjAiIHN0cm9rZS13aWR0aD0iMyIvPjxwYXRoIGQ9Ik04NC4zIDE4LjMgTDgwLjMgMjcuNSIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTEwMS4yIDMwLjUgTDkzLjcgMzcuMiIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTExMS42IDQ4LjUgTDEwMi4wIDUxLjYiIHN0cm9rZS13aWR0aD0iMyIvPjxwYXRoIGQ9Ik0xMTMuNyA2OS4yIEwxMDMuOCA2OC4yIiBzdHJva2Utd2lkdGg9IjMiLz48cGF0aCBkPSJNMTA3LjMgODkuMCBMOTguNiA4NC4wIiBzdHJva2Utd2lkdGg9IjMiLz48L2c+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQgOTQpIHJvdGF0ZSgwKSBzY2FsZSgwLjE2KSB0cmFuc2xhdGUoLTY0IC02NCkiPjxwYXRoIGQ9Ik02NCAyNiBDNjcgMjYgNzAgMzAgNzAgMzYgQzcyIDQwIDc0IDQyIDc2IDQ0IEM5MiA0MiAxMDggMzYgMTI0IDM0IEMxMTIgNDQgOTYgNTQgNzggNjIgQzc2IDY4IDc0IDc0IDc0IDgwIEw4MCAxMDAgQzc0IDEwNCA2OCAxMDQgNjQgMTA0IFoiIGZpbGw9IiM0YTVhNzgiLz48cGF0aCBkPSJNNjQgMjYgQzY3IDI2IDcwIDMwIDcwIDM2IEM3MiA0MCA3NCA0MiA3NiA0NCBDOTIgNDIgMTA4IDM2IDEyNCAzNCBDMTEyIDQ0IDk2IDU0IDc4IDYyIEM3NiA2OCA3NCA3NCA3NCA4MCBMODAgMTAwIEM3NCAxMDQgNjggMTA0IDY0IDEwNCBaIiBmaWxsPSIjNGE1YTc4IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMjggMCkgc2NhbGUoLTEgMSkiLz48L2c+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQgNjQpIHJvdGF0ZSgxMTIpIj48cGF0aCBkPSJNMCAtNDYgTDQgMCBMLTQgMFoiIGZpbGw9IiMxYjJhNGEiLz48L2c+CjxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjciIGZpbGw9IiMxYjJhNGEiLz4KPC9zdmc+Cg=="} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ try { (function(){ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('falcon', 'Falcon')) { falconHandOver(); return; } /* A copy that stands down still owns its GM storage, which no other copy can read: a "Send to Falcon" batch the OTHER copy wrote on Harmony is keyed there. The two copies are picked per site (the claim's note is per-origin localStorage), so Harmony's copy and MusicBrainz's need not be the same one — and the running copy then found no batch ("neither valid base64 JSON nor a known pending token"). That is what the rename to plain "Falcon" did: a manager installs a renamed script as a second script. So the copy that stands down hands the batch over through the page, which every sandbox shares: an attribute for a copy that starts later, an event for one already waiting. */ function falconHandOver() { try { const token = new URLSearchParams(location.search).get('falcon'); if (!token) return; const json = GM_getValue('falcon:pending:' + token, null); if (json == null) return; const note = GM_getValue('falcon:pendingNote:' + token, null); try { GM_deleteValue('falcon:pending:' + token); GM_deleteValue('falcon:pendingNote:' + token); } catch (e) {} const detail = JSON.stringify({ token, json, note }); document.documentElement.setAttribute('data-falcon-handover', detail); document.dispatchEvent(new CustomEvent('falcon:handover', { detail })); } catch (e) { /* nothing to hand over */ } } // the shared MusicBrainz request gate (#633) — first, so nothing can call it before its consts exist // <ST-NET> — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT const MBN_GAP = 1000; const MBN_BURST = 3; const MBN_KEY = "mbu:mb-gate"; const MBN_LOCK = "mbu-mb-gate"; const MBN_MAX_HOLD = 60000; function mbnGated(url) { const s = String(url || ''); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null'); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; // no storage: this page's copy only else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) {} return out; }; // The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is // stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang // every request behind it. let timer = 0; try { if (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === 'function' ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3000); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState(s => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? 'MusicBrainz asked everyone to wait' : 'pacing, one request a second' }; }); let told = false; for (;;) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); // a hold that appeared while we queued if (left <= 0) break; if (!told && until - t0 > 1000 && o.log) { told = true; o.log('info', 'MusicBrainz gate: ' + (o.label || 'request') + ' waits ' + Math.round((until - t0) / 100) / 10 + 's (' + (cool > booked.at ? 'MusicBrainz asked everyone to wait' : booked.why) + ')'); } await new Promise(r => setTimeout(r, Math.min(left, 250))); // in slices, so a cancel lands promptly } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header('Retry-After') : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState(s => { const now = Date.now(); // One throttle, one step: the requests already in flight when it began all answer // 503 too, and each must not stretch the hold again. A throttle long gone doesn't // count at all: `hot` is shared and persists, and would otherwise slow every later // page for good. if (now > (s.cool || 0) + 30000) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1000, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log('warn', 'MusicBrainz gate: HTTP ' + status + (raw != null ? ' (Retry-After: ' + raw + ')' : '') + ' — every script holds ' + Math.round(hold / 100) / 10 + 's'); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState(s => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 30000 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) { const r = await fetch(url, init); if (!mbnWebsite(url) || !(await mbnIsCheck(r))) return r; // #733: MusicBrainz's browser check answered instead: nothing was done (a POST is safe // to repeat), so pass the check and ask again return (await mbnReverify(url, init, o)) ? fetch(url, init) : r; } const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, n => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnHot() { let s; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; } return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over } function mbnRawHeader(raw, name) { const want = String(name).toLowerCase(); for (const line of String(raw || '').split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim(); } return null; } function mbnWebsite(url) { const s = String(url || ''); if (mbnGated(s)) return false; if (/^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org(\/|$)/i.test(s)) return true; return /^\/(?!\/)/.test(s) && typeof location !== 'undefined' && /(^|\.)musicbrainz\.org$/i.test(location.hostname); } async function mbnIsCheck(r) { try { if (!r || !/text\/html/i.test((r.headers && r.headers.get('content-type')) || '')) return false; const head = (await r.clone().text()).slice(0, 3000); return /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(head); } catch (e) { return false; } } function mbnReverify(url, init, o) { o = o || {}; const log = (lvl, m) => { try { if (o.log) o.log(lvl, m); else (lvl === 'warn' ? console.warn : console.info)('[MusicBrainz gate] ' + m); } catch (e) {} }; const g = globalThis.__mbnCheck || (globalThis.__mbnCheck = { busy: null, failedAt: 0 }); if (g.busy) return g.busy; let target; try { target = new URL(String(url), location.href); } catch (e) { return Promise.resolve(false); } if (target.host !== location.host || typeof document === 'undefined') { log('warn', 'MusicBrainz asked to verify the browser — open any MusicBrainz page, then retry'); return Promise.resolve(false); } if (Date.now() - g.failedAt < 60000) return Promise.resolve(false); // just failed: don't loop // a POST's URL can't be loaded: the check is per browser, so any light GET passes it const method = String((init && init.method) || 'GET').toUpperCase(); const src = method === 'GET' ? target.href : location.origin + '/ws/js/entity/89ad4ac3-39f7-470e-963a-56509c546377'; const t0 = Date.now(); log('warn', 'MusicBrainz asked to verify the browser again ("Verifying your browser") — passing it in a hidden frame'); g.busy = new Promise(resolve => { const fr = document.createElement('iframe'); // fresh each time, never re-navigated fr.setAttribute('aria-hidden', 'true'); fr.setAttribute('data-mbn-check', ''); fr.tabIndex = -1; fr.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;top:0;border:0;opacity:0;pointer-events:none'; let loads = 0, done = false, timer = 0; const finish = ok => { if (done) return; done = true; clearTimeout(timer); fr.remove(); g.busy = null; if (ok) log('info', 'browser check passed in ' + (Date.now() - t0) + ' ms — retrying'); else { g.failedAt = Date.now(); const m = 'MusicBrainz wants to verify your browser — open any MusicBrainz page in another tab, then retry'; log('warn', m + ' (the hidden frame did not clear it in 20 s)'); try { if (o.notify) o.notify(m); } catch (e) {} } resolve(ok); }; // the first load is the check; it posts its answer and is redirected to the real URL fr.onload = () => { loads++; let still; try { const d = fr.contentDocument; still = !d || /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(d.documentElement ? d.documentElement.outerHTML.slice(0, 3000) : ''); } catch (e) { still = loads < 2; } if (!still) finish(true); }; timer = setTimeout(() => finish(false), 20000); fr.src = src; (document.body || document.documentElement).appendChild(fr); }); return g.busy; } // </ST-NET> // <ST-MBPAGE> — generated by dev/mbpage/sync-mbpage.mjs from dev/mbpage/mb-page.mjs — DO NOT EDIT const MBP_COVER_TYPES = {"1":"Front","2":"Back","3":"Booklet","4":"Medium","5":"Obi","6":"Spine","7":"Track","8":"Other","9":"Tray","10":"Sticker","11":"Poster","12":"Liner","13":"Watermark","14":"Raw/Unedited","15":"Matrix/Runout","48":"Top","49":"Bottom"}; function mbpCoverTypeId(name) { const want = String(name || '').trim().toLowerCase(); for (const [id, n] of Object.entries(MBP_COVER_TYPES)) if (n.toLowerCase() === want) return id; return null; } function mbpReleaseLinkList(doc) { doc = doc || document; const lists = [...doc.querySelectorAll('ul.external_links')].map(ul => { let h = ul.previousElementSibling; while (h && !/^H\d$/.test(h.tagName)) h = h.previousElementSibling; return { ul, head: h && h.classList.contains('external-links') ? (h.textContent || '').trim() : null }; }).filter(l => l.head !== null); const lang = (doc.documentElement && doc.documentElement.lang) || 'en'; const key = 'mbu.extLinksHeading.' + lang; // shared storage: every script learns it once if (lists.length >= 2) { // the release group's is the last; the release's is the one headed differently from it const rgHead = lists[lists.length - 1].head; const own = lists.find(l => l.head !== rgHead); if (!own) return null; if (!/^en\b/i.test(lang)) { try { localStorage.setItem(key, JSON.stringify(own.head)); } catch (e) { /* storage blocked: nothing learnt */ } } return own.ul; } if (lists.length !== 1) return null; if (/^external links$/i.test(lists[0].head)) return lists[0].ul; if (/^en\b/i.test(lang)) return null; // English needs nothing learnt: any other heading is the release group's let learnt = null; try { learnt = JSON.parse(localStorage.getItem(key)); } catch (e) { /* storage blocked */ } return learnt && learnt === lists[0].head ? lists[0].ul : null; } function mbpReleaseJsonLinks(doc) { doc = doc || document; const mbid = ((doc.location && doc.location.pathname) || '').match(/\/release\/([0-9a-f-]{36})/i); for (const s of doc.querySelectorAll('script[type="application/json"]')) { const t = s.textContent || ''; if (t.indexOf('"relationships"') < 0) continue; let rel = null; try { rel = JSON.parse(t).release; } catch (e) { continue; } if (!rel || rel.entityType !== 'release' || !Array.isArray(rel.relationships)) continue; if (mbid && rel.gid && rel.gid.toLowerCase() !== mbid[1].toLowerCase()) continue; // MB writes some protocol-relative (//x.bandcamp.com), as in its sidebar: resolve them const abs = h => { try { return new URL(h, (doc.location && doc.location.href) || 'https://musicbrainz.org/').href; } catch (e) { return ''; } }; return rel.relationships.map(r => r && r.target).filter(u => u && u.entityType === 'url' && u.href_url).map(u => abs(u.href_url)).filter(u => /^https?:/.test(u)); } return null; } function mbpReleaseLinks(doc) { const data = mbpReleaseJsonLinks(doc); if (data) return [...new Set(data)]; const ul = mbpReleaseLinkList(doc); // MB writes some hrefs protocol-relative (//x.bandcamp.com): a.href resolves them return ul ? [...ul.querySelectorAll('a[href]')].map(a => a.href).filter(u => /^https?:/.test(u)) : []; } // </ST-MBPAGE> const VERSION = '?'; // GM_info carries the real one; every script manager provides it (a hard-coded copy only ever went stale) const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } }; const NAME = 'Falcon'; // The shared design tokens (#562). Definitions live in dev/tokens/design-tokens.mjs // and are inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never here. // <ST-TOKENS> — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // </ST-TOKENS> // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here. // <ST-UI> — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on <html>, which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'<svg…>' | '<img…>', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // o.history opt-in history of past sessions (#705), below // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // viewer appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) : '--:--:--.---'; }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + (e.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button, input, select') || pop.classList.contains('full')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); views = views.filter(function (v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in <body>: a permanent stray node under <html>, outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No <body> yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and <head> and <body> don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } // </ST-UI> const MB_ORIGIN = location.origin; // the ACTUAL MusicBrainz origin — used to build the outbound url when this script // is running ON Harmony (there, MB_ORIGIN above is Harmony's own origin, not MB's). // // ⚠ This must follow whichever MusicBrainz server the panel is open on, not be // pinned to production. @match covers *.musicbrainz.org, which includes // test.musicbrainz.org and beta — and a hardcoded production target meant a // batch queued while testing on the SANDBOX would build its edit urls against // the LIVE site and quietly edit real data. Workers are same-origin iframes, so // a cross-origin target could not work anyway. Only fall back to production for // the one case the comment above describes: running on Harmony, where the // current origin genuinely isn't MusicBrainz. const MB_TARGET = /(^|\.)musicbrainz\.org$/i.test(location.hostname) ? location.origin : 'https://musicbrainz.org'; const ON_HARMONY = /(^|\.)harmony\.pulsewidth\.org\.uk$/i.test(location.hostname); const HELP_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/falcon/README.md'; // the corner launcher, the panel header and the Harmony button all wear // the script's own icon (the same picture as @icon) const ICON_URL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5GYWxjb248L3RpdGxlPgo8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSI1OCIgZmlsbD0iI2Y2ZWZlMiIgc3Ryb2tlPSIjMWIyYTRhIiBzdHJva2Utd2lkdGg9IjYiLz48cGF0aCBkPSJNMTA1LjYgNDAuMCBBNDggNDggMCAwIDEgMTA1LjYgODguMCIgc3Ryb2tlPSIjZDYyODI4IiBzdHJva2Utd2lkdGg9IjciIGZpbGw9Im5vbmUiLz4KPGcgc3Ryb2tlPSIjMWIyYTRhIiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxwYXRoIGQ9Ik0yMC43IDg5LjAgTDI5LjQgODQuMCIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTE0LjMgNjkuMiBMMjQuMiA2OC4yIiBzdHJva2Utd2lkdGg9IjMiLz48cGF0aCBkPSJNMTYuNCA0OC41IEwyNi4wIDUxLjYiIHN0cm9rZS13aWR0aD0iMyIvPjxwYXRoIGQ9Ik0yNi44IDMwLjUgTDM0LjMgMzcuMiIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTQzLjcgMTguMyBMNDcuNyAyNy41IiBzdHJva2Utd2lkdGg9IjMiLz48cGF0aCBkPSJNNjQuMCAxNC4wIEw2NC4wIDI0LjAiIHN0cm9rZS13aWR0aD0iMyIvPjxwYXRoIGQ9Ik04NC4zIDE4LjMgTDgwLjMgMjcuNSIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTEwMS4yIDMwLjUgTDkzLjcgMzcuMiIgc3Ryb2tlLXdpZHRoPSIzIi8+PHBhdGggZD0iTTExMS42IDQ4LjUgTDEwMi4wIDUxLjYiIHN0cm9rZS13aWR0aD0iMyIvPjxwYXRoIGQ9Ik0xMTMuNyA2OS4yIEwxMDMuOCA2OC4yIiBzdHJva2Utd2lkdGg9IjMiLz48cGF0aCBkPSJNMTA3LjMgODkuMCBMOTguNiA4NC4wIiBzdHJva2Utd2lkdGg9IjMiLz48L2c+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQgOTQpIHJvdGF0ZSgwKSBzY2FsZSgwLjE2KSB0cmFuc2xhdGUoLTY0IC02NCkiPjxwYXRoIGQ9Ik02NCAyNiBDNjcgMjYgNzAgMzAgNzAgMzYgQzcyIDQwIDc0IDQyIDc2IDQ0IEM5MiA0MiAxMDggMzYgMTI0IDM0IEMxMTIgNDQgOTYgNTQgNzggNjIgQzc2IDY4IDc0IDc0IDc0IDgwIEw4MCAxMDAgQzc0IDEwNCA2OCAxMDQgNjQgMTA0IFoiIGZpbGw9IiM0YTVhNzgiLz48cGF0aCBkPSJNNjQgMjYgQzY3IDI2IDcwIDMwIDcwIDM2IEM3MiA0MCA3NCA0MiA3NiA0NCBDOTIgNDIgMTA4IDM2IDEyNCAzNCBDMTEyIDQ0IDk2IDU0IDc4IDYyIEM3NiA2OCA3NCA3NCA3NCA4MCBMODAgMTAwIEM3NCAxMDQgNjggMTA0IDY0IDEwNCBaIiBmaWxsPSIjNGE1YTc4IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMjggMCkgc2NhbGUoLTEgMSkiLz48L2c+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQgNjQpIHJvdGF0ZSgxMTIpIj48cGF0aCBkPSJNMCAtNDYgTDQgMCBMLTQgMFoiIGZpbGw9IiMxYjJhNGEiLz48L2c+CjxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjciIGZpbGw9IiMxYjJhNGEiLz4KPC9zdmc+Cg=='; const ICON = `<img src="${ICON_URL}" alt="" width="20" height="20" style="display:block">`; // mbRestackCorner, the #468 corner slots, is in the ST-UI block (dev/ui/ui-components.mjs). /* ── settings ────────────────────────────────────────────────────────── */ const cfg = { // Default 5, from majkinetor measuring a real batch: 1.8s/item at 5 workers // vs 2.3s at 3. Above that the gain flattens and MB gets a rougher ride, so // the cap stays at 6. get workers() { const n = Number(GM_getValue('falcon:workers', 5)); return Math.max(1, Math.min(6, isFinite(n) ? n : 5)); }, set workers(n) { GM_setValue('falcon:workers', Math.max(1, Math.min(6, Number(n) || 5))); }, // #467 (majkinetor): "Add worker window size so I can see better what is // wrong [with] multiple windows" — the default thumbnail is too small to // read a validation message, so make it adjustable and remember it. Card // height tracks width at 4:5. get workerSize() { const n = Number(GM_getValue('falcon:workerSize', 260)); return Math.max(200, Math.min(900, isFinite(n) ? n : 260)); }, set workerSize(n) { GM_setValue('falcon:workerSize', Math.max(200, Math.min(900, Number(n) || 260))); }, // #508 (majkinetor): "Options" screen additions. get hideLauncher() { return GM_getValue('falcon:hideLauncher', false) === true; }, // #663: the queue's layout, 'list' (a labelled form per open row) or 'grid' (one line per row) // #663: the locale a typed alias gets when it carries no @locale of its own ('' = none) get aliasLang() { return String(GM_getValue('falcon:aliasLang', '') || ''); }, set aliasLang(v) { GM_setValue('falcon:aliasLang', String(v || '').trim()); }, get queueView() { return GM_getValue('falcon:queueView', 'list') === 'grid' ? 'grid' : 'list'; }, set queueView(v) { GM_setValue('falcon:queueView', v === 'grid' ? 'grid' : 'list'); }, set hideLauncher(v) { GM_setValue('falcon:hideLauncher', !!v); }, // 2026-10-11 (majkinetor): this and the three Harmony automations below are on by default. get coverOnlyIfNone() { return GM_getValue('falcon:coverOnlyIfNone', true) === true; }, set coverOnlyIfNone(v) { GM_setValue('falcon:coverOnlyIfNone', !!v); }, // #537 (majkinetor): "I think this is all very complicated and is not // something Falcon should do. Add an option to not process Harmony covers." // Cover art is the one payload Falcon cannot get exactly right on its own: // Harmony gives the image URL the provider's own API returns, which is // often not the largest that provider will serve, and reaching the bigger // one means owning per-provider URL rules (see the issue). With this on, // Falcon leaves covers alone entirely and you use ECAU or Art Station for // them; everything else in a Harmony batch still goes through as usual. get skipHarmonyCovers() { return GM_getValue('falcon:skipHarmonyCovers', false) === true; }, set skipHarmonyCovers(v) { GM_setValue('falcon:skipHarmonyCovers', !!v); }, // #508 follow-up (majkinetor): "Auto start Harmony import"; on by default since 2026-10-11. get autoStartHarmonyImport() { return GM_getValue('falcon:autoStartHarmonyImport', true) === true; }, set autoStartHarmonyImport(v) { GM_setValue('falcon:autoStartHarmonyImport', !!v); }, // #557 (majkinetor): "Current auto start works on MB side. Let's have another // option 'Auto send' in Harmony category. Having both enabled would // automatically finish everything after successful Harmony import." This is // the HARMONY half — it presses "Send to Falcon" for you; autoStartHarmonyImport // above is the MB half that then starts the queue. get autoSendFromHarmony() { return GM_getValue('falcon:autoSendFromHarmony', true) === true; }, set autoSendFromHarmony(v) { GM_setValue('falcon:autoSendFromHarmony', !!v); }, // How long the cancel window lasts before an auto-send fires. Not exposed in // the options UI — it exists so the send is interruptible, not to be tuned. get autoSendDelayMs() { const n = Number(GM_getValue('falcon:autoSendDelayMs', 4000)); return Math.max(0, Math.min(30000, isFinite(n) ? n : 4000)); }, set autoSendDelayMs(v) { GM_setValue('falcon:autoSendDelayMs', Math.max(0, Math.min(30000, Number(v) || 0))); }, // #590: no option. majkinetor, after trying the first cut: "lets just look // for MB errors (without an option) and send at the end whatever is there." // Not in the options UI, same reasoning as autoSendDelayMs: this bounds the // retry so it can't become a loop against MusicBrainz, it is not a knob. get harmonyReloadMax() { const n = Number(GM_getValue('falcon:harmonyReloadMax', 5)); return Math.max(0, Math.min(20, isFinite(n) ? n : 5)); }, set harmonyReloadMax(v) { GM_setValue('falcon:harmonyReloadMax', Math.max(0, Math.min(20, Number(v) || 0))); }, // #588 (chaban-mb / majkinetor): "add new Harmony option: Reload release page // after import without errors." chaban runs Falcon fully automatic, so all he // ever sees is a drained queue over a stale release view. majkinetor's own // ruling on #588 was that a blanket standard is wrong (PC restarts because // Scout must see it; Falcon doesn't because you want the queue's results) — // hence an option (on by default since 2026-10-11), and the run log survives the reload // regardless since #512 put sessions in localStorage. get reloadReleaseAfterImport() { return GM_getValue('falcon:reloadReleaseAfterImport', true) === true; }, set reloadReleaseAfterImport(v) { GM_setValue('falcon:reloadReleaseAfterImport', !!v); }, // #512 (majkinetor): "keep configurable number of last runs in local // storage so those can be selected and loaded by datetime". // #705 (majkinetor): "lets reduce default from 20 to 10" get logHistoryCount() { const n = Number(GM_getValue('falcon:logHistoryCount', 10)); return Math.max(1, Math.min(100, isFinite(n) ? n : 10)); }, set logHistoryCount(n) { GM_setValue('falcon:logHistoryCount', Math.max(1, Math.min(100, Number(n) || 10))); }, // #508 follow-up (majkinetor): "Open from Harmony in new tab (on by // default)... Implement 'Off' option that doesn't open new tab but // opens MB in existing one" — the existing one being the Harmony tab // itself, navigated away to MB instead of window.open()'d elsewhere. get openHarmonyInNewTab() { return GM_getValue('falcon:openHarmonyInNewTab', true) === true; }, set openHarmonyInNewTab(v) { GM_setValue('falcon:openHarmonyInNewTab', !!v); }, // #578. This started life as "add ?tport= or not" and is now narrower: // ?tport= is always added (see picardParam), so the tagger button is always // there to click; this governs only whether Falcon ALSO calls Picard by // itself when a run finishes — "[x] Automatically send to Picard using port // [8000]". Off by default, since an unattended hand-over to a program that // may not be running is not a sensible default. // // The storage key still says sendToPicard rather than autoSendToPicard: it // is the same setting doing the same job for the same people, and renaming // it would silently reset it for anyone who has already ticked the box. get sendToPicard() { return GM_getValue('falcon:sendToPicard', false) === true; }, set sendToPicard(v) { GM_setValue('falcon:sendToPicard', !!v); }, // Clamped on the way in AND on the way out, so a hand-edited stored value // can't put something that isn't a port into a URL. get picardPort() { const n = Math.floor(Number(GM_getValue('falcon:picardPort', 8000))); return (isFinite(n) && n >= 1 && n <= 65535) ? n : 8000; }, set picardPort(v) { const n = Math.floor(Number(v)); GM_setValue('falcon:picardPort', (isFinite(n) && n >= 1 && n <= 65535) ? n : 8000); }, }; /* ── the log: the shared activity log (#705), mounted in the panel's Log tab ── */ // #467 (majkinetor, emphatically: "I DON'T WANT LOGS FROM OTHER RUNS. I WANT YOU TO // KEEP THE WINDOW OPEN AND HAVE A SINGLE LOG OF THAT SESSION"), #512 ("keep // configurable number of last runs in local storage so those can be selected and // loaded by datetime") and #593 ("it is missing starting lines"): each run has its // own log, written to localStorage within 100 ms of each line (a crashed or // navigated tab keeps it; GM storage wasn't flushed on the way out), the last N runs // kept, and a long run keeps its start and end and drops the middle. #705 moved // those rules into the shared log's history; Falcon decides when a run starts // (newSession), that it is worth keeping (once its workers start: "We should have // only processing logs"), and when a navigation carries one on (the reattach below). const LOG_PERSIST_MAX = 400, LOG_PERSIST_HEAD = 140; const LS = (() => { try { return window.localStorage; } catch (e) { return null; } })(); const LS_PREFIX = 'falcon:session:'; // a session id is its start, YYYYMMDDHHMMSS-N; falcon:session:current and :midrun // share the prefix, so a key counts only when the rest looks like an id const SESSION_ID_RE = /^\d{14}-\d+$/; function listSessionKeys() { if (!LS) return []; const out = []; try { for (let i = 0; i < LS.length; i++) { const k = LS.key(i); if (!k || !k.startsWith(LS_PREFIX)) continue; const id = k.slice(LS_PREFIX.length); if (SESSION_ID_RE.test(id)) out.push(id); } } catch (e) {} return out.sort(); // the id's own YYYYMMDDHHMMSS-N prefix sorts chronologically as a string } // a run stored before #705 is lines of text: "[hh:mm:ss] LEVEL [w1] message", the time in UTC function parseStoredLine(line, day) { const m = /^\[(\d\d):(\d\d):(\d\d)\] (\w+)\s+(?:\[(w\d+|w\?)\] )?([\s\S]*)$/.exec(line); if (!m) { const d = /^\[--:--:--\] (\w+)\s+([\s\S]*)$/.exec(line); return d ? { sev: d[1].toLowerCase(), msg: d[2] } : null; } const t = day ? new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), +m[1], +m[2], +m[3])) : null; return { t, sev: m[4].toLowerCase(), cat: m[5] || '', msg: m[6], pre: m[6].includes('\n') }; } const Log = mbuLog({ name: NAME, version: scriptVersion, header: 'Falcon — activity log', key: 'falcon:logwin', subtitle: () => { try { return (LS && LS.getItem(sessionNameKey(Log.sessionId()))) || ''; } catch (e) { return ''; } }, history: { prefix: LS_PREFIX, keep: () => cfg.logHistoryCount, lines: LOG_PERSIST_MAX, head: LOG_PERSIST_HEAD, parse: parseStoredLine, nameOf: extractReleaseName }, }); // #512 follow-up (majkinetor): "Logs sometimes do not have a name although in log it // is present" — the release name has its own small key, never trimmed with the log function sessionNameKey(id) { return LS_PREFIX + id + ':name'; } function noteSessionReleaseName(name) { if (name) Log.name(name); } // #671: the name a JSON model gives its batch (its root `name`), for a queue with no release // in it to name the session after — Platform Check's artists and labels, say let _batchName = ''; function deleteSessionData(id) { Log.forget(id); } function loadSessionLines(id) { return Log.load(id); } // the name key, else mined from the log of a session stored before that key existed function sessionReleaseName(id) { try { const n = LS && LS.getItem(sessionNameKey(id)); if (n) return n; } catch (e) {} return extractReleaseName(Log.load(id) || []); } function pruneOldSessions() { const ids = listSessionKeys(); const excess = ids.length - cfg.logHistoryCount; if (excess > 0) ids.slice(0, excess).forEach(deleteSessionData); } // #512 follow-up (majkinetor: "I got bunch of historic logs without any processing... // We should have only processing logs"). A run is kept once it logs "starting N // worker(s)"; this catches a session stored before #705 that never got that far. function sessionHasRealWork(entries) { return entries.some(e => /^starting \d+ worker/.test(e.msg)); } function newSession(reason) { const prevId = Log.last(); if (prevId && prevId !== Log.sessionId()) { const prev = Log.load(prevId); if (prev && !sessionHasRealWork(prev)) deleteSessionData(prevId); } Log.session(); /* #593 (majkinetor): "It didn't have a name (just date) while release name is resolved." A Harmony seed starts a session at boot, the release name arrives a moment later, and then Start begins the run's own session. The queue survives across the two, so carry the name over. */ try { const rel = queue.find(i => i.entityType === 'release' && i.name); if (rel) noteSessionReleaseName(rel.name); else if (_batchName && queue.length) noteSessionReleaseName(_batchName); } catch (e) {} log('info', `=== session ${Log.sessionId()} started (${reason}) ===`); log('info', mbuStartupInfo('Falcon')); } // carry on a run's log after a navigation, but ONLY when a run was still going when // the tab unloaded (#512 follow-up, majkinetor: "I reload the MB page and expect // empty log, but I get this" — a finished run's log) try { const cur = Log.last(); if (cur && LS && LS.getItem('falcon:session:midrun') === '1') Log.resume(cur); } catch (e) {} const writeLogNow = () => Log.flush(); // Forensics for the panel vanishing mid-run: record every unload synchronously, // with where the tab went, so the next load can say so. #512 follow-up: an unload // after a finished run is expected, so only a mid-run one is an error. function noteUnload(ev) { try { const busy = typeof queue !== 'undefined' && queue.some(i => i.status === 'active' || i.status === 'queued'); Log.add(busy ? 'error' : 'info', `*** THIS TAB IS BEING UNLOADED (${busy ? 'MID-RUN' : 'after the run finished'}) *** from ${location.href} via ${(ev && ev.type) || '?'} — the panel and its workers are being destroyed by the page going away, NOT by Falcon closing them.${busy ? ' Report this line.' : ''}`); // `running`, not `busy`: a seeded-but-never-Started item sits in 'queued' // forever, but nothing was in progress, so it must not reattach try { if (LS) LS.setItem('falcon:session:midrun', running ? '1' : '0'); } catch (e) {} Log.flush(); } catch (e) {} } try { window.addEventListener('pagehide', noteUnload); window.addEventListener('beforeunload', noteUnload); } catch (e) {} // a worker's lines start "[w1] …" and the run summary "[run] …": that becomes the line's // category (#705), a filter chip in the Log tab; a message of several lines (the summary) is kept as written function log(level, msg) { const text = String(msg), m = /^\[(w\d+|w\?|run)\] /.exec(text); const L = m ? Log.cat(m[1]) : Log, body = m ? text.slice(m[0].length) : text; if (level === 'info' && body.includes('\n')) L.pre(body); else L.add(level, body); try { (console[level] || console.log).call(console, '[Falcon]', msg); } catch (e) {} } // #467 (majkinetor: "it was slow and UI was frozen after some time"): every // log line used to re-render the whole log pane synchronously (join of up to // 1500 lines + textContent + scroll), and every queue mutation re-rendered // the entire queue list. Under 3 concurrent workers that's a lot of layout // thrash on the SAME main thread the worker iframes are competing for. // Coalesce instead: mark what's dirty and repaint once per animation frame. let _dirty = {}, _rafPending = false; function scheduleRender(what) { _dirty[what] = true; if (_rafPending) return; _rafPending = true; requestAnimationFrame(() => { _rafPending = false; const d = _dirty; _dirty = {}; if (d.queue) renderQueue(); if (d.workers) renderWorkerLayout(); }); } // #467 (majkinetor, discussion #459): "some workers still randomly do not // submit stuff for the unknown reason" — intermittent and not reproducible // locally, so every step of a worker's run is traced at debug level, tagged // with WHICH worker (they run concurrently, so untagged lines interleave into // an unreadable mess). The goal is that a single real run's log says exactly // where a worker stopped short, without needing a live repro. // #705: the switch is the shared log's own (majkinetor: "component should have its own UI // for that"), off by default; a setting made with Falcon's old switch carries over once try { const was = GM_getValue('falcon:debug', null); if (was !== null) { Log.setDebug(was === true); GM_deleteValue('falcon:debug'); } } catch (e) {} const debugOn = () => Log.debugOn(); function dbg(tag, msg) { if (debugOn()) log('debug', `${tag} ${msg}`); } /* ── queue item shape: {id, entityType, mbid, urls: [{url,linkTypeId}], note, disambiguation, isrcs, video, source, aliases: [{name, locale, type, primary, sortName, begin, end, ended}], cover: [{url, comment, type, candidates: [{provider,url,width?,height?,size?}]}], coverExistingCount, urlResults, status, error} ── entityType is one of artist/label/recording/release/ release_group. Field usage by type (#496 — the rest are always present on the item internally for simplicity, but only these are ever read/rendered): - urls[]: all types (release_group and release included, #495) - isrcs[]: recording only - aliases[]: all types (#535) — each entry is one MB edit, submitted through /<entity>/<mbid>/add-alias rather than the entity's own edit form (see submitAlias). `type` is the alias type's NAME as MB spells it for that entity ("Recording name", "Search hint", "Legal name" on artists) and is resolved against the form's own <select>. - video: recording only (#534) — boolean, MB's Video checkbox. Only ever seeded when TRUE: leaving it out preserves an existing flag, so false means "don't touch", not "clear it". Falcon never unsets it on MB. - disambiguation: all five types (#533; MB's own form field for this is internally called `comment`, but Falcon's own field is named for what it actually is — #496: "don't use `comment` for two unrelated things"). A release's is typed into MB's Knockout editor rather than seeded. - cover[]: release only — an ARRAY (a release can carry more than one cover image; Falcon today only ever populates one entry from Harmony, but the shape supports more so a future batch/JSON import can add several at once, #496). Each entry's own `comment` is THAT image's upload comment (e.g. "page 1"), unrelated to `disambiguation`. `type` is the MB cover-art type (front/back/booklet/…), default 'front'. `url` is auto-picked by pickBestCover, always user-editable. coverExistingCount (item-level, not per-cover-entry — it's a fact about the RELEASE, not about any one image being added) is set by checkExistingCoverArt. A release item can have urls[] AND cover[] at once (independent MB edits on the same entity — see workerLoop's `needsCover` handling and runCoverItem's `priorLinks` merge) or either alone. A release_group/release/artist/label/recording item with urls[] goes through the normal iframe/form worker path; a release's cover goes through runCoverItem's upload-API path instead — never both through the same mechanism. #467 (majkinetor): the same entity can carry several URLs — group those into ONE item/one edit-page visit rather than revisiting the same mbid N times (both unsafe — two workers must never load the same entity's /edit at once — and wasteful). Grouping happens at add-time (see addToQueue); nextQueued() additionally refuses to hand out a queued item whose entity is already 'active' in another worker, so a later-added item for the same entity can never race the one already in flight. `linkTypeId` is optional — when present (e.g. from Harmony, which already knows exactly which relationship type it wants) it's used to set the type select if MB renders one instead of auto-classifying; when absent MB's own classifier decides, same as before. The SAME url can legitimately appear twice with two different linkTypeId values (Harmony does this for e.g. a Bandcamp track that's both "stream for free" and "purchase for download") — handled by fillAndSubmit via MB's own "Add another relationship" row rather than re-typing the url, so dedup below is keyed on (url, linkTypeId), not url alone. */ let queue = []; let _idSeq = 0; let running = false; // review-UX state (#467): which rows are checked (for bulk remove) / expanded // (showing their full url list) — kept outside `queue` itself since it's pure // display state, survives across renderQueue() calls (a full innerHTML replace). let _selectedIds = new Set(); let _expandedIds = new Set(); // #497 (majkinetor): per-entity-type processing toggle, shown as chips in // the queue toolbar ("artist 5", "release 7", ...). All ON by default — // turning one off excludes every QUEUED item of that type from the run // (nextQueued skips them) without removing them from the queue; an // already-active/done/failed item is untouched by toggling since this only // gates what a worker picks up NEXT, not history. let _disabledTypes = new Set(); // #513 (majkinetor): "Its still not easily seeable that there were issues. // Lets add some chip with results in appropriate color if there are // issues... Make the chip clickable to filter in just those results." — // null shows everything; a status string shows only rows in that status. let _statusFilter = null; // #494: 'release' can carry BOTH a urls[] link (via the normal iframe/form // pipeline, like every other type — #495) AND a cover (via the upload API, // never the form; see runCoverItem) — a release item's worker turn runs // whichever of those it actually has. 'release_group' (#495) is a plain // urls[]-only type, same pipeline as artist/label/recording, no special // casing — MB's URL path for it is 'release-group' (hyphen) even though the // internal entityType value and seed-param prefix use 'release_group' // (underscore, matching MB's own `edit-release_group.` seed params); see // entityUrlSegment. const ENTITY_RE = /^(artist|label|recording|release|release_group)$/; const MBID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // #713: a release item's barcode, digits only ('' for none) const barcodeOf = v => String(v == null ? '' : v).replace(/\D/g, ''); // #727: a release item's labels: [{ mbid, name, catno, add }], a catalog number for a label the release has, or // (add) a label to add, with its catalog number if any const catnosOf = v => (Array.isArray(v) ? v : []).filter(l => l && MBID_RE.test(String(l.mbid || '')) && (String(l.catno || '').trim() || l.add === true)) .map(l => ({ mbid: String(l.mbid), name: String(l.name || ''), catno: String(l.catno || '').trim(), ...(l.add === true ? { add: true } : {}) })); // #496: MB's own cover-art type vocabulary — same list as Art Station's // COVER_TYPES (art_station.user.js), kept identical so a type picked here // maps onto the exact same label MB's own add-cover-art form shows. const COVER_TYPES = ['Front', 'Back', 'Booklet', 'Medium', 'Tray', 'Obi', 'Spine', 'Track', 'Liner', 'Sticker', 'Poster', 'Watermark', 'Raw/Unedited', 'Matrix/Runout', 'Top', 'Bottom', 'Panel', 'Other']; const newCoverEntry = (url, candidates) => ({ url: url || '', comment: '', type: 'Front', candidates: candidates || [] }); // #496: accepts the current array shape, the pre-#496 single-object shape // ({url, candidates}), and the even older bare top-level `coverCandidates` // — so a JSON file exported before this change still imports cleanly. function normalizeCoverForImport(r) { if (Array.isArray(r.cover)) return r.cover.filter(c => c && typeof c === 'object').map(c => ({ url: String(c.url || ''), comment: String(c.comment || ''), type: String(c.type || 'Front'), candidates: Array.isArray(c.candidates) ? c.candidates : [], })); if (r.cover && typeof r.cover === 'object') return [{ url: String(r.cover.url || ''), comment: String(r.comment || ''), type: 'Front', candidates: Array.isArray(r.cover.candidates) ? r.cover.candidates : [], }]; if (Array.isArray(r.coverCandidates) && r.coverCandidates.length) return [newCoverEntry('', r.coverCandidates)]; return []; } function normalizeEntityType(raw) { const t = String(raw || 'artist').trim().toLowerCase().replace(/-/g, '_'); return ENTITY_RE.test(t) ? t : 'artist'; } // MB's own URL path segment for an entity type — every type matches its // entityType verbatim except release_group, whose real path is // 'release-group' (hyphen) while the internal value/seed-param prefix stays // 'release_group' (underscore, MB's own inconsistency — verified live). function entityUrlSegment(entityType) { return entityType === 'release_group' ? 'release-group' : entityType; } // #533 (majkinetor): "We currently support Disambiguation for recordings. // Lets complete it for other entities." — then: "Release also has // disambiguation attribute. A field in 'Additional Information'. Not sure if // there is an API or form must be driven." // // Every MB entity has one, and Falcon now sets all five. They take two // different routes, because the release editor is not a form like the others: const DISAMBIGUATABLE = new Set(['artist', 'label', 'recording', 'release_group', 'release']); // …four are plain server-rendered forms, seeded straight from the url like // every other field here… const COMMENT_SEEDS = new Set(['artist', 'label', 'recording', 'release_group']); // #571 (majkinetor): "We should be able to change the name of the entity." // // Renaming splits exactly the same way disambiguation does, for exactly the // same reason — four server-rendered forms take `edit-<type>.name=` from the // query string, and the release editor is a Knockout app that ignores it, so // that one gets typed into (setReleaseName). // // The item field is `rename`, NOT `name`: `name` already means the entity's // CURRENT display name throughout the queue, the JSON model and the // `?falcon=` param. Overloading it would make an existing exported file with // names in it silently rename every entity in the queue on re-import. // #663: the names of MB's url link types, so a queued link reads "free streaming" // rather than "type 194". A static copy of /ws/js/type-info/link_type (url types // only); an id missing from it still shows as "type N". const URL_LINK_TYPES = {"72":"production","73":"get the music","74":"purchase for download","75":"download for free","76":"discogs","77":"amazon asin","78":"cover art link","79":"purchase for mail-order","82":"other databases","83":"IMDB samples","85":"free streaming","86":"vgmdb","88":"discography","89":"wikipedia","90":"discogs","93":"lyrics","94":"review","96":"other databases","97":"IMDb","171":"discography","172":"fanpage","173":"image","174":"purevolume","175":"purchase for mail-order","176":"purchase for download","177":"download for free","178":"IMDb","179":"wikipedia","180":"discogs","182":"biography","183":"official homepage","184":"discography page","185":"online community","187":"get the music","188":"other databases","189":"myspace","190":"BBC Music page","191":"vgmdb","192":"social network","193":"youtube","194":"free streaming","197":"lyrics","199":"blog","210":"vgmdb","211":"history site","212":"catalog site","213":"logo","214":"fanpage","215":"myspace","216":"wikipedia","217":"discogs","218":"social network","219":"official site","221":"online data","222":"other databases","224":"blog","225":"youtube","254":"purchase for download","255":"download for free","256":"production","257":"get the music","258":"IMDB samples","268":"free streaming","271":"lyrics","273":"other databases","274":"download for free","279":"wikipedia","280":"secondhandsongs","283":"allmusic","284":"allmusic","285":"allmusic","286":"allmusic","287":"official homepage","288":"discography entry","289":"songfacts","290":"soundcloud","291":"soundcloud","301":"license","302":"license","303":"video channel","304":"video channel","306":"other databases","307":"secondhandsongs","308":"secondhandsongs","310":"VIAF","311":"VIAF","312":"VIAF","313":"IMDb","351":"wikidata","352":"wikidata","353":"wikidata","354":"wikidata","355":"wikipedia","358":"wikidata","363":"official homepage","396":"image","429":"social network","462":"myspace","495":"video channel","528":"youtube","561":"other databases","594":"wikidata","595":"wikipedia","627":"blog","660":"online data","705":"discogs","706":"IMDb","707":"interview","713":"geonames","718":"bandcamp","719":"bandcamp","729":"show notes","730":"other databases","731":"wikipedia","732":"image","733":"wikidata","734":"information page","744":"wikipedia","745":"official homepage","746":"other databases","747":"discogs","749":"wikidata","753":"other databases","754":"IMSLP","755":"allmusic","782":"official homepage","783":"social network","784":"social network","785":"songkick","786":"songkick","787":"songkick","788":"vgmdb","789":"wikipedia","790":"wikidata","791":"youtube","792":"youtube","803":"other databases","804":"video channel","805":"video channel","808":"poster","811":"setlistfm","816":"setlistfm","817":"setlistfm","837":"last.fm","838":"last.fm","839":"last.fm","840":"last.fm","841":"online data","842":"review","843":"IMDb","850":"BookBrainz","851":"BookBrainz","852":"BookBrainz","853":"BookBrainz","854":"BookBrainz","860":"bandsintown","861":"bandsintown","862":"bandsintown","870":"soundcloud","897":"patronage","898":"patronage","899":"patronage","900":"patronage","901":"patronage","902":"crowdfunding","903":"crowdfunding","904":"crowdfunding","905":"crowdfunding","906":"crowdfunding","907":"crowdfunding","908":"crowdfunding","909":"crowdfunding","910":"crowdfunding","911":"get the score","912":"purchase for download","913":"purchase for mail-order","915":"podcast feed","919":"CD Baby","920":"VIAF","921":"work list entry","934":"geonames","938":"setlistfm","939":"license","940":"soundcloud","957":"get the music","958":"download for free","959":"purchase for download","960":"purchase for mail-order","971":"discogs","976":"secondhandsongs","977":"secondhandsongs","978":"streaming","979":"streaming","980":"streaming","981":"CPDL","982":"lyrics","984":"history site","992":"vgmdb","997":"free streaming","1001":"VIAF","1005":"streaming","1013":"vgmdb","1080":"youtube music","1083":"schedule","1086":"other databases","1087":"wikidata","1089":"discogs","1091":"get the music","1092":"bandcamp","1093":"allmusic","1130":"apple music","1131":"apple music","1167":"BookBrainz","1169":"discography entry","1188":"fanpage","1189":"fanpage","1190":"fanpage","1191":"fanpage","1192":"art gallery","1193":"ticketing","1194":"ticketing","1195":"ticketing","1196":"ticketing","1197":"ticketing","1254":"purchase artwork","1275":"discogs","1288":"logo","1289":"IMDb","1301":"fanpage","1302":"schedule"}; const linkTypeName = id => URL_LINK_TYPES[id] || `type ${id}`; // #663: the url link types each entity can take (MusicBrainz's, current ones only), for the link editor's type picker const URL_TYPES_BY_ENTITY = {"release":"72,73,74,75,76,77,79,82,85,86,288,301,308,729,755,850,906,980","release_group":"88,89,90,93,94,96,97,284,287,353,853,907","artist":"171,172,173,174,175,176,177,178,179,180,182,183,184,185,187,188,189,191,192,193,194,197,199,283,291,303,307,310,352,707,718,754,785,816,840,841,852,862,897,902,919,978,981,1080,1131","label":"210,211,212,213,214,215,216,217,218,219,221,222,224,225,290,304,311,313,354,719,838,851,899,903,957,958,959,960,977,982,997,1005,1130","recording":"254,255,256,257,258,268,285,302,306,905,976,979","work":"271,273,274,279,280,286,289,312,351,843,854,908,911,912,913,921,939,992","area":"355,358,713,730","place":"363,396,429,462,495,528,561,594,595,627,660,705,706,787,817,861,900,909,920,934,940,984,1013","instrument":"732,733,734,753","series":"744,745,746,747,749,784,792,805,870,901,910,915,938,1001,1083","event":"782,783,786,788,789,790,791,803,804,808,811,839,842,860,898,904","genre":"1086,1087,1089,1091,1092,1093"}; const urlTypesFor = entityType => (URL_TYPES_BY_ENTITY[entityType] || '').split(',').filter(Boolean) .sort((a, b) => linkTypeName(a).localeCompare(linkTypeName(b))); // "open.spotify.com" -> "Spotify": the registrable name, for the service tag function linkService(url) { try { const parts = new URL(url).hostname.replace(/^www\./, '').split('.'); const n = parts.length > 2 && /^(co|com|org|net)$/.test(parts[parts.length - 2]) ? parts[parts.length - 3] : parts[parts.length - 2] || parts[0]; return n.charAt(0).toUpperCase() + n.slice(1); } catch (e) { return ''; } } const RENAMEABLE = new Set(['artist', 'label', 'recording', 'release_group', 'release']); const NAME_SEEDS = new Set(['artist', 'label', 'recording', 'release_group']); // …and `release` is the odd one out. Its editor is a Knockout APP: the server // only hands seeded data to it on /release/add, so on /release/<mbid>/edit // both `edit-release.comment=` and `comment=` are ignored outright (verified // on the sandbox — the box stays empty and no edit is staged). Its state is // reachable though, so the field gets typed into instead; see // setReleaseComment. Note this is specific to the KO-owned fields: the // external-links section on the same page is a separate component that DOES // read `edit-release.url.N.text` from the query string, which is why seeded // release urls have always worked. // accepts: "<mbid>,<url>" · "<mbid> <url>" · "<entityType>:<mbid>,<url>" · // or a full MB entity URL in place of the bare mbid. function parseLine(line) { const s = line.trim(); if (!s || s.startsWith('#')) return null; let entityType = 'artist'; let rest = s; const etm = rest.match(/^(artist|label|recording|release|release[_-]group)\s*:\s*(.+)$/i); if (etm) { entityType = normalizeEntityType(etm[1]); rest = etm[2]; } const parts = rest.split(/[,\s]+/).filter(Boolean); if (parts.length < 2) return null; let [entityPart, ...urlParts] = parts; const url = urlParts.join(' '); const um = entityPart.match(/musicbrainz\.org\/(artist|label|recording|release|release-group)\/([0-9a-f-]{36})/i); let mbid = entityPart; if (um) { entityType = normalizeEntityType(um[1]); mbid = um[2]; } if (!MBID_RE.test(mbid) || !/^https?:\/\//i.test(url)) return null; return { entityType, mbid: mbid.toLowerCase(), url, linkTypeId: null }; } function parsePaste(text) { return String(text || '').split('\n').map(parseLine).filter(Boolean); } // Shared MB API throttle for name lookups (majkinetor, #467: "fetch them in // paralel with rate limit protection as usual (retry after etc, see how CH // does it") — mirrors Credit Hoarder's api-mb.js pattern: up to MAX_CONCURRENT // requests in flight with NO artificial per-request gap (a strict serial // 1.1s-apart queue was tried first and was simply too slow for a big batch), // and on an actual 429/503 every in-flight request cooperatively backs off // until a shared `pauseUntil` timestamp elapses (from the Retry-After header, // or exponential backoff if MB didn't send one). // #633: the pacing and the hold are now the shared request gate's (the ST-NET block), // one budget for every script and tab on the origin. This keeps its queue, so a run // can still drop the pending name lookups, and a priority entry still goes first. const gateLog = (lv, m) => log(lv === 'warn' ? 'warn' : 'info', m); const mbThrottle = (() => { const MAX_CONCURRENT = 4; let running = 0; const queue = []; function drain() { while (running < MAX_CONCURRENT && queue.length) { running++; const item = queue.shift(); run(item).finally(() => { running--; drain(); }); } } async function run(item) { for (let attempt = 0; attempt <= item.retries; attempt++) { await mbnSlot({ background: !item.priority, label: item.url.split('/ws/2/').pop(), log: gateLog }); try { const res = await fetch(item.url, { headers: { Accept: 'application/json' } }); // #546: a backoff must never be silent, or a run patiently waiting out MusicBrainz // looks like one that hung; the gate logs every hold it starts. if ((await mbnAnswer(res.status, n => res.headers.get(n), { log: gateLog })).throttled) { log('warn', `MusicBrainz returned ${res.status} — retrying after the hold (attempt ${attempt + 1} of ${item.retries + 1})`); continue; } if (!res.ok) { item.resolve(null); return; } item.resolve(await res.json()); return; } catch (e) { if (attempt === item.retries) { item.resolve(null); return; } await wait(500); } } item.resolve(null); } return { // #494 follow-up (majkinetor, live: a duplicate-cover-art warning took // ~20s to appear on a batch with a full recording list ahead of it — // "It should be prioritized"): `priority` jumps the FRONT of this // FIFO queue rather than the back, for the (rare) callers whose result // is actionable, not cosmetic like a name lookup. fetchJson: (url, retries, priority) => new Promise(resolve => { const entry = { url, retries: retries == null ? 3 : retries, resolve, priority: !!priority }; priority ? queue.unshift(entry) : queue.push(entry); drain(); }), // Drop everything not yet started, resolving each caller with null. Used // when a run begins: a backlog of cosmetic name lookups must not spend the // rate-limit budget the workers need. In-flight requests are left to // finish — there are at most MAX_CONCURRENT of them and aborting mid-flight // buys nothing. cancelPending() { const n = queue.length; queue.splice(0).forEach(item => item.resolve(null)); return n; }, pendingCount: () => queue.length, }; })(); // resolves an entity's real name/title for display, instead of a truncated mbid — // same-origin fetch to MB's own public API (no GM_xmlhttpRequest needed; Falcon's // panel only ever renders on musicbrainz.org itself), through the throttle above. // Cached per entity since the same mbid can appear across several queue items. const _nameCache = new Map(); // ⚠ Name lookups YIELD to a run (majkinetor: "any ongoing scanning for names // should probably be stopped on starting the queue so not to slow it down or // induce rate limit that would influence workers"). // // They are cosmetic — a row shows `recording/2aa5e6cc` until its title lands — // but they are not free: a big paste queues one /ws/2 call per entity, and // MusicBrainz rate-limits per IP, so a backlog still in flight when Start is // pressed competes with the workers' own edit-page loads for the same budget. // A 503 there is not cosmetic at all; it fails the item. // // So a run suspends them: new lookups return immediately, and the throttle's // whole pending backlog is dropped rather than deferred (those requests would // otherwise still fire, just later, which is the same problem moved). Rows // keep whatever label they already have. let _namesSuspended = false; function suspendNameLookups() { _namesSuspended = true; const dropped = mbThrottle.cancelPending(); if (dropped) dbg('[names]', `dropped ${dropped} pending name lookup(s) so they don't compete with the workers`); } function resumeNameLookups() { _namesSuspended = false; } // #509 follow-up (majkinetor): "I don't think its working, although not // sure. Lets add debug log for track fetching - list entity mbid and if // name is fetched or passed." — every entity's name resolution logs which // path it took (Harmony-scraped and passed straight through, vs. an MB API // fetch) so it's actually verifiable from the Log tab instead of assumed. async function fetchEntityName(entityType, mbid) { const key = entityType + ':' + mbid; if (_nameCache.has(key)) { dbg('[names]', `${key} — cached: "${_nameCache.get(key)}"`); return _nameCache.get(key); } if (_namesSuspended) { dbg('[names]', `${key} — fetch skipped, lookups suspended (a run is active)`); return null; } dbg('[names]', `${key} — fetching from MB (no name passed from source)`); const j = await mbThrottle.fetchJson(`${MB_ORIGIN}/ws/2/${entityUrlSegment(entityType)}/${mbid}?fmt=json`); const name = j ? (j.title || j.name || null) : null; // recordings/releases/RGs: title; artist/label: name if (name) _nameCache.set(key, name); dbg('[names]', `${key} — fetched: ${name ? `"${name}"` : 'null (MB lookup failed or returned no name)'}`); return name; } function entityLabel(item) { return item.name || `${item.entityType}/${item.mbid.slice(0, 8)}`; } // #509 follow-up (majkinetor, live: several items stayed nameless // permanently — "when name is null, it blocks from fetching it"). Root // cause: suspendNameLookups()'s cancelPending() resolves every not-yet- // STARTED cosmetic name lookup with null so it doesn't compete with the // workers' rate-limit budget — correct while a run is active, but nothing // ever gave a cancelled item a second try afterwards, so a lookup that // lost that race stayed null forever even once the budget was free again. // Sweep every still-nameless item once the suspension actually lifts // (run finishes or is stopped) — same shape as importQueueJson's own // post-import sweep, just re-triggerable instead of one-shot. function resolveMissingNames() { queue.filter(i => !i.name && i.status === 'queued').forEach(i => { fetchEntityName(i.entityType, i.mbid).then(n => { if (n) { i.name = n; scheduleRender('queue'); if (i.entityType === 'release') noteSessionReleaseName(n); } }); }); } // merges each parsed {entityType,mbid,url,linkTypeId,note?} into an existing // STILL-QUEUED item for the same entity (never merges into an active/done/failed // one — that item has already been claimed or finished), else creates a new item. function addToQueue(parsed) { let merged = 0, added = 0; parsed.forEach(p => { // #494/#495: a cover-shaped tuple (coverCandidates, no url — only ever // 'release', from Harmony's cover scraper) merges into/creates a release // item's `cover`, entirely separately from urls[] — a release item can // have BOTH a cover AND urls (added via the generic path just below, // same as any other type), landing in the same item since both paths // dedup on (entityType, mbid) identically. // #537: with "Ignore Harmony cover art" on, a cover payload is dropped // here as well as at send time — an older Harmony tab (or a hand-made // ?falcon= url) can still carry one, and the option should hold. if (p.entityType === 'release' && p.coverCandidates && cfg.skipHarmonyCovers) { log('info', `ignoring cover art for release ${p.mbid} — "Ignore Harmony cover art" is on`); return; // this is a forEach callback, not a loop } if (p.entityType === 'release' && p.coverCandidates) { const existingRel = queue.find(i => i.status === 'queued' && i.entityType === 'release' && i.mbid === p.mbid); if (existingRel) { if (p.source && !existingRel.source) existingRel.source = p.source; if (!existingRel.cover.length) existingRel.cover.push(newCoverEntry()); const entry = existingRel.cover[0]; const before = entry.candidates.length; p.coverCandidates.forEach(c => { if (!entry.candidates.some(x => x.url === c.url || x.original === c.url)) entry.candidates.push(c); }); if (entry.candidates.length > before) existingRel._coverPickPromise = trackSettling(pickBestCover(existingRel)); if (existingRel.coverExistingCount == null) existingRel._coverCheckPromise = checkExistingCoverArt(existingRel); merged++; return; } const relItem = { id: 'f' + (++_idSeq), entityType: 'release', mbid: p.mbid, urls: [], note: p.note || '', source: p.source || '', disambiguation: '', rename: '', isrcs: [], video: false, aliases: [], cover: [newCoverEntry('', p.coverCandidates)], coverExistingCount: null, name: null, urlResults: null, status: 'queued', error: '' }; queue.push(relItem); fetchEntityName('release', p.mbid).then(name => { if (name) { relItem.name = name; renderQueue(); noteSessionReleaseName(name); } }); relItem._coverPickPromise = trackSettling(pickBestCover(relItem)); relItem._coverCheckPromise = checkExistingCoverArt(relItem); added++; return; } // #500 (majkinetor): a release whose recordings have NO external links // at all (only a cover + ISRCs) left scrapeHarmonyActions with zero // recording tuples to zip isrcs onto — dropped silently, since Falcon // had no independent view of the tracklist to place them on otherwise. // This tuple carries no mbid/url of its own yet — resolveIsrcFallback // fetches the release's actual tracklist and re-enters addToQueue with // real per-recording isrc tuples once it has one, same // queue-first-resolve-after shape as #494's cover candidates. if (p.entityType === 'recording' && p.pendingIsrcs) { trackSettling(resolveIsrcFallback(p.pendingIsrcs.mbid, p.pendingIsrcs.isrcs, p.pendingIsrcs.note)); return; } const existing = queue.find(i => i.status === 'queued' && i.entityType === p.entityType && i.mbid === p.mbid); const linkTypeId = p.linkTypeId || null; if (existing) { // #500: a url-less tuple (isrc-only fallback resolution) has nothing // to add to urls[] — only #474's comment/isrc fields. if (p.url && !existing.urls.some(u => u.url === p.url && u.linkTypeId === linkTypeId)) { existing.urls.push({ url: p.url, linkTypeId }); merged++; } if (p.note && !existing.note) existing.note = p.note; // #474: disambiguation + ISRC — recording-only, additive (a later tuple // for the same mbid shouldn't clobber a disambiguation/isrc it already has). if (p.disambiguation && !existing.disambiguation) existing.disambiguation = p.disambiguation; if (p.isrc && !(existing.isrcs || []).includes(p.isrc)) (existing.isrcs = existing.isrcs || []).push(p.isrc); // #509: a name Harmony already resolved beats a later async MB lookup. if (p.name && !existing.name) { existing.name = p.name; if (existing.entityType === 'release') noteSessionReleaseName(p.name); } return; } const item = { id: 'f' + (++_idSeq), entityType: p.entityType, mbid: p.mbid, urls: p.url ? [{ url: p.url, linkTypeId }] : [], note: p.note || '', disambiguation: p.disambiguation || '', rename: p.rename || '', isrcs: p.isrc ? [p.isrc] : [], video: p.video === true, aliases: normalizeAliases(p.aliases), cover: [], coverExistingCount: null, name: p.name || null, urlResults: null, status: 'queued', error: '' }; queue.push(item); // #509 (majkinetor): "Since Harmony already resolves names, we could // just fetch and use them instead of bombing MB." scrapeHarmonyActions // already scraped the name straight off Harmony's own page — only // fall back to Falcon's own MB lookup when a tuple didn't carry one // (paste, `?falcon=` URL, JSON import without a name field). if (p.name) { dbg('[names]', `${p.entityType}:${p.mbid} — passed from source: "${p.name}"`); if (p.entityType === 'release') noteSessionReleaseName(p.name); } else fetchEntityName(p.entityType, p.mbid).then(name => { if (name) { item.name = name; renderQueue(); if (p.entityType === 'release') noteSessionReleaseName(name); } }); added++; }); // #508 follow-up: anything newly queued while a run is already active // (ISRC fallback resolving late, a mid-run import, ...) should get // picked up by a full worker fleet, not whatever's left running. if (added > 0) topUpWorkers(); return { merged, added }; } /* ── #532: add the current release's entities to the queue ──────────────── (majkinetor) "When falcon is started on release page, it is empty. We could have an Add button that can add related entities so we could edit supported fields. I am interested in recordings disambiguation now. It could also serve as a way to produce JSON that person can fill up later." So this is deliberately NOT an import of things to submit — it seeds the queue with the release's own entities as EMPTY, editable rows. Fill in a disambiguation (or ISRC, or url) on the ones you care about and press Start; rows still carrying nothing are skipped rather than failed (see the no-work guard in workerLoop), which is what makes "add now, fill in later" and "export as JSON, fill it in, re-import" both work. */ const RELEASE_PATH_RE = /\/release\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i; const RG_PATH_RE = /\/release-group\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i; const SERIES_PATH_RE = /\/series\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i; // What can this page offer? release-group pages have no tracklist of their own. function pageEntityContext() { if (ON_HARMONY) return null; const rg = location.pathname.match(RG_PATH_RE); if (rg) return { kind: 'release-group', mbid: rg[1] }; const rel = location.pathname.match(RELEASE_PATH_RE); if (rel) return { kind: 'release', mbid: rel[1] }; // #572 (majkinetor): "Falcon should be able to seed from RG and release // series." A series page is the natural place to start a bulk rename — the // whole point of the issue is conforming the names of everything in one. const ser = location.pathname.match(SERIES_PATH_RE); if (ser) return { kind: 'series', mbid: ser[1] }; return null; } // #572: a series' contents are RELATIONSHIPS, not a browsable collection — // there is no /ws/2/release-group?series=… endpoint — so one lookup with the // relationship includes returns the whole membership in a single request. // `ordering-key` is the series' own ordering, which is the order a human sees // on the page, so rows arrive in that order rather than MB's relation order. async function fetchSeriesMembers(mbid) { const url = `${MB_ORIGIN}/ws/2/series/${mbid}?inc=release-group-rels+release-rels&fmt=json`; const j = await mbThrottle.fetchJson(url, undefined, true); const members = []; for (const rel of (j && j.relations) || []) { const tt = rel['target-type']; if (tt !== 'release_group' && tt !== 'release') continue; const tgt = rel[tt]; if (!tgt || !tgt.id) continue; members.push({ entityType: tt, mbid: tgt.id, name: tgt.title || tgt.name || null, orderingKey: Number(rel['ordering-key']) || 0, }); } members.sort((a, b) => a.orderingKey - b.orderingKey); return { name: (j && j.name) || '', type: (j && j.type) || '', members }; } // The release groups behind a list of releases. There is no browse endpoint // for this either, so it is one lookup per release — deliberately opt-in in // the menu, and noisy in the log, because 40 releases is 40 round trips. async function fetchReleaseGroupsOf(releases) { const out = []; const seen = new Set(); for (let i = 0; i < releases.length; i++) { const j = await mbThrottle.fetchJson(`${MB_ORIGIN}/ws/2/release/${releases[i].mbid}?inc=release-groups&fmt=json`, undefined, true); const rg = j && j['release-group']; if (rg && rg.id && !seen.has(rg.id)) { seen.add(rg.id); out.push({ entityType: 'release_group', mbid: rg.id, name: rg.title || null, note: '' }); } if ((i + 1) % 10 === 0) log('info', `…read the release group of ${i + 1}/${releases.length} release(s)`); } return out; } // One request covers every type offered below. async function fetchReleaseGraph(mbid) { const url = `${MB_ORIGIN}/ws/2/release/${mbid}?inc=recordings+artist-credits+labels+release-groups+media&fmt=json`; return await mbThrottle.fetchJson(url, undefined, true); } // #533 follow-up (majkinetor): "Add from group should have releases" — on a // release-group page the menu only ever offered the group itself. // ⚠ BROWSE, not search. `/ws/2/release?release-group=<mbid>` pages // deterministically; the indexed-search equivalent (`query=rgid:`) drops and // repeats rows across pages — measured elsewhere in this repo at 310 hits for // 224 distinct releases, with 86 never returned at all. A missing release // here would look like the group simply not having it. async function fetchGroupReleases(rgMbid) { const out = []; const seen = new Set(); for (let offset = 0; offset < 5000; offset += 100) { const url = `${MB_ORIGIN}/ws/2/release?release-group=${rgMbid}&limit=100&offset=${offset}&fmt=json`; const j = await mbThrottle.fetchJson(url, undefined, true); const batch = (j && j.releases) || []; // #546: a group with several hundred releases is several requests deep // with nothing said in between. if (offset > 0 || batch.length >= 100) log('info', `…read ${out.length + batch.length} release(s) so far from this group`); for (const r of batch) { if (!r.id || seen.has(r.id)) continue; seen.add(r.id); out.push({ entityType: 'release', mbid: r.id, name: r.title || null, note: '' }); } const total = j && j['release-count']; if (!batch.length || (total != null && out.length >= total)) break; } return out; } // Turn that graph into addToQueue tuples for the chosen types. Names come // straight from the response so no row needs a follow-up name lookup (#509). function releaseGraphTuples(j, want, note) { const out = []; const seen = new Set(); const push = (entityType, mbid, name) => { if (!mbid) return; const k = entityType + ':' + mbid; if (seen.has(k)) return; seen.add(k); out.push({ entityType, mbid, name: name || null, note }); }; if (want.recording) for (const m of (j.media || [])) for (const t of (m.tracks || [])) { const r = t.recording; if (r) push('recording', r.id, r.title || t.title); } if (want.release) push('release', j.id, j.title); if (want.release_group && j['release-group']) push('release_group', j['release-group'].id, j['release-group'].title); if (want.artist) { for (const ac of (j['artist-credit'] || [])) if (ac.artist) push('artist', ac.artist.id, ac.artist.name); for (const m of (j.media || [])) for (const t of (m.tracks || [])) { for (const ac of ((t.recording && t.recording['artist-credit']) || t['artist-credit'] || [])) if (ac.artist) push('artist', ac.artist.id, ac.artist.name); } } if (want.label) for (const li of (j['label-info'] || [])) if (li.label) push('label', li.label.id, li.label.name); return out; } // #500: fetches the release's real tracklist (recording mbids in track // order) and places each Nth ISRC onto the Nth recording — the same // positional convention scrapeHarmonyActions already uses when it DOES // have link-tuples to zip onto, just resolved from MB's own API instead of // scraped anchors when there are none. async function resolveIsrcFallback(releaseMbid, isrcs, note) { const j = await mbThrottle.fetchJson(`${MB_ORIGIN}/ws/2/release/${releaseMbid}?inc=recordings&fmt=json`, undefined, true); // ⚠ A failed lookup must not become "no ISRCs" — say so loudly. Silently // dropping them is how a batch looks finished while half its data never // left (#530/#531 are the same bug class). if (!j || !Array.isArray(j.media)) { log('error', `ISRCs: could not read ${releaseMbid}'s tracklist from MusicBrainz — ${isrcs.filter(Boolean).length} ISRC(s) were NOT queued. Reload and send again.`); return; } const recMbids = []; for (const m of j.media) for (const t of (m.tracks || [])) recMbids.push(t.recording && t.recording.id); const tuples = []; const orphans = []; isrcs.forEach((isrc, i) => { if (!isrc) return; if (recMbids[i]) tuples.push({ entityType: 'recording', mbid: recMbids[i], note, isrc }); else orphans.push(`isrc${i + 1}=${isrc}`); }); // #540: MagicISRC numbers isrcN by track position, so a count mismatch means // the tracklist changed since Harmony read it. Placing "as many as fit" // would be exactly the misalignment this whole change exists to prevent. if (orphans.length) { log('warn', `ISRCs: MusicBrainz shows ${recMbids.length} track(s) but Harmony sent ${isrcs.length} ISRC(s) — ${orphans.length} had no track to sit on and were dropped rather than guessed at (${orphans.join(', ')}). Has the tracklist changed?`); } if (!tuples.length) { log('warn', `ISRCs: none of ${releaseMbid}'s ISRCs could be matched to a track position`); return; } const res = addToQueue(tuples); log('info', `ISRCs: placed ${res.added + res.merged} on ${releaseMbid}'s tracklist by track position (isrc1 → track 1, …)`); } // Reads what the Export button writes, and is deliberately forgiving about the // shape: the wrapper object `{items:[...]}`, or a bare array of items, or a // bare array of the flat `{mbid,url,linkTypeId}` rows that `?falcon=` and // Harmony use — someone hand-writing a queue shouldn't have to guess which. // // An imported item keeps its STATUS. Re-importing a finished run therefore // shows what already happened rather than silently re-queueing committed // edits; only rows that are still `queued` will be picked up by a worker. That // makes "export a failed run, fix the bad urls, import, Start" work without // duplicating everything that already succeeded. // opts.merge (#671): an item for an entity already queued adds its links to that item instead // of queueing it twice — a batch handed over again (another script, ?falcon=) would otherwise // run each entity once per send. A file Import restores a saved queue as it was, so it doesn't. function importQueueJson(text, sourceName, opts) { let data; try { data = JSON.parse(text); } catch (e) { log('error', `${sourceName || 'import'}: not valid JSON — ${e.message}`); return { added: 0, merged: 0 }; } const rows = Array.isArray(data) ? data : (data && Array.isArray(data.items) ? data.items : null); // #573: a root-level `note` is the batch note for the whole file. Per-item // `note` is untouched and still means that one edit's own note. if (data && !Array.isArray(data) && typeof data.note === 'string') { setBatchNote(data.note); syncBatchNoteUi(true); if (batchNote()) log('info', `batch edit note set from ${sourceName || 'import'}: ${JSON.stringify(batchNote())}`); } if (data && !Array.isArray(data) && typeof data.name === 'string' && data.name.trim()) { _batchName = data.name.trim(); noteSessionReleaseName(_batchName); log('info', `batch name from ${sourceName || 'import'}: ${JSON.stringify(_batchName)}`); } if (!rows) { log('error', `${sourceName || 'import'}: no items found (expected {"items":[…]} or an array)`); return { added: 0, merged: 0 }; } let added = 0, merged = 0, skipped = 0; const flat = []; rows.forEach(r => { if (!r || typeof r !== 'object' || !MBID_RE.test(String(r.mbid || ''))) { skipped++; return; } const type = normalizeEntityType(r.entityType); // #494: a release row has no urls[] at all — its payload is r.cover. const coverArr = type === 'release' ? normalizeCoverForImport(r) : []; const hasCover = coverArr.some(c => c.url || (c.candidates && c.candidates.length)); // ⚠ Work out what this row actually CARRIES before deciding how to read // it. This gate used to be `Array.isArray(r.urls) || hasCover`, which // silently rejected any hand-written row that simply had no "urls" key — // including the alias example shipped in examples/aliases.json ("added 0 // item(s), skipped 2 unusable row(s)"). urls is optional now; a row is // importable if it carries any payload Falcon knows how to submit. const hasMeta = (DISAMBIGUATABLE.has(type) && !!(r.disambiguation || r.comment)) || (RENAMEABLE.has(type) && !!r.rename) || (type === 'release' && !!barcodeOf(r.barcode)) || (type === 'release' && catnosOf(r.labels).length > 0) || (type === 'recording' && Array.isArray(r.isrcs) && r.isrcs.some(Boolean)) || (type === 'recording' && r.video === true) || normalizeAliases(r.aliases).length > 0; if (Array.isArray(r.urls) || hasCover || hasMeta) { // full item: reinstate it whole, status and all const urls = Array.isArray(r.urls) ? r.urls.filter(u => u && u.url).map(u => ({ url: String(u.url), linkTypeId: u.linkTypeId || null })) : []; // #474/#533: a row carrying only a disambiguation/isrc/video/alias (no // urls at all) is a legitimate item — hasMeta above is what decides // that, per type: disambiguation on any of the five, ISRC and video on // recordings, aliases on everything. (r.comment accepted too — // pre-#496 exports used that field name.) A row with none of it is the // only kind that gets rejected. if (!urls.length && !hasMeta && !hasCover) { skipped++; return; } const reItem = { id: 'f' + (++_idSeq), entityType: type, mbid: r.mbid, urls, note: r.note || '', disambiguation: r.disambiguation || r.comment || '', rename: RENAMEABLE.has(type) ? String(r.rename || '') : '', barcode: type === 'release' ? barcodeOf(r.barcode) : '', labels: type === 'release' ? catnosOf(r.labels) : [], isrcs: Array.isArray(r.isrcs) ? r.isrcs.filter(Boolean).map(String) : [], video: type === 'recording' && r.video === true, aliases: normalizeAliases(r.aliases), source: typeof r.source === 'string' ? r.source : '', cover: coverArr, coverExistingCount: null, name: r.name || null, urlResults: r.urlResults || null, status: ['done', 'failed', 'partial', 'skipped', 'manual'].includes(r.status) ? r.status : 'queued', error: r.error || '', }; const same = opts && opts.merge && reItem.status === 'queued' && queue.find(i => i.status === 'queued' && i.entityType === type && i.mbid === r.mbid); if (same) { const before = same.urls.length; urls.forEach(u => { if (!same.urls.some(x => x.url === u.url && x.linkTypeId === u.linkTypeId)) same.urls.push(u); }); if (reItem.name && !same.name) same.name = reItem.name; if (reItem.barcode && !same.barcode) same.barcode = reItem.barcode; reItem.labels.forEach(l => { same.labels = same.labels || []; if (!same.labels.some(x => x.mbid === l.mbid)) same.labels.push(l); }); merged += same.urls.length - before; dbg('[import]', `${type}:${r.mbid} is queued already — ${same.urls.length - before} new url(s) merged into it`); return; } queue.push(reItem); if (hasCover && reItem.status === 'queued') reItem._coverCheckPromise = checkExistingCoverArt(reItem); added++; } else if (r.url) { flat.push({ entityType: type, mbid: r.mbid, url: String(r.url), linkTypeId: r.linkTypeId || null, note: r.note || '', disambiguation: r.disambiguation || r.comment || '', rename: r.rename || '', isrc: r.isrc || (Array.isArray(r.isrcs) ? r.isrcs[0] : null) || null, name: r.name || null }); } else skipped++; }); if (flat.length) { const res = addToQueue(flat); added += res.added; merged += res.merged; } renderQueue(); // names for anything that arrived without one — unless a run is under way, // in which case these would compete with the workers for MB's rate limit // (fetchEntityName's own _namesSuspended check still applies here). resolveMissingNames(); log('info', `${sourceName || 'import'}: added ${added} item(s)` + (merged ? `, merged ${merged} url(s)` : '') + (skipped ? `, skipped ${skipped} unusable row(s)` : '')); return { added, merged, skipped }; } // `?falcon=` accepts TWO schemes: // 1. base64(JSON) directly in the URL — the documented contract for any external // script: the JSON model, as Import reads it (#671). // 2. a short random TOKEN keyed into GM storage (`falcon:pending:<token>`) — used // by the Harmony bridge itself (see ensureHarmonyButton below), since GM // storage is shared across every tab this SAME script runs in regardless of // domain. This is what lets a batch include recordings again: no JSON ever // has to fit in a URL, so there's no length ceiling to hit (#467's // PR_END_OF_FILE_ERROR was hitting exactly that ceiling on the base64 scheme). // A different script can't use this scheme — GM storage isn't shared BETWEEN // different userscripts, only within one script's own tabs — hence keeping // scheme 1 as the general contract. function tryDecodeBase64Json(raw) { // fatal: bytes that are not UTF-8 throw, as decodeURIComponent(escape(…)) did let text; try { text = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(atob(raw), c => c.charCodeAt(0))); } catch (e) { return null; } try { JSON.parse(text); return text; } catch (e) { return null; } } function parseUrlParam() { const raw = new URLSearchParams(location.search).get('falcon'); if (!raw) return null; let json = tryDecodeBase64Json(raw); // #508 (majkinetor): "Auto start Harmony import" needs to tell a Harmony- // sourced seed (the GM-storage token scheme — see the comment above) apart // from the general `?falcon=` contract any other script/user can construct // (base64 JSON directly in the URL) — auto-starting on an arbitrary // external payload isn't what was asked for. let fromHarmony = false, note = null; if (json == null) { const stored = GM_getValue('falcon:pending:' + raw, null); if (stored != null) { json = stored; fromHarmony = true; // #590: the "this batch may be short, and why" note the Harmony side // left with the payload, applied to every edit of the run (#573). note = GM_getValue('falcon:pendingNote:' + raw, null); try { GM_deleteValue('falcon:pending:' + raw); GM_deleteValue('falcon:pendingNote:' + raw); } catch (e) {} } } if (json == null) { const h = handedOver(raw); if (h) { log('info', 'the batch was written by another installed copy of Falcon, which handed it over'); return decodeSeed(h.json, true, h.note); } // A token another copy may still hand over (it starts after this one): boot waits for it. if (PENDING_TOKEN_RE.test(raw)) return { awaitToken: raw }; log('warn', 'falcon= param present but neither valid base64 JSON nor a known pending token'); return null; } return decodeSeed(json, fromHarmony, note); } const PENDING_TOKEN_RE = /^h[0-9a-z]{6,}$/; // A batch a copy that stood down handed over (falconHandOver), for this token only. function handedOver(token, detail) { try { const o = JSON.parse(detail || document.documentElement.getAttribute('data-falcon-handover') || 'null'); if (!o || o.token !== token || typeof o.json !== 'string') return null; document.documentElement.removeAttribute('data-falcon-handover'); return o; } catch (e) { return null; } } // The warning when no copy had the batch, naming the likely reason when there is a second copy. function tokenLost() { const other = document.documentElement.getAttribute('data-mbu-run-falcon-off'); log('warn', 'falcon= param present but neither valid base64 JSON nor a known pending token' + (other ? ' — Falcon is installed twice, and the batch was most likely written by the other copy, which is too old to hand it over. Remove the older copy in your userscript manager (it may still be called "Falcon — bulk MusicBrainz link editor") and send from Harmony again.' : ' — the batch was already taken (this tab was reloaded or reopened), or it was written by another installed copy of Falcon.')); } function decodeSeed(json, fromHarmony, note) { if (note) { setBatchNote(note); log('warn', `Harmony note carried over to this batch's edit note: ${note}`); } try { const arr = JSON.parse(json); // #671: a base64 payload is the JSON model, read as Import reads a file: everything a // row can carry, the note too. The flat one-link rows below are Harmony's token only. if (!fromHarmony) return { importText: json }; if (!Array.isArray(arr)) return null; const out = arr.map(it => { // #494/#495: a cover-shaped tuple (coverCandidates, no url — only // ever 'release') is kept separate from a normal url tuple, which // works the same for 'release'/'release_group' as every other type. if (normalizeEntityType(it.entityType) === 'release' && Array.isArray(it.coverCandidates)) { return { entityType: 'release', mbid: String(it.mbid || '').toLowerCase(), coverCandidates: it.coverCandidates.filter(c => c && c.url).map(c => ({ provider: String(c.provider || ''), url: String(c.url), width: Number(c.width) || undefined, height: Number(c.height) || undefined, size: Number(c.size) || undefined })), }; } // #500: an isrc-only fallback tuple carries no url/mbid of its own — // just the release it needs to resolve a tracklist from. if (it.pendingIsrcs && it.pendingIsrcs.mbid) { return { entityType: 'recording', pendingIsrcs: { mbid: String(it.pendingIsrcs.mbid).toLowerCase(), isrcs: Array.isArray(it.pendingIsrcs.isrcs) ? it.pendingIsrcs.isrcs.map(x => String(x || '')) : [], note: it.pendingIsrcs.note ? String(it.pendingIsrcs.note) : '', }, }; } // #509 (majkinetor, live): the token round-trip (harmonyBtn.onclick's // GM_setValue -> this parse, on the NEW tab) was dropping `name` // entirely — scrapeHarmonyActions() scraped it correctly, but it // never survived to reach addToQueue(), so EVERY Harmony import fell // back to an MB lookup regardless of whether the scrape succeeded. return { entityType: normalizeEntityType(it.entityType), mbid: String(it.mbid || '').toLowerCase(), url: String(it.url || ''), linkTypeId: it.linkTypeId ? String(it.linkTypeId) : null, note: it.note ? String(it.note) : '', isrc: it.isrc ? String(it.isrc) : null, name: it.name ? String(it.name) : null }; }).filter(it => it.coverCandidates ? (MBID_RE.test(it.mbid) && it.coverCandidates.length) : it.pendingIsrcs ? (MBID_RE.test(it.pendingIsrcs.mbid) && it.pendingIsrcs.isrcs.some(Boolean)) : (MBID_RE.test(it.mbid) && /^https?:\/\//i.test(it.url))); out.fromHarmony = fromHarmony; return out; } catch (e) { log('warn', 'falcon= payload not valid JSON: ' + e.message); return null; } } // Parses one Harmony "Link external IDs" href — a standard MB seed URL: // https://musicbrainz.org/<artist|label|recording|release|release-group>/<mbid>/edit // ?edit-<type>.url.0.text=<url>&edit-<type>.url.0.link_type_id=<id> // &edit-<type>.url.1.text=...&edit-<type>.url.1.link_type_id=... // &edit-<type>.edit_note=<text> // Returns a FLAT array of {entityType,mbid,url,linkTypeId,note} tuples (one per // url.N) ready for addToQueue, or [] if href isn't a recognized seed URL. // Harmony itself doesn't offer release/release-group "Link external IDs" // actions yet (#495) — this just keeps the parser consistent/ready for when // it does; release/RG links are queued via paste or `?falcon=` JSON for now. function parseHarmonySeedUrl(href) { let u; try { u = new URL(href, MB_ORIGIN); } catch (e) { return []; } const m = u.pathname.match(/^\/(artist|label|recording|release|release-group)\/([0-9a-f-]{36})\/edit\/?$/i); if (!m) return []; const entityType = normalizeEntityType(m[1]); // 'release-group' -> 'release_group' const mbid = m[2].toLowerCase(); const prefix = `edit-${entityType}.`; const note = u.searchParams.get(prefix + 'edit_note') || ''; const byIndex = {}; for (const [key, val] of u.searchParams) { if (!key.startsWith(prefix + 'url.')) continue; const km = key.slice((prefix + 'url.').length).match(/^(\d+)\.(text|link_type_id)$/); if (!km) continue; (byIndex[km[1]] || (byIndex[km[1]] = {}))[km[2]] = val; } return Object.values(byIndex).filter(e => e.text).map(e => ({ entityType, mbid, url: e.text, linkTypeId: e.link_type_id || null, note })); } function encodeFalconPayload(tuples) { const json = JSON.stringify(tuples.map(t => ({ entityType: t.entityType, mbid: t.mbid, url: t.url, linkTypeId: t.linkTypeId || undefined, note: t.note || undefined, isrc: t.isrc || undefined, name: t.name || undefined }))); let bin = ''; for (const b of new TextEncoder().encode(json)) bin += String.fromCharCode(b); // UTF-8 bytes, one char each, for btoa return btoa(bin); } /* ── Harmony bridge (#467, #459) ───────────────────────────────────────── Running ON a Harmony actions page: every "Link external IDs" action IS already a standard MB seed url (parseHarmonySeedUrl handles it) — scrape them all, combine into one queue, and open MB with it in a new tab instead of Harmony's own tab-per-entity popups. Harmony's actions render asynchronously (client-rendered), so the button's count is kept live by a short polling loop that settles once the count stops changing. Recordings ARE included again (majkinetor, #467) — a real release's recording actions vastly outnumber its artist/label ones (86 total, 80 recordings, measured live), and packing all of them into a base64 query-string payload blew past ~32,000 characters, past what MB's front-end accepts (Firefox surfaced that as a bare PR_END_OF_FILE_ERROR instead of a clean "414 URI Too Long"). The GM-storage-token scheme (see parseUrlParam) sidesteps that entirely — nothing goes in the URL but a short random token. */ // #474 (majkinetor): "ISRC should be fetched on Harmony side too when // present." Checked a real Harmony actions page live — ISRCs are NOT in the // recording "Link external IDs" hrefs at all; they live in one separate // "Open with MagicISRC" action per release, handing off to a third-party // tool: `?isrc1=...&isrc2=...&musicbrainzid=<release>`, positional to track // order. Falcon has no independent view of the release's tracklist (it only // knows what scrapeHarmonyActions finds), so the match is positional: the // Nth distinct recording mbid to appear among the "Link external IDs" // anchors gets isrcN. This assumes every track has at least one link action // — true of every real Harmony page seen so far, but a track with zero // links would shift every isrc after it. majkinetor is testing this by hand. function harmonyMagicIsrcHref() { const a = [...document.querySelectorAll('a')].find(x => /open with magicisrc/i.test(x.textContent || '')); return a && a.getAttribute('href'); } function scrapeHarmonyIsrcs() { const href = harmonyMagicIsrcHref(); if (!href) return []; let u; try { u = new URL(href, location.href); } catch (e) { return []; } const out = []; for (const [k, v] of u.searchParams) { const m = k.match(/^isrc(\d+)$/i); if (m && v) out[+m[1] - 1] = v; } return out; } // #500 (majkinetor): "Harmony ISRC sending does not work when there are no // links" — a release whose recordings have zero "Link external IDs" // actions (only a cover + ISRCs) leaves scrapeHarmonyActions with nothing // to zip isrcs onto at all, so they were dropped entirely rather than // shifted. The SAME MagicISRC href already carries the release's own mbid // (`musicbrainzid`) and a ready-made edit note (`edit-note`) — enough to // resolve the real tracklist later (see resolveIsrcFallback) instead of // relying on scraped anchors that don't exist here. function harmonyIsrcFallback() { const href = harmonyMagicIsrcHref(); if (!href) return null; let u; try { u = new URL(href, location.href); } catch (e) { return null; } const mbid = u.searchParams.get('musicbrainzid'); if (!mbid || !MBID_RE.test(mbid)) return null; const isrcs = scrapeHarmonyIsrcs(); if (!isrcs.some(Boolean)) return null; return { mbid: mbid.toLowerCase(), isrcs, note: u.searchParams.get('edit-note') || '' }; } // #509 (majkinetor): "Since Harmony already resolves names, we could just // fetch and use them instead of bombing MB." Each action row already shows // the resolved entity as one more icon+name pill alongside the provider // icons — <a href="https://musicbrainz.org/<type>/<mbid>"><span // class="musicbrainz">…</span>Dusk</a>, the plain entity page (no query // string), distinct from the "Link external IDs" anchor's own seed-edit // href. Scraping that name here means addToQueue can skip its own // fetchEntityName() MB round-trip entirely for Harmony-sourced items. function harmonyRowName(actionAnchor) { const row = actionAnchor.closest('.action') || actionAnchor.parentElement; if (!row) return null; const mbLink = [...row.querySelectorAll('a[href]')].find(a => new RegExp(`^${MB_TARGET.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/(artist|label|recording|release|release-group)/[0-9a-f-]{36}$`, 'i').test(a.getAttribute('href') || '')); const name = mbLink && (mbLink.textContent || '').trim(); return name || null; } function scrapeHarmonyActions() { const anchors = [...document.querySelectorAll('a')].filter(a => /link external ids/i.test(a.textContent || '')); const tuples = []; anchors.forEach(a => { const href = a.getAttribute('href'); if (!href) return; const name = harmonyRowName(a); parseHarmonySeedUrl(href).forEach(t => { if (name) t.name = name; tuples.push(t); }); }); // ⚠ #540: ISRCs are deliberately NOT attached here any more. They used to be // zipped positionally onto the recordings found among these anchors — the // Nth distinct recording got isrcN — and the comment above harmonyIsrcHref // called out the assumption that every track has at least one link action. // That assumption is false in the single most common re-run case: Harmony // stops offering a "Link external IDs" action once the link exists, so on a // second pass over a partly-finished release the earlier tracks drop out of // this list and EVERY later ISRC shifts up by however many are missing. // // Reported on the MetaBrainz forum ("if it's a big album and some of the // submissions failed halfway through and you restart it, some links and // ISRCs will be attached to the wrong recordings") and confirmed on // majkinetor's release 216a51c7: GBNRN1543507 is isrc7 and landed on track // 10's recording — a 3-position shift, i.e. three earlier tracks no longer // had an action. MagicISRC numbers isrcN by TRACK position, so the only // sound mapping is against the real tracklist: see resolveIsrcFallback, // which is now the single path for ISRCs rather than a fallback. return tuples; } // #494: Harmony sometimes annotates a cover figure's caption with a plain // "WxH, SIZE unit, FORMAT" label (e.g. "1200×1200, 703.99 kB, JPEG" — seen // verbatim in the issue's own example HTML) alongside "Type: front Source: // X". Parsing it lets pickBestCover skip a network fetch entirely for that // candidate. It is NOT reliably present (live testing this session — both a // clean fetch and majkinetor's own logged-out browser — only ever showed // "Type: front Source: X", no size caption), so this is an optimization, // never a requirement: candidates missing it fall back to fetch+measure. function parseCoverCaptionMeta(figure) { const labels = [...figure.querySelectorAll('figcaption .label')].map(el => (el.textContent || '').trim()); for (const t of labels) { const m = t.match(/^(\d+)\s*[×x]\s*(\d+)\s*,\s*([\d.]+)\s*(B|kB|MB)\b/i); if (!m) continue; const unit = m[4].toLowerCase(); const mult = unit === 'mb' ? 1000 * 1000 : unit === 'kb' ? 1000 : 1; return { width: +m[1], height: +m[2], size: Math.round(parseFloat(m[3]) * mult) }; } return null; } // Collects one candidate per non-Discogs cover figure — the direct <a href> // (Harmony's own best copy per provider), not the smaller thumbnail <img // src>. Best-across-providers is picked later by pickBestCover. function scrapeHarmonyCover() { const mbLink = [...document.querySelectorAll('a')].find(a => /^open in musicbrainz$/i.test((a.textContent || '').trim())); const href = mbLink && mbLink.getAttribute('href'); const m = href && href.match(/musicbrainz\.org\/release\/([0-9a-f-]{36})/i); if (!m) return null; const mbid = m[1].toLowerCase(); const candidates = [...document.querySelectorAll('figure.cover-image[data-provider]')] .filter(f => (f.getAttribute('data-provider') || '').toLowerCase() !== 'discogs') .map(f => { const a = f.querySelector('a[href]'); if (!a) return null; return { provider: f.getAttribute('data-provider') || '', url: a.getAttribute('href'), ...(parseCoverCaptionMeta(f) || {}) }; }) .filter(Boolean); return candidates.length ? { mbid, coverCandidates: candidates } : null; } // #719: the shared maximizer (Art Station's, #717) turns each suggested cover into the // store's largest version — a Deezer 1000x1000 into its 4000x0, a Bandcamp _10 into _0. // <ST-IMGMAX> — generated by dev/imgmax/sync-imgmax.mjs from dev/imgmax/image-maximizer.js — DO NOT EDIT class ImageMaximizer { // ── URL rules ──────────────────────────────────────────────────────────────── // host → (URL) → bigger URLs, largest first. Measured on the live CDNs (2026-10-11). static RULES = [ // Apple: the uploaded original (a1.mzstatic.com/us/r1000/0/<path>), then the largest // rendition the resizer gives. 100000x100000-999 answers 400 now. { host: /(^|\.)mzstatic\.com$/, max: u => { const m = u.pathname.match(/^\/image\/thumb\/(.+)\/[^/]+$/); return m ? [`https://a1.mzstatic.com/us/r1000/0/${m[1]}`, `https://${u.host}/image/thumb/${m[1]}/9999x9999bb.jpg`] : []; } }, // Deezer: 4000x0 at quality 100 gives the stored size (1900 too; 0x0 and 3000x3000 are 403) { host: /(^|\.)dzcdn\.net$/, max: u => { const m = u.pathname.match(/^(\/images\/\w+\/[0-9a-f]{32})\/\d+x\d+-[^/]+$/); return m ? [`https://${u.host}${m[1]}/4000x0-000000-100-0-0.jpg`, `https://${u.host}${m[1]}/1200x1200-000000-80-0-0.jpg`] : []; } }, // Spotify: the size is a code inside the id; 82c1 is the upload (1500 px and more), b273 640 { host: /(^|\.)(scdn\.co|spotifycdn\.com)$/, max: u => { const m = u.pathname.match(/\/image\/ab67616d[0-9a-f]{8}([0-9a-f]+)$/); return m ? ['000082c1', '0000b273'].map(c => `https://i.scdn.co/image/ab67616d${c}${m[1]}`) : []; } }, // Bandcamp: _0 is the original upload, _10 the largest rendition { host: /(^|\.)bcbits\.com$/, max: u => { const m = u.pathname.match(/^\/img\/(a?\d+)_\d+\.(jpg|png|gif)$/); return m ? [`https://f4.bcbits.com/img/${m[1]}_0.${m[2]}`, `https://f4.bcbits.com/img/${m[1]}_10.jpg`] : []; } }, // Tidal: origin.jpg is the upload; 1280 the largest rendition (3000 is 403) { host: /^resources\.tidal\.com$/, max: u => { const m = u.pathname.match(/^(\/images\/(?:[0-9a-f]+\/){4}[0-9a-f]+)\/[^/]+$/); return m ? [`https://resources.tidal.com${m[1]}/origin.jpg`, `https://resources.tidal.com${m[1]}/1280x1280.jpg`] : []; } }, // Qobuz: _org is the upload, _max the largest rendition { host: /^static\.qobuz\.com$/, max: u => { const m = u.pathname.match(/^(\/images\/covers\/.+?)_(?:\d+|max|org)\.jpg$/); return m ? [`https://static.qobuz.com${m[1]}_org.jpg`, `https://static.qobuz.com${m[1]}_max.jpg`] : []; } }, // Beatport: any size is rendered on demand (3000 is an upscale); 0x0 is the stored size { host: /(^|\.)beatport\.com$/, max: u => { const m = u.pathname.match(/^\/image_size\/\d+x\d+\/(.+)$/); return m ? [`https://${u.host}/image_size/0x0/${m[1]}`] : []; } }, // Amazon: drop the ._SL500_-style modifiers for the stored image { host: /(^|\.)(media-amazon\.com|ssl-images-amazon\.com|images-amazon\.com)$/, max: u => { const m = u.pathname.match(/^\/images\/I\/([^./]+)(?:\.[^/]*)?\.(jpg|png|gif)$/); return m ? [`https://m.media-amazon.com/images/I/${m[1]}.${m[2]}`] : []; } }, // 7digital: 800 is the largest rendition { host: /(^|\.)7static\.com$/, max: u => { const m = u.pathname.match(/^(.+)_\d+\.jpg$/); return m ? [`https://${u.host}${m[1]}_800.jpg`] : []; } }, // Juno: /full/…-BIG { host: /(^|\.)(juno\.co\.uk|junostatic\.com)$/, max: u => { const m = u.pathname.match(/^\/(?:\d+|full)\/(.+?)(?:-(?:TN|MED|BIG))?\.(jpg|png)$/i); return m ? [`https://${u.host}/full/${m[1]}-BIG.${m[2]}`] : []; } }, // VGMdb: thumbnails and medium copies live on their own hosts; the full image on media.vgm.io { host: /(^|\.)vgm\.io$/, max: u => /^(thumb|medium)-media\./.test(u.host) ? [`https://media.vgm.io${u.pathname}`] : [] }, // Google (YouTube Music, Play): =s0 is the original { host: /^(lh\d|yt\d)\.(googleusercontent|ggpht)\.com$/, max: u => { const m = u.href.match(/^([^=]+)=[^/]*$/); return m ? [`${m[1]}=s0`] : []; } }, // YouTube thumbnails { host: /^i\d?\.ytimg\.com$/, max: u => { const m = u.pathname.match(/^\/vi(?:_webp)?\/([^/]+)\/[a-z]*default\.(?:jpg|webp)$/); return m ? [`https://i.ytimg.com/vi/${m[1]}/maxresdefault.jpg`, `https://i.ytimg.com/vi/${m[1]}/hqdefault.jpg`] : []; } }, // SoundCloud: -original, else the 500 px rendition { host: /(^|\.)sndcdn\.com$/, max: u => { const m = u.pathname.match(/^(.+)-(?:t\d+x\d+|large|crop|small|badge|tiny|mini|original)\.(jpg|png)$/); return m ? [`https://${u.host}${m[1]}-original.${m[2]}`, `https://${u.host}${m[1]}-t500x500.${m[2]}`] : []; } }, // Cover Art Archive thumbnails → the original (its extension is unknown, so both) { host: /^(coverartarchive\.org|archive\.org|ia\d+\.us\.archive\.org)$/, max: u => { const m = u.href.match(/^(.+\/\d+)-(?:250|500|1200)\.jpg$/); return m ? [`${m[1]}.jpg`, `${m[1]}.png`] : []; } }, ]; // Images a store shows when it has none. static PLACEHOLDERS = [ /dzcdn\.net\/images\/cover\/d41d8cd98f00b204e9800998ecf8427e\//, // Deezer's empty md5 /7static\.com\/.*\/0{8}16_\d+\.jpg$/, // 7digital /\/images\/I\/(01RmK\+J4pJL|01QFb8SNuTL|01PkLIhTX3L|01MKUOLsA5L|31CTP6oiIBL)\./, // Amazon /vgmdb\.net\/db\/img\/album-(nocover|nsfw)-medium\.gif$/, ]; // ── platform resolvers ──────────────────────────────────────────────────────── // A resolver turns a store page into its cover image(s): [{ url, types?, comment? }]. // `this` is the ImageMaximizer, so a resolver uses this._text / this._json / this._log. static PLATFORMS = [ { id: 'apple', name: 'Apple Music', host: /^(music|itunes)\.apple\.com$/, async find(u) { const id = (u.pathname.match(/\/album\/(?:[^/]+\/)?(?:id)?(\d+)/) || [])[1]; if (!id) throw new Error('no album id in the URL'); const cc = (u.pathname.match(/^\/([a-z]{2})\//) || [])[1] || 'us'; const j = await this._json(`https://itunes.apple.com/lookup?id=${id}&country=${cc}&entity=album`); const art = j && j.results && j.results[0] && j.results[0].artworkUrl100; if (art) return [{ url: art, types: ['Front'] }]; this._log('debug', `Apple: lookup had no artwork for ${id} in ${cc}, reading the page`); return this._metaImage(u.href, ['Front']); } }, { id: 'deezer', name: 'Deezer', host: /(^|\.)deezer\.com$/, async find(u) { const id = (u.pathname.match(/\/album\/(\d+)/) || [])[1]; if (!id) throw new Error('no album id in the URL'); const j = await this._json(`https://api.deezer.com/album/${id}`); if (j && j.error) throw new Error(j.error.message || 'album not found'); return j && j.cover_xl ? [{ url: j.cover_xl, types: ['Front'] }] : []; } }, { id: 'spotify', name: 'Spotify', host: /^open\.spotify\.com$/, async find(u) { const id = (u.pathname.match(/\/album\/(\w+)/) || [])[1]; if (!id) throw new Error('no album id in the URL'); const j = await this._json(`https://open.spotify.com/oembed?url=${encodeURIComponent('https://open.spotify.com/album/' + id)}`); return j && j.thumbnail_url ? [{ url: j.thumbnail_url, types: ['Front'] }] : []; } }, { id: 'bandcamp', name: 'Bandcamp', host: /(^|\.)bandcamp\.com$/, async find(u) { const html = await this._text(u.href); // data-tralbum's art_id where the page has it, else the og:image const art = (html.match(/"art_id":(\d+)/) || html.match(/"art_id":(\d+)/) || [])[1]; if (art) return [{ url: `https://f4.bcbits.com/img/a${art}_10.jpg`, types: ['Front'] }]; const og = ImageMaximizer.metaImage(html, u.href); return og ? [{ url: og, types: ['Front'] }] : []; } }, { id: 'tidal', name: 'Tidal', host: /^(listen\.|store\.|www\.)?tidal\.com$/, async find(u) { const id = (u.pathname.match(/\/album\/(\d+)/) || [])[1]; if (!id) throw new Error('no album id in the URL'); const headers = { 'x-tidal-token': ImageMaximizer.TIDAL_TOKEN }; const countries = []; try { const c = await this._json('https://listen.tidal.com/v1/country/context?countryCode=WW&locale=en_US&deviceType=BROWSER', headers); if (c && c.countryCode) countries.push(c.countryCode); } catch (e) { this._log('debug', 'Tidal: country lookup failed — ' + e.message); } for (const c of ['US', 'GB', 'DE', 'NL', 'CA', 'NZ', 'JP', 'IT']) if (!countries.includes(c)) countries.push(c); let last; for (const cc of countries) { try { const j = await this._json(`https://api.tidal.com/v1/albums/${id}?countryCode=${cc}`, headers); if (j && j.cover) return [{ url: `https://resources.tidal.com/images/${j.cover.replace(/-/g, '/')}/1280x1280.jpg`, types: ['Front'] }]; } catch (e) { last = e; this._log('debug', `Tidal: album ${id} not in ${cc} — ${e.message}`); } } throw last || new Error('album not found'); } }, { id: 'qobuz', name: 'Qobuz', host: /(^|\.)qobuz\.com$/, async find(u) { const id = (u.pathname.match(/\/album\/(?:[^/]+\/)?([A-Za-z\d]+)\/?$/) || [])[1]; if (!id) throw new Error('no album id in the URL'); // the store's covers are addressed by the album id (…/covers/<last 2>/<the 2 before>/<id>); // the API, when it answers, adds the booklets const out = [{ url: `https://static.qobuz.com/images/covers/${id.slice(-2)}/${id.slice(-4, -2)}/${id}_600.jpg`, types: ['Front'] }]; try { const j = await this._json(`https://www.qobuz.com/api.json/0.2/album/get?album_id=${id}&offset=0&limit=20`, { 'x-app-id': ImageMaximizer.QOBUZ_APP }); if (j && j.image && j.image.large) out[0].url = j.image.large; for (const g of (j && j.goodies) || []) { if (!g.original_url) continue; const booklet = /livret|booklet/i.test(g.name || ''); out.push({ url: g.original_url, types: booklet ? ['Booklet'] : [], comment: booklet ? '' : (g.name || '') }); } } catch (e) { this._log('debug', `Qobuz: API gave nothing for ${id} (${e.message}); the cover alone, from its URL`); } return out; } }, { id: 'beatport', name: 'Beatport', host: /(^|\.)beatport\.com$/, async find(u) { const html = await this._text(u.href); const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/); if (m) { try { const d = JSON.parse(m[1]), r = d.props && d.props.pageProps && d.props.pageProps.release; if (r && r.image && r.image.uri) return [{ url: r.image.uri, types: ['Front'] }]; } catch (e) { this._log('debug', 'Beatport: page data unreadable — ' + e.message); } } const og = ImageMaximizer.metaImage(html, u.href); return og ? [{ url: og, types: ['Front'] }] : []; } }, { id: 'discogs', name: 'Discogs', host: /(^|\.)discogs\.com$/, async find(u) { const rel = (u.pathname.match(/\/release\/(\d+)/) || [])[1], master = (u.pathname.match(/\/master\/(\d+)/) || [])[1]; if (!rel && !master) throw new Error('no release id in the URL'); if (rel) { // the site's own API has the full-size images (Cloudflare may stop it until the // site has been opened once); the public API caps them at 600 px try { const q = new URLSearchParams({ operationName: 'ReleaseAllImages', variables: JSON.stringify({ discogsId: +rel, count: 500 }), extensions: JSON.stringify({ persistedQuery: { version: 1, sha256Hash: ImageMaximizer.DISCOGS_QUERY } }) }); const j = await this._json('https://www.discogs.com/internal/release-page/api/graphql?' + q); const edges = j && j.data && j.data.release && j.data.release.images && j.data.release.images.edges; if (edges && edges.length) return edges.map(e => ({ url: e.node.fullsize.sourceUrl })); } catch (e) { this._log('debug', 'Discogs: full-size images unavailable (' + e.message + '), using the public API (600 px)'); } } const j = await this._json(`https://api.discogs.com/${rel ? 'releases/' + rel : 'masters/' + master}`); return ((j && j.images) || []).map(i => ({ url: i.uri })).filter(i => i.url); } }, { id: 'amazon', name: 'Amazon', host: /(^|\.)amazon\.(com|ca|de|fr|it|es|nl|pl|se|in|sg|ae|sa|eg|cn|jp|co\.jp|co\.uk|com\.au|com\.br|com\.mx|com\.tr|com\.be)$/, async find(u) { const asin = (u.pathname.match(/\/(?:gp\/product|dp|albums|hz\/audible\/mlp\/mfpdp)\/([A-Z\d]{10})(?:[/?]|$)/i) || [])[1]; if (!asin) throw new Error('no product id in the URL'); const tld = u.host.replace(/^.*?amazon\./, ''); const html = await this._text(`https://www.amazon.${tld}/dp/${asin}`); if (/validateCaptcha|\/errors\/validateCaptcha/.test(html)) throw new Error('Amazon answered with a captcha; open the page once, then retry'); const m = html.match(/'colorImages':\s*\{\s*'initial':\s*(\[[\s\S]*?\])\s*\},?\s*\n/); if (m) { try { const variant = { MAIN: 'Front', FRNT: 'Front', BACK: 'Back', SIDE: 'Spine' }; return JSON.parse(m[1]).map(i => ({ url: i.hiRes || i.large, types: variant[i.variant] ? [variant[i.variant]] : [] })).filter(i => i.url); } catch (e) { this._log('debug', 'Amazon: image data unreadable — ' + e.message); } } const hi = (html.match(/data-old-hires="([^"]+)"/) || [])[1]; if (hi) return [{ url: hi, types: ['Front'] }]; const og = ImageMaximizer.metaImage(html, u.href); return og ? [{ url: og }] : []; } }, { id: 'vgmdb', name: 'VGMdb', host: /(^|\.)vgmdb\.net$/, async find(u) { const html = await this._text(u.href); const out = [], seen = new Set(); const gallery = (html.match(/id="cover_gallery"[\s\S]*?(?=<\/table>|<div id="(?!cover)|$)/) || [''])[0]; for (const m of gallery.matchAll(/<a[^>]*id="thumb_\d+"[^>]*href="([^"]+)"[^>]*>[\s\S]*?class="label"[^>]*>([^<]*)</g)) { const url = ImageMaximizer.decode(m[1]); if (seen.has(url)) continue; seen.add(url); out.push(Object.assign({ url }, ImageMaximizer.captionType(ImageMaximizer.decode(m[2]).trim()))); } const main = (html.match(/id="coverart"[^>]*background-image:\s*url\(['"]?([^'")]+)/) || [])[1]; const base = n => String(n).split('/').pop(); if (main && !/album-(nocover|nsfw)-medium\.gif$/.test(main) && !out.some(i => base(i.url) === base(main))) out.unshift({ url: new URL(ImageMaximizer.decode(main), u.origin).href, types: ['Front'] }); return out; } }, { id: '7digital', name: '7digital', host: /(^|\.)7digital\.com$/, async find(u) { return this._metaImage(u.href, ['Front']); } }, { id: 'juno', name: 'Juno', host: /(^|\.)(junodownload\.com|juno\.co\.uk)$/, async find(u) { return this._metaImage(u.href, ['Front']); } }, ]; static TIDAL_TOKEN = 'CzET4vdadNUFQ5JU'; // the web player's public token static QOBUZ_APP = '712109809'; static DISCOGS_QUERY = 'c7033a9fd1facb3e69fa50074b47e8aa0076857a968e6ed086153840e02b988a'; // ReleaseAllImages // VGMdb-style captions ("Front", "Back", "Disc 2", "Booklet p. 3–4") → a type + the rest as comment static captionType(caption) { const map = { front: 'Front', cover: 'Front', back: 'Back', booklet: 'Booklet', disc: 'Medium', disk: 'Medium', cd: 'Medium', medium: 'Medium', vinyl: 'Medium', obi: 'Obi', spine: 'Spine', tray: 'Tray', inside: 'Tray', sticker: 'Sticker', poster: 'Poster', liner: 'Liner', inlay: 'Liner', insert: 'Liner', jacket: 'Front', matrix: 'Matrix/Runout' }; const word = (String(caption).match(/^[A-Za-z]+/) || [''])[0].toLowerCase(); const type = map[word]; return type ? { types: [type], comment: caption.toLowerCase() === word ? '' : caption } : { types: [], comment: caption }; } // ── instance ───────────────────────────────────────────────────────────────── // request(url, { headers, type: 'text' | 'blob' }) → { status, url, contentType, text | blob } // log(level, message), level = 'debug' | 'info' | 'warn' constructor(opts) { opts = opts || {}; this.request = opts.request || ImageMaximizer.fetchRequest; this.log = opts.log || null; } _log(level, msg) { try { if (this.log) this.log(level, msg); } catch (e) { /* a broken logger must not break a fetch */ }} async _get(url, type, headers) { const r = await this.request(url, { type, headers: headers || {} }); if (!r || r.status < 200 || r.status >= 300) { const body = r && typeof r.text === 'string' ? r.text : ''; if (r && r.status === 403 && /<title>Just a moment\.\.\.<\/title>/.test(body)) throw new Error('Cloudflare check; open the page once in this browser, then retry'); throw new Error('HTTP ' + (r ? r.status : '?')); } return r; } async _text(url, headers) { return (await this._get(url, 'text', headers)).text; } async _json(url, headers) { const t = await this._text(url, headers); try { return JSON.parse(t); } catch (e) { throw new Error('not JSON'); } } async _metaImage(url, types) { const og = ImageMaximizer.metaImage(await this._text(url), url); return og ? [{ url: og, types: types || [] }] : []; } /** The platform resolver for a page URL, or null. */ platform(url) { let u; try { u = new URL(url); } catch (e) { return null; } const p = ImageMaximizer.PLATFORMS.find(x => x.host.test(u.hostname)); return p ? { id: p.id, name: p.name } : null; } /** Bigger versions of an image URL, largest first, ending with the URL itself. Synchronous. */ candidates(url) { let u; try { u = new URL(url); } catch (e) { return [String(url)]; } const out = []; for (const r of ImageMaximizer.RULES) { if (!r.host.test(u.hostname)) continue; try { out.push(...r.max(u)); } catch (e) { this._log('debug', `rule for ${u.hostname} failed — ${e.message}`); } break; } out.push(u.href); return [...new Set(out)]; } /** Images behind a URL, unmaximised: [{ url, types, comment, page }]. A store page goes to its * resolver; any other page to its og:image; an image URL is itself. */ async find(url) { const u = new URL(url); const p = ImageMaximizer.PLATFORMS.find(x => x.host.test(u.hostname)); // an image URL is an image even on a store's host (Beatport's geo-media, say), not a page if (ImageMaximizer.looksLikeImage(u.href)) { return [{ url: u.href, types: [], comment: '', page: '' }]; } let list; if (p) { this._log('debug', `${p.name}: reading ${u.href}`); list = await p.find.call(this, u); } else { // unknown host: an image answers as one, a page by its og:image const r = await this._get(u.href, 'text'); if (/^image\//i.test(r.contentType || '')) return [{ url: u.href, types: [], comment: '', page: '' }]; const og = ImageMaximizer.metaImage(r.text || '', u.href); list = og ? [{ url: og }] : []; } list = (list || []).filter(i => i && i.url && !ImageMaximizer.PLACEHOLDERS.some(re => re.test(i.url))); this._log('debug', `${p ? p.name : u.hostname}: ${list.length} image(s)${list.length ? ' — ' + list.map(i => i.url).join(', ') : ''}`); return list.map(i => ({ url: i.url, types: i.types || [], comment: i.comment || '', page: u.href })); } /** Download the largest version of each image behind a URL. * → [{ blob, url, original, page, types, comment, width, height }]; throws when there is none. * opts.minSize drops images whose longer side is smaller (a store's logo). */ async fetchImages(url, opts) { opts = opts || {}; const found = await this.find(url); if (!found.length) throw new Error('no image found'); const got = []; for (const img of found) { const r = await this.fetchLargest(img.url); if (!r) { this._log('warn', `no candidate of ${img.url} answered with an image`); continue; } if (opts.minSize && r.width && Math.max(r.width, r.height) < opts.minSize) { this._log('debug', `skipped ${r.url}: ${r.width}×${r.height} is under ${opts.minSize} px`); continue; } got.push(Object.assign(r, { original: img.url, page: img.page, types: img.types, comment: img.comment })); } if (!got.length) throw new Error('no image could be downloaded'); return got; } /** The first candidate of one image URL that answers with an image: { blob, url, width, height } or null. */ async fetchLargest(url) { for (const c of this.candidates(url)) { try { const r = await this.request(c, { type: 'blob', headers: {} }); const type = (r && (r.contentType || (r.blob && r.blob.type))) || ''; if (!r || r.status < 200 || r.status >= 300 || !r.blob || !r.blob.size) { this._log('debug', `candidate ${c}: HTTP ${r ? r.status : '?'}`); continue; } if (!/^image\/|^application\/pdf/i.test(type) && !/^(application\/octet-stream|binary\/octet-stream)?$/i.test(type)) { this._log('debug', `candidate ${c}: not an image (${type})`); continue; } let d = {}; try { d = ImageMaximizer.dimensions(await r.blob.slice(0, 262144).arrayBuffer()) || {}; } catch (e) { /* size unknown */ } if (!d.type && !/^image\/|pdf/i.test(type)) { this._log('debug', `candidate ${c}: unrecognised bytes`); continue; } this._log('debug', `candidate ${c}: ${d.width ? d.width + '×' + d.height : 'size unknown'}, ${r.blob.size} bytes`); return { blob: r.blob, url: c, width: d.width || 0, height: d.height || 0 }; } catch (e) { this._log('debug', `candidate ${c}: ${e.message}`); } } return null; } // ── helpers (static, pure) ──────────────────────────────────────────────────── /** True when the URL's path ends in an image extension. */ static looksLikeImage(url) { try { return /\.(jpe?g|png|gif|webp|avif|bmp|tiff?)$/i.test(new URL(url).pathname); } catch (e) { return false; } } /** og:image / twitter:image / image_src of an HTML page, resolved against its URL. */ static metaImage(html, base) { const tags = String(html).match(/<(?:meta|link)\b[^>]*>/gi) || []; const want = [/^og:image(:secure_url|:url)?$/i, /^twitter:image(:src)?$/i, /^image_src$/i]; for (const re of want) { for (const t of tags) { const key = (t.match(/\b(?:property|name|itemprop|rel)\s*=\s*["']([^"']+)["']/i) || [])[1]; const val = (t.match(/\b(?:content|href)\s*=\s*["']([^"']+)["']/i) || [])[1]; if (key && val && re.test(key.trim())) { try { return new URL(ImageMaximizer.decode(val), base).href; } catch (e) { /* bad URL */ } } } } return null; } static decode(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>'); } /** { type, width, height } from the first bytes of a JPEG, PNG, GIF or WebP; null otherwise. */ static dimensions(buf) { const b = new Uint8Array(buf); if (b.length > 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47) return { type: 'png', width: (b[16] << 24 | b[17] << 16 | b[18] << 8 | b[19]) >>> 0, height: (b[20] << 24 | b[21] << 16 | b[22] << 8 | b[23]) >>> 0 }; if (b.length > 10 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return { type: 'gif', width: b[6] | b[7] << 8, height: b[8] | b[9] << 8 }; if (b.length > 30 && b[0] === 0x52 && b[1] === 0x49 && b[8] === 0x57 && b[9] === 0x45) { const f = String.fromCharCode(b[12], b[13], b[14], b[15]); if (f === 'VP8X') return { type: 'webp', width: 1 + (b[24] | b[25] << 8 | b[26] << 16), height: 1 + (b[27] | b[28] << 8 | b[29] << 16) }; if (f === 'VP8 ') return { type: 'webp', width: (b[26] | b[27] << 8) & 0x3fff, height: (b[28] | b[29] << 8) & 0x3fff }; if (f === 'VP8L') return { type: 'webp', width: 1 + ((b[22] << 8 | b[21]) & 0x3fff), height: 1 + ((b[24] << 4 | b[23] >> 4) & 0x3fff) }; return { type: 'webp', width: 0, height: 0 }; } if (b.length > 4 && b[0] === 0xFF && b[1] === 0xD8) { let i = 2; while (i + 9 < b.length) { if (b[i] !== 0xFF) { i++; continue; } const m = b[i + 1]; if (m === 0xD8 || m === 0x01 || (m >= 0xD0 && m <= 0xD7) || m === 0xFF) { i++; continue; } const len = b[i + 2] << 8 | b[i + 3]; if (m >= 0xC0 && m <= 0xCF && m !== 0xC4 && m !== 0xC8 && m !== 0xCC) return { type: 'jpeg', width: b[i + 7] << 8 | b[i + 8], height: b[i + 5] << 8 | b[i + 6] }; i += 2 + len; } return { type: 'jpeg', width: 0, height: 0 }; } if (b.length > 4 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46) return { type: 'pdf', width: 0, height: 0 }; return null; } // ── request adapters ────────────────────────────────────────────────────────── /** The default: global fetch (Node 18+, a page; CORS applies in a page). */ static async fetchRequest(url, o) { const r = await fetch(url, { headers: (o && o.headers) || {} }); const out = { status: r.status, url: r.url || url, contentType: r.headers.get('content-type') || '' }; if (o && o.type === 'blob') out.blob = await r.blob(); else out.text = await r.text(); return out; } /** An adapter for a userscript manager's GM_xmlhttpRequest / GM.xmlHttpRequest (no CORS). */ static gmRequest(gmXhr, timeout) { return (url, o) => new Promise((resolve, reject) => { gmXhr({ method: 'GET', url, headers: (o && o.headers) || {}, responseType: o && o.type === 'blob' ? 'blob' : 'text', timeout: timeout || 60000, onload: r => { const ct = (String(r.responseHeaders || '').match(/^content-type:\s*([^\r\n]+)/im) || [])[1] || ''; const out = { status: r.status, url: r.finalUrl || url, contentType: ct.trim() }; if (o && o.type === 'blob') out.blob = r.response; else out.text = typeof r.responseText === 'string' ? r.responseText : String(r.response || ''); resolve(out); }, onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timed out')) }); }); } } // </ST-IMGMAX> const IMGMAX = new ImageMaximizer({ request: (url, o) => ImageMaximizer.gmRequest(GM_xmlhttpRequest, 30000)(url, o), // looked up per call, as gmFetch does log: (level, msg) => dbg('[cover]', 'maximizer: ' + msg), }); // The largest version of one cover URL: { blob, url, width, height, size, original }. // `original` is the URL asked for when the maximizer swapped it, else ''. Throws when // nothing answers with an image. Dimensions come from the header; the bitmap is the // fallback for a format the header parser doesn't know, and 0×0 when that fails too // (the upload goes on; MusicBrainz judges the file). async function fetchLargestCover(url) { const r = await IMGMAX.fetchLargest(url); if (!r) throw new Error('no image could be downloaded'); let { width, height } = r; if (!width || !height) { try { const bitmap = await createImageBitmap(r.blob); width = bitmap.width; height = bitmap.height; if (bitmap.close) bitmap.close(); } catch (e) { width = height = 0; } } return { blob: r.blob, url: r.url, width, height, size: r.blob.size, original: r.url !== url ? url : '' }; } // Cross-origin image fetch as a Blob. Cover candidates live on arbitrary // provider CDNs (Deezer, Spotify, Qobuz, Apple, Tidal, ...), not // musicbrainz.org/harmony.pulsewidth.org.uk, so a plain fetch() would hit // page CORS — GM_xmlhttpRequest bypasses it. Same helper (and same need) as // Art Station's gmFetch (art_station.user.js). function gmFetch(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', timeout: 30000, onload: r => { (r.status >= 200 && r.status < 300 && r.response) ? resolve(r.response) : reject(new Error('fetch ' + r.status)); }, onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timed out')), }); }); } // #494: picks the release's best cover — highest resolution, then lowest // size. Runs AFTER the item is already queued (fire-and-forget, mirroring // fetchEntityName's post-add enrichment at addToQueue) rather than before // Harmony's window.open(), since an await there risks the popup being // blocked. // // ⚠ EVERY candidate is measured. This used to trust Harmony's caption // metadata whenever it was present and only measure the rest — and the // caption is not describing the linked image. Measured on majkinetor's own // export (release c5e238d3, "CHROME"), three of four were wrong: // // provider caption actual image // Spotify 2000x2000 790KB 640x640 72KB // Deezer 1200x1200 727KB 1000x1000 115KB // iTunes 3000x3000 5.58MB 3000x3000 5.7MB (the only honest one) // Tidal 3000x3000 2.56MB 1280x1280 191KB // // Tidal's fake 3000x3000 tied with iTunes on area and won the smaller-size // tie-break, so Falcon uploaded a 1280px image while a real 3000px one was // sitting in the list — the "it added lower res" both majkinetor and chaban // reported. The caption is now only a fallback for a candidate that cannot be // fetched at all, and the measured numbers are written back onto the // candidate so the row's provider chips stop advertising fiction. async function pickBestCover(item) { // #496: cover[] is an array, but Falcon only ever auto-picks for the // first entry — the one entry Harmony's candidates land in today. const entry = item.cover && item.cover[0]; const candidates = (entry && entry.candidates) || []; if (!candidates.length) return; let best = null; const consider = (c, width, height, size) => { const area = width * height; if (!best || area > best.area || (area === best.area && size < best.size)) best = { url: c.url, provider: c.provider, area, size, width, height }; }; for (const c of candidates) { try { const m = await fetchLargestCover(c.url); if (!m.width || !m.height) throw new Error('not a readable image'); if (m.original) { // #719: a bigger version answered; it replaces the suggestion, which the // edit note still names ("Maximised from") log('info', `cover: ${c.provider} maximised to ${m.width}×${m.height} — ${m.url}`); c.original = c.original || c.url; c.url = m.url; c.width = c.height = c.size = undefined; // the caption described the small one } if (c.width && c.height && (c.width !== m.width || c.height !== m.height)) { log('warn', `cover: ${c.provider} advertised ${c.width}×${c.height} but the image is ${m.width}×${m.height} — using the real size`); } c.width = m.width; c.height = m.height; c.size = m.size; dbg('[cover]', `${item.mbid}: ${c.provider} measured ${m.width}×${m.height}, ${(m.size / 1024).toFixed(0)}KB`); consider(c, m.width, m.height, m.size); } catch (e) { dbg('[cover]', `${item.mbid}: ${c.provider} candidate failed to measure — ${e.message}`); // Only now is the caption worth anything: it is all we have. Flagged, // because a wrong number here can still win the pick. if (c.width && c.height && c.size) { dbg('[cover]', `${item.mbid}: falling back to ${c.provider}'s advertised ${c.width}×${c.height} (unverified)`); consider(c, c.width, c.height, c.size); } } } if (!best) return; entry.url = best.url; log('info', `cover: picked ${best.provider} — ${best.width}×${best.height}, ${(best.size / 1024).toFixed(0)}KB (best of ${candidates.length} measured candidate(s))`); scheduleRender('queue'); } // #494 follow-up (majkinetor): "Harmony always presents cover art even if // it is already present on MB... adding cover is not idempotent. We could // at minimum show if release has any covers as a warning." Originally // called the Cover Art Archive's own API directly, but that's a genuine // cross-origin request and it started failing outright live ("Failed to // fetch" — a real userscript-manager sandbox is more restrictive here than // a plain devtools fetch, unlike Falcon's other cross-origin need which // goes through GM_xmlhttpRequest for exactly this reason). MB's own WS2 // release endpoint already reports the same information same-origin — // `cover-art-archive.count` — so use that instead: no cross-origin call at // all, through the same throttle every other MB lookup here uses. Same // fire-and-forget-after-queuing shape as pickBestCover; sets // item.coverExistingCount (null while unknown, 0 once confirmed there's // nothing there) for renderRowDetail to warn on — item-level, not per-cover- // entry, since it's a fact about the RELEASE, not about any one image // being added (#496). async function checkExistingCoverArt(item) { // priority: this is actionable safety info, not cosmetic like a name // lookup — it shouldn't sit behind a whole batch's worth of those. const j = await mbThrottle.fetchJson(`${MB_ORIGIN}/ws/2/release/${item.mbid}?fmt=json`, undefined, true); if (!j) { dbg('[cover]', `${item.mbid}: existing-cover-art check failed (no response)`); return; } // transient — leave unknown rather than assert "none" item.coverExistingCount = (j['cover-art-archive'] && j['cover-art-archive'].count) || 0; dbg('[cover]', `${item.mbid}: ${item.coverExistingCount} existing cover image(s) per MB's own cover-art-archive field`); scheduleRender('queue'); } function makePendingToken() { return 'h' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10); } // #498 (chaban-mb via majkinetor): after a release is added to MB FROM // Harmony, Harmony's own actions page carries that release's mbid right in // its query string (`?...&release_mbid=<mbid>`) — landing Falcon's panel on // that release's own page instead of MB's bare homepage skips a manual // navigation. The target is the plain release page, NOT its relationship // editor (chaban-mb's own correction: provider links Harmony doesn't cover, // tagging, and adding to a collection all happen from there, none of it // from inside the relationship editor — matching what MB's native // post-creation redirect already does without Harmony's "Redirect to // Release Actions" setting turned on). // The MBID Harmony assigns once the release actually exists in MusicBrainz — // i.e. the marker of a COMPLETED import, which is what #557's auto send gates on. // // ⚠ It is not always a bare MBID. On a real actions page the parameter holds the // whole release URL: // ?release_mbid=https%3A%2F%2Fmusicbrainz.org%2Frelease%2F20b03c7d-… // and MBID_RE is anchored, so that form always failed the test and the send fell // back to seeding musicbrainz.org's HOME page instead of the release (measured on // the shipped build: it opened `https://musicbrainz.org/?falcon=<token>`). Accept // either shape — a bare MBID, or an MBID embedded in a /release/<mbid> URL. // #578: ?tport= is what makes MusicBrainz render the green tagger button, which // hands the release to Picard listening on that port. The whole feature is this // one parameter on the URL "Send to Falcon" already opens. Its own function so // the suffix — including the empty one — can be asserted directly rather than // inferred from a URL built on someone else's site. // #578 (majkinetor, round 3): "Add `tport` regardless of option (possible // since we have default value for port)." So the parameter is always there and // MusicBrainz always offers its tagger button; the option below governs only // whether Falcon ALSO hands the release over by itself when a run finishes. // That is what lets Jormangeud turn the automatic send off and still import by // hand, from the same one control. function picardParam() { return `&tport=${cfg.picardPort}`; } function harmonyReleaseMbid() { const v = new URLSearchParams(location.search).get('release_mbid'); if (!v) return null; if (MBID_RE.test(v)) return v.toLowerCase(); const m = v.match(/\/release\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); return m ? m[1].toLowerCase() : null; } let harmonyBtn = null; // #557: the body of the button's click, lifted out so the auto-send can take // exactly the same path — one implementation, no drift between "you clicked // it" and "Falcon clicked it for you". async function sendToFalcon(auto) { /* #590, second cut. The first one stood down on an errored page; majkinetor * tried it and ruled the other way: "we should certainly submit at the end * with whatever comes through after all repeats are exhausted. Falcon is * idempotent in any case, so half input is still better than no input." * * So nothing here refuses any more. The reload has already had its bounded * go at the MusicBrainz errors by the time this runs; whatever the page has * now is what gets sent, and the reason it may be short travels with the * batch as its edit note rather than being lost in a console line. */ const errSt = onHarmonyActionsPage() ? harmonyErrorState() : null; const partialWhy = errSt && errSt.bad ? harmonyErrorSummary(errSt) : ''; if (partialWhy) harmonyLog(`${auto ? 'auto ' : ''}send: sending a possibly incomplete batch — ${partialWhy}`); let found = scrapeHarmonyActions(); if (found.some(t => !t.name)) { // #509 follow-up (majkinetor, live: a saved copy of the exact page // scraped 75/75 tuples WITH names when replayed offline — proving the // scraper itself is correct — but his real click got 0/12 named). // Harmony renders each row's icon+name pill via its OWN async // per-entity MB-match lookup, separate from (and slower than) the // action list itself — on a small batch it's usually done by the time // anyone clicks; on a 76-item release it visibly wasn't. Rather than // hope the user waits, give Harmony's own resolution a bounded second // chance right at click time if the FIRST scrape came back with any // gaps — cheap compared to the MB fallback lookups it avoids. const lbl = document.getElementById('falcon-harmony-lbl'); const prevLbl = lbl ? lbl.textContent : ''; if (lbl) lbl.textContent = 'Resolving names…'; const deadline = Date.now() + 5000; while (Date.now() < deadline && found.some(t => !t.name)) { await wait(400); found = scrapeHarmonyActions(); } if (lbl) lbl.textContent = prevLbl; } // #537: nothing to send, and nothing to count on the button either. const foundCover = cfg.skipHarmonyCovers ? null : scrapeHarmonyCover(); const foundIsrcFallback = harmonyIsrcFallback(); // #540: always, see scrapeHarmonyActions if (!found.length && !foundCover && !foundIsrcFallback) { // #557: an auto-send must never raise a modal on a page nobody asked it // to act on — it just stands down. A real click still gets told why. if (auto) { harmonyLog('auto send: nothing to send on this page — standing down'); return false; } alert(`${NAME}: no "Link external IDs" actions, cover art, or ISRCs found on this page.`); return false; } const token = makePendingToken(); const payload = found.map(t => ({ entityType: t.entityType, mbid: t.mbid, url: t.url, linkTypeId: t.linkTypeId || undefined, note: t.note || undefined, isrc: t.isrc || undefined, name: t.name || undefined })); // #536 follow-up (majkinetor): "edit note should probably show that it // was from harmony page". Nothing downstream knew where a batch came // from, so the cover note could name the image's provider but not the // page that proposed it. Harmony's Release Actions url IS that page. if (foundCover) payload.push({ entityType: 'release', mbid: foundCover.mbid, coverCandidates: foundCover.coverCandidates, source: location.href }); if (foundIsrcFallback) payload.push({ entityType: 'recording', pendingIsrcs: foundIsrcFallback }); GM_setValue('falcon:pending:' + token, JSON.stringify(payload)); /* #590 (majkinetor): "Falcon could add this partial info in its edit note for the batch." Carried alongside the payload rather than inside it — parseUrlParam requires the payload to be a bare array, and widening that contract for one string would touch every producer of it. */ if (partialWhy) { GM_setValue('falcon:pendingNote:' + token, `${partialWhy}. These links may therefore be incomplete; Falcon is idempotent, so re-running over this release later will fill in the rest.`); } const relMbid = harmonyReleaseMbid(); const tport = picardParam(); // #578 const target = relMbid ? `${MB_TARGET}/release/${relMbid}?falcon=${token}${tport}` : `${MB_TARGET}/?falcon=${token}${tport}`; // #578: name the Picard decision in the log. majkinetor reported it "not // working" and there was no way to tell from the log whether the option had // reached this page at all — which is most of the diagnosis. harmonyLog(`${auto ? 'auto ' : ''}send: ${payload.length} item(s) → ${target}` + ` · Picard: tport=${cfg.picardPort}, auto-send ${cfg.sendToPicard ? 'on' : 'off'}` + (relMbid ? '' : ' (no release mbid on this page, so MusicBrainz has nothing to tag)')); if (cfg.openHarmonyInNewTab) openMbTab(target, auto); else location.href = target; return true; } // #557: an AUTO send happens outside a user gesture, and every browser popup // blocker rejects window.open there — the batch would be written to GM storage // and then silently go nowhere. GM_openInTab is not subject to the blocker, so // prefer it; fall back to window.open (fine for a real click), and if that is // blocked too, navigate this tab rather than lose the send. function openMbTab(target, auto) { if (typeof GM_openInTab === 'function') { try { GM_openInTab(target, { active: true, insert: true, setParent: true }); return; } catch (e) { harmonyLog('GM_openInTab failed (' + e.message + ') — falling back to window.open'); } } let w = null; try { w = window.open(target, '_blank'); } catch (e) { w = null; } if (w) return; harmonyLog(auto ? 'the popup blocker refused a new tab for an automatic send — navigating this tab instead' : 'window.open was blocked — navigating this tab instead'); location.href = target; } // Falcon's panel (and its log) live on the MusicBrainz side; on Harmony there // is nothing but this button, so say it where it can actually be seen. #557 function harmonyLog(msg) { try { console.info(`[${NAME}] ${msg}`); } catch (e) {} } function ensureHarmonyButton() { const items = scrapeHarmonyActions(); const cover = cfg.skipHarmonyCovers ? null : scrapeHarmonyCover(); // #537 // #500: the isrc fallback only matters when there are no recording // tuples for scrapeHarmonyActions to have already zipped isrcs onto. // #540: no longer conditional on there being no recording actions — the // tracklist is the only trustworthy source of "which track is isrcN". const isrcFallback = harmonyIsrcFallback(); const total = items.length + (cover ? 1 : 0) + (isrcFallback ? isrcFallback.isrcs.filter(Boolean).length : 0); if (!harmonyBtn) { harmonyBtn = document.createElement('button'); harmonyBtn.type = 'button'; harmonyBtn.id = 'falcon-harmony-btn'; harmonyBtn.style.cssText = 'position:fixed;right:14px;bottom:14px;z-index:2147483646;padding:10px 16px;border-radius:20px;border:none;cursor:pointer;background:#1b2a4a;color:#fff;font:bold 13px Arial;box-shadow:0 3px 12px rgba(0,0,0,.3);display:flex;align-items:center;gap:8px;transition:opacity .15s'; harmonyBtn.innerHTML = `<span style="display:flex;color:var(--mbu-warn)">${ICON}</span><span id="falcon-harmony-lbl"></span>`; // While an auto-send is counting down the button is the CANCEL — clicking // it stops the countdown and leaves you exactly where you were, rather // than sending twice. Otherwise it is the ordinary manual send. #557 harmonyBtn.onclick = () => { // #590: whichever countdown is running, the click stops it — and stops // there. Only a click on a settled button sends. if (cancelHarmonyReload('cancelled — you clicked the button')) return; if (cancelAutoSend('cancelled — you clicked the button')) return; sendToFalcon(false); }; document.body.appendChild(harmonyBtn); } const lbl = document.getElementById('falcon-harmony-lbl'); if (_autoSendTimer || _reloadTimer) return; // a countdown owns the label while it runs /* #590 second cut: the button still SENDS — it just says the batch may be short, so a partial import is a visible choice rather than a surprise. The tooltip carries the detail; the same wording goes on the edit note. */ const err = onHarmonyActionsPage() ? harmonyErrorState() : null; if (err && err.bad && total) { lbl.textContent = `Send ${total} to Falcon (partial)`; harmonyBtn.style.opacity = '1'; harmonyBtn.title = harmonyErrorSummary(err) + '. Sending anyway — Falcon is idempotent, so a partial import can be topped up later.'; return; } lbl.textContent = total ? `Send ${total} to Falcon` : 'No Falcon actions found yet…'; harmonyBtn.style.opacity = total ? '1' : '.6'; harmonyBtn.title = total ? `Opens MusicBrainz with ${total} item(s) queued in Falcon` : 'Waiting for Harmony to render its actions…'; } /* ── #557: auto send ───────────────────────────────────────────────────── * "Let's have another option 'Auto send' in Harmony category. Having both * enabled would automatically finish everything after successful Harmony * import." So: press "Send to Falcon" without being asked, and let the MB * side's existing "Auto start import" carry it the rest of the way. * * Three things it deliberately will NOT do: * • fire on any Harmony page that isn't a completed import. A release_mbid * in the query string is exactly that signal — it is the MBID Harmony * assigns once the release is actually in MusicBrainz, so no MBID means * no successful import and nothing to send anywhere. * • fire before the action list has settled. Harmony renders its actions * client-side, so an early send would ship a partial batch — the boot * poller already waits for three unchanged reads, and this hangs off it. * • fire more than once per page load. * And it counts down visibly first, because with "Open in new tab" OFF a * send NAVIGATES this tab away from Harmony — that should never happen * without a beat in which to stop it. Clicking the button cancels. */ let _autoSendTimer = null, _autoSendDone = false; function cancelAutoSend(why) { if (!_autoSendTimer) return false; clearInterval(_autoSendTimer); _autoSendTimer = null; harmonyLog('auto send ' + why); ensureHarmonyButton(); // repaint the ordinary label return true; } function maybeAutoSend() { if (_autoSendDone || _autoSendTimer) return; if (!cfg.autoSendFromHarmony) return; if (!harmonyReleaseMbid()) { harmonyLog('auto send: no release_mbid on this page — not a completed import, standing down'); _autoSendDone = true; return; } if (_reloadTimer) return; // #590: a reload is already armed — let it happen const items = scrapeHarmonyActions(); const cover = cfg.skipHarmonyCovers ? null : scrapeHarmonyCover(); const isrcFallback = harmonyIsrcFallback(); const total = items.length + (cover ? 1 : 0) + (isrcFallback ? isrcFallback.isrcs.filter(Boolean).length : 0); /* #590: "nothing left to send" was being logged for shape A too, where the truth is the opposite — Harmony never managed to load the release, so there is everything left to do and this tab is about to look finished. An errored page with actions on it still sends (see sendToFalcon); only a page with genuinely nothing on it stands down, and it now says which of the two it is. */ const errSt = harmonyErrorState(); if (!total) { harmonyLog(errSt.bad ? `auto send: nothing on this page to send — ${harmonyErrorSummary(errSt)}` : 'auto send: the import succeeded but there is nothing left to send'); _autoSendDone = true; return; } _autoSendDone = true; let left = Math.ceil(cfg.autoSendDelayMs / 1000); const lbl = document.getElementById('falcon-harmony-lbl'); const paint = () => { if (lbl) lbl.textContent = `Auto-sending ${total} in ${left}… (click to cancel)`; if (harmonyBtn) { harmonyBtn.style.opacity = '1'; harmonyBtn.title = 'Falcon will send this batch automatically — click to cancel'; } }; harmonyLog(`auto send: ${total} item(s) in ${left}s — click the button to cancel`); paint(); _autoSendTimer = setInterval(() => { if (--left > 0) return paint(); clearInterval(_autoSendTimer); _autoSendTimer = null; sendToFalcon(true); }, 1000); if (left <= 0) { clearInterval(_autoSendTimer); _autoSendTimer = null; sendToFalcon(true); } } /* ── #590: a Harmony page that errored ─────────────────────────────────── * chaban-mb: "Harmony release actions page is often missing recording, artist, * label links when an error occurs. However Falcon ignores this in 'Auto send' * mode leading to incomplete entries." * * Read off Harmony's own source (routes/release/actions.tsx) rather than off * the rendered page, because this is THREE different failures wearing one * error box — everything the route does sits in a single try, so where the * throw lands decides what survives: * * A MB.lookup / getMergedRelease threw → `release` is undefined, so * allArtists and allRecordings are empty, so LinkWithMusicBrainz returns * null for every type and ISRCSubmission returns null. An error box over * the bare MBID form, and not one action. This is the issue's screenshot. * Falcon's auto-send today sees total === 0 and logs "the import * succeeded but there is nothing left to send" — wrong, and * indistinguishable from a release that genuinely had nothing to do. * B one of the artist/recording/label browse calls threw → `release` IS * set, so the sections render, but UNFILTERED: Harmony can no longer tell * which links MusicBrainz already has, and says so itself, per type * ("Already existing … could not be checked"). ISRCSubmission with an * empty recordingsCache calls every ISRC new. Falcon sends duplicates — * not destructive (MB rejects them) but it buries what was really new. * C one provider's lookup failed and the merge succeeded without it. The * page looks complete and the actions are genuinely incomplete — the * failed provider's links are simply absent. THIS is the "incomplete * entries" of the title, and nothing in the action list reveals it. * * So `.message.error` is the right trigger (chaban's recommendation, and the * only thing that covers all three) — but never on its own, because of the * deny-list below. */ // Reloading these can never help: the input is wrong, or the thing asked for // is not there. Note the POLARITY — this is not an allow-list of errors worth // retrying (Harmony surfaces far too many strings for that to stay correct); // an error nobody has seen before still gets its bounded retry, and only the // known-hopeless ones are excluded. That degrades safely when Harmony adds one. const HARMONY_PERMANENT_RE = /(is not a valid mbid|does not contain a valid release mbid|invalid mbid|release not found|^\s*not found\s*$|no provider supports|no release lookups have been queued|can only be used once per lookup|could not determine the musicbrainz release mbid)/i; // Errors that mean "the server wants less traffic, not the same traffic // again" — a rate limit, a full queue, or a 503 "currently busy". Not all of // them are rate limits (MusicBrainz's busy message is plain overload), but the // right response to every one of them is the same: start the backoff further // out. Named for what it decides rather than for one of its members, because // calling the set "rate limit" is what made this ambiguous in the first place. const HARMONY_SLOW_DOWN_RE = /(rate limit|too many requests queued|currently busy|429)/i; // Harmony marks the shape-B case itself, in LinkWithMusicBrainz — verbatim. const HARMONY_UNCHECKED_RE = /Already existing (\w+) links on MusicBrainz could not be checked/; // MessageBox renders <span class="provider"> for a ProviderError and nothing // else, so no chip means the error came from the MusicBrainz API calls // themselves. MusicBrainz is also one of Harmony's own providers, so a chip // naming it is still a MusicBrainz error. const isMbErrorBox = b => !b.provider || /^musicbrainz$/i.test(b.provider); function harmonyErrorState() { const main = document.querySelector('main') || document.body; const boxes = [...main.querySelectorAll('.message.error')].map(b => ({ text: ((b.querySelector('.markdown') || b.querySelector('p') || b).textContent || '').trim(), // MessageBox only renders <span class="provider"> for a ProviderError — // its presence is the strongest signal available for shape C. provider: ((b.querySelector('.provider') || {}).textContent || '').replace(/:\s*$/, '').trim() || null, // chaban's harmony-beatport-recovery deliberately mimics Harmony's markup, // so its errors match too. Kept, not filtered: a rate-limited recovery is // worth a reload just the same. This only ATTRIBUTES it, so that if a loop // ever does happen the log names which script's box caused it. from: b.id === 'hbr-beatport-message' ? 'beatport-recovery' : 'harmony', })); // ⚠ NOT a trigger, only a classifier. Harmony deliberately SKIPS the label // browse when no label has external IDs ("save pointless API call"), so the // label flavour of this warning appears with nothing whatsoever wrong. const uncheckedTypes = [...new Set([...main.querySelectorAll('.message.warning')] .map(w => (HARMONY_UNCHECKED_RE.exec(w.textContent || '') || [])[1]) .filter(Boolean))]; /* ⚠ Only a MusicBrainz error is worth reloading for, and this is the line that decides which is which. majkinetor, after running the first cut: "'Beatport: Failed to extract embedded JSON' is a showstopper for me, and it seems to never resolve, so it blocks all other providers that did. It blocked 6 providers that did have data… lets just look for MB errors." MessageBox renders <span class="provider"> for a ProviderError and for nothing else, so an error with no provider chip came from the MusicBrainz API calls themselves. The MusicBrainz provider is also a provider in Harmony's own lookup, so a chip reading "MusicBrainz" counts too. */ const mbErrors = boxes.filter(isMbErrorBox); const providerErrors = boxes.filter(b => !isMbErrorBox(b)); const slowDown = mbErrors.some(b => HARMONY_SLOW_DOWN_RE.test(b.text)); return { boxes, mbErrors, providerErrors, uncheckedTypes, slowDown, // <h2 class="release-title"> is only rendered when `release` merged, which // is precisely the A/not-A distinction. hasRelease: !!document.querySelector('h2.release-title'), providers: [...new Set(providerErrors.map(b => b.provider).filter(Boolean))], // "permanent" only ever decides whether to RELOAD, so it is about the // MusicBrainz errors alone. permanent: mbErrors.length > 0 && mbErrors.every(b => HARMONY_PERMANENT_RE.test(b.text)), get bad() { return boxes.length > 0; }, get mbBad() { return mbErrors.length > 0; }, get shape() { return !boxes.length ? 'clean' : !this.hasRelease ? 'A' : uncheckedTypes.length ? 'B' : 'C'; }, }; } /* A one-line human summary — used on the button, in the log, and as the batch edit note, so all three say the same thing. majkinetor: "Falcon could add this partial info in its edit note for the batch." */ function harmonyErrorSummary(st) { if (!st.bad) return ''; const bits = []; if (st.mbErrors.length) { bits.push(st.shape === 'A' ? 'MusicBrainz could not be read at all, so this page has no actions' : st.uncheckedTypes.length ? `existing ${st.uncheckedTypes.join('/')} links could not be checked against MusicBrainz, so these actions are unfiltered` : `MusicBrainz returned an error (${st.mbErrors[0].text.replace(/\s+/g, ' ').slice(0, 80)})`); } if (st.providerErrors.length) { bits.push(`${st.providers.join(', ')} failed, so whatever ${st.providers.length === 1 ? 'it' : 'they'} would have contributed is missing`); } return 'Harmony reported an error — ' + bits.join('; '); } function onHarmonyActionsPage() { return ON_HARMONY && /\/release\/actions\/?$/.test(location.pathname) && !!harmonyReleaseMbid(); } // Per-TAB, which is exactly the lifetime wanted: it survives the reloads it is // counting, and a second tab on the same release does not inherit a count. function harmonyReloadKey() { return 'falcon:harmonyReload:' + (harmonyReleaseMbid() || location.search); } function harmonyReloadCount() { try { return Number(sessionStorage.getItem(harmonyReloadKey())) || 0; } catch (e) { return 0; } } function setHarmonyReloadCount(n) { try { if (n) sessionStorage.setItem(harmonyReloadKey(), String(n)); else sessionStorage.removeItem(harmonyReloadKey()); } catch (e) {} } // 5s → 60s (15s → 60s when rate-limited), capped, plus up to a second of // jitter so several tabs erroring on the same MusicBrainz hiccup don't all // come back at the same instant. function harmonyReloadDelayMs(attempt, slowDown) { const base = slowDown ? 15000 : 5000; return Math.min(60000, base * Math.pow(2, Math.max(0, attempt - 1))) + Math.floor(Math.random() * 1000); } let _reloadTimer = null, _reloadCancelled = false; function cancelHarmonyReload(why) { if (!_reloadTimer) return false; clearInterval(_reloadTimer); _reloadTimer = null; _reloadCancelled = true; // sticky for this page load — one refusal is enough harmonyLog('reload on error ' + why); ensureHarmonyButton(); return true; } /* Returns true when a reload is armed (the caller should not bother scraping). * Deliberately called at BOOT, ahead of the settle poller: Harmony renders * these boxes server-side, so they are in the first byte of HTML — waiting for * three unchanged action counts and then a 4s send countdown only delays a * page that was never going to be worth reading. */ function maybeReloadOnError() { if (!onHarmonyActionsPage()) return false; const st = harmonyErrorState(); st.boxes.forEach(b => harmonyLog(`${isMbErrorBox(b) ? 'MusicBrainz' : 'provider'} error (${b.from}${b.provider ? ', ' + b.provider : ''}): ${b.text.replace(/\s+/g, ' ').slice(0, 300)}`)); if (!st.mbBad) { // A clean MusicBrainz load ends the retry series, so a flaky moment can // never accumulate a count across successful imports. if (harmonyReloadCount()) { harmonyLog(`MusicBrainz came back cleanly after ${harmonyReloadCount()} reload(s)`); setHarmonyReloadCount(0); } /* ⚠ A PROVIDER error is deliberately not reloaded for. majkinetor hit exactly this: "'Beatport: Failed to extract embedded JSON'… seems to never resolve, so it blocks all other providers that did. It blocked 6 providers that did have data." Reloading cannot fix a provider whose page Harmony can't parse, and the six that worked are worth sending. */ if (st.providerErrors.length) harmonyLog(`${st.providers.join(', ')} failed, but MusicBrainz itself is fine — reloading would not bring them back, so this sends as it is`); return false; } const why = harmonyErrorSummary(st); harmonyLog(`${why} [shape ${st.shape}]`); if (st.permanent) { harmonyLog('every MusicBrainz error on this page is permanent — reloading it could never help, so it will not be reloaded'); return false; } const n = harmonyReloadCount() + 1; if (n > cfg.harmonyReloadMax) { /* majkinetor: "we should certainly submit at the end with whatever comes through after all repeats are exhausted. Falcon is idempotent in any case, so half input is still better than no input." So the retries stop and the send goes ahead — carrying the reason in the batch edit note. */ harmonyLog(`gave up after ${cfg.harmonyReloadMax} reload(s) — sending whatever this page does have, since a partial import beats none`); return false; } if (_reloadCancelled || _reloadTimer) return true; let left = Math.ceil(harmonyReloadDelayMs(n, st.slowDown) / 1000); harmonyLog(`reloading in ${left}s — attempt ${n}/${cfg.harmonyReloadMax}${st.slowDown ? ' (the server asked for less traffic, so backing off further)' : ''}. Click the button to cancel.`); const paint = () => { const lbl = document.getElementById('falcon-harmony-lbl'); if (lbl) lbl.textContent = `Harmony errored — reloading in ${left}… (click to cancel)`; if (harmonyBtn) { harmonyBtn.style.opacity = '1'; harmonyBtn.title = why + '. Falcon will load the page again — click to cancel.'; } }; paint(); _reloadTimer = setInterval(() => { if (--left > 0) return paint(); clearInterval(_reloadTimer); _reloadTimer = null; setHarmonyReloadCount(n); // ⚠ location.reload(), NEVER a rebuilt URL. The query string carries the // whole lookup state Harmony reads back out of it (providers, regions, // gtin, snapshotMaxTimestamp — see extractReleaseLookupState); anything // reconstructed here would quietly perform a DIFFERENT lookup. location.reload(); }, 1000); return true; } /* ── #591: attach a disc ID from a CD rip log ──────────────────────────── * majkinetor: "Picard is used to add disk id - there is no web option… We * should show each media as drop target above current disk ids if they exist * - you click on it to select a file or drop a file to it and you are done." * * The whole thing turns out to be computable in the browser. A MusicBrainz * disc ID is a SHA-1 over the TOC, so nothing here needs libdiscid, Picard, or * a request to anyone — the log file is read locally and the result is a URL. * Verified against the exact log and disc ID in the issue: * * toc 1+13+279873+150+19836+…+265363 * id UHvvp8Oyi0D5QEK.qYfeX7GrcLw- * * The parsers below are ports of Picard's (picard/disc/*.py), deliberately * kept structurally identical so the two stay comparable — same regexes, same * pregap constant, same data-track rule. "Check out the Picard code on how it * formats query params and what kind of logs it processes." */ const CD_PREGAP = 150; // picard.disc.utils.PREGAP_LENGTH const CD_DATA_TRACK_GAP = 11400; // picard.disc.utils.DATA_TRACK_GAP /* EAC / XLD / fre:ac all write the same table. Two regexes, both Picard's: one to find the header (localised EAC output means the column NAMES cannot be matched, only the shape), then rows until the table ends. */ const RE_EAC_HEADER = /^\s*.+\s+\|\s+.+\s+\|\s+.+\s+\|\s+.+\s+\|\s+.+\s*$/; const RE_EAC_ROW = /^\s*(\d+)\s*\|\s*[0-9:.]+\s*\|\s*[0-9:.]+\s*\|\s*(\d+)\s*\|\s*(\d+)\s*$/; const RE_DBPA_ROW = /^Track\s+(\d+):\s+Ripped LBA (\d+) to (\d+)/; const RE_CYANRIP_HEADER = /^cyanrip\s+\d+\.\d+/; function parseEacLog(lines) { const out = []; let i = 0; for (; i < lines.length; i++) if (RE_EAC_HEADER.test(lines[i])) { i += 2; break; } // +1 header, +1 separator for (; i < lines.length; i++) { const m = RE_EAC_ROW.exec(lines[i]); if (!m) { if (out.length) break; continue; } out.push({ number: +m[1], start: +m[2], end: +m[3] }); } return out; } function parseDbPowerampLog(lines) { const out = []; let last = 0; for (const line of lines) { const m = RE_DBPA_ROW.exec(line); if (!m) continue; const n = +m[1]; // Picard raises here rather than guessing, and so should this: a partial // rip produces a disc ID for a disc that does not exist. if (last + 1 !== n) throw new Error(`non-consecutive track numbers (${last} → ${n}) — this looks like a partial rip, so no disc ID can be calculated`); last = n; out.push({ number: n, start: +m[2], end: +m[3] - 1 }); // dBpoweramp's end is exclusive } return out; } function parseCyanripLog(lines) { const out = []; let cur = null, start = null; for (const line of lines) { const th = /^Track (\d+) ripped/.exec(line); if (th) { cur = +th[1]; start = null; continue; } if (cur == null) continue; const s = /^\s*Start LSN:\s+(\d+)\s*$/.exec(line); if (s) { start = +s[1]; continue; } const e = /^\s*End LSN:\s+(\d+)\s*/.exec(line); if (e && start != null) { out.push({ number: cur, start, end: +e[1] }); cur = null; start = null; } } return out; } /* whipper writes YAML. Picard runs it through a real YAML parser; a userscript has none and is not going to grow one for four keys, so this reads the TOC block by indentation. Narrow on purpose: it only looks inside `TOC:`, and only for the two keys it needs. */ function parseWhipperLog(lines) { const out = []; let inToc = false, tocIndent = -1, cur = null; const indentOf = l => l.length - l.replace(/^\s*/, '').length; for (const line of lines) { if (!line.trim()) continue; if (/^\s*TOC:\s*$/.test(line)) { inToc = true; tocIndent = indentOf(line); cur = null; continue; } if (!inToc) continue; const ind = indentOf(line); if (ind <= tocIndent) break; // out of the TOC block const num = /^\s*(\d+):\s*$/.exec(line); if (num) { cur = { number: +num[1], start: null, end: null }; out.push(cur); continue; } if (!cur) continue; const s = /^\s*Start sector:\s*(\d+)\s*$/.exec(line); if (s) { cur.start = +s[1]; continue; } const e = /^\s*End sector:\s*(\d+)\s*$/.exec(line); if (e) cur.end = +e[1]; } return out.filter(t => t.start != null && t.end != null); } // Tried in order; the first one that yields entries wins. cyanrip is checked // by its header first (Picard does the same) because its "Track N ripped" // lines could otherwise be mistaken for something else. function parseRipLog(text) { const lines = text.split(/\r\n|\r|\n/); const tried = []; const attempt = (name, fn) => { let r = []; try { r = fn(lines); } catch (e) { tried.push(`${name}: ${e.message}`); throw e; } tried.push(`${name}: ${r.length} track(s)`); return r; }; if (RE_CYANRIP_HEADER.test(lines[0] || '')) { const r = attempt('cyanrip', parseCyanripLog); if (r.length) return { entries: r, format: 'cyanrip', tried }; } for (const [name, fn] of [['EAC/XLD/fre:ac', parseEacLog], ['whipper', parseWhipperLog], ['dBpoweramp', parseDbPowerampLog], ['cyanrip', parseCyanripLog]]) { const r = attempt(name, fn); if (r.length) return { entries: r, format: name, tried }; } return { entries: [], format: null, tried }; } // picard.disc.utils.calculate_mb_toc_numbers, including its two refusals — // an empty list and a non-standard track sequence both mean the disc ID would // be wrong rather than missing, which is the worse failure. function calcMbToc(entries) { let toc = entries.slice(); if (toc.length > 1 && toc[toc.length - 1].start - toc[toc.length - 2].end === CD_DATA_TRACK_GAP + 1) { toc = toc.slice(0, -1); // a trailing data track is not part of the disc ID } if (!toc.length) throw new Error('no tracks were found in this log'); toc.forEach((e, i) => { if (e.number !== i + 1) throw new Error(`non-standard track number sequence (${toc.map(t => t.number).join(', ')})`); }); const leadout = toc[toc.length - 1].end + CD_PREGAP + 1; const offsets = toc.map(e => e.start + CD_PREGAP); return { first: 1, last: toc.length, leadout, offsets, dataTrackDropped: toc.length !== entries.length }; } /* The disc ID itself: SHA-1 over first track, last track and 100 eight-digit offsets (leadout, then tracks 1..99, zero-padded), base64 with +/= swapped for ._- . Not needed to ATTACH — MusicBrainz recomputes it from the toc parameter — but it is what makes the result checkable by eye against what Picard would have produced, and it is what the page shows afterwards. */ async function mbDiscId(first, last, leadout, offsets) { const h8 = n => (n >>> 0).toString(16).toUpperCase().padStart(8, '0'); let s = first.toString(16).toUpperCase().padStart(2, '0') + last.toString(16).toUpperCase().padStart(2, '0') + h8(leadout); for (let i = 0; i < 99; i++) s += h8(offsets[i] || 0); const bytes = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i); const digest = new Uint8Array(await crypto.subtle.digest('SHA-1', bytes)); let bin = ''; digest.forEach(b => { bin += String.fromCharCode(b); }); return btoa(bin).replace(/\+/g, '.').replace(/\//g, '_').replace(/=/g, '-'); } function tocParam(toc) { return [toc.first, toc.last, toc.leadout, ...toc.offsets].join('+'); } /* ⚠ EAC writes UTF-16 by default, and reading one of those as UTF-8 gives a string full of NULs in which none of the regexes above match — the log would look "unrecognised" rather than mis-decoded. Picard sniffs the encoding; this does the same from the BOM, and falls back to counting NULs for the (real) case of a UTF-16 log with no BOM at all. */ async function readLogText(file) { const buf = new Uint8Array(await file.arrayBuffer()); if (buf[0] === 0xFF && buf[1] === 0xFE) return { text: new TextDecoder('utf-16le').decode(buf.subarray(2)), encoding: 'UTF-16LE (BOM)' }; if (buf[0] === 0xFE && buf[1] === 0xFF) return { text: new TextDecoder('utf-16be').decode(buf.subarray(2)), encoding: 'UTF-16BE (BOM)' }; const body = (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) ? buf.subarray(3) : buf; const look = body.subarray(0, 1000); let nulls = 0; look.forEach(b => { if (b === 0) nulls++; }); if (nulls > look.length / 4) { const odd = look.filter((b, i) => i % 2 === 1 && b === 0).length; const even = look.filter((b, i) => i % 2 === 0 && b === 0).length; return { text: new TextDecoder(odd >= even ? 'utf-16le' : 'utf-16be').decode(body), encoding: 'UTF-16 (no BOM)' }; } return { text: new TextDecoder('utf-8').decode(body), encoding: (body === buf ? 'UTF-8' : 'UTF-8 (BOM)') }; } // one place that turns a dropped file into everything downstream needs async function discIdFromLog(file) { const { text, encoding } = await readLogText(file); const { entries, format, tried } = parseRipLog(text); if (!entries.length) { const e = new Error('this file is not a CD rip log Falcon recognises (EAC, XLD, fre:ac, whipper, dBpoweramp or cyanrip)'); e.tried = tried; throw e; } const toc = calcMbToc(entries); const id = await mbDiscId(toc.first, toc.last, toc.leadout, toc.offsets); return { id, toc, tocString: tocParam(toc), tracks: toc.last, format, encoding, tried }; } /* ── the Disc IDs tab ───────────────────────────────────────────────────── * Rendered above whatever MusicBrainz already shows, one zone per medium. * Dropping a log on a zone goes to MusicBrainz's own attach page — Falcon * never submits the edit itself, so the edit note and "Enter edit" stay * exactly where they are. */ const DISCIDS_PATH_RE = /^\/release\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/discids\/?$/i; // MusicBrainz only allows disc IDs on CD-family formats (medium_format // .has_discids). Matched by name rather than by id: the names are stable and // an id list would be one more thing to keep in step with MusicBrainz. const DISCID_FORMAT_RE = /(^|\b)(cd|cd-r|8cm cd|8cm cd\+g|enhanced cd|hdcd|copy control cd|data cd|dts cd|playstation|dualdisc \(cd side\)|shm-cd|hqcd|cd\+g|blu-spec cd|svcd|vcd|cdv)($|\b)/i; function mediumMayHaveDiscIds(format) { return !format || DISCID_FORMAT_RE.test(format); } // The medium row id is MusicBrainz's internal one, and it is only in this // page's own markup for mediums that ALREADY have a disc ID (the Remove/Move // links carry it). When it is known the drop can go straight to the // confirmation page; when it is not, it goes through MusicBrainz's own medium // picker, which is one extra click and no extra request. function scrapeMediumIds() { const byPosition = {}; document.querySelectorAll('#content a[href*="medium"]').forEach(a => { const href = a.getAttribute('href') || ''; const m = /[?&]medium(?:_id)?=(\d+)/.exec(href); if (!m) return; const head = a.closest('table') && a.closest('table').querySelector('th, .medium'); const row = a.closest('tr'); let pos = null; // the medium's heading row above this one (tr.subh) links it as a#disc<N>; #722 #723 read // N from that id, as its text ("CD 1", "Vinyl 2") is the format's name, in MB's language for (let el = row && row.previousElementSibling; el; el = el.previousElementSibling) { const d = el.classList.contains('subh') && el.querySelector('a[id^="disc"]'); const dm = d && /^disc(\d+)$/.exec(d.id); if (dm) { pos = +dm[1]; break; } } // older markup without the id: the heading row's text for (let el = row && row.previousElementSibling; pos == null && el; el = el.previousElementSibling) { const t = (el.textContent || '').trim(); const pm = /(?:medium|cd|disc)\s*(\d+)/i.exec(t); if (pm) { pos = +pm[1]; break; } } if (pos == null && head) { const pm = /(?:medium|cd|disc)\s*(\d+)/i.exec(head.textContent || ''); if (pm) pos = +pm[1]; } if (pos == null) pos = 1; byPosition[pos] = +m[1]; }); return byPosition; } let _discIdUiBuilt = false; async function ensureDiscIdUi() { if (_discIdUiBuilt) return; const m = DISCIDS_PATH_RE.exec(location.pathname); if (!m) return; _discIdUiBuilt = true; const mbid = m[1].toLowerCase(); const host = document.getElementById('content'); if (!host) return; /* majkinetor: "Dark theme please". The first cut hardcoded light colours, which is doubly wrong here: MBU_TOKENS is injected by ensurePanel(), and this box exists on pages where the panel was never opened — so the tokens have to come with it. mbuTheme() is what stamps data-mbu-theme on <html>; calling it here means the box follows a dark userstyle even when nothing else of Falcon's is on screen. */ try { mbuTheme(); } catch (e) {} const box = document.createElement('div'); box.id = 'falcon-discid-box'; box.className = 'mbu-ui'; box.innerHTML = `<style> ${document.getElementById('falcon-panel') ? '' : MBU_TOKENS} #falcon-discid-box{margin:10px 0 16px;font:13px var(--mbu-font);color:var(--mbu-text)} #falcon-discid-box h2{color:var(--mbu-text)} #falcon-discid-box .fd-zones{display:flex;flex-wrap:wrap;gap:10px;margin:8px 0} #falcon-discid-box .fd-zone{flex:1 1 220px;min-height:76px;border:2px dashed var(--mbu-border-strong);border-radius:var(--mbu-radius-lg);padding:10px 12px; display:flex;flex-direction:column;justify-content:center;gap:3px;cursor:pointer;color:var(--mbu-text); background:var(--mbu-bg-raised);transition:background .12s,border-color .12s} #falcon-discid-box .fd-zone:hover,#falcon-discid-box .fd-zone.over{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} #falcon-discid-box .fd-zone.busy{cursor:progress;opacity:.7} #falcon-discid-box .fd-zone.bad{border-color:var(--mbu-error-border);background:var(--mbu-error-bg)} #falcon-discid-box .fd-zone.off{cursor:not-allowed;opacity:.55;border-style:solid} #falcon-discid-box .fd-t{font-weight:600;color:var(--mbu-text)} #falcon-discid-box .fd-s{font-size:11px;color:var(--mbu-text-dim)} #falcon-discid-box .fd-err{color:var(--mbu-error)} #falcon-discid-box code{font-family:var(--mbu-font-mono);background:var(--mbu-bg-sunken);padding:0 3px;border-radius:3px} #falcon-discid-box a{color:var(--mbu-accent-text)} #falcon-discid-box .fd-foot{font-size:11px;color:var(--mbu-text-dim)} </style> <h2>Disc IDs from a rip log</h2> <div class="fd-zones" id="falcon-discid-zones"><div class="fd-s">Reading this release…</div></div> <p class="fd-foot">Drop an <strong>EAC</strong>, <strong>XLD</strong>, <strong>fre:ac</strong>, <strong>whipper</strong>, <strong>dBpoweramp</strong> or <strong>cyanrip</strong> log on a medium — or click to pick one — and Falcon works out the disc ID and takes you to MusicBrainz's attach page. Nothing is submitted on your behalf. See <a href="/doc/How_to_Add_Disc_IDs">How to Add Disc IDs</a>.</p>`; const heading = [...host.querySelectorAll('h2')].find(h => /disc ids/i.test(h.textContent || '')); host.insertBefore(box, heading || host.firstChild); const zones = box.querySelector('#falcon-discid-zones'); let mediums = []; try { // the same /ws/2 release lookup Falcon already makes elsewhere, for the // one thing this page does not render: the medium list of a release with // no disc IDs on it yet const j = await mbThrottle.fetchJson(`${MB_ORIGIN}/ws/2/release/${mbid}?inc=media&fmt=json`, undefined, true); mediums = (j && j.media) || []; } catch (e) { zones.innerHTML = `<div class="fd-s fd-err">Could not read this release's media (${esc(e.message)}).</div>`; return; } const ids = scrapeMediumIds(); zones.innerHTML = ''; if (!mediums.length) { zones.innerHTML = '<div class="fd-s">This release has no media.</div>'; return; } mediums.forEach(med => { const pos = med.position || 1; const fmt = med.format || ''; const tracks = med['track-count'] != null ? med['track-count'] : (med.tracks || []).length; const z = document.createElement('div'); z.className = 'fd-zone'; z.dataset.position = String(pos); z.dataset.tracks = String(tracks); if (ids[pos]) z.dataset.mediumId = String(ids[pos]); const title = `${fmt || 'Medium'} ${pos}${med.title ? ' — ' + med.title : ''}`; const ok = mediumMayHaveDiscIds(fmt); z.innerHTML = `<div class="fd-t">${esc(title)}</div>` + `<div class="fd-s">${ok ? `${tracks} track${tracks === 1 ? '' : 's'} · drop a rip log or click to choose` : `${esc(fmt)} cannot have disc IDs`}</div>`; if (!ok) { z.classList.add('off'); zones.appendChild(z); return; } const pick = () => { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.log,.txt,text/plain'; inp.onchange = () => { if (inp.files && inp.files[0]) handleRipLog(z, inp.files[0], mbid); }; inp.click(); }; z.onclick = pick; z.ondragover = e => { e.preventDefault(); z.classList.add('over'); }; z.ondragleave = () => z.classList.remove('over'); z.ondrop = e => { e.preventDefault(); z.classList.remove('over'); const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) handleRipLog(z, f, mbid); }; zones.appendChild(z); }); log('info', `disc IDs: ${mediums.length} medium(s) on this release, ${Object.keys(ids).length} with a known medium id`); } const zoneSay = (z, cls, html) => { z.classList.remove('bad', 'busy'); if (cls) z.classList.add(cls); const s = z.querySelector('.fd-s'); if (s) s.innerHTML = html; }; async function handleRipLog(z, file, mbid) { const pos = +z.dataset.position; z.classList.add('busy'); zoneSay(z, 'busy', `Reading ${esc(file.name)}…`); let r; try { r = await discIdFromLog(file); } catch (e) { log('warn', `disc IDs: ${file.name} could not be read — ${e.message}${e.tried ? ' [' + e.tried.join('; ') + ']' : ''}`); zoneSay(z, 'bad', `<span class="fd-err">${esc(e.message)}</span>`); return; } log('info', `disc IDs: ${file.name} — ${r.format} log, ${r.encoding}, ${r.tracks} track(s)` + `${r.toc.dataTrackDropped ? ', trailing data track dropped' : ''} → ${r.id} (toc ${r.tocString})`); // A mismatch is worth stopping for: a disc ID on the wrong medium is an // edit someone else has to undo. const want = +z.dataset.tracks; if (want && want !== r.tracks) { zoneSay(z, 'bad', `<span class="fd-err">This log has ${r.tracks} track(s), but this medium has ${want}.</span> <a href="#" data-go="1">Attach anyway</a>`); const a = z.querySelector('a[data-go]'); if (a) a.onclick = e => { e.preventDefault(); e.stopPropagation(); goAttach(r, mbid, pos, z.dataset.mediumId); }; return; } zoneSay(z, null, `${esc(r.format)} log · ${r.tracks} tracks · <code>${esc(r.id)}</code> — opening MusicBrainz…`); await goAttachResolved(r, mbid, pos, z.dataset.mediumId, z); } // Known medium id → straight there. Otherwise resolve it first, and only fall // back to MusicBrainz's own picker if that cannot be done honestly. async function goAttachResolved(r, mbid, pos, knownId, z) { let id = knownId; if (!id) { if (z) zoneSay(z, 'busy', 'Finding this medium on MusicBrainz…'); id = await resolveMediumId(mbid, pos, r.tocString); } goAttach(r, mbid, pos, id); } /* Straight to the confirmation when the medium's internal id is known, otherwise to MusicBrainz's own medium picker filtered to this release. The `falcon-medium` parameter is ignored by MusicBrainz and read back by Falcon on that page (see autoPickAttachMedium) to preselect the right one. */ /* majkinetor: "It shows me a page to select MBID, which it shouldn't as Falcon * already knows it… When MBID is added we again have page that can be skipped * - select media - Falcon already knows which media was used." * * Both pages are the same page: /cdtoc/attach?toc=…&filter-release.query=<mbid> * renders the release's mediums as RADIOS named `medium` (measured on the * sandbox: value 1123588, row "CD 3: Know"), and the only thing standing * between that and the confirmation is the internal medium id. So fetch that * page once, read the id off the radio, and go straight to the confirmation. * * ⚠ MusicBrainz lists only the mediums whose track count matches the TOC, so * the one dropped on can legitimately be absent. That returns null and the * caller falls back to showing the page rather than guessing at a medium — * attaching a disc ID to the wrong one is an edit someone else has to undo. */ async function resolveMediumId(mbid, position, tocString) { const url = `${MB_ORIGIN}/cdtoc/attach?toc=${tocString}&filter-release.query=${mbid}`; let html; try { const res = await fetch(url, { credentials: 'include' }); if (!res.ok) { log('warn', `disc IDs: could not read the medium list (HTTP ${res.status})`); return null; } html = await res.text(); } catch (e) { log('warn', `disc IDs: could not read the medium list — ${e.message}`); return null; } const doc = new DOMParser().parseFromString(html, 'text/html'); const radios = [...doc.querySelectorAll('input[name="medium"]')]; if (!radios.length) { log('warn', 'disc IDs: MusicBrainz offered no medium for this TOC — falling back to its own picker'); return null; } const seen = radios.map(i => { const row = i.closest('tr') || i.closest('li') || i.parentElement; const text = ((row && row.textContent) || '').replace(/\s+/g, ' ').trim(); // rows read "CD 3: Know (show tracklist)" — format, then the position const m = /(?:^|\s)(?:CD|Medium|Disc|DVD|Vinyl|Cassette)?\s*(\d+)\s*:/i.exec(text); return { id: i.value, pos: m ? +m[1] : null, text: text.slice(0, 60) }; }); log('info', `disc IDs: MusicBrainz offers ${seen.length} medium(s) for this TOC — ${seen.map(s => `#${s.pos}=${s.id}`).join(', ')}`); const hit = seen.find(s => s.pos === position) // one candidate and no position to read: unambiguous either way || (seen.length === 1 && seen[0].pos == null ? seen[0] : null); if (!hit) { log('warn', `disc IDs: none of the offered mediums is #${position} — letting MusicBrainz ask`); return null; } return hit.id; } function goAttach(r, mbid, position, mediumId) { /* ⚠ The toc parameter is NOT percent-encoded, and that is the whole trick. * MusicBrainz validates it with /\A\d+(?: \d+)*\z/ — SPACE separated * (Entity/CDTOC.pm, new_from_toc). Picard sends `1+13+279873+150+…` because * a `+` in a query string decodes to a space, so Perl sees the spaces it * wants. Running the same string through encodeURIComponent turns each `+` * into `%2B`, which decodes to a literal plus, and MusicBrainz answers * "The provided CD TOC is not valid" — majkinetor hit exactly that. * tocString is digits and plus signs only, so there is nothing here that * needs escaping anyway. */ const base = `${MB_ORIGIN}/cdtoc/attach?toc=${r.tocString}&id=${encodeURIComponent(r.id)}&tracks=${r.tracks}`; const url = mediumId ? `${base}&medium=${mediumId}` : `${base}&filter-release.query=${mbid}&falcon-medium=${position}`; log('info', `disc IDs: opening ${url}`); location.href = url; } /* On the attach page: MusicBrainz lists every medium of the release as its own "attach to this medium" link. Falcon marks the one the drop was aimed at, and follows it when it is unambiguous — the next page is still only a confirmation, so nothing is submitted without the edit being entered by hand. */ function autoPickAttachMedium() { if (!/^\/cdtoc\/attach\/?$/.test(location.pathname)) return; const q = new URLSearchParams(location.search); // The confirmation page — the last stop, where the edit note lives. // majkinetor: "That will then leave the user to the final edit note (where // Falcon should add its own signature too)." if (q.get('medium')) return signAttachEditNote(); const want = q.get('falcon-medium'); if (!want) return; /* ⚠ RADIOS, not links. The first cut looked for a[href*="medium="] and so did nothing at all on this page — which is why majkinetor still had to pick the medium by hand. Measured on the sandbox: the mediums are <input type="radio" name="medium" value="1123588"> in a GET form whose submit button reads "Attach CD TOC". */ const radios = [...document.querySelectorAll('input[type="radio"][name="medium"]')]; if (!radios.length) { console.info(`[${NAME}] disc IDs: no medium to choose on this page`); return; } const rowOf = i => ((i.closest('tr') || i.closest('li') || i.parentElement || {}).textContent || '').replace(/\s+/g, ' ').trim(); const match = radios.find(i => new RegExp(`(?:^|\\s)(?:CD|Medium|Disc|DVD|Vinyl|Cassette)?\\s*${want}\\s*:`, 'i').test(rowOf(i))) || (radios.length === 1 ? radios[0] : null); if (!match) { console.info(`[${NAME}] disc IDs: could not tell which of ${radios.length} mediums is #${want} — pick it yourself`); return; } match.checked = true; match.dispatchEvent(new Event('change', { bubbles: true })); const form = match.closest('form'); // #722 the form's one submit button (MB translates "Attach CD TOC"); its label only among several const submits = form ? [...form.querySelectorAll('button[type="submit"], input[type="submit"]')] : []; const submit = submits.length === 1 ? submits[0] : submits.find(b => /attach/i.test(b.textContent || b.value || '')); if (!submit) { console.info(`[${NAME}] disc IDs: medium #${want} selected — press "Attach CD TOC" to continue`); return; } console.info(`[${NAME}] disc IDs: medium #${want} selected (${match.value}), attaching`); submit.click(); } /* The confirmation page is where MusicBrainz asks for the edit note, and it is the only edit in this flow — Falcon never submits it, so signing the note is the only trace it leaves. Appended, never replacing: anything already typed (or seeded) stays. */ function signAttachEditNote() { const ta = document.querySelector('textarea.edit-note, textarea[name="edit-note"], textarea[name*="edit_note"], #id-edit-note, .edit-note textarea'); if (!ta) { console.info(`[${NAME}] disc IDs: no edit note box on this page to sign`); return; } const sig = `${FALCON_SIGNATURE()}${String.fromCharCode(10)}Disc ID computed from a CD rip log.`; if ((ta.value || '').includes(FALCON_SIGNATURE())) return; // a re-render must not stack them try { const set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; const existing = (ta.value || '').trim(); set.call(ta, existing ? existing + String.fromCharCode(10) + sig : sig); ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); console.info(`[${NAME}] disc IDs: signed the edit note`); } catch (e) { console.info(`[${NAME}] disc IDs: could not sign the edit note — ${e.message}`); } } /* ── waiters (mirrors Platform Check's pcWait/pcWaitFor, retargeted at a frame doc) ── */ function wait(ms) { return new Promise(r => setTimeout(r, ms)); } function waitFor(predicate, timeoutMs) { timeoutMs = timeoutMs || 10000; return new Promise(resolve => { const start = Date.now(); const tick = () => { let v; try { v = predicate(); } catch (e) { v = null; } if (v) return resolve(v); if (Date.now() - start >= timeoutMs) return resolve(null); setTimeout(tick, 150); }; tick(); }); } // accepts EITHER a worker <iframe> element OR a plain window handle (from // window.open — used by the "open in a real tab" manual-review path, #467) — // same-origin either way, so fillAndSubmit's own logic never needs to know which. function frameDoc(target) { try { return ('contentDocument' in target) ? target.contentDocument : (target.document || null); } catch (e) { return null; } } // ⚠ NEVER NAVIGATE THROUGH THE WINDOW THIS RETURNS. // It is deliberately allowed to be the current window — fillAndSubmit is also // driven directly against the page you're on (the "open in a real tab" path, // and several tests). That makes it fine for reading and for filling, and // categorically unsafe for navigating: under a userscript manager's Xray // wrappers these handles are not always what they look like, and a navigation // meant for a worker frame taking the whole tab — panel, workers and queue // with it — is exactly how #467 lost a run. Navigate iframes via `.src`, which // can only ever move the element it belongs to. function frameWin(target) { try { return ('contentWindow' in target) ? target.contentWindow : target; } catch (e) { return null; } } // ⚠ #556: identify the field STRUCTURALLY, never by its placeholder text. // Apollo Editor rewrites MusicBrainz's "Add another link" to "Paste one or more // links" (its multi-link paste hint) and re-applies it from a MutationObserver, // and Apollo has no @noframes — so it runs inside Falcon's edit iframes too. // Platform Check carried the identical lookup and that is what #556 turned out // to be: the field was there all along under a different label. Fixed in both. function findAddLinkInput(doc) { const ed = doc.getElementById('external-links-editor'); if (ed) { const empty = [...ed.querySelectorAll('input[type="url"]')].filter(i => !i.value); if (empty.length) return empty[empty.length - 1]; } const all = [...doc.querySelectorAll('input[type="text"], input[type="url"], input:not([type])')]; const RE = /^(?:add (?:another )?link|add another url|paste one or more links)$/i; return all.find(i => RE.test((i.placeholder || '').trim()) && !i.value) || all.find(i => RE.test((i.placeholder || '').trim())) || null; } function findSubmitButton(doc) { return doc.querySelector('button.submit.positive') || [...doc.querySelectorAll('button')].find(b => /enter edit/i.test(b.textContent || '')); } // #467 (majkinetor): when a url is rejected, MB almost always renders the REAL reason // right on the page (e.g. "This URL is not allowed for artists.", "This relationship // already exists.") — scrape it instead of guessing. Falls back to a generic message // only when MB genuinely didn't show one. // #759: only what MB actually shows. The release editor keeps every validation message // it has in the page, hidden until it applies, so a scrape of all of them read as a wall of // "A release title is required. / You must select an artist… / Tracks are required…" on a // release that was fine. A message on another of its tabs still counts: it is real, only // out of view. function shownOnPage(el) { const win = el.ownerDocument?.defaultView; for (let n = el; n && n.nodeType === 1; n = n.parentElement) { if (n.classList.contains('ui-tabs-panel')) return true; const st = win?.getComputedStyle(n); if (st && (st.display === 'none' || st.visibility === 'hidden')) return false; } return true; } function findFieldError(doc) { const texts = [...doc.querySelectorAll('.error, .field-error')].filter(shownOnPage).map(el => (el.textContent || '').trim()).filter(Boolean); return texts.length ? [...new Set(texts)].join(' / ') : null; } // #514 (majkinetor): a field change (ISRC, disambiguation) Falcon can't // always tell already matches what's stored — unlike a relationship row, // there's no clean "pending" DOM state to diff against beforehand — so // Falcon submits it, and MB's OWN server-side validation catches the // redundant edit and re-renders /edit with this banner instead of // committing. Lives in `.banner.warning-header`, not `.error`/ // `.field-error` — findFieldError() doesn't see it at all. function findNoChangesWarning(doc) { const texts = [...doc.querySelectorAll('.banner.warning-header, .banner')].map(el => (el.textContent || '').trim()); return texts.some(t => /does not make any changes/i.test(t)); } function setEditNote(doc, win, text) { const ta = doc.querySelector('textarea.edit-note, textarea[name="edit-note"], textarea[name="edit_note"], #id-edit-note, .edit-note textarea'); if (!ta) return false; try { const setVal = Object.getOwnPropertyDescriptor(win.HTMLTextAreaElement.prototype, 'value').set; const existing = (ta.value || '').trim(); setVal.call(ta, existing ? existing + '\n' + text : text); ta.dispatchEvent(new win.Event('input', { bubbles: true })); ta.dispatchEvent(new win.Event('change', { bubbles: true })); return true; } catch (e) { return false; } } // #533 follow-up: the release's disambiguation ("Additional information" → // Disambiguation on the Release information tab). Seeding can't reach it (see // DISAMBIGUATABLE), so type it the way a human would: native value setter + // real input/change events. Deliberately DOM-driven rather than poking // MB.releaseEditor's observable directly — the observable lives in the page's // realm, which a userscript sandbox sees through an Xray wrapper in Firefox, // and the KO `value` binding picks a typed change up anyway. // // The read-back afterwards is the part that matters: if MB ever renames the // field, this returns false and the caller reports the item as failed instead // of quietly submitting a form with nothing in it. // MB.releaseEditor lives in the PAGE's realm. Falcon is a @grant-ed userscript, // so in Firefox the sandbox sees the iframe's window through an Xray wrapper // and `win.MB` reads as undefined — `.wrappedJSObject` is what gets past it. // Only ever used to CHECK the editor's state, never as the write path. function releaseEditorApi(win) { try { const w = (win && win.wrappedJSObject) || win; const re = w && w.MB && w.MB.releaseEditor; return (re && re.rootField && typeof re.rootField.release === 'function') ? re : null; } catch (e) { return null; } } // ⚠ The release editor boots asynchronously: the input exists in the markup // well before Knockout binds to it, and binding OVERWRITES whatever is in the // box with the model's value. Typing too early therefore looks like it worked // (the field reads back correctly) and is then silently wiped a moment later — // which is exactly what happened on the first cut of this: Falcon submitted a // form with no change on it, MB created no edit, and the item still reported // 'done'. So wait for the editor, and re-read after a beat to be sure the // value survived binding. // #571 generalised this from the disambiguation-only version: renaming a // release hits exactly the same wall (KO app, seeds ignored) and needs // exactly the same care, so both go through one implementation rather than // a near-copy that could drift on only one side. // field = { sel, ko, label } — DOM selector, observable name, human noun async function setReleaseField(iframe, value, field) { const ready = await waitFor(() => { const d = frameDoc(iframe); if (!d) return null; const el = d.querySelector(field.sel); if (!el) return null; const api = releaseEditorApi(frameWin(iframe)); // when the API is reachable, insist the release itself is loaded; // otherwise fall back to MB's own submit button existing, which the // editor only renders once it has built its tabs if (api) { try { return api.rootField.release() ? true : null; } catch (e) { return null; } } return d.querySelector('#enter-edit') ? true : null; }, 20000); const doc = frameDoc(iframe), win = frameWin(iframe); const input = doc && doc.querySelector(field.sel); if (!ready || !input) return { ok: false, why: `the release editor never finished loading its ${field.label} field` }; if ((input.value || '').trim() === String(value).trim()) return { ok: true, unchanged: true, before: input.value || '' }; const before = input.value || ''; const type = () => { const setVal = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value').set; setVal.call(input, value); input.dispatchEvent(new win.Event('input', { bubbles: true })); input.dispatchEvent(new win.Event('change', { bubbles: true })); }; try { type(); await wait(500); if (input.value !== value) { type(); await wait(700); } // bound late and wiped it — type again } catch (e) { return { ok: false, why: `could not write the field — ${e.message || e}` }; } if (input.value !== value) return { ok: false, why: `the field did not keep the value (reads ${JSON.stringify(input.value)})`, before }; // The field holding the text is not proof the editor noticed. Where the API // is reachable, ask it what it will actually submit. const api = releaseEditorApi(win); if (api) { let staged = null, ko = null; try { ko = api.rootField.release()[field.ko](); } catch (e) {} try { staged = api.allEdits().length; } catch (e) {} if (ko !== null && ko !== value) return { ok: false, why: `the release editor's own state still reads ${JSON.stringify(ko)} — the typed value did not reach it`, before }; if (staged === 0) return { ok: false, why: 'MusicBrainz staged no edit for this change', before }; return { ok: true, before, after: input.value, staged }; } return { ok: true, before, after: input.value, staged: null }; } const setReleaseComment = (iframe, value) => setReleaseField(iframe, value, { sel: '#release-editor #comment, #comment', ko: 'comment', label: 'disambiguation' }); // The release editor's title box. Selector is deliberately broader than the // comment one: `#name` is a likelier id for MB to reuse elsewhere on the page, // so scope to the editor first and only then fall back. const setReleaseName = (iframe, value) => setReleaseField(iframe, value, { sel: '#release-editor #name, #release-editor input[name="name"], #name', ko: 'name', label: 'name' }); // #713: a release item's barcode, typed into the Barcode field. "This release does not have a // barcode" disables the field, so it is unticked first. Like the fields above, the editor binds // late and resets an early write (the box ticked again, the field emptied), so this waits for // the editor, then keeps the barcode there until it holds. A release that has another barcode // by now is left alone: { ok: false, other }. async function setReleaseBarcode(iframe, value) { const digits = v => String(v || '').replace(/\D/g, ''); const bc = digits(value); const ready = await waitFor(() => { const d = frameDoc(iframe); if (!d || !d.getElementById('barcode')) return null; const api = releaseEditorApi(frameWin(iframe)); if (api) { try { return api.rootField.release() ? true : null; } catch (e) { return null; } } return d.querySelector('#enter-edit') ? true : null; }, 20000); const doc = frameDoc(iframe), win = frameWin(iframe); if (!ready || !doc) return { ok: false, why: 'the release editor never finished loading its barcode field' }; await wait(300); const read = () => ({ input: doc.getElementById('barcode'), none: doc.getElementById('no-barcode') }); const first = read(), before = digits(first.input.value); if (before && before !== bc) return { ok: false, other: before, why: `the release has barcode ${before} by now — ${bc} left out` }; if (before === bc && !(first.none && first.none.checked)) return { ok: true, unchanged: true, before }; const setVal = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value').set; const t0 = Date.now(); let since = 0; try { while (Date.now() - t0 < 15000) { const { input, none } = read(), cur = digits(input.value); if (cur && cur !== bc) return { ok: false, other: cur, why: `the release has barcode ${cur} by now — ${bc} left out` }; if (cur === bc && !(none && none.checked) && !input.disabled) { if (!since) since = Date.now(); else if (Date.now() - since >= 1200) break; } else { since = 0; if (none && none.checked) none.click(); input.focus(); setVal.call(input, bc); input.dispatchEvent(new win.Event('input', { bubbles: true })); input.dispatchEvent(new win.Event('change', { bubbles: true })); input.blur(); } await wait(150); } } catch (e) { return { ok: false, why: `could not write the barcode — ${e.message || e}` }; } if (!since) return { ok: false, why: 'the barcode field kept resetting', before }; const api = releaseEditorApi(win); if (api) { let ko = null, staged = null; try { ko = digits(api.rootField.release().barcode.value()); } catch (e) {} try { staged = api.allEdits().length; } catch (e) {} if (ko !== null && ko !== bc) return { ok: false, why: `the release editor's own barcode still reads ${JSON.stringify(ko)}`, before }; if (staged === 0) return { ok: false, why: 'MusicBrainz staged no edit for this barcode', before }; return { ok: true, before, after: bc, staged }; } return { ok: true, before, after: bc, staged: null }; } // #727: catalog numbers, each into the release label row of its label (by MBID) in the KO model, where // the editor keeps it (release().labels()[i].catalogNumber). Only an empty one is filled: a row that has // this catalog number already is unchanged, one with another is left alone. A label the release hasn't // is added only when asked (add): into the empty row the editor shows, else a new one (addReleaseLabel), // as the label's full entity (/ws/js/entity), which the editor needs to save it. { ok, set, unchanged, failed: [why], staged } async function setReleaseCatnos(iframe, labels) { const ready = await waitFor(() => { const api = releaseEditorApi(frameWin(iframe)); try { return api && api.rootField.release() && frameDoc(iframe).getElementById('catno-0') ? api : null; } catch (e) { return null; } }, 20000); if (!ready) return { ok: false, set: 0, unchanged: 0, failed: ['the release editor never finished loading its labels'] }; await wait(300); const norm = c => String(c || '').trim().toLowerCase(); let set = 0, unchanged = 0; const failed = []; const rows = () => { try { return ready.rootField.release().labels() || []; } catch (e) { return []; } }; const rowOf = mbid => rows().find(r => { try { const l = r.label(); return l && l.gid === mbid; } catch (e) { return false; } }); for (const l of labels) { let row = rowOf(l.mbid); if (!row && l.add) { try { const r = await mbnFetch(`${MB_ORIGIN}/ws/js/entity/${l.mbid}`, { credentials: 'same-origin' }); const text = r && r.ok ? await r.text() : null; if (!text || !/"gid"/.test(text)) throw new Error(`MusicBrainz answered ${r ? r.status : 'nothing'} for the label`); const w = (frameWin(iframe).wrappedJSObject) || frameWin(iframe), rel = ready.rootField.release(); row = rows().find(x => { try { const e = x.label(); return !(e && (e.gid || e.name)) && !String(x.catalogNumber() || '').trim(); } catch (e) { return false; } }); if (!row) { ready.addReleaseLabel(rel); row = rows()[rows().length - 1]; } row.label(w.MB.entity(w.JSON.parse(text), 'label')); if (!rowOf(l.mbid)) throw new Error('the editor did not take the label'); if (!l.catno) set++; // with a catalog number, it counts once, below if (!l.catno) continue; } catch (e) { failed.push(`could not add the label ${l.name || l.mbid} — ${e.message || e}`); continue; } } if (!row) { failed.push(`the release has no label ${l.name || l.mbid} by now — cat. no. ${l.catno} left out`); continue; } if (!l.catno) { unchanged++; continue; } const cur = String(row.catalogNumber() || '').trim(); if (norm(cur) === norm(l.catno)) { unchanged++; continue; } if (cur) { failed.push(`${l.name || l.mbid} has cat. no. ${cur} by now — ${l.catno} left out`); continue; } try { row.catalogNumber(l.catno); set++; } catch (e) { failed.push(`could not write cat. no. ${l.catno} — ${e.message || e}`); } } // the editor binds late: read it back after a beat, as the fields above do if (set) { await wait(1200); for (const l of labels) { const row = rowOf(l.mbid); if (!l.catno || failed.some(f => f.includes(l.mbid) || f.includes(l.name || '\0'))) continue; if (!row) { set--; failed.push(`the label ${l.name || l.mbid} didn't hold in the release editor`); } else if (norm(row.catalogNumber()) !== norm(l.catno) && !failed.some(f => f.includes(l.catno))) { set--; failed.push(`cat. no. ${l.catno} didn't hold in the release editor`); } } } let staged = null; try { staged = ready.allEdits().length; } catch (e) {} if (set && staged === 0) return { ok: false, set: 0, unchanged, failed: failed.concat('MusicBrainz staged no edit for the catalog numbers'), staged }; return { ok: !failed.length, set, unchanged, failed, staged }; } // #495: the release editor's own "Enter edit" button lives inside its // jQuery-UI-tabs "Edit note" panel (display:none until that tab is // active) — a bare element.click() on the tab link does nothing (jQuery UI // binds its handler expecting a real event), so dispatch a genuine // MouseEvent instead, exactly as a real click would arrive. No-op (returns // false) on any page that isn't actually tabbed this way. function activateReleaseEditNoteTab(doc) { const nav = doc.querySelector('#release-editor .ui-tabs-nav, #release-editor ul.ui-tabs-nav'); const a = nav && [...nav.querySelectorAll('a')].find(x => (x.getAttribute('href') || '') === '#edit-note'); if (!a) return false; a.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: doc.defaultView })); return true; } // harmonyNote is deliberately NOT re-inserted here: buildSeedEditUrl already // put it in the page's edit_note query param, so MB's own rendering has // already seeded it into the textarea by the time this runs — pushing it // again duplicated the line (#475: "the edit notes repeat the line // 'Matched recording while importing ... with Harmony'"). // #573 (majkinetor): "When I rename several entities, I want to put an // additional message on why that is done, visible on all edits." // // One note for the whole batch, appended to every edit the run produces — // form edits, aliases and cover art alike. Deliberately NOT persisted across // reloads: a reason left over from an earlier batch, silently attaching // itself to unrelated edits, is a worse failure than retyping it. let _batchNote = ''; const batchNote = () => String(_batchNote || '').trim(); const setBatchNote = v => { _batchNote = String(v || ''); }; // Appends the batch note to an already-composed note, keeping it as its own // paragraph so it reads as the human message rather than part of Falcon's // own boilerplate. const withBatchNote = text => { const b = batchNote(); return b ? [text, b].filter(Boolean).join(String.fromCharCode(10, 10)) : text; }; const FALCON_SIGNATURE = () => `${NAME} v${scriptVersion()} by majkinetor - ${HELP_URL}`; const editNoteText = (results) => { // #737: a count, not a list of the links: MB copies this note onto each // link's edit, which shows its own url, so a list repeated on every edit. const added = results.filter(r => r.ok).length; const lines = [FALCON_SIGNATURE()]; // #571/#573: a rename or disambiguation carries no urls; only claim to // have added links when we did. if (added) lines.push('', 'Bulk-added ' + added + ' link' + (added === 1 ? '' : 's') + ' via the Falcon queue'); // The batch note is added HERE rather than in the seed url: setEditNote // appends to whatever seeding already put in the box, and this runs for // every type including release, whose KO editor ignores seeded fields. // Doing both is how #475 ended up with duplicated lines. return withBatchNote(lines.join(String.fromCharCode(10))); }; // MB shows the relationship type as a plain read-only label when there's only one // valid type for that url (nothing to set), or a <select class="link-type"> when // ambiguous (e.g. Bandcamp: streaming vs purchase). When Harmony hands us an // explicit linkTypeId, honor it whenever a select is actually present. async function setRowLinkType(iframe, row, linkTypeId) { if (!linkTypeId) return null; const typeRow = row.nextElementSibling; const select = typeRow?.classList?.contains('relationship-item') ? typeRow.querySelector('select.link-type') : null; if (!select) return null; // unambiguous — MB already resolved it, nothing to override if (![...select.options].some(o => o.value === String(linkTypeId))) return false; const w = frameWin(iframe); const setSel = Object.getOwnPropertyDescriptor(w.HTMLSelectElement.prototype, 'value').set; setSel.call(select, String(linkTypeId)); select.dispatchEvent(new w.Event('change', { bubbles: true })); return true; } // Harmony sometimes wants TWO relationship types on the IDENTICAL url (e.g. a // Bandcamp track as both "stream for free" and "purchase for download") — MB // supports this via the row's own "Add another relationship" control, not by // re-typing the url again (that wouldn't create a second row at all). async function addSecondRelationshipType(iframe, row, linkTypeId) { if (!linkTypeId) return false; const typeRow = row.nextElementSibling; if (!typeRow || !typeRow.classList?.contains('relationship-item')) return false; const addRow = typeRow.nextElementSibling; const addBtn = addRow?.classList?.contains('add-relationship') ? addRow.querySelector('button.add-item') : null; if (!addBtn) return false; addBtn.click(); const newSelect = await waitFor(() => { const s = typeRow.nextElementSibling; if (!s || !s.classList?.contains('relationship-item')) return null; const sel = s.querySelector('select.link-type'); return (sel && !sel.value) ? sel : null; }, 3000); if (!newSelect || ![...newSelect.options].some(o => o.value === String(linkTypeId))) return false; const w = frameWin(iframe); const setSel = Object.getOwnPropertyDescriptor(w.HTMLSelectElement.prototype, 'value').set; setSel.call(newSelect, String(linkTypeId)); newSelect.dispatchEvent(new w.Event('change', { bubbles: true })); return true; } // fill every URL in item.urls (one "Add another link" round-trip each, or a second // relationship type on an already-present url) then submit ONCE for the whole // group, all directly against the iframe's OWN document/window (same-origin — no // postMessage needed, see #467). A url that MB rejects (e.g. already present) // doesn't abort the rest — it's recorded in the returned per-url results and the // others still go in. Only truly infra-level failures (no submit button, never // redirected) throw; "nothing to submit" is signalled via `committed: false`, not // a throw, since it isn't necessarily an error (every url in the group may simply // already be on the entity). // #467 (majkinetor): after a url row exists (whether just typed, or already // pre-filled by MB's own seed-url params — see buildSeedEditUrl/workerLoop), // this is the shared "is it actually usable" check: a genuinely-duplicate url // MB rejected (real .error/.field-error text) or a REQUIRED relationship-type // <select> still sitting blank because Falcon had no linkTypeId to give it — // either one, left in place, invalidates the WHOLE form's submit button, not // just this row. Removes the row and returns a clear, actionable reason; // returns null when the row is fine as-is. // Finds the row for a url, in EITHER state MB can leave it in: // - resolved: an <a href> link row (typed-and-classified, or seeded and // successfully auto-classified) // - unresolved: still an editable <input> holding the url text, which is // what a SEEDED url MB couldn't classify looks like (confirmed live: a // bandcamp track seeded onto a recording renders as an input row with a // blank required select and NO href). Matching only on `a[href]` missed // these entirely — the blank select then silently disabled submit for the // whole group while Falcon re-typed a duplicate of the same url. // #759: MB cleans a url as it seeds or takes it, so its row can show a shorter form than // the one Falcon asked for: Apple Music drops the name (…/us/album/demonic-single/6821820610 // shows as …/us/album/6821820610), and tracking parameters, www and a trailing slash go. // `shown` is the same link when it is `asked` with only parts left out: the same host, the // same last path segment (the id), its path segments in the asked one's in order, and its // query a subset of the asked one's. function sameLink(shown, asked) { if (!shown || !asked) return false; if (shown === asked) return true; let a, b; try { a = new URL(shown); b = new URL(asked); } catch (e) { return false; } const host = u => u.hostname.toLowerCase().replace(/^www\./, ''); if (host(a) !== host(b)) return false; const segs = u => u.pathname.split('/').filter(Boolean); const sa = segs(a), sb = segs(b); if (sa[sa.length - 1] !== sb[sb.length - 1]) return false; let i = 0; for (const x of sb) if (i < sa.length && sa[i] === x) i++; if (i < sa.length) return false; for (const [k, v] of a.searchParams) if (b.searchParams.get(k) !== v) return false; return true; } const rowHref = tr => tr.querySelector('a[href]')?.getAttribute('href') || ''; const rowInput = tr => tr.querySelector('input')?.value || ''; const rowIsUrl = (tr, url) => sameLink(rowHref(tr), url) || sameLink(rowInput(tr), url); function findRowForUrl(doc, url) { if (!doc) return null; const rows = [...doc.querySelectorAll('tr.external-link-item')]; return rows.find(tr => sameLink(rowHref(tr), url)) || rows.find(tr => sameLink(rowInput(tr), url)) || null; } // Both states at once, for a url that can legitimately have TWO rows: when a // url is ALREADY on the entity and we also seed it, MB keeps the existing // resolved row AND adds our seeded copy as a separate unresolved input row. // That extra row's blank required select disables submit for the whole group, // so it has to be found and dropped even though the "real" row looks fine. function findRowsForUrl(doc, url) { const rows = doc ? [...doc.querySelectorAll('tr.external-link-item')] : []; const match = rows.filter(tr => rowIsUrl(tr, url)); return { resolved: match.find(tr => !!tr.querySelector('a[href]')) || null, unresolved: match.filter(tr => !tr.querySelector('a[href]') && !!tr.querySelector('input')), }; } // Every relationship-type row hanging off `url`'s row(s) — MB renders one // `tr.relationship-item` per type directly after the `tr.external-link-item` // it belongs to. Used to tell whether a type Falcon wants is ALREADY on the // page (seeded by MB itself) before reaching for the DOM-poking fallback. function typeRowsForUrl(doc, url) { const rows = doc ? [...doc.querySelectorAll('tr.external-link-item')] : []; const owners = rows.filter(tr => rowIsUrl(tr, url)); const out = []; owners.forEach(owner => { let n = owner.nextElementSibling; while (n && n.classList?.contains('relationship-item')) { out.push(n); n = n.nextElementSibling; } }); return out; } const hasTypeForUrl = (doc, url, linkTypeId) => typeRowsForUrl(doc, url) .some(tr => tr.querySelector('select.link-type')?.value === String(linkTypeId)); // Final safety net before submit. Seeding a url that's ALREADY on the entity // makes MB attach a SECOND, blank relationship row under that url's existing // row (its own dual-relationship mechanism) rather than adding a new url row // — confirmed live. That blank required select disables submit for the WHOLE // form, and it isn't reachable by url lookup (the row carries no href of its // own), so per-url checks can't see it. Sweeps every still-blank type select, // removes its relationship row, and reports which url it belonged to (found // by walking back to the owning external-link-item). function sweepBlankTypeRows(doc) { if (!doc) return []; const dropped = []; [...doc.querySelectorAll('select.link-type')].filter(s => !s.value).forEach(sel => { const relRow = sel.closest('tr'); if (!relRow) return; let owner = relRow.previousElementSibling; while (owner && !owner.classList.contains('external-link-item')) owner = owner.previousElementSibling; const url = owner?.querySelector('a[href]')?.getAttribute('href') || owner?.querySelector('input')?.value || null; const removeBtn = relRow.querySelector('button.remove-item') || relRow.querySelector('button.remove-button'); if (removeBtn) { removeBtn.click(); dropped.push(url); } }); return dropped; } // ⚠ NEVER remove a row that represents a PRE-EXISTING relationship. // #467: checkRowUsable used to click "remove" on any row MB complained about // — but when the complaint is "This relationship already exists", that row is // REAL EXISTING DATA, and removing it stages a DELETION. Caught live on // majkinetor's failing recording: a re-run over an already-imported entity had // three `.rel-remove` markers staged against its qobuz relationship. It never // reached the server (those runs all timed out), but a submit would have // destroyed existing links. MB marks rows WE added with `rel-add`, so only // those are ours to take back; anything unmarked pre-dates us — report the // url as failed and leave the row strictly alone. function isOurs(row) { if (!row) return false; if (row.querySelector('.rel-add') || /\brel-add\b/.test(row.className)) return true; const next = row.nextElementSibling; return !!(next && next.classList?.contains('relationship-item') && next.querySelector('.rel-add')); } function removeIfOurs(row) { if (!isOurs(row)) return false; row.querySelector('button.remove-item')?.click(); return true; } function checkRowUsable(doc, row, linkTypeId) { const typeRow = row.nextElementSibling; // scoped to just THIS row + its own type-row sibling — a real entity can // easily carry OTHER, unrelated .error/.field-error text elsewhere on the // page (e.g. from a genuinely pre-existing relationship that has nothing // to do with what was just seeded); a page-wide findFieldError() scrape // here misattributed those to whichever url happened to be checked next. const scope = [row, typeRow].filter(Boolean); const errAlready = scope.map(el => [...el.querySelectorAll('.error, .field-error')].map(e => (e.textContent || '').trim()).filter(Boolean).join(' / ')).filter(Boolean).join(' / '); // a blank REQUIRED type select is checked FIRST: MB's own text for it // ("Please select a link type...") says what's wrong but not what to do // about it, so this reports the actionable version instead. Any OTHER MB // error (duplicate link, url not allowed for this entity type...) is // reported verbatim below — MB says it better than a guess would. const ambiguousSelect = typeRow?.classList?.contains('relationship-item') ? typeRow.querySelector('select.link-type') : null; if (!linkTypeId && ambiguousSelect && !ambiguousSelect.value) { removeIfOurs(row); return 'ambiguous relationship type — MusicBrainz needs you to pick one; use ⇗ "open in tab" to add this url manually'; } if (errAlready) { removeIfOurs(row); return errAlready; } return null; } // Every external link present BEFORE Falcon touches the form. Falcon only // ever adds, so every one of these must still be there at submit time. This // is checked structurally — by url — independently of MB's rel-* classes, so // the protection can't be silently defeated by a markup change on MB's side. function snapshotExistingUrls(doc) { if (!doc) return []; return [...doc.querySelectorAll('tr.external-link-item')] .map(tr => tr.querySelector('a[href]')?.getAttribute('href')) .filter(Boolean); } // #671: one platform page under any of its url forms — MusicBrainz keeps a Qobuz or Apple // Music link with whatever locale it was entered in (gb-en, us-en, /gb/, /us/; open.qobuz.com), // so an exact-url check misses the page already being there. null: no known equivalence. function platformPageKey(url) { let u; try { u = new URL(url); } catch (e) { return null; } const h = u.hostname.replace(/^www\./, ''), p = u.pathname; let m; if (/(^|\.)qobuz\.com$/.test(h) && (m = p.match(/\/(interpreter|artist|label)\/(?:.*\/)?(\d+)\/?$/))) return `qobuz:${m[1] === 'label' ? 'label' : 'artist'}:${m[2]}`; if (/^(music|itunes|geo\.music)\.apple\.com$/.test(h) && (m = p.match(/\/(artist|label)\/(?:[^/]*\/)?(?:id)?(\d+)\/?$/))) return `apple:${m[1]}:${m[2]}`; if (/(^|\.)deezer\.com$/.test(h) && (m = p.match(/\/(artist|label)\/(\d+)\/?$/))) return `deezer:${m[1]}:${m[2]}`; return null; } // A link already on the entity (not one Falcon seeded) that is the same platform page as // `url` in another form; its href, or null function equivalentExistingUrl(doc, url) { const key = platformPageKey(url); if (!key || !doc) return null; for (const tr of doc.querySelectorAll('tr.external-link-item')) { const href = tr.querySelector('a[href]')?.getAttribute('href') || ''; if (href && href !== url && !isOurs(tr) && platformPageKey(href) === key) return href; } return null; } async function fillAndSubmit(iframe, item, opts) { const skipSubmit = !!(opts && opts.skipSubmit); const tag = (opts && opts.tag) || '[w?]'; const baseline = (opts && opts.baseline) || snapshotExistingUrls(frameDoc(iframe)); const results = []; dbg(tag, `fillAndSubmit start — ${item.urls.length} url(s), skipSubmit=${skipSubmit}, ${baseline.length} pre-existing link(s) to preserve`); const tFillStart = Date.now(); // workerLoop navigates straight to buildSeedEditUrl(item) — MB's own seed-url // params (the same ones Harmony uses) pre-fill the FIRST occurrence of each // distinct url as the page renders, no typing needed at all (majkinetor, // #467: "why you didn't use URL params... it should basically be instant"). // MB collapses a REPEAT occurrence of the identical url text into that same // one row, though, so a dual-relationship-type group (the same url twice // with two different linkTypeIds) still needs its second occurrence added // by hand via "Add another relationship" — occurrence-tracked below. const seen = new Map(); for (const { url, linkTypeId } of item.urls) { const occurrence = seen.get(url) || 0; seen.set(url, occurrence + 1); try { const doc0 = frameDoc(iframe); // #671: the same page is already on the entity under another locale or form — adding // this one would only duplicate it, so take back what was seeded for it const same = occurrence === 0 ? equivalentExistingUrl(doc0, url) : null; if (same) { const { resolved: sr, unresolved: su } = findRowsForUrl(doc0, url); [sr, ...su].filter(Boolean).forEach(removeIfOurs); await wait(150); dbg(tag, `url[0] ${url} — the same page is already on the entity as ${same}; not added`); results.push({ url, ok: false, present: true, error: `already on MusicBrainz as ${same}` }); continue; } const existingRow = findRowForUrl(doc0, url); dbg(tag, `url[${occurrence}] ${url} (type=${linkTypeId || 'auto'}) — preexisting row: ${existingRow ? (existingRow.querySelector('a[href]') ? 'resolved' : 'unresolved-input') : 'none'}`); if (existingRow && occurrence > 0) { // Normal (seeded) path: buildSeedEditUrl gave this url+type its own // slot, so MB has already rendered the second type and there is // nothing to do. Only a non-seeded caller (fillAndSubmit driven // directly, as the tests do) still needs the click-and-poke fallback. if (linkTypeId && hasTypeForUrl(doc0, url, linkTypeId)) { dbg(tag, ` repeat occurrence -> type ${linkTypeId} already seeded by MB, nothing to do`); results.push({ url, ok: true }); continue; } const added = await addSecondRelationshipType(iframe, existingRow, linkTypeId); dbg(tag, ` repeat occurrence -> addSecondRelationshipType = ${added}`); results.push({ url, ok: added, error: added ? undefined : (linkTypeId ? 'could not add a second relationship type — this url is already present' : 'this url is already present') }); continue; } if (existingRow) { // First occurrence, row(s) already present — seeded by us, or // genuinely already on the entity before Falcon touched it, or BOTH // (in which case MB keeps the pre-existing resolved row and adds our // seeded copy as a separate unresolved one). const { resolved, unresolved } = findRowsForUrl(doc0, url); dbg(tag, ` rows: resolved=${!!resolved} unresolved=${unresolved.length}`); if (resolved && unresolved.length) { // the url was already on this entity — our seeded duplicate is just // dead weight whose blank select would block the whole submit. unresolved.forEach(removeIfOurs); await wait(150); dbg(tag, ` already on entity -> dropped ${unresolved.length} seeded duplicate row(s)`); results.push({ url, ok: false, present: true, error: 'this url is already present on the entity' }); continue; } const target = resolved || unresolved[0] || existingRow; // applying a known linkTypeId also RESOLVES a row MB left unclassified; // without one, checkRowUsable decides whether the row is committable // as-is and removes+reports it when it isn't, so one bad row can't // silently disable submit for the entire group. // Only type a row WE added. Re-typing a PRE-EXISTING relationship // would rewrite curated data — caught live: a re-run was staging a // rel-edit that changed an existing qobuz 'license' relationship to // the type Harmony suggested. Falcon adds links; it doesn't // reinterpret ones an editor already classified. if (linkTypeId && isOurs(target)) { const set = await setRowLinkType(iframe, target, linkTypeId); dbg(tag, ` setRowLinkType(${linkTypeId}) = ${set}`); } else if (linkTypeId) { dbg(tag, ` leaving type alone — this relationship already existed before Falcon touched it`); } const reason = checkRowUsable(doc0, target, linkTypeId); dbg(tag, ` checkRowUsable -> ${reason ? 'REJECT: ' + reason : 'ok'}`); if (reason) { results.push({ url, ok: false, error: reason }); continue; } results.push({ url, ok: true }); continue; } // not pre-filled at all — type it, which runs MB's own url classifier. dbg(tag, ' not seeded — falling back to typing it'); const input = await waitFor(() => frameDoc(iframe) && findAddLinkInput(frameDoc(iframe)), 12000); if (!input) { dbg(tag, ' no "Add another link" input appeared within 12s'); results.push({ url, ok: false, error: 'no "Add another link" input ever appeared' }); continue; } const w2 = frameWin(iframe); const setVal = Object.getOwnPropertyDescriptor(w2.HTMLInputElement.prototype, 'value').set; input.focus(); setVal.call(input, url); input.dispatchEvent(new w2.Event('input', { bubbles: true })); input.dispatchEvent(new w2.Event('change', { bubbles: true })); input.dispatchEvent(new w2.KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true })); input.dispatchEvent(new w2.KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true })); input.blur(); const row = await waitFor(() => { const dd = frameDoc(iframe); if (!dd) return null; const r = [...dd.querySelectorAll('tr.external-link-item')].find(tr => sameLink(rowHref(tr), url)); return r || null; }, 8000); if (!row) { const mbError = findFieldError(frameDoc(iframe)); dbg(tag, ` typed row never appeared within 8s — MB said: ${mbError || '(nothing)'}`); results.push({ url, ok: false, error: mbError || 'URL row never appeared after Enter (MB gave no specific reason)' }); continue; } if (linkTypeId) await setRowLinkType(iframe, row, linkTypeId); const reason = checkRowUsable(frameDoc(iframe), row, linkTypeId); dbg(tag, ` typed ok, checkRowUsable -> ${reason ? 'REJECT: ' + reason : 'ok'}`); if (reason) { results.push({ url, ok: false, error: reason }); continue; } results.push({ url, ok: true }); } catch (e) { dbg(tag, ` threw: ${e.message || e}`); results.push({ url, ok: false, error: e.message || String(e) }); } } // any type select still blank at this point would silently disable submit // for the whole form — drop those rows and demote the urls they belonged to // (see sweepBlankTypeRows) so the rest of the group can still commit. const swept = sweepBlankTypeRows(frameDoc(iframe)); if (swept.length) { await wait(200); dbg(tag, `blank-select sweep removed ${swept.length} row(s): ${JSON.stringify(swept)}`); swept.forEach(sweptUrl => { const hit = results.find(r => r.ok && (sweptUrl === null || sameLink(sweptUrl, r.url))); // #671: MusicBrainz offers a blank second type only on a url the entity already has // (the seed folded into its existing row), so this one is there already: nothing to do if (hit) { hit.ok = false; hit.present = true; hit.error = 'this url is already present on the entity'; } }); } dbg(tag, `per-url outcome: ${JSON.stringify(results.map(r => ({ u: r.url.slice(-40), ok: r.ok, e: r.error })))}`); // #474: a recording queued with a comment/isrc (with or without urls) has // something to submit even when no url in this group committed — MB's // comment field and its ISRC list are plain fields with no .rel-add-style // "this changed" marker at all (checked MB's own source: comment is a bare // <input>, ISRC is a React FormRowTextList whose rows look identical // whether seeded or pre-existing), so there's no reliable way to tell // "genuinely new" from "already there" without an extra fetch per item. // Trusting the sender here matches majkinetor's own scoping for this // feature ("sender is responsible for filling it") — Falcon doesn't try // to be clever about it, same as it doesn't verify a url is really new // before offering to add it. // #495 (majkinetor, live: a release item whose url got rejected still // attempted to submit — "worker now has to set links for the submit // button to appear" turned out to be the actual signal: MB's release // editor gets stuck showing fabricated new-release validation errors // specifically when asked to submit a form with NOTHING genuinely // changed on it). `comment`/`isrcs` are only ever seeded into the form // for entityType 'recording' (see buildSeedEditUrl) — a release item's // `comment` means its cover-art image comment (#494), never submitted // here at all, so it must not count as "something to submit" for THIS // form. // A release's disambiguation is the one field that is not already in the // page by the time we get here (it can't be seeded), so type it now — // before the has-anything-changed checks below, which must see it. let releaseCommentSet = false; if (item.entityType === 'release' && (item.disambiguation || '').trim()) { const r = await setReleaseComment(iframe, item.disambiguation.trim()); releaseCommentSet = !!r.ok; if (r.ok && r.unchanged) dbg(tag, `disambiguation already reads ${JSON.stringify(r.before)} — nothing to change`); else if (r.ok) dbg(tag, `disambiguation typed into the release editor: ${JSON.stringify(r.before)} → ${JSON.stringify(r.after)}${r.staged != null ? ` (MB staged ${r.staged} edit(s))` : ''}`); else dbg(tag, `DISAMBIGUATION NOT SET — ${r.why}`); if (!r.ok) results.push({ url: '(disambiguation)', ok: false, error: r.why }); // "already reads that" is not a change; let the no-op path below catch it // rather than submitting a form MB will reject as changing nothing. if (r.unchanged) releaseCommentSet = false; } // #571: a release rename is in the same boat as its disambiguation — the KO // editor ignores the seed, so it has to be typed in here, before the // has-anything-changed checks below can see it. let releaseNameSet = false; if (item.entityType === 'release' && (item.rename || '').trim()) { const r = await setReleaseName(iframe, item.rename.trim()); releaseNameSet = !!r.ok; if (r.ok && r.unchanged) dbg(tag, `name already reads ${JSON.stringify(r.before)} — nothing to change`); else if (r.ok) dbg(tag, `name typed into the release editor: ${JSON.stringify(r.before)} → ${JSON.stringify(r.after)}${r.staged != null ? ` (MB staged ${r.staged} edit(s))` : ''}`); else dbg(tag, `NAME NOT SET — ${r.why}`); if (!r.ok) results.push({ url: '(name)', ok: false, error: r.why }); if (r.unchanged) releaseNameSet = false; } // #713: the barcode, the same way: typed in here, where the KO editor keeps it let releaseBarcodeSet = false; if (item.entityType === 'release' && item.barcode) { const r = await setReleaseBarcode(iframe, item.barcode); releaseBarcodeSet = !!r.ok && !r.unchanged; if (r.ok && r.unchanged) dbg(tag, `barcode already reads ${r.before} — nothing to change`); else if (r.ok) log('info', `${tag} barcode typed into the release editor: ${r.before || '(none)'} → ${r.after}${r.staged != null ? ` (MB staged ${r.staged} edit(s))` : ''}`); else log('warn', `${tag} BARCODE NOT SET — ${r.why}`); if (!r.ok) results.push({ url: '(barcode)', ok: false, error: r.why }); } // #727: catalog numbers, into the release's own label rows let releaseCatnosSet = false; if (item.entityType === 'release' && (item.labels || []).length) { const r = await setReleaseCatnos(iframe, item.labels); releaseCatnosSet = r.set > 0; if (r.set) log('info', `${tag} ${r.set} label(s) or catalog number(s) filled in the release editor: ${item.labels.map(l => `${l.add ? '+' : ''}${l.name || l.mbid} ${l.catno}`).join(', ')}${r.staged != null ? ` (MB staged ${r.staged} edit(s))` : ''}`); if (r.unchanged) dbg(tag, `${r.unchanged} catalog number(s) already there — nothing to change`); r.failed.forEach(why => { log('warn', `${tag} CATALOG NUMBER NOT SET — ${why}`); results.push({ url: '(catalog number)', ok: false, error: why }); }); } const hasFieldChange = !!( (COMMENT_SEEDS.has(item.entityType) && item.disambiguation) || releaseCommentSet || (NAME_SEEDS.has(item.entityType) && item.rename) || releaseNameSet || releaseBarcodeSet || releaseCatnosSet || (item.entityType === 'recording' && item.isrcs && item.isrcs.length) || (item.entityType === 'recording' && item.video) ); // #671: every url already on the entity — the state asked for holds, nothing to submit if (!hasFieldChange && results.length && results.every(r => r.present)) { dbg(tag, 'NOT SUBMITTING — every url is already on the entity'); return { committed: false, results, noop: true, fillMs: Date.now() - tFillStart }; } if (!results.some(r => r.ok) && !hasFieldChange) { dbg(tag, 'NOT SUBMITTING — no url in this group ended up committable, and no disambiguation/isrc queued'); return { committed: false, results, fillMs: Date.now() - tFillStart }; } // #467 (majkinetor, "still fails if not shown" — actually nothing to do with // visibility): when every url is ALREADY on the entity with the right type, // nothing changed and MB has no edit to create. It leaves "Enter edit" // enabled regardless, so clicking it silently does nothing and Falcon sat // out the full 50s before reporting "never redirected" — on a batch that was // in fact already up to date. MB marks pending changes on the rows // themselves, so ask it directly rather than submitting into the void. // Verified live on his exact failing recording: 0 markers, submit enabled, // click does nothing. // ⚠ Falcon only ever ADDS links. A staged removal means something went // wrong on our side (see isOurs/removeIfOurs) and submitting would destroy // existing data, so refuse outright rather than risk it — a failed item is // recoverable, a deleted relationship is not. // Two independent checks, because this is the one failure mode that // destroys data rather than merely failing: // 1. MB's own marker for a staged removal. // 2. Structural — did any link that existed before we started disappear? // Survives MB renaming its CSS classes, which check 1 would not. const doomed = (frameDoc(iframe)?.querySelectorAll('.rel-remove') || []).length; const nowUrls = new Set(snapshotExistingUrls(frameDoc(iframe))); const vanished = baseline.filter(u => !nowUrls.has(u)); if (doomed || vanished.length) { const why = doomed ? `${doomed} relationship removal(s) staged` : `${vanished.length} pre-existing link(s) vanished from the form: ${vanished.slice(0, 3).join(', ')}`; dbg(tag, `REFUSING TO SUBMIT — ${why}. Falcon only ever adds links, so this form is not safe to commit.`); return { committed: false, results: results.map(r => r.ok ? { ...r, ok: false, error: `not submitted — ${why}; Falcon never removes links, so it refused to commit this form` } : r), fillMs: Date.now() - tFillStart, }; } const pending = (frameDoc(iframe)?.querySelectorAll('.rel-add, .rel-edit') || []).length; if (!pending && !hasFieldChange) { dbg(tag, 'NOT SUBMITTING — MB shows no pending change; every url is already on the entity with this type'); return { committed: false, results, noop: true, fillMs: Date.now() - tFillStart }; } dbg(tag, `${pending} pending relationship change(s) staged` + (hasFieldChange ? ' + disambiguation/isrc queued' : '')); const d2 = frameDoc(iframe), w2 = frameWin(iframe); const noteSet = setEditNote(d2, w2, editNoteText(results)); dbg(tag, `edit note set = ${noteSet}`); // #495: unlike artist/label/recording's single-purpose edit page, the // release editor is tabbed (Release information / Tracklist / Recordings // / Edit note) and keeps its own "Enter edit" button (#enter-edit) inside // the Edit note tab panel, display:none until that tab is genuinely // activated — verified live: a bare .click() doesn't trigger jQuery UI's // tab handler, but dispatching a real MouseEvent on the tab's own <a // href="#edit-note"> does. Filling the External Links section above // happens on the default-active Information tab regardless, so this only // needs to run once, right before we go looking for the submit button. if (item.entityType === 'release') activateReleaseEditNoteTab(d2); await wait(150); // manual-review path (openInTab, #467): fill the form and stop here — a human // reviews and clicks "Enter edit" themselves, exactly like a Harmony tab. if (skipSubmit) return { committed: false, results, manual: true, fillMs: Date.now() - tFillStart }; const btn = findSubmitButton(frameDoc(iframe)); if (!btn) { dbg(tag, 'NOT SUBMITTING — no submit button found on the page'); throw new Error('no submit button found'); } if (btn.disabled) { const reason = findFieldError(frameDoc(iframe)); const blanks = [...(frameDoc(iframe)?.querySelectorAll('select.link-type') || [])].filter(s => !s.value).length; dbg(tag, `NOT SUBMITTING — submit disabled; page errors: ${reason || '(none)'}; still-blank type selects: ${blanks}`); // #495 (majkinetor, live on both test.musicbrainz.org and production): a // known MusicBrainz client bug — the release editor's own validation // can report "a release title is required" etc. on an EXISTING release // that plainly has one (checked live: window.MB.releaseEditor's actual // data model has the real title/tracklist at the same moment the // validator claims none of it exists — a genuine MB-side state // disconnect, not bad data). Not something Falcon can route around // reliably (no interaction reproducibly clears it), so just name it // plainly instead of leaving a wall of "add a medium"/"track titles // required" text that reads like this release is actually broken. const knownBug = item.entityType === 'release' && /a release title is required/i.test(reason || ''); const note = knownBug ? ' (this looks like a known MusicBrainz client bug on an otherwise-fine release, not bad data — retry, or use ⇗ to finish it by hand)' : ''; throw new Error((reason ? `submit button disabled — ${reason}` : 'submit button disabled (form invalid?)') + note); } // #467 (majkinetor): "One didn't pass, there was nothing in worker that was // regarded as error and Enter button was enabled, with all links and types // set." A valid form with an enabled button that still never submits points // at the CLICK, not validation — and MB's editor is React: setting the edit // note just above re-renders, which can DETACH the button node found // earlier. Clicking a detached node silently does nothing, and the only // symptom is the 25s "never redirected" timeout on a form that looks // perfectly fine. So: re-query the button immediately before every click, // never reuse a stale reference, and if the page hasn't moved shortly after // a click, re-query and try again (falling back to submitting the form // directly) rather than waiting out the whole timeout on one lost click. // ⚠ Retry timing is load-bearing. A real submit on a busy MB routinely takes // 2-7s (measured on majkinetor's own runs: 2366 / 2390 / 3872 / 6392 / // 6557ms). An earlier version gave the FIRST attempt only a 4s window and // then clicked again — which fired a second submit while the first was // still in flight, and double-submitting this form leaves it wedged on // /edit. That made things strictly worse: entire batches of 3 failed // together, every time, until the queue thinned out. So the first click now // gets the FULL patient wait, exactly as it did before retries existed; // re-clicking only happens after the page has demonstrably gone nowhere for // that whole time, when nothing can still be in flight to disturb. const tSubmit = Date.now(); const fillMs = tSubmit - tFillStart; const MAX_CLICKS = 2; const pageLeft = () => { const w = frameWin(iframe); if (!w) return null; try { return /\/edit(?:[?#]|$)/.test(w.location.pathname) ? null : true; } catch (e) { return null; } }; // #514 (majkinetor): a field-change edit (ISRC/disambiguation) that // exactly matches what's already stored gets rejected server-side with // a "does not make any changes" banner — MB re-renders /edit itself // rather than redirecting away, so plain pageLeft() polling would sit // through the full 25s timeout waiting for a navigation that was never // coming, then get classified as a hard failure. Poll for the banner // too, so this resolves as fast as the redirect case does. const noChangesShown = () => { const d = frameDoc(iframe); if (!d) return null; try { return findNoChangesWarning(d) ? true : null; } catch (e) { return null; } }; let left = null, noChanges = false; for (let attempt = 1; attempt <= MAX_CLICKS && !left && !noChanges; attempt++) { const doc = frameDoc(iframe); const fresh = findSubmitButton(doc); if (!fresh) { dbg(tag, `submit attempt ${attempt}: button vanished from the page`); break; } if (fresh.disabled) { dbg(tag, `submit attempt ${attempt}: button went disabled — ${findFieldError(doc) || '(no message)'}`); break; } dbg(tag, `submit attempt ${attempt}: clicking${fresh !== btn ? ' (button node was replaced since it was first found)' : ''}`); fresh.click(); await waitFor(() => pageLeft() || noChangesShown(), 25000); left = pageLeft(); noChanges = !left && !!noChangesShown(); if (!left && !noChanges) dbg(tag, `submit attempt ${attempt}: page still on /edit after 25s`); } if (noChanges) { dbg(tag, `MB says this submit changes nothing — treating as already up to date, not a failure`); return { committed: false, results, noop: true, fillMs, submitMs: Date.now() - tSubmit }; } if (!left) { let where = '(unreadable)', pageErrs = '(unreadable)', btnState = '(unreadable)'; try { const w = frameWin(iframe), d = frameDoc(iframe); where = w.location.href; pageErrs = findFieldError(d) || '(none)'; const b = findSubmitButton(d); btnState = b ? `found, disabled=${b.disabled}` : 'gone'; } catch (e) {} dbg(tag, `SUBMIT DID NOT LAND — ${Date.now() - tSubmit}ms, ${MAX_CLICKS} attempt(s); button: ${btnState}; MB errors: ${pageErrs}; frame url: ${where}`); throw new Error(`never redirected off /edit after submit${pageErrs && pageErrs !== '(none)' ? ' — MB says: ' + pageErrs : ''}`); } dbg(tag, `submit landed in ${Date.now() - tSubmit}ms`); return { committed: true, results, fillMs, submitMs: Date.now() - tSubmit }; } // never hand out a queued item for an entity that's ALREADY being worked on by // another iframe — closes the race a later-added item for the same mbid would // otherwise open (grouping at add-time only covers items still queued at that // moment; this covers the rest). function nextQueued() { const activeKeys = new Set(queue.filter(i => i.status === 'active').map(i => i.entityType + ':' + i.mbid)); // #497: a toggled-off type's queued items sit out this run entirely — // still in the queue, just never handed to a worker while off. return queue.find(i => i.status === 'queued' && !_disabledTypes.has(i.entityType) && !activeKeys.has(i.entityType + ':' + i.mbid)); } // #730: MusicBrainz's special-purpose entities — Various Artists, [anonymous], [data], [dialogue], // [no artist], [traditional], [unknown] and the label [no label]. Since MBS-14146 (musicbrainz-server // #3626) a regular editor can't edit any of them: the edit page never loads. The list is MusicBrainz's // own, root/static/scripts/common/constants.js (SPECIAL_ARTIST_GIDS, NOLABEL_GID). const SPECIAL_PURPOSE = new Set([ '89ad4ac3-39f7-470e-963a-56509c546377', 'f731ccc4-e22a-43af-a747-64213329e088', '33cf029c-63b0-41a0-9855-be2a3665fb3b', '314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc', 'eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61', '9be7f096-97ec-4615-8957-8d40b5dcbc41', '125ec42a-7229-4250-afc5-e057484327fe', '157afde4-4bf5-4039-8ad2-5a15acc85176', ]); const isSpecialPurpose = (type, mbid) => (type === 'artist' || type === 'label') && SPECIAL_PURPOSE.has(String(mbid || '').toLowerCase()); function editUrl(item) { return `${MB_ORIGIN}/${entityUrlSegment(item.entityType)}/${item.mbid}/edit`; } // Same seed-url format Harmony itself uses (parseHarmonySeedUrl above decodes // exactly this) — MB's OWN edit-page JS reads these query params and pre-fills // the form NATIVELY as it renders, including auto-resolving an ambiguous // relationship-type <select> to the seeded linkTypeId. majkinetor, #467: "why // you didn't use URL params as usual so MB will set it immediately... it // should basically be instant, just like when opened from Harmony" — measured // live: ~2-3s (page load only) vs 10+s for typing simulation. One real // limitation, also confirmed live: MB collapses a DUPLICATE url TEXT into a // single row, so the same url seeded twice with two different linkTypeIds // (Harmony's dual-relationship case) only keeps the first — deduped here, // rare enough for a manual-review tab that the human can add the second by // hand if they need it (they're already there reviewing). function buildSeedEditUrl(item) { // ⚠ MB's form prefix uses the URL segment, not Falcon's internal type name: // a release group's fields are `edit-release-group.*` (hyphen), NOT // `edit-release_group.*`. Verified on the sandbox while doing #533 — // seeding the underscore form leaves the field EMPTY, the hyphen form fills // it. This was silently breaking every seeded release-group url too (#495), // not just the disambiguation this comment was added for. const prefix = `edit-${entityUrlSegment(item.entityType)}.`; const params = new URLSearchParams(); // ⚠ Dedupe on url + link type, NOT on url alone. The same url under two // different link types is a legitimate pair (a Bandcamp track that is both // "stream for free" and "purchase for download"), and MB seeds it natively: // two indexed slots with the same text and different link_type_id produce // two relationships as the page renders. Collapsing them to one slot here // was what forced the old "click MB's Add-another-relationship button and // poke the new select" path — and THAT is what froze Firefox for 30-46s at // a stretch (#467), because a same-origin iframe shares the parent's main // thread, so MB's re-render storm on an already-rendered row blocked the // whole tab. Seeded up front, the identical batch runs ~4s/item, no stalls. const seen = new Set(); let idx = 0; item.urls.forEach(u => { const key = `${u.url} ${u.linkTypeId || ''}`; if (seen.has(key)) return; seen.add(key); params.set(`${prefix}url.${idx}.text`, u.url); if (u.linkTypeId) params.set(`${prefix}url.${idx}.link_type_id`, u.linkTypeId); idx++; }); // #474: disambiguation + ISRC — recording-only fields, seeded the exact // same way as everything else here (checked MB's own Form/Recording.pm: // MB's own field is internally called `comment` — Falcon's own field is // named `disambiguation` instead, #496, but the wire param name below is // MB's, not ours — and `isrcs` is a repeatable field keyed by index, same // pattern as url.N above). // release is absent from COMMENT_SEEDS on purpose — its KO editor ignores // the param (see DISAMBIGUATABLE); setReleaseComment types it in instead. if (COMMENT_SEEDS.has(item.entityType) && item.disambiguation) params.set(`${prefix}comment`, item.disambiguation); // #571: same story as `comment` above — MB's own wire param is `name`, and // release is absent from NAME_SEEDS because its KO editor ignores it // (setReleaseName types it in instead). if (NAME_SEEDS.has(item.entityType) && item.rename) params.set(`${prefix}name`, item.rename); if (item.entityType === 'recording') { (item.isrcs || []).forEach((code, i) => params.set(`${prefix}isrcs.${i}.value`, code)); // #534: MB's Video checkbox — `edit-recording.video=1` ticks it as the // page renders, same as every other seeded field (verified on the // sandbox). Only ever seeded when asked for: an UNSEEDED video param // leaves an already-video recording ticked (also verified), so Falcon's // other recording edits can never silently clear the flag. if (item.video) params.set(`${prefix}video`, '1'); } if (item.note) params.set(`${prefix}edit_note`, item.note); return `${MB_TARGET}/${entityUrlSegment(item.entityType)}/${item.mbid}/edit?${params.toString()}`; } // "open in a real tab" (majkinetor, #467): same reason Harmony itself opens a // tab per entity — a human can inspect, fix, and commit by hand. Navigates // straight to the seed url above rather than opening a blank edit page and // simulating typing — MB fills+resolves everything on its own as the page // renders, so there's nothing left for Falcon to do once the tab is open. // window.open must be the very first thing that runs (no preceding await) or // popup blockers treat it as not user-triggered. function openInTab(item) { // #494: release items have no edit-relationships form to seed — the // closest manual fallback is MB's own add-cover-art page (no seeding // possible there either, it's a plain upload form). if (item.entityType === 'release') { const tab = window.open(`${MB_ORIGIN}/release/${item.mbid}/add-cover-art`, '_blank'); if (!tab) { log('error', `${item.mbid}: popup blocked — allow popups for this site to use "open in tab"`); return; } item.status = 'manual'; item.error = ''; renderQueue(); log('info', `${entityLabel(item)} — opened MusicBrainz's add-cover-art page in a new tab for manual completion`); return; } const seedUrl = buildSeedEditUrl(item); const tab = window.open(seedUrl, '_blank'); if (!tab) { log('error', `${item.mbid}: popup blocked — allow popups for this site to use "open in tab"`); return; } item.status = 'manual'; item.error = ''; renderQueue(); const uniqueUrls = new Set(item.urls.map(u => u.url)).size; const dropped = item.urls.length - uniqueUrls; const droppedNote = dropped ? ` (${dropped} duplicate-url/second-type entr${dropped === 1 ? 'y' : 'ies'} couldn't be seeded — add by hand if needed)` : ''; log('info', `${entityLabel(item)} — opened in a new tab, pre-filled instantly via MB's own seed-url params (${item.urls.length} link(s))${droppedNote}`); } // #467 (majkinetor, production hang): a MB edit form with typed-but-unsubmitted // changes registers a STICKY "unsaved changes" flag (addEventListener('beforeunload')) // — even removing the offending row afterward doesn't clear it (verified live). // Reassigning .src on that SAME iframe then triggers a native "leave site?" confirm // dialog, which — being a real modal — freezes the WHOLE TAB until a human dismisses // it. That's why every item now gets a genuinely FRESH iframe (see workerLoop) — // removing the old element outright, rather than reassigning .src, sidesteps the // dialog regardless of whether the previous page was dirty. // #467 follow-up (majkinetor: "the UI was unresponsive... full session"): a run // with NO failures at all still went unresponsive over several minutes — pointing // at leftover background activity (MB's own client JS: polling/retry timers) from // EACH earlier document not being fully torn down by a plain .src reassignment, // compounding across a long session. Always creating a fresh iframe per item (see // workerLoop) is the fix for that — a run no longer reassigns .src on one iframe // across many sequential items, so nothing compounds. A single RETIRED card's one // remaining iframe is bounded (that card processes nothing further), so — per // majkinetor: "I want to have worker visible there, in its active state" — it's // kept alive and inspectable rather than discarded. The error also gets its own // banner right in the card (not just a hover tooltip), so it's visible once zoomed. function retireCard(card, reason) { card.dataset.retired = '1'; card.style.opacity = '.55'; const lbl = card.querySelector('.falcon-worker-lbl'); if (lbl) lbl.textContent = (lbl.textContent || '') + ' — stopped'; card.title = 'This worker hit an issue and was retired — kept visible for inspection. ' + (reason || ''); const banner = card.querySelector('.falcon-worker-errbanner'); if (banner && reason) { banner.textContent = reason; banner.style.display = 'block'; } } function spawnWorkerCard() { const strip = document.getElementById('falcon-workers'); if (!strip) return null; const idx = workerCards.length; const card = document.createElement('div'); card.className = 'falcon-worker-card'; card.dataset.idle = '1'; card.style.cssText = `border:1px solid var(--mbu-border);border-radius:4px;overflow:hidden;display:flex;flex-direction:column;flex:0 0 auto;width:${cfg.workerSize}px;height:${Math.round(cfg.workerSize * 0.8)}px;`; card.innerHTML = ` <div style="display:flex;align-items:center;gap:4px;padding:3px 6px;background:var(--mbu-bg-raised);color:var(--mbu-text);border-bottom:1px solid var(--mbu-border);font-size:10px"> <span class="falcon-worker-lbl" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--mbu-text-dim)">idle</span> <button type="button" class="falcon-worker-zoom" title="Maximize this worker" style="border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px">⛶</button> </div> <div class="falcon-worker-errbanner" style="display:none;padding:4px 6px;background:var(--mbu-error-bg);color:var(--mbu-error);font-size:10px;white-space:pre-wrap;border-bottom:1px solid var(--mbu-error)"></div> <div class="falcon-worker-body" style="flex:1;position:relative;"></div>`; card.querySelector('.falcon-worker-zoom').onclick = () => { _zoomedWorker = _zoomedWorker === idx ? null : idx; renderWorkerLayout(); }; strip.appendChild(card); workerCards.push(card); renderWorkerLayout(); return card; } // MB's own pages aren't responsive down to a worker card's small width — loaded // at 260px wide, real content renders far outside that narrow viewport and // never becomes visible at all, showing as a blank white box (majkinetor, // #467, confirmed live: DOM had real, visible content, it was just laid out // off-screen). Fix: render the iframe at MB's normal desktop width, then // CSS-scale the whole thing down to exactly fill the card — MB always lays // out the page the way it was actually designed to, and the card just shows a // shrunk, still-legible thumbnail of it. const IFRAME_NATIVE_W = 980; // low-level: given a target visible area (w x h), render the iframe at MB's // natural desktop width and CSS-scale it down/up to exactly fill that area. // Shared by worker cards AND the item popup (which reparents a card's LIVE // iframe into itself, majkinetor: "I want to have worker visible there, in // its active state" — a fresh reload would lose the exact failure state). function sizeIframeFor(iframe, w, h) { if (!iframe || !w || h <= 0) return; const scale = w / IFRAME_NATIVE_W; iframe.style.width = IFRAME_NATIVE_W + 'px'; iframe.style.height = Math.round(h / scale) + 'px'; iframe.style.transform = `scale(${scale})`; } function applyIframeScale(card) { const body = card.querySelector('.falcon-worker-body'); const iframe = body?.querySelector('iframe'); if (!body || !iframe) return; // body.clientHeight is unreliable here — observed reading 0 even once the // card itself was fully laid out (flex:1 child of a column flex container // whose own height came from a plain inline style; some engines don't // settle its cross-size on the first pass). card's OWN clientWidth/Height // are solid, so derive the body's actual area from those instead of // trusting the (buggy) computed height on the flex child itself. const header = card.children[0]; const banner = card.querySelector('.falcon-worker-errbanner'); const bannerH = (banner && getComputedStyle(banner).display !== 'none') ? banner.clientHeight : 0; const w = card.clientWidth; const h = card.clientHeight - (header ? header.clientHeight : 0) - bannerH; sizeIframeFor(iframe, w, h); } function newIframeIn(card) { const body = card.querySelector('.falcon-worker-body'); const old = body.querySelector('iframe'); if (old) old.remove(); const f = document.createElement('iframe'); f.className = 'falcon-worker'; f.style.cssText = 'position:absolute;top:0;left:0;border:none;background:var(--mbu-bg);transform-origin:0 0;'; body.appendChild(f); applyIframeScale(card); return f; } // #494: release cover art has no MB edit-relationships form to seed, so it // never goes through the iframe/form pipeline — ported from Art Station's // proven sign -> upload -> register cover-art API flow // (art_station.user.js): GET /ws/js/cover-art-upload/<mbid> reserves an // image_id/nonce + an archive.org presigned upload target, POST the file // bytes there, then POST the release's own add-cover-art form with that // id/nonce (scraped fresh for its CSRF/type_id fields, same as Art // Station's getPostForm/copyHidden/typeMapOf). // // #495: a release item can ALSO carry urls[] now (added through the normal // iframe/form path, same as any other type) — two unrelated MB edits on the // same entity. `priorLinks` ({status,error}) is passed when that path // already ran on this SAME item; omitted entirely for the cover-only case // (#494's original shape), so existing callers/tests are unaffected. // A registered image mime type from its URL extension — used when // blob.type doesn't look trustworthy (see below). function mimeFromUrl(url) { const ext = (String(url).split(/[?#]/)[0].match(/\.([a-z0-9]+)$/i) || [])[1]; return { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' }[(ext || '').toLowerCase()] || null; } // #496: uploads ONE cover[] entry (sign -> upload -> register). `position` // is this entry's 1-based slot within THIS run (entries submit as separate // add-cover-art POSTs — MB has no batch-upload endpoint — so each needs its // own position; existing gallery art isn't touched either way). async function uploadOneCover(item, entry, tag, position) { if (!entry.url) throw new Error('no cover image URL yet (still resolving, or none found on this release)'); // majkinetor (#494 follow-up, live failure — "sign 400", "since we have // candidates, maybe we should go with the next one"): try the picked // candidate first, then fall through the rest (original scrape order) // on any fetch/sign failure, instead of failing the whole item outright // over one bad candidate. // #719: each URL goes through the maximizer, so a cover typed in or queued from a // JSON file is uploaded at its largest too; a candidate's own suggestion is the last // resort when its maximised URL stops answering. const order = [entry.url, ...((entry.candidates || []).flatMap(c => [c.url, c.original]))] .filter((u, i, arr) => u && arr.indexOf(u) === i); let blob, mime, signed, lastErr = null; for (const asked of order) { try { log('info', `${tag} release ${item.mbid} — fetching cover image${asked === entry.url ? '' : ' (fallback candidate)'}`); const got = await fetchLargestCover(asked); const url = got.url; blob = got.blob; if (got.original) log('info', `${tag} release ${item.mbid} — maximised ${got.original} → ${url}`); // GM_xmlhttpRequest doesn't always populate blob.type reliably from // the response's real Content-Type (seen live: a 400 from the sign // endpoint, which rejects an unrecognized mime_type) — trust it // only when it actually looks like an image mime, else sniff from // the URL's own extension. mime = /^image\//.test(blob.type) ? blob.type : (mimeFromUrl(url) || 'image/jpeg'); log('info', `${tag} release ${item.mbid} — signing upload (${mime})`); const signRes = await mbnFetch(`${MB_ORIGIN}/ws/js/cover-art-upload/${item.mbid}?mime_type=${encodeURIComponent(mime)}`, { credentials: 'same-origin' }); if (!signRes.ok) throw new Error('sign ' + signRes.status); signed = await signRes.json(); // {action, image_id, formdata, nonce} if (asked !== entry.url) dbg(tag, `release ${item.mbid}: fell back to a working candidate (${asked})`); const cand = (entry.candidates || []).find(c => c.url === asked || c.original === asked); entry.original = got.original || (cand && cand.original) || ''; entry.url = url; break; } catch (e) { lastErr = e; dbg(tag, `release ${item.mbid}: candidate ${asked} failed — ${e.message || e}`); } } if (!signed) throw lastErr || new Error('no cover candidate could be fetched/signed'); // what MusicBrainz is actually about to receive — the edit note quotes this // rather than any provider-supplied number const actual = { size: blob.size, width: 0, height: 0 }; try { const bmp = await createImageBitmap(blob); actual.width = bmp.width; actual.height = bmp.height; if (bmp.close) bmp.close(); } catch (e) { dbg(tag, `release ${item.mbid}: could not measure the fetched image — ${e.message || e}`); } log('info', `${tag} release ${item.mbid} — uploading (${(blob.size / 1024).toFixed(0)} KB${actual.width ? `, ${actual.width}×${actual.height}` : ''})`); const fd = new FormData(); Object.entries(signed.formdata).forEach(([k, v]) => fd.append(k, v)); fd.append('file', blob, 'cover.' + (mime.split('/')[1] || 'jpg')); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', signed.action); xhr.timeout = 300000; xhr.onload = () => (xhr.status >= 200 && xhr.status < 300) ? resolve() : reject(new Error('upload ' + xhr.status)); xhr.onerror = () => reject(new Error('upload network error')); xhr.ontimeout = () => reject(new Error('upload timed out')); xhr.send(fd); }); log('info', `${tag} release ${item.mbid} — registering (${entry.type})`); const addUrl = `${MB_ORIGIN}/release/${item.mbid}/add-cover-art`; const formHtml = await fetch(addUrl, { credentials: 'same-origin' }).then(r => { if (!r.ok) throw new Error('GET add-cover-art ' + r.status); return r.text(); }); const formDoc = new DOMParser().parseFromString(formHtml, 'text/html'); const form = [...formDoc.querySelectorAll('form')].find(f => (f.getAttribute('method') || '').toUpperCase() === 'POST'); if (!form) throw new Error('add-cover-art form not found'); const p = new URLSearchParams(); form.querySelectorAll('input[type=hidden]').forEach(h => { if (h.name && !/\.(id|position|nonce|mime_type|comment|type_id)$/.test(h.name)) p.append(h.name, h.value); }); // #496: match the entry's own `type` (default 'Front') to MB's own // type_id checkbox by its visible label — was hardcoded to always find // "front" regardless of what the row asked for. let typeId = null; // #722 by MB's type id (the checkbox's value), as MB translates the labels ("Vorderseite"); // the label only for a name with no known id, and Front when neither finds it const wantType = (entry.type || 'Front').trim().toLowerCase(); const boxes = [...form.querySelectorAll('input[name="add-cover-art.type_id"]')]; const byId = id => (id && boxes.some(cb => cb.value === id)) ? id : null; typeId = byId(mbpCoverTypeId(wantType)); if (!typeId) boxes.forEach(cb => { const l = cb.closest('label'); if (l && (l.textContent || '').trim().toLowerCase() === wantType) typeId = cb.value; }); if (!typeId) typeId = byId(mbpCoverTypeId('Front')); p.append('add-cover-art.id', signed.image_id); p.append('add-cover-art.position', String(position)); p.append('add-cover-art.nonce', signed.nonce); p.append('add-cover-art.mime_type', mime); if (typeId) p.append('add-cover-art.type_id', typeId); p.append('add-cover-art.comment', entry.comment || ''); // Every other Falcon edit carries the "Falcon vX.Y.Z by majkinetor - <help // url>" signature (see editNoteText) — cover art gets it too (majkinetor, // #494 follow-up: "add falcon edit message as usual"), appended after // whatever note the item already carries (e.g. from Harmony) rather than // replacing it. p.append('add-cover-art.edit_note', coverEditNote(item, entry, actual)); const addRes = await fetch(addUrl, { method: 'POST', body: p, credentials: 'same-origin' }); if (!addRes.ok) throw new Error('add-cover-art submit ' + addRes.status); log('info', `${tag} release ${item.mbid} — cover art added (${entry.type})`); } // ── #535 aliases ─────────────────────────────────────────────────────────── // majkinetor: "It would be good to support at least recording aliases as // Picard 3 will probably use them for localization … there is an tab aliases // outside of normal edit. Not sure if there is API". // // There is no write API, but /<entity>/<mbid>/add-alias is a PLAIN // server-rendered form (`form.edit-alias`) with no CSRF token — so it can be // fetched, filled and POSTed directly, exactly like the add-cover-art form // already is. No iframe, no page load, one HTTP round trip per alias, which // matters because MB creates ONE EDIT PER ALIAS: "2 translations for all // recordings" of a 20-track release is 40 submissions. // // Field names verified live on the sandbox: // edit-alias.name / .sort_name / .locale / .primary_for_locale / .type_id // edit-alias.period.{begin,end}_date.{year,month,day} / .period.ended // edit-alias.edit_note // Accepts the shorthand a hand-written JSON is likely to use: a bare string // ("Nazwa"), "name@locale" ("Nazwa@pl"), or the full object. Anything without // a name is dropped rather than silently submitted as a blank alias. function normalizeAliases(raw) { if (!Array.isArray(raw)) return []; const out = []; for (const a of raw) { if (typeof a === 'string') { const m = a.match(/^(.*?)@([a-zA-Z]{2,3}(?:[_-][A-Za-z]{2,4})?)$/); const name = (m ? m[1] : a).trim(); if (name) out.push({ name, locale: m ? m[2].replace('-', '_') : '', type: '', primary: false, sortName: '', begin: '', end: '', ended: false }); continue; } if (!a || typeof a !== 'object') continue; const name = String(a.name || '').trim(); if (!name) continue; out.push({ name, locale: String(a.locale || '').trim().replace('-', '_'), type: a.type == null ? '' : String(a.type).trim(), primary: a.primary === true || a.primary_for_locale === true, sortName: String(a.sortName || a.sort_name || '').trim(), begin: String(a.begin || '').trim(), end: String(a.end || '').trim(), ended: a.ended === true, }); } return out; } const ALIAS_TYPE_IDS = {}; // per entity type, resolved from the form itself // The type_id numbering differs per entity (artist has "Legal name", a // recording doesn't — majkinetor noted this), so never hardcode it: read the // <select> on the form we just fetched and match on the option's TEXT. function resolveAliasTypeId(doc, wanted) { const sel = doc.querySelector('select[name="edit-alias.type_id"]'); if (!sel) return { id: null, why: 'this entity has no alias-type list' }; const opts = [...sel.querySelectorAll('option')].map(o => ({ v: o.value, t: (o.textContent || '').trim() })); if (/^\d+$/.test(String(wanted))) { return opts.some(o => o.v === String(wanted)) ? { id: String(wanted) } : { id: null, why: `type id ${wanted} is not offered here` }; } const want = String(wanted).trim().toLowerCase(); const hit = opts.find(o => o.t.toLowerCase() === want) || opts.find(o => o.t.toLowerCase().startsWith(want)); if (hit) return { id: hit.v }; return { id: null, why: `unknown alias type ${JSON.stringify(wanted)} — this entity offers: ${opts.filter(o => o.v).map(o => o.t).join(', ')}` }; } function splitDateParts(s) { const m = String(s || '').trim().match(/^(\d{4})(?:-(\d{1,2}))?(?:-(\d{1,2}))?$/); return m ? { year: m[1], month: m[2] || '', day: m[3] || '' } : { year: '', month: '', day: '' }; } // One alias → one MB edit. Returns nothing on success, throws with MB's own // message on failure. async function submitAlias(item, alias, tag) { const seg = entityUrlSegment(item.entityType); const url = `${MB_ORIGIN}/${seg}/${item.mbid}/add-alias`; const html = await fetch(url, { credentials: 'same-origin' }).then(r => { if (!r.ok) throw new Error(`GET add-alias ${r.status}`); return r.text(); }); const doc = new DOMParser().parseFromString(html, 'text/html'); const form = doc.querySelector('form.edit-alias'); if (!form) throw new Error('MusicBrainz did not serve an alias form (are we still logged in?)'); // start from the form's own defaults so nothing MB expects goes missing const p = new URLSearchParams(); form.querySelectorAll('input, select, textarea').forEach(el => { if (!el.name) return; if (el.type === 'checkbox' || el.type === 'radio') { if (el.checked) p.set(el.name, el.value || '1'); return; } p.set(el.name, el.value || ''); }); p.set('edit-alias.name', alias.name); // MB pre-fills sort_name with the entity's own name; for a localised alias // the sensible default is the alias text itself, not the original title. p.set('edit-alias.sort_name', alias.sortName || alias.name); p.set('edit-alias.locale', alias.locale || ''); if (alias.primary && alias.locale) p.set('edit-alias.primary_for_locale', '1'); else p.delete('edit-alias.primary_for_locale'); if (alias.type) { const { id, why } = resolveAliasTypeId(doc, alias.type); if (!id) throw new Error(why); p.set('edit-alias.type_id', id); ALIAS_TYPE_IDS[item.entityType] = ALIAS_TYPE_IDS[item.entityType] || {}; ALIAS_TYPE_IDS[item.entityType][String(alias.type).toLowerCase()] = id; // ⚠ MusicBrainz SILENTLY DISCARDS locale and primary-for-locale on a // "Search hint" alias — measured on the sandbox: the same POST stores // locale "pl" under type "Artist name" and null under "Search hint". // Nothing in the response says so, so without this the queue would show // a localised alias that MB stored as locale-less. Say it, and stop // sending the fields, so what's submitted matches what gets stored. if (/search hint/i.test(String(alias.type)) && (alias.locale || alias.primary)) { log('warn', `${entityLabel(item)} — alias "${alias.name}": MusicBrainz ignores locale/primary on a Search hint, so ${JSON.stringify(alias.locale || '')} will not be stored (use a "${item.entityType.replace('_', ' ')} name" alias for a localised title)`); p.set('edit-alias.locale', ''); p.delete('edit-alias.primary_for_locale'); } } const b = splitDateParts(alias.begin), e = splitDateParts(alias.end); p.set('edit-alias.period.begin_date.year', b.year); p.set('edit-alias.period.begin_date.month', b.month); p.set('edit-alias.period.begin_date.day', b.day); p.set('edit-alias.period.end_date.year', e.year); p.set('edit-alias.period.end_date.month', e.month); p.set('edit-alias.period.end_date.day', e.day); if (alias.ended) p.set('edit-alias.period.ended', '1'); else p.delete('edit-alias.period.ended'); p.set('edit-alias.edit_note', withBatchNote([item.note, FALCON_SIGNATURE()].filter(Boolean).join(String.fromCharCode(10, 10)))); dbg(tag, `alias POST ${seg}/${item.mbid}: ${JSON.stringify({ name: alias.name, locale: alias.locale || '(none)', type: alias.type || '(none)', primary: !!alias.primary })}`); const res = await fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: p.toString(), }); if (!res.ok) throw new Error(`add-alias submit ${res.status}`); // MB redirects to the entity's alias tab on success and re-renders the form // with errors on failure — so "did we land back on the form" is the signal. const backHtml = await res.text(); if (!/\/add-alias\b/.test(res.url)) return; const errDoc = new DOMParser().parseFromString(backHtml, 'text/html'); const msg = [...errDoc.querySelectorAll('.error, .errors li, p.error')].map(n => (n.textContent || '').trim()).filter(Boolean)[0]; throw new Error(msg || 'MusicBrainz rejected the alias without saying why'); } // #535 (majkinetor): "One scary issue is that MB allows total duplicates, so // spamming is possible." Confirmed on the sandbox — submitting the identical // alias twice creates TWO of them, no error, no warning. Re-running a queue, // or importing the same JSON twice, would quietly litter the database. // // So Falcon checks before every item and refuses to add one MusicBrainz // already has. Two aliases are "the same" when the name and locale match and // (where a type was asked for) the type matches — MB's own notion of an // alias's identity. // // ⚠ A FAILED lookup must never read as "no aliases yet". That is the bug // class that has bitten this repo repeatedly (art_station #530, credit // hoarder #531): catch → empty → confident negative. mbThrottle.fetchJson // resolves NULL on failure, which is indistinguishable from an entity with // no aliases unless it is checked explicitly — so it is, and the aliases are // held back rather than risking the duplicates this guard exists to prevent. async function fetchExistingAliases(item) { const seg = entityUrlSegment(item.entityType); const j = await mbThrottle.fetchJson(`${MB_ORIGIN}/ws/2/${seg}/${item.mbid}?inc=aliases&fmt=json`, undefined, true); if (!j || !Array.isArray(j.aliases)) return null; // could not ask — NOT "none" return j.aliases; } const aliasKey = (name, locale, type) => [String(name || '').trim(), String(locale || '').trim().toLowerCase(), String(type || '').trim().toLowerCase()].join('\x00'); function isDuplicateAlias(alias, existing) { const want = String(alias.name || '').trim(); const wantLocale = String(alias.locale || '').trim().toLowerCase(); const wantType = String(alias.type || '').trim().toLowerCase(); return existing.some(e => { if (String(e.name || '').trim() !== want) return false; if (String(e.locale || '').trim().toLowerCase() !== wantLocale) return false; // no type asked for → any type counts as already-there return !wantType || String(e.type || '').trim().toLowerCase() === wantType; }); } // All of an item's aliases, in order. One failing doesn't stop the rest — // same contract as cover[] and urls[]. async function runAliasItem(item, tag, card) { if (card) updateWorkerLabel(card, item, 'aliases'); // no card when driven directly (tests) let list = (item.aliases || []).filter(a => a && String(a.name || '').trim()); const errs = []; let ok = 0, dupes = 0; // the same alias twice in one payload is a duplicate too, and cheaper to // catch here than after MB has created both const seenInBatch = new Set(); list = list.filter(a => { const k = aliasKey(a.name, a.locale, a.type); if (seenInBatch.has(k)) { dupes++; dbg(tag, `alias "${a.name}" appears twice in this item — submitting it once`); return false; } seenInBatch.add(k); return true; }); const existing = list.length ? await fetchExistingAliases(item) : []; if (existing === null) { const why = 'could not read the existing aliases from MusicBrainz, so these were not submitted (MB allows exact duplicates, and re-running would create them) — Retry failed to try again'; log('warn', `${tag} ${entityLabel(item)} — ${why}`); item.aliasResults = { ok: 0, total: list.length, dupes, errors: list.map(a => `alias "${a.name}": ${why}`) }; return item.aliasResults; } const before = list.length; list = list.filter(a => { if (!isDuplicateAlias(a, existing)) return true; dupes++; log('info', `${tag} ${entityLabel(item)} — alias "${a.name}"${a.locale ? ` [${a.locale}]` : ''} is already on this entity, skipping (MusicBrainz would happily add a second copy)`); return false; }); dbg(tag, `aliases: ${before} requested, ${list.length} new, ${dupes} already present or repeated`); for (let i = 0; i < list.length; i++) { const a = list[i]; try { log('info', `${tag} ${entityLabel(item)} — adding alias ${i + 1}/${list.length}: "${a.name}"${a.locale ? ` [${a.locale}]` : ''}`); workerPhase(card, `alias ${i + 1} of ${list.length}`); // #547 await submitAlias(item, a, tag); ok++; } catch (e) { const m = (e && e.message) || String(e); errs.push(`alias "${a.name}"${a.locale ? ` [${a.locale}]` : ''}: ${m}`); log('error', `${tag} ${entityLabel(item)} — alias "${a.name}" failed: ${m}`); } } item.aliasResults = { ok, total: list.length, dupes, errors: errs }; dbg(tag, `aliases: ${ok}/${list.length} added` + (dupes ? `, ${dupes} skipped as already present` : '') + (errs.length ? ` — ${errs.join(' | ')}` : '')); return item.aliasResults; } // Aliases are their own MB edits, independent of the entity's form edit and // of the cover upload — so they fold into the row's status the same way the // cover does: all good keeps whatever the rest achieved, some good is // 'partial', none good is 'failed'. function aliasPrior(item) { const r = item.aliasResults; if (!r || !r.total) return undefined; if (!r.errors.length) return { status: 'done', error: '' }; return { status: r.ok ? 'partial' : 'failed', error: r.errors.join(' | ') }; } function finishAliasOnlyItem(item, tag) { const r = item.aliasResults; // every alias already present: the desired state holds, nothing to do — // 'skipped', the same way an all-links-already-there item is reported. if (r && !r.total && r.dupes) { item.status = 'skipped'; item.error = ''; log('info', `${tag} ${entityLabel(item)} — already has all ${r.dupes} alias(es), nothing to add`); return; } if (!r || !r.total) { item.status = 'skipped'; item.error = ''; return; } if (!r.errors.length) { item.status = 'done'; item.error = ''; } else { item.status = r.ok ? 'partial' : 'failed'; item.error = r.errors.join(' | '); } log(r.errors.length ? 'warn' : 'info', `${tag} ${entityLabel(item)} — ${r.ok}/${r.total} alias(es) added`); } function mergeAliasOutcome(item) { const r = item.aliasResults; if (!r || !r.total || !r.errors.length) return; const msg = r.errors.join(' | '); const restOk = item.status === 'done' || item.status === 'skipped'; if (restOk) { item.status = r.ok ? 'partial' : 'failed'; item.error = msg; } else { item.status = 'failed'; item.error = [item.error, msg].filter(Boolean).join(' | '); } } // #536 (majkinetor): "Unlike Art Station or native MB cover art uploader with // ECAU Falcon doesn't add detailed edit note indicating source". Falcon was // sending its signature line and nothing else, so a reviewer had no way to // see where the image came from. // // Modelled on ECAU's own note (edit 151499664 on production), which reads: // <source page> // * <original image url> // → Maximised to <url> // – // MB: Enhanced Cover Art Uploads … / <repo url> // // Falcon has no "maximised" step, but it does CHOOSE between providers, so // the note says which one won and what it beat — the part a voter cannot // otherwise reconstruct. function coverEditNote(item, entry, actual) { const cands = entry.candidates || []; const chosen = cands.find(c => c.url === entry.url || c.original === entry.url) || null; const dims = c => (c && c.width && c.height) ? `${c.width}×${c.height}` : ''; const mb = n => (n && n > 0) ? `${(n / 1048576).toFixed(2)} MB` : ''; // Prefer what was actually downloaded and uploaded over anything recorded // earlier — the note should describe the bytes MusicBrainz received, not a // provider's claim about them (see pickBestCover on how wrong those get). const size = mb((actual && actual.size) || (chosen && chosen.size)); const wh = (actual && actual.width && actual.height) ? `${actual.width}×${actual.height}` : dims(chosen); const lines = []; if (item.note) lines.push(item.note); // ECAU leads with the page the image came from; Falcon's is Harmony's // Release Actions page (or whatever produced the queue). Named explicitly // rather than left as a bare url — "Harmony" is the useful word for a voter. if (item.source) { let where = 'Imported from'; try { if (/(^|\.)harmony\./i.test(new URL(item.source).hostname)) where = 'Imported from Harmony'; } catch (e) {} lines.push(`${where}: ${item.source}`); } lines.push(`Cover art (${entry.type || 'Front'})${chosen && chosen.provider ? ` from ${chosen.provider}` : ''}`); lines.push(`* ${entry.url}${(wh || size) ? ` (${[wh, size].filter(Boolean).join(', ')})` : ''}`); const from = entry.original || (chosen && chosen.original); if (from && from !== entry.url) lines.push(`Maximised from: ${from}`); // #719 what was suggested const others = cands.filter(c => c.url !== entry.url).map(c => `${c.provider}${dims(c) ? ` ${dims(c)}` : ''}`); if (others.length) lines.push(`Chosen as the largest of ${cands.length} candidates — also offered: ${others.join(', ')}`); if (entry.comment) lines.push(`Image comment: ${entry.comment}`); lines.push('–'); lines.push(FALCON_SIGNATURE()); if (batchNote()) lines.push('', batchNote()); return lines.join('\n'); } async function runCoverItem(item, tag, card, priorLinks) { updateWorkerLabel(card, item, 'cover art'); const tStart = Date.now(); // #508 (majkinetor): "Add covers only when there aren't any" — an opt-in // safety toggle. checkExistingCoverArt() fires fire-and-forget right // after the item is queued and USUALLY settles well before a worker // reaches it — but live: majkinetor's queue reached this item before // the check resolved (a single-item, auto-started run leaves it no head // start at all), reading coverExistingCount as still null/falsy and // uploading a duplicate anyway. Only when the option is actually on is // it worth waiting on the in-flight promise the item was stamped with. // the pick may still be measuring candidates — never upload the wrong one // (or nothing at all) because we asked too early if (item._coverPickPromise && (item.cover || []).some(c => !c.url && (c.candidates || []).length)) { dbg(tag, `release ${item.mbid}: waiting for the best-cover pick to finish measuring`); await item._coverPickPromise.catch(() => {}); } if (cfg.coverOnlyIfNone && item.coverExistingCount == null && item._coverCheckPromise) await item._coverCheckPromise; // Skipped, not failed: the item still counts as handled, just untouched. const skipCover = cfg.coverOnlyIfNone && !!item.coverExistingCount; if (skipCover) log('info', `${tag} release ${item.mbid} — cover art skipped (already has ${item.coverExistingCount}, "add only when there aren't any" is on)`); // #496: cover[] can carry more than one entry — upload each independently; // one failing doesn't stop the rest. Overall coverOk only if ALL succeed. const errs = []; if (!skipCover) for (let i = 0; i < item.cover.length; i++) { try { await uploadOneCover(item, item.cover[i], tag, i + 1); } catch (e) { const msg = e.message || String(e); errs.push(item.cover.length > 1 ? `${item.cover[i].type || 'Front'}: ${msg}` : msg); log('error', `${tag} release ${item.mbid}: cover art (${item.cover[i].type || 'Front'}) — ${msg}`); } } const coverError = errs.join(' | '); const coverOk = !coverError; if (priorLinks) { const linksOk = priorLinks.status === 'done' || priorLinks.status === 'skipped'; if (linksOk && coverOk) { item.status = priorLinks.status; item.error = ''; } else if (linksOk && !coverOk) { item.status = 'partial'; item.error = `cover art: ${coverError}`; } else if (!linksOk && coverOk) { item.status = priorLinks.status === 'failed' ? 'partial' : priorLinks.status; item.error = priorLinks.error ? `links: ${priorLinks.error}` : ''; } else { item.status = 'failed'; item.error = [priorLinks.error && `links: ${priorLinks.error}`, `cover art: ${coverError}`].filter(Boolean).join(' | '); } dbg(tag, `release ${item.mbid} — combined outcome: links=${priorLinks.status} cover=${coverOk ? 'ok' : 'failed'} -> ${item.status}`); } else if (skipCover) { item.status = 'skipped'; item.error = ''; } else { item.status = coverOk ? 'done' : 'failed'; item.error = coverOk ? '' : coverError; } item.timing = { worker: tag, totalMs: Date.now() - tStart }; updateWorkerLabel(card, null); scheduleRender('queue'); } async function workerLoop(card) { const tag = `[w${workerCards.indexOf(card) + 1}]`; while (running) { const item = nextQueued(); if (!item) { dbg(tag, 'nothing left queued — going idle'); break; } item.status = 'active'; scheduleRender('queue'); // #730: a special-purpose artist or label ([no label], [unknown], …) can't be edited by a // regular editor, so its edit page never loads: skip it plainly, not as a failure if (isSpecialPurpose(item.entityType, item.mbid)) { item.status = 'skipped'; item.error = 'a special-purpose ' + item.entityType + ' — MusicBrainz lets only a few editors change it'; log('info', `${tag} ${entityLabel(item)} — skipped: special-purpose ${item.entityType}, not editable`); renderQueue(); continue; } // #494/#495: a release item with a cover but NO urls is cover-only — // skip the iframe/form pipeline entirely (nothing to seed a form with). // One WITH urls falls through to the normal pipeline below like any // other type, and picks up the cover step afterward at each exit (see // needsCover below) since the two are independent MB edits. // #532: a row seeded from the release page starts EMPTY on purpose — the // point is to fill in a disambiguation (or ISRC, or url) on the ones you // care about. Pressing Start with the rest still blank must not open an // edit page per untouched row and submit nothing; skip them plainly. // ⚠ Everything that counts as work has to be listed in BOTH places: here, // and in the no-form check just below. #533 was exactly this — a release // whose only work was a disambiguation got routed to the cover path and // reported 'done' having submitted nothing. // ⚠ Candidates count as work even with no url chosen yet: the pick is // asynchronous (it measures every candidate), and reading "no url" as // "no cover" is what made a Harmony release report "nothing filled in". const needsCover = item.entityType === 'release' && (item.cover || []).some(c => c.url || (c.candidates || []).length); const needsAliases = (item.aliases || []).some(a => a && String(a.name || '').trim()); const needsForm = !!(item.urls.length || (DISAMBIGUATABLE.has(item.entityType) && (item.disambiguation || '').trim()) || (RENAMEABLE.has(item.entityType) && (item.rename || '').trim()) || (item.entityType === 'release' && item.barcode) || (item.entityType === 'release' && (item.labels || []).length) || (item.entityType === 'recording' && (item.isrcs || []).some(Boolean)) || (item.entityType === 'recording' && item.video)); if (!needsForm && !needsCover && !needsAliases) { item.status = 'skipped'; item.error = 'nothing to submit yet — add a url, name, disambiguation, barcode, catalog number, ISRC, alias or cover'; log('info', `${tag} ${item.entityType} ${item.mbid} — skipped, nothing filled in`); renderQueue(); continue; } // Nothing for the edit FORM to do: the remaining work (aliases, cover art) // is submitted straight to MusicBrainz, no iframe needed. #535 aliases go // through their own plain form POST, same as the cover upload API. if (!needsForm) { // #535 follow-up (majkinetor, on an alias-only run: "also missing // worker time?"): this path never touched the iframe pipeline, which is // where item.timing is set — so the summary showed a blank worker // column and dashes for every duration on a run that plainly did work. const tNoForm = Date.now(); // #547: this path never touches an iframe, so without a phase its card // sat on whatever the previous item left there. updateWorkerLabel(card, item, needsAliases ? 'aliases' : 'cover art'); let aliasMs = 0; if (needsAliases) { const a0 = Date.now(); await runAliasItem(item, tag, card); aliasMs = Date.now() - a0; } if (needsCover) await runCoverItem(item, tag, card, aliasPrior(item)); else finishAliasOnlyItem(item, tag); item.timing = { worker: tag, loadMs: 0, settleMs: 0, fillMs: 0, submitMs: 0, aliasMs, totalMs: Date.now() - tNoForm }; renderQueue(); continue; } log('info', `${tag} ${item.entityType} ${item.mbid} — loading edit page (${item.urls.length} link(s))`); // ⚠ A GENUINELY FRESH IFRAME PER ITEM. Never re-navigate one that has // already been used. // // Reusing it was a speed fix of mine, and it is what has been killing // majkinetor's tab (#467). His logs isolate it precisely: an item that // FAILS retires its card and the next item gets a brand-new iframe — // fine, every time. An item that COMMITS keeps the same card, and the // next navigation of that already-used iframe takes the whole tab down, // panel and remaining queue with it. Reproduced across two builds, once // via contentWindow.location.replace() and again via plain .src, so it is // the reuse itself and not the navigation method. // // The measurement that justified reuse (2 items: 256s -> 4.9s) was taken // while the dual-type SEEDING bug was still present — back then every // later item was also doing post-render DOM poking on MB's React tree, // which is what actually cost the time. With seeding fixed, a fresh iframe // per item measures the same as reuse, so the optimisation buys nothing // and costs him runs. const iframe = newIframeIn(card); card.dataset.itemId = item.id; // lets showItemPopup find "the worker that ran this item" updateWorkerLabel(card, item, 'loading edit page'); // #467 (majkinetor): navigate straight to the seed url — MB pre-fills // every url as the page renders, so fillAndSubmit has little or nothing // left to type. Measured live: ~2-3s vs 10+s for typing simulation. const tNav = Date.now(); // ⚠ NAVIGATE THE IFRAME BY ASSIGNING .src, AND BY NOTHING ELSE. // // This briefly used `frameWin(iframe).location.replace(seedUrl)` to keep // the frame's session history at depth 1. It cost majkinetor a run: his // tab went away one second into item 2 — the first navigation of a REUSED // iframe — taking the panel, the workers and the rest of the queue with it // (#467: "the Falcon popup closes abruptly in the middle of the process"). // // It never reproduced here, because these tests inject into the page's own // realm while he runs inside a userscript manager's sandbox, where // `contentWindow` comes back as an Xray wrapper and `w.location` cannot be // relied on to still mean the FRAME's location. Assigning `.src` has no // such ambiguity: it is a property of the element, it can only ever // navigate that element, and it cannot reach the top window however the // script is wrapped. // // The history argument was speculative anyway — the measured freeze fix // was seeding both link types up front, and replace() was verified NOT to // fix the 6-item freeze on its own. Reusing the iframe DOES stay: that one // is measured (2 items, 256s -> 4.9s). const seedUrl = buildSeedEditUrl(item); iframe.src = seedUrl; // A FRESH iframe starts on about:blank, whose readyState is already // 'complete' — so a bare "readyState !== 'loading'" check passes // INSTANTLY, against the blank document, before MB's page has loaded at // all. Everything downstream then races a document that's about to be // replaced. Require the frame to actually be ON this entity's edit page // (and to have rendered the links section) before believing it's loaded. // #495 (majkinetor, live on test.musicbrainz.org: "worker goes too fast // over it... didn't add link"): a bare `input` fallback (meant to catch // "no links yet, but the add-link input is ready") is satisfied by ANY // input on the page — fine on artist/recording's small single-purpose // form, but a release edit page has many unrelated inputs (title, // barcode, tracklist...) that render well before the External Links // section itself mounts, so this used to pass instantly and let the // worker start filling before there was anything to fill. Matching // findAddLinkInput's specific placeholder text instead of a bare // `input` fixes it for every type, not just release. // #517 follow-up (majkinetor, live, with an animated gif as proof): // "I clearly see worker finishing seeding in 2-3s and waiting 13s to // incorrectly show 'edit page never loaded'" — a real detection bug, // not a timing/contention issue (staggering was tried and reverted; // it couldn't have fixed this). None of the reasoning further down // (about contention needing a longer timeout) explains a page that's // visibly ready and submittable within seconds. Logging the actual // state of each sub-condition periodically while this polls, so the // NEXT time this happens the log itself says which check kept // failing instead of needing another live repro to find out. let _loadedPollCount = 0; let _sawInterstitial = false; // #551 const loaded = await waitFor(() => { _loadedPollCount++; const w = frameWin(iframe), doc = frameDoc(iframe); const diag = () => { if (_loadedPollCount % 20 !== 0) return; // ~every 3s (150ms poll interval) let path = '(unreadable)'; try { path = w ? w.location.pathname : '(no window)'; } catch (e) { path = '(threw: ' + e.message + ')'; } dbg(tag, `still waiting for edit page — readyState=${doc ? doc.readyState : '(no doc)'}, path=${path}, hasExternalLinkRow=${doc ? !!doc.querySelector('tr.external-link-item') : '?'}, hasAddLinkInput=${doc ? !!findAddLinkInput(doc) : '?'}`); }; if (!w || !doc || doc.readyState === 'loading') { diag(); return null; } // #551: a worker's iframe can be served the same challenge. It solves // itself and navigates on within a moment, so KEEP WAITING — but say so, // because otherwise this is 30 silent seconds that end in a misleading // "edit page never loaded" when the page was never even served. if (isVerifyInterstitial(doc)) { _sawInterstitial = true; if (_loadedPollCount % 20 === 1) dbg(tag, 'MusicBrainz is running its "Verifying your browser" challenge in this worker — waiting for it to clear'); return null; } let path = ''; try { path = w.location.pathname; } catch (e) { diag(); return null; } if (!path.includes(item.mbid)) { diag(); return null; } // still about:blank / previous doc const ready = doc.querySelector('tr.external-link-item') || findAddLinkInput(doc); if (!ready) diag(); return ready ? true : null; }, 30000); if (!loaded) { // #551: naming the cause matters — "never loaded" reads as a Falcon bug // when MusicBrainz simply never served the page in the first place. item.status = 'failed'; item.error = _sawInterstitial ? 'MusicBrainz kept serving its "Verifying your browser" challenge instead of the edit page' : 'edit page never loaded'; log('error', `${tag} ${item.mbid}: ${item.error} (waited 30s)`); // #495: the cover upload is a plain API call, independent of this // iframe — still worth attempting even though the link form never // loaded. if (needsAliases) { await runAliasItem(item, tag, card); mergeAliasOutcome(item); } if (needsCover) await runCoverItem(item, tag, card, { status: item.status, error: item.error }); retireCard(card, item.error); scheduleRender('queue'); const replacement = spawnWorkerCard(); if (replacement) workerLoop(replacement); return; } const loadMs = Date.now() - tNav; dbg(tag, `edit page loaded in ${loadMs}ms`); workerPhase(card, 'waiting for the seeded rows'); // MB's client JS turns the seed params into rows a moment after load. // A seeded url can land in EITHER shape (see findRowsForUrl): a resolved // <a href> row, or — when MB couldn't classify it — an editable input // row holding the url text. Matching only the href shape here meant every // unclassifiable url burned the full 8s timeout for nothing. const settled = await waitFor(() => { const doc = frameDoc(iframe); if (!doc) return null; const uniqueUrls = [...new Set(item.urls.map(u => u.url))]; return uniqueUrls.every(u => !!findRowForUrl(doc, u)) ? true : null; }, 8000); const settleMs = Date.now() - tNav - loadMs; // snapshot the entity's links BEFORE we touch anything — the submit gate // compares against this to prove nothing pre-existing was lost (#467). const baseline = snapshotExistingUrls(frameDoc(iframe)); dbg(tag, `seeded rows settled=${!!settled} after ${Date.now() - tNav}ms total`); let r = null; try { workerPhase(card, 'filling and submitting the edit'); r = await fillAndSubmit(iframe, item, { tag, baseline }); item.urlResults = r.results; // per-stage timings, kept on the item so the end-of-run summary table // can show where the time actually went (majkinetor, #467). item.timing = { worker: tag, loadMs, settleMs, fillMs: r.fillMs || 0, submitMs: r.submitMs || 0, totalMs: Date.now() - tNav }; // a url already on the entity (#671) is nothing to do, not a failure const failedUrls = r.results.filter(x => !x.ok && !x.present); const presentUrls = r.results.filter(x => x.present); if (r.noop) { // everything was already there — the desired state holds, so this is // a success with nothing to do, NOT a failure. item.status = 'skipped'; item.error = ''; log('info', `${tag} ${entityLabel(item)} — already up to date, nothing to submit (${r.results.length} link(s) already present)`); } else if (!r.committed) { item.status = 'failed'; item.error = failedUrls.map(x => `${x.url}: ${x.error}`).join('; '); log('error', `${tag} ${item.mbid}: nothing added — ${item.error}`); } else if (failedUrls.length) { item.status = 'partial'; item.error = failedUrls.map(x => `${x.url}: ${x.error}`).join('; '); log('warn', `${tag} ${item.entityType} ${item.mbid} — committed ${r.results.filter(x => x.ok).length}/${r.results.length} link(s)`); } else { item.status = 'done'; log('info', `${tag} ${item.entityType} ${item.mbid} — committed ${r.results.filter(x => x.ok).length} link(s)` + (presentUrls.length ? `, ${presentUrls.length} already there` : '')); } presentUrls.forEach(x => log('info', `${tag} ${entityLabel(item)} — ${x.url}: ${x.error}`)); } catch (e) { item.status = 'failed'; item.error = e.message || String(e); item.timing = { worker: tag, loadMs, settleMs, fillMs: 0, submitMs: 0, totalMs: Date.now() - tNav }; log('error', `${tag} ${item.mbid}: ${item.error}`); } // #495: the cover upload is independent of how the link submission went // (or whether it ran at all) — run it and fold its outcome into // item.status/error before deciding whether this card can keep going. // #535: aliases are separate edits; run them whatever the form edit did, // then fold their outcome in before the cover step reads item.status. if (needsAliases) { const a0 = Date.now(); await runAliasItem(item, tag, card); mergeAliasOutcome(item); if (item.timing) item.timing.aliasMs = Date.now() - a0; } if (needsCover) await runCoverItem(item, tag, card, { status: item.status, error: item.error }); renderQueue(); if (r && (r.committed || r.noop)) { // a real submit happened — or there was genuinely nothing to submit, in // which case the form was never dirtied either. Both leave this card // safe to keep going (a fresh iframe loads the NEXT item next // iteration, see above) rather than retiring, so you can watch one // worker flow through a whole run instead of every item spawning a card. updateWorkerLabel(card, null); continue; } // anything else: this card's form may still be dirty — retire it in place // (stays visible with its last state, per majkinetor) and hand off to a fresh // replacement card rather than risk the beforeunload freeze. retireCard(card, item.error); const next = spawnWorkerCard(); if (next) workerLoop(next); return; } updateWorkerLabel(card, null); // triggers its own re-render since this flips the card to idle (see above) // last worker out turns the jank heartbeat off — no point measuring thread // blocking once nothing of ours is running. // Last worker out. `running` used to stay true forever here, so once a run // drained: the button still read "■ Stop", and clicking Start again did // nothing at all (start() bails on `if (running) return`) — the only way to // run a second batch was to reload the page. Clear the flag so the panel // returns to a usable idle state instead of looking wedged. if (!queue.some(i => i.status === 'active')) { stopHeartbeat(); logRunSummary(); // #497: a queued item whose type is toggled off will never be picked up // (see nextQueued) — don't count it as "still to do," or a run whose // only leftovers are excluded types would sit forever looking active // with every worker actually idle. if (!queue.some(i => i.status === 'queued' && !_disabledTypes.has(i.entityType))) { running = false; resumeNameLookups(); // the rate-limit budget is ours again resolveMissingNames(); // give any cancelled-mid-run lookups a second try updateRunBtn(); renderProgress(); log('info', '=== run finished — the tab and panel stay open; the log above is this session only ==='); sendReleaseToPicard(); // #578 maybeClosePanelAfterRun(); writeLogNow(); // #588 — last, and after the log is flushed: this one can navigate. maybeReloadReleasePage(); } } } // #578 follow-up (majkinetor): "Maybe just invoke in new tab or someting // http://127.0.0.1:<port>/openalbum?id=<mbid>". Appending ?tport= is proven to // work — a clean browser renders the tagger button for exactly the URL Falcon // opens (live-578-picard-harmony-proof) — but it did not appear in HIS browser, // and waiting for a button to be clicked was never the ask anyway: Jormangeud // wanted the release "brought into Picard right away". So Falcon calls Picard's // own endpoint and stops depending on MusicBrainz rendering anything. The // ?tport= parameter stays as the manual fallback; it costs nothing. // // Fired when the RUN finishes rather than when the tab opens, so Picard reads // the release AFTER Falcon has added its links, ISRCs and cover — otherwise it // would tag the version that made Falcon necessary in the first place. const _picardSent = new Set(); // #671: Picard and the reload after import are Harmony options (they sit in its settings // group), so they follow only a queue Harmony seeded in this tab — not Import, a ?falcon= // link, or a batch another script (Platform Check) hands over on the page. let _harmonySeeded = false; function sendReleaseToPicard() { if (!cfg.sendToPicard) return; if (!_harmonySeeded) { log('debug', 'Picard: this queue did not come from Harmony, so nothing is sent'); return; } const m = /\/release\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(location.pathname); if (!m) { log('debug', 'Picard: this tab is not a release page, so there is nothing to hand over'); return; } const mbid = m[1].toLowerCase(); // a tab can finish a run more than once (Start again, a re-seed); Picard // should not be poked repeatedly for the same release if (_picardSent.has(mbid)) { log('debug', `Picard: already sent ${mbid} from this tab`); return; } _picardSent.add(mbid); const url = `http://127.0.0.1:${cfg.picardPort}/openalbum?id=${mbid}`; // GM_xmlhttpRequest rather than fetch: the page is https and this is plain // http, and rather than rely on every browser treating 127.0.0.1 as a // trustworthy origin, go around it. No tab is opened either way — Picard // answers with a stub page nobody wants to look at. if (typeof GM_xmlhttpRequest === 'function') { log('info', `Picard: sending release ${mbid} → ${url}`); try { GM_xmlhttpRequest({ method: 'GET', url, timeout: 5000, onload: r => log(r.status >= 200 && r.status < 400 ? 'ok' : 'warn', `Picard: responded ${r.status}`), // by far the most likely cause, and worth saying rather than leaving a // bare "error" for someone to interpret onerror: () => log('warn', `Picard: could not be reached on port ${cfg.picardPort} — is Picard running with "Browser integration" enabled?`), ontimeout: () => log('warn', `Picard: timed out on port ${cfg.picardPort}`), }); } catch (e) { log('warn', `Picard: send failed — ${e.message}`); } return; } log('info', `Picard: opening ${url} (no GM_xmlhttpRequest available)`); try { window.open(url, '_blank'); } catch (e) { log('warn', `Picard: send failed — ${e.message}`); } } /* ── #588: reload the release page once the run comes out clean ─────────── * chaban-mb: "Falcon is set to fully auto for me. So all I see is an already * processed queue and stale release view." * * majkinetor's ruling on the issue was that standardising this across the * scripts is wrong because the context differs — "PC restarts as Scout must * see. Scout doesn't as nobody needs that info. AS does as you want to see the * end result. Falcon doesn't as you want to see a queue results" — and then * asked for it as a Harmony option here. So: opt in, off by default. * * Nothing is lost by reloading. The run's log is a #512 session in * localStorage, so it is still there on the Log tab afterwards; the queue is * in memory and is genuinely gone, which is why this only fires when every * item settled clean and none was left for a human to look at. */ const RELOADED_KEY = 'falcon:reloadedAfterImport'; function maybeReloadReleasePage() { if (!cfg.reloadReleaseAfterImport) return; if (!_harmonySeeded) { log('debug', 'reload after import: this queue did not come from Harmony, so the page is left as it is'); return; } // the release page itself only: /release/<mbid>/cover-art and the other subpages // matched too, and were reloaded after every clean run (#588) const m = /^\/release\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i.exec(location.pathname); if (!m) { log('debug', 'reload after import: this tab is not a release page, so there is nothing to refresh'); return; } // Same definition of "bad" the panel's own summary chip uses — 'skipped' is // a success (already up to date), not a problem. const bad = queue.filter(i => i.status === 'failed' || i.status === 'partial'); if (bad.length) { log('info', `reload after import: ${bad.length} item(s) did not go through, so the page is left as it is — the queue is the only place those are still visible`); return; } // 'manual' means a tab was opened for someone to review by hand. Reloading // out from under that is the one way this option could lose real work. const manual = queue.filter(i => i.status === 'manual'); if (manual.length) { log('info', `reload after import: ${manual.length} item(s) are waiting on a manual review — not reloading`); return; } if (!queue.some(i => SETTLED_STATUSES.includes(i.status))) { log('debug', 'reload after import: nothing settled in this run'); return; } /* Unlike #590's Harmony reload — which MUST be location.reload() because * Harmony reads its whole lookup state back out of the query string — this * one deliberately REBUILDS the url, to drop the `falcon=` token. The token * was consumed at boot (parseUrlParam deletes the GM value), so reloading it * verbatim would land on a page that logs "neither valid base64 JSON nor a * known pending token" every single time. `tport` is kept: that is what puts * MusicBrainz's own green tagger button on the page. */ const q = new URLSearchParams(location.search); q.delete('falcon'); const target = location.pathname + (q.toString() ? '?' + q : '') + location.hash; try { sessionStorage.setItem(RELOADED_KEY, m[1].toLowerCase()); } catch (e) {} log('info', `reload after import: the run finished clean — reloading ${target} so it shows what Falcon just added`); writeLogNow(); // the session is what survives the navigation; flush before going location.replace(target); } // majkinetor: "Change the background color of the Falcon icon (or equivalent) // on reloaded page. The point is, with many tabs open, to know which one was // reloaded by Falcon after success." Keyed by MBID and checked against the // page actually being shown, so it marks the release Falcon finished rather // than every page the tab visits afterwards. function reloadedAfterImportHere() { let flag = null; try { flag = sessionStorage.getItem(RELOADED_KEY); } catch (e) {} if (!flag) return false; const m = /\/release\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(location.pathname); return !!m && m[1].toLowerCase() === flag; } // #467 (majkinetor): each worker gets its own card — a small label (which entity // it's on right now) plus a ⛶ toggle to view just that one large, so a failure can // actually be read instead of squinting at a 220x160 thumbnail. Zooming one worker // hides the others rather than trying to fit everything in a responsive grid. // Cards accumulate as a visible history (retired ones dim + freeze in place, see // above) rather than being torn down — only ever appended, never removed/reused // across a Start click, so old state stays inspectable until the panel closes. let workerCards = []; let _zoomedWorker = null; // index into workerCards, or null // #547 (majkinetor): "We should also remove '0 links' from workers and write // there whats it doing instead." A link count is the least interesting thing // about a running worker — and on an alias/disambiguation/cover run it was // always "0 link(s)", which reads as "this worker has nothing to do" while it // is in the middle of doing it. The header carries the live phase instead. function updateWorkerLabel(card, item, phase) { if (card.dataset.retired) return; // don't overwrite a retired card's frozen label card._item = item || null; const lbl = card.querySelector('.falcon-worker-lbl'); if (lbl) lbl.textContent = item ? `${entityLabel(item)} — ${phase || 'starting'}` : 'idle'; // a freshly-spawned card starts hidden (marked idle at creation, #467's // hide-idle-workers), so it must re-render the moment it actually GETS an // item too, not just when it goes idle again — re-rendering only on the // rare transition (not every label update) keeps this cheap. const wasIdle = card.dataset.idle === '1'; const isIdleNow = !item; card.dataset.idle = isIdleNow ? '1' : ''; if (wasIdle !== isIdleNow) renderWorkerLayout(); } /* #547: update only the phase, keeping whichever item the card is already on. Cheap enough to call from anywhere in an item's lifecycle — it touches one text node and never re-renders the layout. Silently does nothing on a retired or idle card, so a late callback from a finished item cannot resurrect a frozen label. */ function workerPhase(card, text) { if (!card || card.dataset.retired || !card._item) return; const lbl = card.querySelector('.falcon-worker-lbl'); if (lbl) lbl.textContent = `${entityLabel(card._item)} — ${text}`; } function renderWorkerLayout() { let anyVisible = false; workerCards.forEach((card, i) => { const zoomBtn = card.querySelector('.falcon-worker-zoom'); // #467 (majkinetor): "hide idle workers on Workers tab" — an idle card // (nothing currently loaded, or the whole run finished) has nothing worth // looking at in the default thumbnail strip; only applies there — a // specifically zoomed card stays visible even if it happens to go idle. const isIdle = card.dataset.idle === '1'; // Hiding a card that's mid-item is the same trap as hiding the whole pane // (see setTab): display:none stops its iframe rendering and its submit // never lands. An idle card is safe to drop out of the DOM flow; a busy // one gets parked off-screen instead so it keeps working while out of view. const hide = () => { if (isIdle) { card.style.display = 'none'; return; } card.style.cssText = card.style.cssText.replace(/position:absolute;left:-100000px;top:0;/, ''); card.style.position = 'absolute'; card.style.left = '-100000px'; card.style.top = '0'; card.style.display = ''; }; const show = (w, h) => { card.style.position = ''; card.style.left = ''; card.style.top = ''; card.style.display = ''; card.style.width = w; card.style.height = h; }; if (_zoomedWorker === null) { if (isIdle) { card.style.display = 'none'; return; } show(cfg.workerSize + 'px', Math.round(cfg.workerSize * 0.8) + 'px'); if (zoomBtn) { zoomBtn.textContent = '⛶'; zoomBtn.title = 'Maximize this worker'; } } else if (_zoomedWorker === i) { show('100%', '560px'); if (zoomBtn) { zoomBtn.textContent = '❐'; zoomBtn.title = 'Restore'; } } else { hide(); } // a retired card normally dims to signal "done, nothing to act on" — but // zooming it in specifically to inspect its failure (from the queue tab's // status-label click, #467) should show it at full readable opacity. card.style.opacity = (card.dataset.retired === '1' && _zoomedWorker !== i) ? '.55' : '1'; // only count as "visible" (for the empty-state message) when it's actually // on screen — an off-screen parked card is live but not something you see. const onScreen = card.style.display !== 'none' && card.style.position !== 'absolute'; if (card.style.display !== 'none') applyIframeScale(card); // card size just changed — rescale its iframe to match if (onScreen) anyVisible = true; }); const emptyMsg = document.getElementById('falcon-workers-empty'); if (emptyMsg) emptyMsg.style.display = anyVisible ? 'none' : 'block'; } // #467 (majkinetor: "UI was frozen after some time... Notice the 30s silence // in log at the end"). That silence is itself the evidence: waitFor checks // its predicate BEFORE its deadline, so a 25s timeout that returned at 37s // means the main thread simply wasn't running our timers for most of that // stretch — i.e. a genuine freeze, not a slow server. Whether it's MB's own // page JS in the worker iframes or something Falcon does is the open // question, so measure it: a heartbeat that reports how long the thread was // actually blocked, right in the same log as everything else. let _heartbeat = null; function startHeartbeat() { if (_heartbeat) return; let last = Date.now(); _heartbeat = setInterval(() => { const now = Date.now(), late = now - last - 500; last = now; if (late > 1500) log('warn', `UI thread was blocked for ~${(late / 1000).toFixed(1)}s (nothing could render or respond during that time)`); }, 500); } function stopHeartbeat() { if (_heartbeat) { clearInterval(_heartbeat); _heartbeat = null; } } // #467 (majkinetor): "add to falcon log table with timing data as a summary". // Printed once when the last worker goes idle. The point is to make WHERE the // time goes obvious at a glance — the run that prompted this had ~30s submits // purely because five workers were submitting at once (MB serialises edit // submissions per user), which is invisible in a stream of interleaved lines // but jumps out of a sorted table. Columns are the real stages: load = MB's // edit page arriving, settle = MB turning seed params into rows, fill = our // own DOM work, submit = click → redirect. const SETTLED_STATUSES = ['done', 'failed', 'partial', 'manual', 'skipped']; let _summaryPending = false, _runStartedAt = 0; function logRunSummary() { // #513 (majkinetor, live: "What about 'edit page was never loaded'"): // that failure sets item.status = 'failed' straight away, WITHOUT ever // setting item.timing (it never gets past the initial iframe-load wait) // — restricting to queue.filter(i => i.timing) silently dropped it (and // any status like it) from the table AND from the totals/byStatus // count. Every settled item now gets a row; ones with no timing data // just show dashes for the timed columns instead of vanishing. const rows = queue.filter(i => SETTLED_STATUSES.includes(i.status)); if (!rows.length || _summaryPending) return; _summaryPending = true; // let any last status writes land before snapshotting setTimeout(() => { _summaryPending = false; const pad = (v, n, right) => { const s = String(v); return right ? s.padStart(n) : s.padEnd(n); }; const ms = v => (v == null ? '-' : String(Math.round(v))); const nameW = Math.min(28, Math.max(6, ...rows.map(i => entityLabel(i).length))); // #535 follow-up (majkinetor): "Summary should contain alias info on // workers". Only shown when the run involved aliases — an ordinary link // run should not grow a column of dashes. const anyAliases = rows.some(i => (i.aliases || []).length || i.aliasResults); const aliasCell = i => { const r = i.aliasResults; if (!r) return (i.aliases || []).length ? '—' : ''; const bits = [`${r.ok}/${r.total + (r.dupes || 0)}`]; if (r.dupes) bits.push(`${r.dupes} dup`); if (r.errors && r.errors.length) bits.push(`${r.errors.length} err`); return bits.join(' '); }; const head = `${pad('w', 4)} ${pad('entity', nameW)} ${pad('status', 8)} ${pad('load', 7, 1)} ${pad('settle', 7, 1)} ${pad('fill', 7, 1)} ${pad('submit', 8, 1)}` + (anyAliases ? ` ${pad('alias', 9, 1)} ${pad('aliasMs', 8, 1)}` : '') + ` ${pad('total', 8, 1)}`; const lines = [head, '-'.repeat(head.length)]; // #512 follow-up (majkinetor, live: "add on each worker what was done // in this log table like I shown on w1 and w2") — same categories the // collapsed queue row and the aggregate "worked on" line already use, // per item this time so a failed batch shows what each one actually had. const rowBreakdown = i => [ i.urls && i.urls.length ? `${i.urls.length} link${i.urls.length === 1 ? '' : 's'}` : '', i.rename ? 'rename' : '', i.disambiguation ? 'disambiguation' : '', (i.isrcs || []).length ? 'isrc' : '', i.cover && i.cover.some(c => c.url) ? 'cover' : '', (i.aliases || []).length ? `${i.aliases.length} alias${i.aliases.length === 1 ? '' : 'es'}` : '', ].filter(Boolean).join(', '); rows.forEach(i => { const t = i.timing || {}; const what = rowBreakdown(i); lines.push(`${pad(t.worker || '', 4)} ${pad(entityLabel(i).slice(0, nameW), nameW)} ${pad(i.status, 8)} ${pad(ms(t.loadMs), 7, 1)} ${pad(ms(t.settleMs), 7, 1)} ${pad(ms(t.fillMs), 7, 1)} ${pad(ms(t.submitMs), 8, 1)}` + (anyAliases ? ` ${pad(aliasCell(i), 9, 1)} ${pad(ms(t.aliasMs), 8, 1)}` : '') + ` ${pad(ms(t.totalMs), 8, 1)}` + (what ? ` ; ${what}` : '')); }); const timed = rows.filter(i => i.timing); const sum = k => timed.reduce((a, i) => a + (i.timing[k] || 0), 0); const avg = k => timed.length ? Math.round(sum(k) / timed.length) : null; const maxSubmit = timed.length ? Math.max(...timed.map(i => i.timing.submitMs || 0)) : 0; lines.push('-'.repeat(head.length)); const aliasTotals = rows.reduce((m, i) => { const r = i.aliasResults; if (!r) return m; m.ok += r.ok; m.total += r.total + (r.dupes || 0); m.dupes += (r.dupes || 0); m.errors += (r.errors || []).length; return m; }, { ok: 0, total: 0, dupes: 0, errors: 0 }); lines.push(`${pad('', 4)} ${pad(`${rows.length} item(s)`, nameW)} ${pad('avg', 8)} ${pad(ms(avg('loadMs')), 7, 1)} ${pad(ms(avg('settleMs')), 7, 1)} ${pad(ms(avg('fillMs')), 7, 1)} ${pad(ms(avg('submitMs')), 8, 1)}` + (anyAliases ? ` ${pad(`${aliasTotals.ok}/${aliasTotals.total}`, 9, 1)} ${pad(ms(avg('aliasMs')), 8, 1)}` : '') + ` ${pad(ms(avg('totalMs')), 8, 1)}`); const byStatus = rows.reduce((m, i) => { m[i.status] = (m[i.status] || 0) + 1; return m; }, {}); // wall clock is NOT the sum of the item totals — workers overlap — so show // both: the sum is how much work happened, the wall clock is how long you // actually waited, and the gap between them is what concurrency bought. const wallMs = _runStartedAt ? Date.now() - _runStartedAt : 0; const fmt = m => (m >= 1000 ? (m / 1000).toFixed(1) + 's' : m + 'ms'); // #512 (majkinetor): "in work summary at the end of the log, add what // was done the same as shown in collapsed queue (e.g. 2 link, isrc)" — // same categories the row itself shows (links / ISRC / disambiguation // / cover), aggregated across the whole run instead of per row. const totalLinks = rows.reduce((n, i) => n + (i.urls ? i.urls.length : 0), 0); const withIsrc = rows.filter(i => (i.isrcs || []).length).length; const withRename = rows.filter(i => i.rename).length; const withDisambiguation = rows.filter(i => i.disambiguation).length; const withCover = rows.filter(i => i.cover && i.cover.some(c => c.url)).length; const worked = [ totalLinks ? `${totalLinks} link${totalLinks === 1 ? '' : 's'}` : '', withIsrc ? `isrc on ${withIsrc}` : '', withRename ? `rename on ${withRename}` : '', withDisambiguation ? `disambiguation on ${withDisambiguation}` : '', withCover ? `cover on ${withCover}` : '', aliasTotals.total ? `${aliasTotals.ok}/${aliasTotals.total} alias(es) on ${rows.filter(i => i.aliasResults).length}` + (aliasTotals.dupes ? ` (${aliasTotals.dupes} already present)` : '') + (aliasTotals.errors ? ` (${aliasTotals.errors} failed)` : '') : '', ].filter(Boolean).join(', ') || '—'; log('info', `[run] run summary (all times ms)\n${lines.join('\n')}\n` + `total run time: ${fmt(wallMs)} wall clock` + (rows.length > 1 ? ` · ${fmt(sum('totalMs'))} of item work · avg ${fmt(Math.round(wallMs / rows.length))} per item` : '') + '\n' + `totals: ${Object.entries(byStatus).map(([k, v]) => `${v} ${k}`).join(', ')}; slowest submit ${maxSubmit}ms; ${cfg.workers} worker(s)\n` + `worked on: ${worked}` + (maxSubmit > 10000 ? `\nnote: submits this slow usually mean several workers submitted at once — MusicBrainz serialises edit submissions per user, so they queue behind each other. Fewer workers is often no slower overall.` : '')); }, 300); } // #476: with no authenticated session, every worker's /edit navigation gets // redirected to MB's login page — which, unlike the edit page itself, DOES // refuse to be framed, so the iframe shows browser chrome ("Firefox Can't // Open This Page") and each item just times out 15s later as "edit page // never loaded". That's true of every item in the queue at once, so check // once, up front, instead of letting the whole batch burn through it. // The panel's own tab is a normal top-level MB page (never framed), so this // reads the SAME login link MB's header always shows when logged out — // confirmed absent once logged in. function isLoggedIn() { return !document.querySelector('a[href^="/login?"], a[href="/login"]'); } /* #551 (chaban-mb): "Sometimes MusicBrainz will present a small proof-of-work challenge instead of the requested page. If that happens the Falcon session from Harmony is briefly run and after being served the requested page the session is 'lost', i.e. the dialog is not shown again." The challenge is served AT the requested URL — `?falcon=<token>` and all — solves a SHA-256 puzzle in JavaScript, POSTs to /__meb_verify, and only then hands over the real page. So Falcon booted on that interstitial, read the token, DELETED it (parseUrlParam consumes it deliberately, so a reload cannot queue the same batch twice), mounted a panel nobody ever sees, and was navigated away a moment later. The real page then booted with the token already gone — which is exactly the "session is lost" he describes. Recognised by two things that are in the SERVED HTML, and so already parsed by the time a userscript runs at document-idle: the <title>, and the <noscript> sentinel. The <form> and its /__meb_verify action are built by the challenge's own script and are not dependable. ⚠ Do NOT test for "/__meb_verify" appearing in an inline script. Ordinary MusicBrainz pages carry a beacon that posts to that same endpoint, so that check matches EVERY page — measured here: it fired on the release page, the edit page and the site root alike, which would have disabled Falcon everywhere. Caught only because the test asserted the absence of false positives as well as the presence of the fix. */ const VERIFY_TITLE_RE = /^\s*Verifying your browser\s*$/i; const VERIFY_NOSCRIPT_RE = /JavaScript is required to access this page/i; function isVerifyInterstitial(doc) { const d = doc || document; try { if (VERIFY_TITLE_RE.test(d.title || '')) return true; return [...d.querySelectorAll('noscript')].some(n => VERIFY_NOSCRIPT_RE.test(n.textContent || '')); } catch (e) { return false; } // cross-origin frame — not our problem to classify } // #517 (majkinetor, live: "Revert stagger too, it worked before without // it, I am not going to wait 1s between starts") — tried staggering // worker starts (350ms, then widened to 1000ms) to reduce main-thread/ // network contention, but majkinetor confirmed with an actual recording // — worker's edit page visibly loaded and was submittable in 2-3s, yet // Falcon's own "is it loaded" check still sat waiting the full timeout — // that this is a genuine detection bug, not a timing/contention issue // staggering could ever fix. Reverted to spawning all N workers at once, // same as before any of this. function spawnWorkersStaggered(count) { for (let i = 0; i < count; i++) { const card = spawnWorkerCard(); if (card) workerLoop(card); } } // #508 follow-up (majkinetor, live: auto-started a Harmony import with 6 // workers configured, only 1 ever ran): start()'s worker count is // Math.min(cfg.workers, queued-at-that-instant) — correct for a queue // that's fully populated before Start is pressed, but a Harmony release // with ISRC-only recordings (no plain external-link actions to zip them // onto) queues its cover/release item SYNCHRONOUSLY and its recordings // ASYNCHRONOUSLY (resolveIsrcFallback fetches the real tracklist first) — // auto-start fired in between, seeing only 1 queued item, so only 1 // worker ever spawned for the other 11 that arrived a moment later. // Called from addToQueue() whenever it adds anything while a run is // already active, this brings the spawned count up to what the CURRENT // queue actually calls for, regardless of what triggered the addition // (ISRC fallback, a mid-run import, anything). // #536 follow-up (majkinetor, live log): a Harmony import auto-started, ran // for 534ms, reported "release … — skipped, nothing filled in", and finished // — while its cover pick was still measuring and its 10 ISRC-only recordings // had not been resolved yet. Both arrived a second later, to a run that was // already over. topUpWorkers only helps a run that is still going. // // So the queue now says when it is still growing/settling, and auto-start // waits for that instead of racing it. Measuring every cover candidate (the // fix for the wrong-resolution pick) made this window seconds wide; before // that it was often zero, which is why it took this long to show up. const _settling = new Set(); function trackSettling(promise) { if (!promise || typeof promise.then !== 'function') return promise; _settling.add(promise); promise.catch(() => {}).finally(() => _settling.delete(promise)); return promise; } async function whenQueueSettles(timeoutMs) { const deadline = Date.now() + (timeoutMs || 20000); while (_settling.size && Date.now() < deadline) { await Promise.race([ Promise.allSettled([..._settling]), wait(500), ]); } if (_settling.size) dbg('[queue]', `${_settling.size} enrichment(s) still in flight after the settle wait — starting anyway`); } function topUpWorkers() { if (!running) return; const remaining = queue.filter(i => i.status === 'queued' && !_disabledTypes.has(i.entityType)).length; const target = Math.min(cfg.workers, remaining); const toSpawn = target - workerCards.length; if (toSpawn <= 0) return; log('info', `queue grew — topping up from ${workerCards.length} to ${target} worker(s)`); spawnWorkersStaggered(toSpawn); } function start() { if (running) return; if (!isLoggedIn()) { alert(`${NAME}: you're not logged into MusicBrainz on this tab. Log in, then reload this page and Start again — an unauthenticated run can't submit any edits.`); log('warn', 'refused to start — not logged into MusicBrainz'); return; } if (!queue.some(i => i.status === 'queued')) { log('warn', 'nothing queued'); return; } // #497: everything queued is a toggled-off type — nothing would actually run. if (!queue.some(i => i.status === 'queued' && !_disabledTypes.has(i.entityType))) { log('warn', 'nothing queued that isn\'t excluded by a toggled-off type chip'); return; } running = true; _runStartedAt = Date.now(); if (_closedClean) { _closedClean = false; removeLauncher(); ensureLauncher(); } // back to the usual icon // one log per run — majkinetor: "I DON'T WANT LOGS FROM OTHER RUNS" newSession(`${queue.filter(i => i.status === 'queued').length} queued, ${cfg.workers} worker(s), ${location.href}`); // #508 follow-up (majkinetor, live: "BTW, list options in the log, I had // Add covers only when there aren't any enabled here") — a log dump // alone doesn't say which toggles were active, which matters for // reading a run's behavior back later (this exact bug report needed it). log('info', `options: hide-icon=${cfg.hideLauncher ? 'on' : 'off'}, cover-only-if-none=${cfg.coverOnlyIfNone ? 'on' : 'off'}, skip-harmony-covers=${cfg.skipHarmonyCovers ? 'on' : 'off'}, auto-send-harmony=${cfg.autoSendFromHarmony ? 'on' : 'off'}, auto-start-harmony=${cfg.autoStartHarmonyImport ? 'on' : 'off'}, picard=tport ${cfg.picardPort}, auto-send ${cfg.sendToPicard ? 'on' : 'off'}`); suspendNameLookups(); // cosmetic lookups must not eat the workers' rate-limit budget startHeartbeat(); const need = Math.min(cfg.workers, queue.filter(i => i.status === 'queued').length); log('info', `starting ${need} worker(s) for ${queue.filter(i => i.status === 'queued').length} queued item(s)`); Log.keep(); // a run that got this far is worth reading back spawnWorkersStaggered(need); updateRunBtn(); renderProgress(); } // Asked for by the falcon:run that started this run (closeWhenDone): hide the panel when // every item settled as done or skipped. A failed, partial or manual one keeps it open. let _closeWhenDone = false, _closedClean = false; function maybeClosePanelAfterRun() { if (!_closeWhenDone) return; _closeWhenDone = false; const bad = queue.filter(i => ['failed', 'partial', 'manual'].includes(i.status)).length; if (bad) { log('info', `panel left open: ${bad} item(s) not fully done`); return; } if (panel) panel.style.display = 'none'; _closedClean = true; removeLauncher(); ensureLauncher(); // rebuilt green, as after #588's reload log('info', 'panel closed: the run that asked for it finished with every item done'); } function stop() { _closeWhenDone = false; running = false; stopHeartbeat(); resumeNameLookups(); resolveMissingNames(); log('info', 'stopping — in-flight items finish, no new ones start'); updateRunBtn(); renderProgress(); } /* ════════════════════════ UI ════════════════════════ */ let launcher = null; // #508 (majkinetor): "Hide MB icon" — the floating corner launcher is // optional; the panel is still reachable via Ctrl+Alt+F either way (that // shortcut listener is independent of this button existing at all). function removeLauncher() { if (launcher) { launcher.remove(); launcher = null; } } // #675 (majkinetor): the launcher stands down on MusicBrainz's own release // editor — /release/add and /release/<mbid>/edit. That full-screen Knockout // app is Apollo Editor's territory, and Falcon can't act on the edit the human // is making there (it drives release workers inside iframes, where @noframes // keeps this script out), so a second corner icon beside Apollo's is only // clutter. The panel is still one Ctrl+Alt+F away. function onReleaseEditorPage() { return /^\/release\/(add|[0-9a-f-]{36}\/edit)\/?$/i.test(location.pathname); } function ensureLauncher() { if (cfg.hideLauncher || onReleaseEditorPage()) { removeLauncher(); return; } if (launcher) return; launcher = document.createElement('button'); launcher.type = 'button'; launcher.id = 'falcon-launcher'; launcher.title = `${NAME} — bulk MusicBrainz editor (Ctrl+Alt+F)\nRight-click: settings`; launcher.dataset.mbCorner = 'br'; launcher.dataset.mbCornerOrder = '90'; // lowest priority: furthest from the corner // #588: a page Falcon reloaded after a clean run wears a green icon instead // of the usual translucent one — majkinetor's ask was to be able to pick // that tab out of many. It is also the only visible trace that the reload // happened at all, since the page it lands on looks like any other. // The same green when the panel closed itself after a clean run (closeWhenDone), so // that tab stands out the same way; it lasts until the next run starts. const done = reloadedAfterImportHere() || _closedClean; const rest = done ? '1' : '.55'; /* ⚠ Literal colours with the tokens only as an override, NOT var() alone: MBU_TOKENS is injected by ensurePanel(), and the launcher exists long before any panel does — so on a page where the panel was never opened every var() here resolves to nothing and the "green" icon comes out fully transparent. Measured exactly that way before this line said so. A solid fill rather than the pale --mbu-ok-bg tint, because the ask is to spot this tab among many at a glance. */ launcher.style.cssText = 'position:fixed;right:14px;bottom:14px;z-index:2147483000;width:40px;height:40px;border-radius:50%;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;' // literal, not var(--mbu-ok): once the panel has injected the tokens, the dark // theme's --mbu-ok is a darker mix, and a self-closed panel's icon came out a // different green from a reloaded page's + (done ? 'background:#1f9d6b;color:#fff;box-shadow:0 0 0 2px #9bd3b6,0 2px 10px rgba(31,157,107,.45);' : 'background:transparent;box-shadow:none;') + `transition:background .15s,transform .1s;opacity:${rest}`; if (done) launcher.title = `${NAME} — ${_closedClean ? 'closed itself' : 'this page was reloaded'} after a clean run (Ctrl+Alt+F for the log)\nRight-click: settings`; const img = document.createElement('img'); img.src = ICON_URL; img.alt = ''; img.style.cssText = 'width:34px;height:34px;display:block;pointer-events:none'; launcher.appendChild(img); launcher.onmouseenter = () => { launcher.style.transform = 'scale(1.08)'; launcher.style.opacity = '1'; }; launcher.onmouseleave = () => { launcher.style.transform = 'scale(1)'; launcher.style.opacity = rest; }; launcher.onclick = () => togglePanel(); launcher.oncontextmenu = e => { e.preventDefault(); showPanel(); setTab('options'); }; // #695: right-click on every corner launcher opens its settings document.body.appendChild(launcher); mbRestackCorner('br'); } let panel = null, tab = 'queue'; // Standing rule across every script here (Art Station is the reference): a // toolbar's labelled buttons collapse to icon-only rather than wrapping, with // the tooltips carrying the meaning. Falcon needs it more than most — the // panel is a 460px box the user can drag-resize and maximize, so its three // bars go from roomy to cramped constantly. // // Widths are summed rather than read off scrollWidth: these bars use flex:1 // spacers (the progress bar, `margin-left:auto`), which absorb the overflow // and make scrollWidth/offsetTop-based detection report a comfortable fit // right up until it wraps. function fitBar(bar) { if (!bar) return; bar.classList.remove('falcon-compact'); // measure with full labels const kids = [...bar.children]; const gap = parseFloat(getComputedStyle(bar).gap) || 8; let need = gap * Math.max(0, kids.length - 1); kids.forEach(el => { if (!el.id || el.id !== 'falcon-progress-wrap') need += el.id === 'falcon-type-chips-in' ? el.scrollWidth : el.offsetWidth; }); // the chips shrink to fit: count what they hold bar.classList.toggle('falcon-compact', need > bar.clientWidth - 20); // minus h-padding } function fitBars() { if (!panel) return; panel.querySelectorAll('.falcon-bar').forEach(fitBar); } function ensurePanel() { if (panel) return; const style = document.createElement('style'); // tokens first, then the shared components, then Falcon's own rules — the // token block has to be in the same sheet or every var() below resolves to // nothing (#562) style.textContent = MBU_TOKENS + MBU_UI_CSS + [ // #532 follow-up (majkinetor): "disamb. comment hint looks like its normal // text". Both meta inputs are styled identically — the ISRC one only READ // as a hint because it happens to be monospace, so the disambiguation one // looked like a value already typed in. Placeholders say so explicitly now // (and MusicBrainz's own page CSS was darkening the browser default). '#falcon-panel input::placeholder{color:var(--mbu-text-weak);font-style:italic;opacity:1}', '.falcon-bar.falcon-compact .falcon-bt{display:none}', // #663: the open tab is marked; the select-all box and the selection count head the rows '#falcon-hdr .falcon-tab{border-bottom:2px solid transparent!important;padding:2px 4px}', '#falcon-hdr .falcon-tab.falcon-tab-on{border-bottom-color:var(--mbu-warn)!important;font-weight:700}', // #705: the shared log viewer fills the Log tab '#falcon-body-log>.mbu-logemb{flex:1 1 auto;min-height:0}', '.falcon-selhead{display:inline-flex;align-items:center;gap:6px}', 'th.falcon-selhead{display:table-cell}', // the titles start where the values' text does: the cell's 4px plus the input's 7px padding and 1px border '.falcon-grid th:not(.falcon-selhead){padding-left:12px!important}', // the header icon sits over the row icons: centred, no title padding '.falcon-grid th.falcon-videohead{padding:6px 0!important;text-align:center}', '.falcon-grid th.falcon-videohead>input{margin:0!important;width:18px!important;height:18px!important;vertical-align:middle}', '.falcon-selhead .falcon-selcount{color:var(--mbu-warn);font-weight:700;font-size:11px}', '.falcon-xp{border:none;background:none!important;cursor:pointer;color:var(--mbu-text-dim);font-size:13px;line-height:1;width:20px;height:20px;padding:0;display:inline-flex;align-items:center;justify-content:center}', '.falcon-lbl .falcon-lplus{border:none;background:none!important;cursor:pointer;color:var(--mbu-ok);font-size:13px;line-height:1;padding:0 0 0 5px}', '.falcon-grid .falcon-gsum{font-size:10px;color:var(--mbu-text-weak);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.falcon-grid tr.falcon-sub>td{padding:4px 4px 8px 0!important}', '.falcon-grid .falcon-form{grid-template-columns:92px minmax(0,1fr);column-gap:4px;padding:2px 0 0}', '.falcon-grid td,.falcon-grid th{border-bottom-color:var(--mbu-border)!important}', '#falcon-queue-list{scrollbar-width:thin}', '#falcon-queue-list::-webkit-scrollbar{width:8px;height:8px}', '#falcon-queue-list::-webkit-scrollbar-thumb{background:var(--mbu-border);border-radius:4px}', '.falcon-lhead{position:sticky;top:0;z-index:1;background:var(--mbu-bg);padding:4px 0;border-bottom:1px solid var(--mbu-border)}', '.falcon-als{display:flex;flex-direction:column;gap:3px;min-width:0;align-items:stretch}', '.falcon-al{display:grid;grid-template-columns:minmax(0,1fr) 64px 18px;gap:6px;align-items:center}', '.falcon-al input.falcon-alias-loc{font-family:var(--mbu-font-mono);font-size:10.5px!important}', '.falcon-al .falcon-alias-del{border:none;background:none!important;cursor:pointer;color:var(--mbu-text-weak);padding:0}', '.falcon-alias-plus{align-self:flex-start;border:1px solid var(--mbu-border);background:none!important;color:var(--mbu-ok);cursor:pointer;border-radius:9px;font-size:11px;line-height:1;padding:1px 0;width:20px}', '.falcon-ln{grid-template-columns:minmax(0,1fr) auto 20px 18px!important}', '.falcon-ln a.falcon-lst{text-align:center;text-decoration:none;font-size:11px}', '.falcon-ln-new>input.falcon-link-new{grid-column:1 / -1}', '.falcon-ltb{display:inline-flex;align-items:center;padding:0 3px 0 0!important}', '.falcon-lt select{appearance:none;-webkit-appearance:none;border:none!important;background:transparent!important;color:inherit;font:inherit;font-size:9.5px;padding:0 0 0 6px!important;cursor:pointer;max-width:130px;min-width:0}', '.falcon-lt{gap:2px!important}', '.falcon-lt .falcon-ltb{padding:0 2px 0 0!important;flex:0 0 auto}', '.falcon-lt .falcon-ltb button{padding:0 1px 0 2px!important}', '.falcon-ltb-new{outline:1px dashed var(--mbu-ok)}', '.falcon-lauto{background:none!important;color:var(--mbu-text-weak)!important;font-style:italic}', '.falcon-link-type-add{border:1px solid var(--mbu-border);background:none!important;color:var(--mbu-ok);cursor:pointer;border-radius:9px;font-size:11px;line-height:1;padding:1px 0;width:20px}', '.falcon-ln input.falcon-link-url{padding:2px 6px!important;font-size:11px}', '.falcon-lt span button{border:none;background:none!important;color:inherit;cursor:pointer;padding:0 0 0 3px;font-size:9px;line-height:1}', '.falcon-ln .falcon-link-del{border:none;background:none!important;cursor:pointer;color:var(--mbu-text-weak);padding:0}', // #663: the open row is a labelled form (list view), or every row is one grid line '.falcon-form{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:5px 10px;align-items:center;padding:4px 6px 9px 30px;font-size:11px}', '.falcon-form>.falcon-lbl{color:var(--mbu-text-weak);text-align:right;font-size:10.5px;white-space:nowrap}', '.falcon-form input[type=text],.falcon-grid input[type=text]{width:100%;box-sizing:border-box;min-width:0;font-size:11px;padding:3px 7px;border:1px solid var(--mbu-border);border-radius:4px}', '.falcon-form input.falcon-isrc-input,.falcon-grid input.falcon-isrc-input,.falcon-form input.falcon-barcode-input{font-family:var(--mbu-font-mono)}', '.falcon-form input.falcon-renamed{border-color:var(--mbu-accent)}', '.falcon-chips{display:flex;align-items:center;gap:5px;flex-wrap:wrap;min-width:0}', '.falcon-chips>input[type=text]{flex:1 1 140px;width:auto}', '.falcon-chip{display:inline-flex;align-items:center;gap:3px;background:var(--mbu-bg-raised);border:1px solid var(--mbu-border);border-radius:10px;padding:0 3px 0 7px;max-width:100%;white-space:nowrap}', '.falcon-chip .falcon-loc{font-size:9.5px;color:var(--mbu-text-weak)}', '.falcon-chip button{border:none;background:none!important;cursor:pointer;color:var(--mbu-text-weak);font-size:10px;line-height:1;padding:2px 3px}', '.falcon-links{display:flex;flex-direction:column;gap:2px;min-width:0}', '.falcon-ln{display:grid;grid-template-columns:12px 62px minmax(0,1fr) auto;gap:6px;align-items:center}', '.falcon-ln .falcon-svc{font-size:9.5px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--mbu-text-weak);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.falcon-ln a{color:var(--mbu-info);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.falcon-lt{display:flex;gap:3px;flex-wrap:wrap;justify-content:flex-end}', '.falcon-lt span{font-size:9.5px;padding:0 7px;border-radius:9px;background:var(--mbu-ok-bg);color:var(--mbu-ok);white-space:nowrap}', '.falcon-more{border:none;background:none!important;color:var(--mbu-accent-text);cursor:pointer;font-size:10.5px;padding:0;align-self:flex-start}', '.falcon-video-lbl{display:inline-flex;align-items:center;gap:6px;color:var(--mbu-text-dim);cursor:pointer}', '.falcon-grid{width:100%;border-collapse:collapse;font-size:11px;table-layout:fixed}', '.falcon-grid th{position:sticky;top:0;z-index:1;background:var(--mbu-bg);text-align:left;font-weight:500;font-size:9.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--mbu-text-weak);padding:6px 4px;border-bottom:1px solid var(--mbu-border);white-space:nowrap}', '.falcon-grid td{overflow:hidden;padding:2px 4px;border-bottom:1px solid var(--mbu-border-soft);vertical-align:middle}', '.falcon-grid td:has(>.falcon-video-input){padding:2px 0;text-align:center;overflow:visible}', '.falcon-grid input.falcon-video-input{margin:0!important;flex:none;width:18px!important;height:18px!important}', // #663: the checked Video box was clipped on its right '.falcon-grid input[type=text]{border-color:transparent;background:transparent!important}', '.falcon-grid input[type=text]:hover{border-color:var(--mbu-border)}', '.falcon-grid input[type=text]:focus{border-color:var(--mbu-accent);background:var(--mbu-bg-sunken)!important}', '.falcon-grid .falcon-chips{flex-wrap:nowrap;overflow:hidden}', '.falcon-grid .falcon-chips>input[type=text]{flex:1 1 70px;min-width:60px}', '.falcon-grid-links{border:none;background:none!important;color:var(--mbu-info);cursor:pointer;font-size:11px;padding:2px 4px;white-space:nowrap}', '.falcon-grid tr.falcon-sub>td{padding:4px 8px 8px 40px;background:var(--mbu-bg-sunken)}', '.falcon-bar button{display:inline-flex;align-items:center;gap:5px;white-space:nowrap}', // Borderless (majkinetor: "its too intrusive"). A row of boxed native // buttons competes with the queue list, which is the actual content. The // affordance moves to hover, so a button still reads as a button when you // go for it without drawing a frame around itself the rest of the time. // #falcon-run keeps its solid fill — it sets background inline, which wins // over this, and the primary action should stay the loud one. // min-height matters: the native border was quietly providing 4px of the // hit area, so dropping it shrank these to 16px tall. Targets stay // generous whether or not there's a box drawn around them (#419). '.falcon-bar button{background:none;border:none;border-radius:4px;color:inherit;padding:3px 8px;min-height:22px}', '.falcon-bar button:hover:not(:disabled){background:rgba(0,0,0,.08)}', '.falcon-bar button:active:not(:disabled){background:rgba(0,0,0,.14)}', // disabled used to read as a greyed box; with no box it has to fade instead '.falcon-bar button:disabled{opacity:.4;cursor:default}', '.falcon-bar .falcon-bi{display:inline-flex;line-height:1}', // with the label gone the button would shrink to a sliver of glyph, so // give the icon-only state a real hit area (no tiny targets — #419). '.falcon-bar.falcon-compact button{min-width:26px;justify-content:center}', // #519 (majkinetor): "Just like with CH and Apollo (#412) lets have // progress bar flashing while operation is in process as a visual // marker that operation is in progress." Same pulsing-background idea // as #412's toolbars. // #519 follow-up (majkinetor, live: "It doesn't work") — the pulse was // on #falcon-progress-bar, the FILL, which sits at 0% width (0px, no // visible box at all) for as long as nothing has finished yet — a // filter on an invisible box is itself invisible. Moved to the TRACK // behind it, which is always full width regardless of progress %. // #546 (majkinetor): "On a release with lots of recordings, it appears // nothing happen when you use 'Add from release' … it takes around 20s // to load all. The only way to tell is that Add from release button is // grayed - nothing else happens, including log." A disabled button reads // as "not available", not as "working" — so a busy toolbar button spins. // The glyph is hidden with font-size:0 rather than replaced, so the // icon-only collapsed state (#419/toolbar rule) keeps its hit area. '@keyframes falcon-spin{to{transform:rotate(360deg)}}', '.falcon-bar button.falcon-busy{opacity:1;cursor:progress}', '.falcon-bar button.falcon-busy .falcon-bi{font-size:0;width:11px;height:11px;box-sizing:border-box;' + 'border:2px solid rgba(27,42,74,.25);border-top-color:#1b2a4a;border-radius:50%;animation:falcon-spin .7s linear infinite}', '@keyframes falcon-progress-pulse{0%,100%{background:var(--mbu-bg-sunken)}50%{background:var(--mbu-ok-bg)}}', '#falcon-progress-track.falcon-running{animation:falcon-progress-pulse 1.1s ease-in-out infinite}', ].join('\n'); document.head.appendChild(style); panel = document.createElement('div'); panel.id = 'falcon-panel'; // #495 (majkinetor, live: "if I first maximize the page, it doesn't go // empty... in unmaximized state it is simply moved off window" — // reproduced: the panel's own box was rendering as short as ~170px, // squeezing every row onto one sliver instead of showing normally). // Root cause: `max-height` alone doesn't give a `display:flex` column an // actual height to distribute — without a real `height`, #falcon-queue-list's // `flex:1` has no space to grow into and the whole panel just shrinks to // its non-flexible children's content size, which collapses hard on a // smaller viewport/queue. A real `height` alongside the existing // `max-height` cap fixes it (viewport-capped either way, just no longer // degenerate on the way there). // #508 (majkinetor): "make window resizable" — native CSS resize (a drag // grip in the bottom-right corner); needs overflow != visible, which the // panel already has. min-width/min-height keep it from collapsing into // the flex column laid out below. // #513 (majkinetor): "Make window wider if needed to fit the chips" — the // header can now carry up to 4 status chips (failed/partial/manual/ // skipped, once #513's own follow-up added skipped to the list) — even // 600px clipped the 4th chip against the tab buttons (measured live). panel.style.cssText = 'display:none;flex-direction:column;position:fixed;z-index:2147483647;left:50%;top:50%;transform:translate(-50%,-50%);width:700px;max-width:90vw;height:70vh;max-height:70vh;min-width:340px;min-height:320px;resize:both;background:var(--mbu-bg);color:var(--mbu-text);border-radius:8px;font:12px -apple-system,Segoe UI,Arial,sans-serif;box-shadow:0 8px 28px rgba(0,0,0,.28);border:1px solid var(--mbu-border);overflow:hidden'; panel.innerHTML = ` <div id="falcon-hdr" style="display:flex;align-items:center;gap:6px;padding:8px 10px;background:#1b2a4a;color:#fff;cursor:move;user-select:none"> <span style="display:flex;color:var(--mbu-warn)">${ICON}</span> <span style="flex:0 0 auto;font-weight:700">${NAME}</span> <div id="falcon-status-chips" style="display:flex;gap:5px;flex:1;overflow-x:auto;overflow-y:hidden;min-width:0"></div> <button type="button" id="falcon-tab-queue" class="falcon-tab" style="background:none;border:none;color:#fff;cursor:pointer;font:inherit">Queue</button> <button type="button" id="falcon-tab-workers" class="falcon-tab" style="background:none;border:none;color:#fff;cursor:pointer;font:inherit">Workers</button> <button type="button" id="falcon-tab-log" class="falcon-tab" style="margin-right:14px;background:none;border:none;color:#fff;cursor:pointer;font:inherit">Log</button> <button type="button" id="falcon-tab-options" class="falcon-tab" title="Options" style="background:none;border:none;color:#fff;cursor:pointer;font:inherit;font-size:14px">${MBU_CFG_ICON}</button> <button type="button" id="falcon-maximize" title="Maximize" style="background:none;border:none;color:#fff;cursor:pointer;font:inherit;font-size:14px">⛶</button> <button type="button" id="falcon-close" style="background:none;border:none;color:#fff;cursor:pointer;font:inherit;font-size:14px">✕</button> </div> <div id="falcon-body-queue" style="padding:0;overflow:hidden;flex:1;display:flex;flex-direction:column"> <div id="falcon-queue-toolbar" class="falcon-bar" style="display:flex;align-items:center;gap:10px;padding:6px 10px;border-bottom:1px solid var(--mbu-border);font-size:11px;color:var(--mbu-text);flex:0 0 auto"> <button type="button" id="falcon-add-page" title="Add this release's entities to the queue as empty rows to edit" style="padding:2px 8px;cursor:pointer;display:none"><span class="falcon-bi">+</span><span class="falcon-bt">Add from release</span></button> <button type="button" id="falcon-import" title="Load a queue from a JSON file" style="padding:2px 8px;cursor:pointer"><span class="falcon-bi">↓</span><span class="falcon-bt">Import</span></button> <button type="button" id="falcon-export" title="Save the queue — and each item's outcome — to a JSON file" style="margin-left:auto;padding:2px 8px;cursor:pointer"><span class="falcon-bi">↑</span><span class="falcon-bt">Export</span></button> <input type="file" id="falcon-import-file" accept="application/json,.json" style="display:none" /> </div> <div id="falcon-type-chips" class="falcon-bar" style="display:flex;align-items:center;gap:6px;padding:6px 10px;border-bottom:1px solid var(--mbu-border);font-size:11px;flex:0 0 auto"> <span id="falcon-type-chips-in" style="display:flex;gap:6px;flex-wrap:nowrap;overflow:hidden;min-width:0"></span> <button type="button" id="falcon-remove-selected" disabled title="Remove the selected rows from the queue" style="margin-left:auto;padding:2px 8px;cursor:pointer"><span class="falcon-bi">🗑</span><span class="falcon-bt">Remove selected</span></button> <button type="button" id="falcon-view-toggle" style="padding:2px 8px;cursor:pointer" title="Switch between the list and the grid"><span class="falcon-bi">▦</span><span class="falcon-bt">Grid</span></button> </div> <div id="falcon-queue-list" style="overflow:auto;flex:1;padding:0 10px"></div> <div id="falcon-notepanel" style="display:none;padding:8px 10px;border-top:1px solid var(--mbu-border);background:var(--mbu-bg-sunken);flex:0 0 auto"> <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:5px"> <span style="font-weight:600;font-size:11px;flex:0 0 auto">Batch edit note</span> <span style="color:var(--mbu-text-weak);font-size:10.5px;flex:1 1 auto;min-width:0">Appended to every edit this run makes — say why, so a voter can see it.</span> </div> <textarea id="falcon-note-text" rows="3" placeholder="e.g. Conforming release group titles to the series' standard" style="width:100%;box-sizing:border-box;font:12px var(--mbu-font);padding:5px 7px;border:1px solid var(--mbu-border);border-radius:4px;background:var(--mbu-bg);color:var(--mbu-text);resize:vertical"></textarea> </div> <div id="falcon-queue-bottom" class="falcon-bar" style="display:flex;gap:8px;align-items:center;padding:8px 10px;border-top:1px solid var(--mbu-border);flex:0 0 auto"> <div id="falcon-progress-wrap" style="flex:1;display:flex;align-items:center;gap:6px;min-width:0"> <div id="falcon-progress-track" style="flex:1;height:8px;background:var(--mbu-bg-sunken);border-radius:4px;overflow:hidden;min-width:40px"> <div id="falcon-progress-bar" style="height:100%;width:0%;background:#2e9e5b;transition:width .2s"></div> </div> <span id="falcon-progress-text" style="color:var(--mbu-text-dim);font-size:10px;white-space:nowrap;flex:0 0 auto"></span> </div> <button type="button" id="falcon-note-btn" title="Batch edit note — appended to every edit in this run" style="flex:0 0 auto;padding:4px 10px;cursor:pointer"><span class="falcon-bi">✎</span><span class="falcon-bt">Note</span></button> <div id="falcon-run-split" style="flex:0 0 auto;display:flex;position:relative"> <button type="button" id="falcon-run" title="Start processing the queue" style="padding:4px 12px;font-weight:700;cursor:pointer;background:#1b2a4a;color:#fff;border:none;border-radius:4px 0 0 4px;justify-content:center"><span class="falcon-bi">▶</span><span class="falcon-bt">Start</span></button> <button type="button" id="falcon-run-more" title="More: Retry failed" style="padding:4px 8px;cursor:pointer;background:#1b2a4a;color:#fff;border:none;border-left:1px solid rgba(255,255,255,.25);border-radius:0 4px 4px 0;font-size:10px">▾</button> <div id="falcon-run-menu" style="display:none;position:absolute;right:0;bottom:calc(100% + 4px);z-index:5;background:var(--mbu-bg-raised);border:1px solid var(--mbu-border);border-radius:6px;box-shadow:0 4px 14px rgba(0,0,0,.25);padding:4px;min-width:150px"> <button type="button" id="falcon-retry-failed" disabled title="Re-queue every failed/partial item for another attempt — useful when MusicBrainz was just slow, not when an item is genuinely broken" style="display:flex;width:100%;gap:6px;padding:4px 8px;cursor:pointer;border:none;background:none;text-align:left;color:var(--mbu-text)"><span>↻</span><span>Retry failed</span></button> </div> </div> </div> <div id="falcon-cover-warning" style="display:none;padding:5px 10px;background:var(--mbu-warn-bg);color:var(--mbu-warn);font-size:10.5px;border-top:1px solid var(--mbu-warn);flex:0 0 auto"></div> </div> <div id="falcon-body-workers" style="position:absolute;left:-100000px;top:0;width:1200px;height:800px;overflow:auto;display:block;padding:8px 10px;"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;color:var(--mbu-text-weak)"> <span style="flex:1">Live worker iframes — each one loads an entity's edit page, fills it, submits, then moves to the next queued item.</span> <label style="display:flex;align-items:center;gap:5px;flex:0 0 auto" title="How large each worker card is drawn — bigger cards make it easier to read what a worker is actually showing"> <span>size</span> <input type="range" id="falcon-worker-size" min="200" max="900" step="20" style="width:110px" /> <span id="falcon-worker-size-val" style="width:34px;text-align:right"></span> </label> </div> <div id="falcon-workers" style="display:flex;gap:8px;flex-wrap:wrap"></div> <div id="falcon-workers-empty" style="display:none;color:var(--mbu-text-weak);padding:8px 0">No active workers right now — idle ones are hidden here. Click ▶ Start to begin.</div> </div> <div id="falcon-body-log" style="display:none;padding:0;overflow:hidden;flex:1;flex-direction:column"></div> <div id="falcon-body-options" style="display:none;overflow:auto;flex:1;padding:10px;flex-direction:column;gap:10px"> <div style="display:flex;align-items:baseline;gap:8px;padding-bottom:8px;border-bottom:1px solid var(--mbu-border)"> <span style="flex:1;font-weight:700;color:var(--mbu-info)">${NAME} <span style="opacity:.6;font-weight:400">v${scriptVersion()}</span></span> <a href="${HELP_URL}" target="_blank" rel="noopener" style="color:var(--mbu-info);text-decoration:none;font-weight:600">? Help</a> </div> <label style="display:flex;align-items:center;gap:7px;cursor:pointer" title="The floating corner icon is optional — the panel is always reachable via Ctrl+Alt+F either way"> <input type="checkbox" id="falcon-opt-hide-launcher" /> <span>Hide ${NAME} icon</span> </label> <label style="display:flex;align-items:center;gap:7px;cursor:pointer" title="If a release already has cover art, skip adding another instead of uploading blind — Harmony offers cover art whether or not the release already has some"> <input type="checkbox" id="falcon-opt-cover-only-if-none" /> <span>Add covers only when there aren't any</span> </label> <!-- majkinetor: "Group harmony options under 1 section". Three of these only ever apply to a batch arriving from Harmony, so they read as a set rather than as three unrelated toggles that happen to repeat the word. The heading carries the context, so the labels drop it. --> <fieldset style="border:1px solid var(--mbu-border);border-radius:5px;margin:2px 0 0;padding:6px 10px 8px;display:flex;flex-direction:column;gap:10px"> <legend style="padding:0 5px;font-weight:600;color:var(--mbu-info);font-size:11px"> <a href="https://harmony.pulsewidth.org.uk/" target="_blank" rel="noopener" style="color:inherit;text-decoration:none" title="Harmony — the importer these options apply to">Harmony</a> </legend> <label style="display:flex;align-items:center;gap:7px;cursor:pointer" title="Ignore the cover art Harmony offers. The URL Harmony gives is whatever the provider's API returns, which is often not the largest that provider will serve, so if you upload covers with ECAU or Art Station instead, this keeps Falcon out of it — links, ISRCs, disambiguations and aliases still come through"> <input type="checkbox" id="falcon-opt-skip-harmony-covers" /> <span>Ignore cover art</span> </label> <label style="display:flex;align-items:center;gap:7px;cursor:pointer" title="Press 'Send to Falcon' for you once a Harmony import finishes and its actions have settled, instead of waiting for you to click it. A short countdown shows on the button first — click it to cancel. With 'Auto start import' below also on, a finished Harmony import carries straight through to a finished Falcon run."> <input type="checkbox" id="falcon-opt-auto-send-harmony" /> <span>Auto send</span> </label> <label style="display:flex;align-items:center;gap:7px;cursor:pointer" title="Start processing the queue immediately after 'Send to Falcon' from Harmony, instead of waiting for you to click Start"> <input type="checkbox" id="falcon-opt-auto-start-harmony" /> <span>Auto start import</span> </label> <label style="display:flex;align-items:center;gap:7px;cursor:pointer" title="When a Harmony import finishes with nothing failed, reload the MusicBrainz release page so it shows what Falcon just added instead of the state it had before the run. The run's log survives the reload, and Falcon's corner icon turns green on a page it reloaded so you can tell which tab it was among many."> <input type="checkbox" id="falcon-opt-reload-after-import" /> <span>Reload release page after import without errors</span> </label> <label style="display:flex;align-items:center;gap:7px;cursor:pointer" title="On: 'Send to Falcon' opens MusicBrainz in a new tab (today's behavior). Off: navigates this same Harmony tab to MusicBrainz instead"> <input type="checkbox" id="falcon-opt-open-new-tab" /> <span>Open in new tab</span> </label> <label style="display:flex;align-items:center;gap:7px;cursor:pointer" title="When a run finishes, hand the release to Picard by itself. The MusicBrainz URL always carries ?tport= with this port, so MusicBrainz's own green tagger button is there to click either way — untick this to import only the releases you choose. Picard must be running with 'Browser integration' enabled, listening on this port (its default is 8000)."> <input type="checkbox" id="falcon-opt-picard" /> <span>Automatically send to Picard using port</span> <input type="number" id="falcon-opt-picard-port" min="1" max="65535" style="width:62px" /> </label> </fieldset> <label style="display:flex;align-items:center;gap:7px" title="How many entities are processed at once — each worker is its own iframe submitting independently"> <span>Workers</span> <input type="number" id="falcon-worker-count" min="1" max="6" style="width:40px" /> </label> <label style="display:flex;align-items:center;gap:7px" title="Each run keeps its own log, selectable later from the Log tab's history dropdown — older ones beyond this count are dropped"> <span>Keep last</span> <input type="number" id="falcon-opt-log-history-count" min="1" max="100" style="width:48px" /> <span>run logs</span> </label> </div>`; document.body.appendChild(panel); // the panel is drag-resizable as well as maximizable, so the bars have to // re-fit continuously, not just on the events we happen to hook. try { new ResizeObserver(() => fitBars()).observe(panel); } catch (e) {} document.getElementById('falcon-close').onclick = () => { panel.style.display = 'none'; }; document.getElementById('falcon-maximize').onclick = toggleMaximize; const wIn = document.getElementById('falcon-worker-count'); wIn.value = cfg.workers; wIn.onchange = () => { cfg.workers = wIn.value; wIn.value = cfg.workers; }; // ── JSON import / export ──────────────────────────────────────────────── // (majkinetor: "lets remove + and add json import/export instead") // // The paste box this replaces only understood Falcon's own `mbid,url` line // format, which nothing else produces. A queue is far more useful as a file: // it outlives the tab, it can be prepared elsewhere, and — because the // export carries each item's STATUS and per-url outcome — a partly-finished // run can be handed to someone else, kept as a record, or re-imported to // retry just the parts that failed. // // parsePaste stays: the `?falcon=` url and the Harmony button still use it. document.getElementById('falcon-export').onclick = () => { if (!queue.length) { log('warn', 'nothing to export — the queue is empty'); return; } const payload = { falcon: scriptVersion(), exported: new Date().toISOString(), ...(batchNote() ? { note: batchNote() } : {}), items: queue.map(i => { const item = { entityType: i.entityType, mbid: i.mbid, name: i.name || null, note: i.note || '', urls: i.urls.map(u => ({ url: u.url, linkTypeId: u.linkTypeId || null })), status: i.status, error: i.error || '', urlResults: i.urlResults || null, }; // #496: disambiguation (recording) and cover[] (release) are // type-specific — only including them where the type actually uses // them, same reasoning as isrcs[] just below. if (DISAMBIGUATABLE.has(i.entityType)) item.disambiguation = i.disambiguation || ''; // #571: only written when it actually carries one. Exporting an empty // `rename` on every row would turn the export into a template that // looks like it renames things, and a round-trip through Export → // Import must never acquire an edit nobody asked for. if (RENAMEABLE.has(i.entityType) && (i.rename || '').trim()) item.rename = i.rename.trim(); if (i.entityType === 'release' && i.barcode) item.barcode = i.barcode; if (i.entityType === 'release' && (i.labels || []).length) item.labels = i.labels.map(l => ({ mbid: l.mbid, name: l.name, catno: l.catno, ...(l.add ? { add: true } : {}) })); if (i.entityType === 'recording') { item.isrcs = i.isrcs || []; item.video = i.video === true; } // #535: aliases exist on every type, so they are exported whenever // the item has any — and the export doubles as the JSON template. if ((i.aliases || []).length) item.aliases = i.aliases.map(a => ({ name: a.name, locale: a.locale || '', type: a.type || '', primary: !!a.primary, sortName: a.sortName || '', begin: a.begin || '', end: a.end || '', ended: !!a.ended })); if (i.aliasResults) item.aliasResults = i.aliasResults; if (i.source) item.source = i.source; // #494/#496: cover art is a release item's whole payload — an // array of {url, comment, type, candidates}, one entry per image. if (i.entityType === 'release') item.cover = (i.cover || []).map(c => ({ url: c.url || '', comment: c.comment || '', type: c.type || 'Front', candidates: c.candidates || [] })); return item; }), }; const name = `falcon-queue-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.json`; const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })); a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => { try { URL.revokeObjectURL(a.href); } catch (e) {} }, 10000); log('info', `exported ${queue.length} item(s) to ${name}`); }; // #517 (majkinetor, live: "MB Is slowish today... is there anything to // be done here (like providing an option to wait more, retry/continue // from failed step etc.)" — proved by hand that the SAME items commit // fine on a second try, so the failure was transient (MB being slow), // not the item being broken. Re-queuing is the direct fix: reset a // failed/partial item back to 'queued' — same starting state a fresh // import would give it — so the next Start naturally picks it up again. document.getElementById('falcon-retry-failed').onclick = () => { const retryable = queue.filter(i => i.status === 'failed' || i.status === 'partial'); if (!retryable.length) return; retryable.forEach(i => { i.status = 'queued'; i.error = ''; i.urlResults = null; i.timing = undefined; }); log('info', `re-queued ${retryable.length} failed/partial item(s) for another attempt`); renderQueue(); }; // #532: only offered where there is actually something to add — a release // page (its tracklist, artists, labels, RG) or a release-group page. (function wireAddFromPage() { const btn = document.getElementById('falcon-add-page'); if (!btn) return; const ctx = pageEntityContext(); if (!ctx) return; // stays display:none elsewhere btn.style.display = ''; const bt = btn.querySelector('.falcon-bt'); if (bt) bt.textContent = ctx.kind === 'release-group' ? 'Add from group' : ctx.kind === 'series' ? 'Add from series' : 'Add from release'; // #572: a series page cannot label its own menu until we know whether it // holds release groups or releases, so read it once on first open and // cache — the menu then names what is actually in there, with counts. let seriesInfo = null; btn.onclick = async () => { document.querySelectorAll('.falcon-addmenu').forEach(m => m.remove()); if (ctx.kind === 'series' && !seriesInfo) { log('info', 'reading this series from MusicBrainz…'); setBtnBusy(btn, true, { label: 'Reading…', title: 'Reading this series from MusicBrainz — see the Log tab' }); try { seriesInfo = await fetchSeriesMembers(ctx.mbid); } catch (e) { log('error', `could not read this series — ${e.message || e}`); setBtnBusy(btn, false); return; } finally { setBtnBusy(btn, false); } if (!seriesInfo.members.length) { log('warn', 'this series lists no release groups or releases — nothing Falcon can queue from it'); return; } log('info', `series "${seriesInfo.name}" (${seriesInfo.type || 'unknown type'}) — ${seriesInfo.members.length} member(s)`); } const menu = document.createElement('div'); menu.className = 'falcon-addmenu'; const r = btn.getBoundingClientRect(); menu.style.cssText = `position:fixed;left:${Math.round(r.left)}px;top:${Math.round(r.bottom + 4)}px;z-index:2147483647;` + 'background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.2);padding:8px 10px;font-size:12px;color:var(--mbu-text);min-width:210px'; const rgOnly = ctx.kind === 'release-group'; const isSeries = ctx.kind === 'series'; const seriesRgs = isSeries ? seriesInfo.members.filter(m => m.entityType === 'release_group') : []; const seriesRels = isSeries ? seriesInfo.members.filter(m => m.entityType === 'release') : []; if (isSeries) { menu.innerHTML = `<div style="font-weight:600;margin-bottom:6px">Add from series</div>` + `<div style="color:var(--mbu-text-weak);margin-bottom:6px;max-width:260px">${esc(seriesInfo.name || '')}</div>` + (seriesRgs.length ? `<label style="display:block;margin:3px 0"><input type="checkbox" data-w="series_rg" checked> Release groups <span style="color:var(--mbu-text-weak)">(${seriesRgs.length})</span></label>` + '<label style="display:block;margin:3px 0"><input type="checkbox" data-w="series_rg_releases"> Releases <span style="color:var(--mbu-text-weak)">(all in those groups)</span></label>' : '') + (seriesRels.length ? `<label style="display:block;margin:3px 0"><input type="checkbox" data-w="series_release" checked> Releases <span style="color:var(--mbu-text-weak)">(${seriesRels.length})</span></label>` + '<label style="display:block;margin:3px 0"><input type="checkbox" data-w="series_release_rg"> Release groups <span style="color:var(--mbu-text-weak)">(of those releases, one request each)</span></label>' : '') + '<div style="margin-top:8px;display:flex;gap:6px;justify-content:flex-end">' + '<button type="button" data-a="cancel" style="padding:2px 8px;cursor:pointer">Cancel</button>' + '<button type="button" data-a="ok" style="padding:2px 10px;cursor:pointer;font-weight:600">Add</button></div>'; } else menu.innerHTML = '<div style="font-weight:600;margin-bottom:6px">Add to queue</div>' + (rgOnly ? '' : '<label style="display:block;margin:3px 0"><input type="checkbox" data-w="recording" checked> Recordings <span style="color:var(--mbu-text-weak)">(tracklist)</span></label>') + (rgOnly ? '<label style="display:block;margin:3px 0"><input type="checkbox" data-w="release"> Releases <span style="color:var(--mbu-text-weak)">(all in this group)</span></label>' : '<label style="display:block;margin:3px 0"><input type="checkbox" data-w="release"> This release</label>') + '<label style="display:block;margin:3px 0"><input type="checkbox" data-w="release_group"' + (rgOnly ? ' checked' : '') + '> Release group</label>' + (rgOnly ? '' : '<label style="display:block;margin:3px 0"><input type="checkbox" data-w="artist"> Artists</label>') + (rgOnly ? '' : '<label style="display:block;margin:3px 0"><input type="checkbox" data-w="label"> Labels</label>') + '<div style="margin-top:8px;display:flex;gap:6px;justify-content:flex-end">' + '<button type="button" data-a="cancel" style="padding:2px 8px;cursor:pointer">Cancel</button>' + '<button type="button" data-a="ok" style="padding:2px 10px;cursor:pointer;font-weight:600">Add</button></div>'; document.body.appendChild(menu); const close = () => { menu.remove(); document.removeEventListener('mousedown', off, true); }; const off = e => { if (!menu.contains(e.target) && e.target !== btn) close(); }; setTimeout(() => document.addEventListener('mousedown', off, true), 0); menu.querySelector('[data-a="cancel"]').onclick = close; menu.querySelector('[data-a="ok"]').onclick = async () => { const want = {}; menu.querySelectorAll('input[data-w]').forEach(cb => { want[cb.dataset.w] = cb.checked; }); close(); // #546 (majkinetor): "The only way to tell is that Add from release // button is grayed - nothing else happens, including log." Measured // on his own release (e70b4221, 47 tracks): the ONE // /ws/2/release?inc=recordings+artist-credits+labels+release-groups // +media request took 20-45s against production MusicBrainz, while // building the rows from the answer took 4ms and rendering them 15ms. // So the wait is entirely MusicBrainz's, there is no work to split up // or speed up, and the whole fix is to stop being silent about it: // say what is being read BEFORE awaiting it, spin the button while // waiting, keep saying so every 5s, and report how long it took. // #572: the series branch resolves entirely from the membership we // already read, plus (optionally) one browse per group / one lookup // per release for the expansions. It returns early — none of the // release/release-group wording below applies to a series. if (isSeries) { const picks = Object.keys(want).filter(k => want[k]); if (!picks.length) { log('warn', 'nothing selected — tick at least one box'); return; } setBtnBusy(btn, true, { label: 'Reading…', title: 'Building the queue from this series — see the Log tab' }); const waiting = beginWait('this series'); const tuples = []; try { if (want.series_rg) tuples.push(...seriesRgs.map(m => ({ entityType: 'release_group', mbid: m.mbid, name: m.name, note: '' }))); if (want.series_release) tuples.push(...seriesRels.map(m => ({ entityType: 'release', mbid: m.mbid, name: m.name, note: '' }))); if (want.series_rg_releases && seriesRgs.length) { log('info', `reading the releases of ${seriesRgs.length} release group(s) — one request each, more for a large group…`); for (let i = 0; i < seriesRgs.length; i++) { const rels = await fetchGroupReleases(seriesRgs[i].mbid); tuples.push(...rels); log('info', `…${i + 1}/${seriesRgs.length} group(s) read, ${tuples.length} row(s) so far`); } } if (want.series_release_rg && seriesRels.length) { log('info', `reading the release group of ${seriesRels.length} release(s) — one request each…`); tuples.push(...await fetchReleaseGroupsOf(seriesRels)); } } catch (e) { log('error', `could not build the queue from this series — ${e.message || e}`); waiting.done(); setBtnBusy(btn, false); return; } const ms = waiting.done(); setBtnBusy(btn, false); if (!tuples.length) { log('warn', 'nothing to add from this series'); return; } const res = addToQueue(tuples); const by = tuples.reduce((a, t) => { a[t.entityType] = (a[t.entityType] || 0) + 1; return a; }, {}); log('info', `added ${res.added} row(s) from series "${seriesInfo.name}" (${Object.entries(by).map(([k, v]) => `${v} ${k}`).join(', ')})` + (res.merged ? ` — ${res.merged} merged into rows already queued` : '') + ` — ${(ms / 1000).toFixed(1)}s`); renderQueue(); return; } const WANT_LABEL = { recording: 'recordings', release: rgOnly ? 'releases' : 'the release itself', release_group: 'the release group', artist: 'artists', label: 'labels' }; const picked = Object.keys(want).filter(k => want[k]); if (!picked.length) { log('warn', 'nothing selected — tick at least one box'); return; } const label = (rgOnly ? `this release group's ` : `this release's `) + picked.map(k => WANT_LABEL[k] || k).join(' + '); log('info', `reading ${label} from MusicBrainz — one request, and a large release can keep it busy for 20-40s…`); setBtnBusy(btn, true, { label: 'Reading…', title: 'Reading this page’s entities from MusicBrainz — see the Log tab' }); const waiting = beginWait(label); let waitMs = 0; try { if (rgOnly) { const tuples = []; if (want.release_group) tuples.push({ entityType: 'release_group', mbid: ctx.mbid, name: null, note: '' }); if (want.release) { const rels = await fetchGroupReleases(ctx.mbid); if (!rels.length) log('warn', 'MusicBrainz listed no releases in this group'); tuples.push(...rels); } waitMs = waiting.done(); if (!tuples.length) { log('warn', 'nothing selected — tick Release group or Releases'); return; } const res = addToQueue(tuples); const by = tuples.reduce((a, t) => { a[t.entityType] = (a[t.entityType] || 0) + 1; return a; }, {}); log('info', `added ${res.added} row(s) from this release group (${Object.entries(by).map(([k, v]) => `${v} ${k}`).join(', ')})` + (res.merged ? ` — ${res.merged} merged into rows already queued` : '') + ` — ${(waitMs / 1000).toFixed(1)}s, nearly all of it waiting on MusicBrainz`); } else { const j = await fetchReleaseGraph(ctx.mbid); waitMs = waiting.done(); if (!j || !j.id) { log('warn', `could not read release ${ctx.mbid} from MusicBrainz — nothing added`); return; } const tuples = releaseGraphTuples(j, want, ''); if (!tuples.length) { log('warn', 'nothing selected, or the release has none of the chosen entities'); return; } const res = addToQueue(tuples); const by = tuples.reduce((a, t) => { a[t.entityType] = (a[t.entityType] || 0) + 1; return a; }, {}); log('info', `added ${res.added} row(s) from "${j.title || ctx.mbid}" (${Object.entries(by).map(([k, v]) => `${v} ${k}`).join(', ')})` + (res.merged ? ` — ${res.merged} merged into rows already queued` : '') + ` — ${(waitMs / 1000).toFixed(1)}s, nearly all of it waiting on MusicBrainz`); } renderQueue(); } catch (e) { log('error', `add from page failed: ${(e && e.message) || e}`); } finally { waiting.done(); setBtnBusy(btn, false); } }; }; })(); document.getElementById('falcon-import').onclick = () => document.getElementById('falcon-import-file').click(); document.getElementById('falcon-import-file').onchange = function () { const file = this.files && this.files[0]; this.value = ''; // so re-picking the same file still fires if (!file) return; const reader = new FileReader(); reader.onload = () => importQueueJson(String(reader.result || ''), file.name); reader.onerror = () => log('error', `could not read ${file.name}`); reader.readAsText(file); }; // #467 (majkinetor): "Do not switch to worker tab on start" — the queue list // (with its progress bar) is the more useful view during a run; the Workers // tab is for when you actually want to look inside one. document.getElementById('falcon-run').onclick = () => { if (running) stop(); else start(); }; // #573: the batch note. The button only toggles the panel; the value lives // in _batchNote so an import can set it without the panel ever being open. (function wireBatchNote() { const btn = document.getElementById('falcon-note-btn'); const panel = document.getElementById('falcon-notepanel'); const box = document.getElementById('falcon-note-text'); if (!btn || !panel || !box) return; btn.onclick = () => { const show = panel.style.display === 'none'; panel.style.display = show ? '' : 'none'; if (show) { box.value = batchNote(); box.focus(); } }; box.addEventListener('input', () => { setBatchNote(box.value); syncBatchNoteUi(); }); syncBatchNoteUi(); })(); document.getElementById('falcon-queue-list').addEventListener('change', e => { if (e.target.id !== 'falcon-select-all') return; const selectable = queue.filter(i => i.status !== 'active'); if (e.target.checked) selectable.forEach(i => _selectedIds.add(i.id)); else selectable.forEach(i => _selectedIds.delete(i.id)); renderQueue(); }); // #663 (majkinetor): Retry failed is "not that frequent" — it lives in Start's ▾ menu const runMenu = document.getElementById('falcon-run-menu'); document.getElementById('falcon-run-more').onclick = e => { e.stopPropagation(); runMenu.style.display = runMenu.style.display === 'none' ? 'block' : 'none'; }; document.addEventListener('click', e => { if (runMenu.style.display !== 'none' && !runMenu.contains(e.target)) runMenu.style.display = 'none'; }); document.getElementById('falcon-retry-failed').addEventListener('click', () => { runMenu.style.display = 'none'; }); document.getElementById('falcon-remove-selected').onclick = () => { const removable = [..._selectedIds].filter(id => { const it = queue.find(q => q.id === id); return it && it.status !== 'active'; }); if (!removable.length) return; queue = queue.filter(i => !removable.includes(i.id)); removable.forEach(id => _selectedIds.delete(id)); log('info', `removed ${removable.length} item(s) from the queue`); renderQueue(); }; // #467 (majkinetor): toggles between expanding every row's url detail at // once and collapsing them all — its own label reflects which action is // next, kept in sync from renderQueue() since expanding/collapsing an // individual row can also change whether "all" are currently expanded. document.getElementById('falcon-queue-list').addEventListener('click', e => { if (!e.target.closest('#falcon-expand-all')) return; const allExpanded = queue.length > 0 && queue.every(i => _expandedIds.has(i.id)); if (allExpanded) _expandedIds.clear(); else queue.forEach(i => _expandedIds.add(i.id)); renderQueue(); }); document.getElementById('falcon-view-toggle').onclick = () => { cfg.queueView = cfg.queueView === 'grid' ? 'list' : 'grid'; log('debug', `queue view → ${cfg.queueView}`); renderQueue(); }; // #497: same delegated-listener reasoning — the chip strip is fully // rebuilt by renderTypeChips() on every renderQueue(). document.getElementById('falcon-type-chips').addEventListener('click', e => { const chip = e.target.closest('.falcon-type-chip'); if (!chip) return; const t = chip.dataset.type; _disabledTypes.has(t) ? _disabledTypes.delete(t) : _disabledTypes.add(t); renderQueue(); }); // #513: same delegated-listener reasoning — rebuilt by renderStatusChips() // on every renderQueue(). Clicking the already-active chip clears the // filter; clicking a different one switches to it (only one at a time — // "just those results" reads as one status, not a multi-select). document.getElementById('falcon-status-chips').addEventListener('click', e => { const chip = e.target.closest('.falcon-status-chip'); if (!chip) return; const s = chip.dataset.status; _statusFilter = _statusFilter === s ? null : s; setTab('queue'); renderQueue(); }); // one delegated listener for every row action — rows are fully re-rendered on // every renderQueue(), so per-element handlers would just leak; look the // clicked/changed item up by its data-id instead. const list = document.getElementById('falcon-queue-list'); list.addEventListener('click', e => { if (e.target.closest('#falcon-status-filter-clear')) { _statusFilter = null; renderQueue(); return; } const expandBtn = e.target.closest('.falcon-row-expand'); if (expandBtn) { const id = expandBtn.dataset.id; _expandedIds.has(id) ? _expandedIds.delete(id) : _expandedIds.add(id); renderQueue(); return; } const removeBtn = e.target.closest('.falcon-row-remove'); if (removeBtn) { const id = removeBtn.dataset.id; queue = queue.filter(i => i.id !== id); _selectedIds.delete(id); _expandedIds.delete(id); renderQueue(); return; } const moreBtn = e.target.closest('.falcon-more'); if (moreBtn) { const id = moreBtn.dataset.id; _linksOpen.has(id) ? _linksOpen.delete(id) : _linksOpen.add(id); renderQueue(); return; } const tabBtn = e.target.closest('.falcon-row-opentab'); if (tabBtn) { const it = queue.find(i => i.id === tabBtn.dataset.id); if (it) openInTab(it); return; } const statusBtn = e.target.closest('.falcon-row-status'); if (statusBtn) { const it = queue.find(i => i.id === statusBtn.dataset.id); if (it && (it.status === 'failed' || it.status === 'partial')) focusItemWorker(it); return; } // #494/#496: swap the picked cover to a different candidate provider, // for the specific cover[] entry the button belongs to. const pickBtn = e.target.closest('.falcon-cover-pick'); if (pickBtn) { const it = queue.find(i => i.id === pickBtn.dataset.id); const entry = it && it.cover[+pickBtn.dataset.idx]; if (entry && it.status !== 'active') { entry.url = pickBtn.dataset.url; renderQueue(); } return; } }); // #467 (majkinetor): right-clicking the entity-type cell selects every // queued/finished item of that SAME type at once — a quick way to bulk-act // on (e.g. remove) just the recordings, or just the artists, in a mixed batch. list.addEventListener('contextmenu', e => { const typeCell = e.target.closest('.falcon-row-type'); if (!typeCell) return; e.preventDefault(); const type = typeCell.dataset.type; queue.filter(i => i.entityType === type && i.status !== 'active').forEach(i => _selectedIds.add(i.id)); renderQueue(); }); list.addEventListener('click', e => { const lp = e.target.closest('.falcon-link-plus'); if (lp) { _newLink = lp.dataset.id; renderQueue(); const box = document.querySelector(`#falcon-queue-list .falcon-link-new[data-id="${lp.dataset.id}"]`); if (box) box.focus(); return; } const plus = e.target.closest('.falcon-alias-plus'); if (plus) { addAliasRow(plus.dataset.id, null); return; } const ta = e.target.closest('.falcon-link-type-add'); if (ta) { _pendingType = { id: ta.dataset.id, url: ta.dataset.url }; renderQueue(); const sel = document.querySelector('#falcon-queue-list .falcon-ltb-new select'); if (sel) { sel.focus(); try { sel.showPicker(); } catch (x) {} } return; } const lt = e.target.closest('.falcon-link-type-del'); if (lt) { e.preventDefault(); editLink(lt.dataset.id, 'untype', lt.dataset.url, lt.dataset.type); return; } const ld = e.target.closest('.falcon-link-del'); if (ld) { e.preventDefault(); editLink(ld.dataset.id, 'remove', ld.dataset.url, ''); return; } const del = e.target.closest('.falcon-alias-del'); if (!del) return; e.preventDefault(); e.stopPropagation(); const it = queue.find(i => i.id === del.dataset.id); if (it) { it.aliases.splice(+del.dataset.idx, 1); renderQueue(); } }); list.addEventListener('focusout', e => { if (e.target.classList && e.target.classList.contains('falcon-link-type') && _pendingType) { setTimeout(() => { if (_pendingType && !document.activeElement?.classList?.contains('falcon-link-type')) { _pendingType = null; renderQueue(); } }, 0); return; } if (e.target.classList && e.target.classList.contains('falcon-link-new')) setTimeout(() => { if (_newLink && !document.activeElement?.classList?.contains('falcon-link-new')) { _newLink = null; renderQueue(); } }, 0); // an alias row left with no name is dropped once focus leaves it const al = e.target.closest && e.target.closest('.falcon-al'); if (al) setTimeout(() => { if (!al.isConnected || al.contains(document.activeElement)) return; if (dropEmptyAliases()) renderQueue(); }, 0); }); // #663 (majkinetor): Enter on a filled alias opens a new one, focused ("the edit box to enter a new // one isn't selected"); Enter on an empty one goes on to the next row's aliases ("make empty enter // in alias switch to another row"); Esc drops a new, still empty one list.addEventListener('keydown', e => { const box = e.target; if (e.isComposing || !box.classList || !(box.classList.contains('falcon-alias-nm') || box.classList.contains('falcon-alias-loc'))) return; const it = queue.find(q => q.id === box.dataset.id), idx = +box.dataset.idx, a = it && it.aliases[idx]; if (!a) return; if (e.key === 'Escape' && !a.name.trim()) { e.preventDefault(); e.stopPropagation(); it.aliases.splice(idx, 1); renderQueue(); return; } if (e.key !== 'Enter') return; e.preventDefault(); e.stopPropagation(); readAliasRow(box.closest('.falcon-al'), it, idx); if (it.aliases[idx] && it.aliases[idx].name.trim() && !e.shiftKey) { addAliasRow(it.id, idx + 1); return; } if (it.aliases[idx] && !it.aliases[idx].name.trim()) it.aliases.splice(idx, 1); aliasGoRow(it.id, e.shiftKey ? -1 : 1); }, true); // #663: spreadsheet keys. Enter / Down go to the same field of the next row, // Shift+Enter / Up to the previous one; Tab / Shift+Tab walk the fields, and // so do Right / Left once the caret is at the end / start of the text. list.addEventListener('keydown', e => { const el = e.target.closest && e.target.closest('[data-col]'); if (!el || e.isComposing || e.ctrlKey || e.altKey || e.metaKey) return; const text = el.type === 'text', len = text ? el.value.length : 0; const atStart = !text || (el.selectionStart === 0 && el.selectionEnd === 0); const atEnd = !text || (el.selectionStart === len && el.selectionEnd === len); let dRow = 0, dCol = 0; if (e.key === 'Enter') { dRow = e.shiftKey ? -1 : 1; } else if (e.key === 'ArrowDown' && !e.shiftKey) dRow = 1; else if (e.key === 'ArrowUp' && !e.shiftKey) dRow = -1; else if (e.key === 'Tab') dCol = e.shiftKey ? -1 : 1; else if (e.key === 'ArrowRight' && !e.shiftKey && atEnd) dCol = 1; else if (e.key === 'ArrowLeft' && !e.shiftKey && atStart) dCol = -1; else return; const target = walkTarget(el.dataset.id, el.dataset.col, dRow, dCol); dbg('keys', `${e.shiftKey ? 'Shift+' : ''}${e.key} on ${el.dataset.col}@${el.dataset.id} → ${target ? target.col + '@' + target.id : 'nowhere (edge of the queue)'}`); if (!target) { if (e.key === 'Tab') e.preventDefault(); return; } e.preventDefault(); moveFocus(el, target); }); list.addEventListener('change', e => { const chk = e.target.closest('.falcon-row-check'); if (chk) { chk.checked ? _selectedIds.add(chk.dataset.id) : _selectedIds.delete(chk.dataset.id); renderQueue(); return; } // #474: disambiguation + ISRC — plain text fields, no validation here; // MB's own edit form is what actually validates an ISRC on submit. const disambigInp = e.target.closest('.falcon-disambiguation-input'); if (disambigInp) { const it = queue.find(i => i.id === disambigInp.dataset.id); if (it) it.disambiguation = disambigInp.value.trim(); return; } // #571: only a value that actually DIFFERS from the current name is a // rename. The box is prefilled with the current name, so without this // every expanded row would queue an edit that changes nothing — MB would // reject them as no-ops and the run would read as a wall of 'skipped'. const renameInp = e.target.closest('.falcon-rename-input'); if (renameInp) { const it = queue.find(i => i.id === renameInp.dataset.id); if (it) { const v = renameInp.value.trim(); it.rename = (v && v !== String(it.name || '').trim()) ? v : ''; renderQueue(); } return; } // #713: a release's barcode, digits only const bcInp = e.target.closest('.falcon-barcode-input'); if (bcInp) { const it = queue.find(i => i.id === bcInp.dataset.id); if (it) { it.barcode = barcodeOf(bcInp.value); bcInp.value = it.barcode; } return; } const isrcInp = e.target.closest('.falcon-isrc-input'); if (isrcInp) { const it = queue.find(i => i.id === isrcInp.dataset.id); if (it) it.isrcs = isrcInp.value.split(',').map(s => s.trim().toUpperCase().replace(/[\s-]/g, '')).filter(Boolean); return; } // #534 (majkinetor): "Recordings video attribute … its just a checkbox on // recording form". Ticking it on a row that is part of the current // SELECTION applies to every selected recording at once — the point of // the issue is mass-flagging (it cites loujine's set-video-recordings // script), and doing that one expanded row at a time would be no better // than MB's own form. // #535: type a name (optionally "name@pl") and it becomes an alias on // this row. Deliberately minimal — the bulk path is JSON. // #663 (majkinetor): "Added aliases can't be edited after they are in chip" — a // clicked chip turns back into a box; Enter or leaving it puts the chip back const aliasIn = e.target.closest('.falcon-alias-nm, .falcon-alias-loc'); if (aliasIn) { const it = queue.find(q => q.id === aliasIn.dataset.id); if (it) readAliasRow(aliasIn.closest('.falcon-al'), it, +aliasIn.dataset.idx); return; } const linkUrl = e.target.closest('.falcon-link-url'); if (linkUrl) { editLink(linkUrl.dataset.id, 'url', linkUrl.dataset.url, linkUrl.value); return; } const linkType = e.target.closest('.falcon-link-type'); if (linkType) { _pendingType = null; editLink(linkType.dataset.id, 'type', linkType.dataset.url, linkType.value); return; } const linkChg = e.target.closest('.falcon-link-type-chg'); if (linkChg) { editLink(linkChg.dataset.id, 'retype', linkChg.dataset.url, linkChg.value, linkChg.dataset.old); return; } const linkNew = e.target.closest('.falcon-link-new'); if (linkNew) { editLink(linkNew.dataset.id, 'add', '', linkNew.value); return; } const videoChk = e.target.closest('.falcon-video-input'); if (videoChk) { const it = queue.find(i => i.id === videoChk.dataset.id); if (it) { const on = videoChk.checked; const targets = _selectedIds.has(it.id) ? queue.filter(i => _selectedIds.has(i.id) && i.entityType === 'recording' && i.status !== 'active') : [it]; targets.forEach(i => { i.video = on; }); if (targets.length > 1) log('info', `${on ? 'flagged' : 'un-flagged'} ${targets.length} selected recording(s) as video`); renderQueue(); } return; } // #494/#496: "Falcon side — accept URL for the image" — the auto-picked // candidate is always user-editable/overridable, same spirit as the // disambiguation/ISRC fields above. Each targets its own cover[] entry. const coverInp = e.target.closest('.falcon-cover-input'); if (coverInp) { const it = queue.find(i => i.id === coverInp.dataset.id); const entry = it && it.cover[+coverInp.dataset.idx]; if (entry) entry.url = coverInp.value.trim(); return; } const coverTypeSel = e.target.closest('.falcon-cover-type'); if (coverTypeSel) { const it = queue.find(i => i.id === coverTypeSel.dataset.id); const entry = it && it.cover[+coverTypeSel.dataset.idx]; if (entry) entry.type = coverTypeSel.value; return; } const coverCommentInp = e.target.closest('.falcon-cover-comment-input'); if (coverCommentInp) { const it = queue.find(i => i.id === coverCommentInp.dataset.id); const entry = it && it.cover[+coverCommentInp.dataset.idx]; if (entry) entry.comment = coverCommentInp.value.trim(); return; } }); document.getElementById('falcon-tab-queue').onclick = () => setTab('queue'); document.getElementById('falcon-tab-workers').onclick = () => setTab('workers'); document.getElementById('falcon-tab-log').onclick = () => setTab('log'); document.getElementById('falcon-tab-options').onclick = () => setTab('options'); // #508: options — hiding the launcher takes effect immediately (no need // to close/reopen the panel to see it disappear/reappear). const hideLauncherCb = document.getElementById('falcon-opt-hide-launcher'); hideLauncherCb.checked = cfg.hideLauncher; hideLauncherCb.onchange = () => { cfg.hideLauncher = hideLauncherCb.checked; if (cfg.hideLauncher) removeLauncher(); else ensureLauncher(); }; const coverOnlyCb = document.getElementById('falcon-opt-cover-only-if-none'); coverOnlyCb.checked = cfg.coverOnlyIfNone; coverOnlyCb.onchange = () => { cfg.coverOnlyIfNone = coverOnlyCb.checked; }; const skipCoversCb = document.getElementById('falcon-opt-skip-harmony-covers'); skipCoversCb.checked = cfg.skipHarmonyCovers; skipCoversCb.onchange = () => { cfg.skipHarmonyCovers = skipCoversCb.checked; }; const autoSendCb = document.getElementById('falcon-opt-auto-send-harmony'); // #557 autoSendCb.checked = cfg.autoSendFromHarmony; autoSendCb.onchange = () => { cfg.autoSendFromHarmony = autoSendCb.checked; }; const autoStartCb = document.getElementById('falcon-opt-auto-start-harmony'); autoStartCb.checked = cfg.autoStartHarmonyImport; autoStartCb.onchange = () => { cfg.autoStartHarmonyImport = autoStartCb.checked; }; const reloadAfterCb = document.getElementById('falcon-opt-reload-after-import'); // #588 reloadAfterCb.checked = cfg.reloadReleaseAfterImport; reloadAfterCb.onchange = () => { cfg.reloadReleaseAfterImport = reloadAfterCb.checked; }; const openNewTabCb = document.getElementById('falcon-opt-open-new-tab'); openNewTabCb.checked = cfg.openHarmonyInNewTab; openNewTabCb.onchange = () => { cfg.openHarmonyInNewTab = openNewTabCb.checked; }; // #578 round 3: the port is used for the ?tport= parameter whether or not the // box is ticked, so it must stay editable — it used to grey out with the // checkbox, which was right when the checkbox meant "use tport at all" and is // wrong now that it only means "and send automatically". const picardCb = document.getElementById('falcon-opt-picard'); const picardPortIn = document.getElementById('falcon-opt-picard-port'); picardCb.checked = cfg.sendToPicard; picardPortIn.value = cfg.picardPort; picardCb.onchange = () => { cfg.sendToPicard = picardCb.checked; }; picardPortIn.onchange = () => { cfg.picardPort = picardPortIn.value; picardPortIn.value = cfg.picardPort; }; const logHistoryIn = document.getElementById('falcon-opt-log-history-count'); logHistoryIn.value = cfg.logHistoryCount; logHistoryIn.onchange = () => { cfg.logHistoryCount = logHistoryIn.value; logHistoryIn.value = cfg.logHistoryCount; }; // #705: the shared log viewer (filters, a chip per worker, the debug switch, past runs, // Copy as Markdown) Log.mount(document.getElementById('falcon-body-log')); const sizeSlider = document.getElementById('falcon-worker-size'); const sizeVal = document.getElementById('falcon-worker-size-val'); sizeSlider.value = cfg.workerSize; sizeVal.textContent = cfg.workerSize; sizeSlider.oninput = () => { cfg.workerSize = sizeSlider.value; sizeVal.textContent = cfg.workerSize; renderWorkerLayout(); // resizes every visible card AND rescales its iframe }; // drag by header const hdr = document.getElementById('falcon-hdr'); let dragging = false, dx = 0, dy = 0; hdr.addEventListener('mousedown', e => { if (e.target.closest('button, a')) return; dragging = true; const r = panel.getBoundingClientRect(); // detach from the centering transform BEFORE reading dx/dy — otherwise the // first drag move jumps (translate(-50%,-50%) would double-apply against the // new left/top). getBoundingClientRect() already reflects the transformed // (visual) position, so this keeps the panel exactly where it looked like it was. panel.style.transform = 'none'; panel.style.left = r.left + 'px'; panel.style.top = r.top + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; dx = e.clientX - r.left; dy = e.clientY - r.top; e.preventDefault(); }); window.addEventListener('mousemove', e => { if (!dragging) return; panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = Math.max(0, Math.min(window.innerWidth - 60, e.clientX - dx)) + 'px'; panel.style.top = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - dy)) + 'px'; }); window.addEventListener('mouseup', () => { dragging = false; }); // Normalise tab visibility through the same code path the tab buttons use, // so the panes can never start in a state setTab() wouldn't produce. The // Workers pane in particular must not be display:none (see setTab) — it was // shipped that way in the initial markup, so workers only ran once you'd // clicked the Workers tab at least once (majkinetor: "workers still not // starting without watching them"). setTab(tab); } // ⚠ The Workers pane must NEVER be display:none while workers are running. // #467 (majkinetor: "when worker tab is not open, it doesnt work"): a worker // iframe inside a display:none container loads and fills fine, but its submit // click goes nowhere — his log shows the button found and enabled, MB // reporting no error, and the page simply never navigating for the full 50s; // opening the tab made the same 3 items finish in 5s. Browsers don't run a // display:none subtree's rendering, and the form submission dies with it. // So hide it the way that keeps it fully live: parked off-screen, still laid // out, still rendering. (Idle cards can still be display:none — they have no // work in flight to lose. See renderWorkerLayout.) const OFFSCREEN = 'position:absolute;left:-100000px;top:0;width:1200px;height:800px;overflow:auto;'; function setTab(t) { tab = t; ['queue', 'workers', 'log', 'options'].forEach(n => { const b = document.getElementById('falcon-tab-' + n); if (b) b.classList.toggle('falcon-tab-on', n === t); }); document.getElementById('falcon-body-queue').style.display = t === 'queue' ? 'flex' : 'none'; const w = document.getElementById('falcon-body-workers'); if (t === 'workers') w.style.cssText = 'display:block;padding:8px 10px;overflow:auto;flex:1'; else w.style.cssText = OFFSCREEN + 'display:block;padding:8px 10px;'; document.getElementById('falcon-body-log').style.display = t === 'log' ? 'flex' : 'none'; document.getElementById('falcon-body-options').style.display = t === 'options' ? 'flex' : 'none'; if (t === 'log') { // #705 (majkinetor: "Switching to falcon log is not instant"): a slow listing of the past runs is logged const t0 = performance.now(); Log.refresh(); const ms = Math.round(performance.now() - t0); if (ms > 50) log('debug', `log tab: listing the past runs took ${ms} ms`); const l = document.querySelector('#falcon-body-log .mbu-log-list'); if (l) l.scrollTop = l.scrollHeight; // it couldn't scroll while hidden } if (t === 'workers') renderWorkerLayout(); // sizes were computed while off-screen; recompute for the real viewport fitBars(); // a bar that was hidden measured 0-wide; re-fit now it's laid out } function showPanel() { ensurePanel(); if (panel.dataset.headless) { // #680: back from a headless batch's off-screen spot let was = {}; try { was = JSON.parse(panel.dataset.headless); } catch (e) {} delete panel.dataset.headless; panel.style.left = was.left || '50%'; panel.style.top = was.top || '50%'; panel.style.removeProperty('opacity'); panel.style.removeProperty('pointer-events'); } panel.style.display = 'flex'; renderQueue(); fitBars(); } function togglePanel() { ensurePanel(); if (panel.style.display === 'none' || panel.dataset.headless) showPanel(); else panel.style.display = 'none'; } // #467 (majkinetor): "maximize" — grows the whole panel (and, on the Workers tab, // gives each worker card more natural room) so log/queue/worker content isn't // squinted at in a 460px-wide box. Detaches from whatever corner it's anchored to // (or wherever it's been dragged) and restores the exact prior box on toggle-back. let _maxed = false, _prevBox = null; function toggleMaximize() { const btn = document.getElementById('falcon-maximize'); if (!_maxed) { _prevBox = { left: panel.style.left, top: panel.style.top, right: panel.style.right, bottom: panel.style.bottom, width: panel.style.width, height: panel.style.height, maxWidth: panel.style.maxWidth, maxHeight: panel.style.maxHeight, transform: panel.style.transform }; panel.style.left = '3vw'; panel.style.top = '3vh'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.transform = 'none'; panel.style.width = '94vw'; panel.style.maxWidth = '94vw'; panel.style.height = '94vh'; panel.style.maxHeight = '94vh'; _maxed = true; if (btn) { btn.textContent = '❐'; btn.title = 'Restore'; } } else { if (_prevBox) Object.assign(panel.style, _prevBox); _maxed = false; if (btn) { btn.textContent = '⛶'; btn.title = 'Maximize'; } } fitBars(); } const DOT = { queued: '#999999', active: '#e08a1e', done: '#2e9e5b', partial: '#d68910', failed: '#c0392b', manual: '#6b5bce', skipped: '#5b8fa8' }; // #506 (majkinetor): "queue processing status fields should have color which // should be used for row background in unintrusive way" — same palette as the // status dot, just a faint tint (~7% alpha) so it reads at a glance without // fighting the row's text for attention. const ROW_BG = Object.fromEntries(Object.entries(DOT).map(([k, v]) => [k, v + '12'])); // #495: a bare slice(0,3) collides — 'release' and 'release_group' both give // 'rel'. Everything else still just takes its natural first 3 letters. const TYPE_BADGE = { release_group: 'rg' }; // #663: a url queued under several link types is one line with a chip per type function groupedLinks(it) { const results = it.urlResults || [], byUrl = new Map(); for (const u of it.urls) { if (!byUrl.has(u.url)) byUrl.set(u.url, { url: u.url, types: [], results: [] }); const g = byUrl.get(u.url); if (u.linkTypeId && !g.types.includes(u.linkTypeId)) g.types.push(u.linkTypeId); } for (const r of results) if (byUrl.has(r.url)) byUrl.get(r.url).results.push(r); return [...byUrl.values()]; } const LINKS_SHOWN = 3; let _linksOpen = new Set(); // rows whose link list is shown past the first LINKS_SHOWN function renderLinks(it) { const groups = groupedLinks(it); const dis = it.status === 'active' ? 'disabled' : ''; const add = _newLink === it.id ? `<div class="falcon-ln falcon-ln-new"><input type="text" class="falcon-link-new" data-id="${it.id}" placeholder="paste a link" title="Add a link: paste its url" ${dis} /></div>` : ''; if (!groups.length) return `<div class="falcon-links">${add}</div>`; const all = _linksOpen.has(it.id) || groups.length <= LINKS_SHOWN + 1; const shown = all ? groups : groups.slice(0, LINKS_SHOWN); const rows = shown.map(g => { const failed = g.results.find(r => !r.ok && !r.present), done = g.results.length && !failed; const present = !failed && g.results.find(r => r.present); // #671: already on the entity const icon = failed ? '✗' : done ? '✓' : '·'; const color = failed ? 'var(--mbu-error)' : done ? 'var(--mbu-ok)' : 'var(--mbu-text-weak)'; // #663 (majkinetor): "Find another way for prefix" — the status mark opens the link; the service name is gone const opts = cur => [...new Set([...(cur ? [String(cur)] : []), ...urlTypesFor(it.entityType)])].filter(t => t === String(cur) || !g.types.map(String).includes(t)).map(t => `<option value="${t}"${t === String(cur) ? ' selected' : ''}>${esc(linkTypeName(t))}</option>`).join(''); const pending = _pendingType && _pendingType.id === it.id && _pendingType.url === g.url; return `<div class="falcon-ln" title="${failed && failed.error ? esc(failed.error) : ''}"> <input type="text" class="falcon-link-url" data-id="${it.id}" data-url="${esc(g.url)}" value="${esc(g.url)}" title="Edit the link; empty it to remove it" ${dis} /> <span class="falcon-lt"><a class="falcon-lst" href="${esc(g.url)}" target="_blank" rel="noopener" title="Open ${esc(g.url)}${failed && failed.error ? ' — ' + esc(failed.error) : present ? ' — ' + esc(present.error) : ''}" style="color:${color}">${icon === '·' ? '↗' : icon}</a>${g.types.map(t => `<span class="falcon-ltb" title="link type: pick another to change it"><select style="width:${Math.round(linkTypeName(t).length * 5.4 + 12)}px" class="falcon-link-type-chg" data-id="${it.id}" data-url="${esc(g.url)}" data-old="${esc(t)}" ${dis}>${opts(t) || `<option selected>${esc(linkTypeName(t))}</option>`}</select><button type="button" class="falcon-link-type-del" data-id="${it.id}" data-url="${esc(g.url)}" data-type="${esc(t)}" title="Remove this type" ${dis}>✕</button></span>`).join('')}${pending ? `<span class="falcon-ltb falcon-ltb-new"><select class="falcon-link-type" data-id="${it.id}" data-url="${esc(g.url)}"><option value="">type…</option>${opts(null)}</select></span>` : ''}${!g.types.length && !pending ? '<span class="falcon-lauto" title="No type: MusicBrainz guesses it from the url">auto</span>' : ''}</span> <button type="button" class="falcon-link-type-add" data-id="${it.id}" data-url="${esc(g.url)}" title="Add a type to this link" ${dis}>+</button> <button type="button" class="falcon-link-del" data-id="${it.id}" data-url="${esc(g.url)}" title="Remove this link from the queue row" ${dis}>✕</button> </div>`; }).join(''); const rest = groups.slice(LINKS_SHOWN); const more = groups.length > LINKS_SHOWN + 1 ? `<button type="button" class="falcon-more" data-id="${it.id}">${all ? 'show fewer' : `+ ${rest.length} more (${esc([...new Set(rest.map(g => linkService(g.url)))].join(', '))})`}</button>` : ''; return `<div class="falcon-links">${rows}${more}${add}</div>`; } // #663: alias rows. The row's boxes are read into the alias as they change; the rest of the alias // (type, primary, sort name) is kept. name@locale in the name box sets the language box too. function readAliasRow(row, it, idx) { const a = it && it.aliases[idx]; if (!row || !a) return; const nm = row.querySelector('.falcon-alias-nm'), loc = row.querySelector('.falcon-alias-loc'); const before = a.name + '@' + a.locale; const m = nm.value.match(/^(.*\S)\s*@\s*([A-Za-z]{2,3}(?:[-_][A-Za-z0-9]+)*)\s*$/); if (m) { nm.value = m[1]; loc.value = m[2]; } a.name = nm.value.trim(); a.locale = loc.value.trim().replace('-', '_'); // #663 (majkinetor): "Last input is used for new ones" — the language last typed is the next alias's if (a.locale && a.locale !== cfg.aliasLang) cfg.aliasLang = a.locale; if (before !== a.name + '@' + a.locale) dbg('alias', `${it.id} alias ${idx}: ${before} → ${a.name}@${a.locale}`); } function addAliasRow(id, at) { const it = queue.find(q => q.id === id); if (!it || it.status === 'active') return; it.aliases = it.aliases || []; const idx = at == null ? it.aliases.length : at; it.aliases.splice(idx, 0, { name: '', locale: cfg.aliasLang || '', type: '', primary: false, sortName: '', begin: '', end: '', ended: false }); renderQueue(); const box = document.querySelector(`#falcon-queue-list .falcon-alias-nm[data-id="${id}"][data-idx="${idx}"]`); if (box) { box.focus({ preventScroll: true }); box.scrollIntoView({ block: 'nearest' }); } } function dropEmptyAliases() { let n = 0; queue.forEach(it => { const k = (it.aliases || []).length; if (k) { it.aliases = it.aliases.filter(a => a && String(a.name || '').trim()); n += k - it.aliases.length; } }); return n; } // on to the next (or previous) row's aliases, opening it: a new alias row there, ready to type function aliasGoRow(id, d) { let i = _visibleIds.indexOf(id) + d, next = null; for (; i >= 0 && i < _visibleIds.length; i += d) { const it = queue.find(q => q.id === _visibleIds[i]); if (it && it.status !== 'active') { next = it; break; } } if (!next) { dbg('keys', `alias Enter on ${id}: no ${d > 0 ? 'next' : 'previous'} row`); renderQueue(); return; } if (_navOpened.has(id)) { _expandedIds.delete(id); _navOpened.delete(id); } if (!_expandedIds.has(next.id)) { _expandedIds.add(next.id); _navOpened.add(next.id); } dbg('keys', `alias Enter on ${id} → the aliases of ${next.id}`); addAliasRow(next.id, null); } // #663 (majkinetor): "Add option to edit elements, and remove them. Add/change/remove // type, edit link, remove row." A url with no type is left for MusicBrainz to guess, as // pasted links always were. let _pendingType = null; let _newLink = null; // the row whose empty link box is open (the + by Links) // {id, url}: the link whose [+] badge is waiting for a type function editLink(id, op, url, val, old) { const it = queue.find(i => i.id === id); if (!it || it.status === 'active') return; const v = String(val || '').trim(), before = it.urls.length; if (op === 'url') { if (!v) it.urls = it.urls.filter(u => u.url !== url); else it.urls.forEach(u => { if (u.url === url) u.url = v; }); log('info', `${entityLabel(it)} — link ${url} → ${v || '(removed)'}`); } else if (op === 'remove') { it.urls = it.urls.filter(u => u.url !== url); log('info', `${entityLabel(it)} — link ${url} removed`); } else if (op === 'add') { if (!v) return; if (!/^https?:\/\//i.test(v)) { log('warn', `${entityLabel(it)} — "${v}" is not a link (it needs http:// or https://)`); renderQueue(); return; } _newLink = null; if (!it.urls.some(u => u.url === v)) it.urls.push({ url: v, linkTypeId: null }); log('info', `${entityLabel(it)} — link ${v} added`); } else if (op === 'type') { if (!v) return; const untyped = it.urls.find(u => u.url === url && !u.linkTypeId); if (untyped) untyped.linkTypeId = v; else if (!it.urls.some(u => u.url === url && String(u.linkTypeId) === v)) it.urls.push({ url, linkTypeId: v }); log('info', `${entityLabel(it)} — link ${url} gets the type ${linkTypeName(v)}`); } else if (op === 'retype') { if (!v || v === String(old)) return; if (it.urls.some(u => u.url === url && String(u.linkTypeId) === v)) it.urls = it.urls.filter(u => !(u.url === url && String(u.linkTypeId) === String(old))); else it.urls.forEach(u => { if (u.url === url && String(u.linkTypeId) === String(old)) u.linkTypeId = v; }); log('info', `${entityLabel(it)} — link ${url}: ${linkTypeName(old)} → ${linkTypeName(v)}`); } else if (op === 'untype') { const rest = it.urls.filter(u => u.url === url && String(u.linkTypeId) !== String(v)); it.urls = it.urls.filter(u => u.url !== url).concat(rest.length ? rest : [{ url, linkTypeId: null }]); log('info', `${entityLabel(it)} — link ${url} loses the type ${linkTypeName(v)}`); } dbg('links', `${it.id} ${op} ${url} ${v}: ${before} → ${it.urls.length} url entries ${JSON.stringify(it.urls)}`); renderQueue(); } // The editable fields, keyed by the column name the keyboard walk uses (#663). // Both views draw the same fields in this order, so Tab and Enter mean the same // thing in each. function fieldCols(it) { return [ RENAMEABLE.has(it.entityType) && 'name', DISAMBIGUATABLE.has(it.entityType) && 'disambig', cfg.queueView !== 'grid' && it.entityType === 'release' && 'barcode', // #713; the grid has it below the row it.entityType === 'recording' && 'isrc', cfg.queueView !== 'grid' && 'alias', // the grid edits aliases below the row, off the keyboard walk it.entityType === 'recording' && 'video', ].filter(Boolean); } function fieldHtml(it, col, short) { const dis = it.status === 'active' ? 'disabled' : ''; if (col === 'name') return `<input type="text" class="falcon-rename-input${it.rename ? ' falcon-renamed' : ''}" data-id="${it.id}" data-col="name" title="New name for this entity — leave as-is to keep the current one" placeholder="${esc(it.name || 'new name')}" value="${esc(it.rename || it.name || '')}" ${dis} />`; if (col === 'disambig') return `<input type="text" class="falcon-disambiguation-input" data-id="${it.id}" data-col="disambig" placeholder="${short ? 'disambiguation' : 'disambiguation comment'}" value="${esc(it.disambiguation || '')}" ${dis} />`; if (col === 'barcode') return `<input type="text" class="falcon-barcode-input" data-id="${it.id}" data-col="barcode" inputmode="numeric" placeholder="barcode, for a release that has none" title="A barcode for a release that has none; a release with another barcode keeps its own" value="${esc(it.barcode || '')}" ${dis} />`; if (col === 'isrc') return `<input type="text" class="falcon-isrc-input" data-id="${it.id}" data-col="isrc" placeholder="${short ? 'ISRCs' : 'ISRCs, comma-separated'}" value="${esc((it.isrcs || []).join(', '))}" ${dis} />`; if (col === 'video') return `<input type="checkbox" class="falcon-video-input mbu-video" data-id="${it.id}" data-col="video" title="Flag this recording as a video (MusicBrainz's Video checkbox)" ${it.video ? 'checked' : ''} ${dis} />`; if (col === 'alias') { // #535: aliases apply to every entity type. Each alias is one MB edit; the // bulk case ("2 translations for all recordings") is meant to arrive as // JSON, so this stays a light add/remove list rather than a form. // #663 (majkinetor): "each alias on its own row along with the language … so it behaves like // links. Have + to add new". The language box falls back to the toolbar's Alias language. const walk = short === 'sub' ? '' : ' data-col="alias"'; const rows = (it.aliases || []).map((a, idx) => `<div class="falcon-al" title="${esc([a.type, a.primary ? 'primary for locale' : '', a.sortName ? 'sort: ' + a.sortName : ''].filter(Boolean).join(' · '))}"> <input type="text" class="falcon-alias-nm" data-id="${it.id}" data-idx="${idx}"${idx === 0 ? walk : ''} value="${esc(a.name)}" placeholder="alias${a.primary ? ' ★' : ''}" title="The alias; name@locale sets its language too. Enter adds another" ${dis} /> <input type="text" class="falcon-alias-loc" data-id="${it.id}" data-idx="${idx}" value="${esc(a.locale || '')}" placeholder="lang" title="Language (locale) of this alias, e.g. en or sr_Latn; empty for none" spellcheck="false" ${dis} /> <button type="button" class="falcon-alias-del" data-id="${it.id}" data-idx="${idx}" title="Remove this alias from the queue row" ${dis}>✕</button></div>`).join(''); return `<div class="falcon-als">${rows}</div>`; } return ''; } const FIELD_LABEL = { name: 'Name', disambig: 'Disambiguation', barcode: 'Barcode', isrc: 'ISRCs', alias: 'Aliases', video: 'Video' }; // #663 (majkinetor): the + that adds a link or an alias sits by its label const plusLbl = (it, kind, walk) => { const dis = it.status === 'active' ? 'disabled' : ''; const has = kind === 'alias' ? (it.aliases || []).length : 0; return `<span class="falcon-lbl">${kind === 'alias' ? 'Aliases' : 'Links'}<button type="button" class="falcon-${kind}-plus falcon-lplus" data-id="${it.id}"${kind === 'alias' && !has && walk ? ' data-col="alias"' : ''} title="Add ${kind === 'alias' ? 'an alias' + (cfg.aliasLang ? ' (language ' + esc(cfg.aliasLang) + ', the last one typed)' : '') : 'a link'}" ${dis}>+</button></span>`; }; // `gridSub`: the grid already has the fields on its line, so its sub-row is links + cover only function renderRowDetail(it, gridSub) { // #474/#533/#534/#535/#571: the editable fields; #663 lays them out as one // labelled form. The name box is prefilled with the CURRENT name (#571: the // job is conforming names, not retyping them); its change handler stores // nothing while it still equals that name, so a row you only looked at // never submits an edit. const links = renderLinks(it); const fields = (gridSub ? (it.entityType === 'release' ? ['barcode', 'alias'] : ['alias']) : fieldCols(it)).map(col => col === 'alias' ? plusLbl(it, 'alias', !gridSub) + fieldHtml(it, col, gridSub ? 'sub' : undefined) : col === 'video' ? `<span class="falcon-lbl">Type</span><label class="falcon-video-lbl">${fieldHtml(it, col)} video</label>` : `<span class="falcon-lbl">${FIELD_LABEL[col]}</span>${fieldHtml(it, col)}`); fields.splice(!gridSub && fieldCols(it).includes('name') ? 1 : 0, 0, plusLbl(it, 'link') + links); // #494/#496: cover art has no urls[] row to show — the image URL IS the // payload, so it gets the same input treatment (auto-picked from Harmony's // candidates but always user-editable/overridable), plus a type picker and // a provider picker when more than one candidate was found. cover[] is an // array — one row per entry (Falcon only ever populates one today, but a // JSON import can carry more). // ⚠ A release needs both its disambiguation box and this (#533 once made // them alternatives and the cover editor became unreachable). const metaCover = it.entityType === 'release' ? ` <div style="display:flex;flex-direction:column;gap:6px;font-size:10.5px"> ${it.cover.map((c, idx) => ` <div style="display:flex;flex-direction:column;gap:4px"> <div style="display:flex;align-items:center;gap:6px"> <input type="text" class="falcon-cover-input" data-id="${it.id}" data-idx="${idx}" placeholder="${c.candidates.length && !c.url ? 'resolving best cover…' : 'cover image URL'}" value="${esc(c.url || '')}" ${it.status === 'active' ? 'disabled' : ''} style="flex:1 1 auto;min-width:0;font-size:10.5px;padding:2px 6px;border:1px solid var(--mbu-border);border-radius:3px" /> <select class="falcon-cover-type" data-id="${it.id}" data-idx="${idx}" ${it.status === 'active' ? 'disabled' : ''} title="Cover art type" style="font-size:10.5px;padding:2px 4px;border:1px solid var(--mbu-border);border-radius:3px">${COVER_TYPES.map(t => `<option value="${esc(t)}"${(c.type || 'Front') === t ? ' selected' : ''}>${esc(t)}</option>`).join('')}</select> <input type="text" class="falcon-cover-comment-input" data-id="${it.id}" data-idx="${idx}" placeholder="image comment (optional)" value="${esc(c.comment || '')}" ${it.status === 'active' ? 'disabled' : ''} style="flex:1 1 auto;min-width:0;font-size:10.5px;padding:2px 6px;border:1px solid var(--mbu-border);border-radius:3px" /> </div> ${c.candidates.length > 1 ? `<div style="display:flex;gap:6px;flex-wrap:wrap">${c.candidates.map(cand => `<button type="button" class="falcon-cover-pick" data-id="${it.id}" data-idx="${idx}" data-url="${esc(cand.url)}" title="${esc(cand.url)}" ${it.status === 'active' ? 'disabled' : ''} style="border:1px solid ${cand.url === c.url ? 'var(--mbu-accent)' : 'var(--mbu-border)'};background:${cand.url === c.url ? 'var(--mbu-accent-soft)' : 'var(--mbu-bg-raised)'};border-radius:10px;padding:1px 8px;font-size:9.5px;cursor:pointer;color:${cand.url === c.url ? 'var(--mbu-accent-deep-text)' : 'var(--mbu-text)'}">${esc(cand.provider)}${cand.width ? ` ${cand.width}×${cand.height}` : ''}</button>`).join('')}</div>` : ''} </div>`).join('')} ${it.coverExistingCount ? `<div style="color:var(--mbu-warn);font-size:10px">⚠ this release already has ${it.coverExistingCount} cover image${it.coverExistingCount === 1 ? '' : 's'} — Harmony doesn't check before suggesting one, so adding this may create a duplicate</div>` : ''} </div>` : ''; if (metaCover) fields.push(`<span class="falcon-lbl">Cover</span>${metaCover}`); return `<div class="falcon-form">${fields.join('')}</div>`; } // #497: one chip per distinct entity type currently in the queue, showing // its count — click to toggle whether that type's still-queued items get // picked up by the next run. Rebuilt on every renderQueue() so it always // reflects the current queue contents (types can appear/disappear as items // are added/removed). // #663: the keyboard walk. Rows are the visible queue in order; a row whose // item lacks the column (an artist has no ISRCs) or is mid-run is skipped. let _navOpened = new Set(); // list-view rows the walk opened, closed again when it leaves them function walkTarget(id, col, dRow, dCol) { const editable = it => it && it.status !== 'active'; const byId = id2 => queue.find(i => i.id === id2); let idx = _visibleIds.indexOf(id); if (idx < 0) return null; if (dRow) { for (let i = idx + dRow; i >= 0 && i < _visibleIds.length; i += dRow) { const it = byId(_visibleIds[i]); if (editable(it) && fieldCols(it).includes(col)) return { id: it.id, col }; } return null; } let cols = fieldCols(byId(id)), c = cols.indexOf(col) + dCol; if (c >= 0 && c < cols.length) return { id, col: cols[c] }; for (let i = idx + dCol; i >= 0 && i < _visibleIds.length; i += dCol) { const it = byId(_visibleIds[i]); if (!editable(it)) continue; cols = fieldCols(it); if (cols.length) return { id: it.id, col: dCol > 0 ? cols[0] : cols[cols.length - 1] }; } return null; } function focusField(id, col, caret) { const el = document.querySelector(`#falcon-queue-list [data-id="${id}"][data-col="${col}"]`); if (!el) { dbg('keys', `no ${col} field on ${id} to focus`); return false; } el.focus({ preventScroll: true }); el.scrollIntoView({ block: 'nearest' }); if (el.type === 'text') { if (caret == null) el.select(); else el.setSelectionRange(caret, caret); } return true; } function moveFocus(from, target) { const list = view => cfg.queueView === view; let redraw = false; if (list('list') && target.id !== from.dataset.id) { if (_navOpened.has(from.dataset.id)) { _expandedIds.delete(from.dataset.id); _navOpened.delete(from.dataset.id); redraw = true; } if (!_expandedIds.has(target.id)) { _expandedIds.add(target.id); _navOpened.add(target.id); redraw = true; } } _pendingFocus = { id: target.id, col: target.col, caret: null }; // committing the field (its change handler) may redraw the queue, which // takes _pendingFocus with it; otherwise focus it ourselves from.blur(); if (_pendingFocus && redraw) renderQueue(); if (_pendingFocus) { const p = _pendingFocus; _pendingFocus = null; focusField(p.id, p.col, null); } } function renderTypeChips() { const wrap = document.getElementById('falcon-type-chips-in'); if (!wrap) return; const counts = {}; queue.forEach(i => { counts[i.entityType] = (counts[i.entityType] || 0) + 1; }); const types = Object.keys(counts).sort(); if (!types.length) { wrap.innerHTML = ''; return; } wrap.innerHTML = types.map(t => { const on = !_disabledTypes.has(t); const label = TYPE_BADGE[t] || t.slice(0, 3); return `<button type="button" class="falcon-type-chip" data-type="${esc(t)}" title="${on ? `Click to exclude every ${esc(t)} item from the next run` : `Click to include every ${esc(t)} item in the next run`}" style="border:1px solid ${on ? '#1b2a4a' : 'var(--mbu-border)'};background:${on ? '#1b2a4a' : 'var(--mbu-bg-raised)'};color:${on ? '#fff' : 'var(--mbu-text-dim)'};border-radius:12px;padding:2px 10px;font-size:10.5px;cursor:pointer;text-transform:uppercase">${esc(label)} ${counts[t]}</button>`; }).join(''); } // #513 (majkinetor): "Its still not easily seeable that there were issues. // Lets add some chip with results in appropriate color if there are // issues." One chip per non-trivial outcome status, colored from the // same DOT palette the row dots already use. Clicking toggles // _statusFilter, so it doubles as "show me just those." // #513 follow-up (majkinetor): "Lets add other statuses that are not // Done/Excluded here (Like skipped, partial etc.)" — every SETTLED // outcome except the plain success ('done') gets a chip now, including // 'skipped' (an intentional no-op, not a failure, but still worth being // able to see/filter at a glance). 'queued'/'active' are work still in // progress, not an outcome — the progress bar already covers those; // 'excluded' isn't a real status at all, just a display label for a // still-queued item whose type is toggled off. const CHIP_STATUSES = ['failed', 'partial', 'manual', 'skipped']; function renderStatusChips() { const wrap = document.getElementById('falcon-status-chips'); if (!wrap) return; const counts = {}; queue.forEach(i => { if (CHIP_STATUSES.includes(i.status)) counts[i.status] = (counts[i.status] || 0) + 1; }); const present = CHIP_STATUSES.filter(s => counts[s]); if (_statusFilter && !counts[_statusFilter]) _statusFilter = null; // the last item in that state was removed/re-run // #513 follow-up (majkinetor, live: "I don't like version without // background color as its not greatly visible... Lets make it always // have red background and find other method to show toggle state.") — // always filled now; the active filter is shown with a white ring // instead of losing the fill. wrap.innerHTML = present.map(s => { const on = _statusFilter === s; const color = DOT[s]; return `<button type="button" class="falcon-status-chip" data-status="${esc(s)}" title="${on ? 'Click to show every item again' : `Click to show only ${esc(s)} items`}" style="border:1.5px solid ${color};background:${color};color:#fff;border-radius:12px;padding:2px 10px;font-size:11px;font-weight:700;cursor:pointer;text-transform:uppercase;white-space:nowrap;flex:0 0 auto;${on ? 'box-shadow:0 0 0 2px #fff' : ''}">${esc(s)} ${counts[s]}</button>`; }).join(''); } // #663: the grid view, one line per row. The links column opens a sub-row with // the link list (and a release's cover editor), sharing the list view's // expanded state. function renderGrid(visible) { const head = `<tr>${selHead(true)}<th>Name</th>` + '<th>Disambiguation</th><th>ISRCs</th><th></th><th class="falcon-videohead" title="Video">' + '<input type="checkbox" class="mbu-video" checked disabled style="opacity:.6" /></th><th></th><th></th></tr>'; const rows = visible.map(it => { const cols = fieldCols(it), isActive = it.status === 'active'; const excluded = it.status === 'queued' && _disabledTypes.has(it.entityType); const cell = col => cols.includes(col) ? fieldHtml(it, col, true) : ''; const expanded = _expandedIds.has(it.id); const nLinks = groupedLinks(it).length; const hasCover = it.entityType === 'release'; const sub = expanded ? `<tr class="falcon-sub" data-id="${it.id}"><td colspan="10">${renderRowDetail(it, true)}</td></tr>` : ''; const nLinksTxt = nLinks ? nLinks + (nLinks === 1 ? ' link' : ' links') : '', nAl = (it.aliases || []).length; const what = [nLinksTxt, nAl ? nAl + (nAl === 1 ? ' alias' : ' aliases') : '', hasCover ? 'cover art' : ''].filter(Boolean).join(', '); return `<tr class="falcon-row" data-id="${it.id}" style="background:${ROW_BG[it.status] || ''};${excluded ? 'opacity:.45' : ''}" title="${it.error ? esc(it.error) : ''}"> <td><input type="checkbox" class="falcon-row-check" data-id="${it.id}" ${_selectedIds.has(it.id) ? 'checked' : ''} ${isActive ? 'disabled' : ''} /></td> <td><button type="button" class="falcon-row-expand falcon-xp" data-id="${it.id}" title="${expanded ? 'Hide' : 'Show and edit'} the links and aliases${what ? ' (' + esc(what) + ')' : ''}">${expanded ? '▾' : '▸'}</button></td> <td style="white-space:nowrap"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${DOT[it.status] || '#999'}"></span> <a class="falcon-row-type" data-id="${it.id}" data-type="${esc(it.entityType)}" href="${MB_ORIGIN}/${entityUrlSegment(it.entityType)}/${it.mbid}" target="_blank" rel="noopener" title="Open ${esc(entityLabel(it))} — right-click to select every ${esc(it.entityType)}" style="font-size:9px;text-transform:uppercase;color:var(--mbu-text-weak);text-decoration:none;margin-left:3px">${esc(TYPE_BADGE[it.entityType] || it.entityType.slice(0, 3))}</a></td> <td>${cols.includes('name') ? cell('name') : `<span style="padding:0 7px">${esc(entityLabel(it))}</span>`}</td> <td>${cell('disambig')}</td> <td>${cell('isrc')}</td> <td class="falcon-gsum">${esc(what)}</td> <td style="text-align:center">${cell('video')}</td> <td class="falcon-row-status" data-id="${it.id}" style="text-transform:uppercase;font-size:9px;white-space:nowrap;${it.status === 'failed' || it.status === 'partial' ? 'color:var(--mbu-error);cursor:pointer;text-decoration:underline' : 'color:var(--mbu-text-weak)'}">${excluded ? 'excluded' : it.status}</td> <td style="white-space:nowrap"><button type="button" class="falcon-row-opentab" data-id="${it.id}" title="Open this entity's edit page in a real tab, pre-filled" style="border:none;background:none;cursor:pointer;color:var(--mbu-text-dim)">⇗</button><button type="button" class="falcon-row-remove" data-id="${it.id}" ${isActive ? 'disabled' : ''} title="Remove from queue" style="border:none;background:none;cursor:pointer;color:var(--mbu-text-weak)">✕</button></td> </tr>${sub}`; }).join(''); const colgroup = '<colgroup><col style="width:24px"><col style="width:22px"><col style="width:46px"><col style="width:26%"><col style="width:19%"><col style="width:15%"><col><col style="width:28px"><col style="width:62px"><col style="width:44px"></colgroup>'; return rows ? `<table class="falcon-grid">${colgroup}${head}${rows}</table>` : ''; } // #663: which field to focus after the queue is redrawn — a redraw replaces // every input, so focus (and the keyboard walk) would otherwise drop out. let _pendingFocus = null; let _visibleIds = []; // #663: select-all and the selection count head the rows, in both views // `cells`: the grid puts each in its own header cell, over the row's own columns (#663: the ▸ lined up) function selHead(cells) { const selectable = queue.filter(i => i.status !== 'active'); const all = selectable.length > 0 && selectable.every(i => _selectedIds.has(i.id)); const open = queue.length > 0 && queue.every(i => _expandedIds.has(i.id)); const parts = [`<input type="checkbox" id="falcon-select-all" title="Select all" ${all ? 'checked' : ''} />`, `<button type="button" id="falcon-expand-all" class="falcon-xp" title="${open ? 'Collapse' : 'Expand'} every row">${open ? '▾' : '▸'}</button>`, `<span class="falcon-selcount" id="falcon-select-count" title="Selected rows">${_selectedIds.size || ''}</span>`]; return cells ? parts.map(x => `<th class="falcon-selhead">${x}</th>`).join('') : `<span class="falcon-selhead">${parts.join('')}</span>`; } // #680: a headless batch (Mission Control's, through falcon:run with `headless` and `tag`) // never opens the panel; each change to its items is reported as falcon:status instead, // { tag, running, items: [{ entityType, mbid, name, status, error, urls }] }, once a tick. const _tagged = new Map(); // item id -> tag let _statusQueued = false; function emitTaggedStatus() { if (!_tagged.size || _statusQueued) return; _statusQueued = true; setTimeout(() => { _statusQueued = false; const byTag = new Map(); queue.forEach(i => { const t = _tagged.get(i.id); if (t) (byTag.get(t) || byTag.set(t, []).get(t)).push(i); }); byTag.forEach((items, tag) => { const detail = JSON.stringify({ tag, running, items: items.map(i => ({ entityType: i.entityType, mbid: i.mbid, name: i.name || '', status: i.status, error: i.error || '', urls: (i.urls || []).length, cover: (i.cover || []).length })) }); document.dispatchEvent(new CustomEvent('falcon:status', { detail })); // forget a tag once every item settled and the run is over if (!running && items.every(i => !['queued', 'active'].includes(i.status))) items.forEach(i => _tagged.delete(i.id)); }); }, 0); } function renderQueue() { emitTaggedStatus(); renderTypeChips(); renderStatusChips(); const list = document.getElementById('falcon-queue-list'); if (!list) return; const af = document.activeElement; const keep = _pendingFocus || (af && list.contains(af) && af.dataset.col ? { id: af.dataset.id, col: af.dataset.col, caret: af.type === 'text' ? af.selectionStart : null } : null); _pendingFocus = null; const visible = _statusFilter ? queue.filter(i => i.status === _statusFilter) : queue; _visibleIds = visible.map(i => i.id); const grid = cfg.queueView === 'grid'; const viewBtn = document.getElementById('falcon-view-toggle'); if (viewBtn) { viewBtn.querySelector('.falcon-bi').textContent = grid ? '☰' : '▦'; viewBtn.querySelector('.falcon-bt').textContent = grid ? 'List' : 'Grid'; viewBtn.title = grid ? 'Show the queue as a list, one form per open row' : 'Show the queue as a grid, one line per row'; } const listRows = grid ? '' : visible.map(it => { const expanded = _expandedIds.has(it.id); const checked = _selectedIds.has(it.id); const isActive = it.status === 'active'; // #497: still queued, but its type is toggled off — won't be picked up // by the next run. Dimmed + a distinct label, but the underlying // item.status stays 'queued' (this is a queue-level filter, not an // outcome — an already active/done/failed item is never affected). const excluded = it.status === 'queued' && _disabledTypes.has(it.entityType); return ` <div class="falcon-row" data-id="${it.id}" style="border-bottom:1px solid var(--mbu-border);color:var(--mbu-text);background:${ROW_BG[it.status] || ''};${excluded ? 'opacity:.45' : ''}"> <div style="display:flex;align-items:center;gap:6px;padding:2px 0" title="${it.error ? esc(it.error) : excluded ? 'This type is toggled off above — won\'t be processed until turned back on' : ''}"> <input type="checkbox" class="falcon-row-check" data-id="${it.id}" ${checked ? 'checked' : ''} ${isActive ? 'disabled' : ''} style="flex:0 0 auto" /> <button type="button" class="falcon-row-expand" data-id="${it.id}" title="${groupedLinks(it).length > 1 ? 'Show/hide urls' : it.entityType === 'recording' ? 'Show detail / edit disambiguation & ISRC' : DISAMBIGUATABLE.has(it.entityType) ? 'Show detail / edit disambiguation' + (it.entityType === 'release' ? ' & cover art image' : '') : 'Show url detail'}" style="border:none;background:none;cursor:pointer;color:var(--mbu-text-dim);flex:0 0 auto;font-size:15px;line-height:1;width:22px;height:22px;padding:0;display:flex;align-items:center;justify-content:center">${expanded ? '▾' : '▸'}</button> <span style="width:8px;height:8px;border-radius:50%;background:${DOT[it.status] || '#999'};flex:0 0 auto"></span> <span class="falcon-row-type" data-id="${it.id}" data-type="${esc(it.entityType)}" title="Right-click to select every ${esc(it.entityType)} in the queue" style="width:32px;flex:0 0 auto;font-size:9px;text-transform:uppercase;color:var(--mbu-text-weak);text-align:center;cursor:context-menu">${esc(TYPE_BADGE[it.entityType] || it.entityType.slice(0, 3))}</span> <a href="${MB_ORIGIN}/${entityUrlSegment(it.entityType)}/${it.mbid}" target="_blank" rel="noopener" title="${esc(it.entityType)}/${esc(it.mbid)}" style="color:var(--mbu-info);text-decoration:none;font-weight:600;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto">${esc(entityLabel(it))}</a> ${(() => { // #518 (majkinetor): "When there is 1 link, its shown instead of // `1 link, isrc`" — disambiguation/ISRC/cover only ever got // folded into the summary in the zero-links branch below, so a // single-link item silently hid whichever of those it also had. // // #547 (majkinetor): "Here, we show no links, and do not show // aliases. I think we should be consistent and report only what is // present, without those - placeholders as they are just spam." // So: one list built only from fields that actually carry // something, and a genuinely empty row says nothing at all rather // than "no links — —". Aliases and video were missing from this // entirely — a row could show an alias chip in its own detail and // still summarise itself as "no links". const isrcN = (it.isrcs || []).filter(Boolean).length; const aliasN = (it.aliases || []).filter(a => a && String(a.name || '').trim()).length; const hasCover = (it.cover || []).some(c => c.url || (c.candidates || []).length); const extras = [ it.rename ? 'rename' : '', it.disambiguation ? 'disambiguation' : '', isrcN ? (isrcN === 1 ? 'ISRC' : `${isrcN} ISRCs`) : '', it.video ? 'video' : '', aliasN ? (aliasN === 1 ? 'alias' : `${aliasN} aliases`) : '', hasCover ? (it.coverExistingCount ? 'cover ⚠' : 'cover') : '', ].filter(Boolean).join(' + '); const coverTitle = hasCover && it.coverExistingCount ? esc(`already has ${it.coverExistingCount} cover image${it.coverExistingCount === 1 ? '' : 's'} — this may duplicate it`) : ''; // Count distinct URLs, as the grid does — urls[] holds one entry // per URL × link type, so a URL with two types counted twice. const linkN = groupedLinks(it).length; if (linkN > 1) return `<span style="color:var(--mbu-text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${coverTitle}">${linkN} links${extras ? ' + ' + esc(extras) : ''}</span>`; if (linkN === 1) return `<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${coverTitle}"><a href="${esc(it.urls[0].url)}" target="_blank" rel="noopener" style="color:var(--mbu-info);text-decoration:none">${esc(it.urls[0].url)}</a>${extras ? ` <span style="color:var(--mbu-text-dim)">+ ${esc(extras)}</span>` : ''}</span>`; // #494/#496: release rows never carry a urls[] entry — cover art // is their whole payload — so they always land here. if (extras) return `<span style="color:var(--mbu-text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${coverTitle}">${esc(extras)}</span>`; // Genuinely empty — an "Add from release" row waiting to be filled // in. The spacer holds the status column where it sits on every // other row; it deliberately says nothing. return '<span style="flex:1"></span>'; })()} <span class="falcon-row-status" data-id="${it.id}" title="${it.status === 'failed' || it.status === 'partial' ? 'Click to inspect this failure' : ''}" style="text-transform:uppercase;font-size:9px;flex:0 0 auto;${it.status === 'failed' || it.status === 'partial' ? 'color:var(--mbu-error);cursor:pointer;text-decoration:underline' : 'color:var(--mbu-text-weak)'}">${excluded ? 'excluded' : it.status}</span> <button type="button" class="falcon-row-opentab" data-id="${it.id}" title="Open this entity's edit page in a real tab, pre-filled, to inspect/complete manually" style="border:none;background:none;cursor:pointer;color:var(--mbu-text-dim);flex:0 0 auto">⇗</button> <button type="button" class="falcon-row-remove" data-id="${it.id}" ${isActive ? 'disabled' : ''} title="Remove from queue" style="border:none;background:none;cursor:pointer;color:var(--mbu-text-weak);flex:0 0 auto">✕</button> </div> ${expanded ? renderRowDetail(it) : ''} </div>`; }).join(''); list.innerHTML = (grid ? renderGrid(visible) : listRows && `<div class="falcon-lhead">${selHead()}</div>${listRows}`) || (_statusFilter ? `<div style="color:var(--mbu-text-weak);padding:8px 0">No ${esc(_statusFilter)} items right now — <button type="button" id="falcon-status-filter-clear" style="border:none;background:none;color:var(--mbu-info);cursor:pointer;padding:0;font:inherit;text-decoration:underline">show everything</button>.</div>` : '<div style="color:var(--mbu-text-weak);padding:8px 0">Queue is empty — click + above to paste some entities.</div>'); if (keep) focusField(keep.id, keep.col, keep.caret); const selCount = document.getElementById('falcon-select-count'); if (selCount) selCount.textContent = _selectedIds.size || ''; const removeBtn = document.getElementById('falcon-remove-selected'); if (removeBtn) removeBtn.disabled = _selectedIds.size === 0; const retryBtn = document.getElementById('falcon-retry-failed'); if (retryBtn) retryBtn.disabled = !queue.some(i => i.status === 'failed' || i.status === 'partial'); const moreBtn = document.getElementById('falcon-run-more'); if (moreBtn) moreBtn.textContent = retryBtn && !retryBtn.disabled ? '▾•' : '▾'; // a dot: there is something to retry const selectAll = document.getElementById('falcon-select-all'); if (selectAll) { const selectable = queue.filter(i => i.status !== 'active'); selectAll.checked = selectable.length > 0 && selectable.every(i => _selectedIds.has(i.id)); } renderProgress(); renderCoverWarning(); fitBars(); } // #467 (majkinetor): "Lets have a progress bar". Counts anything that has // reached a terminal state as finished — done/partial/failed/manual all mean // "this one is no longer waiting on us" — so the bar tracks the run, not just // the successes. Bar turns amber if anything failed, so a run that completed // but left casualties doesn't read as a clean green sweep. function renderProgress() { emitTaggedStatus(); const bar = document.getElementById('falcon-progress-bar'); const track = document.getElementById('falcon-progress-track'); const txt = document.getElementById('falcon-progress-text'); if (!bar || !txt) return; // #497 (majkinetor, live: "progress bar max items remains old"): a // toggled-off type's still-queued items will never settle (nextQueued // skips them), so counting them in the denominator meant the bar could // never reach 100% while any type was excluded. An item that already // reached a terminal state BEFORE being excluded still counts — this // only drops the ones currently sitting out. const total = queue.filter(i => !(i.status === 'queued' && _disabledTypes.has(i.entityType))).length; // #519: a plain static bar looks the same whether a run is grinding // through the queue or just sitting at its last finished percentage — // pulse it for as long as `running` actually is, the same signal // updateRunBtn() already uses for its own Start/Stop label. On the // TRACK, not the fill: the fill sits at 0% width (invisible) for as // long as nothing has finished yet, exactly when the pulse matters most. if (track) track.classList.toggle('falcon-running', running); if (!total) { bar.style.width = '0%'; txt.textContent = ''; return; } const settled = queue.filter(i => i.status !== 'queued' && i.status !== 'active').length; const bad = queue.filter(i => i.status === 'failed' || i.status === 'partial').length; // 'skipped' is a success (already up to date), not a problem const active = queue.filter(i => i.status === 'active').length; bar.style.width = Math.round((settled / total) * 100) + '%'; bar.style.background = bad ? '#d68910' : '#2e9e5b'; txt.textContent = `${settled}/${total}` + (active ? ` · ${active} running` : '') + (bad ? ` · ${bad} problem${bad > 1 ? 's' : ''}` : ''); } // #494 follow-up (majkinetor: "that requires row to be in view. Lets put the // warning bellow the progress bar so its visible all the time") — a // standing summary of every release in the queue whose existingCount check // (see checkExistingCoverArt) came back positive, always visible instead of // needing that row expanded/scrolled into view. function renderCoverWarning() { const el = document.getElementById('falcon-cover-warning'); if (!el) return; const dupes = queue.filter(i => i.entityType === 'release' && i.coverExistingCount); if (!dupes.length) { el.style.display = 'none'; el.textContent = ''; return; } el.style.display = 'block'; const names = dupes.map(i => `${entityLabel(i)} (${i.coverExistingCount})`).join(', '); el.textContent = `⚠ ${dupes.length} release${dupes.length > 1 ? 's' : ''} already ${dupes.length > 1 ? 'have' : 'has'} cover art — ${names}`; } // #467 (majkinetor): "click the failed label, open its worker alone in a // popup... show error in header" — then, after seeing a text-only version: // "I didn't envision item details like this. I want to have worker visible // there, in its active state." Tried REPARENTING the real iframe into a // popup next — moving an iframe element to a new parent turned out to reset // it to about:blank in Chromium (confirmed live), destroying the exact state // we were trying to preserve. The iframe must never move, so instead: jump to // the Workers tab and zoom the SAME card the retire/queue mechanism already // keeps alive in place (see retireCard) — the real, untouched iframe, just // shown larger, with its error now in a banner right on the card (not just a // hover tooltip) so it's visible once zoomed. Falls back to a plain text // popup (url list + error) only if the item was never picked up by any // worker at all (so there's no card/iframe to jump to). function focusItemWorker(item) { const idx = workerCards.findIndex(c => c.dataset.itemId === item.id); if (idx === -1) { showItemPopup(item); return; } _zoomedWorker = idx; setTab('workers'); renderWorkerLayout(); } let _itemPopupId = null; function ensureItemPopup() { if (document.getElementById('falcon-item-popup')) return; const el = document.createElement('div'); el.id = 'falcon-item-popup'; el.style.cssText = 'display:none;position:fixed;z-index:2147483647;left:50%;top:50%;transform:translate(-50%,-50%);width:520px;max-width:92vw;max-height:70vh;background:var(--mbu-bg);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.3);border:1px solid var(--mbu-border);overflow:hidden;flex-direction:column;font:12px -apple-system,Segoe UI,Arial,sans-serif'; el.innerHTML = ` <div id="falcon-item-popup-hdr" style="padding:8px 10px;background:#7a2020;color:#fff;display:flex;align-items:center;gap:8px"> <span id="falcon-item-popup-title" style="flex:1;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span> <button type="button" id="falcon-item-popup-close" style="background:none;border:none;color:#fff;cursor:pointer;font-size:14px">✕</button> </div> <div id="falcon-item-popup-error" style="padding:8px 10px;background:var(--mbu-error-bg);color:var(--mbu-error);font-size:11px;white-space:pre-wrap;border-bottom:1px solid var(--mbu-error)"></div> <div id="falcon-item-popup-body" style="padding:8px 10px;overflow:auto;flex:1"></div> <div style="padding:8px 10px;border-top:1px solid var(--mbu-border);display:flex;justify-content:flex-end"> <button type="button" id="falcon-item-popup-opentab" style="padding:4px 10px;cursor:pointer">⇗ Open in tab</button> </div>`; document.body.appendChild(el); document.getElementById('falcon-item-popup-close').onclick = () => { el.style.display = 'none'; _itemPopupId = null; }; document.getElementById('falcon-item-popup-opentab').onclick = () => { const it = queue.find(i => i.id === _itemPopupId); if (it) openInTab(it); }; } function showItemPopup(item) { ensureItemPopup(); _itemPopupId = item.id; const el = document.getElementById('falcon-item-popup'); document.getElementById('falcon-item-popup-title').textContent = `${entityLabel(item)} — ${item.status.toUpperCase()}`; document.getElementById('falcon-item-popup-error').textContent = item.error || '(no error message recorded)'; document.getElementById('falcon-item-popup-body').innerHTML = renderRowDetail(item) || '<div style="color:var(--mbu-text-weak)">No url detail available — this item was never picked up by a worker.</div>'; el.style.display = 'flex'; } // #512 follow-up (majkinetor): "add release name in the log name if present" — read // back from the [names] line #509 logs, for a session stored before the name key function extractReleaseName(entries) { for (const e of entries) { const m = /\[names\] release:[0-9a-f-]+ — (?:fetched|passed from source): "([^"]+)"/.exec(e.msg); if (m) return m[1]; } return null; } /* #546: mark a toolbar button as working — spinner, a label saying what it is doing, and disabled so the action cannot be stacked on itself. Kept next to updateRunBtn because it shares that function's constraint: write the .falcon-bt / .falcon-bi spans, never the button's textContent, or the collapse markup is wiped and the button sticks at full width. */ // #573: reflect whether a batch note is set, so it is obvious from the bar // that every edit in the run will carry one — a reason silently attached to // edits is the thing to avoid. // force — an IMPORT is an explicit user action and must win over the // don't-fight-the-cursor guard below; typing must not. function syncBatchNoteUi(force) { const btn = document.getElementById('falcon-note-btn'); const box = document.getElementById('falcon-note-text'); if (box && (force || document.activeElement !== box)) box.value = batchNote(); if (!btn) return; const has = !!batchNote(); btn.style.borderColor = has ? 'var(--mbu-accent)' : ''; btn.style.color = has ? 'var(--mbu-accent-text)' : ''; btn.style.fontWeight = has ? '700' : ''; btn.title = has ? `Batch edit note (on every edit this run): ${batchNote()}` : 'Batch edit note — appended to every edit in this run'; } function setBtnBusy(btn, on, opts) { if (!btn) return; const o = opts || {}; const bt = btn.querySelector('.falcon-bt'), bi = btn.querySelector('.falcon-bi'); if (on) { if (btn.dataset.falconIdle == null) btn.dataset.falconIdle = JSON.stringify({ bt: bt ? bt.textContent : '', bi: bi ? bi.textContent : '', title: btn.title }); if (bt && o.label) bt.textContent = o.label; if (o.title) btn.title = o.title; } else if (btn.dataset.falconIdle != null) { let was = {}; try { was = JSON.parse(btn.dataset.falconIdle); } catch (e) { /* fall through to defaults */ } if (bt) bt.textContent = was.bt || ''; if (bi) bi.textContent = was.bi || ''; btn.title = was.title || ''; delete btn.dataset.falconIdle; } btn.classList.toggle('falcon-busy', !!on); btn.disabled = !!on; fitBar(btn.closest('.falcon-bar')); } /* #546: MusicBrainz answering slowly is the normal case here, not the exception — this release's own one-request graph fetch was measured at 20-45s against production. Nothing can make that faster (it is already a single request), so say so, repeatedly, instead of going silent. */ function beginWait(what) { const t0 = Date.now(); const iv = setInterval(() => { const s = Math.round((Date.now() - t0) / 1000); log('info', `still waiting on MusicBrainz for ${what} — ${s}s so far. Large releases can take half a minute; nothing is stuck.`); }, 5000); return { done: () => { clearInterval(iv); return Date.now() - t0; } }; } function updateRunBtn() { const b = document.getElementById('falcon-run'); if (!b) return; // label/icon spans only — see the expand-all note; textContent would wipe // out the collapse markup and leave the button stuck at full width. const bt = b.querySelector('.falcon-bt'), bi = b.querySelector('.falcon-bi'); if (bt) bt.textContent = running ? 'Stop' : 'Start'; if (bi) bi.textContent = running ? '■' : '▶'; b.title = running ? 'Stop after the running workers finish their current item' : 'Start processing the queue'; fitBar(b.closest('.falcon-bar')); } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } /* ── boot ────────────────────────────────────────────────────────────── */ // #551: do nothing whatsoever on MusicBrainz's "Verifying your browser" // interstitial. Not merely "don't consume the token" — there is no point // mounting a launcher or opening a session on a page that is about to replace // itself. The real page loads a moment later and everything runs there // untouched, exactly as it does when no challenge is served. if (isVerifyInterstitial()) { try { console.info('[Falcon] MusicBrainz served its "Verifying your browser" challenge instead of the page — standing down. Nothing is consumed; Falcon starts normally on the real page once the challenge clears.'); } catch (e) {} } else if (ON_HARMONY) { ensureHarmonyButton(); // #590: FIRST, before any of the scraping below. Harmony renders its error // boxes server-side, so they are in the first byte of HTML — a page that // errored is known to be worthless immediately, and waiting for three // unchanged action counts and then a 4s send countdown only delays it. maybeReloadOnError(); // Harmony's actions render client-side after load — rescan until the count // settles (3 unchanged reads), then stop polling. let stableCount = 0, lastN = -1; const iv = setInterval(() => { const n = scrapeHarmonyActions().length; ensureHarmonyButton(); if (n === lastN) { // #557: the auto-send hangs off the SAME settle signal the polling // already computes — sending earlier would ship a partial batch. if (++stableCount >= 3) { clearInterval(iv); maybeAutoSend(); } } else { stableCount = 0; lastN = n; } }, 1000); } else { const seeded = parseUrlParam(); ensureLauncher(); // #671: a script on this same page (Platform Check's Artists & labels) hands over the // JSON model as a DOM event, which every userscript sandbox hears; it is queued as // Import queues a file, and the ack tells the sender not to open a tab instead. // falcon:run also starts the queue (only a script already on this page can ask that; // a ?falcon= link never starts on its own). // A root `closeWhenDone: true` on falcon:run closes the panel once that run finishes // with nothing failed (Platform Check's "Close Falcon after a successful import"). // #680: a root `headless: true` keeps the panel shut, and `tag` names the batch in the // falcon:status reports (see emitTaggedStatus). A failed item still shows on the launcher. const fromPage = run => e => { if (typeof e.detail !== 'string') return; document.dispatchEvent(new CustomEvent('falcon:import-ok')); let root = {}; try { root = JSON.parse(e.detail) || {}; } catch (x) { /* importQueueJson logs it */ } const before = new Set(queue.map(i => i.id)); const r = importQueueJson(e.detail, 'a script on this page', { merge: true }); if (root.tag) { const keys = new Set((root.items || []).map(x => normalizeEntityType(x.entityType) + ':' + String(x.mbid || '').toLowerCase())); queue.forEach(i => { if (!before.has(i.id) || (i.status === 'queued' && keys.has(i.entityType + ':' + String(i.mbid).toLowerCase()))) _tagged.set(i.id, String(root.tag)); }); log('info', `batch tagged ${JSON.stringify(String(root.tag))}: ${[..._tagged.values()].filter(t => t === String(root.tag)).length} item(s)${root.headless ? ', headless (panel stays shut)' : ''}`); emitTaggedStatus(); } if (!root.headless) showPanel(); else if (!panel || panel.style.display !== 'flex' || panel.dataset.headless) { // the workers live in the panel's strip, so build it, but off-screen (showPanel brings it back) ensurePanel(); if (!panel.dataset.headless) panel.dataset.headless = JSON.stringify({ left: panel.style.left, top: panel.style.top }); panel.style.cssText += ';display:flex;left:-20000px;top:0;opacity:0;pointer-events:none'; } if (run && r && (r.added || r.merged)) { let close = false; try { close = JSON.parse(e.detail).closeWhenDone === true; } catch (x) { /* importQueueJson logged it */ } whenQueueSettles(20000).then(() => { start(); _closeWhenDone = close && running; }); } }; document.addEventListener('falcon:import', fromPage(false)); document.addEventListener('falcon:run', fromPage(true)); document.addEventListener('falcon:show', () => showPanel()); // #680: Mission Control's "Open Falcon" // #591 — both halves of the rip-log flow live on ordinary MusicBrainz pages // and are independent of the queue, so they run whether or not this tab was // seeded with anything. ensureDiscIdUi(); autoPickAttachMedium(); const applySeed = seeded => { if (seeded && seeded.importText) { // #671: the whole JSON model, read exactly as Import reads a file newSession('seeded from the falcon= URL param (the JSON model)'); importQueueJson(seeded.importText, 'the falcon= URL', { merge: true }); showPanel(); } else if (seeded && seeded.length) { // #512 (majkinetor, live: "See the log before starting queue - it // still contains older logs from 13:54") — falcon:session:current // lives in localStorage, which is shared across EVERY tab on // musicbrainz.org, not scoped to this one. A brand new tab opened by // "Send to Falcon" (or any fresh `?falcon=` seed) was reattaching to // whatever session the LAST tab's LAST run left behind, instead of // starting clean. A genuine new seed always means a new session. newSession(`seeded ${seeded.length} item(s) from the falcon= URL param`); if (seeded.fromHarmony) _harmonySeeded = true; addToQueue(seeded); showPanel(); // #508 follow-up (majkinetor): "Auto start Harmony import (off by // default)" — only for a genuine Harmony-sourced seed (the GM-storage // token scheme), not an arbitrary `?falcon=` base64 payload some other // script/user constructed by hand. if (seeded.fromHarmony && cfg.autoStartHarmonyImport) { // …after the queue stops growing. See whenQueueSettles: starting on the // synchronous half of a Harmony payload skipped the cover and left the // recordings behind. whenQueueSettles(20000).then(() => start()); } } }; if (seeded && seeded.awaitToken) { // Not in this copy's storage: another installed copy, starting after this one, may // hold it and hand it over (falconHandOver). It starts within moments, or not at all. const token = seeded.awaitToken; let done = false; const onHandOver = e => { const h = handedOver(token, e.detail); if (!h || done) return; done = true; document.removeEventListener('falcon:handover', onHandOver); log('info', 'the batch was written by another installed copy of Falcon, which handed it over'); applySeed(decodeSeed(h.json, true, h.note)); }; document.addEventListener('falcon:handover', onHandOver); setTimeout(() => { if (done) return; done = true; document.removeEventListener('falcon:handover', onHandOver); tokenLost(); }, 4000); } else applySeed(seeded); // (An interrupted run used to force the panel open here on the Log tab. It // was scaffolding for chasing the tab-closing bug, and it had a nasty edge: // with the panel already open at boot, the launcher's first click TOGGLED IT // SHUT. The previous session's log is still restored and still one click // away on the Log tab — it just doesn't hijack the panel any more.) window.addEventListener('keydown', e => { if (!e.ctrlKey || !e.altKey || e.shiftKey || e.metaKey) return; if ((e.key || '').toLowerCase() !== 'f') return; e.preventDefault(); e.stopPropagation(); togglePanel(); }); // Esc closes the panel, or first whatever is open over it: an item's popup here, the // add menu and the log window by their own Esc. Listened to on the way down, so it // sees those still open. Typing in the page's own fields is left alone; in the // panel's fields Esc still closes it. document.addEventListener('keydown', e => { if (e.key !== 'Escape' || e.defaultPrevented || e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return; if (!panel || panel.style.display === 'none') return; const pop = document.getElementById('falcon-item-popup'); if (pop && pop.style.display !== 'none') { pop.style.display = 'none'; _itemPopupId = null; e.stopPropagation(); return; } if (document.querySelector('.falcon-addmenu, #mbu-logpop')) return; const t = e.target; if (t && t.closest && !t.closest('#falcon-panel') && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return; // a new, still empty alias: its own Esc drops it (#663), which this one, heard first, used to beat const al = t && t.classList && (t.classList.contains('falcon-alias-nm') || t.classList.contains('falcon-alias-loc')) ? queue.find(q => q.id === t.dataset.id)?.aliases?.[+t.dataset.idx] : null; if (al && !String(al.name || '').trim()) return; panel.style.display = 'none'; }, true); } // Test hook only (#467) — no behavior change; built only on a test page (#623). if (mbuTestHooks()) window.__falconTest = { DISAMBIGUATABLE, pageEntityContext, fetchReleaseGraph, fetchGroupReleases, releaseGraphTuples, normalizeAliases, isDuplicateAlias, fetchExistingAliases, submitAlias, runAliasItem, resolveAliasTypeId, parseLine, parsePaste, parseUrlParam, parseHarmonySeedUrl, encodeFalconPayload, scrapeHarmonyActions, makePendingToken, addToQueue, getQueue: () => queue, setQueue: q => { queue = q; renderQueue(); }, start, stop, cfg, fillAndSubmit, findAddLinkInput, findSubmitButton, findFieldError, findNoChangesWarning, setRowLinkType, addSecondRelationshipType, editUrl, buildSeedEditUrl, nextQueued, fetchEntityName, entityLabel, openInTab, getSelectedIds: () => _selectedIds, getExpandedIds: () => _expandedIds, mbThrottle, showItemPopup, focusItemWorker, importQueueJson, suspendNameLookups, resumeNameLookups, getLog: () => Log.entries().map(e => `[${e.t ? e.t.toISOString().slice(11, 19) : "--:--:--"}] ${e.sev.toUpperCase().padEnd(5)} ${e.cat ? `[${e.cat}] ` : ""}${e.msg}`), getSessionId: () => Log.sessionId(), noteUnload, editNoteText, setEditNote, isLoggedIn, scrapeHarmonyIsrcs, harmonyIsrcFallback, resolveIsrcFallback, // #494 scrapeHarmonyCover, parseCoverCaptionMeta, pickBestCover, coverEditNote, gmFetch, IMGMAX, runCoverItem, mimeFromUrl, checkExistingCoverArt, // #495 entityUrlSegment, activateReleaseEditNoteTab, // #497 getDisabledTypes: () => _disabledTypes, setDisabledTypes: s => { _disabledTypes = s; renderQueue(); }, // #671 setHarmonySeeded: v => { _harmonySeeded = !!v; }, // #500 // #509 follow-up resolveMissingNames, // #512 listSessionKeys, pruneOldSessions, loadSessionLines, Log, logRunSummary, // #512 follow-up: release name persisted outside the trimmable log sessionNameKey, noteSessionReleaseName, sessionReleaseName, deleteSessionData, // #593 newSession, writeLogNow, log, dbg, LOG_PERSIST_MAX: () => LOG_PERSIST_MAX, LOG_PERSIST_HEAD: () => LOG_PERSIST_HEAD, // #513 getStatusFilter: () => _statusFilter, setStatusFilter: s => { _statusFilter = s; renderQueue(); }, // #508 follow-up topUpWorkers, getWorkerCardCount: () => workerCards.length, isRunning: () => running, // #512 follow-up sessionHasRealWork, extractReleaseName, isVerifyInterstitial, // #551 // #546 setBtnBusy, beginWait, renderQueue, // #547 updateWorkerLabel, workerPhase, spawnWorkerCard, // #557 sendToFalcon, maybeAutoSend, cancelAutoSend, openMbTab, harmonyReleaseMbid, picardParam, sendReleaseToPicard, autoSendPending: () => !!_autoSendTimer, autoSendFired: () => _autoSendDone, // #590 harmonyErrorState, harmonyErrorSummary, harmonyReloadDelayMs, maybeReloadOnError, cancelHarmonyReload, onHarmonyActionsPage, harmonyReloadCount, setHarmonyReloadCount, HARMONY_PERMANENT_RE, HARMONY_SLOW_DOWN_RE, HARMONY_UNCHECKED_RE, isMbErrorBox, reloadPending: () => !!_reloadTimer, // #588 maybeReloadReleasePage, reloadedAfterImportHere, RELOADED_KEY, maybeClosePanelAfterRun, setCloseWhenDone: v => { _closeWhenDone = v; }, showPanel, // #591 parseRipLog, parseEacLog, parseWhipperLog, parseDbPowerampLog, parseCyanripLog, calcMbToc, mbDiscId, tocParam, readLogText, discIdFromLog, ensureDiscIdUi, autoPickAttachMedium, mediumMayHaveDiscIds, scrapeMediumIds, goAttach, resolveMediumId, goAttachResolved, signAttachEditNote, FALCON_SIGNATURE, // #571 RENAMEABLE, NAME_SEEDS, setReleaseName, setReleaseField, // #572 fetchSeriesMembers, fetchReleaseGroupsOf, // #573 batchNote, setBatchNote, withBatchNote, syncBatchNoteUi }; // editNoteText is listed above })(); })(); } catch (e) { try { console.error('[String Theory] falcon failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== first_contact (@run-at document-start) ===================================== if (__stOff.indexOf("first_contact") < 0 && __stRuns("first_contact", {"inc":["^https:\\/\\/www\\.deezer\\.com(?::\\d+)?/.*$","^https:\\/\\/(?:[^/]*\\.)?bandcamp\\.com(?::\\d+)?/album/.*$","^https:\\/\\/www\\.discogs\\.com(?::\\d+)?/.*$","^https:\\/\\/music\\.apple\\.com(?::\\d+)?/.*$","^https:\\/\\/tidal\\.com(?::\\d+)?/.*$","^https:\\/\\/listen\\.tidal\\.com(?::\\d+)?/.*$","^https:\\/\\/www\\.qobuz\\.com(?::\\d+)?/.*/album/.*$","^https:\\/\\/www\\.beatport\\.com(?::\\d+)?/.*$","^https:\\/\\/open\\.spotify\\.com(?::\\d+)?/.*$","^https:\\/\\/music\\.youtube\\.com(?::\\d+)?/.*$","^https:\\/\\/www\\.youtube\\.com(?::\\d+)?/playlist.*$","^https:\\/\\/volumo\\.com(?::\\d+)?/.*$","^https:\\/\\/www\\.hdtracks\\.com(?::\\d+)?/.*$","^https:\\/\\/soundcloud\\.com(?::\\d+)?/.*$","^https:\\/\\/audiomack\\.com(?::\\d+)?/.*$","^https:\\/\\/(?:[^/]*\\.)?7digital\\.com(?::\\d+)?/artist/.*$","^https:\\/\\/ototoy\\.jp(?::\\d+)?/_/default/p/.*$","^https:\\/\\/music\\.amazon\\.com(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.co\\.uk(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.de(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.fr(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.it(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.es(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.ca(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.co\\.jp(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.com\\.au(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.com\\.br(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.com\\.mx(?::\\d+)?/.*$","^https:\\/\\/music\\.amazon\\.in(?::\\d+)?/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/add.*$"],"exc":[],"noframes":true})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"First Contact*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Import a release into MusicBrainz from the platform's album page with one click: the release editor opens with the title, tracklist, artists, date, label, barcode and link filled in. Each artist's platform link is handed to Apollo Editor for matching. A second button sends the album to Harmony instead. By default the album page is also saved on the Internet Archive (it sends each imported URL to archive.org; off in the settings), and the edit note links the snapshot. Platforms: Deezer, Bandcamp, Discogs, Apple Music, Tidal, Qobuz, Beatport, Spotify, YouTube Music, Volumo, HDtracks, SoundCloud, Amazon Music, Audiomack, 7digital, Ototoy.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/first_contact/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5GaXJzdCBDb250YWN0PC90aXRsZT4KPGxpbmVhckdyYWRpZW50IGlkPSJmYy1kIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjYmFlNmZkIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMzhiZGY4Ii8+PC9saW5lYXJHcmFkaWVudD48cmFkaWFsR3JhZGllbnQgaWQ9ImZjLWIiIGN4PSIuMzUiIGN5PSIuMyIgcj0iLjgiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iI2ZmZjdjYyIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2Y2YzQzMSIvPjwvcmFkaWFsR3JhZGllbnQ+PHBhdGggZD0iTTUwIDQyIEw3OCA0MiBMMTA0IDExMiBMMjQgMTEyIFoiIGZpbGw9IiNmNmM0MzEiIG9wYWNpdHk9Ii45Ii8+PHBhdGggZD0iTTU2IDQyIEw3MiA0MiBMODggMTEyIEw0MCAxMTIgWiIgZmlsbD0iI2ZkZTY4YSIgb3BhY2l0eT0iLjciLz48ZWxsaXBzZSBjeD0iNjQiIGN5PSIxMTQiIHJ4PSI0MiIgcnk9IjgiIGZpbGw9IiMxZTIzNDYiIHN0cm9rZT0iI2M5Y2RmMiIgc3Ryb2tlLXdpZHRoPSIzIi8+PGVsbGlwc2UgY3g9IjY0IiBjeT0iMTEzIiByeD0iMjYiIHJ5PSI0LjIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2Y2YzQzMSIgc3Ryb2tlLXdpZHRoPSIyLjQiLz48cGF0aCBkPSJNNDIgMjggQTIyIDE4IDAgMCAxIDg2IDI4IFoiIGZpbGw9InVybCgjZmMtZCkiIHN0cm9rZT0iI2M5Y2RmMiIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTUwLjM2IDE5LjkgQTE1LjM5OTk5OTk5OTk5OTk5OSAxMy41IDAgMCAxIDYxLjggMTEuOCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBvcGFjaXR5PSIuOSIvPjxlbGxpcHNlIGN4PSI2NCIgY3k9IjMyIiByeD0iNTYiIHJ5PSIxMiIgZmlsbD0iIzFlMjM0NiIgc3Ryb2tlPSIjYzljZGYyIiBzdHJva2Utd2lkdGg9IjMiLz48ZWxsaXBzZSBjeD0iNjQiIGN5PSI0MCIgcng9IjIyIiByeT0iNiIgZmlsbD0idXJsKCNmYy1iKSIgc3Ryb2tlPSIjYzljZGYyIiBzdHJva2Utd2lkdGg9IjIuNCIvPjxjaXJjbGUgY3g9IjIyIiBjeT0iMzEiIHI9IjQiIGZpbGw9IiNmNmM0MzEiLz48Y2lyY2xlIGN4PSI0MCIgY3k9IjMxIiByPSI0IiBmaWxsPSIjZjZjNDMxIi8+PGNpcmNsZSBjeD0iODgiIGN5PSIzMSIgcj0iNCIgZmlsbD0iI2Y2YzQzMSIvPjxjaXJjbGUgY3g9IjEwNiIgY3k9IjMxIiByPSI0IiBmaWxsPSIjZjZjNDMxIi8+PHBhdGggZD0iTTY0IDU4IEw4NCA2OS41IEw4NCA5Mi41IEw2NCAxMDQgTDQ0IDkyLjUgTDQ0IDY5LjUgWiIgZmlsbD0iIzdiNGZkNiIvPjxwYXRoIGQ9Ik02NCA1OCBMODQgNjkuNSBMNjQgODEgTDQ0IDY5LjUgWiIgZmlsbD0iIzliNzJlYSIvPjxwYXRoIGQ9Ik02MCA3MiBMNzQgNjkgTDc0IDg5IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMy40IiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTYwIDcyIEw2MCA5MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjMuNCIvPjxlbGxpcHNlIGN4PSI1NiIgY3k9IjkyIiByeD0iNC42IiByeT0iMy42IiBmaWxsPSIjZmZmIi8+PGVsbGlwc2UgY3g9IjcwIiBjeT0iODkiIHJ4PSI0LjYiIHJ5PSIzLjYiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg=="}) }) : { script: {"name":"First Contact*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Import a release into MusicBrainz from the platform's album page with one click: the release editor opens with the title, tracklist, artists, date, label, barcode and link filled in. Each artist's platform link is handed to Apollo Editor for matching. A second button sends the album to Harmony instead. By default the album page is also saved on the Internet Archive (it sends each imported URL to archive.org; off in the settings), and the edit note links the snapshot. Platforms: Deezer, Bandcamp, Discogs, Apple Music, Tidal, Qobuz, Beatport, Spotify, YouTube Music, Volumo, HDtracks, SoundCloud, Amazon Music, Audiomack, 7digital, Ototoy.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/first_contact/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5GaXJzdCBDb250YWN0PC90aXRsZT4KPGxpbmVhckdyYWRpZW50IGlkPSJmYy1kIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjYmFlNmZkIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMzhiZGY4Ii8+PC9saW5lYXJHcmFkaWVudD48cmFkaWFsR3JhZGllbnQgaWQ9ImZjLWIiIGN4PSIuMzUiIGN5PSIuMyIgcj0iLjgiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iI2ZmZjdjYyIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2Y2YzQzMSIvPjwvcmFkaWFsR3JhZGllbnQ+PHBhdGggZD0iTTUwIDQyIEw3OCA0MiBMMTA0IDExMiBMMjQgMTEyIFoiIGZpbGw9IiNmNmM0MzEiIG9wYWNpdHk9Ii45Ii8+PHBhdGggZD0iTTU2IDQyIEw3MiA0MiBMODggMTEyIEw0MCAxMTIgWiIgZmlsbD0iI2ZkZTY4YSIgb3BhY2l0eT0iLjciLz48ZWxsaXBzZSBjeD0iNjQiIGN5PSIxMTQiIHJ4PSI0MiIgcnk9IjgiIGZpbGw9IiMxZTIzNDYiIHN0cm9rZT0iI2M5Y2RmMiIgc3Ryb2tlLXdpZHRoPSIzIi8+PGVsbGlwc2UgY3g9IjY0IiBjeT0iMTEzIiByeD0iMjYiIHJ5PSI0LjIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2Y2YzQzMSIgc3Ryb2tlLXdpZHRoPSIyLjQiLz48cGF0aCBkPSJNNDIgMjggQTIyIDE4IDAgMCAxIDg2IDI4IFoiIGZpbGw9InVybCgjZmMtZCkiIHN0cm9rZT0iI2M5Y2RmMiIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTUwLjM2IDE5LjkgQTE1LjM5OTk5OTk5OTk5OTk5OSAxMy41IDAgMCAxIDYxLjggMTEuOCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBvcGFjaXR5PSIuOSIvPjxlbGxpcHNlIGN4PSI2NCIgY3k9IjMyIiByeD0iNTYiIHJ5PSIxMiIgZmlsbD0iIzFlMjM0NiIgc3Ryb2tlPSIjYzljZGYyIiBzdHJva2Utd2lkdGg9IjMiLz48ZWxsaXBzZSBjeD0iNjQiIGN5PSI0MCIgcng9IjIyIiByeT0iNiIgZmlsbD0idXJsKCNmYy1iKSIgc3Ryb2tlPSIjYzljZGYyIiBzdHJva2Utd2lkdGg9IjIuNCIvPjxjaXJjbGUgY3g9IjIyIiBjeT0iMzEiIHI9IjQiIGZpbGw9IiNmNmM0MzEiLz48Y2lyY2xlIGN4PSI0MCIgY3k9IjMxIiByPSI0IiBmaWxsPSIjZjZjNDMxIi8+PGNpcmNsZSBjeD0iODgiIGN5PSIzMSIgcj0iNCIgZmlsbD0iI2Y2YzQzMSIvPjxjaXJjbGUgY3g9IjEwNiIgY3k9IjMxIiByPSI0IiBmaWxsPSIjZjZjNDMxIi8+PHBhdGggZD0iTTY0IDU4IEw4NCA2OS41IEw4NCA5Mi41IEw2NCAxMDQgTDQ0IDkyLjUgTDQ0IDY5LjUgWiIgZmlsbD0iIzdiNGZkNiIvPjxwYXRoIGQ9Ik02NCA1OCBMODQgNjkuNSBMNjQgODEgTDQ0IDY5LjUgWiIgZmlsbD0iIzliNzJlYSIvPjxwYXRoIGQ9Ik02MCA3MiBMNzQgNjkgTDc0IDg5IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMy40IiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTYwIDcyIEw2MCA5MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjMuNCIvPjxlbGxpcHNlIGN4PSI1NiIgY3k9IjkyIiByeD0iNC42IiByeT0iMy42IiBmaWxsPSIjZmZmIi8+PGVsbGlwc2UgY3g9IjcwIiBjeT0iODkiIHJ4PSI0LjYiIHJ5PSIzLjYiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg=="} }; (f=>f())(function(){ try { (function(){ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('first_contact', 'First Contact')) return; const VERSION = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '?'; const SCRIPT = 'first_contact'; const NAME = 'First Contact'; const HOMEPAGE = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/first_contact/README.md'; const ON_MB = /(^|\.)musicbrainz\.org$/.test(location.hostname); const ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="18" height="18" aria-hidden="true">' + '<path d="M50 44 L78 44 L104 112 L24 112 Z" fill="#f6c431"/>' + '<ellipse cx="64" cy="114" rx="42" ry="8" fill="#1e2346" stroke="#c9cdf2" stroke-width="3"/>' + '<path d="M40 26 A24 18 0 0 1 88 26 Z" fill="#1e2346" stroke="#c9cdf2" stroke-width="3"/>' + '<ellipse cx="64" cy="32" rx="56" ry="13" fill="#1e2346" stroke="#c9cdf2" stroke-width="3"/>' + '<circle cx="38" cy="32" r="4.4" fill="#fff"/><circle cx="64" cy="34" r="4.4" fill="#fff"/><circle cx="90" cy="32" r="4.4" fill="#fff"/>' + '<path d="M64 58 L84 69.5 L84 92.5 L64 104 L44 92.5 L44 69.5 Z" fill="#7b4fd6"/>' + '<path d="M60 72 L74 69 L74 89 M60 72 L60 92" fill="none" stroke="#fff" stroke-width="3.4"/>' + '<ellipse cx="56" cy="92" rx="4.6" ry="3.6" fill="#fff"/><ellipse cx="70" cy="89" rx="4.6" ry="3.6" fill="#fff"/></svg>'; // MusicBrainz's special-purpose artists that a platform names verbatim. const VARIOUS_ARTISTS_MBID = '89ad4ac3-39f7-470e-963a-56509c546377'; // Handoffs (the platform links of every credited artist) wait in GM storage for the release // editor tab. They are small; a day is plenty for a tab that was opened and left. const HANDOFF_PREFIX = 'fc.handoff.'; // A seed waiting for the editor's tab (sendSeed), pruned with the handoffs const SEED_PREFIX = HANDOFF_PREFIX + 'seed.'; // majkinetor: "handoff probably shouldn't spam the settings?" — every import left its handoff in // the script's storage for a day. Now the release editor takes it out once it has it (keeping a // copy for that tab, so a reload still finds it), and one that was never picked up (the editor // never opened) goes after an hour. const HANDOFF_TTL_MS = 3600 * 1000; const SERVERS = ['musicbrainz.org', 'beta.musicbrainz.org', 'test.musicbrainz.org']; /* ── activity log: the shared window and buffer (mbuLog, in the ST-UI block below) ── */ const Log = mbuLog({ name: NAME, version: VERSION, header: NAME + ' — activity log', key: 'fc.logwin', before: () => injectStyle() }); Log.info(mbuStartupInfo(NAME)); mbuToast.log = (kind, msg) => (kind === 'warn' ? Log.warn(msg) : kind === 'ok' ? Log.ok(msg) : Log.info(msg)); function settings() { const stored = GM_getValue('fc.settings', {}) || {}; // #736: the archive.org keys moved to mbu.archive, shared with Apollo Editor; keys already set there win if ('iaKey' in stored || 'iaSecret' in stored) { const k = mbaKeysStored(); if (!k.iaKey && !k.iaSecret && (stored.iaKey || stored.iaSecret)) mbaSetKeys(stored.iaKey, stored.iaSecret); delete stored.iaKey; delete stored.iaSecret; GM_setValue('fc.settings', stored); } const s = Object.assign({ server: 'musicbrainz.org', iconOnly: false, gearHidden: false, closeAfter: false, harmony: true, consolidate: true, archive: true }, stored); if (!SERVERS.includes(s.server)) s.server = 'musicbrainz.org'; // the option that showed ⚙︎ only on hover became Hide the settings button: who had it on keeps ⚙︎ out of sight if ('gearOnHover' in s) { if (!('gearHidden' in (GM_getValue('fc.settings', {}) || {}))) s.gearHidden = !!s.gearOnHover; delete s.gearOnHover; } return s; } function saveSettings(s) { GM_setValue('fc.settings', s); } /* ── network ─────────────────────────────────────────────────────────────── */ // A GET through the manager (no CORS): the response text, or throws. `headers` add to the request; // an error carries the HTTP status (`e.status`) for a caller that retries on one. // `cookies`: send the browser's cookies for that site (a Cloudflare clearance), which an anonymous request lacks. function gmText(url, headers, cookies) { const t0 = Date.now(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: Object.assign({ Accept: 'application/json' }, headers || {}), timeout: 20000, anonymous: !cookies, onload: r => { Log.debug(`GET ${url} → ${r.status}, ${(r.responseText || '').length} b in ${Date.now() - t0} ms`); if (r.status < 200 || r.status >= 300) return reject(Object.assign(new Error(`HTTP ${r.status} for ${url}`), { status: r.status })); resolve(r.responseText || ''); }, onerror: () => reject(new Error(`network error for ${url}`)), ontimeout: () => reject(new Error(`timeout for ${url}`)), }); }); } async function gmJson(url, headers) { const text = await gmText(url, headers); try { return JSON.parse(text); } catch (e) { throw new Error(`bad JSON from ${url}: ${e.message}`); } } // A form POST through the manager: the parsed JSON answer, or throws. function gmPostJson(url, body, headers) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, data: body, timeout: 20000, anonymous: true, headers: Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, headers || {}), onload: r => { Log.debug(`POST ${url} → ${r.status}`); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { /* not JSON */ } if (r.status < 200 || r.status >= 300) return reject(Object.assign(new Error(`HTTP ${r.status} for ${url}${j && j.error ? ': ' + j.error : ''}`), { status: r.status })); resolve(j); }, onerror: () => reject(new Error(`network error for ${url}`)), ontimeout: () => reject(new Error(`timeout for ${url}`)), }); }); } // Run fn over items with at most `limit` in flight; results keep the input order. async function mapLimit(items, limit, fn) { const out = new Array(items.length); let next = 0; const worker = async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i], i); } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return out; } /* ── shared parsing helpers (platform-neutral) ───────────────────────────── */ // A "(feat. A and B)" / "[ft. A & B]" / "feat. A, B" clause in a title. Returns the title // without it and the featured names, in order. function splitFeat(title) { const s = String(title || ''); const re = /\s*[([]\s*(?:feat\.?|ft\.?|featuring)\s+([^)\]]+)[)\]]|(?:^|\s+)(?:feat\.?|ft\.?|featuring)\s+(.+)$/i; const m = s.match(re); if (!m) return { title: s.trim(), feat: [] }; const names = (m[1] || m[2]).split(/\s*(?:,|&|\band\b)\s*/i).map(n => n.trim()).filter(Boolean); return { title: (s.slice(0, m.index) + s.slice(m.index + m[0].length)).replace(/\s{2,}/g, ' ').trim(), feat: names }; } // #702: a page read headless for Mission Control is a fetched document, not this tab's; it carries // the origin it was fetched from (fcOrigin), which the page readers use in place of location.origin const originOf = doc => (doc && doc.fcOrigin) || location.origin; const normName = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[\s.'’]+/g, ' ').trim(); // Artist list → MB artist credit: mains joined "A, B & C", then " feat. " and the featured // ones the same way. Each entry keeps the platform link for Apollo. function toCredit(mains, feats) { const join = (list, last) => list.map((a, i) => Object.assign({}, a, { join: i === list.length - 1 ? last : i === list.length - 2 ? ' & ' : ', ' })); const m = join(mains, feats.length ? ' feat. ' : ''); const f = join(feats, ''); return m.concat(f); } // Split a platform's flat contributor list into mains and featured by the title's feat. clause. function creditFromTitle(contributors, featNames) { const featSet = new Set(featNames.map(normName)); const mains = [], feats = []; for (const c of contributors) (featSet.has(normName(c.name)) ? feats : mains).push(c); // a featured name the platform didn't list as a contributor still belongs in the credit for (const n of featNames) if (!feats.some(c => normName(c.name) === normName(n))) feats.push({ name: n }); if (!mains.length && feats.length) mains.push(feats.shift()); // never a credit that starts with " feat." return toCredit(mains, feats); } // The primary type a release looks like, after murdos's fnGuessReleaseType (mbimport.js, // github.com/murdos/musicbrainz-userscripts), most confident first: // 1. an "EP" / "E.P." token in the title; // 2. "Single" ending the title ("Song - Single", "Song (Single)") — or anywhere in it, within // 8 tracks and 50 minutes (it's common English, so it needs the guard); // 3. every track the same song once versions are taken off ("Song", "Song (Remix)", // "Song (Instrumental)") → Single; // 4. by size: 7+ tracks or over 30 minutes → Album; up to 7 minutes → Single; else EP from // 2 tracks up. Without every length: 1 track Single, 3–6 EP, 7+ Album, 2 left open. // Returns { type: 'Album' | 'EP' | 'Single' | null, why, explicit } — explicit when the title // says so, which then outranks a platform that calls everything an album. const TYPE_VERSION_MARKER = /\b(?:a ?cap+el+a|acoustic|alt(?:ernate)?|bootleg|clean|club|demo|dirty|dub|edit|explicit|extended|instrumental|karaoke|live|mix|mono|original|radio|remaster(?:ed)?|remix|rework|short|slowed|sped[ -]up|stereo|version|vip|vocal)\b/i; // (the brackets are { / } escapes so the test harness's brace matching reads the function whole) function normTrackTitle(title) { return String(title || '').normalize('NFKC').toLocaleLowerCase() .replace(/\s*[([\x7B]([^\])\x7D]*?)[\])\x7D]/g, (m, inner) => (TYPE_VERSION_MARKER.test(inner) ? '' : m)) .replace(/\s*[-–—:]\s*([^\n]*)$/, (m, tail) => (TYPE_VERSION_MARKER.test(tail) ? '' : m)) .replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); } function guessReleaseType(title, tracks) { const n = tracks.length; if (!n) return { type: null, why: 'no tracks' }; const t = String(title || ''); const lengths = tracks.map(x => x.lengthMs); const ms = lengths.every(l => l > 0) ? lengths.reduce((a, l) => a + l, 0) : NaN; const min = ms / 60000; if (/(?:^|[\s([\-–])E\.?P\b\.?/i.test(t)) return { type: 'EP', why: 'the title says EP', explicit: true }; if (/[\s([\-–]single\s*[)\]]?$/i.test(t)) return { type: 'Single', why: 'the title ends in Single', explicit: true }; if (/\bsingle\b/i.test(t) && n <= 8 && !(min > 50)) return { type: 'Single', why: 'the title says Single', explicit: true }; if (n >= 2) { const norm = tracks.map(x => normTrackTitle(x.title)); if (norm[0] && norm.every(x => x === norm[0])) return { type: 'Single', why: `every track is "${norm[0]}" in another version` }; } if (!Number.isFinite(ms)) { if (n === 1) return { type: 'Single', why: 'one track' }; if (n >= 3 && n <= 6) return { type: 'EP', why: `${n} tracks` }; if (n >= 7) return { type: 'Album', why: `${n} tracks` }; return { type: null, why: 'two tracks without lengths' }; } if (n >= 7) return { type: 'Album', why: `${n} tracks` }; if (min > 30) return { type: 'Album', why: `${Math.round(min)} minutes` }; if (min < 1) return { type: null, why: 'under a minute' }; if (min <= 7) return { type: 'Single', why: `${n} track(s), ${min.toFixed(1)} minutes` }; if (n >= 2) return { type: 'EP', why: `${n} tracks, ${Math.round(min)} minutes` }; return { type: null, why: `one track of ${Math.round(min)} minutes` }; } // Latn when every letter in the titles is Latin; nothing otherwise (left for the editor). // #650 (majkinetor): "fail to add multiple labels 'Crystal Method / Geffen'". A platform that has // one label field writes two labels in it with " / " between them (Apple, Qobuz). Only a spaced // slash splits: "AC/DC Records" is one name. A split label loses the platform's link, which is // the combined name's page, not either label's. function splitLabels(labels) { const out = []; for (const l of labels || []) { const parts = String(l.name || '').split(/\s+\/\s+/).map(x => x.trim()).filter(Boolean); if (parts.length < 2) { out.push(l); continue; } Log.info(`label "${l.name}" is ${parts.length} labels: ${parts.join(' | ')}`); parts.forEach(name => out.push({ name, catno: l.catno || '' })); } return out; } // A label from a copyright line, for a platform that has no label field (Tidal, #650: "This tidal // release didn't add label (Outpost Recordings)"). "℗ 2020 Outpost Recordings" → Outpost Recordings; // "℗ 2013 Daft Life Limited under exclusive license to Columbia Records, a Division of Sony Music // Entertainment" → Columbia Records, the label the release came out on. Anything that doesn't read // as one name (several years and owners, "All rights reserved" alone) gives none. function labelFromCopyright(text) { let t = String(text || '').trim(); if (!t) return null; t = t.replace(/^(?:\(?[℗©]\)?|\([pc]\))\s*/i, '').replace(/^(?:\d{4}\s*(?:[-–,]\s*\d{4}\s*)?)+/, '').trim(); const lic = t.match(/\bunder (?:exclusive )?licen[cs]e to\s+(.+)$/i); if (lic) t = lic[1]; t = t.replace(/,\s*(?:a|an)\s+(?:division|label|imprint|company)\b.*$/i, '').replace(/\.?\s*all rights reserved\.?$/i, '').replace(/[.,;\s]+$/, '').trim(); if (!t || t.length > 60 || /[℗©]|\b\d{4}\b/.test(t) || /^(?:all rights reserved|under licen[cs]e)/i.test(t)) return null; return t; } // #650 (majkinetor): "lets make FC annotation also per platform (enable by default)". Kept as the // platforms it is turned off on ({ platformName: true }), so a new platform starts on. The old // one-for-all setting (off by default) is dropped once: every platform starts on. const ANNOTATION_OFF_KEY = 'fc.annotation.off'; function annotationOn(name) { const s = GM_getValue('fc.settings', {}) || {}; if (Object.prototype.hasOwnProperty.call(s, 'annotation')) { delete s.annotation; GM_setValue('fc.settings', s); Log.info('Annotation from the platform\'s notes is now per platform, on everywhere to start with'); } return !(GM_getValue(ANNOTATION_OFF_KEY, {}) || {})[name]; } function setAnnotationOn(name, on) { const all = Object.assign({}, GM_getValue(ANNOTATION_OFF_KEY, {}) || {}); if (on) delete all[name]; else all[name] = true; GM_setValue(ANNOTATION_OFF_KEY, all); } // #650 (majkinetor): "we should add annotations (should be optional) from all providers (Qobuz // above has it, BC almost always has it, Discogs has notes etc.)". A platform's notes as plain // text: its HTML's breaks and paragraphs become lines, the rest of the markup goes. function notesText(...parts) { const one = x => { let t = String(x || ''); if (/<[a-z][^>]*>|&[a-z#0-9]+;/i.test(t)) { t = t.replace(/<br\s*\/?>/gi, '\n').replace(/<\/(?:p|div|li|h\d)>/gi, '\n\n'); const d = document.createElement('textarea'); d.innerHTML = mbuHtml(t.replace(/<[^>]+>/g, '')); t = d.value; } return t.replace(/\r\n?/g, '\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); }; return parts.map(one).filter(Boolean).join('\n\n') || null; } // A compilation a platform credits to one of its artists (#650: Spotify credits "African Pearls - // Guinée - Cultural Revolution" to Bembeya Jazz National, who plays 2 of its 25 tracks). When the // release's artists are on fewer than half the tracks and the tracks have five or more artists, // it is Various Artists, as MusicBrainz credits a compilation. Returns why, or null. function variousArtistsWhy(rel) { if (!rel.credit.length || rel.credit.some(c => c.mbid === VARIOUS_ARTISTS_MBID)) return null; const tracks = [].concat(...rel.mediums.map(m => m.tracks)).filter(t => !t.placeholder); // an empty track (#684) is no song if (tracks.length < 5) return null; const mains = new Set(rel.credit.map(c => normName(c.artistName || c.name))); const on = tracks.filter(t => (t.credit || []).some(c => mains.has(normName(c.artistName || c.name)))).length; const artists = new Set([].concat(...tracks.map(t => (t.credit || []).slice(0, 1).map(c => normName(c.artistName || c.name))))); if (on * 2 >= tracks.length || artists.size < 5) return null; return `${rel.credit.map(c => c.name).join(', ')} on ${on} of ${tracks.length} tracks, ${artists.size} track artists`; } function guessScript(texts) { const letters = texts.join(' ').replace(/[^\p{L}]/gu, ''); if (!letters) return null; return /^[\p{Script=Latin}]+$/u.test(letters) ? 'Latn' : null; } // The storefronts and domains MusicBrainz may store an Apple Music or Amazon Music artist link // under (the album page doesn't say which); see the providers' urlForms. const APPLE_STOREFRONTS = ['us', 'gb', 'fr', 'de', 'jp', 'ca', 'au', 'nl', 'se', 'it', 'es', 'br', 'mx', 'pl', 'kr', 'be', 'ch', 'at', 'dk', 'no', 'fi', 'nz']; const AMAZON_TLDS = ['com', 'co.uk', 'de', 'fr', 'it', 'es', 'ca', 'co.jp', 'com.au', 'com.br', 'com.mx', 'in']; /* ── providers ─────────────────────────────────────────────────────────────── One object per platform. Kept deliberately thin: recognise the album page, fetch the release and return it in First Contact's model. Everything MusicBrainz-specific (the seed, the handoff) is shared code below; matching is Apollo's. Model: { source, url, title, credit:[Credit], types:[primary, ...secondary], status, packaging, date:{year,month,day}, country, barcode, labels:[{name, catno}], urls:[{url, linkType}], mediums:[{ format, name, tracks:[{ title, lengthMs, isrc, url, credit:[Credit] }] }], missing?:{ of, count, at:['disc.pos'] } } (tracks the album has but the platform didn't give) Credit = { name, url?, mbid?, join } */ const DEEZER = { id: 'deezer', name: 'Deezer', abbr: 'dz', host: /^www\.deezer\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?album\/(\d+)\/?$/i); return m ? m[1] : null; }, albumUrl: id => `https://www.deezer.com/album/${id}`, // #659: the page is built in the browser, so its snapshot is near empty; the API answer FC reads is the evidence archiveApi: id => `https://api.deezer.com/album/${id}`, artistUrl: id => `https://www.deezer.com/artist/${id}`, TYPES: { album: ['Album'], ep: ['EP'], single: ['Single'], compile: ['Album', 'Compilation'] }, VARIOUS: 5080, async fetchRelease(id, progress) { const album = await gmJson(`https://api.deezer.com/album/${id}`); if (album.error) throw new Error(`Deezer: ${album.error.message || album.error.type} (album ${id})`); Log.info(`Deezer album ${id}: "${album.title}" by ${(album.contributors || []).map(c => `${c.name} [${c.role}]`).join(', ') || album.artist?.name} · ${album.nb_tracks} track(s) · ${album.record_type} · ${album.release_date} · UPC ${album.upc || '—'} · label "${album.label || ''}"`); // the album's own tracks list is capped; page through the tracks endpoint const tracks = []; let next = `https://api.deezer.com/album/${id}/tracks?limit=100`; while (next) { const page = await gmJson(next); if (page.error) throw new Error(`Deezer: ${page.error.message || page.error.type} (tracks of ${id})`); tracks.push(...(page.data || [])); next = page.next || null; } Log.info(`Deezer: ${tracks.length} track(s) listed${tracks.length !== album.nb_tracks ? ` (album says ${album.nb_tracks})` : ''}`); // Per-track contributors: the tracks list carries only the main artist. Deezer marks // featured artists "Main" too, so the title's feat. clause decides who is featured. let done = 0; const details = await mapLimit(tracks, 4, async t => { let d = null; for (let attempt = 0; attempt < 3 && !d; attempt++) { try { const r = await gmJson(`https://api.deezer.com/track/${t.id}`); if (r.error && r.error.code === 4) { Log.debug(`Deezer quota hit on track ${t.id}, retrying`); await new Promise(res => setTimeout(res, 1200)); continue; } if (r.error) { Log.warn(`Deezer track ${t.id}: ${r.error.message || r.error.type}`); break; } d = r; } catch (e) { Log.warn(`Deezer track ${t.id}: ${e.message}`); } } progress && progress(++done, tracks.length); return d; }); const artistOf = c => ({ name: c.name, url: this.artistUrl(c.id), platformId: c.id }); const mediums = []; tracks.forEach((t, i) => { const d = details[i] || {}; // title_short has no version; title_version can repeat the feat. clause const short = splitFeat(t.title_short || t.title); const ver = splitFeat(t.title_version || ''); const feat = short.feat.length ? short.feat : ver.feat; let title = short.title; if (ver.title && !/^\(?\s*original mix\s*\)?$/i.test(ver.title)) title += ' ' + ver.title; const contributors = (d.contributors && d.contributors.length ? d.contributors : [t.artist]).filter(Boolean).map(artistOf); const credit = creditFromTitle(contributors, feat); const disc = t.disk_number || d.disk_number || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title, lengthMs: (t.duration || 0) * 1000 || null, isrc: t.isrc || d.isrc || null, url: t.link || null, credit }); Log.debug(`track ${disc}.${mediums[disc - 1].tracks.length}: "${title}" — ${credit.map(c => c.name + c.join).join('')} (${t.duration}s, ${t.isrc || 'no ISRC'})${d.contributors ? '' : ' [no track details: main artist only]'}`); }); const at = splitFeat(album.title); const albumContribs = (album.contributors && album.contributors.length ? album.contributors : [album.artist]).filter(Boolean) .filter(c => c.role !== 'Featured' || at.feat.length).map(artistOf); let credit = creditFromTitle(albumContribs, at.feat); if (album.artist && album.artist.id === this.VARIOUS) credit = [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, url: this.artistUrl(this.VARIOUS), join: '' }]; const [y, m, dd] = String(album.release_date || '').split('-').map(n => parseInt(n, 10)); return { source: this.id, url: this.albumUrl(id), title: at.title, credit, types: this.TYPES[album.record_type] || [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: dd || null }, country: 'XW', barcode: album.upc || null, labels: album.label ? [{ name: album.label, catno: '' }] : [], urls: [{ url: this.albumUrl(id), linkType: 85 }], // 85 = stream for free mediums, }; }, }; // Bandcamp: everything is on the album page itself (the tralbum JSON and the ld+json), so // nothing is fetched. Only the artist's own page has a link: track artists on a label's // compilation are names. const BANDCAMP = { id: 'bandcamp', name: 'Bandcamp', abbr: 'bc', host: /(^|\.)bandcamp\.com$/, albumId(loc) { return /^\/album\/[^/]+\/?$/.test(loc.pathname) ? loc.pathname.replace(/\/$/, '') : null; }, VARIOUS: /^various( artists)?$/i, // a "04 Mar 2011 00:00:00 GMT" date, read in UTC date(s) { const d = s ? new Date(s) : null; return d && !isNaN(d) ? { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() } : { year: null, month: null, day: null }; }, async fetchRelease(path, progress, doc) { doc = doc || document; const tEl = doc.querySelector('script[data-tralbum]'); if (!tEl) throw new Error('Bandcamp: no album data on this page (tralbum)'); const t = JSON.parse(tEl.dataset.tralbum); let ld = null; try { ld = JSON.parse((doc.querySelector('script[type="application/ld+json"]') || {}).textContent || 'null'); } catch (e) { Log.warn(`Bandcamp ld+json: ${e.message}`); } const cur = t.current || {}; const url = (t.url || originOf(doc) + path).replace(/^http:/, 'https:'); const by = ld && ld.byArtist ? { name: ld.byArtist.name, url: ld.byArtist['@id'] || null } : null; const pub = ld && ld.publisher ? { name: ld.publisher.name, url: ld.publisher['@id'] || null } : null; const albumArtist = t.artist || cur.artist || (by && by.name) || ''; Log.info(`Bandcamp album "${cur.title}" by ${albumArtist} · ${(t.trackinfo || []).length} track(s) · released ${t.album_release_date || cur.release_date} · UPC ${cur.upc || '—'} · by ${by ? by.name + ' ' + (by.url || '(no page)') : '—'} · publisher ${pub ? pub.name + ' ' + (pub.url || '') : '—'}${t.album_is_preorder ? ' · PREORDER' : ''}`); let band = null; try { band = JSON.parse((doc.querySelector('script[data-band]') || {}).dataset?.band || 'null'); } catch (e) { Log.debug(`Bandcamp data-band: ${e.message}`); } // the account this page belongs to: an artist's own, or a label's const account = { name: (band && band.name) || (pub && pub.name) || '', url: (pub && pub.url) || originOf(doc) }; Log.debug(`Bandcamp account: ${account.name} ${account.url}`); const isVarious = this.VARIOUS.test(albumArtist); // "Future Funk Squad, Omega Sparx, Stu Brootal, The Crystal Method" is four artists; // the account's own name is never split ("Earth, Wind & Fire" on its own page) const splitNames = s => normName(s) === normName(account.name) ? [s] : String(s).split(/\s*,\s*|\s+&\s+/).map(n => n.trim()).filter(Boolean); // a Bandcamp page for an artist: byArtist's own, or the account's when it is that artist's const linkFor = n => (by && by.url && normName(n) === normName(by.name)) ? by.url : normName(n) === normName(account.name) ? account.url : null; const artistsOf = s => splitNames(s).map(n => ({ name: n, url: linkFor(n) })); const albumArtists = isVarious ? [] : artistsOf(albumArtist); const at = splitFeat(cur.title || ''); const credit = isVarious ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(albumArtists, at.feat); const tracks = (t.trackinfo || []).map((x, i) => { let title = x.title || ''; const trackArtist = x.artist || null; // a compilation's track titles repeat the artist: "Michna - Triple Chrome Dipped" if (trackArtist && title.toLowerCase().startsWith(trackArtist.toLowerCase() + ' - ')) title = title.slice(trackArtist.length + 3); const tf = splitFeat(title); const mains = trackArtist && normName(trackArtist) !== normName(albumArtist) ? artistsOf(trackArtist) : isVarious ? [{ name: trackArtist || 'Various Artists' }] : albumArtists; const tc = creditFromTitle(mains, tf.feat); progress && progress(i + 1, t.trackinfo.length); return { title: tf.title, lengthMs: x.duration ? Math.round(x.duration * 1000) : null, isrc: null, url: x.title_link ? new URL(x.title_link, url).href : null, credit: tc, }; }); // #743: an album not out yet hides the lengths of its unreleased tracks (duration 0) on the page; Bandcamp's app // API (mobile/24/tralbum_details) still has them. They can change before the release: the edit note says so. let hiddenLengths = 0; const hidden = (t.trackinfo || []).map((x, i) => (!x.duration && x.unreleased_track) ? i : -1).filter(i => i >= 0); const bandId = (band && band.id) || cur.band_id; if (hidden.length && bandId && t.id) { try { const app = await gmJson(`https://bandcamp.com/api/mobile/24/tralbum_details?band_id=${bandId}&tralbum_id=${t.id}&tralbum_type=a`); const byNum = new Map((app.tracks || []).map(x => [x.track_num, x])); hidden.forEach(i => { const d = byNum.get(t.trackinfo[i].track_num); if (d && d.duration > 0) { tracks[i].lengthMs = Math.round(d.duration * 1000); hiddenLengths++; } }); Log.warn(`Bandcamp hides ${hidden.length} track length(s) until the album is out (${t.album_release_date || 'no date'}): ${hiddenLengths} taken from Bandcamp's app data, which can change before the release`); } catch (e) { Log.warn(`Bandcamp hides ${hidden.length} track length(s) until the album is out; its app data didn't answer (${e.message}): they stay empty`); } } tracks.forEach((x, i) => Log.debug(`track ${i + 1}: "${x.title}" — ${x.credit.map(c => c.name + c.join).join('')} (${x.lengthMs ? x.lengthMs / 1000 + 's' : 'no length'})`)); // a label account publishing someone else's album: that's the label. An account that // belongs to one of the credited artists is not. const label = pub && pub.name && (!by || !by.url || pub.url !== by.url) && normName(pub.name) !== normName(albumArtist) && !albumArtists.some(a => normName(a.name) === normName(pub.name)) ? pub.name : null; const streamable = t.hasAudio && (t.trackinfo || []).some(x => x.streaming); return { source: this.id, annotation: notesText(cur.about, cur.credits), // the album's about and credits url, title: at.title, credit, types: [], // Bandcamp doesn't say; the title's EP / Single, or one track, decide it (importCurrent) status: 'official', packaging: 'None', date: this.date(t.album_release_date || cur.release_date), country: 'XW', barcode: cur.upc || null, labels: label ? [{ name: label, catno: '' }] : [], // 74 = purchase for download, 85 = stream for free: both, on one URL, as MB allows urls: [{ url, linkType: 74 }].concat(streamable ? [{ url, linkType: 85 }] : []), mediums: [{ format: 'Digital Media', name: '', tracks }], hiddenLengths, }; }, }; // Discogs: the public API (api.discogs.com/releases/<id>), no token. The mappings are those of // murdos's Discogs importer (github.com/murdos/musicbrainz-userscripts): its country and media // tables, LP = 12" vinyl, sides A/B → medium 1, C/D → 2, sub-tracks folded into their index track. const DISCOGS_COUNTRIES = { Worldwide: "XW", Afghanistan: "AF", Albania: "AL", Algeria: "DZ", "American Samoa": "AS", Andorra: "AD", Angola: "AO", Anguilla: "AI", Antarctica: "AQ", "Antigua and Barbuda": "AG", Argentina: "AR", Armenia: "AM", Aruba: "AW", Australia: "AU", Austria: "AT", Azerbaijan: "AZ", Bahamas: "BS", Bahrain: "BH", Bangladesh: "BD", Barbados: "BB", "Barbados, The": "BB", Belarus: "BY", Belgium: "BE", Belize: "BZ", Benin: "BJ", Bermuda: "BM", Bhutan: "BT", Bolivia: "BO", Croatia: "HR", Botswana: "BW", "Bouvet Island": "BV", Brazil: "BR", "British Indian Ocean Territory": "IO", "Brunei Darussalam": "BN", Bulgaria: "BG", "Burkina Faso": "BF", Burundi: "BI", Cambodia: "KH", Cameroon: "CM", Canada: "CA", "Cape Verde": "CV", "Cayman Islands": "KY", "Central African Republic": "CF", Chad: "TD", Chile: "CL", China: "CN", "Christmas Island": "CX", "Cocos (Keeling) Islands": "CC", Colombia: "CO", Comoros: "KM", Congo: "CG", "Cook Islands": "CK", "Costa Rica": "CR", "Virgin Islands, British": "VG", Cuba: "CU", Cyprus: "CY", "Czech Republic": "CZ", Denmark: "DK", Djibouti: "DJ", Dominica: "DM", "Dominican Republic": "DO", Ecuador: "EC", Egypt: "EG", "El Salvador": "SV", "Equatorial Guinea": "GQ", Eritrea: "ER", Estonia: "EE", Ethiopia: "ET", "Falkland Islands (Malvinas)": "FK", "Faroe Islands": "FO", Fiji: "FJ", Finland: "FI", France: "FR", "French Guiana": "GF", "French Polynesia": "PF", "French Southern Territories": "TF", Gabon: "GA", Gambia: "GM", Georgia: "GE", Germany: "DE", Ghana: "GH", Gibraltar: "GI", Greece: "GR", Greenland: "GL", Grenada: "GD", Guadeloupe: "GP", Guam: "GU", Guatemala: "GT", Guinea: "GN", "Guinea-Bissau": "GW", Guyana: "GY", Haiti: "HT", "Virgin Islands, U.S.": "VI", Honduras: "HN", "Hong Kong": "HK", Hungary: "HU", Iceland: "IS", India: "IN", Indonesia: "ID", "Wallis and Futuna": "WF", Iraq: "IQ", Ireland: "IE", Israel: "IL", Italy: "IT", Jamaica: "JM", Japan: "JP", Jordan: "JO", Kazakhstan: "KZ", Kenya: "KE", Kiribati: "KI", Kuwait: "KW", Kyrgyzstan: "KG", "Lao People's Democratic Republic": "LA", Latvia: "LV", Lebanon: "LB", Lesotho: "LS", Liberia: "LR", "Libyan Arab Jamahiriya": "LY", Liechtenstein: "LI", Lithuania: "LT", Luxembourg: "LU", Montserrat: "MS", Macedonia: "MK", Madagascar: "MG", Malawi: "MW", Malaysia: "MY", Maldives: "MV", Mali: "ML", Malta: "MT", "Marshall Islands": "MH", Martinique: "MQ", Mauritania: "MR", Mauritius: "MU", Mayotte: "YT", Mexico: "MX", "Micronesia, Federated States of": "FM", Morocco: "MA", Monaco: "MC", Mongolia: "MN", Mozambique: "MZ", Myanmar: "MM", Namibia: "NA", Nauru: "NR", Nepal: "NP", Netherlands: "NL", "Netherlands Antilles": "AN", "New Caledonia": "NC", "New Zealand": "NZ", Nicaragua: "NI", Niger: "NE", Nigeria: "NG", Niue: "NU", "Norfolk Island": "NF", "Northern Mariana Islands": "MP", Norway: "NO", Oman: "OM", Pakistan: "PK", Palau: "PW", Panama: "PA", "Papua New Guinea": "PG", Paraguay: "PY", Peru: "PE", Philippines: "PH", Pitcairn: "PN", Poland: "PL", Portugal: "PT", "Puerto Rico": "PR", Qatar: "QA", Reunion: "RE", Romania: "RO", "Russian Federation": "RU", Russia: "RU", Rwanda: "RW", "Saint Kitts and Nevis": "KN", "Saint Lucia": "LC", "Saint Vincent and The Grenadines": "VC", Samoa: "WS", "San Marino": "SM", "Sao Tome and Principe": "ST", "Saudi Arabia": "SA", Senegal: "SN", Seychelles: "SC", "Sierra Leone": "SL", Singapore: "SG", Slovenia: "SI", "Solomon Islands": "SB", Somalia: "SO", "South Africa": "ZA", Spain: "ES", "Sri Lanka": "LK", Sudan: "SD", Suriname: "SR", Swaziland: "SZ", Sweden: "SE", Switzerland: "CH", "Syrian Arab Republic": "SY", Tajikistan: "TJ", "Tanzania, United Republic of": "TZ", Thailand: "TH", Togo: "TG", Tokelau: "TK", Tonga: "TO", "Trinidad & Tobago": "TT", Tunisia: "TN", Turkey: "TR", Turkmenistan: "TM", "Turks and Caicos Islands": "TC", Tuvalu: "TV", Uganda: "UG", Ukraine: "UA", "United Arab Emirates": "AE", UK: "GB", US: "US", "United States Minor Outlying Islands": "UM", Uruguay: "UY", Uzbekistan: "UZ", Vanuatu: "VU", "Vatican City State (Holy See)": "VA", Venezuela: "VE", "Viet Nam": "VN", "Western Sahara": "EH", Yemen: "YE", Zambia: "ZM", Zimbabwe: "ZW", Taiwan: "TW", "[Worldwide]": "XW", Europe: "XE", USSR: "SU", "East Germany (historical, 1949-1990)": "XG", Czechoslovakia: "XC", "Congo, Republic of the": "CD", Slovakia: "SK", "Bosnia & Herzegovina": "BA", "Korea (North), Democratic People's Republic of": "KP", "North Korea": "KP", "Korea (South), Republic of": "KR", "South Korea": "KR", Montenegro: "ME", "South Georgia and the South Sandwich Islands": "GS", "Palestinian Territory": "PS", Macao: "MO", "Timor-Leste": "TL", "<85>land Islands": "AX", Guernsey: "GG", "Isle of Man": "IM", Jersey: "JE", Serbia: "RS", "Saint Barthélemy": "BL", "Saint Martin": "MF", Moldova: "MD", Yugoslavia: "YU", "Serbia and Montenegro": "CS", "Côte d'Ivoire": "CI", "Heard Island and McDonald Islands": "HM", "Iran, Islamic Republic of": "IR", "Saint Pierre and Miquelon": "PM", "Saint Helena": "SH", "Svalbard and Jan Mayen": "SJ" }; const DISCOGS_MEDIA = { "8-Track Cartridge": "Cartridge", Acetate: "Acetate", "Acetate7\"": "7\" Acetate", "Acetate10\"": "10\" Acetate", "Acetate12\"": "12\" Acetate", Betamax: "Betamax", "Blu-ray": "Blu-ray", "Blu-ray-R": "Blu-ray", Cassette: "Cassette", CD: "CD", CDr: "CD-R", CDV: "CDV", "CD+G": "CD+G", Cylinder: "Wax Cylinder", DAT: "DAT", Datassette: "Other", DCC: "DCC", DVD: "DVD", DVDr: "DVD", "DVD-Audio": "DVD-Audio", "DVD-Video": "DVD-Video", "Edison Disc": "Vinyl", File: "Digital Media", "Flexi-disc": "Vinyl", "Floppy Disk": "Other", HDCD: "HDCD", "HD DVD": "HD-DVD", "HD DVD-R": "HD-DVD", Hybrid: "Other", Laserdisc: "LaserDisc", "Memory Stick": "USB Flash Drive", Microcassette: "Other", Minidisc: "MiniDisc", MVD: "Other", "Reel-To-Reel": "Reel-to-reel", SACD: "SACD", SelectaVision: "Other", Shellac: "Shellac", "Shellac7\"": "7\" Shellac", "Shellac10\"": "10\" Shellac", "Shellac12\"": "12\" Shellac", SVCD: "SVCD", UMD: "UMD", VCD: "VCD", VHS: "VHS", "Video 2000": "Other", Vinyl: "Vinyl", "Vinyl7\"": "7\" Vinyl", "Vinyl10\"": "10\" Vinyl", "Vinyl12\"": "12\" Vinyl", "Lathe Cut": "Phonograph record" }; const DISCOGS_PACKAGING = [[/cardboard|paper/, 'Cardboard/Paper Sleeve'], [/digi[\s\-‐]?pac?k/, 'Digipak'], [/keepcase/, 'Keep Case'], [/slimjewel/, 'Slim Jewel Case'], [/jewel/, 'Jewel Case'], [/gatefold|digisleeve/, 'Gatefold Cover']]; const DISCOGS = { id: 'discogs', name: 'Discogs', abbr: 'disc', artistLinkType: 180, // MB's Discogs artist link host: /^(www\.)?discogs\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/(?:[a-z]{2}\/)?release\/(\d+)(?:-[^/]*)?\/?$/i); return m ? m[1] : null; }, // #736: discogs.com refuses archive.org (HTTP 403 to every save); its API doesn't archivePage: false, archiveApi: id => mbaSavable(`https://www.discogs.com/release/${id}`), VARIOUS: 194, NO_LABEL: 750, // "Not On Label" noNum: n => String(n || '').replace(/ \(\d+\)$/, ''), // Discogs joins: "&", ",", "Feat.", "Vs", "And", "With", "x" → MB style join(j) { const t = String(j || '').trim(); if (!t) return ''; if (t === ',') return ', '; const k = t.toLowerCase().replace(/\.$/, ''); const map = { feat: ' feat. ', featuring: ' feat. ', ft: ' feat. ', vs: ' vs. ', and: ' and ', with: ' with ', x: ' x ', '&': ' & ', '+': ' + ', '/': ' / ' }; return map[k] || ` ${t} `; }, artist(a) { if (a.id === this.VARIOUS) return { name: 'Various Artists', artistName: 'Various Artists', url: null, mbid: VARIOUS_ARTISTS_MBID }; return { name: a.anv || this.noNum(a.name), artistName: this.noNum(a.name), url: a.id ? `https://www.discogs.com/artist/${a.id}` : null, mbid: null, }; }, credit(artists, featuring) { const out = (artists || []).map(a => Object.assign(this.artist(a), { join: this.join(a.join) })); if (out.length) out[out.length - 1].join = ''; const feats = (featuring || []).filter(f => !out.some(o => o.url && o.url === this.artist(f).url)); if (feats.length) { if (out.length) out[out.length - 1].join = ' feat. '; feats.forEach((a, i) => out.push(Object.assign(this.artist(a), { join: i === feats.length - 1 ? '' : i === feats.length - 2 ? ' & ' : ', ' }))); } return out; }, ms(d) { const p = String(d || '').split(':').map(Number); return p.length > 1 && p.every(n => Number.isFinite(n)) ? p.reduce((a, n) => a * 60 + n, 0) * 1000 : null; }, async fetchRelease(id) { const r = await gmJson(`https://api.discogs.com/releases/${id}`); if (!r || !r.title) throw new Error(`Discogs: no release ${id}${r && r.message ? ` (${r.message})` : ''}`); Log.info(`Discogs release ${id}: "${r.title}" by ${(r.artists || []).map(a => a.name + (a.join ? ' ' + a.join : '')).join(' ')} · ${r.country || '—'} · ${r.released || '—'} · formats ${(r.formats || []).map(f => `${f.qty}×${f.name} [${(f.descriptions || []).join(', ')}]${f.text ? ' "' + f.text + '"' : ''}`).join(' + ')} · ${(r.tracklist || []).length} tracklist row(s)`); // formats → one MB format per medium, plus type, status, packaging const formats = []; let primary = null, status = 'official', packaging = null; const secondary = []; for (const f of r.formats || []) { let fmt = DISCOGS_MEDIA[f.name]; for (const d of f.descriptions || []) { if (/7"|10"|12"/.test(d) && DISCOGS_MEDIA[f.name + d]) fmt = DISCOGS_MEDIA[f.name + d]; if (/^(VCD|SVCD|CD\+G|HDCD|DVD-Audio|DVD-Video)$/.test(d) && DISCOGS_MEDIA[d]) fmt = DISCOGS_MEDIA[d]; if (f.name === 'Vinyl' && d === 'LP') fmt = '12" Vinyl'; if (f.name === 'CD' && d === 'Mini') fmt = '8cm CD'; if (/Promo|Smplr/.test(d)) status = 'promotion'; if (/Unofficial Release/.test(d)) status = 'bootleg'; if (/Compilation/.test(d) && !secondary.includes('Compilation')) secondary.push('Compilation'); if (/^Album/.test(d)) primary = primary || 'Album'; if (/Single(?! Sided)/.test(d)) primary = 'Single'; if (/^(EP|Mini-Album)$/.test(d)) primary = 'EP'; } const text = String(f.text || '').toLowerCase().replace(/[\s-]/g, ''); for (const [re, p] of DISCOGS_PACKAGING) if (!packaging && re.test(text)) packaging = p; if (fmt) for (let q = 0; q < (parseInt(f.qty, 10) || 1); q++) formats.push(fmt); else if (f.name !== 'Box Set' && f.name !== 'All Media') Log.warn(`Discogs format "${f.name}" has no MusicBrainz format`); } Log.debug(`formats → ${formats.join(', ') || 'none'} · type ${primary || '—'}${secondary.length ? ' + ' + secondary.join(' + ') : ''} · status ${status} · packaging ${packaging || '—'}`); // tracklist → mediums by position: "1-3" / "CD2-4" / "2.4" (medium-track), "A1" (sides, // two per medium), "1" (a number that starts over begins the next medium) const mediums = []; let heading = '', med = 1, last = 0, odd = false; const releaseCredit = this.credit(r.artists); for (const t of r.tracklist || []) { // a heading names the medium that follows: "CD 1 Routine" → "Routine" if (t.type_ === 'heading') { heading = String(t.title || '').replace(/^(?:CD|Disc|Disk|DVD|LP|Vinyl|Side)\s*\d+\s*[-:–.]?\s*/i, ''); continue; } if (t.type_ !== 'track' && t.type_ !== 'index') continue; let title = String(t.title || '').replace(/´/g, '’'); let pos = t.position || ''; let len = this.ms(t.duration); if (t.type_ === 'index' && t.sub_tracks) { const subs = t.sub_tracks.filter(x => x.type_ === 'track'); if (!pos && subs[0]) pos = subs[0].position || ''; if (subs.length) title += (title ? ': ' : '') + subs.map(x => x.title || '[unknown]').join(' / '); if (!len) { const sum = subs.reduce((a, x) => a + (this.ms(x.duration) || 0), 0); len = sum || null; } } if (!pos || /^(video|mp3)/i.test(pos)) { Log.debug(`skipped tracklist row "${title}" (position "${t.position}")`); continue; } let m; if ((m = pos.match(/^(?:[a-z]+)?(\d+)[.-](\d+)/i))) { med = +m[1]; last = +m[2]; } else if (/^[A-Z]\d*$/i.test(pos)) { med = (((32 | pos.charCodeAt(0)) - 97) >> 1) + 1; last++; } else if ((m = pos.match(/^(\d+)/))) { if (+m[1] <= last) med++; last = +m[1]; } else { odd = true; last++; } while (mediums.length < med) { mediums.push({ format: formats[mediums.length] || formats[formats.length - 1] || null, name: heading, tracks: [] }); heading = ''; } const feat = (t.extraartists || []).filter(e => /^Featuring\b/.test(e.role || '')); const credit = t.artists && t.artists.length ? this.credit(t.artists, feat) : feat.length ? this.credit(r.artists, feat) : releaseCredit; const medium = mediums[med - 1]; const sided = /Vinyl|Cassette|Shellac|Acetate/.test(medium.format || ''); medium.tracks.push({ title, lengthMs: len, isrc: null, url: null, credit, number: sided && /^[A-Z]+[.-]?\d*$/i.test(pos) ? pos : null }); Log.debug(`track ${pos} → medium ${med}: "${title}" — ${credit.map(c => c.name + c.join).join('')}${len ? '' : ' (no length)'}`); } if (odd) Log.warn('Discogs: some track positions could not be read; check the medium split'); const empty = mediums.filter(x => !x.tracks.length).length; if (empty) Log.warn(`Discogs: ${empty} medium(s) without tracks`); if (mediums.length === 1) mediums[0].name = ''; // labels; the same label listed twice with its catalog number written two ways is kept once const seen = new Set(), labels = []; for (const l of r.labels || []) { const none = l.id === this.NO_LABEL; const name = none ? '[no label]' : this.noNum(l.name); const catno = /^none$/i.test(l.catno || '') ? '[none]' : (l.catno || ''); const key = normName(name) + '|' + catno.replace(/[\s.-]/g, '').toLowerCase(); if (seen.has(key)) continue; seen.add(key); labels.push({ name, catno, url: none ? null : `https://www.discogs.com/label/${l.id}`, mbid: none ? '157afde4-4bf5-4039-8ad2-5a15acc85176' : null }); } const barcode = ((r.identifiers || []).find(i => i.type === 'Barcode' && i.value) || {}).value; const [y, mo, d] = String(r.released || '').split(/\D+/).map(n => parseInt(n, 10)); const country = DISCOGS_COUNTRIES[r.country] || null; if (r.country && !country) Log.info(`Discogs country "${r.country}" is no single MusicBrainz country; left for you`); const url = `https://www.discogs.com/release/${id}`; return { source: this.id, annotation: notesText(r.notes), url, title: r.title, credit: releaseCredit, types: primary ? [primary].concat(secondary) : secondary.length ? ['Album'].concat(secondary) : [], status, packaging, date: { year: y || null, month: mo || null, day: d || null }, country, barcode: barcode ? barcode.replace(/[^\dX]/gi, '') : null, labels, urls: [{ url, linkType: 76 }], // 76 = discogs mediums, }; }, }; // Apple Music: its own catalogue API (amp-api.music.apple.com), read with the bearer token the // web player's public JS carries — the way Platform Check, ISRC Scout and Credit Hoarder read // it (#627). Unlike the iTunes Search API it has the UPC, each track's ISRC, the label and every // track artist's Apple id. The storefront is the page's (music.apple.com/<sf>/album/…). const APPLE = { id: 'apple', name: 'Apple Music', abbr: 'am', artistLinkType: 978, // streaming page: MB offers several for Apple Music // MB stores an Apple Music artist under any storefront, without the slug urlForms(url) { const m = url.match(/^https?:\/\/(?:music|itunes)\.apple\.com\/([a-z]{2})\/artist\/(?:[^/?#]+\/)?(?:id)?(\d+)/i); if (!m) return []; const cc = m[1].toLowerCase(); return [cc, ...APPLE_STOREFRONTS.filter(c => c !== cc)].map(c => `https://music.apple.com/${c}/artist/${m[2]}`); }, host: /^music\.apple\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)\/?$/i); return m ? `${m[1].toLowerCase()}/${m[2]}` : null; }, // #659: amp-api needs a token Wayback can't send; the public iTunes lookup has the same album and tracks archiveApi(id) { const [cc, n] = id.split('/'); return `https://itunes.apple.com/lookup?id=${n}&entity=song&country=${cc}`; }, TOKEN_KEY: 'fc.apple-token', _tok: null, async token(fresh) { if (this._tok && !fresh) return this._tok; if (!fresh) { const c = GM_getValue(this.TOKEN_KEY, null); if (c && c.t && Date.now() - c.at < 12 * 3600e3) return (this._tok = c.t); } Log.info('Apple Music: fetching the web player\'s token'); const home = await gmText('https://music.apple.com/us/browse', { Accept: 'text/html' }); const asset = (home.match(/\/assets\/index-legacy~[a-z0-9]+\.js/i) || home.match(/\/assets\/index~[a-z0-9]+\.js/i) || [])[0]; if (!asset) throw new Error('Apple Music: the web player\'s JS was not found'); const js = await gmText('https://music.apple.com' + asset, { Accept: '*/*' }); const tok = (js.match(/eyJ[A-Za-z0-9._-]{80,}/) || [])[0]; if (!tok) throw new Error('Apple Music: no token in the web player\'s JS'); GM_setValue(this.TOKEN_KEY, { t: tok, at: Date.now() }); return (this._tok = tok); }, // one amp-api read; a 401 is a rotated token: fetch a new one, once async amp(path) { for (let attempt = 0; ; attempt++) { const tok = await this.token(attempt > 0); try { return await gmJson(`https://amp-api.music.apple.com${path}${path.includes('?') ? '&' : '?'}l=en-US`, { Authorization: 'Bearer ' + tok, Origin: 'https://music.apple.com' }); } catch (e) { if (e.status === 401 && attempt === 0) { Log.warn('Apple Music: the token was refused (401), fetching a new one'); continue; } throw e; } } }, // "Daft Punk, Pharrell Williams & Nile Rodgers" → its names, each with the Apple artist of that // name when the song lists one artists(text, rel, sf) { const byName = new Map((rel || []).map(a => [normName(a.attributes && a.attributes.name), a])); const url = a => (a.attributes && a.attributes.url) || `https://music.apple.com/${sf}/artist/${a.id}`; const names = String(text || '').split(/\s*,\s*|\s+&\s+/).map(n => n.trim()).filter(Boolean); const out = names.map(n => { const a = byName.get(normName(n)); return { name: n, url: a ? url(a) : null }; }); // a listed artist the text leaves out (rare) still belongs in the credit for (const a of rel || []) if (!out.some(o => normName(o.name) === normName(a.attributes && a.attributes.name))) out.push({ name: a.attributes ? a.attributes.name : String(a.id), url: url(a) }); return out; }, // the iTunes Store sells the album when its lookup gives a price (a streaming-only one has none): // then its link is also 'purchase for download'. Unknown (the lookup failed) leaves it out. async forSale(sf, id) { try { const r = await gmJson(`https://itunes.apple.com/lookup?id=${id}&country=${sf}`); const c = ((r && r.results) || []).find(x => x.wrapperType === 'collection'); Log.debug(`Apple Music: iTunes Store price ${c ? (c.collectionPrice ?? 'none') + ' ' + (c.currency || '') : 'not listed'} for ${sf}/${id}`); return !!(c && c.collectionPrice > 0); } catch (e) { Log.warn(`Apple Music: the iTunes Store lookup failed (${e.message}), so its link is a streaming page only`); return false; } }, async fetchRelease(key, progress) { const [sf, id] = key.split('/'); const forSale = this.forSale(sf, id); const j = await this.amp(`/v1/catalog/${sf}/albums/${id}?include=tracks,artists&include[songs]=artists`); const a = j && j.data && j.data[0]; if (!a) throw new Error(`Apple Music: no album ${id} in the "${sf}" storefront`); const at = a.attributes || {}; Log.info(`Apple Music album ${sf}/${id}: "${at.name}" by ${at.artistName} · ${at.trackCount} track(s) · ${at.releaseDate} · UPC ${at.upc || '—'} · label "${at.recordLabel || ''}"${at.isSingle ? ' · single' : ''}${at.isCompilation ? ' · compilation' : ''}`); const items = (a.relationships.tracks.data || []).slice(); for (let next = a.relationships.tracks.next; next;) { const page = await this.amp(next + (next.includes('include') ? '' : '&include[songs]=artists')); items.push(...(page.data || [])); next = page.next || null; } const videos = items.filter(t => t.type !== 'songs'); if (videos.length) Log.info(`Apple Music: ${videos.length} music video(s) left out`); const songs = items.filter(t => t.type === 'songs'); const mediums = []; songs.forEach((t, i) => { const x = t.attributes || {}; const tf = splitFeat(x.name || ''); const listed = this.artists(x.artistName, t.relationships && t.relationships.artists && t.relationships.artists.data, sf); const credit = creditFromTitle(listed, tf.feat); const disc = x.discNumber || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title: tf.title, lengthMs: x.durationInMillis || null, isrc: x.isrc || null, url: x.url || null, credit, pos: +x.trackNumber || null }); progress && progress(i + 1, songs.length); Log.debug(`track ${disc}.${x.trackNumber}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${x.isrc || 'no ISRC'})`); }); // #684: an album can have tracks the storefront doesn't offer (Eddie Harris, "Artist's Choice": // 24 tracks, 1.9, 2.1, 2.4 and 2.6 not offered). The API leaves them out and numbers around // them. An empty track holds each missing position, so every song keeps its number (1.10 stays // 1.10) and the missing ones are filled in where they belong; the import asks first (askMissing). const at0 = new Map(); items.forEach(t => { const x = t.attributes || {}, d = x.discNumber || 1; (at0.get(d) || at0.set(d, new Set()).get(d)).add(+x.trackNumber); }); const gaps = []; [...at0.keys()].sort((a, b) => a - b).forEach(d => { const s = at0.get(d); for (let n = 1; n < Math.max(...s); n++) if (!s.has(n)) gaps.push(d + '.' + n); }); const lack = Math.max((at.trackCount || 0) - items.length, gaps.length); const missing = lack ? { of: items.length + lack, count: lack, at: gaps } : null; if (missing) Log.warn(`Apple Music: the album has ${missing.of} tracks, ${lack} not offered in the "${sf}" storefront${gaps.length ? ` (${gaps.join(', ')}): an empty track at each` : ''}`); for (const g of gaps) { const [d, n] = g.split('.').map(Number); while (mediums.length < d) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[d - 1].tracks.push({ title: '', lengthMs: null, isrc: null, url: null, credit: [], pos: n, placeholder: true }); } if (gaps.length) mediums.forEach(m => m.tracks.sort((p, q) => (p.pos || 0) - (q.pos || 0))); // " - Single" / " - EP" is Apple's label for the release, not part of its title const suffix = (String(at.name || '').match(/\s+-\s+(Single|EP)$/) || [])[1]; const af = splitFeat(String(at.name || '').replace(/\s+-\s+(Single|EP)$/, '')); const relArtists = a.relationships.artists && a.relationships.artists.data; const credit = /^various artists$/i.test(at.artistName || '') ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(this.artists(at.artistName, relArtists, sf), af.feat); const [y, m, d] = String(at.releaseDate || '').split('-').map(n => parseInt(n, 10)); const url = (at.url || `https://music.apple.com/${sf}/album/${id}`).replace(/\?.*$/, ''); return { source: this.id, annotation: notesText(at.editorialNotes && (at.editorialNotes.standard || at.editorialNotes.short)), url, title: af.title, credit, types: (suffix ? [suffix] : at.isSingle ? ['Single'] : []).concat(at.isCompilation ? ['Compilation'] : []), status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: at.upc || null, labels: at.recordLabel ? [{ name: at.recordLabel, catno: '' }] : [], // 980 = streaming page (paid), 74 = purchase for download when the iTunes Store sells it urls: [{ url, linkType: 980 }].concat(await forSale ? [{ url, linkType: 74 }] : []), mediums, missing, }; }, }; // Tidal: the official catalogue API (openapi.tidal.com/v2) with an app token from the // client-credentials grant — no login — as ISRC Scout reads it. One request has the album, its // tracks and every artist (include=artists,items,items.artists); longer albums page on. Tidal // has no label field, only a copyright line, which names the label most of the time: the label is // read from it (labelFromCopyright), and left for you when it doesn't read as one name. const TIDAL = { id: 'tidal', name: 'Tidal', abbr: 'td', host: /^(listen\.)?tidal\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/(?:browse\/)?album\/(\d+)\/?$/); return m ? m[1] : null; }, CLIENT: 'cRhhDJDpYXXBn82U:K7UX40jDOZ5p4y4JMYZgoiwKi7jymTHWcLMb4gkewKs=', COUNTRIES: ['US', 'GB', 'DE'], TOKEN_KEY: 'fc.tidal-token', TYPES: { ALBUM: ['Album'], EP: ['EP'], SINGLE: ['Single'] }, artistUrl: id => `https://tidal.com/artist/${id}`, async token() { const c = GM_getValue(this.TOKEN_KEY, null); if (c && c.t && Date.now() < c.exp - 60000) return c.t; Log.info('Tidal: fetching an app token'); const j = await gmPostJson('https://auth.tidal.com/v1/oauth2/token', 'grant_type=client_credentials', { Authorization: 'Basic ' + btoa(this.CLIENT) }); if (!j || !j.access_token) throw new Error('Tidal: no app token'); GM_setValue(this.TOKEN_KEY, { t: j.access_token, exp: Date.now() + (j.expires_in || 14400) * 1000 }); return j.access_token; }, // one read; a 429 (Tidal throttles hard) waits as long as it says, three times at most async get(path) { for (let attempt = 0; ; attempt++) { try { return await gmJson('https://openapi.tidal.com/v2' + path, { Authorization: 'Bearer ' + await this.token(), Accept: 'application/vnd.api+json' }); } catch (e) { if (e.status === 401 && attempt === 0) { GM_deleteValue(this.TOKEN_KEY); continue; } if (e.status !== 429 || attempt >= 3) throw e; const wait = 1000 * 2 ** attempt; Log.warn(`Tidal: throttled (429), retrying in ${wait / 1000} s`); await new Promise(r => setTimeout(r, wait)); } } }, secs(iso) { const m = String(iso || '').match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/); return m ? ((+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0)) : 0; }, async fetchRelease(id, progress) { let j = null, cc = null; for (const c of this.COUNTRIES) { try { j = await this.get(`/albums/${id}?countryCode=${c}&include=artists,items,items.artists`); cc = c; break; } catch (e) { if (e.status !== 404) throw e; Log.info(`Tidal: album ${id} is not in the ${c} catalogue`); } } if (!j || !j.data) throw new Error(`Tidal: album ${id} is in none of the ${this.COUNTRIES.join(', ')} catalogues`); const a = j.data, at = a.attributes || {}; const included = new Map(); const keep = list => (list || []).forEach(x => included.set(x.type + ':' + x.id, x)); keep(j.included); const refs = ((a.relationships.items || {}).data || []).slice(); for (let next = ((a.relationships.items || {}).links || {}).next; next;) { const page = await this.get(next.replace(/^.*\/v2/, '') + (/[?&]include=/.test(next) ? '' : '&include=items,items.artists')); keep(page.included); refs.push(...(page.data || [])); next = (page.links || {}).next || null; } Log.info(`Tidal album ${id} (${cc}): "${at.title}" · ${at.albumType} · ${refs.length} item(s) · ${at.releaseDate} · UPC ${at.barcodeId || '—'} · ${(at.copyright || {}).text || 'no copyright line'}`); const artist = r => { const x = included.get('artists:' + r.id); return { name: x && x.attributes ? x.attributes.name : String(r.id), url: this.artistUrl(r.id) }; }; const mediums = []; let videos = 0; refs.forEach((r, i) => { if (r.type !== 'tracks') { videos++; return; } const t = included.get('tracks:' + r.id) || {}; const x = t.attributes || {}; const tf = splitFeat(x.title || ''); let title = tf.title; const vf = splitFeat(x.version || ''); if (vf.title) title += ` (${vf.title.replace(/^\((.*)\)$/, '$1')})`; const listed = ((t.relationships && t.relationships.artists && t.relationships.artists.data) || []).map(artist); const credit = creditFromTitle(listed, tf.feat.length ? tf.feat : vf.feat); const disc = (r.meta && r.meta.volumeNumber) || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title, lengthMs: this.secs(x.duration) * 1000 || null, isrc: x.isrc || null, url: `https://tidal.com/track/${r.id}`, credit }); progress && progress(i + 1, refs.length); Log.debug(`track ${disc}.${r.meta && r.meta.trackNumber}: "${title}" — ${credit.map(c => c.name + c.join).join('')} (${x.isrc || 'no ISRC'})`); }); if (videos) Log.info(`Tidal: ${videos} video(s) left out`); const af = splitFeat(at.title || ''); const listed = ((a.relationships.artists || {}).data || []).map(artist); const credit = listed.length === 1 && /^various artists$/i.test(listed[0].name) ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(listed, af.feat); const [y, m, d] = String(at.releaseDate || '').split('-').map(n => parseInt(n, 10)); const url = `https://tidal.com/album/${id}`; return { source: this.id, url, title: af.title, credit, types: this.TYPES[at.albumType] || [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: at.barcodeId || null, labels: (lbl => (lbl ? [{ name: lbl, catno: '' }] : []))(labelFromCopyright((at.copyright || {}).text)), urls: [{ url, linkType: 980 }], // 980 = streaming page (paid) mediums, }; }, }; // Qobuz: the store page itself (www.qobuz.com/<cc-ll>/album/<slug>/<id>). Qobuz's API answers // only from the countries it serves, but the store page is rendered in full everywhere, so // nothing is fetched. The page's per-track artist is unreliable (Qobuz shows a member, like // Thomas Bangalter on a Daft Punk track), so an album by one artist credits its main artists on // every track, with the title's feat.; only a Various Artists album takes the track's artist. const QOBUZ = { id: 'qobuz', name: 'Qobuz', abbr: 'qz', artistLinkType: 176, // purchase music for download: MB offers several for Qobuz // MB stores a Qobuz artist also as open.qobuz.com urlForms(url) { const m = url.match(/^https?:\/\/(?:www\.)?qobuz\.com\/[a-z]{2}-[a-z]{2}\/interpreter\/[^/]+\/(\d+)/i); return m ? [`https://open.qobuz.com/artist/${m[1]}`] : []; }, host: /^www\.qobuz\.com$/, albumId(loc) { return /^\/[a-z]{2}-[a-z]{2}\/album\/[^/]+\/[A-Za-z0-9]+\/?$/.test(loc.pathname) ? loc.pathname.replace(/\/$/, '') : null; }, hms(t) { const p = String(t || '').trim().split(':').map(Number); return p.length > 1 && p.every(Number.isFinite) ? p.reduce((a, n) => a * 60 + n, 0) * 1000 : null; }, text: el => (el ? el.textContent.replace(/\s+/g, ' ').trim() : ''), async fetchRelease(path, progress, doc) { doc = doc || document; let album = null, product = null; for (const sc of doc.querySelectorAll('script[type="application/ld+json"]')) { try { const j = JSON.parse(sc.textContent); if (j['@type'] === 'MusicAlbum') album = j; if (j['@type'] === 'Product') product = j; } catch (e) { Log.debug(`Qobuz ld+json: ${e.message}`); } } const title = this.text(doc.querySelector('.album-meta__title .album-title')) || (album && album.name) || ''; if (!title) throw new Error('Qobuz: no album on this page'); const root = new URL(path, originOf(doc)); const mains = [...doc.querySelectorAll('.album-meta__item')].filter(li => /^\s*Main artists?\s*:/i.test(li.textContent)) .flatMap(li => [...li.querySelectorAll('a[href*="/interpreter/"]')]) .map(a => ({ name: (a.getAttribute('title') || this.text(a)).trim(), url: new URL(a.getAttribute('href'), root).href })); const shown = this.text(doc.querySelector('.album-meta__title .artist-name')); const labelA = [...doc.querySelectorAll('.album-meta__item a[href*="/label/"]')][0]; const label = this.text(labelA); const upc = String((product && product.sku) || '').replace(/\D/g, ''); const box = doc.querySelector('#playerTracks'); const nb = box ? parseInt(box.getAttribute('data-nbTracks'), 10) : NaN, shownN = box ? parseInt(box.getAttribute('data-nbTracksDisplayed'), 10) : NaN; Log.info(`Qobuz album ${path}: "${title}" by ${shown} · main artists ${mains.map(m => m.name).join(', ') || '—'} · label "${label}" · ${album && album.datePublished} · UPC ${upc || '—'} · ${nb} track(s)${shownN < nb ? `, ${shownN} on the page` : ''}`); if (shownN < nb) Log.warn(`Qobuz: the page lists only ${shownN} of ${nb} tracks; the rest are left out`); const various = /^various artists$/i.test(shown) || (mains.length === 1 && /^various artists$/i.test(mains[0].name)); const albumArtists = mains.length ? mains : (shown ? [{ name: shown }] : []); // one row per real track: the page also renders empty copies of each row for its layout const rows = [...doc.querySelectorAll('#playerTracks div.track[data-track]')]; const mediums = []; let disc = 1, last = 0; rows.forEach((row, i) => { const name = this.text(row.querySelector('.track__item--name')); const n = parseInt(this.text(row.querySelector('.track__item--number')), 10) || (last + 1); if (n <= last) disc++; // numbering starts over: the next disc last = n; const tf = splitFeat(name); let credit; if (various) { const raw = this.text(row.querySelector('.track__item--artist')); const af = splitFeat(raw); credit = creditFromTitle(String(af.title).split(/\s*,\s*|\s+&\s+/).filter(Boolean).map(x => ({ name: x })), tf.feat.length ? tf.feat : af.feat); } else credit = creditFromTitle(albumArtists, tf.feat); while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title: tf.title, lengthMs: this.hms(this.text(row.querySelector('.track__item--duration'))), isrc: null, url: null, credit }); progress && progress(i + 1, rows.length); Log.debug(`track ${disc}.${n}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')}`); }); const af = splitFeat(title); const credit = various ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(albumArtists, af.feat); const [y, m, d] = String((album && album.datePublished) || '').split('-').map(x => parseInt(x, 10)); const url = originOf(doc) + path; return { source: this.id, annotation: notesText((doc.querySelector('#description .album-block__text') || {}).innerHTML), // the album review url, title: af.title, credit, types: [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: /^\d{12,14}$/.test(upc) ? upc : null, labels: label ? [{ name: label, catno: '', url: labelA ? new URL(labelA.getAttribute('href'), root).href : null }] : [], urls: [{ url, linkType: 74 }], // 74 = purchase for download mediums, }; }, }; // Beatport: the release page's Next.js data (__NEXT_DATA__), the way Harmony reads it: the // release, and its tracks in a second query. The site is a single-page app, so after an in-app // navigation the page's data is stale and the release page is fetched again (same origin, so it // passes the site's bot check). More than one page of tracks is read from Beatport's API with // the anonymous token the page carries. const BEATPORT = { id: 'beatport', name: 'Beatport', abbr: 'bp', host: /^www\.beatport\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/(?:[a-z]{2}\/)?release\/[^/]+\/(\d+)\/?$/i); return m ? m[1] : null; }, TYPES: { Album: ['Album'], EP: ['EP'], Single: ['Single'], Compilation: ['Album', 'Compilation'] }, url: (kind, x) => `https://www.beatport.com/${kind}/${x.slug || '-'}/${x.id}`, withoutPage: true, // #745: a headless read whose page is refused goes through Platform Check (nextData) // the page's __NEXT_DATA__ when it is this release's, else the release page fetched anew async nextData(id, doc) { const read = d => { const el = d.querySelector('script#__NEXT_DATA__'); return el ? JSON.parse(el.textContent) : null; }; const here = read(doc || document); const rel = here && here.props && here.props.pageProps && here.props.pageProps.release; if (rel && String(rel.id) === String(id)) { Log.debug('Beatport: the page\'s own data is this release\'s'); return here; } Log.info(`Beatport: the page's data is ${rel ? 'release ' + rel.id : 'not a release'}; fetching release ${id}`); let html; if (doc && doc.fcRefused) { // #745: the album page was refused already (mcRead): Platform Check, without asking Cloudflare again const api = await this.viaPc(id); if (api) return api; throw new Error(`Beatport: its bot check refused the release page, and Platform Check ${api === null ? 'isn\'t logged in to Beatport' : 'didn\'t answer'}: open a Beatport page once in this browser, or log in to Beatport in Platform Check`); } if (doc && doc.fcOrigin) { // #745: a headless read (Mission Control's). The page is behind Cloudflare, which refuses a request without the // browser's clearance cookie (403, "Just a moment..."): sent with the cookies, and when that's refused too, the // release is read through Platform Check, which asks Beatport's API with your login. The API's answer is the // page's own data, so it is read the same way. try { html = await gmText(`https://www.beatport.com/release/-/${id}`, { Accept: 'text/html' }, true); if (/<title>Just a moment/i.test(html)) throw Object.assign(new Error('Beatport\'s bot check (Cloudflare)'), { status: 403 }); } catch (e) { Log.warn(`Beatport: the release page was refused (${e.message}); asking Platform Check for it through Beatport's API`); const api = await this.viaPc(id); if (api) return api; throw new Error(`Beatport: its bot check refused the release page, and Platform Check ${api === null ? 'isn\'t logged in to Beatport' : 'didn\'t answer'}: open a Beatport page once in this browser, or log in to Beatport in Platform Check`); } } else { const r = await fetch(`${location.origin}/release/-/${id}`, { credentials: 'include' }); if (!r.ok) throw new Error(`Beatport: HTTP ${r.status} for release ${id}`); html = await r.text(); } const got = read(new DOMParser().parseFromString(html, 'text/html')); if (!got) throw new Error('Beatport: no __NEXT_DATA__ on the release page (bot check?)'); return got; }, // #745: the release through Platform Check (pc:beatport-release), in the page data's shape; null when Platform Check // isn't logged in to Beatport, undefined when it doesn't answer viaPc(id) { return new Promise(resolve => { const req = Math.random().toString(36).slice(2); const on = e => { let d = null; try { d = JSON.parse(e.detail); } catch (x) { return; } if (!d || d.req !== req) return; document.removeEventListener('pc:beatport-release-result', on); clearTimeout(t); if (!d.release) { Log.warn(`Beatport via Platform Check: ${d.error || 'nothing'}`); resolve(null); return; } Log.info(`Beatport via Platform Check: release ${id}, ${(d.tracks || []).length} track(s) from the API`); resolve({ props: { pageProps: { release: d.release, dehydratedState: { queries: [{ state: { data: { results: d.tracks || [], next: null, count: (d.tracks || []).length } } }] } } } }); }; const t = setTimeout(() => { document.removeEventListener('pc:beatport-release-result', on); Log.warn('Beatport via Platform Check: no answer in 30 s'); resolve(undefined); }, 30000); document.addEventListener('pc:beatport-release-result', on); document.dispatchEvent(new CustomEvent('pc:beatport-release', { detail: JSON.stringify({ id, req }) })); }); }, async fetchRelease(id, progress, doc) { const nd = await this.nextData(id, doc); const pp = nd.props.pageProps; const r = pp.release; if (!r) throw new Error(`Beatport: no release ${id} in the page data`); const q = ((pp.dehydratedState || {}).queries || []).map(x => x.state && x.state.data).find(d => d && Array.isArray(d.results) && d.results.some(t => t.release && String(t.release.id) === String(id))); if (!q) throw new Error('Beatport: no tracks in the page data'); const results = q.results.slice(); for (let next = q.next; next;) { const tok = pp.anonSession && pp.anonSession.access_token; if (!tok) { Log.warn(`Beatport: ${q.count} tracks but no token to read past ${results.length}`); break; } // the page names an internal host; the same path answers on the public one const page = await gmJson(next.replace(/^https?:\/\/[^/]+/, 'https://api.beatport.com'), { Authorization: 'Bearer ' + tok }); results.push(...(page.results || [])); next = page.next || null; } Log.info(`Beatport release ${id}: "${r.name}" by ${(r.artists || []).map(a => a.name).join(', ')} · type ${(r.type || {}).name} · ${r.track_count} track(s), ${results.length} read · ${r.new_release_date} · UPC ${r.upc || '—'} · label "${(r.label || {}).name}" ${r.catalog_number || ''}`); // release.tracks lists the track URLs last to first; a track it misses keeps the API's order const byUrl = new Map(results.map(t => [t.url, t])); const order = (r.tracks || []).slice().reverse().map(u => byUrl.get(u)).filter(Boolean); const tracks = order.length === results.length ? order : results; if (tracks !== order) Log.warn(`Beatport: the release's track list matched ${order.length} of ${results.length} tracks; using the API's order`); const artist = a => ({ name: a.name, url: this.url('artist', a) }); const list = tracks.map((t, i) => { const tf = splitFeat(t.name || ''); const title = tf.title + (t.mix_name && !/^original mix$/i.test(t.mix_name) ? ` (${t.mix_name})` : ''); const credit = creditFromTitle((t.artists || []).map(artist), tf.feat); progress && progress(i + 1, tracks.length); Log.debug(`track ${i + 1}: "${title}" — ${credit.map(c => c.name + c.join).join('')} (${t.isrc || 'no ISRC'})`); return { title, lengthMs: t.length_ms || null, isrc: t.isrc || null, url: this.url('track', t), credit }; }); // Beatport credits every track artist to the release; many of them mean Various Artists const ra = r.artists || []; const af = splitFeat(r.name || ''); const credit = ra.length > 4 || (ra.length === 1 && /^various artists$/i.test(ra[0].name)) ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(ra.map(artist), af.feat); const [y, m, d] = String(r.new_release_date || r.publish_date || '').split('-').map(n => parseInt(n, 10)); const url = this.url('release', r); return { source: this.id, annotation: notesText(r.desc), url, title: af.title, credit, types: this.TYPES[(r.type || {}).name] || [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: r.upc || null, labels: r.label ? [{ name: r.label.name, catno: r.catalog_number || '', url: this.url('label', r.label) }] : [], // 74 = purchase for download, 980 = streaming page (paid) urls: [{ url, linkType: 74 }].concat(r.is_available_for_streaming ? [{ url, linkType: 980 }] : []), mediums: [{ format: 'Digital Media', name: '', tracks: list }], }; }, }; // Spotify: the web player's own album query (api-partner.spotify.com, "getAlbum"). The player's // token is refused by Spotify's public API, and the query needs the player's token, client token // and the query's current id, so FC listens to the player's requests from the start (early) and // replays the album query with what it heard. The barcode comes from the player's metadata service // (spclient, the same token), which the album query doesn't have. No ISRCs. const SPOTIFY = { id: 'spotify', name: 'Spotify', abbr: 'sp', host: /^open\.spotify\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/(?:intl-[a-z-]+\/)?album\/([A-Za-z0-9]{22})\/?$/); return m ? m[1] : null; }, TYPES: { ALBUM: ['Album'], SINGLE: ['Single'], EP: ['EP'], COMPILATION: ['Album', 'Compilation'] }, // #650 (majkinetor: "Spotify fails" — the player's album query was never heard). The hook took // only fetch(url, init) with a string body: a Request object, an XHR, or a userscript manager // that starts the script after the player's first query all slipped past it. Now the token // comes from ANY authorised request of the player (fetch or XHR, Request or init), the query id // from its getAlbum when heard and a known one otherwise, and the log says what was heard. QUERY_URL: 'https://api-partner.spotify.com/pathfinder/v2/query', GETALBUM_HASH: '6a74b456cd1735c9193d9e8ec8cc5184cad7ce13572210315229db3975964361', // the player's getAlbum id on 2026-10-01 KEEP: ['authorization', 'client-token', 'app-platform', 'spotify-app-version', 'accept-language'], auth: null, // { headers } of the player's last authorised request hash: null, // getAlbum's query id, when the player's own getAlbum was heard seen: 0, // requests to Spotify's APIs the hook saw (for the log) note(url, headers, body) { if (!/^https:\/\/[^/]*spotify\.com\//.test(url) || /open\.spotify\.com\//.test(url)) return; this.seen++; const h = {}; for (const k of this.KEEP) if (headers[k]) h[k] = headers[k]; if (h.authorization) { if (!this.auth) Log.debug(`Spotify: heard the player's token (${url.replace(/\?.*$/, '')}; ${Object.keys(h).join(', ')})`); this.auth = { headers: h }; } if (typeof body === 'string' && /"getAlbum"/.test(body)) { try { const hash = JSON.parse(body).extensions.persistedQuery.sha256Hash; if (hash && hash !== this.hash) Log.debug(`Spotify: heard the player's album query (id ${hash.slice(0, 12)}…)`); if (hash) this.hash = hash; } catch (e) { Log.debug(`Spotify: an album query I couldn't read: ${e.message}`); } } }, early() { const w = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const self = this; const lower = src => { const h = {}; try { if (src && typeof src.forEach === 'function') src.forEach((v, k) => { h[String(k).toLowerCase()] = v; }); else if (src) Object.keys(src).forEach(k => { h[k.toLowerCase()] = src[k]; }); } catch (e) { /* unreadable headers */ } return h; }; const origFetch = w.fetch; if (typeof origFetch === 'function') { const hook = function (input, init) { try { const isReq = input && typeof input === 'object' && 'url' in input; const url = String(isReq ? input.url : input || ''); const headers = Object.assign(isReq ? lower(input.headers) : {}, lower(init && init.headers)); const body = init && typeof init.body === 'string' ? init.body : null; if (body !== null || !isReq) self.note(url, headers, body); else if (/pathfinder/.test(url)) { // a Request carries its body inside: read a copy, the page reads the original self.note(url, headers, null); input.clone().text().then(t => self.note(url, {}, t), () => {}); } else self.note(url, headers, null); } catch (e) { Log.debug(`Spotify hook (fetch): ${e.message}`); } return origFetch.apply(this, arguments); }; w.fetch = typeof exportFunction === 'function' ? exportFunction(hook, w) : hook; } const XP = w.XMLHttpRequest && w.XMLHttpRequest.prototype; if (XP) { const oOpen = XP.open, oSet = XP.setRequestHeader, oSend = XP.send; const wrap = fn => (typeof exportFunction === 'function' ? exportFunction(fn, w) : fn); XP.open = wrap(function (method, url) { try { this.__fcUrl = String(url); this.__fcH = {}; } catch (e) { /* ignore */ } return oOpen.apply(this, arguments); }); XP.setRequestHeader = wrap(function (k, v) { try { if (this.__fcH) this.__fcH[String(k).toLowerCase()] = v; } catch (e) { /* ignore */ } return oSet.apply(this, arguments); }); XP.send = wrap(function (body) { try { if (this.__fcUrl) self.note(this.__fcUrl, this.__fcH || {}, typeof body === 'string' ? body : null); } catch (e) { Log.debug(`Spotify hook (XHR): ${e.message}`); } return oSend.apply(this, arguments); }); } Log.debug(`Spotify: listening to the player's requests (fetch${XP ? ' and XHR' : ''}, document ${document.readyState})`); this.inPage(); }, /* majkinetor's log: "the hook saw 0 request(s) to Spotify's APIs", hooked while the document was still loading. His userscript manager runs the script in its own sandbox, where unsafeWindow's fetch isn't the page's: the hook above wraps a fetch the player never calls. So the same hook also goes into the page itself, as a script — Spotify's policy refuses inline scripts but allows blob: ones — and tells the userscript what it hears with a DOM event, which crosses the sandbox. In a manager that does share the page's window both hooks run; a request heard twice is the same request. */ inPage() { const EV = 'first-contact:spotify-heard'; document.addEventListener(EV, e => { try { const d = JSON.parse(e.detail); if (!this._fromPage) { this._fromPage = true; Log.debug('Spotify: the in-page hook is hearing the player'); } this.note(d.url, d.headers || {}, d.body); } catch (x) { Log.debug(`Spotify (in-page hook): ${x.message}`); } }); const code = '(' + function (EV) { if (window.__fcSpotifyHook) return; window.__fcSpotifyHook = true; const tell = (url, headers, body) => { try { document.dispatchEvent(new CustomEvent(EV, { detail: JSON.stringify({ url: String(url), headers, body: typeof body === 'string' ? body : null }) })); } catch (e) { /* nothing to tell with */ } }; const lower = src => { const h = {}; try { if (src && typeof src.forEach === 'function') src.forEach((v, k) => { h[String(k).toLowerCase()] = v; }); else if (src) Object.keys(src).forEach(k => { h[k.toLowerCase()] = src[k]; }); } catch (e) { /* unreadable */ } return h; }; const spot = u => /^https:\/\/[^/]*spotify\.com\//.test(u) && !/open\.spotify\.com\//.test(u); const of = window.fetch; window.fetch = function (input, init) { try { const isReq = input && typeof input === 'object' && 'url' in input; const url = String(isReq ? input.url : input || ''); if (spot(url)) { const headers = Object.assign(isReq ? lower(input.headers) : {}, lower(init && init.headers)); if (init && typeof init.body === 'string') tell(url, headers, init.body); else { tell(url, headers, null); if (isReq && /pathfinder/.test(url)) input.clone().text().then(t => tell(url, {}, t), () => {}); } } } catch (e) { /* never in the player's way */ } return of.apply(this, arguments); }; const XP = XMLHttpRequest.prototype, oo = XP.open, os = XP.setRequestHeader, osd = XP.send; XP.open = function (m, u) { this.__fcU = String(u); this.__fcH = {}; return oo.apply(this, arguments); }; XP.setRequestHeader = function (k, v) { if (this.__fcH) this.__fcH[String(k).toLowerCase()] = v; return os.apply(this, arguments); }; XP.send = function (b) { try { if (this.__fcU && spot(this.__fcU)) tell(this.__fcU, this.__fcH || {}, typeof b === 'string' ? b : null); } catch (e) { /* never in the way */ } return osd.apply(this, arguments); }; } + ')(' + JSON.stringify(EV) + ');'; try { const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' })); const el = document.createElement('script'); let src = url; try { // a page that enforces Trusted Types takes a script URL only from a policy if (window.trustedTypes && window.trustedTypes.createPolicy) src = window.trustedTypes.createPolicy('first-contact-' + Math.random().toString(36).slice(2, 7), { createScriptURL: x => x }).createScriptURL(url); } catch (e) { /* no policy: the plain URL */ } el.src = src; el.onload = () => { Log.debug('Spotify: the in-page hook is in'); el.remove(); URL.revokeObjectURL(url); }; el.onerror = () => Log.warn('Spotify: the page refused the in-page hook'); // at document-start there may be no <html> yet to put it in: wait for it const put = () => { const at = document.head || document.documentElement; if (at) { at.appendChild(el); return true; } return false; }; if (!put()) new MutationObserver((m, o) => { if (put()) o.disconnect(); }).observe(document, { childList: true, subtree: true }); } catch (e) { Log.warn(`Spotify: no in-page hook: ${e.message}`); } }, post(body) { const url = this.QUERY_URL, headers = this.auth.headers; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, data: JSON.stringify(body), timeout: 20000, anonymous: true, headers: Object.assign({}, headers, { 'content-type': 'application/json;charset=UTF-8', accept: 'application/json' }), onload: r => { Log.debug(`POST ${url} (getAlbum offset ${body.variables.offset}) → ${r.status}, ${(r.responseText || '').length} b`); if (r.status < 200 || r.status >= 300) return reject(Object.assign(new Error(`Spotify: HTTP ${r.status} for the album query`), { status: r.status })); try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(new Error(`Spotify: bad JSON: ${e.message}`)); } }, onerror: () => reject(new Error('Spotify: network error')), ontimeout: () => reject(new Error('Spotify: timeout')), }); }); }, idOf: uri => String(uri || '').split(':').pop(), // base62 id → the 32-hex gid the metadata service names things by gidOf(id) { const A = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; let n = 0n; for (const c of id) n = n * 62n + BigInt(A.indexOf(c)); return n.toString(16).padStart(32, '0'); }, // majkinetor: "FC didn't get barcode" (album 0QPZBEO7W3gho2UiFBPJz2, UPC 881626542866). The // player's album query has none; its metadata service has the album's external ids (upc). // Optional: without it the import goes on, barcode empty. upc(id) { const url = `https://spclient.wg.spotify.com/metadata/4/album/${this.gidOf(id)}?market=from_token`; return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 15000, anonymous: true, headers: Object.assign({}, this.auth.headers, { accept: 'application/json' }), onload: r => { let upc = null; try { upc = ((JSON.parse(r.responseText).external_id || []).find(x => /^upc$/i.test(x.type) && x.id) || {}).id || null; } catch (e) { /* not JSON: no barcode */ } Log.debug(`GET ${url} → ${r.status}, ${(r.responseText || '').length} b, UPC ${upc || '—'}`); resolve(upc && /^\d{8,14}$/.test(upc) ? upc : null); }, onerror: () => { Log.warn('Spotify: no barcode (the metadata service could not be reached)'); resolve(null); }, ontimeout: () => { Log.warn('Spotify: no barcode (the metadata service timed out)'); resolve(null); }, }); }); }, async fetchRelease(id, progress) { for (let i = 0; !this.auth && i < 50; i++) await new Promise(r => setTimeout(r, 100)); Log.info(`Spotify: the hook saw ${this.seen} request(s) to Spotify's APIs; token ${this.auth ? 'heard' : 'not heard'}; album query id ${this.hash ? 'heard' : 'not heard, using the known one'}`); if (!this.auth) throw new Error(this.seen ? 'Spotify: the player\u2019s requests carried no token; reload the page and try again' : 'Spotify: no request of the player was heard. Your userscript manager may start First Contact too late on Spotify; reload the page, and if it persists, copy the log to #650'); const hash = this.hash || this.GETALBUM_HASH; const page = offset => this.post({ variables: { uri: `spotify:album:${id}`, locale: '', offset, limit: 50 }, operationName: 'getAlbum', extensions: { persistedQuery: { version: 1, sha256Hash: hash } } }); const upcP = this.upc(id); // alongside the album query const first = await page(0); const a = first && first.data && first.data.albumUnion; if (!a || !a.name) throw new Error(`Spotify: no album ${id}${first && first.errors ? ': ' + JSON.stringify(first.errors).slice(0, 200) : ''}`); const items = (a.tracksV2.items || []).slice(); while (items.length < a.tracksV2.totalCount) { const more = await page(items.length); const got = (((more.data || {}).albumUnion || {}).tracksV2 || {}).items || []; if (!got.length) break; items.push(...got); } Log.info(`Spotify album ${id}: "${a.name}" by ${a.artists.items.map(x => x.profile.name).join(', ')} · ${a.type} · ${items.length} of ${a.tracksV2.totalCount} track(s) · ${a.date && a.date.isoString} (${a.date && a.date.precision}) · label "${a.label || ''}" · UPC ${(await upcP) || '—'}`); const artist = x => ({ name: x.profile.name, url: `https://open.spotify.com/artist/${this.idOf(x.uri)}` }); const mediums = []; items.forEach((it, i) => { const t = it.track || {}; const tf = splitFeat(t.name || ''); const credit = creditFromTitle(((t.artists || {}).items || []).map(artist), tf.feat); const disc = t.discNumber || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title: tf.title, lengthMs: (t.duration && t.duration.totalMilliseconds) || null, isrc: null, url: `https://open.spotify.com/track/${this.idOf(t.uri)}`, credit }); progress && progress(i + 1, items.length); Log.debug(`track ${disc}.${t.trackNumber}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')}`); }); const ra = a.artists.items || []; const af = splitFeat(a.name); const credit = ra.length === 1 && /^various artists$/i.test(ra[0].profile.name) ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(ra.map(artist), af.feat); const p = (a.date && a.date.precision) || ''; const [y, m, d] = String((a.date && a.date.isoString) || '').slice(0, 10).split('-').map(n => parseInt(n, 10)); const url = `https://open.spotify.com/album/${id}`; return { source: this.id, url, title: af.title, credit, types: this.TYPES[a.type] || [], status: 'official', packaging: 'None', date: { year: y || null, month: p === 'YEAR' ? null : m || null, day: p === 'DAY' ? d || null : null }, country: 'XW', barcode: await upcP, labels: a.label ? [{ name: a.label, catno: '' }] : [], urls: [{ url, linkType: 85 }], // 85 = stream for free mediums, }; }, }; // YouTube Music: the API its own web player uses (youtubei/v1), which answers anonymous // requests — as Platform Check and Credit Hoarder read it. The album page (browse MPREb_…) has // the title, the kind (Album / EP / Single), the year, and each track with its artists' channels // and length; an album playlist (OLAK5uy_…) names its album page. No label, barcode, ISRCs or // full date: the player doesn't show them. YouTube Music lists a featured artist as a main // one, so feat. comes from the title, as on Deezer. const YTMUSIC = { id: 'ytmusic', name: 'YouTube Music', abbr: 'ytm', host: /^(?:music|www)\.youtube\.com$/, // #679: an album playlist (OLAK5uy_) on www.youtube.com is the same album albumId(loc) { const b = loc.pathname.match(/^\/browse\/(MPREb_[\w-]+)\/?$/); if (b) return b[1]; const l = loc.pathname === '/playlist' && String(loc.search || '').match(/[?&]list=(OLAK5uy_[\w-]+)/); return l ? 'list:' + l[1] : null; }, API: 'https://music.youtube.com/youtubei/v1/', CLIENT: { clientName: 'WEB_REMIX', clientVersion: '1.20250101.01.00', hl: 'en', gl: 'US' }, TYPES: { Album: ['Album'], EP: ['EP'], Single: ['Single'] }, call(endpoint, body) { const url = `${this.API}${endpoint}?prettyPrint=false`; const t0 = Date.now(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, data: JSON.stringify(Object.assign({ context: { client: this.CLIENT } }, body)), timeout: 20000, anonymous: true, headers: { 'Content-Type': 'application/json' }, onload: r => { Log.debug(`POST ${url} ${body.browseId || ''} → ${r.status}, ${(r.responseText || '').length} b in ${Date.now() - t0} ms`); if (r.status < 200 || r.status >= 300) return reject(new Error(`YouTube Music: HTTP ${r.status} for ${body.browseId}`)); try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(new Error(`YouTube Music: bad JSON for ${body.browseId}`)); } }, onerror: () => reject(new Error('YouTube Music: network error')), ontimeout: () => reject(new Error('YouTube Music: timeout')), }); }); }, text: t => (t && t.runs ? t.runs.map(x => x.text).join('') : (t && t.simpleText) || ''), walk(o, fn) { if (!o || typeof o !== 'object') return; fn(o); for (const k in o) this.walk(o[k], fn); }, // the artists a run list links: "Daft Punk, Pharrell Williams & Nile Rodgers", each with its channel artists(t) { return ((t && t.runs) || []).filter(r => { const b = r.navigationEndpoint && r.navigationEndpoint.browseEndpoint; return b && /^UC/.test(b.browseId || ''); }).map(r => ({ name: r.text.trim(), url: `https://music.youtube.com/channel/${r.navigationEndpoint.browseEndpoint.browseId}` })); }, hms(s) { const p = String(s || '').trim().split(':').map(Number); return p.length > 1 && p.every(Number.isFinite) ? p.reduce((a, n) => a * 60 + n, 0) * 1000 : null; }, async fetchRelease(key, progress) { let id = key; if (/^list:/.test(key)) { const list = key.slice(5); const pl = await this.call('browse', { browseId: 'VL' + list }); id = (JSON.stringify(pl).match(/"(MPREb_[\w-]+)"/) || [])[1]; Log.info(`YouTube Music: playlist ${list} → album page ${id || 'none'}`); if (!id) throw new Error(`YouTube Music: playlist ${list} names no album`); } const j = await this.call('browse', { browseId: id }); let h = null, shelf = null; this.walk(j, o => { if (!h && o.musicResponsiveHeaderRenderer) h = o.musicResponsiveHeaderRenderer; if (!shelf && o.musicShelfRenderer) shelf = o.musicShelfRenderer; }); if (!h || !shelf) throw new Error(`YouTube Music: no album on ${id} (the page may have changed)`); const title = this.text(h.title); const sub = this.text(h.subtitle).split(' • '); // "Album • 2013" const kind = sub[0] || '', year = parseInt(sub.find(s => /^\d{4}$/.test(s)) || '', 10) || null; const albumArtists = this.artists(h.straplineTextOne); const canon = (j.microformat && j.microformat.microformatDataRenderer && j.microformat.microformatDataRenderer.urlCanonical) || ''; const list = (canon.match(/[?&]list=(OLAK5uy_[\w-]+)/) || [])[1]; const rows = (shelf.contents || []).map(c => c.musicResponsiveListItemRenderer).filter(Boolean); Log.info(`YouTube Music album ${id}: "${title}" by ${this.text(h.straplineTextOne)} · ${kind} · ${year || 'no year'} · ${this.text(h.secondSubtitle)} · ${rows.length} row(s)${list ? ' · playlist ' + list : ''}`); const tracks = rows.map((r, i) => { const cols = (r.flexColumns || []).map(c => c.musicResponsiveListItemFlexColumnRenderer && c.musicResponsiveListItemFlexColumnRenderer.text); const name = this.text(cols[0]); const tf = splitFeat(name); const listed = this.artists(cols[1]); const credit = creditFromTitle(listed.length ? listed : albumArtists, tf.feat); const len = this.hms(this.text((((r.fixedColumns || [])[0] || {}).musicResponsiveListItemFixedColumnRenderer || {}).text)); const vid = ((((cols[0] || {}).runs || [])[0] || {}).navigationEndpoint || {}).watchEndpoint; progress && progress(i + 1, rows.length); Log.debug(`track ${this.text(r.index) || i + 1}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${len || '?'} ms)`); return { title: tf.title, lengthMs: len, isrc: null, url: vid && vid.videoId ? `https://music.youtube.com/watch?v=${vid.videoId}` : null, credit }; }); const af = splitFeat(title); const credit = /^various artists$/i.test(this.text(h.straplineTextOne)) ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(albumArtists.length ? albumArtists : [{ name: this.text(h.straplineTextOne) }], af.feat); const url = list ? `https://music.youtube.com/playlist?list=${list}` : `https://music.youtube.com/browse/${id}`; return { source: this.id, annotation: notesText(this.text(h.description && h.description.musicDescriptionShelfRenderer && h.description.musicDescriptionShelfRenderer.description)), url, title: af.title, credit, types: this.TYPES[kind] || [], status: 'official', packaging: 'None', date: { year, month: null, day: null }, country: 'XW', barcode: null, labels: [], urls: [{ url, linkType: 85 }], // 85 = stream for free mediums: [{ format: 'Digital Media', name: '', tracks }], }; }, }; // Volumo: its public API (volumo.com/api/v1), as ISRC Scout and Platform Check read it — no token. // The album page is /album/<barcode>-<slug> (or /album/<id>); the barcode form asks // /album_by_icpn, the id form /albums. Every track has its ISRC and every artist its Volumo id. const VOLUMO = { id: 'volumo', name: 'Volumo', abbr: 'vo', artistLinkType: 176, // MB doesn't recognise Volumo host: /^(www\.)?volumo\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/album\/(\d+)(?:-[^/]*)?\/?$/); return m ? m[1] : null; }, artist: a => ({ name: a.name, url: `https://volumo.com/artist/${a.id}` }), async fetchRelease(id, progress) { const j = await gmJson('https://volumo.com/api/v1' + (id.length >= 12 ? '/album_by_icpn/' : '/albums/') + id); const a = Array.isArray(j) ? j[0] : (j && (j.album || j)); if (!a || !a.title) throw new Error(`Volumo: no album ${id}`); const list = a.tracks || []; Log.info(`Volumo album ${id}: "${a.title}" by ${(a.artists || []).map(x => x.name).join(', ')} · ${list.length} track(s) · ${a.original_release_date || a.release_start_at || 'no date'} · UPC ${a.icpn || '—'} · label "${(a.recordlabel || {}).name || ''}" ${a.catalog_number || ''}`); const mediums = []; list.forEach((t, i) => { const tf = splitFeat(t.title || ''); const title = tf.title + (t.version && !/^original mix$/i.test(t.version) ? ` (${t.version})` : ''); const credit = creditFromTitle((t.artists || []).map(this.artist).concat((t.featured_artists || []).map(this.artist)), tf.feat.length ? tf.feat : (t.featured_artists || []).map(x => x.name)); const disc = t.disc_number || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title, lengthMs: Math.round(t.duration) || null, isrc: t.isrc || null, url: t.id ? `https://volumo.com/track/${t.id}` : null, credit }); progress && progress(i + 1, list.length); Log.debug(`track ${i + 1}: "${title}" — ${credit.map(c => c.name + c.join).join('')} (${t.isrc || 'no ISRC'})`); }); const ra = a.artists || []; const af = splitFeat(a.title); // a label's sampler credits every track artist to the release: many of them mean Various Artists const credit = ra.length > 4 || (ra.length === 1 && /^various artists$/i.test(ra[0].name)) ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(ra.map(this.artist), af.feat); const [y, m, d] = String(a.original_release_date || a.release_start_at || '').slice(0, 10).split('-').map(n => parseInt(n, 10)); const url = `https://volumo.com/album/${a.icpn || a.id}`; // the slug-less form (Platform Check #202) return { source: this.id, annotation: notesText(a.description), url, title: af.title, credit, types: [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: a.icpn || null, labels: a.recordlabel && a.recordlabel.name ? [{ name: a.recordlabel.name, catno: a.catalog_number || '' }] : [], urls: [{ url, linkType: 74 }], // 74 = purchase for download mediums, }; }, }; // HDtracks: its public API (hdtracks.azurewebsites.net/api/v1), as ISRC Scout reads it — one // call has the album and every track with its ISRC. The site routes in the address's hash // (www.hdtracks.com/#/album/<id>). HDtracks names artists only, with no artist pages. const HDTRACKS = { id: 'hdtracks', name: 'HDtracks', abbr: 'hd', artistLinkType: 176, // MB doesn't recognise HDtracks host: /^(www\.)?hdtracks\.com$/, albumId(loc) { const m = String(loc.hash || '').match(/^#\/album\/([a-f0-9]{24})\b/i) || loc.pathname.match(/^\/album\/([a-f0-9]{24})\/?$/i); return m ? m[1] : null; }, API: 'https://hdtracks.azurewebsites.net/api/v1', async fetchRelease(id, progress) { const j = await gmJson(`${this.API}/album/${id}`); if (!j || !j.id) throw new Error(`HDtracks: no album ${id}`); // an unknown id answers 200 with nothing in it const list = (j.tracks || []).slice().sort((a, b) => (a.discIndex || 1) - (b.discIndex || 1) || (a.index || 0) - (b.index || 0)); Log.info(`HDtracks album ${id}: "${j.name}" by ${j.mainArtist} · ${list.length} track(s) · ${j.release || j.originalRelease || 'no date'} · UPC ${j.upc || '—'} · label "${j.label || ''}" · ${j.quality || ''}`); const names = text => String(text || '').split(/\s*,\s*|\s+&\s+/).filter(Boolean).map(name => ({ name })); const mediums = []; list.forEach((t, i) => { const tf = splitFeat(t.name || ''); const credit = creditFromTitle(names(t.mainArtist || j.mainArtist), tf.feat); const disc = t.discIndex || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title: tf.title, lengthMs: t.duration ? Math.round(t.duration * 1000) : null, isrc: t.isrc || null, url: null, credit }); progress && progress(i + 1, list.length); Log.debug(`track ${disc}.${t.index}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${t.isrc || 'no ISRC'})`); }); const af = splitFeat(j.name || ''); const credit = /^various artists$/i.test(j.mainArtist || '') ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(names(j.mainArtist), af.feat); const [y, m, d] = String(j.release || j.originalRelease || '').slice(0, 10).split('-').map(n => parseInt(n, 10)); const url = `https://www.hdtracks.com/#/album/${id}`; return { source: this.id, annotation: notesText(j.notes), url, title: af.title, credit, types: [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: j.upc || null, labels: j.label ? [{ name: j.label, catno: '' }] : [], urls: [{ url, linkType: 74 }], // 74 = purchase for download mediums, }; }, }; // SoundCloud: its public API (api-v2.soundcloud.com) with the client id the web player's JS // carries, as ISRC Scout reads it. A set (/<user>/sets/<slug>) is the release; a set the label // distributed has each track's ISRC, the barcode and the label in the track's publisher data. const SOUNDCLOUD = { id: 'soundcloud', name: 'SoundCloud', abbr: 'sc', host: /^soundcloud\.com$/, albumId(loc) { return /^\/[^/]+\/sets\/[^/]+\/?$/.test(loc.pathname) ? loc.pathname.replace(/\/$/, '') : null; }, API: 'https://api-v2.soundcloud.com', CID_KEY: 'fc.soundcloud-cid', TYPES: { album: ['Album'], ep: ['EP'], single: ['Single'], compilation: ['Album', 'Compilation'] }, async clientId(fresh) { const c = !fresh && GM_getValue(this.CID_KEY, null); if (c && c.id && Date.now() - c.at < 12 * 3600e3) return c.id; Log.info('SoundCloud: reading the web player\'s client id'); const home = await gmText('https://soundcloud.com/discover', { Accept: 'text/html' }); const assets = [...home.matchAll(/https:\/\/a-v2\.sndcdn\.com\/assets\/[^"']+\.js/g)].map(m => m[0]).reverse(); for (const a of assets) { let js; try { js = await gmText(a, { Accept: '*/*' }); } catch (e) { continue; } const m = js.match(/client_id\s*[:=]\s*"([a-zA-Z0-9]{20,40})"/); if (m) { GM_setValue(this.CID_KEY, { id: m[1], at: Date.now() }); return m[1]; } } throw new Error('SoundCloud: no client id in the web player\'s JS'); }, async api(path) { for (let attempt = 0; ; attempt++) { const cid = await this.clientId(attempt > 0); try { return await gmJson(`${this.API}${path}${path.includes('?') ? '&' : '?'}client_id=${cid}`); } catch (e) { if ((e.status === 401 || e.status === 403) && attempt === 0) { Log.warn('SoundCloud: the client id was refused, reading a new one'); continue; } throw e; } } }, async fetchRelease(path, progress) { const page = `https://soundcloud.com${path}`; const pl = await this.api('/resolve?url=' + encodeURIComponent(page)); if (!pl || pl.kind !== 'playlist') throw new Error('SoundCloud: not a set'); // the set names its first tracks in full and the rest by id: read those in batches of 50 const byId = new Map((pl.tracks || []).filter(t => t && t.title).map(t => [t.id, t])); const missing = (pl.tracks || []).filter(t => t && !byId.has(t.id)).map(t => t.id); for (let i = 0; i < missing.length; i += 50) (await this.api('/tracks?ids=' + missing.slice(i, i + 50).join(','))).forEach(t => byId.set(t.id, t)); const list = (pl.tracks || []).map(t => byId.get(t.id)).filter(Boolean); const pm0 = (list[0] && list[0].publisher_metadata) || {}; Log.info(`SoundCloud set ${path}: "${pl.title}" by ${pl.user && pl.user.username} · ${pl.set_type || 'set'} · ${list.length} track(s) · ${pl.release_date || pl.published_at || pl.created_at || 'no date'} · label "${pl.label_name || ''}" · UPC ${pm0.upc_or_ean || '—'}`); const uploader = pl.user || {}; const linkFor = name => (uploader.username && normName(name) === normName(uploader.username) ? uploader.permalink_url : null); // only the uploader has a page we know const names = text => String(text || '').split(/\s*,\s*|\s+&\s+/).filter(Boolean).map(name => ({ name, url: linkFor(name) })); const tracks = list.map((t, i) => { const pm = t.publisher_metadata || {}; const tf = splitFeat(t.title || ''); const credit = creditFromTitle(names(pm.artist || uploader.username), tf.feat); progress && progress(i + 1, list.length); Log.debug(`track ${i + 1}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${pm.isrc || 'no ISRC'})`); return { title: tf.title, lengthMs: t.full_duration || t.duration || null, isrc: pm.isrc || null, url: t.permalink_url || null, credit }; }); const artists = [...new Set(list.map(t => (t.publisher_metadata || {}).artist).filter(Boolean))]; const af = splitFeat(pl.title || ''); const credit = artists.length > 4 ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(names(artists.length === 1 ? artists[0] : uploader.username), af.feat); const upcs = [...new Set(list.map(t => String((t.publisher_metadata || {}).upc_or_ean || '').trim()).filter(Boolean))]; const [y, m, d] = String(pl.release_date || pl.published_at || pl.created_at || '').slice(0, 10).split('-').map(n => parseInt(n, 10)); const url = pl.permalink_url || page; return { source: this.id, annotation: notesText(pl.description), url, title: af.title, credit, types: this.TYPES[String(pl.set_type || '').toLowerCase()] || [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: upcs.length === 1 ? upcs[0] : null, labels: pl.label_name ? [{ name: pl.label_name, catno: '' }] : [], urls: [{ url, linkType: 85 }], // 85 = stream for free mediums: [{ format: 'Digital Media', name: '', tracks }], }; }, }; // Amazon Music (#650, majkinetor: "implement AM"): the API its own web player uses // (na.mesk.skill.music.a2z.com), as a guest — music.amazon.com/config.json hands out a session (CSRF // token, device and session id, no login), carried as a JSON "headers" string in each request's body. // Platform Check (#644) and ISRC Scout read it the same way. The guest session is amazon.com's (US) // catalogue: an album from another country's store is looked up by the same id there. The album // page is one flat tracklist (no disc numbers), with each track's length, link and, when it isn't // the album artist, an artist line like "A, B & C" whose first name alone is linked. No barcode or // ISRCs (Amazon Music shows neither); the label is read from the page's ℗ line. const AMAZON = { id: 'amazonmusic', name: 'Amazon Music', abbr: 'amz', // MB stores an Amazon Music artist under any of its domains urlForms(url) { const m = url.match(/^https?:\/\/music\.amazon\.[a-z.]+\/artists\/([A-Z0-9]{10})/i); return m ? AMAZON_TLDS.map(t => `https://music.amazon.${t}/artists/${m[1].toUpperCase()}`) : []; }, host: /^music\.amazon\.(?:com|co\.uk|de|fr|it|es|ca|co\.jp|com\.au|com\.br|com\.mx|in)$/, albumId(loc) { const m = loc.pathname.match(/^\/albums\/([A-Z0-9]{10})\/?$/i); return m ? m[1].toUpperCase() : null; }, API: 'https://na.mesk.skill.music.a2z.com/api/', TYPES: { album: ['Album'], ep: ['EP'], single: ['Single'] }, MONTHS: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'], session: null, post(url, data, headers) { const t0 = Date.now(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, data, timeout: 20000, anonymous: true, headers: headers || {}, onload: r => { Log.debug(`POST ${url} → ${r.status}, ${(r.responseText || '').length} b in ${Date.now() - t0} ms`); if (r.status < 200 || r.status >= 300) return reject(Object.assign(new Error(`Amazon Music: HTTP ${r.status} for ${url}`), { status: r.status })); try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(new Error(`Amazon Music: bad JSON from ${url}`)); } }, onerror: () => reject(new Error('Amazon Music: network error')), ontimeout: () => reject(new Error('Amazon Music: timeout')), }); }); }, async guest() { if (this.session) return this.session; const j = await this.post('https://music.amazon.com/config.json', ''); if (!j || !j.csrf || !j.deviceId) throw new Error('Amazon Music: no guest session; its web player may have changed'); Log.info(`Amazon Music: guest session (marketplace ${j.marketplaceId || '?'}, player ${j.version || '?'})`); return (this.session = j); }, async call(endpoint, body) { const s = await this.guest(); const headers = JSON.stringify({ 'x-amzn-authentication': JSON.stringify({ interface: 'ClientAuthenticationInterface.v1_0.ClientTokenElement', accessToken: s.accessToken || '' }), 'x-amzn-device-model': 'WEBPLAYER', 'x-amzn-device-width': '1920', 'x-amzn-device-family': 'WebPlayer', 'x-amzn-device-id': s.deviceId, 'x-amzn-user-agent': navigator.userAgent, 'x-amzn-session-id': s.sessionId, 'x-amzn-device-height': '1080', 'x-amzn-request-id': (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)), 'x-amzn-device-language': 'en_US', 'x-amzn-currency-of-preference': 'USD', 'x-amzn-os-version': '1.0', 'x-amzn-application-version': s.version, 'x-amzn-device-time-zone': 'UTC', 'x-amzn-timestamp': String(Date.now()), 'x-amzn-csrf': JSON.stringify({ interface: 'CSRFInterface.v1_0.CSRFHeaderElement', token: s.csrf.token, timestamp: s.csrf.ts, rndNonce: s.csrf.rnd }), 'x-amzn-music-domain': 'music.amazon.com', 'x-amzn-referer': '', 'x-amzn-affiliate-tags': '', 'x-amzn-ref-marker': '', 'x-amzn-page-url': 'https://music.amazon.com/', 'x-amzn-weblab-id-overrides': '', 'x-amzn-video-player-token': '', 'x-amzn-feature-flags': '', 'x-amzn-has-profile-id': '', 'x-amzn-age-band': '', }); let j; try { j = await this.post(this.API + endpoint, JSON.stringify(Object.assign({}, body, { headers })), { 'Content-Type': 'text/plain;charset=UTF-8' }); } catch (e) { if (e.status === 401 || e.status === 403) this.session = null; throw e; } const tpl = j && j.methods && (j.methods.find(m => m.template) || {}).template; if (!tpl) throw new Error(`Amazon Music: ${endpoint} gave no page; its API may have changed`); return tpl; }, text: t => String((t && typeof t === 'object' ? t.text : t) || '').trim(), slug: s => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/&/g, ' ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''), // "A, B & C" with a link to one artist's page (/artists/<id>/<slug>) → [{ name, url }]. The link // names who it is: when its slug is the whole line ("Simon & Garfunkel"), the line is one artist; // otherwise it's a list, and the link belongs to the name its slug matches (the first, so far). artists(line, link) { const m = String(link || '').match(/^\/artists\/([A-Z0-9]{10})(?:\/([^/?#]+))?/i); const url = m ? `https://music.amazon.com/artists/${m[1].toUpperCase()}` : null; const slug = m && m[2] ? m[2].toLowerCase() : ''; const names = /,\s|\s&\s/.test(line) && !(slug && slug === this.slug(line)) ? line.split(/\s*,\s+|\s+&\s+/).map(x => x.trim()).filter(Boolean) : [line]; let at = slug ? names.findIndex(n => this.slug(n) === slug) : -1; if (at < 0 && url) { at = 0; if (slug) Log.debug(`Amazon Music: the link /${slug} is none of ${JSON.stringify(names)}; it goes to the first`); } return names.map((name, i) => (i === at ? { name, url } : { name })); }, // "MAY 17 2013" → { year, month, day } date(s) { const m = String(s || '').match(/\b([A-Za-z]{3})[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4})\b/); if (m) return { year: +m[3], month: this.MONTHS.indexOf(m[1].toLowerCase()) + 1 || null, day: +m[2] }; const y = String(s || '').match(/\b(\d{4})\b/); return { year: y ? +y[1] : null, month: null, day: null }; }, secs(x) { const p = String(x || '').split(':').map(Number); return p.length > 1 && p.every(n => !isNaN(n)) ? p.reduce((a, n) => a * 60 + n, 0) : null; }, async fetchRelease(id, progress) { const tpl = await this.call('showCatalogAlbum', { id, userHash: JSON.stringify({ level: 'LIBRARY_MEMBER' }) }); const title = this.text(tpl.headerText); if (!title) throw new Error(`Amazon Music: no album ${id}`); const sub = String(tpl.headerTertiaryText || '').split(/\s*•\s*/); const artistLine = this.text(tpl.headerPrimaryText); const artistLink = (tpl.headerPrimaryTextLink || {}).deeplink; const rows = []; for (const w of tpl.widgets || []) for (const it of w.items || []) { const tid = (String((it.primaryTextLink || {}).deeplink || '').match(/^\/tracks\/([A-Z0-9]{10})/) || String((it.primaryLink || {}).deeplink || '').match(/[?&]trackAsin=([A-Z0-9]{10})/) || [])[1]; if (tid && !rows.some(r => r.id === tid)) rows.push({ id: tid, it }); } const said = parseInt(((sub[0] || '').match(/^([\d,.]+)\s+(?:song|track)s?\b/i) || [])[1] || '', 10); Log.info(`Amazon Music album ${id}: "${title}" by ${artistLine} (${artistLink || 'no link'}) · ${this.text(tpl.headerLabel) || 'no kind'} · ${rows.length} track(s)${said && said !== rows.length ? ` (the header says ${said})` : ''} · ${sub.slice(1).join(' · ')} · ${tpl.footer || 'no ℗ line'}`); if (!rows.length) throw new Error(`Amazon Music: album ${id} has no tracks in its answer; its page may have changed`); const va = /^various artists$/i.test(artistLine); const albumArtists = va ? null : this.artists(artistLine, artistLink); const tracks = rows.map(({ id: tid, it }, i) => { const tf = splitFeat(this.text(it.primaryText)); const line = this.text(it.secondaryText2); const listed = line ? this.artists(line, (it.secondaryText2Link || {}).deeplink) : (albumArtists || [{ name: 'Various Artists' }]); const credit = creditFromTitle(listed, tf.feat); const secs = this.secs(it.secondaryText3); progress && progress(i + 1, rows.length); Log.debug(`track ${i + 1}: "${tf.title}" ${it.secondaryText3 || '?'} — ${credit.map(c => c.name + c.join).join('')}${line ? ` (line "${line}")` : ' (album artist)'}`); return { title: tf.title, lengthMs: secs == null ? null : secs * 1000, isrc: null, url: `https://music.amazon.com/tracks/${tid}`, credit }; }); const af = splitFeat(title); const credit = va ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(albumArtists, af.feat); const date = this.date(sub.find(x => /\b\d{4}$/.test(x)) || ''); const lbl = labelFromCopyright(tpl.footer); const url = `https://music.amazon.com/albums/${id}`; return { source: this.id, url, title: af.title, credit, types: this.TYPES[this.text(tpl.headerLabel).toLowerCase()] || [], status: 'official', packaging: 'None', date, country: 'XW', barcode: null, labels: lbl ? [{ name: lbl, catno: '' }] : [], urls: [{ url, linkType: 980 }], // 980 = streaming page (paid) // #650 (majkinetor, of "℗© VIDLIK": "this is not label", "could probably be added in // annotation"): the page's ℗ line is its only note; it goes there whole, read as a label or not annotation: notesText(tpl.footer), mediums: [{ format: 'Digital Media', name: '', tracks }], }; }, }; // Audiomack (#664): its API (api.audiomack.com/v1), as ISRC Scout and Platform Check read it. Every // call is OAuth 1.0a-signed with the web player's own public key and secret, so no login. An album // (/<artist>/album/<slug>) has its tracks in order, each with its ISRC; a song (/<artist>/song/<slug>) // is a one-track release. The barcode is the album's `upc`, the label comes from its ℗ line. const AUDIOMACK = { id: 'audiomack', name: 'Audiomack', abbr: 'amk', artistLinkType: 194, // free streaming: MB offers free and paid (#664) host: /^(www\.)?audiomack\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/([^/]+)\/(album|song)\/([^/]+)\/?$/); return m ? `${m[1]}/${m[2]}/${m[3]}` : null; }, API: 'https://api.audiomack.com/v1/', KEY: 'audiomack-web', SECRET: 'bd8a07e9f23fbe9d808646b730f89b8e', async api(endpoint) { const enc = s => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); const url = this.API + endpoint; const p = { oauth_consumer_key: this.KEY, oauth_nonce: Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), oauth_signature_method: 'HMAC-SHA1', oauth_timestamp: String(Math.floor(Date.now() / 1000)), oauth_version: '1.0' }; const qs = Object.keys(p).sort().map(k => `${enc(k)}=${enc(p[k])}`).join('&'); const te = new TextEncoder(); const key = await crypto.subtle.importKey('raw', te.encode(enc(this.SECRET) + '&'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); const sig = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC', key, te.encode(['GET', enc(url), enc(qs)].join('&')))))); const j = await gmJson(`${url}?${qs}&oauth_signature=${enc(sig)}`); if (!j || !j.results) throw new Error(`Audiomack: nothing at ${endpoint}`); return j.results; }, // "℗ Spaceship/ Bad Habit/ Atlantic Records, 2025 Spaceship …" → "Spaceship/ Bad Habit/ Atlantic Records" label: pline => String(pline || '').replace(/^[\s©℗]*(?:\(P\)\s*)?(?:\d{4}\s+)?/i, '').split(/,\s+\d{4}\b/)[0].trim(), async fetchRelease(id, progress) { const [artistSlug, kind, slug] = id.split('/'); const a = await this.api(`music/${kind}/${artistSlug}/${slug}`); const list = kind === 'album' ? (a.tracks || []) : [a]; const ts = Number(a.original_release_date || a.released || 0); Log.info(`Audiomack ${kind} ${id}: "${a.title}" by ${a.artist} · ${list.length} track(s) · ${ts ? new Date(ts * 1000).toISOString().slice(0, 10) : 'no date'} · UPC ${a.upc || '—'} · ℗ "${a.pline || ''}"`); const uploader = a.uploader || {}; const linkFor = name => (uploader.name && normName(name) === normName(uploader.name) ? `https://audiomack.com/${uploader.url_slug}` : null); // only the uploader has a page we know const names = text => String(text || '').split(/\s*,\s*|\s+&\s+/).filter(Boolean).map(name => ({ name, url: linkFor(name) })); const tracks = list.map((t, i) => { const tf = splitFeat(t.title || ''); const feat = tf.feat.length ? tf.feat : String(t.featuring || '').split(/\s*,\s*|\s+&\s+/).filter(Boolean); const credit = creditFromTitle(names(t.artist || a.artist), feat); progress && progress(i + 1, list.length); Log.debug(`track ${i + 1}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${t.isrc || 'no ISRC'})`); const songSlug = t.url_slug; return { title: tf.title, lengthMs: Number(t.duration) > 0 ? Number(t.duration) * 1000 : null, isrc: t.isrc || null, url: songSlug ? `https://audiomack.com/${t.uploader_url_slug || uploader.url_slug || artistSlug}/song/${songSlug}` : null, credit }; }); const af = splitFeat(a.title || ''); const credit = /^various artists$/i.test(a.artist || '') ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(names(a.artist), af.feat.length ? af.feat : String(a.featuring || '').split(/\s*,\s*|\s+&\s+/).filter(Boolean)); const d = ts ? new Date(ts * 1000) : null; const url = `https://audiomack.com/${id}`; const lbl = this.label(a.pline); return { source: this.id, annotation: notesText(a.description), url, title: af.title, credit, types: kind === 'song' ? ['Single'] : [], // an album: its title's EP / Single, or its track count, decide it (importCurrent) status: 'official', packaging: 'None', date: { year: d ? d.getUTCFullYear() : null, month: d ? d.getUTCMonth() + 1 : null, day: d ? d.getUTCDate() : null }, country: 'XW', barcode: String(a.upc || '').replace(/\D/g, '') || null, labels: lbl ? [{ name: lbl, catno: '' }] : [], urls: [{ url, linkType: 85 }], // 85 = stream for free mediums: [{ format: 'Digital Media', name: '', tracks }], }; }, }; // 7digital (#669): the release page has the title, the release artist with their page, the release // date, the label, and the tracklist (each track's id, title and length). The barcode, type, discs, // track artists and ISRCs come from 7digital's catalogue API, with the key its own store ships in its // app.js, as Platform Check and ISRC Scout read it. That key may only search, so the release is found // by its title, and each track by its title (with the album's title, or after its artist), by their // ids. The catalogue is per country: the store's. const SEVENDIGITAL = { id: 'sevendigital', name: '7digital', abbr: '7d', host: /^(?:[a-z]{2}\.|www\.)?7digital\.com$/, albumId(loc) { return /^\/artist\/[^/]+\/release\/(?:[^/]*-)?\d{3,}\/?$/.test(loc.pathname) ? loc.pathname.replace(/\/$/, '') : null; }, KEY: '7drfpc993qp5', withoutPage: true, // #747: a headless read refused by the captcha is read from the API (fromApi) // the store's key when this was written; a refused one is replaced by the store's current one TYPES: { Album: ['Album'], EP: ['EP'], Single: ['Single'] }, text: el => (el ? el.textContent.replace(/\s+/g, ' ').trim() : ''), // "PT4M9S" → ms iso(d) { const m = String(d || '').match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/); return m ? ((+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0)) * 1000 || null : null; }, async apiKey(fresh) { if (this.key && !fresh) return this.key; if (!fresh) return (this.key = this.KEY); if (this.keyP) return this.keyP; this.keyP = (async () => { const own = document.querySelector('script[src*="/static/build/javascript/app."]'); let src = own && own.getAttribute('src'); if (!src) src = ((await gmText('https://us.7digital.com/', { Accept: 'text/html' })).match(/\/\/js-cdn\.7digital\.com\/static\/build\/javascript\/app\.[a-z0-9]+\.js/i) || [])[0]; if (!src) throw new Error('7digital: the store\'s JS was not found'); const k = ((await gmText(new URL(src, 'https://us.7digital.com/').href, { Accept: '*/*' })).match(/oauth_consumer_key="([A-Za-z0-9]+)"/) || [])[1]; if (!k) throw new Error('7digital: no key in the store\'s JS'); return (this.key = k); })().finally(() => { this.keyP = null; }); return this.keyP; }, // One API read: its JSON, or throws. A refused key (HTTP 401) is replaced, once. async api(path, params) { for (let attempt = 0; ; attempt++) { const key = await this.apiKey(attempt > 0); const qs = new URLSearchParams(Object.assign({}, params, { usageTypes: 'download', oauth_consumer_key: key })); try { const j = await gmJson(`https://api.7digital.com/1.2/${path}?${qs}`); if (!j || j.status !== 'ok') throw new Error(`7digital ${path}: ${(j && j.error && j.error.message) || 'no answer'}`); return j; } catch (e) { if (e.status === 401 && attempt === 0) { Log.warn('7digital: the key was refused; fetching the store\'s current one'); continue; } throw e; } } }, // "A, B & C" / "A;B;C" / "A x B" → names; the release's own artist ("Simon & Garfunkel") stays one names(line, own) { const s = String(line || '').trim(); if (!s) return []; if (own && normName(s) === normName(own)) return [s]; return s.split(/\s*;\s*|\s*,\s+|\s+&\s+|\s+x\s+/).map(x => x.trim()).filter(Boolean); }, // #747: a release without its page (7digital's captcha refused Mission Control's read): found by the words of its // address (artist and title) in the API's release search, by its id; its tracks from the track search on the same // words, by the release's id. What the page would give: { title, artist, artistUrl, pageLabel, rows, apiRel, apiTracks }. async fromApi(url, country, releaseId, progress) { const m = new URL(url).pathname.match(/^\/artist\/([^/]+)\/release\/(.*?)-?\d{3,}\/?$/) || []; const words = x => decodeURIComponent(x || '').replace(/-/g, ' ').trim(); const a = words(m[1]), t = words(m[2]); let r = null, at = country; for (const c of [...new Set([country, 'GB', 'US'])]) { for (const q of [`${a} ${t}`, t].filter(x => x.trim())) { try { const j = await this.api('release/search', { q, country: c, pageSize: '50' }); r = ((j.searchResults || {}).searchResult || []).map(x => x.release).find(x => x && String(x.id) === String(releaseId)) || null; } catch (e) { Log.warn(`7digital: release search "${q}" (${c}) failed: ${e.message}`); } if (r) { at = c; break; } } if (r) break; } if (!r) throw new Error(`7digital asked to verify you're human, and release ${releaseId} wasn't found in its catalogue search: open the page once in this browser`); const want = Number(r.trackCount) || 0, tracks = new Map(); for (const q of [`${(r.artist || {}).name || a} ${r.title}`, r.title, `${a} ${t}`]) { if (want && tracks.size >= want) break; try { const j = await this.api('track/search', { q, country: at, pageSize: '100' }); for (const x of (j.searchResults || {}).searchResult || []) if (x.track && x.track.release && String(x.track.release.id) === String(releaseId)) tracks.set(String(x.track.id), x.track); } catch (e) { Log.warn(`7digital: track search "${q}" failed: ${e.message}`); } progress && progress(tracks.size, want || tracks.size); } const list = [...tracks.values()].sort((x, y) => (x.discNumber || 1) - (y.discNumber || 1) || (x.trackNumber || x.number || 0) - (y.trackNumber || y.number || 0)); Log.warn(`7digital: the page asked to verify you're human; read from the API's search instead: "${r.title}" by ${(r.artist || {}).name} · ${list.length} of ${want || '?'} track(s)`); return { title: r.title || t, artist: (r.artist || {}).name || a, artistUrl: r.artist && r.artist.slug ? `${new URL(url).origin}/artist/${r.artist.slug}` : null, pageLabel: (r.label || {}).name || '', rows: list.map(x => ({ id: String(x.id), title: x.title || '', lengthMs: x.duration ? x.duration * 1000 : null })), apiRel: r, apiTracks: list, }; }, async fetchRelease(path, progress, doc) { doc = doc || document; const og = (doc.querySelector('meta[property="og:url"]') || {}).content || ''; const url = (/7digital\.com\/artist\//.test(og) ? og : originOf(doc) + path).replace(/[?#].*$/, '').replace(/\/$/, ''); const sub = (new URL(url).hostname.match(/^([a-z]{2})\./) || [])[1]; const country = sub === 'uk' || !sub ? 'GB' : sub.toUpperCase(); const info = doc.querySelector('.release-info'); const releaseId = (info && info.dataset.releaseid) || (url.match(/(\d{3,})$/) || [])[1]; let title, artistA, artist, artistUrl, pageLabel, pageDate, plines, all, extras, rows, apiRel = null, apiTracks = []; if (doc.fcRefused) { // #747: Mission Control's read, refused by 7digital's captcha: the release and its tracks from the API's search ({ title, artist, artistUrl, pageLabel, rows, apiRel, apiTracks } = await this.fromApi(url, country, releaseId, progress)); pageDate = ''; plines = []; } else { title = this.text(doc.querySelector('.release-info-title')); if (!title) throw new Error('7digital: no release on this page (is it asking you to prove you\'re human?)'); artistA = doc.querySelector('.release-info-artist a'); artist = this.text(artistA) || (doc.querySelector('.release-info-artist [itemprop="name"]') || {}).content || ''; artistUrl = artistA ? new URL(artistA.getAttribute('href'), url).href : null; pageLabel = this.text(doc.querySelector('.release-label-info .release-data-info')); pageDate = this.text(doc.querySelector('.release-date-info .release-data-info')); // majkinetor: "It doesnt get annotation <dd class="release-data-info">℗ 2014 Asylum Records UK, a Warner // Music UK Company</dd>". Below the tracklist, the ℗ and © lines are the page's only notes: they go // to the annotation whole, as Amazon Music's ℗ line does plines = [...doc.querySelectorAll('.release-data-info')].map(el => this.text(el)).filter(t => /^(?:[℗©]|\([pc]\))/i.test(t)); // majkinetor: "reads PDF as track": a digital booklet is a row of the tracklist too, marked with // its file type (<em class="release-track-file-type is-pdf">); a song's row has none all = [...doc.querySelectorAll('tr.release-track[data-trackid]')]; extras = all.filter(tr => { const ft = tr.querySelector('.release-track-file-type'); return ft && !/\baudio\b/i.test(ft.className + ' ' + ft.textContent); }); if (extras.length) Log.info(`7digital: left out ${extras.map(tr => `"${this.text(tr.querySelector('.release-track-name p'))}" (${this.text(tr.querySelector('.release-track-file-type'))})`).join(', ')}: not a track`); rows = all.filter(tr => !extras.includes(tr)).map(tr => ({ id: tr.dataset.trackid, title: this.text(tr.querySelector('.release-track-name p')) || (tr.querySelector('.release-track-name [itemprop="name"]') || {}).content || '', lengthMs: this.iso((tr.querySelector('[itemprop="duration"]') || {}).content), })); } const various = /^various artists$/i.test(artist); Log.info(`7digital release ${releaseId} (${country}): "${title}" by ${artist} · ${rows.length} track(s) · ${pageDate || 'no date'} · label "${pageLabel}" · ${plines.join(' · ') || 'no ℗ line'}`); // the release: its barcode, type and date (found by its title, as the store's key can't read it by id). // 7digital's search can take 10 s or more, so it runs alongside the tracks' searches below. const relP = apiRel ? Promise.resolve(apiRel) : (async () => { for (const c of [...new Set([country, 'GB', 'US'])]) { for (const q of [title, `${artist} ${title}`]) { try { const j = await this.api('release/search', { q, country: c, pageSize: '50' }); const r = ((j.searchResults || {}).searchResult || []).map(x => x.release).find(x => x && String(x.id) === String(releaseId)); if (r) return r; } catch (e) { Log.warn(`7digital: release search "${q}" (${c}) failed: ${e.message}`); } } } return null; })(); // the tracks: each by its title with the album's (often the whole album comes back for one), after its // artist, or alone; a hit fills every row it is const hits = new Map(apiTracks.map(t => [String(t.id), t])); const search = async (q, c) => { try { const j = await this.api('track/search', { q, country: c, pageSize: '50' }); for (const x of (j.searchResults || {}).searchResult || []) if (x.track && x.track.release && String(x.track.release.id) === String(releaseId)) hits.set(String(x.track.id), x.track); } catch (e) { Log.warn(`7digital: track search "${q}" failed: ${e.message}`); } }; // the count is of tracks found: one search often brings up the whole album at once const found = () => rows.filter(r => hits.has(r.id)).length; // the store's country first; a release its catalogue doesn't have (a UK compilation on the US // store) is looked for in the UK's and the US's for (const c of [...new Set([country, 'GB', 'US'])]) { await mapLimit(rows, 6, async row => { for (const q of [`${row.title} ${title}`, various ? null : `${artist} ${row.title}`, row.title]) { if (hits.has(row.id)) break; if (q) { await search(q, c); progress && progress(found(), rows.length); } } }); if (found()) break; Log.info(`7digital: none of the tracks is in the ${c} catalogue`); } const rel = await relP; if (rel) Log.info(`7digital API: type ${rel.type} · UPC ${rel.barcode || '—'} · ${(rel.download || {}).releaseDate || 'no date'} · label "${(rel.label || {}).name || ''}"`); else Log.warn('7digital: the release wasn\'t found in the API: no barcode, and the type is guessed'); Log.info(`7digital API: ${rows.filter(r => hits.has(r.id)).length} of ${rows.length} track(s) found, ${rows.filter(r => (hits.get(r.id) || {}).isrc).length} with an ISRC`); const storeArtist = a => (a && a.slug ? `${new URL(url).origin}/artist/${a.slug}` : null); const mediums = []; let disc = 1; rows.forEach((row, i) => { const t = hits.get(row.id); if (t && t.discNumber) disc = t.discNumber; const tf = splitFeat((t && t.title) || row.title); // the track's artist line: "A, B & C", "A x B", "A feat. B" ("The Blessed Madonna feat. Clementine Douglas") const lf = splitFeat(t ? (t.artist || {}).name : (various ? '' : artist)); const names = this.names(lf.title, artist); // the release artist links their page; a track's own 7digital artist is one entry for its whole // line, so it links only an artist who is the whole line const contributors = names.map(name => ({ name, url: normName(name) === normName(artist) ? artistUrl : names.length === 1 && !lf.feat.length && t ? storeArtist(t.artist) : null })); const credit = creditFromTitle(contributors.length ? contributors : [{ name: artist, url: artistUrl }], lf.feat.concat(tf.feat.filter(n => !lf.feat.some(f => normName(f) === normName(n))))); while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title: tf.title, lengthMs: row.lengthMs || (t && t.duration ? t.duration * 1000 : null), isrc: (t && t.isrc) || null, url: null, credit }); Log.debug(`track ${disc}.${t ? t.number : '?'} (${i + 1}): "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${(t && t.isrc) || 'no ISRC'})`); }); const empty = mediums.findIndex(m => !m.tracks.length); if (empty >= 0) { Log.warn(`7digital: disc ${empty + 1} has no tracks; the tracklist is one medium`); mediums.splice(0, mediums.length, { format: 'Digital Media', name: '', tracks: [].concat(...mediums.map(m => m.tracks)) }); } const af = splitFeat(title); const credit = various ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(this.names(artist, artist).map(name => ({ name, url: artistUrl })), af.feat); // the release date is the store's local midnight in UTC ("2020-07-22T23:00:00Z" is 23 July in the UK): // the day 12 hours on is the local day, wherever the store is const at = Date.parse(((rel || {}).download || {}).releaseDate || ''); let [y, m, d] = Number.isFinite(at) ? new Date(at + 12 * 3600e3).toISOString().slice(0, 10).split('-').map(n => parseInt(n, 10)) : []; if (!y && sub === 'uk') [d, m, y] = pageDate.split('/').map(n => parseInt(n, 10)); // the UK store writes 23/07/2020 // 7digital stores some barcodes as 14 digits (00602508436901): one leading zero too many for an EAN const bc = String((rel || {}).barcode || '').replace(/\D/g, '').replace(/^0(?=\d{13}$)/, ''); const label = pageLabel || ((rel || {}).label || {}).name || labelFromCopyright(plines[0]) || ''; return { source: this.id, annotation: notesText(plines.join('\n')), url, title: af.title, credit, types: (rel && this.TYPES[rel.type]) || [], status: 'official', packaging: 'None', date: { year: y || (rel && rel.year) || null, month: m || null, day: d || null }, country: 'XW', barcode: /^\d{12,13}$/.test(bc) ? bc : null, labels: label ? [{ name: label, catno: '' }] : [], urls: [{ url, linkType: 74 }], // 74 = purchase for download, the only type MusicBrainz allows for 7digital mediums, }; }, }; // Ototoy (#670): a Japanese download store. Everything is on the album page, server-rendered: the title, // the album artist with their page, "DISC n" rows, each track's title, length and the artists Ototoy links // for it, the release dates, the label with its page, and the album info. No barcode, no ISRCs, no type. // A track's linked artists are all of Ototoy's for it: on some compilations that includes the arranger or // the label (IMAGINATION vol.1: ときのそら and Yunomi), which can't be told from a co-singer (three on // にじさんじ's "3倍!Sun Shine!カーニバル!"), so all are kept, and the title's "(feat. …)" makes the // featured ones. Hi-res and CD-quality editions are separate pages; older titles carry their format. const OTOTOY = { id: 'ototoy', name: 'Ototoy', abbr: 'oto', host: /^ototoy\.jp$/, albumId(loc) { const m = loc.pathname.match(/^\/_\/default\/p\/(\d+)\/?$/); return m ? m[1] : null; }, text: el => (el ? el.textContent.replace(/\s+/g, ' ').trim() : ''), // "04:28" / "1:02:03" → ms hms(t) { const p = String(t || '').trim().split(':').map(Number); return p.length > 1 && p.every(Number.isFinite) ? p.reduce((a, n) => a * 60 + n, 0) * 1000 : null; }, // "Live Archives Disc2(24bit/44.1kHz)", "… (dsd+mp3)": the format the page sells, not the title FORMAT: /\s*[((][^()()]*(?:\d+\s*bit|khz|\bdsd\b|\bmp3\b|\bflac\b|\bwav\b|\balac\b|\baac\b|hi-?res|ハイレゾ)[^()()]*[))]\s*$/i, artist(a) { return { name: this.text(a), url: a.getAttribute('href') ? new URL(a.getAttribute('href'), 'https://ototoy.jp/').href.replace(/[?#].*$/, '') : null }; }, async fetchRelease(id, progress, doc) { doc = doc || document; const rawTitle = this.text(doc.querySelector('h1.album-title')); if (!rawTitle) throw new Error('Ototoy: no album on this page'); const title = rawTitle.replace(this.FORMAT, ''); const albumArtists = [...doc.querySelectorAll('p.album-artist a[href*="/_/default/a/"]')].map(a => this.artist(a)); const detail = [...doc.querySelectorAll('.album-meta-data .detail p')].map(p => this.text(p)); const field = re => { const l = detail.find(x => re.test(x)); return l ? l.replace(re, '').trim() : ''; }; const released = field(/^Release date:\s*/i), original = field(/^Original release date:\s*/i), catno = field(/^Catalog number:\s*/i); const labelA = doc.querySelector('.album-meta-data .detail p.label-name a'); const label = this.text(labelA); Log.info(`Ototoy album ${id}: "${rawTitle}"${title !== rawTitle ? ` (title "${title}")` : ''} by ${albumArtists.map(a => a.name).join(', ') || '—'} · ${detail.join(' · ')}`); if (catno) Log.info(`Ototoy: catalog number ${catno} left out: Ototoy shows the CD's or a distributor's code as often as the release's`); const various = albumArtists.length === 1 && /^(?:various artists|v\.?\s*a\.?)$/i.test(albumArtists[0].name); const mediums = []; let disc = 0; const rows = [...doc.querySelectorAll('#tracklist tr')]; const nTracks = rows.filter(tr => tr.querySelector('[id^="title-"]')).length; let n = 0; for (const tr of rows) { if (tr.classList.contains('disc-row')) { disc = mediums.push({ format: 'Digital Media', name: '', tracks: [] }); continue; } const t = tr.querySelector('[id^="title-"]'); if (!t) continue; if (!mediums.length) disc = mediums.push({ format: 'Digital Media', name: '', tracks: [] }); const tf = splitFeat(this.text(t)); const linked = [...tr.querySelectorAll('a.artist[href*="/_/default/a/"]')].map(a => this.artist(a)); const credit = creditFromTitle(linked.length ? linked : (various ? [] : albumArtists), tf.feat); const len = [...tr.querySelectorAll('td.item.center')].map(td => this.text(td)).find(x => /^\d+(?::\d\d){1,2}$/.test(x)); mediums[disc - 1].tracks.push({ title: tf.title, lengthMs: this.hms(len), isrc: null, url: null, credit }); progress && progress(++n, nTracks); Log.debug(`track ${disc}.${mediums[disc - 1].tracks.length}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('') || '(no artist)'}`); } const emptyDisc = mediums.findIndex(m => !m.tracks.length); if (emptyDisc >= 0) mediums.splice(emptyDisc, 1); const af = splitFeat(title); const credit = various || !albumArtists.length ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(albumArtists, af.feat); // the original release date is the release's; "Release date" can be the day Ototoy listed it const [y, m, d] = (original || released).split('-').map(x => parseInt(x, 10)); const url = `https://ototoy.jp/_/default/p/${id}`; return { source: this.id, annotation: notesText((doc.querySelector('.album-addendum .album-review') || {}).innerHTML), // the album info url, title: af.title, credit, types: [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'JP', // Ototoy sells in Japan only barcode: null, labels: label ? [{ name: label, catno: '', url: labelA.getAttribute('href') ? new URL(labelA.getAttribute('href'), 'https://ototoy.jp/').href : null }] : [], urls: [{ url, linkType: 74 }], // 74 = purchase for download, the only type MusicBrainz allows for Ototoy mediums, }; }, }; const PROVIDERS = [DEEZER, BANDCAMP, DISCOGS, APPLE, TIDAL, QOBUZ, BEATPORT, SPOTIFY, YTMUSIC, VOLUMO, HDTRACKS, SOUNDCLOUD, AMAZON, AUDIOMACK, SEVENDIGITAL, OTOTOY]; /* ── Harmony (#687): the album sent to Harmony's release lookup instead of the editor ── */ const HARMONY = 'https://harmony.pulsewidth.org.uk/release'; // The album link in the form Harmony reads, for the platforms Harmony looks up by URL; Harmony then // finds the others by the barcode it reads there. Checked against Harmony's lookup (each form is // recognised; Beatport's slug can be '-', as Harmony itself writes it). const HARMONY_URLS = { deezer: id => `https://www.deezer.com/album/${id}`, bandcamp: id => `https://${location.hostname}${id}`, discogs: id => `https://www.discogs.com/release/${id}`, apple: id => { const [cc, n] = id.split('/'); return `https://music.apple.com/${cc}/album/${n}`; }, tidal: id => `https://tidal.com/album/${id}`, qobuz: id => `https://www.qobuz.com${id}`, beatport: id => `https://www.beatport.com/release/-/${id}`, spotify: id => `https://open.spotify.com/album/${id}`, ototoy: id => `https://ototoy.jp/_/default/p/${id}`, }; // Platforms Harmony doesn't know and whose albums never carry a barcode: nothing to send const HARMONY_NONE = new Set(['ytmusic', 'amazonmusic']); // spoonkuh: "it does not follow my harmony settings". A link names its providers itself, and one that // names none looks the album up on its own platform only (a barcode alone, on none). category=preferred // is the providers ticked in Harmony's settings (its cookies), and with no region in the link Harmony // takes the settings' region too; Apple's link carries its own country store. function harmonyLookup({ url, gtin }) { const q = new URLSearchParams(); if (url) q.set('url', url); if (gtin) q.set('gtin', gtin); q.set('category', 'preferred'); return `${HARMONY}?${q}`; } // Harmony's own mark, so the button says where it goes const HARMONY_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="18" height="18" aria-hidden="true">' + '<defs><linearGradient id="fc-harmony-g" x1="-7.71" y1="30.11" x2="45.71" y2="-.24" gradientUnits="userSpaceOnUse"><stop offset=".29" stop-color="#ffb92c"/><stop offset="1" stop-color="#c45555"/></linearGradient></defs>' + '<path fill="url(#fc-harmony-g)" d="M17.13,0l-6.27,3.6v6.56c.74-.3,1.53-.45,2.34-.45,3.47,0,6.29,2.82,6.29,6.29s-2.82,6.29-6.29,6.29c-.81,0-1.6-.15-2.34-.45v6.56l6.27,3.6,13.63-7.82V7.82L17.13,0Z"/>' + '<path fill="#c45555" d="M13.2,11.7c-.85,0-1.65.25-2.32.68-.05,0-.1,0-.15,0-.39,0-.78-.08-1.1-.3-.48-.32-.69-1.03-.76-1.76V3.45L1.24,7.82v16.36l7.62,4.37v-6.88c.08-.73.29-1.44.76-1.76.33-.22.72-.3,1.1-.3.05,0,.11,0,.16,0,.67.43,1.46.68,2.31.68,2.37,0,4.29-1.92,4.29-4.29s-1.92-4.29-4.29-4.29Z"/></svg>'; /* ── the seed: model → the release editor's POST parameters ──────────────── */ // https://musicbrainz.org/doc/Development/Release_Editor_Seeding function seedParams(rel, editNote) { const p = []; const add = (k, v) => { if (v !== null && v !== undefined && v !== '') p.push([k, String(v)]); }; const credit = (prefix, c) => (c || []).forEach((a, i) => { add(`${prefix}artist_credit.names.${i}.name`, a.name); add(`${prefix}artist_credit.names.${i}.artist.name`, a.artistName || a.name); add(`${prefix}artist_credit.names.${i}.mbid`, a.mbid); add(`${prefix}artist_credit.names.${i}.join_phrase`, a.join); }); add('name', rel.title); credit('', rel.credit); (rel.types || []).forEach(t => add('type', t)); add('status', rel.status); add('packaging', rel.packaging); add('script', rel.script); add('barcode', rel.barcode); if (rel.date && (rel.date.year || rel.country)) { add('events.0.date.year', rel.date.year); add('events.0.date.month', rel.date.month); add('events.0.date.day', rel.date.day); add('events.0.country', rel.country); } (rel.labels || []).forEach((l, i) => { add(`labels.${i}.name`, l.name); add(`labels.${i}.mbid`, l.mbid); add(`labels.${i}.catalog_number`, l.catno); }); (rel.urls || []).forEach((u, i) => { add(`urls.${i}.url`, u.url); add(`urls.${i}.link_type`, u.linkType); }); rel.mediums.forEach((m, i) => { add(`mediums.${i}.format`, m.format); add(`mediums.${i}.name`, m.name); m.tracks.forEach((t, j) => { const pre = `mediums.${i}.track.${j}.`; add(pre + 'name', t.title); add(pre + 'number', t.number || j + 1); add(pre + 'length', t.lengthMs); credit(pre, t.credit); }); }); add('annotation', rel.annotation); add('edit_note', editNote); return p; } // #684: the album has tracks the platform didn't give (Apple: not offered in the storefront) function missingText(rel, provider) { const m = rel.missing; return `${provider.name} lists ${m.of} tracks but offers ${m.of - m.count}: ${m.count} ${m.count === 1 ? 'is' : 'are'} missing${m.at.length ? ` (${m.at.join(', ')}), left as empty tracks to fill in` : ' from this tracklist'}.`; } // #739 (majkinetor): no archive lines: the snapshots are found by Apollo's archive menu by each link function editNoteFor(rel, provider) { const lines = [`Imported from ${provider.name}: ${rel.url}`]; if (rel.missing) lines.push(missingText(rel, provider)); // #743: lengths Bandcamp hides until the release, from its app data if (rel.hiddenLengths) lines.push(`${rel.hiddenLengths} track length(s) are from ${provider.name}'s app data: its page hides them until the album is out, and they can still change.`); return lines.join('\n') + `\n\n${NAME} v${VERSION} by majkinetor - ${HOMEPAGE}`; } /* ── #659: Internet Archive ──────────────────────────────────────────────── */ // The release editor's tab sends the save requests (the platform tab may close right after the // import). Saving and the archive.org keys are the shared <ST-ARCHIVE> block, with Apollo Editor (#736); whether FC archives is its own setting. // What to archive for this import, or null when archiving is off. The page as the user has it, // not the canonical URL: that one often redirects (Deezer's /album/<id> to a country's // /xx/album/<id>), and Wayback then stores the snapshot under the target, so a link to the // canonical URL by the import's time finds an older snapshot instead of this one. function archivePlan(rel, provider, id) { const s = settings(); if (!s.archive) { Log.info('archive: off in the settings'); return null; } const urls = provider.archivePage === false ? [] : [{ url: mbaCleanUrl(location.href), what: 'page' }]; if (provider.archiveApi) urls.push({ url: provider.archiveApi(id), what: 'api' }); const keys = !!mbaKeys(); Log.info(`archive: ${urls.map(u => u.what + ' ' + u.url).join(', ')} (${keys ? 'Save Page Now 2 with your keys, with a screenshot' : 'anonymous Save Page Now'}); the release editor's tab sends it`); return { ts: mbaTs(new Date()), keys, urls }; } // Send this import's URLs, once per import (a reload of the editor tab doesn't send them again). async function archiveSources(plan, token) { const done = 'fc.archived.' + token; try { if (sessionStorage.getItem(done)) { Log.debug(`archive: already sent for ${token} (this tab was reloaded)`); return; } sessionStorage.setItem(done, '1'); } catch (e) { /* no sessionStorage: send anyway */ } Log.info(`archive: sending ${plan.urls.length} URL(s) to the Internet Archive (${mbaKeys() ? 'Save Page Now 2 with your keys' : 'anonymous'})`); for (const a of plan.urls) await mbaSave(a.url, Log, true); // one at a time: anonymous saves are limited per IP } // What Apollo needs to match: every credited artist with its platform link, by position, and // what it needs to know about the platform (#672), so a new provider needs no change in Apollo: // platform: { abbr, name, artistLinkType? } the badge, the name in logs and edit notes, and the // artist link type where MB can't pick one itself // urlForms: [url, ...] on a link MB may store in other forms: all of them function handoffFor(rel, provider, token) { const forms = url => { if (!url || !provider.urlForms) return undefined; const f = [...new Set([url, ...provider.urlForms(url)])]; return f.length > 1 ? f : undefined; }; const ac = c => (c || []).map(a => ({ name: a.name, artistName: a.artistName || a.name, join: a.join, url: a.url || null, urlForms: forms(a.url), mbid: a.mbid || null })); return { v: 2, token, created: Date.now(), source: provider.id, sourceName: provider.name, platform: { abbr: provider.abbr, name: provider.name, artistLinkType: provider.artistLinkType }, url: rel.url, title: rel.title, barcode: rel.barcode, credit: ac(rel.credit), labels: (rel.labels || []).map(l => ({ name: l.name, catno: l.catno, url: l.url || null, urlForms: forms(l.url), mbid: l.mbid || null })), mediums: rel.mediums.map(m => ({ tracks: m.tracks.map(t => ({ title: t.title, isrc: t.isrc, url: t.url, credit: ac(t.credit) })) })), }; } function pruneHandoffs() { try { for (const k of GM_listValues()) { if (!k.startsWith(HANDOFF_PREFIX)) continue; const h = GM_getValue(k, null); if (!h || !h.created || Date.now() - h.created > HANDOFF_TTL_MS) { GM_deleteValue(k); Log.debug(`pruned old handoff ${k}`); } } } catch (e) { Log.warn(`handoff prune: ${e.message}`); } } /* ── platform side: the button ───────────────────────────────────────────── */ let current = null; // { provider, id } let busy = false; function injectStyle() { if (document.getElementById('fc-style')) return; const st = document.createElement('style'); st.id = 'fc-style'; // On a platform's page a userstyle's --background/--text/--border are the platform's own, // not a MusicBrainz theme: take our defaults there. const css = MBU_TOKENS, tokens = ON_MB ? css : css.replace(/var\(--(?:background|text|border), ([^)]+)\)/g, '$1'); st.textContent = tokens + MBU_UI_CSS + ` #fc-root { position: fixed; z-index: 2147483000; display: flex; gap: 0; font: 13px/1.3 system-ui, sans-serif; box-shadow: var(--mbu-shadow, 0 2px 10px rgba(0,0,0,.25)); border-radius: 8px; } #fc-root button { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; background: var(--mbu-bg); color: var(--mbu-text); border: 1px solid var(--mbu-border); } #fc-root button:hover { background: var(--mbu-bg-hover); } #fc-root button:focus-visible { outline: 2px solid var(--mbu-accent); outline-offset: -2px; } #fc-root .fc-go { border-radius: 8px 0 0 8px; font-weight: 600; } #fc-root .fc-more { border-radius: 0 8px 8px 0; border-left: none; min-width: 30px; justify-content: center; padding: 8px 10px; } #fc-root .fc-go[aria-busy="true"] { cursor: progress; } /* #687: Send to Harmony, between Import and ⚙︎ */ #fc-root .fc-harmony { border-left: none; padding: 8px 9px; justify-content: center; } #fc-root .fc-harmony[hidden] { display: none; } #fc-root .fc-harmony[aria-disabled="true"] { cursor: default; opacity: .45; } #fc-root .fc-harmony[aria-disabled="true"]:hover { background: var(--mbu-bg); } #fc-root .fc-harmony[aria-busy="true"] { cursor: progress; } #fc-root .fc-harmony svg { display: block; } /* #702: Consolidate, beside Import */ #fc-root .fc-cons { border-left: none; padding: 8px 9px; justify-content: center; } #fc-root .fc-cons[hidden] { display: none; } #fc-root .fc-cons[aria-busy="true"] { cursor: progress; } #fc-root .fc-cons svg { display: block; } /* majkinetor: "after the click nothing happens … make it obvious it works": a spinner while it reads */ #fc-root .fc-go[aria-busy="true"]::after { content: ''; width: 14px; height: 14px; flex: 0 0 auto; box-sizing: border-box; border: 2px solid var(--mbu-border); border-top-color: var(--mbu-accent-text); border-right-color: var(--mbu-accent-text); border-radius: 50%; animation: fc-spin .8s linear infinite; } @keyframes fc-spin { to { transform: rotate(360deg); } } #fc-root .fc-go[aria-busy="true"] span { font-variant-numeric: tabular-nums; } #fc-root { touch-action: none; animation: fc-fadein .4s ease; } /* fades in when it appears, as Mammoth's pins do */ @keyframes fc-fadein { from { opacity: 0; } } #fc-root.fc-settling { opacity: 0 !important; pointer-events: none !important; } #fc-root.fc-fadein { transition: opacity .4s ease; } #fc-root.fc-dragging, #fc-root.fc-dragging button { cursor: grabbing; } /* the readable accent (as Log's): --mbu-accent alone is a deep purple that vanishes on the dark panel */ #fc-panel .fc-reset { justify-self: start; color: var(--mbu-text-weak); } #fc-panel .fc-reset button { all: unset; cursor: pointer; color: var(--mbu-accent-text); text-decoration: underline; padding: 0 2px; } #fc-panel .fc-reset button:hover { color: var(--mbu-text); } #fc-panel .fc-reset button:focus-visible { outline: 2px solid var(--mbu-accent); outline-offset: 1px; } #fc-root.fc-iconly .fc-go span { display: none; } #fc-root.fc-iconly .fc-go[aria-busy="true"] span { display: inline; } /* the progress still shows while it reads */ /* majkinetor: the settings on a right-click of any button; ⚙︎ can then go. The last button left takes the round corner */ #fc-root.fc-gear-hidden .fc-more { display: none; } #fc-root.fc-gear-hidden:has(.fc-harmony[hidden]):has(.fc-cons[hidden]) .fc-go { border-radius: 8px; } #fc-root.fc-gear-hidden .fc-harmony { border-radius: 0 8px 8px 0; } #fc-root.fc-gear-hidden:has(.fc-harmony[hidden]) .fc-cons { border-radius: 0 8px 8px 0; } #fc-panel { position: fixed; z-index: 2147483001; box-sizing: border-box; width: max-content; max-width: calc(100vw - 16px); overflow: auto; overscroll-behavior: contain; background: var(--mbu-bg); color: var(--mbu-text); border: 1px solid var(--mbu-border); border-radius: 8px; box-shadow: var(--mbu-shadow, 0 4px 18px rgba(0,0,0,.3)); font: 12px/1.3 system-ui, sans-serif; padding: 10px 12px; } #fc-panel .mbu-cfg-h { margin: 0 0 9px; padding: 0 0 8px; font-size: 13px; gap: 6px; } #fc-panel .mbu-cfg-ic, #fc-panel .mbu-cfg-ic svg { width: 18px; height: 18px; } #fc-panel .fc-body { display: grid; gap: 7px; } #fc-panel .fc-reset { margin-top: 2px; } /* the sections (Import, Archive, Button), headed as Platform Check's settings are */ #fc-panel .fc-sec { font-weight: 700; color: var(--mbu-text); font-size: 11px; letter-spacing: .05em; text-transform: uppercase; margin: 8px 0 0; padding-bottom: 3px; border-bottom: 1px solid var(--mbu-divider, var(--mbu-border-soft)); } #fc-panel .fc-sec:first-child { margin-top: 0; } /* the whole margin, not just the indent: qobuz.com gives every label an 8px bottom margin, which spread the rows apart */ #fc-panel .fc-body > :not(.fc-sec) { margin: 0 0 0 8px; } #fc-panel .fc-pos { color: var(--mbu-text); margin-right: 14px; } #fc-panel label { display: flex; align-items: center; justify-content: space-between; gap: 10px; } #fc-panel label.fc-check { justify-content: flex-start; gap: 6px; cursor: pointer; } /* #659: the archive.org keys (the shared block's mbaKeysCss), under the archive checkbox */ #fc-panel .fc-body > .mbu-ia { margin: -2px 0 0 27px; } /* the browser's own checkbox, whatever the site's sheet says: qobuz.com's sets appearance:none in a rule only Firefox applies, and our checkboxes drew as nothing there (#650) */ #fc-panel input[type=checkbox] { all: revert; margin: 0; accent-color: var(--mbu-accent); cursor: pointer; } /* native controls follow our theme, not the page's: qobuz.com declares color-scheme dark on a light page */ #fc-root, #fc-panel { color-scheme: light; } :root[data-mbu-theme=dark] :is(#fc-root, #fc-panel) { color-scheme: dark; } /* appearance: qobuz.com sets none on every select, which dropped the server list's arrow */ #fc-panel select { appearance: auto; font: inherit; color: inherit; background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 4px; padding: 2px 4px; } /* #684: tracks the platform didn't give: asked before the editor opens */ #fc-warn { position: fixed; right: 16px; bottom: 16px; z-index: 2147483002; box-sizing: border-box; max-width: min(380px, calc(100vw - 16px)); background: var(--mbu-bg); color: var(--mbu-text); border: 1px solid var(--mbu-warn); border-radius: 8px; box-shadow: var(--mbu-shadow, 0 4px 18px rgba(0,0,0,.3)); font: 12.5px/1.4 system-ui, sans-serif; padding: 10px 12px; color-scheme: light; } :root[data-mbu-theme=dark] #fc-warn { color-scheme: dark; } #fc-warn .fc-warn-h { font-weight: 700; color: var(--mbu-warn); margin: 0 0 6px; } #fc-warn p { margin: 0 0 8px; } #fc-warn .fc-warn-b { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } #fc-warn button { all: revert; font: inherit; cursor: pointer; padding: 4px 12px; border-radius: 5px; border: 1px solid var(--mbu-border); background: var(--mbu-bg); color: var(--mbu-text); } #fc-warn button.fc-warn-go { background: var(--mbu-accent); border-color: var(--mbu-accent); color: #fff; } /* links (? Help, get them) in the readable accent: --mbu-accent is the deep purple that vanishes on the dark panel */ #fc-panel a { color: var(--mbu-accent-text); } ` + mbaKeysCss('#fc-panel'); (document.head || document.documentElement).appendChild(st); } function mountButton(provider, id) { injectStyle(); let root = document.getElementById('fc-root'); if (!root) { root = document.createElement('div'); root.id = 'fc-root'; root.dataset.mbCorner = 'br'; root.dataset.mbCornerOrder = '30'; const go = document.createElement('button'); go.type = 'button'; go.className = 'fc-go'; go.innerHTML = mbuHtml(ICON_SVG + '<span>Import to MusicBrainz</span>'); go.addEventListener('click', () => { importCurrent(); }); const more = document.createElement('button'); more.type = 'button'; more.className = 'fc-more'; more.textContent = MBU_CFG_ICON; more.title = 'Settings'; more.setAttribute('aria-label', more.title); more.addEventListener('click', e => { e.stopPropagation(); togglePanel(more); }); const cons = document.createElement('button'); cons.type = 'button'; cons.className = 'fc-cons'; cons.innerHTML = mbuHtml(MC_SVG); cons.addEventListener('click', () => { consolidateCurrent(); }); const harmony = document.createElement('button'); harmony.type = 'button'; harmony.className = 'fc-harmony'; harmony.innerHTML = mbuHtml(HARMONY_SVG); harmony.addEventListener('click', () => { sendToHarmony(); }); root.append(go, cons, harmony, more); document.body.appendChild(root); makeMovable(root); // majkinetor: right-click any of the buttons for the settings; a right-click while they are open // closes them. That close comes on the press, before the panel's own close-on-a-press-outside, // which would then swallow the next click (a right-click makes none) let wasOpen = false; root.addEventListener('pointerdown', e => { if (e.button !== 2) return; wasOpen = !!document.getElementById('fc-panel'); if (wasOpen) closePanel(); }); root.addEventListener('contextmenu', e => { e.preventDefault(); if (wasOpen) { wasOpen = false; return; } if (!document.getElementById('fc-panel')) togglePanel(more); }); window.addEventListener('resize', () => placeButton(root)); } root.dataset.fcProvider = provider.name; root.classList.toggle('fc-iconly', !!settings().iconOnly); // majkinetor: an option to hide the button's text root.classList.toggle('fc-gear-hidden', !!settings().gearHidden); // majkinetor: ⚙︎ can go, a right-click opens the settings root.querySelector('.fc-go').title = `Import to MusicBrainz: open the release editor with this ${provider.name} release filled in`; showHarmony(root, provider); showCons(root, provider); root.style.display = ''; migrateScrollSetting(); placeButton(root); const pinned = storedPos(provider.name); if (scrollsWithPage(provider.name) && pinned && pinned.top != null) settleThenShow(root); Log.debug(`button shown for ${provider.name} album ${id}`); } // majkinetor: the button can be dragged anywhere, and each platform remembers where. A moved // button leaves the shared corner stack (data-mb-corner) and keeps its distance from the // window's right and bottom edges, so it stays put as the window resizes; ⚙︎ → Reset position // puts it back in the corner. const POS_KEY = 'fc.pos'; function storedPos(name) { const all = GM_getValue(POS_KEY, {}) || {}; return all[name] || null; } function storePos(name, pos) { const all = Object.assign({}, GM_getValue(POS_KEY, {}) || {}); if (pos) all[name] = pos; else delete all[name]; GM_setValue(POS_KEY, all); } // majkinetor: "changing the fixed position on page only for some platforms … keeping other // platforms independent". Moved button scrolls with the page is each platform's own, kept beside // its position: { platformName: true }. const SCROLL_KEY = 'fc.scroll'; function scrollsWithPage(name) { return !!(GM_getValue(SCROLL_KEY, {}) || {})[name]; } function setScrollsWithPage(name, on) { const all = Object.assign({}, GM_getValue(SCROLL_KEY, {}) || {}); if (on) all[name] = true; else delete all[name]; GM_setValue(SCROLL_KEY, all); } // The setting used to be one for every platform: where it was on, it stays on for each platform // the button had been moved on. function migrateScrollSetting() { const s = GM_getValue('fc.settings', {}) || {}; if (!Object.prototype.hasOwnProperty.call(s, 'scrollWithPage')) return; const on = !!s.scrollWithPage; delete s.scrollWithPage; GM_setValue('fc.settings', s); const moved = Object.keys(GM_getValue(POS_KEY, {}) || {}); if (on && moved.length) { const all = Object.assign({}, GM_getValue(SCROLL_KEY, {}) || {}); for (const n of moved) all[n] = true; GM_setValue(SCROLL_KEY, all); } Log.info(`Moved button scrolls with the page is now per platform: ${on ? `on for ${moved.join(', ') || 'none (no button moved yet)'}` : 'off everywhere, as it was'}`); } // What scrolls the page. Usually the window (Bandcamp, Qobuz); the app-like platforms (Spotify, // Apple Music) keep the window still and scroll a panel inside it instead, and have more than one // (sidebars, queues). The page's panel is the one that scrolls under the middle of the window; // while the app is still drawing there is none (null, as for the window: pos.inner says which). let scrollerLast = null; const scrolls = el => el.scrollHeight > el.clientHeight + 1 && el.clientHeight >= 200 && /^(auto|scroll|overlay)$/.test(getComputedStyle(el).overflowY); function pageScroller() { const se = document.scrollingElement || document.documentElement; if (se.scrollHeight > se.clientHeight + 1) return null; let best = null; const hits = document.elementsFromPoint ? document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2) : []; const start = hits.find(el => !el.closest('#fc-root, #fc-panel')); for (let el = start; el && el !== document.body && el !== document.documentElement; el = el.parentElement) { if (scrolls(el)) { best = el; break; } } if (best !== scrollerLast) Log.debug(`page scrolls in ${best ? `<${best.tagName.toLowerCase()}${best.className ? ' class="' + String(best.className).slice(0, 60) + '"' : ''}> (${best.clientWidth}×${best.clientHeight})` : 'the window (or nothing yet)'}`); scrollerLast = best; return best; } // a spot on the page: across from the centre of what scrolls, down from the top of its content function pagePosOf(r) { const sc = pageScroller(); if (!sc) return { cx: Math.round(r.left + r.width / 2 - document.documentElement.clientWidth / 2), top: Math.round(r.top + window.scrollY) }; const sr = sc.getBoundingClientRect(); return { cx: Math.round(r.left + r.width / 2 - (sr.left + sr.width / 2)), top: Math.round(r.top - sr.top + sc.scrollTop), inner: true }; } // On the page the button is page content: it goes under the site's fixed bars and the popups // that open from them, and over its sticky headers. 50 sits between them on every platform: // Tidal's header and search popup are at 90 and its fullscreen player at 80 (the 100 this was // went over them), Bandcamp Player Enhanced's player is above 100, while Spotify's, Deezer's and // Tidal's sticky section headers are at 1 or 2. Measured 2026-10-02; the page's lowest fixed bar // can't be used instead: Tidal's sticky header at 1 would take the button under the page. const PAGE_Z = 50; // majkinetor: "it goes behind this": Discogs's header is page content too, not a fixed bar // (position relative, z-index 1000, in a shadow root), and a button dropped on it went under it. // So where the button lands, page content that covers it (not a fixed or sticky bar, nor a popup // opening from one) lifts it just above that content, for as long as the page is open. let pageLift = 0; const pageZ = () => String(Math.max(PAGE_Z, pageLift)); function liftIfCovered(root) { if (root.classList.contains('fc-settling') || root.classList.contains('fc-dragging') || root.style.visibility === 'hidden') return; const r = root.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2; if (!r.width || x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return; let hit = document.elementFromPoint(x, y); while (hit && hit.shadowRoot) { const deeper = hit.shadowRoot.elementFromPoint(x, y); if (!deeper || deeper === hit) break; hit = deeper; } if (!hit || root.contains(hit)) return; let z = 0, by = hit; for (let e = hit; e; e = e.parentElement || (e.getRootNode && e.getRootNode().host)) { const cs = getComputedStyle(e); if (cs.position === 'fixed' || cs.position === 'sticky') { Log.debug(`the button is under a ${cs.position} bar (<${e.tagName.toLowerCase()}>, z-index ${cs.zIndex}): it stays under`); return; } const n = parseInt(cs.zIndex, 10); if (cs.position !== 'static' && n >= z) { z = n; by = e; } } if (z + 1 <= Math.max(PAGE_Z, pageLift)) return; pageLift = z + 1; root.style.zIndex = pageZ(); Log.info(`the button sat under the page's <${by.tagName.toLowerCase()} class="${String(by.className).slice(0, 60)}"> (z-index ${z}): lifted to ${pageLift}`); } // In a scrolling panel the button stays fixed and follows the panel's scroll, cut to the part of // the panel that shows, so it slides under the panel's edges (Spotify's top bar and player) as // content does. function placeInScroller(root, pos, sc, r) { const sr = sc.getBoundingClientRect(); const left = Math.round(Math.min(Math.max(sr.left, sr.left + sr.width / 2 + pos.cx - r.width / 2), Math.max(sr.left, sr.right - r.width))); const top = Math.round(sr.top + pos.top - sc.scrollTop); root.style.position = ''; root.style.zIndex = pageZ(); root.style.right = root.style.bottom = ''; root.style.left = left + 'px'; root.style.top = top + 'px'; const cutTop = Math.max(0, sr.top - top), cutBottom = Math.max(0, top + r.height - sr.bottom); root.style.visibility = cutTop + cutBottom >= r.height ? 'hidden' : ''; root.style.clipPath = cutTop || cutBottom ? `inset(${cutTop}px 0 ${cutBottom}px 0)` : ''; liftIfCovered(root); } const waitScroller = { n: 0, t: 0 }; // majkinetor: "fade in after few like Mammoth". A button pinned to the page would chase the page // as it lays out (and on Spotify sit against the window until the panel exists): keep it unseen // until the page goes quiet for 300 ms (1.5 s at most) and its panel is there, then fade it in // already in place. function settleThenShow(root) { if (root.dataset.fcSettling) return; root.dataset.fcSettling = '1'; root.classList.add('fc-settling'); const t0 = Date.now(); let quiet = 0; const done = () => { clearTimeout(quiet); clearTimeout(cap); mo.disconnect(); delete root.dataset.fcSettling; placeButton(root); Log.debug(`page settled after ${Date.now() - t0} ms${root.dataset.fcWaiting ? '; still waiting for its panel' : ''}`); if (!root.dataset.fcWaiting) revealButton(root); }; const mo = new MutationObserver(() => { clearTimeout(quiet); quiet = setTimeout(done, 300); }); mo.observe(document.body, { childList: true, subtree: true }); quiet = setTimeout(done, 300); const cap = setTimeout(done, 1500); } function revealButton(root) { if (!root.classList.contains('fc-settling')) return; root.classList.remove('fc-settling'); if (root.style.position === 'absolute' || root.style.top) liftIfCovered(root); // on the page: now it can be hit-tested root.classList.add('fc-fadein'); setTimeout(() => root.classList.remove('fc-fadein'), 450); // drop the slow transition once faded in } // the panel moves and resizes as the app lays out (sidebars, the window): follow it let watchedScroller = null, scrollerObserver = null; function watchScroller(root, sc) { if (sc === watchedScroller || typeof ResizeObserver === 'undefined') return; if (scrollerObserver) scrollerObserver.disconnect(); watchedScroller = sc; scrollerObserver = new ResizeObserver(() => { if (root.style.display !== 'none' && !root.classList.contains('fc-dragging') && scrollsWithPage(root.dataset.fcProvider)) placeButton(root); }); scrollerObserver.observe(sc); if (sc.firstElementChild) scrollerObserver.observe(sc.firstElementChild); } let scrollFollow = 0; document.addEventListener('scroll', e => { if (e.target === document || scrollFollow) return; scrollFollow = requestAnimationFrame(() => { scrollFollow = 0; const root = document.getElementById('fc-root'); if (root && root.style.display !== 'none' && !root.classList.contains('fc-dragging') && root.dataset.fcProvider && scrollsWithPage(root.dataset.fcProvider)) placeButton(root); }); }, { capture: true, passive: true }); function placeButton(root) { const pos = storedPos(root.dataset.fcProvider); const pageMode = scrollsWithPage(root.dataset.fcProvider) && pos && pos.top != null; if (!pageMode && root.dataset.fcWaiting) { delete root.dataset.fcWaiting; if (!root.dataset.fcSettling) revealButton(root); } if (!pos) { // back from a spot on the page (Reset): drop what placed it there, or it stays put on the page root.style.position = root.style.zIndex = root.style.clipPath = root.style.visibility = root.style.left = root.style.top = ''; root.dataset.mbCorner = 'br'; mbRestackCorner('br'); return; } if (root.dataset.mbCorner) { delete root.dataset.mbCorner; mbRestackCorner('br'); } // the others close the gap const r = root.getBoundingClientRect(); // majkinetor: "keep its position when scrolling" — the button sits on the page (above the // cover, say) and scrolls with it. Stored against the page's horizontal centre, since the // platforms centre their layout: it stays over the same spot when the window is resized. if (pageMode) { const sc = pageScroller(); if (sc) { waitScroller.n = 0; watchScroller(root, sc); placeInScroller(root, pos, sc, r); if (root.dataset.fcWaiting) { delete root.dataset.fcWaiting; if (!root.dataset.fcSettling) revealButton(root); } return; } // The spot is on a panel that isn't drawn yet (Spotify builds it well after the page // loads): stay out of sight and look again, rather than show it against the window. if (pos.inner) { root.dataset.fcWaiting = '1'; root.classList.add('fc-settling'); if (waitScroller.n++ < 80) { clearTimeout(waitScroller.t); waitScroller.t = setTimeout(() => placeButton(root), 250); } else { Log.warn('the panel this button was placed on never showed: the button shows on the screen instead'); delete root.dataset.fcWaiting; storePos(root.dataset.fcProvider, Object.assign({}, pos, { inner: false })); revealButton(root); } if (waitScroller.n === 1) Log.debug('waiting for the scrolling panel before placing the button'); return; } root.style.clipPath = root.style.visibility = ''; const vw = document.documentElement.clientWidth; const left = Math.round(Math.min(Math.max(0, vw / 2 + pos.cx - r.width / 2), Math.max(0, vw - r.width))); const top = Math.max(0, Math.round(pos.top)); root.style.position = 'absolute'; // on the page it is page content: a site's fixed bar goes over it as the page scrolls (PAGE_Z) root.style.zIndex = pageZ(); root.style.right = root.style.bottom = ''; root.style.left = left + 'px'; root.style.top = top + 'px'; // a positioned <body> (or margin) shifts what left/top mean: measure and correct once const got = root.getBoundingClientRect(); root.style.left = Math.round(left + (left - got.left - window.scrollX)) + 'px'; root.style.top = Math.round(top + (top - got.top - window.scrollY)) + 'px'; liftIfCovered(root); return; } root.style.position = ''; root.style.zIndex = root.style.clipPath = root.style.visibility = ''; root.style.right = Math.round(Math.min(Math.max(0, pos.right), Math.max(0, window.innerWidth - r.width))) + 'px'; root.style.bottom = Math.round(Math.min(Math.max(0, pos.bottom), Math.max(0, window.innerHeight - r.height))) + 'px'; root.style.left = root.style.top = ''; } function makeMovable(root) { let drag = null, moved = false; root.addEventListener('pointerdown', e => { if (e.button !== 0) return; const r = root.getBoundingClientRect(); drag = { x: e.clientX, y: e.clientY, right: window.innerWidth - r.right, bottom: window.innerHeight - r.bottom, w: r.width, h: r.height, id: e.pointerId }; moved = false; }); // move and up are watched on the window: the pointer leaves the button as soon as it moves, // and capturing it on pointerdown would retarget the plain click away from Import / ⚙︎ window.addEventListener('pointermove', e => { if (!drag || e.pointerId !== drag.id) return; const dx = e.clientX - drag.x, dy = e.clientY - drag.y; if (!moved) { if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return; // a click, not a drag moved = true; document.documentElement.style.userSelect = 'none'; root.classList.add('fc-dragging'); pageLift = 0; // measured again where it is dropped root.style.position = root.style.zIndex = root.style.clipPath = root.style.visibility = ''; // fixed and on top while it moves root.style.left = root.style.top = ''; if (root.dataset.mbCorner) { delete root.dataset.mbCorner; mbRestackCorner('br'); } closePanel(); } root.style.right = Math.round(Math.min(Math.max(0, drag.right - dx), window.innerWidth - drag.w)) + 'px'; root.style.bottom = Math.round(Math.min(Math.max(0, drag.bottom - dy), window.innerHeight - drag.h)) + 'px'; }); const end = e => { if (!drag || e.pointerId !== drag.id) return; drag = null; if (!moved) return; root.classList.remove('fc-dragging'); document.documentElement.style.userSelect = ''; // both ways of keeping it: on the screen, and on the page (Scroll with the page) const r = root.getBoundingClientRect(); const pos = { right: parseInt(root.style.right, 10) || 0, bottom: parseInt(root.style.bottom, 10) || 0, ...pagePosOf(r) }; storePos(root.dataset.fcProvider, pos); placeButton(root); Log.info(`button moved on ${root.dataset.fcProvider}: on the screen ${pos.right}px from the right, ${pos.bottom}px from the bottom; on the page ${pos.cx}px from the centre, ${pos.top}px from the top`); }; window.addEventListener('pointerup', end, true); window.addEventListener('pointercancel', end, true); // the click that ends a drag is not an import (or a settings toggle) root.addEventListener('click', e => { if (moved) { moved = false; e.stopPropagation(); e.preventDefault(); } }, true); } function unmountButton() { const root = document.getElementById('fc-root'); if (root && root.style.display !== 'none') { root.style.display = 'none'; mbRestackCorner('br'); } closePanel(); } // Next to the whole button (not ⚙︎, which may be a tab above it): above it, or below when there's // more room there, right edges lined up, and always inside the window — the button can be // dragged anywhere, so the panel can't assume the corner. Too tall for either side: it scrolls. // #684: an album with tracks the platform didn't give asks before the editor opens: the log line, // the edit note and a toast after the import were easy to miss. → true to import anyway. function askMissing(rel, provider, anchor) { return new Promise(resolve => { document.getElementById('fc-warn')?.remove(); const m = rel.missing, box = document.createElement('div'); box.id = 'fc-warn'; box.setAttribute('role', 'alertdialog'); const p = t => { const e = document.createElement('p'); e.textContent = t; return e; }; const h = document.createElement('div'); h.className = 'fc-warn-h'; h.textContent = '⚠ Tracks missing'; const b = document.createElement('div'); b.className = 'fc-warn-b'; const no = document.createElement('button'); no.type = 'button'; no.className = 'fc-warn-no'; no.textContent = 'Cancel'; const go = document.createElement('button'); go.type = 'button'; go.className = 'fc-warn-go'; go.textContent = 'Import anyway'; b.append(no, go); box.append(h, p(missingText(rel, provider)), p(m.at.length ? `The editor gets the ${m.of - m.count} tracks ${provider.name} gave, with an empty track at each missing position: give each its title, or remove it. The edit note says which.` : `The editor gets the ${m.of - m.count} tracks ${provider.name} gave; add the missing ones there. The edit note says so.`), b); document.body.appendChild(box); if (anchor && anchor.isConnected) placePanel(box, anchor); const key = e => { if (e.key === 'Escape') { e.stopPropagation(); done(false); } }; const done = ok => { box.remove(); document.removeEventListener('keydown', key, true); resolve(ok); }; document.addEventListener('keydown', key, true); no.addEventListener('click', () => done(false)); go.addEventListener('click', () => done(true)); go.focus(); }); } function placePanel(panel, anchor) { const r = anchor.getBoundingClientRect(); const vw = document.documentElement.clientWidth, vh = window.innerHeight, M = 8; const above = r.top - M * 2, below = vh - r.bottom - M * 2; const up = panel.offsetHeight <= above || above >= below; panel.style.maxHeight = Math.max(120, up ? above : below) + 'px'; const w = panel.offsetWidth, h = panel.offsetHeight; panel.style.left = Math.round(Math.min(Math.max(M, r.right - w), Math.max(M, vw - w - M))) + 'px'; panel.style.top = Math.round(up ? Math.max(M, r.top - M - h) : Math.min(r.bottom + M, Math.max(M, vh - h - M))) + 'px'; panel.style.right = panel.style.bottom = ''; Log.debug(`settings panel ${w}×${h} ${up ? 'above' : 'below'} the button at ${Math.round(r.left)},${Math.round(r.top)} (window ${vw}×${vh})`); } // The settings close through their dismiss handler, which then lets go of the page's clicks. A bare // remove() left it waiting, and it swallowed the next click anywhere: ⚙︎ after Reset did nothing. let panelDismiss = null; function closePanel() { const f = panelDismiss; panelDismiss = null; if (f) f(); const panel = document.getElementById('fc-panel'); if (panel) panel.remove(); } function togglePanel(anchor) { let panel = document.getElementById('fc-panel'); if (panel) { closePanel(); return; } const s = settings(); const here = (document.getElementById('fc-root') || { dataset: {} }).dataset.fcProvider || ''; panel = document.createElement('div'); panel.id = 'fc-panel'; panel.innerHTML = mbuHtml(mbuCfgHeader({ script: SCRIPT, name: NAME, version: VERSION, icon: ICON_SVG, log: true }) // majkinetor: "make clear categories here not wall of options": Import, Archive, Button + '<div class="fc-body">' + '<div class="fc-sec">Import</div>' + '<label>MusicBrainz server <select class="fc-server">' + SERVERS.map(h => `<option value="${h}"${h === s.server ? ' selected' : ''}>${h}</option>`).join('') + '</select></label>' + `<label class="fc-check" title="On ${here} only: the album's notes on the platform (Bandcamp's about and credits, Discogs's notes, Qobuz's and Apple's reviews, Beatport's and YouTube Music's description, Amazon Music's ℗ line), with a line saying where they come from. Reviews are the critic's text: check you may copy it before you submit."><input type="checkbox" class="fc-annotation"${annotationOn(here) ? ' checked' : ''}> Annotation from ${here}'s notes</label>` + `<label class="fc-check" title="Once the release editor has the release, or the album is sent to Harmony, this platform tab closes. It stays open when the import fails, or when the browser blocked the new tab and the editor or Harmony opened here."><input type="checkbox" class="fc-close-after"${s.closeAfter ? ' checked' : ''}> Close this page after the import</label>` + `<label class="fc-check" title="A button beside Import that reads this album and opens Mission Control on MusicBrainz, which finds it on the other platforms and lets you take the best of each into the release editor"><input type="checkbox" class="fc-cons-opt"${s.consolidate ? ' checked' : ''}> Consolidate button</label>` + `<label class="fc-check" title="A button between Import and ⚙︎ that looks this album up on Harmony instead, with every platform that has its barcode; Falcon carries on from there"><input type="checkbox" class="fc-harmony-opt"${s.harmony ? ' checked' : ''}> Send to Harmony button</label>` + '<div class="fc-sec">Archive</div>' // #659: majkinetor: "It should be ON by default or many people will not use it" + `<label class="fc-check" title="On every import, ask the Internet Archive's Wayback Machine to save the album page (and, for Deezer and Apple Music, the album data FC read). This sends each imported URL to archive.org."><input type="checkbox" class="fc-archive"${s.archive ? ' checked' : ''}> Archive the album page on the Internet Archive</label>` + mbaKeysHtml(!s.archive) + '<div class="fc-sec">Button</div>' + `<label class="fc-check" title="The button shows only its icon; hover it for what it does"><input type="checkbox" class="fc-iconly-opt"${s.iconOnly ? ' checked' : ''}> Icon only</label>` + `<label class="fc-check" title="No ⚙︎ button: right-click any of the buttons for the settings (that works with ⚙︎ shown too)"><input type="checkbox" class="fc-gear-hidden-opt"${s.gearHidden ? ' checked' : ''}> Hide settings button, use right click instead</label>` + `<label class="fc-check" title="On ${here} only: a button you have moved stays on its spot on the page (above the cover, say) and scrolls with it, instead of staying put on the screen. The button in its corner always stays on the screen."><input type="checkbox" class="fc-scroll-opt"${scrollsWithPage(here) ? ' checked' : ''}> Moved button scrolls with the page on ${here}</label>` // majkinetor: "change this to Reset: all | this one" + `<div class="fc-reset" title="Drag the button to move it; each platform remembers its own place. Reset puts it back in the bottom-right corner."><span class="fc-pos">Position</span>Reset: ` + `<button type="button" class="fc-reset-all" title="Back in the corner on every platform">all</button> | ` + `<button type="button" class="fc-reset-pos" title="Back in the corner on ${document.getElementById('fc-root').dataset.fcProvider}">this one</button></div>` + '</div>'); document.body.appendChild(panel); placePanel(panel, anchor.closest('#fc-root') || anchor); panel.querySelector('.fc-reset-pos').addEventListener('click', () => { const root = document.getElementById('fc-root'); storePos(root.dataset.fcProvider, null); setScrollsWithPage(root.dataset.fcProvider, false); placeButton(root); closePanel(); Log.info(`button back in the corner on ${root.dataset.fcProvider}`); }); panel.querySelector('.fc-reset-all').addEventListener('click', () => { const root = document.getElementById('fc-root'); const had = Object.keys(GM_getValue(POS_KEY, {}) || {}); GM_setValue(POS_KEY, {}); GM_setValue(SCROLL_KEY, {}); placeButton(root); closePanel(); Log.info(`button back in the corner on every platform (it had been moved on ${had.join(', ') || 'none'})`); }); panel.querySelector('.mbu-cfg-log').addEventListener('click', () => Log.open()); panel.querySelector('.fc-server').addEventListener('change', e => { const next = Object.assign(settings(), { server: e.target.value }); saveSettings(next); Log.info(`server set to ${next.server}`); }); panel.querySelector('.fc-scroll-opt').addEventListener('change', e => { setScrollsWithPage(here, e.target.checked); const root = document.getElementById('fc-root'); if (root) placeButton(root); Log.info(`moved button on ${here}: ${e.target.checked ? 'scrolls with the page' : 'stays on the screen'}`); }); panel.querySelector('.fc-cons-opt').addEventListener('change', e => { const next = Object.assign(settings(), { consolidate: e.target.checked }); saveSettings(next); const root = document.getElementById('fc-root'); if (root && current) showCons(root, current.provider); Log.info(`Consolidate button: ${next.consolidate ? 'shown' : 'hidden'}`); }); panel.querySelector('.fc-harmony-opt').addEventListener('change', e => { const next = Object.assign(settings(), { harmony: e.target.checked }); saveSettings(next); const root = document.getElementById('fc-root'); if (root && current) showHarmony(root, current.provider); Log.info(`Send to Harmony button: ${next.harmony ? 'shown' : 'hidden'}`); }); panel.querySelector('.fc-gear-hidden-opt').addEventListener('change', e => { const next = Object.assign(settings(), { gearHidden: e.target.checked }); saveSettings(next); const root = document.getElementById('fc-root'); if (root) { root.classList.toggle('fc-gear-hidden', next.gearHidden); mbRestackCorner('br'); } Log.info(`settings button: ${next.gearHidden ? 'hidden (right-click a button for the settings)' : 'shown'}`); }); panel.querySelector('.fc-iconly-opt').addEventListener('change', e => { const next = Object.assign(settings(), { iconOnly: e.target.checked }); saveSettings(next); const root = document.getElementById('fc-root'); if (root) { root.classList.toggle('fc-iconly', next.iconOnly); mbRestackCorner('br'); } Log.info(`button: ${next.iconOnly ? 'icon only' : 'icon and text'}`); }); panel.querySelector('.fc-annotation').addEventListener('change', e => { setAnnotationOn(here, e.target.checked); Log.info(`annotation from ${here}'s notes: ${e.target.checked ? 'on' : 'off'}`); }); panel.querySelector('.fc-close-after').addEventListener('change', e => { const next = Object.assign(settings(), { closeAfter: e.target.checked }); saveSettings(next); Log.info(`close the page after the import: ${next.closeAfter ? 'on' : 'off'}`); }); panel.querySelector('.fc-archive').addEventListener('change', e => { const next = Object.assign(settings(), { archive: e.target.checked }); saveSettings(next); panel.querySelector('.mbu-ia').classList.toggle('mbu-ia-off', !next.archive); Log.info(`archive on the Internet Archive: ${next.archive ? 'on' : 'off'}`); }); mbaKeysWire(panel, Log); panelDismiss = mbuDismissOn(panel, () => { panelDismiss = null; panel.remove(); }); } // What an import does to the release a provider read, before it is seeded: the type guessed where // the platform gives none, labels split, Various Artists, the annotation, the script. Consolidate and // Mission Control's reads (#702) do the same, so every platform's release is finished alike. function finishRelease(rel, provider) { const tracks = [].concat(...rel.mediums.map(m => m.tracks)).filter(t => !t.placeholder); // an empty track (#684) is no song const g = guessReleaseType(rel.title, tracks); Log.info(`type: ${provider.name} says ${rel.types.join(' + ') || 'nothing'}; the guess is ${g.type || 'none'} (${g.why})`); // the platform's own type stands, unless it's a plain Album and the title says EP / Single rel.typeGuessed = false; if (g.type && (!rel.types.length || (g.explicit && rel.types[0] === 'Album' && g.type !== 'Album'))) { rel.typeGuessed = !rel.types.length; rel.types = [g.type].concat(rel.types.slice(1)); Log.info(`type set to ${rel.types.join(' + ')}`); } rel.labels = splitLabels(rel.labels); const va = variousArtistsWhy(rel); if (va) { Log.info(`release artist: Various Artists, not ${rel.credit.map(c => c.name + c.join).join('')} (${va})`); rel.credit = [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }]; if (!rel.types.includes('Compilation')) rel.types = (rel.types.length ? rel.types : ['Album']).concat('Compilation'); } if (rel.annotation && annotationOn(provider.name)) { rel.annotation += `\n\nFrom ${provider.name}: ${rel.url}`; Log.info(`annotation: ${rel.annotation.length} characters of ${provider.name}'s notes`); } else { if (rel.annotation) Log.info(`annotation: ${provider.name} has notes (${rel.annotation.length} characters); off for ${provider.name} in the settings`); rel.annotation = null; } rel.script = guessScript([rel.title].concat(...rel.mediums.map(m => m.tracks.map(t => t.title)))); return rel; } async function importCurrent() { if (busy || !current) return; const { provider, id } = current; const go = document.querySelector('#fc-root .fc-go'); const label = go && go.querySelector('span'); const server = settings().server; const token = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); Log.info(`import ${location.href}`); Log.info(`import ${provider.name} album ${id} → ${server} (token ${token})`); busy = true; if (go) go.setAttribute('aria-busy', 'true'); const t0 = Date.now(); // the button says what it does from the click on, with the tracks read so far (when the platform // counts them) and the seconds gone, so a slow platform (7digital's search takes 10 s) visibly works let count = '', phase = `Reading ${provider.name}…`; const show = () => { if (label) label.textContent = `${phase}${count ? ' ' + count : ''} · ${Math.round((Date.now() - t0) / 1000)} s`; }; show(); const ticker = setInterval(show, 1000); try { const rel = await provider.fetchRelease(id, (n, total) => { count = `${n}/${total}`; show(); }); if (rel.missing) { count = ''; phase = 'Tracks missing'; show(); if (!(await askMissing(rel, provider, go))) { Log.info('import cancelled: tracks missing'); return; } Log.info('import anyway, with tracks missing'); } count = ''; phase = 'Opening MusicBrainz…'; show(); finishRelease(rel, provider); const nTracks = rel.mediums.reduce((n, m) => n + m.tracks.length, 0); Log.info(`release read in ${Date.now() - t0} ms: "${rel.title}" · ${rel.credit.map(c => c.name + c.join).join('')} · ${rel.mediums.length} medium(s), ${nTracks} track(s) · types ${rel.types.join('+') || '—'} · script ${rel.script || '—'}`); if (!nTracks) throw new Error(`${provider.name} returned no tracks`); pruneHandoffs(); // archiving is extra: whatever goes wrong with it, the import goes on without it let archive = null; try { archive = archivePlan(rel, provider, id); } catch (e) { Log.warn(`archive: skipped, it failed: ${e && e.message || e}`); } GM_setValue(HANDOFF_PREFIX + token, Object.assign(handoffFor(rel, provider, token), { archive })); const params = seedParams(rel, editNoteFor(rel, provider)); Log.debug(`seed: ${params.length} parameters`); if (mbuTestHooks()) window.__fcLastSeed = { token, server, params, rel }; const where = sendSeed(server, token, params); if (rel.hiddenLengths) mbuToast(`⚠ ${rel.hiddenLengths} track length(s) are from ${provider.name}'s app data: the album isn't out yet, so they can still change`, { kind: 'warn' }); Log.ok(`seeded ${server}/release/add with ${nTracks} track(s) (${where === 'here' ? 'in this tab: the browser blocked a new one' : 'in a new tab'})`); if (settings().closeAfter) { if (where !== 'here') closeSourceTab(); else Log.info('close after the import: skipped, the editor opened in this tab'); } } catch (e) { Log.err(`import failed: ${e.message}`); mbuToast(`✗ ${NAME}: ${e.message}`, { kind: 'warn', action: { label: 'Copy log', onClick: b => Log.copy(b) } }); } finally { clearInterval(ticker); busy = false; if (go) go.removeAttribute('aria-busy'); if (label) label.textContent = 'Import to MusicBrainz'; } } // #687: Send to Harmony. A platform Harmony knows goes by its album link, at once; Harmony finds the // others by the barcode it reads there. Any other platform goes by the barcode, read here first as // Import reads the album. YouTube Music and Amazon Music have neither, so the button is greyed out. function showHarmony(root, provider) { const b = root.querySelector('.fc-harmony'); if (!b) return; b.hidden = !settings().harmony; const none = HARMONY_NONE.has(provider.id); b.setAttribute('aria-disabled', String(none)); b.setAttribute('aria-label', 'Send to Harmony'); b.title = harmonyWhy(provider); } function harmonyWhy(provider) { if (HARMONY_NONE.has(provider.id)) return `Send to Harmony: Harmony can't look up ${provider.name}, and ${provider.name} shows no barcode`; if (HARMONY_URLS[provider.id]) return `Send to Harmony: look this ${provider.name} album up on Harmony, with every platform that has its barcode`; return `Send to Harmony: read the barcode here first (Harmony can't look up ${provider.name}), then look it up on Harmony`; } async function sendToHarmony() { if (busy || !current) return; const { provider, id } = current; if (HARMONY_NONE.has(provider.id)) { mbuToast(harmonyWhy(provider).replace(/^Send to Harmony: /, ''), { kind: 'warn' }); return; } const direct = HARMONY_URLS[provider.id]; if (direct) { openHarmony(harmonyLookup({ url: direct(id) })); return; } const root = document.getElementById('fc-root'); const go = root && root.querySelector('.fc-go'), hb = root && root.querySelector('.fc-harmony'); const label = go && go.querySelector('span'); busy = true; for (const b of [go, hb]) if (b) b.setAttribute('aria-busy', 'true'); const t0 = Date.now(); let count = ''; const show = () => { if (label) label.textContent = `Reading ${provider.name} for Harmony…${count ? ' ' + count : ''} · ${Math.round((Date.now() - t0) / 1000)} s`; }; show(); const ticker = setInterval(show, 1000); try { Log.info(`Harmony: reading the barcode of ${provider.name} album ${id}`); const rel = await provider.fetchRelease(id, (n, total) => { count = `${n}/${total}`; show(); }); if (!rel.barcode) throw new Error(`${provider.name} gives no barcode for this album, so Harmony can't look it up`); openHarmony(harmonyLookup({ gtin: rel.barcode })); } catch (e) { Log.err(`Send to Harmony failed: ${e.message}`); mbuToast(`✗ ${NAME}: ${e.message}`, { kind: 'warn', action: { label: 'Copy log', onClick: b => Log.copy(b) } }); } finally { clearInterval(ticker); busy = false; for (const b of [go, hb]) if (b) b.removeAttribute('aria-busy'); if (label) label.textContent = 'Import to MusicBrainz'; } } // A tab the manager opens isn't a blocked popup, even after the read; without it the page opens one // majkinetor: "Option to close page after import should apply to Harmony too". Only when Harmony got a // tab of its own: when the browser blocked it, Harmony is in this one. function openHarmony(url) { Log.ok(`sent to Harmony: ${url}`); if (mbuTestHooks()) window.__fcLastHarmony = url; let here = false; if (typeof GM_openInTab === 'function') GM_openInTab(url, { active: true, insert: true }); else { const w = window.open(url, '_blank'); if (w) w.opener = null; else here = true; } if (settings().closeAfter) { if (!here) closeSourceTab(); else Log.info('close after sending to Harmony: skipped, the browser blocked a new tab and Harmony opens in this one'); } if (here) location.assign(url); } /* ── #702: Consolidate in Mission Control ────────────────────────────────── */ // The album is read as for an import and handed to Mission Control on MusicBrainz's // /release/add#mc=<token>, in the script's storage under fc.handoff.cons.<token>. There Platform // Check finds the album on the other platforms, this script reads each of them (headless, mc:read), // and Mission Control lets you take the best of each into the release editor (fc:seed). const CONS_PREFIX = HANDOFF_PREFIX + 'cons.'; const MC_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 8 108 112" width="18" height="18" aria-hidden="true"><g stroke="#22223b" stroke-width="5" stroke-linejoin="round">' + '<path d="M77 35.5 L64 43 L51 35.5 L51 20.5 L64 13 L77 20.5Z" fill="#7a57e8"/>' + '<path d="M59.7 77.5 L46.7 85 L33.7 77.5 L33.7 62.5 L46.7 55 L59.7 62.5Z" fill="#ffc94a"/><path d="M94.3 77.5 L81.3 85 L68.3 77.5 L68.3 62.5 L81.3 55 L94.3 62.5Z" fill="#ffc94a"/>' + '<path d="M42.3 107.5 L29.4 115 L16.4 107.5 L16.4 92.5 L29.4 85 L42.3 92.5Z" fill="#ffc94a"/><path d="M77 107.5 L64 115 L51 107.5 L51 92.5 L64 85 L77 92.5Z" fill="#ffc94a"/>' + '<path d="M111.6 107.5 L98.6 115 L85.7 107.5 L85.7 92.5 L98.6 85 L111.6 92.5Z" fill="#ffc94a"/></g></svg>'; const providerMeta = p => ({ id: p.id, name: p.name, abbr: p.abbr, artistLinkType: p.artistLinkType || null }); // a release with every artist's and label's link carrying the forms MusicBrainz may store it under function relWithForms(rel, provider) { const forms = url => { if (!url || !provider.urlForms) return undefined; const f = [...new Set([url, ...provider.urlForms(url)])]; return f.length > 1 ? f : undefined; }; const ac = c => (c || []).map(a => Object.assign({}, a, { urlForms: forms(a.url) })); return Object.assign({}, rel, { credit: ac(rel.credit), labels: (rel.labels || []).map(l => Object.assign({}, l, { urlForms: forms(l.url) })), mediums: rel.mediums.map(m => Object.assign({}, m, { tracks: m.tracks.map(t => Object.assign({}, t, { credit: ac(t.credit) })) })), }); } function showCons(root, provider) { const b = root.querySelector('.fc-cons'); if (!b) return; b.hidden = !settings().consolidate; b.setAttribute('aria-label', 'Consolidate in Mission Control'); b.title = `Consolidate in Mission Control: read this ${provider.name} album, find it on the other platforms, and take the best of each into the release editor`; } async function consolidateCurrent() { if (busy || !current) return; const { provider, id } = current; const root = document.getElementById('fc-root'); const go = root && root.querySelector('.fc-go'), cb = root && root.querySelector('.fc-cons'); const label = go && go.querySelector('span'); const server = settings().server; const token = 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); busy = true; for (const b of [go, cb]) if (b) b.setAttribute('aria-busy', 'true'); const t0 = Date.now(); let count = ''; const show = () => { if (label) label.textContent = `Reading ${provider.name} for Mission Control…${count ? ' ' + count : ''} · ${Math.round((Date.now() - t0) / 1000)} s`; }; show(); const ticker = setInterval(show, 1000); try { Log.info(`consolidate ${location.href}`); Log.info(`consolidate ${provider.name} album ${id} → ${server} (token ${token})`); const rel = finishRelease(await provider.fetchRelease(id, (n, total) => { count = `${n}/${total}`; show(); }), provider); const nTracks = rel.mediums.reduce((n, m) => n + m.tracks.length, 0); if (!nTracks) throw new Error(`${provider.name} returned no tracks`); Log.info(`release read in ${Date.now() - t0} ms: "${rel.title}" · ${rel.mediums.length} medium(s), ${nTracks} track(s) · barcode ${rel.barcode || '—'}`); pruneHandoffs(); GM_setValue(CONS_PREFIX + token, { v: 1, token, created: Date.now(), source: provider.id, sourceName: provider.name, platform: providerMeta(provider), id, page: mbaCleanUrl(location.href), url: rel.url, rel: relWithForms(rel, provider) }); const url = `https://${server}/release/add#mc=${token}`; if (mbuTestHooks()) window.__fcLastCons = { token, url, rel }; Log.ok(`handed to Mission Control: ${url}`); let here = false; if (typeof GM_openInTab === 'function' && !mbuTestHooks()) GM_openInTab(url, { active: true, insert: true }); else if (!mbuTestHooks()) { const w = window.open(url, '_blank'); if (w) w.opener = null; else here = true; } if (settings().closeAfter) { if (!here) closeSourceTab(); else Log.info('close after Consolidate: skipped, the browser blocked a new tab and Mission Control opens in this one'); } if (here) location.assign(url); } catch (e) { Log.err(`Consolidate failed: ${e.message}`); mbuToast(`✗ ${NAME}: ${e.message}`, { kind: 'warn', action: { label: 'Copy log', onClick: b => Log.copy(b) } }); } finally { clearInterval(ticker); busy = false; for (const b of [go, cb]) if (b) b.removeAttribute('aria-busy'); if (label) label.textContent = 'Import to MusicBrainz'; } } // On MusicBrainz's /release/add#mc=<token>: hand the album to Mission Control, read the other // platforms for it, and seed the release editor with what it took. Every detail is a JSON string. // fc:consolidate → the album as Consolidate stored it, or { token, error }; again on fc:consolidate-request // mc:read { url, run } → mc:read-progress { url, run, n, total } while it reads, then // mc:read-result { url, run, ok, provider, rel, ms } or { url, run, ok: false, error } // fc:seed { rel, platform, editNote, run } → fc:seeded { run, token }, then the release editor in this tab const READ_PAGE = new Set(['bandcamp', 'qobuz', 'beatport', 'sevendigital', 'ototoy']); // the readers that parse the album page function mbConsolidate(token) { const key = CONS_PREFIX + token; let h = GM_getValue(key, null), from = 'storage'; if (!h) { try { h = JSON.parse(sessionStorage.getItem(key) || 'null'); from = 'this tab'; } catch (e) { h = null; } } if (h) { try { sessionStorage.setItem(key, JSON.stringify(h)); } catch (e) { Log.debug(`consolidation ${token}: no copy for this tab (${e.message})`); } GM_deleteValue(key); Log.info(`Mission Control: consolidation ${token} read from ${from}: ${h.sourceName} "${h.rel && h.rel.title}" (${h.url})`); } else Log.warn(`Mission Control page opened with consolidation ${token}, but it is not stored (expired, or another browser)`); const json = JSON.stringify(h || { token, error: 'First Contact has no album stored for this page: it is kept for an hour, in the browser that read it.' }); const publish = why => { document.documentElement.dataset.fcConsolidate = json; document.dispatchEvent(new CustomEvent('fc:consolidate', { detail: json })); Log.debug(`consolidation ${token} published (${why})`); }; publish('load'); document.addEventListener('fc:consolidate-request', () => publish('request')); document.addEventListener('mc:read', e => { mcRead(e.detail); }); document.addEventListener('fc:seed', e => { mcSeed(e.detail); }); } async function mcRead(detail) { let d; try { d = JSON.parse(detail) || {}; } catch (e) { Log.warn(`mc:read with unreadable detail: ${e.message}`); return; } const send = (type, o) => document.dispatchEvent(new CustomEvent(type, { detail: JSON.stringify(Object.assign({ url: d.url, run: d.run }, o)) })); const t0 = Date.now(); try { const u = new URL(d.url); const provider = PROVIDERS.find(p => p.host.test(u.hostname)); if (!provider) throw new Error(`First Contact can't read ${u.hostname}`); const id = provider.albumId(u); if (!id) throw new Error(`not an album page ${provider.name} has (${d.url})`); if (provider.id === 'spotify') throw new Error('Spotify is read through its web player, on its own page only'); let doc = null; if (READ_PAGE.has(provider.id)) { // #745: with the browser's cookies (a bot check's clearance). A page that is refused all the same is no end // for a reader that can do without it (Beatport: through Platform Check): it gets an empty page, marked refused let html = null, refused = null; try { html = await gmText(d.url, { Accept: 'text/html' }, true); if (/<title>(Just a moment|Human Verification)/i.test(html)) refused = 'its bot check'; } catch (e) { refused = e.message; } if (refused && !provider.withoutPage) throw new Error(`${provider.name} refused the page (${refused})`); if (refused) Log.warn(`Mission Control: ${provider.name} refused the page (${refused}); reading it without the page`); doc = new DOMParser().parseFromString(refused ? '<html><body></body></html>' : html, 'text/html'); doc.fcOrigin = u.origin; if (refused) doc.fcRefused = refused; } Log.info(`Mission Control: reading ${provider.name} album ${id}${doc ? ' from its page, fetched' : ''}`); const rel = finishRelease(await provider.fetchRelease(id, (n, total) => send('mc:read-progress', { n, total }), doc), provider); const nTracks = rel.mediums.reduce((n, m) => n + m.tracks.length, 0); Log.ok(`Mission Control: ${provider.name} read in ${Date.now() - t0} ms: "${rel.title}" · ${nTracks} track(s) · barcode ${rel.barcode || '—'}`); send('mc:read-result', { ok: true, provider: providerMeta(provider), rel: relWithForms(rel, provider), ms: Date.now() - t0 }); } catch (e) { Log.warn(`Mission Control: reading ${d.url} failed: ${e.message}`); send('mc:read-result', { ok: false, error: e.message, ms: Date.now() - t0 }); } } // The handoff for the release Mission Control consolidated: each credit keeps its source link // (url) and carries the same artist's links on the other platforms read (alt: [{ url, urlForms, // platform }]), which Apollo tries when the source link alone matches no one (#702). function handoffFromMc(rel, platform, token) { const ac = c => (c || []).map(a => ({ name: a.name, artistName: a.artistName || a.name, join: a.join, url: a.url || null, urlForms: a.urlForms, mbid: a.mbid || null, alt: Array.isArray(a.alt) && a.alt.length ? a.alt : undefined })); return { v: 2, token, created: Date.now(), mc: true, source: platform.id, sourceName: platform.name, platform: { abbr: platform.abbr, name: platform.name, artistLinkType: platform.artistLinkType || undefined }, url: rel.url, title: rel.title, barcode: rel.barcode, credit: ac(rel.credit), labels: (rel.labels || []).map(l => ({ name: l.name, catno: l.catno, url: l.url || null, urlForms: l.urlForms, mbid: l.mbid || null })), mediums: rel.mediums.map(m => ({ tracks: m.tracks.map(t => ({ title: t.title, isrc: t.isrc, url: t.url, credit: ac(t.credit) })) })), }; } function mcSeed(detail) { let d; try { d = JSON.parse(detail) || {}; } catch (e) { Log.warn(`fc:seed with unreadable detail: ${e.message}`); return; } if (!d.rel || !d.platform) { Log.warn('fc:seed without a release'); return; } const token = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); GM_setValue(HANDOFF_PREFIX + token, Object.assign(handoffFromMc(d.rel, d.platform, token), { archive: null })); const params = seedParams(d.rel, d.editNote || ''); const nTracks = d.rel.mediums.reduce((n, m) => n + m.tracks.length, 0); Log.info(`Mission Control: seeding the release editor with "${d.rel.title}", ${nTracks} track(s), ${(d.rel.urls || []).length} link(s) (${params.length} parameters, token ${token})`); if (mbuTestHooks()) window.__fcLastSeed = { token, params, rel: d.rel, mc: true }; document.dispatchEvent(new CustomEvent('fc:seeded', { detail: JSON.stringify({ run: d.run, token }) })); if (d.dry) return; // majkinetor: the browser asked "leave page?" on Add release. MB's editor warns on // beforeunload even while empty, once the page has had a click, unless rootField.redirecting // (its own flag for leaving on purpose). This page is only Mission Control's: nothing is lost. try { const w = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const rf = w.MB && w.MB.releaseEditor && w.MB.releaseEditor.rootField; if (rf) { rf.redirecting = true; Log.debug('release editor: redirecting set, so leaving asks nothing'); } else Log.warn('no MB.releaseEditor.rootField here: the browser may ask before leaving'); } catch (e) { Log.warn(`could not turn the release editor's leave-page warning off: ${e.message}`); } postForm(`/release/add?first_contact=${token}`, '_self', params); } // majkinetor: "why does it open tab while reading?" The editor's tab opens once the release is read, // not at the click. A tab a page opens that long after the click is a popup the browser blocks, but // one the userscript manager opens (GM_openInTab) isn't: the seed waits in the script's storage, and // First Contact on that page posts it there (mbPostSeed). Without GM_openInTab, the page opens the tab // itself and posts into it, or posts in this tab when the browser blocks it. // → 'tab' (a new tab) or 'here' (this one) function sendSeed(server, token, params) { const action = `https://${server}/release/add?first_contact=${token}`; // on a test page the harness's GM_openInTab opens nothing (as for the consolidation): the specs take the window.open path if (typeof GM_openInTab === 'function' && !mbuTestHooks()) { GM_setValue(SEED_PREFIX + token, { created: Date.now(), params }); GM_openInTab(`${action}&fc_post=1`, { active: true, insert: true }); return 'tab'; } const target = 'fc-' + token; const win = window.open('about:blank', target); postForm(action, win ? target : '_self', params); return win ? 'tab' : 'here'; } function postForm(action, target, params) { const form = document.createElement('form'); form.method = 'POST'; form.action = action; form.target = target; form.acceptCharset = 'UTF-8'; form.style.display = 'none'; for (const [k, v] of params) { const inp = document.createElement('input'); inp.type = 'hidden'; inp.name = k; inp.value = v; form.appendChild(inp); } (document.body || document.documentElement).appendChild(form); form.submit(); form.remove(); } // On the tab sendSeed opened: stop the empty release editor loading and post the seed to it, here function mbPostSeed(token) { const key = SEED_PREFIX + token; const seed = GM_getValue(key, null); if (!seed || !Array.isArray(seed.params)) { Log.warn(`release editor opened to take the seed ${token}, but it is not stored (expired, or another browser)`); return; } GM_deleteValue(key); try { window.stop(); } catch (e) { /* it posts anyway */ } Log.debug(`seed ${token}: ${seed.params.length} parameters, posting them to the release editor`); const post = () => postForm(`/release/add?first_contact=${token}`, '_self', seed.params); if (document.documentElement) post(); // at document-start the page may have no root yet else new MutationObserver((m, o) => { if (document.documentElement) { o.disconnect(); post(); } }).observe(document, { childList: true }); } // majkinetor: an option to close the page once the import is clicked. Only after the POST has // gone into the editor's tab (closing sooner could take the submission with it), and only with // @grant window.close: a tab the user opened can't close itself otherwise. function closeSourceTab() { Log.info('closing this page (Close this page after the import)'); if (mbuTestHooks()) { window.__fcClosed = true; return; } setTimeout(() => { try { window.close(); } catch (e) { Log.warn(`could not close this page: ${e.message}`); } setTimeout(() => { Log.warn('this page is still open: the userscript manager did not let it close (it needs @grant window.close)'); }, 1000); }, 500); } function platformMain() { const provider = PROVIDERS.find(p => p.host.test(location.hostname)); if (!provider) return; Log.info(`${NAME} ${VERSION} on ${provider.name}`); // The platforms are single-page apps: watch the address, not the load. let last = null; const check = () => { if (location.href === last) return; last = location.href; const id = provider.albumId(location); current = id ? { provider, id } : null; if (id) { if (document.body) mountButton(provider, id); } else unmountButton(); }; check(); setInterval(check, 700); } /* ── MusicBrainz side: publish the handoff for Apollo ────────────────────── */ // Apollo (or anything else) reads the seed's platform links from // document.documentElement.dataset.firstContact (a JSON string), or // the 'first-contact:seed' event on document (detail: the same JSON string), // and can ask for it again with a 'first-contact:request' event on document. function mbMain() { const mc = (location.hash.match(/[#&]mc=([a-z0-9]+)/i) || [])[1]; if (mc && /^\/release\/add\/?$/.test(location.pathname)) { mbConsolidate(mc); return; } const q = new URLSearchParams(location.search); const token = q.get('first_contact'); if (!token) return; if (q.get('fc_post')) { mbPostSeed(token); return; } pruneHandoffs(); const key = HANDOFF_PREFIX + token; let h = GM_getValue(key, null), from = 'storage'; if (!h) { try { h = JSON.parse(sessionStorage.getItem(key) || 'null'); from = 'this tab'; } catch (e) { h = null; } } if (!h) { Log.warn(`release editor opened with handoff ${token}, but it is not stored (expired, or another browser)`); return; } Log.debug(`handoff ${token} read from ${from}`); // MusicBrainz's "Continue" page (a POST from another site) comes first, at the same address: // the handoff stays stored until the release editor itself is here const consume = () => { if (!document.getElementById('release-editor')) { Log.debug(`handoff ${token} kept: not the release editor yet (the confirmation page)`); return; } try { sessionStorage.setItem(key, JSON.stringify(h)); } catch (e) { Log.debug(`handoff ${token}: no copy for this tab (${e.message})`); } GM_deleteValue(key); Log.debug(`handoff ${token} taken out of the script's storage`); if (h.archive) archiveSources(h.archive, token); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', consume, { once: true }); else consume(); const json = JSON.stringify(h); const publish = why => { document.documentElement.dataset.firstContact = json; document.dispatchEvent(new CustomEvent('first-contact:seed', { detail: json })); Log.debug(`handoff ${token} published (${why})`); }; publish('load'); document.addEventListener('first-contact:request', () => publish('request')); const nArtists = [h.credit].concat(...h.mediums.map(m => m.tracks.map(t => t.credit))).reduce((n, c) => n + c.filter(a => a.url).length, 0); Log.info(`release editor seeded from ${h.sourceName} (${h.url}); ${nArtists} artist credit(s) carry a platform link`); if (mbuTestHooks()) window.__fcHandoff = h; } /* ── shared blocks ───────────────────────────────────────────────────────── */ // <ST-TOKENS> — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // </ST-TOKENS> // <ST-UI> — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on <html>, which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'<svg…>' | '<img…>', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // o.history opt-in history of past sessions (#705), below // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // viewer appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) : '--:--:--.---'; }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + (e.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button, input, select') || pop.classList.contains('full')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); views = views.filter(function (v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in <body>: a permanent stray node under <html>, outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No <body> yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and <head> and <body> don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } // </ST-UI> // <ST-ARCHIVE> — generated by dev/archive/sync-archive.mjs from dev/archive/wayback.mjs — DO NOT EDIT const MBA_KEY = "mbu.archive"; function mbaTs(d) { return (d || new Date()).toISOString().replace(/[^0-9]/g, '').slice(0, 14); } function mbaShort(url) { return String(url).replace(/^https?:\/\//i, '').replace(/^www\./i, ''); } function mbaSnapshot(ts, url) { return 'https://web.archive.org/web/' + ts + '/' + mbaShort(url); } function mbaCleanUrl(href) { const [base, query = ''] = String(href).split('#')[0].split(/\?(.*)/s); const kept = query.split('&').filter(p => p && !/^(si|utm_\w+|fbclid|gclid|ref|from|context|nd|feature|redirected)$/i.test(decodeURIComponent(p.split('=')[0].replace(/\+/g, ' ')))); return base + (kept.length ? '?' + kept.join('&') : ''); } function mbaSavable(url) { const d = String(url).match(/^https?:\/\/(?:www\.)?discogs\.com\/(?:[a-z]{2}\/)?(release|master|artist|label)\/(\d+)(?:[-/?#]|$)/i); return d ? `https://api.discogs.com/${d[1].toLowerCase()}s/${d[2]}` : url; } function mbaKeys() { let v = null; try { v = GM_getValue(MBA_KEY, null); } catch (e) { /* no storage: anonymous */ } return v && v.iaKey && v.iaSecret ? { key: v.iaKey, secret: v.iaSecret } : null; } function mbaKeysStored() { let v = null; try { v = GM_getValue(MBA_KEY, null); } catch (e) { /* no storage */ } return { iaKey: (v && v.iaKey) || '', iaSecret: (v && v.iaSecret) || '' }; } function mbaSetKeys(iaKey, iaSecret) { const prev = (() => { try { return GM_getValue(MBA_KEY, null) || {}; } catch (e) { return {}; } })(); GM_setValue(MBA_KEY, Object.assign({}, prev, { iaKey: String(iaKey || '').trim(), iaSecret: String(iaSecret || '').trim() })); } function mbaSaveAnon(url, log) { const t0 = Date.now(), wait = 180000; return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url: 'https://web.archive.org/save/' + url, timeout: wait, anonymous: true, headers: { Accept: 'text/html' }, onload: r => { const snap = /\/web\/\d{14}\//.test(r.finalUrl || '') ? r.finalUrl : null; const ms = Date.now() - t0; if (r.status >= 200 && r.status < 300 && snap) log.ok(`archive: saved ${url} → ${snap} (${ms} ms)`); else if (r.status === 429) log.warn(`archive: ${url} not saved: Save Page Now is limiting anonymous saves (HTTP 429). archive.org keys in the settings raise the limit`); else log.warn(`archive: ${url} → HTTP ${r.status}${snap ? '' : ', no snapshot in the answer'} (${ms} ms)${r.finalUrl ? ', ended at ' + r.finalUrl : ''}`); resolve(); }, onerror: () => { log.warn(`archive: network error saving ${url}`); resolve(); }, ontimeout: () => { log.warn(`archive: no answer for ${url} in ${wait / 60000} minutes (the save may still finish)`); resolve(); }, }); }); } function mbaRequest(method, url, auth, body) { return new Promise(resolve => { GM_xmlhttpRequest({ method, url, data: body, timeout: 30000, anonymous: true, headers: Object.assign({ Accept: 'application/json', Authorization: 'LOW ' + auth }, body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}), onload: r => { let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { /* not JSON */ } resolve({ status: r.status, json: j, text: r.responseText || '' }); }, onerror: () => resolve({ status: 0, json: null, text: 'network error' }), ontimeout: () => resolve({ status: 0, json: null, text: 'timeout' }), }); }); } async function mbaSaveWithKeys(url, keys, log, follow) { const auth = keys.key + ':' + keys.secret, wait = 180000; const body = new URLSearchParams({ url, if_not_archived_within: '30d', capture_screenshot: '1' }).toString(); const r = await mbaRequest('POST', 'https://web.archive.org/save', auth, body); log.debug(`archive: POST /save ${url} → HTTP ${r.status} ${r.text.slice(0, 300)}`); if (r.status === 401 || r.status === 403) { log.warn(`archive: archive.org refused your keys (HTTP ${r.status}); check them in the settings`); return; } const job = r.json && r.json.job_id; if (!job) { const msg = (r.json && (r.json.message || r.json.status_ext)) || r.text.slice(0, 200) || 'no answer'; if (r.status === 200 && /archived|already/i.test(msg)) log.ok(`archive: ${url} has a recent snapshot, not saved again (${msg})`); else log.warn(`archive: ${url} not saved (HTTP ${r.status}: ${msg})`); return; } log.info(`archive: ${url} queued as job ${job}`); if (!follow) return; const t0 = Date.now(); while (Date.now() - t0 < wait) { await new Promise(res => setTimeout(res, 6000)); const st = await mbaRequest('GET', 'https://web.archive.org/save/status/' + encodeURIComponent(job), auth); const j = st.json || {}; log.debug(`archive: job ${job} → HTTP ${st.status}, ${j.status || '?'}`); if (j.status === 'pending') continue; if (j.status === 'success') log.ok(`archive: saved ${url} → https://web.archive.org/web/${j.timestamp}/${j.original_url || url}${j.screenshot ? ', screenshot ' + j.screenshot : ''} (${Date.now() - t0} ms)`); else log.warn(`archive: job ${job} for ${url} ended ${j.status || 'HTTP ' + st.status}: ${j.message || j.status_ext || st.text.slice(0, 200)}`); return; } log.info(`archive: job ${job} for ${url} still running after ${wait / 60000} minutes; archive.org finishes it on its side`); } async function mbaSave(url, log, follow) { const keys = mbaKeys(); try { await (keys ? mbaSaveWithKeys(url, keys, log, follow) : mbaSaveAnon(url, log)); } catch (e) { log.warn(`archive: ${url}: ${e && e.message || e}`); } } function mbaKeysState(k, s) { k = String(k || '').trim(); s = String(s || '').trim(); return k && s ? '✓ set' : k ? 'secret missing' : s ? 'access key missing' : 'not set'; } function mbaKeysHtml(off) { const k = mbaKeysStored(), q = v => String(v).replace(/&/g, '&').replace(/"/g, '"'); // no password-manager icon in the fields: they opt out by attribute (LastPass, 1Password, // Bitwarden, Dashlane), and the secret is a text field masked in CSS, since type=password draws every manager in const opt = 'autocomplete="off" data-lpignore="true" data-1p-ignore data-bwignore data-form-type="other" spellcheck="false"'; return `<div class="mbu-ia${off ? ' mbu-ia-off' : ''}" title="Optional, and shared by First Contact and Apollo Editor. With your archive.org keys the save uses Save Page Now 2: higher limits, and a screenshot of the page, which is the evidence for platforms that build their pages in the browser (Spotify, Tidal, YouTube Music). Without keys the save is anonymous.">` + `<div class="mbu-ia-h">archive.org keys <span class="mbu-ia-st">${mbaKeysState(k.iaKey, k.iaSecret)}</span> <a href="https://archive.org/account/s3.php" target="_blank" rel="noopener">get them</a></div>` + `<input type="text" class="mbu-ia-key" placeholder="access key" ${opt} value="${q(k.iaKey)}">` + `<input type="text" class="mbu-ia-secret" placeholder="secret" ${opt} value="${q(k.iaSecret)}"></div>`; } function mbaKeysWire(root, log) { const key = root.querySelector('.mbu-ia-key'), secret = root.querySelector('.mbu-ia-secret'), st = root.querySelector('.mbu-ia-st'); if (!key || !secret) return; const save = () => { mbaSetKeys(key.value, secret.value); if (st) { st.textContent = mbaKeysState(key.value, secret.value); st.classList.toggle('mbu-ia-ok', !!(key.value.trim() && secret.value.trim())); } }; const logged = () => log.info(`archive.org keys: ${mbaKeysState(key.value, secret.value)}`); for (const f of [key, secret]) { f.addEventListener('input', save); f.addEventListener('change', logged); } if (st) st.classList.toggle('mbu-ia-ok', !!(key.value.trim() && secret.value.trim())); } function mbaKeysCss(scope) { const s = scope + ' .mbu-ia'; return `${s} { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 6px; } ${s}.mbu-ia-off { opacity: .5; } ${s} .mbu-ia-secret { -webkit-text-security: disc; } ${s} .mbu-ia-h { grid-column: 1 / -1; color: var(--mbu-text-weak); } ${s} .mbu-ia-h a { color: var(--mbu-accent-text); margin-left: 4px; } ${s} .mbu-ia-st { margin-left: 4px; } ${s} .mbu-ia-st.mbu-ia-ok { color: var(--mbu-ok); } ${s} input { all: revert; box-sizing: border-box; width: 100%; min-width: 0; font: 12px var(--mbu-font-mono, monospace); padding: 3px 6px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg-sunken); color: var(--mbu-text); } ${s} input::placeholder { color: var(--mbu-text-weak); opacity: 1; font-style: normal; } `; } // </ST-ARCHIVE> try { // a provider that must hear the player's own requests hooks them now, before the page's scripts run const early = !ON_MB && PROVIDERS.find(p => p.early && p.host.test(location.hostname)); if (early) early.early(); if (ON_MB) mbMain(); else if (document.body) platformMain(); else document.addEventListener('DOMContentLoaded', platformMain, { once: true }); } catch (e) { try { Log.err(`startup: ${e.stack || e.message}`); } catch (_) { /* nothing left to log with */ } } if (mbuTestHooks()) window.__fcTest = { mcRead, splitFeat, creditFromTitle, seedParams, editNoteFor, guessScript, splitLabels, providers: PROVIDERS, importCurrent, finishRelease, relWithForms, handoffFromMc, consolidateCurrent }; })(); })(); } catch (e) { try { console.error('[String Theory] first_contact failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== fusion (@run-at document-end) ============================================ if (__stOff.indexOf("fusion") < 0 && __stRuns("fusion", {"inc":["^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release-group/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/recording/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/artist/.*/recordings$"],"exc":[],"noframes":false})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Fusion*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Merge-recordings assistant for MusicBrainz: gather a pool of candidate recordings from a release / release group / recording page (or paste any MBID/URL), auto-match them into merge groups by ISRC / AcoustID / length / title+artist, review and adjust the groups, then submit the merges directly in the background — no MB merge page involved.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/fusion/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPHRpdGxlPkZ1c2lvbjwvdGl0bGU+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjYTE4NGY4IiBzdHJva2Utd2lkdGg9IjUuNSI+CiAgICA8ZWxsaXBzZSBjeD0iNjQiIGN5PSI2NCIgcng9IjUyIiByeT0iMjIiLz4KICAgIDxlbGxpcHNlIGN4PSI2NCIgY3k9IjY0IiByeD0iNTIiIHJ5PSIyMiIgdHJhbnNmb3JtPSJyb3RhdGUoNjAgNjQgNjQpIi8+CiAgICA8ZWxsaXBzZSBjeD0iNjQiIGN5PSI2NCIgcng9IjUyIiByeT0iMjIiIHRyYW5zZm9ybT0icm90YXRlKDEyMCA2NCA2NCkiLz4KICA8L2c+CiAgPGNpcmNsZSBjeD0iNjQiIGN5PSI2NCIgcj0iMTEiIGZpbGw9IiM3YzRkZmYiLz4KPC9zdmc+Cg=="}) }) : { script: {"name":"Fusion*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Merge-recordings assistant for MusicBrainz: gather a pool of candidate recordings from a release / release group / recording page (or paste any MBID/URL), auto-match them into merge groups by ISRC / AcoustID / length / title+artist, review and adjust the groups, then submit the merges directly in the background — no MB merge page involved.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/fusion/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPHRpdGxlPkZ1c2lvbjwvdGl0bGU+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjYTE4NGY4IiBzdHJva2Utd2lkdGg9IjUuNSI+CiAgICA8ZWxsaXBzZSBjeD0iNjQiIGN5PSI2NCIgcng9IjUyIiByeT0iMjIiLz4KICAgIDxlbGxpcHNlIGN4PSI2NCIgY3k9IjY0IiByeD0iNTIiIHJ5PSIyMiIgdHJhbnNmb3JtPSJyb3RhdGUoNjAgNjQgNjQpIi8+CiAgICA8ZWxsaXBzZSBjeD0iNjQiIGN5PSI2NCIgcng9IjUyIiByeT0iMjIiIHRyYW5zZm9ybT0icm90YXRlKDEyMCA2NCA2NCkiLz4KICA8L2c+CiAgPGNpcmNsZSBjeD0iNjQiIGN5PSI2NCIgcj0iMTEiIGZpbGw9IiM3YzRkZmYiLz4KPC9zdmc+Cg=="} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ try { (function(){ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('fusion', 'Fusion')) return; const VERSION = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '?'; // GM_info carries the real one; every script manager provides it (a hard-coded copy only ever went stale) const HELP_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/fusion/README.md'; const ICON = '⚛'; // the corner launcher wears the script's own icon (the same picture as @icon) const ICON_URL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPHRpdGxlPkZ1c2lvbjwvdGl0bGU+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjYTE4NGY4IiBzdHJva2Utd2lkdGg9IjUuNSI+CiAgICA8ZWxsaXBzZSBjeD0iNjQiIGN5PSI2NCIgcng9IjUyIiByeT0iMjIiLz4KICAgIDxlbGxpcHNlIGN4PSI2NCIgY3k9IjY0IiByeD0iNTIiIHJ5PSIyMiIgdHJhbnNmb3JtPSJyb3RhdGUoNjAgNjQgNjQpIi8+CiAgICA8ZWxsaXBzZSBjeD0iNjQiIGN5PSI2NCIgcng9IjUyIiByeT0iMjIiIHRyYW5zZm9ybT0icm90YXRlKDEyMCA2NCA2NCkiLz4KICA8L2c+CiAgPGNpcmNsZSBjeD0iNjQiIGN5PSI2NCIgcj0iMTEiIGZpbGw9IiM3YzRkZmYiLz4KPC9zdmc+Cg=='; const W = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); // Only mount on the exact entity pages Fusion knows how to seed from — never on // action subpages (edit, edit-relationships, merge, tags, …), so it never collides // with MB's own /recording/merge page or Group Therapy's edit-relationships tools. function detectScope() { const p = location.pathname; let m = p.match(/^\/release\/([0-9a-fA-F-]{36})\/?$/); if (m) return { type: 'release', mbid: m[1].toLowerCase() }; m = p.match(/^\/release-group\/([0-9a-fA-F-]{36})\/?$/); if (m) return { type: 'release-group', mbid: m[1].toLowerCase() }; m = p.match(/^\/recording\/([0-9a-fA-F-]{36})\/?$/); if (m) return { type: 'recording', mbid: m[1].toLowerCase() }; m = p.match(/^\/artist\/([0-9a-fA-F-]{36})\/recordings\/?$/); if (m) return { type: 'artist-recordings', mbid: m[1].toLowerCase() }; return null; } const SCOPE = detectScope(); if (!SCOPE) return; // ── tiny DOM helpers ───────────────────────────────────────────────────── const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }; const escapeHtml = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); /* ── activity log: the shared window and buffer (mbuLog, in the ST-UI block below; #283 / #529, shared since X12 of #623). Every Log.* line lands there, and the Log button opens it, copy/pastable as a Markdown <details> block. ── */ const Log = mbuLog({ name: 'Fusion', version: VERSION, header: 'Fusion — activity log', key: 'fusion.logwin', before: () => fsStyle() }); Log.info(mbuStartupInfo('Fusion')); const openLog = () => Log.open(); /* ── GM_xmlhttpRequest promisified (ported from isrc_scout's http/gmGet/gmPost) — used only for the merge_queue/merge GET+POST sequence; everything else (read-only same-origin WS2/ws-js calls) uses plain fetch(), same as group_therapy's txp* helpers. ── */ function http(opts) { const t0 = Date.now(); const tag = (opts.method || 'GET') + ' ' + String(opts.url).replace(location.origin, ''); Log.info('→ ' + tag); return new Promise((resolve, reject) => { GM_xmlhttpRequest(Object.assign({ timeout: 20000, onload: r => { const ms = Date.now() - t0; if (r.status >= 200 && r.status < 300) Log.info('← ' + r.status + ' ' + tag + ' (' + ms + 'ms)'); else Log.warn('← ' + r.status + ' ' + tag + ' (' + ms + 'ms)'); resolve(r); }, onerror: () => { Log.error('✗ network ' + tag); reject(new Error('network error')); }, ontimeout: () => { Log.error('✗ timeout ' + tag); reject(new Error('timeout')); }, }, opts)); }); } const gmGet = (url, headers) => http({ method: 'GET', url, headers: headers || {} }); const gmPost = (url, data, headers) => http({ method: 'POST', url, data, headers: headers || {} }); // MusicBrainz's /ws/2 goes through the shared request gate (#633): one budget for every // script and tab on the origin, and one 503 holds them all. (#529 had Fusion's own global // gate for the same reasons: its enrichment workers kept knocking while the server asked // everyone to wait. The shared gate keeps that, across scripts.) // <ST-NET> — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT const MBN_GAP = 1000; const MBN_BURST = 3; const MBN_KEY = "mbu:mb-gate"; const MBN_LOCK = "mbu-mb-gate"; const MBN_MAX_HOLD = 60000; function mbnGated(url) { const s = String(url || ''); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null'); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; // no storage: this page's copy only else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) {} return out; }; // The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is // stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang // every request behind it. let timer = 0; try { if (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === 'function' ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3000); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState(s => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? 'MusicBrainz asked everyone to wait' : 'pacing, one request a second' }; }); let told = false; for (;;) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); // a hold that appeared while we queued if (left <= 0) break; if (!told && until - t0 > 1000 && o.log) { told = true; o.log('info', 'MusicBrainz gate: ' + (o.label || 'request') + ' waits ' + Math.round((until - t0) / 100) / 10 + 's (' + (cool > booked.at ? 'MusicBrainz asked everyone to wait' : booked.why) + ')'); } await new Promise(r => setTimeout(r, Math.min(left, 250))); // in slices, so a cancel lands promptly } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header('Retry-After') : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState(s => { const now = Date.now(); // One throttle, one step: the requests already in flight when it began all answer // 503 too, and each must not stretch the hold again. A throttle long gone doesn't // count at all: `hot` is shared and persists, and would otherwise slow every later // page for good. if (now > (s.cool || 0) + 30000) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1000, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log('warn', 'MusicBrainz gate: HTTP ' + status + (raw != null ? ' (Retry-After: ' + raw + ')' : '') + ' — every script holds ' + Math.round(hold / 100) / 10 + 's'); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState(s => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 30000 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) { const r = await fetch(url, init); if (!mbnWebsite(url) || !(await mbnIsCheck(r))) return r; // #733: MusicBrainz's browser check answered instead: nothing was done (a POST is safe // to repeat), so pass the check and ask again return (await mbnReverify(url, init, o)) ? fetch(url, init) : r; } const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, n => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnHot() { let s; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; } return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over } function mbnRawHeader(raw, name) { const want = String(name).toLowerCase(); for (const line of String(raw || '').split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim(); } return null; } function mbnWebsite(url) { const s = String(url || ''); if (mbnGated(s)) return false; if (/^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org(\/|$)/i.test(s)) return true; return /^\/(?!\/)/.test(s) && typeof location !== 'undefined' && /(^|\.)musicbrainz\.org$/i.test(location.hostname); } async function mbnIsCheck(r) { try { if (!r || !/text\/html/i.test((r.headers && r.headers.get('content-type')) || '')) return false; const head = (await r.clone().text()).slice(0, 3000); return /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(head); } catch (e) { return false; } } function mbnReverify(url, init, o) { o = o || {}; const log = (lvl, m) => { try { if (o.log) o.log(lvl, m); else (lvl === 'warn' ? console.warn : console.info)('[MusicBrainz gate] ' + m); } catch (e) {} }; const g = globalThis.__mbnCheck || (globalThis.__mbnCheck = { busy: null, failedAt: 0 }); if (g.busy) return g.busy; let target; try { target = new URL(String(url), location.href); } catch (e) { return Promise.resolve(false); } if (target.host !== location.host || typeof document === 'undefined') { log('warn', 'MusicBrainz asked to verify the browser — open any MusicBrainz page, then retry'); return Promise.resolve(false); } if (Date.now() - g.failedAt < 60000) return Promise.resolve(false); // just failed: don't loop // a POST's URL can't be loaded: the check is per browser, so any light GET passes it const method = String((init && init.method) || 'GET').toUpperCase(); const src = method === 'GET' ? target.href : location.origin + '/ws/js/entity/89ad4ac3-39f7-470e-963a-56509c546377'; const t0 = Date.now(); log('warn', 'MusicBrainz asked to verify the browser again ("Verifying your browser") — passing it in a hidden frame'); g.busy = new Promise(resolve => { const fr = document.createElement('iframe'); // fresh each time, never re-navigated fr.setAttribute('aria-hidden', 'true'); fr.setAttribute('data-mbn-check', ''); fr.tabIndex = -1; fr.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;top:0;border:0;opacity:0;pointer-events:none'; let loads = 0, done = false, timer = 0; const finish = ok => { if (done) return; done = true; clearTimeout(timer); fr.remove(); g.busy = null; if (ok) log('info', 'browser check passed in ' + (Date.now() - t0) + ' ms — retrying'); else { g.failedAt = Date.now(); const m = 'MusicBrainz wants to verify your browser — open any MusicBrainz page in another tab, then retry'; log('warn', m + ' (the hidden frame did not clear it in 20 s)'); try { if (o.notify) o.notify(m); } catch (e) {} } resolve(ok); }; // the first load is the check; it posts its answer and is redirected to the real URL fr.onload = () => { loads++; let still; try { const d = fr.contentDocument; still = !d || /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(d.documentElement ? d.documentElement.outerHTML.slice(0, 3000) : ''); } catch (e) { still = loads < 2; } if (!still) finish(true); }; timer = setTimeout(() => finish(false), 20000); fr.src = src; (document.body || document.documentElement).appendChild(fr); }); return g.busy; } // </ST-NET> let _netTrouble = null; // { kind, detail, at } — surfaced in the title bar, not just logged function setNetTrouble(kind, detail) { _netTrouble = { kind, detail, at: Date.now() }; renderNetBanner(); } function clearNetTrouble() { if (_netTrouble) { _netTrouble = null; renderNetBanner(); } } // #529 (majkinetor): "that yellowish network error appears and disappears now as // needed which is intrusive as it moves entire window up down. Let it just show // in the title instead where loading message is". It used to be a block in the // flow, so every throttle blip reflowed the pool and groups beneath it — on a // run with repeated 503s the whole window jittered. It now lives in the header // alongside the loading text, where appearing and vanishing costs no layout. function renderNetBanner() { const el = document.getElementById('fs-netbanner'); if (!el) return; if (!_netTrouble) { el.style.display = 'none'; el.textContent = ''; el.title = ''; return; } el.style.display = ''; const long = _netTrouble.kind === 'offline' ? 'No connection to MusicBrainz — ' + _netTrouble.detail : 'MusicBrainz is throttling requests — ' + _netTrouble.detail; // short in the bar, the detail in the tooltip: the header is a single line // and a long message would push the scope label out of it. el.textContent = '⚠ ' + (_netTrouble.kind === 'offline' ? 'no connection' : 'throttled'); el.title = long + ' — click for the log'; el.className = 'fs-netbanner' + (_netTrouble.kind === 'offline' ? ' fs-netbanner-err' : ''); } const gateLog = (lv, m) => (lv === 'warn' ? Log.warn : Log.info)(m); async function wsGet(path, retries) { retries = retries == null ? 4 : retries; for (let attempt = 0; attempt <= retries; attempt++) { await mbnSlot({ label: 'GET ' + path, log: gateLog }); // the shared pace, and any hold MusicBrainz asked for const t0 = Date.now(); try { Log.info('GET ' + path + (attempt ? ' (retry ' + attempt + '/' + retries + ')' : '')); // no-store: MB's WS2 sends NO cache headers, so browsers cache these // heuristically and can serve a stale response — which silently fed // Fusion out-of-date ISRC data. #529 const r = await fetch(path, { headers: { Accept: 'application/json' }, cache: 'no-store' }); const ms = Date.now() - t0; const a = await mbnAnswer(r.status, n => r.headers.get(n), { log: gateLog }); if (a.throttled) { // the retry waits out the shared hold in mbnSlot: no private backoff on top (#633) Log.warn('GET ' + path + ' → ' + r.status + ' (' + ms + 'ms) — MB busy'); if (attempt < retries) { setNetTrouble('throttled', 'paused ' + Math.round(a.hold / 1000) + 's as MusicBrainz asked'); continue; } Log.error('GET ' + path + ' gave up after ' + (retries + 1) + ' attempts (still ' + r.status + ')'); setNetTrouble('throttled', 'gave up on a request after ' + (retries + 1) + ' attempts (HTTP ' + r.status + ')'); return null; } if (!r.ok) { Log.warn('GET ' + path + ' → ' + r.status + ' (' + ms + 'ms)'); return null; } Log.info('← ' + r.status + ' ' + path + ' (' + ms + 'ms)'); clearNetTrouble(); // something got through — stop warning return await r.json(); } catch (e) { Log.error('GET ' + path + ' failed: ' + e.message + (attempt < retries ? ' — retrying' : ' — giving up')); if (attempt < retries) { await new Promise(res => setTimeout(res, 800 * Math.pow(2, attempt))); continue; } setNetTrouble('offline', e.message); return null; } } return null; } // ── settings (GM-persisted) ────────────────────────────────────────────── const SETTINGS_KEY = 'fusion.settings'; const SETTINGS_DEFAULTS = { lengthToleranceMs: 5000, grossLengthMs: 30000, acoustidEnrich: true, acoustidPoolCap: 2000, autoMatchOnOpen: false, prefetchGroupReleases: false, releasePrefetchCap: 200, poolCollapsed: false, makeVotable: false, matchCutoff: 'strict' }; function loadSettings() { // (#623: the one-time #529 migrations of the old AcoustID cap and prefetch default are retired) try { return Object.assign({}, SETTINGS_DEFAULTS, JSON.parse(GM_getValue(SETTINGS_KEY, '{}'))); } catch (e) { return Object.assign({}, SETTINGS_DEFAULTS); } } function saveSettings() { try { GM_setValue(SETTINGS_KEY, JSON.stringify(SETTINGS)); } catch (e) {} } let SETTINGS = loadSettings(); // mbRestackCorner, the #468 corner slots, is in the ST-UI block (dev/ui/ui-components.mjs). /* ── matching / normalization (ported from platform_check's tokenMatch/scoreCandidate normalization stack — same token-overlap approach, reused rather than reinvented) ── */ // #623 (sweep, X10): any script. It kept only [a-z0-9], so a non-Latin title or artist // normalised to "" and matched nothing. Diacritics still fold on Latin, Greek and // Cyrillic letters (é → e); other scripts keep their marks (voiced kana, Indic vowels). function normName(s) { return (s || '').toLowerCase().normalize('NFKD').replace(/(?<=[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}])\p{M}+/gu, '').normalize('NFC').replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ').trim(); } function tokenMatch(a, b, mode, threshold) { mode = mode || 'max'; threshold = threshold == null ? 0.6 : threshold; const na = normName(a), nb = normName(b); if (!na || !nb) return false; if (na === nb) return true; const tokensOf = s => new Set(s.split(' ').filter(t => t.length >= 2)); const ta = tokensOf(na), tb = tokensOf(nb); if (!ta.size || !tb.size) return false; let common = 0; for (const t of ta) if (tb.has(t)) common++; const denom = mode === 'min' ? Math.min(ta.size, tb.size) : Math.max(ta.size, tb.size); return common / denom >= threshold; } // #529 follow-up (majkinetor, live, with a screenshot): "Oburumankoma" vs // "Oburumakoma" — a single-word title with a one-letter typo shares ZERO // tokens (tokenMatch is all-or-nothing per word), so it never matched even // though it's obviously the same recording. A small Levenshtein-ratio fallback // catches near-identical spellings that token overlap structurally can't. function levenshtein(a, b) { const m = a.length, n = b.length; if (!m) return n; if (!n) return m; let prev = Array.from({ length: n + 1 }, (_, i) => i); for (let i = 1; i <= m; i++) { const cur = [i]; for (let j = 1; j <= n; j++) { cur[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : 1 + Math.min(prev[j], cur[j - 1], prev[j - 1]); } prev = cur; } return prev[n]; } function fuzzyRatio(a, b) { const na = normName(a).replace(/ /g, ''), nb = normName(b).replace(/ /g, ''); if (!na || !nb) return 0; if (na === nb) return 1; const maxLen = Math.max(na.length, nb.length); return 1 - levenshtein(na, nb) / maxLen; } const titleAlike = (a, b) => tokenMatch(a, b, 'max', 0.6) || fuzzyRatio(a, b) >= 0.85; /* #760 (ernstlx, forum): "In Volo - Drift" / "In Volo - Trace" share 2 of 3 words and "Dwell I" / "Dwell IV" are 92% alike by characters, so both passed as similar and, with the artist and lengths agreeing, were merged at normal. Parts and series name their tracks by the one word that differs. So when EACH title has a word the other lacks, they are different titles, unless those words only spell a mix or edit (majkinetor: "various ways to denote mixes and edits, but are the same"), a year, or a typo of each other ("Oburumankoma"). Words on one side only still match. Returns the two sides' distinct words, or null when there is no such difference. */ const TITLE_QUALIFIERS = new Set(['mix', 'mixed', 'edit', 'edited', 'version', 'ver', 'radio', 'album', 'single', 'lp', 'ep', 'extended', 'original', 'remaster', 'remastered', 'mastered', 'mono', 'stereo', 'explicit', 'clean', 'dirty', 'censored', 'uncensored', 'feat', 'ft', 'featuring', 'with', 'and', 'n', 'the', 'part', 'pt']); const isNumberWord = t => /^\d+$/.test(t) || /^[ivxlcdm]{1,4}$/.test(t); function titleDistinctWords(a, b) { const wa = new Set(normName(a).split(' ').filter(Boolean)), wb = new Set(normName(b).split(' ').filter(Boolean)); const own = (w, other) => [...w].filter(t => !other.has(t) && !TITLE_QUALIFIERS.has(t) && !/^(19|20)\d\d$/.test(t)); const da = own(wa, wb), db = own(wb, wa); // pair off a typo on one side with its spelling on the other; never numbers (I / IV) const typo = (t, u) => t.length >= 4 && u.length >= 4 && !isNumberWord(t) && !isNumberWord(u) && fuzzyRatio(t, u) >= 0.75; const left = da.filter(t => { const k = db.findIndex(u => typo(t, u)); if (k < 0) return true; db.splice(k, 1); return false; }); return left.length && db.length ? { a: left, b: db } : null; } const titleSimilar = (a, b) => titleAlike(a, b) && !titleDistinctWords(a, b); /* #760: two recordings whose ISRCs share none, but where one from each comes from the same registrant and year (the first 7 characters), are different tracks of one batch: QZTRX2699407 / QZTRX2699409. A reissue usually gets its ISRCs under another registrant or year, so it is not caught. */ const isrcStem = x => String(x).toUpperCase().slice(0, 7); function isrcConflict(a, b) { const ia = a.isrcs || [], ib = b.isrcs || []; if (!ia.length || !ib.length || ia.some(x => ib.includes(x))) return null; for (const x of ia) for (const y of ib) if (isrcStem(x) === isrcStem(y)) return x + ' / ' + y; return null; } const artistTokenCount = s => normName(s).split(' ').filter(t => t.length >= 2).length; const artistSimilar = (a, b) => tokenMatch(a, b, artistTokenCount(b) <= artistTokenCount(a) ? 'min' : 'max', 0.8); function lengthClose(a, b, tolMs) { if (a == null || b == null) return false; return Math.abs(a - b) <= tolMs; } // #529 follow-up: "We should be able to select confidence level for match" — // strict (default) = only hard identifiers; normal = identifiers, or // title+artist+length together; loose = identifiers, or title+length alone // (artist not required — useful for various-artist / remixer-credit noise). const MATCH_CUTOFFS = ['strict', 'normal', 'loose']; function shouldUnion(sig, cutoff) { // #529 follow-up (majkinetor): "Video recordings should never be added to // groups with audio recordings." A hard gate — no cutoff level, no signal // strength (not even a shared ISRC) overrides it. if (sig.videoMismatch) return false; if (sig.pendingEdit) return false; // never auto-group a recording with an open edit (#529) // #529 (majkinetor): "we should never have such a big difference in length // in auto merge" — a 0:42 reprise was auto-grouped with 4:11/4:17 takes, // because the loose cutoff's title+artist path checks no length at all. // A grossly different KNOWN length now blocks auto-grouping outright, ahead // of even ISRC/AcoustID: those identify a work, not necessarily the same // take, and four minutes apart is never the same recording. Manual grouping // is still allowed — that's a deliberate human decision, not a guess. if (sig.lengthConflict) return false; // #760: different tracks of one ISRC batch. Ahead of AcoustID, which can // collide on short or quiet tracks; a shared ISRC never sets this. if (sig.isrcConflict) return false; if (sig.isrc || sig.acoustid) return true; if (cutoff === 'strict') return false; if (cutoff === 'loose') return (sig.title && sig.length) || (sig.title && sig.artist); // 'normal' — identifiers, or title AND artist corroborated by length. // #565 (majkinetor): an UNKNOWN length must not veto. pairSignals already // documents that a missing length "just means we can't tell", but requiring // sig.length here made "can't tell" behave exactly like "they disagree": on a // release-group where one release carries no lengths at all, normal formed // zero groups even for titles that were identical bar their capitalisation, // and the only way to get any match was to drop to loose — which weakens // every OTHER pair in the pool at the same time. // // Two lengths that are both KNOWN and not close still block, which is the // case worth blocking; grossly different ones are already out above. return sig.title && sig.artist && (sig.length || sig.lengthUnknown); } function acName(ac) { if (!Array.isArray(ac)) return ''; return ac.map(x => (x.name || (x.artist && x.artist.name) || '') + (x.joinphrase || '')).join(''); } function acPrimaryGid(ac) { return (Array.isArray(ac) && ac[0] && ac[0].artist && ac[0].artist.id) || null; } /* #594 (chaban-mb): every artist MBID in the credit, in order. acPrimaryGid deliberately stays — it is what the artist LINK in the table points at — but matching needs the whole credit. Comparing only the primary would call "Radium" and "Radium feat. Someone" the same artist, which is the opposite mistake to the one being fixed. */ function acGids(ac) { return Array.isArray(ac) ? ac.map(x => x.artist && x.artist.id).filter(Boolean) : []; } function dur(ms) { if (ms == null) return '—'; const s = Math.round(ms / 1000); const m = Math.floor(s / 60), r = s % 60; return m + ':' + String(r).padStart(2, '0'); } function parseMbidFromInput(s) { const m = String(s || '').match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/); return m ? m[0].toLowerCase() : null; } // ── recording model + fetchers ─────────────────────────────────────────── function mkRecording(gid, opts) { // video: null = unknown yet (e.g. artist-page scrape, backfilled lazily by // enrichAllReleases); true/false once known. #529: "Video recordings should // never be added to groups with audio recordings" — null is deliberately // treated as "don't block" everywhere, only a known true/false mismatch does. return Object.assign({ gid, title: '', length: null, isrcs: [], artistCredit: '', artistGid: null, artistGids: [], releases: [], allReleases: null, acoustids: null, video: null, editsPending: null, isrcsKnown: false, isrcSource: null }, opts || {}); } async function fetchReleaseRecordings(releaseMbid) { const j = await wsGet('/ws/2/release/' + releaseMbid + '?inc=recordings+isrcs+artist-credits&fmt=json'); if (!j) return { release: null, recordings: [] }; const recs = []; for (const m of j.media || []) { for (const t of m.tracks || []) { const r = t.recording || {}; const ac = r['artist-credit'] || j['artist-credit']; recs.push(mkRecording(r.id, { title: r.title || t.title, length: r.length != null ? r.length : t.length, isrcs: r.isrcs || [], artistCredit: acName(ac), artistGid: acPrimaryGid(ac), artistGids: acGids(ac), video: !!r.video, isrcsKnown: true, isrcSource: 'index', releases: [{ gid: j.id, title: j.title, trackNumber: t.number || null, trackCount: m['track-count'] || null }], })); } } Log.info('Release seed: ' + recs.length + ' recording(s) from "' + j.title + '"'); return { release: { gid: j.id, title: j.title, artistCredit: acName(j['artist-credit']) }, recordings: recs }; } // Enumerating an artist's recordings via the INDEXED SEARCH is unsound: paging // it with offsets returns the same recording on several pages while never // returning others at all. Measured on Mocky (310 recordings), same minute: // // browse ?artist= 310 returned, 310 distinct, 0 duplicates // search query=arid: 310 returned, 224 distinct, 86 duplicates // …and 86 recordings browse found that search never // returned once. // // That is why majkinetor's pool size wandered between runs (210, 185, 224) and // why AcoustIDs "disappeared": the RECORDINGS were absent, not their data. The // browse endpoint reads the database directly and pages deterministically, so // membership comes from there now. Its ISRCs are DB-backed too, hence // isrcSource 'entity' rather than the search index's 'index'. (#529) async function fetchRecordingsByBrowse(browseQuery, label, onPage) { const recordings = []; let offset = 0, total = Infinity, pages = 0, truncatedBy = null; while (offset < total && pages < SEARCH_MAX_PAGES) { if (onPage) onPage(pages + 1, Number.isFinite(total) ? Math.ceil(total / SEARCH_PAGE_LIMIT) : null, recordings.length, Number.isFinite(total) ? total : null); const j = await wsGet('/ws/2/recording?' + browseQuery + '&inc=isrcs+artist-credits&fmt=json&limit=' + SEARCH_PAGE_LIMIT + '&offset=' + offset); if (!j) { truncatedBy = 'a failed request'; break; } total = j['recording-count'] != null ? j['recording-count'] : 0; for (const r of j.recordings || []) { const ac = r['artist-credit']; // No releases here: browse does not accept inc=releases ("releases is // not a valid inc parameter"). They are filled in afterwards, best // effort, and stay unknown rather than empty when they cannot be. recordings.push(mkRecording(r.id, { title: r.title, length: r.length, isrcs: r.isrcs || [], isrcsKnown: true, isrcSource: 'entity', artistCredit: acName(ac), artistGid: acPrimaryGid(ac), artistGids: acGids(ac), video: !!r.video, releases: [], allReleases: null, })); } offset += SEARCH_PAGE_LIMIT; pages++; if (pages >= SEARCH_MAX_PAGES && offset < total) truncatedBy = 'the ' + SEARCH_MAX_PAGES + '-page cap'; } const known = Number.isFinite(total); if (known && total > recordings.length) Log.warn(label + ': loaded ' + recordings.length + ' of ' + total + ' — stopped by ' + (truncatedBy || 'an incomplete response')); if (!known) Log.error(label + ': could not load anything — MusicBrainz did not answer'); else Log.info(label + ': ' + recordings.length + ' recording(s) of ' + total + ' total'); return { recordings, total }; } // The search index is still the cheapest source of "every release this recording // appears on", including compilations by other artists — it is only its PAGING // that cannot be trusted. So it runs as a best-effort enricher over a membership // list that came from browse: whatever it returns gets used, whatever it misses // keeps allReleases null and is fetched on demand later. // Whatever the search index did not cover is fetched per recording afterwards, // in the background. Without this the pool column would sit on "—" forever for // those recordings (#529: the browse seed cannot ask for releases, and the // index missed 86 of 310 on the artist that surfaced this). Non-blocking, and // it goes through the same rate-limit gate as everything else. async function backfillMissingReleases(recordings) { if (_bgStopped) return; const missing = recordings.filter(r => r.allReleases == null); if (!missing.length) return; Log.info('Filling in release lists for ' + missing.length + ' recording(s) the search index did not return'); await enrichAllReleases(missing, 3, (done, total) => { setBgTask('Loading release lists ' + done + '/' + total + '…'); scheduleBackgroundRender(); }); setBgTask(''); const left = recordings.filter(r => r.allReleases == null).length; Log.info('Release lists: backfill finished' + (left ? ' — ' + left + ' still unknown' : '')); } async function enrichReleasesFromSearch(luceneQuery, recordings) { const byGid = new Map(recordings.map(r => [r.gid, r])); let offset = 0, total = Infinity, pages = 0, matched = 0; while (offset < total && pages < SEARCH_MAX_PAGES) { const j = await wsGet('/ws/2/recording?query=' + encodeURIComponent(luceneQuery) + '&fmt=json&limit=' + SEARCH_PAGE_LIMIT + '&offset=' + offset); if (!j) break; total = j.count || 0; for (const r of j.recordings || []) { const rec = byGid.get(r.id); if (!rec || rec.allReleases != null) continue; const releases = (r.releases || []).map(rel => { let trackNumber = null; for (const med of rel.media || []) { if (med.track && med.track[0]) { trackNumber = med.track[0].number || null; break; } } return { gid: rel.id, title: rel.title, trackNumber, trackCount: rel['track-count'] || null, date: rel.date || null }; }); rec.releases = releases; rec.allReleases = releases; matched++; } offset += SEARCH_PAGE_LIMIT; pages++; } const without = recordings.filter(r => r.allReleases == null).length; Log.info('Release lists: ' + matched + ' of ' + recordings.length + ' recording(s) covered by the search index' + (without ? ' — ' + without + ' left to load on demand (the index did not return them)' : '')); } // Shared paginated recording search. The indexed search returns each // recording's releases, artist credit, ISRCs and video flag inline, so one // query per 100 recordings covers everything the pool needs — no per-recording // follow-ups. Used for both release-group (rgid:) and artist (arid:) seeding. const SEARCH_PAGE_LIMIT = 100; const SEARCH_MAX_PAGES = 20; // 2000 recordings; guards a huge artist async function fetchRecordingsBySearch(luceneQuery, label, onPage) { const recordings = []; let offset = 0, total = Infinity, pages = 0, truncatedBy = null; while (offset < total && pages < SEARCH_MAX_PAGES) { // #529: "it would be cool to have some progress in 'Loading ..' (page 1, // 2...)" — a big artist is several sequential requests with nothing on // screen changing, so report before each one rather than after. if (onPage) onPage(pages + 1, Number.isFinite(total) ? Math.ceil(total / SEARCH_PAGE_LIMIT) : null, recordings.length, Number.isFinite(total) ? total : null); const j = await wsGet('/ws/2/recording?query=' + encodeURIComponent(luceneQuery) + '&fmt=json&limit=' + SEARCH_PAGE_LIMIT + '&offset=' + offset); if (!j) { truncatedBy = 'a failed request'; break; } total = j.count || 0; for (const r of j.recordings || []) { const releases = (r.releases || []).map(rel => { let trackNumber = null; for (const med of rel.media || []) { if (med.track && med.track[0]) { trackNumber = med.track[0].number || null; break; } } return { gid: rel.id, title: rel.title, trackNumber, trackCount: rel['track-count'] || null, date: rel.date || null }; }); const ac = r['artist-credit']; // the search already returns every release a recording appears on, // across every release group — so it doubles as the deduped // "all releases" list, with no extra per-recording fetch. recordings.push(mkRecording(r.id, { title: r.title, length: r.length, isrcs: r.isrcs || [], artistCredit: acName(ac), artistGid: acPrimaryGid(ac), artistGids: acGids(ac), video: !!r.video, isrcsKnown: true, isrcSource: 'index', releases, allReleases: releases })); } offset += SEARCH_PAGE_LIMIT; pages++; if (pages >= SEARCH_MAX_PAGES && offset < total) truncatedBy = 'the ' + SEARCH_MAX_PAGES + '-page cap'; } // total stays Infinity when the FIRST page never came back — reporting // "0 of Infinity" is worse than admitting we don't know (#529). const known = Number.isFinite(total); // Don't blame the page cap for every shortfall: a 503'd page stops the loop // after 3 pages and reporting that as "the 20-page cap" sends you looking in // entirely the wrong place (#529 — it read "stopped at the 20-page cap" // immediately under a give-up-after-5-attempts error). if (known && total > recordings.length) Log.warn(label + ': loaded ' + recordings.length + ' of ' + total + ' — stopped by ' + (truncatedBy || 'an incomplete response')); if (!known) Log.error(label + ': could not load anything — MusicBrainz did not answer'); else Log.info(label + ': ' + recordings.length + ' recording(s) of ' + total + ' total (' + luceneQuery + ')'); return { recordings, total: known ? total : recordings.length }; } // Same unsound-search problem as the artist seed (#529), and here the fix is // strictly better than a workaround: browsing RELEASES in the release group with // inc=recordings walks the actual tracklists, so it is deterministic AND already // knows which release each recording sits on — no search enrichment needed, and // the ISRCs come from the database rather than the index. async function fetchRGRecordings(rgMbid, onPage) { const rgMeta = await wsGet('/ws/2/release-group/' + rgMbid + '?inc=artist-credits&fmt=json'); const rg = rgMeta ? { gid: rgMeta.id, title: rgMeta.title, artistCredit: acName(rgMeta['artist-credit']) } : null; const byGid = new Map(); let offset = 0, total = Infinity, pages = 0; while (offset < total && pages < SEARCH_MAX_PAGES) { if (onPage) onPage(pages + 1, Number.isFinite(total) ? Math.ceil(total / SEARCH_PAGE_LIMIT) : null, byGid.size, null); const j = await wsGet('/ws/2/release?release-group=' + rgMbid + '&inc=recordings+artist-credits+isrcs&fmt=json&limit=' + SEARCH_PAGE_LIMIT + '&offset=' + offset); if (!j) { Log.warn('RG seed: a request failed — the list may be incomplete'); break; } total = j['release-count'] != null ? j['release-count'] : 0; for (const rel of j.releases || []) { const media = rel.media || []; for (const med of media) { for (const t of med.tracks || []) { const r = t.recording; if (!r || !r.id) continue; const relRef = { gid: rel.id, title: rel.title, trackNumber: t.number || null, trackCount: med['track-count'] || null, date: rel.date || null }; const existing = byGid.get(r.id); if (existing) { if (!existing.releases.some(x => x.gid === rel.id)) existing.releases.push(relRef); continue; } const ac = r['artist-credit'] || t['artist-credit']; byGid.set(r.id, mkRecording(r.id, { title: r.title, length: r.length != null ? r.length : t.length, isrcs: r.isrcs || [], isrcsKnown: true, isrcSource: 'entity', artistCredit: acName(ac), artistGid: acPrimaryGid(ac), artistGids: acGids(ac), video: !!r.video, releases: [relRef], allReleases: null, })); } } } offset += SEARCH_PAGE_LIMIT; pages++; } const recordings = [...byGid.values()]; // releases here are the ones INSIDE this release group; a recording may also // appear elsewhere, so allReleases stays null until asked for. recordings.forEach(r => { r.releases = r.releases.slice(); }); Log.info('RG seed: ' + recordings.length + ' distinct recording(s) across ' + (Number.isFinite(total) ? total : '?') + ' release(s)'); return { rg, recordings }; } // #529 (majkinetor): "scraping the page is not going to cut it, we need to use // an API here" — the artist page is paginated at 100 rows and its DOM layout // shifts with login state, so the whole artist catalogue now comes from the // indexed search instead (297 vs 100 for the artist that surfaced this). async function fetchArtistRecordings(artistMbid, onPage) { const meta = await wsGet('/ws/2/artist/' + artistMbid + '?fmt=json'); const artist = meta ? { gid: meta.id, name: meta.name } : null; const { recordings, total } = await fetchRecordingsByBrowse('artist=' + artistMbid, 'Artist seed', onPage); await enrichReleasesFromSearch('arid:' + artistMbid, recordings); backfillMissingReleases(recordings).catch(e => Log.warn('Release backfill failed: ' + e.message)); return { artist, recordings, total }; } // MB's own merge checkboxes on an artist-recordings page carry the internal // numeric id Fusion would otherwise fetch one /ws/js/entity call at a time. // Purely additive: costs nothing, and merging from that page skips those calls. function harvestInternalIdsFromPage() { let n = 0; for (const tr of document.querySelectorAll('table.tbl tbody > tr')) { const a = tr.querySelector('a[href^="/recording/"]'); const cb = tr.querySelector('input[name="add-to-merge"]'); if (!a || !cb || !/^\d+$/.test(cb.value)) continue; const gid = (a.getAttribute('href').match(/[0-9a-fA-F-]{36}/) || [])[0]; if (gid) { _idCache.set(gid, Number(cb.value)); n++; } } if (n) Log.info('Harvested ' + n + ' internal recording id(s) from the page — those merges skip a lookup'); return n; } async function fetchRecordingByGid(gid) { const j = await wsGet('/ws/2/recording/' + gid + '?inc=releases+isrcs+artist-credits&fmt=json'); if (!j) return null; const releases = (j.releases || []).map(rel => ({ gid: rel.id, title: rel.title, trackNumber: null, trackCount: null, date: rel.date || null })); const ac = j['artist-credit']; return mkRecording(j.id, { title: j.title, length: j.length, isrcs: j.isrcs || [], artistCredit: acName(ac), artistGid: acPrimaryGid(ac), artistGids: acGids(ac), video: !!j.video, isrcsKnown: true, isrcSource: 'entity', releases, allReleases: releases }); } // #529 follow-up (majkinetor, with a screenshot of jesus2099's reference // script): "We should have a list of recording releases too (deduped)" — the // full set of releases a recording appears on, not just the one it was seeded // from. Release/recording-page seeding only knows about ONE release per // recording at fetch time, so this backfills the rest lazily. async function fetchAllReleases(gid) { const j = await wsGet('/ws/2/recording/' + gid + '?inc=releases&fmt=json'); // null, not an empty list: a failed lookup is not "this recording is on no // release". Returning [] here made enrichAllReleases record the absence as // fact — the same overclaim the AcoustID and ISRC paths already had to lose. if (!j) return null; const seen = new Set(); const out = []; for (const rel of j.releases || []) { if (seen.has(rel.id)) continue; seen.add(rel.id); out.push({ gid: rel.id, title: rel.title, trackNumber: null, trackCount: null, date: rel.date || null }); } return { releases: out, video: !!j.video }; } // #529 (majkinetor, with a screenshot of MB's own merge-page release table): // "We need all details when merging. Lets have all releases expandable for a // recording." Browsing releases BY recording is the only single request that // carries every column that table shows — label and catalogue number are not // valid inc parameters on the recording resource at all ("labels is not a valid // inc parameter for the recording resource"), and inc=recordings is what turns // the media summary into real tracks, giving the track's own number, length and // artist as they appear on that particular release. // Fetched on demand, per recording, only when a row is actually expanded. async function fetchReleaseDetails(gid) { const j = await wsGet('/ws/2/release?recording=' + gid + '&inc=labels+media+release-groups+artist-credits+recordings&limit=100&fmt=json'); if (!j) return null; const out = []; for (const rel of j.releases || []) { const li = (rel['label-info'] || [])[0] || {}; const rg = rel['release-group'] || {}; const ev = (rel['release-events'] || [])[0] || {}; const area = ev.area || {}; // Find the track for THIS recording; a release can list it more than once. let track = null, medium = null; for (const med of rel.media || []) { for (const t of med.tracks || []) { if (t.recording && t.recording.id === gid) { track = t; medium = med; break; } } if (track) break; } const secondary = (rg['secondary-types'] || []).join(' + '); out.push({ gid: rel.id, status: rel.status || null, title: rel.title || '', artist: acName(rel['artist-credit']), rgType: [rg['primary-type'] || '', secondary].filter(Boolean).join(' + ') || null, country: rel.country || (area['iso-3166-1-codes'] || [])[0] || null, date: rel.date || ev.date || null, label: li.label ? li.label.name : null, catalog: li['catalog-number'] || null, format: medium ? medium.format || null : null, trackNumber: track ? track.number : null, trackTitle: track ? track.title : null, trackLength: track ? (track.length || (track.recording && track.recording.length) || null) : null, trackArtist: track ? acName(track['artist-credit']) : null, discNumber: medium && (rel.media || []).length > 1 ? medium.position : null, }); } // Group the way MB's own table does: Official first, then the rest. const rank = s => (s === 'Official' ? 0 : s ? 1 : 2); out.sort((a, b) => rank(a.status) - rank(b.status) || String(a.date || '').localeCompare(String(b.date || ''))); return out; } // also backfills `video` for recordings whose seed source didn't carry it // (the artist-recordings DOM scrape has no video indicator in the table). async function enrichAllReleases(recs, concurrency, onProgress) { concurrency = concurrency || 3; let i = 0, done = 0; const epoch = _bgEpoch; async function worker() { while (i < recs.length) { if (!bgAlive(epoch)) return; const rec = recs[i++]; if (rec.allReleases == null) { const r = await fetchAllReleases(rec.gid); if (r) { rec.allReleases = r.releases; /* #581 (chaban-mb): "Fusion claims (no release) for some recordings in the table yet when hovering it shows the release." The cell reads rec.releases[0]; the tooltip reads rec.allReleases. This backfill filled only the second, so a recording the search index had missed ended up with a full tooltip above a cell that flatly denied it. storeReleaseDetails — the expand-on-demand path — already seeds both, for this exact reason (#529); this one was written later and did not. Only when empty: a row seeded from a release page carries that release as its primary and must keep it. */ if (!rec.releases || !rec.releases.length) rec.releases = r.releases; if (rec.video == null) rec.video = r.video; } } done++; if (onProgress) onProgress(done, recs.length); } } await Promise.all(Array.from({ length: Math.min(concurrency, recs.length) }, worker)); } // #529 (majkinetor): "we should also have recordings with pending edits // highlighted … Probably shouldn't include them in auto merging too." // WS2 doesn't expose pending edits at all; MB's internal /ws/js/entity does // (editsPending), and Fusion already calls it for internal ids — so one fetch // yields both. Merging an entity with an open edit risks compounding or // conflicting with whatever is already in the queue. const _pendingCache = new Map(); async function fetchEntityMeta(gid) { if (_pendingCache.has(gid)) return _pendingCache.get(gid); let meta = null; try { const j = await mbnFetch('/ws/js/entity/' + gid, { headers: { Accept: 'application/json' } }).then(r => r.json()); if (j && j.gid) meta = { id: j.id || null, editsPending: !!j.editsPending }; } catch (e) { Log.error('fetchEntityMeta(' + gid + ') failed: ' + e.message); } _pendingCache.set(gid, meta); if (meta && meta.id) _idCache.set(gid, meta.id); return meta; } async function enrichPendingEdits(recs, concurrency, onProgress) { const todo = recs.filter(r => r.editsPending == null); if (!todo.length) return; concurrency = concurrency || 2; // one MB request per recording — go easy (#529) let i = 0, done = 0, flagged = 0; async function worker() { while (i < todo.length) { const rec = todo[i++]; const meta = await fetchEntityMeta(rec.gid); rec.editsPending = meta ? meta.editsPending : false; if (rec.editsPending) flagged++; done++; if (onProgress) onProgress(done, todo.length); } } await Promise.all(Array.from({ length: Math.min(concurrency, todo.length) }, worker)); if (flagged) Log.warn(flagged + ' recording(s) have pending edits — excluded from matching and blocked from merging'); } const _idCache = new Map(); async function resolveInternalId(gid) { if (_idCache.has(gid)) return _idCache.get(gid); let id = null; try { const j = await mbnFetch('/ws/js/entity/' + gid, { headers: { Accept: 'application/json' } }).then(r => r.json()); id = j && j.id ? j.id : null; } catch (e) { Log.error('resolveInternalId(' + gid + ') failed: ' + e.message); } _idCache.set(gid, id); return id; } // AcoustID matches are read from MB's own recording→URL relationships (rels Picard // already creates on submit) rather than calling the AcoustID API directly — no API // key, no rate limit, same-origin only. Trades "misses recordings never Picard-tagged" // for "zero external dependency"; ISRC/title/artist/length still catch those. // Also pulls isrcs: this is an authoritative per-recording lookup, unlike the // rgid: SEARCH used for RG seeding, whose index can lag or omit fields. #529 — // a recording that demonstrably had an ISRC showed isrc=none after RG seeding, // so anything derived from search results gets reconciled against this. async function fetchRecordingDetail(gid) { const j = await wsGet('/ws/2/recording/' + gid + '?inc=isrcs&fmt=json'); if (!j) return null; return { isrcs: j.isrcs || [], video: !!j.video }; } // #529 (majkinetor): "I didn't see a single AcoustID, although all should have // it basically" — correct, and the original approach was simply wrong: MB does // NOT store AcoustIDs as recording→URL relationships (verified: a recording // with two AcoustIDs has zero url-rels). They live on AcoustID's own service. // list_by_mbid needs no API key and supports batching, so one request covers // up to ACOUSTID_BATCH recordings. const ACOUSTID_BATCH = 50; // Returns a Map on success, or NULL when the lookup itself failed. The // distinction matters: this used to return an empty Map for a transport error // exactly as it does for "asked, found nothing", and the caller then wrote [] // onto all 50 recordings in the batch — recording a FAILURE as the confident // claim "this recording has no AcoustID" (#529: "in previous run I didn't get // all acoustIDs, in repeated run they showed"). A fresh run got fresh state and // a working request, which is exactly why a repeat looked like a fix. async function fetchAcoustIdsBatch(gids) { if (!gids.length) return new Map(); const qs = gids.map(g => 'mbid=' + encodeURIComponent(g)).join('&'); const url = 'https://api.acoustid.org/v2/track/list_by_mbid?' + qs + '&batch=1&format=json'; const out = new Map(); try { const r = await gmGet(url, { Accept: 'application/json' }); if (r.status < 200 || r.status >= 300) { Log.warn('AcoustID lookup failed: HTTP ' + r.status + ' for ' + gids.length + ' recording(s) — left unknown, not recorded as "none"'); return null; } const j = JSON.parse(r.responseText || '{}'); if (j.status !== 'ok') { Log.warn('AcoustID lookup returned status=' + j.status + ' for ' + gids.length + ' recording(s) — left unknown'); return null; } for (const entry of j.mbids || []) out.set(entry.mbid, (entry.tracks || []).map(t => t.id)); } catch (e) { Log.error('AcoustID lookup error: ' + e.message + ' — ' + gids.length + ' recording(s) left unknown'); return null; } return out; } async function fetchAcoustIds(gid) { const map = await fetchAcoustIdsBatch([gid]); return map ? (map.get(gid) || []) : null; } // ── pool / groups state ────────────────────────────────────────────────── const STATE = { recordings: new Map(), poolOrder: [], groups: [], poolFilter: '', selected: null, activeGroupId: null, _dragSrc: null, releaseInfo: null, rgInfo: null, // #529: per-recording release tables, expanded on demand. The Map doubles as // the cache — a missing key means "never asked" (renders "loading…"), null // means the lookup failed, so a failed fetch doesn't retry on every render. expandedReleases: new Set(), releaseDetails: new Map(), collapsedGroups: new Set() }; function addToPool(rec) { if (STATE.recordings.has(rec.gid)) return false; STATE.recordings.set(rec.gid, rec); STATE.poolOrder.push(rec.gid); return true; } function findGroup(id) { return STATE.groups.find(g => g.id === id); } function removeFromPoolPermanently(gid) { const i = STATE.poolOrder.indexOf(gid); if (i !== -1) STATE.poolOrder.splice(i, 1); STATE.recordings.delete(gid); Log.info('Removed ' + gid + ' from the pool'); } // Two different notions, deliberately kept apart (#529: "chips shine even if // not entire group has them all, isrc here is not shared by 1 member"): // signals — holds for at least ONE pair. Explains why the group formed // (groups are built transitively, so A~B by ISRC and B~C by // title is a legitimate group) and drives the confidence badge. // signalsAll — holds for EVERY pair, i.e. the whole group genuinely agrees. // This is what lights a chip, so a lit chip never overstates. const SIGNAL_KEYS = ['isrc', 'acoustid', 'length', 'title', 'artist']; // #529 (majkinetor): the card badge names the CUTOFF TIER instead of a // high/medium confidence. "strict" means the group still holds together with // Auto-match set to strict; "loose" means it would fall apart if you tightened // the setting. That is connectivity, not "some pair matched": a group is built // transitively, so it survives a tier only if every member is still reachable // from every other under that tier's rules. const TIER_COLORS = { strict: '#1c9b63', normal: '#2f7fbf', loose: '#a8702a', manual: '#9a9aab' }; /* #623 (sweep, U3): union-find whose unions respect the hard gates for the WHOLE group, not just the pair that links two sets. An unknown value never blocks a pair (we can't tell), so checking the gates per pair let a recording with an unknown video flag or length bridge a video to an audio recording, or takes minutes apart, into one group — which Merge All then submitted. Each set keeps what its members know (any video, any audio, the shortest and longest known length), and two sets join only if no member of one conflicts with a member of the other: the same rule as pairSignals' videoMismatch and lengthConflict. */ function gatedUnionFind(recs) { const parent = new Map(), info = new Map(); for (const r of recs) { parent.set(r.gid, r.gid); const known = r.length != null; info.set(r.gid, { video: r.video === true, audio: r.video === false, min: known ? r.length : Infinity, max: known ? r.length : -Infinity, withIsrcs: r.isrcs && r.isrcs.length ? [r] : [] }); } // #760: an ISRC-less recording must not bridge two tracks of one ISRC batch const isrcClash = (a, b) => { for (const x of a.withIsrcs) for (const y of b.withIsrcs) { const c = isrcConflict(x, y); if (c) return c; } return null; }; const find = x => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; }; const gross = SETTINGS.grossLengthMs || 30000; // why sets A and B may not join ('' when they may) const conflict = (a, b) => ((a.video && b.audio) || (a.audio && b.video)) ? 'video and audio' : (Math.max(a.max, b.max) - Math.min(a.min, b.min) > gross) ? 'lengths ' + Math.round((Math.max(a.max, b.max) - Math.min(a.min, b.min)) / 1000) + 's apart' : (() => { const c = isrcClash(a, b); return c ? 'ISRCs ' + c + ' from one batch' : ''; })(); // true when joined (or already one set); a string saying why when refused const union = (x, y) => { const ra = find(x), rb = find(y); if (ra === rb) return true; const a = info.get(ra), b = info.get(rb), why = conflict(a, b); if (why) return why; parent.set(ra, rb); info.set(rb, { video: a.video || b.video, audio: a.audio || b.audio, min: Math.min(a.min, b.min), max: Math.max(a.max, b.max), withIsrcs: a.withIsrcs.concat(b.withIsrcs) }); return true; }; return { find, union }; } function groupTier(members) { if (members.length < 2) return 'manual'; for (const cutoff of MATCH_CUTOFFS) { const uf = gatedUnionFind(members); for (let i = 0; i < members.length; i++) for (let j = i + 1; j < members.length; j++) { const sig = pairSignals(members[i], members[j], SETTINGS.lengthToleranceMs); if (shouldUnion(sig, cutoff)) uf.union(members[i].gid, members[j].gid); } const root = uf.find(members[0].gid); if (members.every(m => uf.find(m.gid) === root)) return cutoff; } return 'manual'; // only hand-built grouping explains it (including a group no cutoff could form, U3) } function computeGroupConfidence(members) { let confidence = null; const signals = new Set(); const all = new Set(members.length > 1 ? SIGNAL_KEYS : []); // intersection seed for (let i = 0; i < members.length; i++) for (let j = i + 1; j < members.length; j++) { const sig = pairSignals(members[i], members[j], SETTINGS.lengthToleranceMs); if (sig.isrc) { signals.add('isrc'); confidence = 'high'; } if (sig.acoustid) { signals.add('acoustid'); confidence = 'high'; } if (sig.length) signals.add('length'); if (sig.title) signals.add('title'); if (sig.artist) signals.add('artist'); SIGNAL_KEYS.forEach(k => { if (!sig[k]) all.delete(k); }); // drop any pair disagrees on if (!confidence && shouldUnion(sig, SETTINGS.matchCutoff)) confidence = 'medium'; } return { confidence: confidence || 'manual', tier: groupTier(members), signals: [...signals], signalsAll: [...all] }; } function refreshGroupMeta(g) { const members = g.memberGids.map(x => STATE.recordings.get(x)).filter(Boolean); const meta = computeGroupConfidence(members); g.confidence = meta.confidence; g.tier = meta.tier; g.signals = meta.signals; g.signalsAll = meta.signalsAll; if (!g.memberGids.includes(g.target)) g.target = g.memberGids[0]; } // #529 follow-up (majkinetor, live): "'Return to pool' returns the entire // group back instead single recording." Root cause: any group left with // fewer than 2 members used to be dissolved entirely, pushing its LAST // remaining member back to the pool too — so returning one of a *pair* // silently evicted the other one as well. A group only truly stops making // sense at 0 members; a 1-member group is a normal (if not-yet-mergeable) // state and should just sit there until the user adds to it or clears it too. function dissolveOrRefresh(g) { if (g.memberGids.length === 0) { STATE.groups = STATE.groups.filter(x => x.id !== g.id); if (STATE.activeGroupId === g.id) STATE.activeGroupId = null; return; } refreshGroupMeta(g); } function returnToPool(gid, groupId) { const g = findGroup(groupId); if (!g) return; const i = g.memberGids.indexOf(gid); if (i === -1) return; g.memberGids.splice(i, 1); STATE.poolOrder.push(gid); dissolveOrRefresh(g); Log.info('Returned ' + gid + ' to the pool (group ' + groupId + ' now has ' + g.memberGids.length + ' member(s))'); } function removeFromGroupAndPool(gid, groupId) { const g = findGroup(groupId); if (!g) return; const i = g.memberGids.indexOf(gid); if (i === -1) return; g.memberGids.splice(i, 1); STATE.recordings.delete(gid); dissolveOrRefresh(g); Log.info('Removed ' + gid + ' from group and pool (group ' + groupId + ' now has ' + g.memberGids.length + ' member(s))'); } // #529 follow-up: the video/audio separation has to hold for MANUAL grouping // too (drag, double-click, select+click), not just Auto-match's shouldUnion. function videoConflict(group, rec) { if (!rec || rec.video == null) return false; for (const memberGid of group.memberGids) { const m = STATE.recordings.get(memberGid); if (m && m.video != null && m.video !== rec.video) return true; } return false; } function addToGroup(gid, groupId) { const g = findGroup(groupId); if (!g) return false; const i = STATE.poolOrder.indexOf(gid); if (i === -1) return false; const rec = STATE.recordings.get(gid); if (videoConflict(g, rec)) { Log.warn('Refused to add ' + gid + ' to group ' + groupId + ' — ' + (rec.video ? 'video' : 'audio') + ' recording, group already has the opposite (video and audio are never merged together)'); return false; } STATE.poolOrder.splice(i, 1); g.memberGids.push(gid); refreshGroupMeta(g); Log.info('Added ' + gid + ' to group ' + groupId); return true; // callers branch on this to set the current group — the // success path used to fall through as undefined, so a // double-click add never made its group current and the next // one silently targeted the LAST group instead. } function createGroupWithMember(gid) { const i = STATE.poolOrder.indexOf(gid); if (i === -1) return null; STATE.poolOrder.splice(i, 1); const g = { id: 'g' + Math.random().toString(36).slice(2, 9), memberGids: [gid], confidence: 'manual', tier: 'manual', signals: [], signalsAll: [], target: gid, state: 'pending', error: null, editNote: null, editing: false }; STATE.groups.push(g); Log.info('Created new group with ' + gid); return g; } // #529 follow-up (majkinetor): "Make me able to kill entire group and also // clear entire board (all items are returned to pool)." A killed/cleared // group's members go back to the pool — nothing here permanently drops a // recording, that's still only the pool's own ✕/group-row ✕. function deleteGroup(groupId) { const g = findGroup(groupId); if (!g) return; if (g.state === 'busy') { Log.warn('Cannot delete group ' + groupId + ' while it is merging'); return; } g.memberGids.forEach(gid => { if (!STATE.poolOrder.includes(gid)) STATE.poolOrder.push(gid); }); STATE.groups = STATE.groups.filter(x => x.id !== groupId); if (STATE.activeGroupId === groupId) STATE.activeGroupId = null; Log.info('Deleted group ' + groupId + ' — ' + g.memberGids.length + ' member(s) returned to pool'); } // #529 (majkinetor): "change the Clear board button so it can also clear // merged: Clear: [all] [merged]". Merged groups are NOT dissolved back into the // pool the way deleteGroup does it — the non-target recordings no longer exist // in MusicBrainz, and the surviving target is stale (its releases and ISRCs just // changed). Putting either back in the pool would invite re-merging something // that is already gone, so a merged group leaves the board entirely. function clearMerged() { const done = STATE.groups.filter(g => g.state === 'done'); if (!done.length) { Log.info('Clear merged: nothing merged yet'); return 0; } let dropped = 0; for (const g of done) { for (const gid of g.memberGids) { STATE.recordings.delete(gid); const i = STATE.poolOrder.indexOf(gid); if (i !== -1) STATE.poolOrder.splice(i, 1); STATE.expandedReleases.delete(gid); STATE.releaseDetails.delete(gid); dropped++; } STATE.collapsedGroups.delete(g.id); if (STATE.activeGroupId === g.id) STATE.activeGroupId = null; } const ids = new Set(done.map(g => g.id)); STATE.groups = STATE.groups.filter(g => !ids.has(g.id)); Log.info('Clear merged: removed ' + done.length + ' merged group(s) and ' + dropped + ' recording(s) from the board'); return done.length; } function clearBoard() { const n = STATE.groups.length; STATE.groups.slice().forEach(g => deleteGroup(g.id)); // Per-card view state belongs to groups that no longer exist; leaving it // behind makes a re-grouped recording come back mysteriously pre-expanded. // releaseDetails is a CACHE keyed by gid, so that one deliberately survives. STATE.expandedReleases.clear(); STATE.collapsedGroups.clear(); Log.info('Cleared board — ' + n + ' group(s) dissolved, every member returned to the pool'); } function pairSignals(a, b, tolMs) { const sig = { isrc: false, acoustid: false, length: false, title: false, artist: false, artistByMbid: false, videoMismatch: false, pendingEdit: false, lengthConflict: false, lengthUnknown: false, isrcConflict: null, titleDistinct: null }; // #565: three states, not two. `length` false meant BOTH "the lengths differ" // and "we have no length to compare", and the normal cutoff required it — so a // release whose recordings carry no length at all could never form a group, // however exactly the titles agreed. Keep them apart. if (a.length == null || b.length == null) sig.lengthUnknown = true; if (a.editsPending || b.editsPending) sig.pendingEdit = true; // both lengths known and far apart — see shouldUnion. Unknown length never // counts as a conflict; it just means we can't tell. if (a.length != null && b.length != null && Math.abs(a.length - b.length) > (SETTINGS.grossLengthMs || 30000)) sig.lengthConflict = true; // null (unknown) video status never blocks — only a KNOWN true vs. false mismatch does. if (a.video != null && b.video != null && a.video !== b.video) sig.videoMismatch = true; if (a.isrcs.length && b.isrcs.length && a.isrcs.some(x => b.isrcs.includes(x))) sig.isrc = true; if (a.acoustids && b.acoustids && a.acoustids.length && b.acoustids.length && a.acoustids.some(x => b.acoustids.includes(x))) sig.acoustid = true; if (lengthClose(a.length, b.length, tolMs)) sig.length = true; sig.isrcConflict = isrcConflict(a, b); // #760: keep the words that refused an otherwise alike title, for the log if (a.title && b.title && titleAlike(a.title, b.title)) { const d = titleDistinctWords(a.title, b.title); if (d) sig.titleDistinct = d; else sig.title = true; } /* #594 (chaban-mb): "auto-matching with normal cutoff fails due to differing artists credits even though the artist themselves are identical." Measured on his release group: both releases credit artist 5b44eac2-b29e-42ba-bd99-213a270149d6, one as "Radium" and the other as "DJ Radium". artistSimilar scores that 1 token of 2 = 0.5, under its 0.8 threshold, so sig.artist was false and `normal` — which needs title && artist && length — could not group even the pair whose titles and 5:05 lengths were identical. Dropping to `loose` was the only way through, and that weakens every other pair in the pool at the same time. A credit string is a display form; the MBID is the artist. When both sides carry MBIDs, compare those and let the string disagree. Sets, not sequence: "A & B" and "B & A" are the same two artists. The string comparison stays as the fallback for recordings fetched without artist credits. */ if (a.artistGids && a.artistGids.length && b.artistGids && b.artistGids.length) { const ka = a.artistGids.slice().sort().join(','), kb = b.artistGids.slice().sort().join(','); if (ka === kb) { sig.artist = true; sig.artistByMbid = true; } } else if (a.artistCredit && b.artistCredit && artistSimilar(a.artistCredit, b.artistCredit)) { sig.artist = true; } return sig; } function autoMatch(pool, tolMs, cutoff) { cutoff = cutoff || 'normal'; if (pool.length < 2) return []; const uf = gatedUnionFind(pool), find = uf.find; // #623 U3: the gates hold for the whole group let lengthBlocked = 0, bridgeBlocked = 0, guardBlocked = 0; for (let i = 0; i < pool.length; i++) { for (let j = i + 1; j < pool.length; j++) { const sig = pairSignals(pool[i], pool[j], tolMs); /* #594: say so when the MBID path is what carried the artist signal and the credit strings would have refused it. This is the whole behaviour change, and without a line for it the only evidence is a group appearing that did not before. */ if (sig.artistByMbid && !artistSimilar(pool[i].artistCredit || '', pool[j].artistCredit || '')) { Log.info(' same artist by MBID despite differing credits: "' + pool[i].artistCredit + '" vs "' + pool[j].artistCredit + '" (' + pool[i].artistGids.join(', ') + ')'); } // a pair that would otherwise have grouped, held back purely by the // gross-length guard — worth reporting rather than silently dropping if (sig.lengthConflict && (sig.isrc || sig.acoustid || (sig.title && sig.artist) || (sig.title && sig.length))) { lengthBlocked++; Log.info(' not grouped (length differs by ' + Math.round(Math.abs(pool[i].length - pool[j].length) / 1000) + 's): "' + pool[i].title + '" ' + dur(pool[i].length) + ' vs "' + pool[j].title + '" ' + dur(pool[j].length)); } // #760: a pair that would have grouped but for the title or ISRC-batch guard if ((sig.titleDistinct || sig.isrcConflict) && !shouldUnion(sig, cutoff) && shouldUnion(Object.assign({}, sig, { title: sig.title || !!sig.titleDistinct, isrcConflict: null }), cutoff)) { guardBlocked++; Log.info(' not grouped (' + (sig.isrcConflict ? 'ISRCs ' + sig.isrcConflict + ' from one batch' : 'titles differ in "' + sig.titleDistinct.a.join(' ') + '" / "' + sig.titleDistinct.b.join(' ') + '"') + '): "' + pool[i].title + '" ' + dur(pool[i].length) + ' vs "' + pool[j].title + '" ' + dur(pool[j].length)); } if (shouldUnion(sig, cutoff)) { const joined = uf.union(pool[i].gid, pool[j].gid); // the pair matches, but joining would put a conflicting pair into one group if (joined !== true) { bridgeBlocked++; Log.info(' not grouped (' + joined + ' in the group it would join): "' + pool[i].title + '" ' + dur(pool[i].length) + (pool[i].video ? ' (video)' : '') + ' ↔ "' + pool[j].title + '" ' + dur(pool[j].length) + (pool[j].video ? ' (video)' : '')); } } } } if (bridgeBlocked) Log.warn(bridgeBlocked + ' matching pair(s) kept apart: joining them would have grouped a video with an audio recording, lengths more than ' + Math.round((SETTINGS.grossLengthMs || 30000) / 1000) + 's apart, or two ISRCs of one batch, through a recording whose value is unknown'); if (guardBlocked) Log.warn(guardBlocked + ' pair(s) held back as different tracks (titles that differ in a word on each side, or ISRCs of one batch) — group them by hand if they really are the same'); if (lengthBlocked) Log.warn(lengthBlocked + ' pair(s) held back by the gross-length guard (>' + Math.round((SETTINGS.grossLengthMs || 30000) / 1000) + 's apart) — group them by hand if they really are the same take'); const byRoot = new Map(); for (const r of pool) { const root = find(r.gid); if (!byRoot.has(root)) byRoot.set(root, []); byRoot.get(root).push(r); } const groups = []; for (const members of byRoot.values()) { if (members.length < 2) continue; const meta = computeGroupConfidence(members); const target = members.slice().sort((a, b) => b.releases.length - a.releases.length)[0].gid; groups.push({ id: 'g' + Math.random().toString(36).slice(2, 9), memberGids: members.map(m => m.gid), confidence: meta.confidence, tier: meta.tier, signals: meta.signals, signalsAll: meta.signalsAll, target, state: 'pending', error: null, editNote: null, editing: false }); } return groups; } // #529 follow-up (majkinetor, live): "Match should show some progress (not // sure why it took so long in my test)" — AcoustID enrichment is one network // round-trip per pool recording; on anything bigger than a handful of tracks // that dominates Auto-match's wall time with nothing visible happening. Report // live N/M counts back to the caller so the button (and the log) can show it. // Serialized on purpose. The seed fires enrichment as a floating promise while // auto-match-on-open starts its own, and each snapshots "which recordings still // have acoustids == null" BEFORE the other writes its results back — so both // asked AcoustID for an overlapping set. In majkinetor's log four batches of 50 // covered far fewer than 200 distinct MBIDs, some queried twice and others // never. Queueing makes the second caller's filter run after the first has // stored its answers, so it only asks for what is genuinely still missing. let _acoustidQueue = Promise.resolve(); function enrichAcoustIds(recs, concurrency, onProgress) { const run = () => enrichAcoustIdsNow(recs, concurrency, onProgress); _acoustidQueue = _acoustidQueue.then(run, run); return _acoustidQueue; } async function enrichAcoustIdsNow(recs, concurrency, onProgress) { const pending = recs.filter(r => r.acoustids == null); if (!pending.length) { if (onProgress) onProgress(recs.length, recs.length); return; } let done = recs.length - pending.length; let found = 0; const failed = []; const runBatch = async (slice) => { const map = await fetchAcoustIdsBatch(slice.map(r => r.gid)); if (!map) return false; // leave them null = "not looked up" slice.forEach(rec => { rec.acoustids = map.get(rec.gid) || []; // only NOW does empty mean "none" if (rec.acoustids.length) found++; }); return true; }; const epoch = _bgEpoch; for (let i = 0; i < pending.length; i += ACOUSTID_BATCH) { if (!bgAlive(epoch)) { Log.warn('AcoustID lookup stopped after ' + done + ' of ' + pending.length + ' recording(s)'); break; } const slice = pending.slice(i, i + ACOUSTID_BATCH); if (!await runBatch(slice)) failed.push(slice); done += slice.length; if (onProgress) onProgress(done, recs.length); } // One retry for whole batches that failed, so a single blip doesn't leave a // chunk of the pool unresolved for the rest of the session. if (failed.length) { const n = failed.reduce((a, s) => a + s.length, 0); Log.warn('AcoustID: ' + failed.length + ' batch(es) failed (' + n + ' recording(s)) — retrying once'); const stillFailed = []; for (const slice of failed) if (!await runBatch(slice)) stillFailed.push(slice); const left = stillFailed.reduce((a, s) => a + s.length, 0); if (left) { Log.error('AcoustID: ' + left + ' recording(s) could not be checked — shown as unknown, NOT as "no AcoustID"'); setNetTrouble('offline', left + ' recording(s) could not be checked against AcoustID'); } } const checked = pending.length - failed.reduce((a, s) => a + s.length, 0); Log.info('AcoustID lookup: ' + found + ' of ' + checked + ' checked recording(s) have an AcoustID' + (checked < pending.length ? ' (' + (pending.length - checked) + ' could not be checked)' : '')); } // ISRC/video reconciliation against MB's authoritative per-recording lookup — // the rgid:/arid: search index can lag and report none where MB actually has // them (#529). Separate from AcoustID now that they come from different services. // #529 (majkinetor): "Why are we getting ISRCs on matching when we already // have them in the pool?" - we shouldn't have been. This used to refetch every // recording whose isrcs array was EMPTY, conflating "MB says it has none" with // "we never looked". Most of a pool genuinely has no ISRC, so Auto-match // re-requested hundreds of recordings on every single run - which is what // provoked the 503 storm. Seeding already returns ISRCs authoritatively (the // search and the per-recording lookup both include the field), so those are // marked known and never refetched. async function enrichIsrcs(recs, concurrency, onProgress) { concurrency = concurrency || 2; // one MB request per recording — go easy (#529) const pending = recs.filter(r => !r.isrcsKnown || r.video == null); if (!pending.length) { Log.info('ISRCs already known for all ' + recs.length + ' recording(s) — no lookups needed'); if (onProgress) onProgress(0, 0); return; } Log.info('Fetching ISRCs for ' + pending.length + ' of ' + recs.length + ' recording(s) (the rest are already known)'); let i = 0, done = 0; async function worker() { while (i < pending.length) { const rec = pending[i++]; const d = await fetchRecordingDetail(rec.gid); if (d) { if (d.isrcs.length && d.isrcs.join(',') !== (rec.isrcs || []).join(',')) { Log.info(' ISRC backfill for ' + rec.title + ': search said [' + ((rec.isrcs || []).join(',') || 'none') + '], MB has [' + d.isrcs.join(',') + ']'); rec.isrcs = d.isrcs; } if (rec.video == null) rec.video = d.video; rec.isrcsKnown = true; rec.isrcSource = 'entity'; // now confirmed against the entity, not the index } done++; if (onProgress) onProgress(done, pending.length); } } await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker)); } // ── merge submission (verified live against test.musicbrainz.org — GET // /recording/merge_queue?add-to-merge=<id>×N redirects to /recording/merge, whose // self-posting form has merge.merging.N / merge.target / merge.edit_note / // merge.make_votable and no CSRF token, session cookie authorises) ────────── // #529 (majkinetor): "we should also have more precise log message … says // similar title while title is the same. Check also how other userscript does // it." Modelled on jesus2099's MASS MERGE RECORDINGS note: itemised evidence // carrying the ACTUAL values, and distinguishing an exact match from a close // one, so a reviewer can judge the merge without opening anything. // #608 (chaban-mb): MB keeps the merge queue in ONE per-user session slot // ($c->session->{merger}) and wipes it when any merge submits. Two merges in // flight at once clobber each other: the loser's POST finds no queue and MB // redirects it to "/" without creating an edit. So the merge_queue GET + merge // POST pair runs strictly one at a time, whoever starts it (Merge All workers // or separate per-group clicks). The lookups before it still run in parallel. let _mergeLock = Promise.resolve(); function withMergeLock(fn) { const run = _mergeLock.then(fn, fn); _mergeLock = run.catch(() => {}); return run; } const uniq = arr => [...new Set(arr)]; function fmtList(vals, max) { max = max || 4; const shown = vals.slice(0, max).map(v => '"' + v + '"').join(', '); return vals.length > max ? shown + ' (+' + (vals.length - max) + ' more)' : shown; } function evidenceLines(group, members) { const all = group.signalsAll || []; const lines = []; if (all.includes('title')) { const titles = uniq(members.map(m => m.title).filter(Boolean)); if (titles.length === 1) lines.push('Same title ' + fmtList(titles)); else if (uniq(titles.map(t => t.toLowerCase())).length === 1) lines.push('Same title, differing case: ' + fmtList(titles)); else lines.push('Similar titles: ' + fmtList(titles)); } if (all.includes('artist')) { const acs = uniq(members.map(m => m.artistCredit).filter(Boolean)); lines.push((acs.length === 1 ? 'Same artist credit ' : 'Similar artist credits: ') + fmtList(acs)); } if (all.includes('length')) { const lens = members.map(m => m.length).filter(l => l != null); const lo = Math.min(...lens), hi = Math.max(...lens); // #529 (majkinetor): "length in this case could be stated as Max Length // Difference: 5s" — name the gap explicitly rather than leaving the // reader to subtract the range themselves. lines.push(lo === hi ? 'Same length ' + dur(lo) : lengthDiffLabel(members) + ' ' + Math.round((hi - lo) / 1000) + 's (' + dur(lo) + ' – ' + dur(hi) + ')'); } if (all.includes('isrc')) { const shared = uniq(members[0].isrcs.filter(i => members.every(m => (m.isrcs || []).includes(i)))); if (shared.length) lines.push('Same ISRC ' + shared.join(', ')); } if (all.includes('acoustid')) { const shared = uniq((members[0].acoustids || []).filter(a => members.every(m => (m.acoustids || []).includes(a)))); if (shared.length) lines.push('Same AcoustID ' + shared.join(', ')); } return lines; } // Widest gap between any two known lengths in the group — the number that // actually matters when lengths only "sort of" agree. // #529 (majkinetor): "When there is only 1 to merge edit message should omit // 'Max' from 'Max length difference'." With two recordings there is exactly one // pairwise difference, so "Max" claims a comparison that was never made. function lengthDiffLabel(members) { const known = members.filter(m => typeof m.length === 'number' && m.length > 0).length; return known > 2 ? 'Max length difference' : 'Length difference'; } function lengthSpread(members) { const lens = members.map(m => m.length).filter(l => typeof l === 'number' && l > 0); if (lens.length < 2) return null; return Math.max(...lens) - Math.min(...lens); } function autoEditNote(group) { const members = group.memberGids.map(g => STATE.recordings.get(g)).filter(Boolean); const target = STATE.recordings.get(group.target) || members[0]; const lines = evidenceLines(group, members); // #529 (majkinetor): "Move 'Keeping ...' to the first line and name it // 'Merge 3 recordings into "Sweet music": <URL>'." The kept recording is the // single most important fact in the note, so it leads instead of trailing // after the evidence. const keptUrl = target && target.gid ? location.origin + '/recording/' + target.gid : ''; const out = ['Merge ' + members.length + ' recordings into ' + (target ? '"' + target.title + '"' : 'one') + (keptUrl ? ': ' + keptUrl : '.'), '']; // #529 (majkinetor): "len is sometimes reported outside bullet list along // some other lines, make this consistent." Everything below the first line // is a bullet now — the length and partial-signal notes used to sit as loose // paragraphs between the bullets, which read like separate sections. const bullets = lines.slice(); if (!bullets.length) bullets.push('Grouped manually; no automatic signal matched across every recording.'); // Partial signals are stated as partial rather than silently omitted — and // length gets a concrete number instead of a bare "matching only some of // them: length", which said nothing about how far apart they actually were. const partial = (group.signals || []).filter(x => !(group.signalsAll || []).includes(x)); const rest = partial.filter(x => x !== 'length'); if (partial.includes('length')) { const spread = lengthSpread(members); if (spread != null) bullets.push(lengthDiffLabel(members) + ' ' + Math.round(spread / 1000) + 's'); else rest.push('length'); } if (rest.length) bullets.push('Matching only some of them: ' + rest.join(', ') + '.'); // #529 (majkinetor): "Add confidence level in the edit note" — the tier the // card is tinted by, so a reviewer sees the same judgement the UI showed. const tier = group.tier || 'manual'; bullets.push('Confidence: ' + tier); bullets.forEach(b => out.push('- ' + b)); return out.join('\n'); } // #529 (majkinetor): a per-group edit note, editable from the card. A custom // note REPLACES the auto-generated reason line; the attribution footer is // always appended so the edit stays traceable either way. function buildEditNote(group) { const body = (group.editNote && group.editNote.trim()) ? group.editNote.trim() : autoEditNote(group); return body + '\n\nFusion v' + VERSION + ' by majkinetor - ' + HELP_URL + (group.via ? '\n' + group.via : ''); } async function ensureInternalIds(gids) { const ids = []; for (const gid of gids) { const id = await resolveInternalId(gid); if (!id) throw new Error('could not resolve an internal id for ' + gid); ids.push(id); } return ids; } // #529 follow-up (majkinetor): "In logging I want to see merge all event and // what is going on" + "we can see the data of the recordings" — dump every // member's actual data (title/isrc/acoustid/length/video/releases) into the // log at the moment a merge is attempted, not just ids, so a merge can be // diagnosed from the Log panel alone without a screenshot round-trip. function describeRecordingForLog(rec) { if (!rec) return '(missing from STATE.recordings)'; return rec.title + ' [isrc=' + ((rec.isrcs && rec.isrcs.join(',')) || 'none') + ' acoustid=' + ((rec.acoustids && rec.acoustids.join(',')) || (rec.acoustids == null ? 'not checked' : 'none')) + ' length=' + dur(rec.length) + ' video=' + (rec.video == null ? 'unknown' : rec.video) + ' releases=' + (rec.releases || []).map(r => r.title).join('; ') + ']'; } async function mergeGroup(group) { if (!group) { Log.warn('mergeGroup called with no group'); return; } if (group.state === 'busy') { Log.warn('merge skipped: group ' + group.id + ' is already merging'); return; } if (group.state === 'done') { Log.warn('merge skipped: group ' + group.id + ' is already merged'); return; } if (group.memberGids.length < 2) { Log.warn('merge skipped: group ' + group.id + ' has fewer than 2 members (' + group.memberGids.length + ')'); return; } // establish it for this group if we never looked (grouped by hand, say) const members = group.memberGids.map(g => STATE.recordings.get(g)).filter(Boolean); if (members.some(r => r.editsPending == null)) await enrichPendingEdits(members, 2); const pending = members.filter(r => r.editsPending); if (pending.length) { group.state = 'error'; group.error = 'Has pending edit(s): ' + pending.map(r => r.title).join(', ') + ' — resolve them in MB first'; Log.warn('merge blocked for group ' + group.id + ' — ' + pending.length + ' member(s) have pending edits'); renderGroups(); renderFooter(); return; } Log.info('▶ Merge group ' + group.id + ' — confidence=' + group.confidence + ' signals=[' + group.signals.join(',') + ']'); group.memberGids.forEach(gid => Log.info(' member ' + gid + (gid === group.target ? ' (TARGET/kept)' : '') + ': ' + describeRecordingForLog(STATE.recordings.get(gid)))); group.state = 'busy'; group.error = null; renderGroups(); busyStart('merging…'); try { const ids = await ensureInternalIds(group.memberGids); Log.info(' resolved internal ids: ' + group.memberGids.map((gid, i) => gid.slice(0, 8) + '…→' + ids[i]).join(', ')); const targetIdx = group.memberGids.indexOf(group.target); const targetId = ids[targetIdx === -1 ? 0 : targetIdx]; Log.info(' merging [' + ids.join(', ') + '] → keeping target ' + targetId); const addQs = ids.map(id => 'add-to-merge=' + id).join('&'); const finalUrl = await withMergeLock(async () => { Log.info(' merge session free — submitting group ' + group.id); const gr = await gmGet(location.origin + '/recording/merge_queue?' + addQs, { Accept: 'text/html' }); if (gr.status < 200 || gr.status >= 400) throw new Error('merge_queue GET failed: HTTP ' + gr.status); const mergeUrl = gr.finalUrl || (location.origin + '/recording/merge'); Log.info(' merge_queue redirected to ' + mergeUrl); const body = new URLSearchParams(); ids.forEach((id, i) => body.append('merge.merging.' + i, String(id))); body.append('merge.target', String(targetId)); const note = buildEditNote(group); body.append('merge.edit_note', note); if (SETTINGS.makeVotable) body.append('merge.make_votable', '1'); Log.info(' edit note: ' + note.replace(/\n/g, ' ¶ ')); const pr = await gmPost(mergeUrl, body.toString(), { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'text/html', Referer: mergeUrl, Origin: location.origin }); if (pr.status >= 400) throw new Error('merge POST failed: HTTP ' + pr.status); const landed = pr.finalUrl || ''; const reRendered = /\/recording\/merge(\?|$)/.test(landed) || /name="merge\.target"/.test(pr.responseText || ''); // #608: a real merge redirects to the kept recording's page. Landing // anywhere else (MB sends a POST whose session queue was gone to "/") // means nothing was submitted — it used to count as success. const landedGid = (landed.match(/\/recording\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i) || [])[1] || null; Log.info(' POST landed at ' + landed + (reRendered ? ' (still the merge form — treating as failure)' : landedGid ? ' (recording page — success)' : ' (NOT a recording page — nothing was merged)')); if (reRendered) throw new Error('merge form returned an error (nothing submitted) — check you are logged in with merge privileges'); if (!landedGid) throw new Error('MB did not create the merge (landed on ' + (landed || 'an unknown page') + ', not the kept recording) — its merge queue was empty or taken; try again'); if (landedGid !== group.target) Log.warn(' landed on recording ' + landedGid + ', expected the target ' + group.target + ' — check the merge in MB'); return landed; }); group.state = 'done'; group.mergedUrl = finalUrl || null; Log.ok('✓ Merged group ' + group.id + ' → ' + finalUrl); } catch (e) { group.state = 'error'; group.error = e.message; Log.error('✗ Merge failed for group ' + group.id + ': ' + e.message); } busyEnd(); renderGroups(); renderFooter(); } // #529 follow-up (majkinetor): "Merge all should be parallel if possible" — // a small worker pool prepares several groups at once (pending-edit check, // internal ids). The submission itself is NOT independent per group: MB's merge // queue is one per session, so withMergeLock serialises the GET+POST (#608). // #529 (majkinetor): "Merge all should have summary at the end, basically show // text that is collapsed". A run's outcome otherwise only existed in the log, // which meant opening a separate window to find out whether anything failed. // Collapsed to one line by default; <details> gives the toggle for free. // #529 (majkinetor): "When auto match finds nothing, its not obvious that it // fired at all. We should have a message shown. The only thing is visible in // the Logs." A run that changes nothing looked identical to a button that did // nothing, so the outcome now lands in the window, not only the log. let _notice = null; function showNotice(kind, text, short) { _notice = text ? { kind, text, short } : null; renderNotice(); } function renderNotice() { const host = document.getElementById('fs-matchmsg'); if (!host) return; if (!_notice) { host.textContent = ''; host.style.display = 'none'; host.title = ''; return; } host.style.display = ''; host.className = 'fs-matchmsg fs-matchmsg-' + _notice.kind; host.textContent = _notice.short || _notice.text; host.title = _notice.text; } let _lastRun = null; function renderRunSummary(run) { if (run !== undefined) _lastRun = run; const host = document.getElementById('fs-runsum'); if (!host) return; if (!_lastRun) { host.innerHTML = ''; host.style.display = 'none'; return; } const r = _lastRun; const head = (r.failed ? '⚠ ' : '✓ ') + 'Merge All finished - ' + r.merged + ' merged' + (r.failed ? ', ' + r.failed + ' failed' : '') + ' (' + r.recordings + ' recordings) at ' + r.at; const rows = r.items.map(it => '<li class="' + (it.ok ? 'fs-runok' : 'fs-runbad') + '">' + (it.ok ? '✓ ' : '✗ ') + escapeHtml(it.title) + ' <span class="fs-runn">(' + it.n + ')</span>' + (it.ok ? (it.url ? ' <a href="' + escapeHtml(it.url) + '" target="_blank" rel="noopener">view</a>' : '') : ' <span class="fs-runerr">' + escapeHtml(it.error || 'failed') + '</span>') + '</li>').join(''); host.style.display = ''; host.innerHTML = '<details class="fs-runsum-d"' + (r.failed ? ' open' : '') + '>' + '<summary>' + escapeHtml(head) + '</summary>' + '<ul class="fs-runlist">' + rows + '</ul></details>' + '<span class="fs-runclose" title="dismiss this summary">✕</span>'; } async function mergeAll(concurrency) { // Coerce defensively: a stray click Event (or anything non-numeric) landing // here used to poison Math.min into NaN, which made Array.from({length:NaN}) // spawn ZERO workers — Merge All logged "queued" then instantly "finished: // 0 merged, 0 failed" without touching a single group. Never trust this arg. const n = Number(concurrency); concurrency = Number.isFinite(n) && n >= 1 ? Math.floor(n) : 3; const pending = STATE.groups.filter(g => g.state === 'pending' || g.state === 'error'); const workers = Math.max(1, Math.min(concurrency, pending.length)); busyStart('merging ' + pending.length + ' group(s)…'); try { Log.info('══ Merge All: ' + pending.length + ' group(s) queued, up to ' + workers + ' prepared in parallel, submitted one at a time (#608) ══'); if (!pending.length) { Log.warn('Merge All: nothing to do — no group is in pending/error state (already merged, or none formed yet)'); return; } let doneCount = 0, failCount = 0; let i = 0; async function worker() { while (i < pending.length) { const g = pending[i++]; await mergeGroup(g); if (g.state === 'done') doneCount++; else failCount++; Log.info('── Merge All progress: ' + (doneCount + failCount) + '/' + pending.length + ' (' + doneCount + ' ok, ' + failCount + ' failed) ──'); } } await Promise.all(Array.from({ length: workers }, worker)); _lastRun = { merged: doneCount, failed: failCount, recordings: pending.reduce((a, g) => a + (g.state === 'done' ? g.memberGids.length : 0), 0), at: new Date().toLocaleTimeString(), items: pending.map(g => { const head = STATE.recordings.get(g.target) || STATE.recordings.get(g.memberGids[0]); return { ok: g.state === 'done', title: head ? head.title : g.id, n: g.memberGids.length, url: g.mergedUrl || null, error: g.error || null }; }), }; renderRunSummary(); Log.info('══ Merge All finished: ' + doneCount + ' merged, ' + failCount + ' failed (of ' + pending.length + ') ══'); } finally { busyEnd(); } } /* ════════════════════════════════ UI ════════════════════════════════ */ // The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and // are inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never here. // <ST-TOKENS> — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // </ST-TOKENS> // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here. // <ST-UI> — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on <html>, which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'<svg…>' | '<img…>', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // o.history opt-in history of past sessions (#705), below // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // viewer appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) : '--:--:--.---'; }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + (e.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button, input, select') || pop.classList.contains('full')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); views = views.filter(function (v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in <body>: a permanent stray node under <html>, outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No <body> yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and <head> and <body> don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } // </ST-UI> function fsStyle() { if (document.getElementById('fs-style')) return; const s = el('style'); s.id = 'fs-style'; s.textContent = MBU_TOKENS + MBU_UI_CSS // majkinetor: "Make fusion icon be without text so it naturaly stack // over other icons" … "make it more like Falcon icon in appearance". // A wide labelled pill sat awkwardly beside the round icon-only // launchers the other scripts put in the same corner. Same circle, // same size, same resting opacity as Falcon's — only the glyph and its // colour differ, so the stack is uniform but each is still identifiable. + '.fs-launch{position:fixed;z-index:2147483000;width:40px;height:40px;border-radius:50%;border:none;padding:0;' + 'display:flex;align-items:center;justify-content:center;cursor:pointer;' + 'background:transparent;box-shadow:none;' + 'opacity:.85;transition:background .15s,transform .1s,opacity .15s}' + '.fs-launch img{width:34px;height:34px;display:block;pointer-events:none}' + '.fs-launch:hover{opacity:1;transform:scale(1.08)}' + '.fs-overlay{position:fixed;inset:0;background:rgba(15,12,28,.45);z-index:var(--mbu-z-modal);display:flex;align-items:center;justify-content:center}' // Light palette (#529: "make UI white") — every colour in the window is // driven from these tokens, so the theme is this one line plus the log // panel below. #562: those tokens are now aliases onto the repo-wide set // in dev/tokens/design-tokens.mjs, which is what makes "this one line" the same // one line for every script. Fusion's own names are kept so the ~250 // var(--fs-*) call sites don't have to churn. The values shift slightly // (its purple was #6d3ff0, the shared accent is #5f3ec0, and the greys // move a shade) — that is the consolidation #562 asks for, not a bug. + '.fs-cons{--fs-bg:var(--mbu-bg-sunken);--fs-panel:var(--mbu-bg);--fs-panel2:var(--mbu-bg-raised);--fs-border:var(--mbu-border-soft);--fs-text:var(--mbu-text);--fs-muted:var(--mbu-text-dim);--fs-purple:var(--mbu-accent);--fs-purple-d:var(--mbu-accent-hover);--fs-green:var(--mbu-ok);--fs-amber:var(--mbu-warn);--fs-red:var(--mbu-error);--fs-blue:var(--mbu-info);' + 'width:min(1560px,calc(100% - 24px));height:min(760px,92vh);max-width:calc(100% - 16px);max-height:96vh;min-width:640px;min-height:400px;resize:both;' + 'background:var(--fs-panel);color:var(--fs-text);border:1px solid var(--fs-border);border-radius:var(--mbu-radius-lg);box-shadow:0 8px 30px rgba(0,0,0,.5);' + 'font:13px -apple-system,Segoe UI,Helvetica,Arial,sans-serif;display:flex;flex-direction:column;overflow:hidden}' + '.fs-cons.fs-maximized{position:fixed !important;left:8px !important;top:8px !important;right:8px !important;bottom:8px !important;width:auto !important;height:auto !important;max-width:none !important;max-height:none !important;margin:0 !important}' + '.fs-hdr{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--fs-panel2);border-bottom:1px solid var(--fs-border);cursor:move}' + '.fs-title{font-weight:700;font-size:14px}' + '.fs-scope{color:var(--fs-muted);font-size:12px}' + '.fs-busy{color:var(--mbu-accent-deep-text);font-size:12px;font-weight:600;animation:fs-pulse 1.1s ease-in-out infinite;white-space:nowrap}' + '@keyframes fs-pulse{0%,100%{opacity:1}50%{opacity:.25}}' + '.fs-sp{flex:1}' + '.fs-cons-x{background:none;border:none;font-size:16px;color:var(--mbu-text-weak);cursor:pointer;padding:2px 8px;border-radius:5px;line-height:1}' + '.fs-cons-x:hover{background:rgba(0,0,0,.06);color:var(--fs-text)}' + '.fs-cfgbtn,.fs-x{color:var(--fs-muted);cursor:pointer;font-size:15px;padding:2px 6px}' + '.fs-cfgbtn:hover,.fs-x:hover{color:var(--fs-text)}' + '.fs-ctrl{display:flex;align-items:center;gap:8px;padding:9px 14px;background:var(--fs-panel);border-bottom:1px solid var(--fs-border);flex-wrap:wrap}' + '.fs-ctrl select,.fs-ctrl input[type=text]{background:var(--fs-panel2);border:1px solid var(--fs-border);color:var(--fs-text);border-radius:var(--mbu-radius);padding:5px 8px;font-size:12px}' + '#fs-rg-editions{max-width:420px}' + '#fs-rg-editions:disabled{opacity:.7;font-style:italic;cursor:default}' + '.fs-ctrl input[type=text]{width:220px}' + '.fs-btn{border:1px solid var(--fs-border);background:var(--fs-panel2);color:var(--fs-text);border-radius:var(--mbu-radius);padding:5px 10px;font-size:12px;cursor:pointer}' + '.fs-btn.fs-primary{background:linear-gradient(180deg,var(--fs-purple),var(--fs-purple-d));border-color:var(--fs-purple-d);color:var(--mbu-text-on-accent);font-weight:600}' + '.fs-btn:disabled{opacity:.5;cursor:default}' + '.fs-netbanner{display:inline-flex;align-items:center;box-sizing:border-box;height:18px;padding:0 8px;border-radius:4px;font-size:11px;line-height:1;font-weight:600;white-space:nowrap;cursor:pointer;background:rgba(168,112,42,.14);color:var(--mbu-warn);border:1px solid rgba(168,112,42,.4)}' + '.fs-netbanner.fs-netbanner-err{background:rgba(200,56,79,.12);color:var(--fs-red);border-color:rgba(200,56,79,.45)}' + '.fs-legend{display:flex;gap:10px;color:var(--fs-muted);font-size:11px;align-items:center}' + '.fs-body{display:flex;flex:1;min-height:0}' + '.fs-col{display:flex;flex-direction:column;min-width:0;min-height:0}' + '.fs-pool{width:360px;border-right:1px solid var(--fs-border);background:var(--fs-bg)}' + '.fs-body.fs-poolhidden .fs-pool{display:none}' + '.fs-poolrail{display:none;flex-direction:column;align-items:center;gap:10px;width:28px;flex-shrink:0;padding:10px 0;cursor:pointer;border-right:1px solid var(--fs-border);background:var(--fs-panel2);color:var(--fs-muted)}' + '.fs-body.fs-poolhidden .fs-poolrail{display:flex}' + '.fs-poolrail:hover{background:rgba(109,63,240,.06);color:var(--mbu-accent-text)}' + '.fs-railarrow{font-size:14px;line-height:1}' + '.fs-raillabel{writing-mode:vertical-rl;font-size:10px;font-weight:700;letter-spacing:.12em}' + '.fs-pooltog{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;padding:2px 5px;border-radius:3px;cursor:pointer;color:var(--mbu-info);font-size:12px;line-height:1;flex-shrink:0}' + '.fs-pooltog:hover{background:rgba(0,0,0,.05);color:var(--fs-muted)}' + '.fs-groups{flex:1;background:var(--fs-panel)}' + '.fs-colhdr{display:flex;align-items:center;gap:8px;padding:9px 14px;border-bottom:1px solid var(--fs-border);font-weight:700;font-size:12px;letter-spacing:.3px;color:var(--fs-muted);text-transform:uppercase;background:var(--fs-panel2)}' + '.fs-cnt{background:var(--fs-panel);border:1px solid var(--fs-border);border-radius:var(--mbu-radius-lg);padding:1px 7px;color:var(--fs-text);font-weight:600}' + '.fs-hint{text-transform:none;font-weight:400;color:var(--fs-muted)}' + '.fs-poolfilter{flex:1;min-width:0;max-width:210px;box-sizing:border-box;background:var(--fs-panel);border:1px solid var(--fs-border);color:var(--fs-text);border-radius:var(--mbu-radius);padding:3px 8px;font:400 11.5px -apple-system,Segoe UI,Arial,sans-serif;text-transform:none;letter-spacing:0}' + '.fs-poolfilter:focus{outline:none;border-color:var(--fs-purple)}' + '.fs-cnt.fs-cnt-filtered{color:var(--mbu-accent-deep-text);border-color:var(--fs-purple)}' // #529 follow-up (majkinetor, screenshot): "I can't see individual // recordings here (have to zoom out)" — classic flexbox trap: a flex // item's default min-height:auto refuses to shrink below its natural // content height, so with many groups this box grew past .fs-cons's // own fixed height instead of scrolling — the overflow got clipped by // .fs-cons's overflow:hidden rather than showing a scrollbar in here. + '.fs-colbody{flex:1;min-height:0;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:7px}' + '.fs-empty{color:var(--fs-muted);font-size:12px;padding:14px;text-align:center}' + '.fs-pcard{background:var(--fs-panel);border:1px solid var(--fs-border);border-radius:7px;padding:7px 9px;display:flex;align-items:center;gap:8px;cursor:grab;flex-shrink:0}' + '.fs-pcard.fs-selected{border-color:var(--fs-purple)}' + '.fs-grip{color:var(--mbu-text-weak);font-size:12px;letter-spacing:-1px}' + '.fs-info{flex:1;min-width:0}' + '.fs-t{font-weight:600;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' // #529 follow-up (majkinetor, screenshot): "you can see merged item no // link" — the <a> was always there and functional (verified: real href, // pointer-events:auto even after a merge), but color:inherit + // text-decoration:none until hover made every link visually identical // to plain text at rest, so it genuinely read as "no link" on sight. + '.fs-t a{color:var(--mbu-accent-text);text-decoration:underline;text-decoration-color:rgba(109,63,240,.4)}' + '.fs-t a:hover{color:var(--mbu-accent-text);text-decoration-color:var(--fs-purple)}' + '.fs-artist{font-weight:400;color:var(--fs-muted);font-size:11.5px}' + '.fs-artist a{color:var(--fs-muted);text-decoration:underline;text-decoration-color:rgba(154,155,176,.35)}' + '.fs-artist a:hover{text-decoration-color:var(--fs-text);color:var(--fs-text)}' + '.fs-m{color:var(--fs-muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}' + '.fs-ids{display:flex;gap:5px;margin-top:2px}' + '.fs-idtag{font-size:9.5px;color:var(--fs-muted);font-family:ui-monospace,Consolas,monospace;background:rgba(0,0,0,.05);padding:1px 5px;border-radius:3px}' // Presence dots: does this recording have an ISRC / an AcoustID. The // legend that used to explain them is gone (#529: "I don't see how it is // used"), so the dots carry their own meaning — each keeps a distinct // colour and a tooltip that names the identifier and says present/absent. + '.fs-badges{display:flex;gap:3px;flex-shrink:0}' + '.fs-b{width:6px;height:6px;border-radius:50%;background:var(--mbu-bg-sunken)}' + '.fs-b-isrc.fs-b-on{background:var(--fs-green)} .fs-b-acid.fs-b-on{background:var(--fs-blue)}' + '.fs-b-unknown{background:transparent;box-shadow:inset 0 0 0 1px #ccccd8}' + '.fs-b-unconfirmed{background:var(--mbu-info-bg);box-shadow:inset 0 0 0 1px #b9b9c8}' + '.fs-rm{color:var(--fs-muted);cursor:pointer;font-size:13px;padding:2px 4px;flex-shrink:0}' + '.fs-rm:hover{color:var(--fs-red)}' // flex-shrink:0 is the actual fix for the missing-rows bug: .fs-gcard // has overflow:hidden, and per spec that makes its automatic min-height // resolve to 0 instead of content-based — so without flex-shrink:0, // flexbox happily squashed every card to fit .fs-colbody's available // space (clipping the rows inside via that same overflow:hidden) // instead of letting .fs-colbody's own overflow-y:auto scroll. + '.fs-gcard{background:var(--fs-panel);border:1px solid var(--fs-border);border-left:3px solid var(--fs-green);border-radius:7px;overflow:hidden;flex-shrink:0}' + '.fs-gcard-med{border-left-color:var(--fs-amber)}' + '.fs-gcard-manual{border-left-color:var(--fs-blue);border-left-style:dashed}' + '.fs-gcard.fs-active{outline:2px solid var(--fs-purple);outline-offset:-1px}' + '.fs-ghdr{position:relative;display:flex;align-items:center;gap:8px;padding:7px 10px;background:rgba(0,0,0,.025);border-bottom:1px solid var(--fs-border);cursor:pointer}' // relative: anchors the #619 length badge // Collapse/expand toggles. Full-size glyph AND a generous hit area (#419, // #467, #529 — third recurrence of the same rule). ▶/▼ are the full-size // triangles; ▸/▾ are Unicode's *small* ones and read as shrunken however // much padding is wrapped around them. // Full-size glyph, but LIGHT: a solid 14px triangle in the muted text // colour reads as heavy as the titles it sits next to (#529 — "too dark // and intrusive"). Size is the accessibility requirement, weight is not, // so the contrast lives in the hover instead of the resting state. + '.fs-ctog,.fs-exp{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;padding:2px 5px;border-radius:3px;cursor:pointer;color:var(--mbu-info);font-size:14px;line-height:1;flex-shrink:0;transition:color .12s,background .12s}' + '.fs-ctog:hover,.fs-exp:hover{background:rgba(0,0,0,.05);color:var(--fs-muted)}' + '.fs-exp-on,.fs-gcard-collapsed .fs-ctog{color:var(--mbu-text-weak)}' + '.fs-gcard-collapsed .fs-ghdr{border-bottom:none}' + '.fs-cnt{font-size:10.5px;color:var(--fs-muted);white-space:nowrap}' // The release table must scroll INSIDE the card — it has far more columns // than the card is wide, and letting it stretch would blow out the layout. + '.fs-reltbl{overflow-x:auto;margin:0 0 6px 30px;border:1px solid var(--fs-border);border-radius:4px;background:var(--mbu-bg);scrollbar-width:thin;scrollbar-color:#d3d3e0 transparent}' + '.fs-reltbl::-webkit-scrollbar{height:7px}' + '.fs-reltbl::-webkit-scrollbar-track{background:transparent}' + '.fs-reltbl::-webkit-scrollbar-thumb{background:var(--mbu-bg-sunken);border-radius:4px}' + '.fs-reltbl:hover::-webkit-scrollbar-thumb{background:var(--mbu-bg-sunken)}' + '.fs-reltbl table{border-collapse:collapse;font-size:10.5px;width:100%;table-layout:fixed;min-width:720px}' + '.fs-reltbl col.c-num{width:3.5%} .fs-reltbl col.c-title{width:14%} .fs-reltbl col.c-len{width:4.5%}' + '.fs-reltbl col.c-tart{width:11%} .fs-reltbl col.c-rel{width:17%} .fs-reltbl col.c-rart{width:10.5%}' + '.fs-reltbl col.c-rgt{width:13%} .fs-reltbl col.c-cd{width:8.5%} .fs-reltbl col.c-label{width:10.5%} .fs-reltbl col.c-cat{width:7%}' + '.fs-reltbl th{text-align:left;font-weight:600;padding:4px 7px;border-bottom:1px solid var(--fs-border);white-space:nowrap;background:rgba(0,0,0,.03)}' + '.fs-reltbl td{padding:3px 7px;border-bottom:1px solid rgba(0,0,0,.05);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' + '.fs-reltbl tr:last-child td{border-bottom:none}' + '.fs-relband td{font-weight:600;background:rgba(0,0,0,.05);color:var(--fs-text)}' + '.fs-relload,.fs-relerr,.fs-relempty{padding:6px 9px;font-size:10.5px;color:var(--fs-muted)}' + '.fs-relerr{color:var(--fs-red)}' + '.fs-gt{font-weight:700;font-size:12.5px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' + '.fs-ghdr-grid{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:10px}' + '.fs-ghl{display:flex;align-items:center;gap:8px;min-width:0}' + '.fs-ghr{display:flex;align-items:center;gap:8px;justify-self:end}' + '.fs-gnum{width:20px;flex-shrink:0;text-align:right;font-weight:800;font-size:12px;color:var(--fs-muted);cursor:help}' + '.fs-sig span{box-sizing:border-box;width:64px;text-align:center}' + '.fs-tier-strict{border-left-color:var(--mbu-ok);background:rgba(28,155,99,.045)}' + '.fs-tier-normal{border-left-color:var(--mbu-info);background:rgba(47,127,191,.045)}' + '.fs-tier-loose{border-left-color:var(--mbu-warn);background:rgba(168,112,42,.05)}' + '.fs-tier-manual{border-left-color:var(--mbu-border);background:rgba(154,154,171,.05)}' + '.fs-gcard.fs-dragover{outline:2px dashed var(--fs-purple);outline-offset:-3px}' + '.fs-gt a{color:var(--mbu-accent-text);text-decoration:underline;text-decoration-color:rgba(109,63,240,.4)}' + '.fs-gt a:hover{color:var(--mbu-accent-text);text-decoration-color:var(--fs-purple)}' + '.fs-conf{font-size:10px;padding:1px 6px;border-radius:8px;font-weight:700;letter-spacing:.2px}' + '.fs-conf-high{background:rgba(28,155,99,.12);color:var(--fs-green)}' + '.fs-conf-med{background:rgba(168,112,42,.13);color:var(--fs-amber)}' + '.fs-conf-manual{background:rgba(47,127,191,.13);color:var(--fs-blue)}' + '.fs-sig{display:flex;gap:3px}' // A matched signal has to READ as lit (#529: "chips are still not // highlighted, although it says HIGH"). The .hit class was applied all // along, but green-text-on-white against grey-text-on-white is nearly // invisible at 9.5px — so matched chips now carry a filled tint, a solid // border and bold text, and unmatched ones are deliberately faded back. // #564 (majkinetor): "Fusion chips all look the same on dark theme (i // can't see for example if isrc is matched or not)". The old pair was a // fixed rgba(28,155,99,.16) tint against a light panel plus opacity:.55 // on the miss — on a dark background that tint is nearly the panel // colour, and dimming an already-dim chip leaves two faint outlines that // differ only in hue at 9.5px. So the difference is structural now: a hit // is FILLED with a tint mixed from the theme's own green and its text is // pulled most of the way to the foreground colour; a miss carries no fill // at all and stays a bare outline. A first attempt at 22% fill was still // too quiet to read at 9.5px on dark — the screenshots showed two nearly // identical outlines — so the fill is 42% and the text near-white. + '.fs-sig span{font-size:9.5px;padding:1px 5px;border-radius:4px;background:transparent;border:1px solid var(--mbu-border);color:var(--mbu-text-weak);font-weight:500}' + '.fs-sig span.warn{background:color-mix(in srgb, var(--mbu-warn) 22%, transparent);border-color:var(--mbu-warn);color:var(--mbu-warn);font-weight:700}' // #619 lengths past the tolerance // #619 follow-up (majkinetor): "Highlighted length is not aligned vertically with // normal length". The tint was a centred 44px box, so its digits sat several px right // of the plain lengths above and below. The tint now reaches 4px LEFT into the gap // instead (margin -4 / padding +4 / width +4): same 44px footprint, digits start // exactly where the unhighlighted ones do. + '.fs-grow .fs-len.fs-len-off{color:var(--mbu-warn);font-weight:700;background:color-mix(in srgb, var(--mbu-warn) 16%, transparent);border-radius:3px;margin-left:-4px;padding-left:4px;width:48px}' + '.fs-lenwarn{flex:0 0 auto;margin-left:6px;padding:0 6px;border:1px solid var(--mbu-warn);border-radius:9px;color:var(--mbu-warn);background:color-mix(in srgb, var(--mbu-warn) 14%, transparent);font-size:10.5px;font-weight:700;line-height:1.5;white-space:nowrap;cursor:help}' // #619 follow-up: "it should be in len column" — placeLenBadges() lifts it out of // the title track and over the rows' length column (left edge = the amber cells'). + '.fs-lenwarn.fs-lenwarn-col{position:absolute;top:50%;transform:translateY(-50%);margin-left:0}' + '.fs-sig span.hit{background:color-mix(in srgb, var(--mbu-ok) 42%, transparent);border-color:var(--mbu-ok);color:color-mix(in srgb, var(--mbu-ok) 18%, var(--mbu-text));font-weight:700}' + '.fs-sig span.partial{background:color-mix(in srgb, var(--mbu-ok) 16%, transparent);border-style:dashed;border-color:color-mix(in srgb, var(--mbu-ok) 60%, transparent);color:color-mix(in srgb, var(--mbu-ok) 45%, var(--mbu-text))}' + '.fs-mergeicon{vertical-align:-2px;margin-right:2px}' + '.fs-mbtn{font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--fs-purple-d);background:rgba(109,63,240,.1);color:var(--mbu-accent-deep-text);cursor:pointer;font-weight:600;white-space:nowrap;flex-shrink:0;display:inline-flex;align-items:center;gap:5px}' + '.fs-mbtn.fs-done{background:rgba(28,155,99,.12);border-color:var(--fs-green);color:var(--fs-green);cursor:default}' + '.fs-mbtn.fs-err{background:rgba(200,56,79,.1);border-color:var(--fs-red);color:var(--fs-red)}' // shared-identifier tints (#529) — one colour per distinct value shared // by 2+ members of a card, so agreeing rows are obvious at a glance. + '.fs-idc0,.fs-idc1,.fs-idc2,.fs-idc3,.fs-idc4,.fs-idc5{border-radius:3px;padding:1px 4px;font-weight:600}' + '.fs-idc0{background:rgba(28,155,99,.16);color:var(--mbu-ok) !important}' + '.fs-idc1{background:rgba(47,127,191,.16);color:var(--mbu-info) !important}' + '.fs-idc2{background:rgba(168,112,42,.18);color:var(--mbu-warn) !important}' + '.fs-idc3{background:rgba(109,63,240,.14);color:var(--mbu-accent-text) !important}' + '.fs-idc4{background:rgba(200,56,79,.14);color:var(--mbu-error) !important}' + '.fs-idc5{background:rgba(20,140,150,.16);color:var(--mbu-info) !important}' + '.fs-rec-video{display:inline-flex;vertical-align:-2px;color:var(--mbu-accent-text);margin-left:1px}' + '.fs-rec-video svg{display:block}' + '.fs-pending{background:rgba(168,112,42,.15);color:var(--mbu-warn);border:1px solid rgba(168,112,42,.4);border-radius:3px;padding:0 4px;font-size:9.5px;font-weight:700;white-space:nowrap}' + '.fs-gcard.fs-has-pending{border-left-color:var(--fs-amber) !important;border-left-style:dashed !important}' + '.fs-note-btn{cursor:pointer;font-size:12px;padding:1px 6px;border-radius:4px;border:1px solid var(--fs-border);color:var(--fs-muted);background:var(--fs-panel2);flex-shrink:0}' + '.fs-note-btn:hover{color:var(--fs-text);border-color:var(--fs-muted)}' + '.fs-note-btn.fs-has-note{background:rgba(109,63,240,.12);border-color:var(--fs-purple);color:var(--mbu-accent-deep-text);font-weight:700}' + '.fs-note-wrap{padding:8px 10px 10px}' + '.fs-note-ta{width:100%;box-sizing:border-box;min-height:110px;resize:vertical;font:12px ui-monospace,Consolas,monospace;color:var(--fs-text);background:var(--fs-panel2);border:1px solid var(--fs-border);border-radius:var(--mbu-radius);padding:7px 9px}' + '.fs-note-ta:focus{outline:none;border-color:var(--fs-purple)}' + '.fs-note-hint{color:var(--fs-muted);font-size:10.5px;margin-top:5px}' + '.fs-gcard.fs-editing{border-left-color:var(--fs-purple);border-left-style:solid}' + '.fs-note-save,.fs-note-cancel,.fs-note-clear{padding:2px 10px;font-size:11px}' + '.fs-note-save{background:linear-gradient(180deg,var(--fs-purple),var(--fs-purple-d));border-color:var(--fs-purple-d);color:var(--mbu-text-on-accent);font-weight:600}' + '.fs-kill{cursor:pointer;font-size:13px;padding:2px 4px;opacity:.6}' + '.fs-kill:hover{opacity:1}' + '.fs-clearboard-btn{padding:2px 8px;font-size:11px;text-transform:none;font-weight:400;letter-spacing:0}' + '.fs-grows{padding:4px 6px}' + '.fs-grow{display:flex;align-items:flex-start;gap:8px;padding:5px 7px;border-radius:5px}' + '.fs-grow:hover{background:rgba(0,0,0,.035)}' // no target-row wash: the ★ and the row's position at the top of the card already say which recording is kept (#529). + '.fs-star{width:16px;flex-shrink:0;text-align:center;font-size:13px;color:var(--mbu-text-weak);cursor:pointer;opacity:0;transition:opacity .1s}' + '.fs-grow:hover .fs-star{opacity:1}' + '.fs-star-on{color:var(--fs-amber) !important;opacity:1 !important;cursor:default}' + '.fs-star-disabled{cursor:default}' // box-sizing matters on these fixed-width flex cells (#529): the // shared-identifier tint adds horizontal padding, and under content-box that // padding ADDED to the width (96px→104px), so tinted rows sat 8px left of // untinted ones — the artist/length columns visibly failed to line up. + '.fs-grow .fs-t{flex:1;min-width:0}' + '.fs-artistcol{box-sizing:border-box;color:var(--fs-muted);font-size:11px;width:120px;flex-shrink:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' + '.fs-artistcol a{color:var(--fs-muted);text-decoration:underline;text-decoration-color:rgba(154,155,176,.35)}' + '.fs-artistcol a:hover{text-decoration-color:var(--fs-text);color:var(--fs-text)}' + '.fs-grow .fs-rel{box-sizing:border-box;color:var(--fs-muted);font-size:11px;width:190px;flex-shrink:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' + '.fs-grow .fs-len{box-sizing:border-box;color:var(--fs-muted);font-size:11px;width:44px;flex-shrink:0}' + '.fs-grow .fs-isrc{box-sizing:border-box;color:var(--fs-muted);font-size:10px;width:96px;flex-shrink:0;font-family:ui-monospace,Consolas,monospace;display:flex;flex-direction:column;gap:2px;align-items:flex-start}' + '.fs-idv{border-radius:3px;padding:1px 4px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.45}' + '.fs-idnone{padding:1px 0;line-height:1.45}' + '.fs-idunk{color:var(--mbu-info);cursor:help;font-weight:700}' + '.fs-grow .fs-t,.fs-grow .fs-artistcol,.fs-grow .fs-rel,.fs-grow .fs-len,.fs-grow .fs-star,.fs-grow .fs-acts,.fs-grow .fs-exp{line-height:1.45;padding-top:1px}' + '.fs-acts{display:flex;gap:4px;flex-shrink:0;opacity:0;transition:opacity .1s}' + '.fs-grow:hover .fs-acts{opacity:1}' + '.fs-acts span{color:var(--fs-muted);cursor:pointer;font-size:12px;padding:1px 3px}' + '.fs-acts span:hover{color:var(--fs-text)}' + '.fs-rm-x:hover{color:var(--fs-red) !important}' + '.fs-gerr{margin:0 8px 6px;padding:5px 8px;background:rgba(200,56,79,.09);border:1px solid rgba(200,56,79,.35);border-radius:5px;color:var(--fs-red);font-size:11px}' + '.fs-newgroup{border:1px dashed var(--fs-border);border-radius:7px;padding:9px;text-align:center;color:var(--fs-muted);font-size:12px;cursor:pointer}' + '.fs-ftr{display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--fs-panel2);border-top:1px solid var(--fs-border)}' + '.fs-idmore{color:var(--fs-muted);font-size:9px}' + '.fs-bgtask{font-size:11px;color:var(--fs-muted);white-space:nowrap}' + '.fs-bgstop{margin-left:6px;padding:0 4px;border-radius:3px;font-weight:700;color:var(--fs-muted);background:rgba(0,0,0,.06)}' + '.fs-bgstop:hover{background:rgba(200,56,79,.15);color:var(--fs-red)}' + '.fs-clearset{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--fs-muted);white-space:nowrap}' + '.fs-subcnt{font-size:10.5px;font-weight:600;color:var(--fs-muted);text-transform:none;letter-spacing:0;white-space:nowrap}' + '.fs-runsum{position:relative;margin:8px 10px 0;border:1px solid rgba(28,155,99,.4);background:rgba(28,155,99,.07);border-radius:var(--mbu-radius);font-size:11.5px}' + '.fs-runsum-d>summary{cursor:pointer;padding:7px 26px 7px 10px;font-weight:600;color:var(--mbu-ok);list-style:revert}' + '.fs-runlist{margin:0;padding:2px 10px 8px 26px;max-height:220px;overflow:auto}' + '.fs-runlist li{padding:1px 0;color:var(--fs-text)}' + '.fs-runbad{color:var(--fs-red)}' + '.fs-runn{color:var(--fs-muted);font-size:10px}' + '.fs-runerr{color:var(--fs-red);font-size:10.5px}' + '.fs-runclose{position:absolute;top:5px;right:7px;cursor:pointer;color:var(--fs-muted);font-size:12px;padding:2px 4px}' + '.fs-runclose:hover{color:var(--fs-text)}' + '.fs-matchmsg{font-size:10.5px;font-weight:600;text-transform:none;letter-spacing:0;white-space:nowrap;padding:2px 8px;border-radius:9px;cursor:help}' + '.fs-matchmsg-none{background:rgba(168,112,42,.13);color:var(--mbu-warn)}' + '.fs-matchmsg-ok{background:rgba(28,155,99,.13);color:var(--mbu-ok)}' + '.fs-tierlegend{display:inline-flex;align-items:center;gap:9px;font-size:10px;color:var(--fs-muted)}' + '.fs-tierkey{display:inline-flex;align-items:center;gap:4px;cursor:help}' + '.fs-tierkey i{width:9px;height:9px;border-radius:2px;display:inline-block}' + '.fs-toLog{cursor:pointer} .fs-toLog:hover{text-decoration:underline}' + '.fs-clearset .fs-btn{padding:2px 8px}' + '.fs-detbtn{font-size:12px;line-height:1;color:var(--fs-muted);border:1px solid var(--fs-border);border-radius:4px;padding:3px 6px;cursor:pointer;white-space:nowrap;background:var(--mbu-bg);flex-shrink:0}' + '.fs-detbtn:hover{border-color:var(--fs-purple);color:var(--mbu-accent-text)}' + '.fs-detbtn-on{background:rgba(109,63,240,.09);border-color:var(--fs-purple);color:var(--mbu-accent-text)}' + '.fs-idstat{white-space:nowrap;cursor:help}' + '.fs-idstat-none{color:var(--fs-muted)}' + '.fs-sum{color:var(--fs-muted);font-size:12px}' + '.fs-sum b{color:var(--fs-text)}' + '.fs-note{color:var(--fs-muted);font-size:11px}' + '.fs-settings{position:fixed;z-index:2147483001;background:var(--mbu-bg);color:var(--mbu-text);border:1px solid var(--mbu-border);border-radius:8px;padding:10px 14px;width:max-content;min-width:300px;max-width:min(460px,calc(100vw - 28px));box-sizing:border-box;box-shadow:0 8px 26px rgba(0,0,0,.25);font:13px -apple-system,Segoe UI,Arial,sans-serif}' + '.fs-settings .fs-ver{font-size:11px;color:var(--mbu-text-weak);font-weight:normal}' + '.fs-opt{display:block;margin:8px 0;font-size:12px}' + '.fs-opt textarea{width:100%;box-sizing:border-box;margin-top:4px;font-size:12px;font-family:inherit}' // log viewer — ported from apollo_editor's #283 window (wider, centred, // minimize/restore, badge, per-severity colouring), in Fusion's light palette. document.head.appendChild(s); } // #529 follow-up: "make recordings names in the cards links that open // recording MB page" — every place a recording's title is shown. function recLink(gid, text) { return '<a href="' + location.origin + '/recording/' + gid + '" target="_blank" rel="noopener">' + escapeHtml(text) + '</a>'; } function artistLink(rec) { if (!rec.artistCredit) return ''; return rec.artistGid ? '<a href="' + location.origin + '/artist/' + rec.artistGid + '" target="_blank" rel="noopener">' + escapeHtml(rec.artistCredit) + '</a>' : escapeHtml(rec.artistCredit); } // #529 follow-up (majkinetor, with a screenshot of the reference script's // "Releases (including from other release groups)" column): summarize every // release a recording appears on, deduped by release id — not just the one // it happened to be seeded from. Full list is in the tooltip (one per line); // the visible text stays short so rows don't blow out. function releasesSummary(rec) { const primary = rec.releases[0]; const full = rec.allReleases; // #529 (majkinetor): "We have 'no release' on some rows although expanding // them shows releases" — and the tooltip claimed a load was in progress when // nothing was. Browse seeding cannot ask for releases, and the search pass // that fills them in does not cover every recording, so an empty list here // usually means "never fetched" rather than "on no release". Claiming the // latter states a fact we do not have — the same mistake the AcoustID and // ISRC displays used to make. if (!primary && full == null) return { text: '—', tooltip: 'Release list not loaded for this recording yet — expand it to fetch' }; const primaryText = primary ? (primary.title + (primary.trackNumber ? ' · track ' + primary.trackNumber : '')) : '(no release)'; if (full == null) return { text: primaryText + ' …', tooltip: primaryText + '\n(more releases may exist — not loaded yet)' }; const seen = new Set(); const lines = []; for (const r of full) { const key = r.gid || r.title; if (key && !seen.has(key)) { seen.add(key); lines.push(r.title + (r.date ? ' (' + r.date + ')' : '')); } } if (lines.length <= 1) return { text: primaryText, tooltip: lines[0] || primaryText }; return { text: primaryText + ' +' + (lines.length - 1) + ' more', tooltip: lines.join('\n') }; } // #529 follow-up: "Show video marker" — a visible badge, not just a // behind-the-scenes exclusion rule, so it's obvious before you even try to group one. // #529 (majkinetor): "use the same image for video recording as Apollo" — // apollo_editor's #303 VIDEO_MARK, which itself mirrors MB's native recordings-table marker. const MERGE_MARK = '<svg class="fs-mergeicon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3v6a5 5 0 0 0 5 5h7"/><path d="M18 3v6a5 5 0 0 1-5 5h-2"/><polyline points="15 11 19 14 15 17"/></svg>'; const VIDEO_MARK = '<span class="fs-rec-video" title="This recording is a video"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></span>'; function videoBadge(rec) { return rec.video === true ? VIDEO_MARK + ' ' : ''; } function pendingBadge(rec) { return rec.editsPending ? '<span class="fs-pending" title="This recording has pending edits in MusicBrainz — excluded from matching and blocked from merging until they are applied">⏳ pending</span> ' : ''; } // #529 follow-up: "we should see isrc and accousticid in the card too" — the // pool card only showed presence dots; show the actual values (AcoustID // truncated, it's a 36-char UUID — full value is in the tooltip). function idsLine(rec) { const isrc = (rec.isrcs && rec.isrcs[0]) || null; const acid = (rec.acoustids && rec.acoustids[0]) || null; if (!isrc && !acid) return ''; let out = '<div class="fs-ids">'; if (isrc) out += '<span class="fs-idtag" title="' + escapeHtml((rec.isrcs || []).join(', ')) + '">' + escapeHtml(isrc) + '</span>'; if (acid) out += '<span class="fs-idtag" title="AcoustID ' + escapeHtml(acid) + '">' + escapeHtml(acid.slice(0, 8)) + '…</span>'; return out + '</div>'; } // A dot per identifier. "not looked up yet" is deliberately a THIRD state, not // folded into "absent": with acoustids left null (lookup skipped or still // running) an unlit dot would otherwise claim the recording has no AcoustID // when nobody ever asked. Same reason isrcsKnown exists on the fetch side. function presenceDots(rec) { const dot = (cls, label, list, known, unconfirmed, why) => { const n = list ? list.length : 0; // "absent" is only claimed when something authoritative actually said so. // An empty list from the SEARCH INDEX is not that: the index lags, so it // reports none for recordings MusicBrainz does have (#529). const state = !known ? 'unknown' : n ? 'on' : (unconfirmed ? 'unconfirmed' : 'off'); const title = state === 'unknown' ? label + ' not looked up yet' : state === 'on' ? n + ' ' + label + (n > 1 ? 's' : '') + ': ' + list.join(', ') : state === 'unconfirmed' ? 'no ' + label + ' found — ' + why : 'no ' + label + ' on this recording'; return '<span class="fs-b fs-b-' + cls + ' fs-b-' + state + '" title="' + escapeHtml(title) + '"></span>'; }; return '<div class="fs-badges">' + dot('isrc', 'ISRC', rec.isrcs, rec.isrcsKnown, rec.isrcSource === 'index', 'this comes from the search index, which lags; not confirmed against the recording itself') + dot('acid', 'AcoustID', rec.acoustids, rec.acoustids != null, false, '') + '</div>'; } function poolCardHtml(rec) { const rs = releasesSummary(rec); return '<div class="fs-pcard" draggable="true" data-gid="' + rec.gid + '">' + '<span class="fs-grip">⠿</span>' + '<div class="fs-info"><div class="fs-t" title="' + escapeHtml(rec.title) + '">' + pendingBadge(rec) + videoBadge(rec) + recLink(rec.gid, rec.title) + (rec.artistCredit ? ' <span class="fs-artist">— ' + artistLink(rec) + '</span>' : '') + '</div>' + '<div class="fs-m" title="' + escapeHtml(rs.tooltip) + '">' + escapeHtml(rs.text) + ' · ' + dur(rec.length) + '</div>' + idsLine(rec) + '</div>' + presenceDots(rec) + '<span class="fs-rm" data-act="pool-remove" title="remove from pool">✕</span></div>'; } // The expanded per-recording release table (#529). Columns follow MB's own // merge-page table so the two can be read side by side, with a status band // ("Official" / "Promotion" / …) heading each run exactly as MB does it. // #529: "add collapse all toggle above (should toggle collapsing on all cards)". // One button, and what it does next is decided by the majority: if anything is // still expanded it collapses everything, otherwise it expands everything — // so a half-collapsed board always has an obvious next move. // #529: "make pool collapsable itself to the left side". Collapsing hands the // pool's 360px to the groups column, which is what actually relieves the // release table's horizontal scrolling. A thin rail stays behind so the pool is // one click away and its count is still visible — collapsed, not hidden. function setPoolCollapsed(on) { const body = document.getElementById('fs-body'); if (!body) return; body.classList.toggle('fs-poolhidden', !!on); const tog = document.getElementById('fs-pooltog'); if (tog) tog.title = on ? 'show the pool' : 'collapse the pool to give the groups the full width'; if (SETTINGS.poolCollapsed !== !!on) { SETTINGS.poolCollapsed = !!on; saveSettings(); } renderPoolCount(); } // Only the rail's copy: renderPool owns fs-pool-cnt, including its filtered // "shown / total" form, and two writers would fight over it. function renderPoolCount() { const b = document.getElementById('fs-rail-cnt'); if (b) b.textContent = String(STATE.poolOrder.length); } function allGroupsCollapsed() { return STATE.groups.length > 0 && STATE.groups.every(g => STATE.collapsedGroups.has(g.id)); } function toggleCollapseAll() { if (allGroupsCollapsed()) STATE.collapsedGroups.clear(); else STATE.groups.forEach(g => STATE.collapsedGroups.add(g.id)); renderGroups(); } // #529 (majkinetor): "In the header add recursive expand (both cards and // recordings)". Collapse all only ever touched the cards; opening every release // table underneath still meant clicking each card's own control. This does // both, and fetches whatever those rows still need. function everythingExpanded() { return STATE.groups.length > 0 && STATE.groups.every(g => !STATE.collapsedGroups.has(g.id) && g.memberGids.every(m => STATE.expandedReleases.has(m))); } async function toggleExpandAllDeep() { resumeBackground('you used Expand all details'); if (everythingExpanded()) { STATE.groups.forEach(g => g.memberGids.forEach(m => STATE.expandedReleases.delete(m))); STATE.groups.forEach(g => STATE.collapsedGroups.add(g.id)); renderGroups(); return; } STATE.collapsedGroups.clear(); STATE.groups.forEach(g => g.memberGids.forEach(m => STATE.expandedReleases.add(m))); renderGroups(); const missing = []; for (const g of STATE.groups) for (const m of g.memberGids) if (!STATE.releaseDetails.has(m) && !missing.includes(m)) missing.push(m); if (!missing.length) return; Log.info('Expand all details: loading release tables for ' + missing.length + ' recording(s)'); let done = 0; const epoch = _bgEpoch; for (const gid of missing) { if (!bgAlive(epoch)) { Log.warn('Stopped loading release tables after ' + done + ' of ' + missing.length); break; } setBgTask('Loading recording releases ' + (done + 1) + '/' + missing.length + '…'); try { storeReleaseDetails(gid, await fetchReleaseDetails(gid)); } catch (e) { STATE.releaseDetails.set(gid, null); Log.warn('Release lookup failed for ' + gid + ': ' + e.message); } done++; renderGroups(); } setBgTask(''); } function renderDeepBtn() { const b = document.getElementById('fs-expandall-deep'); if (!b) return; const on = everythingExpanded(); b.textContent = (on ? '⇱' : '⇲') + ' All details'; b.title = on ? 'collapse every group and every release table' : "expand every group AND every recording's release table"; b.disabled = STATE.groups.length === 0; } function renderCollapseAllBtn() { const b = document.getElementById('fs-collapseall'); if (!b) return; const collapsed = allGroupsCollapsed(); b.textContent = collapsed ? '▶ Expand all' : '▼ Collapse all'; b.title = collapsed ? 'expand every group card' : 'collapse every group card'; b.disabled = STATE.groups.length === 0; } // #529: "add an option to load releases in the background for created groups … // but should not block from working". Only recordings that are actually IN a // group are prefetched — that is the set you are about to merge, and it is far // smaller than the pool. Sequential and gated, so it queues behind (and yields // to) anything the user does; nothing here calls busyStart. let _prefetchRunning = false, _prefetchCapWarned = 0; async function prefetchGroupReleases() { if (!SETTINGS.prefetchGroupReleases || _prefetchRunning || _bgStopped) return; const wanted = []; for (const g of STATE.groups) { for (const gid of g.memberGids) { if (!STATE.releaseDetails.has(gid) && !wanted.includes(gid)) wanted.push(gid); } } if (!wanted.length) return; const cap = SETTINGS.releasePrefetchCap || 200; if (wanted.length > cap) { // renderGroups calls this on EVERY render, so warn once per board size // rather than replaying the same line into the log on every repaint. if (_prefetchCapWarned !== wanted.length) { _prefetchCapWarned = wanted.length; Log.warn('Background release prefetch: ' + wanted.length + ' recording(s) exceeds the cap (' + cap + ') — expand a row to load its releases on demand instead'); } return; } _prefetchCapWarned = 0; _prefetchRunning = true; const epoch = _bgEpoch; Log.info('Background release prefetch: ' + wanted.length + ' recording(s) in ' + STATE.groups.length + ' group(s)'); let done = 0; try { for (const gid of wanted) { if (!bgAlive(epoch)) { Log.warn('Release prefetch stopped after ' + done + ' of ' + wanted.length); break; } // Groups can be dissolved or merged while this runs — skip anything // that has left the board rather than fetching data nobody wants. if (!STATE.groups.some(g => g.memberGids.includes(gid))) { done++; continue; } if (STATE.releaseDetails.has(gid)) { done++; continue; } setBgTask('Loading recording releases ' + (done + 1) + '/' + wanted.length + '…'); try { storeReleaseDetails(gid, await fetchReleaseDetails(gid)); } catch (e) { STATE.releaseDetails.set(gid, null); Log.warn('Release prefetch failed for ' + gid + ': ' + e.message); } done++; // only repaint if the row is actually open; the rest is cache warming if (STATE.expandedReleases.has(gid)) renderGroups(); } Log.info('Background release prefetch: finished ' + done + ' of ' + wanted.length); } finally { _prefetchRunning = false; setBgTask(''); // a group created while this was running still needs its turn if (STATE.groups.some(g => g.memberGids.some(m => !STATE.releaseDetails.has(m)))) setTimeout(prefetchGroupReleases, 0); } } // #529: "add uncollapse/all details toggle in the card itself" — one control per // card that opens (or closes) the release table for EVERY recording in it, // rather than clicking each row's caret in turn. Same all-or-nothing rule as // the board-level Collapse all: if anything is still closed, open everything. function groupAllExpanded(group) { return group.memberGids.length > 0 && group.memberGids.every(g => STATE.expandedReleases.has(g)); } async function toggleAllDetails(groupId) { const g = findGroup(groupId); if (!g) return; resumeBackground('you opened a card’s details'); if (groupAllExpanded(g)) { g.memberGids.forEach(gid => STATE.expandedReleases.delete(gid)); renderGroups(); return; } g.memberGids.forEach(gid => STATE.expandedReleases.add(gid)); renderGroups(); // loading stubs appear at once const missing = g.memberGids.filter(gid => !STATE.releaseDetails.has(gid)); if (!missing.length) return; let done = 0; const epoch = _bgEpoch; for (const gid of missing) { if (!bgAlive(epoch)) { Log.warn('Stopped loading release tables after ' + done + ' of ' + missing.length); break; } setBgTask('Loading recording releases ' + (done + 1) + '/' + missing.length + '…'); try { storeReleaseDetails(gid, await fetchReleaseDetails(gid)); } catch (e) { STATE.releaseDetails.set(gid, null); Log.warn('Release lookup failed for ' + gid + ': ' + e.message); } done++; renderGroups(); // these rows ARE open, so repaint } setBgTask(''); Log.info('Loaded release details for ' + done + ' recording(s) in the group'); } // The expanded table and the row summary were reading different sources: the // table used the freshly fetched detail, the summary used whatever the seed // happened to carry. Store in one place and backfill the recording, so a row // can never say "no release" while its own table lists them (#529). function storeReleaseDetails(gid, rows) { STATE.releaseDetails.set(gid, rows); const rec = STATE.recordings.get(gid); if (!rec || !rows) return; const seen = new Set(); const list = []; for (const r of rows) { if (!r.gid || seen.has(r.gid)) continue; seen.add(r.gid); list.push({ gid: r.gid, title: r.title, trackNumber: r.trackNumber, trackCount: null, date: r.date || null }); } rec.allReleases = list; if (!rec.releases || !rec.releases.length) rec.releases = list; } async function toggleReleaseDetails(gid) { resumeBackground('you expanded a recording'); if (STATE.expandedReleases.has(gid)) { STATE.expandedReleases.delete(gid); renderGroups(); return; } STATE.expandedReleases.add(gid); renderGroups(); // show "loading…" straight away if (STATE.releaseDetails.has(gid)) return; // cached (including a cached failure) const rec = STATE.recordings.get(gid); Log.info('Fetching full release details for ' + (rec ? describeRecordingForLog(rec) : gid)); busyStart(); try { const rows = await fetchReleaseDetails(gid); storeReleaseDetails(gid, rows); Log.info(rows ? 'Release details: ' + rows.length + ' release(s) for ' + (rec ? rec.title : gid) : 'Release details lookup failed for ' + gid); } catch (e) { STATE.releaseDetails.set(gid, null); Log.error('Release details lookup error: ' + e.message); } finally { busyEnd(); renderGroups(); } } function releaseTableHtml(rec) { const rows = STATE.releaseDetails.get(rec.gid); if (rows === undefined) return '<div class="fs-reltbl fs-relload">loading releases…</div>'; if (rows === null) return '<div class="fs-reltbl fs-relerr">could not load releases for this recording</div>'; if (!rows.length) return '<div class="fs-reltbl fs-relempty">this recording is not on any release</div>'; const cell = v => escapeHtml(v == null || v === '' ? '—' : String(v)); const td = (v, extra) => { const t = v == null || v === '' ? '—' : String(v); return '<td title="' + escapeHtml(t) + '"' + (extra || '') + '>' + escapeHtml(t) + '</td>'; }; let out = '<div class="fs-reltbl"><table>' + '<colgroup><col class="c-num"><col class="c-title"><col class="c-len"><col class="c-tart"><col class="c-rel">' + '<col class="c-rart"><col class="c-rgt"><col class="c-cd"><col class="c-label"><col class="c-cat"></colgroup>' + '<thead><tr>' + '<th>#</th><th>Title</th><th>Length</th><th>Track artist</th><th>Release title</th>' + '<th>Release artist</th><th>Release group type</th><th>Country/Date</th><th>Label</th><th>Catalog#</th>' + '</tr></thead><tbody>'; let band = undefined; for (const r of rows) { if (r.status !== band) { band = r.status; out += '<tr class="fs-relband"><td colspan="10">' + escapeHtml(band || 'No status') + '</td></tr>'; } const num = (r.discNumber ? r.discNumber + '.' : '') + (r.trackNumber == null ? '—' : r.trackNumber); const cd = [r.country, r.date].filter(Boolean).join(' ') || '—'; out += '<tr>' + '<td>' + escapeHtml(num) + '</td>' + td(r.trackTitle) + '<td>' + (r.trackLength ? dur(r.trackLength) : '—') + '</td>' + td(r.trackArtist) + '<td title="' + escapeHtml(r.title || '') + '"><a href="/release/' + r.gid + '" target="_blank" rel="noopener">' + cell(r.title) + '</a></td>' + td(r.artist) + td([r.rgType, r.format].filter(Boolean).join(' · ')) + '<td>' + escapeHtml(cd) + '</td>' + td(r.label) + td(r.catalog) + '</tr>'; } return out + '</tbody></table></div>'; } function groupCardHtml(group) { const members = group.memberGids.map(g => STATE.recordings.get(g)).filter(Boolean); // #529 follow-up: "remove radios, make hover action that one is merge // target. It should also go to the top of the card" — the target member // sorts first, an always-visible star marks it, and a hover-only star on // the other rows sets a new target. const ordered = members.slice().sort((a, b) => (a.gid === group.target ? -1 : 0) - (b.gid === group.target ? -1 : 0)); const confClass = group.confidence === 'high' ? 'high' : group.confidence === 'medium' ? 'med' : 'manual'; // #619 (chaban-mb): a group held together by AcoustID/title/artist can still have lengths // well past the tolerance (4:53 vs 4:38) — the card was all green and only a faded Length // chip said so. Flag it: amber Length chip, a "⚠ Ns" badge by the title, the off rows' // length cells, and the card tooltip. Grouping itself is unchanged (the gross-length guard // still decides that); this is the warning MBS-10966 asks for at merge time. const lenTol = SETTINGS.lengthToleranceMs != null ? SETTINGS.lengthToleranceMs : 5000; const lenSpread = lengthSpread(members); const lenOff = lenSpread != null && lenSpread > lenTol; const lenRefRec = members.find(m => m.gid === group.target && typeof m.length === 'number' && m.length > 0) || null; const lenOffBy = m => (lenRefRec && m !== lenRefRec && typeof m.length === 'number' && m.length > 0 && Math.abs(m.length - lenRefRec.length) > lenTol) ? m.length - lenRefRec.length : null; const secs = ms => Math.round(Math.abs(ms) / 1000); const lenWarnTitle = lenOff ? lengthDiffLabel(members) + ' ' + secs(lenSpread) + 's — more than the ' + secs(lenTol) + 's length tolerance. Check it is really the same take before merging.' : ''; const sigNames = { isrc: 'ISRC', acoustid: 'AcoustID', length: 'Length', title: 'Title', artist: 'Artist' }; const sigAll = group.signalsAll || []; const sigChips = Object.keys(sigNames).map(k => { const lit = sigAll.includes(k); const partial = !lit && (group.signals || []).includes(k); if (k === 'length' && lenOff) return '<span class="warn" title="' + escapeHtml(lenWarnTitle) + '">' + sigNames[k] + '</span>'; // #619 const title = lit ? sigNames[k] + ' matches across every recording in this group' : partial ? sigNames[k] + ' matches only some of these recordings, not all' : 'no ' + sigNames[k] + ' match'; return '<span class="' + (lit ? 'hit' : partial ? 'partial' : '') + '" title="' + title + '">' + sigNames[k] + '</span>'; }).join(''); const busy = group.state === 'busy', done = group.state === 'done'; const tooFew = members.length < 2; const stateLabel = busy ? '⏳ Merging…' : done ? '✓ Merged' : group.state === 'error' ? '⚠ Retry merge' : tooFew ? MERGE_MARK + ' Merge (needs 2+)' : MERGE_MARK + ' Merge'; const stateCls = done ? 'fs-done' : group.state === 'error' ? 'fs-err' : ''; // #529 (majkinetor): "we should color the same isrc/acousticid within card … // If there are multiple groups, each should have its own color" — any // identifier shared by 2+ members gets a tint, and each DISTINCT shared value // gets its own colour, so which rows actually agree is visible at a glance. // Values appearing only once stay plain, since they prove nothing. // Count how many MEMBERS carry each value, not how many times it occurs: // a value listed twice on one recording proves nothing about agreement. const idCounts = new Map(); ordered.forEach(m => { uniq([...(m.isrcs || []), ...(m.acoustids || [])]).forEach(v => idCounts.set(v, (idCounts.get(v) || 0) + 1)); }); const idColor = new Map(); ordered.forEach(m => { uniq([...(m.isrcs || []), ...(m.acoustids || [])]).forEach(v => { if (idCounts.get(v) > 1 && !idColor.has(v)) idColor.set(v, 'fs-idc' + (idColor.size % 6)); }); }); // #529 (majkinetor, "This accousticId isn't matching"): a recording can carry // SEVERAL AcoustIDs, and showing list[0] displayed a non-shared one next to // another row's shared one — so a correctly-matched group looked mismatched. // Show the value that actually links this row to the rest, and say how many // more there are rather than hiding them. // #529 (majkinetor, option B): show EVERY identifier, stacked one per line, // for ISRCs as well as AcoustIDs. Showing a single value could never express // the truth once a recording carries more than one — two rows can be linked // by different ids, so whichever one the cell picked, some real relationship // stayed invisible and the colours read as a contradiction. // #529 (majkinetor): "sort id's … so we don't have them moving places across // rows (9e is first on pos 1 then at pos 2)". The order has to come from the // GROUP, not from each recording's own array, or the same value lands on a // different line in each row. Most-shared first (that is the line that // explains why the group exists), ties broken alphabetically so it is total // and stable. const idRank = (v) => (-(idCounts.get(v) || 0)); const sortIds = (vals) => vals.slice().sort((a, b) => idRank(a) - idRank(b) || (a < b ? -1 : a > b ? 1 : 0)); const idCell = (rec, kind) => { const isAcoustid = kind === 'acoustid'; const list = isAcoustid ? rec.acoustids : rec.isrcs; const vals = sortIds(uniq(list || [])); const label = isAcoustid ? 'AcoustID' : 'ISRC'; if (!vals.length) { // An em dash asserts "none". Only say that when something // authoritative did; otherwise say we do not know (#529). const known = isAcoustid ? rec.acoustids != null : !!rec.isrcsKnown; const unconfirmed = !isAcoustid && rec.isrcSource === 'index'; const mark = !known ? '?' : unconfirmed ? '·' : '—'; const tip = !known ? label + ' not looked up yet' : unconfirmed ? 'no ' + label + ' found, but this comes from the search index, which lags — not confirmed against the recording itself' : 'no ' + label + ' on this recording'; return '<span class="fs-isrc"><span class="fs-idnone' + (known && !unconfirmed ? '' : ' fs-idunk') + '" title="' + escapeHtml(tip) + '">' + mark + '</span></span>'; } const tags = vals.map(v => { const cls = idColor.get(v) || ''; const shown = isAcoustid ? escapeHtml(v.slice(0, 8)) + '…' : escapeHtml(v); const title = label + ' ' + escapeHtml(v) + (cls ? ' — shared with another recording in this group' : ' — only on this recording'); return '<span class="fs-idv ' + cls + '" title="' + title + '">' + shown + '</span>'; }).join(''); return '<span class="fs-isrc">' + tags + '</span>'; }; const rows = ordered.map(m => { const rs = releasesSummary(m); const isTarget = group.target === m.gid; const canPick = !busy && !done; const star = isTarget ? '<span class="fs-star fs-star-on" title="merge target — this one is kept">★</span>' : '<span class="fs-star' + (canPick ? '' : ' fs-star-disabled') + '" data-act="' + (canPick ? 'set-target' : '') + '" title="' + (canPick ? 'make this the merge target' : '') + '">☆</span>'; const expanded = STATE.expandedReleases.has(m.gid); return '<div class="fs-growwrap">' + '<div class="fs-grow' + (isTarget ? ' fs-target-row' : '') + '" draggable="true" data-gid="' + m.gid + '">' + '<span class="fs-exp' + (expanded ? ' fs-exp-on' : '') + '" data-act="toggle-releases" title="' + (expanded ? 'hide' : 'show') + ' every release this recording appears on">' + (expanded ? '▼' : '▶') + '</span>' + star + '<span class="fs-t" title="' + escapeHtml(m.title) + '">' + pendingBadge(m) + videoBadge(m) + recLink(m.gid, m.title) + '</span>' + '<span class="fs-artistcol" title="' + escapeHtml(m.artistCredit || '') + '">' + artistLink(m) + '</span>' + '<span class="fs-rel" title="' + escapeHtml(rs.tooltip) + '">' + escapeHtml(rs.text) + '</span>' + (() => { const d = lenOffBy(m); return d == null ? '<span class="fs-len">' + dur(m.length) + '</span>' : '<span class="fs-len fs-len-off" title="' + (d > 0 ? '+' : '−') + secs(d) + 's against the merge target (' + dur(lenRefRec.length) + ') — tolerance ' + secs(lenTol) + 's">' + dur(m.length) + '</span>'; })() // #619 + idCell(m, 'isrc') + idCell(m, 'acoustid') + '<span class="fs-acts"><span data-act="return" title="return to pool">↩</span><span class="fs-rm-x" data-act="remove-both" title="remove from group + pool">✕</span></span></div>' + (expanded ? releaseTableHtml(m) : '') + '</div>'; }).join(''); const errMsg = group.state === 'error' ? '<div class="fs-gerr">' + escapeHtml(group.error || 'merge failed') + '</div>' : ''; const activeCls = (STATE.activeGroupId === group.id ? ' fs-active' : '') + (members.some(m => m.editsPending) ? ' fs-has-pending' : ''); const head = ordered[0]; const hasNote = !!(group.editNote && group.editNote.trim()); // #529 (majkinetor): "add in each card edit button in the title - clicking it // should transform entire card into textbox for edit note … If there is edit // note, button should be different color." const noteBtn = '<span class="fs-note-btn' + (hasNote ? ' fs-has-note' : '') + '" data-act="edit-note" title="' + (hasNote ? 'Custom edit note set — click to edit' : 'Add a custom edit note for this merge') + '">✎</span>'; if (group.editing) { const draft = group.editNote != null ? group.editNote : autoEditNote(group); return '<div class="fs-gcard fs-gcard-' + confClass + activeCls + ' fs-editing" data-gid="' + group.id + '">' + '<div class="fs-ghdr"><span class="fs-gt">✎ Edit note — ' + escapeHtml(head ? head.title : 'group') + '</span><div class="fs-sp"></div>' + '<button class="fs-btn fs-note-save" type="button" data-act="note-save">Save</button>' + '<button class="fs-btn fs-note-cancel" type="button" data-act="note-cancel">Cancel</button>' + (hasNote ? '<button class="fs-btn fs-note-clear" type="button" data-act="note-clear" title="revert to the auto-generated note">Reset</button>' : '') + '</div>' + '<div class="fs-note-wrap"><textarea class="fs-note-ta" spellcheck="false" placeholder="Edit note submitted with this merge…">' + escapeHtml(draft) + '</textarea>' + '<div class="fs-note-hint">Replaces the auto-generated reason line. Fusion\'s attribution footer is always appended.</div></div></div>'; } // #529: "We should also be able to collapse entire card." Collapsed keeps the // header — confidence, chips and Merge stay usable — and hides only the rows, // so a long board can be skimmed without losing the ability to act on it. const collapsed = STATE.collapsedGroups.has(group.id); // #529 (majkinetor): "Keep matrix at the middle (A), remove '2 recordings' as // it is redundant … remove STRICT/NORMAL etc. and use colors (C), with non // intrusive background row color for each variant." // · the leading count already says how many recordings, so the words go // · the title cell is FIXED width, which is what puts every chip at the // same x across cards — the matrix only aligns if the thing before it // cannot vary // · the tier is carried by the row tint and left rail, not a text pill const tier = group.tier || 'manual'; const tierWhy = (tier === 'manual' ? 'Grouped by hand — no cutoff level would have formed this group automatically' : 'Holds together at the "' + tier + '" cutoff' + (tier === 'loose' ? ' — it would not form at a stricter setting' : '')) + (lenOff ? ' — but its lengths differ by up to ' + secs(lenSpread) + 's (tolerance ' + secs(lenTol) + 's)' : ''); // #619: auto or by hand return '<div class="fs-gcard fs-gcard-' + confClass + ' fs-tier-' + tier + activeCls + (collapsed ? ' fs-gcard-collapsed' : '') + '" data-gid="' + group.id + '" title="' + escapeHtml(tierWhy) + '">' // Three tracks — 1fr | auto | 1fr — so the chip matrix is centred on the // CARD, not merely at a fixed offset after the title (#529: "matrix is // not in the middle"). Equal side tracks also mean the centre column // lands at the same x on every card without the title needing a fixed // width, so long titles no longer have to be truncated to keep the // matrix aligned. + '<div class="fs-ghdr fs-ghdr-grid">' + '<div class="fs-ghl">' + '<span class="fs-gnum" title="' + members.length + ' recording' + (members.length === 1 ? '' : 's') + ' in this group">' + members.length + '</span>' + '<span class="fs-ctog" data-act="toggle-card" title="' + (collapsed ? 'expand this group' : 'collapse this group') + '">' + (collapsed ? '▶' : '▼') + '</span>' + '<span class="fs-gt" title="' + escapeHtml(head ? head.title : '') + '">' + (head ? recLink(head.gid, head.title) : 'New group') + '</span>' + (lenOff ? '<span class="fs-lenwarn" title="' + escapeHtml(lenWarnTitle) + '">⚠ ' + secs(lenSpread) + 's</span>' : '') // #619 + '</div>' + '<div class="fs-sig">' + sigChips + '</div>' + '<div class="fs-ghr">' + (collapsed ? '' : '<span class="fs-detbtn' + (groupAllExpanded(group) ? ' fs-detbtn-on' : '') + '" data-act="toggle-all-details" title="' + (groupAllExpanded(group) ? 'hide the release tables for every recording in this group' : 'show the release tables for every recording in this group') + '">' + '▤' + '</span>') + noteBtn + '<button class="fs-mbtn ' + stateCls + '" type="button" data-act="merge-group" ' + (busy || done || tooFew ? 'disabled' : '') + '>' + stateLabel + '</button>' + '<span class="fs-kill" data-act="delete-group" title="delete this group — members return to the pool" ' + (busy ? 'style="display:none"' : '') + '>🗑</span>' + '</div></div>' + (collapsed ? '' : '<div class="fs-grows">' + rows + '</div>' + errMsg) + '</div>'; } // #529 (majkinetor): "add pool filtering as one types in the header instead" — // the header hint was static text; it is now a filter box. Purely a VIEW // filter: Auto-match, merges and counts still operate on the whole pool, so a // filter left in the box can never silently narrow what actually gets matched. function poolMatches(rec, q) { if (!q) return true; const hay = [rec.title, rec.artistCredit, ...(rec.isrcs || []), ...(rec.acoustids || []), ...(rec.releases || []).map(r => r.title)] .filter(Boolean).join(' '); return normName(hay).includes(normName(q)); } function renderPool() { const body = document.getElementById('fs-pool-body'); if (!body) return; const q = STATE.poolFilter || ''; const all = STATE.poolOrder.map(g => STATE.recordings.get(g)).filter(Boolean); const recs = q ? all.filter(r => poolMatches(r, q)) : all; const cnt = document.getElementById('fs-pool-cnt'); if (cnt) { cnt.textContent = q ? recs.length + ' / ' + all.length : String(all.length); cnt.classList.toggle('fs-cnt-filtered', !!q); } body.innerHTML = recs.length ? recs.map(poolCardHtml).join('') : '<div class="fs-empty">' + (q ? 'No pool recording matches “' + escapeHtml(q) + '”.' : 'Pool is empty — add a recording by MBID/URL above.') + '</div>'; if (STATE.selected) { const c = body.querySelector('[data-gid="' + STATE.selected + '"]'); if (c) c.classList.add('fs-selected'); } renderPoolCount(); } function renderGroups() { const body = document.getElementById('fs-groups-body'); if (!body) return; document.getElementById('fs-groups-cnt').textContent = String(STATE.groups.length); // #529 (majkinetor): "add number of recordings in the groups too … It shows // 16 groups but no number of total recordings" — the group count alone says // nothing about how much of the pool is actually staged for merging. const recs = document.getElementById('fs-groups-recs'); if (recs) { const n = STATE.groups.reduce((a, g) => a + g.memberGids.length, 0); recs.textContent = n ? n + ' recording' + (n === 1 ? '' : 's') : ''; recs.title = n ? n + ' recording(s) staged across ' + STATE.groups.length + ' group(s)' : ''; } body.innerHTML = STATE.groups.map(groupCardHtml).join('') + '<div class="fs-newgroup" id="fs-newgroup">+ New group — drag a pool recording here, or select one and click here</div>'; placeLenBadges(body); // the rows' length column moves whenever the window is resized or maximized if (!body._fsLenObs && typeof ResizeObserver !== 'undefined') { let raf = 0; body._fsLenObs = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => placeLenBadges(body)); }); body._fsLenObs.observe(body); } renderCollapseAllBtn(); renderDeepBtn(); // Groups just changed, so there may be new members to warm the cache for. // Fire-and-forget: it must never make rendering wait on the network. prefetchGroupReleases(); } // #619 follow-up (majkinetor): "Move ⚠ 31s to the right, it should be in 'len column'". // The header is its own grid (title | chips | buttons) and shares no columns with the // member rows, so the badge is placed by measurement: its left edge goes to the rows' // length column (the edge the amber length cells start at). It stays beside the title // when there are no rows to line up with (a collapsed card) or when that column would // run into the title, the chips or the buttons (a narrow window). function placeLenBadges(root) { (root || document).querySelectorAll('.fs-gcard').forEach(card => { const badge = card.querySelector('.fs-lenwarn'); if (!badge) return; badge.classList.remove('fs-lenwarn-col'); badge.style.left = ''; const hdr = card.querySelector('.fs-ghdr'); const cell = card.querySelector('.fs-grows .fs-grow .fs-len'); if (!hdr || !cell || !cell.offsetParent) return; const h = hdr.getBoundingClientRect(), c = cell.getBoundingClientRect(); // an amber cell already reaches 4px left (see .fs-len-off); a plain one doesn't const edge = c.left - (cell.classList.contains('fs-len-off') ? 0 : 4); badge.classList.add('fs-lenwarn-col'); badge.style.left = Math.round(edge - h.left - hdr.clientLeft) + 'px'; const b = badge.getBoundingClientRect(); const clash = ['.fs-gt', '.fs-sig', '.fs-ghr'].some(sel => { const el = card.querySelector(sel); if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && b.left < r.right + 4 && b.right > r.left - 4; }); if (clash) { badge.classList.remove('fs-lenwarn-col'); badge.style.left = ''; } }); } function renderFooter() { const ready = STATE.groups.filter(g => g.state === 'pending' || g.state === 'error').length; const done = STATE.groups.filter(g => g.state === 'done').length; const sum = document.getElementById('fs-summary'); // #529: "Add AcoustID/ISRC counts in the statusbar (footer) that are shown in // log". Counted over everything on the board — pool AND grouped — so the // number doesn't drop as recordings move into groups. Recordings never // looked up are excluded from the denominator rather than counted as zero, // matching the pool dots' unknown/absent distinction. const all = [...STATE.recordings.values()]; const isrcUnconfirmed = all.filter(r => r.isrcsKnown && r.isrcSource === 'index' && !(r.isrcs && r.isrcs.length)).length; const isrcKnown = all.filter(r => r.isrcsKnown); const withIsrc = isrcKnown.filter(r => r.isrcs && r.isrcs.length).length; const acidKnown = all.filter(r => r.acoustids != null); const withAcid = acidKnown.filter(r => r.acoustids.length).length; const idPart = (label, n, known, total) => { if (!known) return '<span class="fs-idstat fs-idstat-none" title="' + label + 's have not been looked up yet">' + label + ' —</span>'; const pending = total - known; const unconf = label === 'ISRC' ? isrcUnconfirmed : 0; return '<span class="fs-idstat" title="' + n + ' of ' + known + ' checked recording(s) have an ' + label + (pending ? '; ' + pending + ' not looked up yet' : '') + (unconf ? '; ' + unconf + ' reported none by the search index only, which lags — not confirmed against the recording' : '') + '"><b>' + n + '</b>/' + known + ' ' + label + (pending || unconf ? '*' : '') + '</span>'; }; if (sum) sum.innerHTML = '<b>' + STATE.groups.length + '</b> group' + (STATE.groups.length === 1 ? '' : 's') + ' · <b>' + ready + '</b> ready' + (done ? ' · <b>' + done + '</b> merged' : '') + ' · <b>' + STATE.poolOrder.length + '</b> in pool' + ' · ' + idPart('ISRC', withIsrc, isrcKnown.length, all.length) + ' · ' + idPart('AcoustID', withAcid, acidKnown.length, all.length); const btn = document.getElementById('fs-mergeall'); // #529: the lightning bolt belongs to Auto-match alone; this is a merge. if (btn) { btn.disabled = ready === 0; btn.innerHTML = MERGE_MARK + ' Merge All (' + ready + ') →'; } } function renderAll() { renderPool(); renderGroups(); renderFooter(); } // _scopeBaseLabel remembers the scope text on its own, so later additions (the // RG-edition count) can't compound by re-reading and re-appending the DOM text. // #529 (majkinetor): "it's not clear loading is happening - make it flashing in // the title" — a pulsing indicator next to the title for as long as ANY async // operation is in flight. Ref-counted, so overlapping operations (seeding while // the RG lookup runs) don't clear it early. let _busyCount = 0, _busyLabel = ''; function renderBusy() { const e = document.getElementById('fs-busy'); if (!e) return; if (_busyCount > 0) { e.textContent = '⏳ ' + (_busyLabel || 'working…'); e.style.display = ''; } else { e.style.display = 'none'; e.textContent = ''; } } // A SEPARATE indicator from the _busyCount one on purpose. Background work must // not participate in that counter: maybeAutoMatchOnOpen waits for it to drain, // so counting a long prefetch there would stall auto-match — and #529 asked for // this explicitly ("should not block from working"). let _bgLabel = ''; function setBgTask(text) { _bgLabel = text || ''; const e = document.getElementById('fs-bgtask'); if (!e) return; if (_bgLabel) { e.innerHTML = '<span class="fs-bgtext">' + escapeHtml(_bgLabel) + '</span>' + '<span class="fs-bgstop" title="stop loading">✕</span>'; e.style.display = ''; } else { e.style.display = 'none'; e.innerHTML = ''; } } // #529 (majkinetor): "Provide a way to stop ongoing fetching". Background work // runs in loops that can span hundreds of requests; there was no way out but // closing the page. Every such loop captures the epoch it started in and stops // as soon as it changes, so cancelling takes effect at the next request rather // than needing an abortable transport. // Cancelling has to STICK. renderGroups kicks the prefetch on every render, and // the prefetch re-queues itself while anything is still missing, so a plain // "stop this loop" was undone by the very next repaint (#529: "after hitting // [x] button it keeps coming back"). Automatic background work stays off until // the user asks for something explicitly. let _bgEpoch = 0, _bgStopped = false; function bgAlive(epoch) { return epoch === _bgEpoch && !_bgStopped; } function resumeBackground(why) { if (!_bgStopped) return; _bgStopped = false; Log.info('Background loading re-enabled (' + why + ')'); } function cancelBackground() { _bgEpoch++; _bgStopped = true; setBgTask(''); clearNetTrouble(); Log.warn('Background loading stopped — what already arrived is kept; it will not restart by itself'); } function busyStart(label) { _busyCount++; if (label) _busyLabel = label; renderBusy(); } function busyEnd() { _busyCount = Math.max(0, _busyCount - 1); if (_busyCount === 0) _busyLabel = ''; renderBusy(); } // #529 (majkinetor): "acoustic id still not fully fetched … no dot in the pool // is lighted". AcoustIDs used to be looked up ONLY inside Auto-match, so a // freshly seeded pool always showed them as unknown even though the data was // one batched request away. Seeding now kicks this off in the background — // batched (50 MBIDs/request), non-blocking, re-rendering as results land. // Background enrichment re-renders as results land, which REPLACES pool card // nodes. A native dblclick only fires when both clicks hit the same element, so // a render landing between a user's two clicks silently swallows the gesture — // the same failure mode that made Merge All look dead (#529). Coalesce // background renders and hold them off briefly after any pool click. let _bgRenderTimer = null, _lastPoolClick = 0; const POOL_CLICK_GRACE = 700; // ms after a pool click during which we never re-render function scheduleBackgroundRender() { if (!FUSION_OPEN) return; clearTimeout(_bgRenderTimer); // Re-check the grace window at FIRE time, not just when scheduling: a timer // armed before the user clicked would otherwise still fire mid-gesture and // swallow their double-click. Keep pushing it out while clicks keep coming. const fire = () => { const since = Date.now() - _lastPoolClick; if (since < POOL_CLICK_GRACE) { _bgRenderTimer = setTimeout(fire, POOL_CLICK_GRACE - since); return; } if (FUSION_OPEN) renderAll(); }; _bgRenderTimer = setTimeout(fire, 250); } function enrichPoolInBackground(recordings) { if (!recordings.length) return; if (SETTINGS.acoustidEnrich === false) { Log.info('AcoustID lookup disabled in options — skipping background lookup'); return; } if (recordings.length > SETTINGS.acoustidPoolCap) { Log.warn('Skipping AcoustID lookup — ' + recordings.length + ' recordings exceeds the safety cap (' + SETTINGS.acoustidPoolCap + ')'); return; } Log.info('Fetching AcoustIDs for ' + recordings.length + ' recording(s) in batches of ' + ACOUSTID_BATCH + ' (~' + Math.ceil(recordings.length / ACOUSTID_BATCH) + ' request(s))'); busyStart('fetching AcoustIDs…'); // AcoustIDs are batched (50 MBIDs per request) so they are cheap to do up // front. Pending edits are one request per recording, so they wait until // something is actually grouped — see onAutoMatch and mergeGroup. enrichAcoustIds(recordings, 4, scheduleBackgroundRender) .catch(e => Log.error('Background AcoustID lookup failed: ' + e.message)) .finally(() => { busyEnd(); scheduleBackgroundRender(); }); } let _scopeBaseLabel = ''; function setScopeLabel(text) { _scopeBaseLabel = text; const e = document.getElementById('fs-scope'); if (e) e.textContent = text; } // append to the REMEMBERED base, never to whatever is currently rendered — // otherwise re-running this would keep tacking the suffix on again and again. // #529: "it would be cool to have some progress in 'Loading ..' (page 1, 2...)" // The total is unknown until the first page comes back, so page 1 reports bare // and every later page can say how many there are in total. function seedPageProgress(page, totalPages, loaded, total) { const of = totalPages ? ' of ' + totalPages : ''; const got = total ? ' — ' + loaded + ' of ' + total + ' recordings so far' : ''; setScopeLabel('Loading… page ' + page + of + got); } function setScopeSuffix(suffix) { const e = document.getElementById('fs-scope'); if (e) e.textContent = _scopeBaseLabel + suffix; } async function onAutoMatch() { const btn = document.getElementById('fs-automatch'); if (!btn) return; btn.disabled = true; const orig = btn.textContent; btn.textContent = 'Matching…'; busyStart('matching…'); try { const poolRecs = STATE.poolOrder.map(g => STATE.recordings.get(g)).filter(Boolean); Log.info('Match starting on ' + poolRecs.length + ' pool recording(s), cutoff=' + SETTINGS.matchCutoff); poolRecs.forEach(r => Log.info(' pool: ' + describeRecordingForLog(r))); // Enrich EVERY known recording, not just ungrouped ones (#529): members // already sitting in a group were previously never looked up, so two rows // sharing an AcoustID could show one value and one blank. const allRecs = [...STATE.recordings.values()]; btn.textContent = 'Matching… (ISRCs)'; await enrichIsrcs(allRecs, 2, (done, total) => { btn.textContent = 'Matching… (ISRC ' + done + '/' + total + ')'; }); if (SETTINGS.acoustidEnrich) { if (allRecs.length <= SETTINGS.acoustidPoolCap) { Log.info('Looking up AcoustIDs for ' + allRecs.length + ' recording(s) (batched, api.acoustid.org)…'); await enrichAcoustIds(allRecs, 4, (done, total) => { btn.textContent = 'Matching… (AcoustID ' + done + '/' + total + ')'; }); } else Log.warn('Skipping AcoustID lookup — ' + allRecs.length + ' recordings exceeds the cap (' + SETTINGS.acoustidPoolCap + ')'); } btn.textContent = 'Matching… (comparing)'; let groupings = autoMatch(poolRecs, SETTINGS.lengthToleranceMs, SETTINGS.matchCutoff); // Only now, for the few recordings actually grouped, is a pending-edit // lookup worth a request each. A group containing one is dissolved: its // members go back to the pool rather than being silently merged. const grouped = [...new Set(groupings.flatMap(g => g.memberGids))].map(g => STATE.recordings.get(g)).filter(Boolean); if (grouped.length) { btn.textContent = 'Matching… (pending edits)'; await enrichPendingEdits(grouped, 2, (done, total) => { btn.textContent = 'Matching… (pending ' + done + '/' + total + ')'; }); const before = groupings.length; groupings = groupings.filter(g => { const bad = g.memberGids.map(x => STATE.recordings.get(x)).filter(r => r && r.editsPending); if (bad.length) Log.warn('Dropped a proposed group — pending edit(s) on: ' + bad.map(r => r.title).join(', ')); return !bad.length; }); if (before !== groupings.length) Log.warn((before - groupings.length) + ' proposed group(s) dropped because a member has pending edits'); } Log.info('Match formed ' + groupings.length + ' group(s) from ' + poolRecs.length + ' pool recording(s)'); groupings.forEach(g => { Log.info(' formed group ' + g.id + ' — confidence=' + g.confidence + ' signals=[' + g.signals.join(',') + ']'); g.memberGids.forEach(gid => Log.info(' ' + gid + (gid === g.target ? ' (target)' : '') + ': ' + describeRecordingForLog(STATE.recordings.get(gid)))); }); for (const g of groupings) { g.memberGids.forEach(gid => { const i = STATE.poolOrder.indexOf(gid); if (i !== -1) STATE.poolOrder.splice(i, 1); }); STATE.groups.push(g); } // Short in the header (it is one line), full sentence in the tooltip. if (!groupings.length) { showNotice('none', 'Match found nothing to group in ' + poolRecs.length + ' pool recording(s) at the "' + SETTINGS.matchCutoff + '" cutoff. Try a looser cutoff, or group by hand.', 'match: nothing matched at "' + SETTINGS.matchCutoff + '"'); } else { const recs = groupings.reduce((a, g) => a + g.memberGids.length, 0); showNotice('ok', 'Match formed ' + groupings.length + ' group' + (groupings.length === 1 ? '' : 's') + ' from ' + recs + ' recording' + (recs === 1 ? '' : 's') + ' at the "' + SETTINGS.matchCutoff + '" cutoff.', 'match: +' + groupings.length + ' group' + (groupings.length === 1 ? '' : 's')); } renderAll(); } finally { busyEnd(); btn.disabled = false; btn.textContent = orig; } } // #529 follow-up (majkinetor, live): "I should be able to add release URL and // release group URL to get all recordings from them" — detect the entity type // from the pasted URL's path and bulk-add every recording it resolves to, // rather than always assuming a bare recording. function parseAddInput(s) { s = String(s || '').trim(); const mbid = parseMbidFromInput(s); if (!mbid) return null; let type = 'recording'; if (/\/release-group\//.test(s)) type = 'release-group'; else if (/\/release\//.test(s)) type = 'release'; else if (/\/artist\//.test(s)) type = 'artist'; return { type, mbid }; } async function onAddByMbid() { const input = document.getElementById('fs-add-input'); if (!input) return; const parsed = parseAddInput(input.value); if (!parsed) { Log.warn('Add: no MBID found in "' + input.value + '"'); return; } const { type, mbid } = parsed; if (type === 'recording') { if (STATE.recordings.has(mbid)) { Log.warn('Add: ' + mbid + ' is already in the pool or a group'); input.value = ''; return; } Log.info('Adding recording ' + mbid + '…'); busyStart('adding recording…'); const rec = await fetchRecordingByGid(mbid).finally(() => busyEnd()); if (!rec) { Log.error('Add: could not fetch recording ' + mbid + ' (is it a recording MBID?)'); return; } addToPool(rec); input.value = ''; renderAll(); enrichPoolInBackground([rec]); return; } if (type === 'artist') { Log.warn('Add: pasting an artist URL/MBID isn\'t supported — open Fusion from that artist\'s Recordings tab instead'); return; } Log.info('Adding all recordings from ' + type + ' ' + mbid + '…'); busyStart('adding ' + type + '…'); try { const { recordings } = type === 'release' ? await fetchReleaseRecordings(mbid) : await fetchRGRecordings(mbid); let added = 0; const fresh = recordings.filter(r => addToPool(r)); added = fresh.length; Log.info('Added ' + added + ' new recording(s) from that ' + type); enrichPoolInBackground(fresh); input.value = ''; } finally { busyEnd(); } renderAll(); } // #529 (majkinetor): "include all details on the release in RG (year, format // etc.)" — a bare title+date made near-identical reissues impossible to tell // apart. inc=releases+media carries format/track-count/country/status too, at // no extra request cost. function mediaSummary(rel) { const media = rel.media || []; if (!media.length) return ''; const counts = new Map(); media.forEach(m => { const f = m.format || 'unknown'; counts.set(f, (counts.get(f) || 0) + 1); }); return [...counts].map(([f, n]) => (n > 1 ? n + '×' : '') + f).join(' + '); } function describeEdition(rel) { const bits = []; if (rel.date) bits.push(rel.date); const fmt = mediaSummary(rel); if (fmt) bits.push(fmt); const tracks = (rel.media || []).reduce((n, m) => n + (m['track-count'] || 0), 0) || rel['track-count']; if (tracks) bits.push(tracks + ' track' + (tracks === 1 ? '' : 's')); if (rel.country) bits.push(rel.country); if (rel.status && rel.status !== 'Official') bits.push(rel.status); return rel.title + (bits.length ? ' — ' + bits.join(' · ') : ''); } // #529 (majkinetor): "RG button on release doesn't show immediately so it's not // obvious that there is anything there" — this takes two sequential WS2 calls, // so show the control up front in a disabled "checking…" state rather than // having it pop into existence seconds later (or never). async function maybeShowRGDropdown(releaseMbid) { const sel = document.getElementById('fs-rg-editions'); if (!sel) return; const setPlaceholder = (text, show) => { sel.innerHTML = '<option value="">' + escapeHtml(text) + '</option>'; sel.disabled = true; sel.style.display = show ? '' : 'none'; }; setPlaceholder('⏳ Checking release group for other editions…', true); Log.info('Checking release group for sibling editions…'); busyStart('checking release group…'); try { const j = await wsGet('/ws/2/release/' + releaseMbid + '?inc=release-groups&fmt=json'); const rgId = j && j['release-group'] && j['release-group'].id; if (!rgId) { Log.warn('Could not resolve this release\'s release group — edition loader unavailable'); setPlaceholder('', false); return; } const rgRels = await wsGet('/ws/2/release-group/' + rgId + '?inc=releases+media&fmt=json'); if (!rgRels) { setPlaceholder('⚠ Could not load release group editions', true); sel.disabled = true; return; } const siblings = ((rgRels.releases) || []).filter(r => r.id !== releaseMbid); if (!siblings.length) { Log.info('Release group has no other editions'); setPlaceholder('No other editions in this release group', true); return; } siblings.sort((a, b) => String(a.date || '9999').localeCompare(String(b.date || '9999'))); sel.innerHTML = '<option value="">+ Load recordings from RG edition (' + siblings.length + ') ▾</option>' + siblings.map(r => '<option value="' + r.id + '">' + escapeHtml(describeEdition(r)) + '</option>').join(''); sel.disabled = false; sel.style.display = ''; Log.info('Release group has ' + siblings.length + ' other edition(s): ' + siblings.map(r => describeEdition(r)).join(' | ')); setScopeSuffix(' — release group has ' + siblings.length + ' other edition' + (siblings.length === 1 ? '' : 's')); } finally { busyEnd(); } } async function onLoadRgEdition(e) { const relMbid = e.target.value; if (!relMbid) return; Log.info('Loading recordings from edition ' + relMbid + '…'); busyStart('loading edition…'); try { const { recordings } = await fetchReleaseRecordings(relMbid); let added = 0; const freshEd = recordings.filter(r => addToPool(r)); added = freshEd.length; Log.info('Added ' + added + ' new recording(s) from that edition'); enrichPoolInBackground(freshEd); e.target.value = ''; } finally { busyEnd(); } renderAll(); } // #529 follow-up (majkinetor, live): a recording needs to move pool→group, // group→pool, AND group→group by drag, and dropping anywhere in the target // column (not just on a narrow strip) should work. STATE._dragSrc tracks // where the dragged recording came FROM (null groupId = the pool) so drop // handlers can move it out of there before placing it wherever it landed. function moveDraggedTo(targetGroupId) { const src = STATE._dragSrc; if (!src) return; if (src.groupId === targetGroupId) { STATE._dragSrc = null; return; } let ok = true; if (src.groupId == null) { ok = addToGroup(src.gid, targetGroupId); } else { const rec = STATE.recordings.get(src.gid); const ng = findGroup(targetGroupId); if (ng && videoConflict(ng, rec)) { Log.warn('Refused to move ' + src.gid + ' into group ' + targetGroupId + ' — video/audio mismatch'); ok = false; } else { const g = findGroup(src.groupId); if (g) { const i = g.memberGids.indexOf(src.gid); if (i !== -1) g.memberGids.splice(i, 1); dissolveOrRefresh(g); } if (ng) { ng.memberGids.push(src.gid); refreshGroupMeta(ng); Log.info('Moved ' + src.gid + ' into group ' + targetGroupId); } } } STATE.selected = null; STATE._dragSrc = null; if (ok) STATE.activeGroupId = targetGroupId; } function moveDraggedToPool() { const src = STATE._dragSrc; if (!src || src.groupId == null) return; returnToPool(src.gid, src.groupId); STATE._dragSrc = null; } function moveDraggedToNewGroup() { const src = STATE._dragSrc; if (!src) return; let g; if (src.groupId == null) g = createGroupWithMember(src.gid); else { const old = findGroup(src.groupId); if (old) { const i = old.memberGids.indexOf(src.gid); if (i !== -1) old.memberGids.splice(i, 1); dissolveOrRefresh(old); } g = { id: 'g' + Math.random().toString(36).slice(2, 9), memberGids: [src.gid], confidence: 'manual', signals: [], signalsAll: [], target: src.gid, state: 'pending', error: null, editNote: null, editing: false }; STATE.groups.push(g); } STATE.selected = null; STATE._dragSrc = null; if (g) STATE.activeGroupId = g.id; } function targetGroupForQuickAdd() { if (STATE.activeGroupId && findGroup(STATE.activeGroupId)) return STATE.activeGroupId; return STATE.groups.length ? STATE.groups[STATE.groups.length - 1].id : null; } function wireDelegatedEvents() { const poolBody = document.getElementById('fs-pool-body'); const groupsBody = document.getElementById('fs-groups-body'); const poolCol = document.querySelector('.fs-pool'); const groupsCol = document.querySelector('.fs-groups'); poolBody.addEventListener('click', e => { const card = e.target.closest('.fs-pcard'); if (!card) return; const gid = card.dataset.gid; _lastPoolClick = Date.now(); if (e.target.dataset.act === 'pool-remove') { removeFromPoolPermanently(gid); renderAll(); return; } STATE.selected = STATE.selected === gid ? null : gid; // Real bug (majkinetor: "Merge all is unclickable"): a full renderPool() // here replaces every card's DOM node, including the one just clicked — // and a native dblclick event only fires when BOTH clicks land on the // SAME element. Replacing it after the first click silently killed every // double-click, so no group was ever created and Merge All stayed // permanently disabled. A plain class toggle keeps the node identity. poolBody.querySelectorAll('.fs-pcard.fs-selected').forEach(el => el.classList.remove('fs-selected')); if (STATE.selected) card.classList.add('fs-selected'); }); // #529 follow-up: "Let double click on recording in a pool make it added // to the current group. Group is selectable. If none is selected last // group is used or new one is created if there isn't any." poolBody.addEventListener('dblclick', e => { const card = e.target.closest('.fs-pcard'); if (!card) return; const gid = card.dataset.gid; const targetId = targetGroupForQuickAdd(); if (targetId) { if (addToGroup(gid, targetId)) STATE.activeGroupId = targetId; } else { const g = createGroupWithMember(gid); if (g) STATE.activeGroupId = g.id; } renderAll(); }); poolBody.addEventListener('dragstart', e => { const card = e.target.closest('.fs-pcard'); if (!card) return; STATE._dragSrc = { gid: card.dataset.gid, groupId: null }; try { e.dataTransfer.setData('text/plain', card.dataset.gid); e.dataTransfer.effectAllowed = 'move'; } catch (ex) {} }); // whole pool column accepts a drop (from a group) to return a recording — // one listener on the outer column; events from its children bubble up. poolCol.addEventListener('dragover', e => { if (STATE._dragSrc && STATE._dragSrc.groupId != null) e.preventDefault(); }); poolCol.addEventListener('drop', e => { if (!STATE._dragSrc || STATE._dragSrc.groupId == null) return; e.preventDefault(); moveDraggedToPool(); renderAll(); }); groupsBody.addEventListener('dragstart', e => { const row = e.target.closest('.fs-grow'); if (!row) return; const card = e.target.closest('.fs-gcard'); if (!card) return; STATE._dragSrc = { gid: row.dataset.gid, groupId: card.dataset.gid }; try { e.dataTransfer.setData('text/plain', row.dataset.gid); e.dataTransfer.effectAllowed = 'move'; } catch (ex) {} }); groupsBody.addEventListener('click', e => { if (e.target.closest('#fs-newgroup')) { if (STATE.selected && STATE.poolOrder.includes(STATE.selected)) { const g = createGroupWithMember(STATE.selected); if (g) STATE.activeGroupId = g.id; STATE.selected = null; renderAll(); } return; } const act = e.target.dataset.act; const row = e.target.closest('.fs-grow'); const card = e.target.closest('.fs-gcard'); if (!card) return; // click the card's header (but not its Merge button) to make it the // "current" group for double-click-to-add and empty-zone drops. if (!act && e.target.closest('.fs-ghdr') && !e.target.closest('.fs-mbtn')) { // The standing "drop from pool…" line is gone (#529: spammy), so the // click-to-add it carried moves here: with a pool recording selected, // clicking a group adds it; otherwise the click just makes the group // current, as before. if (STATE.selected && STATE.poolOrder.includes(STATE.selected)) { if (addToGroup(STATE.selected, card.dataset.gid)) STATE.activeGroupId = card.dataset.gid; STATE.selected = null; renderAll(); return; } STATE.activeGroupId = STATE.activeGroupId === card.dataset.gid ? null : card.dataset.gid; renderGroups(); return; } if (act === 'toggle-releases' && row) { toggleReleaseDetails(row.dataset.gid); return; } if (act === 'toggle-all-details') { toggleAllDetails(card.dataset.gid); return; } if (act === 'toggle-card') { const id = card.dataset.gid; if (STATE.collapsedGroups.has(id)) STATE.collapsedGroups.delete(id); else STATE.collapsedGroups.add(id); renderGroups(); return; } if (act === 'set-target' && row) { const g = findGroup(card.dataset.gid); if (g) { g.target = row.dataset.gid; renderGroups(); } return; } if (act === 'return' && row) { returnToPool(row.dataset.gid, card.dataset.gid); renderAll(); return; } if (act === 'remove-both' && row) { removeFromGroupAndPool(row.dataset.gid, card.dataset.gid); renderAll(); return; } if (act === 'merge-group') { mergeGroup(findGroup(card.dataset.gid)); return; } if (act === 'delete-group') { deleteGroup(card.dataset.gid); renderAll(); return; } if (act === 'edit-note') { const g = findGroup(card.dataset.gid); if (g) { g.editing = true; renderGroups(); const ta = groupsBody.querySelector('.fs-gcard[data-gid="' + g.id + '"] .fs-note-ta'); if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } } return; } if (act === 'note-save') { const g = findGroup(card.dataset.gid); if (!g) return; const ta = card.querySelector('.fs-note-ta'); const val = ta ? ta.value.trim() : ''; // storing the auto note verbatim would falsely read as "custom" g.editNote = (!val || val === autoEditNote(g)) ? null : val; g.editing = false; Log.info('Edit note for group ' + g.id + (g.editNote ? ' set: ' + g.editNote.replace(/\n/g, ' ¶ ') : ' reset to auto')); renderGroups(); return; } if (act === 'note-cancel') { const g = findGroup(card.dataset.gid); if (g) { g.editing = false; renderGroups(); } return; } if (act === 'note-clear') { const g = findGroup(card.dataset.gid); if (g) { g.editNote = null; g.editing = false; Log.info('Edit note for group ' + g.id + ' reset to auto'); renderGroups(); } return; } }); // "entire zone" drag&drop (#529 follow-up): dropping anywhere over a group // card adds to THAT group; dropping on #fs-newgroup OR on empty background // (not over any card) creates a new group — not just a narrow strip. One // listener on the outer column; children's events bubble up to it. groupsCol.addEventListener('dragover', e => { if (STATE._dragSrc) e.preventDefault(); }); groupsCol.addEventListener('dragover', e => { const card = e.target.closest && e.target.closest('.fs-gcard'); groupsCol.querySelectorAll('.fs-dragover').forEach(x => { if (x !== card) x.classList.remove('fs-dragover'); }); if (card) card.classList.add('fs-dragover'); }); groupsCol.addEventListener('dragleave', e => { const card = e.target.closest && e.target.closest('.fs-gcard'); if (card && !card.contains(e.relatedTarget)) card.classList.remove('fs-dragover'); }); groupsCol.addEventListener('drop', e => { groupsCol.querySelectorAll('.fs-dragover').forEach(x => x.classList.remove('fs-dragover')); if (!STATE._dragSrc) return; e.preventDefault(); const card = e.target.closest('.fs-gcard'); if (card) moveDraggedTo(card.dataset.gid); else moveDraggedToNewGroup(); renderAll(); }); } function openSettings(anchor) { document.getElementById('fs-settings')?.remove(); const s = el('div', 'fs-settings'); s.id = 'fs-settings'; s.innerHTML = mbuCfgHeader({ script: 'fusion', name: 'Fusion', version: VERSION, icon: ICON, log: true }) + '<label class="fs-opt"><input type="checkbox" id="fs-opt-votable"> Always require a vote (make_votable)</label>' + '<label class="fs-opt"><input type="checkbox" id="fs-opt-acoustid"> Look up AcoustIDs (acoustid.org, batched)</label>' + '<label class="fs-opt" title="Run Match by itself as soon as the pool has finished loading, instead of waiting for you to press the button."><input type="checkbox" id="fs-opt-automatch"> Match on open</label>' + '<label class="fs-opt" title="As soon as a group is formed, fetch the full release list for every recording in it, so expanding a row is instant. Costs one request per grouped recording; off by default because seeding already makes a lot of them. Runs in the background, never blocks the UI, and the header stop button halts it."><input type="checkbox" id="fs-opt-prefetch"> Preload group release details in the background</label>' + '<label class="fs-opt">Length tolerance <input type="number" id="fs-opt-tol" min="0" max="60" style="width:48px"> s</label>' + '<label class="fs-opt" title="Match never groups two recordings whose known lengths differ by more than this, whatever else matches. Manual grouping is unaffected.">Match never groups if lengths differ by more than <input type="number" id="fs-opt-gross" min="5" max="600" style="width:56px"> s</label>'; document.body.appendChild(s); const r = anchor.getBoundingClientRect(); s.style.top = (r.bottom + 6) + 'px'; s.style.right = '14px'; if (r.bottom + 6 + s.offsetHeight > window.innerHeight - 8) { s.style.top = 'auto'; s.style.bottom = (window.innerHeight - r.top + 6) + 'px'; } // #695: from the corner launcher, open upward s.querySelector('#fs-opt-votable').checked = !!SETTINGS.makeVotable; s.querySelector('#fs-opt-acoustid').checked = SETTINGS.acoustidEnrich !== false; s.querySelector('#fs-opt-automatch').checked = !!SETTINGS.autoMatchOnOpen; s.querySelector('#fs-opt-tol').value = Math.round(SETTINGS.lengthToleranceMs / 1000); s.querySelector('#fs-opt-gross').value = Math.round((SETTINGS.grossLengthMs != null ? SETTINGS.grossLengthMs : 30000) / 1000); s.querySelector('#fs-opt-prefetch').checked = !!SETTINGS.prefetchGroupReleases; s.querySelector('#fs-opt-votable').onchange = e => { SETTINGS.makeVotable = e.target.checked; saveSettings(); }; s.querySelector('#fs-opt-acoustid').onchange = e => { SETTINGS.acoustidEnrich = e.target.checked; saveSettings(); }; s.querySelector('#fs-opt-automatch').onchange = e => { SETTINGS.autoMatchOnOpen = e.target.checked; saveSettings(); Log.info('Match on open: ' + (SETTINGS.autoMatchOnOpen ? 'on' : 'off')); }; s.querySelector('#fs-opt-prefetch').onchange = e => { SETTINGS.prefetchGroupReleases = e.target.checked; saveSettings(); Log.info('Background release prefetch: ' + (SETTINGS.prefetchGroupReleases ? 'on' : 'off')); if (SETTINGS.prefetchGroupReleases) prefetchGroupReleases(); }; s.querySelector('#fs-opt-tol').onchange = e => { SETTINGS.lengthToleranceMs = Math.max(0, Number(e.target.value) || 0) * 1000; saveSettings(); }; s.querySelector('#fs-opt-gross').onchange = e => { SETTINGS.grossLengthMs = Math.max(5, Number(e.target.value) || 30) * 1000; saveSettings(); Log.info('Gross-length guard set to ' + Math.round(SETTINGS.grossLengthMs / 1000) + 's'); }; s.querySelector('.mbu-cfg-log').onclick = () => { s.remove(); openLog(); }; const off = e => { if (!s.contains(e.target) && !anchor.contains(e.target)) { s.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); // Escape closes the popup only (capture, so the main window's own Escape doesn't also fire) const esc = e => { if (!s.isConnected) { document.removeEventListener('keydown', esc, true); return; } if (e.key !== 'Escape') return; e.stopPropagation(); s.remove(); document.removeEventListener('keydown', esc, true); document.removeEventListener('mousedown', off); }; document.addEventListener('keydown', esc, true); } // #529 follow-up: "Window should be movable and maximizable" — remembered // across opens the same way apollo_editor's log popup remembers its position. const WINSTATE_KEY = 'fusion.winstate'; function loadWinState() { try { return JSON.parse(GM_getValue(WINSTATE_KEY, '{}')); } catch (e) { return {}; } } function saveWinState(patch) { try { GM_setValue(WINSTATE_KEY, JSON.stringify(Object.assign(loadWinState(), patch))); } catch (e) {} } function applyWinState(cons) { const st = loadWinState(); if (st.maximized) { cons.classList.add('fs-maximized'); const mb = document.getElementById('fs-max'); if (mb) { mb.textContent = '❐'; mb.title = 'Restore'; } return; } if (st.left != null && st.top != null) { cons.style.position = 'fixed'; cons.style.left = st.left + 'px'; cons.style.top = st.top + 'px'; cons.style.margin = '0'; } if (st.width != null) cons.style.width = st.width + 'px'; if (st.height != null) cons.style.height = st.height + 'px'; } // glyphs/behaviour match group_therapy's maximize control (⛶ / ❐, gt-cons-x style) function toggleMaximize(cons, btn) { const nowMax = !cons.classList.contains('fs-maximized'); cons.classList.toggle('fs-maximized', nowMax); if (nowMax) { cons._savedW = cons.style.width; cons._savedH = cons.style.height; cons.style.width = ''; cons.style.height = ''; } else { cons.style.width = cons._savedW || ''; cons.style.height = cons._savedH || ''; } if (btn) { btn.textContent = nowMax ? '❐' : '⛶'; btn.title = nowMax ? 'Restore' : 'Maximize'; } saveWinState({ maximized: nowMax }); } function wireWindowChrome(cons, hdr, maxBtn) { applyWinState(cons); maxBtn.onclick = () => toggleMaximize(cons, maxBtn); hdr.addEventListener('mousedown', e => { if (e.target.closest('button, .fs-cfgbtn, .fs-x, select, input')) return; if (cons.classList.contains('fs-maximized')) return; const rect = cons.getBoundingClientRect(); cons.style.position = 'fixed'; cons.style.left = rect.left + 'px'; cons.style.top = rect.top + 'px'; cons.style.margin = '0'; const ox = e.clientX - rect.left, oy = e.clientY - rect.top; const mv = ev => { const nl = Math.max(0, Math.min(innerWidth - 80, ev.clientX - ox)); const nt = Math.max(0, Math.min(innerHeight - 40, ev.clientY - oy)); cons.style.left = nl + 'px'; cons.style.top = nt + 'px'; }; const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); const r2 = cons.getBoundingClientRect(); saveWinState({ left: r2.left, top: r2.top, width: r2.width, height: r2.height, maximized: false }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); // resize:both is native CSS (see fsStyle) — just persist the size afterward new ResizeObserver(() => { if (!cons.classList.contains('fs-maximized')) saveWinState({ width: cons.offsetWidth, height: cons.offsetHeight }); }).observe(cons); } let FUSION_OPEN = false; // Escape closes the TOP-MOST thing only: if the log window (or the settings // popup) is open it handles its own Escape, and the main window stays put — // otherwise one keypress tore down everything at once. function _fsEscHandler(e) { if (e.key !== 'Escape') return; if (document.getElementById('mbu-logpop') || document.getElementById('fs-settings')) return; closeFusion(); } function buildShell() { document.getElementById('fs-overlay')?.remove(); const overlay = el('div', 'fs-overlay'); overlay.id = 'fs-overlay'; const CUTOFF_HELP = { strict: 'Strict — group only on a shared identifier (ISRC or AcoustID). Fewest, safest matches.', normal: 'Normal — a shared identifier, or title AND artist together with a close length. A length that is missing on either side does not block the match (it just can’t corroborate it); two known lengths that disagree do.', loose: 'Loose — a shared identifier, or title with either a close length or a matching artist. Most matches, needs the most review.', }; // #529 (majkinetor): "show color in the cutoff combo for reference" — the // card rows are tinted by tier and nothing else decoded those colours. // Native <option> styling is patchy across browsers, so the swatch is drawn // as a coloured square in a sibling strip that is always visible, and the // options carry the colour too where the browser honours it. const cutoffOpts = MATCH_CUTOFFS.map(c => '<option value="' + c + '"' + (SETTINGS.matchCutoff === c ? ' selected' : '') + ' style="background:' + TIER_COLORS[c] + '22"' + ' title="' + escapeHtml(CUTOFF_HELP[c] || '') + '">' + c[0].toUpperCase() + c.slice(1) + '</option>').join(''); const tierKey = MATCH_CUTOFFS.concat('manual').map(c => '<span class="fs-tierkey" title="' + escapeHtml(c === 'manual' ? 'Grouped by hand — no cutoff level forms it automatically' : (CUTOFF_HELP[c] || '')) + '"><i style="background:' + TIER_COLORS[c] + '"></i>' + c + '</span>').join(''); overlay.innerHTML = '<div class="fs-cons" id="fs-cons">' + '<div class="fs-hdr" id="fs-hdr"><div class="fs-title">' + ICON + ' Fusion — Merge Recordings</div><span class="fs-busy" id="fs-busy" style="display:none" title="open the activity log"></span><span class="fs-bgtask" id="fs-bgtask" style="display:none" title="open the activity log"></span><span class="fs-netbanner" id="fs-netbanner" style="display:none"></span><div class="fs-scope" id="fs-scope" title="open the activity log">…</div><div class="fs-sp"></div>' + '<button class="fs-cons-x" id="fs-max" type="button" title="Maximize / restore">⛶</button><button class="fs-cons-x" id="fs-cfg" type="button" title="Fusion — options / log / help">' + MBU_CFG_ICON + '</button><button class="fs-cons-x" id="fs-close" type="button" title="Close">✕</button></div>' + '<div class="fs-ctrl"><select id="fs-rg-editions" style="display:none;"><option value="">+ Load recordings from RG edition ▾</option></select>' + '<input type="text" id="fs-add-input" placeholder="paste a recording, release, or release-group MBID|URL…" title="Paste an MBID or MusicBrainz URL — it is added automatically">' + '<div class="fs-sp"></div><div class="fs-legend">' + '<span>Cutoff <select id="fs-cutoff" title="How strict Match is. Hover an option for what it means.">' + cutoffOpts + '</select></span>' + '<span class="fs-tierlegend" title="Each group card is tinted by the strictest cutoff at which it still holds together">' + tierKey + '</span></div>' + '<button type="button" id="fs-automatch" class="fs-btn fs-primary">⚡ Match</button></div>' + '<div class="fs-body" id="fs-body"><div class="fs-col fs-pool"><div class="fs-colhdr">Pool <span class="fs-cnt" id="fs-pool-cnt">0</span><span class="fs-sp"></span><input type="text" id="fs-pool-filter" class="fs-poolfilter" placeholder="filter the pool…" title="Filter by title, artist, release, ISRC or AcoustID. Match still considers the whole pool."><span class="fs-pooltog" id="fs-pooltog" title="collapse the pool to give the groups the full width">◀</span></div>' + '<div class="fs-colbody" id="fs-pool-body"></div></div>' + '<div class="fs-poolrail" id="fs-poolrail" title="show the pool again"><span class="fs-railarrow">▶</span><span class="fs-raillabel">POOL <span id="fs-rail-cnt">0</span></span></div>' + '<div class="fs-col fs-groups"><div class="fs-colhdr">Groups <span class="fs-cnt" id="fs-groups-cnt">0</span><span class="fs-subcnt" id="fs-groups-recs"></span><button type="button" id="fs-expandall-deep" class="fs-btn" title="expand every group and every release table (or collapse it all again)">⇲ All details</button><button type="button" id="fs-collapseall" class="fs-btn" title="collapse or expand every group card">▼ Collapse all</button><span class="fs-matchmsg" id="fs-matchmsg" style="display:none"></span><span class="fs-sp"></span><span class="fs-clearset">Clear: <button type="button" id="fs-clearboard" class="fs-btn fs-clearboard-btn" title="dissolve every group — all recordings return to the pool">all</button><button type="button" id="fs-clearmerged" class="fs-btn fs-clearboard-btn" title="remove groups that have already been merged — their recordings leave the board (the merged-away ones no longer exist in MusicBrainz)">merged</button></span></div>' + '<div class="fs-runsum" id="fs-runsum" style="display:none"></div><div class="fs-colbody" id="fs-groups-body"></div></div></div>' + '<div class="fs-ftr"><div class="fs-sum" id="fs-summary"></div><div class="fs-sp"></div>' + '<div class="fs-note">Merges submit directly in the background — no MB merge page involved</div>' + '<button type="button" id="fs-mergeall" class="fs-btn fs-primary">Merge All →</button></div></div>'; document.body.appendChild(overlay); overlay.addEventListener('mousedown', e => { if (e.target === overlay) closeFusion(); }); document.addEventListener('keydown', _fsEscHandler); document.getElementById('fs-close').onclick = closeFusion; document.getElementById('fs-cfg').onclick = () => openSettings(document.getElementById('fs-cfg')); // #529 (majkinetor): "remove Add button on MBID edit, it should be added on // paste" — pasting is the only realistic way to enter a 36-char MBID, so the // paste itself is the action. Deferred a tick because on paste the input's // value is still the OLD text; it updates after the event completes. // Enter still works for anything typed or edited by hand. const addInput = document.getElementById('fs-add-input'); addInput.addEventListener('paste', () => setTimeout(() => onAddByMbid(), 0)); addInput.addEventListener('keydown', e => { if (e.key === 'Enter') onAddByMbid(); }); document.getElementById('fs-automatch').onclick = onAutoMatch; // NOT `onclick = mergeAll` — the click Event would land in mergeAll's first // parameter (concurrency), and being truthy it survives `|| 3`, making // Math.min(Event, n) → NaN → Array.from({length:NaN}) → zero workers → the // whole Merge All silently no-ops. Bit us live; see the guard in mergeAll too. document.getElementById('fs-mergeall').onclick = () => mergeAll(); document.getElementById('fs-clearboard').onclick = () => { clearBoard(); renderAll(); }; // wrapper, not a bare reference: onclick hands the click Event to the first // parameter, which has bitten this script before (#503, mergeAll's NaN) document.getElementById('fs-collapseall').onclick = () => toggleCollapseAll(); document.getElementById('fs-expandall-deep').onclick = () => toggleExpandAllDeep(); document.getElementById('fs-pooltog').onclick = () => setPoolCollapsed(true); document.getElementById('fs-poolrail').onclick = () => setPoolCollapsed(false); setPoolCollapsed(SETTINGS.poolCollapsed === true); document.getElementById('fs-clearmerged').onclick = () => { clearMerged(); renderAll(); }; document.getElementById('fs-runsum').addEventListener('click', e => { if (e.target.classList.contains('fs-runclose')) { _lastRun = null; renderRunSummary(); } }); // The header status texts all describe work whose detail is in the log, so // clicking any of them opens it — including the network pill, whose tooltip // already said "see Log for detail" without offering a way to get there. for (const id of ['fs-scope', 'fs-busy', 'fs-bgtask', 'fs-netbanner']) { const el = document.getElementById(id); if (el) { el.classList.add('fs-toLog'); el.onclick = (e) => { if (e.target.classList.contains('fs-bgstop')) cancelBackground(); else openLog(); }; } } document.getElementById('fs-rg-editions').addEventListener('change', onLoadRgEdition); const poolFilter = document.getElementById('fs-pool-filter'); poolFilter.addEventListener('input', () => { STATE.poolFilter = poolFilter.value.trim(); renderPool(); }); poolFilter.addEventListener('keydown', e => { if (e.key === 'Escape' && poolFilter.value) { e.stopPropagation(); poolFilter.value = ''; STATE.poolFilter = ''; renderPool(); } }); document.getElementById('fs-cutoff').addEventListener('change', e => { SETTINGS.matchCutoff = e.target.value; saveSettings(); Log.info('Match cutoff set to ' + SETTINGS.matchCutoff); }); wireWindowChrome(document.getElementById('fs-cons'), document.getElementById('fs-hdr'), document.getElementById('fs-max')); wireDelegatedEvents(); } function closeFusion() { FUSION_OPEN = false; document.getElementById('fs-overlay')?.remove(); document.removeEventListener('keydown', _fsEscHandler); } async function seedFromScope() { setScopeLabel('Loading…'); busyStart('loading recordings…'); try { if (SCOPE.type === 'release') { const { release, recordings } = await fetchReleaseRecordings(SCOPE.mbid); STATE.releaseInfo = release; recordings.forEach(r => addToPool(r)); setScopeLabel(release ? ('Release: "' + release.title + '" · ' + release.artistCredit) : 'Release'); renderAll(); await maybeShowRGDropdown(SCOPE.mbid); enrichAllReleases(recordings, 3, scheduleBackgroundRender).catch(() => {}); enrichPoolInBackground(recordings); } else if (SCOPE.type === 'release-group') { const { rg, recordings } = await fetchRGRecordings(SCOPE.mbid, seedPageProgress); STATE.rgInfo = rg; recordings.forEach(r => addToPool(r)); setScopeLabel(rg ? ('Release group: "' + rg.title + '" · ' + rg.artistCredit) : 'Release group'); enrichPoolInBackground(recordings); } else if (SCOPE.type === 'recording') { const rec = await fetchRecordingByGid(SCOPE.mbid); if (rec) addToPool(rec); setScopeLabel(rec ? ('Recording: "' + rec.title + '"') : 'Recording'); if (rec) enrichPoolInBackground([rec]); } else if (SCOPE.type === 'artist-recordings') { harvestInternalIdsFromPage(); // free ids from the visible page, independent of seeding const { artist, recordings, total } = await fetchArtistRecordings(SCOPE.mbid, seedPageProgress); recordings.forEach(r => addToPool(r)); setScopeLabel('Artist: ' + (artist ? '"' + artist.name + '"' : SCOPE.mbid) + ' — ' + recordings.length + (Number.isFinite(total) && total > recordings.length ? ' of ' + total : '') + ' recording(s)' + (recordings.length === 0 ? ' — nothing loaded' : '')); enrichPoolInBackground(recordings); } } finally { busyEnd(); } renderAll(); } // #529 (majkinetor): "Lets also have an option to run match automatically." // Deliberately waits for the AcoustID pass to finish first — matching before // the identifiers land would just produce weaker groups. async function maybeAutoMatchOnOpen() { if (!SETTINGS.autoMatchOnOpen) return; if (!STATE.poolOrder.length) { Log.info('Match on open: nothing in the pool'); return; } // eslint-disable-next-line no-unmodified-loop-condition -- busyEnd() lowers it while this awaits for (let i = 0; i < 120 && _busyCount > 0; i++) await new Promise(r => setTimeout(r, 250)); if (!FUSION_OPEN) return; Log.info('Match on open: starting'); await onAutoMatch(); } async function openFusion() { if (FUSION_OPEN) return; FUSION_OPEN = true; fsStyle(); buildShell(); renderAll(); if (STATE.recordings.size === 0) { await seedFromScope(); maybeAutoMatchOnOpen().catch(e => Log.error('Match on open failed: ' + e.message)); } } function ensureLauncher() { if (document.getElementById('fs-launch')) return; fsStyle(); const btn = el('button', 'fs-launch'); btn.id = 'fs-launch'; btn.type = 'button'; const img = document.createElement('img'); img.src = ICON_URL; img.alt = ''; btn.appendChild(img); btn.title = 'Fusion — merge recordings\nRight-click: settings'; // icon-only: the tooltip carries the name btn.dataset.mbCorner = 'br'; btn.dataset.mbCornerOrder = '30'; btn.onclick = () => openFusion(); btn.oncontextmenu = e => { e.preventDefault(); if (document.getElementById('fs-settings')) document.getElementById('fs-settings').remove(); else openSettings(btn); }; // #695: right-click on every corner launcher opens its settings document.body.appendChild(btn); mbRestackCorner('br'); } function boot() { Log.info('Fusion v' + VERSION + ' — startup on ' + SCOPE.type + ' ' + SCOPE.mbid); ensureLauncher(); // reopen the log window if it was left open (same as apollo_editor #283) setTimeout(() => { try { Log.reopen(); } catch (e) {} }, 800); // #283 reopen the log if it was left open } boot(); /* ── Mission Control adapter (#680) ────────────────────────────────────────── Mission Control asks over document events with JSON-string details (see userscripts/mission_control/DEVELOP.md). On a release page a probe loads the release group's recordings (the slow fetch MC's Auto/Ask switch exists for), auto-matches them with Fusion's own settings (ISRCs and AcoustIDs are not enriched here: that is minutes more, the Fusion window does it), and reports per track of THIS release the group it falls in, with what Fusion's group shows: each member's artist, release and track, length, ISRCs, AcoustIDs and open edits, and the signals. As in the window, a group with an open edit on a member is dropped (#529: never proposed) and its recordings stay ungrouped; a track whose own recording has one says so (`pending`), like the window's pool badge. MC can then ask to check one group's ISRCs and AcoustIDs (a few requests, not the release group's), or to open it in Fusion's window. Apply merges the ticked tracks' groups through mergeGroup, one at a time (MB's merge queue is one per session). Only Open puts a group on Fusion's board. */ if (SCOPE.type === 'release') { const mcSend = (type, detail) => document.dispatchEvent(new CustomEvent(type, { detail: JSON.stringify(detail) })); const mcHello = () => mcSend('mc:provider', { id: 'fusion', name: 'Fusion', version: VERSION, release: SCOPE.mbid, capabilities: ['probe', 'apply', 'check', 'open'] }); let mcGroups = new Map(); // recording gid of this release's track -> { g, byGid } let mcOwn = []; // this release's recordings, from the last probe let mcByGid = new Map(); // the release group's recordings, from the last probe const rgOfPage = () => { const a = document.querySelector('.releaseheader a[href*="/release-group/"]'); return a ? (a.getAttribute('href').match(/[0-9a-f-]{36}/) || [])[0] : null; }; const parse = (e, what) => { try { return JSON.parse(e.detail) || {}; } catch (x) { Log.warn('Mission Control ' + what + ' with unreadable detail: ' + x.message); return null; } }; const membersOf = hit => hit.g.memberGids.map(x => hit.byGid.get(x)).filter(Boolean); // one recording as MC's comparison shows it; rel is the release to name (this one, for our own track) const recInfo = (o, rel) => { rel = rel || o.releases[0] || {}; return { gid: o.gid, title: o.title, artist: o.artistCredit || '', release: rel.title || '', pos: rel.trackNumber || null, of: rel.trackCount || null, more: Math.max(0, o.releases.length - 1), len: dur(o.length), ms: o.length, isrcs: o.isrcs || [], acoustids: o.acoustids, pending: !!o.editsPending, }; }; const mcFinding = r => { const hit = mcGroups.get(r.gid); const base = { key: r.gid, track: r.gid, name: r.title }; if (!hit) return Object.assign(base, { state: 'none', matches: [], pending: !!(mcByGid.get(r.gid) || {}).editsPending }); const members = membersOf(hit); return Object.assign(base, { state: 'new', confidence: hit.g.confidence, tier: hit.g.tier, cutoff: SETTINGS.matchCutoff, signals: hit.g.signalsAll, any: hit.g.signals, checked: { isrc: members.every(m => m.isrcsKnown), acoustid: members.every(m => m.acoustids != null) }, why: hit.g.signals.join(', '), self: recInfo(hit.byGid.get(r.gid) || r, r.releases[0]), matches: members.filter(o => o.gid !== r.gid).map(o => recInfo(o)), }); }; document.addEventListener('mc:discover', () => { Log.info('Mission Control asked — answering as provider fusion'); mcHello(); }); document.addEventListener('mc:probe', async e => { const d = parse(e, 'probe'); if (!d) return; if ((d.release && d.release !== SCOPE.mbid) || (d.only && !d.only.includes('fusion'))) return; const progress = note => mcSend('mc:progress', { id: 'fusion', run: d.run, state: 'busy', note }); const done = (findings, note) => mcSend('mc:findings', { id: 'fusion', run: d.run, release: SCOPE.mbid, findings, note }); try { progress('loading the release'); const own = await fetchReleaseRecordings(SCOPE.mbid); const rg = rgOfPage(); if (!rg) { Log.warn('Mission Control probe: no release group link on the page'); done([], 'no release group'); return; } progress('loading the release group'); const { recordings } = await fetchRGRecordings(rg, (p, n, got) => progress('release group page ' + p + (n ? '/' + n : '') + ' · ' + got + ' recordings')); Log.info('Mission Control probe: ' + recordings.length + ' recording(s) in the release group, matching at "' + SETTINGS.matchCutoff + '"'); progress('matching ' + recordings.length + ' recordings'); const byGid = new Map(recordings.map(r => [r.gid, r])); const groups = autoMatch(recordings, SETTINGS.lengthToleranceMs, SETTINGS.matchCutoff); // as the window does: a group with a pending edit on a member is never // proposed (#529), and its recordings stay ungrouped const grouped = groups.flatMap(g => g.memberGids).map(x => byGid.get(x)).filter(Boolean); if (grouped.length) { progress('checking ' + grouped.length + ' grouped recordings for pending edits'); await enrichPendingEdits(grouped, 2); } mcGroups = new Map(); let dropped = 0; for (const g of groups) { const bad = g.memberGids.map(x => byGid.get(x)).filter(r => r && r.editsPending); if (bad.length) { dropped++; Log.warn('Dropped a proposed group — pending edit(s) on: ' + bad.map(r => r.title).join(', ')); continue; } for (const gid of g.memberGids) mcGroups.set(gid, { g, byGid }); } mcOwn = own.recordings; mcByGid = byGid; const findings = mcOwn.map(mcFinding); Log.info('Mission Control probe answered: ' + (groups.length - dropped) + ' group(s), ' + findings.filter(f => f.state === 'new').length + ' track(s) with duplicates' + (dropped ? ', ' + dropped + ' group(s) dropped for a pending edit' : '')); done(findings); } catch (x) { Log.error('Mission Control probe failed: ' + (x && x.message)); done([], 'failed: ' + (x && x.message)); } }); // check one group's ISRCs and AcoustIDs, then send its tracks' findings again document.addEventListener('mc:check', async e => { const d = parse(e, 'check'); if (!d) return; if (d.id !== 'fusion' || (d.release && d.release !== SCOPE.mbid)) return; const hit = mcGroups.get(d.key); const update = o => mcSend('mc:update', Object.assign({ id: 'fusion', run: d.run, release: SCOPE.mbid, keys: [d.key] }, o)); if (!hit) { update({ findings: [], error: 'that group is gone: fetch the release group again' }); return; } const members = membersOf(hit); try { Log.info('Mission Control: checking ISRCs and AcoustIDs of ' + members.length + ' recording(s) in the group of ' + d.key); await enrichIsrcs(members, 2); await enrichAcoustIds(members, 4); Object.assign(hit.g, computeGroupConfidence(members)); Log.info('Mission Control check: tier ' + hit.g.tier + ', signals on every pair [' + hit.g.signalsAll.join(', ') + ']'); update({ findings: mcOwn.filter(r => hit.g.memberGids.includes(r.gid)).map(mcFinding) }); } catch (x) { Log.error('Mission Control check failed: ' + (x && x.message)); update({ findings: [], error: 'the check failed: ' + (x && x.message) }); } }); // open Fusion's window with this group on the board document.addEventListener('mc:open', async e => { const d = parse(e, 'open'); if (!d) return; if (d.id !== 'fusion' || (d.release && d.release !== SCOPE.mbid)) return; const hit = mcGroups.get(d.key); if (!hit) { Log.warn('Mission Control open: no group for ' + d.key); return; } await openFusion(); const members = membersOf(hit); // each member into the pool first: out of any group it sits in, or added if the window never loaded it members.forEach(m => { const was = STATE.groups.find(g => g.memberGids.includes(m.gid)); if (was) returnToPool(m.gid, was.id); else if (!STATE.recordings.has(m.gid)) addToPool(m); else if (!STATE.poolOrder.includes(m.gid)) STATE.poolOrder.push(m.gid); }); const target = members.some(m => m.gid === hit.g.target) ? hit.g.target : members[0].gid; const g = createGroupWithMember(target); if (!g) return; members.forEach(m => { if (m.gid !== target) addToGroup(m.gid, g.id); }); g.target = target; STATE.activeGroupId = g.id; Log.info('Mission Control opened the group of ' + d.key + ' in Fusion (' + g.memberGids.length + ' recordings)'); renderAll(); }); document.addEventListener('mc:apply', async e => { const d = parse(e, 'apply'); if (!d) return; if (d.id !== 'fusion' || (d.release && d.release !== SCOPE.mbid)) return; const reply = o => mcSend('mc:applied', Object.assign({ id: 'fusion', run: d.run, release: SCOPE.mbid }, o)); const seen = new Set(), groups = []; for (const k of d.keys || []) { const h = mcGroups.get(k); if (h && !seen.has(h.g)) { seen.add(h.g); groups.push(h); } } if (!groups.length) { reply({ ok: true, sent: 0, note: 'nothing to merge' }); return; } if (d.dry) { reply({ ok: true, sent: 0, note: 'dry run: ' + groups.length + ' merge' + (groups.length === 1 ? '' : 's') + ' would be submitted' }); return; } let ok = 0; const errs = []; for (const { g, byGid } of groups) { g.via = 'Via Mission Control' + (d.mc ? ' v' + d.mc : '') + ': ' + location.origin + '/release/' + SCOPE.mbid; // the note's last line (buildEditNote) g.memberGids.forEach(gid => { if (!STATE.recordings.has(gid)) STATE.recordings.set(gid, byGid.get(gid)); }); // mergeGroup reads its members from here await mergeGroup(g); if (g.state === 'done') ok++; else errs.push(g.error || 'failed'); } reply(errs.length ? { ok: false, sent: ok, note: ok + ' merged, ' + errs.length + ' failed: ' + errs[0] } : { ok: true, sent: ok, note: ok + ' merge' + (ok === 1 ? '' : 's') + ' submitted' }); }); mcHello(); } try { W.__fusion = { VERSION, SCOPE, STATE, SETTINGS_DEFAULTS, MATCH_CUTOFFS, get SETTINGS() { return SETTINGS; }, normName, tokenMatch, titleSimilar, titleAlike, titleDistinctWords, isrcConflict, artistSimilar, lengthClose, fuzzyRatio, levenshtein, acName, acPrimaryGid, acGids, dur, parseMbidFromInput, parseAddInput, mkRecording, fetchRecordingsByBrowse, enrichReleasesFromSearch, fetchReleaseRecordings, fetchRGRecordings, fetchRecordingByGid, fetchAllReleases, resolveInternalId, fetchAcoustIds, fetchAcoustIdsBatch, enrichIsrcs, fetchRecordingDetail, fetchEntityMeta, enrichPendingEdits, fetchRecordingsBySearch, fetchArtistRecordings, harvestInternalIdsFromPage, pairSignals, poolMatches, computeGroupConfidence, groupTier, gatedUnionFind, TIER_COLORS, SIGNAL_KEYS, ACOUSTID_BATCH, shouldUnion, autoMatch, enrichAcoustIds, enrichAllReleases, presenceDots, // SETTINGS_DEFAULTS is listed above fetchReleaseDetails, releaseTableHtml, toggleReleaseDetails, storeReleaseDetails, releasesSummary, renderFooter, seedPageProgress, lengthSpread, renderRunSummary, getLastRun: () => _lastRun, showNotice, renderNotice, cancelBackground, bgAlive, resumeBackground, isBgStopped: () => _bgStopped, lengthDiffLabel, toggleCollapseAll, allGroupsCollapsed, placeLenBadges, toggleExpandAllDeep, everythingExpanded, setPoolCollapsed, renderPoolCount, backfillMissingReleases, prefetchGroupReleases, setBgTask, renderCollapseAllBtn, toggleAllDetails, groupAllExpanded, clearMerged, addToPool, createGroupWithMember, addToGroup, returnToPool, removeFromGroupAndPool, removeFromPoolPermanently, findGroup, deleteGroup, clearBoard, videoConflict, buildEditNote, autoEditNote, evidenceLines, ensureInternalIds, mergeGroup, mergeAll, describeRecordingForLog, openFusion, closeFusion, onAutoMatch, seedFromScope, maybeAutoMatchOnOpen, renderAll, renderPool, renderGroups, busyStart, busyEnd, gmGet, gmPost, wsGet, setNetTrouble, clearNetTrouble, getLogLines: () => Log.messages(), getBusyCount: () => _busyCount, }; } catch (e) {} })(); })(); } catch (e) { try { console.error('[String Theory] fusion failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== group_therapy (@run-at document-end) ===================================== if (__stOff.indexOf("group_therapy") < 0 && __stRuns("group_therapy", {"inc":["^https?:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*/edit-relationships$","^https?:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/artist/.*$","^https?:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/label/.*$","^https?:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/place/.*$"],"exc":[],"noframes":true})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Group Therapy*","namespace":"https://github.com/majkinetor/musicbrainz-userscripts","version":"2026.10.16","description":"MusicBrainz relationship helpers: batch-delete rel groups from a right-click menu, page-wide hover highlight with a count tooltip, and copy/move credits between recordings & clone release credits. Chrome-light — context menus + hover, no toolbar.","author":"majkinetor","homepage":null,"homepageURL":null,"supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48ZyBmaWxsPSJub25lIiBzdHJva2U9IiM1YjZiN2EiIHN0cm9rZS13aWR0aD0iNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9Ijk0IiB5Mj0iNDIiLz48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48bGluZSB4MT0iOTQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48L2c+PGcgZmlsbD0iIzJlOWU1YiIgc3Ryb2tlPSIjMjU2ZjQzIiBzdHJva2Utd2lkdGg9IjQiPjxjaXJjbGUgY3g9IjM0IiBjeT0iNDIiIHI9IjE2Ii8+PGNpcmNsZSBjeD0iOTQiIGN5PSI0MiIgcj0iMTYiLz48Y2lyY2xlIGN4PSI2NCIgY3k9Ijk0IiByPSIxNiIvPjwvZz48L3N2Zz4="}) }) : { script: {"name":"Group Therapy*","namespace":"https://github.com/majkinetor/musicbrainz-userscripts","version":"2026.10.16","description":"MusicBrainz relationship helpers: batch-delete rel groups from a right-click menu, page-wide hover highlight with a count tooltip, and copy/move credits between recordings & clone release credits. Chrome-light — context menus + hover, no toolbar.","author":"majkinetor","homepage":null,"homepageURL":null,"supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48ZyBmaWxsPSJub25lIiBzdHJva2U9IiM1YjZiN2EiIHN0cm9rZS13aWR0aD0iNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9Ijk0IiB5Mj0iNDIiLz48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48bGluZSB4MT0iOTQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48L2c+PGcgZmlsbD0iIzJlOWU1YiIgc3Ryb2tlPSIjMjU2ZjQzIiBzdHJva2Utd2lkdGg9IjQiPjxjaXJjbGUgY3g9IjM0IiBjeT0iNDIiIHI9IjE2Ii8+PGNpcmNsZSBjeD0iOTQiIGN5PSI0MiIgcj0iMTYiLz48Y2lyY2xlIGN4PSI2NCIgY3k9Ijk0IiByPSIxNiIvPjwvZz48L3N2Zz4="} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ try { (function(){ /* eslint-disable no-undef */ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('group_therapy', 'Group Therapy')) return; // the shared MusicBrainz request gate (#633) — first, so nothing can call it before its consts exist // <ST-NET> — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT const MBN_GAP = 1000; const MBN_BURST = 3; const MBN_KEY = "mbu:mb-gate"; const MBN_LOCK = "mbu-mb-gate"; const MBN_MAX_HOLD = 60000; function mbnGated(url) { const s = String(url || ''); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null'); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; // no storage: this page's copy only else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) {} return out; }; // The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is // stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang // every request behind it. let timer = 0; try { if (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === 'function' ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3000); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState(s => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? 'MusicBrainz asked everyone to wait' : 'pacing, one request a second' }; }); let told = false; for (;;) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); // a hold that appeared while we queued if (left <= 0) break; if (!told && until - t0 > 1000 && o.log) { told = true; o.log('info', 'MusicBrainz gate: ' + (o.label || 'request') + ' waits ' + Math.round((until - t0) / 100) / 10 + 's (' + (cool > booked.at ? 'MusicBrainz asked everyone to wait' : booked.why) + ')'); } await new Promise(r => setTimeout(r, Math.min(left, 250))); // in slices, so a cancel lands promptly } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header('Retry-After') : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState(s => { const now = Date.now(); // One throttle, one step: the requests already in flight when it began all answer // 503 too, and each must not stretch the hold again. A throttle long gone doesn't // count at all: `hot` is shared and persists, and would otherwise slow every later // page for good. if (now > (s.cool || 0) + 30000) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1000, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log('warn', 'MusicBrainz gate: HTTP ' + status + (raw != null ? ' (Retry-After: ' + raw + ')' : '') + ' — every script holds ' + Math.round(hold / 100) / 10 + 's'); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState(s => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 30000 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) { const r = await fetch(url, init); if (!mbnWebsite(url) || !(await mbnIsCheck(r))) return r; // #733: MusicBrainz's browser check answered instead: nothing was done (a POST is safe // to repeat), so pass the check and ask again return (await mbnReverify(url, init, o)) ? fetch(url, init) : r; } const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, n => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnHot() { let s; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; } return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over } function mbnRawHeader(raw, name) { const want = String(name).toLowerCase(); for (const line of String(raw || '').split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim(); } return null; } function mbnWebsite(url) { const s = String(url || ''); if (mbnGated(s)) return false; if (/^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org(\/|$)/i.test(s)) return true; return /^\/(?!\/)/.test(s) && typeof location !== 'undefined' && /(^|\.)musicbrainz\.org$/i.test(location.hostname); } async function mbnIsCheck(r) { try { if (!r || !/text\/html/i.test((r.headers && r.headers.get('content-type')) || '')) return false; const head = (await r.clone().text()).slice(0, 3000); return /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(head); } catch (e) { return false; } } function mbnReverify(url, init, o) { o = o || {}; const log = (lvl, m) => { try { if (o.log) o.log(lvl, m); else (lvl === 'warn' ? console.warn : console.info)('[MusicBrainz gate] ' + m); } catch (e) {} }; const g = globalThis.__mbnCheck || (globalThis.__mbnCheck = { busy: null, failedAt: 0 }); if (g.busy) return g.busy; let target; try { target = new URL(String(url), location.href); } catch (e) { return Promise.resolve(false); } if (target.host !== location.host || typeof document === 'undefined') { log('warn', 'MusicBrainz asked to verify the browser — open any MusicBrainz page, then retry'); return Promise.resolve(false); } if (Date.now() - g.failedAt < 60000) return Promise.resolve(false); // just failed: don't loop // a POST's URL can't be loaded: the check is per browser, so any light GET passes it const method = String((init && init.method) || 'GET').toUpperCase(); const src = method === 'GET' ? target.href : location.origin + '/ws/js/entity/89ad4ac3-39f7-470e-963a-56509c546377'; const t0 = Date.now(); log('warn', 'MusicBrainz asked to verify the browser again ("Verifying your browser") — passing it in a hidden frame'); g.busy = new Promise(resolve => { const fr = document.createElement('iframe'); // fresh each time, never re-navigated fr.setAttribute('aria-hidden', 'true'); fr.setAttribute('data-mbn-check', ''); fr.tabIndex = -1; fr.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;top:0;border:0;opacity:0;pointer-events:none'; let loads = 0, done = false, timer = 0; const finish = ok => { if (done) return; done = true; clearTimeout(timer); fr.remove(); g.busy = null; if (ok) log('info', 'browser check passed in ' + (Date.now() - t0) + ' ms — retrying'); else { g.failedAt = Date.now(); const m = 'MusicBrainz wants to verify your browser — open any MusicBrainz page in another tab, then retry'; log('warn', m + ' (the hidden frame did not clear it in 20 s)'); try { if (o.notify) o.notify(m); } catch (e) {} } resolve(ok); }; // the first load is the check; it posts its answer and is redirected to the real URL fr.onload = () => { loads++; let still; try { const d = fr.contentDocument; still = !d || /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(d.documentElement ? d.documentElement.outerHTML.slice(0, 3000) : ''); } catch (e) { still = loads < 2; } if (!still) finish(true); }; timer = setTimeout(() => finish(false), 20000); fr.src = src; (document.body || document.documentElement).appendChild(fr); }); return g.busy; } // </ST-NET> const VERSION = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '?'; // from the @version header at runtime const W = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); // ── tiny DOM helpers ────────────────────────────────────────────────────── // #522 follow-up (majkinetor, live): "why is LastPass recognizing edits as // passwords?" — password managers heuristically flag plain text inputs // that carry no autocomplete/ignore hint. Every <input> this tool creates // is a search/filter/edit box, never a credential, so opt every one of // them out up front rather than chasing individual false positives. const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; if (tag === 'input') { e.autocomplete = 'off'; e.setAttribute('data-lpignore', 'true'); e.setAttribute('data-1p-ignore', 'true'); e.setAttribute('data-bwignore', 'true'); e.setAttribute('data-form-type', 'other'); } return e; }; const trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }; // MB renders each rel as <tr class="<role-kebab>"> … <div class="relationship-item"> <button class="icon remove-item">×</button> <a href="/artist|work|…/<mbid>">name</a> … const REMOVE_SEL = 'button.icon.remove-item'; const pickRoleLabel = tr => { const l = tr && tr.querySelector('th.link-phrase label'); return l ? (l.textContent || '').replace(/:\s*$/, '').trim() : 'role'; }; // medium number a track row belongs to — the nearest preceding `tr.subh` ("1▼CD" → 1). Each medium // is its own <tbody>, so we scan the table's rows in document order (not just siblings). Cached per row. // #623 (sweep): one pass over the table fills both caches for every row, instead of // re-scanning all its rows once per row asked about (O(rows²) on a big box set — the // hover highlight asks for every hit, and the format lookup was never cached at all). const _medCache = new WeakMap(), _fmtCache = new WeakMap(); const indexMedia = tbl => { let med = null, fmt = ''; for (const row of tbl.querySelectorAll('tr')) { if (row.classList && row.classList.contains('subh')) { const t = row.textContent || '', m = t.match(/(\d+)/); med = m ? m[1] : null; fmt = t.replace(/^\s*\d+\s*/, '').replace(/^[^A-Za-z0-9]+/, '').trim(); } else { _medCache.set(row, med); _fmtCache.set(row, fmt); } } }; const mediumNumberOf = tr => { if (!tr) return null; if (!_medCache.has(tr)) { const tbl = tr.closest && tr.closest('table'); if (!tbl) return null; indexMedia(tbl); } return _medCache.has(tr) ? _medCache.get(tr) : null; }; // medium FORMAT label for a track row (from the nearest preceding tr.subh, e.g. "1▼CD" → "CD", "2▼12″ Vinyl" → "12″ Vinyl") const mediumFormatOf = tr => { if (!tr) return ''; if (!_fmtCache.has(tr)) { const tbl = tr.closest && tr.closest('table'); if (!tbl) return ''; indexMedia(tbl); } return _fmtCache.get(tr) || ''; }; // track position label from the row's position cell — handles vinyl/multi-disc numbers ("D5", "A1") // as well as plain "5". On multi-medium releases, a plain number is prefixed with the medium so // "1" on CD 1 vs CD 2 read as "1.1" / "2.1". Returns a string (or null for non-track rows). // `multi` (optional): whether the release has several media, when the caller already // knows — the highlight asks once, not once per hit (#623) const posLabel = (tr, multi) => { if (!tr || !tr.querySelector) return null; const c = tr.querySelector('td.pos'); let t = c ? (c.textContent || '').trim() : ''; if (!t) { const m = (tr.textContent || '').match(/^\s*(\d+)\b/); t = m ? m[1] : ''; } if (!t) return null; if (/^\d+$/.test(t) && (multi != null ? multi : document.querySelectorAll('tr.subh').length > 1)) { const med = mediumNumberOf(tr); if (med) t = med + '.' + t; } return t; }; const targetHref = item => { const a = item && item.querySelector('a[href*="/artist/"], a[href*="/work/"], a[href*="/label/"], a[href*="/place/"], a[href*="/recording/"], a[href*="/url/"], a[href*="/event/"], a[href*="/instrument/"]'); return a ? a.getAttribute('href') : null; }; const targetLabel = item => { const a = item && item.querySelector('a[href*="/"]'); return a ? (a.textContent || '').trim() : 'target'; }; const itemHasHref = (item, href) => !!(href && item.querySelector(`a[href="${CSS.escape(href)}"]`)); // a rel's "role" for grouping = its link type PLUS its attributes — because e.g. every instrument rel // shares the one "instrument" link type and the specific instrument (drums, shakers, …) is an // attribute; matching on the CSS role class alone would lump drums + shakers + vocals together. const _roleKeyCache = new WeakMap(); function relRoleKey(item) { if (_roleKeyCache.has(item)) return _roleKeyCache.get(item); const rel = relFromNode(item); let key = null; if (rel) { let attrs = ''; try { if (rel.attributes) attrs = [...W.MB.tree.iterate(rel.attributes)].map(a => a.typeID).sort((x, y) => x - y).join(','); } catch (e) {} key = rel.linkTypeID + '#' + attrs; } _roleKeyCache.set(item, key); return key; } // collect peer relationship-items matching a scope relative to a seed × button function collect(seedBtn, scope) { const seedItem = seedBtn.closest('.relationship-item'); if (!seedItem) return []; const seedKey = relRoleKey(seedItem), href = targetHref(seedItem); return [...document.querySelectorAll('.relationship-item')].filter(item => { if (scope === 'role') return relRoleKey(item) === seedKey; if (scope === 'target') return itemHasHref(item, href); return relRoleKey(item) === seedKey && itemHasHref(item, href); // role+target }); } const removeButtons = items => items.map(it => it.querySelector(REMOVE_SEL)).filter(Boolean); // ── edit-note signature — stamped into MB's edit-note field ONLY when GT actually changes something ── const GT_HOMEPAGE = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/group_therapy/README.md'; function editNoteSig() { let s = {}; try { if (typeof GM_info !== 'undefined' && GM_info.script) s = GM_info.script; } catch (e) {} const name = (s.name || 'Group Therapy').split(/\s+[—–-]\s+/)[0].trim(); // drop the "— MusicBrainz relationship helper" suffix // Same shape as every other script in the repo: "<Name> v<version> by <author> - <url>". // GT used to put the author before the version, which is the odd one out and made // version stats unparseable for anything reading edit notes (dev/script-metrics #570). return `${name} v${s.version || VERSION} by ${s.author || 'majkinetor'} - ${s.homepageURL || s.homepage || GT_HOMEPAGE}`; } // Stamp our signature into MB's edit-note field and, under it, an accumulating list of what GT did // ("Copied credits from track 4 to tracks 1–5", "Removed guitar (14)"). Any note that preceded ours // (another script's) is preserved ahead of our block. Idempotent per identical action line. function stampEditNote(action) { const ta = document.querySelector('textarea.edit-note, #edit-note-text'); if (!ta) return; const sig = editNoteSig(), cur = ta.value || ''; let pre = cur.replace(/\s+$/, ''), ourLines = []; const idx = cur.indexOf(sig); if (idx >= 0) { pre = cur.slice(0, idx).replace(/\s+$/, ''); ourLines = cur.slice(idx + sig.length).split('\n').map(l => l.trim()).filter(Boolean); } if (action && !ourLines.includes(action)) ourLines.push(action); const block = ourLines.length ? `${sig}\n\n${ourLines.join('\n')}` : sig; const next = pre ? `${pre}\n\n${block}` : block; try { const set = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(ta), 'value').set; set.call(ta, next); ta.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {} } function markUsed(action) { try { stampEditNote(action); } catch (e) {} } // ── subtle context menu ─────────────────────────────────────────────────── let menuEl = null; function closeMenu() { if (menuEl) { menuEl.remove(); menuEl = null; document.removeEventListener('mousedown', onDocDown, true); document.removeEventListener('keydown', onKey, true); } } function onDocDown(e) { if (menuEl && !menuEl.contains(e.target)) closeMenu(); } function onKey(e) { if (e.key === 'Escape') closeMenu(); } function openMenu(x, y, items) { // items: [{label, sub, danger, run} | 'sep'] closeMenu(); menuEl = el('div', 'gt-menu'); for (const it of items) { if (it === 'sep') { menuEl.appendChild(el('div', 'gt-sep')); continue; } if (it.header != null) { const h = el('div', 'gt-hdr', it.header); it._set = v => { try { h.textContent = v; } catch (e) {} }; menuEl.appendChild(h); continue; } // #377 live header if (it.note != null) { menuEl.appendChild(el('div', 'gt-note', it.note)); continue; } // #597: a text field inside the menu — the track selector for the vertical // moves. onInput reports back {text, ok, title} so the row underneath can // say what the spec actually matched BEFORE anything is copied, the same // way the text parser's Scope does. if (it.input) { const wrap = el('div', 'gt-mi-input'); const inp = el('input', 'gt-mi-in'); inp.type = 'text'; inp.placeholder = it.input.placeholder || ''; inp.title = it.input.title || ''; inp.value = it.input.value || ''; const info = el('div', 'gt-mi-ininfo', ''); const upd = () => { const res = it.input.onInput(inp.value) || {}; info.textContent = res.text || ''; info.style.color = res.ok ? 'var(--mbu-ok)' : 'var(--mbu-warn)'; info.title = res.title || ''; }; inp.addEventListener('input', upd); // A menu whose CONTENT depends on the spec (the ⬇ checklist is built // from the tracks being scanned) has to be rebuilt, and rebuilding on // every keystroke would yank the field out from under the typing. So // the live echo runs on input and the rebuild waits for `change` — // Enter, or clicking away. if (it.input.onChange) inp.addEventListener('change', () => it.input.onChange(inp.value)); wrap.append(inp, info); menuEl.appendChild(wrap); upd(); setTimeout(() => { try { inp.focus(); } catch (e) {} }, 0); continue; } if (it.checklist) { // per-credit toggles for copy/move — clicking a box toggles, doesn't close the menu const box = el('div', 'gt-ck-list'); it.checklist.forEach(entry => { const lab = el('label', 'gt-ck'); const cb = el('input', 'gt-ck-cb'); cb.type = 'checkbox'; cb.checked = entry.checked !== false; entry.cb = cb; cb.addEventListener('change', () => { if (it.onToggle) it.onToggle(); }); // whole row toggles (it's a <label>); right-click selects only this role — SHIFT-right-click ADDS // this role to the current selection instead of replacing it (#377) lab.addEventListener('contextmenu', ev => { ev.preventDefault(); ev.stopPropagation(); it.checklist.forEach(en => { if (!en.cb) return; if (ev.shiftKey) { if (en.role === entry.role) en.cb.checked = true; } else en.cb.checked = en.role === entry.role; }); if (it.onToggle) it.onToggle(); }); lab.appendChild(cb); lab.appendChild(el('span', 'gt-ck-pos', `[${entry.pos}]`)); lab.appendChild(el('span', 'gt-ck-tx', entry.text)); if (it.rowBtns) { const acts = el('span', 'gt-ck-acts'); it.rowBtns.forEach(rb => { const b = el('button', 'gt-ck-act', rb.label); b.type = 'button'; b.title = rb.title || ''; b.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); rb.run(entry); }); acts.appendChild(b); }); lab.appendChild(acts); } // #377 [A]/[R] per-row select buttons box.appendChild(lab); }); menuEl.appendChild(box); continue; } const row = el('button', 'gt-mi' + (it.danger ? ' gt-danger' : '')); const top = el('div', 'gt-mi-top'); top.appendChild(el('span', 'gt-mi-l', it.label)); if (it.sub != null) { const badge = el('span', 'gt-mi-s', it.sub); top.appendChild(badge); it._setSub = v => { try { badge.textContent = v; } catch (e) {} }; } row.appendChild(top); if (it.lines && it.lines.length) { // #338: fully-detailed per-track blast breakdown const box = el('div', 'gt-mi-lines'), MAX = 12; it.lines.slice(0, MAX).forEach(ln => { const l = el('div', 'gt-mi-ln'); l.appendChild(el('span', 'gt-mi-pos', `[${ln.pos}]`)); l.appendChild(el('span', 'gt-mi-tx', ln.text)); box.appendChild(l); }); if (it.lines.length > MAX) box.appendChild(el('div', 'gt-mi-ln gt-mi-more', `… ${it.lines.length - MAX} more`)); row.appendChild(box); } row.onclick = () => { closeMenu(); try { it.run(); } catch (e) {} }; menuEl.appendChild(row); } document.body.appendChild(menuEl); // keep on-screen const r = menuEl.getBoundingClientRect(); menuEl.style.left = Math.min(x, window.innerWidth - r.width - 8) + 'px'; menuEl.style.top = Math.min(y, window.innerHeight - r.height - 8) + 'px'; setTimeout(() => { document.addEventListener('mousedown', onDocDown, true); document.addEventListener('keydown', onKey, true); }, 0); } // ── batch delete: right-click a rel's × → remove a whole group ───────────── // We never fabricate a removal — we click MB's own peer × buttons, so React // handles each exactly like a manual click (works on existing + new rels). function runRemoval(items, desc) { const btns = removeButtons(items); for (const b of btns) { try { b.click(); } catch (e) {} } if (btns.length) markUsed(desc || `Removed ${btns.length} relationship${btns.length > 1 ? 's' : ''}`); } // per-rel facts: its track position (null = release-level), role label, and target name function itemInfo(it) { const roleTr = it.closest && it.closest('tr'); const trackTr = it.closest && it.closest('tr.track'); const a = it.querySelector('a[href*="/"]'); // #373 a work credit (writer/composer/…) belongs to its WORK, not the track it's nested under — so the // ×-delete blast-radius counts and lists it by work, not by track position. const rc = relClass(it); const pos = (rc && rc.kind === 'work' && rc.work) ? ('“' + (val(rc.work.name) || 'work') + '”') : posLabel(trackTr); return { pos, role: pickRoleLabel(roleTr), target: a ? (a.textContent || '').trim() : '' }; } // fully-detailed blast breakdown: group the group's rels by track, and per track list the varying // dimension — the targets (for a role scope), the roles (for a target scope), the role (for both). function breakdown(items, scope) { const byPos = new Map(); for (const it of items) { const info = itemInfo(it), key = info.pos == null ? 'R' : info.pos; if (!byPos.has(key)) byPos.set(key, { pos: info.pos, vals: [] }); byPos.get(key).vals.push(scope === 'role' ? info.target : info.role); } return [...byPos.values()].sort((a, b) => { if (a.pos == null) return 1; if (b.pos == null) return -1; return String(a.pos).localeCompare(String(b.pos), undefined, { numeric: true }); }) .map(r => ({ pos: r.pos == null ? 'rel' : r.pos, text: [...new Set(r.vals.filter(Boolean))].join(', ') })); } // keys of the recordings / works MB currently has selected (ticked checkboxes) function selectionKeys() { const re = RE(), recs = new Set(), works = new Set(); if (!re) return { recs, works }; const add = (tree, set) => { try { for (const e of W.MB.tree.iterate(tree)) { const w = Array.isArray(e) ? e[1] : e; if (w) set.add((w.gid || '') + '|' + w.id); } } catch (e) {} }; add(re.state.selectedRecordings, recs); add(re.state.selectedWorks, works); return { recs, works }; } // is a rel item on one of the selected recordings/works? function itemInSelection(item, sel) { const rel = relFromNode(item); if (!rel) return true; // unreadable → don't exclude for (const e of [rel.entity0, rel.entity1]) { if (!e) continue; const key = (e.gid || '') + '|' + e.id; if (e.entityType === 'recording' && sel.recs.has(key)) return true; if (e.entityType === 'work' && sel.works.has(key)) return true; } return false; } function onContextMenu(ev) { // #338 P2: right-click a recording's checkbox → copy/move its credits to the ticked recordings; // right-click a work checkbox → copy/move its work rels the same way const recCb = ev.target.closest && ev.target.closest('input.recording'); if (recCb) { const tr = recCb.closest('tr.track'); if (tr) { ev.preventDefault(); openCopyMenu(tr, ev.clientX, ev.clientY); } return; } const workCb = ev.target.closest && ev.target.closest('input.work'); if (workCb) { ev.preventDefault(); openWorkMenu(workCb, ev.clientX, ev.clientY); return; } // #399 right-click an entity NAME (or anywhere in a rel item that isn't an action control) → open that // rel's edit dialog by clicking its pencil. Runs FIRST (before the recording-of / copy menus below) so a // name always opens the dialog — previously a recording→work rel's work name fell to openRecOfMenu, and a // NEW work's name (no /entity/<gid> href) matched nothing at all. We key off the item + "not a button/ // checkbox" instead of the href, so it works for new entities too. The ×/pencil/+/checkbox still reach // their own handlers below (right-click the PENCIL for the recording-of copy menu; the name → dialog). const nameItem = ev.target.closest && !ev.target.closest('button, input') && ev.target.closest('.relationship-item'); if (nameItem) { const pencil = nameItem.querySelector('button.icon.edit-item'); if (pencil) { ev.preventDefault(); ev.stopPropagation(); try { pencil.click(); } catch (e) {} return; } } // #374 right-click a "recording of" rel (or its pencil) → copy its attributes + dates onto the selected // recordings' own recording-of rels (or all if none selected). Handled before the credit +/pencil below. const roItem = ev.target.closest && !ev.target.closest(REMOVE_SEL) && ev.target.closest('.relationship-item'); // not the × — that keeps its delete menu (#374 review) if (roItem) { const rr = relFromNode(roItem); if (rr && rr.entity0 && rr.entity0.entityType === 'recording' && rr.entity1 && rr.entity1.entityType === 'work') { ev.preventDefault(); openRecOfMenu(roItem, ev.clientX, ev.clientY); return; } } // #373 right-click the role-group "+" (add another) → copy scoped to that role's credits; right-click a // rel's pencil (edit) → copy scoped to just that one credit. Both reuse the recording copy menu. const addBtn = ev.target.closest && ev.target.closest('button.add-item.add-another-entity'); if (addBtn) { const tr = addBtn.closest('tr.track'), grp = addBtn.closest('tr'); if (grp) { ev.preventDefault(); const items = [...grp.querySelectorAll('.relationship-item')], set = new Set(items), rc = items.length ? relClass(items[0]) : null; if (rc && rc.kind === 'work') openWorkMenu(rc.work, ev.clientX, ev.clientY, rel => !!(rel.item && set.has(rel.item))); // #373 work role group → work copy else if (rc && rc.kind === 'rec' && tr) openCopyMenu(tr, ev.clientX, ev.clientY, rel => !!(rel.item && set.has(rel.item))); } return; } const editBtn = ev.target.closest && ev.target.closest('button.icon.edit-item'); if (editBtn) { const tr = editBtn.closest('tr.track'), item = editBtn.closest('.relationship-item'); if (item) { ev.preventDefault(); const rc = relClass(item); // #470 the pencil menu also carries "Replace role…" for this credit const repl = replaceRoleMenuItems(item); if (rc && rc.kind === 'work') openWorkMenu(rc.work, ev.clientX, ev.clientY, rel => rel.item === item, repl); // #373 work credit pencil → work copy, scoped else if (rc && rc.kind === 'rec' && tr) openCopyMenu(tr, ev.clientX, ev.clientY, rel => rel.item === item, repl); // recof pencil is handled above (#374 openRecOfMenu) where present; otherwise no recording menu } return; } const btn = ev.target.closest && ev.target.closest(REMOVE_SEL); if (!btn) return; // not a rel × — let the browser menu through ev.preventDefault(); const seedRow = btn.closest('tr'), seedItem = btn.closest('.relationship-item'); const roleLabel = pickRoleLabel(seedRow), tgt = targetLabel(seedItem); let roleItems = collect(btn, 'role'), tgtItems = collect(btn, 'target'), bothItems = collect(btn, 'role-and-target'); // scope the group removals to the selected recordings/works, if any are ticked (#338) const sel = selectionKeys(); let scopeNote = null; if (sel.recs.size || sel.works.size) { const keep = it => itemInSelection(it, sel); roleItems = roleItems.filter(keep); tgtItems = tgtItems.filter(keep); bothItems = bothItems.filter(keep); const parts = []; if (sel.recs.size) parts.push(`${sel.recs.size} recording${sel.recs.size > 1 ? 's' : ''}`); if (sel.works.size) parts.push(`${sel.works.size} work${sel.works.size > 1 ? 's' : ''}`); scopeNote = `scoped to ${parts.join(' + ')} selected`; } const trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }; const desc = (label, n) => `${label.replace(/^Remove\s+/, 'Removed ')} (${n})` + (scopeNote ? ` — ${scopeNote}` : ''); const opt = (label, its, scope) => ({ label, sub: String(its.length), lines: breakdown(its, scope), danger: true, run: () => runRemoval(its, desc(label, its.length)) }); const items = [ { label: `Remove this one`, run: () => { try { btn.click(); markUsed(`Removed ${roleLabel}${tgt ? ` — ${tgt}` : ''}`); } catch (e) {} } }, 'sep', ]; if (scopeNote) items.push({ note: scopeNote }); items.push( opt(`Remove ${trunc(roleLabel, 46)}`, roleItems, 'role'), opt(`Remove “${trunc(tgt, 46)}”`, tgtItems, 'target'), opt(`Remove ${trunc(roleLabel, 24)} + ${trunc(tgt, 24)}`, bothItems, 'role-and-target'), ); openMenu(ev.clientX, ev.clientY, items); } // ── hover highlight (page-wide) + count tooltip ─────────────────────────── // Hover an entity name or a role label → light up every matching occurrence on the page (CSS // Custom Highlight API), split into "already in MB" vs "newly added this session", and show a // tooltip: how many, and which tracks / the release it appears on. function needleFor(target) { if (!target || !target.closest) return null; // only the actual relationship (track + release credits) UI — not GT's own overlays, and not // stray entity links elsewhere on the page (release title, sidebar, the work-match dialog, …) if (target.closest('.gt-cons-ov, .gt-wm-pop, .gt-pop, .gt-menu, .gt-tip, #mbu-toast')) return null; const phraseTh = target.closest('th.link-phrase'); if (phraseTh && !target.closest('button')) { const l = phraseTh.querySelector('label'); if (l) { let t = (l.textContent || '').trim().replace(/:\s*$/, ''); if (t) return t; } } const link = target.closest('a[href]'); if (link && link.closest('.relationship-item') && /\/(artist|work|label|place|recording|series|release-group|event|instrument|area)\/[a-f0-9-]/.test(link.getAttribute('href') || '')) return (link.textContent || '').trim(); return null; } // newly-added rels get negative MB ids on their remove button; persisted ones are positive function isNewRow(node) { const item = node.parentNode && node.parentNode.closest ? node.parentNode.closest('.relationship-item') : null; if (!item) return false; const rm = item.querySelector('button.remove-item[id^="remove-relationship-"]'); if (!rm) return false; const segs = rm.id.split('-'), last = segs[segs.length - 1]; return (segs[segs.length - 2] === '' && /^\d+$/.test(last)) || /^-\d+$/.test(last); } const trackPosOf = (node, multi) => { const tr = node.parentNode && node.parentNode.closest ? node.parentNode.closest('tr.track') : null; return posLabel(tr, multi); }; // #623 (sweep): where `lower` occurs in `lt` as a whole word — hovering "Bob" used to light up // "Bobby" too. An edge of the needle that isn't a letter or digit needs no boundary there. function wordHits(lt, lower) { const w = c => !!c && /[\p{L}\p{N}]/u.test(c); const needStart = w(lower[0]), needEnd = w(lower[lower.length - 1]), out = []; let i = 0; while ((i = lt.indexOf(lower, i)) !== -1) { if (!(needStart && w(lt[i - 1])) && !(needEnd && w(lt[i + lower.length]))) { out.push(i); i += lower.length; } else i += 1; } return out; } function highlightPage(needle) { if (!needle || !window.CSS?.highlights || typeof Highlight === 'undefined') return { n: 0 }; const lower = needle.toLowerCase(); if (lower.length < 2) return { n: 0 }; const exist = [], neu = [], tracks = new Set(); let release = false, n = 0; // #379 count/highlight only within the relationship editor — not the (possibly hidden) edit log, edit // notes, sidebar, etc., which were inflating the tooltip counts. const root = document.querySelector('div.release-relationship-editor') || document.body; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(x) { const p = x.parentNode; if (!p) return NodeFilter.FILTER_REJECT; const t = p.tagName; return (t === 'STYLE' || t === 'SCRIPT' || t === 'NOSCRIPT' || t === 'TEXTAREA' || t === 'INPUT') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } }); const multi = document.querySelectorAll('tr.subh').length > 1; // once, not per hit (#623) let node; while ((node = walker.nextNode())) { const lt = node.nodeValue.toLowerCase(); if (lt.length < lower.length) continue; const at = wordHits(lt, lower); if (!at.length) continue; const isNew = isNewRow(node); for (const i of at) { const r = document.createRange(); r.setStart(node, i); r.setEnd(node, i + lower.length); (isNew ? neu : exist).push(r); n++; } const p = trackPosOf(node, multi); if (p != null) tracks.add(p); else if (node.parentNode && node.parentNode.closest && node.parentNode.closest('.relationship-item')) release = true; } try { window.CSS.highlights.set('gt-hl-existing', new Highlight(...exist)); window.CSS.highlights.set('gt-hl-new', new Highlight(...neu)); } catch (e) {} return { n, tracks, release }; } function clearHighlight() { try { window.CSS.highlights?.delete('gt-hl-existing'); window.CSS.highlights?.delete('gt-hl-new'); } catch (e) {} } // compress [1,2,3,5] → "1–3, 5"; non-numeric positions (vinyl "D4","D5") are natural-sorted + joined function ranges(vals) { const a = [...vals]; if (a.length && a.every(v => /^\d+$/.test(String(v)))) { const nums = a.map(Number).sort((x, y) => x - y), out = []; let s = null, p = null; for (const v of nums) { if (s == null) { s = p = v; } else if (v === p + 1) { p = v; } else { out.push(s === p ? `${s}` : `${s}–${p}`); s = p = v; } } if (s != null) out.push(s === p ? `${s}` : `${s}–${p}`); return out.join(', '); } return a.map(String).sort((x, y) => x.localeCompare(y, undefined, { numeric: true })).join(', '); } let tipEl = null; function showTip(x, y, info, name) { if (!info || (!info.n && !name)) { hideTip(); return; } if (!tipEl) { tipEl = el('div', 'gt-tip'); document.body.appendChild(tipEl); } tipEl.innerHTML = ''; // we hid MB's native title (the entity's real/sort name, shown when Credited As differs) — // so surface it here instead so that info isn't lost. if (name) tipEl.appendChild(el('div', 'gt-tip-name', name)); const parts = []; if (info.n) { parts.push(`${info.n}×`); if (info.tracks && info.tracks.size) parts.push(`track${info.tracks.size > 1 ? 's' : ''} ${ranges(info.tracks)}`); if (info.release) parts.push('release'); } if (parts.length) tipEl.appendChild(el('div', 'gt-tip-stat', parts.join(' · '))); tipEl.style.display = ''; const r = tipEl.getBoundingClientRect(); tipEl.style.left = Math.min(x + 14, window.innerWidth - r.width - 8) + 'px'; tipEl.style.top = Math.min(y + 16, window.innerHeight - r.height - 8) + 'px'; } function hideTip() { if (tipEl) tipEl.style.display = 'none'; } // MB puts the entity's sort name in the link's `title`, so the browser's native tooltip stacks on // top of ours. Temporarily strip it while our count tooltip is up, and restore it on the way out. let _hidTitle = null; function suppressTitle(target) { const te = target.closest && target.closest('[title]'); if (!te || (_hidTitle && te === _hidTitle.el)) return; restoreTitle(); const v = te.getAttribute('title'); if (v == null) return; _hidTitle = { el: te, val: v }; te.removeAttribute('title'); } function restoreTitle() { if (_hidTitle) { try { _hidTitle.el.setAttribute('title', _hidTitle.val); } catch (e) {} _hidTitle = null; } } function onOver(ev) { const nd = needleFor(ev.target); if (!nd) return; suppressTitle(ev.target); const nm = (_hidTitle && _hidTitle.val && _hidTitle.val !== nd) ? _hidTitle.val : null; const info = highlightPage(nd); showTip(ev.clientX, ev.clientY, info, nm); } function onMove(ev) { if (tipEl && tipEl.style.display !== 'none' && needleFor(ev.target)) { tipEl.style.left = Math.min(ev.clientX + 14, window.innerWidth - tipEl.offsetWidth - 8) + 'px'; tipEl.style.top = Math.min(ev.clientY + 16, window.innerHeight - tipEl.offsetHeight - 8) + 'px'; } } function onOut(ev) { if (!needleFor(ev.target)) return; const rt = ev.relatedTarget; if (_hidTitle && rt && _hidTitle.el.contains && _hidTitle.el.contains(rt)) return; clearHighlight(); hideTip(); restoreTitle(); } // ── styles ──────────────────────────────────────────────────────────────── // The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and are // inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never in this block. // <ST-TOKENS> — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // </ST-TOKENS> // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here. // <ST-UI> — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on <html>, which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'<svg…>' | '<img…>', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // o.history opt-in history of past sessions (#705), below // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // viewer appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) : '--:--:--.---'; }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + (e.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button, input, select') || pop.classList.contains('full')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); views = views.filter(function (v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in <body>: a permanent stray node under <html>, outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No <body> yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and <head> and <body> don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } // </ST-UI> function injectStyle() { const s = el('style'); s.textContent = MBU_TOKENS + MBU_UI_CSS + ` .gt-menu{position:fixed;z-index:2147483647;min-width:260px;max-width:600px;max-height:74vh;overflow-y:auto;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:7px; box-shadow:0 8px 26px rgba(0,0,0,.18);padding:4px;font:13px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text);user-select:none} .gt-mi .gt-mi-lines{margin:3px 0 1px 4px} .gt-mi .gt-mi-ln{display:flex;gap:6px;font-size:11px;color:var(--mbu-text-dim);line-height:1.4} .gt-mi .gt-mi-pos{flex:none;color:var(--mbu-info);min-width:24px} .gt-mi .gt-mi-tx{flex:1;white-space:normal;word-break:break-word} .gt-mi .gt-mi-more{color:var(--mbu-info);font-style:italic} .gt-menu .gt-sep{height:1px;background:var(--mbu-bg-sunken);margin:4px 2px} .gt-menu .gt-mi-input{padding:3px 9px 6px} .gt-menu .gt-mi-in{width:100%;box-sizing:border-box;font:12px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text); background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:5px;padding:3px 7px} .gt-menu .gt-mi-in:focus{border-color:var(--mbu-info);outline:none} .gt-menu .gt-mi-ininfo{font-size:11px;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .gt-menu .gt-hdr{padding:5px 9px 4px;font-size:11px;font-weight:700;letter-spacing:.02em;color:var(--mbu-text-dim);text-transform:uppercase} .gt-menu .gt-note{padding:0 9px 6px;font-size:11px;color:var(--mbu-text-weak);white-space:normal;word-break:break-word} .gt-menu .gt-ck-list{margin:2px 0 3px} .gt-menu .gt-ck{display:flex;align-items:flex-start;gap:7px;padding:3px 9px;font-size:11px;color:var(--mbu-text-dim);line-height:1.4;cursor:pointer;user-select:none} .gt-menu .gt-ck:hover{background:var(--mbu-info-bg)} /* #377 per-row [A]/[R] select-by-artist/role buttons, shown on row hover */ .gt-menu .gt-ck-acts{margin-left:auto;display:none;gap:3px;flex:none;align-self:center} .gt-menu .gt-ck:hover .gt-ck-acts{display:inline-flex} .gt-menu .gt-ck-act{font:bold 10px Arial;color:var(--mbu-info);background:var(--mbu-info-bg);border:1px solid var(--mbu-info);border-radius:3px;padding:1px 6px;cursor:pointer;line-height:1.4} .gt-menu .gt-ck-act:hover{background:var(--mbu-info-bg);border-color:var(--mbu-info)} .gt-menu .gt-ck-cb{margin:1px 0 0;flex:none;accent-color:#2e9e5b;cursor:pointer} .gt-menu .gt-ck-pos{flex:none;color:var(--mbu-info)} .gt-menu .gt-ck-tx{flex:1;white-space:normal;word-break:break-word} .gt-mi{display:block;width:100%;box-sizing:border-box;background:none;border:none;text-align:left; padding:6px 9px;border-radius:5px;cursor:pointer;color:inherit;font:inherit} .gt-mi:hover{background:var(--mbu-info-bg)} .gt-mi .gt-mi-top{display:flex;align-items:center;gap:10px} .gt-mi .gt-mi-l{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .gt-mi .gt-mi-d{font-size:11px;color:var(--mbu-text-weak);margin-top:2px} .gt-mi .gt-mi-s{flex:none;min-width:20px;text-align:center;font-weight:700;font-size:11px;color:var(--mbu-text-dim);background:var(--mbu-info-bg);border-radius:9px;padding:1px 7px} .gt-mi.gt-danger:hover{background:var(--mbu-error-bg)} .gt-mi.gt-danger .gt-mi-s{color:var(--mbu-text-on-accent);background:#c0392b} /* #564 (majkinetor, light theme): "black on blue name highlight". The two highlights shared one blue background and were told apart by their TEXT colour, and --mbu-warn on that blue computes to about 1.1:1 — the amber and the blue have nearly the same luminance, so the name vanished into its own highlight. (Custom-highlight styling can't be read back with getComputedStyle, which is why it survived: it has to be measured off rendered pixels, in both engines — see test/verify-564-highlight-readable.mjs.) The distinction now lives in the BACKGROUND, with white text on both. Literal colours, not tokens: the pair has to stay legible on its own terms and neither background follows the page theme. */ ::highlight(gt-hl-existing){background:#1f6feb;color:#fff} ::highlight(gt-hl-new){background:#b45309;color:#fff} .gt-tip{position:fixed;z-index:2147483647;pointer-events:none;background:#1b2430;color:#eef2f7; font:12px -apple-system,Segoe UI,Arial,sans-serif;padding:4px 9px;border-radius:5px;box-shadow:0 3px 12px rgba(0,0,0,.28);white-space:nowrap} .gt-tip .gt-tip-name{font-weight:600} .gt-tip .gt-tip-stat{color:#a9bed4;font-size:11px;margin-top:1px} /* #564: the pill is always dark, so this is too — --mbu-info is mixed toward the page text and went near-black on it in the light theme */ .gt-clone-btn{margin-left:10px;font:600 12px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-info);background:var(--mbu-info-bg); border:1px solid var(--mbu-info);border-radius:5px;padding:2px 9px;cursor:pointer;vertical-align:middle} .gt-clone-btn:hover{background:var(--mbu-info-bg)} .gt-cfg-btn{float:right;margin-left:8px;font-size:15px;line-height:1.4;color:var(--mbu-text-weak);background:none;border:none;cursor:pointer;padding:2px 7px;border-radius:5px} .gt-cfg-btn:hover{background:var(--mbu-info-bg);color:var(--mbu-text-dim)} /* #372 top toolbar (moved off the "Release relationships" heading to the top of the tab) */ .gt-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:6px 0 14px;padding:8px 10px;background:var(--mbu-bg);border:1px solid var(--mbu-info);border-radius:7px} .gt-toolbar .gt-clone-btn{margin-left:0} /* #365 "Vertical:" section — label + two icon-only up/down buttons */ .gt-vert{display:inline-flex;align-items:center;gap:5px} .gt-vert-lbl{font:600 12px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text-dim)} .gt-vert-btn{margin-left:0;font-size:14px;line-height:1;padding:2px 8px} .gt-vert .gt-vert-btn+.gt-vert-btn{margin-left:0} .gt-toolbar .gt-cfg-btn{float:none;margin-left:auto} /* #372 config window (⚙): standard header (icon + name + version + Help) + options body */ .gt-cfg-pop{min-width:270px;padding:0} .gt-cfg-body{padding:9px 12px;display:flex;flex-direction:column;gap:8px} .gt-cfg-opt{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--mbu-text);cursor:pointer}.gt-cfg-opt input{margin:0} .gt-pop{position:fixed;z-index:2147483647;min-width:300px;max-width:460px;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:8px; box-shadow:0 10px 30px rgba(0,0,0,.2);padding:6px;font:13px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text)} .gt-pop .gt-pop-hdr{padding:4px 8px 6px;font-size:11px;font-weight:700;letter-spacing:.02em;color:var(--mbu-text-dim);text-transform:uppercase} .gt-pop .gt-pop-list{max-height:44vh;overflow-y:auto} .gt-pop .gt-pop-note{padding:8px;color:var(--mbu-text-weak);font-size:12px} .gt-pop .gt-pop-rel{display:flex;align-items:center;gap:4px;border-radius:5px} .gt-pop .gt-pop-rel:hover{background:var(--mbu-info-bg)} .gt-pop .gt-pop-rel-info{flex:1;min-width:0;box-sizing:border-box;text-align:left;background:none;border:none;border-radius:5px;padding:6px 9px;cursor:pointer;color:inherit;font:inherit} .gt-pop .gt-pop-rel-t{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .gt-pop .gt-pop-rel-m{display:block;font-size:11px;color:var(--mbu-text-weak);margin-top:1px} .gt-pop .gt-pop-rel-open{flex:none;text-decoration:none;color:var(--mbu-text-weak);font-size:14px;line-height:1;padding:6px 9px;border-radius:5px} .gt-pop .gt-pop-rel-open:hover{background:var(--mbu-info-bg);color:var(--mbu-info)} .gt-pop .gt-pop-add{padding:4px 6px 6px} .gt-pop .gt-pop-add-btn{display:block;width:100%;box-sizing:border-box;text-align:left;background:none;border:none;border-radius:5px;padding:6px 9px;cursor:pointer;color:var(--mbu-info);font:inherit} .gt-pop .gt-pop-add-btn:hover{background:var(--mbu-info-bg)} .gt-pop .gt-pop-tf{display:block;width:100%;box-sizing:border-box;min-width:0;padding:6px 8px;border:1px solid var(--mbu-info);border-radius:5px;font:inherit;outline:none} .gt-pop .gt-hidden{display:none} /* subtle discoverability: the controls Group Therapy adds a right-click menu to (recording/work checkboxes → copy/move; the × → group delete; the +/pencil → scoped copy #373) get a green ring on hover */ tr.track input.recording, tr.track input.work { accent-color:#2e9e5b; } tr.track input.recording:hover, tr.track input.work:hover, button.icon.remove-item:hover, tr.track button.add-item.add-another-entity:hover, tr.track button.icon.edit-item:hover { outline:2px solid rgba(46,158,91,.55); outline-offset:1px; border-radius:3px; } /* Consolidate RG (#349) — the release×role matrix modal */ .gt-cons-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center} .gt-cons.gt-role-pick{width:520px;max-width:92vw;max-height:min(560px,70vh);display:flex;flex-direction:column} /* #544: .gt-cons is declared later with the same specificity and was overriding every one of these */ .gt-role-search{margin:8px 10px 6px;padding:5px 8px;font-size:13px;font-family:inherit;border:1px solid var(--mbu-border);border-radius:4px} .gt-role-list{overflow:auto;flex:1;padding:0 4px 8px} .gt-role-row{padding:5px 8px;border-radius:4px;cursor:pointer;display:flex;flex-direction:column;gap:1px} .gt-role-row.gt-role-active{background:var(--mbu-bg-raised);box-shadow:inset 2px 0 0 #5f3ec0} /* #544 keyboard cursor */ .gt-role-row:hover{background:var(--mbu-bg-raised)} .gt-role-name{font-weight:600;font-size:13px} .gt-role-recent{font-weight:400;font-size:10px;color:var(--mbu-info);margin-left:6px} .gt-role-desc{font-size:11px;color:var(--mbu-text-dim);line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} /* #544: was one nowrap line + ellipsis, which cut every description; two wrapped lines instead */ .gt-cons{background:var(--mbu-bg);border-radius:var(--mbu-radius-lg);box-shadow:0 18px 50px rgba(0,0,0,.35);width:min(920px,94vw);max-height:88vh;display:flex;flex-direction:column;font:13px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text)} .gt-cons-hdr{display:flex;align-items:center;gap:8px;padding:11px 14px;border-bottom:1px solid var(--mbu-border)} .gt-cons-title{font-weight:700;font-size:14px;flex:1} .gt-cons-x{background:none;border:none;font-size:16px;color:var(--mbu-text-weak);cursor:pointer;padding:2px 8px;border-radius:5px} .gt-cons-x:hover{background:var(--mbu-info-bg);color:var(--mbu-text-dim)} .gt-cons-body{padding:10px 14px;overflow:auto} .gt-cons-leg{display:flex;flex-wrap:wrap;gap:4px 16px;margin-bottom:10px;font-size:12px;color:var(--mbu-text-dim)} .gt-cons-legi b{display:inline-block;min-width:16px;text-align:center;background:var(--mbu-info-bg);border:1px solid var(--mbu-info);border-radius:4px;color:var(--mbu-info);margin-right:2px} .gt-cons-legi.gt-cur b{background:#2e6da4;color:var(--mbu-text-on-accent)} .gt-cons-legt{color:inherit;text-decoration:none} .gt-cons-legt:hover{text-decoration:underline;color:var(--mbu-info)} .gt-cons-leglabel{font-size:11px;color:var(--mbu-text-weak);text-transform:uppercase;letter-spacing:.02em;margin-bottom:5px} .gt-cons-leg{gap:5px 6px} .gt-cons-selitem{cursor:pointer;border:1px solid transparent;border-radius:var(--mbu-radius);padding:2px 7px;display:inline-flex;align-items:center} .gt-cons-selitem.gt-on{background:var(--mbu-info-bg);border-color:var(--mbu-info)} .gt-cons-selitem:not(.gt-on){opacity:.5} .gt-cons-selitem:not(.gt-on):hover{opacity:.85;background:var(--mbu-bg-raised)} .gt-cons-legopen{text-decoration:none;color:var(--mbu-text-weak);margin-left:5px} .gt-cons-legopen:hover{color:var(--mbu-info)} .gt-cons-legyr{color:var(--mbu-text-weak);margin-left:5px} .gt-cons-paste{display:block;width:100%;box-sizing:border-box;margin:9px 0 2px;padding:5px 8px;border:1px solid var(--mbu-border);border-radius:5px;font-size:12px;font-family:inherit;color:var(--mbu-text);outline:none} .gt-cons-paste:focus{border-color:var(--mbu-info)} .gt-cons-tbl{border-collapse:collapse;width:100%} .gt-cons-tbl th{font-size:11px;color:var(--mbu-text-dim);text-transform:uppercase;letter-spacing:.02em;text-align:left;padding:4px 8px;border-bottom:1px solid var(--mbu-border)} .gt-cons-tbl th.gt-cons-col{text-align:center;width:30px} .gt-cons-tbl th.gt-cons-colsel{cursor:pointer;color:var(--mbu-info)} .gt-cons-tbl th.gt-cons-colsel:hover{background:var(--mbu-info-bg);border-radius:4px} .gt-cons-coll{font-weight:700} .gt-fmt{display:inline-flex;gap:2px;vertical-align:middle;margin:0 4px} .gt-fmt-b{display:inline-block;min-width:13px;box-sizing:border-box;padding:0 3px;border-radius:3px;font:700 9px/14px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text-on-accent);text-align:center;letter-spacing:.02em;border:1px solid var(--mbu-border)} /* #564: a hairline so the badge SHAPE is visible even when its fill is close to the page — the near-black vinyl one on dark. The inline colour set in fmtBadges wins over the color above. */ .gt-cons-col .gt-fmt{margin:2px 0 0;justify-content:center} .gt-cons-tbl td{padding:4px 8px;border-bottom:1px solid var(--mbu-border);vertical-align:top} .gt-cons-role{color:var(--mbu-text-dim);white-space:nowrap} .gt-cons-cr{color:var(--mbu-text-weak)} .gt-cons-cell{text-align:center;font-weight:700;width:30px;user-select:none} .gt-cons-cell.gt-has{color:var(--mbu-ok)} .gt-cons-cell.gt-prop{color:var(--mbu-info);outline:1px dashed #9cc2e6;outline-offset:-3px;border-radius:4px} .gt-cons-cell.gt-none{color:var(--mbu-info)} .gt-cons-cell.gt-fail{color:var(--mbu-error);cursor:help} .gt-cons-col.gt-fail .gt-cons-coll{color:var(--mbu-error)} .gt-cons-fail{display:flex;align-items:center;gap:8px;margin:4px 0 6px;padding:5px 8px;border:1px solid var(--mbu-error-border);border-radius:5px;background:var(--mbu-error-bg);color:var(--mbu-error);font-size:12px} .gt-cons-retry{margin-left:auto;cursor:pointer} .gt-cons-foot{display:flex;align-items:center;gap:12px;padding:10px 14px;border-top:1px solid var(--mbu-border)} .gt-cons-btn{font-weight:600;font-size:13px;font-family:inherit;padding:5px 14px;border-radius:var(--mbu-radius);border:1px solid var(--mbu-info);background:var(--mbu-info-bg);color:var(--mbu-info);cursor:pointer} .gt-cons-btn:hover{background:var(--mbu-info-bg)} .gt-cons-apply{margin-left:auto;background:#2e9e5b;border-color:var(--mbu-ok);color:var(--mbu-text-on-accent)} .gt-cons-apply:hover{background:#278a4f} .gt-cons-apply:disabled{background:var(--mbu-bg-sunken);border-color:var(--mbu-border);cursor:default} .gt-cons-plan{color:var(--mbu-text-dim);font-size:12px} /* Date picker (#398) */ .gt-dp{width:min(560px,94vw)} .gt-dp-ctrl{padding:10px 14px;border-bottom:1px solid var(--mbu-border);display:flex;flex-direction:column;gap:8px} .gt-dp-line{display:flex;align-items:center;flex-wrap:wrap;gap:6px} .gt-dp-lbl{font-size:11px;font-weight:700;color:var(--mbu-text-dim);text-transform:uppercase;letter-spacing:.02em;min-width:42px} .gt-dp-date{width:112px;box-sizing:border-box;padding:4px 7px;border:1px solid var(--mbu-border);border-radius:5px;font-size:13px;font-family:inherit;outline:none} .gt-dp-date:focus{border-color:var(--mbu-info)} .gt-dp-date.gt-dp-bad{border-color:var(--mbu-error);background:var(--mbu-bg-raised)} .gt-dp-dash{color:var(--mbu-text-weak)} .gt-dp-ended{display:inline-flex;align-items:center;gap:4px;margin-left:6px;color:var(--mbu-text-dim);cursor:pointer} .gt-dp-roles{gap:5px} .gt-dp-hint{color:var(--mbu-text-weak);font-style:italic} .gt-dp-chip{display:inline-flex;align-items:center;gap:4px;background:var(--mbu-info-bg);border:1px solid var(--mbu-info);color:var(--mbu-info);border-radius:12px;padding:2px 8px;font-size:12px;cursor:pointer} .gt-dp-chip:hover{background:var(--mbu-info-bg)} .gt-dp-chipx{color:var(--mbu-info);font-weight:700} .gt-dp-chip:hover .gt-dp-chipx{color:var(--mbu-error)} .gt-dp-body{padding:6px 10px} .gt-dp-trow{padding:4px 0;border-bottom:1px solid var(--mbu-border)} .gt-dp-tlab{display:flex;align-items:center;gap:7px;cursor:pointer;font-weight:600} .gt-dp-tpos{min-width:22px;color:var(--mbu-info);font-weight:700} .gt-dp-ttitle{color:var(--mbu-text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .gt-dp-clist{margin:2px 0 4px 26px;display:flex;flex-direction:column;gap:1px} .gt-dp-crow{display:flex;align-items:center;gap:7px;padding:2px 4px;border-radius:5px;cursor:pointer} .gt-dp-crow:hover{background:var(--mbu-bg-raised)} .gt-dp-role{color:var(--mbu-text-dim);white-space:nowrap} .gt-dp-role:hover{color:var(--mbu-info);text-decoration:underline dotted} .gt-dp-cname{color:var(--mbu-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1} .gt-dp-dated .gt-dp-role,.gt-dp-dated .gt-dp-cname{color:var(--mbu-text-weak)} .gt-dp-has{color:var(--mbu-warn);font-size:11px;white-space:nowrap;background:var(--mbu-warn-bg);border:1px solid var(--mbu-warn);border-radius:4px;padding:0 5px} `; document.head.appendChild(s); } // ── copy / move credits (P2, #338) ──────────────────────────────────────── // Reuse MB's own editing path. We read a source recording's rels straight off the // rendered `.relationship-item` nodes — each carries the full rel object on its React // fiber (linkTypeID, both entities WITH internal ids, credits, and the attributes tree) — // then dispatch copies onto the destination recordings through MB's reducer. No lossy // DOM-text parsing, no nested-state traversal. const REL_TEMPLATE = { _lineage: [], _original: null, _status: 1, attributes: null, begin_date: null, editsPending: false, end_date: null, ended: false, entity0_credit: '', entity1_credit: '', id: null, linkOrder: 0, linkTypeID: null }; const RE = () => (W.MB && W.MB.relationshipEditor) || null; const val = v => (typeof v === 'function' ? v() : v); // walk a DOM node's React fiber to the rel object (or entity) it renders function fiberFind(node, looks) { const key = node && Object.keys(node).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')); if (!key) return null; const seen = new Set(), q = [node[key]]; let steps = 0; while (q.length && steps < 80) { steps++; const f = q.shift(); if (!f || seen.has(f)) continue; seen.add(f); for (const prop of ['memoizedProps', 'pendingProps', 'memoizedState']) { const p = f[prop]; if (p && typeof p === 'object') for (const v of Object.values(p)) { if (looks(v)) return v; if (v && typeof v === 'object' && looks(v.relationship)) return v.relationship; if (v && typeof v === 'object' && looks(v.recording)) return v.recording; if (v && typeof v === 'object' && looks(v.work)) return v.work; } } if (f.child) q.push(f.child); if (f.sibling) q.push(f.sibling); if (f.return) q.push(f.return); } return null; } const looksRel = o => o && typeof o === 'object' && ('linkTypeID' in o) && ('entity0' in o || 'entity1' in o); // gid OR id != null — new (in-session) works/recordings have a NEGATIVE id and no gid yet const looksRec = o => o && typeof o === 'object' && o.entityType === 'recording' && (o.gid || o.id != null); const looksWork = o => o && typeof o === 'object' && o.entityType === 'work' && (o.gid || o.id != null); // same MB entity — MUST compare entityType too: a work id and a recording id can be the SAME number // (e.g. "Phuture Jacks" work #6762137 vs recording #6762137), so an id-only match would wrongly pull // a recording's producer/mix rels into a work. const sameEntity = (e, ref) => !!(e && ref && e.entityType === ref.entityType && ((ref.gid && e.gid === ref.gid) || (ref.id != null && e.id === ref.id))); const relFromNode = node => fiberFind(node, looksRel); // #373 classify a rel item so the +/pencil/× route correctly: a recording credit, a WORK credit (carrying // the work entity from the rel), a recording-of, or null. Fixes work rels showing the recording's menu. function relClass(item) { const rel = relFromNode(item); if (!rel) return null; const t0 = rel.entity0 && rel.entity0.entityType, t1 = rel.entity1 && rel.entity1.entityType; if ((t0 === 'recording' && t1 === 'work') || (t0 === 'work' && t1 === 'recording')) return { kind: 'recof', rel }; if (t0 === 'work' || t1 === 'work') return { kind: 'work', rel, work: t0 === 'work' ? rel.entity0 : rel.entity1 }; if (t0 === 'recording' || t1 === 'recording') return { kind: 'rec', rel }; return null; } const recordingEntity = tr => fiberFind(tr, looksRec); const workEntity = node => fiberFind(node, looksWork); // a recording track-row's rels, normalised to {other, credit, linkTypeID, attributes} // where `other` is the non-recording entity (the artist/work/…) and `credit` its credited-as function recordingRels(tr) { const out = []; tr.querySelectorAll('.relationship-item').forEach(item => { const rel = relFromNode(item); if (!rel || !looksRel(rel)) return; const rec0 = rel.entity0 && rel.entity0.entityType === 'recording'; const rec1 = rel.entity1 && rel.entity1.entityType === 'recording'; if (!rec0 && !rec1) return; const other = rec0 ? rel.entity1 : rel.entity0; const credit = rec0 ? rel.entity1_credit : rel.entity0_credit; // _status: 0 = existing, 1 = added this session, 3 = marked removed (stays in the DOM struck) out.push({ item, other, credit: val(credit) || '', linkTypeID: rel.linkTypeID, attributes: rel.attributes || null, begin_date: rel.begin_date || null, end_date: rel.end_date || null, ended: !!rel.ended, // preserve ℗/© years etc. on copy removed: rel._status === 3 }); }); return out; } // dispatch one rel into MB's editor (ported from Credit Hoarder's editor-state — the shared // editing lib). MB requires entity0 to be the lower entityType; swap + route credit accordingly. function dispatchRelationship(re, sourceEntity, targetEntity, linkTypeID, credit, attributes, dates) { if (credit && credit === (val(targetEntity.name) || '')) credit = ''; const swapped = sourceEntity.entityType > targetEntity.entityType; const e0 = swapped ? targetEntity : sourceEntity; const e1 = swapped ? sourceEntity : targetEntity; re.dispatch({ type: 'update-relationship-state', sourceEntity, batchSelectionCount: null, creditsToChangeForSource: '', creditsToChangeForTarget: '', oldRelationshipState: null, newRelationshipState: { ...REL_TEMPLATE, entity0: e0, entity0_credit: swapped ? (credit || '') : '', entity1: e1, entity1_credit: swapped ? '' : (credit || ''), id: re.getRelationshipStateId(), linkTypeID, attributes: attributes || null, begin_date: (dates && dates.begin_date) || null, end_date: (dates && dates.end_date) || null, ended: !!(dates && dates.ended), }, }); } // copy a set of source rels onto each destination entity (recording or work), preserving credit, // attributes and dates. Each dispatch is guarded so one bad target can't abort the whole batch. function copyCredits(srcRels, destEntities) { const re = RE(); if (!re) return 0; let n = 0, failed = 0; for (const dest of destEntities) { if (!dest || dest.id == null) { failed++; try { console.warn('[Group Therapy] skipping copy to a target with no id (unsaved entity?):', dest && val(dest.name)); } catch (e) {} continue; } for (const s of srcRels) { try { dispatchRelationship(re, dest, s.other, s.linkTypeID, s.credit, s.attributes, s); n++; } catch (e) { failed++; try { console.warn('[Group Therapy] copy failed for one credit:', e); } catch (_) {} } } } if (failed) try { toast(`Copied ${n}, but ${failed} could not be copied (see console)`); } catch (e) {} return n; } // #365 map a link type to the equivalent for a different entity end, by NAME — e.g. artist-recording // "producer" (#141) ↔ artist-release "producer" (#30). Returns null when the role has no equivalent for // that entity type (e.g. "instrument" is recording-only), so we can skip + report it. function ltEquiv(srcLtId, targetType) { const lt = W.MB.linkedEntities && W.MB.linkedEntities.link_type, src = lt && lt[srcLtId]; if (!src) return null; const otherEnd = [src.type0, src.type1].find(t => t !== 'release' && t !== 'recording'); if (!otherEnd) return null; // must be a credit (artist/label) if (src.type0 === targetType || src.type1 === targetType) return srcLtId; // already the right pair const want = [targetType, otherEnd].sort().join('-'); const m = Object.values(lt).find(t => t.name === src.name && [t.type0, t.type1].slice().sort().join('-') === want); return m ? m.id : null; } // like copyCredits, but maps each source credit's link type to the destination's entity type (#365 // release↔recording). Returns { n applied, skipped (no equivalent role for that entity) }. function copyCreditsMapped(srcRels, destEntities) { const re = RE(); if (!re) return { n: 0, skipped: 0 }; let n = 0, skipped = 0; for (const dest of destEntities) { if (!dest || dest.id == null) { skipped += srcRels.length; continue; } for (const s of srcRels) { const lt = ltEquiv(s.linkTypeID, dest.entityType); if (lt == null) { skipped++; continue; } try { dispatchRelationship(re, dest, s.other, lt, s.credit, s.attributes, s); n++; } catch (e) { skipped++; } } } return { n, skipped }; } // #365 read the release's OWN credits — the relationship-items in the Release relationships section // (i.e. NOT inside a track row). Filtered to artist/label credits, like recordingRels. function releaseCreditRels() { const out = []; [...document.querySelectorAll('.relationship-item')].filter(i => !i.closest('tr.track')).forEach(item => { const rel = relFromNode(item); if (!rel || !looksRel(rel)) return; const rel0 = rel.entity0 && rel.entity0.entityType === 'release', rel1 = rel.entity1 && rel.entity1.entityType === 'release'; if (!rel0 && !rel1) return; const other = rel0 ? rel.entity1 : rel.entity0; if (!other || !['artist', 'label'].includes(other.entityType)) return; // only copyable credits const credit = rel0 ? rel.entity1_credit : rel.entity0_credit; out.push({ item, other, credit: val(credit) || '', linkTypeID: rel.linkTypeID, attributes: rel.attributes || null, begin_date: rel.begin_date || null, end_date: rel.end_date || null, ended: !!rel.ended, removed: rel._status === 3 }); }); return out; } const releaseEntity = () => { try { const re = RE(); return re && (re.state.entity || re.state.release) || null; } catch (e) { return null; } }; // #365 (1) copy/move the release's own credits onto its recordings (the ticked ones, or all if none ticked) /* #597 (majkinetor): "Vertical move could also have option to paste tracks or specify them as in Text Pattern scope as selecting checkboxes is slow." One selector, shared by both vertical moves and using the text parser's own matcher, so the syntax, the live preview and the vinyl/ordinal handling cannot drift apart between the three places that offer it. Precedence: a typed spec wins; otherwise the ticked tracks; otherwise all. `state.v` is the live text — the caller reads it back through rowsFor(). */ function gtTrackSpecState(opts) { // blankAll: an empty field means EVERY track, ignoring the tick boxes. That // is what ⬇ has always done, and quietly narrowing its harvest to whatever // happened to be ticked would change an existing tool's behaviour. const blankAll = !!(opts && opts.blankAll); const state = { v: '' }; const tickedRows = rows => rows.filter(x => { const cb = x.tr.querySelector('input.recording'); return cb && cb.checked; }); state.rowsFor = () => { const rows = txpTrackRows(); if (state.v.trim()) return txpMatchTracks(state.v, rows); if (blankAll) return rows; const t = tickedRows(rows); return t.length ? t : rows; }; state.label = () => { const rows = state.rowsFor(); const cap = ns => (ns.length > 8 ? `${ns.slice(0, 8).join(', ')} +${ns.length - 8}` : ns.join(', ')); if (state.v.trim()) return rows.length ? `${rows.length} track${rows.length > 1 ? 's' : ''} (${cap(rows.map(x => x.num))})` : 'nothing matched'; return (!blankAll && tickedRows(txpTrackRows()).length) ? `${rows.length} selected recording${rows.length > 1 ? 's' : ''}` : `all ${rows.length} recordings`; }; state.item = (blank, onChange, onCommit) => ({ input: { value: state.v, onChange: onCommit || null, placeholder: `tracks: 1,3,5-7 · 2:4 · all (blank = ${blank})`, title: 'A track number as shown (3, A1), a range (5-7), a medium-qualified number or range (2:4, 2:4-6), ' + 'a whole medium (2:*), or all.\nA plain number matching no displayed position means the Nth track, ' + `which is how a vinyl's "track 4" finds B1.\nLeave empty to use ${blank}.`, onInput: (v) => { state.v = v; const rows = state.rowsFor(); if (onChange) onChange(); return { ok: rows.length > 0, text: '→ ' + state.label(), title: rows.map(x => `${x.num} ${x.title}`).join('\n') }; }, }, }); return state; } function openRelToRec(anchor) { const srcRels = releaseCreditRels().filter(r => !r.removed); const sel = gtTrackSpecState(); const dests = () => sel.rowsFor().map(x => x.rec).filter(Boolean); const where = () => sel.label(); // #365 cleansing — release-level / packaging roles that don't belong on a recording start UNTICKED // (re-tick to override). Matched as substrings against the role label. const CLEANSE = ['liner note', 'compiler', 'mastering', 'remaster', 'artwork', 'art direction', 'design', 'illustration', 'photograph', 'graphic', 'manufactured', 'pressed by', 'printed by', 'booklet', 'translat', 'lacquer', 'publish', 'copyright', 'booking', '℗', '©']; const entries = srcRels.map(s => { const lbl = (roleLabelOf(s) || '').toLowerCase(); return { rel: s, role: roleKeyOfSpec(s), pos: roleLabelOf(s), text: val(s.other.name) + (s.credit && s.credit !== val(s.other.name) ? ` (${s.credit})` : ''), checked: !CLEANSE.some(k => lbl.includes(k)) }; }); const chosen = () => entries.filter(e => e.cb ? e.cb.checked : e.checked !== false).map(e => e.rel); const r = anchor.getBoundingClientRect(); if (!srcRels.length) { openMenu(r.left, r.bottom + 4, [{ header: 'No release-level credits to copy' }]); return; } if (!txpTrackRows().length) { openMenu(r.left, r.bottom + 4, [{ header: 'No recordings on this release' }]); return; } // #597: `dests`/`where` are functions now — the destination is whatever the // track field says at the moment Copy/Move is pressed, not what it said // when the menu opened. const guard = (run) => () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } const d = dests(); if (!d.length) { toast('No track matched that selection'); return; } run(c, d, where()); }; const copyItem = { label: 'Copy', sub: String(chosen().length), run: guard((c, d, w) => { const { n, skipped } = copyCreditsMapped(c, d); if (n) markUsed(`Copied ${n} release credit${n > 1 ? 's' : ''} to ${w}`); toast(`Copied ${n} to ${w}${skipped ? ` · ${skipped} had no per-recording role` : ''} — review & save`); }) }; const moveItem = { label: 'Move (remove from release)', danger: true, run: guard((c, d, w) => { const { n } = copyCreditsMapped(c, d); c.forEach(s => { try { const rm = s.item.querySelector('button.remove-item, button.icon.remove-item'); rm && rm.click(); } catch (e) {} }); if (n) markUsed(`Moved ${n} release credit${n > 1 ? 's' : ''} to ${w}`); toast(`Moved ${n} to ${w} — review & save`); }) }; const hdr = { header: `Copy release credits → ${where()}` }; openMenu(r.left, r.bottom + 4, [hdr, sel.item('ticked tracks, or all', () => hdr._set && hdr._set(`Copy release credits → ${where()}`)), { checklist: entries, onToggle: () => copyItem._setSub && copyItem._setSub(String(chosen().length)) }, copyItem, moveItem]); } // #365 (2) collect the recordings' credits onto the release — a UNION across all tracks (dedup by // role+artist+credit), each row showing the track range it covers (* = every track). function openRecToRel(anchor, sel) { const rel = releaseEntity(); const r = anchor.getBoundingClientRect(); if (!rel || rel.id == null) { openMenu(r.left, r.bottom + 4, [{ header: 'Release not ready' }]); return; } // #597: which tracks to harvest FROM. Carried across the rebuild (see the // onCommit below) so the field keeps what was typed into it. sel = sel || gtTrackSpecState({ blankAll: true }); const srcRows = sel.rowsFor(); const total = srcRows.length, byKey = new Map(); srcRows.map(x => x.tr).forEach(tr => { const pos = trackPosOfRow(tr); recordingRels(tr).filter(s => !s.removed && s.other && ['artist', 'label'].includes(s.other.entityType)).forEach(s => { const key = roleKeyOfSpec(s) + '|' + (val(s.other.gid) || '') + '|' + (s.credit || ''); let e = byKey.get(key); if (!e) { e = { rel: s, roleLbl: roleLabelOf(s), other: s.other, credit: s.credit, tracks: new Set(), items: [] }; byKey.set(key, e); } if (pos != null) e.tracks.add(pos); e.items.push(s.item); }); }); const trkLbl = set => (set.size && set.size >= total) ? '*' : ranges(set); const entries = [...byKey.values()].map(e => ({ _e: e, role: e.rel.linkTypeID + '#' + e.roleLbl, pos: e.roleLbl, text: `${trkLbl(e.tracks)} ${val(e.other.name)}${e.credit && e.credit !== val(e.other.name) ? ` (${e.credit})` : ''}` })); const chosen = () => entries.filter(x => x.cb ? x.cb.checked : true); // #597: rebuilding is how the checklist follows the track field — the // entries ARE the harvest, so they cannot be recomputed in place. `sel` is // handed back in so the field keeps its text and the caret lands at the end. const reopen = () => openRecToRel(anchor, sel); const specItem = sel.item('all tracks', null, reopen); // #597: the "nothing to collect" menu keeps the field too. Without it, a // spec matching no credits would strand you in a dead menu with no way to // correct what you typed except closing and starting over. const from = sel.v.trim() ? ` from ${sel.label()}` : ''; if (!entries.length) { openMenu(r.left, r.bottom + 4, [{ header: 'No recording credits to collect' + from }, specItem]); return; } const copyItem = { label: 'Copy', sub: String(chosen().length), run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } const { n, skipped } = copyCreditsMapped(c.map(x => x._e.rel), [rel]); if (n) markUsed(`Collected ${n} credit${n > 1 ? 's' : ''} onto the release${from}`); toast(`Added ${n} to the release${skipped ? ` · ${skipped} had no release role` : ''} — review & save`); } }; const moveItem = { label: 'Move (remove from recordings)', danger: true, run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } const { n } = copyCreditsMapped(c.map(x => x._e.rel), [rel]); c.forEach(x => x._e.items.forEach(it => { try { const rm = it.querySelector('button.remove-item, button.icon.remove-item'); rm && rm.click(); } catch (e) {} })); if (n) markUsed(`Moved ${n} credit${n > 1 ? 's' : ''} from recordings onto the release${from}`); toast(`Moved ${n} onto the release — review & save`); } }; openMenu(r.left, r.bottom + 4, [{ header: 'Collect recording credits → the release (union)' }, specItem, { checklist: entries, onToggle: () => copyItem._setSub && copyItem._setSub(String(chosen().length)) }, copyItem, moveItem]); } // destination recordings = every OTHER track row whose recording checkbox is ticked function checkedDestinations(sourceTr) { const dests = []; document.querySelectorAll('tr.track').forEach(tr => { if (tr === sourceTr) return; const cb = tr.querySelector('input.recording'); if (cb && cb.checked) { const rec = recordingEntity(tr); if (rec) dests.push(rec); } }); return dests; } // for Move: click MB's own × on each source rel (so React removes it like a manual click). // Must go ONE AT A TIME with a re-read between clicks — all the rels are on the same row, and // each removal re-renders it, so pre-collected × buttons go stale after the first click. We // re-query the row each pass and remove the next rel matching (linkType + target gid). // ── #539: choosing recordings by track number ─────────────────────────────── // Every track row carries its DISPLAYED number in td.pos — "1" on a CD, "A1" // on a vinyl — and MB numbers restart per medium, so a plain "3" can name // more than one track. Rows are returned with their medium so the selector // can disambiguate, and the parser shows what it matched before anything is // applied. function txpTrackRows() { const out = []; let medium = 1; const all = [...document.querySelectorAll('#content tr, table tr')]; for (const tr of all) { if (!tr.classList.contains('track')) { // medium headers look like "1▼CD" / "2▼CD"; keep the last number seen const m = (tr.className || '').includes('subh') && (tr.innerText || '').trim().match(/^(\d+)/); if (m) medium = parseInt(m[1], 10); continue; } const rec = recordingEntity(tr); if (!rec) continue; const num = ((tr.querySelector('td.pos') || {}).textContent || '').trim(); out.push({ tr, rec, medium, num, ordinal: out.length + 1, title: val(rec.name) || '' }); } return out; } // Accepts, comma- or space-separated: a literal track number as displayed // (`3`, `A1`), a numeric range (`5-7`), a medium-qualified number or range // (`2:4`, `2:4-6`), a whole medium (`2:*`), or `all`. A colon is used for the // medium rather than a dash so `2-4` can only ever mean a range. // One token of a selector -> the rows it names. Split out of txpMatchTracks // (#597) because two callers now need to know whether a token matched // ANYTHING on its own: the ordinal fallback below, and auto-detect, whose // whole safety rule is "every token must name a real track". function txpMatchTrackToken(tok, rows) { const numOf = r => { const n = parseInt(String(r.num).replace(/^\D+/, ''), 10); return isFinite(n) ? n : null; }; let m; if ((m = tok.match(/^(\d+):(\*|all)$/i))) return rows.filter(r => r.medium === +m[1]); if ((m = tok.match(/^(\d+):(\d+)(?:-(\d+))?$/))) { const [lo, hi] = [+m[2], m[3] ? +m[3] : +m[2]]; return rows.filter(r => r.medium === +m[1] && numOf(r) !== null && numOf(r) >= lo && numOf(r) <= hi); } // #597: a plain number (or numeric range) that names no DISPLAYED position // falls back to the Nth track overall. Liner notes on a vinyl say "(tracks // 1,2,4)" while the positions on the page are A1/B2, so a literal match // finds nothing at all and the ordinal is what the note plainly means. // Strictly a fallback: on a CD "3" matches the position "3" literally and // never gets here, so nothing that already worked moves. if ((m = tok.match(/^(\d+)-(\d+)$/))) { const [lo, hi] = [+m[1], +m[2]]; const hit = rows.filter(r => numOf(r) !== null && numOf(r) >= lo && numOf(r) <= hi); return hit.length ? hit : rows.filter(r => r.ordinal >= lo && r.ordinal <= hi); } const t = tok.toLowerCase(); const hit = rows.filter(r => String(r.num).toLowerCase() === t); if (hit.length || !/^\d+$/.test(t)) return hit; return rows.filter(r => r.ordinal === +t); } function txpMatchTracks(spec, rows) { const s = String(spec || '').trim(); if (!s) return []; if (/^all$/i.test(s)) return rows.slice(); const picked = new Set(); for (const tok of s.split(/[,\s]+/).filter(Boolean)) txpMatchTrackToken(tok, rows).forEach(r => picked.add(r)); return rows.filter(r => picked.has(r)); } /* ── #597: reading the track list out of the credit text itself ───────────── (majkinetor) "Tracks are never mentioned this tidy but we could probably parse them if user selects or gives part that has tracks e.g. 1,2,3,7 or (tracks 1,2,3,7) or even auto recognized as numbers usually do not appear just like that (especially not vinyl)." Until now a clause like "Lead vocals (tracks 2,6,9)" was parsed, resolved and then silently discarded — the credit went on the whole release. Nothing LOOKED wrong either, because MB's role matcher tolerates the suffix: measured, "Lead vocals (tracks 2,6,9)" resolves to `vocal` exactly as the clean text does, and "Djembe (track 4)" to `djembe`. A plausible result crediting the wrong scope is the reason the preview column exists. The one rule that makes auto-detection safe: EVERY token in the clause must name a real track on this release. "(2003)" is not a track list because 2003 is not a track. That needs no blocklist of years, catalogue numbers or durations, and it fails by deciding the clause was never a track clause at all — which costs nothing. */ function txpDetectTracks(text, rows) { const s = String(text || ''); if (!s.trim() || !rows || !rows.length) return null; const cands = []; const covered = i => cands.some(c => i >= c.start && i < c.end); let m; // (tracks 1,2,4,7,8) · [track 3] · (trk 4) · (on tracks 2 and 6) const reWord = /[([]\s*(?:on\s+)?(?:tracks?|trks?)\.?\s*([^)\]]+?)\s*[)\]]/gi; while ((m = reWord.exec(s))) cands.push({ start: m.index, end: m.index + m[0].length, inner: m[1], explicit: true }); // trailing and unbracketed: "…, tracks 2 & 6" · "… on tracks 2, 6 and 9" const reTail = /[,;]?\s*(?:on\s+)?(?:tracks?|trks?)\.?\s+([0-9A-Za-z][0-9A-Za-z,;&\s-]*?)\s*\.?\s*$/i; if ((m = reTail.exec(s)) && !covered(m.index)) cands.push({ start: m.index, end: m.index + m[0].length, inner: m[1], explicit: true }); // bare: "(2,6,9)" · "(A1, B2)" · "(1-3)" const reBare = /[([]\s*([^)\]]+?)\s*[)\]]/g; while ((m = reBare.exec(s))) { if (!covered(m.index)) cands.push({ start: m.index, end: m.index + m[0].length, inner: m[1], explicit: false }); } const norm = t => String(t).replace(/\band\b/gi, ',').replace(/[&;]/g, ',') .replace(/\s*,\s*/g, ',').replace(/,+/g, ',').replace(/^,|[,.]+$/g, '').trim(); const ok = []; for (const c of cands) { const spec = norm(c.inner); const toks = spec ? spec.split(/[,\s]+/).filter(Boolean) : []; if (!toks.length) continue; const hits = toks.map(t => txpMatchTrackToken(t, rows)); if (hits.some(h => !h.length)) continue; // ← the safety rule // A bare single number — "Guitar (1)" — counts. It was refused at first, // on the grounds that it is a footnote marker at least as often as a // track; majkinetor overruled that after testing a real liner-note block // (#597): "also recognize (1) as it is shortest way to specify tracks // without adding any other words. It isn't likely that footnote will come // in that way and if it does, it can be removed by hand." The safety rule // above still applies, so the number has to BE a track on this release. ok.push({ ...c, spec, targets: rows.filter(r => hits.some(h => h.includes(r))) }); } if (!ok.length) return null; // (majkinetor) "We should assume that 1 line contains only 1 track list." // When that assumption is broken — "Mandolin (tracks 1,2), Guitar (tracks // 3,4)" — nothing here can tell which role owns which list, so it stands // down and says so instead of guessing. Splitting the line is the fix, and // saying that is more useful than crediting half of it wrongly. if (ok.length > 1) return { ambiguous: ok.length }; const c = ok[0]; const clean = (s.slice(0, c.start) + ' ' + s.slice(c.end)).replace(/\s+/g, ' ').replace(/[\s,;.]+$/, '').trim(); return { spec: c.spec, targets: c.targets, clean }; } const rowForRecording = gid => [...document.querySelectorAll('tr.track')].find(tr => { const rec = recordingEntity(tr); return rec && rec.gid === gid; }); async function removeSourceRels(srcGid, srcRels) { const want = new Set(srcRels.map(s => s.linkTypeID + '|' + (s.other && s.other.gid))); for (let guard = 0; guard < 300; guard++) { const tr = rowForRecording(srcGid); if (!tr) break; // re-find the row each pass — React replaces it on every removal // skip rels already marked removed (_status 3) — × leaves them struck in the DOM, so // without this the loop would re-click the same one forever const hit = recordingRels(tr).find(r => !r.removed && want.has(r.linkTypeID + '|' + (r.other && r.other.gid))); const b = hit && hit.item && hit.item.querySelector(REMOVE_SEL); if (!b) break; try { b.click(); } catch (e) {} await new Promise(r => setTimeout(r, 70)); // let React re-render before re-finding } } function toast(msg) { return mbuToast(msg); } // #563: the shared toast // build an MB attribute ImmutableTree from a /ws/js rel's attribute array (they carry typeIDs directly) function buildAttrTree(wsAttrs) { if (!wsAttrs || !wsAttrs.length) return null; const MB = W.MB, lat = MB && MB.linkedEntities && MB.linkedEntities.link_attribute_type; if (!lat || !MB.tree) return null; const objs = wsAttrs.map(a => ({ type: lat[a.typeID], typeID: a.typeID, text_value: a.text_value || '', credited_as: a.credited_as || '' })) .filter(o => o.type).sort((a, b) => a.typeID - b.typeID); if (!objs.length) return null; try { return MB.tree.fromDistinctAscArray(objs); } catch (e) { return null; } } // #374 apply recording-of attributes + dates onto an EXISTING recording→work rel — an update (keeps the // rel's id/entities, replaces its attributes/dates), not an add. Verified against MB's reducer. function updateRelAttrs(re, destRel, attrList, dates) { const src = destRel.entity0 && destRel.entity0.entityType === 'recording' ? destRel.entity0 : destRel.entity1; re.dispatch({ type: 'update-relationship-state', sourceEntity: src, batchSelectionCount: null, creditsToChangeForSource: '', creditsToChangeForTarget: '', oldRelationshipState: destRel, // #383 MUST bump _status to EDIT (2) for an EXISTING rel — spreading destRel keeps _status 0 (NOOP), so // MB treats the attribute/date change as nothing and submit says "You haven't made any changes". A // not-yet-saved rel (ADD = 1) stays ADD so it isn't turned into an edit-of-nothing. newRelationshipState: { ...destRel, _status: destRel._status === 1 ? 1 : 2, attributes: buildAttrTree(attrList), begin_date: (dates && dates.begin_date) || null, end_date: (dates && dates.end_date) || null, ended: !!(dates && dates.ended) }, }); } // #385 set the date period (begin/end/ended) on an existing rel, leaving attributes/credit untouched — // powers "Set missing dates". Same _status discipline as updateRelAttrs (#383): bump an existing rel to // EDIT (2) so the change submits; a not-yet-saved rel (ADD = 1) stays ADD. // // ── Findings (so we don't re-investigate — see issue #385) ────────────────────────────────────────── // This can only FILL a blank date. It can NOT overwrite an existing date, nor clear one. Why: // • MB's editor reducer routes 'update-relationship-state' through getUpdatesForAcceptedRelationship → // mergeRelationship, which MERGES the new state into the existing relationship and KEEPS the non-empty // date. So an empty or different date sent via MB.relationshipEditor.dispatch is silently ignored and // the old date stays. Verified empirically: null, {year:null,month:null,day:null}, and empty strings // ALL leave the date intact; setting a different date keeps the first one. // • The ONLY way to overwrite/clear a date is to drive MB's own edit dialog: click the rel's // "edit-relationship-…" button to OPEN the dialog (only then does MB.relationshipEditor // .relationshipDialogDispatch exist — it's undefined otherwise), dispatch // {type:'update-date-period', action:{type:'update-begin-date', action:{type:'set-date', date}}} etc., // then click the dialog's positive (Done) button. loujine's "Copy dates" userscript works this way. // • Or submit the edit directly to MB's edit API (like GT's RG Consolidation), bypassing the editor // reducer entirely — that can set/remove too, but it auto-submits instead of staging for review. // • DECISION (majkinetor): we neither drive the UI nor auto-submit edits, so overwrite/remove are // abandoned; this stays fill-only. // ──────────────────────────────────────────────────────────────────────────────────────────────────── function applyRelDate(re, destRel, dates) { const e0 = destRel.entity0, e1 = destRel.entity1; const src = (e0 && e0.entityType === 'recording') ? e0 : (e1 && e1.entityType === 'recording') ? e1 : e0; re.dispatch({ type: 'update-relationship-state', sourceEntity: src, batchSelectionCount: null, creditsToChangeForSource: '', creditsToChangeForTarget: '', oldRelationshipState: destRel, newRelationshipState: { ...destRel, _status: destRel._status === 1 ? 1 : 2, begin_date: (dates && dates.begin_date) || null, end_date: (dates && dates.end_date) || null, ended: !!(dates && dates.ended) }, }); } function relAttrList(rel) { // a rel's attributes as [{typeID, text_value, credited_as, name}] if (!rel || !rel.attributes || !W.MB || !W.MB.tree) return []; try { return [...W.MB.tree.iterate(rel.attributes)].map(a => ({ typeID: a.typeID, text_value: a.text_value || '', credited_as: a.credited_as || '', name: (a.type && a.type.name) || ((W.MB.linkedEntities.link_attribute_type[a.typeID] || {}).name) || '' })); } catch (e) { return []; } } const fmtRoDate = d => { const one = x => x && x.year ? [x.year, x.month, x.day].filter(Boolean).join('‑') : ''; const b = one(d.begin_date), e = one(d.end_date); if (b && e) return `${b} → ${e}`; if (b) return d.ended ? `${b} → ` : b; if (e) return `→ ${e}`; return d.ended ? 'ended' : ''; }; // #374 copy a "recording of" rel's attributes + dates onto the selected recordings' own recording-of // rels (or all if none selected) — "always set the others exactly like that one". function openRecOfMenu(sourceItem, x, y) { const re = RE(); if (!re) return; const srcRel = relFromNode(sourceItem); if (!srcRel) return; const attrList = relAttrList(srcRel); const dates = { begin_date: srcRel.begin_date || null, end_date: srcRel.end_date || null, ended: !!srcRel.ended }; const selTr = [...document.querySelectorAll('tr.track')].filter(tr => { const cb = tr.querySelector('input.recording'); return cb && cb.checked; }); const trs = selTr.length ? selTr : [...document.querySelectorAll('tr.track')]; const destRels = []; trs.forEach(tr => tr.querySelectorAll('.relationship-item').forEach(it => { if (it === sourceItem) return; const r = relFromNode(it); if (r && r._status !== 3 && r.entity0 && r.entity0.entityType === 'recording' && r.entity1 && r.entity1.entityType === 'work') destRels.push(r); })); const summary = [attrList.map(a => a.name).filter(Boolean).join(', '), fmtRoDate(dates)].filter(Boolean).join(' · ') || '(no attributes or dates set)'; const where = selTr.length ? `${destRels.length} selected` : `all ${destRels.length}`; const items = [{ header: `Copy “recording of” attributes → ${where} recording${destRels.length !== 1 ? 's' : ''}` }, { note: summary }]; if (!destRels.length) items.push({ header: 'No other recording-of rels to copy to' }); else items.push({ label: 'Copy', run: () => { let n = 0; destRels.forEach(dr => { try { updateRelAttrs(re, dr, attrList, dates); n++; } catch (e) {} }); if (n) markUsed(`Copied “recording of” attributes to ${n} recording${n !== 1 ? 's' : ''}`); toast(`Set “recording of” attributes on ${n} — review & save`); } }); openMenu(x, y, items); } // fetch another release's release-level credits (artists + labels) as copy specs (not yet dispatched) async function fetchReleaseRels(sourceGid) { const j = await (await mbnFetch('/ws/js/entity/' + sourceGid + '?inc=rels', { credentials: 'include', headers: { Accept: 'application/json' } })).json(); return (j.relationships || []).filter(r => (r.target_type === 'artist' || r.target_type === 'label') && r.target && r.target.id != null) .map(r => ({ other: { entityType: r.target_type, id: r.target.id, gid: r.target.gid, name: r.target.name }, // artist/label sort before release → carries entity0_credit linkTypeID: r.linkTypeID, credit: r.entity0_credit || '', attributes: buildAttrTree(r.attributes), begin_date: r.begin_date || null, end_date: r.end_date || null, ended: !!r.ended, })); } // this release's single medium format (from tr.subh), or '' when mixed/unknown const releaseFormat = () => { const f = [...new Set([...document.querySelectorAll('tr.subh')].map(s => (s.textContent || '').replace(/^\s*\d+\s*/, '').replace(/^[^A-Za-z0-9]+/, '').trim().toLowerCase()).filter(Boolean))]; return f.length === 1 ? f[0] : ''; }; const GID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; // popover: pick a sibling release from this release group, or paste any release URL/MBID let popEl = null, cloneBtnRef = null; function closePopover() { if (popEl) { popEl.remove(); popEl = null; document.removeEventListener('mousedown', onPopDown, true); document.removeEventListener('keydown', onPopKey, true); } } function onPopKey(e) { if (e.key === 'Escape') { e.stopPropagation(); closePopover(); } } function onPopDown(e) { if (!popEl || popEl.contains(e.target)) return; closePopover(); // swallow the trailing click so dismissing doesn't activate whatever's underneath (#305) const swallow = ev => { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', swallow, true); }; document.addEventListener('click', swallow, true); setTimeout(() => document.removeEventListener('click', swallow, true), 500); } async function doCopyFrom(gid, srcLabel) { if (!gid) return; const here = RE() && RE().state.entity; if (!here) return; if (gid.toLowerCase() === (here.gid || '').toLowerCase()) { toast('That’s this release'); return; } const a = cloneBtnRef && cloneBtnRef.getBoundingClientRect(); const ax = a ? a.left : 120, ay = a ? a.bottom + 4 : 120; closePopover(); toast('Fetching…'); let specs; try { specs = await fetchReleaseRels(gid); } catch (e) { toast('Copy failed: ' + (e && e.message || e)); return; } if (!specs.length) { toast('No artist/label credits on that release'); return; } // show a checklist of what will be copied (like the recording/work menus), with format-aware // cleansing against THIS release's format (cross-format copy is where cleansing matters) const entries = specs.map(s => ({ rel: s, role: roleKeyOfSpec(s), pos: roleLabelOf(s), text: val(s.other.name) + (s.credit && s.credit !== val(s.other.name) ? ` (${s.credit})` : '') })); const chosen = () => entries.filter(e => !e.cb || e.cb.checked).map(e => e.rel); const fmt = releaseFormat(), exRoles = formatExcludeRolesFor(fmt); let excluded = 0; if (exRoles.length) entries.forEach(e => { const rn = ltName(e.rel.linkTypeID).toLowerCase(); if (exRoles.some(k => rn.includes(k))) { e.checked = false; e.text += ` — off (not typical for ${fmt})`; excluded++; } }); const copyItem = { label: 'Copy', sub: String(chosen().length), run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } if (copyCredits(c, [here])) markUsed(`Copied ${c.length} release credit${c.length > 1 ? 's' : ''} from ${srcLabel ? `“${srcLabel}” (${gid})` : gid}`); toast(`Copied ${c.length} release credit${c.length > 1 ? 's' : ''} — review & save`); } }; const items = [{ header: 'Copy release credits' }]; if (excluded) items.push({ note: `${excluded} pre-unticked for format “${fmt}”` }); items.push({ checklist: entries, onToggle: () => { copyItem._setSub && copyItem._setSub(String(chosen().length)); } }, copyItem); openMenu(ax, ay, items); } async function loadRgReleases(list) { try { const here = RE().state.entity.gid; const rg = await (await mbnFetch('/ws/2/release/' + here + '?inc=release-groups&fmt=json', { headers: { Accept: 'application/json' } })).json(); const rgid = rg['release-group'] && rg['release-group'].id; if (!rgid) { list.textContent = ''; list.appendChild(el('div', 'gt-pop-note', 'No release group')); return; } const sib = await (await mbnFetch('/ws/2/release?release-group=' + rgid + '&inc=media&limit=100&fmt=json', { headers: { Accept: 'application/json' } })).json(); const rels = (sib.releases || []).filter(r => r.id !== here); list.textContent = ''; if (!rels.length) { list.appendChild(el('div', 'gt-pop-note', 'No other releases in this group')); return; } for (const r of rels) { const b = el('div', 'gt-pop-rel'); const info = el('button', 'gt-pop-rel-info'); info.appendChild(el('span', 'gt-pop-rel-t', r.title + (r.disambiguation ? ` (${r.disambiguation})` : ''))); const fmt = [...new Set((r.media || []).map(m => m.format).filter(Boolean))].join(' + '); const tracks = (r.media || []).reduce((s, m) => s + (m['track-count'] || 0), 0); const meta = [r.date, r.country, fmt, tracks ? tracks + ' tracks' : ''].filter(Boolean).join(' · '); if (meta) info.appendChild(el('span', 'gt-pop-rel-m', meta)); info.title = 'Copy this release’s credits onto this one'; info.onclick = () => doCopyFrom(r.id, r.title + (r.disambiguation ? ` (${r.disambiguation})` : '')); const open = el('a', 'gt-pop-rel-open', '↗'); // ↗ open the release in a new tab to inspect first open.href = '/release/' + r.id; open.target = '_blank'; open.rel = 'noopener'; open.title = 'Open this release in a new tab'; open.addEventListener('click', ev => ev.stopPropagation()); b.appendChild(info); b.appendChild(open); list.appendChild(b); } } catch (e) { list.textContent = ''; list.appendChild(el('div', 'gt-pop-note', 'Could not load release group')); } } function openCopyFromPopover(anchor) { closePopover(); popEl = el('div', 'gt-pop'); popEl.appendChild(el('div', 'gt-pop-hdr', 'Copy release credits from…')); const list = el('div', 'gt-pop-list'); list.appendChild(el('div', 'gt-pop-note', 'Loading release group…')); popEl.appendChild(list); popEl.appendChild(el('div', 'gt-sep')); // paste-to-copy: a (+) that unrolls into a field and acts immediately on paste — no Copy button // (same idiom as Apollo's link/artist add + ISRC Scout). Enter is a fallback for typed input. const add = el('div', 'gt-pop-add'); const plus = el('button', 'gt-pop-add-btn'); plus.type = 'button'; plus.textContent = '+ from a release URL / MBID'; const inp = el('input', 'gt-pop-tf gt-hidden'); inp.type = 'text'; inp.placeholder = 'paste a release URL / MBID…'; const fromInput = () => { const m = (inp.value || '').match(GID_RE); if (m) doCopyFrom(m[0]); }; plus.onclick = () => { plus.classList.add('gt-hidden'); inp.classList.remove('gt-hidden'); try { inp.focus(); } catch (e) {} }; inp.addEventListener('paste', () => setTimeout(fromInput, 0)); // wait for the pasted text to land in .value inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fromInput(); } }); add.appendChild(plus); add.appendChild(inp); popEl.appendChild(add); document.body.appendChild(popEl); const a = anchor.getBoundingClientRect(), r = popEl.getBoundingClientRect(); popEl.style.left = Math.max(8, Math.min(a.left, window.innerWidth - r.width - 8)) + 'px'; popEl.style.top = Math.min(a.bottom + 4, window.innerHeight - r.height - 8) + 'px'; setTimeout(() => { document.addEventListener('mousedown', onPopDown, true); document.addEventListener('keydown', onPopKey, true); }, 0); loadRgReleases(list); } // #372 standard config window — title bar (icon + name + version + Help) then a body of options, // matching the other userscripts' settings dialogs. function openAboutPopover(anchor) { closePopover(); popEl = el('div', 'gt-pop gt-cfg-pop'); // #563: the shared config title bar. Built as markup rather than assembled by // hand, so this header cannot drift from the other six again. const hd = el('div'); hd.innerHTML = mbuCfgHeader({ script: 'group_therapy', name: 'Group Therapy', version: VERSION, icon: '<img src="https://raw.githubusercontent.com/majkinetor/musicbrainz-userscripts/main/userscripts/group_therapy/icon.svg" alt="">' }); // a broken icon URL should leave a tidy header, not a broken-image glyph const hdIc = hd.querySelector('.mbu-cfg-ic img'); if (hdIc) hdIc.onerror = () => hdIc.remove(); popEl.appendChild(hd.firstElementChild); const body = el('div', 'gt-cfg-body'); const opt = (label, hint, get, set) => { const l = el('label', 'gt-cfg-opt'); l.title = hint; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = get(); cb.onchange = () => set(cb.checked); l.append(cb, el('span', null, label)); return l; }; body.appendChild(opt('Hide help text', 'Hide the two MusicBrainz help paragraphs at the top of the edit-relationships page', () => gtHideHelp, v => { gtHideHelp = v; try { GM_setValue('gt-hide-help', v); } catch (e) {} gtApplyHelp(); })); body.appendChild(opt('Hide native batch tools', "Hide MusicBrainz's own batch-tools table (#batch-tools) on the edit-relationships page", () => gtHideBatch, v => { gtHideBatch = v; try { GM_setValue('gt-hide-batch', v); } catch (e) {} gtApplyBatch(); })); body.appendChild(opt('Auto-match on start', 'Open the work matcher and run matching automatically when the page loads', () => gtAutoMatch, v => { gtAutoMatch = v; try { GM_setValue('gt-auto-match', v); } catch (e) {} })); body.appendChild(opt('Auto-match on open', 'When you open the work matcher, run matching automatically — off (default): the popup opens with everything unresolved and you click ⚡ Match yourself', () => wmAutoOnOpen, v => { wmAutoOnOpen = v; try { GM_setValue('gt-wm-auto-open', v); } catch (e) {} })); body.appendChild(opt('Uncollapse media on start', 'On load, click MusicBrainz’s “Expand all mediums” so every medium’s tracks are reachable during the fill phase (MB collapses mediums past the first few). Off by default — expanding a large release takes a moment.', () => gtUncollapse, v => { gtUncollapse = v; try { GM_setValue('gt-uncollapse', v); } catch (e) {} if (v) gtApplyUncollapse(); })); popEl.appendChild(body); document.body.appendChild(popEl); const a = anchor.getBoundingClientRect(), r = popEl.getBoundingClientRect(); popEl.style.left = Math.max(8, Math.min(a.right - r.width, window.innerWidth - r.width - 8)) + 'px'; popEl.style.top = Math.min(a.bottom + 4, window.innerHeight - r.height - 8) + 'px'; setTimeout(() => { document.addEventListener('mousedown', onPopDown, true); document.addEventListener('keydown', onPopKey, true); }, 0); } function injectCloneButton() { const content = document.getElementById('content'); if (!content) return false; if (content.querySelector('.gt-toolbar')) { gtApplyHelp(); gtApplyBatch(); gtApplyUncollapse(); return true; } // wait until the relationship editor has rendered: its release section (#722 by id, as MB // translates the "Release relationships" heading) if (!document.getElementById('release-rels')) return false; const bar = el('div', 'gt-toolbar'); const b = el('button', 'gt-clone-btn', '⧉ Copy from release…'); b.title = 'Copy release-level credits (artists, labels) from another release onto this one'; b.type = 'button'; b.onclick = () => openCopyFromPopover(b); bar.appendChild(b); cloneBtnRef = b; const cons = el('button', 'gt-clone-btn', '▦ Consolidate RG…'); cons.title = 'Spread release-level credits across every release in this group (union minus format-specific)'; cons.type = 'button'; cons.onclick = () => openConsolidate(); bar.appendChild(cons); const wm = el('button', 'gt-clone-btn', '◎ Match works…'); wm.title = 'Match each recording to an existing MusicBrainz work (via ISRC + title/artist siblings) and stage recording→work “performance” relationships'; wm.type = 'button'; wm.onclick = () => openWorkMatch(); bar.appendChild(wm); const tp = el('button', 'gt-clone-btn', '✎ Text parser…'); tp.title = 'Parse pasted or annotation credit text (e.g. "Role: Artist") into release relationships'; tp.type = 'button'; tp.onclick = () => openTextParser(); bar.appendChild(tp); // #365 one "Vertical:" section — ⬆ pushes the release's credits DOWN to its recordings, ⬇ pulls the // recordings' credits UP to the release (icons per majkinetor: up = release→recordings, down = →release) const vwrap = el('span', 'gt-vert'); vwrap.appendChild(el('span', 'gt-vert-lbl', 'Vertical:')); const r2r = el('button', 'gt-clone-btn gt-vert-btn', '⬆'); r2r.title = 'Release → recordings: copy (or move) the release-level credits onto its recordings — the ticked ones, or all if none ticked'; r2r.type = 'button'; r2r.onclick = () => openRelToRec(r2r); const c2r = el('button', 'gt-clone-btn gt-vert-btn', '⬇'); c2r.title = 'Recordings → release: collect the recordings’ credits onto the release — a union across all tracks (shows the track range each covers)'; c2r.type = 'button'; c2r.onclick = () => openRecToRel(c2r); vwrap.append(r2r, c2r); bar.appendChild(vwrap); const cfg = el('button', 'gt-cfg-btn', MBU_CFG_ICON); cfg.type = 'button'; cfg.title = 'Group Therapy — options, about / help'; cfg.onclick = () => openAboutPopover(cfg); bar.appendChild(cfg); // #372 the toolbar goes at the top of the tab (right after the entity tabs), not on the heading const tabs = content.querySelector(':scope > .tabs'); content.insertBefore(bar, tabs ? tabs.nextSibling : content.firstChild); gtApplyHelp(); gtApplyBatch(); gtApplyUncollapse(); // #372 auto-open + match — but skip it when every recording already has a work (nothing to do) if (gtAutoMatch) setTimeout(() => { try { if (wmRecordings().some(r => !r.hasWorkOnPage)) openWorkMatch(true); } catch (e) {} }, 500); // "Auto-match on start" opens AND runs, regardless of the per-open toggle return true; } const ltName = id => (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type[id] || {}).name || String(id); // #491: does this link type even support relationship-level dates? "recorded at" is defined for BOTH // recording-place (has_dates true — the Place entity carries no date of its own) AND recording-event // (has_dates false — the Event entity already has one), so name-based matching alone can't tell them // apart. has_dates is missing from the cache when the type hasn't loaded yet — default true (unknown) // rather than silently hiding a genuinely datable row. const ltHasDates = id => { const lt = W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type[id]; return !lt || lt.has_dates !== false; }; if (mbuTestHooks()) window.__gtTest = Object.assign(window.__gtTest || {}, { ltHasDates }); // test hook only (#491) — no behaviour change // grouping key for a copy spec: link type + its attribute typeIDs (so drums ≠ shakers ≠ vocals) const roleKeyOfSpec = s => { let a = ''; try { if (s.attributes) a = [...W.MB.tree.iterate(s.attributes)].map(x => x.typeID).sort((p, q) => p - q).join(','); } catch (e) {} return s.linkTypeID + '#' + a; }; // display label for a copy spec — MB's own rendered role label when the rel is on the page // ("drums (drum set)", "background vocals"); else the link type + resolved attribute names. function roleLabelOf(s) { if (s.item) { const l = pickRoleLabel(s.item.closest && s.item.closest('tr')); if (l && l !== 'role') return l; } const base = ltName(s.linkTypeID), parts = []; try { if (s.attributes) { const lat = W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type; for (const a of W.MB.tree.iterate(s.attributes)) { const nm = (a.type && a.type.name) || (lat && lat[a.typeID] && lat[a.typeID].name); if (nm) parts.push(a.text_value ? `${nm}: ${a.text_value}` : nm); } } } catch (e) {} return parts.length ? `${base} (${parts.join(', ')})` : base; } const trackPosOfRow = tr => posLabel(tr); // ── format-aware cleansing (#338, #349) ─────────────────────────────────── // When copying/consolidating credits onto a destination of a given FORMAT, credits whose ROLE is // format-inappropriate start UNTICKED (re-tick to override). Two layers: // FORMAT_EXCLUDE — format-substring → role-substrings dropped FOR that format (physical-only roles off // a digital edition). Override via GM value 'gt-format-exclude' (JSON object). // FORMAT_ONLY — role-substring → the ONLY format families it belongs to; dropped from every OTHER // format. Lacquer cutting is vinyl-only; glass mastering is optical-disc-only (CD/DVD/SACD/Blu-ray). // Override via GM value 'gt-format-only' (JSON object). const FORMAT_EXCLUDE_DEFAULT = { digital: ['vinyl', 'pressed', 'printed', 'manufactured'] }; const FORMAT_ONLY_DEFAULT = { lacquer: ['vinyl'], glass: ['cd', 'dvd', 'sacd', 'blu-ray'] }; const _gmJson = (key, def) => { try { const raw = (typeof GM_getValue === 'function') && GM_getValue(key, ''); if (raw) return JSON.parse(raw); } catch (e) {} return def; }; function formatExcludeMap() { return _gmJson('gt-format-exclude', FORMAT_EXCLUDE_DEFAULT); } function formatOnlyMap() { return _gmJson('gt-format-only', FORMAT_ONLY_DEFAULT); } function formatExcludeRolesFor(fmt) { fmt = (fmt || '').toLowerCase(); if (!fmt) return []; const out = [], excl = formatExcludeMap(), only = formatOnlyMap(); for (const k in excl) if (fmt.includes(String(k).toLowerCase())) out.push(...(excl[k] || [])); for (const role in only) if (!(only[role] || []).some(f => fmt.includes(String(f).toLowerCase()))) out.push(role); return [...new Set(out.map(s => String(s).toLowerCase()))]; } // ── format-family markers (#350): collapse any MB format to Digital / Vinyl / CD / Cassette, drawn as a // compact colored badge (full format in the tooltip). Optical (DVD/SACD/Blu-ray) folds into CD. /* #564 follow-up (majkinetor, RG Consolidation, light theme): "still has format button issues … D and CD". Not the label this time — badgeInk already picks a readable ink — but the FILL against a white page: Digital #4a90d9 3.34:1 CD #7d8894 3.61:1 Vinyl #3a3f47 10.60:1 which is why LP looked like a badge and D and CD looked washed out beside it. Darkened so every family sits at 5:1 or better on white, which also puts all four on white ink — the LP treatment, applied consistently. badgeInk still chooses; it just now chooses the same thing for all of them. */ const FMT_FAMILY = { Digital: { label: 'D', color: '#2f6fb0' }, Vinyl: { label: 'LP', color: '#3a3f47' }, CD: { label: 'CD', color: '#556270' }, Cassette: { label: 'MC', color: '#8a5f37' } }; function formatFamily(f) { f = (f || '').toLowerCase(); if (/cassette|tape/.test(f)) return 'Cassette'; if (/vinyl|shellac|flexi/.test(f)) return 'Vinyl'; if (/cd|sacd|dvd|blu.?ray|hd.?dvd|minidisc|umd|disc/.test(f)) return 'CD'; if (/digital|file|download|stream|web/.test(f)) return 'Digital'; return ''; } const formatFamilies = fmt => [...new Set((fmt || '').split('+').map(formatFamily).filter(Boolean))]; /* #564 (majkinetor): "Format icons are not visible clearly in both themes", with a shot of each. The badge background is the format's identity colour — vinyl near-black, CD mid-grey — while the label was always var(--mbu-text-on-accent), i.e. white. A fixed foreground over a fixed palette has to fail somewhere: white on the CD grey is ~2.6:1 on either theme, and the near-black vinyl badge disappears into a dark page entirely. So the label picks itself, from the background it actually sits on: whichever of black or white has more contrast there. That holds for every format and every theme without a per-theme table, and it improves the light theme too, which was never the complaint but was just as wrong. */ function badgeInk(hex) { const h = String(hex).replace('#', ''); const n = h.length === 3 ? h.split('').map(c => c + c).join('') : h; const ch = i => parseInt(n.slice(i * 2, i * 2 + 2), 16) / 255; const lin = v => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)); const L = 0.2126 * lin(ch(0)) + 0.7152 * lin(ch(1)) + 0.0722 * lin(ch(2)); const onWhite = 1.05 / (L + 0.05); // contrast of #fff against it const onBlack = (L + 0.05) / 0.05; // contrast of #000 against it return onBlack >= onWhite ? '#000' : '#fff'; } function fmtBadges(fmt) { const wrap = el('span', 'gt-fmt'); for (const fam of formatFamilies(fmt)) { const b = el('span', 'gt-fmt-b', FMT_FAMILY[fam].label); b.style.background = FMT_FAMILY[fam].color; b.style.color = badgeInk(FMT_FAMILY[fam].color); b.title = fam + (fmt && fmt !== fam ? ` (${fmt})` : ''); wrap.appendChild(b); } return wrap; } // ── Consolidate RG (#349) ────────────────────────────────────────────────── // Build a (role, entity) × release matrix of every release's release-level credits — artist/label/place // entity credits. URLs are EXCLUDED (edition-specific: each release has its own discogs / streaming / // purchase link, so spreading them is wrong); recording/work are excluded too (shared already). Propose // the union minus format-specific roles, and let the user // toggle cells, whole columns (click a header letter), or the whole matrix (Auto select). Apply POSTs // the additions as edit_type:90 relationship-creates to /ws/js/edit/create — the internal endpoint ISRC // Scout uses (session-cookie auth, no CSRF). We read via /ws/js for the NUMERIC linkTypeID + entity // credits the edit API needs; formats come from the RG enumeration. const sleep = ms => new Promise(r => setTimeout(r, ms)); // fetch one release's release-level rels, retrying on rate-limit (429/503) and transient errors with backoff async function consFetchRels(gid, tries = 4) { for (let i = 0; i < tries; i++) { try { const res = await mbnFetch('/ws/js/entity/' + gid + '?inc=rels', { credentials: 'include', headers: { Accept: 'application/json' } }); if ((res.status === 429 || res.status === 503) && i < tries - 1) { await sleep(700 * (i + 1)); continue; } if (!res.ok) throw new Error('HTTP ' + res.status); const j = await res.json(); return (j.relationships || []).filter(r => r.target && r.target.gid && !['recording', 'work', 'url'].includes(r.target_type)); } catch (e) { if (i === tries - 1) throw e; await sleep(600 * (i + 1)); } } return []; } // #624 (majkinetor): "RG Consolidation reports no RG due to error/rate limit" — on a release that // plainly had 3 versions. The WS2 reads here never looked at the response: a throttled 503 is JSON // too ({"error": …}), so it parsed as "no release group" / "no releases". Every WS2 read in the // dialog now goes through the /ws/2 gate (the shared one since #633), retries 429/503 after its hold (Retry-After is a // floor, never a replacement — MB sends "Retry-After: 0"), and THROWS when it gives up, so a // failure is reported as a failure, never as an empty result. async function consWs2(url, tries = 5) { let last = ''; for (let i = 0; i < tries; i++) { await mbnSlot({ label: url, log: gtGateLog }); // the shared gate (#633): its pace, and any hold MusicBrainz asked for let r; try { r = await fetch(url, { headers: { Accept: 'application/json' } }); } catch (e) { last = (e && e.message) || 'network error'; console.debug('[Group Therapy] consolidate:', url, 'failed:', last); await sleep(700 * (i + 1)); continue; } if ((await mbnAnswer(r.status, n => r.headers.get(n), { log: gtGateLog })).throttled) { last = 'MusicBrainz is rate-limiting (HTTP ' + r.status + ')'; console.debug(`[Group Therapy] consolidate: ${url} → ${r.status}` + (i < tries - 1 ? `, retry ${i + 1}/${tries - 1} after the hold` : ', giving up')); if (i < tries - 1) continue; // the next slot waits out the shared hold break; } if (!r.ok) throw new Error('HTTP ' + r.status); return await r.json(); } throw new Error(last || 'request failed'); } // run worker over items with a bounded number of concurrent tasks (parallel, but throttled) async function throttledMap(items, worker, concurrency = 4) { const out = new Array(items.length); let idx = 0; const run = async () => { while (idx < items.length) { const i = idx++; try { out[i] = await worker(items[i], i); } catch (e) { out[i] = null; } } }; await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run)); return out; } const consKey = r => [r.linkTypeID, r.target.gid, (r.attributes || []).map(a => a.typeID).sort((p, q) => p - q).join(','), r.entity0_credit || '', r.entity1_credit || ''].join('|'); // #371 the link type alone is generic ("instrument"); the specific instrument (guitar, tuba, drums …) // lives in the attributes — resolve their names so every instrument row is distinguishable, matching // roleLabelOf. Consolidation rels come from /ws/js (plain array, typeID only), so resolve via linkedEntities. const consRole = r => { const base = ltName(r.linkTypeID), lat = W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type; const parts = (r.attributes || []).map(a => { const nm = (a.type && a.type.name) || (lat && lat[a.typeID] && lat[a.typeID].name); return nm ? (a.text_value ? `${nm}: ${a.text_value}` : nm) : null; }).filter(Boolean); return parts.length ? `${base} (${parts.join(', ')})` : base; }; const consLabel = r => { const ent = r.target_type === 'url' ? (r.target.name || '').replace(/^https?:\/\/(www\.)?/, '').replace(/\/+$/, '') : (r.target.name || '?'); const credit = (r.entity0_credit && r.entity0_credit !== r.target.name && r.entity0_credit) || (r.entity1_credit && r.entity1_credit !== r.target.name && r.entity1_credit) || ''; return { role: consRole(r), ent, credit }; }; const consExcluded = (row, rel) => formatExcludeRolesFor(rel.fmt).some(k => row.label.role.toLowerCase().includes(k)); // ── edit_type:90 relationship-create payload for adding `r` onto release `relGid` ── function consAttrs(r) { return (r.attributes || []).map(a => { const gid = (a.type && a.type.gid) || ((W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type || {})[a.typeID] || {}).gid; if (!gid) return null; const o = { type: { gid } }; if (a.credited_as) o.credited_as = a.credited_as; if (a.text_value) o.text_value = a.text_value; return o; }).filter(Boolean); } function consEdit(r, relGid) { const lt = ((W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type) || {})[r.linkTypeID] || {}; const relEnd = { entityType: 'release', gid: relGid }, targetEnd = { entityType: r.target_type, gid: r.target.gid }; // entities must match the link type's type0/type1 order; release→url has release as entity0 const relIsE0 = lt.type0 === 'release' || r.target_type === 'url'; const e = { edit_type: 90, linkTypeID: r.linkTypeID, entities: relIsE0 ? [relEnd, targetEnd] : [targetEnd, relEnd], attributes: consAttrs(r) }; if (r.entity0_credit) e.entity0_credit = r.entity0_credit; if (r.entity1_credit) e.entity1_credit = r.entity1_credit; if (r.begin_date) e.begin_date = r.begin_date; if (r.end_date) e.end_date = r.end_date; if (r.ended) e.ended = true; return e; } let consEl = null; function onConsKey(e) { if (e.key === 'Escape') { e.stopPropagation(); closeConsolidate(); } } function closeConsolidate() { if (consEl) { consEl.remove(); consEl = null; document.removeEventListener('keydown', onConsKey, true); } } // ══ Date picker (#398) ═════════════════════════════════════════════════════ // Redesign of "Set missing dates". Right-clicking a dated rel opens a popup listing every credit on the // SELECTED tracks (ticked recordings, or all when none are ticked) as a track→credits tree. The header // carries an EDITABLE date — seeded from the invoking rel, so the tool no longer depends on that rel's // own date — plus the list of ROLES that drive the DEFAULT tick (persisted across sessions: right-click a // credit's role to remember it, click a header chip to drop it). Any credit can also be ticked by hand. // Apply stamps the header date on every ticked, still-UNDATED rel (fill-only — MB's reducer keeps an // existing date, see the note on applyRelDate; already-dated rows are shown with their date, unchanged). const DATE_ROLES_DEFAULT = ['instrument', 'recording', 'recorded at', 'vocals', 'performer']; let gtDateRoles = (() => { try { const raw = GM_getValue('gt-date-roles', ''); if (raw) { const a = typeof raw === 'string' ? JSON.parse(raw) : raw; if (Array.isArray(a)) return a.map(String); } } catch (e) {} return DATE_ROLES_DEFAULT.slice(); })(); const saveDateRoles = () => { try { GM_setValue('gt-date-roles', JSON.stringify(gtDateRoles)); } catch (e) {} }; // null = empty (clear), undefined = unparsable (keep prior); accepts YYYY, YYYY-MM, YYYY-MM-DD (/ or . too) const dpParseDate = s => { s = (s || '').trim(); if (!s) return null; const m = s.match(/^(\d{1,4})(?:[-/.](\d{1,2}))?(?:[-/.](\d{1,2}))?$/); if (!m) return undefined; const d = { year: +m[1] || null, month: m[2] ? +m[2] : null, day: m[3] ? +m[3] : null }; return (d.year || d.month || d.day) ? d : null; }; const dpDateStr = d => { if (!d) return ''; const p = (n, w) => n == null ? '' : String(n).padStart(w, '0'); return [p(d.year, 4), d.month != null ? p(d.month, 2) : '', d.day != null ? p(d.day, 2) : ''].filter(Boolean).join('-'); }; const dpDated = r => !!((r.begin_date && r.begin_date.year) || (r.end_date && r.end_date.year) || r.ended); // a credit is a default match when any remembered role token matches its base link-type name OR its full // rendered label (so "instrument" catches "drums (drum set)" via the base, "vocals" catches "background // vocals" via the label; x.includes(base) makes the stored token tolerant of plurals like vocal↔vocals) const dpRoleMatches = r => { const base = ltName(r.linkTypeID).toLowerCase(); const full = (roleLabelOf(r) || '').toLowerCase(); return gtDateRoles.some(x => { x = String(x).toLowerCase(); return !!x && (base === x || full === x || full.includes(x) || x.includes(base)); }); }; const dpRows = () => { const all = [...document.querySelectorAll('tr.track')]; const t = all.filter(tr => { const cb = tr.querySelector('input.recording'); return cb && cb.checked; }); return t.length ? t : all; }; let dpEl = null; function onDpKey(e) { if (e.key === 'Escape') { e.stopPropagation(); closeDatePicker(); } } function closeDatePicker() { if (dpEl) { dpEl.remove(); dpEl = null; document.removeEventListener('keydown', onDpKey, true); } } function openDatePicker(clickedRel) { closeDatePicker(); closeConsolidate(); closePopover(); const state = { begin_date: clickedRel && clickedRel.begin_date ? { ...clickedRel.begin_date } : null, end_date: clickedRel && clickedRel.end_date ? { ...clickedRel.end_date } : null, ended: !!(clickedRel && clickedRel.ended), }; const skipItem = clickedRel && clickedRel.item; const manual = new Map(); // credit id → bool: user override of the role-driven default tick let uid = 0; const tracks = dpRows().map(tr => { const credits = recordingRels(tr) // #491: exclude relationship types that don't support dates at all (e.g. recording-event // "recorded at" — the Event carries its own date) so this tool can't offer, pre-select, or // stamp a date onto something MB has no date field for in the first place. .filter(r => !r.removed && r.other && r.other.entityType !== 'url' && r.item !== skipItem && ltHasDates(r.linkTypeID)) .map(r => ({ id: ++uid, rel: r, tr, dated: dpDated(r), name: val(r.other.name) || '?', credit: r.credit && r.credit !== val(r.other.name) ? r.credit : '' })); return { tr, pos: posLabel(tr) || '?', title: val((recordingEntity(tr) || {}).name) || '', credits }; }).filter(t => t.credits.length); const allCredits = tracks.flatMap(t => t.credits); const isChecked = c => manual.has(c.id) ? manual.get(c.id) : (dpRoleMatches(c.rel) && !c.dated); const chosen = () => allCredits.filter(isChecked); dpEl = el('div', 'gt-cons-ov'); const panel = el('div', 'gt-cons gt-dp'), hdr = el('div', 'gt-cons-hdr'); hdr.appendChild(el('span', 'gt-cons-title', 'Set relationship dates')); const xb = el('button', 'gt-cons-x', '✕'); xb.type = 'button'; xb.onclick = closeDatePicker; hdr.appendChild(xb); const ctrl = el('div', 'gt-dp-ctrl'), dateLine = el('div', 'gt-dp-line'); dateLine.appendChild(el('span', 'gt-dp-lbl', 'Date')); const bIn = el('input', 'gt-dp-date'); bIn.type = 'text'; bIn.placeholder = 'YYYY-MM-DD'; bIn.value = dpDateStr(state.begin_date); const eIn = el('input', 'gt-dp-date'); eIn.type = 'text'; eIn.placeholder = 'YYYY-MM-DD'; eIn.value = dpDateStr(state.end_date); const endedL = el('label', 'gt-dp-ended'); const endedCb = el('input'); endedCb.type = 'checkbox'; endedCb.checked = state.ended; endedL.append(endedCb, el('span', null, 'ended')); const commitDates = () => { const b = dpParseDate(bIn.value), e2 = dpParseDate(eIn.value); bIn.classList.toggle('gt-dp-bad', b === undefined); eIn.classList.toggle('gt-dp-bad', e2 === undefined); if (b !== undefined) state.begin_date = b; if (e2 !== undefined) state.end_date = e2; state.ended = endedCb.checked; }; bIn.oninput = eIn.oninput = commitDates; endedCb.onchange = commitDates; dateLine.append(bIn, el('span', 'gt-dp-dash', '—'), eIn, endedL); const rolesLine = el('div', 'gt-dp-line gt-dp-roles'); ctrl.append(dateLine, rolesLine); const body = el('div', 'gt-cons-body gt-dp-body'), foot = el('div', 'gt-cons-foot'); const plan = el('div', 'gt-cons-plan'), applyBtn = el('button', 'gt-cons-btn gt-cons-apply', 'Apply'); applyBtn.type = 'button'; foot.append(plan, applyBtn); const refreshCounts = () => { const n = chosen().length; plan.textContent = n ? `${n} relationship${n > 1 ? 's' : ''} selected` : 'nothing selected'; applyBtn.disabled = !n; }; const setTrackState = (t, tcb) => { const on = t.credits.filter(isChecked).length; tcb.checked = on === t.credits.length && on > 0; tcb.indeterminate = on > 0 && on < t.credits.length; }; let renderBody; const renderRoles = () => { rolesLine.textContent = ''; rolesLine.appendChild(el('span', 'gt-dp-lbl', 'Roles')); if (!gtDateRoles.length) rolesLine.appendChild(el('span', 'gt-dp-hint', 'none — right-click a role below to add')); gtDateRoles.forEach(r => { const chip = el('span', 'gt-dp-chip', r); chip.title = 'click to remove'; chip.appendChild(el('span', 'gt-dp-chipx', '×')); chip.onclick = () => { gtDateRoles = gtDateRoles.filter(v => v !== r); saveDateRoles(); renderRoles(); renderBody(); }; rolesLine.appendChild(chip); }); }; const addRole = r => { r = String(r).toLowerCase().trim(); if (!r || gtDateRoles.includes(r)) return; gtDateRoles.push(r); saveDateRoles(); renderRoles(); renderBody(); }; renderBody = () => { body.textContent = ''; if (!tracks.length) { body.appendChild(el('div', 'gt-pop-note', 'No datable credits on the selected tracks')); refreshCounts(); return; } tracks.forEach(t => { const trow = el('div', 'gt-dp-trow'), tlab = el('label', 'gt-dp-tlab'), tcb = el('input'); tcb.type = 'checkbox'; tlab.append(tcb, el('span', 'gt-dp-tpos', t.pos), el('span', 'gt-dp-ttitle', t.title)); trow.appendChild(tlab); setTrackState(t, tcb); tcb.onchange = () => { const want = tcb.checked; t.credits.forEach(c => manual.set(c.id, want)); renderBody(); }; const clist = el('div', 'gt-dp-clist'); t.credits.forEach(c => { const row = el('label', 'gt-dp-crow' + (c.dated ? ' gt-dp-dated' : '')), cb = el('input'); cb.type = 'checkbox'; cb.checked = isChecked(c); cb.onchange = () => { manual.set(c.id, cb.checked); setTrackState(t, tcb); refreshCounts(); }; const role = el('span', 'gt-dp-role', roleLabelOf(c.rel) || ltName(c.rel.linkTypeID)); role.title = 'right-click: remember this role for the default selection'; role.addEventListener('contextmenu', ev => { ev.preventDefault(); ev.stopPropagation(); addRole(ltName(c.rel.linkTypeID)); }); row.append(cb, role, el('span', 'gt-dp-cname', c.name + (c.credit ? ` (${c.credit})` : ''))); if (c.dated) row.append(el('span', 'gt-dp-has', fmtRoDate(c.rel))); clist.appendChild(row); }); trow.appendChild(clist); body.appendChild(trow); }); refreshCounts(); }; applyBtn.onclick = () => { commitDates(); const re = RE(); if (!re) { toast('Editor not ready'); return; } if (!(state.begin_date || state.end_date || state.ended)) { toast('Enter a date first'); return; } const picked = chosen(), undated = picked.filter(c => !c.dated); if (!undated.length) { toast(picked.length ? 'All selected already have dates (unchanged)' : 'Nothing selected'); return; } // c.rel is recordingRels' STRIPPED view (item/linkTypeID/dates but NO entity0/entity1) — applyRelDate // needs the raw fiber rel to match MB's reducer, so re-resolve it from the live .relationship-item node. // (#398 fix: apply was a silent no-op because the stripped rel had no entities to route the update.) let n = 0, miss = 0; undated.forEach(c => { try { const real = c.rel.item && relFromNode(c.rel.item); if (real) { applyRelDate(re, real, state); n++; } else miss++; } catch (e) { miss++; console.warn('[Group Therapy] set-date failed:', e && e.message); } }); const dl = fmtRoDate(state) || 'date', skipped = picked.length - undated.length; console.debug(`[Group Therapy] Date picker: set ${dl} on ${n} rel(s); ${skipped} already dated, ${miss} unresolved`); if (n) markUsed(`Set date ${dl} on ${n} relationship${n > 1 ? 's' : ''}`); toast(`Set ${dl} on ${n} relationship${n > 1 ? 's' : ''}${skipped ? ` (${skipped} already dated)` : ''} — review & save`); closeDatePicker(); }; panel.append(hdr, ctrl, body, foot); dpEl.appendChild(panel); document.body.appendChild(dpEl); dpEl.addEventListener('mousedown', e => { if (e.target === dpEl) closeDatePicker(); }); document.addEventListener('keydown', onDpKey, true); renderRoles(); renderBody(); } async function applyConsolidation(releases, rows, refresh) { const byRel = new Map(); for (const row of rows) for (const rel of releases) if (row.propose.has(rel.gid) && !row.present.has(rel.gid)) { if (!byRel.has(rel.gid)) byRel.set(rel.gid, []); byRel.get(rel.gid).push(row); } const total = [...byRel.values()].reduce((s, a) => s + a.length, 0); if (!total) { toast('Nothing selected to add'); return; } toast(`Applying ${total} edit${total > 1 ? 's' : ''} across ${byRel.size} release${byRel.size > 1 ? 's' : ''}…`); const sig = editNoteSig(); let okRel = 0, okEdits = 0; const failed = []; for (const [gid, rowsFor] of byRel) { const rel = releases.find(r => r.gid === gid); const edits = rowsFor.map(row => consEdit(row.sample, gid)); // #737: no list of the credits: MB copies the note onto each relationship's edit, which shows its own const note = `Consolidated ${edits.length} release-level credit${edits.length > 1 ? 's' : ''} across the release group onto this release\n\n${sig}`; try { const res = await mbnFetch('/ws/js/edit/create', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ edits, editNote: note, makeVotable: 0 }) }); const txt = await res.text().catch(() => ''); let j = null; try { j = JSON.parse(txt); } catch (e) {} if (!res.ok || (j && j.error)) throw new Error((((j && (j.error.message || j.error)) || ('HTTP ' + res.status)) + '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160)); okRel++; okEdits += edits.length; // reflect success: drop the proposal and cache the new rel on this release so the render's rebuild shows it present rowsFor.forEach(row => { row.propose.delete(gid); if (rel && rel._rels) rel._rels.push(row.sample); }); } catch (e) { failed.push(`${rel ? rel.letter : gid}: ${(e && e.message) || e}`); } refresh && refresh(); await sleep(1200); // throttle between releases } if (failed.length) toast(`Added ${okEdits} across ${okRel} release(s); ${failed.length} failed — ${failed[0]}`); else toast(`✓ Added ${okEdits} credit${okEdits > 1 ? 's' : ''} across ${okRel} release${okRel > 1 ? 's' : ''} — check your edits`); return okEdits; } // The legend doubles as the release selector: columns follow which releases are ticked. Rels are fetched // lazily (only for selected releases) and cached; rows are rebuilt from the selected set on every render, // while each row's `propose` set persists in `rowsByKey`. function renderConsMatrix(ctx) { const { body, foot, releases, rowsByKey, recompute } = ctx; body.textContent = ''; body.appendChild(el('div', 'gt-cons-leglabel', 'Releases in this group — click to add/remove a column')); const leg = el('div', 'gt-cons-leg'); releases.forEach(r => { const s = el('span', 'gt-cons-legi gt-cons-selitem' + (r.selected ? ' gt-on' : '') + (r.current ? ' gt-cur' : '')); s.appendChild(el('b', null, r.letter)); s.appendChild(fmtBadges(r.fmt)); s.appendChild(document.createTextNode(' ' + r.title)); if (r.year) s.appendChild(el('span', 'gt-cons-legyr', ' · ' + r.year)); const open = el('a', 'gt-cons-legopen', '↗'); open.href = '/release/' + r.gid; open.target = '_blank'; open.rel = 'noopener'; open.title = 'Open this release in a new tab'; open.onclick = ev => ev.stopPropagation(); s.appendChild(open); s.title = (r.selected ? 'In the matrix — click to remove its column' : 'Click to add its column') + (r.current ? ' · the release you’re editing' : ''); s.onclick = () => { r.selected = !r.selected; recompute(); }; leg.appendChild(s); }); body.appendChild(leg); const paste = el('input', 'gt-cons-paste'); paste.type = 'text'; paste.placeholder = 'paste release URLs / MBIDs to add them to the matrix…'; paste.addEventListener('paste', () => setTimeout(() => { const gids = (paste.value.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi) || []); let n = 0; gids.forEach(g => { const rel = releases.find(r => r.gid.toLowerCase() === g.toLowerCase()); if (rel && !rel.selected) { rel.selected = true; n++; } }); paste.value = ''; if (n) recompute(); else toast('No matching releases in this group'); }, 0)); body.appendChild(paste); // #624: releases whose credits could not be read — said so, with a Retry; never shown as "no credits" const unread = releases.filter(r => r.selected && r._relsErr); if (unread.length) { const f = el('div', 'gt-cons-fail', `⚠ Could not read ${unread.map(r => r.letter).join(', ')} — ${unread[0]._relsErr}. Shown as “?” and left out of Auto select / Apply.`); const again = el('button', 'gt-cons-btn gt-cons-retry', 'Retry'); again.type = 'button'; again.onclick = () => recompute(); f.appendChild(again); body.appendChild(f); } const cols = releases.filter(r => r.selected); // rebuild rows from the selected releases: present is recomputed, propose persists on the row objects for (const row of rowsByKey.values()) row.present = new Set(); for (const rel of cols) for (const r of (rel._rels || [])) { const k = consKey(r); let row = rowsByKey.get(k); if (!row) { row = { key: k, sample: r, label: consLabel(r), present: new Set(), propose: new Set() }; rowsByKey.set(k, row); } row.present.add(rel.gid); } const rows = [...rowsByKey.values()].filter(row => row.present.size > 0).sort((a, b) => (a.label.role + a.label.ent).localeCompare(b.label.role + b.label.ent)); foot.textContent = ''; if (!cols.length) { body.appendChild(el('div', 'gt-pop-note', 'Select one or more releases above to build the matrix.')); return; } if (!rows.length) { body.appendChild(el('div', 'gt-pop-note', unread.length && unread.length === cols.length ? 'None of the selected releases could be read — see above.' : 'No release-level credits on the selected release(s).')); return; } const tbl = el('table', 'gt-cons-tbl'), head = el('tr'); head.append(el('th', 'gt-cons-role', 'Role'), el('th', 'gt-cons-ent', 'Entity')); const addableFor = rel => rel._relsErr ? [] : rows.filter(row => !row.present.has(rel.gid) && !consExcluded(row, rel)); // not present + not format-specific; nothing on an unread release (#624) const planLbl = el('span', 'gt-cons-plan'); const applyBtn = el('button', 'gt-cons-btn gt-cons-apply', 'Apply'); applyBtn.type = 'button'; const updatePlan = () => { let e = 0; const rs = new Set(); rows.forEach(row => cols.forEach(rel => { if (row.propose.has(rel.gid) && !row.present.has(rel.gid)) { e++; rs.add(rel.gid); } })); planLbl.textContent = e ? `${e} addition${e > 1 ? 's' : ''} across ${rs.size} release${rs.size > 1 ? 's' : ''}` : 'nothing selected'; applyBtn.disabled = !e; }; const draw = () => { [...tbl.querySelectorAll('tr.gt-cons-row')].forEach(n => n.remove()); for (const row of rows) { const tr = el('tr', 'gt-cons-row'); tr.appendChild(el('td', 'gt-cons-role', row.label.role)); const ent = el('td', 'gt-cons-ent'); ent.appendChild(document.createTextNode(row.label.ent)); if (row.label.credit) ent.appendChild(el('span', 'gt-cons-cr', ' “' + row.label.credit + '”')); tr.appendChild(ent); for (const rel of cols) { if (rel._relsErr) { // #624 unread → unknown, not "missing" const tf = el('td', 'gt-cons-cell gt-fail', '?'); tf.title = `Couldn’t read this release’s credits (${rel._relsErr}) — left out; use Retry above`; tr.appendChild(tf); continue; } const td = el('td', 'gt-cons-cell'), has = row.present.has(rel.gid), prop = row.propose.has(rel.gid); td.classList.add(has ? 'gt-has' : prop ? 'gt-prop' : 'gt-none'); td.textContent = has || prop ? rel.letter : '·'; if (has) td.title = 'already present'; else { td.style.cursor = 'pointer'; td.title = prop ? 'will be added — click to skip' : `skipped (format-specific for ${rel.fmt || '?'}) — click to add`; td.onclick = () => { row.propose.has(rel.gid) ? row.propose.delete(rel.gid) : row.propose.add(rel.gid); draw(); updatePlan(); }; } tr.appendChild(td); } tbl.appendChild(tr); } }; cols.forEach(r => { const th = el('th', 'gt-cons-col gt-cons-colsel' + (r._relsErr ? ' gt-fail' : '')); th.appendChild(el('div', 'gt-cons-coll', r.letter)); th.appendChild(fmtBadges(r.fmt)); th.title = r._relsErr ? `${r.title} — couldn’t read its credits (${r._relsErr}); use Retry above` : `${r.title} — click to select / clear every addable credit for this release (skips format-specific)`; th.onclick = () => { const p = addableFor(r); const all = p.length && p.every(row => row.propose.has(r.gid)); p.forEach(row => all ? row.propose.delete(r.gid) : row.propose.add(r.gid)); draw(); updatePlan(); }; head.appendChild(th); }); tbl.appendChild(head); body.appendChild(tbl); const autoBtn = el('button', 'gt-cons-btn', 'Auto select'); autoBtn.type = 'button'; autoBtn.title = 'Select every addable credit across the selected releases, except format-specific roles'; const clearBtn = el('button', 'gt-cons-btn', 'Clear'); clearBtn.type = 'button'; clearBtn.title = 'Deselect every proposed credit'; autoBtn.onclick = () => { cols.forEach(rel => addableFor(rel).forEach(row => row.propose.add(rel.gid))); draw(); updatePlan(); }; clearBtn.onclick = () => { rows.forEach(row => row.propose.clear()); draw(); updatePlan(); }; applyBtn.onclick = async () => { const n = await applyConsolidation(cols, rows, () => renderConsMatrix(ctx)); if (n) closeConsolidate(); }; // close on success (no auto-scroll — leave the page where it was) foot.append(autoBtn, clearBtn, planLbl, applyBtn); draw(); updatePlan(); } async function openConsolidate() { closeConsolidate(); closePopover(); consEl = el('div', 'gt-cons-ov'); const panel = el('div', 'gt-cons'), hdr = el('div', 'gt-cons-hdr'); hdr.appendChild(el('span', 'gt-cons-title', 'Consolidate release-level credits across the group')); const x = el('button', 'gt-cons-x', '✕'); x.type = 'button'; x.onclick = closeConsolidate; hdr.appendChild(x); const body = el('div', 'gt-cons-body'), foot = el('div', 'gt-cons-foot'); body.appendChild(el('div', 'gt-pop-note', 'Loading release group…')); panel.append(hdr, body, foot); consEl.appendChild(panel); document.body.appendChild(consEl); document.addEventListener('keydown', onConsKey, true); consEl.addEventListener('mousedown', e => { if (e.target === consEl) closeConsolidate(); }); const note = m => { const n = body.querySelector('.gt-pop-note'); if (n) n.textContent = m; }; // #624: a failure says so and offers another go — it must never read as "there is nothing" const noteFail = m => { const n = body.querySelector('.gt-pop-note'); if (!n) return; n.textContent = m + ' '; const again = el('button', 'gt-cons-btn gt-cons-retry', 'Try again'); again.type = 'button'; again.onclick = () => openConsolidate(); n.appendChild(again); }; let releases; try { const ent = (RE() && RE().state && RE().state.entity) || {}; const here = ent.gid; // #624: the release group is already on the page — the relationship editor's state carries it, // and the header links it ("see all versions of this release"). No request, so no rate limit // can lose it. The WS2 lookup is only the last resort. let rgid = (ent.releaseGroup && ent.releaseGroup.gid) || null, rgFrom = rgid ? 'editor state' : ''; if (!rgid) { rgid = [...document.querySelectorAll('a[href^="/release-group/"]')].map(a => ((a.getAttribute('href') || '').match(/^\/release-group\/([0-9a-f-]{36})/i) || [])[1]).find(Boolean) || null; if (rgid) rgFrom = 'page link'; } if (!rgid && here) { const rg = await consWs2('/ws/2/release/' + here + '?inc=release-groups&fmt=json'); rgid = (rg['release-group'] && rg['release-group'].id) || null; if (rgid) rgFrom = 'WS2'; } console.debug('[Group Therapy] consolidate: release', here, '→ release group', rgid, rgFrom ? '(from ' + rgFrom + ')' : ''); if (!rgid) return note('This release has no release group'); // enumerate every release (WS2 caps at 100/page — paginate). A page that still fails after the // retries aborts the whole list: a partial group would look complete and propose the wrong things. const all = []; let offset = 0, total = Infinity; while (offset < total) { note('Loading the releases in this group…' + (Number.isFinite(total) ? ` ${all.length}/${total}` : '')); const sib = await consWs2(`/ws/2/release?release-group=${rgid}&inc=media&limit=100&offset=${offset}&fmt=json`); total = sib['release-count'] != null ? sib['release-count'] : (all.length + (sib.releases || []).length); all.push(...(sib.releases || [])); if (!(sib.releases || []).length) break; offset += 100; } console.debug('[Group Therapy] consolidate:', all.length, 'release(s) in the group'); releases = all.sort((a, b) => (a.date || '~').localeCompare(b.date || '~')).map((r, i) => ({ gid: r.id, title: r.title + (r.disambiguation ? ` (${r.disambiguation})` : ''), letter: (i < 26 ? '' : String.fromCharCode(64 + Math.floor(i / 26))) + String.fromCharCode(65 + (i % 26)), fmt: [...new Set((r.media || []).map(m => m.format).filter(Boolean))].join('+') || '', year: (r.date || '').slice(0, 4), current: r.id === here, selected: false, _rels: null, })); } catch (e) { console.warn('[Group Therapy] consolidate: loading the release group failed —', e && e.message); return noteFail(`Could not load the release group — ${(e && e.message) || 'request failed'}.`); } if (!releases || releases.length < 2) return note('Need at least 2 releases in the group to consolidate'); // auto-include all when the group is small; otherwise start with just the release we're editing (the // user picks the rest) — a 100+-release group would be an unusable wall of columns otherwise. const AUTO_MAX = 10; releases.forEach(r => r.selected = releases.length <= AUTO_MAX || r.current); const rowsByKey = new Map(); const ctx = { body, foot, releases, rowsByKey, recompute: null }; ctx.recompute = async () => { const need = releases.filter(r => r.selected && !r._rels); // #624: a release whose credits could not be read stays UNREAD (_rels null + _relsErr), shown as // "?" and left out of Auto select / Apply — not stored as "no credits", which made every credit // look missing on it and proposed adding all of them. Each recompute retries the unread ones. if (need.length) { let d = 0; note(`Reading releases… 0/${need.length}`); await throttledMap(need, async r => { try { r._rels = await consFetchRels(r.gid); r._relsErr = null; } catch (e) { r._rels = null; r._relsErr = (e && e.message) || 'request failed'; console.warn('[Group Therapy] consolidate: could not read release', r.gid, '—', r._relsErr); } note(`Reading releases… ${++d}/${need.length}`); }); } renderConsMatrix(ctx); }; await ctx.recompute(); } // ══ Work matching (#363) ═══════════════════════════════════════════════════ // Match each recording to an EXISTING MB work and stage a recording→work "performance" rel. // Disambiguation is the whole problem — a bare standard title ("St. Louis Blues") matches many works. // Two signals: (1) works of ISRC-sharing recordings — an /isrc LOOKUP returns work-rels (a /recording // SEARCH does not), the strongest when ISRCs exist; and (2) a WORK title search ranked by MB's own // score — the canonical work (bare title) scores ~100 while arrangements trail and are disambiguated, // so the top score + the gap to the runner-up say whether to auto-tick or leave it for a manual pick. const PERF_GID = 'a3005666-a872-32c3-ad06-98af558e99b0'; // recording→work "performance" link type // colours mirror Apollo's recording matcher (CONF_COLOR) const WM_LEVEL = { exact:{ c:'#2f6fd6', t:'ISRC-confirmed' }, tolerance:{ c:'#86c686', t:'the only work with this title' }, near:{ c:'#fff176', t:'dominant — most-recorded work' }, low:{ c:'#e5534b', t:'ambiguous — often wrong, check it' }, none:{ c:'#9aa0a6', t:'no work found' } }; const WM_RANK = { exact:0, tolerance:1, near:2, low:3, none:4 }; const WM_LVL_BY_RANK = ['exact', 'tolerance', 'near', 'low']; // how far ⚡ Match / the initial pre-tick reaches down the confidence ladder (persisted) let wmCutoff = (() => { try { const v = GM_getValue('gt-wm-cutoff', WM_RANK.near); return typeof v === 'number' ? v : WM_RANK.near; } catch (e) { return WM_RANK.near; } })(); // #363 whether opening the matcher auto-runs the (API-heavy) match. Default OFF — you click ⚡ Match yourself. let wmAutoOnOpen = (() => { try { return !!GM_getValue('gt-wm-auto-open', false); } catch (e) { return false; } })(); // #718 a medley track ("Land of Hope and Dreams / People Get Ready") gets a row per work. Only " / " with // spaces splits — MB's title guideline for medleys — so "AC/DC" stays whole. On by default, toggled in // the matcher's toolbar (persisted). let wmSplitMedley = (() => { try { return GM_getValue('gt-wm-split', true) !== false; } catch (e) { return true; } })(); const WM_MEDLEY_RE = /\s+\/\s+/; const wmMedleyParts = t => { const p = (t || '').split(WM_MEDLEY_RE).map(x => x.trim()).filter(Boolean); return p.length > 1 ? p : null; }; // #372 page options (persisted): hide MB's edit-relationships help text (on by default), and auto-open + // run the work matcher on page load (off by default). let gtHideHelp = (() => { try { return GM_getValue('gt-hide-help', true) !== false; } catch (e) { return true; } })(); let gtAutoMatch = (() => { try { return GM_getValue('gt-auto-match', false) === true; } catch (e) { return false; } })(); let gtHideBatch = (() => { try { return GM_getValue('gt-hide-batch', false) === true; } catch (e) { return false; } })(); // hide MB's own batch-tools table (off by default) let gtUncollapse = (() => { try { return GM_getValue('gt-uncollapse', false) === true; } catch (e) { return false; } })(); // #390 auto-expand all mediums on load (off by default) // the two help paragraphs are the only direct-child <p> of #content (the batch-tools hint + the guidelines // link) — a stable selector even after we insert our toolbar, since that's a <div>. const gtApplyHelp = () => { document.querySelectorAll('#content > p').forEach(p => { p.style.display = gtHideHelp ? 'none' : ''; }); }; const gtApplyBatch = () => { const t = document.getElementById('batch-tools'); if (t) t.style.display = gtHideBatch ? 'none' : ''; }; // #390 MB collapses mediums past the first few, so their tracks aren't reachable during the fill phase. // Click MB's own "Expand all mediums" once on load (it then loads each collapsed medium). Guarded so a // re-inject doesn't click it repeatedly. let _gtUncollapsed = false; const gtApplyUncollapse = () => { if (!gtUncollapse || _gtUncollapsed) return; const b = document.getElementById('expand-all-mediums'); if (b) { _gtUncollapsed = true; try { b.click(); } catch (e) {} gtLoadingBanner(); } }; // #390 a bottom banner while the expanded mediums load (MB renders one .loading-message per medium). // Only shown once loading is actually happening — a release whose mediums are all already loaded // (nothing to lazy-load) must not flash the banner, so it's created lazily on the first .loading-message. function gtLoadingBanner() { let banner = null, ticks = 0, seen = false; const show = n => { if (!banner) { banner = document.createElement('div'); banner.style.cssText = 'position:fixed;left:50%;bottom:14px;transform:translateX(-50%);z-index:2147483646;background:#2c3a33;color:#fff;padding:7px 14px;border-radius:8px;font:13px -apple-system,Segoe UI,Arial,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.3)'; document.body.appendChild(banner); } banner.textContent = 'Loading media… (' + n + ' left)'; }; const poll = () => { ticks++; const n = document.querySelectorAll('.loading-message').length; if (n > 0) { seen = true; show(n); setTimeout(poll, 400); return; } if (!seen && ticks < 12) { setTimeout(poll, 400); return; } // grace: wait up to ~5s for loading to begin — banner not shown yet if (banner) banner.remove(); // nothing ever loaded, or it finished → no/removed banner }; setTimeout(poll, 300); } // writer/composer relationship types — used to pull authors from a pasted work MBID (the autocomplete // already carries authors inline for searched works) const WM_WRITER_RE = /composer|writer|lyricist|librettist|translat|revis|arrang|orchestrat/i; // create a synthetic NEW work (negative id, no gid) — MB's submit creates it like a natively-added work // (verified: the reducer accepts it and renders a pending new-work rel). Same-title new works within a // session share one entity, so two unmatched same-title tracks don't spawn duplicate works. let wmNewSeq = -1000000; const wmNewWorks = new Map(); // #363 optional params applied to every new work created this session — Type + lyrics language(s), like // MB's own "Batch-add new works" dialog. Both catalogues live on the page (MB.linkedEntities), so there's // no pagination/fetch: cache them once for the searchable combos. Choices are NOT persisted (per maintainer). let _wmTypesCache = null, _wmLangsCache = null; const wmWorkTypes = () => (_wmTypesCache || (_wmTypesCache = Object.values((W.MB.linkedEntities && W.MB.linkedEntities.work_type) || {}).slice().sort((a, b) => a.name.localeCompare(b.name)))); const wmLanguages = () => (_wmLangsCache || (_wmLangsCache = Object.values((W.MB.linkedEntities && W.MB.linkedEntities.language) || {}).filter(l => l.frequency > 0 || l.name).sort((a, b) => (b.frequency - a.frequency) || a.name.localeCompare(b.name)))); let wmNewType = null; // work-type id (number) or null // #363 default the work type to "Song" once the work_type catalogue is available (it isn't at script-init). // Runs once; a later user pick sticks (the flag stays set so we never override it). let _wmTypeInit = false; const wmDefaultType = () => { if (_wmTypeInit) return; const s = wmWorkTypes().find(t => /^song$/i.test(t.name || '')); if (s) { wmNewType = s.id; _wmTypeInit = true; } }; let wmNewLangs = []; // array of MB language objects const wmLangRels = () => wmNewLangs.map(l => ({ language: l, last_updated: null })); // MB's work.languages shape // #363 attributes + dates that go on the recording→work "recording of" RELATIONSHIP (not the work) — // acappella/cover/demo/instrumental/karaoke/live/medley/partial + begin/end date + ended. Eligible // attributes are read from the performance link type itself, so we track whatever MB currently allows. const wmRelAttrs = new Set(); // selected attribute typeIDs let wmBegin = { year: null, month: null, day: null }, wmEnd = { year: null, month: null, day: null }, wmEnded = false; let _wmPerfAttrsCache = null; const wmPerfAttrs = () => (_wmPerfAttrsCache || (_wmPerfAttrsCache = (() => { const le = W.MB.linkedEntities, lat = le && le.link_attribute_type, perf = le && Object.values(le.link_type || {}).find(t => t.gid === PERF_GID); if (!perf || !perf.attributes || !lat) return []; return Object.keys(perf.attributes).map(k => { const id = perf.attributes[k].type_id || +k; return { typeID: id, name: lat[id] && lat[id].name }; }).filter(a => a.name).sort((a, b) => a.name.localeCompare(b.name)); })())); const wmHasDates = () => !!(wmBegin.year || wmBegin.month || wmBegin.day || wmEnd.year || wmEnd.month || wmEnd.day || wmEnded); // the attribute tree + dates object to hand dispatchRelationship for a recording→work rel const wmRelExtras = () => ({ attrs: wmRelAttrs.size ? buildAttrTree([...wmRelAttrs].map(id => ({ typeID: id }))) : null, dates: wmHasDates() ? { begin_date: wmBegin, end_date: wmEnd, ended: wmEnded } : null, }); // push the current Type/language choice onto every new work already staged (params can change after some // were created — MB reads these off the entity on submit) function wmApplyNewParams() { wmNewWorks.forEach(w => { w.typeID = wmNewType; w.languages = wmLangRels(); }); } function wmMakeNewWork(title) { const key = (title || '').normalize('NFC').toLowerCase().replace(/\s+/g, ' ').trim(); if (key && wmNewWorks.has(key)) return wmNewWorks.get(key); // Mirror EXACTLY the entity MB's own "Batch-add new works" produces (captured from the reducer): a // negative id + empty-string gid, every field the editor reads present with an empty default, and the // `_fromBatchCreateWorksDialog` flag. That flag is what marks it as a to-be-created work — without it MB // rejected the target ("must select … target entity") and threw loading its relationships ("e is null"). // typeID + languages carry the optional #363 params. MB creates the work for real on submit. (#363) const w = { entityType: 'work', id: wmNewSeq--, gid: '', name: title || '[untitled]', comment: '', typeID: wmNewType, languages: wmLangRels(), iswcs: [], attributes: [], artists: [], other_artists: [], authors: [], editsPending: false, last_updated: null, _fromBatchCreateWorksDialog: true, _gtNew: true, }; if (key) wmNewWorks.set(key, w); return w; } // #363 the "New work options" controls in the matcher toolbar — a Type <select> and a searchable, // multi-select Lyrics-language combo (common languages first). Both catalogues are already on the page. function wmNewParamsUi() { wmDefaultType(); // #363 default to "Song" (once the catalogue exists) const wrap = el('div', 'gt-wm-nwp'); const typeSel = el('select', 'gt-wm-nwp-type'); typeSel.title = 'Work type applied to every new work'; typeSel.appendChild(new Option('— type —', '')); wmWorkTypes().forEach(t => { const o = new Option(t.name, String(t.id)); if (t.id === wmNewType) o.selected = true; typeSel.appendChild(o); }); typeSel.onchange = () => { wmNewType = typeSel.value ? +typeSel.value : null; wmApplyNewParams(); }; wrap.appendChild(typeSel); const lc = el('div', 'gt-wm-nwp-lang'); lc.title = 'Lyrics language(s) applied to every new work'; const chips = el('span', 'gt-wm-nwp-chips'), inp = el('input', 'gt-wm-nwp-inp'); inp.placeholder = 'lyrics language…'; inp.spellcheck = false; let drop = null; const onDown = e => { if (!lc.contains(e.target)) closeDrop(); }; function closeDrop() { if (drop) { drop.remove(); drop = null; document.removeEventListener('mousedown', onDown, true); } } function renderChips() { chips.textContent = ''; wmNewLangs.forEach(l => { const c = el('span', 'gt-wm-nwp-chip', l.name); const x = el('span', 'gt-wm-nwp-x', '×'); x.title = 'remove'; x.onclick = () => { wmNewLangs = wmNewLangs.filter(o => o !== l); wmApplyNewParams(); renderChips(); }; c.appendChild(x); chips.appendChild(c); }); } function showDrop() { closeDrop(); const q = inp.value.trim().toLowerCase(), picked = new Set(wmNewLangs.map(l => l.id)); const list = wmLanguages().filter(l => !picked.has(l.id) && (!q || l.name.toLowerCase().includes(q))); if (!list.length) return; drop = el('div', 'gt-wm-nwp-drop'); list.slice(0, 50).forEach(l => { const it = el('div', 'gt-wm-nwp-opt', l.name); it.onmousedown = e => { e.preventDefault(); wmNewLangs.push(l); wmApplyNewParams(); renderChips(); inp.value = ''; showDrop(); inp.focus(); }; drop.appendChild(it); }); lc.appendChild(drop); document.addEventListener('mousedown', onDown, true); } inp.oninput = showDrop; inp.onfocus = showDrop; inp.onkeydown = e => { if (e.key === 'Escape' && drop) { closeDrop(); e.stopPropagation(); } }; lc.append(chips, inp); wrap.appendChild(lc); renderChips(); const more = el('button', 'gt-wm-nwp-more', '⋯'); more.type = 'button'; more.title = 'recording-of relationship options — attributes (live, cover…) + dates'; more.onclick = () => wmRelOptsPopover(more); wrap.appendChild(more); // #432 (vzell): the ⋯ popover selections were invisible once it closed — surface them // as live chips right next to the button (attributes and "ended" removable via ×, the // date chip re-opens the popover), and highlight ⋯ while anything is set. const roSum = el('span', 'gt-wm-ro-sum'); const fmtD = d => [d.year, d.month, d.day].some(v => v != null) ? [d.year != null ? String(d.year) : '????', d.month != null ? String(d.month).padStart(2, '0') : (d.day != null ? '??' : null), d.day != null ? String(d.day).padStart(2, '0') : null].filter(Boolean).join('‑') : null; wmRoRefresh = () => { roSum.textContent = ''; const names = new Map(wmPerfAttrs().map(a => [a.typeID, a.name])); wmRelAttrs.forEach(id => { const c = el('span', 'gt-wm-nwp-chip', names.get(id) || ('#' + id)); const x = el('span', 'gt-wm-nwp-x', '×'); x.title = 'remove attribute'; x.onclick = () => { wmRelAttrs.delete(id); closeRoPop(); wmRoRefresh(); }; c.appendChild(x); roSum.appendChild(c); }); const b = fmtD(wmBegin), e2 = fmtD(wmEnd); if (b || e2) { const c = el('span', 'gt-wm-nwp-chip', (b || '…') + ' → ' + (e2 || '…')); c.title = 'recording-of dates — click to edit'; c.style.cursor = 'pointer'; c.onclick = () => { if (!wmRoPop) wmRelOptsPopover(more); }; roSum.appendChild(c); } if (wmEnded) { const c = el('span', 'gt-wm-nwp-chip', 'ended'); const x = el('span', 'gt-wm-nwp-x', '×'); x.title = 'remove'; x.onclick = () => { wmEnded = false; closeRoPop(); wmRoRefresh(); }; c.appendChild(x); roSum.appendChild(c); } more.classList.toggle('on', !!(wmRelAttrs.size || b || e2 || wmEnded)); }; wrap.appendChild(roSum); wmRoRefresh(); return wrap; } let wmRoRefresh = null; // #432 — repaints the rel-options chips; set by wmNewParamsUi // #363 the recording-of relationship options popover (opened by the ⋯ button): the performance // attributes as checkboxes + begin/end date + ended. These go on the recording→work rel, not the work. let wmRoPop = null; function closeRoPop() { if (wmRoPop) { wmRoPop.remove(); wmRoPop = null; document.removeEventListener('mousedown', wmRoDown, true); document.removeEventListener('keydown', wmRoKey, true); } } function wmRoDown(e) { if (wmRoPop && !wmRoPop.contains(e.target) && !e.target.classList.contains('gt-wm-nwp-more')) closeRoPop(); } function wmRoKey(e) { if (e.key === 'Escape' && wmRoPop) { e.stopPropagation(); closeRoPop(); } } function wmRelOptsPopover(anchor) { if (wmRoPop) { closeRoPop(); return; } const pop = el('div', 'gt-wm-relopts'); wmRoPop = pop; pop.appendChild(el('div', 'gt-wm-ro-hd', 'recording of')); wmPerfAttrs().forEach(a => { const lb = el('label', 'gt-wm-ro-cb'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = wmRelAttrs.has(a.typeID); cb.onchange = () => { if (cb.checked) wmRelAttrs.add(a.typeID); else wmRelAttrs.delete(a.typeID); if (wmRoRefresh) wmRoRefresh(); }; // #432 live chips lb.append(cb, el('span', null, a.name)); pop.appendChild(lb); }); const mkDate = (label, obj) => { const row = el('div', 'gt-wm-ro-date'); row.appendChild(el('span', 'gt-wm-ro-dl', label)); const mk = (ph, key, cls) => { const i = el('input', cls); i.type = 'text'; i.placeholder = ph; i.value = obj[key] || ''; i.oninput = () => { const v = parseInt(i.value, 10); obj[key] = Number.isFinite(v) ? v : null; if (wmRoRefresh) wmRoRefresh(); }; return i; }; // #432 live chips row.append(mk('YYYY', 'year', 'gt-wm-ro-y'), el('span', 'gt-wm-ro-sep', '‑'), mk('MM', 'month', 'gt-wm-ro-m'), el('span', 'gt-wm-ro-sep', '‑'), mk('DD', 'day', 'gt-wm-ro-d')); return row; }; pop.appendChild(mkDate('Begin date', wmBegin)); pop.appendChild(mkDate('End date', wmEnd)); const endedL = el('label', 'gt-wm-ro-cb'); const endedCb = document.createElement('input'); endedCb.type = 'checkbox'; endedCb.checked = wmEnded; endedCb.onchange = () => { wmEnded = endedCb.checked; if (wmRoRefresh) wmRoRefresh(); }; endedL.append(endedCb, el('span', null, 'This relationship has ended.')); pop.appendChild(endedL); // #432 live chips document.body.appendChild(pop); const r = anchor.getBoundingClientRect(); pop.style.left = Math.min(r.left, window.innerWidth - pop.offsetWidth - 8) + 'px'; pop.style.top = (r.bottom + 4) + 'px'; setTimeout(() => { document.addEventListener('mousedown', wmRoDown, true); document.addEventListener('keydown', wmRoKey, true); }, 0); } // /ws/2 is paced by the shared MusicBrainz request gate (#633, the ST-NET block at the top), one budget for every script and tab // on the origin. (It used to be spaced 380ms apart here alone, and a long tracklist still burst past // the limit and dropped its tail to 503s, which surfaced as false "no match".) const gtGateLog = (lv, m) => console[lv === 'warn' ? 'warn' : 'debug']('[Group Therapy] ' + m); let wmAbort = null, wmRunning = false; // #372 cancel aborts in-flight matching fetches; wmRunning drives the "matching…" row state let wmPrefetchP = null; // #363 the in-flight prefetch (fired, not awaited up front) — wmMatchOne folds it in per row let _wmPrefetchCache = null; // #363 { gid, at, data } — cached 1h so re-opening the matcher doesn't re-fetch the release async function wmJson(url) { const sig = wmAbort && wmAbort.signal; for (let i = 0; i < 5; i++) { if (sig && sig.aborted) return null; // cancelled — stop retrying / sleeping try { const gated = url.startsWith('/ws/2/'); // only the public API is rate-limited; /ws/js is the editor's own if (gated && !(await mbnSlot({ label: url, log: gtGateLog, cancelled: () => !!(sig && sig.aborted) })).ok) return null; const r = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' }, signal: sig }); if (gated && (await mbnAnswer(r.status, n => r.headers.get(n), { log: gtGateLog })).throttled && i < 4) continue; // the next slot waits out the shared hold if ((r.status === 429 || r.status === 503) && i < 4) { await sleep(900 * (i + 1)); continue; } if (!r.ok) return null; return await r.json(); } catch (e) { if ((sig && sig.aborted) || i === 4) return null; await sleep(500 * (i + 1)); } } return null; } // MB's internal work autocomplete — returns authors, artist-popularity (hits), type and disambiguation // inline, ranked by relevance, and it's the editor's own endpoint so it isn't on the /ws/2 rate limit. async function wmWorkSearch(term) { const j = await wmJson('/ws/js/work?q=' + encodeURIComponent(term) + '&direct=false&limit=10'); const arr = Array.isArray(j) ? j : (j && j.results) || []; // authors (writers) + artist popularity live under related_artists.{authors,artists} — the top-level // w.authors / w.artists are empty return arr.filter(w => w && w.gid).map(w => { const ra = w.related_artists || {}; return { gid: w.gid, id: w.id, title: w.name, disambiguation: w.comment || '', type: w.typeName || '', authors: (ra.authors && ra.authors.results) || [], artists: (ra.artists && ra.artists.results) || [], pop: (ra.artists && ra.artists.hits) || 0 }; }); } const wmNorm = s => (s || '').normalize('NFC').toLowerCase().replace(/[’‘']/g, "'").replace(/[‐‑‒–—―]/g, '-').replace(/…\s*/g, '…').replace(/\s+/g, ' ').trim(); function performanceLtId() { const lt = W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type; if (!lt) return null; for (const k in lt) if (lt[k] && lt[k].gid === PERF_GID) return lt[k].id != null ? lt[k].id : +k; return null; } // the release's recordings, from the rendered track rows (deduped); flag ones already work-linked on the page function wmRecordings() { const out = [], seen = new Set(); document.querySelectorAll('tr.track').forEach(tr => { const rec = recordingEntity(tr); if (!rec) return; const key = (rec.gid || '') + '|' + rec.id; if (seen.has(key)) return; seen.add(key); const hasWork = recordingRels(tr).some(r => !r.removed && r.other && r.other.entityType === 'work'); const title = val(rec.name) || '', pos = posLabel(tr) || ''; const parts = wmSplitMedley ? wmMedleyParts(title) : null; // #718 one row per medley part — same recording, the part as its title; Apply links each part's work if (parts) parts.forEach((t, i) => out.push({ tr, rec, pos, title: t, fullTitle: title, part: i + 1, parts: parts.length, hasWorkOnPage: hasWork })); else out.push({ tr, rec, pos, title, hasWorkOnPage: hasWork }); }); return out; } // The recording entities on the page are lean (no artist credit), so the performer can't come from the // fiber. One release lookup fills every row's artist up front — independent of the per-row matching — so // the left column is populated immediately instead of trickling in as each match lands. async function wmPrefetchArtists(rows, draw) { const re = RE(); if (!re || !re.state || !re.state.entity) return; const gid = re.state.entity.gid; // #363 ONE release lookup fills every row's artist + ISRCs + already-linked status, so wmMatchOne can // skip the per-recording /ws/2/recording detail call. Cached for 1h so re-opening the matcher (or a // quick close/reopen) doesn't re-fetch. It's fired in PARALLEL with matching now (not awaited up front): // artist is only a ranking hint, so the title search starts immediately and folds this in when ready. let byGid = (_wmPrefetchCache && _wmPrefetchCache.gid === gid && (Date.now() - _wmPrefetchCache.at) < 3600000) ? _wmPrefetchCache.data : null; if (!byGid) { const j = await wmJson('/ws/2/release/' + gid + '?inc=recordings+artist-credits+isrcs+recording-level-rels+work-rels&fmt=json'); if (!j) return; byGid = new Map(); (j.media || []).forEach(m => (m.tracks || []).forEach(t => { const r = t.recording; if (r && r.id) byGid.set(r.id, { artist: (r['artist-credit'] || []).map(c => (c.name || (c.artist && c.artist.name) || '') + (c.joinphrase || '')).join('').trim(), isrcs: r.isrcs || [], linked: (r.relations || []).some(rel => rel.work), }); })); _wmPrefetchCache = { gid, at: Date.now(), data: byGid }; } let any = false; rows.forEach(row => { const d = byGid.get(row.rec.gid); if (!d) return; if (!row.artist && d.artist) { row.artist = d.artist; any = true; } row._isrcs = d.isrcs; row._linked = d.linked; row._prefetched = true; }); if (any) draw(); } // gather candidate works for one recording: ISRC-sibling works + the internal work autocomplete async function wmMatchOne(row) { const rec = row.rec; // compare exactness against both the full title and the title minus a trailing parenthetical, so // "Take My Breath Away (love theme…)" counts as an exact match of the work "Take My Breath Away" const bare = row.title.replace(/\s*\([^)]*\)\s*$/, '').trim(); const tnorm = wmNorm(row.title), bnorm = wmNorm(bare); const isExact = t => { const n = wmNorm(t); return n === tnorm || (!!bnorm && n === bnorm); }; // #363 kick off the title work-search NOW — it hits the editor's UNGATED /ws/js and only needs the track // title (already known), so it isn't blocked on the prefetch (artist/ISRCs), which runs in parallel. A // DESCRIPTIVE trailing parenthetical isn't part of the work title → retry stripped when the full title // finds nothing (titles where it IS part of the work match on the full title and skip the fallback). const searchP = row.title ? (async () => { let ws = await wmWorkSearch(row.title); if (!ws.length && bare && bare !== row.title) ws = await wmWorkSearch(bare); return ws; })() : Promise.resolve([]); // fold in the prefetch (artist + ISRCs + already-linked): awaited PER ROW (a shared one-time cost), not // an up-front block. If there was no prefetch at all, fall back to a per-recording detail call. if (wmPrefetchP) { try { await wmPrefetchP; } catch (e) {} } let isrcs; if (row._prefetched) { if (row._linked) { row.linked = true; return; } isrcs = row._isrcs || []; } else { const self = await wmJson('/ws/2/recording/' + rec.gid + '?inc=artist-credits+isrcs+work-rels&fmt=json'); if (self && self['artist-credit']) row.artist = self['artist-credit'].map(c => (c.name || (c.artist && c.artist.name) || '') + (c.joinphrase || '')).join('').trim(); if (self && (self.relations || []).some(r => r.work)) { row.linked = true; return; } // already linked isrcs = (self && self.isrcs) || []; } // does the work's own writer/artist match the track's performer? A strong ranking signal — split the // performer credit into names so a duet matches regardless of order/join. (artist comes from the // prefetch, so it's only ready here — hence a ranking hint, never a gate on finding candidates.) const perfNames = wmNorm(row.artist || '').split(/\s*(?:&|,|;|\/|\bfeat\.?\b|\bfeaturing\b|\bwith\b|\bvs\.?\b|\band\b|\bx\b)\s*/).map(x => x.trim()).filter(x => x.length > 2); const nameHit = names => !!perfNames.length && (names || []).some(n => { const nn = wmNorm(n); return perfNames.some(p => nn.includes(p)); }); const cands = new Map(); // workGid → { gid, id, title, disambiguation, type, authors, artists, pop, isrc, exact, artistMatch } const add = (w, isIsrc) => { if (!w || !w.gid) return; let e = cands.get(w.gid); if (!e) { e = { gid: w.gid, id: w.id != null ? w.id : null, title: w.title, disambiguation: w.disambiguation || '', type: w.type || '', authors: w.authors || [], artists: w.artists || [], pop: w.pop || 0, isrc: 0, exact: isExact(w.title) }; e.artistMatch = nameHit(e.authors) || nameHit(e.artists); cands.set(w.gid, e); } if (isIsrc) e.isrc++; if (!e.authors.length && w.authors && w.authors.length) e.authors = w.authors; if (!e.artists.length && w.artists && w.artists.length) e.artists = w.artists; if (!e.artistMatch) e.artistMatch = nameHit(e.authors) || nameHit(e.artists); }; // ISRC-sharing recordings (the /isrc lookup returns work-rels, unlike a /recording search) for (const code of isrcs.slice(0, 3)) { const j = await wmJson('/ws/2/isrc/' + encodeURIComponent(code) + '?inc=work-rels&fmt=json'); // #718 an ISRC sibling of a medley carries every part's work — it confirms only the one titled like this part (j && j.recordings || []).forEach(r => (r.relations || []).forEach(rel => { if (rel.work) add({ gid: rel.work.id, id: null, title: rel.work.title, disambiguation: rel.work.disambiguation || '' }, !row.parts || isExact(rel.work.title)); })); } // fold in the title search that's been running since the top of this call (await searchP).forEach(w => add(w, false)); // rank: ISRC-confirmed → performer is on the work → exact-title → most-recorded (popularity) const list = [...cands.values()].sort((a, b) => (b.isrc - a.isrc) || ((b.artistMatch ? 1 : 0) - (a.artistMatch ? 1 : 0)) || (b.exact - a.exact) || (b.pop - a.pop)); row.cands = list; if (!list.length) { row.level = 'none'; return; } const best = list[0], second = list[1]; row.best = best; row.writers = best.authors || []; row.workArtists = best.artists || []; row.artistMatched = !!best.artistMatch; const exacts = list.filter(c => c.exact).length; if (best.isrc > 0) row.level = 'exact'; // ISRC-confirmed else if (best.exact && (best.artistMatch || exacts === 1)) row.level = 'tolerance'; // exact title + (performer is on the work | only one such work) else if (best.artistMatch || (best.exact && (!second || !second.exact || best.pop >= (second.pop || 0) * 2))) row.level = 'near'; // performer on the work, or exact + clearly most-used else row.level = 'low'; // several plausible works — the user picks if (WM_RANK[row.level] <= wmCutoff) row.chosen = best; } function wmStyle() { if (document.getElementById('gt-wm-style')) return; const s = el('style'); s.id = 'gt-wm-style'; s.textContent = // toolbar (clone of Apollo's .tc-rec-tb) // #372 sticky toolbar; #376 flush to the header + bled to the panel edges so nothing shows behind the gap '.gt-wm .gt-cons-body{padding-top:0}' // #381 drag-to-resize from the lower-right so a big screen can widen the table (less Artist wrap) + '.gt-cons.gt-wm{resize:both;overflow:hidden;max-width:98vw;max-height:94vh;min-width:620px;min-height:320px}' // #381 maximize toggle → near-fullscreen + '.gt-cons.gt-wm.gt-wm-max{position:fixed;left:10px;right:10px;top:10px;bottom:10px;width:auto;height:auto;max-width:none;max-height:none;margin:0}' + '.gt-wm-tb{display:flex;align-items:center;gap:8px;padding:9px 14px;flex-wrap:wrap;position:sticky;top:0;z-index:6;background:var(--mbu-bg);border-bottom:1px solid var(--mbu-border);margin:0 -14px 8px}' + '.gt-wm-tb .gt-wm-amstatus{color:var(--mbu-accent-text);font-size:12px;flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;padding-right:4px}' + '.gt-wm-tbl2{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--mbu-text-dim)}.gt-wm-tbl2 b{color:var(--mbu-accent-deep-text)}' + '.gt-wm-warn{color:var(--mbu-error);font-weight:600;font-size:12px}.gt-wm-warn.click{cursor:pointer}.gt-wm-warn.click:hover{text-decoration:underline}' + '.gt-wm-cancel{font:12px Arial;color:var(--mbu-error);background:var(--mbu-bg);border:1px solid var(--mbu-error);border-radius:12px;padding:1px 9px;cursor:pointer;flex:none}.gt-wm-cancel:hover{background:var(--mbu-error-bg)}' + '.gt-wm-tbsep{width:1px;height:18px;background:var(--mbu-bg-sunken);flex:none;margin:0 2px}' + '.gt-wm-btn{padding:4px 11px;border:1px solid transparent;border-radius:3px;background:transparent;cursor:pointer;font:13px Arial;color:var(--mbu-text)}.gt-wm-btn:hover{background:linear-gradient(var(--mbu-bg),var(--mbu-bg-sunken));border-color:var(--mbu-border)}' + '.gt-wm-btn:disabled,.gt-wm-caret:disabled{opacity:.45;cursor:default;pointer-events:none}' + '.gt-wm-btn.primary{color:var(--mbu-accent-text);font-weight:bold}.gt-wm-btn.primary:hover{background:linear-gradient(#7a52df,#5f3ec0);color:#fff;border-color:var(--mbu-accent)}' + '.gt-wm-caret{padding:4px 7px;color:var(--mbu-accent-text);border:1px solid transparent;border-radius:3px;background:transparent;cursor:pointer;font:13px Arial}.gt-wm-caret:hover{background:var(--mbu-bg-hover)}' // cutoff chip (clone of .tc-cutoff) + '.gt-wm-cutoff{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--mbu-border);border-radius:14px;padding:2px 9px;cursor:pointer;font:12px Arial;background:var(--mbu-bg);user-select:none}.gt-wm-cutoff:hover{border-color:var(--mbu-border)}' + '.gt-wm-cutoff-dot,.gt-wm-menu .dot{width:12px;height:12px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,.18);flex:none}.gt-wm-cutoff-caret{color:var(--mbu-text-weak);font-size:10px}' + '.gt-wm-menu{position:fixed;z-index:2147483647;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:7px;box-shadow:0 8px 24px rgba(40,20,80,.22);padding:4px;font:13px Arial}' + '.gt-wm-menu .mi{display:flex;align-items:center;gap:9px;padding:5px 11px 5px 8px;border-radius:5px;cursor:pointer;white-space:nowrap;color:var(--mbu-text)}.gt-wm-menu .mi:hover,.gt-wm-menu .mi.sel{background:var(--mbu-bg-hover)}' // table (clone of .tc-rectbl) + '.gt-wm-tbl{border-collapse:collapse;width:100%;background:var(--mbu-bg);table-layout:fixed}' + '.gt-wm-tbl th{text-align:left;font-size:11px;color:var(--mbu-text-dim);border-bottom:1px solid var(--mbu-border);padding:4px 7px;white-space:nowrap}' + '.gt-wm-tbl td{padding:4px 7px;vertical-align:top;font-size:13px}' + '.gt-wm-tbl .c-n{color:var(--mbu-text-weak);text-align:right;width:38px;white-space:nowrap}' + '.gt-wm-tbl .c-sep{width:20px;text-align:center;border-left:1px solid var(--mbu-accent);border-right:1px solid var(--mbu-accent)}' + '.gt-wm-tbl .tc-grp-l{background:var(--mbu-bg-raised);color:var(--mbu-info)}.gt-wm-tbl .tc-grp-r{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)}' + '.gt-wm-dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid rgba(0,0,0,.15)}' + '.gt-wm-tkt{font-weight:600}.gt-wm-tka{color:var(--mbu-text-dim)}' // #718 medley: the Split toggle, the track's tag, each part's label + '.gt-wm-split{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--mbu-text);border:1px solid var(--mbu-border);border-radius:14px;padding:2px 9px 2px 6px;background:var(--mbu-bg-hover);cursor:pointer;white-space:nowrap}.gt-wm-split input{margin:0}.gt-wm-split code{font-weight:700;color:var(--mbu-accent-text)}' + '.gt-wm-medtag{display:inline-block;margin-top:3px;font:11px Arial;font-weight:400;color:var(--mbu-text-on-accent);background:var(--mbu-accent);border-radius:8px;padding:0 7px}' + '.gt-wm-pt{display:block;color:var(--mbu-text-dim);font-size:11px;margin-top:1px}' + '.gt-wm-wk{position:relative}.gt-wm-wa{color:var(--mbu-info);font-weight:600;text-decoration:none;cursor:pointer}.gt-wm-wa:hover{text-decoration:underline}' + '.gt-wm-none{color:var(--mbu-error);cursor:pointer}.gt-wm-none:hover{text-decoration:underline}.gt-wm-newtag{color:var(--mbu-ok);cursor:pointer}' + '.gt-wm-disamb{color:var(--mbu-text-weak);font-weight:400}.gt-wm-authors{color:var(--mbu-text-dim);font-size:12px}.gt-wm-dim{color:var(--mbu-text-weak);font-style:italic}.gt-wm-linked{color:var(--mbu-ok)}' + '.gt-wm-wwr{color:var(--mbu-text-dim)}.gt-wm-wart{display:block;color:var(--mbu-text-weak);font-size:11px;margin-top:1px}' + '.gt-wm-acts{position:absolute;right:2px;top:2px;display:none;gap:2px}.gt-wm-row:hover .gt-wm-acts{display:inline-flex}' + '.gt-wm-act{border:none;background:var(--mbu-bg);cursor:pointer;color:var(--mbu-accent-text);font-size:13px;line-height:1;padding:1px 5px;border-radius:3px}.gt-wm-act:hover{background:var(--mbu-bg-hover)}' // picker (kept) + '.gt-wm-pop{position:fixed;z-index:2147483647;background:var(--mbu-bg);color:var(--mbu-text);border:1px solid var(--mbu-border);border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,.25);padding:8px;min-width:360px;max-width:480px;font:13px -apple-system,Segoe UI,Arial,sans-serif}' + '.gt-wm-qrow{display:flex;align-items:stretch;gap:6px;margin:4px 0 6px}' + '.gt-wm-q{flex:1 1 auto;min-width:0;box-sizing:border-box;padding:4px 6px}.gt-wm-results{max-height:300px;overflow:auto}' + '.gt-wm-newplus{flex:none;width:40px;font-size:17px;line-height:1;color:var(--mbu-ok);background:var(--mbu-ok-bg);border:1px solid var(--mbu-border);border-radius:5px;cursor:pointer}.gt-wm-newplus:hover{background:var(--mbu-ok-bg)}' + '.gt-wm-res{padding:4px 6px;border-radius:5px;cursor:pointer}.gt-wm-res:hover{background:var(--mbu-info-bg)}.gt-wm-rt{font-size:13px}' + '.gt-wm-rw{color:var(--mbu-text-dim);font-size:12px;margin-left:4px}.gt-wm-sub{color:var(--mbu-text-dim);font-size:11px;margin:-2px 0 5px 2px}' + '.gt-wm-open{margin-left:6px;color:var(--mbu-info);text-decoration:none;font-size:12px}.gt-wm-open:hover{text-decoration:underline}' + '.gt-wm-cur{margin:2px 0 6px;padding:4px 7px;background:var(--mbu-bg-raised);border-radius:5px;font-size:12px}.gt-wm-cur-l{color:var(--mbu-text-dim)}' + '.gt-wm-new{display:block;width:100%;box-sizing:border-box;margin-top:6px;padding:5px;border:1px dashed rgba(127,127,127,.5);border-radius:5px;background:transparent;color:inherit;cursor:pointer}.gt-wm-new:hover{background:rgba(127,127,127,.15)}' // #363 New-work params (Type + searchable lyrics-language combo) in the footer + '.gt-wm-nwp{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--mbu-text-dim)}' + '.gt-wm-ro-sum{display:inline-flex;align-items:center;gap:3px;flex-wrap:wrap;max-width:340px}' + '.gt-wm-nwp-more.on{border-color:var(--mbu-accent);background:var(--mbu-bg-hover);font-weight:700}' + '.gt-wm-nwp-more{padding:2px 6px;border:1px solid var(--mbu-border);border-radius:4px;background:var(--mbu-bg);color:var(--mbu-accent-text);cursor:pointer;font:13px Arial;line-height:1}.gt-wm-nwp-more:hover{background:var(--mbu-bg-hover)}' + '.gt-wm-nwp-type{font:12px Arial;padding:2px 4px;border:1px solid var(--mbu-border);border-radius:4px;background:var(--mbu-bg);max-width:130px}' + '.gt-wm-nwp-lang{position:relative;display:inline-flex;align-items:center;flex-wrap:wrap;gap:3px;min-width:120px;max-width:240px;border:1px solid var(--mbu-border);border-radius:4px;background:var(--mbu-bg);padding:2px 4px}' + '.gt-wm-nwp-chip{display:inline-flex;align-items:center;gap:3px;background:var(--mbu-bg-hover);color:var(--mbu-accent-deep-text);border-radius:9px;padding:1px 4px 1px 7px;font-size:11px;white-space:nowrap}' + '.gt-wm-nwp-x{cursor:pointer;color:var(--mbu-accent-text);font-weight:700;line-height:1}.gt-wm-nwp-x:hover{color:var(--mbu-error)}' + '.gt-wm-nwp-inp{border:none;outline:none;background:transparent;font:12px Arial;min-width:60px;flex:1 1 60px}' + '.gt-wm-nwp-drop{position:absolute;left:0;top:100%;z-index:5;margin-top:2px;max-height:220px;overflow:auto;min-width:160px;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:5px;box-shadow:0 4px 14px rgba(0,0,0,.15)}' + '.gt-wm-nwp-opt{padding:4px 9px;cursor:pointer;font-size:12px;white-space:nowrap}.gt-wm-nwp-opt:hover{background:var(--mbu-bg-hover)}' // #363 recording-of relationship options popover + '.gt-wm-relopts{position:fixed;z-index:2147483647;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:6px;box-shadow:0 6px 20px rgba(40,20,80,.22);padding:8px 12px;font-size:13px;color:var(--mbu-text);min-width:190px}' + '.gt-wm-ro-hd{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--mbu-text-weak);margin-bottom:5px}' + '.gt-wm-ro-cb{display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer}.gt-wm-ro-cb input{margin:0}' + '.gt-wm-ro-date{display:flex;align-items:center;gap:3px;margin-top:5px}' + '.gt-wm-ro-dl{width:66px;color:var(--mbu-text-dim);font-size:12px;text-align:right;margin-right:4px}' + '.gt-wm-ro-y{width:44px}.gt-wm-ro-m,.gt-wm-ro-d{width:30px}.gt-wm-relopts input[type=text]{border:1px solid var(--mbu-border);border-radius:3px;padding:2px 3px;font:12px Arial;text-align:center}' + '.gt-wm-ro-sep{color:var(--mbu-text-weak)}'; document.head.appendChild(s); } let wmEl = null; function onWmKey(e) { if (e.key === 'Escape') { if (popEl) return; e.stopPropagation(); closeWorkMatch(); } } // let an open picker take Escape first function closeWorkMatch() { closeRoPop(); if (wmAbort) { try { wmAbort.abort(); } catch (e) {} } wmRunning = false; if (wmEl) { wmEl.remove(); wmEl = null; document.removeEventListener('keydown', onWmKey, true); } } async function openWorkMatch(forceMatch) { closeWorkMatch(); closePopover(); wmStyle(); const re = RE(); if (!re) { toast('Open the relationship editor first'); return; } if (performanceLtId() == null) { toast('Could not resolve the “performance” link type'); return; } wmEl = el('div', 'gt-cons-ov'); const panel = el('div', 'gt-cons gt-wm'), hdr = el('div', 'gt-cons-hdr'); hdr.appendChild(el('span', 'gt-cons-title', 'Match recordings to works')); // #381 maximize/restore — toggles a near-fullscreen class. Clears any drag-resized inline size on the way // in (inline would beat the class) and restores it on the way out. const max = el('button', 'gt-cons-x', '⛶'); max.type = 'button'; max.title = 'Maximize / restore'; max.onclick = () => { if (panel.classList.toggle('gt-wm-max')) { panel._savedW = panel.style.width; panel._savedH = panel.style.height; panel.style.width = ''; panel.style.height = ''; max.textContent = '❐'; max.title = 'Restore'; } else { panel.style.width = panel._savedW || ''; panel.style.height = panel._savedH || ''; max.textContent = '⛶'; max.title = 'Maximize'; } }; hdr.appendChild(max); const x = el('button', 'gt-cons-x', '✕'); x.type = 'button'; x.onclick = closeWorkMatch; hdr.appendChild(x); const body = el('div', 'gt-cons-body'), foot = el('div', 'gt-cons-foot'); body.appendChild(el('div', 'gt-pop-note', 'Reading recordings…')); panel.append(hdr, body, foot); wmEl.appendChild(panel); document.body.appendChild(wmEl); document.addEventListener('keydown', onWmKey, true); wmEl.addEventListener('mousedown', e => { if (e.target === wmEl) closeWorkMatch(); }); const note = m => { const n = body.querySelector('.gt-pop-note'); if (n) n.textContent = m; }; const rows = wmRecordings(); if (!rows.length) { note('No recordings found on this release.'); return; } const api = renderWorkMatch(body, foot, rows); // show the whole matrix at once — rows start "matching…" wmPrefetchP = wmPrefetchArtists(rows, api.draw); // #363 fire it in PARALLEL (don't block the popup/matching); wmMatchOne awaits it per row and the title search runs regardless // #363 auto-run the match only if opted in (settings) or forced by "Auto-match on start"; otherwise the // popup opens with every row unresolved and you click ⚡ Match yourself. if (forceMatch || wmAutoOnOpen) api.runMatch(); // #372 the initial pass (and ⚡ Match afterwards) go through the same re-runnable, cancellable path } // floating menu near an anchor (cutoff options / caret actions), Apollo-style function wmFloatMenu(anchor, items) { const m = el('div', 'gt-wm-menu'); const close = () => { m.remove(); document.removeEventListener('mousedown', onDown, true); }; const onDown = e => { if (!m.contains(e.target)) close(); }; items.forEach(it => { const mi = el('div', 'mi' + (it.sel ? ' sel' : '')); if (it.dot) { const d = el('span', 'dot'); d.style.background = it.dot; mi.appendChild(d); } mi.appendChild(document.createTextNode(it.label)); mi.onclick = () => { close(); it.run(); }; m.appendChild(mi); }); document.body.appendChild(m); const a = anchor.getBoundingClientRect(), r = m.getBoundingClientRect(); m.style.left = Math.max(6, Math.min(a.left, window.innerWidth - r.width - 6)) + 'px'; m.style.top = Math.min(a.bottom + 4, window.innerHeight - r.height - 6) + 'px'; setTimeout(() => document.addEventListener('mousedown', onDown, true), 0); } // interface mirrors Apollo's Recordings matcher: no checkboxes — a row is resolved (has a work) or not; // Apply stages every resolved row; the toolbar carries the cutoff chip, an unresolved count, and ⚡ Match. function renderWorkMatch(body, foot, rows) { body.textContent = ''; foot.textContent = ''; const mkAct = (glyph, title, run) => { const b = el('button', 'gt-wm-act', glyph); b.type = 'button'; b.title = title; b.onclick = e => { e.stopPropagation(); run(); }; return b; }; // ── toolbar ── const tb = el('div', 'gt-wm-tb'); const amstatus = el('span', 'gt-wm-amstatus'); const cutWrap = el('label', 'gt-wm-tbl2'); cutWrap.appendChild(el('b', null, 'Cutoff')); const chip = el('span', 'gt-wm-cutoff'); chip.tabIndex = 0; chip.title = 'lowest confidence that ⚡ Match still resolves'; const chipDot = el('span', 'gt-wm-cutoff-dot'), chipLbl = el('span', 'gt-wm-cutoff-lbl'); chip.append(chipDot, chipLbl, el('span', 'gt-wm-cutoff-caret', '▾')); cutWrap.appendChild(chip); const paintChip = () => { const lvl = WM_LVL_BY_RANK[wmCutoff] || 'near'; chipDot.style.background = WM_LEVEL[lvl].c; chipLbl.textContent = lvl; }; paintChip(); const warn = el('span', 'gt-wm-warn'); let cancelled = false; // #372 cancel an ongoing match without closing the matcher const cancelBtn = el('button', 'gt-wm-cancel', '✕ cancel'); cancelBtn.type = 'button'; cancelBtn.title = 'stop matching (keeps what has matched so far)'; cancelBtn.style.display = 'none'; cancelBtn.onclick = () => { cancelled = true; if (wmAbort) { try { wmAbort.abort(); } catch (e) {} } cancelBtn.style.display = 'none'; }; // abort in-flight fetches so it stops immediately const matchBtn = el('button', 'gt-wm-btn primary', '⚡ Match'); matchBtn.type = 'button'; matchBtn.title = 'resolve every unresolved track whose best match is at/above the cutoff'; const matchCaret = el('button', 'gt-wm-caret', '▾'); matchCaret.type = 'button'; matchCaret.title = 'more actions'; matchCaret.onclick = () => wmFloatMenu(matchCaret, [{ label: 'Clear all', run: () => { rows.forEach(r => { r.chosen = null; }); draw(); updatePlan(); } }]); // #363 new-work options on the left; matched status + cutoff + Match (with a caret menu for Clear) on the right tb.append(wmNewParamsUi()); // #718 shown only when a track title has " / "; toggling rebuilds the rows (and re-matches if a match ran) if (rows.some(r => r.parts || wmMedleyParts(r.title))) { const split = el('label', 'gt-wm-split'); split.title = 'Split track titles on “ / ” (a medley) and match each part to its own work'; const scb = document.createElement('input'); scb.type = 'checkbox'; scb.checked = wmSplitMedley; scb.onchange = () => { wmSplitMedley = scb.checked; try { GM_setValue('gt-wm-split', wmSplitMedley); } catch (e) {} openWorkMatch(rows.some(r => r._matched)); }; split.append(scb, document.createTextNode('Split on '), el('code', null, '/')); tb.appendChild(split); } tb.append(amstatus, cancelBtn, cutWrap, warn, el('span', 'gt-wm-tbsep'), matchBtn, matchCaret); body.appendChild(tb); const setProgress = (d, n) => { wmRunning = !!n; amstatus.textContent = n ? `matching ${d}/${n}…` : (d ? `matched ${d} track${d > 1 ? 's' : ''}` : ''); matchBtn.disabled = !!n; matchCaret.disabled = !!n; cancelBtn.style.display = n ? '' : 'none'; }; // disable ⚡ Match + offer cancel while matching runs // ── table ── const tbl = el('table', 'gt-wm-tbl'); const cg = document.createElement('colgroup'); ['4%', '27%', '19%', '3%', '28%', '19%'].forEach(w => { const c = document.createElement('col'); c.style.width = w; cg.appendChild(c); }); tbl.appendChild(cg); // fixed widths — the # column was ballooning to an equal 1/6 (blank left column) const grp = el('tr'); const gl = el('th', 'tc-grp-l', 'Track'); gl.colSpan = 3; grp.appendChild(gl); grp.appendChild(el('th', 'c-sep', '')); const gr = el('th', 'tc-grp-r', 'Work'); gr.colSpan = 2; grp.appendChild(gr); tbl.appendChild(grp); const head = el('tr'); head.append(el('th', 'c-n', '#'), el('th', null, 'Title'), el('th', null, 'Artist'), el('th', 'c-sep', ''), el('th', null, 'Work'), el('th', null, 'Writers')); tbl.appendChild(head); const applyBtn = el('button', 'gt-cons-btn gt-cons-apply', 'Apply'); applyBtn.type = 'button'; const plan = el('span', 'gt-cons-plan'); let unresCursor = 0; // #363 cycle through the unresolved on each ⚠ click, not always the first const updatePlan = () => { const n = rows.filter(r => r.chosen && !r.hasWorkOnPage && !r.linked).length; plan.textContent = n ? `${n} work${n > 1 ? 's' : ''} to add` : 'nothing resolved'; applyBtn.disabled = !n; const uns = rows.filter(r => (r._matched || !wmRunning) && !r.chosen && !r.hasWorkOnPage && !r.linked); // #363 once stopped, cancelled (un-matched) rows count as unresolved too warn.textContent = uns.length ? `⚠ ${uns.length} unresolved` : ''; warn.className = 'gt-wm-warn' + (uns.length ? ' click' : ''); warn.onclick = uns.length ? () => { const r0 = uns[unresCursor % uns.length]; unresCursor++; if (r0 && r0._wk) { try { r0._wk.scrollIntoView({ block: 'center' }); } catch (e) {} wmPicker(r0, r0._wk, draw, updatePlan); } } : null; }; const draw = () => rows.forEach(row => { drawRow(row); if (row.parts && row._wk && !(row.hasWorkOnPage || row.linked)) row._wk.appendChild(el('span', 'gt-wm-pt', `part ${row.part} · “${row.title}”`)); // #718 }); const drawRow = row => { const wkd = row._wk, dot = row._dot, wad = row._wa; if (!wkd) return; wkd.textContent = ''; if (wad) wad.textContent = ''; if (row._artEl) row._artEl.textContent = row.artist || ''; if (row.hasWorkOnPage || row.linked) { if (dot) dot.style.visibility = 'hidden'; wkd.appendChild(el('span', 'gt-wm-linked', 'already linked ✓')); return; } // #363 not matched yet: while the run is going it's "matching…"; once it stops (finished/cancelled) // treat it like an unresolved row — a clickable "— none —" (pick a work) + a + new-work action — so a // cancelled row isn't a dead "—". We keep _matched=false so ⚡ Match still resumes it. if (!row._matched && wmRunning) { if (dot) dot.style.visibility = 'hidden'; wkd.appendChild(el('span', 'gt-wm-dim', 'matching…')); return; } // whatever's chosen (matched, picked, or a +/new-work on a cancelled row) — NOT gated on _matched, or a // chosen set after a cancel (un-matched row) would wrongly render as "— none —" (#363: + / New-work no-op) const w = row.chosen; if (!w) { if (dot) dot.style.visibility = 'hidden'; const none = el('span', 'gt-wm-none', '— none —'); none.title = 'pick a work'; none.onclick = () => wmPicker(row, wkd, draw, updatePlan); wkd.appendChild(none); const acts = el('span', 'gt-wm-acts'); acts.appendChild(mkAct('+', 'set to a new work', () => { row.chosen = wmMakeNewWork(row.title); draw(); updatePlan(); })); wkd.appendChild(acts); return; } if (dot) { dot.style.visibility = 'visible'; if (w._gtNew) { dot.style.background = '#2c7a51'; dot.title = 'new work'; } else { const L = WM_LEVEL[row.level] || WM_LEVEL.near; dot.style.background = L.c; dot.title = L.t; } } if (w._gtNew) { const nw = el('span', 'gt-wm-newtag', '+ new work: ' + (w.name || w.title || '')); nw.title = 'change / pick a work'; nw.onclick = () => wmPicker(row, wkd, draw, updatePlan); wkd.appendChild(nw); } else { const a = el('a', 'gt-wm-wa', w.title); a.href = '/work/' + w.gid; a.target = '_blank'; a.rel = 'noopener'; a.title = 'change / pick a work (middle-click to open the work)'; a.onclick = e => { e.preventDefault(); e.stopPropagation(); wmPicker(row, wkd, draw, updatePlan); }; wkd.appendChild(a); if (w.disambiguation) wkd.appendChild(el('span', 'gt-wm-disamb', ` (${w.disambiguation})`)); } if (wad && !w._gtNew) { // #363 a NEW work has no writers yet — don't leave the auto-match candidate's writers showing wad.textContent = ''; if (row.writers && row.writers.length) wad.appendChild(el('span', 'gt-wm-wwr', row.writers.slice(0, 4).join(', '))); // when the performer is one of the work's recording artists (why it matched), show them too — the // native work dropdown's "Artists:" line, e.g. Phil Collins on "You Can't Hurry Love" if (row.artistMatched && row.workArtists && row.workArtists.length) { const ar = el('span', 'gt-wm-wart', '♫ ' + row.workArtists.slice(0, 3).join(', ')); ar.title = 'recording artists of this work — the performer is among them'; wad.appendChild(ar); } } const acts = el('span', 'gt-wm-acts'); acts.appendChild(mkAct('↺', 'clear this match', () => { row.chosen = null; draw(); updatePlan(); })); if (!w._gtNew) acts.appendChild(mkAct('+', 'set to a new work', () => { row.chosen = wmMakeNewWork(row.title); draw(); updatePlan(); })); wkd.appendChild(acts); }; rows.forEach((row, i) => { const tr = el('tr', 'gt-wm-row' + (row.parts ? ' gt-wm-med' : '')); // #718 a medley's parts share one track: its #, full title and artist span the part rows if (!row.parts || row.part === 1) { const span = c => { if (row.parts) c.rowSpan = row.parts; return c; }; tr.appendChild(span(el('td', 'c-n', row.pos ? String(row.pos) : String(i + 1)))); const tkt = span(el('td', 'gt-wm-tkt', row.parts ? row.fullTitle : (row.title || '(untitled)'))); if (row.parts) { tkt.appendChild(document.createElement('br')); tkt.appendChild(el('span', 'gt-wm-medtag', `medley · ${row.parts} works`)); } tr.appendChild(tkt); const tka = span(el('td', 'gt-wm-tka')); row._artEl = tka; tr.appendChild(tka); } const sepd = el('td', 'c-sep'); const dot = el('span', 'gt-wm-dot'); dot.style.visibility = 'hidden'; sepd.appendChild(dot); row._dot = dot; tr.appendChild(sepd); const wkd = el('td', 'gt-wm-wk'); row._wk = wkd; tr.appendChild(wkd); const wad = el('td', 'gt-wm-authors'); row._wa = wad; tr.appendChild(wad); tbl.appendChild(tr); }); body.appendChild(tbl); // resolve every matched row whose best is at/above the cutoff (⚡ Match + cutoff change) const applyCutoff = () => { rows.forEach(r => { if (!(r.hasWorkOnPage || r.linked) && r._matched) r.chosen = (r.best && WM_RANK[r.level] <= wmCutoff) ? r.best : null; }); draw(); updatePlan(); }; // #372 (re-)run matching for any not-yet-matched rows, then apply the cutoff. Re-runnable: this is // both the initial pass and what ⚡ Match does — so "Match" after a cancel resumes the leftover rows. const runMatch = async () => { if (wmRunning) return; cancelled = false; if (wmAbort) { try { wmAbort.abort(); } catch (e) {} } wmAbort = new AbortController(); const total = rows.length; let done = rows.filter(r => r._matched || r.hasWorkOnPage || r.linked).length; setProgress(done, total); draw(); // #363 match recordings CONCURRENTLY (not one-at-a-time). Each recording's work-title search hits the // editor's /ws/js endpoint, which ISN'T on the /ws/2 rate gate — so running several rows at once lets // those ungated searches overlap and fills the idle time while the gated ISRC lookups are spaced out, // instead of the old serial loop where every row waited on the one before it. wmMatchOne rows are // independent (shared state is only the global gate + abort), so this is safe. const todo = rows.filter(r => !(r._matched || r.hasWorkOnPage)); await throttledMap(todo, async row => { if (cancelled || !wmEl) return; try { await wmMatchOne(row); } catch (e) {} if (cancelled || !wmEl) return; row._matched = true; done++; setProgress(done, total); draw(); updatePlan(); }, 6); if (!wmEl) { wmRunning = false; return; } // dialog closed mid-run setProgress(done, 0); // clears wmRunning → leftover rows show "—", ⚡ Match re-enabled // #363 the run is over (finished or cancelled) — drop the AbortController. A cancel leaves it in the // aborted state, and wmJson bails on an aborted signal, so keeping it would make the ✎ picker's OWN // searches return nothing after a cancel (they don't after a full run, which never aborts it). if (wmAbort && wmAbort.signal.aborted) wmAbort = null; if (!cancelled) applyCutoff(); // auto-select strong matches after a full pass (not after a cancel) draw(); updatePlan(); }; matchBtn.onclick = runMatch; chip.onclick = () => wmFloatMenu(chip, WM_LVL_BY_RANK.map(lvl => ({ label: lvl, dot: WM_LEVEL[lvl].c, sel: WM_RANK[lvl] === wmCutoff, run: () => { wmCutoff = WM_RANK[lvl]; try { GM_setValue('gt-wm-cutoff', wmCutoff); } catch (e) {} paintChip(); applyCutoff(); } }))); const newAllBtn = el('button', 'gt-cons-btn', '+ New work for unresolved'); newAllBtn.type = 'button'; newAllBtn.title = 'Create a new work (named after the track) for every recording still unresolved — same-title tracks share one'; newAllBtn.onclick = () => { rows.forEach(r => { if (!(r.hasWorkOnPage || r.linked) && (r._matched || !wmRunning) && !r.chosen) r.chosen = wmMakeNewWork(r.title); }); draw(); updatePlan(); }; // #363 include cancelled rows once matching has stopped applyBtn.onclick = async () => { const n = await wmApply(rows, null); if (n > 0) closeWorkMatch(); }; // close the popup once staged (#363 follow-up) foot.append(newAllBtn, plan, applyBtn); // #363 Clear moved to the Match caret menu; new-work options moved to the toolbar draw(); updatePlan(); return { draw, updatePlan, setProgress, runMatch }; } function wmResRow(work, row, draw, updatePlan) { const r = el('div', 'gt-wm-res'); r.appendChild(el('span', 'gt-wm-rt', work.title + (work.disambiguation ? ` (${work.disambiguation})` : ''))); if (work.type && work.type !== 'Song') r.appendChild(el('span', 'gt-wm-rw', ' · ' + work.type)); if (work.authors && work.authors.length) r.appendChild(el('span', 'gt-wm-rw', ' — ' + work.authors.slice(0, 4).join(', '))); if (work.artists && work.artists.length) r.appendChild(el('span', 'gt-wm-rw', ' · ♫ ' + work.artists.slice(0, 3).join(', '))); if (work.gid) { const open = el('a', 'gt-wm-open', '↗'); open.href = '/work/' + work.gid; open.target = '_blank'; open.rel = 'noopener'; open.title = 'open this work in a new tab'; open.onclick = e => e.stopPropagation(); r.appendChild(open); } r.onclick = () => { row.chosen = work; row.best = work; if (!row.level || row.level === 'none') row.level = 'near'; row.writers = work.authors || []; draw && draw(); updatePlan && updatePlan(); closePopover(); }; return r; } function wmPicker(row, anchor, draw, updatePlan) { closePopover(); popEl = el('div', 'gt-wm-pop'); popEl.appendChild(el('div', 'gt-pop-hdr', 'Pick a work for “' + trunc(row.title, 54) + '”')); if (row.parts) popEl.appendChild(el('div', 'gt-wm-sub', `part ${row.part} of “${trunc(row.fullTitle, 60)}”`)); // #718 if (row.artist) popEl.appendChild(el('div', 'gt-wm-sub', 'by ' + trunc(row.artist, 60))); // current match (mirrors Apollo's picker header) — the work as a link you can open in a new tab, its // writers, and a clear button const cur = el('div', 'gt-wm-cur'); const paintCur = () => { cur.textContent = ''; cur.appendChild(el('span', 'gt-wm-cur-l', 'Current: ')); const w = row.chosen; if (!w) { cur.appendChild(el('span', 'gt-wm-none', '— none —')); return; } if (w._gtNew) { cur.appendChild(el('span', 'gt-wm-newtag', '+ new work: ' + (w.name || w.title || ''))); return; } const a = el('a', 'gt-wm-wa', w.title + (w.disambiguation ? ` (${w.disambiguation})` : '')); a.href = '/work/' + w.gid; a.target = '_blank'; a.rel = 'noopener'; a.title = 'open this work in a new tab'; cur.appendChild(a); if (row.writers && row.writers.length) cur.appendChild(el('span', 'gt-wm-rw', ' — ' + row.writers.slice(0, 4).join(', '))); const clr = el('button', 'gt-wm-act', '↺'); clr.type = 'button'; clr.title = 'clear this match'; clr.onclick = () => { row.chosen = null; paintCur(); draw && draw(); updatePlan && updatePlan(); }; cur.appendChild(clr); }; paintCur(); popEl.appendChild(cur); const qrow = el('div', 'gt-wm-qrow'); const q = el('input', 'gt-wm-q'); q.type = 'text'; q.placeholder = 'search works, or paste a work MBID / URL…'; qrow.appendChild(q); // #363 create-new-work as a + button right of the search (like Apollo's recordings picker), not a footer button const newBtn = el('button', 'gt-wm-newplus', '+'); newBtn.type = 'button'; newBtn.title = 'Create a new work “' + trunc(row.title, 40) + '”'; newBtn.onclick = () => { const w = wmMakeNewWork(row.title); row.chosen = w; row.best = w; row.level = (!row.level || row.level === 'none') ? 'near' : row.level; row.writers = []; draw && draw(); updatePlan && updatePlan(); closePopover(); }; qrow.appendChild(newBtn); popEl.appendChild(qrow); const list = el('div', 'gt-wm-results'); popEl.appendChild(list); const showCands = () => { list.textContent = ''; (row.cands || []).forEach(c => list.appendChild(wmResRow(c, row, draw, updatePlan))); if (!(row.cands || []).length) list.appendChild(el('div', 'gt-pop-note', 'No candidates yet — search or paste a work.')); }; showCands(); // #363 if this row was never matched (e.g. cancelled before its turn), run the same match ⚡ Auto-match // uses right now, so candidates appear automatically instead of a blank list until you type. if (!(row.cands || []).length && !row._matched && !row.linked) { list.textContent = ''; list.appendChild(el('div', 'gt-pop-note', 'Searching…')); const myPop = popEl; wmMatchOne(row).then(() => { row._matched = true; if (popEl === myPop) { showCands(); paintCur(); } draw && draw(); updatePlan && updatePlan(); }) .catch(() => { if (popEl === myPop) showCands(); }); } let t = null; const run = async () => { const term = (q.value || '').trim(); if (!term) return showCands(); const gid = (term.match(GID_RE) || [])[0]; list.textContent = ''; if (gid) { const j = await wmJson('/ws/2/work/' + gid + '?inc=artist-rels&fmt=json'); if (j && j.id) list.appendChild(wmResRow({ gid: j.id, title: j.title, disambiguation: j.disambiguation || '', authors: (j.relations || []).filter(r => r.artist && WM_WRITER_RE.test(r.type || '')).map(r => r.artist.name) }, row, draw, updatePlan)); else list.appendChild(el('div', 'gt-pop-note', 'No work with that MBID.')); return; } const works = await wmWorkSearch(term); if (!works.length) { list.appendChild(el('div', 'gt-pop-note', 'No matches.')); return; } works.forEach(w => list.appendChild(wmResRow(w, row, draw, updatePlan))); }; q.addEventListener('input', () => { clearTimeout(t); t = setTimeout(run, 300); }); q.addEventListener('paste', () => setTimeout(run, 0)); document.body.appendChild(popEl); const a = anchor.getBoundingClientRect(), r = popEl.getBoundingClientRect(); popEl.style.left = Math.max(8, Math.min(a.left, window.innerWidth - r.width - 8)) + 'px'; popEl.style.top = Math.min(a.bottom + 4, window.innerHeight - r.height - 8) + 'px'; setTimeout(() => { document.addEventListener('mousedown', onPopDown, true); document.addEventListener('keydown', onPopKey, true); q.focus(); }, 0); } async function wmApply(rows, refresh) { const re = RE(); const ltId = performanceLtId(); if (!re || ltId == null) { toast('Cannot apply — editor not ready'); return; } const todo = rows.filter(r => r.chosen && !r.hasWorkOnPage && !r.linked); if (!todo.length) { toast('Nothing resolved to apply'); return; } toast(`Linking ${todo.length} work${todo.length > 1 ? 's' : ''}…`); let ok = 0, fail = 0; const okRecs = new Set(); // #718 a medley links one recording to several works const extras = wmRelExtras(); // #363 recording-of attributes + dates, applied to every staged rel for (const row of todo) { try { // existing work: fetch its internal id (the editor needs it). New work: dispatch the synthetic // entity as-is — MB creates it on submit, exactly like a natively-added new work. const workEnt = row.chosen._gtNew ? row.chosen : await wmJson('/ws/js/entity/' + row.chosen.gid); if (!workEnt || workEnt.id == null) { fail++; continue; } dispatchRelationship(re, row.rec, workEnt, ltId, '', extras.attrs, extras.dates); row.linked = true; row.chosen = null; ok++; okRecs.add(row.rec); } catch (e) { fail++; } } const nRec = okRecs.size; if (ok) markUsed(`Matched ${nRec} recording${nRec > 1 ? 's' : ''} to works`); toast(fail ? `Linked ${ok}, ${fail} failed — see console` : `✓ Linked ${ok} work${ok > 1 ? 's' : ''} — review & save`); refresh && refresh(); return ok; } // recording checkbox → copy every recording rel except work/url/recording-samples // (so artists, ℗/© labels, recorded-at places, …) onto the ticked recordings // preselect (optional): a predicate rel→bool for which credits START ticked (#373 — the + / pencil // right-clicks scope the copy to one role group / one credit; others start unticked but stay selectable). function openCopyMenu(sourceTr, x, y, preselect, extraItems) { const srcRels = recordingRels(sourceTr).filter(r => !r.removed && r.other && !['work', 'url', 'recording'].includes(r.other.entityType)); const entries = srcRels.map(s => ({ rel: s, role: roleKeyOfSpec(s), pos: roleLabelOf(s), text: val(s.other.name) + (s.credit && s.credit !== val(s.other.name) ? ` (${s.credit})` : ''), checked: preselect ? !!preselect(s) : true })); // before the checkboxes render, respect the preselect (e.checked) so the Copy count reflects the ticked subset (#373) const chosen = () => entries.filter(e => e.cb ? e.cb.checked : e.checked !== false).map(e => e.rel); const nR = srcRels.length, srcPos = posLabel(sourceTr); // #377 destinations are LIVE (recomputed when you Copy/Move) so the [A]/[R] row buttons can grow the // selection; with nothing ticked it means ALL other tracks. const tickedRows = () => [...document.querySelectorAll('tr.track')].filter(tr => { if (tr === sourceTr) return false; const cb = tr.querySelector('input.recording'); return cb && cb.checked; }); const destRows = () => { const t = tickedRows(); return t.length ? t : [...document.querySelectorAll('tr.track')].filter(tr => tr !== sourceTr); }; const getDests = () => destRows().map(recordingEntity).filter(Boolean); const whereText = () => { const t = tickedRows(); if (!t.length) return `all ${destRows().length} tracks`; const pos = new Set(t.map(trackPosOfRow).filter(p => p != null)); return pos.size ? `track${pos.size > 1 ? 's' : ''} ${ranges(pos)}` : `${t.length} recording${t.length > 1 ? 's' : ''}`; }; // #377 tick destination tracks by a credit: [A] = any track crediting this artist (any role); [R] = same role const selectByCredit = (gid, roleKey) => { let n = 0; document.querySelectorAll('tr.track').forEach(tr => { if (tr === sourceTr) return; const has = recordingRels(tr).some(r => !r.removed && r.other && r.other.gid === gid && (roleKey == null || roleKeyOfSpec(r) === roleKey)); if (has) { const cb = tr.querySelector('input.recording'); if (cb && !cb.checked) { cb.click(); n++; } } }); return n; }; // #385 "Set missing dates": the pencil-clicked rel carries a date period (e.g. a "recorded at" place // with a date). Stamp that date onto every UNDATED relationship (all roles + recording-of) on the target // tracks. It only FILLS blanks — MB's editor reducer merges an update into the existing relationship and // keeps a non-empty date, so an existing date can't be overwritten or cleared from here (that would need // driving MB's own edit dialog, which we don't do). url rels can't carry a date period, so they're skipped. const clickedRel = (preselect && srcRels.find(s => preselect(s))) || null; const hasDate = !!(clickedRel && (clickedRel.begin_date || clickedRel.end_date || clickedRel.ended)); const items = []; if (!nR) { items.push({ header: 'No credits here' }); } else { const hdr = { header: `Copy to ${whereText()}` }; const refreshHdr = () => { hdr._set && hdr._set(`Copy to ${whereText()}`); }; const copyItem = { label: 'Copy', sub: String(chosen().length), run: () => { const c = chosen(), dests = getDests(); if (!c.length) { toast('No credits selected'); return; } if (copyCredits(c, dests)) markUsed(`Copied ${c.length} credit${c.length > 1 ? 's' : ''} from track ${srcPos || '?'} to ${whereText()}`); toast(`Copied ${c.length} credit${c.length > 1 ? 's' : ''} to ${dests.length} recording${dests.length > 1 ? 's' : ''} — review & save`); } }; const moveItem = { label: 'Move (remove here)', danger: true, run: () => { const c = chosen(), dests = getDests(); if (!c.length) { toast('No credits selected'); return; } const srcGid = (recordingEntity(sourceTr) || {}).gid; if (copyCredits(c, dests)) markUsed(`Moved ${c.length} credit${c.length > 1 ? 's' : ''} from track ${srcPos || '?'} to ${whereText()}`); removeSourceRels(srcGid, c); toast(`Moved ${c.length} credit${c.length > 1 ? 's' : ''} to ${dests.length} recording${dests.length > 1 ? 's' : ''} — review & save`); } }; items.push(hdr, { checklist: entries, onToggle: () => { const n = chosen().length; copyItem._setSub && copyItem._setSub(String(n)); }, rowBtns: [ { label: 'A', title: 'select every track crediting this artist — any role', run: e => { const n = selectByCredit(e.rel.other.gid, null); refreshHdr(); toast(n ? `+${n} track${n > 1 ? 's' : ''} with this artist` : 'No other tracks with this artist'); } }, { label: 'R', title: 'select every track crediting this artist in the same role', run: e => { const n = selectByCredit(e.rel.other.gid, e.role); refreshHdr(); toast(n ? `+${n} track${n > 1 ? 's' : ''} with this role` : 'No other tracks with this role'); } }, ] }, copyItem, moveItem); // #398 "Set dates…" — only when the clicked rel carries a date to seed with. Opens the date picker // (editable date + per-credit selection across the selected tracks) instead of blindly filling every // undated rel; the clicked rel merely SEEDS the initial date, the picker owns the rest. if (hasDate) { const dl = fmtRoDate(clickedRel) || 'date'; items.push({ label: `Set dates from ${dl}…`, run: () => openDatePicker(clickedRel) }); } } if (extraItems && extraItems.length) items.push(...extraItems); // #470 "Replace role…" from the pencil openMenu(x, y, items); } /* ── #522 Text parser: pattern-match unstructured credit text into (role, entity) rows ──── * majkinetor: "In a new branch, create a Text parser tool. It should work sorta like Pattern * parser of Apollo but for credits." Same DSL shape as Apollo's tpTokenize/tpCompile * (apollo_editor.user.js) — a one-line PATTERN compiles to a regex, run over every pasted * line — but with credit fields (R role, E entity — artist OR label, see #525 below) instead * of track fields, plus a new [,] split modifier so one matched line can expand into several * (role, entity) rows (e.g. "Cameron Allen - Flute, Tenor Saxophone" → two rows, one entity, * two roles). Deliberately * duplicated rather than shared — same one-file, dependency-free philosophy this script * already states in its own README, and the same "keep in sync" convention Apollo uses * between its own live copy and test/pattern-engine.test.mjs; this file gets its own mirror * at test/pattern-engine.test.mjs. Per majkinetor's own scoping: "let's parse comments on * single line only" — there is no multi-line grammar; a line that doesn't match anything * (a section header, a blank line) is simply unmatched, not a special case. */ // #525 follow-up (majkinetor): "change A to E (as entity, and everywhere // where Artist appears)" — the field can resolve to either an artist OR a // label (see the entity-type generalization below), so the DSL letter and // the field's own name are now the generic "entity", not "artist". const TXP_FIELDS = { 'R': 'role', 'E': 'entity' }; const TXP_SEPS = ['-', '‐', '–', '—', '/', ':']; // #525 follow-up (majkinetor): "Add R[,] - E[,] and E[,] - R[,] instead // variants on single side. It is more general." — splitting BOTH sides // strictly subsumes a single-side split (a field with no comma in it is // just a no-op split), so these replace the old entity-only-split dash // preset ("E - R[,]") rather than sitting alongside it. // #525: MB wants a sort name on a new artist, and typing it by hand for every // created credit is exactly the tedium this parser exists to remove. Ported // from credit_hoarder's guessSortName (mappers.js) — duplicated rather than // shared, the same way the pattern engine already is, since Group Therapy // ships as one file. Assumes type=Person, which is what we seed alongside it. function txpGuessSortName(name) { if (!name || !name.trim()) return name || ''; name = name.trim(); const words = name.split(/\s+/); if (words.length === 1) return name; // "The Doors" -> "Doors, The" const art = name.match(/^(the|a|an)\s+(.+)$/i); if (art) return `${art[2]}, ${art[1].charAt(0).toUpperCase() + art[1].slice(1).toLowerCase()}`; // keep a trailing suffix attached to the given names: "Sammy Davis Jr." -> // "Davis, Sammy Jr." let suffix = '', base = name; const sm = name.match(/^(.*?),?\s+(jr\.?|sr\.?|ii|iii|iv|v|esq\.?)$/i); if (sm) { base = sm[1].trim(); suffix = ' ' + sm[2]; } const bw = base.split(/\s+/); if (bw.length === 1) return name; return `${bw[bw.length - 1]}, ${bw.slice(0, -1).join(' ')}${suffix}`; } const TXP_PRESETS = ['R: E', 'R: E[,]', 'R[,] - E[,]', 'E[,] - R[,]', 'R by E[&]']; let _txpPattern = 'R: E'; // remembered across opens this session const txpEsc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const txpUsable = out => !!out && Object.values(out).some(v => v && String(v).trim()); function txpTokenize(pattern, seps) { const segs = [], sepSet = new Set(seps); const isFieldLetter = c => c === 'R' || c === 'E'; const readSlice = (str, i) => { if (str[i] !== '[') return null; const close = str.indexOf(']', i); if (close < 0) return null; const inner = str.slice(i + 1, close); let m; // #522 split modifier: [,] splits this field's captured text on commas, expanding // one matched line into several output rows (see txpExpand). [, and] also splits on // the literal word "and" (space-separated extra alternatives after the leading comma) // — covers wiki shapes like "Flute Alto, Tenor, Baritone and Soprano Saxophones". // #525 (majkinetor): "R: E[&]" didn't work for "Ricardo H Fernandes & // Yacine Blaeich" — [&] splits the SAME way, on " & " (with required // surrounding whitespace, so it doesn't fire mid-word for things like // "AT&T"), for lines joined by "&" instead of a comma. if (inner[0] === ',' || inner[0] === '&') { const primary = inner[0]; const rest = inner.slice(1).trim(); return { slice: { split: [primary, ...(rest ? rest.split(/\s+/) : [])] }, next: close + 1 }; } // colon form [FROM:TO] — FROM is a numeric position (~ = from end) OR a literal char to // start AFTER; TO is a literal char to stop BEFORE (empty = to end), same as Apollo's. const colon = inner.indexOf(':'); if (colon >= 0) { let left = inner.slice(0, colon); const hadTilde = left[0] === '~'; if (hadTilde) left = left.slice(1); let toDelim = inner.slice(colon + 1); const toLast = toDelim[0] === '~'; if (toLast) toDelim = toDelim.slice(1); const slice = /^\d*$/.test(left) ? { a: left === '' ? null : parseInt(left, 10), fromEnd: hadTilde, toDelim, toLast } : { fromDelim: left, fromLast: hadTilde, toDelim, toLast }; return { slice, next: close + 1 }; } // dash form [a-b] / [a] — numeric char range (1-based inclusive; ~ = from the end) if ((m = /^(~?)(\d*)(-?)(\d*)$/.exec(inner))) { const a = m[2] === '' ? null : parseInt(m[2], 10), b = m[4] === '' ? null : parseInt(m[4], 10); return { slice: m[3] === '-' ? { a, b, fromEnd: m[1] === '~' } : { a, b: a, fromEnd: m[1] === '~' }, next: close + 1 }; } return null; }; for (let i = 0; i < pattern.length;) { const c = pattern[i]; if (c === '$') { const letter = pattern[i + 1]; if (isFieldLetter(letter)) { const sl = readSlice(pattern, i + 2); segs.push({ kind: 'field', field: TXP_FIELDS[letter], slice: sl ? sl.slice : null }); i = sl ? sl.next : i + 2; continue; } segs.push({ kind: 'lit', text: '$' }); i++; continue; } if (c === '_') { segs.push({ kind: 'skip' }); i++; continue; } if (isFieldLetter(c)) { const letterish = ch => ch && /[A-Za-z]/.test(ch); if (!(letterish(pattern[i - 1]) || letterish(pattern[i + 1]))) { const sl = readSlice(pattern, i + 1); segs.push({ kind: 'field', field: TXP_FIELDS[c], slice: sl ? sl.slice : null }); i = sl ? sl.next : i + 1; continue; } } if (sepSet.has(c)) { segs.push({ kind: 'sep' }); i++; continue; } if (/\s/.test(c)) { let j = i; while (j < pattern.length && /\s/.test(pattern[j])) j++; segs.push({ kind: 'ws' }); i = j; continue; } segs.push({ kind: 'lit', text: c }); i++; } return segs; } function txpCompile(pattern, opts = {}) { const seps = opts.separators || TXP_SEPS; const segs = txpTokenize(pattern, seps); // dangling leading/trailing ws/sep tokens compile to a hard boundary requirement a // trimmed input line can never satisfy — same fix as Apollo's #522, re-ported here since // this is a fresh, independent copy of the engine. while (segs.length && (segs[0].kind === 'ws' || segs[0].kind === 'sep')) segs.shift(); while (segs.length && (segs[segs.length - 1].kind === 'ws' || segs[segs.length - 1].kind === 'sep')) segs.pop(); const fieldSegs = segs.filter(s => s.kind === 'field'); const fields = new Set(fieldSegs.map(s => s.field)); // split-flagged fields (R[,]) can't be positionally sliced — they need an ordinary // capture (still compiled below) plus post-match splitting in txpExpand, so they're // excluded from slice-mode eligibility. const sliced = fieldSegs.filter(s => s.slice && !s.slice.split); const splitSpecs = new Map(fieldSegs.filter(s => s.slice && s.slice.split).map(s => [s.field, s.slice.split])); const sepClass = '(?:' + seps.map(txpEsc).join('|') + ')'; if (fieldSegs.length > 0 && sliced.length === fieldSegs.length) { // slice-mode (all fields positional) return { fields, splitSpecs, exec(line) { const s = String(line), out = {}; for (const seg of fieldSegs) { const { a, b, fromEnd, fromDelim, fromLast, toDelim, toLast } = seg.slice; let start, end; if (fromDelim != null) { const fi = fromDelim === '' ? 0 : (fromLast ? s.lastIndexOf(fromDelim) : s.indexOf(fromDelim)); if (fi < 0) { out[seg.field] = ''; continue; } start = fi + fromDelim.length; } else if (fromEnd) start = a == null ? 0 : s.length - a; else start = (a == null ? 1 : a) - 1; start = Math.max(0, start); if (toDelim != null) { let ti; if (toDelim === '') ti = -1; else if (toLast) { ti = s.lastIndexOf(toDelim); if (ti < start) ti = -1; } else ti = s.indexOf(toDelim, start); end = ti < 0 ? s.length : ti; } else if (fromEnd) end = b == null ? s.length : s.length - b + 1; else end = b == null ? s.length : b; out[seg.field] = s.slice(start, Math.max(start, end)).trim(); } return txpUsable(out) ? out : null; } }; } // one text field is greedy so the split lands on the FIRST separator (default) — the // other stays lazy. A field immediately followed by a literal is ALWAYS lazy (stops at // the FIRST occurrence) regardless of that heuristic — same fix as Apollo's #522 // follow-up, re-ported here. const textSegs = fieldSegs.filter(s => s.field === 'role' || s.field === 'entity'); const greedyText = textSegs[textSegs.length - 1]; let re = '^\\s*'; for (let idx = 0; idx < segs.length; idx++) { const seg = segs[idx]; if (seg.kind === 'ws') re += '\\s+'; else if (seg.kind === 'lit') re += /\s/.test(seg.text) ? '\\s+' : txpEsc(seg.text); else if (seg.kind === 'sep') re += '\\s*' + sepClass + '\\s*'; else if (seg.kind === 'skip') re += '.*?'; else if (seg.kind === 'field') { if (seg.slice && (seg.slice.toDelim != null || seg.slice.fromDelim != null)) { const sl = seg.slice; re += (sl.fromDelim ? (sl.fromLast ? '.*' : '.*?') + txpEsc(sl.fromDelim) : '') + (sl.toDelim ? (sl.toLast ? '(.+)' : '(.+?)') + txpEsc(sl.toDelim) : '(.+)'); } // X[from:to] in flow: skip-to-fromDelim, capture, toDelim else { let j = idx + 1; while (j < segs.length && segs[j].kind === 'ws') j++; const followedByLiteral = j < segs.length && segs[j].kind === 'lit'; re += (seg === greedyText && !followedByLiteral) ? '(.+)' : '(.+?)'; } } } re += '\\s*$'; const rx = new RegExp(re), order = fieldSegs.map(s => s.field); return { fields, splitSpecs, exec(line) { const m = rx.exec(String(line).trim()); if (!m) return null; const out = {}; order.forEach((f, i) => { out[f] = (m[i + 1] || '').trim(); }); return txpUsable(out) ? out : null; } }; } // one comma is always a split point; any additional alternatives (e.g. "and") are // treated as whole surrounding-whitespace-delimited words, not substrings. function txpSplitRegex(alts) { return new RegExp(alts.map(a => a === ',' ? '\\s*,\\s*' : '\\s+' + txpEsc(a) + '\\s+').join('|'), 'i'); } // run a compiled pattern against one line; expand any split-flagged field's captured // text into several output rows (one matched line → N role/artist pairs). function txpExpand(compiled, line) { const out = compiled && compiled.exec(line); if (!out) return null; if (!compiled.splitSpecs || !compiled.splitSpecs.size) return [out]; let rows = [out]; for (const [field, alts] of compiled.splitSpecs) { const rx = txpSplitRegex(alts); rows = rows.flatMap(r => { const vals = (r[field] || '').split(rx).map(s => s.trim()).filter(Boolean); return vals.length ? vals.map(v => ({ ...r, [field]: v })) : [r]; }); } return rows; } // ── artist resolution (ported from apollo_editor.user.js's search + siblings block) ────── // #613: the exact-identity rule now comes from the shared matching module // (dev/match/artist-match.mjs, inlined below by dev/match/sync-match.mjs); the txp* // folding helpers stay as they were. // <ST-MATCH> — generated by dev/match/sync-match.mjs from dev/match/artist-match.mjs — DO NOT EDIT const MBM_EXACT_LIMIT = 100; const MBM_SPECIAL_PURPOSE = new Set(["125ec42a-7229-4250-afc5-e057484327fe","f731ccc4-e22a-43af-a747-64213329e088","33cf029c-63b0-41a0-9855-be2a3665fb3b","314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc","eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61","9be7f096-97ec-4615-8957-8d40b5dcbc41","89ad4ac3-39f7-470e-963a-56509c546377","7e84f845-ac16-41fe-9ff8-df12eb32af55","66ea0139-149f-4a0c-8fbf-5ea9ec4a6e49","a0ef7e1d-44ff-4039-9435-7d5fefdeecc9","90068d37-bae7-4292-be4a-704c145bd616","80a8851f-444c-4539-892b-ad2a49292aa9"]); function mbmFold(s) { return String(s == null ? '' : s).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').toLowerCase().replace(/\s+/g, ' ').trim(); } function mbmFoldKeepCase(s) { return String(s == null ? '' : s).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').replace(/\s+/g, ' ').trim(); } function mbmSameName(a, b) { return mbmFold(a) === mbmFold(b) && mbmFold(a) !== ''; } function mbmSameNameCase(a, b) { return mbmFoldKeepCase(a) === mbmFoldKeepCase(b) && mbmFoldKeepCase(a) !== ''; } function mbmHolds(entity, name, caseExact) { if (!entity) return null; const same = caseExact ? mbmSameNameCase : mbmSameName; if (same(entity.name, name)) return 'name'; if ((entity.aliases || []).some(al => same(al && (al.name != null ? al.name : al), name))) return 'alias'; return null; } function mbmIdentityQuery(name, field) { const q = String(name == null ? '' : name).replace(/["\\]/g, ' ').replace(/\s+/g, ' ').trim(); return q ? 'alias:"' + q + '" OR ' + (field || 'artist') + ':"' + q + '"' : ''; } function mbmExactIdentity(json, name, opts) { const o = opts || {}; if (!json || typeof json !== 'object') return { status: 'failed', exact: [] }; const list = json.artists || json.labels || json.places || []; let exact = list.filter(e => mbmHolds(e, name)); if (exact.length > 1) { // several case-insensitive holders, exactly one WITH case → that one (#445 "Kasane Teto") const caseExact = exact.filter(e => mbmHolds(e, name, true)); if (caseExact.length === 1) exact = caseExact; else if (o.scoreGap) { const scored = exact.filter(e => typeof e.score === 'number').sort((a, b) => b.score - a.score); if (scored.length >= 2 && scored[0].score - scored[1].score >= o.scoreGap) exact = [scored[0]]; } } const offset = typeof json.offset === 'number' ? json.offset : 0; const complete = typeof json.count === 'number' && json.count <= offset + list.length; if (exact.length === 1 && complete) return { status: 'unique', hit: exact[0], via: mbmHolds(exact[0], name) === 'name' ? 'name' : 'alias', exact, complete }; if (exact.length > 1) return { status: 'ambiguous', exact, complete }; if (!complete) return { status: 'incomplete', exact, complete }; return { status: 'none', exact, complete }; } function mbmRelatedArtists(artistJson) { if (!artistJson || !artistJson.id) return []; const out = [{ gid: artistJson.id, name: artistJson.name || '', aliases: (artistJson.aliases || []).map(a => a && a.name).filter(Boolean), rel: 'self' }]; for (const r of artistJson.relations || []) { const a = r && r.artist; if (!a || !a.id || out.some(x => x.gid === a.id)) continue; out.push({ gid: a.id, name: a.name || '', aliases: [], rel: r.type || '' }); } return out; } function mbmContextHolders(related, name, candidates) { const cand = new Map((candidates || []).map(c => [c.id || c.gid, c])); const out = []; for (const r of related || []) { let via = mbmSameName(r.name, name) ? 'name' : (r.aliases || []).some(a => mbmSameName(a, name)) ? 'alias' : null; if (!via) { const c = cand.get(r.gid); if (c && mbmHolds(c, name)) via = mbmHolds(c, name); } if (via && !out.some(x => x.gid === r.gid)) out.push({ gid: r.gid, name: r.name, via, rel: r.rel }); } return out; } function mbmCoCreditHits(recordingsJson, ctxGid, name) { const out = []; for (const rec of (recordingsJson && recordingsJson.recordings) || []) { for (const c of rec['artist-credit'] || []) { const a = c && c.artist; if (!a || !a.id || a.id === ctxGid) continue; if ((mbmSameName(c.name, name) || mbmSameName(a.name, name)) && !out.some(x => x.gid === a.id)) out.push({ gid: a.id, name: a.name }); } } return out; } function mbmGuessSortName(name) { if (!name || !name.trim()) return name; name = name.trim().replace(/\s+/g, ' '); // names in other scripts are not "given family" in Latin order: leave them to the editor if (/[^\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]/u.test(name)) return name; const words = name.split(' '); if (words.length === 1) return name; const article = name.match(/^(the|a|an)\s+(.+)$/i); if (article) return article[2] + ', ' + article[1].charAt(0).toUpperCase() + article[1].slice(1).toLowerCase(); let base = name, suffix = ''; const sfx = name.match(/^(.*?),?\s+(jr\.?|sr\.?|ii|iii|iv|v|esq\.?)$/i); if (sfx) { base = sfx[1].trim(); suffix = ' ' + sfx[2]; } const parts = base.split(' '); if (parts.length === 1) return name; return parts[parts.length - 1] + ', ' + parts.slice(0, -1).join(' ') + suffix; } // </ST-MATCH> const txpFold = s => (s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').toLowerCase().replace(/\s+/g, ' ').trim(); const txpSameName = (a, b) => txpFold(a) === txpFold(b); // resolve an MBID to a full entity (incl. the numeric id dispatchRelationship needs) async function txpFetchEntity(gid, fallbackType) { try { const j = await mbnFetch(`/ws/js/entity/${gid}`, { headers: { Accept: 'application/json' } }).then(r => r.json()); if (j && j.gid) { if (!j.entityType) j.entityType = fallbackType || 'artist'; return j; } } catch (e) {} return null; } const _txpSearchCache = new Map(); async function txpSearchArtist(name, limit) { limit = limit || 8; const k = txpFold(name) + '|' + limit; if (!txpFold(name)) return []; if (_txpSearchCache.has(k)) return _txpSearchCache.get(k); let list = []; try { const j = await mbnFetch(`/ws/js/artist?q=${encodeURIComponent(name)}&limit=${limit}&direct=false`, { headers: { Accept: 'application/json' } }).then(r => r.json()); list = Array.isArray(j) ? j : (j.results || []); } catch (e) {} list = list.filter(c => c && (c.name || '').trim()); // drop the trailing empty placeholder entry _txpSearchCache.set(k, list); return list; } // the unified exact-identity resolver (ported from Apollo's #442/#445): resolves a // credited name to the unique MB artist that carries it as an exact NAME or ALIAS, // accepted ONLY when exactly one artist across name+alias space has it. Returns // {entity, via} ('name'|'alias'), or null when ambiguous/no match. const _txpAliasCache = new Map(); // #522 follow-up (majkinetor, live): "Why is this label not auto resolved // as it seems like a single name match?" (© 2004 Geffen Records / ℗ 2015 // Geffen Records) — live-verified against production: MB genuinely has // TWO labels named exactly "Geffen Records" (the real one, plus a // "bootleg version of the real Geffen Records label" duplicate), so // refusing as ambiguous was technically correct. But MB's own search // relevance score already separates them clearly (100 vs 45) — a decisive // gap is tried as a last narrowing step before giving up as ambiguous. function txpNarrowByScore(exact) { const scored = exact.filter(a => typeof a.score === 'number').sort((a, b) => b.score - a.score); if (scored.length < 2) return exact; return (scored[0].score - scored[1].score >= 20) ? [scored[0]] : exact; } async function txpResolveByExactAlias(name) { const key = txpFold(name); if (!key) return null; if (_txpAliasCache.has(key)) return _txpAliasCache.get(key); const q = mbmIdentityQuery(name, 'artist'); if (!q) return null; let arr = null; try { arr = await mbnFetch(`/ws/2/artist?query=${encodeURIComponent(q)}&fmt=json&limit=${MBM_EXACT_LIMIT}`, { headers: { Accept: 'application/json' } }).then(r => r.json()); } catch (e) { return null; } // transient → don't cache, let a later pass retry if (!arr || arr.error || typeof arr.count !== 'number') return null; // throttled / error envelope — not an answer, don't cache // #613: unique only when MB returned EVERY match (a common name is never provably // unique — the search doesn't rank exact holders first). #522's score-gap tie-break kept. const idn = mbmExactIdentity(arr, name, { scoreGap: 20 }); let out = null; if (idn.status === 'unique') { const ent = await txpFetchEntity(idn.hit.id); if (ent && ent.gid) out = { entity: ent, via: idn.via }; } _txpAliasCache.set(key, out); return out; } // label counterparts (#522 follow-up: copyright-notice holders are usually // labels, occasionally the release artist itself) — same shape, /ws/js/label // and /ws/2/label instead of artist. const _txpLabelSearchCache = new Map(); async function txpSearchLabel(name, limit) { limit = limit || 8; const k = txpFold(name) + '|' + limit; if (!txpFold(name)) return []; if (_txpLabelSearchCache.has(k)) return _txpLabelSearchCache.get(k); let list = []; try { const j = await mbnFetch(`/ws/js/label?q=${encodeURIComponent(name)}&limit=${limit}&direct=false`, { headers: { Accept: 'application/json' } }).then(r => r.json()); list = Array.isArray(j) ? j : (j.results || []); } catch (e) {} list = list.filter(c => c && (c.name || '').trim()); _txpLabelSearchCache.set(k, list); return list; } // #525 (majkinetor): "We should have Place entity too, along with adequate // roles and tab on picker." Places are how liner notes credit studios — // "Recorded At Abbey Road" — and MB models those as place<->release link // types ("recorded at", "mixed at", "mastered at", …), a different set from // the artist/label ones. Same search shape as labels. const _txpPlaceSearchCache = new Map(); async function txpSearchPlace(name, limit) { limit = limit || 8; const k = txpFold(name) + '|' + limit; if (!txpFold(name)) return []; if (_txpPlaceSearchCache.has(k)) return _txpPlaceSearchCache.get(k); let list = []; try { const j = await mbnFetch(`/ws/js/place?q=${encodeURIComponent(name)}&limit=${limit}&direct=false`, { headers: { Accept: 'application/json' } }).then(r => r.json()); list = Array.isArray(j) ? j : (j.results || []); } catch (e) {} list = list.filter(c => c && (c.name || '').trim()); _txpPlaceSearchCache.set(k, list); return list; } const _txpLabelAliasCache = new Map(); async function txpResolveLabelByExactAlias(name) { const key = txpFold(name); if (!key) return null; if (_txpLabelAliasCache.has(key)) return _txpLabelAliasCache.get(key); const q = mbmIdentityQuery(name, 'label'); if (!q) return null; let arr = null; try { arr = await mbnFetch(`/ws/2/label?query=${encodeURIComponent(q)}&fmt=json&limit=${MBM_EXACT_LIMIT}`, { headers: { Accept: 'application/json' } }).then(r => r.json()); } catch (e) { return null; } // transient → don't cache, let a later pass retry if (!arr || arr.error || typeof arr.count !== 'number') return null; // throttled / error envelope — not an answer, don't cache // #613: unique only when MB returned EVERY match (a common name is never provably // unique — the search doesn't rank exact holders first). #522's score-gap tie-break kept. const idn = mbmExactIdentity(arr, name, { scoreGap: 20 }); let out = null; if (idn.status === 'unique') { const ent = await txpFetchEntity(idn.hit.id, 'label'); if (ent && ent.gid) out = { entity: ent, via: idn.via }; } _txpLabelAliasCache.set(key, out); return out; } // #522 follow-up (majkinetor): "take a look at kellnerd's parse-copyright-notice // and incorporate some form of copyright note parsing" — copyright/phonographic // notices have their own fixed shape (a symbol/word, a year, a holder), not the // R/A credit DSL. "phonographic copyright" contains the word "copyright" too, so // its markers are stripped before checking for a bare © — otherwise every ℗ line // would also register as a (wrong) plain © hit. // #524 follow-up (majkinetor): "distributed by and friends, that would be // some improvement" — kellnerd's musicbrainz-scripts wiki (Parse-Copyright- // Notices) treats these as the same class of notice as ©/℗: a marker // phrase, an optional holder, all label→release (or artist→release, see // TXP_CR_LABEL_ONLY below) relationship types. "licensed to X" → X is the // MB "licensee"; "licensed from X" → X is the MB "licensor" (live-verified // against linkTypesForPair — "licensor"/"licensee" are MB's actual names, // not "licensed from/to"). "marketed and distributed by X" fires BOTH // "marketed" and "distributed" against the same X. const TXP_CR_TYPE_NAME = { phonographic: 'phonographic copyright', copyright: 'copyright', licensee: 'licensee', licensor: 'licensor', distributed: 'distributed', marketed: 'marketed', }; const TXP_CR_LABELS = { phonographic: '℗ phonographic copyright', copyright: '© copyright', licensee: 'licensed to', licensor: 'licensed from', distributed: 'distributed by', marketed: 'marketed by', }; // distributor/marketer/licensee are company-only concepts — MB has no // artist-release relationship type for any of them at all (live-verified); // copyright/phonographic/licensor exist for both, see txpCrEntityType. const TXP_CR_LABEL_ONLY = new Set(['distributed', 'marketed', 'licensee']); // leading-marker phrase alternatives — the compound "marketed and // distributed by" is listed first so it isn't partially eaten by the // plainer "distributed by" / "marketed by" alternatives first. const TXP_CR_MARKER_ALT = '(?:marketed\\s+and\\s+distributed\\s+by' + '|under\\s+(?:exclusive\\s+)?licen[sc]e\\s+to|licen[sc]ed\\s+to' + '|under\\s+(?:exclusive\\s+)?licen[sc]e\\s+from|licen[sc]ed\\s+from' + '|distributed\\s+by|marketed\\s+by' + '|℗|\\(p\\)|phonographic\\s+copyright' + '|©|\\(c\\)|copyright)'; function txpParseCopyrightLine(line) { const s = String(line).trim(); const types = []; if (/℗|\(p\)|\bphonographic\s+copyright\b/i.test(s)) types.push('phonographic'); const sansPhono = s.replace(/℗|\(p\)|\bphonographic\s+copyright\b/gi, ' '); if (/©|\(c\)|\bcopyright\b/i.test(sansPhono)) types.push('copyright'); if (/\bunder\s+(?:exclusive\s+)?licen[sc]e\s+to\b|\blicen[sc]ed\s+to\b/i.test(s)) types.push('licensee'); if (/\bunder\s+(?:exclusive\s+)?licen[sc]e\s+from\b|\blicen[sc]ed\s+from\b/i.test(s)) types.push('licensor'); if (/\bmarketed\s+and\s+distributed\s+by\b/i.test(s)) { types.push('marketed'); types.push('distributed'); } else { if (/\bdistributed\s+by\b/i.test(s)) types.push('distributed'); if (/\bmarketed\s+by\b/i.test(s)) types.push('marketed'); } if (!types.length) return null; // #524 follow-up (majkinetor, live): "℗ & © «R&S Records»" and // "© «R&S Records»" have no year at all and were silently rejected — a // year used to be required just to anchor where the holder text starts. // Strip the LEADING marker cluster instead and anchor on THAT; the year // (if any) is now optional, pulled out from wherever it sits in what's // left. Only the leading cluster — never markers ANYWHERE in the line — // a first cut that stripped "copyright" globally also ate it out of a // holder name that legitimately contained the word ("Some Copyright // Test Label"). One marker (+ its own leading glue) at a time, repeated, // since "℗ & ©" is two markers joined by "&". // #525 follow-up (majkinetor, live, screenshot): "Distributed By – // Rush Hour Music" left a stray leading "–" in the holder text — the // glue-stripping char classes below only covered the plain ASCII // hyphen, not the en/em-dash (and U+2010 hyphen) variants majkinetor's // own credit format actually uses as a separator after the marker // phrase. TXP_CR_GLUE matches the same dash set TXP_SEPS already // treats as equivalent separators elsewhere in this file. const TXP_CR_GLUE = '\\s,.:;&+/‐–—-'; const LEAD_MARKER_RE = new RegExp(`^(?:[${TXP_CR_GLUE}]|and\\b)*${TXP_CR_MARKER_ALT}`, 'i'); let rest = s; while (LEAD_MARKER_RE.test(rest)) rest = rest.replace(LEAD_MARKER_RE, ''); // strip leading glue AND a leading wrapper quote/guillemet before // hunting for the year — otherwise "℗ «1995 R&S Records»" keeps the "«" // in the way and the year never reads as adjacent to the marker. const LEAD_GLUE_RE = new RegExp(`^[${TXP_CR_GLUE}]+`, 'i'); rest = rest.replace(LEAD_GLUE_RE, '').replace(/^[«"“”'’‘]+/, '').replace(LEAD_GLUE_RE, ''); // #524 follow-up: the year (or year LIST, "1994, 1996" / "1994 & 1996") // is only ever expected immediately after the marker, before the holder // name starts — NOT scanned for throughout the rest of the line, which // would also strip a year that's genuinely part of a holder's own name // ("Pink Floyd (1987) Ltd."). Multiple years are ambiguous (which one // is THE year?) so, same call kellnerd's musicbrainz-scripts makes, // none is kept — but they're still consumed so they don't leak into // the holder text. const yearListRe = /^(\d{4})(?:\s*[,&/+]\s*(\d{4}))*\s*/; const ym = rest.match(yearListRe); let year = null; if (ym) { const allYears = ym[0].match(/\d{4}/g); year = allYears.length === 1 ? allYears[0] : null; rest = rest.slice(ym[0].length); } // strip leading connective glue ("&", "and", punctuation) then any // wrapping quote marks — plain quotes AND guillemets («»), since // "R&S Records" arrived quoted that way and used to leak a stray "»". let holderText = rest.replace(LEAD_GLUE_RE, '').replace(/^and\s+/i, ''); holderText = holderText.replace(/^[«"“”'’‘]+/, '').replace(/[»"“”'’‘]+$/, ''); holderText = holderText.replace(/\s+/g, ' ').trim(); if (!holderText) return null; // #524 follow-up: "distributed by and friends" also raised multi-holder // splits ("Shady Records/Aftermath Records/Interscope Records") — split // on / and | (not "-": far more often part of a real single name than a // separator), but only when EVERY resulting piece has enough substance // (kellnerd's own guard, refined to >=3 word characters after finding // 2 too permissive for "EMI Belgium SA/NV") — otherwise a real name // like "SA/NV" gets wrongly chopped into two fake holders. const splitParts = holderText.split(/\s*[/|]\s*/).map(p => p.trim()).filter(Boolean); const holders = (splitParts.length > 1 && splitParts.every(p => (p.match(/\w/g) || []).length >= 3)) ? splitParts : [holderText]; return { types, year, holders }; } // #574 (majkinetor): the date period a parsed notice year turns into. A notice // year is a POINT in time, not an open range: filling only begin_date made MB // render the relationship as "from 2021 to present", where what it should say // is "in 2021". Begin = end, with ended set, is what produces that. // // This is MB's own documented guideline for these relationship types, not our // reading of them (chaban-mb on #574) — verbatim, on the Label-Release // Copyright type this parser actually writes: // "When a year is specified (as in "© 2015 Naxos Rights US, Inc."), use that // year as both the begin and end date." // musicbrainz.org/relationship/2ed5a497-4f85-4b3f-831e-d341ad28c544 (©) // musicbrainz.org/relationship/7fd5fbc0-fbf4-4d04-be23-417d50a4dc30 (℗) // It is also the call kellnerd's parse-copyright-notice makes — its setYear() // sets begin_date, end_date and ended together. // // Applies to every notice kind, not only ©/℗: a year is only ever parsed off a // notice line in the first place (txpParseCopyrightLine), an ordinary R/A credit // row carries none, and licensed/distributed/marketed come off that same line // with that same single year. kellnerd draws the line in the same place. // // Returns null for a missing or unparseable year, so callers can pass it // straight through to dispatchRelationship as "no dates". const txpNoticeDatePeriod = year => { const y = parseInt(year, 10); if (!y) return null; return { begin_date: { year: y, month: null, day: null }, end_date: { year: y, month: null, day: null }, ended: true }; }; // #528 (majkinetor): "Copyright: X under exclusive license to Y" packs TWO // holders (X the copyright holder, Y the licensee) into one line — // txpParseCopyrightLine above strips only the LEADING marker, so both // derived rows (copyright + licensee) get the SAME undifferentiated // remainder as their holder text, and the right-click "clean up to // canonical name" fix (#525) then destructively rewrites BOTH rows since // they share identical raw text. Rather than teach the parser an "ordered // marker positions" model, PRE-SPLIT the raw line into two lines — each // with its own single marker — so the unmodified pipeline above just // handles each one normally. Deliberately mechanical, not semantic: // majkinetor, live: "you don't know where the X ends... this is enough // and right click will clean the suffixes" — this doesn't try to // understand where holder1 "ends" grammatically, it just cuts at the // NEXT recognized marker's own start (so both halves stay independently // parseable without any regex changes), accepting that leftover glue is // right-click's job, not this function's. Called ONCE on paste / // annotation-load only (see the 'paste' listener and annoBtn.onclick // below) — never on ordinary typing, so it doesn't fight a mid-edit user // (majkinetor: "Once on load"). function txpSplitCompoundCopyrightLines(text) { const markerRe = new RegExp(TXP_CR_MARKER_ALT, 'gi'); return text.split('\n').map(line => { const positions = [...line.matchAll(markerRe)].map(m => ({ start: m.index, end: m.index + m[0].length })); if (positions.length < 2) return line; // 0 or 1 marker (incl. one compound phrase) — nothing to split const cuts = []; for (let i = 1; i < positions.length; i++) { // real substance between the PREVIOUS marker's end and this one's // start — otherwise it's glue/punctuation, not a genuine second // holder (e.g. two adjacent symbols like "℗©" sit right next to // each other with nothing real between them). const gap = line.slice(positions[i - 1].end, positions[i].start); if ((gap.match(/\w/g) || []).length >= 3) cuts.push(positions[i].start); } if (!cuts.length) return line; const out = []; let last = 0; for (const idx of cuts) { out.push(line.slice(last, idx).trim()); last = idx; } out.push(line.slice(last).trim()); return out.join('\n'); }).join('\n'); } // ── annotation loading (#522): "make sure that tool can also load annotation as a source // as that is where credits may end up after import." MB's public /ws/2/ API doesn't expose // raw annotation text — ported (collapsed to just the LATEST version) from Apollo's own // annoFetchHistory/annoFetchVersion two-hop scrape, same-origin. function txpAnnoHtmlToText(html) { if (!html || /this annotation is empty/i.test(html)) return ''; const root = new DOMParser().parseFromString('<div>' + html + '</div>', 'text/html').body.firstChild; // turn block-level boundaries into newlines before reading textContent — the parser // works one credit per LINE, so paragraph/list/heading boundaries matter here. root.querySelectorAll('br').forEach(br => br.replaceWith('\n')); root.querySelectorAll('p, li, div, h1, h2, h3, tr').forEach(b => b.insertAdjacentText('afterend', '\n')); const text = root.textContent || ''; return text.split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n'); } async function txpFetchAnnotation(mbid) { const r = await fetch(`/release/${mbid}/annotations`, { credentials: 'same-origin' }); if (!r.ok) throw new Error('history ' + r.status); const doc = new DOMParser().parseFromString(await r.text(), 'text/html'); // the newest version's "View this version" link, #722 by its address (MB translates the text) const view = doc.querySelector('#annotation-history a[href*="/annotation/"]') || [...doc.querySelectorAll('table tr a')].find(a => /this version/i.test(a.textContent || '')); if (!view) return ''; // no annotation history at all const vr = await fetch(new URL(view.getAttribute('href'), location.origin).href, { credentials: 'same-origin' }); if (!vr.ok) throw new Error('version ' + vr.status); const vdoc = new DOMParser().parseFromString(await vr.text(), 'text/html'); const body = vdoc.querySelector('.annotation-body'); return body ? txpAnnoHtmlToText(body.innerHTML) : ''; } // ── Text parser modal (#522) ──────────────────────────────────────────── function txpStyle() { if (document.getElementById('gt-tp-style')) return; const s = el('style'); s.id = 'gt-tp-style'; s.textContent = '.gt-cons.gt-tp{width:min(1040px,96vw);resize:both;overflow:hidden;max-width:98vw;max-height:94vh;min-width:640px;min-height:340px}' + '.gt-cons.gt-tp.gt-tp-max{position:fixed;left:10px;right:10px;top:10px;bottom:10px;width:auto;height:auto;max-width:none;max-height:none;margin:0}' + '.gt-tp-ctrl{display:flex;align-items:center;gap:6px;padding:8px 14px;border-bottom:1px solid var(--mbu-border);flex-wrap:wrap}' // #539 follow-up (majkinetor): "With enough tracks, Match button goes to // next row" — a long "→ 12 tracks: 1, 2, 3 …" pushed the button off the // end. Scope and Match now travel as ONE right-hand group that shrinks // (the track list truncates) instead of wrapping the button away. + '.gt-cons-foot .gt-tp-scope{margin-right:auto}' + '.gt-tp-pat{width:180px;padding:5px 8px;border:1px solid var(--mbu-border);border-radius:5px;font:13px monospace;outline:none}' + '.gt-tp-pat:focus{border-color:var(--mbu-info)}' + '.gt-tp-clr{background:none;border:none;color:var(--mbu-text-weak);cursor:pointer;padding:2px 4px}.gt-tp-clr:hover{color:var(--mbu-text-dim)}' + '.gt-tp-presets{display:flex;gap:4px;flex-wrap:wrap}' + '.gt-tp-chip{font:11px monospace;background:var(--mbu-bg-raised);border:1px solid var(--mbu-border);border-radius:11px;padding:2px 9px;cursor:pointer;color:var(--mbu-text)}.gt-tp-chip:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-info)}' + '.gt-tp-scope{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--mbu-text-dim);background:var(--mbu-bg-raised);border-radius:11px;padding:3px 10px;min-width:0}' + '.gt-tp-scope-lbl{color:var(--mbu-text-weak)}' + '.gt-tp-scope-sel,.gt-tp-det-sel{font:inherit;font-size:11px;border:1px solid var(--mbu-border);border-radius:5px;background:var(--mbu-bg);padding:1px 3px}' + '.gt-tp-tracks{font:inherit;font-size:11px;width:130px;border:1px solid var(--mbu-border);border-radius:5px;padding:1px 6px}' + '.gt-tp-tracks-info{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;min-width:0}' + '.gt-cons-hdr .gt-tp-anno{margin-left:auto;padding:4px 10px;border:1px solid var(--mbu-border);border-radius:5px;background:var(--mbu-bg);cursor:pointer;font-size:12px;font-family:inherit;color:var(--mbu-text)}.gt-cons-hdr .gt-tp-anno:hover{background:var(--mbu-bg-raised)}' + '.gt-tp-src{padding:0 14px}' + '.gt-tp-srctgl{display:block;background:none;border:none;color:var(--mbu-info);cursor:pointer;font-size:12px;font-family:inherit;padding:6px 0}' + '.gt-tp-ta{width:100%;box-sizing:border-box;min-height:70px;padding:6px 8px;border:1px solid var(--mbu-border);border-radius:5px;font:12px monospace;outline:none;resize:vertical}.gt-tp-ta:focus{border-color:var(--mbu-info)}' + '.gt-tp-body{padding-top:8px;overflow:auto}' // #522 follow-up (majkinetor, live): the column widths' own sum was // meant to roughly fit the panel, but td/th padding defaults to // content-box (added ON TOP of each <col>'s specified width, not // absorbed into it) — border-box makes the specified width the // actual rendered width, padding included, which is what a resize // handle dragged to an exact pixel amount should mean anyway. + '.gt-tp-tbl{table-layout:fixed}' + '.gt-tp-tbl th,.gt-tp-tbl td{box-sizing:border-box}' + '.gt-tp-tbl th{white-space:nowrap;position:relative;overflow:hidden;text-overflow:ellipsis}' + '.gt-tp-tbl td{vertical-align:top;padding:3px 8px;font-size:12px;border-bottom:1px solid var(--mbu-border);overflow:hidden;text-overflow:ellipsis}' /* #597 the "tracks" column — where this one credit is going */ + '.gt-tp-trk{font-size:11px;white-space:nowrap}' + '.gt-tp-trk-on{color:var(--mbu-ok);font-weight:600;cursor:help}' + '.gt-tp-trk-warn{color:var(--mbu-warn);cursor:help}' + '.gt-tp-trk-dim{color:var(--mbu-text-weak)}' // #522 follow-up (majkinetor, live): "Column bars are not visible (so // it hard to resize)" then "column separators are very fat now, make // them line" — a permanent but THIN (1px) line, drawn via a // pseudo-element inset inside a wider (7px) invisible drag target; // both stay within right:0's own box (not a negative offset) so a // th's own overflow:hidden (for its text-overflow ellipsis) doesn't // clip either of them. + '.gt-tp-colresize{position:absolute;top:0;right:0;width:7px;height:100%;cursor:col-resize;z-index:2}' + '.gt-tp-colresize::after{content:"";position:absolute;top:0;bottom:0;right:3px;width:1px;background:var(--mbu-bg-sunken)}' + '.gt-tp-colresize:hover::after{background:var(--mbu-info-bg)}' + '.gt-tp-row.gt-tp-nomatch{opacity:.55}' // #522 sixth round (majkinetor, live): "We still don't have // non-intrusive raw background color (like in CH)" — Credit // Hoarder tints its whole review-table ROW by status (a soft // yellow for "needs a look", soft red for "needs attention", white // once clean), not just a small dot; same idea here, keyed off the // same status the dot already uses. + '.gt-tp-row.gt-tp-st-amber{background:var(--mbu-warn-bg)}' + '.gt-tp-row.gt-tp-st-red{background:var(--mbu-error-bg)}' + '.gt-tp-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-top:3px}' + '.gt-tp-dot-green{background:#2e9e5b}.gt-tp-dot-amber{background:#d68910}.gt-tp-dot-red{background:#c0392b}' + '.gt-tp-ov{box-sizing:border-box;width:100%;min-width:0;padding:3px 6px;border:1px solid var(--mbu-border);border-radius:4px;font:11px monospace;outline:none}.gt-tp-ov:focus{border-color:var(--mbu-info)}' /* #544: track the column instead of a fixed 110px, so a narrow column cannot clip the caret */ + '.gt-tp-rawwrap{display:flex;align-items:center;gap:4px;width:100%}' + '.gt-tp-raw{flex:1;min-width:0;padding:3px 6px;border:1px solid transparent;border-radius:4px;background:none;color:var(--mbu-text-dim);font-size:11px;font-family:inherit;outline:none}.gt-tp-raw:hover{border-color:var(--mbu-border)}.gt-tp-raw:focus{border-color:var(--mbu-info);background:var(--mbu-bg)}' + '.gt-tp-rowdel{flex:none;background:none;border:none;color:var(--mbu-info);cursor:pointer;padding:2px 4px;font-size:12px}.gt-tp-rowdel:hover{color:var(--mbu-error)}' + '.gt-tp-txt{color:var(--mbu-text)}' // #522 follow-up (majkinetor, live): "Tidy up artist / role column - // remove circles, short helper text so it doesn't reflow (use just // search word in both), plain text after selection. Remove [↗] icon, // let left click open artist." Unresolved = a plain text-link reading // "search"; resolved = plain text (a real <a> for artist, so the // click IS the open — no separate icon). + '.gt-tp-search{background:none;border:none;padding:0;cursor:pointer;color:var(--mbu-info);text-decoration:underline;font-size:11px;font-family:inherit}.gt-tp-search:hover{color:var(--mbu-info)}' + '.gt-tp-resolved{color:var(--mbu-text)}' + 'a.gt-tp-resolved,button.gt-tp-resolved{color:var(--mbu-info);text-decoration:none}a.gt-tp-resolved:hover,button.gt-tp-resolved:hover{text-decoration:underline;color:var(--mbu-info)}' + '.gt-tp-status{color:var(--mbu-text-weak);font-size:11px;white-space:nowrap}' + '.gt-tp-applied{color:var(--mbu-ok);font-weight:600}' + '.gt-cons-foot .gt-tp-cnt{flex:1;font-size:12px;color:var(--mbu-text-dim)}' // #525: moved up into .gt-tp-ctrl (top bar, right-aligned after the // Scope pill via its margin-left:auto) to match Match Works' own // toolbar convention — same bold-purple "primary" treatment as its // ⚡ Match button (.gt-wm-btn.primary). + '.gt-tp-freeze{flex:0 0 auto;cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);border-radius:4px;padding:3px 7px;font-size:12px;line-height:1}' + '.gt-tp-freeze:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)}' + '.gt-tp-ctrl .gt-tp-resolve{margin-left:auto;flex:0 0 auto;white-space:nowrap;padding:5px 12px;border:1px solid transparent;border-radius:5px;background:transparent;cursor:pointer;font-size:13px;font-family:inherit;color:var(--mbu-accent-text);font-weight:bold}' + '.gt-tp-ctrl .gt-tp-resolve:hover{background:linear-gradient(#7a52df,#5f3ec0);color:#fff;border-color:var(--mbu-accent)}' + '.gt-tp-ctrl .gt-tp-resolve:disabled{opacity:.45;cursor:default;pointer-events:none}' + '.gt-tp-apop{width:340px}' // #524/#525 follow-up (majkinetor): "2 tabs in search, one for artist // and other for labels" — shown for any row whose entity type isn't // FORCED (see canToggle in txpPickEntity): copyright/phonographic/ // licensor holders, and ordinary roles MB allows on both sides. + '.gt-tp-apop .gt-tp-tabs{display:flex;gap:4px;margin-bottom:6px}' + '.gt-tp-apop .gt-tp-tab{flex:1;padding:4px 0;border:1px solid var(--mbu-border);border-radius:5px;background:var(--mbu-bg);cursor:pointer;font-size:12px;font-family:inherit;color:var(--mbu-text-dim)}' + '.gt-tp-apop .gt-tp-tab:hover{background:var(--mbu-bg-raised)}' + '.gt-tp-apop .gt-tp-tab-on{background:#2e6da4;border-color:var(--mbu-info);color:#fff}' + '.gt-tp-apop .gt-tp-qwrap{display:flex;gap:5px;margin-bottom:6px}' + '.gt-tp-apop .gt-tp-q{flex:1;min-width:0;box-sizing:border-box;padding:5px 7px;border:1px solid var(--mbu-border);border-radius:5px;font-size:13px;font-family:inherit;outline:none}' + '.gt-tp-apop .gt-tp-plus{flex:0 0 auto;width:28px;box-sizing:border-box;border:1px solid var(--mbu-border);border-radius:5px;background:var(--mbu-bg);cursor:pointer;font:15px monospace;color:var(--mbu-info);line-height:1}.gt-tp-apop .gt-tp-plus:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-info)}' + '.gt-tp-apop .gt-tp-hint{color:var(--mbu-text-weak);font-size:10px;margin-bottom:4px}' + '.gt-tp-apop .gt-tp-results{max-height:260px;overflow:auto}' + '.gt-tp-apop .gt-tp-res{padding:5px 7px;border-radius:5px;cursor:pointer}.gt-tp-apop .gt-tp-res:hover{background:var(--mbu-info-bg)}' + '.gt-tp-apop .gt-tp-restype{color:var(--mbu-text-weak);font-size:10px;text-transform:uppercase;margin-right:5px}' // #544: disambiguation must not read as part of the name — smaller, grey, // italic, in the results AND in the resolved table cell. + '.gt-tp-disamb{color:var(--mbu-text-weak);font-size:11px;font-style:italic;font-weight:400}' + '.gt-tp-apop .gt-tp-resname{color:var(--mbu-text)}' // #544: a background create no longer holds the popover open — the row says // what it is waiting for, and pulses so "still working" is visible at a glance. // nowrap + ellipsis: the entity column is narrow and a two-line placeholder // pushed every row taller — the name is already trimmed, this is the backstop + '.gt-tp-creating{display:inline-block;max-width:100%;box-sizing:border-box;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom;color:var(--mbu-accent-text);font-size:11px;font-style:italic;border:none;background:none;font-family:inherit;cursor:pointer;border-radius:3px;padding:0 4px;animation:gt-tp-pulse 1.15s ease-in-out infinite}' + '.gt-tp-creating:hover{color:var(--mbu-error);text-decoration:underline}' // #544: it cancels + '@keyframes gt-tp-pulse{0%,100%{background:transparent}50%{background:var(--mbu-bg-hover)}}' + '@media (prefers-reduced-motion:reduce){.gt-tp-creating{animation:none;background:var(--mbu-bg-raised)}}'; document.head.appendChild(s); } let txpEl = null; // #522 follow-up (majkinetor, live): "Esc during role selection closes main // popup" — a nested picker (the role picker's own overlay, or the shared // artist/label-search popover) should close on Escape WITHOUT taking the // whole tool down too. Both this and the nested pickers' own Escape // handling are registered on `document` with capture:true — when a nested // picker is open, just return (don't stopPropagation) so its own handler, // registered later, still gets its turn on the same event. function onTxpKey(e) { if (e.key !== 'Escape') return; if (document.querySelector('.gt-role-pick') || popEl) return; e.stopPropagation(); closeTextParser(); } // #544 follow-up (majkinetor): "Maximized state is still not remembered." // saveState() was only ever called by the actions that CHANGE row data — // maximizing, or loading the annotation, changed the window and saved nothing, // so whether they survived a close depended entirely on whether some unrelated // save happened to fire afterwards. (That is also why the round-trip test // passed: its freeze click saved right after the maximize.) Saving once on the // way out makes the rule "what you last saw is what comes back", regardless of // which control was touched last. The parser registers its own saver here, // since saveState lives inside openTextParser's closure. let _txpSaveOnClose = null; function closeTextParser() { if (!txpEl) return; try { if (_txpSaveOnClose) _txpSaveOnClose(); } catch (e) {} _txpSaveOnClose = null; txpEl.remove(); txpEl = null; document.removeEventListener('keydown', onTxpKey, true); } // #522 follow-up: "it would be good to keep window state after closing it // on particular release" — remembers the pasted text / pattern per release // gid. Deliberately an in-memory object, NOT a GM value: majkinetor // (live, follow-up): "restarting popup should keep the state only within // current session. If I reload the page, it should not." A plain // module-level object already does exactly that — it survives the popup's // own DOM being torn down and rebuilt (same page, same JS context) but // resets for free on a real reload (fresh script injection). const _txpStateMemory = {}; function txpLoadState(gid) { return _txpStateMemory[gid] || null; } function txpSaveState(gid, state) { _txpStateMemory[gid] = state; const keys = Object.keys(_txpStateMemory); if (keys.length > 30) delete _txpStateMemory[keys[0]]; } // #522 follow-up (majkinetor): "auto resolve could find similar roles // (compiled -> compiler; mastered by -> mastering)". A crude but effective // stem: drop "by", drop a handful of common suffixes, so "mastered" and // "mastering" both reduce to "master", "compiled" and "compiler" to // "compil". Used only as a FALLBACK when a plain substring match is // ambiguous or absent, and only auto-binds when it narrows to one. function txpRoleStem(s) { return String(s || '').toLowerCase().replace(/\bby\b/g, ' ').replace(/\s+/g, ' ').trim() .replace(/(ies)$/, 'y').replace(/(ations?|ing|ers?|ed|es|s)$/, ''); } function openTextParser() { closeTextParser(); closeDatePicker(); closeConsolidate(); closePopover(); txpStyle(); const re0 = RE(); const release = re0 && re0.state && re0.state.entity; if (!release) { toast('Editor not ready'); return; } const saved = txpLoadState(release.gid); let pattern = (saved && saved.pattern) || _txpPattern; // #539 (majkinetor): "Currently we have Scope: Release only … This could be // done faster if user is allowed to select recording by track number(s)." // He was applying credits to the release and then moving them onto one // recording, four times over. `spec` is a track selector (see txpMatchTracks) // and only means anything while kind === 'recording'. // #597: `tracks` is the per-row track detection mode — 'auto' reads a track // clause out of each credit's own role text (see txpDetectTracks), 'off' // restores the pre-#597 behaviour where a row can only ever go where the // Scope control says. On by default: the clause is there to be obeyed, and // a run that silently ignores it is the bug this fixes. let scope = { kind: (saved && saved.scopeKind) || 'release', spec: (saved && saved.scopeSpec) || '', tracks: (saved && saved.scopeTracks) || 'auto' }; let lines = []; // [{ raw, override }] // #522 follow-up (majkinetor): drives whether "Apply & clear annotation" // is even offered — only true right after "Load annotation" succeeds, // reset by any real typing into the textarea (see the dedicated 'input' // listener below; a *programmatic* ta.value set does NOT fire it). let loadedFromAnnotation = false; // both auto-resolved (resolveAll) AND manual (a picker choice) resolutions // live in these same two caches, keyed by the row's own TEXT — not row // POSITION. Position-keying was tried first and had a real bug: pasting // brand-new text reuses the same li:pi:si row positions, so an OLD // manual pick silently "leaked" onto completely unrelated NEW text at // that same position (caught live: "Alice" inherited a stale artist // pick left over from an earlier, different paste). Text-keying matches // how the auto-resolve cache already behaved, and means "I picked X for // 'Alice'" sticks to 'Alice' wherever she appears, not to a row slot. const roleCache = new Map(); // lowercased role text -> classifyRoleText() result (ordinary rows only — crKind rows never touch this) const entityCache = new Map(); // lowercased holder/entity text -> entity | null // #522 follow-up (majkinetor): "while it is resolving, lets show a // message ... resolving 4/N" then "make it show in the button itself" — // resolveAll() updates the Resolve button's own text as it goes. // Scoped to just the two real-network-lookup loops (copyright/legal // holders + ordinary credit artists) — role resolution is synchronous/ // instant, counting it wouldn't make the "how much is left" number any // more useful. let resolveDone = 0, resolveTotal = 0; // #522 follow-up (majkinetor, live): "if one artist has multiple // instruments, selecting one selects all. Lets redo that thing so that // right clicking a choice in search sets all, and normal clicking only // that 1." A text-keyed cache is right for ROLE (a role name like // "Guitar" always means the same MB link type, so one pick should // apply everywhere it appears) but wrong for ARTIST by default (the // same name string is a common real collision) — so a normal click now // writes here, POSITION-keyed, scoped to just the one row clicked; // right-click still writes into the shared entityCache above, // propagating to every row with that exact text like before. const entityOverride = new Map(); // row.key (li:pi:si) -> entity // #544 (majkinetor): "After right clicking to create entity in the background, // close search popup. While background tab is active, add info about it // (`creating ...`) that resolves to entity name after tab is closed as usual. // ... The goal is me not waiting for tab to finish or manually closing it, as // I want to continue to other items while this one is being worked on." // Keyed by the same lowercased entity text as entityCache, because a // background create resolves in BULK — so every row showing that text says // "creating…" and every one of them flips to the entity when it lands. // Deliberately NOT persisted by saveState: the BroadcastChannel listener that // completes it dies with the page, so a restored "creating…" could never // resolve. After a reload the row honestly reads "search" again. const pendingCreates = new Map(); // lowercased entity text -> { kind, name } const appliedKeys = new Set(); // row position keys (li:pi:si) — "applied" IS about a specific dispatch, stays position-based const compiledCache = new Map(); const compiledFor = pat => { if (!compiledCache.has(pat)) { try { compiledCache.set(pat, txpCompile(pat)); } catch (e) { compiledCache.set(pat, null); } } return compiledCache.get(pat); }; // #522 follow-up (majkinetor): "one raw line can have multiple artists // and roles" — a line with a `;` is treated as several independent // credit pairs joined together (e.g. "Guitar: Alice; Bass: Bob"), each // parsed on its own; a line's pattern OVERRIDE still applies to every // piece of that same line uniformly. const splitPairs = raw => raw.includes(';') ? raw.split(/\s*;\s*/).filter(s => s.trim()) : [raw]; // #522 follow-up (majkinetor): "Copyright is always bundled together // with other credits. The line can be recognized by mentioned symbols // so that should be marker to switch mode." No separate Copyright mode // anymore — a ©/(C)/℗/(P) marker on a line/piece is itself the trigger, // checked before the normal R/A pattern, so a paste can freely mix // ordinary credits and copyright lines together. function parsedRows() { const out = []; // #597: read the page's track rows ONCE per parse, not once per credit — // txpTrackRows walks the whole editor table, and this runs on every // render. null when detection is off, which also skips the work entirely. const trackRows = scope.tracks === 'auto' ? txpTrackRows() : null; // Lift a track clause off a row's role text and hand the row its own // targets. The clause is REMOVED from the role, so everything downstream // (role matching, roleCache keying, the preview) sees "Lead vocals", not // "Lead vocals (tracks 2,6,9)". const attachTracks = (rec) => { if (!trackRows || !rec.role) return rec; const det = txpDetectTracks(rec.role, trackRows); if (!det) return rec; if (det.ambiguous) { rec.trackNote = `${det.ambiguous} track lists — split the line`; return rec; } if (det.clean) rec.role = det.clean; rec.trackSpec = det.spec; rec.trackTargets = det.targets; return rec; }; lines.forEach((ln, li) => { if (!ln.raw.trim()) return; // blank lines are silently skipped, not even shown unmatched splitPairs(ln.raw).forEach((piece, pi) => { const cr = txpParseCopyrightLine(piece); if (cr) { // #524: one row per (type × holder) combination — "marketed // and distributed by Sony" is 2 types, 1 holder = 2 rows; // "℗ 2012 Shady Records/Aftermath Records/..." is 1 type, 3 // holders = 3 rows, all sharing the same underlying notice. let si = 0; cr.types.forEach(kind => cr.holders.forEach(holder => out.push({ li, pi, si: si++, raw: ln.raw, role: TXP_CR_LABELS[kind], crKind: kind, entity: holder, year: cr.year, matched: true, }))); return; } const pat = (ln.override || pattern || '').trim(); const compiled = pat ? compiledFor(pat) : null; const expanded = compiled ? txpExpand(compiled, piece) : null; if (!expanded) { out.push({ li, pi, si: 0, raw: ln.raw, role: null, entity: null, matched: false }); return; } expanded.forEach((row, si) => out.push(attachTracks({ li, pi, si, raw: ln.raw, role: row.role || '', entity: row.entity || '', matched: true }))); }); }); return out; } // #524 follow-up (majkinetor): "regarding artist vs label, maybe we can // have 2 tabs in search" — kellnerd's musicbrainz-scripts takes a // simpler tack that covers the common case for free: default to label, // but if the holder name matches one of the RELEASE's own credited // artists, it's almost certainly that artist crediting themselves (the // whole reason the ambiguity exists) — auto-detect that, and offer the // 2-tab toggle in the picker (txpPickEntity) as the manual override for // when auto-detect guesses wrong. Shared by BOTH copyright/legal-notice // holders (via txpCrEntityType below) and ordinary credit rows (via // rowEntityType, #525) — the same ambiguity, the same fallback. // #525 follow-up (majkinetor, live): "one for the company was selected" // — "mastering" and "graphic design" (among others) turn out to ALSO // exist as label-release relationship types in MB (id 1293 and 1172, // live-verified), so classifyRoleText correctly sees them matching BOTH // sides at an equal (exact) tier and calls it genuinely ambiguous — but // defaulting an ambiguous ORDINARY credit row to label (a "company") // is wrong far more often than right: liner-note credits are // overwhelmingly people, not companies, and the release-own-artist // auto-detect below almost never fires true for them (it's really // about a self-releasing artist crediting themselves in a COPYRIGHT // line, #524's original case). Copyright/legal-notice holders keep the // opposite default (label) — that bias is correct there; a ©/℗ line // really is usually attributed to a company. function txpAutoEntityType(text, defaultType) { const names = (release.artistCredit && release.artistCredit.names) || []; const isReleaseArtist = names.some(n => n.artist && (txpSameName(n.artist.name, text) || txpSameName(n.artist.sort_name, text))); return isReleaseArtist ? 'artist' : (defaultType || 'label'); } function txpCrEntityType(kind, holder) { if (TXP_CR_LABEL_ONLY.has(kind)) return 'label'; // MB has no artist-release type for these at all return txpAutoEntityType(holder, 'label'); } // #525 (majkinetor, from a screenshot): "'Published by' is also label, // but artist is offered... We either map roles to entities or always // show label/artist tab." Ordinary credit rows used to be hardcoded // artist-only on BOTH ends (role AND entity) — "published" has no // artist-release relationship type at all (live-verified), so it could // never actually resolve, toggle or not. Now every role text is // classified against BOTH linkTypesForPair('artist','release') and // ('label','release'): a role that only exists on one side FORCES that // entity type (no toggle needed, e.g. "published" → label); a role that // exists on both (e.g. "licensor") or matches neither falls back to the // same auto-detect + manual-toggle escape hatch #524 already built for // copyright holders. See classifyRoleText (used by resolveAll to build // roleCache) and rowEntityType below. // returns {hit, tier} — tier 0 (exact) beats 1 (loose substring) beats 2 // (stem fallback), or null if nothing matches unambiguously at any tier. // #525 (majkinetor, live, screenshot): "Biography and Pictures" role // wrongly auto-resolved to "pi" — a real MB instrument name that just // happens to be a 2-character substring of "pictures". The loose tier // checks containment in BOTH directions; cn.includes(rt) (the real // candidate name contains what the user typed, e.g. "Hammond" inside // "Hammond organ") is safe regardless of length — the user's own text // is the deliberate, specific side. rt.includes(cn) (an unrelated, // often much longer role text happens to contain a SHORT candidate // name) is the risky direction: almost any sufficiently long string // will coincidentally contain some short vocabulary entry. Requiring // the candidate be at least 4 characters for THAT direction keeps // genuine short-name matches (e.g. "bass" inside "Bass Guitar") while // rejecting 2-3 character coincidences like "pi". function txpMatchRoleText(cands, rt) { if (!rt) return null; const exact = cands.filter(c => c.name.toLowerCase() === rt); if (exact.length === 1) return { hit: exact[0], tier: 0 }; const loose = cands.filter(c => { const cn = c.name.toLowerCase(); return cn.includes(rt) || (cn.length >= 4 && rt.includes(cn)); }); if (loose.length === 1) return { hit: loose[0], tier: 1 }; const stem = txpRoleStem(rt); const fuzzy = cands.filter(c => txpRoleStem(c.name) === stem); return fuzzy.length === 1 ? { hit: fuzzy[0], tier: 2 } : null; } // #666: "Guest Vocals", "Additional Guitar", "Co-Producer", "Executive // Producer" — a leading qualifier is an ATTRIBUTE, not part of the role. // Strip it, classify the rest, and add the qualifier as an extra attribute // when the resolved link type allows it (e.g. "co" is fine on producer, // not on writer). Anything else — no qualifier, the rest doesn't resolve, // or the type doesn't take that qualifier — falls back to classifying the // whole text exactly as before. function classifyRoleText(rt, artistCands, labelCands, instrumentLt, instrumentCands, placeCands) { const fm = TXP_FLAG_RE.exec(rt || ''); if (fm) { const flagId = txpFlagAttrId(fm[1]); const base = flagId ? classifyBaseRole(fm[2], artistCands, labelCands, instrumentLt, instrumentCands, placeCands) : null; const m = base && (base.instrumentMatch || (base.forced === 'artist' ? base.artistMatch : null)); if (m && txpLtAllowsAttr(m.id, flagId)) { const ids = [...(m.attributeIds || (m.attributeId ? [m.attributeId] : [])), flagId]; const withFlag = { id: m.id, name: `${fm[1]} ${m.name}`, attributeIds: ids }; try { console.debug('[Group Therapy] #666 role qualifier:', JSON.stringify({ rt, flag: fm[1], flagId, lt: m.id, ids })); } catch (e) {} return { artistMatch: null, labelMatch: null, placeMatch: null, instrumentMatch: withFlag, forced: 'artist' }; } try { console.debug('[Group Therapy] #666 qualifier not applied:', JSON.stringify({ rt, flag: fm[1], flagId, base: m ? m.id : null })); } catch (e) {} } return classifyBaseRole(rt, artistCands, labelCands, instrumentLt, instrumentCands, placeCands); } function classifyBaseRole(rt, artistCands, labelCands, instrumentLt, instrumentCands, placeCands) { rt = TXP_VOCAL_SYNONYMS[rt] || rt; // #666: "backing vocals" → "background vocals" const a = txpMatchRoleText(artistCands, rt); const l = txpMatchRoleText(labelCands, rt); const pl = txpMatchRoleText(placeCands || [], rt); const artistMatch = a ? a.hit : null; const labelMatch = l ? l.hit : null; const placeMatch = pl ? pl.hit : null; let instrumentMatch = null; // #666: vocal types. "lead vocals" LOOSELY matches the bare "vocal" link // type (it contains "vocal"), so a vocal-attribute hit wins over that // loose/stem hit — never over an exact link-type match. Only tried when // the text says "vocal", so "Tenor" / "Soprano" stay with the // saxophones in the instrument vocabulary as before. const vocalLt = artistCands.find(c => c.name === 'vocal'); if (vocalLt && /vocal/.test(rt) && (!a || (a.hit.id === vocalLt.id && a.tier > 0))) { const vHit = txpMatchRoleText(txpVocalCandidates(), rt); if (vHit) instrumentMatch = { id: vocalLt.id, name: vHit.hit.name, attributeId: vHit.hit.id }; try { console.debug('[Group Therapy] #666 vocal type:', JSON.stringify({ rt, hit: vHit ? vHit.hit : null })); } catch (e) {} } if (!instrumentMatch && !a && !l && instrumentLt) { const iHit = txpMatchRoleText(instrumentCands, rt); if (iHit) instrumentMatch = { id: instrumentLt.id, name: iHit.hit.name, attributeId: iHit.hit.id }; } // instrument roles are inherently artist-only. A role matching only ONE // side forces that side outright. A role matching BOTH sides is only // genuinely ambiguous when both matched at the SAME confidence tier — // when one side matched more confidently (e.g. "published by" LOOSELY // matches the label type "published", id 362, but only STEM-matches // the unrelated artist type "publishing", id 32 — "the artist // publishes this release", a real but different concept, live-caught // by this exact case), the stronger tier wins outright rather than // falling back to "ambiguous, ask the user." // Place roles read "<verb> at" where the artist/label ones read "<verb> by", // so they rarely collide — but when they do, the same tier rule decides. let forced = null; const tiers = [['artist', a], ['label', l], ['place', pl]].filter(([, x]) => x); if (instrumentMatch) forced = 'artist'; else if (tiers.length === 1) forced = tiers[0][0]; else if (tiers.length > 1) { tiers.sort((x, y) => x[1].tier - y[1].tier); forced = tiers[0][1].tier < tiers[1][1].tier ? tiers[0][0] : null; // a clear winner, or genuinely ambiguous } return { artistMatch, labelMatch, placeMatch, instrumentMatch, forced }; } // the entity type for an ORDINARY credit row: forced by its role's own // classification when unambiguous, otherwise the same release-artist- // credit auto-detect crKind rows already use. function rowEntityType(row) { const rt = (row.role || '').toLowerCase().trim(); const cls = roleCache.get(rt); return (cls && cls.forced) || txpAutoEntityType(row.entity, 'artist'); } function attachResolution(row) { row.key = row.li + ':' + row.pi + ':' + row.si; if (!row.matched) { row.roleMatch = null; row.entityMatch = null; row.entityType = null; row.entityForced = null; row.creating = null; return row; } row.entityMatch = entityOverride.has(row.key) ? entityOverride.get(row.key) : (entityCache.get((row.entity || '').toLowerCase().trim()) || null); row.creating = pendingCreates.get((row.entity || '').toLowerCase().trim()) || null; // #544 background create in flight // #525 follow-up (majkinetor): "Can we just replace role with the // other one once the entity is selected? That way it should never // happen." A pre-resolution guess (forced-or-auto-detected) can only // ever be a best effort for a genuinely ambiguous role — once an // entity is ACTUALLY resolved (auto or manually, including via the // Artist/Label toggle), its own real entityType is ground truth and // decides which relationship type applies, no more guessing. A // FORCED type (role only exists on one side) still wins outright — // there's no valid alternate relationship type to fall back to even // if a pasted MBID happened to be the other kind. if (row.crKind) { row.entityForced = TXP_CR_LABEL_ONLY.has(row.crKind) ? 'label' : null; row.entityType = row.entityForced || (row.entityMatch ? row.entityMatch.entityType : txpCrEntityType(row.crKind, row.entity)); row.roleMatch = row.entityMatch ? txpCopyrightLinkType(row.crKind, row.entityType) : null; return row; } const rt = (row.role || '').toLowerCase().trim(); const cls = roleCache.get(rt) || null; row.roleClass = cls; row.entityForced = (cls && cls.forced) || null; row.entityType = row.entityForced || (row.entityMatch ? row.entityMatch.entityType : rowEntityType(row)); row.roleMatch = cls ? (row.entityType === 'label' ? cls.labelMatch : row.entityType === 'place' ? cls.placeMatch : (cls.instrumentMatch || cls.artistMatch)) : null; return row; } function txpCopyrightLinkType(kind, entityType) { const roles = linkTypesForPair(entityType, 'release'); return roles.find(r => r.name.toLowerCase() === TXP_CR_TYPE_NAME[kind]) || null; } const dotClass = r => !r.matched ? 'gt-tp-dot-red' : (r.roleMatch && r.entityMatch) ? 'gt-tp-dot-green' : 'gt-tp-dot-amber'; const statusText = r => { if (!r.matched) return 'no match'; if (appliedKeys.has(r.key)) return '✓ applied'; if (r.roleMatch && r.entityMatch) return 'ready'; return [!r.roleMatch && 'role?', !r.entityMatch && 'entity?'].filter(Boolean).join(' '); }; txpEl = el('div', 'gt-cons-ov'); const panel = el('div', 'gt-cons gt-tp'), hdr = el('div', 'gt-cons-hdr'); hdr.appendChild(el('span', 'gt-cons-title', 'Text parser')); const annoBtn = el('button', 'gt-tp-anno', 'Load annotation'); annoBtn.type = 'button'; hdr.appendChild(annoBtn); // #522 follow-up (majkinetor): "Add resizable columns and maximize // button" — same maximize/restore toggle Match Works already has. const maxBtn = el('button', 'gt-cons-x', '⛶'); maxBtn.type = 'button'; maxBtn.title = 'Maximize / restore'; maxBtn.onclick = () => { if (panel.classList.toggle('gt-tp-max')) { panel._savedW = panel.style.width; panel._savedH = panel.style.height; panel.style.width = ''; panel.style.height = ''; maxBtn.textContent = '❐'; maxBtn.title = 'Restore'; } else { panel.style.width = panel._savedW || ''; panel.style.height = panel._savedH || ''; maxBtn.textContent = '⛶'; maxBtn.title = 'Maximize'; } saveState(); // #544 follow-up: maximizing changed the window and saved nothing }; hdr.appendChild(maxBtn); const xb = el('button', 'gt-cons-x', '✕'); xb.type = 'button'; xb.onclick = closeTextParser; hdr.appendChild(xb); const ctrl = el('div', 'gt-tp-ctrl'); const patIn = el('input', 'gt-tp-pat'); patIn.type = 'text'; patIn.value = pattern; const patClr = el('button', 'gt-tp-clr', '✕'); patClr.type = 'button'; patClr.title = 'Clear pattern'; const presets = el('span', 'gt-tp-presets'); TXP_PRESETS.forEach(p => { const c = el('button', 'gt-tp-chip', p); c.type = 'button'; c.onclick = () => { patIn.value = p; onPatternChange(); }; presets.appendChild(c); }); // #525 (majkinetor): "lets move the Resolve all to the top, to match the // standard, here is the example in GT's work matcher" then "Name it also // Match with the icon" — mirrors Match Works' own toolbar exactly: its // "⚡ Match" button lives at the right of the TOP bar, not the footer // (renderWorkMatch's `tb`), styled the same bold-purple "primary" way. const resolveBtn = el('button', 'gt-tp-resolve', '⚡ Match'); resolveBtn.type = 'button'; resolveBtn.title = 'Resolve every role and entity that can auto-resolve'; // #539: scope picker. Release is still the default — the recording scope is // opt-in and inert until a track selector is typed. const scopeWrap = el('span', 'gt-tp-scope'); const scopeSel = el('select', 'gt-tp-scope-sel'); [['release', 'Release'], ['recording', 'Recordings']].forEach(([v, t]) => { const o = el('option', '', t); o.value = v; scopeSel.appendChild(o); }); scopeSel.value = scope.kind; const tracksIn = el('input', 'gt-tp-tracks'); tracksIn.type = 'text'; tracksIn.value = scope.spec; tracksIn.placeholder = 'tracks: 1,3,5-7 · 2:4 · all'; tracksIn.title = 'Which recordings the credits go on: a track number as shown (3, A1), a range (5-7), ' + 'a medium-qualified number or range (2:4, 2:4-6), a whole medium (2:*), or all. ' + 'Leave empty to use the tracks ticked in the editor.'; const tracksInfo = el('span', 'gt-tp-tracks-info', ''); // #597: per-row track detection. Separate from Scope on purpose — Scope // says where rows that name NO tracks go, this says whether a row may name // its own at all, and the two are useful in every combination. // ⚠ its own class, NOT a second .gt-tp-scope-sel — Playwright's page.* helpers // are strict, so a duplicate class makes verify-539's selectOption throw. const detSel = el('select', 'gt-tp-det-sel'); [['auto', 'Tracks: auto'], ['off', 'Tracks: off']].forEach(([v, t]) => { const o = el('option', '', t); o.value = v; detSel.appendChild(o); }); detSel.value = scope.tracks; detSel.title = 'auto: a credit that names its own tracks — "Lead vocals (tracks 2,6,9)", "Djembe (track 4)" — goes on those recordings, ' + 'whatever Scope says; everything else follows Scope. A clause only counts when every number in it is a real track on this release, ' + 'so "(2003)" is left alone.\noff: the pre-#597 behaviour — Scope decides for every row.'; scopeWrap.append(el('span', 'gt-tp-scope-lbl', 'Scope'), scopeSel, tracksIn, tracksInfo, detSel); // What did that selector actually match? Shown before anything is applied, // because "1-3" meaning something other than you thought is a silent way to // credit the wrong recordings. const refreshScopeUi = () => { const rec = scope.kind === 'recording'; tracksIn.style.display = rec ? '' : 'none'; tracksInfo.style.display = rec ? '' : 'none'; if (!rec) return; const targets = txpScopeTargets(); const ticked = !scope.spec.trim(); // #539 follow-up: listing all 23 numbers made the toolbar wide enough to // push ⚡ Match onto a second row. Show a handful, keep the rest in the // tooltip — the count is the part that matters at a glance. const nums = targets.map(t => t.num); const shown = nums.length > 6 ? `${nums.slice(0, 6).join(', ')} +${nums.length - 6}` : nums.join(', '); tracksInfo.textContent = targets.length ? `→ ${targets.length} track${targets.length > 1 ? 's' : ''}${ticked ? ' (ticked)' : ''}: ${shown}` : (ticked ? '→ no tracks ticked' : '→ nothing matched'); tracksInfo.style.color = targets.length ? 'var(--mbu-ok)' : 'var(--mbu-warn)'; tracksInfo.title = targets.map(t => `${t.num} ${t.title}`).join('\n'); }; // The recordings this run will credit: an explicit selector wins, otherwise // the editor's own ticked tracks (the same idiom as GT's other tools). function txpScopeTargets() { const rows = txpTrackRows(); if (scope.spec.trim()) return txpMatchTracks(scope.spec, rows); return rows.filter(r => { const cb = r.tr.querySelector('input.recording'); return cb && cb.checked; }); } scopeSel.onchange = () => { scope.kind = scopeSel.value; roleCache.clear(); refreshScopeUi(); render(); saveState(); }; tracksIn.oninput = () => { scope.spec = tracksIn.value; refreshScopeUi(); saveState(); }; detSel.onchange = () => { scope.tracks = detSel.value; render(); saveState(); }; // #597 // #539 follow-up (majkinetor): "We again have new row, this time for out of // the box patterns … Move scope to the bottom, opposite of Apply." So the // top bar is back to what it was — pattern · presets · ⚡ Match — and the // scope control lives in the footer instead, at the opposite end from // Apply. It belongs there anyway: it is part of deciding what Apply does, // not part of writing the pattern. // #544 (majkinetor): "Freeze matched should be next to the pattern input in // the header (like Apollo)". Icon-only, per the standing toolbar rule — the // top bar has twice been one control too wide (#539), and the tooltip // carries the meaning. const freezeBtn = el('button', 'gt-tp-freeze', '🔒'); freezeBtn.type = 'button'; freezeBtn.title = 'Freeze matched — lock this pattern onto every line that still uses the default pattern and already matches it, then try another pattern on what is left'; ctrl.append(patIn, patClr, freezeBtn, presets, resolveBtn); const src = el('div', 'gt-tp-src'); const srcTgl = el('button', 'gt-tp-srctgl', '▾ Paste credit text'); srcTgl.type = 'button'; const ta = el('textarea', 'gt-tp-ta'); // #522 follow-up (majkinetor, live): "move copyright help text into the // textbox hint when its empty" — folded into the placeholder instead of // a permanent line taking up space even once you're pasting real text. ta.placeholder = 'Paste credit text here, one credit per line…\n\nA ©/(C)/copyright or ℗/(P)/phonographic copyright line ("© 2020 Some Label", "℗ & © 2020 Some Label") is recognized automatically alongside your pattern. Each entity resolves against artists or labels depending on its role — a label-only role like "distributed by" is searched as a label; an ambiguous one auto-detects against this release\'s own credited artists, with a toggle to override.'; src.append(srcTgl, ta); const body = el('div', 'gt-cons-body gt-tp-body'); const tbl = el('table', 'gt-cons-tbl gt-tp-tbl'); // #522 follow-up: "Add resizable columns" — table-layout:fixed + a // <colgroup> so each column's width is just one <col>'s inline style, // adjustable by dragging a handle on its header without touching every // cell in the column. // #522 follow-up (majkinetor, live): "memorize as you do it constantly" // — resized column widths are remembered (a global GM value, not a // per-release one — this is a layout preference, not release data). const TXP_COLS_KEY = 'gt-tp-colwidths'; // #597 added the "tracks" column. A saved 8-entry array no longer matches // the 9 cols, so the length check below falls back to these defaults — // widths reset once on upgrade, which is the right way for that to go. const defaultColWidths = ['24px', '120px', '230px', '110px', '170px', '110px', '170px', '90px', '80px']; let savedColWidths = null; try { savedColWidths = JSON.parse(GM_getValue(TXP_COLS_KEY, '')) || null; } catch (e) {} const colgroup = el('colgroup'); (savedColWidths && savedColWidths.length === defaultColWidths.length ? savedColWidths : defaultColWidths).forEach(w => { const c = document.createElement('col'); c.style.width = w; colgroup.appendChild(c); }); const saveColWidths = () => { try { GM_setValue(TXP_COLS_KEY, JSON.stringify([...colgroup.children].map(c => c.style.width))); } catch (e) {} }; const thead = el('thead'); thead.innerHTML = '<tr><th></th><th>pattern</th><th>raw line</th><th>role</th><th>entity</th><th>→ role</th><th>→ entity</th><th>tracks</th><th></th></tr>'; [...thead.querySelectorAll('th')].forEach((th, i) => { if (i === 0) return; // the status-dot column stays fixed const handle = el('span', 'gt-tp-colresize'); th.appendChild(handle); let startX = 0, startW = 0; const onMove = e => { colgroup.children[i].style.width = Math.max(40, startW + (e.clientX - startX)) + 'px'; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); saveColWidths(); }; handle.addEventListener('mousedown', e => { e.preventDefault(); startX = e.clientX; startW = colgroup.children[i].getBoundingClientRect().width; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); const tbody = el('tbody'); tbl.append(colgroup, thead, tbody); body.appendChild(tbl); const foot = el('div', 'gt-cons-foot'); const cnt = el('span', 'gt-tp-cnt'); // #550: no ↗ any more — nothing opens, the annotation edit is submitted here. const applyClearBtn = el('button', 'gt-cons-btn', 'Apply & clear annotation'); applyClearBtn.type = 'button'; applyClearBtn.title = 'Apply the resolved rows, then clear this release\'s annotation — submitted for you, with its own edit note'; applyClearBtn.style.display = 'none'; const applyBtn = el('button', 'gt-cons-btn gt-cons-apply', 'Apply'); applyBtn.type = 'button'; // #544 (majkinetor): "Add [freeze pattern] option for matching rows the same // as Apollo" — Apollo's tc-tpp-freeze. Stamps the current pattern into every // still-default line that matches it right now, so trying a different // pattern afterwards leaves those alone and only re-parses the rest. // It lives in the footer rather than the top bar because that bar has twice // been one control too wide (#539). foot.append(scopeWrap, cnt, applyClearBtn, applyBtn); // #539: scope at the far left, Apply at the far right const syncTextareaFromLines = () => { ta.value = lines.map(l => l.raw).join('\n'); }; // #522 follow-up (majkinetor, live): "Lets have an option to remove a // row. It is also removed from the input text." Removing a LINE shifts // every later line's index down by one — appliedKeys AND entityOverride // are keyed by position (li:pi:si), so both are renumbered here rather // than left stale (an appliedKeys entry could mislabel an unrelated row // as already-applied; a leftover override could silently reassign a // pick that was meant for a different, now-shifted row). const renumberKey = (k, delLi) => { const [li, pi, si] = k.split(':').map(Number); return (li > delLi ? li - 1 : li) + ':' + pi + ':' + si; }; function deleteLine(delLi) { lines.splice(delLi, 1); const renumbered = new Set(); appliedKeys.forEach(k => { const li = +k.split(':')[0]; if (li !== delLi) renumbered.add(renumberKey(k, delLi)); }); appliedKeys.clear(); renumbered.forEach(k => appliedKeys.add(k)); const ovRenumbered = new Map(); entityOverride.forEach((v, k) => { const li = +k.split(':')[0]; if (li !== delLi) ovRenumbered.set(renumberKey(k, delLi), v); }); entityOverride.clear(); ovRenumbered.forEach((v, k) => entityOverride.set(k, v)); syncTextareaFromLines(); render(); saveState(); } function render() { // #522 follow-up (majkinetor, live): "after editing pattern in a row, // focus is lost immediately" — render() rebuilds the whole tbody, which // destroys and recreates whichever input was focused on every // keystroke. Both the pattern-override AND the (new) inline raw-text // edit inputs carry a shared data-fkey; remember it (+ caret position) // and restore focus to whichever element gets the same fkey again // after rebuilding. const active = document.activeElement; const activeFkey = (active && active.dataset && active.dataset.fkey) || null; const activeSel = activeFkey && typeof active.selectionStart === 'number' ? [active.selectionStart, active.selectionEnd] : null; const rows = parsedRows().map(attachResolution); const spanByLine = new Map(); rows.forEach(r => spanByLine.set(r.li, (spanByLine.get(r.li) || 0) + 1)); tbody.textContent = ''; rows.forEach(r => { const stCls = dotClass(r).replace('gt-tp-dot-', 'gt-tp-st-'); // amber/red row tint, green stays plain const tr = el('tr', 'gt-tp-row' + (r.matched ? '' : ' gt-tp-nomatch') + (stCls === 'gt-tp-st-green' ? '' : ' ' + stCls)); const dotTd = el('td'); dotTd.appendChild(el('span', 'gt-tp-dot ' + dotClass(r))); tr.appendChild(dotTd); if (r.pi === 0 && r.si === 0) { const span = spanByLine.get(r.li); const ovTd = el('td'); if (span > 1) ovTd.rowSpan = span; const ov = el('input', 'gt-tp-ov'); ov.type = 'text'; ov.placeholder = pattern; ov.value = lines[r.li].override || ''; ov.title = 'Override the pattern for just this line'; ov.dataset.fkey = 'ov:' + r.li; ov.addEventListener('input', () => { lines[r.li].override = ov.value; render(); saveState(); }); ovTd.appendChild(ov); tr.appendChild(ovTd); // #522 follow-up: "add option to edit raw column in the table, and // it should also change original row in the text" — an editable // input synced back into the textarea, plus a remove button. const rawTd = el('td'); if (span > 1) rawTd.rowSpan = span; const rawWrap = el('span', 'gt-tp-rawwrap'); const rawIn = el('input', 'gt-tp-raw'); rawIn.type = 'text'; rawIn.value = r.raw; rawIn.title = r.raw; rawIn.dataset.fkey = 'raw:' + r.li; rawIn.addEventListener('input', () => { lines[r.li].raw = rawIn.value; [...entityOverride.keys()].filter(k => +k.split(':')[0] === r.li).forEach(k => entityOverride.delete(k)); syncTextareaFromLines(); render(); saveState(); }); const delBtn = el('button', 'gt-tp-rowdel', '✕'); delBtn.type = 'button'; delBtn.title = 'Remove this line'; delBtn.onclick = () => deleteLine(r.li); rawWrap.append(rawIn, delBtn); rawTd.appendChild(rawWrap); tr.appendChild(rawTd); } tr.appendChild(el('td', 'gt-tp-c', r.role || '')); // #525 (majkinetor): "since column Entity is used as 'credited as', // lets add right click to it, which will set it to choosen entity // (if there is one)... used to fast clear any suffixes that came // from raw text" — a raw credit like "Felix Vincent*" (Discogs-style // footnote marker) resolves fine to the real artist, but the raw // text is still what gets sent as the credited-as override on Apply // (applyResolvedRows: r.entity !== r.entityMatch.name → credit). // Right-click swaps the raw entity substring for the resolved // entity's own canonical name, clearing that stray override. const entRawTd = el('td', 'gt-tp-c', r.entity || ''); if (r.matched && r.entity) { entRawTd.title = r.entityMatch ? `Right-click: replace with the resolved name "${r.entityMatch.name}" (clears suffixes/typos in the raw text)` : 'Resolve the entity first, then right-click here to clean up the raw text'; entRawTd.addEventListener('contextmenu', e => { e.preventDefault(); if (!r.entityMatch) { toast('Resolve this row’s entity first'); return; } const canonical = r.entityMatch.name || ''; if (!canonical || canonical === r.entity) { toast('Already matches the resolved name'); return; } const rawBefore = lines[r.li].raw; if (!rawBefore.includes(r.entity)) { toast('Could not find that exact text in the raw line'); return; } lines[r.li].raw = rawBefore.replace(r.entity, canonical); // seed both caches with the cleaned-up text so the row (and any // other row sharing it) reads as already-resolved on the very // next render, instead of regressing to "search" just because // the raw text — and therefore the parsed entity text the // caches are keyed by — changed underneath it. entityOverride.set(r.key, r.entityMatch); entityCache.set(canonical.toLowerCase().trim(), r.entityMatch); syncTextareaFromLines(); render(); saveState(); toast(`Set to “${canonical}”`); }); } tr.appendChild(entRawTd); // #522 follow-up (majkinetor, live): "Tidy up artist / role column — // remove circles, short helper text..." then, after trying a plain // non-clickable resolved cell: "After a role is selected, I am not // able to change it as its not a link any more... Clicking an // element should always bring back search and for artist right // click should open it." Unresolved still shows the plain word // "search"; resolved is clickable again to reopen the picker — for // the entity, a real <a> so right-click still offers "open in new // tab" natively too, but a plain left-click is intercepted to reopen // the picker instead of navigating, and OUR contextmenu handler // opens it directly (skipping the browser's own context menu). const roleTd = el('td', 'gt-tp-c'); if (r.matched && !r.crKind) { if (r.roleMatch) { const rs = el('button', 'gt-tp-search gt-tp-resolved', r.roleMatch.name); rs.type = 'button'; rs.title = 'Click to change'; rs.onclick = () => txpPickRole(r); roleTd.appendChild(rs); } else { const rb = el('button', 'gt-tp-search', 'search'); rb.type = 'button'; rb.onclick = () => txpPickRole(r); roleTd.appendChild(rb); } } else if (r.matched) { roleTd.appendChild(el('span', r.roleMatch ? 'gt-tp-resolved' : 'gt-tp-status', r.roleMatch ? r.roleMatch.name : '(resolves once holder is picked)')); } tr.appendChild(roleTd); const entTd = el('td', 'gt-tp-c'); if (r.matched) { if (r.entityMatch) { const a = el('a', 'gt-tp-resolved', r.entityMatch.name || ''); // #544: the disambiguation is set apart from the name here too, so // the resolved cell and the search results read the same way. if (r.entityMatch.disambiguation) a.appendChild(el('span', 'gt-tp-disamb', ` (${r.entityMatch.disambiguation})`)); a.href = '/' + r.entityMatch.entityType + '/' + r.entityMatch.gid; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Click to change · right-click to open'; a.addEventListener('click', e => { e.preventDefault(); txpPickEntity(r, a); }); a.addEventListener('contextmenu', e => { e.preventDefault(); window.open(a.href, '_blank', 'noopener'); }); entTd.appendChild(a); } else if (r.creating) { // #544: a background create is in flight for this text — say so // instead of offering "search", and let the row resolve itself when // the create tab commits. // the kind lives in the tooltip, not the label: this column is narrow // and "creating artist “…”…" wrapped to two lines in every row. // #544 follow-up: it is a BUTTON — clicking cancels, so a create that // failed in a way nothing here can see never strands the row. const c = el('button', 'gt-tp-creating', `creating “${trunc(r.creating.name, 16)}”… ✕`); c.type = 'button'; c.title = `A background tab is creating this ${r.creating.kind}; the row fills in on its own once it commits.\nClick to stop waiting and get the search back.`; c.onclick = () => { const p = r.creating; if (p && p.cancel) p.cancel('cancelled'); else { pendingCreates.delete((r.entity || '').toLowerCase().trim()); render(); } }; entTd.appendChild(c); } else { const ab = el('button', 'gt-tp-search', 'search'); ab.type = 'button'; ab.onclick = () => txpPickEntity(r, ab); entTd.appendChild(ab); } } tr.appendChild(entTd); /* #597: where this credit is actually going. Not decoration — the role matcher tolerates a "(tracks 2,6,9)" suffix and resolves it exactly as the clean text would, so before this column a run that ignored the clause and credited the whole release looked completely normal. */ const trkTd = el('td', 'gt-tp-trk'); if (r.trackNote) { // the line carried more than one track list — see txpDetectTracks const w = el('span', 'gt-tp-trk-warn', '⚠ split line'); w.title = `${r.trackNote}\n\nOne line can only carry one track list, so this credit is going wherever Scope points instead. Split it into one role per line.`; trkTd.appendChild(w); } else if (r.trackTargets && r.trackTargets.length) { const nums = r.trackTargets.map(t => t.num); const s = el('span', 'gt-tp-trk-on', nums.length > 4 ? `${nums.slice(0, 4).join(', ')} +${nums.length - 4}` : nums.join(', ')); s.title = `From “${r.trackSpec}” in the credit text:\n` + r.trackTargets.map(t => `${t.num} ${t.title}`).join('\n'); trkTd.appendChild(s); } else if (scope.kind === 'recording') { trkTd.appendChild(el('span', 'gt-tp-trk-dim', 'scope')); } tr.appendChild(trkTd); const stTd = el('td', 'gt-tp-status' + (appliedKeys.has(r.key) ? ' gt-tp-applied' : ''), statusText(r)); tr.appendChild(stTd); tbody.appendChild(tr); }); // #522 follow-up (majkinetor, live): "There is a tight column on the // left with text when there is no text input" — the empty-state <td> // had no colspan, so table-layout:fixed confined it to the FIRST // (narrowest) column, wrapping the message into a tall sliver. if (!rows.length) { const td = el('td', 'gt-pop-note', 'Paste credit text above, or load the annotation.'); td.colSpan = 9; tbody.appendChild(el('tr')).appendChild(td); } const matched = rows.filter(r => r.matched).length; const ready = rows.filter(r => r.matched && r.roleMatch && r.entityMatch && !appliedKeys.has(r.key)).length; const applied = rows.filter(r => appliedKeys.has(r.key)).length; cnt.textContent = rows.length ? `${matched}/${rows.length} matched` + (ready ? ` · ${ready} ready` : '') + (applied ? ` · ${applied} applied` : '') : ''; applyBtn.disabled = !ready; applyClearBtn.disabled = !ready; applyClearBtn.style.display = loadedFromAnnotation ? '' : 'none'; if (activeFkey) { const restored = tbody.querySelector(`[data-fkey="${activeFkey}"]`); if (restored) { restored.focus(); if (activeSel) { try { restored.setSelectionRange(activeSel[0], activeSel[1]); } catch (e) {} } } } } async function resolveAll() { resolveBtn.disabled = true; resolveBtn.textContent = 'Resolving…'; try { const rows = parsedRows().filter(r => r.matched); const crRows = rows.filter(r => r.crKind); const creditRows = rows.filter(r => !r.crKind); // #525 (majkinetor): "Published by is also label... map roles to // entities" — role classification is entirely synchronous (no // network) and must happen BEFORE the unified entity-resolution // list below, since an ordinary row's entity type (rowEntityType) // depends on its role's classification. // #539: the role vocabulary is per entity PAIR — "recorded at" exists // for both, but plenty of release roles have no recording equivalent // and vice versa. Follow the chosen scope, or a recording run would be // matched against release-only link types. const scopeTarget = scope.kind === 'recording' ? 'recording' : 'release'; const artistCands = linkTypesForPair('artist', scopeTarget); const labelCands = linkTypesForPair('label', scopeTarget); const placeCands = linkTypesForPair('place', scopeTarget); // #525: "recorded at", "mixed at", "mastered at", … const instrumentLt = artistCands.find(c => c.name === 'instrument'); const instrumentCands = instrumentLt ? txpInstrumentCandidates() : []; const roleTexts = [...new Set(creditRows.map(r => (r.role || '').toLowerCase().trim()).filter(Boolean))]; roleTexts.forEach(rt => { if (!roleCache.has(rt)) roleCache.set(rt, classifyRoleText(rt, artistCands, labelCands, instrumentLt, instrumentCands, placeCands)); }); // role matches are synchronous — show them immediately, before the // network-bound entity loop below even starts. render(); saveState(); // #524/#525: ONE unified (text, entityType) resolution list — // copyright/legal-notice holders and ordinary credit entities share // the exact same artist-or-label lookup, entityType decided per // text (forced where MB only allows one, otherwise auto-detected // against the release's own credited artists). Dedup by entity text // (the common case — the same text rarely needs both an artist AND // a label resolution in the same block; see txpCrEntityType/ // rowEntityType's own docs for that edge). const entityTexts = new Map(); // text -> entityType ('artist'|'label') crRows.forEach(r => { if (r.entity) entityTexts.set(r.entity, txpCrEntityType(r.crKind, r.entity)); }); creditRows.forEach(r => { if (r.entity) entityTexts.set(r.entity, rowEntityType(r)); }); const entities = [...entityTexts.entries()]; // #522 follow-up (majkinetor): "while it is resolving, lets show a // message ... resolving 4/N" then "make it show in the button // itself" — total counts only the names that actually need a real // lookup (already-cached ones are instant skips, not "work" the // button should promise is coming). resolveTotal = entities.filter(([t]) => !entityCache.has(t.toLowerCase().trim())).length; resolveDone = 0; const bumpProgress = () => { resolveDone++; resolveBtn.textContent = `Resolving ${resolveDone}/${resolveTotal}`; }; resolveBtn.textContent = `Resolving ${resolveDone}/${resolveTotal}`; for (const [text, entityType] of entities) { const key = text.toLowerCase().trim(); if (entityCache.has(key)) continue; try { let hit = entityType === 'artist' ? await txpResolveByExactAlias(text) : entityType === 'place' ? null // no alias resolver for places; exact-name search below covers it : await txpResolveLabelByExactAlias(text); if (!hit) { const cands = entityType === 'artist' ? await txpSearchArtist(text, 5) : entityType === 'place' ? await txpSearchPlace(text, 5) : await txpSearchLabel(text, 5); const exact = cands.filter(c => (c.name || '').toLowerCase().trim() === key); if (exact.length === 1) { const full = await txpFetchEntity(exact[0].gid || exact[0].id, entityType); if (full) hit = { entity: full }; } } entityCache.set(key, hit ? hit.entity : null); } catch (e) { entityCache.set(key, null); } // #524 follow-up (majkinetor): "'Resolve all' should update the // table as it goes... to avoid the looks of 'nothing happens'" — // each entity is a real network round-trip, so re-render after // every one instead of batching the whole thing behind one // render() at the very end. bumpProgress(); render(); saveState(); } } finally { resolveBtn.disabled = false; resolveBtn.textContent = '⚡ Match'; render(); saveState(); } } function txpPickRole(r) { const scopeTarget = scope.kind === 'recording' ? 'recording' : 'release'; // #539 const artistCands = linkTypesForPair('artist', scopeTarget); const labelCands = linkTypesForPair('label', scopeTarget); const instrumentLt = artistCands.find(c => c.name === 'instrument'); // offer specific instruments alongside the direct link types (tagged // so onPick can tell them apart and build the right {id, attributeId} // shape — see classifyRoleText's own auto-resolution for the same // split). #525: label-pair roles are offered too (tagged "(label)" // in their description) — a manual pick from either list is the // ultimate override for a role classifyRoleText couldn't force. const instrumentOpts = instrumentLt ? txpInstrumentCandidates().map(c => ({ id: c.id, name: c.name, desc: 'instrument', _instrument: true, _ltId: instrumentLt.id, _entityType: 'artist' })) : []; // #666: vocal types the same way — the "vocal" link type plus that attribute const vocalLt = artistCands.find(c => c.name === 'vocal'); const vocalOpts = vocalLt ? txpVocalCandidates().map(c => ({ id: c.id, name: c.name, desc: 'vocal', _instrument: true, _ltId: vocalLt.id, _entityType: 'artist' })) : []; const roles = [ ...artistCands.map(c => ({ ...c, _entityType: 'artist' })), ...labelCands.map(c => ({ ...c, _entityType: 'label', desc: (c.desc ? c.desc + ' ' : '') + '(label)' })), // #525: place roles too — "recorded at", "mixed at", "mastered at" … ...linkTypesForPair('place', scopeTarget).map(c => ({ ...c, _entityType: 'place', desc: (c.desc ? c.desc + ' ' : '') + '(place)' })), ...vocalOpts, ...instrumentOpts, ]; // #522 follow-up (majkinetor, live): "when clicking search on a role, // it is not already filled in in search box in popup like Artist" — // pre-fill/pre-filter with the parsed role text, same as the entity // popover already does with r.entity. openRolePicker(roles, `Pick a role for “${trunc(r.entity || r.raw, 40)}”`, picked => { const resolved = picked._instrument ? { id: picked._ltId, name: picked.name, attributeId: picked.id } : { id: picked.id, name: picked.name }; roleCache.set((r.role || '').toLowerCase().trim(), { artistMatch: picked._entityType === 'artist' && !picked._instrument ? resolved : null, labelMatch: picked._entityType === 'label' ? resolved : null, placeMatch: picked._entityType === 'place' ? resolved : null, instrumentMatch: picked._instrument ? resolved : null, forced: picked._entityType, }); render(); saveState(); }, r.role || ''); } function txpPickEntity(r, anchor) { closePopover(); // #524/#525 follow-up (majkinetor): "regarding artist vs label, maybe // we can have 2 tabs in search" — auto-detect (r.entityType, already // computed by attachResolution for BOTH copyright holders and // ordinary credit rows) picks the starting tab; a row whose entity // type is FORCED (r.entityForced — e.g. crKind distributed/marketed/ // licensee, or an ordinary role that only exists on one side like // "published") can't toggle at all, since the other search would just // produce an unresolvable pick. const canToggle = !r.entityForced; let searchKind = r.entityType || 'artist'; popEl = el('div', 'gt-pop gt-tp-apop'); const hdr = el('div', 'gt-pop-hdr'); popEl.appendChild(hdr); if (canToggle) { const tabs = el('div', 'gt-tp-tabs'); const TAB_LABEL = { artist: 'Artist', label: 'Label', place: 'Place' }; // #525: Place added const mkTab = kind => { const b = el('button', 'gt-tp-tab', TAB_LABEL[kind]); b.type = 'button'; b.dataset.kind = kind; b.onclick = () => { searchKind = kind; renderChrome(); runSearch(); }; return b; }; tabs.append(mkTab('label'), mkTab('artist'), mkTab('place')); popEl.appendChild(tabs); } // #522 follow-up (majkinetor, live): "Remove 'Create artist' from the // bottom of artist search, add it as a + in the search, as we do in // all other scripts" (mock: a "[+]" button inside the search box // itself) — replaces the separate link row below the results. const qWrap = el('div', 'gt-tp-qwrap'); const q = el('input', 'gt-tp-q'); q.type = 'text'; q.value = r.entity || ''; const createBtn = el('button', 'gt-tp-plus', '+'); createBtn.type = 'button'; // #525 (majkinetor): "We need to wait tab close after creating an artist // to pick up the mbid (as in CH). Also, as in CH, preset type to person // and set default sort (if artist is created, not if label is created)." // // Credit Hoarder gets the MBID back over a BroadcastChannel, but it also // RUNS on /artist/create and the created entity page. Group Therapy only // matches the relationship editor, so there is nobody on the other side to // post back. Keeping the window handle achieves the same thing: the create // tab is same-origin, so we can watch it land on /<kind>/<mbid> and adopt // that entity — and fall back to a name search if the tab is just closed. // #544 (majkinetor): "Implement right click on (+) to create entity in the // background" — same creation, without the tab stealing focus, so a run of // several unresolved names can be fired off and picked up as they land. const startCreate = (background) => { // #544: "it should not ignore the search string used but seed that one" // — if the search box was edited to drop a "(Greenprint)" suffix, THAT // is the name to create, not the raw parsed text. const name = (q.value || '').trim() || r.entity || ''; const kind = searchKind; const params = new URLSearchParams(); params.set(`edit-${kind}.name`, name); if (kind === 'artist') { params.set('edit-artist.sort_name', txpGuessSortName(name)); params.set('edit-artist.type_id', '1'); // Person — sort name above assumes it } // #544: "Adding entity doesn't have any edit notes. It should do the // same as CH/Apollo" — the created entity carried no attribution at // all. Same shape as Apollo's entityActionNote: signature, then what // was being done and where. params.set(`edit-${kind}.edit_note`, txpCreateNote(kind)); // ── background: GM_openInTab, and the created page posts the MBID back if (background && typeof GM_openInTab === 'function') { const token = Math.random().toString(36).slice(2); // #598 (majkinetor): "When 2 background tabs are open creating artist, // one of them doesn't close and consequently return MBID to Text // Pattern." This used to store THE pending create — a single slot — so // starting a second one overwrote the first. The first tab's /create // page then read back a token that was not its own, decided the form // in front of it belonged to somebody else, and stood down without // ever pressing Enter: a tab left sitting on a filled-in form, and a // row spinning on "creating…" until the ten-minute timeout. // The store is a LIST now, so concurrent creates cannot evict one // another; see gtPendingAdd and the two handlers at the bottom. gtPendingAdd({ kind, token, ts: Date.now() }); // #544 follow-up (majkinetor): "When creating in the background, it // doesn't click Enter in 2nd tab." The seeded /create page is only a // filled-in FORM — in the foreground you see it and press Enter, but a // background tab just sits there unsubmitted, so nothing is ever // created and the parser waits for a post-back that cannot come. // autoSubmitSeededCreate (below) presses it. This param is how that // handler knows the form in front of it is the one THIS create opened // — matching only on "a pending create of this kind exists" would also // fire on a /artist/create the user opened by hand minutes later. // MusicBrainz ignores parameters it does not know. // // ⚠ NOT named `gt_token`. `>` is a LEGACY HTML entity that decodes // without its semicolon, so `>_token=…` came back out of any HTML // attribute as `>_token=…` — measured: the submitted form's action // carried `%3E_token`, and the handler's own check would then never // match. Any name not starting with a legacy entity is fine. params.set('x_gtcreate', token); let ch = null, timeout = null, tab = null; // #544: the row, not the popover, now carries the "waiting" state — so // the popover closes at once and the next name can be started while this // one is still in its tab. Keyed by the ROW's text (what entityCache is // keyed by), not the possibly-edited search box, so the rows showing that // text are the ones that light up and the ones that later resolve. const pendKey = (r.entity || '').toLowerCase().trim(); // Nothing below may touch the popover: it is gone, and `popEl` may by now // belong to a DIFFERENT picker the user opened in the meantime. let watch = null; const stop = () => { try { ch && ch.close(); } catch (e) {} clearTimeout(timeout); clearInterval(watch); pendingCreates.delete(pendKey); }; // #544 follow-up (majkinetor): "The new `creating ...` placeholder stays // forever if there is error/tab closed. I should be able to cancel it // like in CH." Two ways out, because neither alone is enough: // • the row's placeholder is clickable — the reliable one, since a // create can fail in ways nothing here can observe (a duplicate-check // page, a validation error, the tab left open and forgotten); // • the tab handle is watched, so simply CLOSING the tab clears it. // GM_openInTab's handle is not guaranteed to report `closed` in every // manager, which is exactly why it is the backstop and not the fix. const cancel = (why) => { if (!pendingCreates.has(pendKey)) return; stop(); gtPendingDrop(token); // #598: drop only THIS create — another may still be in flight render(); saveState(); toast(`Stopped waiting for “${trunc(name, 30)}” — ${why}`); }; pendingCreates.set(pendKey, { kind, name, cancel }); closePopover(); render(); try { ch = new BroadcastChannel(GT_CREATE_CH); ch.onmessage = async (ev) => { const d = ev && ev.data; if (!d || d.token !== token || d.kind !== kind || !d.gid) return; stop(); // #544 follow-up (majkinetor): "It doesn't close the tab after // commit, like CH/Apollo." The created entity's page does call // window.close() on itself, but that is a no-op for a tab opened // by GM_openInTab unless the script grants window.close — so the // OPENER closes it, using the handle GM_openInTab hands back. // Exactly what Credit Hoarder does and for the same reason (#273: // "a GM-opened tab can't always self-close via window.close()"). // Closed here, before the entity fetch: the entity exists either // way, so a slow or failed lookup must not leave the tab open. try { if (tab && typeof tab.close === 'function') tab.close(); } catch (e) {} const ent = await txpFetchEntity(d.gid, kind); // #544: resolve in BULK, exactly as a left click in the picker does — // every row with this text flips from "creating…" to the new entity. if (ent) { entityCache.set(pendKey, ent); render(); saveState(); toast(`Created ${kind} “${ent.name || name}”`); } else { render(); toast(`Created the ${kind}, but could not read it back — click the row to search for it`); } }; } catch (e) { /* no BroadcastChannel — falls through to the timeout */ } // Give up quietly rather than spinning forever if the tab is closed // without saving; the row falls back to "search". #544 timeout = setTimeout(() => cancel('timed out after 10 minutes'), 10 * 60 * 1000); tab = GM_openInTab(`/${kind}/create?` + params.toString(), { active: false, insert: true, setParent: true }); // the tab closing without a post-back means the create did not happen — // clear the placeholder instead of leaving it spinning. A short grace // period first, because the SUCCESS path closes the tab too (from the // message handler) and must not be reported as an abandoned create. try { if (tab && 'onclose' in tab) tab.onclose = () => setTimeout(() => cancel('the create tab was closed'), 1500); } catch (e) {} watch = setInterval(() => { let closed = false; try { closed = !!(tab && tab.closed); } catch (e) { closed = false; } if (closed) { clearInterval(watch); watch = null; setTimeout(() => cancel('the create tab was closed'), 1500); } }, 1500); return; } const url = `/${kind}/create?` + params.toString(); // ── foreground: keep the window handle and watch where it lands. // Here the popover STAYS open (you are looking at the tab you just // opened), so the spinner belongs on the + button. The background path // above closed the popover and signals on the row instead. #544 createBtn.classList.add('gt-tp-plus-wait'); createBtn.title = `Waiting for the new ${kind}…`; const win = window.open(url, '_blank'); let done = false; const finish = async (gid) => { if (done) return; done = true; clearInterval(timer); createBtn.classList.remove('gt-tp-plus-wait'); renderChrome(); if (gid) { const ent = await txpFetchEntity(gid, kind); if (ent) { try { win.close(); } catch (e) {} pick(ent, false); return; } } q.value = name; runSearch(); // fell through — let them pick it }; const timer = setInterval(() => { let path = null; try { path = win.closed ? null : win.location.pathname; } catch (e) { path = null; } // mid-navigation if (path) { const m = path.match(new RegExp('^/' + kind + '/(' + GID_RE.source + ')$')); if (m) return finish(m[1]); return; } if (win.closed) finish(null); }, 600); }; createBtn.onclick = () => startCreate(false); createBtn.addEventListener('contextmenu', e => { e.preventDefault(); startCreate(true); }); // #544 qWrap.append(q, createBtn); popEl.appendChild(qWrap); const list = el('div', 'gt-tp-results'); popEl.appendChild(list); // #522 follow-up (majkinetor, live): "if one artist has multiple // instruments, selecting one selects all... right clicking a choice // in search sets all, and normal clicking only that 1." A left click // resolves ONLY this row (entityOverride, position-keyed); a right // click resolves every row sharing this exact entity text (the // shared entityCache, same as auto-resolve uses) — same propagation // as before, now opt-in instead of automatic. // #544 (majkinetor): "Let left click change all entities upon selection // (currently it is on right click)." Swapped: the common case — one // person credited on several lines — is now the plain click, and the // narrow "just this row" case moved to right-click. popEl.appendChild(el('div', 'gt-tp-hint', 'Click: every row with this same text · Right-click: this row only')); // header/placeholder/create-title/tab-highlight all depend on // searchKind, which the tabs above can change after the popover is // already open — kept in one place so toggling stays in sync. const renderChrome = () => { const ARTICLE = { artist: 'an artist', label: 'a label', place: 'a place' }; const PLURAL = { artist: 'artists', label: 'labels', place: 'places' }; hdr.textContent = `Pick ${ARTICLE[searchKind]} for “${trunc(r.role || r.raw, 40)}”`; q.placeholder = `search ${PLURAL[searchKind]}, or paste an MBID / URL…`; createBtn.title = `Create ${searchKind} “${trunc(r.entity || '', 40)}” ↗ · right-click to create in the background`; if (canToggle) [...popEl.querySelectorAll('.gt-tp-tab')].forEach(b => b.classList.toggle('gt-tp-tab-on', b.dataset.kind === searchKind)); }; renderChrome(); const pick = (entity, bulk) => { if (bulk) entityCache.set((r.entity || '').toLowerCase().trim(), entity); else entityOverride.set(r.key, entity); closePopover(); render(); saveState(); }; // #544 (majkinetor): "Disambiguation in search results should have // different font style than entity name so it isn't confused with the // name" — "Fiona (reggae artist)" read as one name. The comment is its // own span now: smaller, grey, italic. const resRow = (name, disamb, typ) => { const row = el('div', 'gt-tp-res'); row.appendChild(el('span', 'gt-tp-restype', typ)); row.appendChild(el('span', 'gt-tp-resname', name)); if (disamb) row.appendChild(el('span', 'gt-tp-disamb', ` (${disamb})`)); return row; }; const runSearch = async () => { const term = (q.value || '').trim(); list.textContent = ''; if (!term) return; const gid = (term.match(GID_RE) || [])[0]; if (gid) { // #544 (majkinetor): "pasting MBID should work imediately rather than // showing search result". An MBID is already an exact answer — there // is nothing to choose between — so resolve and take it. Applies to // every row with this text, matching what a left click now does. const ent = await txpFetchEntity(gid, searchKind); if (ent) { pick(ent, true); return; } list.appendChild(el('div', 'gt-pop-note', 'Nothing found with that MBID.')); return; } const searchFor = { artist: txpSearchArtist, label: txpSearchLabel, place: txpSearchPlace }[searchKind] || txpSearchArtist; const searches = [searchFor(term, 8).then(l => l.map(c => ({ ...c, _kind: searchKind })))]; const cands = (await Promise.all(searches)).flat(); if (!cands.length) { list.appendChild(el('div', 'gt-pop-note', 'No matches.')); return; } cands.forEach(c => { const row = resRow(c.name || '', c.comment || '', c._kind); row.addEventListener('click', async () => { const full = await txpFetchEntity(c.gid || c.id, c._kind); if (full) pick(full, true); }); // #544: all rows row.addEventListener('contextmenu', async e => { e.preventDefault(); const full = await txpFetchEntity(c.gid || c.id, c._kind); if (full) pick(full, false); }); // #544: this row only list.appendChild(row); }); }; document.body.appendChild(popEl); // #522 sixth round (majkinetor, live, screenshot): "Artist popup is // displaced" — it always anchored to the TABLE's own top-left corner // plus a fixed 40px offset, regardless of which row/column was // actually clicked. Anchor to the clicked element itself instead, // same convention every other popover in this file already uses. const anchorEl = anchor || txpEl.querySelector('.gt-tp-tbl'); // #522 follow-up (majkinetor, live, screenshot): "search popup can be // offscreen" — the FIRST clamp ran before any results existed, so it // sized against an almost-empty popover; once results/notes filled // it back in the popover grew well past that clamp. Re-run it after // every search (initial load AND subsequent typing), against the // popover's actual current size. // // #544 (majkinetor): "Entity search popup could be position better. It // bothers me it is always on the edge and touches scrollbar." Three // reasons it ended up jammed against the scrollbar, all fixed here: // • the clamp used window.innerWidth, which INCLUDES the vertical // scrollbar — so "inside the window" still meant underneath it, and // the right-hand tab and + button were clipped. clientWidth is the // scrollbar-free box. // • the 8px margin left it visually glued to the edge even when it fit. // • when the anchor sits far right (maximized parser), aligning the // popover's LEFT edge to it always overflows; align its RIGHT edge to // the anchor instead, which keeps it next to what was clicked. // Vertically it now flips ABOVE the anchor when there isn't room below, // rather than sliding up to cover the row that opened it. const MARGIN = 16; const reposition = () => { // a debounced search can still resolve after the popover itself was // already closed (closePopover nulls the shared popEl) — nothing to // reposition at that point. if (!popEl) return; const rr = popEl.getBoundingClientRect(); // re-measured every time: the anchor moves when the parser window is // maximized or scrolled while the popover is open const ar = anchorEl.getBoundingClientRect(); const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight; const maxLeft = vw - rr.width - MARGIN; let left = ar.left; if (left > maxLeft) left = Math.min(maxLeft, ar.right - rr.width); // right-align to the anchor popEl.style.left = Math.max(MARGIN, Math.min(left, maxLeft)) + 'px'; const below = ar.bottom + 4, above = ar.top - rr.height - 4; const top = (below + rr.height + MARGIN <= vh || above < MARGIN) ? below : above; popEl.style.top = Math.max(MARGIN, Math.min(top, vh - rr.height - MARGIN)) + 'px'; }; const run = async () => { try { await runSearch(); } finally { reposition(); } }; reposition(); let t = null; q.addEventListener('input', () => { clearTimeout(t); t = setTimeout(run, 300); }); q.addEventListener('paste', () => setTimeout(run, 0)); setTimeout(() => { document.addEventListener('mousedown', onPopDown, true); document.addEventListener('keydown', onPopKey, true); q.focus(); run(); }, 0); } // core of Apply, factored out so "Apply & clear annotation" can run the // exact same dispatch logic before opening the annotation editor. async function applyResolvedRows() { const re = RE(); if (!re) { toast('Editor not ready'); return null; } // #539: one dispatch per (credit × target). appliedKeys is keyed per // target too, so the same parsed text can be applied to track 3 now and // track 7 later without the second run thinking it already ran. const fallback = scope.kind === 'recording' ? txpScopeTargets().map(t => t.rec) : [release]; // #597: a row that named its own tracks goes THERE; the Scope control is // only the default for rows that named none. That is what lets one run // handle a liner-note block where most credits are release-wide and a few // are "(track 4)" — no mode switching between them. const targetsFor = r => (r.trackTargets && r.trackTargets.length) ? r.trackTargets.map(t => t.rec) : fallback; const allRows = parsedRows().map(attachResolution); if (!fallback.length && !allRows.some(r => r.trackTargets && r.trackTargets.length)) { toast(scope.spec.trim() ? 'No track matched that selection' : 'No tracks ticked — type track numbers or tick some'); return null; } const keyFor = (r, target) => { const t = targetsFor(r); return (t.length === 1 && t === fallback && scope.kind !== 'recording') ? r.key : `${r.key}|${target.gid || target.id}`; }; const rows = allRows.filter(r => r.matched && r.roleMatch && r.entityMatch && targetsFor(r).length && targetsFor(r).some(t => !appliedKeys.has(keyFor(r, t)))); if (!rows.length) { toast('Nothing resolved to apply'); return null; } let ok = 0, fail = 0, dated = 0; let okOwn = 0; // #597: credits that went to tracks the ROW named const ownNums = new Set(); for (const r of rows) { const own = !!(r.trackTargets && r.trackTargets.length); if (own) r.trackTargets.forEach(t => ownNums.add(t.num)); const credit = r.entity && r.entity !== (r.entityMatch.name || '') ? r.entity : ''; const dates = txpNoticeDatePeriod(r.year); // #574: "in 2021", not "from 2021 to present" if (dates) dated++; // an instrument-role match carries an attributeId (the "instrument" // link type doesn't say WHICH instrument on its own). #666: vocal-type // and qualifier matches carry attributeIds (one or more) instead. const attrIds = r.roleMatch.attributeIds || (r.roleMatch.attributeId ? [r.roleMatch.attributeId] : []); const attrs = attrIds.length ? buildAttrTree(attrIds.map(id => ({ typeID: id, text_value: '', credited_as: '' }))) : null; for (const target of targetsFor(r)) { const k = keyFor(r, target); if (appliedKeys.has(k)) continue; try { dispatchRelationship(re, target, r.entityMatch, r.roleMatch.id, credit, attrs, dates); appliedKeys.add(k); ok++; if (own) okOwn++; } catch (e) { fail++; try { console.warn('[Group Therapy] text-parser apply failed:', e); } catch (_) {} } } } // #574: which rows carried a notice year is the one thing that is invisible // in the parser's own preview (it shows role/holder, never the date), so it // goes in the log — otherwise a wrong year is only findable in MB's diff. try { const yrs = rows.filter(r => r.year).map(r => `${r.crKind || 'credit'} ${r.entity || ''} = in ${r.year}`); console.debug(`[Group Therapy] Text parser: applied ${ok} rel(s), ${fail} failed; ${dated} carried a notice year (begin = end, ended)` + (yrs.length ? ` — ${yrs.join('; ')}` : '')); } catch (_) {} // #539 follow-up (majkinetor): "Make sure scope info is added to the edit // note." Which tracks were credited is the part a reviewer cannot infer // from the diff alone when several runs are batched into one edit, so the // note names them (capped — a 40-track selection would bury the note). // #597: one run can now do both — some credits to tracks they named // themselves, the rest wherever Scope points — so the note has to say so // rather than describing only the Scope half and quietly misreporting // where the other credits went. const cap = ns => (ns.length > 8 ? `${ns.slice(0, 8).join(', ')} … (+${ns.length - 8})` : ns.join(', ')); const nums = scope.kind === 'recording' ? txpScopeTargets().map(t => t.num) : []; const okFallback = ok - okOwn; const on = [...ownNums]; // `where` is a phrase that has to read correctly in two sentences (this // note and txpClearAnnotationNote's), so the two single-destination cases // keep their original wording exactly and only a genuinely MIXED run gets // the longer "n … and n …" form. const fallbackWhere = scope.kind === 'recording' ? ` to ${fallback.length} recording${fallback.length > 1 ? 's' : ''} (track${nums.length > 1 ? 's' : ''} ${cap(nums)})` : ' on the release'; const ownWhere = ` to track${on.length > 1 ? 's' : ''} ${cap(on)} named in the credit text`; const where = (okOwn && okFallback) ? ` — ${okFallback}${fallbackWhere} and ${okOwn}${ownWhere}` : okOwn ? ownWhere : okFallback ? fallbackWhere : ''; if (ok) markUsed(`Parsed ${ok} credit${ok > 1 ? 's' : ''} from text${where}`); const shortWhere = okOwn && !okFallback ? ` to ${ownNums.size} track${ownNums.size > 1 ? 's' : ''}` : scope.kind === 'recording' ? ` to ${fallback.length} recording${fallback.length > 1 ? 's' : ''}` : ''; toast(fail ? `Applied ${ok}, ${fail} failed — see console` : `✓ Applied ${ok} credit${ok > 1 ? 's' : ''}${shortWhere} — review & save`); saveState(); // #550: `where` is handed back so the annotation edit's note can name the // same scope as the relationship edit's, rather than recomputing it. return { ok, fail, where }; } // #522 follow-up (majkinetor, live): "Apply should close the window." async function txpApply() { const res = await applyResolvedRows(); if (res) closeTextParser(); } // #522 follow-up (majkinetor): "after loading annotation, add another // button - Apply and remove annotation" — applies exactly like Apply, // then clears the release's annotation. // // #550 (majkinetor): "Apply and clear annotation should do clearing in the // background. It is weird to double check empty field." It used to open the // annotation editor in a new tab with the text pre-emptied, which left you // reviewing a page whose only content was a field this tool had just // blanked — a confirmation step with nothing to confirm. It is submitted // here instead, with its own edit note. // // Only offered once text has actually come FROM the annotation this // session — clearing an unrelated annotation because you happened to // paste your own credit text would be a real, silent mistake. async function txpApplyAndClearAnnotation() { const res = await applyResolvedRows(); if (!res) return; // Nothing actually dispatched — clearing the annotation now would delete // the text with nothing to show for it. applyResolvedRows has already said // what went wrong. if (!res.ok) return; const label = applyClearBtn.textContent; applyClearBtn.disabled = true; applyBtn.disabled = true; applyClearBtn.textContent = 'Clearing annotation…'; try { const out = await txpClearAnnotation(release.gid, txpClearAnnotationNote(res.ok, res.where), 'Credits moved to relationships'); closeTextParser(); toast(out.skipped ? 'Applied — the annotation was already empty' : `Applied, and the annotation was cleared (${out.was} characters)`); } catch (e) { // the relationships ARE applied at this point; only the clear failed. // Leave the window open so it is obvious which half needs attention. applyClearBtn.textContent = label; applyClearBtn.disabled = false; applyBtn.disabled = false; toast(`Applied, but the annotation was not cleared: ${(e && e.message) || e}`); } } // #522 follow-up (majkinetor, live): "When you exit and return to text // parser window, if you resolved anything its gone. Keep the complete // state." — role/artist resolutions and which rows were already applied // are now persisted too, not just the pasted text and pattern. Maps/Sets // aren't JSON-native, so they're flattened to plain arrays here and // rebuilt on load (see the `saved` restore below). const saveState = () => txpSaveState(release.gid, { // #544 follow-up (majkinetor): "When exiting and returning Text parser, // any freezed patterns are gone. State should be kept completely." The // per-line pattern override IS the freeze — freezeBtn stamps `pattern` // into lines[i].override — and it was the one piece of row state the save // never carried, so reopening rebuilt every line with override:''. // Positional is correct here and nowhere else: `text` is saved in the very // same call, so index i means the same line on the way back in. (Compare // onTextChange, which must DROP position-keyed state, precisely because // there the text has changed underneath it.) overrides: lines.map(l => (l && l.override) || ''), // "State should be kept completely" — the window's own two states reset // on every reopen as well: whether it was maximized, and whether the paste // box was rolled up. Both are one flag each and both are visible. maximized: panel.classList.contains('gt-tp-max'), srcOpen: ta.style.display !== 'none', // #544 follow-up (majkinetor): 'Button "load and remove annotation" is lost // when exiting / returning.' "Apply & clear annotation" only shows once the // text CAME FROM the annotation, and that flag was a plain local — so // reopening always hid the button even though the annotation text it acts on // was restored right beside it. loadedFromAnnotation, text: ta.value, pattern, scopeKind: scope.kind, scopeSpec: scope.spec, scopeTracks: scope.tracks, roleCache: [...roleCache.entries()], entityCache: [...entityCache.entries()], entityOverride: [...entityOverride.entries()], appliedKeys: [...appliedKeys], }); const onTextChange = () => { const newLines = ta.value.split('\n').map((raw, i) => ({ raw, override: (lines[i] && lines[i].override) || '' })); // ANY line whose raw text actually changed — not just a line being // added/removed — can point its li:pi:si position at completely // different content (caught live: replacing a whole single-line // paste with a DIFFERENT single-line paste keeps the same li:0:0 // position). Two position-keyed things go stale this way: the // per-row entity override (a wrong pick could silently reattach to // unrelated new text) AND appliedKeys (a brand-new, never-applied // line could show "✓ applied" and get silently skipped by Apply, // caught live: a fresh paste inherited "applied" from an EARLIER, // unrelated line that happened to land at the same li:0:0). Drop // both for every touched line; untouched lines elsewhere keep theirs. const max = Math.max(newLines.length, lines.length); for (let li = 0; li < max; li++) { if (!lines[li] || !newLines[li] || lines[li].raw !== newLines[li].raw) { [...entityOverride.keys()].filter(k => +k.split(':')[0] === li).forEach(k => entityOverride.delete(k)); [...appliedKeys].filter(k => +k.split(':')[0] === li).forEach(k => appliedKeys.delete(k)); } } lines = newLines; render(); saveState(); }; const onPatternChange = () => { pattern = patIn.value; _txpPattern = pattern; render(); saveState(); }; patIn.addEventListener('input', onPatternChange); patClr.onclick = () => { patIn.value = ''; onPatternChange(); }; ta.addEventListener('input', onTextChange); ta.addEventListener('input', () => { loadedFromAnnotation = false; }); // #528 (majkinetor): "Once on load" — the compound-copyright-line split // runs once right after a paste lands (not on every keystroke, so it // doesn't fight a mid-edit user), same setTimeout(0)-after-paste idiom // already used elsewhere in this file. ta.addEventListener('paste', () => setTimeout(() => { const split = txpSplitCompoundCopyrightLines(ta.value); if (split !== ta.value) { ta.value = split; onTextChange(); } }, 0)); const setSrcOpen = (open) => { ta.style.display = open ? '' : 'none'; srcTgl.textContent = (open ? '▾' : '▸') + ' Paste credit text'; }; srcTgl.onclick = () => { setSrcOpen(ta.style.display === 'none'); saveState(); }; resolveBtn.onclick = resolveAll; // #544 freeze: per LINE, since the override is a line's property — a line // that expanded into several rows is frozen once. freezeBtn.onclick = () => { const seen = new Set(); let n = 0; parsedRows().forEach(r => { if (seen.has(r.li)) return; const ln = lines[r.li]; if (!ln || (ln.override || '').trim()) return; // already pinned to something if (!r.matched) return; // nothing matched, nothing to freeze seen.add(r.li); ln.override = pattern; n++; }); render(); saveState(); toast(n ? `Froze ${n} matched line${n !== 1 ? 's' : ''} to “${pattern}”` : 'No still-default lines match this pattern'); }; applyBtn.onclick = txpApply; applyClearBtn.onclick = txpApplyAndClearAnnotation; // #522 follow-up (majkinetor, live): "Load annotation should load it into // text field without any confirmation, there is no need for additional // interface" — straight into the paste box, no preview/confirm step. annoBtn.onclick = async () => { annoBtn.disabled = true; annoBtn.textContent = 'Loading…'; try { const text = await txpFetchAnnotation(release.gid); if (!text) toast('No annotation found for this release'); // onTextChange() saves, but it also CLEARS loadedFromAnnotation via the // textarea's own input handler — so the flag is set and saved after it. #544 else { ta.value = txpSplitCompoundCopyrightLines(text); onTextChange(); loadedFromAnnotation = true; render(); saveState(); } } catch (e) { toast('Failed to load annotation: ' + (e && e.message || e)); } annoBtn.disabled = false; annoBtn.textContent = 'Load annotation'; }; panel.append(hdr, ctrl, src, body, foot); txpEl.appendChild(panel); document.body.appendChild(txpEl); txpEl.addEventListener('mousedown', e => { if (e.target === txpEl) closeTextParser(); }); document.addEventListener('keydown', onTxpKey, true); if (saved) { if (saved.text) { ta.value = saved.text; const ov = saved.overrides || []; // #544 follow-up: frozen patterns lines = ta.value.split('\n').map((raw, i) => ({ raw, override: ov[i] || '' })); } (saved.roleCache || []).forEach(([k, v]) => roleCache.set(k, v)); (saved.entityCache || []).forEach(([k, v]) => entityCache.set(k, v)); (saved.entityOverride || []).forEach(([k, v]) => entityOverride.set(k, v)); (saved.appliedKeys || []).forEach(k => appliedKeys.add(k)); loadedFromAnnotation = !!saved.loadedFromAnnotation; // #544: brings "Apply & clear annotation" back with its text if (saved.maximized) maxBtn.onclick(); // same path the button takes, so the saved size handling matches if (saved.srcOpen === false) setSrcOpen(false); } // #544 follow-up: whatever was last on screen is what comes back, whichever // control changed it — see closeTextParser. _txpSaveOnClose = saveState; render(); refreshScopeUi(); // #539: show what the saved scope selects, before anything is touched setTimeout(() => { try { patIn.focus(); patIn.select(); } catch (e) {} }, 30); } // ── work credits: right-click a work's checkbox → copy its writer/composer credits to ticked works ─ // (Per maintainer: we don't copy the work itself, we add the source work's own relationships to the // selected works.) Read the work's artist rels (writer/composer/lyricist/…) via fiber, dedup, dispatch. function workCreditRels(work) { const out = [], seen = new Set(); document.querySelectorAll('.relationship-item').forEach(item => { const rel = relFromNode(item); if (!rel || !looksRel(rel)) return; const w0 = sameEntity(rel.entity0, work), w1 = sameEntity(rel.entity1, work); if (!w0 && !w1) return; const other = w0 ? rel.entity1 : rel.entity0; if (!other || ['recording', 'url', 'work'].includes(other.entityType)) return; // work credits (writer/composer/lyricist/publisher/…) — skip performance (recording), based-on (work), url const k = rel.linkTypeID + '|' + other.gid; if (seen.has(k)) return; seen.add(k); const credit = w0 ? rel.entity1_credit : rel.entity0_credit; out.push({ item, other, credit: val(credit) || '', linkTypeID: rel.linkTypeID, attributes: rel.attributes || null, begin_date: rel.begin_date || null, end_date: rel.end_date || null, ended: !!rel.ended, removed: rel._status === 3 }); }); return out; } async function removeWorkRels(work, srcRels) { const want = new Set(srcRels.map(s => s.linkTypeID + '|' + (s.other && s.other.gid))); for (let guard = 0; guard < 300; guard++) { let btn = null; for (const item of document.querySelectorAll('.relationship-item')) { const rel = relFromNode(item); if (!rel || !looksRel(rel) || rel._status === 3) continue; const w0 = sameEntity(rel.entity0, work), w1 = sameEntity(rel.entity1, work); if (!w0 && !w1) continue; const other = w0 ? rel.entity1 : rel.entity0; if (other && !['recording', 'url', 'work'].includes(other.entityType) && want.has(rel.linkTypeID + '|' + other.gid)) { btn = item.querySelector(REMOVE_SEL); break; } } if (!btn) break; try { btn.click(); } catch (e) {} await new Promise(r => setTimeout(r, 70)); } } /* ── #470 replace a role ─────────────────────────────────────────────────── "I'm editing a jazz release that is 100% instrumental. All the works have 'writer' instead of 'composer'. It'd be awesome if I could just mark all writers on highlighted works and change them from 'writer' to 'composer'." MB has no "change the type of this relationship" bulk action, so this is a remove + re-add of the same pair under a different link type, driven through the same editor dispatch everything else here uses. Scope mirrors the ×-menu's: this ROLE everywhere, or this ROLE for this one target — narrowed to the ticked recordings/works when there is a selection. Attributes are deliberately NOT carried over: they belong to a specific link type (a "drums (drum set)" attribute is meaningless on "composer"), and MB would reject or silently drop them. Rels that HAVE attributes are therefore flagged in the confirm step rather than quietly mangled — which is the "it complicates on roles that have attributes" majkinetor called out, handled by being explicit instead of clever. */ // the entity ends of a rel, as {src, other, credit}: `src` is the work or // recording the credit hangs off, `other` the artist/label/… on the far end. // credit always belongs to the far end, so it's read from whichever side that is. function relEnds(rel) { if (!rel) return null; const isContainer = e => e && (e.entityType === 'work' || e.entityType === 'recording'); const a = rel.entity0, b = rel.entity1; if (isContainer(a) && !isContainer(b)) return { src: a, other: b, credit: rel.entity1_credit || '' }; if (isContainer(b) && !isContainer(a)) return { src: b, other: a, credit: rel.entity0_credit || '' }; return null; } const relHasAttributes = rel => { try { return !!rel.attributes && [...W.MB.tree.iterate(rel.attributes)].length > 0; } catch (e) { return false; } }; // every link type valid for the SAME entity pair as `rel` — that's what MB // will accept as a replacement. Deprecated types and the current type are // dropped; the rest are sorted by name so the picker reads alphabetically. function replacementRoles(rel) { const lts = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type) || {}; const cur = lts[rel.linkTypeID]; if (!cur) return []; // MB keys link_type by BOTH numeric id and gid, so Object.values() yields // every type twice — dedupe by id or the picker lists everything double. const seen = new Set(); return Object.values(lts) .filter(t => t && !t.deprecated && t.id !== cur.id && t.type0 === cur.type0 && t.type1 === cur.type1) .filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; }) .map(t => ({ id: t.id, name: t.name, desc: (t.description || '').replace(/<[^>]*>/g, '').trim() })) .sort((p, q) => p.name.localeCompare(q.name)); } // #522: the same filtering as replacementRoles(), but for a pair that has NO // existing relationship yet (the text parser is CREATING new credits, not // replacing one) — driven from two raw entityType strings instead. MB's // link_type table keys type0/type1 in a canonical (sorted) order. // #544: attribution for an entity created from the parser. Mirrors Apollo's // entityActionNote — the script signature, then what was being done and on // which release — so the new artist/label is not an unexplained edit. // Module scope on purpose: the __groupTherapy export at the bottom // references it, and that export is wrapped in try/catch — defined inside // openTextParser it threw ReferenceError there and silently took the WHOLE // test hook down with it. /* #550 (majkinetor): "Apply and clear annotation should do clearing in the background. It is weird to double check empty field. … It should also add appropriate edit note." MusicBrainz's annotation editor is a plain server-rendered form with no CSRF token — same shape as /add-alias, which Falcon already posts to directly — so it can be fetched, emptied and submitted without ever showing the user a page whose only content is a field we just blanked. An annotation edit is an auto-edit, so it applies immediately. Two things this deliberately does NOT do: • it never submits when the annotation is already empty (there is no edit to make, and MB would reject it anyway) — that case is reported, not faked; • it re-reads the annotation afterwards and only reports success if it is actually gone. A 200 from a form POST proves the request left the browser and nothing more. */ const TXP_ANNO_URL = gid => `/release/${gid}/edit_annotation`; function txpClearAnnotationNote(applied, scopeLabel) { return `${editNoteSig()} Cleared the annotation: its credits were entered as ${applied} relationship${applied === 1 ? '' : 's'}${scopeLabel || ''} instead, so the text was redundant.`; } async function txpFetchAnnotationForm(gid) { const r = await fetch(TXP_ANNO_URL(gid), { credentials: 'same-origin' }); if (!r.ok) throw new Error(`could not open the annotation editor (HTTP ${r.status})`); const html = await r.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const ta = doc.querySelector('textarea[name="edit-annotation.text"]'); // A logged-out session is redirected to the login page, which is a perfectly // valid 200 with no annotation form on it — say which of the two happened. if (!ta) throw new Error(/\/login/.test(r.url) ? 'you are not logged in to MusicBrainz' : 'MusicBrainz did not return the annotation form'); return { doc, ta, text: ta.value || '' }; } async function txpClearAnnotation(gid, note, changelog) { const { doc, text } = await txpFetchAnnotationForm(gid); if (!text.trim()) return { skipped: true }; const body = new URLSearchParams(); // carry every hidden field the form declares rather than naming the ones we // know about — if MusicBrainz adds one, dropping it silently would be a // rejected or malformed edit with no clue why. doc.querySelectorAll('form input[type="hidden"][name]').forEach(i => body.append(i.name, i.value)); body.set('edit-annotation.text', ''); body.set('edit-annotation.changelog', changelog || ''); body.set('edit-annotation.edit_note', note || ''); const r = await fetch(TXP_ANNO_URL(gid), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!r.ok) throw new Error(`MusicBrainz rejected the annotation edit (HTTP ${r.status})`); // read it back — the POST returning 200 is not evidence the annotation is gone const after = await txpFetchAnnotationForm(gid); if (after.text.trim()) throw new Error('MusicBrainz accepted the request but the annotation is still there'); return { cleared: true, was: text.trim().length }; } function txpCreateNote(kind) { const relUrl = location.href.split(/[?#]/)[0].replace(/\/edit(-relationships)?$/, ''); return `${editNoteSig()} Created this ${kind} while adding credits parsed from text to ${relUrl}`; } function linkTypesForPair(typeA, typeB) { const lts = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type) || {}; const [t0, t1] = [typeA, typeB].sort(); const seen = new Set(); return Object.values(lts) .filter(t => t && !t.deprecated && t.type0 === t0 && t.type1 === t1) .filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; }) .map(t => ({ id: t.id, name: t.name, desc: (t.description || '').replace(/<[^>]*>/g, '').trim() })) .sort((p, q) => p.name.localeCompare(q.name)); } // #522 follow-up (majkinetor, live): "Instrument roles (flute, saxophone // etc.) are not resolved automatically" — MB has no standalone "Guitar" / // "Flute" link type; a specific instrument is an ATTRIBUTE (id 14's own // subtree, root_id === 14, live-verified) on the generic "instrument" // performer relationship. Role text that doesn't match any direct link // type is also tried against this vocabulary — a hit becomes the // "instrument" link type PLUS that attribute, not a link type on its own. const INSTRUMENT_ATTR_ROOT = 14; // #666: vocal types ("lead vocals", "background vocals", "soprano vocals" …) // are the same shape — the "vocal" link type plus an attribute from root 3 // (live-verified on the sandbox: 4 lead, 12 background, 13 choir, 461 other …). const VOCAL_ATTR_ROOT = 3; function txpAttrCandidates(root) { const lat = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type) || {}; const seen = new Set(); return Object.values(lat) .filter(a => a && a.root_id === root && a.id !== root) .filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; }) .map(a => ({ id: a.id, name: a.name })) .sort((p, q) => p.name.localeCompare(q.name)); } const txpInstrumentCandidates = () => txpAttrCandidates(INSTRUMENT_ATTR_ROOT); const txpVocalCandidates = () => txpAttrCandidates(VOCAL_ATTR_ROOT); // #666: liner-note wordings MB's vocal vocabulary doesn't use. Applied to the // whole (lower-cased, flag-stripped) role text before matching. const TXP_VOCAL_SYNONYMS = { 'backing vocals': 'background vocals', 'backing vocal': 'background vocals', 'backing vocalist': 'background vocals', 'backup vocals': 'background vocals', 'backup vocal': 'background vocals', 'background vocal': 'background vocals', 'background vocalist': 'background vocals', 'backing': 'background vocals', 'lead vocal': 'lead vocals', 'lead vocalist': 'lead vocals', 'lead singer': 'lead vocals', 'choir': 'choir vocals', 'spoken word': 'spoken vocals', }; // #666: qualifier words that are ATTRIBUTES on many link types ("Guest // Vocals", "Co-Producer", "Executive Producer"). Names, not ids, are the key — // the id is looked up from MB's own table at runtime. const TXP_FLAG_RE = /^(guest|additional|solo|co|executive|assistant|associate)(?:\s*-\s*|\s+)(.+)$/; function txpFlagAttrId(name) { const lat = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type) || {}; const hit = Object.values(lat).find(a => a && a.id === a.root_id && a.name === name); return hit ? hit.id : null; } // does link type `ltId` accept attribute `attrId`? (its `attributes` map is keyed by attribute id) function txpLtAllowsAttr(ltId, attrId) { const lt = W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type && W.MB.linkedEntities.link_type[ltId]; return !!(lt && lt.attributes && (lt.attributes[attrId] || lt.attributes[String(attrId)])); } const ROLE_HIST_KEY = 'gt-role-history'; const roleHistory = () => { try { return JSON.parse(GM_getValue(ROLE_HIST_KEY, '[]')) || []; } catch (e) { return []; } }; function rememberRole(id, name) { try { const h = roleHistory().filter(x => x.id !== id); h.unshift({ id, name }); GM_setValue(ROLE_HIST_KEY, JSON.stringify(h.slice(0, 8))); } catch (e) {} } // searchable role picker. Recently-used roles float to the top (majkinetor: // "keep last ones in combo history") since a cleanup pass usually applies the // same replacement over and over. Takes the role LIST directly (not a `rel`) // so #522's text parser can feed it `linkTypesForPair(...)` roles for a pair // that has no existing relationship yet. function openRolePicker(roles, title, onPick, initialQuery) { if (!roles.length) { toast('No relationship types are valid for this pair'); return; } const ov = el('div', 'gt-cons-ov'), panel = el('div', 'gt-cons gt-role-pick'); const hdr = el('div', 'gt-cons-hdr'); // #544 (majkinetor): "Role close button should be on standard position." // The title span carried no class, so it missed the `flex:1` that pushes // the ✕ to the right in every other dialog here — the button sat glued to // the end of the title text. Same classes as the rest now. hdr.appendChild(el('span', 'gt-cons-title', title)); const close = el('button', 'gt-cons-x', '✕'); close.type = 'button'; close.title = 'Cancel'; hdr.appendChild(close); panel.appendChild(hdr); const search = el('input', 'gt-role-search'); search.type = 'text'; search.placeholder = 'Type to filter roles…'; if (initialQuery) search.value = initialQuery; panel.appendChild(search); const list = el('div', 'gt-role-list'); panel.appendChild(list); const done = () => { try { ov.remove(); } catch (e) {} }; close.addEventListener('click', done); ov.addEventListener('mousedown', e => { if (e.target === ov) done(); }); let rows = []; // #544 (majkinetor): "Keyboard up/down cant be used when selecting role." // Enter used to fire rows[0] blindly, so the only reachable choice was // whatever sorted first. An explicit cursor makes the list navigable and // makes Enter mean "the one I can see highlighted". let active = 0; const paintActive = () => { rows.forEach((row, i) => row.classList.toggle('gt-role-active', i === active)); const row = rows[active]; if (row && row.scrollIntoView) row.scrollIntoView({ block: 'nearest' }); }; const moveActive = delta => { if (!rows.length) return; active = (active + delta + rows.length) % rows.length; // wraps, so Up from the top lands on the last paintActive(); }; function render() { const q = search.value.trim().toLowerCase(); const hist = roleHistory(); const rank = r => { const i = hist.findIndex(h => h.id === r.id); return i < 0 ? 999 : i; }; const shown = roles.filter(r => !q || r.name.toLowerCase().includes(q)) .sort((p, o) => (rank(p) - rank(o)) || p.name.localeCompare(o.name)); list.textContent = ''; rows = shown.slice(0, 200).map(r => { const row = el('div', 'gt-role-row'); const nm = el('span', 'gt-role-name', r.name); if (rank(r) < 999) nm.appendChild(el('span', 'gt-role-recent', ' recent')); row.appendChild(nm); if (r.desc) row.appendChild(el('span', 'gt-role-desc', r.desc)); // #544: no 110-char truncation — the CSS clamps to two lines row.addEventListener('click', () => { rememberRole(r.id, r.name); done(); onPick(r); }); row.addEventListener('mousemove', () => { const i = rows.indexOf(row); if (i >= 0 && i !== active) { active = i; paintActive(); } }); // #544: keep mouse and keyboard on the same row list.appendChild(row); return row; }); if (!shown.length) list.appendChild(el('div', 'gt-note', 'No role matches that filter')); if (active >= rows.length) active = 0; // #544: the filter just changed the list under us paintActive(); } search.addEventListener('input', render); search.addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); done(); return; } // #544: Up/Down move the cursor; PageUp/PageDown and Home/End jump, since // the list runs to 200 roles and paging through it one at a time is not // navigation. Enter takes whatever is highlighted. if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); return; } if (e.key === 'PageDown') { e.preventDefault(); moveActive(10); return; } if (e.key === 'PageUp') { e.preventDefault(); moveActive(-10); return; } if (e.key === 'Home') { e.preventDefault(); active = 0; paintActive(); return; } if (e.key === 'End') { e.preventDefault(); active = Math.max(0, rows.length - 1); paintActive(); return; } if (e.key === 'Enter' && rows.length) { e.preventDefault(); (rows[active] || rows[0]).click(); } }); render(); ov.appendChild(panel); document.body.appendChild(ov); // #522 sixth round (majkinetor, live): "Make role text in the search // box selected" — pre-filled text is selected so typing immediately // overwrites it instead of appending. setTimeout(() => { try { search.focus(); search.select(); } catch (e) {} }, 30); } // remove + re-add each matching rel under `newLt`. Re-add FIRST, then remove: // if a dispatch throws we bail with the original still intact rather than // having deleted a credit we then failed to recreate. function replaceRole(items, newLt, describe) { const re = RE(); if (!re) { toast('MusicBrainz relationship editor not ready'); return 0; } let n = 0, skipped = 0, lostAttrs = 0; for (const it of items) { const rel = relFromNode(it); const ends = relEnds(rel); if (!rel || !ends || !ends.src || ends.src.id == null) { skipped++; continue; } if (relHasAttributes(rel)) lostAttrs++; try { dispatchRelationship(re, ends.src, ends.other, newLt.id, ends.credit, null, rel); } catch (e) { skipped++; try { console.warn('[Group Therapy] replace-role dispatch failed:', e); } catch (_) {} continue; } const rm = it.querySelector(REMOVE_SEL); if (rm) { try { rm.click(); } catch (e) {} } n++; } if (n) markUsed(describe(n)); const bits = [`Replaced ${n} credit${n > 1 ? 's' : ''} with “${newLt.name}”`]; if (lostAttrs) bits.push(`${lostAttrs} had attributes that could not carry over`); if (skipped) bits.push(`${skipped} skipped (see console)`); toast(bits.join(' — ') + ' — review & save'); return n; } // the two menu entries offered on a rel's pencil: this role everywhere, and // this role only where the far end is this specific entity. function replaceRoleMenuItems(item) { const rel = relFromNode(item); if (!rel || !relEnds(rel)) return []; const btn = item.querySelector(REMOVE_SEL); if (!btn) return []; const roleLabel = pickRoleLabel(item.closest('tr')) || ltName(rel.linkTypeID); const tgt = targetLabel(item); const sel = selectionKeys(); const scoped = list => (sel.recs.size || sel.works.size) ? list.filter(i => itemInSelection(i, sel)) : list; const byRole = scoped(collect(btn, 'role')); const byRoleTgt = scoped(collect(btn, 'role-and-target')); const scopeNote = (sel.recs.size || sel.works.size) ? `scoped to ${[sel.recs.size && `${sel.recs.size} recording${sel.recs.size > 1 ? 's' : ''}`, sel.works.size && `${sel.works.size} work${sel.works.size > 1 ? 's' : ''}`].filter(Boolean).join(' + ')} selected` : null; const t = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }; const mk = (label, list, what) => ({ label, sub: String(list.length), run: () => { if (!list.length) { toast('Nothing matches that scope'); return; } openRolePicker(replacementRoles(rel), `Replace with which role? (${list.length} credit${list.length > 1 ? 's' : ''})`, newLt => replaceRole(list, newLt, n => `Replaced ${what} with “${newLt.name}” on ${n} credit${n > 1 ? 's' : ''}` + (scopeNote ? ` (${scopeNote})` : ''))); }, }); const out = ['sep']; if (scopeNote) out.push({ note: scopeNote }); out.push( mk(`Replace role ${t(roleLabel, 34)}…`, byRole, `“${roleLabel}”`), mk(`Replace ${t(roleLabel, 20)} for “${t(tgt, 20)}”…`, byRoleTgt, `“${roleLabel}” for “${tgt}”`), ); return out; } function openWorkMenu(workRef, x, y, preselect, extraItems) { const srcWork = (workRef && workRef.entityType === 'work') ? workRef : workEntity(workRef); // #373 accept a work entity (from a rel) or a checkbox if (!srcWork) { openMenu(x, y, [{ header: 'Could not read this work' }]); return; } const srcRels = workCreditRels(srcWork).filter(r => !r.removed); const entries = srcRels.map(s => ({ rel: s, role: roleKeyOfSpec(s), pos: roleLabelOf(s), text: val(s.other.name) + (s.credit && s.credit !== val(s.other.name) ? ` (${s.credit})` : ''), checked: preselect ? !!preselect(s) : true })); // #373 pencil/+ pre-tick a subset const chosen = () => entries.filter(e => e.cb ? e.cb.checked : e.checked !== false).map(e => e.rel); // Destinations: ticked works come from MB's own selection state, not the DOM — a newly-created // work's checkbox has no readable React entity (its fiber differs), so a DOM scan misses it, but // selectedWorks holds every ticked work. New works carry a NEGATIVE id and may have no gid yet, so // identify by gid-or-id (not gid alone, which would drop them). If NOTHING is ticked, fall back to // every OTHER work on the page — mirrors openCopyMenu's recording behavior (#522 follow-up: work // credit copy required a pre-tick while recording credit copy already copied-to-all by default). const idOf = w => w.gid || ('#' + w.id); const tickedWorks = () => { const out = [], seen = new Set(); try { for (const e of W.MB.tree.iterate(RE().state.selectedWorks)) { const w = Array.isArray(e) ? e[1] : e; if (!w) continue; if (srcWork.gid && w.gid === srcWork.gid) continue; // skip the source work if (srcWork.id != null && w.id === srcWork.id) continue; const k = idOf(w); if (seen.has(k)) continue; seen.add(k); out.push(w); } } catch (e) {} return out; }; const allOtherWorks = () => { const out = [], seen = new Set(); document.querySelectorAll('input.work').forEach(cb => { const w = workEntity(cb); if (!w) return; if (srcWork.gid && w.gid === srcWork.gid) return; if (srcWork.id != null && w.id === srcWork.id) return; const k = idOf(w); if (seen.has(k)) return; seen.add(k); out.push(w); }); return out; }; const ticked0 = tickedWorks(); const destWorks = ticked0.length ? ticked0 : allOtherWorks(); const nR = srcRels.length, nD = destWorks.length; const destNames = destWorks.map(w => val(w.name)); const items = []; if (!nR) items.push({ header: `“${trunc(val(srcWork.name), 34)}” has no credits` }); else if (!nD) items.push({ header: 'No other works to copy to' }); else { const copyItem = { label: 'Copy', sub: String(nR), run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } if (copyCredits(c, destWorks)) markUsed(`Copied ${c.length} credit${c.length > 1 ? 's' : ''} from work “${val(srcWork.name)}” to ${nD} work${nD > 1 ? 's' : ''}`); toast(`Copied ${c.length} credit${c.length > 1 ? 's' : ''} to ${nD} work${nD > 1 ? 's' : ''} — review & save`); } }; const moveItem = { label: 'Move (remove here)', danger: true, run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } if (copyCredits(c, destWorks)) markUsed(`Moved ${c.length} credit${c.length > 1 ? 's' : ''} from work “${val(srcWork.name)}” to ${nD} work${nD > 1 ? 's' : ''}`); removeWorkRels(srcWork, c); toast(`Moved ${c.length} credit${c.length > 1 ? 's' : ''} to ${nD} work${nD > 1 ? 's' : ''} — review & save`); } }; items.push({ header: `Copy to ${ticked0.length ? '' : 'all '}${nD} work${nD > 1 ? 's' : ''}` }, { note: destNames.slice(0, 6).join(' · ') + (destNames.length > 6 ? ` +${destNames.length - 6} more` : '') }, { checklist: entries, onToggle: () => { const n = chosen().length; copyItem._setSub && copyItem._setSub(String(n)); } }, copyItem, moveItem); } if (extraItems && extraItems.length) items.push(...extraItems); // #470 "Replace role…" from the pencil openMenu(x, y, items); } // discoverability tooltips: label the controls GT hooks (set lazily on first hover; React may wipe // them on re-render, so we re-set via the data flag). Kept out of `title` when MB already set one. function hintControls(ev) { const t = ev.target; if (!t || !t.closest) return; const cb = t.closest && t.closest('tr.track input.recording, tr.track input.work'); if (cb && !cb.dataset.gtHint) { cb.dataset.gtHint = '1'; if (!cb.title) cb.title = cb.matches('input.work') ? 'Group Therapy: right-click to copy/move this work’s relationships to the ticked works' : 'Group Therapy: right-click to copy/move this recording’s credits to the ticked recordings'; return; } const x = t.closest('button.icon.remove-item'); if (x && !x.dataset.gtHint) { x.dataset.gtHint = '1'; if (!x.title) x.title = 'Group Therapy: right-click to remove a whole group (this role / this target / both)'; } } // ── boot ──────────────────────────────────────────────────────────────── function boot() { injectStyle(); document.body.addEventListener('contextmenu', onContextMenu, true); document.body.addEventListener('mouseover', onOver); document.body.addEventListener('mousemove', onMove); document.body.addEventListener('mouseout', onOut); document.body.addEventListener('mouseover', hintControls, true); let tries = 0; (function tryInject() { if (injectCloneButton() || tries++ > 40) return; setTimeout(tryInject, 500); })(); try { W.__groupTherapy = { VERSION, fmtBadges, badgeInk, FMT_FAMILY, collect, removeButtons, highlightPage, recordingRels, recordingEntity, copyCredits, checkedDestinations, openCopyMenu, removeSourceRels, rowForRecording, fetchReleaseRels, injectCloneButton, openCopyFromPopover, workEntity, workCreditRels, openWorkMenu, mediumFormatOf, formatExcludeRolesFor, RE, replacementRoles, replaceRole, replaceRoleMenuItems, relEnds, openRolePicker, // #522 text parser txpTokenize, txpCompile, txpExpand, linkTypesForPair, openTextParser, closeTextParser, txpTrackRows, txpMatchTracks, // #539 recording scope txpMatchTrackToken, txpDetectTracks, // #597 per-row tracks txpCreateNote, // #544 txpClearAnnotation, txpFetchAnnotationForm, txpClearAnnotationNote, // #550 txpSearchArtist, txpResolveByExactAlias, txpFetchEntity, txpFetchAnnotation, txpAnnoHtmlToText, txpSearchLabel, txpResolveLabelByExactAlias, txpParseCopyrightLine, txpNoticeDatePeriod, txpNarrowByScore, txpInstrumentCandidates, txpVocalCandidates, txpSplitCompoundCopyrightLines, wmMedleyParts, wmRecordings, openWorkMatch, // #718 medleys }; } catch (e) {} console.log('[Group Therapy] ' + mbuStartupInfo('Group Therapy')); console.log(`[Group Therapy] v${VERSION} ready — right-click a relationship's × for group delete; hover a name/role to highlight.`); } /* #544 (majkinetor): "Right click on + doesn't create in the background" — window.open + win.blur()/window.focus() does not put a tab in the background in Chrome, so it behaved exactly like a left click. GM_openInTab({active:false}) does, but it hands back no window handle, so the created MBID cannot be read by polling the tab's URL the way the foreground path does. Credit Hoarder solved this by running on the created entity's own page and posting the MBID back (#273); the same shape here. That is what the new /artist/*, /label/*, /place/* matches are for — this handler and nothing else. Everything below still boots only on the relationship editor. */ const GT_CREATE_CH = 'gt-entity-created'; const GT_PENDING_KEY = 'gt:pendingCreate'; const GT_PENDING_TTL = 10 * 60 * 1000; /* #598: which create a given tab is running. sessionStorage, deliberately — it is per-TAB and survives navigation within that tab, which is exactly the relation we need: the /<kind>/create page and the /<kind>/<mbid> page it submits itself into are the same tab, and no amount of shared GM state can tell those two pages apart once several creates are in flight at once. The create page stamps its token here before pressing Enter; the entity page reads it and knows, with no guessing, which row is waiting for it. */ const GT_TAB_KEY = 'gt:createTab'; /* #598: the pending-create store. One slot became a list, because two concurrent background creates evicted each other — see the comment at the GM_openInTab call. Every read prunes: a create nobody ever completed must not keep matching forever, and the TTL is the same ten minutes the opener waits before it gives up on its own. */ function gtPending() { let v = null; try { v = JSON.parse(GM_getValue(GT_PENDING_KEY, '') || 'null'); } catch (e) {} // An install upgrading mid-create still has the old single-object shape // under this key; adopt it rather than throwing that create away. if (v && !Array.isArray(v)) v = [v]; if (!Array.isArray(v)) return []; const now = Date.now(); return v.filter(p => p && p.token && p.kind && p.ts && now - p.ts < GT_PENDING_TTL); } function gtPendingWrite(list) { try { GM_setValue(GT_PENDING_KEY, JSON.stringify(list)); } catch (e) {} } function gtPendingAdd(rec) { gtPendingWrite(gtPending().concat([rec])); } function gtPendingDrop(token) { gtPendingWrite(gtPending().filter(p => p.token !== token)); } function gtTabMark(rec) { try { sessionStorage.setItem(GT_TAB_KEY, JSON.stringify(rec)); } catch (e) {} } function gtTabRead() { try { return JSON.parse(sessionStorage.getItem(GT_TAB_KEY) || 'null'); } catch (e) { return null; } } function gtTabClear() { try { sessionStorage.removeItem(GT_TAB_KEY); } catch (e) {} } const GT_ENTITY_PATH = /^\/(artist|label|place)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i; (function announceCreatedEntity() { const m = location.pathname.match(GT_ENTITY_PATH); if (!m) return; // not an entity page const kind = m[1].toLowerCase(), gid = m[2].toLowerCase(); const list = gtPending(); let token = null; // #598: the tab's own stamp is the authority. It was written by this script // in this tab moments ago, immediately before pressing "Enter edit", so it // identifies the create exactly even with several tabs mid-flight. const mark = gtTabRead(); if (mark && mark.kind === kind && mark.token && mark.ts && Date.now() - mark.ts < GT_PENDING_TTL) { token = mark.token; } else { // No stamp: an older create page, or sessionStorage unavailable. Fall // back to the pre-#598 behaviour, but ONLY where there is nothing to // guess between — handing the wrong MBID to the wrong row is a worse // outcome than leaving a tab open for the user to deal with. const same = list.filter(p => p.kind === kind); if (same.length !== 1) return; token = same[0].token; } // Only answer a create WE started — otherwise merely browsing an artist // page would close the tab. if (!token) return; gtTabClear(); gtPendingDrop(token); try { const ch = new BroadcastChannel(GT_CREATE_CH); ch.postMessage({ kind, gid, token }); setTimeout(() => { try { ch.close(); } catch (e) {} try { window.close(); } catch (e) {} }, 300); } catch (e) {} })(); /* #544 follow-up (majkinetor): "When creating in the background, it doesn't click Enter in 2nd tab." A seeded /<kind>/create URL only PRE-FILLS the form; MusicBrainz still waits for "Enter edit". In the foreground that is the user's own click. In a background tab nobody ever clicks it, so the entity is never created and the text parser waits out its full ten minutes for a post-back that cannot come. Three things keep this from ever submitting a form it should not: • the x_gtcreate token in the URL must name a create that is actually pending, so this can only ever fire on a page one background create opened — not on a /artist/create the user opened themselves while a create was pending; • the name field must actually be filled, i.e. the seed really landed; • it stamps the tab and marks the record `submitted` BEFORE clicking, so it presses Enter exactly once. If MusicBrainz answers with a duplicate-check or validation page, this stands down and the tab is left for the user — which is the right outcome for anything needing a human decision. (The stamp outlives that hand-off, so a create the user finishes themselves still reports its MBID back to the row. #598) */ const GT_CREATE_PATH = /^\/(artist|label|place)\/create\/?$/i; (function autoSubmitSeededCreate() { const m = location.pathname.match(GT_CREATE_PATH); if (!m) return; const kind = m[1].toLowerCase(); // This runs unattended in a tab nobody is looking at, so it says what it // decided — without a line here, "it didn't create anything" is // indistinguishable from "it never ran". const say = (msg) => { try { console.log('[Group Therapy] background create: ' + msg); } catch (e) {} }; // #598: the URL's token is the question, and the pending LIST is looked up // by it. The old order asked "what is the one pending create?" and then // required this page to be it — so the moment a second create was started, // the first tab's page failed that test and never pressed Enter. Which // create this page belongs to is written right there in its own URL; there // was never a reason to ask a shared slot instead. let token = null; try { token = new URLSearchParams(location.search).get('x_gtcreate'); } catch (e) {} if (!token) { say('no create token in this URL — we did not open this form, leaving it alone'); return; } const pending = gtPending().find(p => p.token === token && p.kind === kind); if (!pending) { say(`no pending ${kind} create matches this page's token — leaving this form alone`); return; } if (pending.submitted || gtTabRead()) { say('already pressed Enter once for this create — standing down (MusicBrainz may be asking something)'); return; } const go = () => { // MusicBrainz's edit form needs its own JavaScript before "Enter edit" // does anything — measured: clicking at document-start left the tab // sitting on a filled-in /create page with nothing submitted, while the // same click after load went straight through to the new artist. A // userscript manager runs this at document-end, but that is not late // enough to rely on, so wait for the document to actually be done. if (document.readyState !== 'complete') return false; const form = document.querySelector(`form.edit-${kind}`); const name = form && form.querySelector(`[name="edit-${kind}.name"]`); const submit = form && form.querySelector('button[type="submit"], input[type="submit"]'); if (!form || !name || !name.value.trim() || !submit) return false; // Re-read the record HERE, not just at start-up: `pending` was captured // before the wait, so two instances of this script on one document (or a // second create started meanwhile) could both pass the start-up guard and // both click — two artists from one press. Observed with a doubled // injection in testing; cheap to make impossible for a create action. // // #598: the tab stamp is what makes this exact rather than approximate. // It is per-tab, so a create running in ANOTHER tab can neither set nor // clear it, while a second instance in THIS document sees it at once. // Writing it is also what lets the created entity's page — the same tab, // one navigation later — know which of several creates it is. if (gtTabRead()) { say('already pressed Enter once in this tab — standing down'); return true; } const now = gtPending().find(p => p.token === token); if (!now || now.submitted) { say('this create is no longer pending (cancelled, or already submitted) — standing down'); return true; } gtTabMark({ kind, token, ts: Date.now() }); gtPendingWrite(gtPending().map(p => (p.token === token ? Object.assign({}, p, { submitted: true }) : p))); say(`pressing "Enter edit" for “${name.value.trim()}”`); try { submit.click(); } catch (e) { say('the click threw: ' + ((e && e.message) || e)); } return true; }; // MusicBrainz's edit forms bind late (the same reason Falcon waits for its // seeded rows to settle), so clicking the instant the DOM exists can land // before the seed is bound. Retry briefly rather than guessing one delay. say('waiting for the seeded form to be ready'); let tries = 0; const iv = setInterval(() => { if (go()) { clearInterval(iv); return; } if (++tries > 120) { clearInterval(iv); say('gave up waiting for the form — the tab is left for you to submit by hand'); } else if (tries % 20 === 0) say(`still waiting (readyState=${document.readyState}, form=${!!document.querySelector('form.edit-' + kind)})`); }, 250); // ~30s })(); // Self-guard the page: in the String Theory bundle this script runs on EVERY union-matched URL // (Apollo's /release/*/edit, /artist/*, …), so its hover-highlight etc. would bleed onto other pages. // Standalone the @match restricts it; the bundle doesn't — so only boot on the relationship editor. if (/\/release\/[^/]+\/edit-relationships\/?$/i.test(location.pathname)) { if (document.body) boot(); else document.addEventListener('DOMContentLoaded', boot, { once: true }); } })(); })(); } catch (e) { try { console.error('[String Theory] group_therapy failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== isrc_scout (@run-at document-start) ======================================== if (__stOff.indexOf("isrc_scout") < 0 && __stRuns("isrc_scout", {"inc":["^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/oauth2/oob.*$","^https:\\/\\/www\\.beatport\\.com(?::\\d+)?/release/.*$"],"exc":[],"noframes":false})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"ISRC Scout*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Scout ISRCs for a MusicBrainz release: reads existing ISRCs, finds missing ones on SoundExchange / Deezer / Spotify / Beatport / Tidal / Volumo / HDtracks / Qobuz, bulk paste & import/export, submits directly to MB (one-time OAuth, never depends on MagicISRC).","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/isrc_scout/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPHRpdGxlPklTUkMgU2NvdXQ8L3RpdGxlPgogICAgPHBhdGggZD0iTTY0IDY0IEw2NCAyNCBBNDAgNDAgMCAwIDEgOTkgODQgWiIgZmlsbD0iI2UzZDhmNyIvPgogIDxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2Ij4KICAgIDxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjQwIi8+CiAgICA8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSIyNiIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2U9IiNiOWEzZTgiLz4KICAgIDxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjEzIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZT0iI2I5YTNlOCIvPgogIDwvZz4KICA8bGluZSB4MT0iNjQiIHkxPSI2NCIgeDI9IjY0IiB5Mj0iMjQiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KICA8Y2lyY2xlIGN4PSI4NiIgY3k9IjUwIiByPSI3IiBmaWxsPSIjNGIyZTgzIi8+Cjwvc3ZnPgo="}) }) : { script: {"name":"ISRC Scout*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Scout ISRCs for a MusicBrainz release: reads existing ISRCs, finds missing ones on SoundExchange / Deezer / Spotify / Beatport / Tidal / Volumo / HDtracks / Qobuz, bulk paste & import/export, submits directly to MB (one-time OAuth, never depends on MagicISRC).","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/isrc_scout/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPHRpdGxlPklTUkMgU2NvdXQ8L3RpdGxlPgogICAgPHBhdGggZD0iTTY0IDY0IEw2NCAyNCBBNDAgNDAgMCAwIDEgOTkgODQgWiIgZmlsbD0iI2UzZDhmNyIvPgogIDxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2Ij4KICAgIDxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjQwIi8+CiAgICA8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSIyNiIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2U9IiNiOWEzZTgiLz4KICAgIDxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjEzIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZT0iI2I5YTNlOCIvPgogIDwvZz4KICA8bGluZSB4MT0iNjQiIHkxPSI2NCIgeDI9IjY0IiB5Mj0iMjQiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KICA8Y2lyY2xlIGN4PSI4NiIgY3k9IjUwIiByPSI3IiBmaWxsPSIjNGIyZTgzIi8+Cjwvc3ZnPgo="} }; (f=>f())(function(){ try { (function(){ /* * ───────────────────────────────────────────────────────────────────────── * SETUP (one time, ever) * ───────────────────────────────────────────────────────────────────────── * Submitting ISRCs to MusicBrainz requires authentication. This script uses * OAuth with the `submit_isrc` scope and `access_type=offline`, so you * authorize EXACTLY ONCE — the refresh token is stored locally and is used * to silently mint short-lived access tokens forever after. * * The OAuth app is baked in, so there's nothing to register: * open the editor (the "ISRC" button on a release page) → ⚙ Setup → Authorize, * approve in the MusicBrainz tab, paste the code it shows back. Done forever. * * Everything except the final "Submit" runs without any credentials. * Trouble? Open the activity log (⚙ Setup → Log) — every action is recorded there. * ───────────────────────────────────────────────────────────────────────── */ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('isrc_scout', 'ISRC Scout')) return; // the shared MusicBrainz request gate (#633) — first, so nothing can call it before its consts exist // <ST-NET> — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT const MBN_GAP = 1000; const MBN_BURST = 3; const MBN_KEY = "mbu:mb-gate"; const MBN_LOCK = "mbu-mb-gate"; const MBN_MAX_HOLD = 60000; function mbnGated(url) { const s = String(url || ''); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null'); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; // no storage: this page's copy only else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) {} return out; }; // The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is // stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang // every request behind it. let timer = 0; try { if (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === 'function' ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3000); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState(s => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? 'MusicBrainz asked everyone to wait' : 'pacing, one request a second' }; }); let told = false; for (;;) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); // a hold that appeared while we queued if (left <= 0) break; if (!told && until - t0 > 1000 && o.log) { told = true; o.log('info', 'MusicBrainz gate: ' + (o.label || 'request') + ' waits ' + Math.round((until - t0) / 100) / 10 + 's (' + (cool > booked.at ? 'MusicBrainz asked everyone to wait' : booked.why) + ')'); } await new Promise(r => setTimeout(r, Math.min(left, 250))); // in slices, so a cancel lands promptly } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header('Retry-After') : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState(s => { const now = Date.now(); // One throttle, one step: the requests already in flight when it began all answer // 503 too, and each must not stretch the hold again. A throttle long gone doesn't // count at all: `hot` is shared and persists, and would otherwise slow every later // page for good. if (now > (s.cool || 0) + 30000) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1000, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log('warn', 'MusicBrainz gate: HTTP ' + status + (raw != null ? ' (Retry-After: ' + raw + ')' : '') + ' — every script holds ' + Math.round(hold / 100) / 10 + 's'); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState(s => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 30000 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) { const r = await fetch(url, init); if (!mbnWebsite(url) || !(await mbnIsCheck(r))) return r; // #733: MusicBrainz's browser check answered instead: nothing was done (a POST is safe // to repeat), so pass the check and ask again return (await mbnReverify(url, init, o)) ? fetch(url, init) : r; } const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, n => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnHot() { let s; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; } return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over } function mbnRawHeader(raw, name) { const want = String(name).toLowerCase(); for (const line of String(raw || '').split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim(); } return null; } function mbnWebsite(url) { const s = String(url || ''); if (mbnGated(s)) return false; if (/^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org(\/|$)/i.test(s)) return true; return /^\/(?!\/)/.test(s) && typeof location !== 'undefined' && /(^|\.)musicbrainz\.org$/i.test(location.hostname); } async function mbnIsCheck(r) { try { if (!r || !/text\/html/i.test((r.headers && r.headers.get('content-type')) || '')) return false; const head = (await r.clone().text()).slice(0, 3000); return /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(head); } catch (e) { return false; } } function mbnReverify(url, init, o) { o = o || {}; const log = (lvl, m) => { try { if (o.log) o.log(lvl, m); else (lvl === 'warn' ? console.warn : console.info)('[MusicBrainz gate] ' + m); } catch (e) {} }; const g = globalThis.__mbnCheck || (globalThis.__mbnCheck = { busy: null, failedAt: 0 }); if (g.busy) return g.busy; let target; try { target = new URL(String(url), location.href); } catch (e) { return Promise.resolve(false); } if (target.host !== location.host || typeof document === 'undefined') { log('warn', 'MusicBrainz asked to verify the browser — open any MusicBrainz page, then retry'); return Promise.resolve(false); } if (Date.now() - g.failedAt < 60000) return Promise.resolve(false); // just failed: don't loop // a POST's URL can't be loaded: the check is per browser, so any light GET passes it const method = String((init && init.method) || 'GET').toUpperCase(); const src = method === 'GET' ? target.href : location.origin + '/ws/js/entity/89ad4ac3-39f7-470e-963a-56509c546377'; const t0 = Date.now(); log('warn', 'MusicBrainz asked to verify the browser again ("Verifying your browser") — passing it in a hidden frame'); g.busy = new Promise(resolve => { const fr = document.createElement('iframe'); // fresh each time, never re-navigated fr.setAttribute('aria-hidden', 'true'); fr.setAttribute('data-mbn-check', ''); fr.tabIndex = -1; fr.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;top:0;border:0;opacity:0;pointer-events:none'; let loads = 0, done = false, timer = 0; const finish = ok => { if (done) return; done = true; clearTimeout(timer); fr.remove(); g.busy = null; if (ok) log('info', 'browser check passed in ' + (Date.now() - t0) + ' ms — retrying'); else { g.failedAt = Date.now(); const m = 'MusicBrainz wants to verify your browser — open any MusicBrainz page in another tab, then retry'; log('warn', m + ' (the hidden frame did not clear it in 20 s)'); try { if (o.notify) o.notify(m); } catch (e) {} } resolve(ok); }; // the first load is the check; it posts its answer and is redirected to the real URL fr.onload = () => { loads++; let still; try { const d = fr.contentDocument; still = !d || /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(d.documentElement ? d.documentElement.outerHTML.slice(0, 3000) : ''); } catch (e) { still = loads < 2; } if (!still) finish(true); }; timer = setTimeout(() => finish(false), 20000); fr.src = src; (document.body || document.documentElement).appendChild(fr); }); return g.busy; } // </ST-NET> /* ═══════════════════════════════════════════════════════════════════════ TIMERS — Firefox + Violentmonkey can throw "called on incompatible object" when a native timer is invoked with the wrong `this` (sandbox/Xray quirk). Bind them to the window so every call has the right receiver. ═══════════════════════════════════════════════════════════════════════ */ const _timerHost = (typeof window !== 'undefined' && window) || globalThis; const _setTimeout = _timerHost.setTimeout.bind(_timerHost); const _setInterval = _timerHost.setInterval.bind(_timerHost); // OAuth tokens belong to one MusicBrainz account database: musicbrainz.org and // beta share one, test.musicbrainz.org (any other host) has its own. GM storage // is shared by every *.musicbrainz.org page, so key the OAuth values per database // or a production token shows "Authorized" on test and fails there (#683). // Production keeps the bare keys, so existing authorizations survive. const OAUTH_SITE = /^(beta\.)?musicbrainz\.org$/i.test(location.hostname) ? '' : '@' + location.hostname.toLowerCase(); /* ═══════════════════════════════════════════════════════════════════════ OAUTH OUT-OF-BAND CODE CATCHER After you approve, MusicBrainz lands on /oauth2/oob?code=… showing the code. Grab it, hand it to the editor tab via GM storage, and close this tab — so you never have to copy/paste the code. ═══════════════════════════════════════════════════════════════════════ */ if (/oauth2\/oob$/.test(location.pathname)) { const code = new URLSearchParams(location.search).get('code'); if (code) { try { GM_setValue('ii:oauth_oob_code' + OAUTH_SITE, { code: code, ts: Date.now() }); } catch (e) {} const finishOob = () => { try { window.close(); } catch (e) {} // Browsers block window.close() on a tab that has navigated (authorize → oob); // the editor tab also tries to close this popup, but if it's still here, show a // clear confirmation so it's obvious it worked. _setTimeout(() => { try { window.close(); document.title = '✓ Authorized — you can close this tab'; if (document.body) document.body.innerHTML = '<div style="font:16px/1.5 system-ui;padding:3em;text-align:center;color:var(--mbu-text)">' + '<h2 style="color:var(--mbu-ok)">✓ Authorized</h2><p>ISRC Scout captured the code. You can close this tab.</p></div>'; } catch (e) {} }, 500); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', finishOob, { once: true }); else finishOob(); } return; // never run the editor on the oob page } /* ═══════════════════════════════════════════════════════════════════════ BEATPORT HARVESTER (runs ON beatport.com) Beatport is Cloudflare-walled, so a cross-origin GM_xmlhttpRequest from MusicBrainz is always challenged. Instead, when the editor opens a release in a brief background tab, THIS instance (matched on beatport.com) reads the ISRCs straight out of the page's embedded __NEXT_DATA__ in real page context — where Cloudflare is already satisfied — stashes them in shared GM storage for the MB tab to pick up, then closes itself. ═══════════════════════════════════════════════════════════════════════ */ if (/(^|\.)beatport\.com$/i.test(location.hostname)) { const bpId = (location.pathname.match(/\/release\/[^/]+\/(\d+)/) || [])[1]; if (!bpId) return; const grab = () => { const el = document.getElementById('__NEXT_DATA__'); if (!el) return false; let j; try { j = JSON.parse(el.textContent); } catch (e) { return false; } const qs = (((j || {}).props || {}).pageProps || {}).dehydratedState; const queries = (qs && qs.queries) || []; let results = null; for (const q of queries) { const r = q && q.state && q.state.data && q.state.data.results; if (Array.isArray(r) && r.length && ('isrc' in r[0])) { results = r; break; } } if (!results) return false; // #457: Beatport's release PAGE embeds the tracklist id-DESCENDING (the reverse of album // order) and carries no `number`, so the index-based position below came out reversed vs the // authenticated API (which serves the same tracks id-ASCENDING = album order). Sort by id // ascending first, so the harvest agrees with the API and MB. const ordered = [...results].sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0)); const tracks = ordered.map((t, i) => { const mix = t.mix_name && !/^original mix$/i.test(t.mix_name) && !String(t.name || '').toLowerCase().includes('(' + String(t.mix_name).toLowerCase() + ')') ? ' (' + t.mix_name + ')' : ''; // #643: not twice return { isrc: String(t.isrc || '').toUpperCase().replace(/[\s-]/g, ''), title: (t.name || '') + mix, artist: (t.artists || []).map(a => a && a.name).filter(Boolean).join(', '), disc: 1, pos: t.number || (i + 1), dur: t.length || '', url: (t.slug && t.id) ? 'https://www.beatport.com/track/' + t.slug + '/' + t.id : '', // #387 per-track link }; }); try { GM_setValue('ii:beatport_harvest_' + bpId, { ts: Date.now(), tracks: tracks }); } catch (e) {} return true; }; const t0 = Date.now(); const tick = () => { if (grab()) { // Only self-close when the editor's background harvest opened this tab // (it sets a per-release close flag first). A tab the USER opened — or // one Platform Check's ↗/link opened — must stay put; we still harvest // it (populating the cache) but leave it on screen. try { const flag = GM_getValue('ii:beatport_close_' + bpId, 0); if (flag && (Date.now() - flag < 120000)) { GM_deleteValue('ii:beatport_close_' + bpId); window.close(); } } catch (e) {} return; } if (Date.now() - t0 > 90000) return; // Cloudflare never cleared — give up silently window.setTimeout(tick, 500); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', tick, { once: true }); else tick(); return; // never run the MB editor on a Beatport page } /* ═══════════════════════════════════════════════════════════════════════ CONSTANTS ═══════════════════════════════════════════════════════════════════════ */ const MB_ROOT = location.origin; // musicbrainz.org or beta const MB_WS2 = MB_ROOT + '/ws/2/'; // Derive from the installed @version so the banner/CLIENT never drift out of // sync with the metadata again (the hardcoded constant kept lagging behind). const SCRIPT_VERSION = (() => { try { return (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) || '2026.6.27'; } catch (e) { return '2026.6.27'; } })(); const SCRIPT_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/tree/main/userscripts/isrc_scout'; const CLIENT = 'isrc_scout-' + SCRIPT_VERSION; const UA = 'MB-ISRC-Scout/1.0'; const SX_API = 'https://isrc-api.soundexchange.com/api/ext/recordings'; const SX_HOME = 'https://isrc.soundexchange.com/'; const BATCH_DELAY = 650; const TIDAL_TRACK_DELAY = 350; // pace per-track Tidal lookups under its rate limit const SX_BATCH_LIMIT = 30; // max individual SoundExchange searches per batch (avoid being blocked) const STREAM_BATCH_LIMIT = 50; // max per-track Deezer fetches per batch (1000-track releases would spam Deezer) const ISRC_RE = /^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/; // Shared, pre-registered MusicBrainz OAuth app (type: Installed application, // redirect urn:ietf:wg:oauth:2.0:oob, scope submit_isrc). Baked in so users only // click "Authorize" once — no per-user app registration. The secret is not truly // confidential for an installed app (same model as MagicISRC / isrchunt). // test.musicbrainz.org keeps its own app registry (#683), so it has its own app, // registered there under the sandbox account. const OAUTH_APPS = { '': { clientId: 'axXnet_AiWglKOQEVSiM8xF6EAlKFBzM', clientSecret: 'gi-S0GuLeKtOgFs5QRZAEEVATD4Lo6l9' }, '@test.musicbrainz.org': { clientId: 'Xn2w0xRom5aZmq0423yt68tR8gRovWITUHNkgVsg8l0', clientSecret: 'jqbQOC8iv7FdZ4zj-oBVhIabwOSb5fXgIb-ZlEZkefE' }, }; const OAUTH = { clientId: (OAUTH_APPS[OAUTH_SITE] || OAUTH_APPS['']).clientId, clientSecret: (OAUTH_APPS[OAUTH_SITE] || OAUTH_APPS['']).clientSecret, authUrl: MB_ROOT + '/oauth2/authorize', tokenUrl: MB_ROOT + '/oauth2/token', redirect: 'urn:ietf:wg:oauth:2.0:oob', scope: 'submit_isrc', }; // Baked-in Tidal API app. The client-credentials grant yields an app-level // token with TIDAL Catalog access (no user login) — same "shared installed // app" trust model as the MB OAuth app above. const TIDAL = { clientId: 'cRhhDJDpYXXBn82U', clientSecret: 'K7UX40jDOZ5p4y4JMYZgoiwKi7jymTHWcLMb4gkewKs=', tokenUrl: 'https://auth.tidal.com/v1/oauth2/token', api: 'https://openapi.tidal.com/v2', country: 'US', }; // Shared platform icons (#404) — stIcon(name, size) / stColor(name). Source of truth is // dev/ui/platform-icons.mjs; the block below is generated by dev/ui/sync-icons.mjs (pre-commit hook). // <ST-ICONS> — generated by dev/ui/sync-icons.mjs from dev/ui/platform-icons.mjs — DO NOT EDIT const ST_ICONS = {"musicbrainz":{"color":"#eb743b","svg":"<svg viewBox=\"0.75 0.75 28.5 28.5\" xmlns=\"http://www.w3.org/2000/svg\"><g transform=\"translate(1.5)\"><path d=\"m13 1-12 7v14l12 7z\" fill=\"#ba478f\"/><path d=\"m14 1 12 7v14l-12 7z\" fill=\"#eb743b\"/></g></svg>"},"discogs":{"color":"#333333","svg":"<svg viewBox=\"71 71 882 882\" xmlns=\"http://www.w3.org/2000/svg\"><g transform=\"translate(512 512) scale(0.86) translate(-512 -512)\"><circle cx=\"512\" cy=\"512\" r=\"496\" fill=\"#333\" stroke=\"#9a9a9a\" stroke-width=\"32\"/><path fill=\"#fff\" d=\"M439.84 511.58A72.58 72.58 0 0 1 512.41 439 72.54 72.54 0 0 1 585 511.58a72.56 72.56 0 0 1-72.57 72.56 72.56 72.56 0 0 1-72.57-72.56zm3.18 0A69.48 69.48 0 0 0 512.41 581a69.4 69.4 0 0 0 69.4-69.38 69.49 69.49 0 0 0-69.4-69.43A69.44 69.44 0 0 0 443 511.58zm69.42-11.44a11.43 11.43 0 1 0 11.47 11.45 11.45 11.45 0 0 0-11.48-11.45zm-131.08 11.43a130.68 130.68 0 0 0 40.3 94.43l24.68-26.69.33.3a94.59 94.59 0 0 1 113.08-149.95l17.51-31.95a130.23 130.23 0 0 0-64.82-17.22c-72.27.01-131.08 58.81-131.08 131.08zm225.73 0a94.6 94.6 0 0 1-138.64 83.79l-17.83 31.74a130.26 130.26 0 0 0 61.82 15.53c72.28 0 131.08-58.8 131.08-131.08a130.63 130.63 0 0 0-37.73-91.9L581 446.39a94.3 94.3 0 0 1 26.1 65.2zm-267.34 0a172.17 172.17 0 0 0 53.68 125l25-27.07a135.38 135.38 0 0 1-41.82-97.89c0-74.88 60.92-135.8 135.8-135.8a134.92 134.92 0 0 1 67.08 17.8l17.73-32.34a171.57 171.57 0 0 0-84.81-22.35c-95.19-.03-172.66 77.43-172.66 172.65zm308.49 0c0 74.88-60.92 135.8-135.8 135.8a135 135 0 0 1-64.14-16.14l-18.07 32.17a171.62 171.62 0 0 0 82.21 20.86c95.22 0 172.69-77.47 172.69-172.69a172.15 172.15 0 0 0-51-122.4l-25.12 27a135.35 135.35 0 0 1 39.23 95.4zm41.61 0c0 97.83-79.58 177.43-177.41 177.43a176.32 176.32 0 0 1-84.52-21.46l-18.18 32.36a213.21 213.21 0 0 0 102.7 26.23C630.74 726.11 727 629.87 727 511.57a213.87 213.87 0 0 0-64.38-153l-25.26 27.18a176.85 176.85 0 0 1 52.49 125.82zm-392 0A213.9 213.9 0 0 0 365 667.24L390.23 640A176.88 176.88 0 0 1 335 511.57c0-97.82 79.59-177.41 177.41-177.41a176.26 176.26 0 0 1 87.08 22.93l17.84-32.55A213.14 213.14 0 0 0 512.44 297c-118.3 0-214.54 96.28-214.54 214.57zm392.55-183-24.64 26.49a218.57 218.57 0 0 1 65.94 156.51c0 120.9-98.36 219.26-219.26 219.26a217.9 217.9 0 0 1-105-26.84l-18.24 32.47A255.43 255.43 0 0 0 512 768c141.39 0 256-114.64 256-256a255.23 255.23 0 0 0-77.55-183.41zm-397.27 183c0-120.9 98.36-219.26 219.26-219.26a217.84 217.84 0 0 1 107.19 28.09L637 288.65A254.46 254.46 0 0 0 516.12 256H512c-140.54.22-254.42 113.26-256 253.5v2.5a255.69 255.69 0 0 0 80.51 186.08l25.31-27.36a218.61 218.61 0 0 1-68.64-159.15z\"/></g></svg>"},"spotify":{"color":"#1DB954","svg":"<svg viewBox=\"1.25 1.375 21.25 21.25\" fill=\"#1DB954\"><path transform=\"translate(12 12) scale(.875) translate(-12 -12)\" d=\"M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.42 1.56-.299.421-1.02.599-1.559.3z\"/></svg>"},"apple":{"color":"#FA243C","svg":"<svg viewBox=\"1.625 1.25 20 20\" fill=\"#FA243C\"><path d=\"M17.05 12.04c-.03-2.5 2.04-3.7 2.13-3.76-1.16-1.7-2.97-1.93-3.61-1.96-1.54-.16-3 .9-3.78.9-.78 0-1.97-.88-3.24-.86-1.67.03-3.21.97-4.07 2.46-1.73 3.01-.44 7.47 1.24 9.92.82 1.2 1.8 2.54 3.08 2.49 1.24-.05 1.71-.8 3.21-.8 1.5 0 1.92.8 3.23.77 1.33-.02 2.18-1.22 3-2.42.94-1.39 1.33-2.73 1.35-2.8-.03-.01-2.59-.99-2.62-3.93zM14.6 4.59c.68-.83 1.14-1.97 1.01-3.11-.98.04-2.17.65-2.87 1.47-.63.73-1.18 1.9-1.03 3.02 1.09.08 2.21-.55 2.89-1.38z\"/></svg>"},"deezer":{"color":"#A238FF","svg":"<svg viewBox=\"3 3 18 18\"><path transform=\"translate(12 12) scale(.74) translate(-12 -12)\" d=\"M4 2h6v2h-6zM14 2h6v2h-6zM2 4h20v2h-20zM0 6h24v2h-24zM0 8h24v2h-24zM0 10h24v2h-24zM2 12h20v2h-20zM4 14h16v2h-16zM6 16h12v2h-12zM8 18h8v2h-8zM10 20h4v2h-4z\" fill=\"#A238FF\"/></svg>"},"tidal":{"color":"#000000","svg":"<svg viewBox=\"3 3 18 18\"><path d=\"M6 6l3 3-3 3-3-3zM12 6l3 3-3 3-3-3zM18 6l3 3-3 3-3-3zM12 12l3 3-3 3-3-3z\" style=\"fill:var(--mbu-text,currentColor)\"/></svg>"},"qobuz":{"color":"#0070ef","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#0070ef\"/><circle cx=\"12\" cy=\"12\" r=\"5\" fill=\"none\" stroke=\"#fff\" stroke-width=\"2.2\"/><path d=\"M14.5 14.5 19 19\" stroke=\"#fff\" stroke-width=\"2.2\" stroke-linecap=\"round\"/></svg>"},"beatport":{"color":"#01FF95","svg":"<svg viewBox=\"0.75 0.75 22.5 22.5\"><circle cx=\"12\" cy=\"12\" r=\"11\" fill=\"#000\"/><g transform=\"translate(12 12) scale(0.84) translate(-12 -12)\" fill=\"none\" stroke=\"#01FF95\" stroke-width=\"2.5\"><path d=\"M10.9 3V8.3c0 1.2-.4 1.9-1.1 2.6L5.6 15.1\"/><circle cx=\"13.9\" cy=\"15.8\" r=\"4.05\" stroke-width=\"2.35\"/></g></svg>"},"bandcamp":{"color":"#629AA9","svg":"<svg viewBox=\"2.25 2.25 19.5 19.5\" fill=\"#629AA9\"><path transform=\"translate(12 12) scale(.8) translate(-12 -12)\" d=\"M0 18.75l7.437-13.5H24l-7.438 13.5z\"/></svg>"},"volumo":{"color":"#7c4dff","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#7c4dff\"/><path d=\"M7 8h2.2l2.8 6 2.8-6H17l-4 9h-2z\" fill=\"#fff\"/></svg>"},"hdtracks":{"color":"#e63329","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#e63329\"/><path d=\"M5 7.5h1.7v3.1h2.6V7.5H11v8H9.3v-3.2H6.7v3.2H5zm7.2 0h2.9c2 0 3.4 1.6 3.4 4s-1.4 4-3.4 4h-2.9zm1.7 1.5v5h1.1c1.1 0 1.8-1 1.8-2.5s-.7-2.5-1.8-2.5z\" fill=\"#fff\"/></svg>"},"soundcloud":{"color":"#ff5500","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#ff5500\"/><g fill=\"#fff\"><rect x=\"6\" y=\"12\" width=\"1.4\" height=\"4\" rx=\".6\"/><rect x=\"8.5\" y=\"10\" width=\"1.4\" height=\"6\" rx=\".6\"/><rect x=\"11\" y=\"8.5\" width=\"1.4\" height=\"7.5\" rx=\".6\"/><rect x=\"13.5\" y=\"10.5\" width=\"1.4\" height=\"5.5\" rx=\".6\"/><rect x=\"16\" y=\"11.5\" width=\"1.4\" height=\"4.5\" rx=\".6\"/></g></svg>"},"audiomack":{"color":"#FFA200","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#FFA200\"/><path d=\"M5 13.5l2-2 1.6 2.4 2.2-5.4 2.4 6.6 2.2-4 1.6 2.4H19\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.6\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/></svg>"},"sevendigital":{"color":"#07606E","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#07606E\"/><path d=\"M7.8 6.8h8.4v1.9l-4.5 8.9H9.4l4.4-8.7h-6z\" fill=\"#fff\"/></svg>"},"ytmusic":{"color":"#FF0000","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#FF0000\"/><circle cx=\"12\" cy=\"12\" r=\"5.6\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.4\"/><path d=\"M10.4 9.5v5l4.2-2.5z\" fill=\"#fff\"/></svg>"},"amazonmusic":{"color":"#25D1DA","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#25D1DA\"/><path d=\"M5.8 11.2c3.5 3.2 8.9 3.5 12.4.9\" fill=\"none\" stroke=\"#0F1111\" stroke-width=\"1.9\" stroke-linecap=\"round\"/><path d=\"M15.5 10.7l3 1.3-.9 3.1\" fill=\"none\" stroke=\"#0F1111\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>"},"soundexchange":{"color":"#6f42c1","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#6f42c1\"/><path d=\"M6.5 12h1.3l1-3 1.6 6 1.6-9 1.6 12 1.4-6h1.5\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.4\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/></svg>"},"globe":{"color":"#6f7d75","svg":"<svg viewBox=\"2 2 20 20\" fill=\"none\" stroke=\"#6f7d75\" stroke-width=\"1.8\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18\"/></svg>"}}; function stIcon(name, size) { var i = ST_ICONS[name]; if (!i) return ''; size = size || 16; return i.svg.replace(/<svg\b([^>]*)>/, function (m, a) { a = a.replace(/\s(?:width|height)="[^"]*"/g, ''); var ns = /\bxmlns=/.test(a) ? '' : ' xmlns="http://www.w3.org/2000/svg"'; return '<svg' + a + ns + ' width="' + size + '" height="' + size + '">'; }); } function stColor(name) { return (ST_ICONS[name] && ST_ICONS[name].color) || ''; } // </ST-ICONS> // Import-source buttons are keyed by short code → map each to the shared icon (uniform 16px). const SRC_ICON = { dz: stIcon('deezer', 16), sp: stIcon('spotify', 16), bp: stIcon('beatport', 16), td: stIcon('tidal', 16), vo: stIcon('volumo', 16), hd: stIcon('hdtracks', 16), qz: stIcon('qobuz', 16), bc: stIcon('bandcamp', 16), am: stIcon('apple', 16), sc: stIcon('soundcloud', 16), yt: stIcon('ytmusic', 16), az: stIcon('amazonmusic', 16), ak: stIcon('audiomack', 16), sd: stIcon('sevendigital', 16), }; // MBIDs are canonically lowercase, but MB serves an upper-/mixed-case URL as-is // (no redirect), so match case-insensitively and normalise — otherwise a link like // /release/Eb13342a-… left `mbid` undefined and the whole button never injected. const mbid = location.pathname.match(/\/release\/([a-f0-9-]{36})/i)?.[1]?.toLowerCase(); if (!mbid) return; /* ═══════════════════════════════════════════════════════════════════════ GM STORAGE HELPERS ═══════════════════════════════════════════════════════════════════════ */ // #501 follow-up (majkinetor: "tidy up config prefixes, some have them, // some don't") — every GM-stored key now carries the `ii:` prefix, // transparently: call sites still pass the bare name (`store.get('col_widths', …)`), // `store` prepends it. One-time, non-destructive migration folded into // `get`: if the new prefixed key is empty but the old bare one has a value, // adopt it (and write it through under the new name); the old key is left // in place, unused. const store = { get: (k, d) => { try { const v = GM_getValue('ii:' + k, undefined); if (v !== undefined) return v; const old = GM_getValue(k, undefined); if (old !== undefined) { GM_setValue('ii:' + k, old); return old; } return d; } catch (e) { return d; } }, set: (k, v) => { try { GM_setValue('ii:' + k, v); } catch (e) {} }, del: (k) => { try { GM_deleteValue('ii:' + k); } catch (e) {} }, }; // majkinetor: "Tokens should go into GM storage, so that we don't have to // initialize plugins on every place" — auth tokens, INCLUDING the // short-lived derived ones, stay on `store` (GM) on purpose; localStorage // below is only for things that are genuinely local, like a per-release // in-progress draft. const localStore = { get: (k, d) => { try { const v = localStorage.getItem('ii:' + k); if (v !== null) return JSON.parse(v); const old = localStorage.getItem(k); if (old !== null) { const parsed = JSON.parse(old); localStorage.setItem('ii:' + k, old); return parsed; } return d; } catch (e) { return d; } }, set: (k, v) => { try { localStorage.setItem('ii:' + k, JSON.stringify(v)); } catch (e) {} }, del: (k) => { try { localStorage.removeItem('ii:' + k); } catch (e) {} }, }; if (mbuTestHooks()) window.__isrcScoutTestStore = { store, localStore }; // test hook only (#501) — no behaviour change /* ═══════════════════════════════════════════════════════════════════════ GENERIC HTTP (GM_xmlhttpRequest promisified) ═══════════════════════════════════════════════════════════════════════ */ const _inflight = new Set(); // live GM requests, so batched SoundExchange work can be aborted (#127) function httpRaw(opts) { const t0 = Date.now(); const tag = (opts.method || 'GET') + ' ' + shortUrl(opts.url); Log.net('→ ' + tag); return new Promise((resolve, reject) => { const entry = { url: opts.url, handle: null }; const done = () => _inflight.delete(entry); entry.handle = GM_xmlhttpRequest(Object.assign({ timeout: 20000, onload: r => { done(); const ms = Date.now() - t0; if (r.status >= 200 && r.status < 300) Log.net('← ' + r.status + ' ' + tag + ' (' + ms + 'ms)'); else Log.warn('← ' + r.status + ' ' + tag + ' (' + ms + 'ms) ' + String(r.responseText || '').replace(/\s+/g, ' ').slice(0, 160)); resolve(r); }, onerror: () => { done(); Log.err('✗ network ' + tag); reject(new Error('network error')); }, ontimeout: () => { done(); Log.err('✗ timeout ' + tag); reject(new Error('timeout')); }, onabort: () => { done(); reject(new Error('aborted')); }, }, opts)); _inflight.add(entry); }); } // Abort in-flight GM requests whose URL contains `urlSubstr` (cancels batched SoundExchange work). #127 function abortInflight(urlSubstr) { [..._inflight].forEach(e => { if (!urlSubstr || (e.url && e.url.indexOf(urlSubstr) !== -1)) { try { e.handle && e.handle.abort && e.handle.abort(); } catch (x) {} _inflight.delete(e); } }); } // MusicBrainz's /ws/2 (the release reads, the ISRC submission) goes through the shared request // gate (#633): its slot first, and its hold after a 429/503, which the next call waits out. const gateLog = (lv, m) => (lv === 'warn' ? Log.warn : Log.info)(m); async function http(opts) { if (!mbnGated(opts && opts.url)) return httpRaw(opts); await mbnSlot({ label: opts.method + ' ' + '/ws/2/' + String(opts.url).split('/ws/2/').pop(), log: gateLog }); const r = await httpRaw(opts); await mbnAnswer(r.status, n => mbnRawHeader(r.responseHeaders, n), { log: gateLog }); return r; } const gmGet = (url, headers) => http({ method: 'GET', url, headers: headers || {} }); const gmPost = (url, data, headers) => http({ method: 'POST', url, data, headers: headers || {} }); /* ═══════════════════════════════════════════════════════════════════════ SMALL UTILITIES ═══════════════════════════════════════════════════════════════════════ */ function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<') .replace(/>/g, '>').replace(/"/g, '"'); } function msToMmSs(ms) { if (!ms) return null; const s = Math.round(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); } function durToSec(str) { const m = String(str || '').match(/^(\d+):(\d{2})$/); return m ? parseInt(m[1]) * 60 + parseInt(m[2]) : null; } // One title written two ways: "&" is "and" ("Above & Beyond" is "Above and Beyond"), and dotted // initials are one word ("J.C." is "JC"), so neither fails a title check const sameWriting = s => String(s || '').replace(/(?<![\p{L}\p{N}])\p{L}(?:\.\p{L})+\.?(?![\p{L}\p{N}])/gu, m => m.replace(/\./g, '')).replace(/\s*&\s*/g, ' and '); function norm(s) { // #623 (sweep, X10): any script — keeping only [a-z0-9] reduced a mixed-script title to // its Latin fragments ("Love ~夜~" = "Love ~朝~"). Diacritics still fold on Latin, Greek // and Cyrillic letters; other scripts keep their marks. // #681: an apostrophe joins its letters ("Don't", "Don’t" and "Dont" are one word), never splits them return sameWriting(s).toLowerCase().replace(/['’‘`´]/g, '').normalize('NFKD') .replace(/(?<=[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}])\p{M}+/gu, '').normalize('NFC') .replace(/[^\p{L}\p{N}\p{M} ]/gu, ' ') .replace(/\s+/g, ' ').trim(); } function wordsMatch(needle, haystack) { const nw = norm(needle).split(' ').filter(Boolean), hw = norm(haystack); return nw.length > 0 && nw.every(w => hw.includes(w)); } function isGoodMatch(aTitle, aArtist, bTitle, bArtist) { // shares titleClose/artistClose (below) so the row classification and the // per-field highlighting never disagree if (titleClose(aTitle, bTitle) !== true) return false; return !bArtist || artistClose(aArtist, bArtist) === true; } // #643: guests don't make another song. A "(feat. …)" clause names them — Tidal titles "Instant // Crush (feat. Julian Casablancas)", and MusicBrainz may carry the guests in the title where Deezer // doesn't ("Get Lucky (feat. Pharrell Williams & Nile Rodgers)" vs "Get Lucky") — so it is left out // of both titles. Guest lists differ between databases too ("Daft Punk feat. Pharrell Williams" on // MusicBrainz, "Daft Punk, Pharrell Williams, Nile Rodgers" on Deezer), so when the whole credits // don't match, the main artist is looked for in the other side's credit. // #681: a bare "Feat. X" ends at the next bracket, not at the title's end: Bandcamp's "Letter From // The Space Feat. Tinavie (Long Arm Remix)" keeps its remix tag, as MusicBrainz's title has it function unfeat(s) { return String(s || '').replace(/\s*[([](?:feat\.?|ft\.?|featuring)\s[^)\]]*[)\]]/gi, '') .replace(/\s+(?:feat\.?|ft\.?|featuring)\s[^()[\]]*/gi, ' ').replace(/\s{2,}/g, ' ').trim(); } function mainArtist(s) { return String(s || '').split(/\s+(?:feat\.?|ft\.?|featuring)\s+|\s*[,;]\s*/i)[0].trim(); } // Per-field comparisons between an SoundExchange result and the MB track, // used to highlight exactly WHICH attribute disagrees. Each returns // true (matches) / false (mismatch) / null (can't compare — no data). // #661: a typo is not another song. Tidal and Apple title it "Les Ecrocs", MusicBrainz "Les // Escrocs". Two words are the same word one letter apart (a letter added, dropped, changed, or // two swapped) when both have 5 letters or more and start alike, so "Love"/"Live" and // "Heart"/"Start" stay different songs. function wordTypo(a, b) { if (a === b) return true; if (Math.min(a.length, b.length) < 5 || Math.abs(a.length - b.length) > 1 || a[0] !== b[0]) return false; let i = 0; while (a[i] === b[i]) i++; const ra = a.slice(i + 1), rb = b.slice(i + 1); return ra === rb // changed || a.slice(i) === rb || ra === b.slice(i) // added or dropped || (a[i] === b[i + 1] && a[i + 1] === b[i] && a.slice(i + 2) === b.slice(i + 2)); // swapped } // exact: words must be spelled alike; otherwise one typo per word is tolerated (wordTypo) function titleClose(sx, mb, exact) { const aw = norm(unfeat(sx)).split(' ').filter(Boolean); const bw = norm(unfeat(mb)).split(' ').filter(Boolean); if (!aw.length || !bw.length) return null; const same = exact ? (x, y) => x === y : wordTypo; const shorter = aw.length <= bw.length ? aw : bw; const longer = aw.length <= bw.length ? bw : aw; if (!shorter.every(w => longer.some(l => same(w, l)))) return false; const extra = longer.length - shorter.length; // extra words are only tolerated as a SUFFIX (a version/remaster tag) — a // leading extra word ("Sacred Motherhood" vs "Motherhood") is a different song return extra === 0 || (extra <= 2 && shorter.every((w, i) => same(longer[i], w))); } function artistClose(sx, mb) { if (!sx || !mb) return null; if (wordsMatch(mb, sx) || wordsMatch(sx, mb)) return true; const a = mainArtist(sx), b = mainArtist(mb); // guests left out (#643) return !!(a && b) && (wordsMatch(b, sx) || wordsMatch(a, mb)); } function durClose(sxDur, mbDur) { const a = durToSec(mbDur), b = durToSec(sxDur); return (a === null || b === null) ? null : Math.abs(a - b) <= 10; } function yearOk(sxYear, mbYear) { if (!mbYear || !sxYear) return null; return parseInt(sxYear) <= mbYear + 1; } // Build the "Title · Artist · Year · Dur" meta for an SX result `f` vs MB track // `t`, wrapping each mismatching field in .ii-bad (with a tooltip explaining // it) so problems are obvious in the chip, the candidate list, and the popup. function sxMetaHtml(f, t) { const span = (txt, ok, tip) => '<span' + (ok === false ? ' class="ii-bad" title="' + esc(tip) + '"' : '') + '>' + esc(txt) + '</span>'; const parts = []; if (f.title) parts.push(span(f.title, titleClose(f.title, t.title), 'Title differs from "' + t.title + '"')); if (f.artist) parts.push(span(f.artist, artistClose(f.artist, t.artist), 'Artist differs from "' + t.artist + '"')); if (f.year) parts.push(span(f.year, yearOk(f.year, RELEASE && RELEASE.releaseYear), 'Recording year ' + f.year + ' is after this release (' + (RELEASE && RELEASE.releaseYear) + ')')); if (f.dur) { const dOk = durClose(f.dur, t.dur); parts.push(span(f.dur, dOk, 'Length differs from MB (' + (t.dur || '?') + ')') + (dOk === false && t.dur ? '<span class="ii-mbdur" title="MusicBrainz length"> ↔ ' + esc(t.dur) + '</span>' : '')); } return parts.join(' · '); } function normalizeIsrc(raw) { return String(raw || '').toUpperCase().replace(/[\s\-]/g, ''); } function isValidIsrc(s) { return ISRC_RE.test(normalizeIsrc(s)); } function sleep(ms) { return new Promise(resolve => { try { _setTimeout(resolve, ms); } catch (e) { resolve(); } // never stall a flow if the env's timer misbehaves }); } // #563: the shared toast. Its own `kind` argument already matched the standard // severities, so it passes straight through. function toast(msg, kind) { return mbuToast(msg == null ? '' : msg, kind ? { kind: kind === 'err' ? 'error' : kind } : undefined); } /* ═══════════════════════════════════════════════════════════════════════ LOG — the shared activity log (mbuLog, ST-UI block), for troubleshooting (#701) ═══════════════════════════════════════════════════════════════════════ */ const LogAll = mbuLog({ name: 'ISRC Scout', version: () => SCRIPT_VERSION, key: 'isrcScout.logwin' }); // #758: a category per source, a filter chip each in the Log window. A line names its source // first ("Spotify: importing album …", "SX lookup …", "Tidal 429 …"), so the category is read // from that; molla and ISRC Hunt are where Spotify's ISRCs come from. The edits on MusicBrainz // are "MusicBrainz"; a line naming no source (the startup line, the release, settings) has none. const LOG_SOURCES = [ [/^(?:SoundExchange|SX)\b/, 'SoundExchange'], [/^(?:Spotify|molla|ISRC Hunt)\b/, 'Spotify'], [/^Deezer\b/, 'Deezer'], [/^Apple\b/, 'Apple Music'], [/^Tidal\b/, 'Tidal'], [/^Beatport\b/, 'Beatport'], [/^Volumo\b/, 'Volumo'], [/^HDtracks\b/, 'HDtracks'], [/^Qobuz\b/, 'Qobuz'], [/^YouTube\b/, 'YouTube Music'], [/^SoundCloud\b/, 'SoundCloud'], [/^Audiomack\b/, 'Audiomack'], [/^7digital\b/, '7digital'], [/^Amazon\b/, 'Amazon Music'], [/^Bandcamp\b/, 'Bandcamp'], [/^Mission Control\b/, 'Mission Control'], [/^(?:Submitting|Submitted|ISRC submit|Linked|Removed|Marked|Un-ended|Add links|Remove links|End toggle|Edit note|Repeat failed submits|No .+ relationship)\b/, 'MusicBrainz'], ]; const logCat = m => { const t = String(m == null ? '' : m); const hit = LOG_SOURCES.find(([re]) => re.test(t)); return LogAll.cat(hit ? hit[1] : null); }; const Log = Object.assign({}, LogAll, { info: (m, ...a) => logCat(m).info(m, ...a), warn: (m, ...a) => logCat(m).warn(m, ...a), err: (m, ...a) => logCat(m).err(m, ...a), error: (m, ...a) => logCat(m).error(m, ...a), ok: (m, ...a) => logCat(m).ok(m, ...a), debug: (m, ...a) => logCat(m).debug(m, ...a), add: (sev, m, ...a) => logCat(m).add(sev, m, ...a), pre: (m, ...a) => logCat(m).pre(m, ...a), }); // every request and its answer, tagged so the window can show them alone const NetLog = LogAll.cat('Net'); Log.net = (m, d) => NetLog.debug(m, d); // test hook only: the log as text, for specs that wait on a line if (mbuTestHooks()) window.__isrcScoutLog = () => Log.lines().join('\n'); if (mbuTestHooks()) window.__isrcScoutLogger = Log; // #758: specs log through it and read the categories back // Keep the FULL url — scheme + query — in the NET log so an API call's real // arguments (album_id, app_id, …) are visible for debugging; only redact // token/secret/signature values, and cap length. (#201: the query was being // dropped, so a 404 gave no clue which id/app_id was actually sent.) const shortUrl = (u) => String(u || '').replace(/([?&](?:[a-z_]*(?:token|secret|sig|password))=)[^&#]*/gi, '$1…').slice(0, 200); Log.info(mbuStartupInfo('ISRC Scout') + ' — ' + MB_ROOT); /* ═══════════════════════════════════════════════════════════════════════ STYLES ═══════════════════════════════════════════════════════════════════════ */ const style = document.createElement('style'); // The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and are // inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never in this block. // <ST-TOKENS> — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // </ST-TOKENS> // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here. // <ST-UI> — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on <html>, which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'<svg…>' | '<img…>', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // o.history opt-in history of past sessions (#705), below // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // viewer appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) : '--:--:--.---'; }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + (e.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button, input, select') || pop.classList.contains('full')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); views = views.filter(function (v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in <body>: a permanent stray node under <html>, outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No <body> yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and <head> and <body> don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } // </ST-UI> style.textContent = MBU_TOKENS + MBU_UI_CSS + ` /* button on the release page */ #ii-btn { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; margin-left: 12px; font-size: 12px; font-weight: 600; color: var(--mbu-text-on-accent) !important; background: var(--mbu-accent); border: none; border-radius: 4px; cursor: pointer; vertical-align: middle; white-space: nowrap; transition: background .15s; } #ii-btn:hover { background: var(--mbu-accent-hover); } /* #749: ISRC and Links columns on the release page's tracklist */ th.ii-col, td.ii-col { width: 1%; } /* as wide as their content; Title takes the rest */ td.ii-col-isrc, td.ii-col-links { white-space: nowrap; vertical-align: top; } td.ii-col-isrc { padding-right: 14px; } .ii-trk-isrc { display: block; font: 12px/1.6 ui-monospace, Consolas, monospace; } .ii-trk-lns { display: flex; flex-wrap: wrap; align-items: center; gap: 3px 4px; padding-top: 2px; width: max-content; max-width: 224px; } /* one icon per platform, wrapping past ~11 so the column never crowds Title */ @media (max-width: 1000px) { .ii-trk-lns { max-width: 80px; } } /* a narrow window: four to a row */ .ii-trk-ln { display: inline-flex; align-items: flex-start; line-height: 0; text-decoration: none !important; border: none !important; } .ii-trk-n { font: 600 9px/1 system-ui; color: var(--mbu-text-dim); margin-left: 1px; vertical-align: top; } .ii-trk-ln svg, .ii-trk-ln img { width: 15px; height: 15px; display: block; } .ii-trk-ln.ended { opacity: .4; } #ii-btn.has-missing { background: #a0225e; animation: ii-pulse 1.6s ease-in-out infinite; } #ii-btn.has-missing:hover { background: #7d1a4a; } #ii-btn .ii-status { font-size: 10px; font-weight: 600; opacity: .9; } @keyframes ii-pulse { 0%,100%{opacity:1} 50%{opacity:.72} } /* overlay + modal */ #ii-overlay { position: fixed; inset: 0; background: rgba(15,12,28,.45); z-index: var(--mbu-z-modal); display: none; } #ii-overlay.open { display: block; } #ii-modal { position: fixed; top: 4vh; left: 50%; transform: translateX(-50%); width: 1300px; max-width: 96vw; /* #471: wider — the table now carries both ISRC and Links columns at once */ /* a DEFINITE height (not just max-height) so the modal never grows as rows / candidates are added — the body scrolls inside a fixed frame and the footer stays put. #471 ("movable, resizable, maximize button"): dropped !important from height so the corner resize handle and the maximize toggle can both change it — nothing else on an MB page has fought for it since. */ height: 92vh; max-height: 92vh; min-width: 480px; min-height: 320px; background: var(--mbu-bg); border-radius: var(--mbu-radius-lg); box-shadow: var(--mbu-shadow-lg); z-index: var(--mbu-z-modal-panel); display: none; flex-direction: column; font-family: system-ui, sans-serif; color: var(--mbu-text); overflow: hidden !important; resize: both; } #ii-modal.open { display: flex; } /* header: status text (left) · centered release (Zen) · actions (right). #471 (majkinetor: "reduce title which takes a lot of space") — the old ISRCs/Links tabs were removed (both columns are always visible now), so this row carries less weight than it used to. */ #ii-hdr { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 4px 14px 0; background: var(--mbu-bg); border-bottom: 1px solid var(--mbu-border); flex-shrink: 0; cursor: move; } /* #471: drag anywhere on the header (not its buttons) to move the window */ #ii-hdr button, #ii-hdr input, #ii-hdr a { cursor: pointer; } .ii-zen { text-align: center; padding-bottom: 7px; min-width: 0; } .ii-zen-t { font: 600 12px system-ui; color: var(--mbu-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ii-zen-s { font: 10.5px system-ui; color: var(--mbu-text-weak); } .ii-hicons { display: flex; gap: 6px; justify-self: end; padding-bottom: 5px; } .ii-hico { width: 28px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); background: var(--mbu-bg); color: var(--mbu-text-dim); font-size: 14px; cursor: pointer; } .ii-hico:hover { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); border-color: var(--mbu-accent); } /* the Log toggle reads as a plain word, as on Platform Check, not one more boxed icon */ .ii-hlog { height: 26px; padding: 0 6px; border: 0; border-radius: var(--mbu-radius); background: none; color: var(--mbu-text-dim); font-size: 13px; cursor: pointer; } .ii-hlog:hover { background: var(--mbu-bg-raised); color: var(--mbu-text); } .ii-hico.on { background: var(--mbu-bg-hover); color: var(--mbu-accent-text); border-color: var(--mbu-accent); } /* #471: plain status text where the ISRCs/Links tabs used to be — no columns to gate anymore, so nothing is hidden by it; just counts. */ .ii-hdr-status { font: 600 12px system-ui; color: var(--mbu-text-weak); white-space: nowrap; padding-bottom: 7px; } .ii-hdr-status b { color: var(--mbu-accent-text); } /* the Clear menu — a small dropdown anchored under its header button */ .ii-clear-wrap { position: relative; } /* #471 review: Clear is caret-only (no label) and sits rightmost, after Find links — #ii-links-btn (not the wrap) carries margin-left:auto now, since it's the first of the two right-pinned toolbar items. #419: a caret still needs a real hit area, not a bare glyph — same min footprint as .ii-hico. */ .ii-clear-toggle { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 26px; border: 1px solid var(--mbu-border); border-radius: 5px; background: var(--mbu-bg); color: var(--mbu-text-dim); font-size: 11px; cursor: pointer; } .ii-clear-toggle:hover, .ii-clear-toggle[aria-expanded="true"] { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); border-color: var(--mbu-accent); } .ii-clear-menu { display: none; position: absolute; top: 100%; right: 0; z-index: 60; flex-direction: column; min-width: 128px; margin-top: 4px; background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); box-shadow: 0 6px 18px rgba(0,0,0,.14); padding: 4px; } .ii-clear-menu.open { display: flex; } .ii-clear-menu button { background: none; border: none; text-align: left; padding: 6px 10px; font-size: 12px; border-radius: 4px; cursor: pointer; color: var(--mbu-text); } .ii-clear-menu button:hover { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); } /* toolbar */ #ii-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 8px 16px; border-bottom: 1px solid var(--mbu-divider); flex-shrink: 0; background: var(--mbu-bg); } #ii-tools #ii-links-btn { margin-left: auto; } /* pins Find links, Match and the Clear caret to the toolbar's right edge */ .ii-tbtn { display: inline-flex; align-items: center; gap: 5px; padding: 4px 11px; font-size: 12px; font-weight: 600; border-radius: 5px; cursor: pointer; text-decoration: none; border: 1px solid var(--mbu-border); background: var(--mbu-bg); color: var(--mbu-text); white-space: nowrap; } a.ii-tbtn:hover { text-decoration: none; } .ii-tbtn:hover { background: var(--mbu-bg-raised); } .ii-tbtn:disabled { opacity: .5; cursor: default; } .ii-tbtn.sx { color: var(--mbu-accent-text); border-color: var(--mbu-accent); } .ii-tbtn.dz { color: var(--mbu-error); border-color: var(--mbu-error); } .ii-tbtn.sp { color: var(--mbu-ok); border-color: var(--mbu-ok); } .ii-tbtn.bp { color: var(--mbu-ok); border-color: var(--mbu-ok); } .ii-tbtn.td { color: var(--mbu-info); border-color: var(--mbu-border); } .ii-tbtn.vo { color: var(--mbu-accent-text); border-color: var(--mbu-accent); } .ii-tbtn.hd { color: var(--mbu-error); border-color: var(--mbu-error); } .ii-tbtn.qz { color: var(--mbu-info); border-color: var(--mbu-info); } /* import-source buttons: independently show icon and/or text (⚙ Setup). Default = icons only (toolbar room); both can be on at once. */ .ii-bico { display: none; line-height: 0; } .ii-bico svg { display: block; } .ii-blabel { display: none; } #ii-tools.ii-show-icons .ii-bico { display: inline-flex; align-items: center; } #ii-tools.ii-show-text .ii-blabel { display: inline; } /* #643: All has no logo: its "icon" is the word, at the logos' height (icons get line-height 0, which collapsed it), and it is said once when icons and labels are both on */ .ii-tbtn.all { color: var(--mbu-text); border-color: var(--mbu-border-strong); } .ii-tbtn.all .ii-bico { line-height: 16px; height: 16px; font-weight: 700; font-size: 12px; letter-spacing: .02em; } #ii-tools.ii-show-icons.ii-show-text .ii-tbtn.all .ii-blabel { display: none; } .ii-tbtn.primary { background: #198754; color: var(--mbu-text-on-accent); border-color: var(--mbu-ok); } .ii-tbtn.primary:hover { background: #157347; } /* #406: a collection is running — fade the whole button in/out so it's clearly in progress */ #ii-submit.ii-collecting { animation: ii-btn-pulse 1.1s ease-in-out infinite; } @keyframes ii-btn-pulse { 0%,100% { opacity: 1; } 50% { opacity: .5; } } .ii-tbtn.ghost { border-color: transparent; } /* #302: a small purple dot marks a provider whose link was pulled from another release in the group (not this release). Same dot on RG-sourced add candidates. */ .ii-tbtn.ii-rg, .ii-tl.ii-rg { position: relative; } .ii-tbtn.ii-rg::after, .ii-tl.ii-rg::after { content: ''; position: absolute; top: -3px; right: -3px; width: 7px; height: 7px; border-radius: 50%; background: var(--mbu-accent); border: 1.5px solid var(--mbu-border); } /* In-MB marker (#180): a provider button gets a ring around its icon + a brand tint when the release already has that platform's URL in MB; an un-tinted/un-ringed button means the link was found by Platform Check. */ /* MB-linked: a strong brand ring on the WHITE button (#404) — the shared icons are now full-colour, so a brand FILL would hide them (e.g. green Spotify on green). */ .ii-tbtn.ii-mb { border-color: currentColor; box-shadow: 0 0 0 1px currentColor; } .ii-tbtn.ii-mb:hover { background: var(--mbu-bg-raised); } /* Unified "paste a URL" control (#180), apollo "+"-unroll style: a small round button that expands to an input on click; auto-detects the platform. */ .ii-urladd { display: inline-flex; align-items: center; gap: 5px; } .ii-urladd.open { flex: 1 1 auto; min-width: 120px; } /* expanded input fills the row (#180) */ .ii-urladd-btn { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 26px; height: 26px; padding: 0; border: 1px solid var(--mbu-border); border-radius: 50%; background: var(--mbu-bg); color: var(--mbu-text-dim); cursor: pointer; font-size: 16px; line-height: 1; } .ii-urladd-btn:hover { background: var(--mbu-bg-raised); border-color: var(--mbu-border); } .ii-urladd-btn svg { display: block; } .ii-urladd-input { display: none; padding: 4px 9px; border: 1px solid var(--mbu-border); border-radius: 5px; font-size: 12px; } .ii-urladd-input:focus { outline: none; border-color: var(--mbu-accent); } .ii-urladd.open .ii-urladd-input { display: inline-block; flex: 1 1 auto; width: auto; min-width: 0; } .ii-urladd.open .ii-urladd-btn { border-radius: 5px; } .ii-prog { font-size: 11px; color: var(--mbu-text-dim); min-width: 0; } .ii-prog.err { color: var(--mbu-error); font-weight: 700; } .ii-prog.continue { color: var(--mbu-accent-text); font-weight: 700; cursor: pointer; text-decoration: underline dotted; } .ii-prog.tolog { cursor: pointer; } .ii-prog.tolog:hover { text-decoration: underline; } .ii-prog.continue:hover { color: var(--mbu-accent-hover); } /* table */ /* min-height:0 → the track list scrolls instead of pushing the footer out of the modal. !important guards against MusicBrainz's page CSS. */ #ii-body { flex: 1 1 auto !important; min-height: 0 !important; overflow: auto !important; overscroll-behavior: contain; /* scrolling to either end stays in the modal, never scrolls the page behind */ padding: 0 0 56px 0; } /* 56px bottom = room for the absolutely-pinned footer */ #ii-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; } #ii-table thead th { position: sticky; top: 0; z-index: 2; background: var(--mbu-bg-raised); text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; color: var(--mbu-text-dim); padding: 7px 10px; border-bottom: 1px solid var(--mbu-border); } /* #471: resizable columns — a thin drag handle on the right edge of each resizable header cell; dragging changes that column's <col> width only (table-layout:fixed), so #ii-body scrolls horizontally past the widened table rather than squeezing its neighbor. th's own sticky positioning (position: sticky, above) already gives it a positioning context for the handle. */ .ii-col-resize { position: absolute; top: 0; right: -4px; width: 8px; height: 100%; cursor: col-resize; z-index: 3; } /* A persistent line so the handle is discoverable, not just a hover surprise (#471 review) — except it was drawn in --mbu-bg-sunken, which is a SURFACE token: 94% of the background mixed with a little ink, i.e. ~#f0f0f0 on a white header. Invisible, exactly as majkinetor reported ("column separators are not visible on light theme, one visible is the one I hover over"). --mbu-border is the token for a line you are meant to see, and is what this table's own medium rows already use. */ .ii-col-resize::after { content: ''; position: absolute; top: 4px; bottom: 4px; left: 3px; width: 1px; background: var(--mbu-border); } .ii-col-resize:hover, .ii-col-resize.dragging { background: rgba(111,66,193,.35); } .ii-col-resize:hover::after, .ii-col-resize.dragging::after { background: var(--mbu-accent); width: 2px; } .ii-medrow td { background: var(--mbu-bg-raised); font-weight: 700; font-size: 11.5px; color: var(--mbu-text-dim); padding: 5px 10px; border-top: 1px solid var(--mbu-border); } #ii-table td { padding: 6px 10px; border-bottom: 1px solid var(--mbu-border); vertical-align: top; } .ii-pos { color: var(--mbu-text-weak); font-variant-numeric: tabular-nums; width: 34px; white-space: nowrap; } .ii-track-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; } .ii-track-title a { color: inherit; text-decoration: none; } .ii-track-title a:hover { color: var(--mbu-accent-text); text-decoration: underline; } .ii-track-artist { color: var(--mbu-text-dim); font-size: 11.5px; } .ii-track-dur { color: var(--mbu-text-dim); font-size: 11px; font-family: 'Courier New', monospace; /* the row is two lines (title + artist) and #ii-table td is vertical-align:top, so this smaller monospace sat ABOVE the title's baseline. baseline lines it up with the title and the ISRC cell beside it. */ } /* #ii-table td sets vertical-align:top at id specificity, so the class alone could not move it — the duration has to be named at the same level. */ #ii-table td.ii-track-dur { vertical-align: baseline; } /* #219/#301 — provider link icons live in the LINKED / ADD columns. Grey monochrome = already linked on MB; brand colour = resolved + addable. */ .ii-tl-linked, .ii-tl-add { display: flex; align-items: center; gap: 9px; min-height: 18px; } .ii-tl-add { flex-wrap: wrap; } .ii-tl-linked { flex-wrap: wrap; } /* #389 one row when it fits (the wide col #406 gives it); wrap, never overflow into ADD */ .ii-tl-add:empty::before, .ii-tl-linked:empty::before { content: '—'; color: var(--mbu-text-weak); } .ii-tl { display: inline-flex; align-items: center; line-height: 0; text-decoration: none; } .ii-tl svg { width: 16px; height: 16px; display: block; } .ii-tl.linked { color: var(--mbu-text-weak); } /* already on MB → quiet monochrome */ .ii-tl.linked:hover { color: var(--mbu-text-dim); } .ii-tl.linked.ended { opacity: .4; } /* #341: relationship marked ended → faded */ .ii-tl.linked.ended:hover { opacity: .7; } .ii-tl.new { cursor: pointer; } /* resolved, not linked → click to add (brand colour set inline) */ .ii-tl.new:hover { filter: brightness(1.12); } .ii-tl.cand { display: none; } /* not resolved yet → hidden until Find links */ .ii-tl.spin { color: var(--mbu-info); animation: ii-tl-pulse 1s ease-in-out infinite; } .ii-tl.removing { opacity: .35; animation: ii-tl-pulse 1s ease-in-out infinite; } .ii-tl.absent { display: none; } /* track not found on provider */ @keyframes ii-tl-pulse { 0%,100% { opacity: .35; } 50% { opacity: .9; } } .ii-existing { width: 150px; } .ii-existing samp { display: block; font-size: 11px; font-weight: 700; color: var(--mbu-ok); font-family: 'Courier New', monospace; } .ii-existing samp.dup { color: var(--mbu-error); background: var(--mbu-error-bg); border-radius: 3px; padding: 0 3px; } .ii-existing .none { color: var(--mbu-info); font-style: italic; font-size: 11px; } /* #159: highlight rows that still have no ISRC (no existing + nothing entered yet) */ .ii-row-missing > td { background: var(--mbu-warn-bg); } .ii-row-missing > td:first-child { box-shadow: inset 3px 0 0 #f0ad4e; } .ii-row-missing .ii-existing .none { color: var(--mbu-warn); font-style: normal; font-weight: 600; } .ii-ex-item { display: flex; align-items: center; gap: 5px; cursor: pointer; } .ii-ex-item input { cursor: pointer; margin: 0; flex-shrink: 0; } .ii-ex-item.del samp { text-decoration: line-through; color: var(--mbu-error); } /* pending Remove-ISRC edit — highlighted like MusicBrainz marks entities with an open edit (orange/peach), with a strike-through to show it's a removal */ .ii-ex-pending { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--mbu-warn); background: var(--mbu-warn-bg); border: 1px solid var(--mbu-warn); border-radius: 3px; padding: 0 4px; } .ii-ex-pending samp { color: var(--mbu-warn); text-decoration: line-through; } a.ii-ex-pending { text-decoration: none; } a.ii-ex-pending:hover { background: var(--mbu-warn); color: var(--mbu-text-on-accent); } a.ii-ex-pending:hover samp { color: var(--mbu-text-on-accent); } /* #587: a removal whose POST failed — it is still on the recording and no edit exists, which must not read the same as a pending one */ .ii-ex-failed { border: 1px solid var(--mbu-error); border-radius: 3px; padding: 0 3px; } .ii-ex-failed samp { color: var(--mbu-error); } /* #587: the Delete button is the global activity indicator while a batch runs — it counts down, so there is one obvious place to look besides the log */ .ii-tbtn.ii-busy { color: var(--mbu-warn); border-color: var(--mbu-warn); cursor: progress; opacity: 1; } .ii-tbtn.ii-busy[disabled] { opacity: 1; } .ii-inwrap { display: flex; align-items: center; gap: 5px; } /* #490: the initial "search SoundExchange by title/artist" entry point used to be a permanently-visible text link under every row (spammy on a long tracklist) — now a row-hover-only icon left of the input. Opacity/pointer-events toggle (not display) so its slot stays reserved and nothing shifts when it fades in. */ .ii-sx-hover { flex-shrink: 0; width: 20px; height: 22px; padding: 0; border: none; background: none; cursor: pointer; color: var(--mbu-text-dim); opacity: 0; pointer-events: none; transition: opacity .1s; display: flex; align-items: center; justify-content: center; } .ii-sx-hover svg { display: block; } tr:hover .ii-sx-hover { opacity: .55; pointer-events: auto; } .ii-sx-hover:hover { opacity: 1 !important; color: var(--mbu-text); } /* the × lives INSIDE the input box (part of the edit), so it doesn't shift the row layout / SX text alignment */ .ii-input-box { position: relative; flex-shrink: 0; width: 150px; } .ii-input { width: 100%; box-sizing: border-box; padding: 4px 22px 4px 7px; border: 1px solid var(--mbu-border); border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12.5px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; } .ii-input:focus { outline: none; border-color: var(--mbu-accent); } .ii-input.bad { border-color: var(--mbu-error); background: var(--mbu-bg-raised); } .ii-input.dup { border-color: var(--mbu-warn); background: var(--mbu-bg-raised); } .ii-input.dupother { border-color: var(--mbu-error); background: var(--mbu-error-bg); } .ii-input.ok { border-color: var(--mbu-ok); } .ii-clear { position: absolute; right: 3px; top: 50%; transform: translateY(-50%); width: 17px; height: 17px; padding: 0; border: none; border-radius: 3px; background: transparent; color: var(--mbu-text-weak); font-size: 13px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; } .ii-clear:hover { background: var(--mbu-bg-hover); color: var(--mbu-error); } .ii-plus { flex-shrink: 0; font-size: 11px; font-weight: 700; padding: 3px 7px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg); cursor: pointer; color: var(--mbu-text-dim); font-family: monospace; } .ii-plus:hover { background: var(--mbu-bg-sunken); color: var(--mbu-text); } .ii-plus-hidden { visibility: hidden; } /* reserve the slot on the first row so SX text still aligns */ /* explicit per-track SoundExchange trigger (#157), sits next to +1 */ .ii-sx { flex-shrink: 0; font-size: 11px; font-weight: 700; padding: 3px 7px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg-raised); cursor: pointer; color: var(--mbu-info); font-family: monospace; } .ii-sx:hover { background: var(--mbu-info-bg); color: var(--mbu-info); } .ii-sx:disabled { opacity: .4; cursor: default; } .ii-sx:disabled:hover { background: var(--mbu-bg-raised); color: var(--mbu-info); } /* #490: margin-left matches .ii-sx-hover's width (20px) + .ii-inwrap's gap (5px), so the candidate list — a sibling of .ii-inwrap, not a child — lines up with the input box instead of starting flush left under the hover icon. */ .ii-cands { margin-top: 4px; margin-left: 25px; display: flex; flex-direction: column; gap: 3px; width: auto; } .ii-cand { display: flex; align-items: flex-start; gap: 7px; padding: 3px 7px; border: 1px solid var(--mbu-border); border-radius: 4px; cursor: pointer; font-size: 11px; background: var(--mbu-bg); } .ii-cand:hover { background: var(--mbu-bg); border-color: var(--mbu-info); } .ii-cands.ii-collapsed .ii-cand:not(.chosen) { display: none; } .ii-cand.chosen { box-shadow: inset 3px 0 0 #198754; } .ii-cand.best { border-color: var(--mbu-info); background: var(--mbu-info-bg); } .ii-cand.warn { border-color: var(--mbu-warn); background: var(--mbu-warn-bg); } .ii-cand.bad { border-color: var(--mbu-error); background: var(--mbu-bg-raised); } .ii-cand-isrc { font-family: 'Courier New', monospace; font-weight: 700; color: var(--mbu-info); flex-shrink: 0; padding-top: 1px; } .ii-cand-meta { flex: 1; min-width: 0; color: var(--mbu-text-dim); white-space: normal; word-break: break-word; line-height: 1.35; } .ii-bad { color: var(--mbu-error); font-weight: 600; text-decoration: underline wavy rgba(220,53,69,.55); text-underline-offset: 2px; } .ii-mbdur { color: var(--mbu-ok); font-weight: 600; } .ii-cand-src { margin-left: auto; font-size: 9px; text-transform: uppercase; color: var(--mbu-text-weak); flex-shrink: 0; } .ii-cand-note { font-size: 11px; color: var(--mbu-text-weak); font-style: italic; padding: 2px 7px; } .ii-row-fill { animation: ii-flash 1s ease-out; } @keyframes ii-flash { 0%{background:rgba(25,135,84,.18)} 100%{background:transparent} } /* footer — pinned ABSOLUTELY to the modal's bottom (out of the flex flow) so it can never be pushed off, whatever the body does. The body reserves 56px of bottom padding for it. #ii-modal is position:fixed → it's the containing block. */ #ii-foot { position: absolute !important; left: 0; right: 0; bottom: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 9px 16px; height: 56px; box-sizing: border-box; border-top: 1px solid var(--mbu-border); background: var(--mbu-bg); } #ii-foot .ii-summary { font-size: 12px; color: var(--mbu-text-dim); min-width: 0; } #ii-foot #ii-summary { flex: 1; } /* #471: both summaries show together — only the first grows */ #ii-foot .ii-summary b { color: var(--mbu-text); } #ii-foot #ii-summary-links:not(:empty)::before { content: ' · '; color: var(--mbu-info); } .ii-seq-badge { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; padding: 2px 9px; font-size: 11px; font-weight: 700; font-family: 'Courier New', monospace; color: var(--mbu-ok); background: var(--mbu-ok-bg); border: 1px solid var(--mbu-ok); border-radius: 11px; vertical-align: middle; letter-spacing: .3px; } /* sub-panels (setup / bulk) */ .ii-pane { display: none; padding: 14px 16px; border-bottom: 1px solid var(--mbu-divider); background: var(--mbu-bg); flex-shrink: 0; } /* an open pane scrolls internally past 45vh so it can never push the footer off */ .ii-pane.open { display: block; max-height: 45vh; overflow-y: auto; overscroll-behavior: contain; } .ii-pane h3 { margin: 0 0 8px; font-size: 13px; display: flex; align-items: center; gap: 8px; } .ii-pane-x { flex-shrink: 0; width: 19px; height: 19px; line-height: 1; padding: 0; font-size: 13px; color: var(--mbu-text-dim); background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 4px; cursor: pointer; } .ii-pane-x:hover { background: var(--mbu-bg-raised); color: var(--mbu-text); border-color: var(--mbu-border); } /* #301 standard config-window header: icon · name · version · Log · ? Help */ .ii-cfg-lnk { font: 600 12px system-ui; color: var(--mbu-accent-text); text-decoration: none; cursor: pointer; background: none; border: none; padding: 2px 4px; } .ii-cfg-lnk:hover { text-decoration: underline; } .ii-cfg-grp { font: 700 10.5px system-ui; text-transform: uppercase; letter-spacing: .3px; color: var(--mbu-text-weak); margin: 4px 0 6px; } .ii-pane label { display: block; font-size: 11.5px; color: var(--mbu-text-dim); margin: 6px 0 2px; } .ii-pane input[type=text], .ii-pane textarea { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--mbu-border); border-radius: 4px; font-size: 12px; font-family: 'Courier New', monospace; } .ii-pane textarea { min-height: 120px; resize: vertical; } .ii-pane .row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; } .ii-pane .row > div { flex: 1; min-width: 200px; } .ii-help { font-size: 11px; color: var(--mbu-text-dim); margin-top: 6px; line-height: 1.5; } .ii-help a { color: var(--mbu-accent-text); } .ii-authstate { font-size: 11.5px; padding: 4px 0; } .ii-authstate.ok { color: var(--mbu-ok); } .ii-authstate.no { color: var(--mbu-error); } /* the shared log window opens over the dialog, not under it (#701) */ body #mbu-logpop { z-index: calc(var(--mbu-z-modal-panel) + 1); } .ii-sx-group { display: inline-flex; align-items: center; gap: 7px; padding: 3px 8px 3px 4px; border: 1px solid var(--mbu-accent); background: var(--mbu-bg-raised); border-radius: 7px; } /* per-track ISRC-provider selector (#181): a split [icon|▾] on each row's button */ .ii-sxsplit { display: inline-flex; align-items: stretch; flex-shrink: 0; } .ii-sxsplit .ii-sx { border-top-right-radius: 0; border-bottom-right-radius: 0; } .ii-sxprov { display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; margin-left: -1px; font-size: 9px; color: var(--mbu-text-dim); background: var(--mbu-bg-raised); border: 1px solid var(--mbu-border); border-left-color: var(--mbu-border); border-top-right-radius: 5px; border-bottom-right-radius: 5px; cursor: pointer; } .ii-sxprov:hover { background: var(--mbu-info-bg); color: var(--mbu-info); } .ii-prov-menu { display: none; position: absolute; z-index: 60; flex-direction: column; min-width: 178px; padding: 4px; background: var(--mbu-bg); border: 1px solid var(--mbu-accent); border-radius: 7px; box-shadow: 0 6px 22px rgba(40,20,80,.18); } .ii-prov-menu.open { display: flex; } .ii-prov-item { display: flex; align-items: center; gap: 8px; padding: 6px 9px; font-size: 12px; font-weight: 600; color: inherit; background: none; border: 0; border-radius: 5px; cursor: pointer; text-align: left; } .ii-prov-item:hover { background: var(--mbu-bg-raised); } .ii-prov-item.active { background: var(--mbu-bg-hover); } .ii-prov-item.active::after { content: '✓'; margin-left: auto; color: var(--mbu-accent-text); font-weight: 700; } .ii-prov-ico { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex-shrink: 0; } .ii-prov-ico svg { width: 16px; height: 16px; } .ii-prov-sx { font-size: 10px; font-weight: 800; color: var(--mbu-accent-text); } .ii-prov-name { color: var(--mbu-text); } /* per-track button shows the chosen provider's icon */ .ii-sx svg { width: 13px; height: 13px; display: block; } /* collapsible "exact" toggle — collapsed by default so the toolbar stays compact */ .ii-exact-toggle { display: inline-flex; align-items: center; gap: 3px; padding: 3px 8px; font-size: 11px; font-weight: 600; color: var(--mbu-accent-text); background: var(--mbu-bg); border: 1px solid var(--mbu-accent); border-radius: 5px; cursor: pointer; } .ii-exact-toggle:hover { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); } .ii-exact-toggle .ii-exact-car { font-size: 9px; transition: transform .15s; } /* NB: the state class is namespaced (ii-collapsed) on purpose — MB's common.css paints a 20px absolute ::after fade overlay on ANY bare .collapsed, which blanketed the toolbar and swallowed clicks on the bottom half of every button (#414). */ .ii-sx-group:not(.ii-collapsed) .ii-exact-toggle .ii-exact-car { transform: rotate(180deg); } /* a filled dot on the toggle when any exact option is active while collapsed */ .ii-exact-toggle.on { color: var(--mbu-accent-text); border-color: var(--mbu-accent); background: var(--mbu-bg-raised); } .ii-exact-toggle.on::after { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--mbu-accent); } .ii-sx-group.ii-collapsed .ii-exact-set { display: none; } .ii-exact-set { display: inline-flex; align-items: center; gap: 9px; font-size: 11px; color: var(--mbu-text-dim); } .ii-ex-all-lbl { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; } .ii-ex-all-lbl input { cursor: pointer; } .ii-exact-set label { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; margin: 0; } .ii-exact-set input { cursor: pointer; } .ii-cand.inmb { opacity: .72; } .ii-cand-inmb { margin-left: auto; font-size: 9px; font-weight: 700; color: var(--mbu-ok); flex-shrink: 0; } .ii-lookup { flex: 1; min-width: 0; font-size: 11px; white-space: normal; word-break: break-word; line-height: 1.35; } .ii-lookup.ok { color: var(--mbu-ok); } .ii-lookup.warn { color: var(--mbu-warn); } .ii-lookup.err { color: var(--mbu-error); } .ii-lookup.spin { color: var(--mbu-text-dim); } /* #643: the All verdict chip and its comparison popover */ .ii-lookup.ii-all-chip { flex: 0 0 auto; cursor: pointer; font-weight: 700; padding: 1px 7px; border-radius: 10px; border: 1px solid currentColor; } .ii-lookup.ii-all-chip.none { color: var(--mbu-text-weak); } .ii-lookup.ii-all-chip:hover { background: var(--mbu-bg-hover); } #ii-all-pop { position: absolute; z-index: 20; min-width: 300px; max-width: 620px; background: var(--mbu-bg); color: var(--mbu-text); border: 1px solid var(--mbu-accent); border-radius: 8px; box-shadow: var(--mbu-shadow-lg); padding: 6px 4px; font-size: 12px; user-select: text; } #ii-all-pop.pinned { box-shadow: 0 0 0 1px var(--mbu-accent), var(--mbu-shadow-lg); } #ii-all-pop .ii-all-h { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; font-weight: 700; color: var(--mbu-text-dim); padding: 2px 6px 5px 8px; border-bottom: 1px solid var(--mbu-border-soft); margin-bottom: 3px; } #ii-all-pop .ii-all-sp { flex: 1 1 auto; } #ii-all-pop .ii-all-pick { font: inherit; font-family: var(--mbu-font-mono); font-weight: 400; font-size: 11px; padding: 1px 6px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg-raised); color: var(--mbu-text); cursor: pointer; } #ii-all-pop .ii-all-pick.other { border-color: var(--mbu-error); } #ii-all-pop .ii-all-pick.sel { border-color: var(--mbu-accent); background: var(--mbu-bg-hover); } #ii-all-pop .ii-all-pick .ii-all-n { color: var(--mbu-text-weak); } #ii-all-pop .ii-all-line { display: grid; grid-template-columns: 14px 18px 96px 1fr auto auto; gap: 6px; align-items: center; padding: 3px 8px; border-radius: 5px; } #ii-all-pop .ii-all-ico svg { width: 14px; height: 14px; display: block; } #ii-all-pop .ii-all-isrc { font-family: var(--mbu-font-mono); } #ii-all-pop .ii-all-note { color: var(--mbu-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #ii-all-pop .ii-all-at { cursor: help; } #ii-all-pop .ii-all-len { font-variant-numeric: tabular-nums; color: var(--mbu-text-dim); text-align: right; } #ii-all-pop .ii-all-sub { color: var(--mbu-text-weak); } #ii-all-pop .ii-all-mark { text-align: center; } #ii-all-pop .ii-all-ok .ii-all-mark { color: var(--mbu-ok); } #ii-all-pop .ii-all-song .ii-all-mark, #ii-all-pop .ii-all-blocked .ii-all-mark { color: var(--mbu-warn); } #ii-all-pop .ii-all-differs { background: var(--mbu-error-bg); } #ii-all-pop .ii-all-differs .ii-all-mark { color: var(--mbu-error); } #ii-all-pop .ii-all-none, #ii-all-pop .ii-all-pending { color: var(--mbu-text-weak); } #ii-all-pop .ii-all-use, #ii-all-pop .ii-all-copy { font-size: 11px; padding: 1px 8px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg-raised); color: var(--mbu-text); cursor: pointer; } #ii-all-pop .ii-all-copy.done { border-color: var(--mbu-ok); color: var(--mbu-ok); } #ii-all-pop .ii-all-sxlink { color: var(--mbu-link, var(--mbu-accent)); text-decoration: none; font-weight: 700; } #ii-all-pop .ii-all-copy:hover, #ii-all-pop .ii-all-pick:hover, #ii-all-pop .ii-all-use:hover { border-color: var(--mbu-accent); } .ii-lookup-rel { color: var(--mbu-text-dim); } .ii-lookup.pending { color: var(--mbu-text-dim); cursor: pointer; text-decoration: underline dotted #adb5bd; text-underline-offset: 2px; } /* #431: a position-matched fill whose length/title doesn't fit the MB track */ input.ii-in-suspect { border-color: var(--mbu-warn) !important; background: var(--mbu-bg-raised) !important; box-shadow: 0 0 0 2px rgba(224,137,42,.28); } #ii-suspect-badge { color: var(--mbu-warn); background: var(--mbu-warn-bg); border: 1px solid var(--mbu-warn); border-radius: 5px; padding: 2px 8px; font-weight: 700; font-size: 11.5px; cursor: pointer; flex-shrink: 0; white-space: nowrap; } #ii-suspect-badge:hover { background: var(--mbu-warn-bg); } .ii-lookup.pending:hover { color: var(--mbu-text); } .ii-cand-refine { font-size: 10.5px; color: var(--mbu-accent-text); cursor: pointer; padding: 2px 7px; border: 1px dashed var(--mbu-accent); border-radius: 4px; background: var(--mbu-bg-raised); width: max-content; } .ii-cand-refine:hover { background: var(--mbu-bg-hover); text-decoration: underline; } .ii-cand-pending { font-size: 10.5px; color: var(--mbu-text-dim); cursor: pointer; padding: 3px 8px; border: 1px dashed var(--mbu-border); border-radius: 4px; background: var(--mbu-bg); width: max-content; } .ii-cand-pending:hover { background: var(--mbu-bg-raised); color: var(--mbu-text); border-color: var(--mbu-border); } /* SoundExchange refine panel */ #ii-sxpanel { position: fixed; top: 9vh; right: 4vw; width: 560px; max-width: 92vw; max-height: 78vh; background: var(--mbu-bg); border: 1px solid var(--mbu-accent); border-radius: var(--mbu-radius-lg); box-shadow: 0 14px 44px rgba(0,0,0,.32); z-index: var(--mbu-z-modal-panel); display: none; flex-direction: column; overflow: hidden; font-family: system-ui, sans-serif; } #ii-sxpanel.open { display: flex; } .ii-sxp-hdr { display: flex; align-items: center; gap: 8px; padding: 9px 13px; background: var(--mbu-bg-raised); border-bottom: 1px solid var(--mbu-accent); cursor: move; user-select: none; } .ii-sxp-hdr .t { flex: 1; font-size: 13px; font-weight: 700; color: var(--mbu-accent-deep-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ii-sxp-hdr .t b { color: var(--mbu-accent-text); } #ii-sxp-close { background: none; border: none; font-size: 17px; color: var(--mbu-text-dim); cursor: pointer; line-height: 1; } #ii-sxp-close:hover { color: var(--mbu-text); } .ii-sxp-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 6px; padding: 10px 13px 10px; align-items: start; } .ii-sxp-field { position: relative; display: flex; align-items: center; } #ii-sxp-f-title { grid-column: 1; } #ii-sxp-f-artist { grid-column: 2; } #ii-sxp-f-release { grid-column: 1 / 3; } .ii-sxp-inp { width: 100%; padding: 6px 31px; border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); font-size: 13px; box-sizing: border-box; } .ii-sxp-inp:focus { outline: none; border-color: var(--mbu-accent); } .ii-sxp-field.off .ii-sxp-inp { color: var(--mbu-text-weak); background: var(--mbu-bg); } .ii-sxp-en { position: absolute; left: 8px; width: 15px; height: 15px; margin: 0; cursor: pointer; z-index: 1; flex-shrink: 0; } .ii-sxp-E { position: absolute; right: 5px; width: 23px; height: 23px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; color: var(--mbu-text-weak); background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 4px; cursor: pointer; } .ii-sxp-E:hover { color: var(--mbu-accent-text); border-color: var(--mbu-accent); } .ii-sxp-E.on { color: var(--mbu-text); border-color: #212529; } .ii-sxp-field.off .ii-sxp-E { opacity: .4; pointer-events: none; } #ii-sxp-search { grid-column: 3; grid-row: 1 / 3; align-self: stretch; padding: 0 18px; border: none; border-radius: var(--mbu-radius); background: var(--mbu-accent); color: var(--mbu-text-on-accent); font-size: 13px; font-weight: 700; cursor: pointer; } #ii-sxp-search:hover { background: var(--mbu-accent-hover); } #ii-sxp-search:disabled { background: var(--mbu-bg-sunken); } .ii-sxp-status { padding: 2px 13px; font-size: 11px; color: var(--mbu-text-dim); min-height: 14px; } .ii-sxp-status.err { color: var(--mbu-error); } .ii-sxp-results { flex: 1; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 4px 13px 12px; display: flex; flex-direction: column; gap: 4px; } .ii-sxp-row { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); cursor: pointer; overflow: hidden; flex-shrink: 0; } .ii-sxp-row:hover { background: var(--mbu-bg); border-color: var(--mbu-info); } .ii-sxp-row.best { border-color: var(--mbu-info); background: var(--mbu-info-bg); } .ii-sxp-row.warn { border-color: var(--mbu-warn); background: var(--mbu-warn-bg); } .ii-sxp-row.bad { border-color: var(--mbu-error); background: var(--mbu-bg-raised); } .ii-sxp-row.cur { border-color: var(--mbu-ok); background: var(--mbu-ok-bg); } .ii-sxp-row { align-items: flex-start; } .ii-sxp-isrc { font-family: 'Courier New', monospace; font-weight: 700; color: var(--mbu-info); flex-shrink: 0; font-size: 12px; padding-top: 1px; } .ii-sxp-meta { flex: 1; min-width: 0; } .ii-sxp-meta .a { display: block; font-size: 12px; color: var(--mbu-text); white-space: normal; word-break: break-word; line-height: 1.35; } .ii-sxp-meta .b { display: block; font-size: 10.5px; color: var(--mbu-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ii-sxp-inmb { font-size: 9px; font-weight: 700; color: var(--mbu-ok); flex-shrink: 0; } .ii-sxp-foot { padding: 8px 13px; border-top: 1px solid var(--mbu-divider); background: var(--mbu-bg); flex-shrink: 0; } .ii-sxp-foot a { font-size: 11.5px; font-weight: 600; color: var(--mbu-accent-text); text-decoration: none; } .ii-sxp-foot a:hover { text-decoration: underline; } /* ────────────────────────────────────────────────────────────────── MOBILE / NARROW VIEWPORTS MusicBrainz serves width=device-width, so phones render the modal at ~96vw of a small viewport. The desktop header (title + tool buttons on ONE flex row) then squeezes the title into a one-word-wide column, and the fixed-width table (560px New-ISRC col) overflows horizontally. Below ~700px we grow the modal, stack the header, turn every track row into a full-width card, and let the toolbar/footer wrap. ────────────────────────────────────────────────────────────────── */ @media (max-width: 700px) { /* Keep the modal CENTERED (inherit the base left:50% + translateX). Do NOT pin it to 0,0 / 100vw: on tablets where MusicBrainz's desktop layout overflows a narrow layout-viewport, a position:fixed 100vw / left:0 modal is sized to the layout viewport and lands in the top-left corner instead of filling the screen. A centered 96vw × 96vh dialog reads correctly everywhere. */ #ii-modal { top: 2vh !important; width: 96vw !important; max-width: 96vw !important; height: 96vh !important; max-height: 96vh !important; } @supports (height: 100dvh) { #ii-modal { height: 96dvh !important; max-height: 96dvh !important; } } /* header: title gets its own row (subtitle truncates), tool buttons wrap below it, close pinned to the top-right corner */ #ii-hdr { flex-wrap: wrap; position: relative; padding: 10px 12px 8px; gap: 6px; } #ii-hdr h2 { flex: 1 1 100%; font-size: 14px; padding-right: 30px; min-width: 0; display: flex; align-items: center; } #ii-hdr h2 em { white-space: nowrap; flex-shrink: 0; } #ii-hdr .ii-sub { flex: 1 1 auto; min-width: 0; margin-left: 6px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #ii-hdr .ii-tbtn { font-size: 11px; padding: 4px 9px; } #ii-close { position: absolute; top: 5px; right: 9px; font-size: 22px; padding: 2px 6px; } /* toolbar: tighten, keep "Clear entered" pushed to the right */ #ii-tools { padding: 7px 12px; gap: 5px; } .ii-exact-set { gap: 7px; } /* table → one full-width card per track row (CSS-only, no DOM change). #471: ISRC and Links columns are always both present now (7 cells per row instead of 5), so every cell gets its own explicit grid-row by nth-child — a class-based selector on an inner div (the old .ii-existing rule) is NOT a grid item and silently does nothing; only the <td> itself, as the grid's direct child, can be positioned. That dead selector is why the Links-scope cells fell back to browser auto-placement and their icon lists collapsed to a near-zero-width column, wrapping one icon per line (#476's screenshot). */ #ii-table { display: block; } #ii-table thead, #ii-table colgroup { display: none; } #ii-table tbody { display: block; } #ii-table tr:not(.ii-medrow) { display: grid; grid-template-columns: 24px 1fr auto; align-items: start; column-gap: 8px; row-gap: 4px; padding: 9px 12px; border-bottom: 1px solid var(--mbu-border); } #ii-table tr:not(.ii-medrow) > td { display: block; border: none !important; padding: 0; } #ii-table tr:not(.ii-medrow) > td:nth-child(1) { grid-column: 1; grid-row: 1; } /* # */ #ii-table tr:not(.ii-medrow) > td:nth-child(2) { grid-column: 2; grid-row: 1; } /* track */ #ii-table tr:not(.ii-medrow) > td:nth-child(3) { grid-column: 3; grid-row: 1; text-align: right; } /* dur */ #ii-table tr:not(.ii-medrow) > td:nth-child(4) { grid-column: 2 / 4; grid-row: 2; width: auto; } /* existing ISRC */ #ii-table tr:not(.ii-medrow) > td:nth-child(5) { grid-column: 2 / 4; grid-row: 3; } /* new ISRC */ #ii-table tr:not(.ii-medrow) > td:nth-child(6) { grid-column: 2 / 4; grid-row: 4; } /* linked */ #ii-table tr:not(.ii-medrow) > td:nth-child(7) { grid-column: 2 / 4; grid-row: 5; } /* add */ #ii-table tr.ii-medrow { display: block; } #ii-table tr.ii-medrow td { display: block; } /* paint the whole card (not just cells, which would leave striped gaps) */ #ii-table tr.ii-row-missing { background: var(--mbu-warn-bg); box-shadow: inset 3px 0 0 var(--mbu-warn); } #ii-table tr.ii-row-missing > td { background: transparent; box-shadow: none; } /* let the New-ISRC input grow to the card width; candidates span it */ .ii-input-box { flex: 1 1 auto; width: auto; min-width: 0; } .ii-inwrap { flex-wrap: wrap; } .ii-cands { width: 100%; } /* footer wraps: summary on its own line, action buttons below it */ #ii-foot { height: auto; min-height: 52px; flex-wrap: wrap; padding: 8px 12px; gap: 6px; } #ii-foot .ii-summary { flex: 1 1 100%; font-size: 11.5px; } #ii-foot .ii-tbtn { font-size: 11px; padding: 6px 10px; flex: 0 1 auto; } #ii-body { padding-bottom: 108px; } /* clear the taller wrapped footer */ /* secondary panels: keep them on-screen */ #ii-sxpanel { top: 2vh !important; left: 2vw !important; right: 2vw !important; width: auto !important; max-width: none !important; max-height: 92vh !important; } } `; // @run-at document-start (needed for the Spotify harvester) can fire before // <html>/<head> exist; the MB-side editor only needs the DOM, so defer to ready. function whenDomReady(fn) { if (document.head || document.body) fn(); else document.addEventListener('DOMContentLoaded', fn, { once: true }); } whenDomReady(() => (document.head || document.documentElement).appendChild(style)); /* ═══════════════════════════════════════════════════════════════════════ RELEASE MODEL (single WS2 fetch → everything) ═══════════════════════════════════════════════════════════════════════ */ let RELEASE = null; // { title, tracks:[{recId, title, artist, dur, mediumPos, trackPos, existing:[], pending:''}], deezerId, spotifyId } // Recognise an album/release provider link → { k: RELEASE field, v: id/url }, or // null. One place so the per-release parse and the release-group scan (#302) agree. function matchProviderLink(u) { let m; // #640: the release's YouTube Music album — its tracklist resolves the per-track links if ((m = u.match(/(?:^|\/\/)(?:(?:music|www|m)\.)?youtube\.com\/(?:playlist\?(?:[^#]*&)?list=(OLAK5uy_[\w-]+)|browse\/(MPREb_[\w-]+))/i))) return { k: 'ytmUrl', v: m[1] ? 'https://music.youtube.com/playlist?list=' + m[1] : 'https://music.youtube.com/browse/' + m[2] }; // #644: the release's Amazon Music album — its tracklist resolves the per-track links if ((m = u.match(/^https?:\/\/music\.amazon\.([a-z.]+)\/albums\/([A-Z0-9]{10})/i))) return { k: 'amzUrl', v: 'https://music.amazon.' + m[1].toLowerCase() + '/albums/' + m[2].toUpperCase() }; if ((m = u.match(/^https?:\/\/[a-z0-9-]+\.bandcamp\.com\/album\/[^?#]+/i))) return { k: 'bandcampUrl', v: m[0] }; // #300: per-track URLs by position if ((m = u.match(/^(https?:\/\/music\.apple\.com\/[a-z]{2}\/album\/(?:[^/?#]+\/)?\d+)/i))) return { k: 'appleUrl', v: m[1] }; // album page ld+json lists every track URL // Legacy iTunes links are equivalent to Apple Music — normalise to the canonical // music.apple.com album URL so the same fetch path handles them (#436). if ((m = u.match(/itunes\.apple\.com\/(?:([a-z]{2})\/)?album\/(?:[^/?#]+\/)?id(\d+)/i))) return { k: 'appleUrl', v: 'https://music.apple.com/' + (m[1] || 'us').toLowerCase() + '/album/' + m[2] }; if ((m = u.match(/open\.spotify\.com\/album\/([A-Za-z0-9]+)/))) return { k: 'spotifyId', v: m[1] }; if ((m = u.match(/deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/))) return { k: 'deezerId', v: m[1] }; if ((m = u.match(/beatport\.com\/release\/[^/]+\/(\d+)/))) return { k: 'beatportId', v: m[1] }; if ((m = u.match(/(?:listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/))) return { k: 'tidalId', v: m[1] }; if ((m = u.match(/volumo\.com\/album\/(\d+)/))) return { k: 'volumoId', v: m[1] }; // id or leading ICPN // Qobuz album id is the last path segment (…/album/<slug>/<id>, or the slug-less open form …/album/<id>) if ((m = u.match(/qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/?#]+\/)?([a-z0-9]{8,})/i))) return { k: 'qobuzId', v: m[1] }; // HDtracks: new #/album/<24-hex ObjectId> resolves directly; legacy 5009 rels carry the // UPC in valbum_code (fetchHDtracks resolves it via barcode search). Slug-id / artist-page // legacy forms have no clean id mapping and are skipped (Platform Check handles by barcode). if ((m = u.match(/hdtracks\.com\/(?:#\/)?album\/([a-f0-9]{24})/i))) return { k: 'hdtracksId', v: m[1] }; if ((m = u.match(/hdtracks\.com\/[^?]*[?&]valbum_code=(\d{8,})/i))) return { k: 'hdtracksId', v: m[1] }; // SoundCloud SET (playlist) = album, or a bare TRACK url = a single-track release // (#439, chaban-mb); the whole permalink URL is the id (api-v2 resolves either). #439 if ((m = u.match(/^(https?:\/\/(?:www\.|m\.)?soundcloud\.com\/[^/?#]+\/(?:sets\/)?[^/?#]+)/i))) return { k: 'scUrl', v: m[1] }; // Audiomack album, or a song = a single-track release (#664); the canonical URL is the id if ((m = u.match(/^https?:\/\/(?:www\.)?audiomack\.com\/([^/?#]+)\/(album|song)\/([^/?#]+)/i))) return { k: 'akUrl', v: 'https://audiomack.com/' + m[1] + '/' + m[2].toLowerCase() + '/' + m[3] }; // 7digital release (#669): the id ends its slug; the link (with its store) is what's searched with if ((m = u.match(/^(https?:\/\/(?:[a-z]+\.)?(?:7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\/(?:[^/?#]*-)?\d{3,})\/?(?:[?#]|$)/i))) return { k: 'sdUrl', v: m[1] }; return null; } const rgProvidersEnabled = () => !!store.get('rg_providers', false); // #314: by default respect Platform Check's link confidence — don't import from a // PC link it withheld by a barcode/format mismatch (it can be a wrong release). // Opt in to the old #211 behaviour (use it anyway) with this toggle. const ignorePcConfidence = () => !!store.get('ignore_pc_confidence', false); // #603: which service turns a Spotify album link into ISRCs — molla (default, // JSON) or ISRC Hunt (the original, HTML scrape; down since #596). const spotifyIsrcSource = () => store.get('spotify_isrc_source', 'molla') === 'isrchunt' ? 'isrchunt' : 'molla'; // #302: releases in a release group are often split by platform (one has Deezer, // another Spotify, …). ISRC and track-link edits target recordings, which are // shared across the RG — so, when the option is on, fill any provider link the // current release lacks from its sibling releases (one browse, fill-if-empty). async function augmentProvidersFromRG(rgId) { if (!rgId) return; try { const r = await gmGet(MB_WS2 + 'release?release-group=' + rgId + '&inc=url-rels&limit=100&fmt=json', { 'Accept': 'application/json', 'User-Agent': UA }); if (r.status !== 200) { Log.warn('Release group: sibling scan gave ' + r.status); return; } const j = JSON.parse(r.responseText || '{}'); const added = []; RELEASE.rgFrom = RELEASE.rgFrom || {}; // provider field -> { release, title } it was pulled from (#302) (j.releases || []).forEach(rel => { if (rel.id === mbid) return; (rel.relations || []).forEach(rl => { const u = rl.url && rl.url.resource; if (!u) return; const hit = matchProviderLink(u); if (hit && !RELEASE[hit.k]) { RELEASE[hit.k] = hit.v; RELEASE.rgFrom[hit.k] = { release: rel.id, title: rel.title || '' }; added.push(hit.k.replace(/Id|Url$/, '')); } }); }); if (added.length) Log.info('Release group: pulled provider links from sibling releases — ' + [...new Set(added)].join(', ')); else Log.info('Release group: no extra provider links on sibling releases'); } catch (e) { Log.warn('Release group provider scan failed: ' + errText(e)); } } /* #587 (majkinetor: "Dedup it."). The overview page fetches the release once on load to prime the button's missing-ISRC count, and opening the panel fetched it AGAIN — `if (!RELEASE)` is still true while the first one is in flight, so a click during that window started a second identical request. Two calls for the same data on every open. Resolved, not re-requested: an already-loaded release is handed back as-is, and a call made while one is in flight joins it. The in-flight handle is cleared on settle so a FAILED load can still be retried. */ let _relInFlight = null; function fetchRelease() { if (RELEASE) return Promise.resolve(RELEASE); if (_relInFlight) return _relInFlight; _relInFlight = fetchReleaseNow(); _relInFlight.catch(() => {}).then(() => { _relInFlight = null; }); // #607: the page button's count follows EVERY successful load, not just the // page-load one. That one can fail (MB 503s under its rate limit) and leave // "?" — and opening the dialog later loaded the release without telling it. _relInFlight.then(() => { try { updateBtnStatus(); } catch (e) {} }, () => {}); return _relInFlight; } function fetchReleaseNow() { return gmGet( // recording-level-rels folds each recording's URL relationships into this one // call (same data the overview's "Display credits inline" shows) — so the // #219 track-link icons know what's already linked with no extra request. MB_WS2 + 'release/' + mbid + '?inc=recordings+artist-credits+isrcs+url-rels+recording-level-rels+release-groups&fmt=json', { 'Accept': 'application/json', 'User-Agent': UA } ).then(async r => { if (r.status !== 200) throw new Error('MB ' + r.status); const data = JSON.parse(r.responseText); const tracks = []; (data.media || []).forEach(med => { (med.tracks || []).forEach(trk => { const rec = trk.recording || {}; tracks.push({ recId: rec.id || '', trackId: trk.id || '', // #749: the tracklist row's id on the release page title: trk.title || rec.title || '', artist: acName(trk['artist-credit'] || rec['artist-credit']), dur: msToMmSs(trk.length || rec.length) || '', mediumPos: med.position, mediumTitle: med.title || '', trackPos: trk.position, number: trk.number, existing: (rec.isrcs || []).slice(), recUrls: (rec.relations || []).map(rel => rel.url && rel.url.resource).filter(Boolean), // #219: existing url rels on the recording endedUrls: new Set((rec.relations || []).filter(rel => rel.url && rel.url.resource && rel.ended).map(rel => rel.url.resource)), // #341: url rels already marked ended → render faded pending: '', }); }); }); const rels = data.relations || []; const prov = { deezerId: null, spotifyId: null, beatportId: null, tidalId: null, volumoId: null, hdtracksId: null, qobuzId: null, bandcampUrl: null, appleUrl: null, scUrl: null, ytmUrl: null, amzUrl: null }; rels.forEach(rel => { const u = rel.url && rel.url.resource; if (!u) return; const hit = matchProviderLink(u); if (hit && !prov[hit.k]) prov[hit.k] = hit.v; // first match per provider }); // THIS release's year — what the header shows AND what the SX "recording newer // than the release" check uses. Prefer the release's own date; only fall back to // the release-group's first-release-date when this release has no date. (Using // the RG-earliest here was wrong for reissues: a 2025 reissue of 2002 material // would reject legitimate later recordings.) const rg = data['release-group'] || {}; const rgYear = parseInt((String(rg['first-release-date'] || '').match(/^(\d{4})/) || [])[1]) || null; const releaseYear = parseInt((String(data.date || '').match(/^(\d{4})/) || [])[1]) || rgYear; const artist = acName(data['artist-credit']); RELEASE = Object.assign({ title: data.title || '', tracks, rgId: rg.id || '', releaseYear, artist }, prov); // #302: when enabled, fill missing provider links from sibling releases in the RG. if (rgProvidersEnabled() && RELEASE.rgId) await augmentProvidersFromRG(RELEASE.rgId); const _pf = { bandcamp: 'bandcampUrl', apple: 'appleUrl', soundcloud: 'scUrl', audiomack: 'akUrl', sevendigital: 'sdUrl' }; const linkStr = ['deezer', 'spotify', 'beatport', 'tidal', 'volumo', 'hdtracks', 'bandcamp', 'apple', 'soundcloud', 'audiomack', 'sevendigital'] .map(k => { const f = _pf[k] || k + 'Id'; return RELEASE[f] ? k[0].toUpperCase() + k.slice(1) + ' ' + RELEASE[f] : null; }).filter(Boolean).join(', '); Log.info('Release "' + RELEASE.title + '"' + (releaseYear ? ' (' + releaseYear + ')' : '') + ': ' + tracks.length + ' track(s), ' + tracks.filter(t => !t.existing.length).length + ' missing ISRC' + (linkStr ? '; links: ' + linkStr : '')); return RELEASE; }); } function acName(ac) { if (!Array.isArray(ac)) return ''; return ac.map(c => (c.name || (c.artist && c.artist.name) || '') + (c.joinphrase || '')).join(''); } /* #587 (majkinetor): "Revert it. If there is no way to get pending status while getting ISRC, we wont do any more requests to MB and will not have this feature. … Remembering is not good idea as this is colaborative space." Both halves are gone, and neither comes back without his say-so: · the ⏳ used to be persisted in localStorage and replayed on later loads. It was a LOCAL belief about SERVER state, and this is a shared database — he cancels the edit, or another editor votes it down, and the marker is silently wrong for good. That is exactly what he hit. · verifying it against /recording/<gid>/open_edits fixed the staleness by spending one request per remembered recording. Not my call to make: ANY new request to MusicBrainz is his decision. WS2 exposes no pending-edit field and no inc= that adds one — an entity's open edits live only on MB's HTML pages — so the release fetch already being made cannot carry it, and by his instruction the feature goes rather than the request count. What survives is only what we know without asking anyone: within the session that submitted them, those removals are marked. A reload forgets, because by then only MusicBrainz knows. */ /* ═══════════════════════════════════════════════════════════════════════ OAUTH (one-time authorize, offline refresh token) ═══════════════════════════════════════════════════════════════════════ */ const Auth = { // baked-in shared app, with an optional GM-storage override for power users clientId() { return store.get('oauth_client_id' + OAUTH_SITE, '') || OAUTH.clientId; }, clientSecret() { return store.get('oauth_client_secret' + OAUTH_SITE, '') || OAUTH.clientSecret; }, refreshTok() { return store.get('oauth_refresh_token' + OAUTH_SITE, ''); }, isAuthorized() { return !!this.refreshTok(); }, authorizeUrl() { const p = new URLSearchParams({ response_type: 'code', client_id: this.clientId(), redirect_uri: OAUTH.redirect, scope: OAUTH.scope, access_type: 'offline', }); return OAUTH.authUrl + '?' + p.toString(); }, async exchangeCode(code) { const body = new URLSearchParams({ grant_type: 'authorization_code', code: code.trim(), client_id: this.clientId(), client_secret: this.clientSecret(), redirect_uri: OAUTH.redirect, }).toString(); const r = await gmPost(OAUTH.tokenUrl, body, { 'Content-Type': 'application/x-www-form-urlencoded' }); const j = JSON.parse(r.responseText || '{}'); if (!j.refresh_token) throw new Error(j.error_description || j.error || ('token exchange failed (' + r.status + ')')); store.set('oauth_refresh_token' + OAUTH_SITE, j.refresh_token); store.set('oauth_access_token' + OAUTH_SITE, j.access_token || ''); store.set('oauth_access_expiry' + OAUTH_SITE, Date.now() + ((j.expires_in || 3600) * 1000)); }, async accessToken() { const tok = store.get('oauth_access_token' + OAUTH_SITE, ''); const exp = store.get('oauth_access_expiry' + OAUTH_SITE, 0); if (tok && Date.now() < exp - 60000) return tok; const refresh = this.refreshTok(); if (!refresh) throw new Error('not authorized — open ⚙ Setup'); const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refresh, client_id: this.clientId(), client_secret: this.clientSecret(), }).toString(); const r = await gmPost(OAUTH.tokenUrl, body, { 'Content-Type': 'application/x-www-form-urlencoded' }); let j = {}; try { j = JSON.parse(r.responseText || '{}'); } catch (e) {} // The server no longer knows the app or the grant (revoked, or test's OAuth // data reset): the stored token is dead, so forget it rather than keep // showing "Authorized" while every submit fails (#683). if (j.error === 'invalid_client' || j.error === 'invalid_grant') { this.signOut(); const err = new Error('MusicBrainz rejected the stored authorization (' + (j.error_description || j.error) + ') — click Authorize again in ⚙ Setup'); err.authCleared = true; throw err; } if (!j.access_token) throw new Error(j.error_description || j.error || ('token refresh failed (' + r.status + ')')); store.set('oauth_access_token' + OAUTH_SITE, j.access_token); store.set('oauth_access_expiry' + OAUTH_SITE, Date.now() + ((j.expires_in || 3600) * 1000)); return j.access_token; }, signOut() { ['oauth_refresh_token', 'oauth_access_token', 'oauth_access_expiry'].forEach(k => store.del(k + OAUTH_SITE)); }, }; /* ═══════════════════════════════════════════════════════════════════════ WS2 ISRC SUBMISSION ═══════════════════════════════════════════════════════════════════════ */ function buildIsrcXml(map, editNote) { let x = '<?xml version="1.0" encoding="UTF-8"?>\n' + '<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">\n'; if (editNote) x += ' <edit-note>' + esc(editNote) + '</edit-note>\n'; x += '<recording-list>\n'; for (const [rid, isrcs] of Object.entries(map)) { x += ' <recording id="' + rid + '"><isrc-list>'; isrcs.forEach(i => { x += '<isrc id="' + i + '"/>'; }); x += '</isrc-list></recording>\n'; } x += '</recording-list>\n</metadata>'; return x; } // #748: a submit that fails on a busy or unreachable server (429, 5xx, a dropped connection) is repeated by itself, as // Art Station repeats a failed upload: up to the settings' minutes or attempts, whichever comes first, the attempts // spread over the minutes (10 s apart at least). Each wait is logged, shown, and told to whoever listens (repeatWatch: // Mission Control's apply), every 5 s while it lasts. A refused submit (400, 401) is not repeated. const repeatOn = () => store.get('auto_repeat', true) !== false; const repeatNum = (k, d, max) => { const n = parseInt(store.get(k, d), 10); return n >= 1 ? Math.min(n, max) : d; }; const repeatMinutes = () => repeatNum('auto_repeat_min', 10, 240); const repeatTimes = () => repeatNum('auto_repeat_times', 10, 100); const isTransient = e => /\((?:429|50[0234])\)|\bHTTP (?:429|50[0234])\b|network|timed? ?out|busy/i.test((e && e.message) || String(e)); let repeatWatch = null; async function withRepeat(fn, what) { const t0 = Date.now(); for (let n = 0; ; ) { try { return await fn(); } catch (e) { const budget = repeatMinutes() * 60000, max = repeatTimes(), used = Date.now() - t0; e.transient = isTransient(e); if (!repeatOn() || !e.transient || n >= max || used >= budget) { e.repeated = n; throw e; } n++; const delay = Math.max(1000, Math.min(Math.max(budget / max, 10000), budget - used)); Log.warn(what + ' failed (' + e.message + '): repeating in ' + Math.round(delay / 1000) + ' s, attempt ' + n + ' of ' + max); try { toast(what + ' failed: repeating in ' + Math.round(delay / 1000) + ' s (attempt ' + n + ' of ' + max + ')', 'warn'); } catch (x) { /* no page UI */ } const until = Date.now() + delay; while (Date.now() < until) { if (repeatWatch) repeatWatch(what + ' failed: repeating in ' + Math.ceil((until - Date.now()) / 1000) + ' s (attempt ' + n + ' of ' + max + ')'); await new Promise(r => setTimeout(r, Math.min(5000, until - Date.now()))); } } } } function submitIsrcs(map, editNote) { return withRepeat(() => submitIsrcsOnce(map, editNote), 'The ISRC submit'); } async function submitIsrcsOnce(map, editNote) { const token = await Auth.accessToken(); const xml = buildIsrcXml(map, editNote); const url = MB_WS2 + 'recording/?client=' + CLIENT; const r = await gmPost(url, xml, { 'Content-Type': 'application/xml; charset=utf-8', 'Authorization': 'Bearer ' + token, 'Accept': 'application/xml', }); if (r.status === 200) return; throw new Error('submit failed (' + r.status + '): ' + (r.responseText || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 220)); } /* ═══════════════════════════════════════════════════════════════════════ SOUNDEXCHANGE (ported from magicisrc_soundexchange, DOM-independent core) ═══════════════════════════════════════════════════════════════════════ */ const SX = (function () { let _token = 'ff5284e764c4a90c1a2c2940f6a9aa593c63b8e8'; let _tokenFetch = null; function extractToken(text) { const pats = [/[Tt]oken ([a-f0-9]{40})/, /["'](Token [a-f0-9]{40})["']/, /([a-f0-9]{40})/]; for (const p of pats) { const m = text.match(p); if (m) { const h = (m[1] || m[0]).match(/[a-f0-9]{40}/); if (h) return h[0]; } } return null; } function refreshToken() { if (_tokenFetch) return _tokenFetch; _tokenFetch = gmGet(SX_HOME, { 'Accept': 'text/html', 'User-Agent': 'Mozilla/5.0' }) .then(r1 => { _tokenFetch = null; if (r1.status !== 200) throw new Error('SX home ' + r1.status); const inline = extractToken(r1.responseText); if (inline) { _token = inline; return _token; } const urls = []; const re = /<script[^>]+src=["']([^"']+\.js[^"']*)["']/gi; let m; while ((m = re.exec(r1.responseText)) !== null) { urls.push(m[1].startsWith('http') ? m[1] : SX_HOME.replace(/\/$/, '') + m[1]); } urls.sort((a, b) => (/entry|main/i.test(b) ? 1 : 0) - (/entry|main/i.test(a) ? 1 : 0)); return (async () => { for (const url of urls.slice(0, 15)) { try { const r2 = await gmGet(url, { 'Accept': '*/*', 'Referer': SX_HOME }); if (r2.status !== 200) continue; const tok = extractToken(r2.responseText); if (tok) { _token = tok; return _token; } } catch (e) {} } throw new Error('SX token not found'); })(); }).catch(e => { _tokenFetch = null; throw e; }); return _tokenFetch; } function fields(item) { return { isrc: item.isrc || '', title: item.recordingTitle || '', artist: item.recordingArtistName || '', version: item.recordingVersion || '', year: item.recordingYear || '', dur: item.duration || '', relTitle: item.releaseName || '', relLabel: item.releaseLabel || '', relDate: (item.releaseDate || '').slice(0, 7), }; } // #486: MB often encodes the edit/version in a trailing "(...)" on the title (e.g. // "Sing (radio edit)"), but SoundExchange keeps that in a separate `version` field // and returns the SAME bare title ("Sing") for every edit — so title/artist alone // (and often duration too — that's the whole reason this is hard) can't tell two // edits of the same recording apart, and the first-ranked "best" result silently // won for every edit that shares a title. Only downgrades when SX actually GIVES us // version text that clearly names a different edit — missing/messy SX version data // (common per chaban) is left alone rather than penalized on absent information. function versionHint(mbTitle) { const m = unfeat(mbTitle).match(/\(([^)]+)\)\s*$/); return m ? norm(m[1]) : ''; } function versionConflicts(f, mbTitle) { const hint = versionHint(mbTitle); if (!hint || !f.version) return false; const v = norm(f.version); return !(v.includes(hint) || hint.includes(v) || wordsMatch(hint, v) || wordsMatch(v, hint)); } function classify(f, mbTitle, mbArtist, mbDurStr, mbYear) { if (!isGoodMatch(f.title, f.artist, mbTitle, mbArtist)) return 'other'; // a recording released after MB's release year (with 1y tolerance) can't be // the source of this release's ISRC — treat as a non-match if (mbYear && f.year && parseInt(f.year) > mbYear + 1) return 'other'; if (versionConflicts(f, mbTitle)) return 'warn'; const a = durToSec(mbDurStr), b = durToSec(f.dur); if (a !== null && b !== null && Math.abs(a - b) > 10) return 'warn'; // #661: the same song with a typo in a title (one or the other) agrees, but never as a sure // match: nothing is picked on a typo alone if (titleClose(f.title, mbTitle, true) !== true) return 'warn'; return 'best'; } const applyExact = (v, exact) => (v && exact) ? '"' + String(v).replace(/"/g, '') + '"' : (v || ''); function dedupe(raw) { const seen = new Map(); raw.forEach(item => { const key = item.isrc || item.id; if (!seen.has(key)) seen.set(key, Object.assign({}, item, { _rels: [] })); if (item.releaseName) seen.get(key)._rels.push(item); }); const rows = [...seen.values()]; rows.forEach(it => { if (it._rels.length > 1) it._rels.sort((a, b) => (a.releaseDate || '9999').localeCompare(b.releaseDate || '9999')); const e = it._rels[0]; if (e) { it.releaseName = e.releaseName; it.releaseLabel = e.releaseLabel; it.releaseDate = e.releaseDate; } }); return rows; } function post(body) { const doReq = (token) => gmPost(SX_API, body, { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Token ' + token, 'Origin': SX_HOME, 'Referer': SX_HOME, }).then(r => { if (r.status === 0) throw new Error('blocked (SX returned 0 — connection refused?)'); if (r.status === 401 || r.status === 403) { Log.warn('SX ' + r.status + ' — refreshing token'); return refreshToken().then(t => doReq(t)); } // 429 = rate limited. The body isn't JSON, so without this it fell through to the // generic "SX parse error" below — masking the real cause. Surface a typed error so // callers can stop the batch and show the right message. #126 if (r.status === 429) { const e = new Error('SoundExchange rate limit (HTTP 429)'); e.rateLimited = true; throw e; } let p; try { p = JSON.parse(r.responseText); } catch (e) { throw new Error('SX parse error'); } // After too many requests SoundExchange serves a captcha: HTTP 202 with // body {"searchCaptcha": true}. It's NOT an empty result — caching it as // "not found" left rows permanently stuck. Surface a typed error so // callers pause (like a rate limit) and prompt the user to solve it. #157 if (p && p.searchCaptcha) { const e = new Error('SoundExchange captcha'); e.captcha = true; throw e; } return dedupe(p.recordings || p.results || p.data || (Array.isArray(p) ? p : [])); }); return doReq(_token); } // exact = { title, artist, release }; opts release string optional function apiSearch(title, artist, start, count, exact, release) { exact = exact || {}; return post(JSON.stringify({ searchFields: { recordingArtistName: { value: applyExact(artist, exact.artist) }, recordingTitle: { value: applyExact(title, exact.title) }, releaseName: { value: applyExact(release || '', exact.release) }, }, start: start || 0, number: count || 20, showReleases: true, })); } function apiSearchByIsrc(isrc) { return post(JSON.stringify({ searchFields: { isrc: isrc }, start: 0, number: 10, showReleases: true })); } return { refreshToken, apiSearch, apiSearchByIsrc, fields, classify }; })(); if (mbuTestHooks()) window.__isrcScoutTestSX = SX; // test hook only (#486) — no behaviour change /* ═══════════════════════════════════════════════════════════════════════ TRACK ISRC PROVIDER (#181) — each per-track [SX] button is a by-ISRC lookup: it takes the row's ISRC (entered or existing) and looks it up on the selected provider, showing that track's metadata next to the row. It ONLY searches — it never fills the field. The choice is GLOBAL for the release and NOT remembered (resets to SoundExchange on each load). The ▾ on each button opens the provider menu; the bulk "⟳ SoundExchange" button is left untouched. • SoundExchange + Deezer + Tidal: global by-ISRC endpoints — offered on any release (no album link needed). • Beatport / Volumo / HDtracks: read the release's album ONCE (cached) and match by ISRC, so they need the album's link (MB rel or Platform Check). ═══════════════════════════════════════════════════════════════════════ */ // Album-based providers — same fetchers the import buttons use. `idField` is the // RELEASE property holding the in-MB album id; availability also honours a // Platform-Check-found URL (via providerAlbumId). // Spotify is intentionally absent: its only by-ISRC route is the Web API // `isrc:` search, which needs an app token (no free anonymous one), so it can't // be a per-track ISRC provider. (Spotify stays a bulk import button via molla / ISRC Hunt.) const ALBUM_PROVIDERS = { deezer: { source: 'Deezer', idField: 'deezerId', fetcher: fetchDeezer, code: 'dz' }, beatport: { source: 'Beatport', idField: 'beatportId', fetcher: fetchBeatport, code: 'bp' }, tidal: { source: 'Tidal', idField: 'tidalId', fetcher: fetchTidal, code: 'td' }, volumo: { source: 'Volumo', idField: 'volumoId', fetcher: fetchVolumo, code: 'vo' }, hdtracks: { source: 'HDtracks', idField: 'hdtracksId', fetcher: fetchHDtracks, code: 'hd' }, qobuz: { source: 'Qobuz', idField: 'qobuzId', fetcher: fetchQobuz, code: 'qz' }, apple: { source: 'Apple', idField: 'appleUrl', fetcher: fetchApple, code: 'am' }, // #435 anonymous amp-api soundcloud:{ source: 'SoundCloud', idField: 'scUrl', fetcher: fetchSoundcloud, code: 'sc' }, // #439 set → publisher_metadata.isrc audiomack: { source: 'Audiomack', idField: 'akUrl', fetcher: fetchAudiomack, code: 'ak' }, // #664 signed API, isrc per track sevendigital: { source: '7digital', idField: 'sdUrl', fetcher: fetchSevendigital, code: 'sd' }, // #669 track/search per track, isrc per hit }; const _PROV_COLOR = { sx: stColor('soundexchange'), deezer: stColor('deezer'), spotify: stColor('spotify'), beatport: stColor('beatport'), tidal: stColor('tidal'), volumo: stColor('volumo'), hdtracks: stColor('hdtracks'), qobuz: stColor('qobuz'), bandcamp: stColor('bandcamp'), apple: stColor('apple'), soundcloud: stColor('soundcloud'), audiomack: stColor('audiomack'), sevendigital: stColor('sevendigital'), ytmusic: stColor('ytmusic'), amazonmusic: stColor('amazonmusic') }; // #404: colours from the shared registry const TRACK_PROV = { sx: { name: 'SoundExchange', short: 'SX', code: 'sx', color: _PROV_COLOR.sx, kind: 'search' } }; Object.keys(ALBUM_PROVIDERS).forEach(k => { const p = ALBUM_PROVIDERS[k]; // `global` providers have a by-ISRC endpoint that doesn't need the album link // (Deezer: /track/isrc:<isrc>; Tidal: /tracks?filter[isrc]), so they're offered // on any release. The rest scan the release's album, so they need its link. TRACK_PROV[k] = { name: p.source, short: p.source, code: p.code, color: _PROV_COLOR[k] || '#444', kind: 'album', global: k === 'deezer' || k === 'tidal' }; }); // #643: 'all' — every provider at once. Not SoundExchange: it serves a captcha on nearly every // run, so majkinetor took it out ("I was wrong about SX, it constantly does captcha"). TRACK_PROV.all = { name: 'All', short: 'All', code: 'all', color: '', kind: 'all' }; const TRACK_PROV_ORDER = ['all', 'sx', 'deezer', 'tidal', 'beatport', 'volumo', 'hdtracks', 'qobuz', 'apple', 'soundcloud', 'audiomack', 'sevendigital']; let trackProv = 'sx'; // NOT persisted (#181) const TPM = () => TRACK_PROV[trackProv]; // a provider is offered only when it can resolve a source for THIS release // (SoundExchange always; an album provider needs an MB link or a PC-found URL). function trackProvAvailable(key) { if (key === 'sx' || key === 'all') return true; if (TRACK_PROV[key] && TRACK_PROV[key].global) return true; // global by-ISRC — no link needed const p = ALBUM_PROVIDERS[key]; return !!(p && RELEASE && providerAlbumId(p.source, RELEASE[p.idField])); } // The per-track button looks up an ISRC, so it needs one: enabled when the row // has a valid entered ISRC or an existing one (uniform across all providers). function trackBtnDisabled(t, inputVal) { return !(isValidIsrc(normalizeIsrc(inputVal)) || (t && t.existing && t.existing.length)); } // Fetch the selected album provider's whole tracklist ONCE (all batches), keyed // per provider for the session. Returns the collected per-track ISRC entries. const _provAlbumEntries = {}; const _provAlbumPromise = {}; function ensureProvAlbum(key) { if (_provAlbumEntries[key]) return Promise.resolve(_provAlbumEntries[key]); if (_provAlbumPromise[key]) return _provAlbumPromise[key]; const p = ALBUM_PROVIDERS[key]; const id = providerAlbumId(p.source, RELEASE[p.idField]); if (!id) return Promise.reject(new Error('no ' + p.source + ' link on this release')); const entries = []; _provAlbumPromise[key] = (async () => { let cursor = 0, guard = 0; while (guard++ < 50) { const res = await p.fetcher(id, () => {}, s => { if (s && s.isrc) entries.push(s); }, cursor); if (!res || res.next == null) break; cursor = res.next; } _provAlbumEntries[key] = entries; delete _provAlbumPromise[key]; return entries; })().catch(e => { delete _provAlbumPromise[key]; throw e; }); return _provAlbumPromise[key]; } // Single-track by-ISRC lookup on a provider → a fields-shaped object // {isrc,title,artist,year,dur,relTitle} carrying all the meta the provider // exposes, or null. Deezer has a global by-ISRC endpoint (no album needed); the // album-only providers scan the release's album for a track whose ISRC matches // (still keyed off the ISRC, never the position). SoundExchange uses lookupIsrc. async function providerLookupByIsrc(key, isrc) { if (key === 'deezer') { const r = await gmGet('https://api.deezer.com/track/isrc:' + encodeURIComponent(isrc), { 'Accept': 'application/json' }); if (r.status !== 200) return null; let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { return null; } if (!j || j.error || !normalizeIsrc(j.isrc || '')) return null; const arts = [(j.artist && j.artist.name)].concat((j.contributors || []).map(c => c && c.name)).filter(Boolean); return { isrc: normalizeIsrc(j.isrc), title: j.title_short || j.title || '', artist: [...new Set(arts)].join(', '), year: '', dur: j.duration ? msToMmSs(j.duration * 1000) : '', relTitle: (j.album && j.album.title) || '' }; } if (key === 'tidal') return await tidalLookupByIsrc(isrc); // album-scoped providers (HDtracks / Volumo / Beatport): // fetch the release's album once and match the entry by ISRC. const entries = await ensureProvAlbum(key); const e = entries.find(s => normalizeIsrc(s.isrc) === isrc); return e ? { isrc, title: e.title || '', artist: e.artist || '', year: '', dur: e.dur || '', relTitle: '' } : null; } // Per-track ISRC lookup on the selected album provider — shows the provider's // meta for the clicked track's ISRC, rendered like a SoundExchange result. It // looks up ONLY that ISRC and NEVER fills the field. async function lookupRowOnProvider(idx, isrc) { const t = RELEASE.tracks[idx], m = TPM(), el = rowLookup(idx); if (el) { el.onclick = null; el.className = 'ii-lookup spin'; el.textContent = '⏳ ' + m.name + '…'; } try { const f = await providerLookupByIsrc(trackProv, isrc); if (!f) { if (el) { el.className = 'ii-lookup err'; el.textContent = '✗ ' + isrc + ' not found on ' + m.name; } Log.info(m.name + ' ' + isrc + ' (#' + (t.number || t.trackPos) + '): not found'); return; } const cls = SX.classify(f, t.title, t.artist, t.dur, RELEASE.releaseYear); const good = cls === 'best'; const rel = [f.relTitle, f.relLabel, f.relDate].filter(Boolean).join(' · '); if (el) { el.className = 'ii-lookup ' + (good ? 'ok' : 'warn'); el.innerHTML = (good ? '✓ ' : '⚠ ') + sxMetaHtml(f, t) + (rel ? '<br><span class="ii-lookup-rel">' + esc(rel) + '</span>' : ''); el.title = [f.title, f.artist, f.year, f.dur].filter(Boolean).join(' · ') + (rel ? ' | ' + rel : ''); } Log.info(m.name + ' ' + isrc + ' (#' + (t.number || t.trackPos) + '): "' + f.title + '"' + (f.artist ? ' — ' + f.artist : '')); } catch (err) { if (err && err.rateLimited) { // 429 that didn't clear after back-off — NOT "not found" if (el) { el.className = 'ii-lookup warn'; el.textContent = '⚠ ' + m.name + ' rate-limited — retry'; } Log.warn(m.name + ' ' + isrc + ' (#' + (t.number || t.trackPos) + '): rate-limited (HTTP 429)'); } else { if (el) { el.className = 'ii-lookup err'; el.textContent = '✗ ' + m.name + ' failed'; } Log.err(m.name + ' lookup failed: ' + errText(err)); } } } // #643: "All" — the row's ISRC checked on every provider available for the release at once // (SoundExchange is not one: it serves captchas), summed up in a verdict chip in the row's result slot. Hovering the chip // (or the row's All button) shows the comparison beside the chip; a click pins it. // What each provider can say about an ISRC: // · Deezer, Tidal (a lookup by ISRC): ✓ their song for it is this track · // ⚠ it is another song (title, artist or length differ; guests don't count) · – they don't know it // · the album providers (the release's album on them, read once): ✓ the album has this ISRC at // the track's place (📍) or, with nothing there, elsewhere (↪ track n) · ✗ it has ANOTHER ISRC // at the track's place — offered with [use] · – nothing there const _allRes = {}; // idx → { isrc, lines: [{ key, name, state, isrc?, at?, song?, note? }] } let _allEpoch = 0; // bumped by closing the dialog / Clear: a running All stops const ALL_OK = 'ok', ALL_SONG = 'song', ALL_DIFF = 'differs', ALL_NONE = 'none', ALL_FAIL = 'fail', ALL_WAIT = 'pending', ALL_BLOCK = 'blocked'; const ALL_MARK = { ok: '✓', song: '⚠', differs: '✗', none: '–', fail: '!', pending: '⏳', blocked: '⛔' }; function allProviders() { return TRACK_PROV_ORDER.filter(k => k !== 'all' && k !== 'sx' && trackProvAvailable(k)); } // the album entry at this track's place: disc + position, as an album import maps it (mapOneToTrack) function albumEntryAt(entries, t) { const discs = new Set(RELEASE.tracks.map(x => +x.mediumPos)); return entries.find(s => +s.pos === +t.trackPos && (+s.disc === +t.mediumPos || !discs.has(+s.disc))) || null; } // the number of another track of this release that has the ISRC (on MB, or entered), or '' function isrcOfOtherTrack(idx, isrc) { const i = RELEASE.tracks.findIndex((x, j) => j !== idx && ((x.existing || []).includes(isrc) || normalizeIsrc(x.pending || '') === isrc)); return i < 0 ? '' : String(RELEASE.tracks[i].number || RELEASE.tracks[i].trackPos); } // an album entry's title reads as this track's (the artist isn't compared: album entries carry none) const albumTitleFits = (x, t) => !!x && SX.classify({ title: x.title || '', artist: t.artist, dur: x.dur || '' }, t.title, t.artist, t.dur) !== 'other'; // asked: every ISRC All asks about on this row (an album entry with one of them isn't "another ISRC") async function allOnProvider(key, t, isrc, idx, asked) { const name = TRACK_PROV[key].name; try { if (TRACK_PROV[key].global) { const f = await providerLookupByIsrc(key, isrc); if (!f) return { key, name, of: isrc, state: ALL_NONE, note: 'doesn’t know ' + isrc }; return Object.assign(allSongLine(key, name, f, t, isrc), { of: isrc }); } // The album has the ISRC → it agrees, at this track's position (📍) or another (↪ track n: // the album orders its tracks differently). Only when the ISRC is nowhere on the album is // the ISRC at this track's position offered instead (✗ + use), and never when that one is // another track's of this release or this row's own (#643: a Qobuz album with the mixes in // another order offered track 4's ISRC for track 2). const entries = await ensureProvAlbum(key); const e = albumEntryAt(entries, t), eIsrc = e ? normalizeIsrc(e.isrc) : ''; const other = entries.find(x => normalizeIsrc(x.isrc) === isrc); const where = x => 'track ' + (x.disc && RELEASE.tracks.some(y => +y.mediumPos !== 1) ? x.disc + '.' : '') + x.pos; const song = x => ({ title: x.title || '', artist: '', dur: x.dur || '' }); const line = { key, name, of: isrc }; if (eIsrc === isrc) return Object.assign(line, { state: ALL_OK, isrc, at: 'here', song: song(e) }); // the ISRC elsewhere, and another at this position: by title, which one is this track // (when neither or both read as it, the album having the ISRC wins) if (other && !(eIsrc && albumTitleFits(e, t) && !albumTitleFits(other, t))) return Object.assign(line, { state: ALL_OK, isrc, at: where(other), song: song(other), note: eIsrc ? '' : 'nothing at this track’s place' }); if (eIsrc && !asked.includes(eIsrc)) { const owner = isrcOfOtherTrack(idx, eIsrc); if (owner) return Object.assign(line, { state: ALL_NONE, note: (other ? isrc + ' is its ' + where(other) + '; ' : 'not on the album; ') + 'at this position it has track ' + owner + '’s ISRC (' + eIsrc + ')' }); return Object.assign(line, { state: ALL_DIFF, isrc: eIsrc, at: 'here', song: song(e), note: other ? isrc + ' is its ' + where(other) : '' }); } // left: at this position the album has nothing or this row's other ISRC if (other) return Object.assign(line, { state: ALL_SONG, isrc, at: where(other), song: song(other), note: 'another track' + (eIsrc ? '; at this position it has ' + eIsrc + ', this row’s other ISRC' : '') }); return Object.assign(line, { state: ALL_NONE, note: 'not on the album' + (eIsrc ? ' (at this position: ' + eIsrc + ', this row’s other ISRC)' : '') }); } catch (err) { return { key, name, of: isrc, state: ALL_FAIL, note: err && err.rateLimited ? 'rate-limited — try again' : 'failed: ' + errText(err) }; } } // A provider's song for the ISRC, against the track: the same song (classify 'best', or 'warn' — // the version or length reads differently, which is noted) agrees; another song ('other') doesn't. // Guests are left out of the comparison, as in every lookup (unfeat, mainArtist). function allSongLine(key, name, f, t, isrc) { const cls = SX.classify(f, t.title, t.artist, t.dur, RELEASE.releaseYear); const song = { title: f.title || '', artist: cls === 'other' ? f.artist || '' : '', dur: f.dur || '' }; if (cls === 'other') return { key, name, state: ALL_SONG, isrc, song, note: 'another song' }; const typo = cls === 'warn' && titleClose(f.title, t.title, true) !== true; // #661 return { key, name, state: ALL_OK, isrc, song, note: typo ? 'its title is spelled differently' : cls === 'warn' ? 'its version or length reads differently' : '' }; } // Where an album provider has the ISRC, said plainly (the album providers read the release's album // there, so they can say at which position it sits; Deezer/Tidal/SoundExchange can't) function allAtText(l) { if (l.at === 'here') return l.state === ALL_DIFF ? 'the album’s ISRC at this position' : 'same position on the album'; return l.at ? 'on the album at ' + l.at + ', not this position' : ''; } // a line as text (the log) and as the panel shows it function allLineText(l, isrc) { return [allAtText(l), l.isrc && l.isrc !== isrc ? l.isrc : '', l.song && l.song.title ? '"' + l.song.title + '"' + (l.song.artist ? ' — ' + l.song.artist : '') + (l.song.dur ? ' ' + l.song.dur : '') : '', l.note || ''].filter(Boolean).join(' · '); } function allLineHtml(l, isrc) { const p = []; if (l.at === 'here') p.push('<span class="ii-all-at" title="' + esc(allAtText(l)) + '">📍</span>'); else if (l.at) p.push('<span class="ii-all-at" title="' + esc(allAtText(l)) + '">↪ ' + esc(l.at) + '</span>'); if (l.isrc && l.isrc !== isrc) p.push('<span class="ii-all-isrc">' + esc(l.isrc) + '</span>'); if (l.song && l.song.title) p.push(esc('"' + l.song.title + '"' + (l.song.artist ? ' — ' + l.song.artist : ''))); // the length has its own column if (l.note) p.push('<span class="ii-all-sub">' + esc(l.note) + '</span>'); return p.join(' '); } // #643: the ISRCs All asks about on a row: the given one (entered, or the first existing) and // every other ISRC the recording already has function allRowIsrcs(idx, isrc) { const t = RELEASE.tracks[idx]; return [...new Set([isrc].concat(t.existing || []).map(normalizeIsrc).filter(isValidIsrc))]; } // One track: every provider at once. const _allInflight = {}; function allLookupRow(idx, isrc, opts) { const k = idx + ' ' + isrc; if (!_allInflight[k]) _allInflight[k] = allLookupRowNow(idx, isrc, opts).finally(() => { delete _allInflight[k]; }); return _allInflight[k]; } async function allLookupRowNow(idx, isrc) { const t = RELEASE.tracks[idx], keys = allProviders(), asked = allRowIsrcs(idx, isrc), num = t.number || t.trackPos; // res.isrc: the row's ISRC (entered, or the first existing), shown first; res.asked: every one asked about const res = _allRes[idx] = { isrc, asked, lines: [].concat(...asked.map(i => keys.map(k => ({ key: k, name: TRACK_PROV[k].name, of: i, state: ALL_WAIT, note: 'asking…' })))) }; renderAllChip(idx); Log.info('All #' + num + ' ' + asked.join(', ') + ': asking ' + keys.map(k => TRACK_PROV[k].name).join(', ')); res.lines = [].concat(...await Promise.all(asked.map(i => Promise.all(keys.map(k => allOnProvider(k, t, i, idx, asked)))))); res.lines.forEach(l => Log.info('All #' + num + ' ' + l.of + ' ' + l.name + ': ' + l.state + ' — ' + allLineText(l, l.of))); renderAllChip(idx); return res; } // The verdict chip: ✓ n/n when every provider that knows the ISRC agrees, ⚠ k/n when some don't, // – 0/m when none knows it, ⏳ while asking, ⛔ when SoundExchange was blocked. Hover shows the // comparison, a click pins it (a click on the pinned one's chip unpins it). function renderAllChip(idx) { const el = rowLookup(idx), res = _allRes[idx]; if (!el || !res) return; const lines = res.lines; const known = lines.filter(l => l.state === ALL_OK || l.state === ALL_SONG || l.state === ALL_DIFF); const ok = known.filter(l => l.state === ALL_OK).length; const waiting = lines.some(l => l.state === ALL_WAIT && !/queued/.test(l.note)); let cls, text; if (waiting && !known.length) { cls = 'spin'; text = '⏳ All…'; } else if (!known.length) { cls = 'none'; text = '– 0/' + lines.length; } else if (ok === known.length) { cls = 'ok'; text = '✓ ' + ok + '/' + known.length; } else { cls = 'warn'; text = '⚠ ' + ok + '/' + known.length; } if (lines.some(l => l.state === ALL_BLOCK)) text += ' ⛔'; el.className = 'ii-lookup ii-all-chip ' + cls; el.textContent = text; el.title = ''; // the comparison is the tooltip el.onclick = (e) => { e.stopPropagation(); const p = modal.querySelector('#ii-all-pop'); if (p && p._pinned && p._idx === idx) closeAllPop(); else openAllPop(idx, { pin: true }); }; el.onmouseenter = () => openAllPop(idx); el.onmouseleave = () => hideAllPopSoon(); const p = modal && modal.querySelector('#ii-all-pop'); if (p && p._idx === idx) renderAllPop(p); // live while it is asking } // The comparison, beside the chip or above its row (placeAllPop) so it covers neither the All // buttons nor the chips below — moving down either column walks the tracks. The header lists // every ISRC the providers named (a click shows only the providers with that one) and [copy] // (the comparison as Markdown); a line per provider: verdict, icon, name, what it has. let _allHideT = 0; function openAllPop(idx, { pin = false } = {}) { if (!modal || !_allRes[idx]) return; clearTimeout(_allHideT); let pop = modal.querySelector('#ii-all-pop'); if (pop && pop._pinned && pop._idx !== idx && !pin) return; // a pinned comparison stays until it is closed if (!pop || pop._idx !== idx) { closeAllPop(); pop = document.createElement('div'); pop.id = 'ii-all-pop'; pop._idx = idx; pop._sel = ''; modal.appendChild(pop); pop.addEventListener('mouseenter', () => clearTimeout(_allHideT)); pop.addEventListener('mouseleave', () => hideAllPopSoon()); pop.addEventListener('click', (e) => allPopClick(pop, e)); renderAllPop(pop); } if (pin && !pop._pinned) allPin(pop); } function hideAllPopSoon() { clearTimeout(_allHideT); _allHideT = setTimeout(() => { const p = modal && modal.querySelector('#ii-all-pop'); if (p && !p._pinned) closeAllPop(); }, 200); } // pinned: it stays when the mouse leaves (text can be selected); Escape or a click elsewhere // closes it, and that click is swallowed unless it lands on another track's chip (which pins that one) function allPin(pop) { pop._pinned = true; pop.classList.add('pinned'); const chip = () => rowLookup(pop._idx); const onDown = (e) => { if (pop.contains(e.target) || e.target === chip()) return; closeAllPop(); if (!(e.target.closest && e.target.closest('.ii-all-chip'))) { const swallow = (ev) => { ev.stopPropagation(); ev.preventDefault(); }; document.addEventListener('click', swallow, { capture: true, once: true }); setTimeout(() => document.removeEventListener('click', swallow, { capture: true }), 400); } }; const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeAllPop(); } }; document.addEventListener('mousedown', onDown, true); // at once: an Escape pressed right away must not reach the dialog document.addEventListener('keydown', onKey, true); pop._off = () => { document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); }; } // the asked ISRC whose comparison the panel shows ('' while it shows a named ISRC's providers), // and whether a line is in it function allShownIsrc(pop, res) { const asked = res.asked || [res.isrc]; return !pop._sel ? res.isrc : asked.includes(pop._sel) ? pop._sel : ''; } function allLineShown(l, pop, res) { const shown = allShownIsrc(pop, res); return shown ? (l.of || res.isrc) === shown : l.isrc === pop._sel; } function renderAllPop(pop, keepPlace) { const idx = pop._idx, res = _allRes[idx]; if (!res) return; const num = RELEASE.tracks[idx].number || RELEASE.tracks[idx].trackPos; const icon = k => k === 'sx' ? '<span class="ii-prov-sx">SX</span>' : (SRC_ICON[TRACK_PROV[k] && TRACK_PROV[k].code] || ''); // #643: the header has every ISRC on the row (each asked about) and then any other a provider // named; the comparison shows one asked ISRC at a time (the row's first), and a named one shows // the providers that name it const asked = res.asked || [res.isrc]; const isrcs = [...new Set(asked.concat(res.lines.map(l => l.isrc).filter(Boolean)))]; if (!isrcs.includes(pop._sel)) pop._sel = ''; const shown = allShownIsrc(pop, res); const count = i => res.lines.filter(l => l.isrc === i && (l.state === ALL_OK || l.state === ALL_DIFF)).length; pop.innerHTML = '<div class="ii-all-h"><span>Track ' + esc(num) + '</span>' + isrcs.map(i => '<button type="button" class="ii-all-pick' + (asked.includes(i) ? '' : ' other') + (i === shown || i === pop._sel ? ' sel' : '') + '" data-isrc="' + esc(i) + '" title="' + esc((i === res.isrc ? 'the row’s ISRC' : asked.includes(i) ? 'another ISRC the recording has' : 'another ISRC a provider has for this track') + ' — ' + count(i) + ' provider(s) have it for this track; click to show ' + (asked.includes(i) ? 'what each provider says about it' : 'only those providers') + (i === pop._sel && !asked.includes(i) ? ' (click again for all)' : '')) + '">' + esc(i) + ' <span class="ii-all-n">' + count(i) + '</span></button>').join('') + '<span class="ii-all-sp"></span><button type="button" class="ii-all-copy" title="Copy this comparison as Markdown">copy</button></div>' + res.lines.map((l, i) => !allLineShown(l, pop, res) ? '' : '<div class="ii-all-line ii-all-' + l.state + '"><span class="ii-all-mark">' + ALL_MARK[l.state] + '</span><span class="ii-all-ico">' + icon(l.key) + '</span>' + '<span class="ii-all-name">' + esc(l.name) + '</span><span class="ii-all-note" title="' + esc(allLineText(l, l.of || res.isrc)) + '">' + allLineHtml(l, l.of || res.isrc) + '</span>' + '<span class="ii-all-len">' + esc((l.song && l.song.dur) || '') + '</span>' + (l.state === ALL_DIFF ? '<button type="button" class="ii-all-use" data-i="' + i + '" title="put ' + esc(l.isrc) + ' in the row">use</button>' : '<span></span>') + '</div>').join(''); if (!keepPlace) placeAllPop(pop); } // Where it goes: right of the chip, level with the row, when it fits there; otherwise above the row // (right-aligned to the chip) — either way the rows below, their All buttons and chips, stay free. // Only with no room above either (the first rows) does it squeeze in beside the chip. function placeAllPop(pop) { const chip = rowLookup(pop._idx); if (!chip) return; const a = chip.getBoundingClientRect(), m = modal.getBoundingClientRect(); const tr = chip.closest('tr'), r = tr ? tr.getBoundingClientRect() : a; pop.style.maxWidth = '620px'; pop.style.left = '0px'; pop.style.top = '0px'; const w = pop.offsetWidth, h = pop.offsetHeight, right = a.right - m.left + 10; const levelTop = Math.max(8, Math.min(a.top - m.top - 8, m.height - h - 8)); if (right + w + 8 <= m.width) { pop.style.left = right + 'px'; pop.style.top = levelTop + 'px'; pop._at = 'right'; return; } const above = r.top - m.top - h - 4; if (above >= 8) { pop.style.left = Math.max(8, Math.min(a.right - m.left - w, m.width - w - 8)) + 'px'; pop.style.top = above + 'px'; pop._at = 'above'; return; } pop.style.maxWidth = Math.max(240, m.width - right - 8) + 'px'; pop.style.left = Math.max(8, Math.min(right, m.width - pop.offsetWidth - 8)) + 'px'; pop.style.top = levelTop + 'px'; pop._at = 'beside'; } function allPopClick(pop, e) { const res = _allRes[pop._idx], idx = pop._idx, num = RELEASE.tracks[idx].number || RELEASE.tracks[idx].trackPos; const pick = e.target.closest('.ii-all-pick'), use = e.target.closest('.ii-all-use'), copy = e.target.closest('.ii-all-copy'); if (pick) { const i = pick.dataset.isrc, asked = res.asked || [res.isrc]; // an ISRC of the row shows its comparison (the row's first: no selection); another one named // by a provider toggles a filter to the providers naming it pop._sel = asked.includes(i) ? (i === res.isrc ? '' : i) : (pop._sel === i ? '' : i); // #643: the panel stays where it is — it used to be placed anew for the other ISRC's lines, // moved out from under the pointer and closed. Above the row it keeps its bottom edge (it // grows upward); elsewhere its top. And a picked tab pins it. const top0 = parseFloat(pop.style.top) || 0, h0 = pop.offsetHeight; renderAllPop(pop, true); if (pop._at === 'above') pop.style.top = Math.max(8, top0 + h0 - pop.offsetHeight) + 'px'; if (!pop._pinned) allPin(pop); return; } if (use) { const l = res.lines[+use.dataset.i]; setPending(idx, l.isrc, true, 'All · ' + l.name); updateSummary(); // #643: the Add ISRCs button counts it Log.info('All #' + num + ': using ' + l.name + '\'s ' + l.isrc + ' in place of ' + res.isrc); closeAllPop(); return; } if (copy) { const cell = x => String(x).replace(/\|/g, '\\|'); const t = RELEASE.tracks[idx]; // what the comparison shows: one asked ISRC's lines, or the providers naming another const md = '**Track ' + num + '** · ' + cell(t.title || '') + ' · `' + (allShownIsrc(pop, res) || pop._sel) + '`\n\n| | Provider | Track | Length | Note |\n|:-:|---|---|---|---|\n' + // no ISRC column: the header's is every line's, except where an album has another one — the note names that res.lines.filter(l => allLineShown(l, pop, res)).map(l => '| ' + ALL_MARK[l.state] + ' | ' + cell(l.name) + ' | ' + cell(l.song && l.song.title ? l.song.title + (l.song.artist ? ' — ' + l.song.artist : '') : '') + ' | ' + cell((l.song && l.song.dur) || '') + ' | ' + cell([l.isrc && l.isrc !== (l.of || res.isrc) ? '`' + l.isrc + '`' : '', allAtText(l), l.note || ''].filter(Boolean).join(' · ')) + ' |').join('\n') + '\n'; const said = (ok) => { copy.textContent = ok ? 'copied ✓' : 'copy failed'; copy.classList.toggle('done', ok); setTimeout(() => { copy.textContent = 'copy'; copy.classList.remove('done'); }, 1500); }; try { navigator.clipboard.writeText(md).then(() => said(true), () => said(false)); } catch (err) { said(false); } Log.info('All #' + num + ': comparison copied as Markdown'); } } function closeAllPop() { clearTimeout(_allHideT); const p = modal && modal.querySelector('#ii-all-pop'); if (p) { if (p._off) p._off(); p.remove(); } } // Right-click on All: every track, on every provider (Tidal paced per track). async function runAllTracks() { const myEpoch = _allEpoch; const todo = []; RELEASE.tracks.forEach((t, idx) => { const isrc = rowIsrc(idx); if (isrc) todo.push({ idx, isrc }); }); Log.info('All: ' + todo.length + ' track(s) with an ISRC, on ' + allProviders().map(k => TRACK_PROV[k].name).join(', ')); for (let k = 0; k < todo.length; k++) { if (myEpoch !== _allEpoch) { Log.info('All: cancelled'); return; } await allLookupRow(todo[k].idx, todo[k].isrc); if (allProviders().includes('tidal') && k < todo.length - 1) await sleep(TIDAL_TRACK_DELAY); } Log.info('All: done'); } // the ISRC a per-track button acts on: the entered value if valid, else the // first existing ISRC on the recording. function rowIsrc(idx) { const t = RELEASE.tracks[idx], input = rowInput(idx); const v = normalizeIsrc(input ? input.value : ''); return (v && isValidIsrc(v)) ? v : ((t && t.existing && t.existing[0]) || ''); } // The per-track button looks up the clicked track's ISRC on the current // provider and shows the result next to the row — it never fills the field. function runTrackSingle(idx) { const isrc = rowIsrc(idx); if (!isrc) return; // nothing to look up (button is disabled) if (trackProv === 'sx') { lookupIsrc(idx, isrc).catch(e => { if (e && (e.rateLimited || e.captcha)) sxBlocked(e); }); return; } if (trackProv === 'all') { allLookupRow(idx, isrc).catch(e => Log.err('All: ' + errText(e))); return; } // #643 lookupRowOnProvider(idx, isrc); } // Right-click → look up EVERY track's ISRC on the current provider, shown next // to each row. SoundExchange routes through its rate-limit/captcha-aware path // (serialized + paced); album providers reuse the album fetched on the first. async function runTrackAll() { if (trackProv === 'all') return runAllTracks(); // #643 const m = TPM(); const myEpoch = _sxEpoch; // closing the popup or hitting Clear bumps this (via abortSxWork) → bail the bulk run so it doesn't keep fetching (e.g. Tidal) in the background const todo = []; RELEASE.tracks.forEach((t, idx) => { const isrc = rowIsrc(idx); if (isrc) todo.push({ idx, isrc }); }); Log.info(m.name + ': looking up ' + todo.length + ' track(s) with an ISRC'); for (let k = 0; k < todo.length; k++) { if (myEpoch !== _sxEpoch) { Log.info(m.name + ': bulk lookup cancelled'); return; } // popup closed / Clear → stop issuing requests const { idx, isrc } = todo[k]; if (trackProv === 'sx') { const cached = !!_isrcLookupCache[isrc]; try { await lookupIsrc(idx, isrc); } catch (e) { if (e && (e.rateLimited || e.captcha)) { sxBlocked(e); return; } } if (!cached && k < todo.length - 1) await sleep(BATCH_DELAY); // pace only real requests } else { try { await lookupRowOnProvider(idx, isrc); } catch (e) { Log.err(m.name + ': ' + errText(e)); } // Tidal makes a real request per track, so pace them (album providers reuse one // cached fetch and don't need it). Combined with tidalGet's back-off, this keeps // a bulk pass under the rate limit instead of 429-ing the tail. #tidal-429 if (trackProv === 'tidal' && k < todo.length - 1) await sleep(TIDAL_TRACK_DELAY); } } } // Re-skin EVERY per-track button to the chosen provider (global, not persisted). // The bulk "⟳ SoundExchange" toolbar button is intentionally left untouched. function setTrackProvider(key, { quiet = false } = {}) { if (!TRACK_PROV[key] || !trackProvAvailable(key)) return; trackProv = key; const m = TPM(); const provGlyph = (m.code !== 'sx' && SRC_ICON[m.code]) ? SRC_ICON[m.code] : null; modal.querySelectorAll('.ii-sx').forEach(b => { b.dataset.prov = key; b.innerHTML = provGlyph || m.short; b.title = (m.kind === 'all' ? 'Check this track’s ISRC on every provider at once — a verdict, and a comparison on click' : m.kind === 'album' ? ('Look up this track’s ISRC on ' + m.name) : 'Look up this track’s ISRC on SoundExchange — verify the entered ISRC, or (if empty) search by title/artist') + ' · right-click: do all tracks'; b.style.color = m.kind === 'album' ? m.color : ''; }); RELEASE.tracks.forEach((t, i) => { const b = tbody.querySelector('tr[data-idx="' + i + '"] .ii-sx'); if (b) { const inp = rowInput(i); b.disabled = trackBtnDisabled(t, inp ? inp.value : ''); } }); if (!quiet) Log.info('Track ISRC provider → ' + m.name); } // Build the provider dropdown (only the providers available for this release). function buildProvMenu() { const menu = modal.querySelector('#ii-prov-menu'); if (!menu) return; menu.innerHTML = ''; TRACK_PROV_ORDER.filter(trackProvAvailable).forEach(key => { const m = TRACK_PROV[key]; const glyph = key === 'all' ? '<span class="ii-prov-sx">ALL</span>' : (m.code !== 'sx' && SRC_ICON[m.code]) ? SRC_ICON[m.code] : '<span class="ii-prov-sx">SX</span>'; const it = document.createElement('button'); it.type = 'button'; it.className = 'ii-prov-item' + (key === trackProv ? ' active' : ''); it.style.color = m.color; it.innerHTML = '<span class="ii-prov-ico">' + glyph + '</span><span class="ii-prov-name">' + m.name + '</span>'; it.addEventListener('click', () => { setTrackProvider(key); closeProvMenu(); }); menu.appendChild(it); }); } // Open the shared menu anchored beneath the clicked per-track ▾ caret. function openProvMenu(anchor) { buildProvMenu(); const menu = modal.querySelector('#ii-prov-menu'); if (!menu) return; menu.classList.add('open'); if (anchor && menu.offsetParent) { const a = anchor.getBoundingClientRect(); const p = menu.offsetParent.getBoundingClientRect(); let left = a.left - p.left; left = Math.min(left, menu.offsetParent.clientWidth - menu.offsetWidth - 8); menu.style.left = Math.max(8, left) + 'px'; menu.style.top = (a.bottom - p.top + 4) + 'px'; } } function closeProvMenu() { const menu = modal.querySelector('#ii-prov-menu'); if (menu) menu.classList.remove('open'); } // SX exact-match toggles (persisted) const sxExact = { title: !!store.get('sx_exact_title', false), artist: !!store.get('sx_exact_artist', false), release: !!store.get('sx_exact_release', false), }; function saveSxExact() { store.set('sx_exact_title', sxExact.title); store.set('sx_exact_artist', sxExact.artist); store.set('sx_exact_release', sxExact.release); } // Refine-panel "use this term" toggles. title/artist reset to ON every time the // panel opens; the release toggle (default OFF) is remembered across invocations. let sxRelEnabled = !!store.get('sx_rel_enabled', false); /* ═══════════════════════════════════════════════════════════════════════ DEEZER (free public API, no auth) ═══════════════════════════════════════════════════════════════════════ */ let _dzListCache = null; // { albumId, list } — tracklist cached across batches // Fetches ONE batch (STREAM_BATCH_LIMIT tracks) starting at `start`. Deezer's // album endpoint lacks ISRCs, so each track needs its own request — a 1000-track // release would be 1000 calls, hence the batching. Returns { total, next }. async function fetchDeezer(albumId, onProgress, onIsrc, start) { start = start || 0; // (re)fetch the tracklist on a fresh import (start 0) or album change; reuse it on continue if (start === 0 || !_dzListCache || _dzListCache.albumId !== albumId) { const r = await gmGet('https://api.deezer.com/album/' + albumId, { 'Accept': 'application/json' }); const data = JSON.parse(r.responseText || '{}'); if (data.error) throw new Error((data.error.message || data.error.type || 'Deezer error') + ' (album ' + albumId + ')'); const list = (data.tracks && data.tracks.data) || []; Log.info('Deezer album "' + (data.title || albumId) + '": ' + list.length + ' track(s)' + (list.length > STREAM_BATCH_LIMIT ? ' — fetching ' + STREAM_BATCH_LIMIT + ' at a time' : '')); _dzListCache = { albumId, list }; } const list = _dzListCache.list; const end = Math.min(start + STREAM_BATCH_LIMIT, list.length); let failed = 0; for (let i = start; i < end; i++) { const t = list[i]; try { const tr = await gmGet('https://api.deezer.com/track/' + t.id, { 'Accept': 'application/json' }); const td = JSON.parse(tr.responseText || '{}'); if (td.error) { failed++; Log.warn('Deezer track ' + t.id + ': ' + (td.error.message || td.error.type)); } const entry = { isrc: normalizeIsrc(td.isrc || ''), title: td.title || t.title || '', artist: (td.artist && td.artist.name) || '', disc: td.disk_number || t.disk_number || 1, pos: td.track_position || t.track_position || (i + 1), dur: td.duration ? msToMmSs(td.duration * 1000) : '', }; if (onIsrc && isValidIsrc(entry.isrc)) onIsrc(entry); // fill this track's input now } catch (e) { failed++; Log.warn('Deezer track ' + t.id + ' fetch failed: ' + errText(e)); } try { if (onProgress) onProgress(i + 1, list.length); } catch (e) { Log.warn('Deezer progress update hiccup: ' + errText(e)); } await sleep(120); } if (failed) Log.warn('Deezer: ' + failed + ' track fetch(es) failed in this batch'); return { total: list.length, next: end < list.length ? end : null }; } /* ═══════════════════════════════════════════════════════════════════════ SPOTIFY (via molla or ISRC Hunt — chosen in ⚙, #603) Spotify's anti-bot makes a direct userscript token-harvest unreliable, and its ISRC API needs a paid developer app. Both services do the Spotify lookup server-side with their own credentials: molla returns JSON, ISRC Hunt renders the ISRCs into a plain HTML table that we scrape (no token, no login). ═══════════════════════════════════════════════════════════════════════ */ function parseIsrchunt(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const tables = [...doc.querySelectorAll('table')]; // results table columns: Track number · Track name · Length · ISRC · … const table = tables.find(t => /\bISRC\b/i.test(t.textContent) && /track/i.test(t.textContent)) || tables[0]; const out = []; if (!table) return out; // #446: ISRC Hunt restarts the track number at 1 for each Spotify disc and gives NO // disc column, so a multi-disc album reads as 1..13, 1..12 with everything looking like // "disc 1" — the disc-2 rows then collided with medium 1 (only the first medium filled). // Track the running position and bump the disc whenever the number resets (<= the // previous one), so mediums map correctly. Sequential single-disc tables are unaffected. let disc = 1, prevPos = 0; [...table.querySelectorAll('tr')].forEach(tr => { const td = [...tr.querySelectorAll('td')]; if (td.length < 4) return; const lenMs = parseInt(td[2].textContent.trim(), 10); const isrc = normalizeIsrc(td[3].textContent.trim()); if (!isValidIsrc(isrc)) return; const pos = parseInt(td[0].textContent.trim(), 10) || (prevPos + 1); if (pos <= prevPos) disc++; // track number reset → next disc / medium prevPos = pos; out.push({ isrc, title: td[1].textContent.trim(), artist: '', pos, disc, dur: lenMs ? msToMmSs(lenMs) : '' }); }); return out; } async function fetchSpotify(albumId, onProgress, onIsrc) { const src = spotifyIsrcSource(); Log.info('Spotify ISRC source: ' + (src === 'isrchunt' ? 'ISRC Hunt' : 'molla') + ' (change in ⚙)'); return src === 'isrchunt' ? fetchSpotifyIsrchunt(albumId, onProgress, onIsrc) : fetchSpotifyMolla(albumId, onProgress, onIsrc); } // #603: molla (isrc.mollamusicgroup.com) takes the album link and returns every // track's ISRC in one JSON request. It gives album order only — no track or disc // numbers. When its track count equals this release's, map by flattened position // (MB media in order ↔ Spotify's (disc, track) order, which is molla's order). // Otherwise send no position, so only title/artist matching can place an ISRC and // anything unmatched stays empty rather than landing on the wrong track. // Its Spotify quota is shared by every molla user; running out comes back as // HTTP 500 with "429 Too Many Requests" in the body and no Retry-After. async function fetchSpotifyMolla(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const input = 'spotify:album:' + albumId; Log.info('Spotify via molla: ' + input); const r = await gmPost('https://isrc.mollamusicgroup.com/api/spotify', JSON.stringify({ input }), { 'Content-Type': 'application/json' }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { Log.warn('molla: response is not JSON (' + String(r.responseText || '').slice(0, 80) + ')'); } if (r.status !== 200) { const msg = (j && j.error) || ('HTTP ' + r.status); if (/\b429\b|too many requests/i.test(msg)) throw new Error('molla is rate-limited by Spotify (its quota is shared by all molla users) — try again in a minute, or switch to ISRC Hunt in ⚙'); throw new Error('molla returned ' + r.status + ': ' + msg); } const tracks = (j && Array.isArray(j.tracks)) ? j.tracks : []; const n = tracks.length, mb = RELEASE.tracks.length; const withIsrc = tracks.filter(t => isValidIsrc(t.isrc || '')).length; Log.info('molla: "' + ((j && j.name) || '?') + '" — ' + n + ' track(s), ' + withIsrc + ' with an ISRC' + (j && j.upc ? ', UPC ' + j.upc : '')); if (!withIsrc) throw new Error('molla found no ISRCs for this album'); const byPos = n === mb; if (byPos) Log.info('molla: track count matches this release (' + n + ') — mapping by position'); else Log.warn('molla: the album has ' + n + ' track(s) but this release has ' + mb + ' — molla gives no track numbers, so ISRCs are placed by title/artist match only'); tracks.forEach((t, i) => { const isrc = normalizeIsrc(t.isrc || ''); const title = t.track_name || ''; // Spotify puts featured artists in the title ("Get Lucky (feat. Pharrell Williams // and Nile Rodgers)"); MB keeps them in the artist credit. molla has no durations, // so the title is the only plausibility check — without this every feat. track // on a correct import was flagged implausible. const matchTitle = title.replace(/\s*[([](?:feat\.?|ft\.?|featuring|with)\s[^)\]]*[)\]]/gi, '').trim() || title; // Match on the main (first) artist only: Spotify lists every featured artist, and // MB's credit often names a different set ("Get Lucky": Spotify adds Nile Rodgers, // MB has "Daft Punk feat. Pharrell Williams"), so neither list contains the other. const matchArtist = (t.artists || [])[0] || ''; if (isValidIsrc(isrc)) { const mt = byPos ? RELEASE.tracks[i] : null; // pos/disc -1 never matches a real track, which forces mapOneToTrack onto title matching const e = { isrc, title: matchTitle, artist: matchArtist, pos: mt ? +mt.trackPos : -1, disc: mt ? +mt.mediumPos : -1, dur: '' }; Log.info('molla #' + (i + 1) + ' ' + isrc + ' "' + title + '"' + (mt ? ' → medium ' + mt.mediumPos + ' track ' + mt.trackPos : ' → by title')); try { if (onIsrc) onIsrc(e); } catch (err) { Log.warn('Spotify map hiccup for ' + isrc + ': ' + errText(err)); } } else { Log.warn('molla #' + (i + 1) + ' "' + title + '": no valid ISRC (' + (t.isrc || 'none') + ')'); } try { if (onProgress) onProgress(i + 1, n); } catch (err) {} }); return { total: n, next: null }; // molla returns everything in one request — never batched } async function fetchSpotifyIsrchunt(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const albumUrl = 'https://open.spotify.com/album/' + albumId; const url = 'https://isrchunt.com/spotify/importisrc?releaseId=' + encodeURIComponent(albumUrl); Log.info('Spotify via ISRC Hunt: ' + shortUrl(url)); const r = await gmGet(url, { 'Accept': 'text/html' }); if (r.status !== 200) throw new Error('ISRC Hunt returned ' + r.status); const rows = parseIsrchunt(r.responseText); Log.info('ISRC Hunt: ' + rows.length + ' track(s) with an ISRC'); if (!rows.length) throw new Error('ISRC Hunt found no ISRCs for this album'); rows.forEach((e, i) => { try { if (onIsrc) onIsrc(e); } catch (err) { Log.warn('Spotify map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(i + 1, rows.length); } catch (err) {} }); return { total: rows.length, next: null }; // ISRC Hunt returns everything in one request — never batched } /* ═══════════════════════════════════════════════════════════════════════ BEATPORT (Cloudflare-walled → harvested in a brief tab; the harvester at the top of the script fills GM storage, which we read back here.) ═══════════════════════════════════════════════════════════════════════ */ function readBeatportHarvest(id) { try { const h = store.get('beatport_harvest_' + id, ''); const o = h ? (typeof h === 'string' ? JSON.parse(h) : h) : null; return (o && Array.isArray(o.tracks)) ? o : null; } catch (e) { return null; } } // Beatport official API — used only when the user has logged in via Platform Check // (the token is shared through a musicbrainz.org localStorage key). When present it's // a clean one-call fetch with no tab; otherwise we fall back to the tab-harvest below. const BEATPORT = { clientId: '0GIvkCltVIuPkkwSJHp6NDb3s0potTjLBQr388Dd', api: 'https://api.beatport.com/v4', lsKey: 'mbtools:beatport' }; const bpRead = () => { try { return JSON.parse(localStorage.getItem(BEATPORT.lsKey) || 'null'); } catch (e) { return null; } }; const bpWrite = t => { try { t ? localStorage.setItem(BEATPORT.lsKey, JSON.stringify(t)) : localStorage.removeItem(BEATPORT.lsKey); } catch (e) {} }; async function beatportToken() { const t = bpRead(); if (!t || !t.refresh_token) return null; if (t.access_token && Date.now() < t.exp - 60000) return t.access_token; const r = await gmPost(BEATPORT.api + '/auth/o/token/', new URLSearchParams({ grant_type: 'refresh_token', refresh_token: t.refresh_token, client_id: BEATPORT.clientId }).toString(), { 'Content-Type': 'application/x-www-form-urlencoded' }); let j; try { j = JSON.parse(r.responseText || '{}'); } catch (e) { j = {}; } if (!j.access_token) { Log.warn('Beatport: token refresh failed — re-login in Platform Check ⚙'); return null; } bpWrite({ access_token: j.access_token, refresh_token: j.refresh_token || t.refresh_token, exp: Date.now() + ((j.expires_in || 36000) * 1000) }); return j.access_token; } // Returns track entries from the API, or null if not logged in / no usable data. // ISRCs live on the /tracks/ sub-endpoint — the release-detail's embedded `tracks` // array omits them (that's why an earlier build saw 0 ISRCs and fell back to the tab). async function fetchBeatportApi(releaseId, onProgress, onIsrc) { const tok = await beatportToken(); if (!tok) return null; Log.info('Beatport: logged in — fetching release ' + releaseId + ' via the API (no tab)'); const headers = { 'Authorization': 'Bearer ' + tok, 'Accept': 'application/json' }; const list = []; let url = BEATPORT.api + '/catalog/releases/' + releaseId + '/tracks/?per_page=100'; let guard = 0; while (url && guard++ < 20) { const r = await gmGet(url, headers); if (r.status !== 200) { Log.warn('Beatport API ' + r.status + ' for release ' + releaseId + ' — falling back to tab harvest'); return null; } let d; try { d = JSON.parse(r.responseText || '{}'); } catch (e) { return null; } (d.results || d.tracks || []).forEach(t => list.push(t)); url = d.next || null; // DRF pagination returns an absolute next URL } if (!list.length) { Log.warn('Beatport API: release had no tracks — falling back to tab harvest'); return null; } let withIsrc = 0; list.forEach((t, i) => { const mix = t.mix_name && !/^original mix$/i.test(t.mix_name) && !String(t.name || '').toLowerCase().includes('(' + String(t.mix_name).toLowerCase() + ')') ? ' (' + t.mix_name + ')' : ''; // #643: not twice const e = { isrc: normalizeIsrc(t.isrc || ''), title: (t.name || '') + mix, artist: (t.artists || []).map(a => a && a.name).filter(Boolean).join(', '), disc: 1, pos: t.number || (i + 1), dur: t.length || (t.length_ms ? msToMmSs(t.length_ms) : ''), url: (t.slug && t.id) ? 'https://www.beatport.com/track/' + t.slug + '/' + t.id : '', // #387 per-track link }; try { if (onIsrc && isValidIsrc(e.isrc)) { onIsrc(e); withIsrc++; } } catch (err) { Log.warn('Beatport map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(i + 1, list.length); } catch (err) {} }); Log.info('Beatport API: ' + withIsrc + '/' + list.length + ' track(s) carried an ISRC'); if (!withIsrc) return null; // nothing usable → let the tab-harvest try return { total: list.length, next: null }; } async function fetchBeatport(releaseId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const viaApi = await fetchBeatportApi(releaseId, onProgress, onIsrc); // logged-in fast path (no tab) if (viaApi) return viaApi; let h = readBeatportHarvest(releaseId); if (h) { Log.info('Beatport: using harvested data for release ' + releaseId + ' (' + h.tracks.length + ' track(s))'); } else { Log.info('Beatport: opening release ' + releaseId + ' in a background tab to harvest ISRCs (Cloudflare blocks a direct fetch)'); store.del('beatport_harvest_' + releaseId); // Tell the harvester (running in the tab we're about to open) that THIS // tab is ours to close once it's done — so the harvester never closes a // Beatport tab the user opened themselves. store.set('beatport_close_' + releaseId, Date.now()); const url = 'https://www.beatport.com/release/-/' + releaseId; let tab = null; try { tab = (typeof GM_openInTab === 'function') ? GM_openInTab(url, { active: false, insert: true, setParent: true }) : window.open(url, '_blank'); } catch (e) { tab = window.open(url, '_blank'); } const t0 = Date.now(); while (Date.now() - t0 < 60000) { await sleep(500); h = readBeatportHarvest(releaseId); if (h) break; } try { if (tab && typeof tab.close === 'function') tab.close(); } catch (e) {} store.del('beatport_close_' + releaseId); // clear the flag whether we succeeded or timed out if (!h) throw new Error('Beatport harvest timed out — the tab may have hit a Cloudflare check. Open the release on beatport.com once, then retry.'); } const rows = h.tracks; rows.forEach((e, i) => { try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Beatport map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(i + 1, rows.length); } catch (err) {} }); const withIsrc = rows.filter(e => isValidIsrc(e.isrc)).length; Log.info('Beatport: ' + withIsrc + '/' + rows.length + ' track(s) carried an ISRC'); if (!withIsrc) throw new Error('Beatport release exposed no ISRCs'); return { total: rows.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ TIDAL (official API; app token via client-credentials — no user login) ═══════════════════════════════════════════════════════════════════════ */ async function tidalToken() { const tok = store.get('tidal_token', ''), exp = store.get('tidal_token_exp', 0); if (tok && Date.now() < exp - 60000) return tok; const basic = btoa(TIDAL.clientId + ':' + TIDAL.clientSecret); const r = await gmPost(TIDAL.tokenUrl, 'grant_type=client_credentials', { 'Authorization': 'Basic ' + basic, 'Content-Type': 'application/x-www-form-urlencoded' }); const j = JSON.parse(r.responseText || '{}'); if (!j.access_token) throw new Error('Tidal auth failed (' + r.status + ')' + (j.error ? ': ' + j.error : '')); store.set('tidal_token', j.access_token); store.set('tidal_token_exp', Date.now() + ((j.expires_in || 14400) * 1000)); return j.access_token; } function isoDurToMmSs(iso) { const m = String(iso || '').match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!m) return ''; const sec = (parseInt(m[1] || 0, 10) * 3600) + (parseInt(m[2] || 0, 10) * 60) + parseInt(m[3] || 0, 10); return sec ? msToMmSs(sec * 1000) : ''; } // Tidal's openapi is aggressively rate-limited (HTTP 429). On a 429, back off — // honouring a Retry-After header when present, else exponential — and retry, so a // throttled request RECOVERS instead of looking like "not found". Only throws a // typed `rateLimited` error when it never clears, so the caller can say so. const TIDAL_MAX_RETRY = 4; function retryAfterMs(r, fallback) { const m = /retry-after:\s*([0-9.]+)/i.exec((r && r.responseHeaders) || ''); const s = m ? parseFloat(m[1]) : NaN; return (s > 0) ? Math.min(s * 1000, 30000) : fallback; } async function tidalGet(url, headers) { for (let attempt = 0; ; attempt++) { const r = await gmGet(url, headers); if (r.status !== 429) return r; if (attempt >= TIDAL_MAX_RETRY) { const e = new Error('Tidal rate limit (HTTP 429)'); e.rateLimited = true; throw e; } const wait = retryAfterMs(r, Math.min(800 * Math.pow(2, attempt), 8000)); Log.warn('Tidal 429 — backing off ' + wait + 'ms (retry ' + (attempt + 1) + '/' + TIDAL_MAX_RETRY + ')'); await sleep(wait); } } async function fetchTidal(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const token = await tidalToken(); const headers = { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.api+json' }; let path = '/albums/' + albumId + '/relationships/items?countryCode=' + TIDAL.country + '&include=items'; const rows = []; let guard = 0; while (path && guard++ < 50) { const r = await tidalGet(TIDAL.api + path, headers); if (r.status !== 200) throw new Error('Tidal ' + r.status + ' for album ' + albumId + (r.status === 404 ? ' (not found / region-locked)' : '')); const j = JSON.parse(r.responseText || '{}'); const inc = {}; (j.included || []).forEach(x => { if (x.type === 'tracks') inc[x.id] = x.attributes || {}; }); (j.data || []).forEach(ref => { const a = inc[ref.id] || {}; const meta = ref.meta || {}; const ver = a.version ? ' (' + a.version + ')' : ''; rows.push({ isrc: normalizeIsrc(a.isrc || ''), title: (a.title || '') + ver, artist: '', // track artists need a second include; pos+disc mapping is enough for a full ordered tracklist disc: meta.volumeNumber || 1, pos: meta.trackNumber || (rows.length + 1), dur: isoDurToMmSs(a.duration), }); }); const next = j.links && j.links.next; path = next ? (next.charAt(0) === '/' ? next : '/' + next.replace(/^.*\/v2\//, '')) : null; } Log.info('Tidal album ' + albumId + ': ' + rows.length + ' track(s)'); let n = 0; rows.forEach(e => { try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Tidal map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, rows.length); } catch (err) {} }); const withIsrc = rows.filter(e => isValidIsrc(e.isrc)).length; if (!withIsrc) throw new Error('Tidal album exposed no ISRCs'); return { total: rows.length, next: null }; } // Global by-ISRC lookup (no album link needed) — Tidal v2 /tracks?filter[isrc], // pulling the artist names via include=artists. Returns a fields-shaped object. async function tidalLookupByIsrc(isrc) { const token = await tidalToken(); const headers = { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.api+json' }; const r = await tidalGet(TIDAL.api + '/tracks?countryCode=' + TIDAL.country + '&filter%5Bisrc%5D=' + encodeURIComponent(isrc) + '&include=artists', headers); if (r.status !== 200) return null; let j; try { j = JSON.parse(r.responseText || '{}'); } catch (e) { return null; } const t = (j.data || [])[0]; if (!t) return null; const a = t.attributes || {}; const names = {}; (j.included || []).forEach(x => { if (x.type === 'artists') names[x.id] = (x.attributes || {}).name; }); const artist = ((((t.relationships || {}).artists || {}).data) || []).map(d => names[d.id]).filter(Boolean).join(', '); const ver = a.version ? ' (' + a.version + ')' : ''; return { isrc: normalizeIsrc(a.isrc || isrc), title: (a.title || '') + ver, artist, year: '', dur: isoDurToMmSs(a.duration), relTitle: '' }; } /* ═══════════════════════════════════════════════════════════════════════ VOLUMO (clean unauthenticated JSON API — no Cloudflare/token; one call per album returns every track's ISRC. Resolves from a Volumo URL only — either an MB rel or the one Platform Check found; Scout never deals with barcodes itself, that's Platform Check's job.) ═══════════════════════════════════════════════════════════════════════ */ /* ═══════════════════════════════════════════════════════════════════════ QOBUZ (#353, #418) — per-track ISRCs come from album/get, which works ANONYMOUSLY (app_id only) when the request comes from a country Qobuz serves: the anonymous API resolves catalogue visibility by request IP and answers 404 "No result matching given argument" everywhere else. (That geo dimension is why the original #353 investigation, run from a non-Qobuz country, concluded a login was required.) So: Platform Check's shared user_auth_token is PREFERRED when present (one request, works from any country — the login's real contribution is the ACCOUNT's region, not auth; mbtools:qobuz localStorage key, same channel as the Beatport token), anonymous otherwise or when the session has gone stale. Only when both paths fail do we point at the PC login. ═══════════════════════════════════════════════════════════════════════ */ const QOBUZ = { appId: '712109809', api: 'https://www.qobuz.com/api.json/0.2', lsKey: 'mbtools:qobuz' }; const qbToken = () => { try { const t = JSON.parse(localStorage.getItem(QOBUZ.lsKey) || 'null'); return (t && t.token) || null; } catch (e) { return null; } }; const qbHeaders = tok => { const h = { 'X-App-Id': QOBUZ.appId, 'Accept': 'application/json' }; if (tok) h['X-User-Auth-Token'] = tok; return h; }; // One Qobuz API GET (#418): the Platform Check session is preferred when present (one // request, works from any country — the token carries the account's region), falling back // to an anonymous call when there is no token or the token has expired. Anonymous works // fine from countries Qobuz serves; elsewhere it answers 404, hence the tailored error. // `pathAndQuery` must already carry its `?`; app_id is appended here. async function qbGet(pathAndQuery, what) { const url = QOBUZ.api + pathAndQuery + '&app_id=' + QOBUZ.appId; const tok = qbToken(); if (tok) { const r = await gmGet(url, qbHeaders(tok)); if (r.status === 200) return r; // expired/broken session — the anonymous path may still work (served country) Log.warn('Qobuz: session ' + what + ' answered ' + r.status + (r.status === 401 ? ' (expired? re-login in Platform Check ⚙)' : '') + ' — trying anonymously'); } const anon = await gmGet(url, qbHeaders(null)); if (anon.status === 200) return anon; throw new Error('Qobuz ' + anon.status + ' for ' + what + (tok ? ' — session and anonymous both failed (re-login in Platform Check ⚙ Setup → Auth?)' : ' — the anonymous API only answers from countries Qobuz serves; sign in to Qobuz in Platform Check ⚙ Setup → Auth to use your account’s region instead')); } async function fetchQobuz(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); let id = String(albumId).trim(); // a bare barcode/UPC → resolve to the album id via search (Qobuz stores the 13-digit EAN, so try zero-padded too, #354) if (/^\d+$/.test(id)) { const want = id.replace(/^0+/, ''); let hit = null, lastErr = null; for (const q of [id, id.length < 13 ? id.padStart(13, '0') : null].filter(Boolean)) { let sr; try { sr = await qbGet('/album/search?query=' + encodeURIComponent(q), 'barcode search ' + q); } catch (e) { lastErr = e; continue; } let sj; try { sj = JSON.parse(sr.responseText || 'null'); } catch (e) { sj = null; } const items = (sj && sj.albums && sj.albums.items) || []; hit = items.find(a => String(a.upc || '').replace(/^0+/, '') === want) || items[0]; if (hit) break; } if (!hit || !hit.id) throw (lastErr || new Error('Qobuz: no album for barcode ' + id)); id = hit.id; } const r = await qbGet('/album/get?album_id=' + encodeURIComponent(id), 'album ' + id); let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('Qobuz: malformed JSON'); } const list = (j && j.tracks && j.tracks.items) || []; Log.info('Qobuz album "' + ((j && j.title) || id) + '": ' + list.length + ' track(s)'); let n = 0; list.forEach((t, i) => { const e = { isrc: normalizeIsrc(t.isrc || ''), title: t.title || '', artist: (t.performer && t.performer.name) || (j.artist && j.artist.name) || '', disc: t.media_number || 1, pos: t.track_number || (i + 1), dur: t.duration ? msToMmSs(t.duration * 1000) : '', // Qobuz duration is in SECONDS url: t.id ? 'https://open.qobuz.com/track/' + t.id : '', // #387 per-track link — id-only open form is what MB stores }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Qobuz map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, list.length); } catch (err) {} }); const withIsrc = list.filter(t => isValidIsrc(normalizeIsrc(t.isrc || ''))).length; if (!withIsrc) throw new Error('Qobuz album exposed no ISRCs (session may lack catalogue access)'); return { total: list.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ APPLE MUSIC (#435) — per-track ISRCs from the amp-api, ANONYMOUSLY. The web player's bearer token lives in its public JS bundle (no login); one `catalog/<sf>/albums/<id>` call returns every track's `attributes.isrc`. The storefront comes from the MB link (…/us/…); credits aren't needed here (that's Credit Hoarder's job), just the album's tracklist. ═══════════════════════════════════════════════════════════════════════ */ const APPLE = { amp: 'https://amp-api.music.apple.com/v1/catalog', lsKey: 'mbtools:apple-token' }; let _appleTok = null; // fresh: skip the cached token — it was refused. The cache is shared with Platform Check, // and a token there can be stale or rotated; without this, ISRC Scout kept presenting a // refused one for up to 12 h (Platform Check has recovered this way since #627). async function appleToken(fresh) { if (fresh) { _appleTok = null; try { localStorage.removeItem(APPLE.lsKey); } catch (e) {} } if (_appleTok) return _appleTok; try { const c = JSON.parse(localStorage.getItem(APPLE.lsKey) || 'null'); if (c && c.t && c.at && (Date.now() - c.at) < 12 * 3600e3) { _appleTok = c.t; return _appleTok; } } catch (e) {} const home = await gmGet('https://music.apple.com/us/browse', { 'Accept': 'text/html' }); const asset = (home.responseText.match(/\/assets\/index-legacy~[a-z0-9]+\.js/i) || home.responseText.match(/\/assets\/index~[a-z0-9]+\.js/i) || [])[0]; if (!asset) throw new Error('Apple: web-player JS asset not found'); const js = await gmGet('https://music.apple.com' + asset, {}); const tok = (js.responseText.match(/eyJ[A-Za-z0-9._\-]{80,}/) || [])[0]; if (!tok) throw new Error('Apple: no bearer token in the web-player JS'); _appleTok = tok; try { localStorage.setItem(APPLE.lsKey, JSON.stringify({ t: tok, at: Date.now() })); } catch (e) {} return tok; } // albumId is "<storefront>/<id>" (from parseStreamingId) or a full Apple album URL. async function fetchApple(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); let sf = 'us', id = String(albumId).trim(); let m = id.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/?#]+\/)?(\d+)/i) || id.match(/^([a-z]{2})\/(\d+)$/i); if (m) { sf = m[1].toLowerCase(); id = m[2]; } let h, r; for (let attempt = 0; attempt < 2; attempt++) { h = { Authorization: 'Bearer ' + await appleToken(attempt > 0), Origin: 'https://music.apple.com', Accept: 'application/json' }; r = await gmGet(APPLE.amp + '/' + sf + '/albums/' + encodeURIComponent(id) + '?l=en-US', h); if (r.status !== 401 || attempt > 0) break; Log.warn('Apple: the cached token was refused (401) — fetching a new one'); } if (r.status !== 200) throw new Error('Apple ' + r.status + ' for album ' + id); let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('Apple: malformed JSON'); } const alb = j && j.data && j.data[0]; let list = (alb && alb.relationships && alb.relationships.tracks && alb.relationships.tracks.data) || []; let next = alb && alb.relationships && alb.relationships.tracks && alb.relationships.tracks.next; while (next) { const pg = await gmGet('https://amp-api.music.apple.com' + next + (next.includes('?') ? '&' : '?') + 'l=en-US', h); let pj; try { pj = JSON.parse(pg.responseText || 'null'); } catch (e) { break; } list = list.concat((pj && pj.data) || []); next = pj && pj.next; } Log.info('Apple album "' + ((alb && alb.attributes && alb.attributes.name) || id) + '": ' + list.length + ' track(s)'); let n = 0; list.forEach((t, i) => { const a = t.attributes || {}; const e = { isrc: normalizeIsrc(a.isrc || ''), title: a.name || '', artist: a.artistName || '', disc: a.discNumber || 1, pos: a.trackNumber || (i + 1), dur: a.durationInMillis ? msToMmSs(a.durationInMillis) : '', url: a.url || '', }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Apple map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, list.length); } catch (err) {} }); const withIsrc = list.filter(t => isValidIsrc(normalizeIsrc((t.attributes || {}).isrc || ''))).length; if (!withIsrc) throw new Error('Apple album exposed no ISRCs'); return { total: list.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ SOUNDCLOUD (#439) — per-track ISRCs from a set (playlist), ANONYMOUSLY. Each track carries `publisher_metadata.isrc` in SoundCloud's public api-v2. The public `client_id` lives in the web player's JS bundle (no login), the same trick the Apple source uses for its bearer token. Flow: resolve the set URL → playlist { tracks:[{id}, …] } /tracks?ids=<id,…> (batched ≤50) → each track's publisher_metadata.isrc A set is an ordered playlist, so ISRCs map to the tracklist BY POSITION (title + duration ride along so the #431 plausibility guard can flag a mismatched edition). Distributed sets also carry the release barcode per track (publisher_metadata.upc_or_ean) — logged here, but not written (setting a release barcode is Platform Check's job). #439 ═══════════════════════════════════════════════════════════════════════ */ const SC = { api: 'https://api-v2.soundcloud.com', lsKey: 'mbtools:soundcloud-clientid' }; let _scClientId = null; async function soundcloudClientId() { if (_scClientId) return _scClientId; try { const c = JSON.parse(localStorage.getItem(SC.lsKey) || 'null'); if (c && c.id && c.at && (Date.now() - c.at) < 12 * 3600e3) { _scClientId = c.id; return _scClientId; } } catch (e) {} // The player page references its JS asset bundles on a-v2.sndcdn.com; one of // them defines `client_id:"…"`. Scan them newest-first (the id lives in a late // chunk) and cache the first hit for the session. const home = await gmGet('https://soundcloud.com/discover', { 'Accept': 'text/html' }); const assets = [...(home.responseText || '').matchAll(/https:\/\/a-v2\.sndcdn\.com\/assets\/[^"']+\.js/g)].map(m => m[0]); for (const a of assets.reverse()) { let js; try { js = await gmGet(a, {}); } catch (e) { continue; } const m = (js.responseText || '').match(/client_id\s*[:=]\s*"([a-zA-Z0-9]{20,40})"/); if (m) { _scClientId = m[1]; try { localStorage.setItem(SC.lsKey, JSON.stringify({ id: _scClientId, at: Date.now() })); } catch (e) {} return _scClientId; } } throw new Error('SoundCloud: no client_id in the web-player JS'); } // albumId is a SoundCloud set (playlist) URL — soundcloud.com/<user>/sets/<slug>. async function fetchSoundcloud(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const setUrl = String(albumId).trim(); const cid = await soundcloudClientId(); const rj = async (url, what) => { const r = await gmGet(url, { 'Accept': 'application/json' }); if (r.status !== 200) throw new Error('SoundCloud ' + r.status + ' for ' + what); try { return JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('SoundCloud: malformed JSON (' + what + ')'); } }; const pl = await rj(SC.api + '/resolve?url=' + encodeURIComponent(setUrl) + '&client_id=' + cid, 'set'); let ordered; if (pl && pl.kind === 'track') { // a bare track URL — a single-track release (#439, chaban-mb): treat it as a 1-track set ordered = [pl]; Log.info('SoundCloud track "' + (pl.title || setUrl) + '": 1 track'); } else if (pl && pl.kind === 'playlist') { const stubs = (pl.tracks || []).filter(t => t && t.id); Log.info('SoundCloud set "' + (pl.title || setUrl) + '": ' + stubs.length + ' track(s)'); // /resolve hydrates only the first few tracks; batch-hydrate the rest by id // (≤50 per call), preserving the set order so positions stay 1:1 with the release. const byId = new Map(); (pl.tracks || []).forEach(t => { if (t && t.title) byId.set(t.id, t); }); const missing = stubs.filter(t => !byId.has(t.id)).map(t => t.id); for (let i = 0; i < missing.length; i += 50) { const ids = missing.slice(i, i + 50).join(','); let batch; try { batch = await rj(SC.api + '/tracks?ids=' + encodeURIComponent(ids) + '&client_id=' + cid, 'tracks'); } catch (e) { Log.warn('SoundCloud batch failed: ' + errText(e)); continue; } (batch || []).forEach(t => { if (t && t.id != null) byId.set(t.id, t); }); } ordered = stubs.map(s => byId.get(s.id)).filter(Boolean); } else { throw new Error('SoundCloud: not a set or track URL'); } let n = 0; ordered.forEach((t, i) => { const pm = t.publisher_metadata || {}; const e = { isrc: normalizeIsrc(pm.isrc || ''), title: t.title || '', artist: pm.artist || (t.user && t.user.username) || '', disc: 1, pos: i + 1, // a set is an ordered playlist — position IS the track order dur: (t.duration || t.full_duration) ? msToMmSs(t.duration || t.full_duration) : '', url: t.permalink_url || '', }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('SoundCloud map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, ordered.length); } catch (err) {} }); // SoundCloud stores the release barcode per track as publisher_metadata.upc_or_ean // (label-supplied — present on distributed sets, absent on self-uploads). ISRC Scout // doesn't set the barcode (that's a release edit — Platform Check's job), but surface // it in the log when the whole set agrees on one value, so it's not lost. #439 const upcs = [...new Set(ordered.map(t => String((t.publisher_metadata || {}).upc_or_ean || '').trim()).filter(Boolean))]; if (upcs.length === 1) Log.info('SoundCloud set barcode (UPC/EAN): ' + upcs[0]); else if (upcs.length > 1) Log.info('SoundCloud set exposes ' + upcs.length + ' different track UPCs (compilation?): ' + upcs.join(', ')); const withIsrc = ordered.filter(t => isValidIsrc(normalizeIsrc((t.publisher_metadata || {}).isrc || ''))).length; if (!withIsrc) throw new Error('SoundCloud set exposed no ISRCs'); return { total: ordered.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ AUDIOMACK (#664) — per-track ISRCs from an album (or a song, a single-track release). api.audiomack.com wants every call OAuth 1.0a-signed (HMAC-SHA1), but with the web player's own public consumer key + secret, so no login. music/album/<artist>/<slug> carries the ordered tracks, each with its isrc, duration and url_slug; music/song/<artist>/<slug> is one such track. Mapped BY POSITION (title + duration ride along for the #431 guard). The album's barcode (upc) is logged, not written — that's Platform Check's job. ═══════════════════════════════════════════════════════════════════════ */ const AK = { api: 'https://api.audiomack.com/v1/', key: 'audiomack-web', secret: 'bd8a07e9f23fbe9d808646b730f89b8e' }; const akEnc = s => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); async function audiomackGet(endpoint, params) { const url = AK.api + endpoint; const p = Object.assign({}, params || {}, { oauth_consumer_key: AK.key, oauth_nonce: Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), oauth_signature_method: 'HMAC-SHA1', oauth_timestamp: String(Math.floor(Date.now() / 1000)), oauth_version: '1.0' }); const qs = Object.keys(p).sort().map(k => akEnc(k) + '=' + akEnc(p[k])).join('&'); const enc = new TextEncoder(); const key = await crypto.subtle.importKey('raw', enc.encode(akEnc(AK.secret) + '&'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); const sig = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(['GET', akEnc(url), akEnc(qs)].join('&')))))); const r = await gmGet(url + '?' + qs + '&oauth_signature=' + akEnc(sig), { 'Accept': 'application/json' }); if (r.status !== 200) throw new Error('Audiomack ' + r.status + ' for ' + endpoint); let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('Audiomack: malformed JSON (' + endpoint + ')'); } if (!j || !j.results) throw new Error('Audiomack: no results for ' + endpoint); return j.results; } // → { title, upc, tracks: [{ title, artist, isrc, dur, url }] } in album order async function audiomackRelease(albumUrl) { const m = String(albumUrl || '').match(/audiomack\.com\/([^/?#]+)\/(album|song)\/([^/?#]+)/i); if (!m) throw new Error('Audiomack: not an album or song URL'); const kind = m[2].toLowerCase(); const it = await audiomackGet('music/' + kind + '/' + m[1] + '/' + m[3]); const list = kind === 'album' ? (it.tracks || []) : [it]; return { title: it.title || '', upc: String(it.upc || '').replace(/\D/g, ''), tracks: list.map(t => ({ title: t.title || '', artist: t.artist || '', isrc: normalizeIsrc(t.isrc || ''), dur: Number(t.duration) > 0 ? msToMmSs(Number(t.duration) * 1000) : '', url: t.url_slug ? 'https://audiomack.com/' + (t.uploader_url_slug || (t.uploader && t.uploader.url_slug) || m[1]) + '/song/' + t.url_slug : '', })), }; } async function fetchAudiomack(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const rel = await audiomackRelease(albumId); Log.info('Audiomack "' + (rel.title || albumId) + '": ' + rel.tracks.length + ' track(s)' + (rel.upc ? ', barcode ' + rel.upc : '')); let n = 0; rel.tracks.forEach((t, i) => { const e = { isrc: t.isrc, title: t.title, artist: t.artist, disc: 1, pos: i + 1, dur: t.dur, url: t.url }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Audiomack map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, rel.tracks.length); } catch (err) {} }); const withIsrc = rel.tracks.filter(t => isValidIsrc(t.isrc)).length; Log.info('Audiomack: ' + withIsrc + ' of ' + rel.tracks.length + ' track(s) carry an ISRC'); if (!withIsrc) throw new Error('Audiomack album exposed no ISRCs'); return { total: rel.tracks.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ 7DIGITAL (#669) — per-track ISRCs from a 7digital release, through its catalogue API (api.7digital.com/1.2) with the key its own web store ships. That key may search but not read a release by id, so each MB track is looked up in track/search (with the album's title, after its artist, then alone), and the hits on the linked release are kept: each carries its ISRC, disc and per-disc position (`number`; `trackNumber` runs on across discs). A search for the album's title is no use: it matches track titles. The key in hand is replaced by the store's current one when the API refuses it, and kept for every MB tab (Platform Check reads the same). ═══════════════════════════════════════════════════════════════════════ */ const SD = { api: 'https://api.7digital.com/1.2/', lsKey: 'mbtools:7digital-key', key: '7drfpc993qp5', countries: ['GB', 'US'] }; let _sdKey = null, _sdKeyP = null; function sdKey(fresh) { if (_sdKey && !fresh) return Promise.resolve(_sdKey); if (!_sdKeyP) _sdKeyP = sdKeyFetch(fresh).finally(() => { _sdKeyP = null; }); return _sdKeyP; } async function sdKeyFetch(fresh) { if (!fresh) { let k = null; try { k = localStorage.getItem(SD.lsKey); } catch (e) {} return (_sdKey = k || SD.key); } Log.info('7digital: fetching the key from the store\'s JS'); const home = await gmGet('https://us.7digital.com/', { 'Accept': 'text/html' }); const js = ((home.responseText || '').match(/\/\/js-cdn\.7digital\.com\/static\/build\/javascript\/app\.[a-z0-9]+\.js/i) || [])[0]; if (!js) throw new Error('7digital: the store\'s JS was not found (home page HTTP ' + home.status + ')'); const r = await gmGet('https:' + js); const k = ((r.responseText || '').match(/oauth_consumer_key="([A-Za-z0-9]+)"/) || [])[1]; if (!k) throw new Error('7digital: no key in the store\'s JS'); try { localStorage.setItem(SD.lsKey, k); } catch (e) {} return (_sdKey = k); } // One API read: its JSON, or throws. A refused key (HTTP 401, error 4001) is replaced, once. async function sdApi(path, params) { for (let attempt = 0; attempt < 2; attempt++) { const key = await sdKey(attempt > 0); const qs = new URLSearchParams(Object.assign({}, params, { usageTypes: 'download', oauth_consumer_key: key })); const r = await gmGet(SD.api + path + '?' + qs, { 'Accept': 'application/json' }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { /* "OAuth authentication error: …" is plain text */ } const code = j && j.error ? String(j.error.code) : ''; if ((r.status === 401 || code === '4001') && attempt === 0) { Log.warn('7digital: the key was refused (HTTP ' + r.status + (code ? ', error ' + code : '') + ') — fetching the store\'s current one'); continue; } if (r.status !== 200 || !j || j.status !== 'ok') throw new Error('7digital ' + path + ': HTTP ' + r.status + (j && j.error ? ' — ' + j.error.message : '')); return j; } throw new Error('7digital: the store\'s current key was refused too'); } // A 7digital release link → { id, country } (uk.7digital.com is GB's store; www or none leaves it open) function sdRef(u) { const m = String(u || '').match(/^https?:\/\/(?:([a-z]{2})\.|www\.)?(7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\/(?:[^/?#]*-)?(\d{3,})\/?(?:[?#]|$)/i); if (!m) return null; const sub = (m[1] || '').toLowerCase(); return { id: m[3], country: /zdigital/i.test(m[2]) ? 'AU' : sub === 'uk' ? 'GB' : sub ? sub.toUpperCase() : null }; } // The release's tracks that searching for `wanted` ([{ title, artist }], this release's tracks) finds, // `album` being this release's title: // → { id, tracks: [{ id, title, artist, isrc, disc, pos, dur }] } in disc/position order async function sevendigitalRelease(releaseUrl, wanted, onProgress, album) { const ref = sdRef(releaseUrl); if (!ref) throw new Error('7digital: not a release URL (…/artist/<artist>/release/<slug>-<id>)'); const found = new Map(); // a track already found by another track's search isn't searched for (the same title, exactly: "Hero (Intro)" is not "Hero") const has = t => [...found.values()].some(x => norm(unfeat(x.title)) === norm(unfeat(t.title))); const search = async (q, country) => { const j = await sdApi('track/search', { q, country, pageSize: '50' }); ((j.searchResults || {}).searchResult || []).map(x => x.track) .filter(t => t && t.release && String(t.release.id) === ref.id) .forEach(t => found.set(String(t.id), { id: String(t.id), title: t.title || '', artist: (t.artist || {}).name || '', isrc: normalizeIsrc(t.isrc || ''), disc: t.discNumber || 1, pos: t.number || t.trackNumber, dur: t.duration > 0 ? msToMmSs(t.duration * 1000) : '', })); }; const countries = [...new Set([ref.country].concat(SD.countries).filter(Boolean))]; for (const country of countries) { let done = 0; const queue = wanted.slice(); const worker = async () => { for (let t; (t = queue.shift());) { // with the album's title (often the whole album comes back for one); after its artist; alone // (a track artist 7digital writes differently) for (const q of [album ? t.title + ' ' + album : null, t.artist ? t.artist + ' ' + t.title : null, t.title]) { if (has(t)) break; if (q) await search(q, country); } if (onProgress) onProgress(++done, wanted.length); } }; await Promise.all(Array.from({ length: 6 }, worker)); // a search takes 1–3 s, so six at a time Log.info('7digital release ' + ref.id + ' (' + country + '): ' + found.size + ' track(s) found for ' + wanted.length + ' searched'); if (found.size) break; // the release is in this country's catalogue } return { id: ref.id, tracks: [...found.values()].sort((a, b) => a.disc - b.disc || a.pos - b.pos) }; } async function fetchSevendigital(releaseUrl, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const rel = await sevendigitalRelease(releaseUrl, RELEASE.tracks.map(t => ({ title: t.title, artist: t.artist })), onProgress, RELEASE.title); rel.tracks.forEach(t => { const e = { isrc: t.isrc, title: t.title, artist: t.artist, disc: t.disc, pos: t.pos, dur: t.dur }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('7digital map hiccup for ' + e.isrc + ': ' + errText(err)); } }); const withIsrc = rel.tracks.filter(t => isValidIsrc(t.isrc)).length; Log.info('7digital: ' + withIsrc + ' of ' + rel.tracks.length + ' track(s) found carry an ISRC'); if (!rel.tracks.length) throw new Error('7digital: none of this release\'s tracks were found on release ' + rel.id); return { total: rel.tracks.length, next: null }; } async function fetchVolumo(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const idStr = String(albumId); // a 12–14-digit token is the ICPN (barcode) embedded in the canonical URL; a // shorter one is the internal album id. Both endpoints return the same shape. const path = idStr.length >= 12 ? '/album_by_icpn/' + idStr : '/albums/' + idStr; const r = await gmGet('https://volumo.com/api/v1' + path, { 'Accept': 'application/json' }); if (r.status !== 200) throw new Error('Volumo ' + r.status + ' for album ' + albumId); let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('Volumo: malformed JSON'); } const a = Array.isArray(j) ? j[0] : (j && (j.album || j)); const list = (a && a.tracks) || []; Log.info('Volumo album "' + ((a && a.title) || albumId) + '": ' + list.length + ' track(s)'); let n = 0; list.forEach((t, i) => { const mix = t.version && !/^original mix$/i.test(t.version) ? ' (' + t.version + ')' : ''; // #387 per-track URL is /track/{id} — the slug is NOT part of MB's canonical form (chaban-mb), so // don't append it; the bare id 308-redirects to the slug URL in a browser anyway. const e = { isrc: normalizeIsrc(t.isrc || ''), title: (t.title || '') + mix, artist: ((t.artists || []).concat(t.featured_artists || [])).map(x => x && x.name).filter(Boolean).join(', '), disc: t.disc_number || 1, pos: t.number || t.track_number || (i + 1), dur: t.duration ? msToMmSs(t.duration) : '', url: t.id ? 'https://volumo.com/track/' + t.id : '', }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Volumo map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, list.length); } catch (err) {} }); const withIsrc = list.filter(t => isValidIsrc(normalizeIsrc(t.isrc || ''))).length; if (!withIsrc) throw new Error('Volumo album exposed no ISRCs'); return { total: list.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ HDTRACKS (#176 — clean unauthenticated, CORS-open JSON API; one call per album returns every track's ISRC inline, no per-track fan-out. The album id is a 24-char hex ObjectId; a numeric token (UPC, from a legacy valbum_code rel or from Platform Check) is resolved to that id via the search endpoint first. Bad ids / unknown barcodes return HTTP 200, so presence is read from the JSON body, never the status code.) ═══════════════════════════════════════════════════════════════════════ */ const HD_API = 'https://hdtracks.azurewebsites.net/api/v1'; async function fetchHDtracks(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); let id = String(albumId).trim(); if (!/^[a-f0-9]{24}$/i.test(id)) { // a barcode/UPC — resolve to the ObjectId via search before fetching tracks const sr = await gmGet(HD_API + '/albums/search?q=' + encodeURIComponent(id), { 'Accept': 'application/json' }); if (sr.status !== 200) throw new Error('HDtracks search ' + sr.status + ' for ' + id); let sj; try { sj = JSON.parse(sr.responseText || 'null'); } catch (e) { throw new Error('HDtracks: malformed search JSON'); } const hit = (sj && Array.isArray(sj.albums) && sj.albums[0]) || null; if (!hit || !hit.id) throw new Error('HDtracks: no album for ' + id); id = hit.id; } const r = await gmGet(HD_API + '/album/' + id, { 'Accept': 'application/json' }); if (r.status !== 200) throw new Error('HDtracks ' + r.status + ' for album ' + id); let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('HDtracks: malformed JSON'); } if (!j || !j.id) throw new Error('HDtracks: album ' + id + ' not found'); const list = (j && j.tracks) || []; Log.info('HDtracks album "' + (j.name || id) + '": ' + list.length + ' track(s)'); let n = 0; list.forEach((t, i) => { const e = { isrc: normalizeIsrc(t.isrc || ''), title: t.name || '', artist: t.mainArtist || '', // discIndex is present on some albums; album-wide `index` is the only // reliable ordering. Disc/position are display-only here — matching is by ISRC. disc: t.discIndex || 1, pos: t.index || (i + 1), dur: t.duration ? msToMmSs(t.duration * 1000) : '', // duration is seconds (float) }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('HDtracks map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, list.length); } catch (err) {} }); const withIsrc = list.filter(t => isValidIsrc(normalizeIsrc(t.isrc || ''))).length; if (!withIsrc) throw new Error('HDtracks album exposed no ISRCs'); return { total: list.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ EDITOR MODAL — DOM ═══════════════════════════════════════════════════════════════════════ */ let overlay, modal, tbody, summaryEl, progEl, submitBtn; let _removalBusy = false; // #587: a Remove-ISRC batch is in flight let built = false; let noteEdited = false; // has the user hand-edited the edit note? const _isrcLookupCache = {}; // isrc -> SX rows (single-ISRC lookup cache) // Header line: script name, version, author, homepage — resolved from GM_info // (Violentmonkey/Greasemonkey: homepageURL; Tampermonkey: homepage) with // hard-coded fallbacks, so "undefined" never leaks into an edit note. function noteHeader() { let s = {}; try { if (typeof GM_info !== 'undefined' && GM_info.script) s = GM_info.script; } catch (e) {} const name = s.name || 'ISRC Scout'; const version = s.version || SCRIPT_VERSION; const author = s.author || 'majkinetor'; const homepage = s.homepageURL || s.homepage || SCRIPT_URL; return name + ' v' + version + ' by ' + author + ' - ' + homepage; } function defaultNote() { const subs = RELEASE ? RELEASE.tracks.filter(t => { const v = normalizeIsrc(t.pending); return v && isValidIsrc(v) && !t.existing.includes(v); }) : []; // per-source breakdown, e.g. "SoundExchange (2), Spotify (1), manual (1)" const counts = {}; subs.forEach(t => { const src = t.source || 'manual'; counts[src] = (counts[src] || 0) + 1; }); const breakdown = Object.keys(counts).sort().map(src => src + ' (' + counts[src] + ')').join(', '); const lines = [ noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, 'Added ' + subs.length + ' ISRC' + (subs.length === 1 ? '' : 's') + (breakdown ? ': ' + breakdown : ''), ]; return lines.join('\n'); } function ensureNote(force) { const ta = modal.querySelector('#ii-note-text'); if (force || (!noteEdited && !ta.value.trim())) { ta.value = defaultNote(); noteEdited = false; } } function getEditNote() { const ta = modal.querySelector('#ii-note-text'); return (noteEdited && ta.value.trim()) ? ta.value.trim() : defaultNote(); } // a "Remove ISRC" edit must NOT say "Added 0 ISRCs" — build a deletion-appropriate note function defaultRemovalNote(recs, total) { // no ISRC list (#737): each recording's edit carries this note and shows its own ISRCs return [ noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, 'Removed ' + total + ' ISRC' + (total === 1 ? '' : 's') + ' from ' + recs.length + ' recording' + (recs.length === 1 ? '' : 's'), ].join('\n'); } function getRemovalNote(recs, total) { const ta = modal.querySelector('#ii-note-text'); return (noteEdited && ta.value.trim()) ? ta.value.trim() : defaultRemovalNote(recs, total); // respect a hand-edited note } function refreshDeleteBtn() { const n = tbody.querySelectorAll('.ii-ex-del:checked').length; const btn = modal.querySelector('#ii-delete'); // #587: while a batch runs the button IS the progress counter — the per-row // repaint calls this to keep the checked count honest, and must not overwrite it. if (_removalBusy) return; btn.disabled = n === 0; btn.textContent = n ? '🗑 Delete ' + n + ' ISRC' + (n === 1 ? '' : 's') : '🗑 Delete checked'; } function buildModal() { if (built) return; built = true; overlay = document.createElement('div'); overlay.id = 'ii-overlay'; // #285: click the backdrop (or Esc) to close — safe now, since closeModal only // HIDES the window; everything entered stays in the DOM and is restored on reopen. overlay.addEventListener('click', () => closeModal()); modal = document.createElement('div'); modal.id = 'ii-modal'; modal.addEventListener('click', e => e.stopPropagation()); // clicks inside don't reach the backdrop modal.innerHTML = ` <div id="ii-hdr"> <!-- #471: no more ISRCs/Links tabs — both columns are always visible, so this is now plain status text, not a control. --> <div class="ii-hdr-status" id="ii-hdr-status"></div> <!-- centered release / artist (Apollo Zen style) --> <div class="ii-zen"><div class="ii-zen-t" id="ii-rel-title"></div><div class="ii-zen-s" id="ii-rel-sub"></div></div> <div class="ii-hicons"> <!-- #471 review: Find links / Clear / the progress readout all moved back down into the toolbar (#ii-tools) — majkinetor wanted them where they used to live, not up here. Only Log + Bulk/Export + config + maximize stay in the header; name/version/Help live in the config window. --> <button class="ii-hlog" id="ii-log-link" type="button" title="Diagnostic log">log</button> <button class="ii-hico" id="ii-bulk-toggle" type="button" title="Bulk / Export">▤</button> <button class="ii-hico" id="ii-setup-toggle" type="button" title="Settings & help">${MBU_CFG_ICON}</button> <button class="ii-hico" id="ii-maximize-toggle" type="button" title="Maximize">⛶</button> </div> </div> <div class="ii-pane" id="ii-setup-pane"> <!-- #301: standard config-window header — icon · name · version · ? Help (no ✕); Log is in the main header, as on Platform Check --> ${mbuCfgHeader({ script: 'isrc_scout', name: 'ISRC Scout', version: SCRIPT_VERSION, icon: '<svg viewBox="0 0 128 128" aria-hidden="true"><path d="M64 64 L64 24 A40 40 0 0 1 99 84 Z" fill="#d8c8f2"/>' + '<g fill="none" stroke="currentColor" stroke-width="9"><circle cx="64" cy="64" r="40"/>' + '<circle cx="64" cy="64" r="9" fill="currentColor" stroke="none"/></g></svg>', })} <div class="ii-cfg-grp">MusicBrainz</div> <div class="ii-authstate" id="ii-auth-state"></div> <div class="row" style="margin-top:8px; flex-wrap:nowrap"> <button class="ii-tbtn primary" id="ii-authorize" style="flex-shrink:0">Authorize</button> <input type="text" id="ii-oauth-code" placeholder="Auto-fills on success — paste the code here only if the tab fails to close" autocomplete="off" style="flex:1; min-width:180px"> <button class="ii-tbtn ghost" id="ii-signout" style="flex-shrink:0; display:none">Sign out</button> </div> <div class="ii-help"> Click <b>Authorize</b> → approve in the MusicBrainz tab → it captures the code and closes itself. If the tab can't close on its own, paste the code it shows into the box above (Enter to submit). </div> <div class="ii-cfg-grp" style="margin-top:16px">Settings</div> <div> <label style="display:block; font-size:11.5px; color:var(--mbu-text-dim); margin-bottom:5px; font-weight:600">Import-source buttons</label> <label style="display:inline-flex; align-items:center; gap:5px; font-size:12px; margin-right:16px; cursor:pointer"><input type="checkbox" id="ii-show-icons">Show icons</label> <label style="display:inline-flex; align-items:center; gap:5px; font-size:12px; cursor:pointer"><input type="checkbox" id="ii-show-text">Show text</label> </div> <div style="margin-top:12px"> <label style="display:inline-flex; align-items:flex-start; gap:6px; font-size:12px; cursor:pointer"> <input type="checkbox" id="ii-track-data" style="margin-top:2px"> <span>Show ISRCs and links on the tracklist<br> <span style="color:var(--mbu-text-weak); font-size:11px">The release page's tracklist gets an ISRC and a Links column.</span></span> </label> </div> <div style="margin-top:12px"> <label style="display:inline-flex; align-items:flex-start; gap:6px; font-size:12px; cursor:pointer"> <input type="checkbox" id="ii-rg-providers" style="margin-top:2px"> <span>Use providers from the whole release group<br> <span style="color:var(--mbu-text-weak); font-size:11px">Fill missing Deezer / Tidal / Bandcamp / … album links from sibling releases in the release group — recordings are shared, so a link on any edition resolves here. Costs one extra lookup.</span></span> </label> </div> <div style="margin-top:12px"> <label style="display:inline-flex; align-items:flex-start; gap:6px; font-size:12px; cursor:pointer"> <input type="checkbox" id="ii-ignore-pc" style="margin-top:2px"> <span>Ignore Platform Check link confidence<br> <span style="color:var(--mbu-text-weak); font-size:11px">Import from a Platform-Check link even when PC withheld it for a barcode/format mismatch. Off by default — a mismatch can mean PC matched the wrong release, so its ISRCs would be wrong (#314).</span></span> </label> </div> <div style="margin-top:12px"> <label style="display:inline-flex; align-items:flex-start; gap:6px; font-size:12px; cursor:pointer"> <input type="checkbox" id="ii-auto-repeat" style="margin-top:2px"> <span>Repeat failed submits up to <input type="number" id="ii-ar-min" min="1" max="240" style="width:46px"> minutes or <input type="number" id="ii-ar-times" min="1" max="100" style="width:46px"> times<br> <span style="color:var(--mbu-text-weak); font-size:11px">When MusicBrainz is busy (503) or the connection drops, the ISRC and link submits are tried again by themselves, the attempts spread over the minutes.</span></span> </label> </div> <div style="margin-top:12px; font-size:12px"> <label style="display:inline-flex; align-items:center; gap:6px; cursor:pointer">Spotify ISRC source <select id="ii-sp-source" style="font-size:12px"> <option value="molla">molla</option> <option value="isrchunt">ISRC Hunt</option> </select> </label> <div style="color:var(--mbu-text-weak); font-size:11px; margin-top:2px">Spotify's own ISRC API needs a paid developer account, so the lookup goes through one of these services. If one is down or rate-limited, switch to the other (#603).</div> </div> <div class="ii-cfg-grp" style="margin-top:16px">More</div> <a class="ii-cfg-lnk" id="ii-history" target="_blank" rel="noopener" href="${MB_ROOT}/search/edits?auto_edit_filter=&order=desc&negation=0&combinator=and&conditions.0.field=type&conditions.0.operator=%3D&conditions.0.args=76&conditions.0.args=78&conditions.1.field=editor&conditions.1.operator=me&conditions.1.name=&conditions.1.args.0=" title="Your Add/Remove ISRC edits on MusicBrainz">🕓 My ISRC edits on MusicBrainz</a> </div> <div class="ii-pane" id="ii-bulk-pane"> <h3><button class="ii-pane-x" title="Close">✕</button>Bulk / Export</h3> <!-- #471: both sections always shown now (no ISRCs/Links scope toggle) --> <div class="ii-cfg-grp">ISRCs</div> <div> <div class="ii-help" style="margin-top:0"> Paste one ISRC per line, in track order (blank line = skip a track). Lines like <code>3=USABC1234567</code> or <code>USABC1234567 | 1.3</code> target a specific track number. Or paste JSON exported below. </div> <textarea id="ii-bulk-text" placeholder="USABC1234567 USABC1234568 ..."></textarea> <div class="row" style="margin-top:8px"> <button class="ii-tbtn primary" id="ii-bulk-apply">Apply to empty fields</button> <button class="ii-tbtn" id="ii-bulk-apply-all">Apply (overwrite)</button> <button class="ii-tbtn" id="ii-export-text">Export text</button> <button class="ii-tbtn" id="ii-export-json">Export JSON</button> </div> </div> <div class="ii-cfg-grp" style="margin-top:16px">Links</div> <div> <div class="ii-help" style="margin-top:0"> Export this release's recording streaming links — what's already linked plus what <b>Find links</b> resolved (Deezer / Tidal / Bandcamp / Apple Music). Copied to the clipboard. </div> <div class="row" style="margin-top:8px"> <button class="ii-tbtn" id="ii-link-export-csv">Export CSV</button> <button class="ii-tbtn" id="ii-link-export-json">Export JSON</button> <button class="ii-tbtn" id="ii-link-copy-urls">Copy URLs</button> </div> <div class="ii-help" id="ii-link-export-note" style="margin-top:8px"></div> </div> </div> <div class="ii-pane" id="ii-note-pane"> <h3><button class="ii-pane-x" title="Close">✕</button>Edit note <button class="ii-tbtn" id="ii-note-reset" style="padding:2px 9px;font-size:11px">Reset to default</button> </h3> <textarea id="ii-note-text" placeholder="Optional edit note attached to the submission…"></textarea> <div class="ii-help" style="margin-top:0">Attached to every ISRC add/remove you submit. Auto-filled with the script name + counts; edit freely (your text is kept until you Reset).</div> </div> <!-- #471 review: Find links / Clear moved back here from the header (pinned top-right, where Clear alone used to sit), and the progress readout is back right of the "+" url-add button — both restored to where majkinetor said they used to live. --> <div id="ii-tools"> <span class="ii-sx-group" id="ii-sx-group"> <button class="ii-tbtn sx" id="ii-sx-all" title="Search every track on SoundExchange">⟳ SoundExchange</button> <button class="ii-exact-toggle" id="ii-exact-toggle" type="button" title="Exact-match options" aria-expanded="false">exact <span class="ii-exact-car">▾</span></button> <span class="ii-exact-set" id="ii-exact-set" title="Wrap the SoundExchange query in quotes for an exact match"> <label><input type="checkbox" id="ii-ex-title">title</label> <label><input type="checkbox" id="ii-ex-artist">artist</label> <label><input type="checkbox" id="ii-ex-release">release</label> </span> </span> <button class="ii-tbtn dz" id="ii-dz-all" title="Import ISRCs from Deezer"><span class="ii-bico">${SRC_ICON.dz}</span><span class="ii-blabel">Deezer</span></button> <button class="ii-tbtn sp" id="ii-sp-all" title="Import ISRCs from Spotify"><span class="ii-bico">${SRC_ICON.sp}</span><span class="ii-blabel">Spotify</span></button> <button class="ii-tbtn bp" id="ii-bp-all" title="Import ISRCs from Beatport"><span class="ii-bico">${SRC_ICON.bp}</span><span class="ii-blabel">Beatport</span></button> <button class="ii-tbtn td" id="ii-td-all" title="Import ISRCs from Tidal"><span class="ii-bico">${SRC_ICON.td}</span><span class="ii-blabel">Tidal</span></button> <button class="ii-tbtn vo" id="ii-vo-all" title="Import ISRCs from Volumo"><span class="ii-bico">${SRC_ICON.vo}</span><span class="ii-blabel">Volumo</span></button> <button class="ii-tbtn hd" id="ii-hd-all" title="Import ISRCs from HDtracks"><span class="ii-bico">${SRC_ICON.hd}</span><span class="ii-blabel">HDtracks</span></button> <button class="ii-tbtn qz" id="ii-qz-all" title="Import ISRCs from Qobuz (needs Qobuz login in Platform Check)"><span class="ii-bico">${SRC_ICON.qz}</span><span class="ii-blabel">Qobuz</span></button> <button class="ii-tbtn am" id="ii-am-all" title="Import ISRCs from Apple Music"><span class="ii-bico">${SRC_ICON.am}</span><span class="ii-blabel">Apple</span></button> <button class="ii-tbtn sc" id="ii-sc-all" title="Import ISRCs from a SoundCloud set"><span class="ii-bico">${SRC_ICON.sc}</span><span class="ii-blabel">SoundCloud</span></button> <button class="ii-tbtn ak" id="ii-ak-all" title="Import ISRCs from Audiomack"><span class="ii-bico">${SRC_ICON.ak}</span><span class="ii-blabel">Audiomack</span></button> <button class="ii-tbtn sd" id="ii-sd-all" title="Import ISRCs from 7digital"><span class="ii-bico">${SRC_ICON.sd}</span><span class="ii-blabel">7digital</span></button> <button class="ii-tbtn all" id="ii-all-all" title="Check every track’s ISRC on every available provider (All)"><span class="ii-bico">All</span><span class="ii-blabel">All</span></button> <span class="ii-urladd" id="ii-urladd"> <button class="ii-urladd-btn" id="ii-url-btn" type="button" title="Paste a streaming URL (Deezer / Spotify / Beatport / Tidal / Volumo / HDtracks / Qobuz) — auto-detected and imported">+</button> <input class="ii-urladd-input" type="text" id="ii-url-input" placeholder="Paste a streaming album URL…" autocomplete="off"> </span> <span class="ii-prog" id="ii-prog"></span> <button class="ii-tbtn sx" id="ii-links-btn" type="button" title="Resolve each track on Deezer / Tidal / Bandcamp and show what's linkable — grey = already linked in MB, colour = found and addable"><svg class="ii-flat-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.5-1.5"/></svg> Find links</button> <!-- #680: Match imports ISRCs, then runs Find links, so it sits after it --> <button class="ii-tbtn sx" id="ii-find-all" type="button" title="Import ISRCs from the fastest source this release links (the next one if it fails or gives none), then Find links">⚡ Match</button> <span class="ii-clear-wrap"> <button class="ii-clear-toggle" id="ii-clear-toggle" type="button" title="Clear…" aria-expanded="false">▾</button> <div class="ii-clear-menu" id="ii-clear-menu"> <button type="button" id="ii-clear-all">Clear All</button> <button type="button" id="ii-clear-links">Clear Links</button> <button type="button" id="ii-clear-isrcs">Clear ISRCs</button> </div> </span> </div> <!-- floating provider menu (#181) — opened from any per-track button's ▾ --> <div class="ii-prov-menu" id="ii-prov-menu"></div> <div id="ii-body"> <table id="ii-table"> <!-- #471: ISRC and Links columns are always both visible (no more colgroup swap per scope) --> <colgroup id="ii-colgroup"> <col style="width:32px"><col><col style="width:44px"><col style="width:108px"><col style="width:340px"><col style="width:160px"><col style="width:140px"> </colgroup> <thead><tr> <th>#</th><th>Track</th><th></th> <th><label class="ii-ex-all-lbl" title="Select all existing ISRCs for removal"><input type="checkbox" id="ii-ex-all">ISRC: linked</label></th> <th>ISRC: new</th> <th>Links: linked</th> <th>Links: new</th> </tr></thead> <tbody id="ii-tbody"></tbody> </table> </div> <div id="ii-foot"> <!-- #471: both summaries always shown now (no ISRCs/Links scope toggle) --> <span class="ii-summary" id="ii-summary"></span> <span class="ii-summary" id="ii-summary-links"></span> <button class="ii-tbtn" id="ii-delete" title="Delete the checked existing ISRCs" disabled>🗑 Delete ISRC</button> <button class="ii-tbtn ghost" id="ii-note-toggle" title="Edit note">✎ Edit note</button> <button class="ii-tbtn primary" id="ii-submit" title="Submit everything pending — entered ISRCs and resolved streaming links — in one go, then close">Submit to MusicBrainz</button> </div> `; document.body.appendChild(overlay); document.body.appendChild(modal); buildSxPanel(); tbody = modal.querySelector('#ii-tbody'); summaryEl = modal.querySelector('#ii-summary'); progEl = modal.querySelector('#ii-prog'); submitBtn = modal.querySelector('#ii-submit'); // #341: right-click a LINKED link toggles its "ended" flag — Ctrl = whole track, Alt = that // provider everywhere; right-clicking an already-ended (faded) link reverts it. MIDDLE-click // (with the same modifiers) does the actual removal (#301). Left-click just opens the link. tbody.addEventListener('contextmenu', e => { const a = e.target.closest('.ii-tl-linked .ii-tl.linked'); if (!a) return; e.preventDefault(); const tr = a.closest('tr[data-idx]'); if (!tr) return; const idx = +tr.dataset.idx; if (e.ctrlKey || e.metaKey) TrackLinks.endTrack(idx, a.dataset.code); else if (e.altKey) TrackLinks.endProvider(a.dataset.code, idx); else TrackLinks.endOne(idx, a.dataset.code); }); tbody.addEventListener('auxclick', e => { if (e.button !== 1) return; // middle button const a = e.target.closest('.ii-tl-linked .ii-tl.linked'); if (!a) return; e.preventDefault(); const tr = a.closest('tr[data-idx]'); if (!tr) return; const idx = +tr.dataset.idx; if (e.ctrlKey || e.metaKey) TrackLinks.removeTrack(idx); else if (e.altKey) TrackLinks.removeProvider(a.dataset.code); else TrackLinks.removeOne(idx, a.dataset.code); }); // stop the browser opening the link in a new tab on middle-click (fires on mousedown in some browsers) tbody.addEventListener('mousedown', e => { if (e.button === 1 && e.target.closest('.ii-tl-linked .ii-tl.linked')) e.preventDefault(); }); // Esc closes the modal (no ✕ in the header) — but first let an open pane close, // and ignore it while typing in a field. document.addEventListener('keydown', e => { if (e.key !== 'Escape' || !modal.classList.contains('open')) return; if (modal.querySelector('#ii-all-pop')) { closeAllPop(); return; } // #643: the All comparison first const openPane = modal.querySelector('.ii-pane.open'); if (openPane) { openPane.classList.remove('open'); return; } const a = document.activeElement; if (a && /^(INPUT|TEXTAREA)$/.test(a.tagName)) return; closeModal(); }); modal.querySelector('#ii-setup-toggle').addEventListener('click', () => togglePane('ii-setup-pane')); modal.querySelector('#ii-bulk-toggle').addEventListener('click', () => togglePane('ii-bulk-pane')); modal.querySelector('#ii-maximize-toggle').addEventListener('click', toggleMaximize); modal.querySelector('#ii-links-btn').addEventListener('click', () => TrackLinks.resolve()); // #219: resolve candidates modal.querySelector('#ii-find-all').addEventListener('click', findEverything); // #680 // #406: no separate "Add links" button — the single Submit button below adds every // resolved link together with any pending ISRCs (right-click a candidate still adds one). modal.querySelector('#ii-sx-all').addEventListener('click', runSxAll); // bulk SoundExchange — unchanged (#181) // #471: "Clear" is now a small menu (Clear Links / Clear ISRCs / Clear All) — // it used to only clear entered ISRCs, but now that resolved-but-unadded // link candidates are equally visible, clearing needs to say which. const clearToggle = modal.querySelector('#ii-clear-toggle'), clearMenu = modal.querySelector('#ii-clear-menu'); clearToggle.addEventListener('click', () => { const open = clearMenu.classList.toggle('open'); clearToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); }); modal.querySelector('#ii-clear-links').addEventListener('click', () => { clearMenu.classList.remove('open'); TrackLinks.clearResolved(); toast('Cleared resolved links'); }); modal.querySelector('#ii-clear-isrcs').addEventListener('click', () => { clearMenu.classList.remove('open'); clearPending(); }); modal.querySelector('#ii-clear-all').addEventListener('click', () => { clearMenu.classList.remove('open'); TrackLinks.clearResolved(); clearPending(); toast('Cleared entered ISRCs and resolved links'); }); document.addEventListener('mousedown', e => { if (!clearMenu.classList.contains('open')) return; if (e.target.closest('.ii-clear-wrap')) return; clearMenu.classList.remove('open'); clearToggle.setAttribute('aria-expanded', 'false'); }); applyColWidths(); wireColumnResize(); wireModalDragResize(); // Track-ISRC-provider menu (#181): the per-track [SX] buttons carry a ▾ that // opens this shared menu of providers available for THIS release; picking one // re-skins EVERY per-track button (global, not persisted). The bulk // SoundExchange button above is intentionally left alone. document.addEventListener('mousedown', e => { const menu = modal.querySelector('#ii-prov-menu'); if (!menu || !menu.classList.contains('open')) return; if (e.target.closest('#ii-prov-menu') || e.target.closest('.ii-sxprov')) return; closeProvMenu(); }); // the shared log window — opened from the config window's "Log" link (#301, #701) modal.querySelector('#ii-log-link').addEventListener('click', () => { if (Log.isOpen()) Log.close(); else Log.open(); }); // SX exact toggles + their collapsible container (collapsed state persisted) const sxGroup = modal.querySelector('#ii-sx-group'); const exactToggle = modal.querySelector('#ii-exact-toggle'); // reflect "any exact option active" on the toggle, so it's visible even when collapsed const refreshExactToggle = () => { const anyOn = sxExact.title || sxExact.artist || sxExact.release; exactToggle.classList.toggle('on', !!anyOn); exactToggle.title = anyOn ? 'Exact-match active: ' + ['title', 'artist', 'release'].filter(k => sxExact[k]).join(', ') : 'Exact-match options'; }; const applyExactCollapsed = (collapsed) => { sxGroup.classList.toggle('ii-collapsed', collapsed); exactToggle.setAttribute('aria-expanded', String(!collapsed)); }; applyExactCollapsed(store.get('sx_exact_collapsed', true)); // collapsed by default to keep the toolbar compact exactToggle.addEventListener('click', () => { const collapsed = !sxGroup.classList.contains('ii-collapsed'); applyExactCollapsed(collapsed); store.set('sx_exact_collapsed', collapsed); }); [['ii-ex-title', 'title'], ['ii-ex-artist', 'artist'], ['ii-ex-release', 'release']].forEach(([id, key]) => { const cb = modal.querySelector('#' + id); cb.checked = sxExact[key]; cb.addEventListener('change', () => { sxExact[key] = cb.checked; saveSxExact(); refreshExactToggle(); Log.info('SX exact ' + key + ' = ' + cb.checked); }); }); refreshExactToggle(); // Import-source buttons: independently show icons and/or text labels // (persisted; default icons-only). Never both off — re-check the last one. const tools = modal.querySelector('#ii-tools'); const cbIcons = modal.querySelector('#ii-show-icons'); const cbText = modal.querySelector('#ii-show-text'); const applySrcDisp = () => { tools.classList.toggle('ii-show-icons', cbIcons.checked); tools.classList.toggle('ii-show-text', cbText.checked); }; cbIcons.checked = store.get('src_show_icons', true); cbText.checked = store.get('src_show_text', false); applySrcDisp(); const onSrcDispChange = changed => { if (!cbIcons.checked && !cbText.checked) { changed.checked = true; } // keep at least one visible store.set('src_show_icons', cbIcons.checked); store.set('src_show_text', cbText.checked); applySrcDisp(); Log.info('Source buttons: icons=' + cbIcons.checked + ' text=' + cbText.checked); }; cbIcons.addEventListener('change', () => onSrcDispChange(cbIcons)); cbText.addEventListener('change', () => onSrcDispChange(cbText)); // #302: pull provider links from the whole release group (opt-in) const cbRg = modal.querySelector('#ii-rg-providers'); if (cbRg) { cbRg.checked = rgProvidersEnabled(); cbRg.addEventListener('change', () => { store.set('rg_providers', cbRg.checked); Log.info('Release-group providers: ' + (cbRg.checked ? 'on' : 'off') + (cbRg.checked ? ' — reopen / reload to rescan' : '')); }); } // #749: ISRCs and links on the release page's tracklist const cbTd = modal.querySelector('#ii-track-data'); if (cbTd) { cbTd.checked = trackDataEnabled(); cbTd.addEventListener('change', () => { store.set('track_data', cbTd.checked); showTrackData(); Log.info('ISRCs and links on the tracklist: ' + (cbTd.checked ? 'on' : 'off')); }); } // #314: respect / ignore Platform Check's barcode-mismatch link confidence const cbPc = modal.querySelector('#ii-ignore-pc'); if (cbPc) { cbPc.checked = ignorePcConfidence(); cbPc.addEventListener('change', () => { store.set('ignore_pc_confidence', cbPc.checked); Log.info('Ignore Platform Check link confidence: ' + (cbPc.checked ? 'on — barcode/format-mismatched PC links will be used' : 'off — PC-withheld links are skipped')); }); } // #748: repeat failed submits const cbAr = modal.querySelector('#ii-auto-repeat'), arMin = modal.querySelector('#ii-ar-min'), arTimes = modal.querySelector('#ii-ar-times'); if (cbAr) { cbAr.checked = repeatOn(); arMin.value = repeatMinutes(); arTimes.value = repeatTimes(); cbAr.addEventListener('change', () => { store.set('auto_repeat', cbAr.checked); Log.info('Repeat failed submits: ' + (cbAr.checked ? 'on' : 'off')); }); const num = (el, k, d, max) => el.addEventListener('change', () => { const v = parseInt(el.value, 10); const n = v >= 1 ? Math.min(v, max) : d; el.value = n; store.set(k, n); Log.info('Repeat failed submits: up to ' + repeatMinutes() + ' min or ' + repeatTimes() + ' times'); }); num(arMin, 'auto_repeat_min', 10, 240); num(arTimes, 'auto_repeat_times', 10, 100); } // #603: which service resolves Spotify ISRCs const selSp = modal.querySelector('#ii-sp-source'); if (selSp) { selSp.value = spotifyIsrcSource(); selSp.addEventListener('change', () => { store.set('spotify_isrc_source', selSp.value); Log.info('Spotify ISRC source: ' + (selSp.value === 'isrchunt' ? 'ISRC Hunt' : 'molla')); }); } modal.querySelector('#ii-dz-all').addEventListener('click', runDeezer); modal.querySelector('#ii-sp-all').addEventListener('click', runSpotify); modal.querySelector('#ii-bp-all').addEventListener('click', runBeatport); modal.querySelector('#ii-td-all').addEventListener('click', runTidal); modal.querySelector('#ii-vo-all').addEventListener('click', runVolumo); modal.querySelector('#ii-hd-all').addEventListener('click', runHDtracks); modal.querySelector('#ii-qz-all').addEventListener('click', runQobuz); modal.querySelector('#ii-am-all').addEventListener('click', runApple); modal.querySelector('#ii-sc-all').addEventListener('click', runSoundcloud); modal.querySelector('#ii-ak-all').addEventListener('click', runAudiomack); modal.querySelector('#ii-sd-all').addEventListener('click', runSevendigital); // #643: All from the toolbar — every track on every provider, as a right-click on a row's All modal.querySelector('#ii-all-all').addEventListener('click', () => { Log.info('All: run from the toolbar'); if (trackProvAvailable('all')) setTrackProvider('all', { quiet: true }); runAllTracks(); }); // Unified "paste a URL" control (#180) — apollo-style unroll. Click the + // to reveal the input; paste any streaming album URL; on Enter the platform // is auto-detected and imported. Replaces the per-provider ▾ submenus. const urlWrap = modal.querySelector('#ii-urladd'); const urlBtn = modal.querySelector('#ii-url-btn'); const urlInput = modal.querySelector('#ii-url-input'); const openUrlAdd = () => { urlWrap.classList.add('open'); _setTimeout(() => urlInput.focus(), 0); }; const closeUrlAdd = () => { urlWrap.classList.remove('open'); urlInput.value = ''; reflectDetectedSource(''); }; urlBtn.addEventListener('click', () => urlWrap.classList.contains('open') ? closeUrlAdd() : openUrlAdd()); urlInput.addEventListener('input', () => reflectDetectedSource(urlInput.value)); urlInput.addEventListener('keydown', e => { if (e.key === 'Enter') { submitUrlAdd(urlInput.value); closeUrlAdd(); } else if (e.key === 'Escape') closeUrlAdd(); }); // Auto-import on paste — no Enter needed when a recognized album URL is pasted // (#300). Unrecognized text pastes normally so it can still be edited + Entered. urlInput.addEventListener('paste', e => { const v = ((e.clipboardData && e.clipboardData.getData('text')) || '').trim(); if (v && detectSource(v)) { e.preventDefault(); reflectDetectedSource(v); submitUrlAdd(v); closeUrlAdd(); } }); // collapse on click-outside (only when empty, so a half-typed URL isn't lost) document.addEventListener('mousedown', e => { if (!urlWrap.classList.contains('open')) return; if (urlWrap.contains(e.target)) return; if (!urlInput.value.trim()) closeUrlAdd(); }); submitBtn.addEventListener('click', doSubmitAll); // delete-existing wiring (checkboxes are delegated) modal.querySelector('#ii-delete').addEventListener('click', doDelete); tbody.addEventListener('change', e => { if (!e.target.classList.contains('ii-ex-del')) return; e.target.closest('.ii-ex-item').classList.toggle('del', e.target.checked); refreshDeleteBtn(); }); modal.querySelector('#ii-ex-all').addEventListener('change', e => { const on = e.target.checked; tbody.querySelectorAll('.ii-ex-del').forEach(cb => { cb.checked = on; cb.closest('.ii-ex-item').classList.toggle('del', on); }); refreshDeleteBtn(); }); // edit-note pane wiring modal.querySelector('#ii-note-toggle').addEventListener('click', () => { ensureNote(); togglePane('ii-note-pane'); }); modal.querySelector('#ii-note-reset').addEventListener('click', () => { noteEdited = false; ensureNote(true); }); modal.querySelector('#ii-note-text').addEventListener('input', () => { noteEdited = true; }); // setup pane wiring modal.querySelector('#ii-authorize').addEventListener('click', onAuthorize); modal.querySelector('#ii-signout').addEventListener('click', () => { Auth.signOut(); refreshAuthState(); toast('Signed out'); }); const codeInput = modal.querySelector('#ii-oauth-code'); const tryCode = () => { const c = codeInput.value.trim(); if (c) { codeInput.value = ''; exchangeAndFinish(c, 'pasted'); } }; codeInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); tryCode(); } }); codeInput.addEventListener('paste', () => _setTimeout(tryCode, 50)); // bulk pane wiring modal.querySelector('#ii-bulk-apply').addEventListener('click', () => applyBulk(false)); modal.querySelector('#ii-bulk-apply-all').addEventListener('click', () => applyBulk(true)); modal.querySelector('#ii-export-text').addEventListener('click', exportText); modal.querySelector('#ii-export-json').addEventListener('click', exportJson); // #301: link export (Links scope) modal.querySelector('#ii-link-export-csv').addEventListener('click', () => exportLinks('csv')); modal.querySelector('#ii-link-export-json').addEventListener('click', () => exportLinks('json')); modal.querySelector('#ii-link-copy-urls').addEventListener('click', () => exportLinks('urls')); // each sub-pane closes via its own ✕ (next to the title) modal.querySelectorAll('.ii-pane-x').forEach(b => b.addEventListener('click', () => b.closest('.ii-pane').classList.remove('open'))); } function togglePane(id) { modal.querySelectorAll('.ii-pane').forEach(p => { if (p.id === id) p.classList.toggle('open'); else p.classList.remove('open'); }); } // #564-adjacent (majkinetor): "lets also add click to 'see log' message like in // Fusion, so it opens a log window". Opening is not toggling — a status text // that says "see Log" must SHOW the log, and clicking it a second time closing // the thing it just offered would be its own small bug. function showPane(id) { modal.querySelectorAll('.ii-pane').forEach(p => p.classList.toggle('open', p.id === id)); const p = modal.querySelector('#' + id); if (p) try { p.scrollIntoView({ block: 'nearest' }); } catch (e) {} } // #471: ISRCs and Links used to be exclusive tabs, each hiding the other's table // columns — "I often have to switch back to ISRC tab before finding links". Both // sets of columns, both toolbars, and both Bulk/Export sections are now always // visible at once (see #ii-colgroup, #ii-tools and #ii-bulk-pane above) — there's // no separate "scope" left to track. // #471 ("make table columns resizable") — a drag handle on the right edge of // every header cell except the first (#, 32px, not worth resizing) and the // last (Links: new — nothing to its right within the table). Dragging changes // ONLY that column's <col> width (table-layout:fixed), so #ii-body scrolls // horizontally past a widened table instead of squeezing a neighbor — the // simplest model, and consistent with how a spreadsheet resizes a column. // Widths persist per-browser via GM storage, keyed by column index. const COL_MIN = 28; function loadColWidths() { try { return JSON.parse(store.get('col_widths', '') || '{}') || {}; } catch (e) { return {}; } } function saveColWidths(w) { store.set('col_widths', JSON.stringify(w)); } function applyColWidths() { const w = loadColWidths(); const cols = modal.querySelectorAll('#ii-colgroup col'); Object.keys(w).forEach(i => { if (cols[i]) cols[i].style.width = w[i] + 'px'; }); } function wireColumnResize() { const ths = [...modal.querySelectorAll('#ii-table thead th')]; const cols = [...modal.querySelectorAll('#ii-colgroup col')]; ths.forEach((th, i) => { if (i === 0 || i === ths.length - 1 || th.querySelector('.ii-col-resize')) return; const handle = document.createElement('span'); handle.className = 'ii-col-resize'; th.appendChild(handle); let dragging = false, startX = 0, startW = 0; handle.addEventListener('mousedown', e => { dragging = true; handle.classList.add('dragging'); startX = e.clientX; startW = cols[i].getBoundingClientRect().width; e.preventDefault(); e.stopPropagation(); }); window.addEventListener('mousemove', e => { if (!dragging) return; cols[i].style.width = Math.max(COL_MIN, Math.round(startW + (e.clientX - startX))) + 'px'; }); window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; handle.classList.remove('dragging'); const w = loadColWidths(); w[i] = parseInt(cols[i].style.width, 10); saveColWidths(w); }); }); } // #471 ("Lets make window movable, resizable and with maximize button") — same // pattern Falcon's panel already uses: drag the header to move, CSS `resize` // for free corner-resize, and a maximize toggle that detaches from centering // and restores the exact prior box on toggle-back. function wireModalDragResize() { const hdr = modal.querySelector('#ii-hdr'); let dragging = false, dx = 0, dy = 0; hdr.addEventListener('mousedown', e => { if (e.target.closest('button, a, input')) return; dragging = true; const r = modal.getBoundingClientRect(); modal.style.transform = 'none'; modal.style.left = r.left + 'px'; modal.style.top = r.top + 'px'; dx = e.clientX - r.left; dy = e.clientY - r.top; e.preventDefault(); }); window.addEventListener('mousemove', e => { if (!dragging) return; modal.style.left = Math.max(0, Math.min(window.innerWidth - 80, e.clientX - dx)) + 'px'; modal.style.top = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - dy)) + 'px'; }); window.addEventListener('mouseup', () => { dragging = false; }); } let _maxed = false, _prevBox = null; // #484: pulled out of toggleMaximize() so openModal() can reapply it — pinModalToViewport() // (the mobile-viewport handling below) clears left/top/width/height/max-width/max-height/ // transform on every open on a DESKTOP viewport (clearModalViewportPin(), so the mobile pin // never lingers once the window widens back out) — the exact same properties this sets. That // silently wiped the maximized size on every reopen while _maxed stayed true, so the toggle // button kept showing "Restore" for a window that had quietly gone back to its normal size. function applyMaximizedStyle() { modal.style.left = '2vw'; modal.style.top = '2vh'; modal.style.transform = 'none'; modal.style.width = '96vw'; modal.style.maxWidth = '96vw'; modal.style.height = '96vh'; modal.style.maxHeight = '96vh'; } function toggleMaximize() { const btn = modal.querySelector('#ii-maximize-toggle'); if (!_maxed) { _prevBox = { left: modal.style.left, top: modal.style.top, width: modal.style.width, height: modal.style.height, maxWidth: modal.style.maxWidth, maxHeight: modal.style.maxHeight, transform: modal.style.transform }; applyMaximizedStyle(); _maxed = true; if (btn) { btn.textContent = '❐'; btn.title = 'Restore'; } } else { if (_prevBox) Object.assign(modal.style, _prevBox); _maxed = false; if (btn) { btn.textContent = '⛶'; btn.title = 'Maximize'; } } } // Some tablets render MusicBrainz's desktop layout wider than the browser's // layout viewport, so the page overflows and the browser zooms-to-fit. A // position:fixed modal is then sized/anchored to the (narrow) layout viewport // and lands in a corner at ~64% width. Pin the modal + overlay to the VISUAL // viewport instead — it reflects what's actually on screen (zoom scale + // offset). CSS handles the normal case; this only engages on narrow viewports // (where the mobile layout is active) and is a no-op without VisualViewport. let _vvSync = null; function pinModalToViewport() { const vv = window.visualViewport; if (!vv || !window.matchMedia('(max-width: 700px)').matches) { clearModalViewportPin(); return; } const w = Math.min(vv.width * 0.96, 1080), h = vv.height * 0.96; const set = (el, props) => { for (const k in props) el.style.setProperty(k, props[k], 'important'); }; set(overlay, { left: vv.offsetLeft + 'px', top: vv.offsetTop + 'px', width: vv.width + 'px', height: vv.height + 'px' }); set(modal, { left: (vv.offsetLeft + (vv.width - w) / 2) + 'px', top: (vv.offsetTop + (vv.height - h) / 2) + 'px', width: w + 'px', 'max-width': 'none', height: h + 'px', 'max-height': 'none', transform: 'none', }); } function clearModalViewportPin() { ['left', 'top', 'width', 'max-width', 'height', 'max-height', 'transform'].forEach(p => modal.style.removeProperty(p)); ['left', 'top', 'width', 'height'].forEach(p => overlay.style.removeProperty(p)); } function openModal() { buildModal(); overlay.classList.add('open'); modal.classList.add('open'); pinModalToViewport(); if (_maxed) applyMaximizedStyle(); // #484: pinModalToViewport() just cleared it on a desktop viewport if (window.visualViewport && !_vvSync) { _vvSync = () => { pinModalToViewport(); if (_maxed) applyMaximizedStyle(); }; // #484: same wipe risk on resize/scroll while maximized window.visualViewport.addEventListener('resize', _vvSync); window.visualViewport.addEventListener('scroll', _vvSync); } refreshAuthState(); if (!RELEASE) { tbody.innerHTML = '<tr><td colspan="7" style="padding:20px;color:var(--mbu-text-weak)">Loading release…</td></tr>'; fetchRelease() .then(renderTracks) // existing track links ride along on the release fetch (recording-level-rels) .catch(err => { tbody.innerHTML = '<tr><td colspan="7" style="padding:20px;color:var(--mbu-error)">Failed to load release: ' + esc(err.message) + '</td></tr>'; }); } else { renderTracks(); } } function closeModal() { // #587 — a removal batch is still POSTing; closing hides the only progress // there is, so make it a decision rather than an accident. if (_removalBusy && !confirm('Remove-ISRC edits are still being submitted.\n\nClose anyway? The submissions already sent stay submitted; the rest will not be sent.')) return; abortSxWork('window closed'); // don't leave batched SX requests running in the background (#127) try { showTrackData(); } catch (e) { /* #749: links added, ended or removed in the editor show on the tracklist */ } if (_vvSync && window.visualViewport) { window.visualViewport.removeEventListener('resize', _vvSync); window.visualViewport.removeEventListener('scroll', _vvSync); _vvSync = null; } clearModalViewportPin(); overlay.classList.remove('open'); modal.classList.remove('open'); } function refreshAuthState() { const el = modal.querySelector('#ii-auth-state'); const pane = modal.querySelector('#ii-setup-pane'); const authed = Auth.isAuthorized(); // when authorized show Sign out; when not, show the code field in its place const code = modal.querySelector('#ii-oauth-code'), out = modal.querySelector('#ii-signout'); if (code) code.style.display = authed ? 'none' : ''; if (out) out.style.display = authed ? '' : 'none'; if (authed && code) code.value = ''; // a non-production server keeps its own authorization (#683) — say which const on = OAUTH_SITE ? ' on ' + OAUTH_SITE.slice(1) : ''; if (authed) { el.className = 'ii-authstate ok'; el.textContent = '✓ Authorized' + on + ' — submit is ready.'; } else { el.className = 'ii-authstate no'; el.textContent = '• Not authorized' + on + ' yet. Click Authorize (one time).'; pane.classList.add('open'); // nudge first-time users } } /* ═══════════════════════════════════════════════════════════════════════ TRACK LINKS (#219 PoC) — per-track provider link icons under the artist line. Two streaming providers that expose a stable, public per-track URL resolvable by ISRC (and already wired in Scout): Deezer and Tidal. • faded monochrome → the recording already links to that provider in MB (read once from a recording browse with url-rels) • full brand colour → the track resolves on the provider by ISRC but isn't linked yet — an "add" candidate The actual background add is the next spike: WS2 only submits ISRCs, so adding URL relationships needs MB's website edit API, not submitIsrcs(). ═══════════════════════════════════════════════════════════════════════ */ const TrackLinks = (function () { // #481 (majkinetor): a link we resolve ourselves reads cosmetically // different from the one Harmony seeds for the SAME track — Tidal's API // hands back a `/browse/track/…` path, Apple's ld+json/amp-api keep the // descriptive title slug — even though MB treats both forms as the same // relationship. MB's own client-side URLCleanup.js is the authority on // the canonical shape (github: metabrainz/musicbrainz-server, root/ // static/scripts/edit/URLCleanup.js); mirrored here for just these two // rules so our "add" candidates already look like Harmony's: // tidal: strips any locale/browse/store prefix → https://tidal.com/track/<id> // apple: strips the descriptive slug → https://music.apple.com/<cc>/song/<id> function normalizeProviderUrl(code, url) { if (!url) return url; if (code === 'td') return url.replace(/^https?:\/\/(?:[^/]+\.)?tidal\.com\/(?:[a-z]{2}\/)?(?:browse\/|store\/)?([a-z]+)\/(\d+).*$/i, 'https://tidal.com/$1/$2'); if (code === 'am') { // MB applies this pair in order: an album link carrying a track's ?i= // query param becomes a song link FIRST, then the descriptive slug // (on any of artist/album/…/song) is stripped. url = url.replace(/^(https:\/\/(?:classical\.)?music\.apple\.com\/[a-z]{2})\/album\/[^?#/]+\/[0-9]+\?i=([0-9]+)$/i, '$1/song/$2'); return url.replace(/^(https:\/\/(?:classical\.)?music\.apple\.com\/[a-z]{2})\/(artist|album|author|label|music-video|song)\/(?:[^?#/]+\/)?(?:id)?([0-9]+)(?:\?.*)?$/i, '$1/$2/$3'); } return url; } // linkTypeID: recording↔url relationship type (confirmed live against MB's // editor): 268 = "free streaming" (Deezer/Spotify free tier), 979 = // "streaming" / streaming page (Tidal, subscription). const PROV = [ { code: 'dz', name: 'Deezer', color: _PROV_COLOR.deezer, icon: SRC_ICON.dz, linkTypeID: 268, conc: 3, gap: 60, // #307: a few in flight; a quota hit RECOVERS below (back off + retry) instead of false-negativing // #406: accept deezer.com with OR without www. The old `(?:^|\.)` anchor required a // leading dot (i.e. www.), so a bare https://deezer.com/track/… link went unrecognised — // it showed as a generic globe in LINKED *and* got re-offered as an addable Deezer // candidate in ADD (Submit would then add a duplicate www link). Use the [./] form the // other providers already use. test: u => /(?:^|[./])deezer\.com\/(?:[a-z]{2}\/)?track\/\d+/i.test(u), async resolve(isrc) { // Deezer signals a rate limit as HTTP 429 or an error body with code 4 (quota) / 700 (busy); // a genuine miss is code 800 ("no data"). Retry only the rate-limit cases, so throttling // never looks like "not found". #307 for (let attempt = 0; ; attempt++) { const r = await gmGet('https://api.deezer.com/track/isrc:' + encodeURIComponent(isrc), { 'Accept': 'application/json' }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) {} const throttled = r.status === 429 || (j && j.error && (j.error.code === 4 || j.error.code === 700)); if (throttled && attempt < 4) { await sleep(Math.min(700 * Math.pow(2, attempt), 6000)); continue; } if (r.status !== 200 || !j || j.error || !j.id) return null; // Don't offer a DEAD track: Deezer keeps the ISRC mapping after pulling the // audio, returning a track that streams in zero countries (readable:false, // available_countries:[]). That's a broken link, so treat it as not found. if (j.readable === false && Array.isArray(j.available_countries) && j.available_countries.length === 0) return null; return j.link || ('https://www.deezer.com/track/' + j.id); } } }, { code: 'td', name: 'Tidal', color: _PROV_COLOR.tidal, icon: SRC_ICON.td, linkTypeID: 979, conc: 3, gap: 0, // #307: tidalGet() self-recovers from 429 (backoff+retry), so a burst is safe — it never false-negatives test: u => /tidal\.com\/(?:browse\/)?track\/\d+/i.test(u), async resolve(isrc) { const token = await tidalToken(); const headers = { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.api+json' }; const r = await tidalGet(TIDAL.api + '/tracks?countryCode=' + TIDAL.country + '&filter%5Bisrc%5D=' + encodeURIComponent(isrc), headers); if (r.status !== 200) return null; let j; try { j = JSON.parse(r.responseText || '{}'); } catch (e) { return null; } const t = (j.data || [])[0]; return t && t.id ? ('https://tidal.com/browse/track/' + t.id) : null; } }, // YouTube Music (#640): it shows no ISRCs, but its search (the "songs" filter) finds most // songs by one. A miss comes back as unrelated songs rather than nothing, so ytResolve // takes a hit only when it is official audio, its title matches and its length is within // 3 s of the track's. The free tier plays everything → "free streaming" (268). { code: 'yt', name: 'YouTube Music', color: _PROV_COLOR.ytmusic, icon: SRC_ICON.yt, linkTypeID: 268, conc: 2, gap: 150, // one search per ISRC; an internal API with no published limit, so gently test: u => /music\.youtube\.com\/watch\?(?:[^#]*&)?v=[\w-]{11}/i.test(u), resolve: (isrc, t, idx) => ytResolve(isrc, t, idx) }, // Bandcamp (#300): no ISRC — resolve by ALBUM. The release's Bandcamp album // page lists every track's URL in order, so we match by position (with a // title sanity-check). Only offered when the release has a Bandcamp album link. { code: 'bc', name: 'Bandcamp', color: _PROV_COLOR.bandcamp, icon: SRC_ICON.bc, linkTypeID: 268, album: true, urlKey: 'bandcampUrl', conc: 99, gap: 0, // #307: one album fetch (cached), the rest is local test: u => /\.bandcamp\.com\/track\//i.test(u), resolve: (isrc, t, idx) => bcResolve(t, idx) }, // Amazon Music (#644): no ISRCs — by the release's album, like Bandcamp: position, title and // length checked (azResolve). Subscription streaming → "streaming page" (979), as MusicBrainz's // editor types a music.amazon.com/tracks link. Only offered when the release has an Amazon Music album link. { code: 'az', name: 'Amazon Music', color: _PROV_COLOR.amazonmusic, icon: SRC_ICON.az, linkTypeID: 979, album: true, urlKey: 'amzUrl', conc: 99, gap: 0, // one album fetch (cached), the rest is local test: u => /music\.amazon\.[a-z.]+\/tracks\/[A-Z0-9]{10}/i.test(u), resolve: (isrc, t, idx) => azResolve(t, idx) }, // Apple Music (#like Bandcamp): the album page's ld+json lists every track URL, // matched by position. Subscription streaming → linkType 979. { code: 'am', name: 'Apple Music', color: _PROV_COLOR.apple, icon: SRC_ICON.am, linkTypeID: 979, album: true, urlKey: 'appleUrl', conc: 99, gap: 0, // #307: one album fetch (cached), the rest is local test: u => /music\.apple\.com\/[a-z]{2}\/(?:song\/|album\/[^"\s]*[?&]i=)/i.test(u), resolve: (isrc, t, idx) => amResolve(t, idx) }, // Volumo (#387): a download store — recording↔url "purchase for download" (254). No global by-ISRC // endpoint, but the release's Volumo album JSON (fetched once, cached) carries every track's ISRC and // id, so we match by ISRC and hand back the per-track URL. Only offered when the release has a Volumo link. { code: 'vo', name: 'Volumo', color: _PROV_COLOR.volumo, icon: SRC_ICON.vo, linkTypeID: 254, album: true, urlKey: 'volumoId', conc: 99, gap: 0, test: u => /(?:^|[./])volumo\.com\/track\//i.test(u), // note the char class incl. '/': the URL is https://volumo.com/… (no www) resolve: (isrc) => provAlbumUrl('volumo', isrc) }, // Beatport (#387): same album-scoped, ISRC-matched shape as Volumo. Its tracklist (with track // id+slug) comes from the API when logged in via Platform Check, else from the Cloudflare tab harvest. { code: 'bp', name: 'Beatport', color: _PROV_COLOR.beatport, icon: SRC_ICON.bp, linkTypeID: 254, album: true, urlKey: 'beatportId', conc: 99, gap: 0, test: u => /(?:^|[./])beatport\.com\/track\//i.test(u), resolve: (isrc) => provAlbumUrl('beatport', isrc) }, // Qobuz (#353/#387): album-scoped, ISRC-matched like Volumo/Beatport. album/get (with the shared // Platform Check login token) carries every track's ISRC + id; the per-track link is the id-only // open.qobuz.com/track/<id> — recording↔url "purchase for download" (254). Needs the Qobuz login. { code: 'qz', name: 'Qobuz', color: _PROV_COLOR.qobuz, icon: SRC_ICON.qz, linkTypeID: 254, album: true, urlKey: 'qobuzId', conc: 99, gap: 0, test: u => /qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?track\//i.test(u), resolve: (isrc) => provAlbumUrl('qobuz', isrc) }, // SoundCloud (#439): the set (fetched once, cached) carries every track's ISRC + // permalink_url, so match by ISRC and hand back the per-track URL. Free streaming → // recording↔url "free streaming" (268). The per-track link is a plain user/slug // permalink — NOT a /sets/ URL (that's the album). { code: 'sc', name: 'SoundCloud', color: _PROV_COLOR.soundcloud, icon: SRC_ICON.sc, linkTypeID: 268, album: true, urlKey: 'scUrl', conc: 99, gap: 0, test: u => /soundcloud\.com\/[^/]+\/(?!sets\/)[^/?#]+/i.test(u), resolve: (isrc, t, idx) => scResolve(t, idx) }, // Audiomack (#664): the album (fetched once, cached) lists every song's page, in order; // matched BY POSITION with a title guard. Free streaming → recording↔url "free streaming" (268). { code: 'ak', name: 'Audiomack', color: _PROV_COLOR.audiomack, icon: SRC_ICON.ak, linkTypeID: 268, album: true, urlKey: 'akUrl', conc: 99, gap: 0, test: u => /audiomack\.com\/[^/]+\/song\//i.test(u), resolve: (isrc, t, idx) => akResolve(t, idx) }, // Spotify (#458): the token-free /embed/album/<id> page ships the ordered tracklist, // matched per track BY POSITION with a title guard (like Bandcamp/Apple). Free tier → // recording↔url "free streaming" (268). Only offered when the release has a Spotify album link. { code: 'sp', name: 'Spotify', color: _PROV_COLOR.spotify, icon: SRC_ICON.sp, linkTypeID: 268, album: true, urlKey: 'spotifyId', conc: 99, gap: 0, test: u => /open\.spotify\.com\/(?:intl-[a-z-]+\/)?track\//i.test(u), resolve: (isrc, t, idx) => spResolve(t, idx) }, ]; let resolving = false; // Existing url rels come free with fetchRelease (inc=recording-level-rels), so // we read what's already linked straight off the track — no extra request. const linkedUrl = (t, p) => (t.recUrls || []).find(u => p.test(u)) || null; // Bandcamp album page (fetched once): ordered [{title, url}] from data-tralbum. let _bcList = null, _bcPromise = null; async function bcAlbum() { if (_bcList && _bcList.length) return _bcList; // an empty result (network hiccup, anti-bot, transient block) must NOT stick — retry on the next Find links click rather than caching a permanent miss for the rest of the page session if (_bcPromise) return _bcPromise; const url = RELEASE && RELEASE.bandcampUrl; if (!url) { _bcList = []; return _bcList; } _bcPromise = (async () => { const r = await gmGet(url, { 'Accept': 'text/html' }); if (r.status !== 200) return []; const m = (r.responseText || '').match(/data-tralbum="([^"]+)"/); if (!m) return []; const json = m[1].replace(/"/g, '"').replace(/�?39;/g, "'").replace(/&/g, '&'); let j; try { j = JSON.parse(json); } catch (e) { return []; } let origin = ''; try { origin = new URL(j.url).origin; } catch (e) { origin = url.replace(/\/album\/.*$/, ''); } return (j.trackinfo || []).map(ti => ({ title: ti.title || '', url: ti.title_link ? origin + ti.title_link : '' })).filter(ti => ti.url); })(); _bcList = await _bcPromise.catch(() => []); _bcPromise = null; return _bcList; } // #463 keep ALL Unicode letters/numbers, not just ASCII — else a non-Latin title (Cyrillic, // CJK, …) collapses to '' and the position title-guard bails, refusing an otherwise-identical // match (e.g. "слезы завтра"). NFD + the 0x300-0x36f drop still folds Latin diacritics (café→cafe). const _nrm = s => [...sameWriting(s).toLowerCase().replace(/['’‘`´]/g, '').normalize('NFD')].filter(c => { const x = c.charCodeAt(0); return x < 0x300 || x > 0x36f; }).join('').replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); // A position match agrees on the title: the same, one inside the other (a version tag), or the // same words with a typo in one (#661: Apple's "Les Ecrocs" is MusicBrainz's "Les Escrocs") // #681: guests are left out of both (MusicBrainz moves a title's "feat." into the artist credit) function _sameTitle(x, y) { const a = _nrm(unfeat(x)), b = _nrm(unfeat(y)); if (!a || !b) return false; if (a === b || a.indexOf(b) >= 0 || b.indexOf(a) >= 0) return true; const aw = a.split(' '), bw = b.split(' '); return aw.length === bw.length && aw.every((w, i) => wordTypo(w, bw[i])); } async function bcResolve(t, idx) { const list = await bcAlbum(); const e = list[idx]; if (!e) return null; // position match must agree on title (else the editions are out of sync) — don't add a likely-wrong link return _sameTitle(e.title, t.title) ? e.url : null; } // YouTube Music (#640), through the API its web player uses (anonymous). Two routes: // · the release's own YouTube Music album, when it has a link to one: its tracklist, // matched by position (or, when the album lists fewer songs, the one song with the // track's title), with the title and length checked. The tracklist is the album's // OLAK5uy_ playlist as YouTube's own client (WEB) lists it: only the album's tracks. // YouTube Music's album page, read logged out, swaps in the music video for a track that // has one (#690: Gallina's "Uberi ruki" got Moon Records' MV), so it is only the // fallback, and there a row counts only when it is official audio (ATV); // · otherwise, or for a track the album doesn't have: the ISRC searched with the "songs" // filter. Of the first three results, one that is official audio (MUSIC_VIDEO_TYPE_ATV, // not a user upload), has the track's title, is within 3 s of its length, AND comes from // this release's album. Labels reuse an ISRC across versions: JP92Q2400507 is both // "メズマライザー" and its "Critical Damage ver.", same title, same 2:37, and the search // brings up the original's song from "Mesmerizer" (rinsuki). A search row reads // "Kerala" | "Bonobo • Migration • 3:58", the album a link to it. // Where each link came from is kept for the edit note (ytSource): once a song is delisted // or geo-blocked, its URL alone no longer says which album it was. const YTM_SONGS_FILTER = 'EgWKAQIIAWoKEAkQBRAKEAMQBA=='; const ytSource = new Map(); // watch URL → { album, albumUrl, how } const ytText = x => (x && x.runs ? x.runs.map(r => r.text).join('') : ''); const ytWatch = vid => 'https://music.youtube.com/watch?v=' + vid; const ytSame = _sameTitle; async function ytCall(endpoint, body) { const r = await gmPost('https://music.youtube.com/youtubei/v1/' + endpoint + '?prettyPrint=false', JSON.stringify(Object.assign({ context: { client: { clientName: 'WEB_REMIX', clientVersion: '1.20250101.01.00', hl: 'en', gl: 'US' } } }, body)), { 'Content-Type': 'application/json' }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) {} if (r.status !== 200 || !j) { Log.warn('YouTube Music: ' + endpoint + ' ' + (body.query || body.browseId) + ' failed (HTTP ' + r.status + ') — its API may have changed'); return null; } return j; } // The songs of a search or an album page: { vid, title, artist, len, type, album, albumId }. function ytSongs(j) { const out = []; const walk = o => { if (!o || typeof o !== 'object') return; const r = o.musicResponsiveListItemRenderer; if (r) { const col = i => r.flexColumns && r.flexColumns[i] && r.flexColumns[i].musicResponsiveListItemFlexColumnRenderer && r.flexColumns[i].musicResponsiveListItemFlexColumnRenderer.text; const sub = ytText(col(1)).split(' • '); const albumRun = ((col(1) && col(1).runs) || []).find(x => x.navigationEndpoint && x.navigationEndpoint.browseEndpoint && /^MPREb_/.test(x.navigationEndpoint.browseEndpoint.browseId || '')); const fixed = r.fixedColumns && r.fixedColumns[0] && r.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer; const vid = (r.playlistItemData && r.playlistItemData.videoId) || (JSON.stringify(r).match(/"videoId":"([\w-]{11})"/) || [])[1]; const type = (JSON.stringify(r).match(/MUSIC_VIDEO_TYPE_[A-Z]+/) || [''])[0].replace('MUSIC_VIDEO_TYPE_', ''); if (vid) out.push({ vid, title: ytText(col(0)), artist: sub[0] || '', type, len: durToSec(fixed ? ytText(fixed.text) : sub[sub.length - 1]), album: albumRun ? albumRun.text : null, albumId: albumRun ? albumRun.navigationEndpoint.browseEndpoint.browseId : null }); return; } for (const k in o) walk(o[k]); }; walk(j); return out; } // Why a song isn't this track ('' when it is): the title, and the length within 3 s. function ytMismatch(s, t) { const want = durToSec(t.dur); if (!ytSame(s.title, t.title)) return 'another title'; if (want != null && (s.len == null || Math.abs(s.len - want) > 3)) return 'another length (' + (s.len == null ? '?' : msToMmSs(s.len * 1000)) + ' vs ' + t.dur + ')'; return ''; } // The songs of an album playlist as YouTube's own client lists it (#690): the album's own // tracks, each { vid, title, artist, len, type: 'ATV' }. A video hidden in this region is // left out by YouTube (positions then shift; the title match catches it). Unlike YouTube // Music, this client lists a song a free listener can't play with its video, flagged // unplayable (rinsuki); such a song is left out. It may be Premium only or not available at // all, which only the watch page or a Premium login tells apart; either way it is no free stream. function ytPlaylistSongs(j) { const out = []; const walk = o => { if (!o || typeof o !== 'object') return; const r = o.playlistVideoRenderer; if (r) { if (r.videoId && r.isPlayable !== false && !r.unplayableText) out.push({ vid: r.videoId, title: ytText(r.title) || (r.title && r.title.simpleText) || '', type: 'ATV', artist: ytText(r.shortBylineText).replace(/ - Topic$/, ''), len: r.lengthSeconds != null ? +r.lengthSeconds : durToSec(r.lengthText && r.lengthText.simpleText) }); return; } // the same row as YouTube lists it since 2026-10: a lockup, its length the thumbnail's badge const l = o.lockupViewModel; if (l) { const md = l.metadata && l.metadata.lockupMetadataViewModel; if (l.contentId && l.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO' && md) { const rows = (md.metadata && md.metadata.contentMetadataViewModel && md.metadata.contentMetadataViewModel.metadataRows) || []; const part = rows[0] && rows[0].metadataParts && rows[0].metadataParts[0]; let badge = ''; const find = x => { if (!x || typeof x !== 'object' || badge) return; const b = x.thumbnailBadgeViewModel; if (b && /^\d+(:\d\d)+$/.test(b.text || '')) { badge = b.text; return; } for (const k in x) find(x[k]); }; find(l); out.push({ vid: l.contentId, title: (md.title && md.title.content) || '', type: 'ATV', artist: ((part && part.text && part.text.content) || '').replace(/ - Topic$/, ''), len: badge ? durToSec(badge) : null }); } return; } for (const k in o) walk(o[k]); }; walk(j); return out; } // The next page's token: the playlist lists 100 songs a page (rinsuki: a soundtrack has more). function ytNextPage(j) { let tok = null; const walk = o => { if (!o || typeof o !== 'object' || tok) return; if (o.continuationCommand && o.continuationCommand.token) { tok = o.continuationCommand.token; return; } for (const k in o) walk(o[k]); }; const find = o => { if (!o || typeof o !== 'object' || tok) return; if (o.continuationItemRenderer) { walk(o.continuationItemRenderer); return; } for (const k in o) find(o[k]); }; find(j); return tok; } async function ytWebPlaylist(list) { const call = body => gmPost('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', JSON.stringify(Object.assign({ context: { client: { clientName: 'WEB', clientVersion: '2.20250101.01.00', hl: 'en', gl: 'US' } } }, body)), { 'Content-Type': 'application/json' }); const r = await call({ browseId: 'VL' + list }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) {} if (r.status !== 200 || !j) { Log.warn('YouTube: playlist ' + list + ' failed (HTTP ' + r.status + ') — its API may have changed; using the YouTube Music album page'); return null; } const md = j.metadata && j.metadata.playlistMetadataRenderer; // "Album - Random Access Memories" const name = ((md && md.title) || '').replace(/^(?:Album|EP|Single) - /, ''); const tracks = ytPlaylistSongs(j); // the rest of a long album, page by page (at most 2000 songs) for (let n = 1, tok = ytNextPage(j); tok && n < 20; n++) { const rc = await call({ continuation: tok }); let jc = null; try { jc = JSON.parse(rc.responseText || 'null'); } catch (e) {} if (rc.status !== 200 || !jc) { Log.warn('YouTube: playlist ' + list + ', page ' + (n + 1) + ' failed (HTTP ' + rc.status + ') — only its first ' + tracks.length + ' song(s)'); break; } tracks.push(...ytPlaylistSongs(jc)); tok = ytNextPage(jc); } return { name, tracks }; } // The release's YouTube Music album (fetched once): { id, name, url, tracks: [song…] }. // YouTube Music is asked first, even for a link to a playlist: an album has two playlists, // the album's own and one that puts the music videos in (rinsuki), and both lead to the // album page, which names the album's own (its canonical URL). That playlist, read through // YouTube's own client, gives the tracks; the album page also says which songs a free // listener can play (a song that can't be has no video there). let _ytAlbum = null, _ytAlbumP = null; async function ytAlbum() { if (_ytAlbum) return _ytAlbum; if (_ytAlbumP) return _ytAlbumP; const url = RELEASE && RELEASE.ytmUrl; if (!url) return { tracks: [] }; _ytAlbumP = (async () => { let id = (url.match(/browse\/(MPREb_[\w-]+)/) || [])[1]; const linked = (url.match(/[?&]list=(OLAK5uy_[\w-]+)/) || [])[1]; if (!id && linked) { const pj = await ytCall('browse', { browseId: 'VL' + linked }); id = pj && (JSON.stringify(pj).match(/"(MPREb_[\w-]+)"/) || [])[1]; } let page = null; // the YouTube Music album page: { name, list, tracks, rows } if (id) { const j = await ytCall('browse', { browseId: id }); let h = null, rows = 0; const walk = o => { if (!o || typeof o !== 'object') return; if (o.musicResponsiveHeaderRenderer && !h) h = o.musicResponsiveHeaderRenderer; if (o.musicResponsiveListItemRenderer) { rows++; return; } for (const k in o) walk(o[k]); }; walk(j); const canon = (j && j.microformat && j.microformat.microformatDataRenderer && j.microformat.microformatDataRenderer.urlCanonical) || ''; page = { name: h ? ytText(h.title) : '', list: (canon.match(/[?&]list=(OLAK5uy_[\w-]+)/) || [])[1], tracks: j ? ytSongs(j) : [], rows }; } const list = (page && page.list) || linked; if (linked && list !== linked) Log.info('YouTube Music: the release links the playlist ' + linked + ', the album\'s own is ' + list + ' — using that'); if (list) { const pl = await ytWebPlaylist(list); if (pl && pl.tracks.length) { let tracks = pl.tracks; // a song the album page has no video for a free listener can't play (Premium only, or not // available at all) — unless the page didn't list them all if (page && page.tracks.length && page.rows >= tracks.length) { const out = tracks.filter(s => !page.tracks.some(p => ytSame(p.title, s.title))); if (out.length) { Log.info('YouTube Music album: ' + out.map(s => '"' + s.title + '"').join(', ') + ' not playable for a free listener (Premium only, or not available) — left out'); tracks = tracks.filter(s => !out.includes(s)); } } else if (!page) Log.info('YouTube Music: no album page for the playlist ' + list + ' — its songs aren\'t checked for being playable for a free listener'); Log.info('YouTube Music album "' + (pl.name || (page && page.name)) + '" (' + list + '): ' + tracks.length + ' song(s), from the release\'s link'); return { id: list, name: pl.name || (page && page.name), url: 'https://www.youtube.com/playlist?list=' + list, tracks }; } if (pl) Log.info('YouTube: playlist ' + list + ' lists no songs here — using the YouTube Music album page'); } if (!page) { Log.warn('YouTube Music: the release\'s album link ' + url + ' names no album — each track falls back to its ISRC'); return { tracks: [] }; } Log.info('YouTube Music album "' + page.name + '" (' + id + '): ' + page.tracks.length + ' song(s), from the release\'s link'); return { id, name: page.name, url: 'https://music.youtube.com/browse/' + id, tracks: page.tracks }; })(); const res = await _ytAlbumP.catch(() => ({ tracks: [] })); _ytAlbumP = null; if (res.tracks.length) _ytAlbum = res; return res; } async function ytResolve(isrc, t, idx) { // 1. the release's own album: by position, else the one song with the track's title const alb = await ytAlbum(); if (alb.tracks.length) { // #690: only official audio — on the album page a music video can stand in for the track const why = x => x.type && x.type !== 'ATV' ? 'not official audio (' + x.type + ')' : ytMismatch(x, t); const at = alb.tracks[idx]; let s = at && !why(at) ? at : null; if (!s) { const c = alb.tracks.filter(x => !why(x)); if (c.length === 1) s = c[0]; } if (s) { const url = ytWatch(s.vid); const sib = RELEASE.rgFrom && RELEASE.rgFrom.ytmUrl; // #302: the link came from a sibling release ytSource.set(url, { album: alb.name, albumUrl: alb.url, how: (sib ? 'the album linked from ' + MB_ROOT + '/release/' + sib.release : 'the release\'s album') + ', track ' + (alb.tracks.indexOf(s) + 1) }); Log.info('YouTube Music track ' + (idx + 1) + ' → "' + s.title + '" ' + (s.len == null ? '' : msToMmSs(s.len * 1000)) + ' from the album "' + alb.name + '"'); return url; } Log.info('YouTube Music track ' + (idx + 1) + ' "' + t.title + '": not on the album "' + alb.name + '"' + (at ? ' (position ' + (idx + 1) + ' is "' + at.title + '", ' + why(at) + ')' : '') + (isrc ? ' — trying its ISRC' : '')); } // 2. the ISRC, searched if (!isrc) return null; const j = await ytCall('search', { query: isrc, params: YTM_SONGS_FILTER }); if (!j) return null; const songs = ytSongs(j).slice(0, 3); for (const s of songs) { const why = s.type !== 'ATV' ? 'not official audio (' + (s.type || '?') + ')' : ytMismatch(s, t) || (!ytSame(s.album, RELEASE.title) ? 'from the album "' + (s.album || '?') + '", not "' + RELEASE.title + '" — the ISRC may be shared with another version' : ''); Log.info('YouTube Music ' + isrc + ' → "' + s.title + '" by ' + (s.artist || '?') + ' ' + (s.len == null ? '' : msToMmSs(s.len * 1000)) + (s.album ? ' on "' + s.album + '"' : '') + (why ? ' — skipped: ' + why : ' — matches "' + t.title + '"')); if (!why) { const url = ytWatch(s.vid); ytSource.set(url, { album: s.album, albumUrl: s.albumId ? 'https://music.youtube.com/browse/' + s.albumId : null, how: 'ISRC ' + isrc }); return url; } } if (!songs.length) Log.info('YouTube Music ' + isrc + ': no songs'); return null; } // Amazon Music (#644), through the API its web player uses, as a guest (no login). It shows no // ISRCs, so only by ALBUM, like Bandcamp: the release's Amazon Music album (when it has a link // to one) lists every track with its id and length, matched by position — or, when the album // lists them in another order, the one track with this title — with the title and the length // (within 3 s) checked. The per-track link is music.amazon.com/tracks/<id>, a "streaming page" // (979), which MusicBrainz's editor picks for it too. music.amazon.com/config.json hands out the // guest session (CSRF token, device and session id), which each request carries in its body. let _azSession = null; function azSession() { if (!_azSession) _azSession = (async () => { const r = await http({ method: 'POST', url: 'https://music.amazon.com/config.json', data: '', headers: {}, anonymous: true }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) {} if (r.status !== 200 || !j || !j.csrf || !j.deviceId) { Log.warn('Amazon Music: no guest session (HTTP ' + r.status + ') — its web player may have changed'); return null; } Log.info('Amazon Music: guest session (marketplace ' + (j.marketplaceId || '?') + ')'); return j; })().catch(e => { Log.warn('Amazon Music: no guest session — ' + errText(e)); return null; }).then(s => { if (!s) _azSession = null; return s; }); return _azSession; } async function azCall(endpoint, body) { const s = await azSession(); if (!s) return null; const headers = JSON.stringify({ 'x-amzn-authentication': JSON.stringify({ interface: 'ClientAuthenticationInterface.v1_0.ClientTokenElement', accessToken: s.accessToken || '' }), 'x-amzn-device-model': 'WEBPLAYER', 'x-amzn-device-width': '1920', 'x-amzn-device-family': 'WebPlayer', 'x-amzn-device-id': s.deviceId, 'x-amzn-user-agent': navigator.userAgent, 'x-amzn-session-id': s.sessionId, 'x-amzn-device-height': '1080', 'x-amzn-request-id': (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)), 'x-amzn-device-language': 'en_US', 'x-amzn-currency-of-preference': 'USD', 'x-amzn-os-version': '1.0', 'x-amzn-application-version': s.version, 'x-amzn-device-time-zone': 'UTC', 'x-amzn-timestamp': String(Date.now()), 'x-amzn-csrf': JSON.stringify({ interface: 'CSRFInterface.v1_0.CSRFHeaderElement', token: s.csrf.token, timestamp: s.csrf.ts, rndNonce: s.csrf.rnd }), 'x-amzn-music-domain': 'music.amazon.com', 'x-amzn-referer': '', 'x-amzn-affiliate-tags': '', 'x-amzn-ref-marker': '', 'x-amzn-page-url': 'https://music.amazon.com/', 'x-amzn-weblab-id-overrides': '', 'x-amzn-video-player-token': '', 'x-amzn-feature-flags': '', 'x-amzn-has-profile-id': '', 'x-amzn-age-band': '', }); const r = await http({ method: 'POST', url: 'https://na.mesk.skill.music.a2z.com/api/' + endpoint, data: JSON.stringify(Object.assign({}, body, { headers })), headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, anonymous: true }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) {} const tpl = j && j.methods && (j.methods.find(m => m.template) || {}).template; if (r.status !== 200 || !tpl) { Log.warn('Amazon Music: ' + endpoint + ' ' + (body.id || '') + ' failed (HTTP ' + r.status + ') — its API may have changed'); return null; } return tpl; } const azSecs = x => { const p = String(x || '').split(':').map(Number); return p.length > 1 && p.every(n => !isNaN(n)) ? p.reduce((a, n) => a * 60 + n, 0) : null; }; // "05:38", "1:02:03" // The release's Amazon Music album (fetched once): { id, name, tracks: [{ id, title, len }] }. let _azAlbum = null, _azAlbumP = null; async function azAlbum() { if (_azAlbum) return _azAlbum; if (_azAlbumP) return _azAlbumP; const id = ((RELEASE && RELEASE.amzUrl) || '').match(/\/albums\/([A-Z0-9]{10})/i); if (!id) return { tracks: [] }; _azAlbumP = (async () => { const tpl = await azCall('showCatalogAlbum', { id: id[1].toUpperCase(), userHash: JSON.stringify({ level: 'LIBRARY_MEMBER' }) }); if (!tpl) return { tracks: [] }; const name = (tpl.headerText && tpl.headerText.text) || ''; const tracks = []; for (const w of tpl.widgets || []) for (const it of w.items || []) { const tid = (String((it.primaryTextLink && it.primaryTextLink.deeplink) || '').match(/^\/tracks\/([A-Z0-9]{10})/) || String((it.primaryLink && it.primaryLink.deeplink) || '').match(/[?&]trackAsin=([A-Z0-9]{10})/) || [])[1]; if (tid && !tracks.some(x => x.id === tid)) tracks.push({ id: tid, title: typeof it.primaryText === 'object' ? (it.primaryText && it.primaryText.text) || '' : it.primaryText || '', len: azSecs(it.secondaryText3) }); } Log.info('Amazon Music album "' + name + '" (' + id[1] + '): ' + tracks.length + ' track(s), from the release\'s link'); return { id: id[1].toUpperCase(), name, tracks }; })(); const res = await _azAlbumP.catch(() => ({ tracks: [] })); _azAlbumP = null; if (res.tracks.length) _azAlbum = res; return res; } async function azResolve(t, idx) { const alb = await azAlbum(); if (!alb.tracks.length) return null; const at = alb.tracks[idx]; let s = at && !ytMismatch(at, t) ? at : null; if (!s) { const c = alb.tracks.filter(x => !ytMismatch(x, t)); if (c.length === 1) s = c[0]; } if (!s) { Log.info('Amazon Music track ' + (idx + 1) + ' "' + t.title + '": not on the album "' + alb.name + '"' + (at ? ' (position ' + (idx + 1) + ' is "' + at.title + '", ' + ytMismatch(at, t) + ')' : '')); return null; } Log.info('Amazon Music track ' + (idx + 1) + ' → "' + s.title + '" ' + (s.len == null ? '' : msToMmSs(s.len * 1000)) + ' from the album "' + alb.name + '"'); return 'https://music.amazon.com/tracks/' + s.id; } // Apple Music album page (fetched once): ordered [{title, url}] from its ld+json. let _amList = null, _amPromise = null; async function amAlbum() { if (_amList && _amList.length) return _amList; // see bcAlbum: don't let a transient empty fetch stick forever if (_amPromise) return _amPromise; const url = RELEASE && RELEASE.appleUrl; if (!url) { _amList = []; return _amList; } _amPromise = (async () => { const r = await gmGet(url, { 'Accept': 'text/html' }); if (r.status !== 200) return []; const m = (r.responseText || '').match(/<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i); if (!m) return []; let j; try { j = JSON.parse(m[1]); } catch (e) { return []; } return (j.tracks || []).map(t => ({ title: t.name || '', url: t.url || '' })).filter(t => t.url); })(); _amList = await _amPromise.catch(() => []); _amPromise = null; return _amList; } async function amResolve(t, idx) { const list = await amAlbum(); const e = list[idx]; if (!e) return null; return _sameTitle(e.title, t.title) ? e.url : null; } // SoundCloud set (fetched once via api-v2): ordered [{title, url}] per track. Like // Apple/Bandcamp we resolve the per-track link BY POSITION (with a title guard) rather // than by ISRC — a set is an ordered playlist, and this works even for tracks that // don't carry an ISRC yet / whose SoundCloud upload ISRC differs from MB's. #439 let _scLinkList = null, _scLinkPromise = null; async function scAlbum() { if (_scLinkList && _scLinkList.length) return _scLinkList; // see bcAlbum: don't let a transient empty fetch stick forever if (_scLinkPromise) return _scLinkPromise; const setUrl = RELEASE && RELEASE.scUrl; if (!setUrl) { _scLinkList = []; return _scLinkList; } _scLinkPromise = (async () => { const cid = await soundcloudClientId(); const rj = async (u) => { const r = await gmGet(u, { 'Accept': 'application/json' }); return r.status === 200 ? JSON.parse(r.responseText || 'null') : null; }; const pl = await rj(SC.api + '/resolve?url=' + encodeURIComponent(setUrl) + '&client_id=' + cid); if (pl && pl.kind === 'track') return [{ title: pl.title || '', url: pl.permalink_url || '' }].filter(t => t.url); // single-track release (#439) if (!pl || pl.kind !== 'playlist') return []; const stubs = (pl.tracks || []).filter(t => t && t.id); const byId = new Map(); (pl.tracks || []).forEach(t => { if (t && t.title) byId.set(t.id, t); }); const missing = stubs.filter(t => !byId.has(t.id)).map(t => t.id); for (let i = 0; i < missing.length; i += 50) { const b = await rj(SC.api + '/tracks?ids=' + encodeURIComponent(missing.slice(i, i + 50).join(',')) + '&client_id=' + cid); (b || []).forEach(t => { if (t && t.id != null) byId.set(t.id, t); }); } return stubs.map(s => byId.get(s.id)).filter(Boolean).map(t => ({ title: t.title || '', url: t.permalink_url || '' })).filter(t => t.url); })(); _scLinkList = await _scLinkPromise.catch(() => []); _scLinkPromise = null; return _scLinkList; } let _akLinkList = null, _akLinkPromise = null; async function akAlbum() { if (_akLinkList && _akLinkList.length) return _akLinkList; // see bcAlbum: don't let a transient empty fetch stick if (_akLinkPromise) return _akLinkPromise; const u = RELEASE && RELEASE.akUrl; if (!u) { _akLinkList = []; return _akLinkList; } _akLinkPromise = audiomackRelease(u).then(r => r.tracks.filter(t => t.url)); _akLinkList = await _akLinkPromise.catch(e => { Log.warn('Audiomack track links: ' + errText(e)); return []; }); _akLinkPromise = null; return _akLinkList; } async function akResolve(t, idx) { const e = (await akAlbum())[idx]; return e && _sameTitle(e.title, t.title) ? e.url : null; } async function scResolve(t, idx) { const list = await scAlbum(); const e = list[idx]; if (!e) return null; return _sameTitle(e.title, t.title) ? e.url : null; } // Spotify embed page (fetched once): ordered [{title, url}] from its __NEXT_DATA__ // trackList. The /embed/album/<id> page is server-rendered and token-free (#458), and // each entry carries uri "spotify:track:<id>" + title, so — like Bandcamp/Apple — we // match per track BY POSITION with a title guard rather than by ISRC (Spotify's ISRCs // come from ISRC Hunt, which never exposes the track id). Spotify has a free tier → // recording↔url "free streaming" (268). function _spTrackList(o, depth) { if (!o || depth > 8 || typeof o !== 'object') return null; if (Array.isArray(o.trackList)) return o.trackList; for (const k in o) { const r = _spTrackList(o[k], (depth || 0) + 1); if (r) return r; } return null; } let _spList = null, _spPromise = null; async function spAlbum() { // an empty [] here (network hiccup, or open.spotify.com's anti-bot serving a // consent/interstitial page instead of the real embed) used to get cached in // _spList forever — and `if (_spList)` treats an empty array as truthy, so every // later Find-links click silently kept reusing that permanent miss for the rest // of the page session, with no way to retry short of a full page reload. if (_spList && _spList.length) return _spList; if (_spPromise) return _spPromise; const id = RELEASE && RELEASE.spotifyId; if (!id) { _spList = []; return _spList; } _spPromise = (async () => { const r = await gmGet('https://open.spotify.com/embed/album/' + id, { 'Accept': 'text/html' }); if (r.status !== 200) return []; const html = r.responseText || ''; let list = []; const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/); if (m) { try { const tl = _spTrackList(JSON.parse(m[1]), 0) || []; list = tl.map(t => { const mm = (t.uri || '').match(/spotify:track:([A-Za-z0-9]+)/); return mm ? { title: t.title || t.name || '', url: 'https://open.spotify.com/track/' + mm[1] } : null; }).filter(Boolean); } catch (e) { list = []; } } // fallback: ordered track ids straight out of the HTML (no titles → position only) if (!list.length) list = [...html.matchAll(/spotify:track:([A-Za-z0-9]+)/g)].map(x => ({ title: '', url: 'https://open.spotify.com/track/' + x[1] })); return list; })(); _spList = await _spPromise.catch(() => []); _spPromise = null; return _spList; } async function spResolve(t, idx) { const list = await spAlbum(); const e = list[idx]; if (!e) return null; if (!e.title) return e.url; // fallback list carries no titles → trust album position return _sameTitle(e.title, t.title) ? e.url : null; } // #387 album-scoped by-ISRC providers (Volumo/Beatport): fetch the release's album tracklist once // (cached in ensureProvAlbum), match the track by ISRC — never position — and return its per-track URL. async function provAlbumUrl(key, isrc) { if (!isrc) return null; const entries = await ensureProvAlbum(key); const e = entries.find(s => normalizeIsrc(s.isrc) === normalizeIsrc(isrc)); return (e && e.url) ? e.url : null; } // Which providers to show for a track: by-ISRC ones always; album ones (Bandcamp) // only when the release has that album link (resolvable) or the recording is // already linked to it. function providersFor(t) { return PROV.filter(p => !p.album || linkedUrl(t, p) || (RELEASE && RELEASE[p.urlKey])); } // #389 Show EVERY linked provider, even ones ISRC Scout can't add per-track. Recognise the common // streaming/store hosts for a nice name/colour (Spotify even gets its glyph); anything else falls back // to its hostname with a generic globe. These are read-only (open in a tab) — no add/remove actions. const _GLOBE = stIcon('globe', 16); // #389 Qobuz uses the same brand-blue roundel as Platform Check (self-coloured, so the chip colour is moot) const _QOBUZ = stIcon('qobuz', 16); const KNOWN_LINK = [ { test: u => /open\.spotify\.com\//i.test(u), name: 'Spotify', color: _PROV_COLOR.spotify, icon: SRC_ICON.sp }, { test: u => /(?:^|[./])qobuz\.com\//i.test(u), name: 'Qobuz', color: '#0070ef', icon: _QOBUZ }, { test: u => /music\.youtube\.com\/watch|youtu\.be\/|youtube\.com\/watch/i.test(u), name: 'YouTube', color: '#ff0000', icon: _GLOBE }, { test: u => /soundcloud\.com\//i.test(u), name: 'SoundCloud', color: _PROV_COLOR.soundcloud, icon: SRC_ICON.sc }, { test: u => /audiomack\.com\//i.test(u), name: 'Audiomack', color: _PROV_COLOR.audiomack, icon: SRC_ICON.ak }, { test: u => /(?:7digital\.com|zdigital\.com\.au)\//i.test(u), name: '7digital', color: _PROV_COLOR.sevendigital, icon: SRC_ICON.sd }, { test: u => /music\.amazon\./i.test(u), name: 'Amazon Music', color: '#00a8e1', icon: _GLOBE }, { test: u => /(?:music|itunes)\.apple\.com\//i.test(u), name: 'Apple Music', color: _PROV_COLOR.apple, icon: SRC_ICON.am }, ]; // recUrls this track carries that no PROV provider already renders → [{url,name,color,icon}] (deduped). function otherLinked(t) { const out = [], seen = new Set(); (t.recUrls || []).forEach(u => { if (!u || seen.has(u) || PROV.some(p => p.test(u))) return; // PROV ones are shown already seen.add(u); const k = KNOWN_LINK.find(x => x.test(u)); if (k) { out.push({ url: u, name: k.name, color: k.color, icon: k.icon }); return; } let host = ''; try { host = new URL(u).hostname.replace(/^www\./, ''); } catch (e) {} if (host) out.push({ url: u, name: host, color: '#8a8f98', icon: _GLOBE }); }); return out; } // #301: two columns. LINKED = monochrome icons for providers already linked on // MB; ADD = a hidden candidate slot per not-yet-linked provider (Find links fills // them, and an added one moves over to the LINKED column). // One icon per platform, as on the page's tracklist: a recording with several links on one // platform (versions, regions) gets a count beside it, its tooltip lists them all, a click // opens the first live one, and end/remove act on all of them. urls: one URL or several. function linkedIcon(p, urls, endedSet, other) { urls = [].concat(urls); const isEnded = u => !!(endedSet && endedSet.has(u)); const ended = urls.every(isEnded), first = urls.find(u => !isEnded(u)) || urls[0], n = urls.length; const verb = ended ? 'un-ends' : 'marks ended', all = n > 1 ? ' all ' + n : ''; const what = n === 1 ? esc(p.name) + ' — linked' + (ended ? ' · ENDED' : '') : esc(p.name) + ' — ' + n + ' links ' + urls.map(u => esc(u) + (isEnded(u) ? ' (ended)' : '')).join(' ') + ' '; return '<a class="ii-tl linked' + (other ? ' ii-tl-other' : '') + (ended ? ' ended' : '') + '" data-code="' + esc(p.code) + '"' + (other ? ' data-other="1" data-name="' + esc(p.name) + '"' : '') + ' data-urls="' + esc(JSON.stringify(urls)) + '" style="color:' + p.color + '" href="' + esc(first) + '" target="_blank" rel="noopener" ' + 'title="' + what + (other ? ' (ISRC Scout can’t add this one back)' : '') + ' · left-click opens · right-click ' + verb + all + ' (Ctrl: whole track · Alt: ' + esc(p.name) + ' everywhere) · middle-click removes' + all + ' (same modifiers)">' + p.icon + (n > 1 ? '<sup class="ii-trk-n">' + n + '</sup>' : '') + '</a>'; } function linkedHtml(t) { const ended = t.endedUrls; const cells = t.recId ? providersFor(t).map(p => { const urls = [...new Set((t.recUrls || []).filter(u => p.test(u)))]; return urls.length ? linkedIcon(p, urls, ended) : ''; }).join('') : ''; // #389 append every OTHER linked provider, so the column shows all links, not just addable ones. // ISRC Scout can't *add* these, but ending/removing acts on the existing relationship by URL — no // resolve/add path needed — so they get the same right-click (end) / middle-click (remove) actions. const groups = new Map(); if (t.recId) otherLinked(t).forEach(o => { let host = ''; try { host = new URL(o.url).hostname.replace(/^www\./, ''); } catch (e) {} const code = 'x:' + (host || o.name); // synthetic per-host code, so Alt = "that provider everywhere" still works const g = groups.get(code); if (g) g.urls.push(o.url); else groups.set(code, { p: { code, name: o.name, color: o.color, icon: o.icon }, urls: [o.url] }); }); const others = [...groups.values()].map(g => linkedIcon(g.p, g.urls, ended, true)).join(''); return '<div class="ii-tl-linked" data-rec="' + esc(t.recId || '') + '">' + cells + others + '</div>'; } function addHtml(t) { const cells = providersFor(t).map(p => (!t.recId || linkedUrl(t, p)) ? '' : '<span class="ii-tl cand" data-code="' + p.code + '" title="' + esc(p.name) + '">' + p.icon + '</span>' ).filter(Boolean).join(''); return '<div class="ii-tl-add" data-rec="' + esc(t.recId || '') + '">' + cells + '</div>'; } const addBox = idx => { const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); return tr ? tr.querySelector('.ii-tl-add') : null; }; const linkedBox = idx => { const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); return tr ? tr.querySelector('.ii-tl-linked') : null; }; function cell(idx, code) { const c = addBox(idx); return c ? c.querySelector('.ii-tl[data-code="' + code + '"]') : null; } // Replace a row's ADD-column slot with a coloured "add" candidate: a link to the // resolved provider URL. Left-click opens the track; RIGHT-click adds the // relationship in the background. function makeNew(idx, p, url) { const el = cell(idx, p.code); if (!el) return; const a = document.createElement('a'); a.className = 'ii-tl new'; a.dataset.code = p.code; a.href = url; a.target = '_blank'; a.rel = 'noopener'; a.style.color = p.color; // #302: mark candidates resolved via an album link pulled from a sibling release const rg = p.urlKey && RELEASE.rgFrom && RELEASE.rgFrom[p.urlKey]; if (rg) a.classList.add('ii-rg'); a.title = p.name + ' — left-click opens · right-click adds this · Ctrl+right-click adds all links on this track · Alt+right-click adds ' + p.name + ' on every track' + (rg ? ' · ' + p.name + ' album link from another release in this group' : ''); a.innerHTML = p.icon; // all add actions on right-click (left-click just opens the track); modifiers // scope it: Ctrl/⌘ = whole track, Alt = this provider across every track. a.addEventListener('contextmenu', e => { e.preventDefault(); if (e.ctrlKey || e.metaKey) addTrack(idx); else if (e.altKey) addProvider(p.code); else addOne(idx, p, url); }); el.replaceWith(a); updateAddBtn(); // #406: bump the live "N link(s)" count on the Submit button as each candidate resolves } // On a successful add: drop the candidate from the ADD column and add a // monochrome icon to the LINKED column; record the URL on the track. function markLinked(idx, p, url) { const t = RELEASE.tracks[idx]; if (t && !(t.recUrls || []).includes(url)) (t.recUrls = t.recUrls || []).push(url); const el = cell(idx, p.code); if (el) el.remove(); const lb = linkedBox(idx); if (lb && t) lb.outerHTML = linkedHtml(t); // re-drawn whole: the new link may join a platform's group } // Same shape as ISRC Scout's standard edit note (noteHeader + Release line + // an "Added …" summary), so link edits read like the script's ISRC edits. function noteFor(provs, urls) { const counts = {}; provs.forEach(p => { counts[p.name] = (counts[p.name] || 0) + 1; }); const breakdown = Object.keys(counts).sort().map(n => n + ' (' + counts[n] + ')').join(', '); return [ noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, 'Added ' + provs.length + ' streaming link' + (provs.length === 1 ? '' : 's') + (breakdown ? ': ' + breakdown : ''), // #640 (rinsuki): once a song is delisted or geo-blocked, its URL no longer says which // album it was — so a YouTube Music link names it, and how it was found. Only for a // single link (#737): MB copies a batch's note onto each of its edits, so a list of // every link repeated itself on every edit. ...((urls || []).length === 1 ? urls : []).filter(u => ytSource.has(u)).map(u => { const y = ytSource.get(u); return 'YouTube Music ' + u + ' ← album "' + (y.album || '?') + '"' + (y.albumUrl ? ' ' + y.albumUrl : '') + ' (' + y.how + ')'; }), ].join('\n'); } // Add recording→url relationships in the background via MB's internal edit API // (same endpoint the native relationship editor uses). Authenticated by the // logged-in session cookie (same-origin) — no OAuth, no CSRF. One POST carries // the whole batch (auto-applied for auto-editors, else queued). items: // [{ recGid, url, linkTypeID }]. function submitRels(items, note) { return withRepeat(() => submitRelsOnce(items, note), 'The links submit'); } // #748 async function submitRelsOnce(items, note) { const body = { edits: items.map(it => ({ edit_type: 90, linkTypeID: it.linkTypeID, attributes: [], entities: [{ entityType: 'recording', gid: it.recGid }, { entityType: 'url', name: it.url }], })), editNote: note || '', makeVotable: 0, }; const r = await mbnFetch(MB_ROOT + '/ws/js/edit/create', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const text = await r.text().catch(() => ''); let j = null; try { j = JSON.parse(text); } catch (e) {} if (!r.ok || (j && j.error)) { const m = (j && (j.error.message || j.error)) || ('HTTP ' + r.status); throw new Error((typeof m === 'string' ? m : JSON.stringify(m)).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200)); } return j; } // Collect resolved "new" candidates matching a selector into add items. function newItems(selector) { const items = []; modal.querySelectorAll(selector).forEach(a => { const p = PROV.find(x => x.code === a.dataset.code); const tr = a.closest('tr[data-idx]'); if (p && tr) items.push({ idx: +tr.dataset.idx, p, url: a.getAttribute('href') }); }); return items; } // Submit a batch of add items in one POST; spin → linked, or restore on failure. async function addBatch(items, confirmMsg) { if (!items.length) return { ok: 0, fail: 0 }; if (confirmMsg && !confirm(confirmMsg)) return { ok: 0, fail: 0 }; items.forEach(it => { const el = cell(it.idx, it.p.code); if (el) { el.className = 'ii-tl spin'; el.dataset.code = it.p.code; } }); let ok = 0, fail = 0; try { await submitRels(items.map(it => ({ recGid: RELEASE.tracks[it.idx].recId, url: it.url, linkTypeID: it.p.linkTypeID })), noteFor(items.map(it => it.p), items.map(it => it.url))); items.forEach(it => markLinked(it.idx, it.p, it.url)); ok = items.length; Log.info('Linked ' + items.length + ' track-link' + (items.length === 1 ? '' : 's') + ' on MusicBrainz'); } catch (e) { fail = items.length; Log.err('Add links failed: ' + errText(e)); items.forEach(it => makeNew(it.idx, it.p, it.url)); // restore the add affordances } updateAddBtn(); return { ok, fail }; } // right-click a candidate → add just that one const addOne = (idx, p, url) => addBatch([{ idx, p, url }]); // ctrl-click → add every resolved link for THIS track const addTrack = idx => addBatch(newItems('tr[data-idx="' + idx + '"] .ii-tl-add .ii-tl.new')); // alt-click → add every resolved link for THIS provider across all tracks const addProvider = code => addBatch(newItems('.ii-tl-add .ii-tl.new[data-code="' + code + '"]')); // footer button → add everything resolved (no confirm — auto-applied edits) // Add every resolved candidate in one batch. Returns { ok, fail } so the unified // Submit (#406) can report/act on the outcome. async function addAll() { const items = newItems('.ii-tl-add .ii-tl.new'); if (!items.length) return { ok: 0, fail: 0 }; return addBatch(items); } // #471 ("Clear Links" menu item) — a resolved-but-not-yet-added candidate // (.ii-tl.new) lives only in the DOM, not in track state (see markLinked: // t.recUrls only ever gets a url once it's genuinely ADDED), so clearing it // is just re-rendering each row's ADD column back to its default unresolved // state — nothing to touch on RELEASE.tracks, and already-linked // relationships (the LINKED column) are untouched. function clearResolved() { (RELEASE ? RELEASE.tracks : []).forEach((t, idx) => { const box = addBox(idx); if (box) box.outerHTML = addHtml(t); }); updateAddBtn(); } // ── DELETE (symmetric to add). Removing a relationship needs its internal id, // which WS2 doesn't expose — fetch it from /ws/js/entity (the rel editor's API) // and submit an EDIT_RELATIONSHIP_DELETE (92 — 91 is EDIT, a no-op for removal). ── const _relCache = {}; // #389 loose URL equality — the icon href (WS2 `url.resource`) and the ws/js rel `target.name` // are normally identical, but tolerate a scheme/trailing-slash difference so the read-only // "other" links (which have no provider-test fallback) still match their relationship. const urlEq = (a, b) => a === b || String(a).replace(/^https?:/i, '').replace(/\/+$/, '') === String(b).replace(/^https?:/i, '').replace(/\/+$/, ''); async function recUrlRels(recGid) { if (_relCache[recGid]) return _relCache[recGid]; const r = await mbnFetch(MB_ROOT + '/ws/js/entity/' + recGid + '?inc=rels', { credentials: 'include', headers: { Accept: 'application/json' } }); if (!r.ok) throw new Error('rels ' + r.status); const j = await r.json(); return (_relCache[recGid] = (j.relationships || []).filter(x => x.target_type === 'url')); } function delNote(provs) { const counts = {}; provs.forEach(p => { counts[p.name] = (counts[p.name] || 0) + 1; }); const breakdown = Object.keys(counts).sort().map(n => n + ' (' + counts[n] + ')').join(', '); return [noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, 'Removed ' + provs.length + ' streaming link' + (provs.length === 1 ? '' : 's') + (breakdown ? ': ' + breakdown : '')].join('\n'); } async function postEdits(edits, note) { const r = await mbnFetch(MB_ROOT + '/ws/js/edit/create', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ edits, editNote: note || '', makeVotable: 0 }) }); const text = await r.text().catch(() => ''); let j = null; try { j = JSON.parse(text); } catch (e) {} if (!r.ok || (j && j.error)) { const m = (j && (j.error.message || j.error)) || ('HTTP ' + r.status); throw new Error((typeof m === 'string' ? m : JSON.stringify(m)).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200)); } return j; } function linkedIcons(selector) { const out = []; modal.querySelectorAll(selector).forEach(a => { const tr = a.closest('tr[data-idx]'); if (!tr) return; let p = PROV.find(x => x.code === a.dataset.code); if (!p && a.dataset.other) { // #389 a read-only "other" linked provider — synthesize a URL-matched descriptor so end/remove work const url = a.getAttribute('href'); let urls = [url]; try { urls = JSON.parse(a.dataset.urls || 'null') || urls; } catch (e) {} p = { code: a.dataset.code, name: a.dataset.name || 'link', icon: a.innerHTML, color: a.style.color, linkTypeID: null, test: u => urls.includes(u), other: true }; } let urls = [a.getAttribute('href')]; try { urls = JSON.parse(a.dataset.urls || 'null') || urls; } catch (e) {} if (p) urls.forEach(url => out.push({ idx: +tr.dataset.idx, p, url, el: a })); }); return out; } async function removeBatch(icons) { if (!icons.length) return; icons.forEach(ic => ic.el.classList.add('removing')); try { const edits = [], used = []; for (const ic of icons) { const t = RELEASE.tracks[ic.idx]; if (!t.recId) continue; const rels = await recUrlRels(t.recId); const rel = rels.find(r => (r.target && r.target.name) === ic.url) || rels.find(r => urlEq((r.target && r.target.name) || '', ic.url)) || rels.find(r => ic.p.test((r.target && r.target.name) || '') && r.linkTypeID === ic.p.linkTypeID); if (!rel) { Log.warn('No ' + ic.p.name + ' relationship found to remove on "' + (t.title || t.recId) + '"'); continue; } edits.push({ edit_type: 92, id: rel.id, linkTypeID: rel.linkTypeID, attributes: [], entities: [{ entityType: 'recording', gid: t.recId }, { entityType: 'url', gid: rel.target.gid, name: rel.target.name }] }); used.push(ic); } if (!edits.length) { icons.forEach(ic => ic.el.classList.remove('removing')); return; } await postEdits(edits, delNote(used.map(ic => ic.p))); used.forEach(ic => { const t = RELEASE.tracks[ic.idx]; if (t) { t.recUrls = (t.recUrls || []).filter(u => u !== ic.url); delete _relCache[t.recId]; } const lb = t && linkedBox(ic.idx); if (lb) lb.outerHTML = linkedHtml(t); // a group's other links stay, with their count const ab = !ic.p.other && addBox(ic.idx); // re-offer as a hidden Add candidate so Find links can resolve it again — but never for the read-only "other" providers ISRC Scout can't add if (ab && !ab.querySelector('.ii-tl[data-code="' + ic.p.code + '"]')) { const s = document.createElement('span'); s.className = 'ii-tl cand'; s.dataset.code = ic.p.code; s.title = ic.p.name; s.innerHTML = ic.p.icon; ab.appendChild(s); } }); Log.info('Removed ' + used.length + ' link' + (used.length === 1 ? '' : 's') + ' on MusicBrainz'); } catch (e) { Log.err('Remove links failed: ' + errText(e)); icons.forEach(ic => ic.el.classList.remove('removing')); } updateAddBtn(); } const removeOne = (idx, code) => removeBatch(linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked[data-code="' + code + '"]')); const removeTrack = idx => removeBatch(linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked')); const removeProvider = code => removeBatch(linkedIcons('.ii-tl-linked .ii-tl.linked[data-code="' + code + '"]')); // #341: toggle the "ended" flag on a link's relationship (EDIT_RELATIONSHIP, edit_type 91) — // e.g. a release taken down so its streaming links no longer resolve. Reversible. Verified: MB // applies `ended` via edit_type 91 with no end_date required. function endNote(provs, ended) { const counts = {}; provs.forEach(p => { counts[p.name] = (counts[p.name] || 0) + 1; }); const breakdown = Object.keys(counts).sort().map(n => n + ' (' + counts[n] + ')').join(', '); return [noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, (ended ? 'Marked ' : 'Un-ended ') + provs.length + ' streaming link' + (provs.length === 1 ? '' : 's') + (ended ? ' as ended' : '') + (breakdown ? ': ' + breakdown : '')].join('\n'); } async function endBatch(icons, ended) { if (!icons.length) return; icons.forEach(ic => ic.el.classList.add('removing')); try { const edits = [], used = []; for (const ic of icons) { const t = RELEASE.tracks[ic.idx]; if (!t.recId) continue; const rels = await recUrlRels(t.recId); const rel = rels.find(r => (r.target && r.target.name) === ic.url) || rels.find(r => urlEq((r.target && r.target.name) || '', ic.url)) || rels.find(r => ic.p.test((r.target && r.target.name) || '') && r.linkTypeID === ic.p.linkTypeID); if (!rel) { Log.warn('No ' + ic.p.name + ' relationship found on "' + (t.title || t.recId) + '"'); ic.el.classList.remove('removing'); continue; } if (!!rel.ended === ended) { ic.el.classList.remove('removing'); ic.el.classList.toggle('ended', ended); continue; } // already in the desired state edits.push({ edit_type: 91, id: rel.id, linkTypeID: rel.linkTypeID, attributes: [], ended: ended, entities: [{ entityType: 'recording', gid: t.recId }, { entityType: 'url', gid: rel.target.gid, name: rel.target.name }] }); used.push(ic); } if (!edits.length) return; await postEdits(edits, endNote(used.map(ic => ic.p), ended)); used.forEach(ic => { const t = RELEASE.tracks[ic.idx]; if (t) { t.endedUrls = t.endedUrls || new Set(); if (ended) t.endedUrls.add(ic.url); else t.endedUrls.delete(ic.url); delete _relCache[t.recId]; } ic.el.classList.remove('removing'); ic.el.classList.toggle('ended', ended); }); Log.info((ended ? 'Marked ' : 'Un-ended ') + used.length + ' link' + (used.length === 1 ? '' : 's') + (ended ? ' as ended' : '') + ' on MusicBrainz'); } catch (e) { Log.err('End toggle failed: ' + errText(e)); icons.forEach(ic => ic.el.classList.remove('removing')); } } const iconEnded = ic => ic.el.classList.contains('ended'); const endOne = (idx, code) => { const ics = linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked[data-code="' + code + '"]'); if (ics.length) endBatch(ics, !iconEnded(ics[0])); }; const endTrack = (idx, code) => { const c = linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked[data-code="' + code + '"]')[0]; endBatch(linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked'), c ? !iconEnded(c) : true); }; const endProvider = (code, idx) => { const c = linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked[data-code="' + code + '"]')[0]; endBatch(linkedIcons('.ii-tl-linked .ii-tl.linked[data-code="' + code + '"]'), c ? !iconEnded(c) : true); }; // Show/label the toolbar "Add N links" button based on resolved candidates. // A track is "missing" links when it has none of our providers linked // (at least one link → not missing) — parallels the ISRC "N missing" badge. function missingCount() { let n = 0; (RELEASE ? RELEASE.tracks : []).forEach(t => { if (t.recId && !providersFor(t).some(p => linkedUrl(t, p))) n++; }); return n; } function updateAddBtn() { const m = missingCount(); const n = modal.querySelectorAll('.ii-tl-add .ii-tl.new').length; // resolved + addable now let linked = 0; // links, not icons: a grouped icon stands for several modal.querySelectorAll('.ii-tl-linked .ii-tl.linked').forEach(a => { let n = 1; try { n = JSON.parse(a.dataset.urls || '[1]').length; } catch (e) {} linked += n; }); const total = RELEASE ? RELEASE.tracks.length : 0; const sum = modal.querySelector('#ii-summary-links'); // #301: links status bar (footer) if (sum) sum.innerHTML = '<b>' + total + '</b> tracks · <b>' + linked + '</b> link' + (linked === 1 ? '' : 's') + (m ? ' · <span style="color:var(--mbu-warn)">' + m + ' track' + (m === 1 ? '' : 's') + ' with none</span>' : '') + (n ? ' · <span style="color:var(--mbu-ok)">' + n + ' to add</span>' : ''); updateHdrStatus(); // #471: header status text replaced the old Links-tab badge refreshSubmitBtn(); // #406: resolved links feed the shared Submit-to-MusicBrainz count } // Throttled resolve pass: for every track with an ISRC and no existing link on // a provider, look the track up by ISRC and, if found, turn the icon into a // coloured "add" candidate (resolve only — no edits are made here). // #307: resolve one provider across all its candidate tracks, with a bounded // pool (p.conc workers) so a provider's own rate limit is respected while its // requests still overlap. Album providers fetch once (cached) then resolve // locally, so they run effectively instantly. async function resolveProvider(p) { if (p.album && !(RELEASE && RELEASE[p.urlKey])) return; // album provider with no album link → nothing to resolve const jobs = []; for (let idx = 0; idx < RELEASE.tracks.length; idx++) { const t = RELEASE.tracks[idx]; if (!t.recId) continue; // #466: a recording can carry MULTIPLE ISRCs (a reissue/relabel gets its own code // for the same recording) — a provider's catalogue may be indexed under any one of // them, so keep every candidate here and try them in turn below instead of only // ever trying the first (which silently missed tracks 8/10 on the reported release // because Qobuz had them under the recording's OTHER isrc). const isrcs = [normalizeIsrc(t.pending), ...(t.existing || []).map(normalizeIsrc)] .filter((v, i, a) => isValidIsrc(v) && a.indexOf(v) === i); // by-ISRC providers need an ISRC; album ones don't — nor does YouTube Music with an album link (#640) if (!p.album && !isrcs.length && !(p.code === 'yt' && RELEASE.ytmUrl)) continue; if (linkedUrl(t, p)) continue; // already linked → leave monochrome const el = cell(idx, p.code); if (!el || !el.classList.contains('cand')) continue; el.className = 'ii-tl spin'; el.dataset.code = p.code; // show all candidates spinning up front jobs.push({ idx, isrcs, t }); } let next = 0; const worker = async () => { while (next < jobs.length) { const j = jobs[next++]; let url = null; for (const isrc of (j.isrcs.length ? j.isrcs : [''])) { try { url = await p.resolve(isrc, j.t, j.idx); } catch (e) { /* rate-limited / not found */ } if (url) { url = normalizeProviderUrl(p.code, url); break; } } const fresh = cell(j.idx, p.code); if (fresh) { if (url) makeNew(j.idx, p, url); else { fresh.className = 'ii-tl absent'; fresh.dataset.code = p.code; } } if (p.gap && next < jobs.length) await sleep(p.gap); } }; await Promise.all(Array.from({ length: Math.min(p.conc || 1, jobs.length) }, worker)); } async function resolve() { if (resolving) return; resolving = true; beginCollect(); // #406: keep Submit live while resolving, don't gray it out const btn = modal.querySelector('#ii-links-btn'); if (btn) { btn.disabled = true; btn.dataset.busy = '1'; } try { await Promise.all(PROV.map(resolveProvider)); // #307: providers run in parallel } finally { resolving = false; endCollect(); if (btn) { btn.disabled = false; delete btn.dataset.busy; } updateAddBtn(); } } // Find links without the dialog (#680, for Mission Control): the same providers, pools and // gaps as resolveProvider, but the results come back as data, never into the table. // isrcOf(t): an ISRC found for the track but not on MusicBrainz yet (tried first). // → { <idx>: [{ code, name, url, linkTypeID }] } async function findHeadless(isrcOf, onProvider) { const out = {}; await Promise.all(PROV.map(async p => { if (p.album && !(RELEASE && RELEASE[p.urlKey])) return; const jobs = []; RELEASE.tracks.forEach((t, idx) => { if (!t.recId || linkedUrl(t, p)) return; const isrcs = [normalizeIsrc(isrcOf(t) || ''), ...(t.existing || []).map(normalizeIsrc)].filter((v, i, a) => isValidIsrc(v) && a.indexOf(v) === i); if (!p.album && !isrcs.length && !(p.code === 'yt' && RELEASE.ytmUrl)) return; jobs.push({ idx, isrcs, t }); }); if (!jobs.length) return; if (onProvider) onProvider(p.name); let next = 0, hits = 0; const worker = async () => { while (next < jobs.length) { const j = jobs[next++]; let url = null; for (const isrc of (j.isrcs.length ? j.isrcs : [''])) { try { url = await p.resolve(isrc, j.t, j.idx); } catch (e) { /* rate-limited / not found */ } if (url) { url = normalizeProviderUrl(p.code, url); break; } } if (url) { (out[j.idx] = out[j.idx] || []).push({ code: p.code, name: p.name, url, linkTypeID: p.linkTypeID }); hits++; } if (p.gap && next < jobs.length) await sleep(p.gap); } }; await Promise.all(Array.from({ length: Math.min(p.conc || 1, jobs.length) }, worker)); Log.info('Find links (headless) ' + p.name + ': ' + hits + ' of ' + jobs.length + ' track(s)'); })); return out; } // #301: per-track streaming links for export — what's linked plus what Find // links resolved, across our providers. function linkRows() { const rows = []; (RELEASE ? RELEASE.tracks : []).forEach((t, idx) => { if (!t.recId) return; const seen = new Set(); (t.recUrls || []).forEach(u => { const p = PROV.find(x => x.test(u)); if (p && !seen.has(u)) { seen.add(u); rows.push({ recording: t.recId, track: t.title || '', provider: p.name, url: u, status: 'linked' }); } }); const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); if (tr) tr.querySelectorAll('.ii-tl-add .ii-tl.new').forEach(a => { const p = PROV.find(x => x.code === a.dataset.code); const u = a.getAttribute('href'); if (p && u && !seen.has(u)) { seen.add(u); rows.push({ recording: t.recId, track: t.title || '', provider: p.name, url: u, status: 'to add' }); } }); }); return rows; } // Test hook only (#466) — no behavior change; lets verify-466.mjs call a single // provider's resolveProvider directly instead of the aggregate Find-links button, // so the test doesn't also have to mock every other provider's network calls. if (mbuTestHooks()) window.__isrcScoutTest466 = { PROV, resolveProvider, normalizeProviderUrl }; if (mbuTestHooks()) window.__isrcScoutTest664 = { audiomackRelease }; if (mbuTestHooks()) window.__isrcScoutTest669 = { sevendigitalRelease, sdRef, sdKey, setSdKey: k => { _sdKey = k; } }; // #749: every link the recording has, for the release page's tracklist: read-only, ended ones flagged function pageLinks(t) { const out = []; const seen = new Set(); PROV.forEach(p => (t.recUrls || []).forEach(u => { if (p.test(u) && !seen.has(u)) { seen.add(u); out.push({ url: u, name: p.name, color: p.color, icon: p.icon }); } })); return out.concat(otherLinked(t)).map(o => Object.assign(o, { ended: !!(t.endedUrls && t.endedUrls.has(o.url)) })); } return { findHeadless, submitRels, noteFor, linkedHtml, addHtml, pageLinks, resolve, addAll, clearResolved, missingCount, refresh: updateAddBtn, removeOne, removeTrack, removeProvider, endOne, endTrack, endProvider, linkRows }; })(); /* ── render the track table ── */ function renderTracks() { // #301: release title centered (Zen), artist + year on the line below modal.querySelector('#ii-rel-title').textContent = RELEASE.title || ''; modal.querySelector('#ii-rel-sub').textContent = [RELEASE.artist, RELEASE.releaseYear].filter(Boolean).join(' · '); // Provider buttons (#180): show a provider only when the release has its // link in MB OR Platform Check found one. MB-linked buttons are marked // (ring + brand tint via .ii-mb); an unmarked button = a PC-found link. [['Deezer', 'ii-dz-all', 'deezerId'], ['Spotify', 'ii-sp-all', 'spotifyId'], ['Beatport', 'ii-bp-all', 'beatportId'], ['Tidal', 'ii-td-all', 'tidalId'], ['Volumo', 'ii-vo-all', 'volumoId'], ['HDtracks', 'ii-hd-all', 'hdtracksId'], ['Qobuz', 'ii-qz-all', 'qobuzId'], ['Apple', 'ii-am-all', 'appleUrl'], ['SoundCloud', 'ii-sc-all', 'scUrl'], ['Audiomack', 'ii-ak-all', 'akUrl'], ['7digital', 'ii-sd-all', 'sdUrl']].forEach(([source, id, field]) => { const btn = modal.querySelector('#' + id); const mbId = RELEASE[field]; // Spotify imports only via its MB-linked album (ISRC Hunt resolves the // release FROM the URL), so a PC-only Spotify link is not usable (#180). const hasPc = source !== 'Spotify' && !!platformCheckUrl(source); const rg = RELEASE.rgFrom && RELEASE.rgFrom[field]; // #302: pulled from a sibling release in the RG btn.style.display = (mbId || hasPc) ? '' : 'none'; btn.classList.toggle('ii-mb', !!mbId); btn.classList.toggle('ii-rg', !!rg); btn.disabled = false; btn.title = rg ? ('Import from ' + source + ' (link from another release in this group' + (rg.title ? ': ' + rg.title : '') + ')') : mbId ? ('Import from ' + source + ' (linked in MusicBrainz)') : hasPc ? ('Import from ' + source + ' (link found by Platform Check — not yet in MB)') : ('No ' + source + ' link'); }); // #643: All, last in the toolbar, when there is more than one provider to compare { const allBtn = modal.querySelector('#ii-all-all'), n = allProviders().length; allBtn.style.display = n > 1 ? '' : 'none'; allBtn.title = 'Check every track’s ISRC on ' + allProviders().map(k => TRACK_PROV[k].name).join(', ') + ' at once — a verdict per row, the comparison on its chip'; } tbody.innerHTML = ''; let lastMedium = null; RELEASE.tracks.forEach((t, idx) => { if (t.mediumPos !== lastMedium) { lastMedium = t.mediumPos; const mr = document.createElement('tr'); mr.className = 'ii-medrow'; mr.innerHTML = '<td colspan="7">Medium ' + t.mediumPos + (t.mediumTitle ? ': ' + esc(t.mediumTitle) : '') + '</td>'; tbody.appendChild(mr); } const tr = document.createElement('tr'); tr.dataset.idx = idx; tr.innerHTML = '<td class="ii-pos">' + esc(t.number || t.trackPos) + '</td>' + '<td><div class="ii-track-title">' + (t.recId ? '<a href="' + MB_ROOT + '/recording/' + t.recId + '" target="_blank" rel="noopener" title="' + esc(t.title) + '">' + esc(t.title) + '</a>' : esc(t.title)) + '</div><div class="ii-track-artist">' + esc(t.artist) + '</div></td>' + '<td class="ii-track-dur">' + esc(t.dur) + '</td>' + // #471: ISRC and Links columns are separate cells now, always both visible — // no more ii-only-isrc/ii-only-links split within a shared td. '<td><div class="ii-existing">' + existingHtml(t.existing, t.pendingRemoval, t.failedRemoval, t.recId) + '</div></td>' + // .ii-cands is a sibling of .ii-inwrap (full-width, under the input), NOT inside it. '<td><div class="ii-inwrap">' + // #490: initial "search SoundExchange by title/artist" entry point — a row-hover-only // icon instead of a permanently-visible text link under every row. '<button class="ii-sx-hover" type="button" tabindex="-1" title="Search SoundExchange by title/artist">' + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="10" cy="10" r="6.5"/><line x1="15" y1="15" x2="20" y2="20"/></svg>' + '</button>' + '<div class="ii-input-box">' + '<input class="ii-input" type="text" maxlength="15" value="' + esc(t.pending) + '">' + '<button class="ii-clear" type="button" tabindex="-1" title="Clear this field">×</button>' + '</div>' + // first track has no previous ISRC to increment — hide +1 but keep its slot so SX text stays aligned '<button class="ii-plus' + (idx === 0 ? ' ii-plus-hidden' : '') + '" title="Previous ISRC + 1 (right-click: fill the +1 sequence down to the last track, overwriting)">+1</button>' + '<span class="ii-sxsplit">' + '<button class="ii-sx" type="button" title="Look up this track\'s ISRC on SoundExchange — verify the entered ISRC, or (if empty) search by title/artist · right-click: do all tracks">SX</button>' + '<button class="ii-sxprov" type="button" tabindex="-1" title="Choose the ISRC provider for all tracks">▾</button>' + '</span>' + '<span class="ii-lookup"></span>' + '</div><div class="ii-cands"></div></td>' + '<td>' + TrackLinks.linkedHtml(t) + '</td>' + '<td>' + TrackLinks.addHtml(t) + '</td>'; const input = tr.querySelector('.ii-input'); tr.querySelector('.ii-clear').addEventListener('click', () => clearRow(idx)); input.addEventListener('input', () => { t.pending = normalizeIsrc(input.value); t.source = 'manual'; if (input.value !== t.pending) { const p = input.selectionStart; input.value = t.pending; input.setSelectionRange(p, p); } input.dataset.autofill = ''; if (input.classList.contains('ii-in-suspect')) { input.classList.remove('ii-in-suspect'); input.title = ''; updateSuspectBadge(); } // #431: a manual edit is the user taking over validateInput(input, t); updateSummary(); // #157: don't hit SoundExchange on every keystroke (that spammed SX). // Clear any stale bullet while editing; the SX check now fires on blur // (manual entry) or the row's [SX] button. const lk = rowLookup(idx); if (lk) { lk.className = 'ii-lookup'; lk.textContent = ''; lk.onclick = null; } const sxb = tr.querySelector('.ii-sx'); if (sxb) sxb.disabled = trackBtnDisabled(t, input.value); // #157/#181: provider-aware enabled state }); // Manual entry → verify on SoundExchange only when the field loses focus (#157). input.addEventListener('blur', () => { if (input.dataset.autofill === '1') return; // filled by a source, not manual typing const v = normalizeIsrc(input.value); if (!v || !isValidIsrc(v)) return; // #643: with All picked, a typed ISRC is checked by All, not by SoundExchange — both would // write the row's one result slot const run = trackProv === 'all' ? allLookupRow(idx, v) : lookupIsrc(idx, v); run.catch(e => { if (e && (e.rateLimited || e.captcha)) sxBlocked(e); }); }); const plusBtn = tr.querySelector('.ii-plus'); if (plusBtn && idx > 0) { // first row's +1 is a hidden spacer — don't wire it plusBtn.addEventListener('click', () => plusOne(idx)); plusBtn.addEventListener('contextmenu', e => { e.preventDefault(); plusOneFillDown(idx); }); } // Explicit per-track SoundExchange trigger (#157): the single by-ISRC fetch // the auto-call used to do. Verifies the ENTERED ISRC, or — when the field is // empty — an EXISTING ISRC already on the recording. Metadata search stays on // the separate "⚙ search SoundExchange…" entry. Disabled when there's nothing // to verify (no valid entered ISRC AND no existing ISRC). const sxBtn = tr.querySelector('.ii-sx'); if (sxBtn) { sxBtn.disabled = trackBtnDisabled(t, input.value); sxBtn.addEventListener('click', () => runTrackSingle(idx)); // right-click → run the current provider for ALL tracks (#181) sxBtn.addEventListener('contextmenu', e => { e.preventDefault(); runTrackAll(); }); // #643: with All, hovering the button shows the row's comparison beside its chip — moving // down the buttons walks the tracks sxBtn.addEventListener('mouseenter', () => { if (trackProv === 'all' && _allRes[idx]) openAllPop(idx); }); sxBtn.addEventListener('mouseleave', () => { if (trackProv === 'all') hideAllPopSoon(); }); } // the ▾ next to each per-track button opens the shared provider menu (#181) const provBtn = tr.querySelector('.ii-sxprov'); if (provBtn) provBtn.addEventListener('click', e => { e.stopPropagation(); modal.querySelector('#ii-prov-menu').classList.contains('open') ? closeProvMenu() : openProvMenu(provBtn); }); // #490: initial per-track entry point to the SoundExchange refine panel — the // row-hover icon left of the input (replaces the old permanently-visible // ".ii-cand-refine" text link that used to be appended into .ii-cands here, // which also meant clearPending()'s ".ii-cands" wipe silently removed it). const hoverSx = tr.querySelector('.ii-sx-hover'); if (hoverSx) hoverSx.addEventListener('click', () => openSxPanel(idx)); tbody.appendChild(tr); validateInput(input, t); }); // #643: the rows are built with SoundExchange's label; the picked provider (kept while the // page is open) re-skins them, or SoundExchange when this release doesn't offer it setTrackProvider(trackProvAvailable(trackProv) ? trackProv : 'sx', { quiet: true }); updateSummary(); TrackLinks.refresh(); // #301: set the Links tab "N missing" badge } function existingHtml(arr, pending, failed, recId) { if (!arr || !arr.length) return '<span class="none">none</span>'; const pend = new Set((pending || []).map(normalizeIsrc)); // #587: a removal whose POST failed. It stays a checkbox — the point is to be // able to try again — but it must not look untouched, and must not be mistaken // for a pending one: nothing was submitted and no edit exists. const bad = new Set((failed || []).map(normalizeIsrc)); return arr.map(i => { if (pend.has(normalizeIsrc(i))) { /* #587: claim only what we know. We submitted it in THIS session; whether it is still open, applied, cancelled or voted down is MusicBrainz's to say, and we don't ask (his call — no extra requests). The link is where to go and look; it costs us nothing. */ const inner = '⏳ <samp>' + esc(i) + '</samp>'; const tip = 'Remove-ISRC edit submitted in this session — MusicBrainz decides what happens to it. Click to see this recording’s open edits.'; return recId ? '<a class="ii-ex-item ii-ex-pending" href="' + MB_ROOT + '/recording/' + esc(recId) + '/open_edits" target="_blank" rel="noopener" title="' + tip + '">' + inner + '</a>' : '<span class="ii-ex-item ii-ex-pending" title="' + tip + '">' + inner + '</span>'; } const isBad = bad.has(normalizeIsrc(i)); return '<label class="ii-ex-item' + (isBad ? ' ii-ex-failed' : '') + '" title="' + (isBad ? 'Remove-ISRC edit failed to submit — see the Log, then check it again to retry' : 'Check to delete this ISRC from the recording') + '">' + '<input type="checkbox" class="ii-ex-del" data-isrc="' + esc(i) + '">' + '<samp>' + esc(i) + '</samp></label>'; }).join(''); } function rowInput(idx) { const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); return tr ? tr.querySelector('.ii-input') : null; } function rowCands(idx) { const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); return tr ? tr.querySelector('.ii-cands') : null; } function rowLookup(idx) { const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); return tr ? tr.querySelector('.ii-lookup') : null; } // verify a typed ISRC on SoundExchange and show inline match/mismatch info function sxLookupCached(isrc) { if (_isrcLookupCache[isrc]) return Promise.resolve(_isrcLookupCache[isrc]); return SX.apiSearchByIsrc(isrc).then(rows => { _isrcLookupCache[isrc] = rows; return rows; }); } function lookupIsrc(idx, isrc) { const el = rowLookup(idx), t = RELEASE.tracks[idx]; if (!el) return Promise.resolve(); el.onclick = null; // drop any "click to verify" handler const cached = !!_isrcLookupCache[isrc]; el.className = 'ii-lookup spin'; el.textContent = cached ? '' : '⏳ checking SoundExchange…'; if (!cached) Log.info('SX lookup ' + isrc + ' (#' + (t.number || t.trackPos) + ')'); return sxLookupCached(isrc).then(rows => { if (!rows.length) { el.className = 'ii-lookup err'; el.textContent = '✗ not found on SoundExchange'; Log.warn('SX lookup ' + isrc + ': not found'); return; } const f = SX.fields(rows[0]); const cls = SX.classify(f, t.title, t.artist, t.dur, RELEASE.releaseYear); const good = cls === 'best'; const rel = [f.relTitle, f.relLabel, f.relDate].filter(Boolean).join(' · '); el.className = 'ii-lookup ' + (good ? 'ok' : 'warn'); el.innerHTML = (good ? '✓ ' : '⚠ ') + sxMetaHtml(f, t) + (rel ? '<br><span class="ii-lookup-rel">' + esc(rel) + '</span>' : ''); el.title = [f.title, f.artist, f.year, f.dur].filter(Boolean).join(' · ') + (rel ? ' | ' + rel : ''); Log.info('SX lookup ' + isrc + ': ' + (good ? 'match' : cls === 'warn' ? 'length mismatch' : 'MISMATCH') + ' "' + f.title + '" — ' + f.artist); }).catch(e => { if (e && (e.rateLimited || e.captcha)) { el.className = 'ii-lookup err'; el.textContent = e.captcha ? '⚠ captcha' : '⚠ rate-limited'; throw e; } // let pumpVerify stop the queue (#126/#157) el.className = 'ii-lookup err'; el.textContent = '✗ lookup failed'; Log.err('SX lookup ' + isrc + ' failed: ' + e.message); }); } // SoundExchange verification queue — bulk fills (Deezer / Spotify / paste) route // their per-ISRC verification through here so it's SERIALIZED (no concurrent SX // hits) and CAPPED at SX_BATCH_LIMIT. Past the cap, the rest show a clickable // "click to verify" bullet; clicking resumes the next batch. (Manual typing // stays immediate — it never goes through the queue.) const _vq = { items: [], running: false, done: 0 }; // During a streaming import (Deezer/Spotify) we DEFER per-fill verification so // SoundExchange's requests don't compete with the import's on the GM queue — // SX must not slow or influence the import. Filled rows are collected here and // verified once the import has finished. let _deferVerify = false; let _deferredVerify = new Set(); function enqueueVerify(idx, isrc) { if (!_vq.items.length && !_vq.running) _vq.done = 0; // a fresh burst → reset the allowance _vq.items = _vq.items.filter(it => it.idx !== idx); // one pending verify per row _vq.items.push({ idx, isrc }); pumpVerify(); } function showVerifyPauses() { const remaining = _vq.items.length; _vq.items.forEach(it => { const el = rowLookup(it.idx); if (!el) return; el.className = 'ii-lookup pending'; el.textContent = '⏳ Not verified — click to check the next ' + Math.min(SX_BATCH_LIMIT, remaining) + ' on SoundExchange'; el.onclick = () => { _vq.done = 0; pumpVerify(); }; }); } async function pumpVerify() { if (_vq.running) return; _vq.running = true; const myEpoch = _sxEpoch; // bumped by abortSxWork / a 429 → this loop bails (#126/#127) try { while (_vq.items.length) { if (myEpoch !== _sxEpoch) return; // cancelled (clear / close / rate-limit) if (_vq.done >= SX_BATCH_LIMIT) { showVerifyPauses(); return; } const { idx, isrc } = _vq.items.shift(); const t = RELEASE.tracks[idx]; // skip if the field no longer holds this value (user changed it meanwhile) if (!t || !isValidIsrc(isrc) || normalizeIsrc(t.pending) !== normalizeIsrc(isrc)) continue; const cached = !!_isrcLookupCache[isrc]; // primed SX rows → no request, no pacing/cap try { await lookupIsrc(idx, isrc); } catch (e) { if (e && (e.rateLimited || e.captcha)) { sxBlocked(e); return; } } if (myEpoch !== _sxEpoch) return; // cancelled while the request was in flight if (!cached) { _vq.done++; if (_vq.items.length && _vq.done < SX_BATCH_LIMIT) await sleep(BATCH_DELAY); } } } finally { // always release the lock — a throw in here previously stalled the whole queue _vq.running = false; } } function validateInput(input, t) { const v = normalizeIsrc(input.value); input.classList.remove('bad', 'dup', 'ok'); if (!v) return; if (!isValidIsrc(v)) { input.classList.add('bad'); return; } if (t.existing.includes(v)) { input.classList.add('dup'); input.title = 'Already on this recording'; return; } input.title = ''; input.classList.add('ok'); } function setPending(idx, isrc, flash, source) { const t = RELEASE.tracks[idx]; const input = rowInput(idx); if (!t || !input) return; t.pending = normalizeIsrc(isrc); t.source = source || 'manual'; input.value = t.pending; if (input.classList.contains('ii-in-suspect')) { input.classList.remove('ii-in-suspect'); input.title = ''; updateSuspectBadge(); } // #431: a fresh fill resets any prior implausibility flag input.dataset.autofill = '1'; // filled by a source — the on-input handler won't fire validateInput(input, t); { const sxb = input.closest('tr')?.querySelector('.ii-sx'); if (sxb) sxb.disabled = trackBtnDisabled(t, t.pending); } // #157/#181: keep per-track button enabled-state in sync after a fill // #157: do NOT auto-hit SoundExchange on fills. Deezer/Spotify/+1/paste fills // used to enqueue a per-track SX verify, which spammed SX (and double-hit it // during the bulk SX search). Now we only show the match bullet when the SX // data is ALREADY cached — i.e. an SX-sourced pick, whose by-ISRC lookup is // served from cache with no request (renderCands / the panel prime the cache). // Everything else stays blank; verify it via the row [SX] button, by blurring // a manual entry, or the bulk SoundExchange search. const lk0 = rowLookup(idx); if (isValidIsrc(t.pending) && _isrcLookupCache[t.pending]) { enqueueVerify(idx, t.pending); // cached → free } else if (lk0) { lk0.className = 'ii-lookup'; lk0.textContent = ''; lk0.onclick = null; } if (flash) { const tr = input.closest('tr'); tr.classList.remove('ii-row-fill'); void tr.offsetWidth; tr.classList.add('ii-row-fill'); } } function clearPending() { abortSxWork('clear entered'); // cancel queued verifications + the bulk SX search (#127) RELEASE.tracks.forEach((t, i) => { t.pending = ''; t.source = ''; const inp = rowInput(i); if (inp) { inp.value = ''; inp.classList.remove('ii-in-suspect'); inp.title = ''; validateInput(inp, t); } }); updateSuspectBadge(); // #431 tbody.querySelectorAll('.ii-cands').forEach(c => c.innerHTML = ''); tbody.querySelectorAll('.ii-lookup').forEach(l => { l.className = 'ii-lookup'; l.textContent = ''; l.title = ''; l.onclick = null; }); updateSummary(); toast('Cleared entered ISRCs'); } // Clear a single track's New-ISRC field (the row's "×" button). function clearRow(idx) { const t = RELEASE.tracks[idx], input = rowInput(idx); if (!t || !input) return; _vq.items = _vq.items.filter(it => it.idx !== idx); // drop any queued verify for this row t.pending = ''; t.source = ''; input.value = ''; input.dataset.autofill = ''; if (input.classList.contains('ii-in-suspect')) { input.classList.remove('ii-in-suspect'); input.title = ''; updateSuspectBadge(); } // #431 validateInput(input, t); const lk = rowLookup(idx); if (lk) { lk.className = 'ii-lookup'; lk.textContent = ''; lk.title = ''; lk.onclick = null; } // re-expand the candidate list so a different suggestion can be picked const box = rowCands(idx); if (box) { box.classList.remove('ii-collapsed'); box.querySelectorAll('.ii-cand.chosen').forEach(c => c.classList.remove('chosen')); } updateSummary(); input.focus(); } function plusOne(idx) { // find nearest previous value (pending or first existing) for (let i = idx - 1; i >= 0; i--) { const prev = RELEASE.tracks[i]; const base = prev.pending || (prev.existing[0] || ''); if (!base) continue; const inc = base.replace(/(\d+)(?!.*\d)/, m => String(parseInt(m, 10) + 1).padStart(m.length, '0')); setPending(idx, inc, true); updateSummary(); return; } toast('No previous ISRC to increment'); } // Right-click +1: fill an incrementing ISRC from here all the way to the LAST // track, overwriting whatever's in each New-ISRC field. function plusOneFillDown(idx) { let base = ''; for (let i = idx - 1; i >= 0; i--) { base = RELEASE.tracks[i].pending || RELEASE.tracks[i].existing[0] || ''; if (base) break; } if (!base) { toast('No previous ISRC to increment'); return; } let count = 0; for (let i = idx; i < RELEASE.tracks.length; i++) { base = base.replace(/(\d+)(?!.*\d)/, m => String(parseInt(m, 10) + 1).padStart(m.length, '0')); setPending(i, base, true); count++; } updateSummary(); toast('Filled ' + count + ' track' + (count === 1 ? '' : 's') + ' with a +1 sequence'); } // #471: replaces the old ISRCs/Links tab badges — plain status text since both // columns are always visible now, with nothing left for a tab to toggle. let _isrcMissing = 0; function updateHdrStatus() { const el = modal && modal.querySelector('#ii-hdr-status'); if (!el) return; const linksMissing = TrackLinks.missingCount(); const parts = []; if (_isrcMissing) parts.push('<b>' + _isrcMissing + '</b> ISRC' + (_isrcMissing === 1 ? '' : 's') + ' missing'); if (linksMissing) parts.push('<b>' + linksMissing + '</b> link' + (linksMissing === 1 ? '' : 's') + ' missing'); el.innerHTML = parts.length ? parts.join(' · ') : 'All ISRCs and links present'; } function updateSummary() { const dupSet = highlightDuplicates(); // ISRCs on >1 recording — not submittable let valid = 0, bad = 0, dup = 0, crossDup = 0, missing = 0; RELEASE.tracks.forEach((t, i) => { const rowMissing = !t.existing.length && !t.pending; if (rowMissing) missing++; const row = tbody && tbody.querySelector('tr[data-idx="' + i + '"]'); // #159: flag still-missing rows if (row) row.classList.toggle('ii-row-missing', rowMissing); if (!t.pending) return; const v = normalizeIsrc(t.pending); if (!isValidIsrc(v)) { bad++; return; } if (t.existing.includes(v)) { dup++; return; } // already on this recording if (dupSet.has(v)) { crossDup++; return; } // same ISRC on another recording → blocked valid++; }); _isrcMissing = missing; updateHdrStatus(); // #471: header status text replaced the old ISRCs-tab badge const seq = iterativeSequence(); // #680: no track count here (the links summary has it); the footer starts with the sequential badge since Match moved to the toolbar summaryEl.innerHTML = [ bad ? '<span style="color:var(--mbu-error)">' + bad + ' invalid</span>' : '', dup ? '<span style="color:var(--mbu-warn)">' + dup + ' already present</span>' : '', crossDup ? '<span style="color:var(--mbu-error)">' + crossDup + ' duplicated across tracks (blocked)</span>' : '', missing ? missing + ' still missing' : ''].filter(Boolean).join(' · ') + (seq ? ' <span class="ii-seq-badge" title="Every track\'s ISRC is the previous one + 1: ' + esc(seq.from) + ' → ' + esc(seq.to) + '">⛓ sequential ' + esc(seq.from) + ' → ' + esc(seq.to) + '</span>' : ''); _validIsrcCount = valid; refreshSubmitBtn(); // #406: label/enable reflect ISRCs + resolved links together } // #406: the one Submit button covers BOTH pending ISRCs and resolved streaming links, // so it shows the breakdown ("(2 ISRCs · 1 link)") and stays live while a collection — // an ISRC import or Find links — is running, rather than graying out until it lands // (majkinetor follow-up). `updateSummary` (ISRC side) and TrackLinks' `updateAddBtn` // (links side) each call this after recomputing their half; the collection wrappers // call it on start/finish. let _validIsrcCount = 0; // Count of in-flight collections (ISRC imports + Find links). >0 ⇒ keep Submit live. let _collecting = 0; const beginCollect = () => { _collecting++; refreshSubmitBtn(); }; const endCollect = () => { if (_collecting > 0) _collecting--; refreshSubmitBtn(); }; function collecting() { return _collecting > 0 || _sxRunning; } function refreshSubmitBtn() { if (!submitBtn || !modal) return; const linkN = modal.querySelectorAll('.ii-tl-add .ii-tl.new').length; const parts = []; if (_validIsrcCount) parts.push(_validIsrcCount + ' ISRC' + (_validIsrcCount === 1 ? '' : 's')); if (linkN) parts.push(linkN + ' link' + (linkN === 1 ? '' : 's')); // #406: while a collection (ISRC import / Find links / SoundExchange) is running, show it ON the // button — a trailing "…" plus a fading pulse — so a stuck-but-ongoing item is visibly in progress. const busy = collecting(); submitBtn.textContent = 'Submit to MusicBrainz' + (parts.length ? ' (' + parts.join(' · ') + ')' : '') + (busy ? ' …' : ''); submitBtn.classList.toggle('ii-collecting', busy); // Enabled when there's something to submit OR a collection is still running (so it's // never grayed mid-import/mid-resolve — the count fills in live as results arrive). submitBtn.disabled = parts.length === 0 && !busy; } // If every track has a valid ISRC and they form one perfect +1 run (same first // 7 chars, last-5 designation incrementing by 1), return {from,to,count}; else null. function iterativeSequence() { const isrcs = RELEASE.tracks.map(t => normalizeIsrc(t.pending || t.existing[0] || '')); if (isrcs.length < 2 || isrcs.some(s => !isValidIsrc(s))) return null; for (let i = 1; i < isrcs.length; i++) { if (isrcs[i].slice(0, 7) !== isrcs[i - 1].slice(0, 7)) return null; if (parseInt(isrcs[i].slice(7), 10) !== parseInt(isrcs[i - 1].slice(7), 10) + 1) return null; } return { from: isrcs[0], to: isrcs[isrcs.length - 1], count: isrcs.length }; } // Flag ISRCs that appear on more than one distinct recording (pending or existing) and // return the Set of those (normalized) ISRCs. Same-recording repeats don't count. function highlightDuplicates() { const recsByIsrc = {}; RELEASE.tracks.forEach((t, i) => { const key = t.recId || ('i' + i); const add = raw => { const v = normalizeIsrc(raw); if (!v) return; (recsByIsrc[v] = recsByIsrc[v] || new Set()).add(key); }; const pv = normalizeIsrc(t.pending); if (pv && isValidIsrc(pv)) add(pv); t.existing.forEach(add); }); const dupSet = new Set(Object.keys(recsByIsrc).filter(v => recsByIsrc[v].size > 1)); RELEASE.tracks.forEach((t, i) => { const tr = tbody.querySelector('tr[data-idx="' + i + '"]'); if (!tr) return; const inp = tr.querySelector('.ii-input'); const pv = normalizeIsrc(t.pending); inp.classList.toggle('dupother', !!(pv && isValidIsrc(pv) && !t.existing.includes(pv) && dupSet.has(pv))); tr.querySelectorAll('.ii-existing samp').forEach(s => s.classList.toggle('dup', dupSet.has(normalizeIsrc(s.textContent)))); }); return dupSet; } /* ── bulk paste / export ── */ function findTrackByNumber(token) { // token like "3" or "1.3" (medium.track) or "1-3" const mt = token.match(/^(\d+)[.\-:](\d+)$/); if (mt) { const med = +mt[1], pos = +mt[2]; return RELEASE.tracks.findIndex(t => t.mediumPos === med && (+t.trackPos === pos)); } const n = token.trim(); return RELEASE.tracks.findIndex(t => String(t.number) === n || String(t.trackPos) === n); } function applyBulk(overwrite) { const text = modal.querySelector('#ii-bulk-text').value; const trimmed = text.trim(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { applyJson(text, overwrite); return; } const lines = text.replace(/\r/g, '').split('\n'); let seq = 0, applied = 0; const tryApply = (idx, isrc) => { if (idx < 0 || idx >= RELEASE.tracks.length) return; const v = normalizeIsrc(isrc); if (!isValidIsrc(v)) return; if (!overwrite && RELEASE.tracks[idx].pending) return; setPending(idx, v, true, 'bulk'); applied++; }; lines.forEach(line => { const raw = line.trim(); // targeted forms (do NOT consume a sequential slot): "3=ISRC" | "ISRC | 1.3" | "1.3 ISRC" let m, target = -1, isrc = null; if ((m = raw.match(/^(.+?)\s*=\s*([A-Za-z0-9-]+)$/))) { target = findTrackByNumber(m[1]); isrc = m[2]; } else if ((m = raw.match(/^([A-Za-z0-9-]+)\s*[|,]\s*(.+)$/))) { isrc = m[1]; target = findTrackByNumber(m[2]); } else if ((m = raw.match(/^([\d.\-:]+)\s+([A-Za-z0-9-]+)$/))) { target = findTrackByNumber(m[1]); isrc = m[2]; } if (isrc !== null) { tryApply(target, isrc); return; } // sequential: a plain ISRC fills the next track; a blank line skips one if (raw) tryApply(seq, raw); seq++; }); updateSummary(); toast('Applied ' + applied + ' ISRC' + (applied === 1 ? '' : 's')); } function applyJson(text, overwrite) { let data; try { data = JSON.parse(text); } catch (e) { toast('Invalid JSON', 'err'); return; } let applied = 0; const apply = (idx, isrc) => { if (idx < 0 || idx >= RELEASE.tracks.length) return; const v = normalizeIsrc(isrc); if (!isValidIsrc(v)) return; if (!overwrite && RELEASE.tracks[idx].pending) return; setPending(idx, v, true, 'bulk'); applied++; }; if (Array.isArray(data)) { data.forEach((entry, i) => { if (typeof entry === 'string') apply(i, entry); else if (entry && entry.isrc) { const idx = entry.recording ? RELEASE.tracks.findIndex(t => t.recId === entry.recording) : (entry.track != null ? findTrackByNumber(String(entry.track)) : i); apply(idx, entry.isrc); } }); } else if (data && typeof data === 'object') { // { recordingMbid: "ISRC" | ["ISRC", ...] } Object.entries(data).forEach(([rid, val]) => { const idx = RELEASE.tracks.findIndex(t => t.recId === rid); const isrc = Array.isArray(val) ? val[0] : val; apply(idx, isrc); }); } updateSummary(); toast('Applied ' + applied + ' ISRC' + (applied === 1 ? '' : 's')); } // #340: a recording can carry MORE THAN ONE ISRC — collect them all (existing + the pending one), // deduped + validated, so the export never drops the 2nd/3rd ISRC of a track. function allIsrcs(t) { const out = []; for (const raw of [...(t.existing || []), t.pending]) { const v = normalizeIsrc(raw); if (v && isValidIsrc(v) && !out.includes(v)) out.push(v); } return out; } function exportText() { const out = RELEASE.tracks.map(t => allIsrcs(t).join(' ')).join('\n'); // one line per track; multiple ISRCs space-separated copyToClipboard(out, out.split('\n').length + ' lines copied'); } function exportJson() { const obj = {}; RELEASE.tracks.forEach(t => { const arr = allIsrcs(t); if (arr.length && t.recId) obj[t.recId] = arr.length === 1 ? arr[0] : arr; // string for one ISRC (back-compat), array for several }); copyToClipboard(JSON.stringify(obj, null, 2), 'JSON copied'); } // #301: export the per-track streaming links (linked + resolved) as CSV / JSON / URL list function exportLinks(fmt) { const rows = TrackLinks.linkRows(); const note = modal.querySelector('#ii-link-export-note'); if (!rows.length) { if (note) note.textContent = 'No links yet — run Find links first (or this release has none of our providers linked).'; toast('No links to export', 'err'); return; } let out, msg; if (fmt === 'urls') { out = [...new Set(rows.map(r => r.url))].join('\n'); msg = out.split('\n').length + ' URLs copied'; } else if (fmt === 'json') { out = JSON.stringify(rows, null, 2); msg = rows.length + ' links copied (JSON)'; } else { const esc = s => /[",\n]/.test(s) ? '"' + String(s).replace(/"/g, '""') + '"' : s; out = ['recording,track,provider,url,status'].concat(rows.map(r => [r.recording, r.track, r.provider, r.url, r.status].map(esc).join(','))).join('\n'); msg = rows.length + ' links copied (CSV)'; } copyToClipboard(out, msg); if (note) { const linked = rows.filter(r => r.status === 'linked').length; note.textContent = rows.length + ' links · ' + linked + ' linked · ' + (rows.length - linked) + ' to add'; } } function copyToClipboard(text, msg) { const ta = modal.querySelector('#ii-bulk-text'); ta.value = text; modal.querySelector('#ii-bulk-pane').classList.add('open'); ta.focus(); ta.select(); try { navigator.clipboard.writeText(text); } catch (e) {} toast(msg); } /* ── candidate suggestions (SoundExchange) ── */ // Collapse a track's candidate list down to just the one matching `isrc` // (marked .chosen); the rest are hidden until the next search re-renders them. function collapseCandsTo(idx, isrc) { const box = rowCands(idx); if (!box) return; const norm = normalizeIsrc(isrc); let found = false; box.querySelectorAll('.ii-cand').forEach(el => { const on = el.dataset.isrc === norm; el.classList.toggle('chosen', on); if (on) found = true; }); box.classList.toggle('ii-collapsed', found); } function renderCands(idx, rows) { const box = rowCands(idx); const t = RELEASE.tracks[idx]; if (!box) return; box.innerHTML = ''; // prime the by-ISRC lookup cache from these search rows, so when one is picked // the verification bullet renders instantly (no extra SoundExchange request) (rows || []).forEach(item => { const iso = normalizeIsrc(SX.fields(item).isrc); if (iso && !_isrcLookupCache[iso]) _isrcLookupCache[iso] = [item]; }); (rows || []).slice(0, 5).forEach(item => { const f = SX.fields(item); const cls = SX.classify(f, t.title, t.artist, t.dur, RELEASE.releaseYear); const inMb = t.existing.includes(normalizeIsrc(f.isrc)); const relInfo = [f.relTitle, f.relLabel, f.relDate].filter(Boolean).join(' · '); const c = document.createElement('div'); c.className = 'ii-cand' + (cls === 'best' ? ' best' : cls === 'warn' ? ' warn' : ' bad') + (inMb ? ' inmb' : ''); c.dataset.isrc = normalizeIsrc(f.isrc); c.title = relInfo ? 'Appears on: ' + relInfo : ''; c.innerHTML = '<span class="ii-cand-isrc">' + esc(f.isrc) + '</span>' + '<span class="ii-cand-meta">' + sxMetaHtml(f, t) + (relInfo ? ' · ' + esc(relInfo) : '') + '</span>' + (inMb ? '<span class="ii-cand-inmb">✓ IN MB</span>' : '<span class="ii-cand-src">SX</span>'); c.addEventListener('click', () => { setPending(idx, f.isrc, true, 'SoundExchange'); updateSummary(); collapseCandsTo(idx, f.isrc); // full list stays one "refine search" click away }); box.appendChild(c); }); box.classList.remove('ii-collapsed'); // "refine search" entry — opens the panel to tweak title/artist/release + exact const refine = document.createElement('div'); refine.className = 'ii-cand-refine'; refine.textContent = (rows && rows.length) ? '⚙ refine search / more…' : '⚙ no match — refine search…'; refine.addEventListener('click', () => openSxPanel(idx)); box.appendChild(refine); } /* ── SoundExchange refine panel ── */ let sxPanel = null, _sxPanelIdx = -1, _sxPanelGen = 0; // Build a URL to the SoundExchange website's own search for the same query, // so the user can fall back to searching there directly (quoted = exact field). function sxPageUrl(title, artist, release) { const enc = s => encodeURIComponent('"' + String(s).replace(/"/g, '') + '"'); const parts = ['tab=' + encodeURIComponent('"simple"')]; if (artist) parts.push('artistName=' + enc(artist)); if (title) parts.push('title=' + enc(title)); if (release) parts.push('releaseName=' + enc(release)); return SX_HOME + '?' + parts.join('&'); } function buildSxPanel() { if (sxPanel) return; sxPanel = document.createElement('div'); sxPanel.id = 'ii-sxpanel'; sxPanel.innerHTML = ` <div class="ii-sxp-hdr"> <span class="t">🔍 <b>SoundExchange</b> — <span id="ii-sxp-track"></span></span> <button id="ii-sxp-close" title="Close">✕</button> </div> <div class="ii-sxp-form"> <div class="ii-sxp-field" id="ii-sxp-f-title"> <input type="checkbox" class="ii-sxp-en" id="ii-sxp-en-title" title="Use the title in the search"> <input type="text" class="ii-sxp-inp" id="ii-sxp-title" placeholder="Title" autocomplete="off"> <button type="button" class="ii-sxp-E" id="ii-sxp-ex-title" title="Exact title (match the whole field)">E</button> </div> <div class="ii-sxp-field" id="ii-sxp-f-artist"> <input type="checkbox" class="ii-sxp-en" id="ii-sxp-en-artist" title="Use the artist in the search"> <input type="text" class="ii-sxp-inp" id="ii-sxp-artist" placeholder="Artist" autocomplete="off"> <button type="button" class="ii-sxp-E" id="ii-sxp-ex-artist" title="Exact artist (match the whole field)">E</button> </div> <div class="ii-sxp-field" id="ii-sxp-f-release"> <input type="checkbox" class="ii-sxp-en" id="ii-sxp-en-release" title="Use the release in the search"> <input type="text" class="ii-sxp-inp" id="ii-sxp-release" placeholder="Release" autocomplete="off"> <button type="button" class="ii-sxp-E" id="ii-sxp-ex-release" title="Exact release (match the whole field)">E</button> </div> <button id="ii-sxp-search">Search</button> </div> <div class="ii-sxp-status"></div> <div class="ii-sxp-results"></div> <div class="ii-sxp-foot"><a id="ii-sxp-web" target="_blank" rel="noopener">🔍 Search on SoundExchange ↗</a></div> `; document.body.appendChild(sxPanel); const closePanel = () => sxPanel.classList.remove('open'); sxPanel.querySelector('#ii-sxp-close').addEventListener('click', closePanel); // Esc / click-outside close — this is a transient search panel (unlike the // main editor, which deliberately ignores both to avoid losing entered work). document.addEventListener('keydown', e => { if (e.key === 'Escape' && sxPanel.classList.contains('open')) { e.stopPropagation(); closePanel(); } }, true); // #285: Esc closes the window (sub-popups consume it first). Data is preserved. document.addEventListener('keydown', e => { if (e.key !== 'Escape' || !modal.classList.contains('open')) return; if (sxPanel.classList.contains('open')) return; // handled by its own (capture) listener above if (modal.querySelector('#ii-all-pop')) { closeAllPop(); return; } // #643: the All comparison first const pm = modal.querySelector('#ii-prov-menu'); if (pm && pm.classList.contains('open')) { closeProvMenu(); return; } const ua = modal.querySelector('#ii-urladd'); if (ua && ua.classList.contains('open')) return; // the url-add field closes itself closeModal(); }); document.addEventListener('mousedown', e => { if (!sxPanel.classList.contains('open')) return; // ignore clicks inside the panel, and on a "refine search" entry (which re-opens it) if (sxPanel.contains(e.target) || (e.target.closest && e.target.closest('.ii-cand-refine'))) return; closePanel(); }); sxPanel.querySelector('#ii-sxp-search').addEventListener('click', sxPanelSearch); ['#ii-sxp-title', '#ii-sxp-artist', '#ii-sxp-release'].forEach(id => sxPanel.querySelector(id).addEventListener('keydown', e => { if (e.key === 'Enter') sxPanelSearch(); })); // per-term "use this" checkbox (greys the field when off; release is remembered) // and "E" exact toggle (persisted in sxExact, kept across tracks) ['title', 'artist', 'release'].forEach(key => { const en = sxPanel.querySelector('#ii-sxp-en-' + key); en.addEventListener('change', () => { sxPanel.querySelector('#ii-sxp-f-' + key).classList.toggle('off', !en.checked); if (key === 'release') { sxRelEnabled = en.checked; store.set('sx_rel_enabled', sxRelEnabled); } }); const ex = sxPanel.querySelector('#ii-sxp-ex-' + key); ex.addEventListener('click', () => { sxExact[key] = !sxExact[key]; saveSxExact(); ex.classList.toggle('on', sxExact[key]); Log.info('SX exact ' + key + ' = ' + sxExact[key]); }); }); // drag by header const hdr = sxPanel.querySelector('.ii-sxp-hdr'); let dx = 0, dy = 0, drag = false; hdr.addEventListener('mousedown', e => { if (e.target.id === 'ii-sxp-close') return; drag = true; const r = sxPanel.getBoundingClientRect(); sxPanel.style.left = r.left + 'px'; sxPanel.style.top = r.top + 'px'; sxPanel.style.right = 'auto'; dx = e.clientX - r.left; dy = e.clientY - r.top; e.preventDefault(); }); document.addEventListener('mousemove', e => { if (drag) { sxPanel.style.left = (e.clientX - dx) + 'px'; sxPanel.style.top = (e.clientY - dy) + 'px'; } }); document.addEventListener('mouseup', () => { drag = false; }); } function openSxPanel(idx) { buildSxPanel(); const t = RELEASE.tracks[idx]; _sxPanelIdx = idx; sxPanel.querySelector('#ii-sxp-track').textContent = t.title + (t.artist ? ' — ' + t.artist : ''); sxPanel.querySelector('#ii-sxp-title').value = t.title; sxPanel.querySelector('#ii-sxp-artist').value = t.artist; sxPanel.querySelector('#ii-sxp-release').value = RELEASE.title || ''; // prefilled from MusicBrainz const setEnabled = (key, on) => { sxPanel.querySelector('#ii-sxp-en-' + key).checked = on; sxPanel.querySelector('#ii-sxp-f-' + key).classList.toggle('off', !on); }; const setExact = (key, on) => sxPanel.querySelector('#ii-sxp-ex-' + key).classList.toggle('on', on); // title/artist default ON (reset per track); release uses the remembered toggle setEnabled('title', true); setEnabled('artist', true); setEnabled('release', sxRelEnabled); // E (exact) reflects the persisted, kept-across-tracks state setExact('title', sxExact.title); setExact('artist', sxExact.artist); setExact('release', sxExact.release); sxPanel.classList.add('open'); sxPanelSearch(); } function sxPanelSearch() { const idx = _sxPanelIdx; const t = RELEASE.tracks[idx]; // only enabled terms are used; a disabled term is sent empty (= ignored by SX) const use = key => sxPanel.querySelector('#ii-sxp-en-' + key).checked; const val = key => sxPanel.querySelector('#ii-sxp-' + key).value.trim(); const title = use('title') ? val('title') : ''; const artist = use('artist') ? val('artist') : ''; const release = use('release') ? val('release') : ''; const exact = { title: sxExact.title, artist: sxExact.artist, release: sxExact.release }; const stEl = sxPanel.querySelector('.ii-sxp-status'); const resEl = sxPanel.querySelector('.ii-sxp-results'); const goBtn = sxPanel.querySelector('#ii-sxp-search'); const webLink = sxPanel.querySelector('#ii-sxp-web'); if (webLink) webLink.href = sxPageUrl(title, artist, release); stEl.className = 'ii-sxp-status'; stEl.textContent = 'Searching…'; goBtn.disabled = true; const gen = ++_sxPanelGen; Log.info('SX refine #' + (t.number || t.trackPos) + ': "' + title + '" / "' + artist + '"' + (release ? ' / rel "' + release + '"' : ''), exact); SX.apiSearch(title, artist, 0, 25, exact, release).then(rows => { if (gen !== _sxPanelGen) return; goBtn.disabled = false; stEl.textContent = rows.length ? rows.length + ' result' + (rows.length === 1 ? '' : 's') : 'No results'; renderSxPanelResults(idx, rows); }).catch(e => { if (gen !== _sxPanelGen) return; goBtn.disabled = false; stEl.className = 'ii-sxp-status err'; resEl.innerHTML = ''; if (e && (e.rateLimited || e.captcha)) { stEl.textContent = e.captcha ? '⚠ captcha — resolve in browser, then retry' : '⚠ rate-limited — wait a minute'; sxBlocked(e); } else stEl.textContent = '⚠ ' + e.message; }); } function renderSxPanelResults(idx, rows) { const t = RELEASE.tracks[idx]; const resEl = sxPanel.querySelector('.ii-sxp-results'); resEl.innerHTML = ''; // prime the by-ISRC cache so picking a result verifies instantly (no extra request) rows.forEach(item => { const iso = normalizeIsrc(SX.fields(item).isrc); if (iso && !_isrcLookupCache[iso]) _isrcLookupCache[iso] = [item]; }); rows.forEach(item => { const f = SX.fields(item); const cls = SX.classify(f, t.title, t.artist, t.dur, RELEASE.releaseYear); const inMb = t.existing.includes(normalizeIsrc(f.isrc)); const cur = normalizeIsrc(t.pending) === normalizeIsrc(f.isrc); const rel = [f.relTitle, f.relLabel, f.relDate].filter(Boolean).join(' · '); const row = document.createElement('div'); row.className = 'ii-sxp-row' + (cur ? ' cur' : cls === 'best' ? ' best' : cls === 'warn' ? ' warn' : ' bad'); row.innerHTML = '<span class="ii-sxp-isrc">' + esc(f.isrc) + '</span>' + '<span class="ii-sxp-meta"><span class="a">' + sxMetaHtml(f, t) + '</span>' + (rel ? '<span class="b">' + esc(rel) + '</span>' : '') + '</span>' + (inMb ? '<span class="ii-sxp-inmb">✓ IN MB</span>' : ''); row.addEventListener('click', () => { setPending(idx, f.isrc, true, 'SoundExchange'); updateSummary(); collapseCandsTo(idx, f.isrc); sxPanel.classList.remove('open'); // picked a result → close the search panel }); resEl.appendChild(row); }); } // SoundExchange batch state — we search at most SX_BATCH_LIMIT tracks at a time // so SX doesn't block us; the rest show a "not loaded" message you click to continue. let _sxTodo = [], _sxCursor = 0, _sxMatched = 0, _sxFilled = 0, _sxRunning = false, _sxEpoch = 0; // Cancel ALL batched SoundExchange work — queued verifications and the bulk search — and abort any // in-flight SX request. Bumping the epoch makes the running loops bail at their next checkpoint. #127 function abortSxWork(reason) { _sxEpoch++; _allEpoch++; // #643: an All run stops too _vq.items = []; _vq.done = 0; _vq.running = false; _sxTodo = []; _sxCursor = 0; _sxRunning = false; _deferredVerify.clear(); _deferVerify = false; abortInflight('soundexchange'); const btn = modal && modal.querySelector('#ii-sx-all'); if (btn) btn.disabled = false; if (progEl) { progEl.textContent = ''; progEl.classList.remove('err'); } if (reason) Log.info('SoundExchange: cancelled all queued work (' + reason + ')'); refreshSubmitBtn(); // #406: SX aborted → re-evaluate the Submit button } // SoundExchange blocked us — either a rate limit (HTTP 429) or a captcha (HTTP // 202 {"searchCaptcha": true}, #157). Either way: stop the bulk run, abort // in-flight requests, and surface the cause + how to recover in the toolbar. // The captcha needs the user to solve it on SX's site, so we link there. #126/#157 function sxBlocked(err) { _sxEpoch++; // stop the running loops — don't issue any more requests _sxRunning = false; _vq.running = false; refreshSubmitBtn(); // #406: SX blocked → re-evaluate Submit abortInflight('soundexchange'); const btn = modal && modal.querySelector('#ii-sx-all'); if (btn) btn.disabled = false; if (err && err.captcha) { if (progEl) { // Through setProg first, to clear whatever the previous message left on // the element — a leftover "open the log" click handler would fight the // link this message is about to put inside itself. setProg('', false); progEl.classList.add('err'); progEl.textContent = '⚠ SoundExchange captcha — '; const a = document.createElement('a'); a.href = SX_HOME; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'resolve captcha in browser to unblock ↗'; progEl.appendChild(a); const tail = document.createElement('span'); tail.textContent = ', then retry'; progEl.appendChild(tail); } toast('SoundExchange captcha — resolve it in the browser, then retry.', 'err'); Log.warn('SoundExchange captcha (202 searchCaptcha) — bulk search stopped; resolve in browser to unblock'); } else { setProg('⚠ SoundExchange rate-limited (HTTP 429) — paused; wait a minute and retry', true); // via setProg so it too offers the log toast('SoundExchange rate-limited (429) — stopped. Wait a minute and retry.', 'err'); Log.warn('SoundExchange rate-limited (429) — bulk search stopped'); } } function runSxAll() { const tracks = RELEASE.tracks; _sxTodo = tracks.map((t, i) => i).filter(i => !tracks[i].existing.length && !tracks[i].pending); _sxCursor = 0; _sxMatched = 0; _sxFilled = 0; Log.info('SoundExchange: ' + _sxTodo.length + ' track(s) without an ISRC (skipping ' + (tracks.length - _sxTodo.length) + ' that already have one); up to ' + SX_BATCH_LIMIT + ' per batch'); SX.refreshToken().then(() => Log.info('SX token ready')).catch(e => Log.warn('SX token prefetch failed: ' + e.message)); processNextSxBatch(); } // Render a clickable "not loaded" message in a track's candidate box. Clicking // any of them loads the next batch (in order — like the MagicISRC userscript). function sxPlaceholder(idx, remaining) { const box = rowCands(idx); if (!box) return; box.innerHTML = ''; const m = document.createElement('div'); m.className = 'ii-cand-pending'; m.textContent = '⏳ Not searched — click to load the next ' + Math.min(SX_BATCH_LIMIT, remaining) + ' on SoundExchange'; m.title = 'SoundExchange searches are capped at ' + SX_BATCH_LIMIT + ' at a time to avoid being blocked'; m.addEventListener('click', () => processNextSxBatch()); box.appendChild(m); } async function processNextSxBatch() { if (_sxRunning) return; _sxRunning = true; refreshSubmitBtn(); // #406: SoundExchange counts as collecting → keep Submit live const myEpoch = _sxEpoch; // a clear / close / 429 bumps this → bail without writing stale results (#126/#127) const btn = modal.querySelector('#ii-sx-all'); btn.disabled = true; if (progEl) progEl.classList.remove('err'); // clear any prior rate-limit warning on a fresh run const tracks = RELEASE.tracks; const batch = _sxTodo.slice(_sxCursor, _sxCursor + SX_BATCH_LIMIT); // clear any "not loaded" placeholders on the tracks we're about to search batch.forEach(i => { const box = rowCands(i); if (box) box.innerHTML = ''; }); let n = 0; for (const i of batch) { const t = tracks[i]; progEl.textContent = 'SoundExchange ' + (_sxCursor + n + 1) + '/' + _sxTodo.length; try { const rows = await SX.apiSearch(t.title, t.artist, 0, 10, sxExact); if (myEpoch !== _sxEpoch) return; // cancelled (clear / close) while in flight renderCands(i, rows); const best = rows.find(r => SX.classify(SX.fields(r), t.title, t.artist, t.dur, RELEASE.releaseYear) === 'best'); const bestIsrc = best && SX.fields(best).isrc; if (bestIsrc) { _sxMatched++; // autofill the input only when it's empty AND the match is a NEW isrc if (!t.pending && !t.existing.includes(bestIsrc)) { setPending(i, bestIsrc, true, 'SoundExchange'); collapseCandsTo(i, bestIsrc); _sxFilled++; } } Log.info('SX #' + (t.number || t.trackPos) + ' "' + t.title + '": ' + rows.length + ' result(s)' + (bestIsrc ? ', best ' + bestIsrc + (t.existing.includes(bestIsrc) ? ' (already in MB)' : '') : ', no confident match')); } catch (e) { if (myEpoch !== _sxEpoch) return; // cancelled while in flight if (e && (e.rateLimited || e.captcha)) { // SoundExchange 429 (rate limit) or 202 captcha → stop the whole bulk run; // leave the unsearched rows (incl. this one) as click-to-retry placeholders, // and surface the cause + recovery in the toolbar. #126/#157 _sxCursor += n; const left = _sxTodo.length - _sxCursor; if (left > 0) _sxTodo.slice(_sxCursor).forEach(j => sxPlaceholder(j, left)); sxBlocked(e); return; } renderCands(i, []); Log.err('SX #' + (t.number || t.trackPos) + ' "' + t.title + '" failed: ' + e.message); } n++; updateSummary(); if (n < batch.length) await sleep(BATCH_DELAY); if (myEpoch !== _sxEpoch) return; // cancelled during the pacing delay } _sxCursor += batch.length; const remaining = _sxTodo.length - _sxCursor; if (remaining > 0) { _sxTodo.slice(_sxCursor).forEach(i => sxPlaceholder(i, remaining)); progEl.textContent = 'SoundExchange ' + _sxCursor + '/' + _sxTodo.length + ' — ' + remaining + ' not loaded (click a row to search the next ' + Math.min(SX_BATCH_LIMIT, remaining) + ')'; Log.info('SoundExchange: paused at ' + _sxCursor + '/' + _sxTodo.length + ' — ' + remaining + ' awaiting a click to continue'); } else { progEl.textContent = 'SoundExchange done — ' + _sxMatched + ' matched, ' + _sxFilled + ' filled'; Log.info('SoundExchange done — ' + _sxMatched + ' matched, ' + _sxFilled + ' newly filled'); } btn.disabled = false; _sxRunning = false; refreshSubmitBtn(); // #406: SX finished → re-evaluate Submit (stays enabled only if something's pending) } /* ── streaming-source import (Deezer / Spotify) ── */ // #431: "m:ss" / "h:mm:ss" → seconds, or null when unknown. const mmssToSec = v => { const m = String(v || '').trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{2})$/); return m ? ((+m[1] || 0) * 3600 + (+m[2]) * 60 + (+m[3])) : null; }; const DUR_TOLERANCE_SEC = 10; // #431: a POSITION match is filled trustingly — provider titles legitimately diverge // ("(feat. …)", "(Album Version)", transliterations), which is exactly why position // mapping exists — but nothing used to catch a WRONG provider link mapping a different // album 1:1 by position. Duration is the variant-proof plausibility signal: a retitle // shifts 0s, a different recording shifts tens of seconds. Suspicious fills are KEPT // (dropping would kill legit cases — maintainer) but flagged: amber input + tooltip, // a Log warning per row and a count in the import summary. // → { suspicious, durOff }: duration decides when both sides know it; title/artist only judges when it doesn't function fillCheck(t, s) { const a = mmssToSec(t.dur), b = mmssToSec(s.dur); const durOff = (a != null && b != null) ? Math.abs(a - b) : null; return { durOff, suspicious: durOff != null ? durOff > DUR_TOLERANCE_SEC : !!(s.title && t.title && !isGoodMatch(s.title, s.artist, t.title, t.artist)) }; } // A fetched ISRC's track: the one at its disc and position, unless that one is plainly another // song and exactly one track has the title, at a length that fits. A provider numbers around the // tracks it doesn't carry (Apple: 1.8 then 1.10, when 1.9 isn't sold in the storefront), which // shifts every position after the gap onto the next song. // → { idx, byPos, loose } or { idx: -1, ambiguous: N } function trackForSource(s) { const tracks = RELEASE.tracks; const pos = tracks.findIndex(t => (+t.trackPos === +s.pos) && ((+t.mediumPos === +s.disc) || tracks.filter(x => +x.mediumPos === +s.disc).length === 0)); if (pos >= 0 && !fillCheck(tracks[pos], s).suspicious) return { idx: pos, byPos: true }; const p = pickTrackByTitle(s, tracks); if (pos < 0) return Object.assign(p, { byPos: false }); if (p.idx >= 0 && !p.loose && p.idx !== pos && !fillCheck(tracks[p.idx], s).suspicious) return { idx: p.idx, byPos: false, moved: true }; return { idx: pos, byPos: true }; } function flagImplausibleFill(idx, s, label) { const t = RELEASE.tracks[idx]; const { suspicious, durOff } = fillCheck(t, s); if (!suspicious) return; const input = rowInput(idx); if (!input) return; const why = durOff != null ? 'length differs by ' + durOff + 's (MB ' + t.dur + ' vs ' + label + ' ' + (s.dur || '?') + ')' : 'title/artist do not match ("' + s.title + '")'; input.classList.add('ii-in-suspect'); input.title = '⚠ matched by position only, but ' + why + ' — verify before submitting'; if (_stream) (_stream.suspects = _stream.suspects || []).push(idx); Log.warn(label + ' #' + (t.number || t.trackPos) + ' "' + t.title + '": filled by position, but ' + why); updateSuspectBadge(); } // #431 follow-up (maintainer): the summary-line "⚠ N implausible" was too easy to // overlook — a persistent amber pill in the footer (next to the Submit button's row) // tracks the live count; clicking it jumps to the first flagged row. function updateSuspectBadge() { if (!modal) return 0; let el = modal.querySelector('#ii-suspect-badge'); if (!el) { el = document.createElement('span'); el.id = 'ii-suspect-badge'; el.className = 'ii-only-isrc'; el.title = 'These fills differ from the MB track in length or title — click to jump to the first one'; el.addEventListener('click', () => { const s = modal.querySelector('input.ii-in-suspect'); if (!s) return; s.scrollIntoView({ block: 'center', behavior: 'smooth' }); const tr = s.closest('tr'); if (tr) { tr.classList.remove('ii-row-fill'); void tr.offsetWidth; tr.classList.add('ii-row-fill'); } }); if (summaryEl) summaryEl.after(el); } const n = modal.querySelectorAll('input.ii-in-suspect').length; el.textContent = n ? ('⚠ ' + n + ' implausible fill' + (n === 1 ? '' : 's') + ' — verify') : ''; el.style.display = n ? '' : 'none'; return n; } // Map ONE fetched ISRC to a track and fill it immediately (live, as it arrives). // Returns 'filled' | 'already' | 'skipped' | 'unmatched'. function mapOneToTrack(s, label) { const m = trackForSource(s); if (m.ambiguous) { Log.warn(label + ': ' + m.ambiguous + ' tracks match "' + s.title + '" by title — not guessing which one gets ' + s.isrc); return 'unmatched'; } const idx = m.idx, byPos = m.byPos, loose = !!m.loose; if (m.moved) Log.info(label + ' ' + s.disc + '.' + s.pos + ' "' + s.title + '" goes on track ' + (RELEASE.tracks[idx].number || RELEASE.tracks[idx].trackPos) + ' by its title: the track at that position is another song'); if (idx < 0) { Log.warn(label + ': no track matched ' + s.isrc + ' "' + s.title + '" (disc ' + s.disc + ' pos ' + s.pos + ')'); return 'unmatched'; } const t = RELEASE.tracks[idx]; if (t.existing.includes(s.isrc)) return 'already'; // a song this import put here by position, flagged as another one, gives way to the song itself const amber = t.pending && m.moved && t.source === label && rowInput(idx)?.classList.contains('ii-in-suspect'); if (t.pending && !amber) return 'skipped'; if (amber) Log.info(label + ' #' + (t.number || t.trackPos) + ' "' + t.title + '": ' + s.isrc + ' by title replaces ' + t.pending + ', which was another song at that position'); setPending(idx, s.isrc, true, label); // fills the input box right now if (amber) { // the same track, filled once: counted as put right, no longer implausible if (_stream && _stream.suspects) _stream.suspects = _stream.suspects.filter(i => i !== idx); updateSummary(); return 'replaced'; } if (byPos) flagImplausibleFill(idx, s, label); // #431 else if (loose) flagLooseTitleFill(idx, s, label); // #623 updateSummary(); return 'filled'; } // #623 (sweep): the title fallback took the FIRST track passing titleClose, which // tolerates two trailing words — so "Song (Live)" landed on "Song", and a second // "Intro" on the first one. An exact title wins; several equally good tracks are // not guessed between; a single match on a looser title is filled, but flagged. // → { idx, loose } or { idx: -1, ambiguous: N } function pickTrackByTitle(s, tracks) { const good = [], exact = []; tracks.forEach((t, i) => { if (!t.title || !isGoodMatch(s.title, s.artist, t.title, t.artist)) return; good.push(i); if (norm(t.title) === norm(s.title)) exact.push(i); }); if (exact.length === 1) return { idx: exact[0], loose: false }; if (exact.length > 1 || good.length > 1) return { idx: -1, ambiguous: exact.length || good.length }; return good.length ? { idx: good[0], loose: true } : { idx: -1 }; } function flagLooseTitleFill(idx, s, label) { const t = RELEASE.tracks[idx], input = rowInput(idx); if (!input) return; input.classList.add('ii-in-suspect'); input.title = '⚠ matched by a similar title only ("' + s.title + '" → "' + t.title + '") — verify before submitting'; if (_stream) (_stream.suspects = _stream.suspects || []).push(idx); Log.warn(label + ' #' + (t.number || t.trackPos) + ' "' + t.title + '": filled by a similar title only ("' + s.title + '")'); updateSuspectBadge(); } const errText = e => (e && (e.message || e.stack)) || String(e) || '(no detail)'; const setProg = (msg, isErr) => { if (!progEl) return; progEl.textContent = msg; progEl.classList.toggle('err', !!isErr); progEl.classList.remove('continue'); progEl.onclick = null; progEl.style.cursor = ''; // A failure message names the log; make it the way there rather than an // instruction to go looking. Only on errors — a plain progress line has // nothing to open and should not look clickable. progEl.classList.toggle('tolog', !!isErr); progEl.title = isErr ? 'Open the log' : ''; if (isErr) progEl.onclick = () => Log.open(); }; // test hook only — no behaviour change. The affordance below is a contract of // setProg (an error offers the log, a plain message doesn't), so a test should // drive that function rather than poke classes onto the element. if (mbuTestHooks()) window.__isrcScoutTestProg = { setProg: (m, e) => setProg(m, e), showPane: (id) => showPane(id) }; const setProgContinue = (msg, onClick) => { if (!progEl) return; progEl.textContent = msg; progEl.classList.remove('err'); progEl.classList.add('continue'); progEl.style.cursor = 'pointer'; progEl.onclick = () => { onClick(); }; }; // flush the SX verifications deferred during a batch (decoupled from the import) function flushDeferredVerify() { const toVerify = [..._deferredVerify]; _deferredVerify = new Set(); toVerify.forEach(i => { const t = RELEASE.tracks[i]; if (t && isValidIsrc(t.pending)) enqueueVerify(i, t.pending); }); } let _stream = null; // current import: { label, albumId, fetcher, cursor, counts } async function runStreamingSource(label, albumId, fetcher, resume) { beginCollect(); // #406: Submit stays live during the import (count fills in as ISRCs land) try { if (!resume || !_stream) { _stream = { label, albumId, fetcher, cursor: 0, counts: { filled: 0, already: 0, skipped: 0, unmatched: 0, replaced: 0 } }; } const st = _stream, counts = st.counts; setProg(label + ': starting…'); // defer SoundExchange verification so its requests don't compete with the import _deferVerify = true; _deferredVerify = new Set(); let res; try { res = await fetcher(albumId, (d, n) => { // #431: a provider album with MORE tracks than this release is the classic // wrong-link/wrong-edition signature (a 12-track album position-mapping onto a // 4-track single) — call it out loudly, once, before the fills finish. if (n && !st.countWarned) { st.countWarned = true; if (+n > RELEASE.tracks.length) Log.warn(label + ': the linked album has ' + n + ' track(s) but this release has ' + RELEASE.tracks.length + ' — possibly the WRONG link/edition; implausible fills get flagged, verify before submitting'); } setProg(n ? (label + ' ' + d + '/' + n) : (label + ': starting…')); }, s => { counts[mapOneToTrack(s, label)]++; }, // ← fill each ISRC as it's fetched st.cursor); } catch (e) { Log.err(label + ' failed: ' + errText(e)); setProg('⚠ ' + label + ' failed: ' + errText(e), true); _deferVerify = false; _deferredVerify = new Set(); return; } finally { _deferVerify = false; } flushDeferredVerify(); // verify this batch's rows now, decoupled from the import // #285: the provider just used to match all tracks becomes the per-track default // (no-op for non-per-track providers like Spotify, and when already selected). const _provKey = Object.keys(ALBUM_PROVIDERS).find(k => ALBUM_PROVIDERS[k].source === label) || (label || '').toLowerCase(); if (trackProv !== _provKey) setTrackProvider(_provKey); const total = (res && res.total != null) ? res.total : st.cursor; const next = (res && res.next != null) ? res.next : null; const parts = [counts.filled + ' filled']; if (counts.replaced) parts.push(counts.replaced + ' put right by title'); if (counts.already) parts.push(counts.already + ' already present'); if (counts.skipped) parts.push(counts.skipped + ' already entered'); if (counts.unmatched) parts.push(counts.unmatched + ' unmatched'); if (st.suspects && st.suspects.length) { // #431 parts.push('⚠ ' + st.suspects.length + ' implausible'); Log.warn(label + ': ' + st.suspects.length + ' filled track(s) look implausible (length/title mismatch, amber inputs) — verify before submitting'); } if (next != null) { // more tracks remain — pause so we don't spam the source; click to continue st.cursor = next; const remaining = total - next; Log.info(label + ': ' + next + '/' + total + ' so far (' + parts.join(', ') + ') — paused to avoid spamming ' + label); setProgContinue(label + ' ' + next + '/' + total + ' — click to fetch the next ' + Math.min(STREAM_BATCH_LIMIT, remaining), () => runStreamingSource(label, albumId, fetcher, true)); } else { Log.info(label + ' done — ' + parts.join(', ')); try { setProg(label + ' done — ' + parts.join(' · ')); } catch (e) { Log.warn(label + ': imported OK, but a UI update hiccuped: ' + errText(e)); } } } finally { endCollect(); // #406: balance beginCollect() — runs on the catch's early return, pause, and normal end alike } } // Run a provider's import using its in-MB album id, falling back to the URL // Platform Check found (#180 — a provider button is only shown when one of // those exists, so this resolves unless the link vanished mid-session). async function runProvider(source, mbId, fetcher, btnSel) { const id = providerAlbumId(source, mbId); if (!id) { Log.warn(source + ': no ' + source + ' link on this release'); return; } const btn = modal.querySelector(btnSel); btn.disabled = true; Log.info(source + ': importing album ' + id); try { await runStreamingSource(source, id, fetcher); } finally { btn.disabled = false; } // always re-enable, even if something throws } async function runDeezer() { return runProvider('Deezer', RELEASE.deezerId, fetchDeezer, '#ii-dz-all'); } async function runSpotify() { return runProvider('Spotify', RELEASE.spotifyId, fetchSpotify, '#ii-sp-all'); } async function runBeatport() { return runProvider('Beatport', RELEASE.beatportId, fetchBeatport, '#ii-bp-all'); } async function runTidal() { return runProvider('Tidal', RELEASE.tidalId, fetchTidal, '#ii-td-all'); } async function runVolumo() { return runProvider('Volumo', RELEASE.volumoId, fetchVolumo, '#ii-vo-all'); } async function runHDtracks() { return runProvider('HDtracks', RELEASE.hdtracksId, fetchHDtracks, '#ii-hd-all'); } async function runQobuz() { return runProvider('Qobuz', RELEASE.qobuzId, fetchQobuz, '#ii-qz-all'); } async function runApple() { return runProvider('Apple', RELEASE.appleUrl, fetchApple, '#ii-am-all'); } // #435 async function runSoundcloud(){ return runProvider('SoundCloud', RELEASE.scUrl, fetchSoundcloud, '#ii-sc-all'); } // #439 async function runAudiomack() { return runProvider('Audiomack', RELEASE.akUrl, fetchAudiomack, '#ii-ak-all'); } // #664 async function runSevendigital() { return runProvider('7digital', RELEASE.sdUrl, fetchSevendigital, '#ii-sd-all'); } // #669 // Map a source label to its fetcher (used by the unified URL-paste import). function fetcherFor(source) { return source === 'Deezer' ? fetchDeezer : source === 'Spotify' ? fetchSpotify : source === 'Beatport' ? fetchBeatport : source === 'Tidal' ? fetchTidal : source === 'Volumo' ? fetchVolumo : source === 'HDtracks' ? fetchHDtracks : source === 'Qobuz' ? fetchQobuz : source === 'Apple' ? fetchApple : source === 'SoundCloud' ? fetchSoundcloud : source === 'Audiomack' ? fetchAudiomack : source === '7digital' ? fetchSevendigital : null; } /* ── source links & the unified "paste a URL" control (#180) ── */ // Source name → SRC_ICON key / brand colour, for the URL-add detection feedback. const SRC_CODE = { Deezer: 'dz', Spotify: 'sp', Beatport: 'bp', Tidal: 'td', Volumo: 'vo', HDtracks: 'hd', Qobuz: 'qz', Apple: 'am', SoundCloud: 'sc', Audiomack: 'ak', '7digital': 'sd' }; const SRC_COLOR = { dz: stColor('deezer'), sp: stColor('spotify'), bp: stColor('beatport'), td: stColor('tidal'), vo: stColor('volumo'), hd: stColor('hdtracks'), qz: stColor('qobuz'), am: stColor('apple'), sc: stColor('soundcloud'), ak: stColor('audiomack'), sd: stColor('sevendigital') }; // #404: colours from the shared registry // If Platform Check (separate userscript) is on the page, read the URL it found // for this source from its sidebar anchor (#mb-online-<source>). function platformCheckUrl(source) { // Platform Check's row key: the provider's ("7digital" is sevendigital), else the name in lower case const key = Object.keys(ALBUM_PROVIDERS).find(k => ALBUM_PROVIDERS[k].source === source) || source.toLowerCase(); const a = document.getElementById('mb-online-' + key); if (!a) return null; // Platform Check not installed const href = a.getAttribute('href') || ''; if (!/^https?:\/\//.test(href)) return null; // nothing found yet ('#') // Skip low-quality matches: PC marks its row pc-st-match (good), pc-st-mismatch // (found but wrong — dimmed), or pc-st-notfound. Only trust a confident match (#180). const row = document.getElementById('row-' + key); if (row && !row.classList.contains('pc-st-match')) { // #211: a link PC withheld ONLY by its barcode/format link-confidence CAN still // be the right album for ISRC purposes — an ISRC identifies a recording, which is // independent of the release's barcode/format. PC demotes such a row to // pc-st-mismatch but flags it pc-blocked while leaving the content-match glyph // intact (✓ = found+counts, ? = found). // #314: but a barcode mismatch can also mean PC simply matched the WRONG release // (e.g. a 1-track Beatport single by a same-prefixed artist), so by DEFAULT we now // respect PC's confidence and reject these too. Only accept them when the user has // opted into "Ignore Platform Check link confidence". A genuine content mismatch (~) // or not-found (×) is always rejected. const glyph = ((document.getElementById('ico-' + key) || {}).textContent || '').trim(); const lenient = ignorePcConfidence() && row.classList.contains('pc-blocked') && (glyph === '✓' || glyph === '?'); if (!lenient) return null; } return parseStreamingId(source, href) ? href : null; // only if it parses to an album id } // Album id for a provider button: prefer the in-MB link, else fall back to the // URL Platform Check found (which is why the button is shown at all). function providerAlbumId(source, mbId) { if (mbId) return mbId; const pc = platformCheckUrl(source); return pc ? parseStreamingId(source, pc) : null; } // #680 Match: the ISRC sources this release has, fastest first. One-request // album reads (Qobuz, Audiomack, Apple, …) lead; per-track ones (Deezer, 7digital) and // Spotify (a third party) come last. A link pulled from the release group (#302) is left // out: whether it fits this release needs the user to look. const ISRC_SPEED_ORDER = ['qobuz', 'audiomack', 'apple', 'tidal', 'hdtracks', 'volumo', 'beatport', 'soundcloud', 'deezer', 'sevendigital']; // linkedOnly: the release's own links only, not Platform Check's sidebar finds (Mission Control // passes the ones ticked in its card instead: an unticked or withheld album is not this release's) function isrcSourcesFastest(linkedOnly) { const rg = (RELEASE && RELEASE.rgFrom) || {}; const idOf = (source, mbId) => linkedOnly ? mbId : providerAlbumId(source, mbId); return ISRC_SPEED_ORDER.map(k => ALBUM_PROVIDERS[k]).filter(Boolean) .map(p => ({ source: p.source, fetcher: p.fetcher, id: rg[p.idField] ? null : idOf(p.source, RELEASE[p.idField]) })) .concat([{ source: 'Spotify', fetcher: fetchSpotify, id: rg.spotifyId ? null : idOf('Spotify', RELEASE.spotifyId) }]) .filter(x => x.id); } // the dialog's Match: ISRCs from the fastest source that gives any (the next one // when a source fails or gives none), then Find links with them let _findingAll = false; async function findEverything() { if (_findingAll) return; _findingAll = true; const btn = modal.querySelector('#ii-find-all'); if (btn) btn.disabled = true; try { const srcs = isrcSourcesFastest(); Log.info('Match: ISRC sources in order ' + (srcs.map(x => x.source).join(', ') || 'none')); for (const src of srcs) { if (RELEASE.tracks.every(t => t.existing.length || isValidIsrc(normalizeIsrc(t.pending)))) { Log.info('Match: every track has an ISRC, no import needed'); break; } _stream = null; await runStreamingSource(src.source, src.id, src.fetcher); const filled = (_stream && _stream.counts && _stream.counts.filled) || 0; Log.info('Match: ' + src.source + ' filled ' + filled); if (filled) break; } Log.info('Match: Find links'); await TrackLinks.resolve(); } catch (e) { Log.err('Match failed: ' + errText(e)); } finally { _findingAll = false; if (btn) btn.disabled = false; } } // Detect which streaming platform a pasted URL belongs to (domain-based, so a // bare numeric id — ambiguous across platforms — is intentionally not matched). function detectSource(input) { const s = String(input || '').trim(); const mk = source => { const id = parseStreamingId(source, s); return id ? { source, code: SRC_CODE[source], id } : null; }; if (/deezer\.com/i.test(s)) return mk('Deezer'); if (/beatport\.com/i.test(s)) return mk('Beatport'); if (/tidal\.com/i.test(s)) return mk('Tidal'); if (/volumo\.com/i.test(s)) return mk('Volumo'); if (/hdtracks\.com/i.test(s)) return mk('HDtracks'); if (/qobuz\.com/i.test(s)) return mk('Qobuz'); if (/(?:music|itunes)\.apple\.com/i.test(s)) return mk('Apple'); // #435, iTunes URLs #436 if (/audiomack\.com\/[^/?#]+\/(?:album|song)\//i.test(s)) return mk('Audiomack'); // #664 an album, or a song (single-track release) if (/(?:7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\//i.test(s)) return mk('7digital'); // #669 if (/soundcloud\.com\/[^/?#]+\/(?:sets\/)?[^/?#]+/i.test(s)) return mk('SoundCloud'); // #439 a set (album) or a track (single-track release) // Spotify intentionally NOT detected here: its import resolves the MB release // FROM the Spotify URL (ISRC Hunt), so a non-MB URL can't work (#180). It's // offered only as a provider button when the release has a Spotify MB link. return null; } // Live feedback: show the detected platform's icon (in brand colour) on the + // button, or reset to a plain + when nothing recognizable is typed. function reflectDetectedSource(value) { const btn = document.getElementById('ii-url-btn'); if (!btn) return; const d = detectSource(value); if (d) { btn.innerHTML = SRC_ICON[d.code] || '+'; btn.style.color = SRC_COLOR[d.code] || ''; btn.title = d.source + ' detected — press Enter to import its ISRCs'; } else { btn.textContent = '+'; btn.style.color = ''; btn.title = 'Paste a streaming URL (Deezer / Spotify / Beatport / Tidal / Volumo / HDtracks / Qobuz / Apple / SoundCloud set / Audiomack / 7digital) — auto-detected and imported'; } } async function submitUrlAdd(value) { const v = String(value || '').trim(); const d = detectSource(v); if (!d) { if (/open\.spotify\.com|spotify:album:/i.test(v)) { toast('Spotify can only be imported from its MusicBrainz-linked album — use the Spotify button', 'err'); } else if (v) { toast('Unrecognized URL — paste a Deezer, Beatport, Tidal, Volumo, HDtracks, Qobuz, Apple, SoundCloud (set), Audiomack or 7digital album link', 'err'); Log.warn('URL import: unrecognized "' + v + '"'); } return; } Log.info(d.source + ': importing pasted album ' + d.id); await runStreamingSource(d.source, d.id, fetcherFor(d.source)); } function parseStreamingId(source, input) { const s = String(input || '').trim(); if (source === 'Deezer') { const m = s.match(/deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/); return m ? m[1] : (/^\d+$/.test(s) ? s : null); } if (source === 'Beatport') { const m = s.match(/beatport\.com\/release\/[^/]+\/(\d+)/); return m ? m[1] : (/^\d+$/.test(s) ? s : null); } if (source === 'Tidal') { const m = s.match(/(?:listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/); return m ? m[1] : (/^\d+$/.test(s) ? s : null); } if (source === 'Volumo') { const m = s.match(/volumo\.com\/album\/(\d+)/); // id or leading ICPN in the canonical /album/{icpn}-{slug} return m ? m[1] : (/^\d+$/.test(s) ? s : null); } if (source === 'Qobuz') { // …/album/<slug>/<id> or the slug-less …/album/<id>; a bare alnum id or a bare barcode also work const m = s.match(/qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/?#]+\/)?([a-z0-9]{8,})/i); return m ? m[1] : (/^[a-z0-9]{8,}$/i.test(s) || /^\d{8,}$/.test(s) ? s : null); } if (source === 'HDtracks') { // new API form #/album/<24-hex ObjectId>; legacy valbum_code=<UPC> resolves // via barcode search. A bare 24-hex id or a bare 8+ digit barcode also work. let m = s.match(/hdtracks\.com\/(?:#\/)?album\/([a-f0-9]{24})/i); if (m) return m[1]; m = s.match(/[?&]valbum_code=(\d{8,})/i); if (m) return m[1]; return /^[a-f0-9]{24}$/i.test(s) ? s : (/^\d{8,}$/.test(s) ? s : null); } if (source === '7digital') { // #669: the release link itself (its store and its id) const m = s.match(/^(https?:\/\/(?:[a-z]+\.)?(?:7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\/(?:[^/?#]*-)?\d{3,})\/?(?:[?#]|$)/i); return m ? m[1] : null; } if (source === 'Audiomack') { const m = s.match(/^https?:\/\/(?:www\.)?audiomack\.com\/([^/?#]+)\/(album|song)\/([^/?#]+)/i); return m ? 'https://audiomack.com/' + m[1] + '/' + m[2].toLowerCase() + '/' + m[3] : null; } if (source === 'SoundCloud') { // A set (playlist) is the album, or a bare track url is a single-track release; // api-v2 resolves the whole permalink URL, so the id we carry IS that URL. #439 const m = s.match(/^(https?:\/\/(?:www\.|m\.)?soundcloud\.com\/[^/?#]+\/(?:sets\/)?[^/?#]+)/i); return m ? m[1] : null; } if (source === 'Apple') { // …/<storefront>/album/<slug>/<id> or the slug-less form; the per-track ?i= form // still identifies the album. Keep the storefront — the amp-api album is region-scoped. // Also accept legacy iTunes links: itunes.apple.com/<sf>/album/<slug>/id<id> (the // `id` prefix and an optional storefront), which are equivalent to Apple Music (#436). const m = s.match(/(?:music|itunes)\.apple\.com\/(?:([a-z]{2})\/)?album\/(?:[^/?#]+\/)?(?:id)?(\d+)/i); return m ? ((m[1] || 'us').toLowerCase() + '/' + m[2]) : null; } let m = s.match(/open\.spotify\.com\/album\/([A-Za-z0-9]+)/) || s.match(/spotify:album:([A-Za-z0-9]+)/); return m ? m[1] : (/^[A-Za-z0-9]{18,30}$/.test(s) ? s : null); } /* ── OAuth UI handlers ── */ async function exchangeAndFinish(code, how) { Log.info('OAuth: exchanging authorization code (' + how + ')'); try { await Auth.exchangeCode(code); refreshAuthState(); Log.info('OAuth: authorized (refresh token stored)'); toast('Authorized — you never need to do this again', 'ok'); } catch (e) { Log.err('OAuth exchange failed: ' + e.message); toast('Authorization failed: ' + e.message, 'err'); } } function onAuthorize() { Log.info('OAuth: opening authorize URL'); store.del('oauth_oob_code' + OAUTH_SITE); // not 'noopener' so the oob tab can close itself once it captures the code const w = window.open(Auth.authorizeUrl(), '_blank'); const ci = modal.querySelector('#ii-oauth-code'); if (ci) _setTimeout(() => ci.focus(), 100); // ready for a manual paste if the tab can't close let n = 0; const iv = _setInterval(() => { const oob = store.get('oauth_oob_code' + OAUTH_SITE, null); if (oob && oob.code) { clearInterval(iv); store.del('oauth_oob_code' + OAUTH_SITE); try { w && w.close(); } catch (e) {} if (ci) ci.value = oob.code; // show it auto-filled, then exchange exchangeAndFinish(oob.code, 'auto-captured'); return; } if (++n > 300) clearInterval(iv); // stop polling after ~5 min }, 1000); } /* ── submit (#406: ONE button for everything pending — ISRCs *and* links) ── Adding ISRCs used to close the window while adding links was a separate button that didn't, so it was easy to submit one and forget the other. This submits BOTH — every entered ISRC and every resolved streaming link — in one action, then closes. Either half can be empty; it does whatever's actually changed. */ // #431: confirmation popup shown when implausible (amber) fills are about to be // submitted. Resolves true only on an explicit "Submit anyway"; "Review first" // closes and jumps to the first flagged row. function confirmSuspectSubmit(n) { return new Promise(resolve => { const ov = document.createElement('div'); ov.style.cssText = 'position:fixed;inset:0;z-index:2147483400;background:rgba(20,10,10,.45);display:flex;align-items:center;justify-content:center;'; const box = document.createElement('div'); box.style.cssText = 'max-width:460px;margin:16px;background:var(--mbu-bg);border-radius:8px;border-top:4px solid var(--mbu-warn);padding:16px 20px 14px;box-shadow:0 14px 44px rgba(0,0,0,.4);font-size:13px;line-height:1.55;color:var(--mbu-text);font-family:inherit;'; box.innerHTML = '<div style="font-weight:800;color:var(--mbu-warn);font-size:15px;margin-bottom:8px;">⚠ ' + n + ' implausible ISRC fill' + (n === 1 ? '' : 's') + '</div>' + '<p style="margin:0 0 12px;">The amber-flagged entries differ from the MB track in <strong>length or title</strong> — they may belong to a <strong>different recording</strong> (wrong provider link or edition). Submitted ISRCs are rarely re-checked, so verify those rows first.</p>' + '<div style="display:flex;justify-content:flex-end;gap:8px;">' + '<button type="button" data-a="review" style="padding:5px 14px;font-size:13px;font-weight:600;color:var(--mbu-text);background:var(--mbu-bg-raised);border:1px solid var(--mbu-border);border-radius:5px;cursor:pointer;">Review first</button>' + '<button type="button" data-a="go" style="padding:5px 14px;font-size:13px;font-weight:600;color:#fff;background:#e0892a;border:none;border-radius:5px;cursor:pointer;">Submit anyway</button>' + '</div>'; const done = ok => { ov.remove(); if (!ok) { const s = modal.querySelector('input.ii-in-suspect'); if (s) s.scrollIntoView({ block: 'center', behavior: 'smooth' }); } resolve(ok); }; box.querySelector('[data-a="review"]').addEventListener('click', () => done(false)); box.querySelector('[data-a="go"]').addEventListener('click', () => done(true)); ov.addEventListener('mousedown', e => { if (e.target === ov) done(false); }); ov.appendChild(box); document.body.appendChild(ov); box.querySelector('[data-a="review"]').focus(); }); } async function doSubmitAll() { // 1) pending ISRC additions (valid, not a cross-track duplicate, not already present) const map = {}; let isrcCount = 0; const dupSet = highlightDuplicates(); // never submit an ISRC that's on >1 recording RELEASE.tracks.forEach(t => { const v = normalizeIsrc(t.pending); if (!v || !isValidIsrc(v) || !t.recId) return; if (t.existing.includes(v) || dupSet.has(v)) return; (map[t.recId] = map[t.recId] || []).push(v); isrcCount++; }); // 2) resolved, addable streaming links (from Find links) const linkCount = modal.querySelectorAll('.ii-tl-add .ii-tl.new').length; if (!isrcCount && !linkCount) { toast('Nothing to submit — enter ISRCs or Find links first', 'err'); return; } // #431 follow-up (maintainer): flagged fills about to be submitted need explicit // confirmation — the amber inputs + summary count alone were too easy to miss. const suspectN = isrcCount ? [...modal.querySelectorAll('input.ii-in-suspect')].filter(i => i.value.trim()).length : 0; if (suspectN && !(await confirmSuspectSubmit(suspectN))) return; // ISRC additions need OAuth; link edits ride the logged-in MB session. Only block // on authorization when there are ISRCs — links alone can still go through. if (isrcCount && !Auth.isAuthorized()) { togglePane('ii-setup-pane'); toast('Authorize first (⚙ Setup)', 'err'); return; } submitBtn.disabled = true; submitBtn.textContent = 'Submitting…'; let isrcOk = true, linkOk = true, linkAdded = 0; if (isrcCount) { const note = getEditNote(); Log.info('Submitting ' + isrcCount + ' ISRC(s) across ' + Object.keys(map).length + ' recording(s)', map); Log.info('Edit note: ' + note.replace(/\n/g, ' ')); try { await submitIsrcs(map, note); Log.info('ISRC submit OK'); // move submitted into "existing", clear pending RELEASE.tracks.forEach(t => { const v = normalizeIsrc(t.pending); if (v && map[t.recId] && map[t.recId].includes(v)) { t.existing.push(v); t.pending = ''; } }); } catch (e) { isrcOk = false; if (e.authCleared) refreshAuthState(); // shows "Not authorized" and opens ⚙ Setup Log.err('ISRC submit failed: ' + e.message); toast('ISRC submit failed: ' + e.message, 'err'); } } if (linkCount) { try { const res = await TrackLinks.addAll(); // add every resolved link in one batch linkAdded = res ? res.ok : 0; if (res && res.fail) linkOk = false; } catch (e) { linkOk = false; Log.err('Link add failed: ' + e.message); toast('Link add failed: ' + e.message, 'err'); } } renderTracks(); // reflect new existing ISRCs + newly-linked providers (also refreshes the button) updateBtnStatus(); // keep the floating page button's ISRC count in sync (modal closes over it) if (isrcOk && linkOk) { const parts = []; if (isrcCount) parts.push(isrcCount + ' ISRC' + (isrcCount === 1 ? '' : 's')); if (linkAdded) parts.push(linkAdded + ' link' + (linkAdded === 1 ? '' : 's')); toast('Submitted ' + parts.join(' + ') + ' ✓', 'ok'); // no errors — close the editor (the ✓ toast lives on <body> and stays visible) _setTimeout(closeModal, 800); } } /* ── delete existing ISRCs (via the recording-edit website form + session cookie) ── */ async function doDelete() { const byRec = {}; // recId -> { idx, isrcs: [] } tbody.querySelectorAll('.ii-ex-del:checked').forEach(cb => { const tr = cb.closest('tr[data-idx]'); if (!tr) return; const idx = +tr.dataset.idx, t = RELEASE.tracks[idx]; if (!t.recId) return; (byRec[t.recId] = byRec[t.recId] || { idx, isrcs: [] }).isrcs.push(normalizeIsrc(cb.dataset.isrc)); }); const recs = Object.entries(byRec); const total = recs.reduce((n, [, v]) => n + v.isrcs.length, 0); if (!total) return; if (!Auth.isAuthorized()) { /* deletion uses the session cookie, not OAuth — no auth needed, but warn if not logged in is handled by the request itself */ } if (!confirm('Submit "Remove ISRC" edits for ' + total + ' ISRC' + (total === 1 ? '' : 's') + ' across ' + recs.length + ' recording' + (recs.length === 1 ? '' : 's') + '?\n\nUses your logged-in MusicBrainz session. Unlike additions, ISRC removals are NOT auto-applied — they go to the edit queue for voting, so the ISRCs stay listed (marked ⏳ for the rest of this session) until the edits pass. Track them under 🕓 My ISRC edits.')) return; const note = getRemovalNote(recs, total); const btn = modal.querySelector('#ii-delete'); btn.disabled = true; Log.info('Submitting Remove-ISRC edits for ' + total + ' ISRC(s) across ' + recs.length + ' recording(s)'); let ok = 0, fail = 0; /* #587 (chaban-mb): "when batch removing ISRCs there is no indicator apart from logs that (network) actions are in progress. Only once all submissions are done is the hourglass pending edit indicator inserted… and/or a global activity indicator so one doesn't accidentally close the tab before everything is done." Three things, because the run is minutes long on a big release (his took 48s for 12) and it looked idle throughout: · each row gets its ⏳ the moment ITS edit comes back, not at the end · the button counts down, so there is one obvious place to look · closing the tab or the window mid-run asks first ⏳ still means SUBMITTED, not applied — the edit is in the queue. */ setRemovalBusy(true); try { for (const [recId, info] of recs) { const done = ok + fail; const label = 'Submitting removals — ' + (done + 1) + '/' + total + '…'; progEl.textContent = label + ' (' + recId.slice(0, 8) + ')'; btn.textContent = '⏳ ' + (done + 1) + '/' + total + '…'; try { await removeIsrcsFromRecording(recId, info.isrcs, note); // mark pending (the edit is queued; don't drop from `existing` — it's still on the recording) const t = RELEASE.tracks[info.idx]; t.pendingRemoval = (t.pendingRemoval || []).concat(info.isrcs); if (t.failedRemoval) { const gone = new Set(info.isrcs.map(normalizeIsrc)); t.failedRemoval = t.failedRemoval.filter(x => !gone.has(normalizeIsrc(x))); } // #587: a retry that worked ok += info.isrcs.length; refreshExistingCell(info.idx); // #587 — this row's ⏳ appears now, not after the batch Log.info('Submitted Remove-ISRC for ' + info.isrcs.join(', ') + ' (recording ' + recId + ') — pending'); } catch (e) { fail += info.isrcs.length; markRemovalFailed(info.idx, info.isrcs); // #587 — and a failed row says so in place Log.err('Remove from recording ' + recId + ' failed: ' + e.message); } await sleep(700); } } finally { setRemovalBusy(false); } renderTracks(); refreshDeleteBtn(); progEl.textContent = ok + ' removal edit(s) submitted' + (fail ? ', ' + fail + ' failed' : ''); toast(ok + ' Remove-ISRC edit' + (ok === 1 ? '' : 's') + ' submitted (pending in the edit queue)' + (fail ? ' · ' + fail + ' failed (see Log)' : ''), fail ? 'err' : 'ok'); } /* #587 — repaint ONE row's existing-ISRC cell. The delete checkboxes are delegated on the tbody, so swapping the cell's HTML keeps them working; doing a whole renderTracks() mid-batch would rebuild every row and throw away the checkboxes still waiting their turn. */ function refreshExistingCell(idx) { const tr = tbody && tbody.querySelector('tr[data-idx="' + idx + '"]'); const box = tr && tr.querySelector('.ii-existing'); const t = RELEASE && RELEASE.tracks[idx]; if (!box || !t) return; box.innerHTML = existingHtml(t.existing, t.pendingRemoval, t.failedRemoval, t.recId); refreshDeleteBtn(); // those ISRCs are no longer deletable — keep the count honest } /* #587 — a removal that failed shouldn't sit there looking untouched. Recorded on the TRACK, not just painted on the row: doDelete ends with a full renderTracks(), which rebuilt every row and threw a row-only marker away. */ function markRemovalFailed(idx, isrcs) { const t = RELEASE && RELEASE.tracks[idx]; if (!t) return; t.failedRemoval = (t.failedRemoval || []).concat(isrcs || []); refreshExistingCell(idx); } /* #587 — the global "something is happening" state: the run is a series of page-form POSTs with nothing to show for it between them, and closing the tab halfway leaves the rest unsubmitted. */ function removalUnloadGuard(e) { e.preventDefault(); e.returnValue = ''; return ''; } function setRemovalBusy(on) { _removalBusy = !!on; const btn = modal && modal.querySelector('#ii-delete'); if (btn) btn.classList.toggle('ii-busy', _removalBusy); if (modal) modal.classList.toggle('ii-removing', _removalBusy); if (on) window.addEventListener('beforeunload', removalUnloadGuard); else window.removeEventListener('beforeunload', removalUnloadGuard); } function decodeHtmlEntities(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/�?39;/g, "'").replace(/'/gi, "'"); } // The recording-edit form is React-rendered, so the static HTML only carries a few // plain inputs + the field data embedded as JSON for hydration. We reconstruct the // full `edit-recording.*` POST from BOTH: the JSON field leaves (artist credit with // numeric IDs, name, isrcs.N.value) and the static inputs (comment, length, etc.), // marking the target ISRCs `isrcs.N.removed=1` (omission alone does NOT remove — // MBS-13969). No CSRF token field is used; the session cookie authorises. WS2-verified. async function removeIsrcsFromRecording(recId, isrcsToRemove, note) { const editUrl = MB_ROOT + '/recording/' + recId + '/edit'; const gr = await gmGet(editUrl, { 'Accept': 'text/html' }); if (gr.status !== 200) throw new Error('GET form ' + gr.status + (gr.status === 401 || gr.status === 403 ? ' (are you logged into MusicBrainz?)' : '')); const html = gr.responseText; const rm = new Set(isrcsToRemove.map(normalizeIsrc)); const params = new URLSearchParams(); const seen = new Set(); const add = (n, v) => { params.append(n, v); seen.add(n); }; // 1) JSON-hydrated field leaves (flat objects carrying html_name + value) const leaves = new Map(); for (const m of html.matchAll(/\{[^{}]*"html_name":"(edit-recording\.[^"]+)"[^{}]*\}/g)) { const vm = m[0].match(/"value":((?:"(?:[^"\\]|\\.)*")|true|false|null|-?\d+(?:\.\d+)?)/); if (vm) { try { leaves.set(m[1], JSON.parse(vm[1])); } catch (e) {} } } // 2) static plain inputs (comment, length, video, make_votable, …) const formM = html.match(/<form[^>]*class="edit-recording"[\s\S]*?<\/form>/i); const formHtml = formM ? formM[0] : html; const statics = new Map(); for (const m of formHtml.matchAll(/<(input|textarea|select)\b([^>]*)>/gi)) { const a = m[2]; const nm = (a.match(/name="([^"]*)"/) || [])[1]; if (!nm || !/^edit-recording\./.test(nm)) continue; statics.set(nm, { type: (a.match(/type="([^"]*)"/) || [])[1] || (m[1].toLowerCase() === 'textarea' ? 'textarea' : 'text'), value: decodeHtmlEntities((a.match(/value="([^"]*)"/) || [])[1] || ''), checked: /\bchecked\b/i.test(a), }); } // ISRC entries: prefer the .value leaves; fall back to the static isrcs.N aliases const isrcEntries = []; for (const [n, v] of leaves) { const mi = n.match(/\.isrcs\.(\d+)\.value$/); if (mi) isrcEntries.push({ idx: +mi[1], value: String(v) }); } if (!isrcEntries.length) for (const [n, s] of statics) { const mi = n.match(/\.isrcs\.(\d+)$/); if (mi && s.value) isrcEntries.push({ idx: +mi[1], value: s.value }); } if (!isrcEntries.length) throw new Error('no ISRC fields in the edit form (already removed?)'); // name + artist credit (numeric IDs) from the JSON leaves for (const [n, v] of leaves) { if (/\.isrcs\./.test(n)) continue; if (v === false) continue; add(n, v === true ? '1' : String(v)); } // comment / length / other plain fields from the static inputs (omit unchecked checkboxes + the isrcs alias + edit_note) for (const [n, s] of statics) { if (seen.has(n) || /\.isrcs\.\d+$/.test(n) || /\.edit_note$/.test(n)) continue; if (s.type === 'checkbox') { if (s.checked) add(n, s.value || '1'); continue; } add(n, s.value); } // ISRCs: every existing value, with removed=1 on the targets isrcEntries.forEach(e => { add('edit-recording.isrcs.' + e.idx + '.value', e.value); if (rm.has(normalizeIsrc(e.value))) add('edit-recording.isrcs.' + e.idx + '.removed', '1'); }); add('edit-recording.edit_note', note); Log.info('POST ' + shortUrl(editUrl) + ' (' + [...params.keys()].length + ' fields, removing ' + [...rm].join(',') + ')'); const pr = await gmPost(editUrl, params.toString(), { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'text/html', 'Referer': editUrl, 'Origin': MB_ROOT, }); if (pr.status >= 400) throw new Error('POST ' + pr.status + (pr.status === 401 || pr.status === 403 ? ' (are you logged into MusicBrainz?)' : '')); // A created edit redirects away from /edit to the recording page. A validation // error re-renders the form (stays on /edit, still has the edit-recording inputs). // NOTE: "Remove ISRC" is a normal (non-auto) edit — it enters the edit queue, so the // ISRC stays visible in WS2 until the edit is applied. So we can't verify by re-reading. const finalUrl = pr.finalUrl || ''; const reRendered = /\/edit\/?(?:[?#]|$)/.test(finalUrl) || /name="edit-recording\.name"/.test(pr.responseText || ''); if (reRendered) throw new Error('edit form returned an error (nothing submitted)'); } /* ═══════════════════════════════════════════════════════════════════════ PAGE BUTTON ═══════════════════════════════════════════════════════════════════════ */ const btn = document.createElement('button'); btn.id = 'ii-btn'; btn.type = 'button'; btn.innerHTML = // ISRC Scout's own radar/target logo (monochrome via currentColor so it reads // white on the purple/pink button), replacing the generic magnifying glass. '<svg width="14" height="14" viewBox="0 0 128 128" fill="none" stroke="currentColor" aria-hidden="true"><path d="M64 64 L64 24 A40 40 0 0 1 99 84 Z" fill="currentColor" opacity=".35" stroke="none"/><g stroke-width="8"><circle cx="64" cy="64" r="40"/><circle cx="64" cy="64" r="26" stroke-width="6"/><circle cx="64" cy="64" r="13" stroke-width="6"/></g><line x1="64" y1="64" x2="64" y2="24" stroke-width="8" stroke-linecap="round"/><circle cx="86" cy="50" r="9" fill="currentColor" stroke="none"/></svg>' + 'ISRC <span class="ii-status" id="ii-btn-status">⏳</span>'; btn.addEventListener('click', openModal); function injectButton() { const h1 = document.querySelector('h1'); if (!h1) return false; if (document.getElementById('ii-btn')) return true; h1.appendChild(btn); updateBtnStatus(); // in case the release already loaded before the button injected return true; } // Only the release *overview* page (`/release/<mbid>`) — not its subpages // (/edit, /edit-relationships, /aliases, /tags, …) which also match `release/*`. const IS_OVERVIEW = /^\/release\/[a-f0-9-]{36}\/?$/i.test(location.pathname); // case-insensitive: MB serves mixed-case MBID URLs as-is whenDomReady(() => { if (!IS_OVERVIEW) return; if (!injectButton()) { const obs = new MutationObserver(() => { if (injectButton()) obs.disconnect(); }); obs.observe(document.documentElement, { childList: true, subtree: true }); } }); // The button's own status span, found through the button: at document-start a load can // settle before the button is on the page, where getElementById finds nothing (#625). // A function declaration, hoisted: whenDomReady above runs injectButton → updateBtnStatus AT ONCE when the // page is already parsed (a manager can start the script late), before a `const` here would exist — // "Cannot access 'btnStatus' before initialization", which in String Theory also stopped every script // after this one, Platform Check's panel included (oblaka, majkinetor). function btnStatus() { return btn.querySelector('#ii-btn-status'); } function updateBtnStatus() { try { showTrackData(); } catch (e) { Log.warn('tracklist ISRCs and links: ' + errText(e)); } const statusEl = btnStatus(); if (!statusEl || !RELEASE) return; let total = RELEASE.tracks.length, missing = 0; RELEASE.tracks.forEach(t => { if (!t.existing.length) missing++; }); if (missing === 0) { statusEl.textContent = '✓ ' + total + '/' + total; btn.classList.remove('has-missing'); btn.title = ''; // drop a "could not load" tooltip left by a failed first load (#607) } else { statusEl.textContent = '⚠ ' + (total - missing) + '/' + total; btn.classList.add('has-missing'); btn.title = missing + ' track' + (missing > 1 ? 's' : '') + ' missing ISRC'; } } /* #749: each track's ISRCs and links as two columns of the release page's own tracklist, from the release ISRC Scout loads anyway (no extra request). A setting, on by default. A row is found by its track MBID (the row's id); the columns go before Rating (else Length), found by class. A medium MusicBrainz loads later (expanded) gets them as it comes. */ // declarations and var, hoisted: updateBtnStatus can run before this point (see btnStatus) function trackDataEnabled() { return !!store.get('track_data', true); } var _tdObs = null, _tdQueued = false; function trackDataCells(t) { const isrcs = t ? t.existing.map(c => '<a class="ii-trk-isrc" href="/isrc/' + esc(c) + '">' + esc(c) + '</a>').join('') : ''; // one icon per platform: a recording with several links on one platform (versions, regions) // gets a count beside it, its tooltip lists them all, and a click opens the first live one const groups = new Map(); if (t) TrackLinks.pageLinks(t).forEach(l => { const g = groups.get(l.name); if (g) g.push(l); else groups.set(l.name, [l]); }); const links = [...groups.values()].map(g => { const l = g.find(x => !x.ended) || g[0], ended = g.every(x => x.ended); const tip = g.length === 1 ? l.name + (l.ended ? ' (ended)' : '') + ' · ' + l.url : l.name + ' · ' + g.length + ' links\n' + g.map(x => x.url + (x.ended ? ' (ended)' : '')).join('\n'); return '<a class="ii-trk-ln' + (ended ? ' ended' : '') + '" href="' + esc(l.url) + '" target="_blank" rel="noopener" style="color:' + l.color + '" title="' + esc(tip) + '">' + l.icon + (g.length > 1 ? '<sup class="ii-trk-n">' + g.length + '</sup>' : '') + '</a>'; }).join(''); return [isrcs, links && '<div class="ii-trk-lns">' + links + '</div>']; } // the cell the columns go before: Rating, else Length, else none (appended) function trackDataAt(cells) { const list = [...cells]; let i = list.findIndex(c => c.classList.contains('rating')); if (i < 0) i = list.findIndex(c => c.classList.contains('treleases')); return i; } function addCols(tr, tag, html, cls) { const at = trackDataAt([...tr.children].filter(c => !c.classList.contains('ii-col'))); const ref = at >= 0 ? [...tr.children].filter(c => !c.classList.contains('ii-col'))[at] : null; html.forEach((h, i) => { const c = document.createElement(tag); c.className = 'ii-col ' + cls[i]; c.innerHTML = h; tr.insertBefore(c, ref); }); } function fillTrackData() { _tdQueued = false; if (!RELEASE) return; const byId = new Map(RELEASE.tracks.filter(t => t.trackId).map(t => [t.trackId, t])); const tables = new Set(); byId.forEach((t, id) => { const tr = document.getElementById(id); const tb = tr && tr.closest('table'); if (tb) tables.add(tb); }); tables.forEach(tb => { tb.querySelectorAll('tr.subh').forEach(tr => { if (!tr.querySelector(':scope > .ii-col')) addCols(tr, 'th', ['ISRC', 'Links'], ['ii-col-isrc', 'ii-col-links']); }); tb.querySelectorAll('thead th[colspan]').forEach(th => { if (!th.dataset.iiColspan) { th.dataset.iiColspan = th.colSpan; th.colSpan = th.colSpan + 2; } }); tb.querySelectorAll('tbody > tr:not(.subh)').forEach(tr => { if (tr.querySelector(':scope > .ii-col')) return; addCols(tr, 'td', trackDataCells(byId.get(tr.id)), ['ii-col-isrc', 'ii-col-links']); }); }); } function showTrackData() { document.querySelectorAll('.ii-col').forEach(el => el.remove()); document.querySelectorAll('th[data-ii-colspan]').forEach(th => { th.colSpan = +th.dataset.iiColspan; delete th.dataset.iiColspan; }); if (!IS_OVERVIEW || !RELEASE || !trackDataEnabled()) { if (_tdObs) { _tdObs.disconnect(); _tdObs = null; } return; } fillTrackData(); if (!_tdObs) { _tdObs = new MutationObserver(() => { if (!_tdQueued) { _tdQueued = true; requestAnimationFrame(fillTrackData); } }); _tdObs.observe(document.documentElement, { childList: true, subtree: true }); } } // initial status fetch (also primes RELEASE for the modal) — overview page only if (IS_OVERVIEW) { fetchRelease().catch(e => { const s = btnStatus(); if (s) s.textContent = '?'; btn.title = 'Could not load the release (' + ((e && e.message) || 'error') + ') — open ISRC Scout to retry; the count updates once it loads'; Log.warn('page button: release load failed — showing "?" until a later load succeeds (#607)'); }); } /* ── Mission Control adapter (#680) ────────────────────────────────────── Mission Control asks over document events with JSON-string details (see userscripts/mission_control/DEVELOP.md). A probe runs headless: no dialog. It takes the release IS already loaded, imports ONE album source (the first one in Match's fastest-first order that gives any), and only its first batch (STREAM_BATCH_LIMIT), like the dialog does before it pauses. Then it maps each ISRC to a track the way the dialog does: position first, then a title that is unambiguous. One finding per track; apply submits the ticked ones through the same OAuth web-service call as Submit. */ if (IS_OVERVIEW) { const mcSend = (type, detail) => document.dispatchEvent(new CustomEvent(type, { detail: JSON.stringify(detail) })); const mcHello = () => mcSend('mc:provider', { id: 'is', name: 'ISRC Scout', version: SCRIPT_VERSION, release: mbid, capabilities: ['probe', 'apply'] }); let mcFound = {}; // recId -> { isrc, source } from the last probe let mcLinks = {}; // 'link:<recId>:<url>' -> { rec, idx, code, name, url, linkTypeID } from the last probe // the same sources and order as the dialog's Match, but only the release's links and // the album links ticked in Mission Control (d.links): never a Platform Check find it didn't tick const mcSources = () => isrcSourcesFastest(true); // the dialog's mapping (trackForSource); a position fill that looks like another song is unsure function mcTrackOf(s) { const m = trackForSource(s); if (m.idx < 0) return { idx: -1 }; const c = m.byPos ? fillCheck(RELEASE.tracks[m.idx], s) : {}; return { idx: m.idx, why: m.loose ? 'matched by a similar title only ("' + s.title + '")' : c.suspicious ? (c.durOff != null ? 'matched by position only, but the length differs by ' + c.durOff + 's (' + (s.dur || '?') + ' at the source)' : 'matched by position only, but the title is "' + s.title + '"') : null }; } // ISRCs on more than one recording of the release, found or already there → isrc -> [track numbers]. // The dialog blocks these (highlightDuplicates), so Mission Control does too (#680). function mcDups() { const recs = {}, multi = RELEASE.tracks.some(x => +x.mediumPos > 1); RELEASE.tracks.forEach((t, i) => { const add = raw => { const v = normalizeIsrc(raw); if (v) (recs[v] = recs[v] || new Map()).set(t.recId || 'i' + i, (multi ? t.mediumPos + '.' : '') + (t.number || i + 1)); }; t.existing.forEach(add); if (mcFound[t.recId]) add(mcFound[t.recId].isrc); }); return new Map(Object.entries(recs).filter(([, m]) => m.size > 1).map(([v, m]) => [v, [...m.values()]])); } function mcFinding(t, dups = mcDups()) { const f = mcFound[t.recId]; const base = { key: t.recId, name: t.title, track: t.recId, existing: t.existing.slice(), links: t.recUrls.length, linkUrls: t.recUrls.slice() }; if (f && t.existing.includes(f.isrc)) return Object.assign(base, { state: 'linked', isrc: f.isrc, source: f.source }); if (f && dups.has(f.isrc)) return Object.assign(base, { state: 'blocked', isrc: f.isrc, source: f.source, why: f.isrc + ' would be on tracks ' + dups.get(f.isrc).join(', ') + ': an ISRC goes on one recording' }); if (f && t.existing.length) return Object.assign(base, { state: 'unsure', isrc: f.isrc, source: f.source, why: 'the recording already has ' + t.existing.join(', ') }); if (f && f.why) return Object.assign(base, { state: 'unsure', isrc: f.isrc, source: f.source, why: f.why }); if (f) return Object.assign(base, { state: 'new', isrc: f.isrc, source: f.source }); return Object.assign(base, { state: t.existing.length ? 'linked' : 'none', isrc: t.existing[0] || null }); } document.addEventListener('mc:discover', () => { Log.info('Mission Control asked — answering as provider is'); mcHello(); }); document.addEventListener('mc:probe', async e => { let d = {}; try { d = JSON.parse(e.detail) || {}; } catch (x) { Log.warn('Mission Control probe with unreadable detail: ' + x.message); return; } if ((d.release && d.release !== mbid) || (d.only && !d.only.includes('is'))) return; const progress = note => mcSend('mc:progress', { id: 'is', run: d.run, state: 'busy', note }); progress('loading the release'); try { await fetchRelease(); } catch (x) { Log.err('Mission Control probe: release load failed: ' + errText(x)); mcSend('mc:findings', { id: 'is', run: d.run, release: mbid, findings: [], note: 'release load failed' }); return; } // #680: album links ticked in Mission Control, not on the release yet (Execute adds them // before it runs IS): read as if they were, where the release has none of that provider. // RELEASE is the cached load the dialog uses too, so they come off again before the answer. const lent = []; (Array.isArray(d.links) ? d.links : []).forEach(u => { const hit = matchProviderLink(String(u)); if (hit && !RELEASE[hit.k]) { RELEASE[hit.k] = hit.v; lent.push(hit.k); Log.info('Mission Control probe: using the ticked ' + hit.k + ' ' + hit.v); } }); mcFound = {}; // #702: the ISRCs taken when Mission Control consolidated this release, by track, stand as // taken: a source is asked only for the tracks still without one let picks = 0; (Array.isArray(d.isrcs) ? d.isrcs : []).forEach((raw, i) => { const t = RELEASE.tracks[i], isrc = normalizeIsrc(raw); if (!t || !isValidIsrc(isrc) || t.existing.includes(isrc)) return; mcFound[t.recId] = { isrc, source: 'Mission Control' }; picks++; }); if (Array.isArray(d.isrcs)) Log.info('Mission Control probe: ' + picks + ' ISRC(s) taken in the consolidation, of ' + d.isrcs.filter(Boolean).length + ' given for ' + RELEASE.tracks.length + ' track(s)'); const srcs = mcSources(); Log.info('Mission Control probe ' + d.run + ': sources ' + (srcs.map(x => x.source).join(', ') || 'none')); let used = null, more = false; for (const src of srcs) { if (RELEASE.tracks.every(t => mcFound[t.recId] || t.existing.length)) break; progress(src.source); let n = 0; try { const res = await src.fetcher(src.id, () => {}, s => { const isrc = normalizeIsrc(s.isrc); if (!isValidIsrc(isrc)) return; const { idx, why } = mcTrackOf(s); if (idx < 0) { Log.info('MC probe ' + src.source + ': no track for ' + isrc + ' "' + s.title + '"'); return; } const t = RELEASE.tracks[idx]; // first come, except that a sure fill replaces an unsure one (another song at that position) const had = mcFound[t.recId]; if (!had || (had.why && !why)) { mcFound[t.recId] = Object.assign({ isrc, source: src.source }, why ? { why } : {}); if (!had) n++; } }, 0); if (res && res.next != null) more = true; } catch (x) { Log.warn('Mission Control probe: ' + src.source + ' failed: ' + errText(x)); continue; } Log.info('Mission Control probe: ' + src.source + ' gave ' + n + ' ISRC(s)'); if (n) { used = src.source; break; } } // then Find links, with the ISRCs just found as well as MB's (#680: one step, as the dialog's two) mcLinks = {}; const dups = mcDups(); // a duplicated ISRC finds another track's links: leave it out of the search try { const got = await TrackLinks.findHeadless(t => mcFound[t.recId] && !dups.has(mcFound[t.recId].isrc) && mcFound[t.recId].isrc, name => progress('links: ' + name)); Object.entries(got).forEach(([idx, list]) => { const t = RELEASE.tracks[+idx]; list.forEach(l => { const k = 'link:' + t.recId + ':' + l.url; mcLinks[k] = Object.assign({ rec: t.recId, idx: +idx }, l); }); }); } catch (x) { Log.warn('Mission Control probe: Find links failed: ' + errText(x)); } lent.forEach(k => { RELEASE[k] = null; }); const linkFindings = Object.entries(mcLinks).map(([key, l]) => ({ key, kind: 'link', track: l.rec, name: l.name, url: l.url, state: 'new' })); Log.info('Mission Control probe: ' + linkFindings.length + ' recording link(s) to add'); linkFindings.forEach(f => Log.info(' link ' + f.track + ' ' + f.name + ' ' + f.url)); const findings = RELEASE.tracks.map(t => mcFinding(t, dups)).concat(linkFindings); const tally = findings.reduce((a, f) => (a[f.state] = (a[f.state] || 0) + 1, a), {}); Log.info('Mission Control probe ' + d.run + ' answered' + (used ? ' from ' + used : '') + ': ' + JSON.stringify(tally)); mcSend('mc:findings', { id: 'is', run: d.run, release: mbid, source: used, more, findings }); }); document.addEventListener('mc:apply', async e => { let d = {}; try { d = JSON.parse(e.detail) || {}; } catch (x) { return; } if (d.id !== 'is' || (d.release && d.release !== mbid)) return; const reply = o => mcSend('mc:applied', Object.assign({ id: 'is', run: d.run, release: mbid }, o)); const via = 'Via Mission Control' + (d.mc ? ' v' + d.mc : '') + ': ' + location.origin + '/release/' + mbid; const map = {}, used = {}; const keys = d.keys || []; const dups = RELEASE ? mcDups() : new Map(); // never submit an ISRC that's on >1 recording, as Submit keys.filter(k => !k.startsWith('link:')).forEach(rid => { const t = RELEASE && RELEASE.tracks.find(x => x.recId === rid), f = mcFound[rid]; if (!t || !f || t.existing.includes(f.isrc)) return; if (dups.has(f.isrc)) { Log.warn('Mission Control apply: ' + f.isrc + ' left out, it would be on tracks ' + dups.get(f.isrc).join(', ')); return; } (map[rid] = map[rid] || []).push(f.isrc); used[f.source] = (used[f.source] || 0) + 1; }); // recording links (Find links), skipping any the recording got since the probe const links = keys.filter(k => k.startsWith('link:')).map(k => mcLinks[k]).filter(l => { const t = l && RELEASE.tracks.find(x => x.recId === l.rec); return t && !(t.recUrls || []).includes(l.url); }); const n = Object.keys(map).length, nl = links.length; const plural = (k, w) => k + ' ' + w + (k === 1 ? '' : 's'); if (!n && !nl) { reply({ ok: true, sent: 0, note: 'nothing left to add' }); return; } const note = [noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, 'Added ' + n + ' ISRC' + (n === 1 ? '' : 's') + ': ' + Object.keys(used).sort().map(s => s + ' (' + used[s] + ')').join(', '), via].join('\n'); if (d.dry) { Log.info('Mission Control dry run: would submit ' + JSON.stringify(map) + ' and links ' + JSON.stringify(links.map(l => l.url))); reply({ ok: true, sent: 0, note: 'dry run: ' + [n && plural(n, 'ISRC'), nl && plural(nl, 'link')].filter(Boolean).join(' and ') + ' would be submitted' }); return; } const done = [], errs = [], doneKeys = []; let sentN = 0, again = false; repeatWatch = note => mcSend('mc:progress', { id: 'is', run: d.run, state: 'busy', note }); // #748 if (n) { try { await submitIsrcs(map, note); Object.entries(map).forEach(([rid, isrcs]) => { const t = RELEASE.tracks.find(x => x.recId === rid); if (t) t.existing.push(...isrcs); }); try { updateBtnStatus(); } catch (x) { /* the page button only */ } Log.info('Mission Control apply: submitted ' + n + ' ISRC(s) ' + JSON.stringify(map)); done.push(plural(n, 'ISRC')); sentN += n; doneKeys.push(...Object.keys(map)); } catch (x) { Log.err('Mission Control apply (ISRCs) failed: ' + errText(x)); errs.push('ISRCs: ' + errText(x) + (x.repeated ? ' (repeated ' + x.repeated + ' times)' : '')); if (x.transient) again = true; } } if (nl) { try { await TrackLinks.submitRels(links.map(l => ({ recGid: l.rec, url: l.url, linkTypeID: l.linkTypeID })), TrackLinks.noteFor(links.map(l => ({ name: l.name })), links.map(l => l.url)) + '\n' + via); links.forEach(l => { const t = RELEASE.tracks.find(x => x.recId === l.rec); if (t) (t.recUrls = t.recUrls || []).push(l.url); }); Log.info('Mission Control apply: linked ' + nl + ' recording link(s) ' + JSON.stringify(links.map(l => l.url))); done.push(plural(nl, 'link')); sentN += nl; doneKeys.push(...keys.filter(k => k.startsWith('link:') && links.includes(mcLinks[k]))); } catch (x) { Log.err('Mission Control apply (links) failed: ' + errText(x)); errs.push('links: ' + errText(x) + (x.repeated ? ' (repeated ' + x.repeated + ' times)' : '')); if (x.transient) again = true; } } repeatWatch = null; // #748: doneKeys, what went through though another part failed (MC shows it as done); retry, when what failed may // go through later (a busy server), for MC's Retry reply({ ok: !errs.length, sent: sentN, doneKeys, retry: !!errs.length && again, note: [done.length && done.join(' and ') + ' submitted', errs.join('; ')].filter(Boolean).join(' · ') }); }); mcHello(); if (mbuTestHooks()) window.__isTest680 = { mcFinding, mcDups, mcTrackOf, trackForSource, mcSources, fastest: isrcSourcesFastest, found: () => mcFound, release: () => RELEASE, log: () => Log.lines().join('\n') }; } })(); })(); } catch (e) { try { console.error('[String Theory] isrc_scout failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== mammoth (@run-at document-idle) =========================================== if (__stOff.indexOf("mammoth") < 0 && __stRuns("mammoth", {"inc":["^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/.*$"],"exc":[],"noframes":false})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Mammoth*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Edit-note memory for MusicBrainz: auto-remembers your last edit notes and lets you save reusable ones, recalling them from a compact panel beside the edit-note field on every edit form. A nicer replacement for Elephant Editor.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mammoth/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzdhNGExZiI+PHBhdGggZD0iTTIxIDE5QzIxIDEyLjUgMTcuNSA4LjUgMTEuNSA4LjVDNyA4LjUgNC4yIDExLjIgNC4yIDE1TDQuMiAxOVoiLz48Y2lyY2xlIGN4PSI3LjYiIGN5PSIxMC42IiByPSI1Ii8+PHJlY3QgeD0iNyIgeT0iMTYuNSIgd2lkdGg9IjIuOCIgaGVpZ2h0PSI1LjIiIHJ4PSIxLjMiLz48cmVjdCB4PSIxNSIgeT0iMTYuNSIgd2lkdGg9IjIuOCIgaGVpZ2h0PSI1LjIiIHJ4PSIxLjMiLz48L2c+PHBhdGggZD0iTTMuMSAxMS4yQzEuNiAxMy42IDIgMTYuNiAzLjcgMTguMUM0LjYgMTguOSA1LjkgMTguNiA2LjEgMTcuNUM2LjMgMTYuNSA1LjcgMTUuOCA1LjMgMTUuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjN2E0YTFmIiBzdHJva2Utd2lkdGg9IjIuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PHBhdGggZD0iTTUuMiAxNS4yQzQuMSAxNi42IDQuMyAxOC4yIDUuNiAxOC45IiBmaWxsPSJub25lIiBzdHJva2U9IiNlZmU3ZDIiIHN0cm9rZS13aWR0aD0iMS40IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4NCg=="}) }) : { script: {"name":"Mammoth*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"Edit-note memory for MusicBrainz: auto-remembers your last edit notes and lets you save reusable ones, recalling them from a compact panel beside the edit-note field on every edit form. A nicer replacement for Elephant Editor.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mammoth/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzdhNGExZiI+PHBhdGggZD0iTTIxIDE5QzIxIDEyLjUgMTcuNSA4LjUgMTEuNSA4LjVDNyA4LjUgNC4yIDExLjIgNC4yIDE1TDQuMiAxOVoiLz48Y2lyY2xlIGN4PSI3LjYiIGN5PSIxMC42IiByPSI1Ii8+PHJlY3QgeD0iNyIgeT0iMTYuNSIgd2lkdGg9IjIuOCIgaGVpZ2h0PSI1LjIiIHJ4PSIxLjMiLz48cmVjdCB4PSIxNSIgeT0iMTYuNSIgd2lkdGg9IjIuOCIgaGVpZ2h0PSI1LjIiIHJ4PSIxLjMiLz48L2c+PHBhdGggZD0iTTMuMSAxMS4yQzEuNiAxMy42IDIgMTYuNiAzLjcgMTguMUM0LjYgMTguOSA1LjkgMTguNiA2LjEgMTcuNUM2LjMgMTYuNSA1LjcgMTUuOCA1LjMgMTUuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjN2E0YTFmIiBzdHJva2Utd2lkdGg9IjIuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PHBhdGggZD0iTTUuMiAxNS4yQzQuMSAxNi42IDQuMyAxOC4yIDUuNiAxOC45IiBmaWxsPSJub25lIiBzdHJva2U9IiNlZmU3ZDIiIHN0cm9rZS13aWR0aD0iMS40IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4NCg=="} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ try { (function(){ // // Mammoth puts a compact saved-notes panel to the RIGHT of MusicBrainz's native // Edit note field (textarea.edit-note), which appears on every edit form, and // widens that (centered) field to make room. // // - AUTO-HISTORY: remembers the last N edit notes you submit (default 10, deduped). // - SAVED notes: ★ favourite (sorts to top), drag (⠿) to reorder, 🗑 delete. // One line each (full text on hover). // - INSERT: a click applies your default action (append or replace, see ⚙); // right-click does the other. Ctrl/⌘ + ↑/↓ cycles saved notes, replacing the // field. Append skips a line already present. Never auto-overwrites blindly, // so it won't clobber notes Apollo / Credit Hoarder / Platform Check write. // - BABY MAMMOTHS (⚙ "Show mammoth babies"): the same save/reuse idea on other // controls — catalog number, label, artist, status, language… A small 🦣 pin // on each field recalls values you've saved for it; ★ pins one as an always- // visible button under the field; one entry can be the default (auto-fills an // empty field). Targets: a built-in release-editor set + any element another // script tags class="mmth-pin". Stored separately (mammoth-fields:data). (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('mammoth', 'Mammoth')) return; try { console.info('[Mammoth] ' + mbuStartupInfo('Mammoth')); } catch (e) { /* no console */ } // Mammoth keeps no session log const KEY = 'mammoth:data'; const SKEY = 'mammoth:settings'; const DEFAULTS = { historySize: 10, hideHelp: false, defaultInsert: 'replace', visibleRows: 6, sideWidth: 300, appendNewline: true, minimized: true, showBabies: true, noteSort: 'manual', btnChars: 24, scopePerResource: false, customFields: [], v: 2 }; // defaultInsert: 'replace' | 'append'; noteSort: 'manual' | 'uses' | 'recent'; btnChars: pinned-button label length; scopePerResource: per-type note pools (#309); customFields: user-defined baby fields [{match,label,key,dx,entity}] const VERSION = '?'; // GM_info carries the real one; every script manager provides it (a hard-coded copy only ever went stale) const SYNTAX_URL = 'https://musicbrainz.org/doc/Edit_Note'; const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } }; // #308: the 🦣 emoji (U+1F9A3) renders as a tofu box in Chrome on systems whose // emoji font lacks it (Firefox bundles its own, hence the inconsistency). Use a // self-contained vector mammoth everywhere the icon shows, so it's font-independent. // #564 (majkinetor): "Mammoth baby could be a bit lighter". The fur was a // fixed #7a4a1f, chosen against a white field; on a dark one it is a brown // smudge. Both colours are tokens now (see --mmth-fur / --mmth-tusk below), // written through style= rather than the fill= attribute, because a // presentation attribute is not a reliable place for var(). const MAMMOTH_SVG = '<svg viewBox="0 0 24 24" width="100%" height="100%" aria-hidden="true" style="display:block"><g style="fill:var(--mmth-fur,#7a4a1f)"><path d="M21 19C21 12.5 17.5 8.5 11.5 8.5C7 8.5 4.2 11.2 4.2 15L4.2 19Z"/><circle cx="7.6" cy="10.6" r="5"/><rect x="7" y="16.5" width="2.8" height="5.2" rx="1.3"/><rect x="15" y="16.5" width="2.8" height="5.2" rx="1.3"/></g><path d="M3.1 11.2C1.6 13.6 2 16.6 3.7 18.1C4.6 18.9 5.9 18.6 6.1 17.5C6.3 16.5 5.7 15.8 5.3 15.3" fill="none" style="stroke:var(--mmth-fur,#7a4a1f)" stroke-width="2.7" stroke-linecap="round"/><path d="M5.2 15.2C4.1 16.6 4.3 18.2 5.6 18.9" fill="none" style="stroke:var(--mmth-tusk,#efe7d2)" stroke-width="1.4" stroke-linecap="round"/></svg>'; // #309: optionally keep notes SEPARATE per edit-note entity type (release / // artist / recording / …), derived from the page URL — opt-in via the // "Scope per resource" setting. Off (default) = one shared pool ('all'). const ENTITY_TYPES = ['area', 'artist', 'event', 'genre', 'instrument', 'label', 'place', 'recording', 'release', 'release-group', 'series', 'work']; const GLOBAL_SCOPE = 'all'; function noteScope() { const seg = (location.pathname.split('/').filter(Boolean)[0] || '').toLowerCase(); return ENTITY_TYPES.includes(seg) ? seg : 'other'; } const SCOPE = noteScope(); const scopeLabel = s => s === 'other' ? 'other' : s.replace(/-/g, ' '); // Multi-scope store: { [scope]: { saved, history } }. Migrate the old flat // { saved, history } into the shared 'all' pool (scoping is off by default). function loadStore() { let raw; try { raw = JSON.parse(GM_getValue(KEY, '{}') || '{}'); } catch (e) { raw = {}; } if (!raw || typeof raw !== 'object') raw = {}; if (Array.isArray(raw.saved) || Array.isArray(raw.history)) raw = { [GLOBAL_SCOPE]: { saved: raw.saved || [], history: raw.history || [] } }; return raw; } // v2 made the panel start minimized; the whole object was saved before, so a stored // minimized: false is the old default, not a choice: drop it once and the new default shows const loadSet = () => { let s; try { s = JSON.parse(GM_getValue(SKEY, '{}') || '{}') || {}; } catch (e) { s = {}; } if (!(s.v >= 2)) { if (s.minimized === false) delete s.minimized; s.v = 2; } return Object.assign({}, DEFAULTS, s); }; const persistSet = () => { try { GM_setValue(SKEY, JSON.stringify(SET)); } catch (e) {} }; // quiet save (no re-render) const saveSet = () => { persistSet(); applyHelp(); render(); }; // Built-in "OTB" baby fields — now seeded INTO the config (SET.customFields) so they're visible/editable // in the Fields tab like any custom field. Storage keys are derived from the LABEL (same-label fields share // one list) — no explicit `key`. `mbid:true` (JSON-only flag) enables entity-MBID capture on Label/Artist. const BUILTIN_FIELDS = [ { match: 'input[id^="catno-"]', label: 'Catalog number' }, { match: '#primary-type', label: 'Primary type' }, { match: '#packaging', label: 'Packaging' }, { match: '#status', label: 'Status' }, { match: '#language', label: 'Language' }, { match: '#script', label: 'Script' }, { match: 'select[id^="country-"]', label: 'Country' }, { match: 'input[id^="label-"]', label: 'Label', mbid: true }, { match: '#ac-source-single-artist, input[id^="ac-source-artist-"]', label: 'Artist', mbid: true }, { match: '.attribute-container.text.task input[type="text"]', label: 'Task' }, ]; let SET = loadSet(); // seed the built-ins once (merge-in any not already present by selector; never re-adds after you delete one) if (!SET.fieldsSeeded) { const have = new Set((SET.customFields || []).map(c => c && c.match)); SET.customFields = [...BUILTIN_FIELDS.filter(b => !have.has(b.match)).map(b => ({ ...b })), ...(SET.customFields || [])]; SET.fieldsSeeded = true; persistSet(); } // keys are now derived from the label — drop any explicit `key` left over from older seeds / pasted JSON if ((SET.customFields || []).some(c => c && c.key != null)) { SET.customFields.forEach(c => { if (c) delete c.key; }); persistSet(); } let STORE = loadStore(); const dataKey = () => SET.scopePerResource ? SCOPE : GLOBAL_SCOPE; let DATA; function useScope() { // (re)point DATA at the current pool (called on load + when the setting toggles) const k = dataKey(); DATA = STORE[k] || (STORE[k] = { saved: [], history: [] }); if (!Array.isArray(DATA.saved)) DATA.saved = []; if (!Array.isArray(DATA.history)) DATA.history = []; } useScope(); const saveData = () => { try { GM_setValue(KEY, JSON.stringify(STORE)); } catch (e) {} render(); }; const updateScopeChips = () => document.querySelectorAll('.mmth-scope').forEach(c => { c.style.display = SET.scopePerResource ? '' : 'none'; }); const uid = () => 'n' + Math.random().toString(36).slice(2, 9); const babyMammoths = createBabyMammoths(); // field-memory module (gated by SET.showBabies) // ── data ops ───────────────────────────────────────────────────────────────── function recordHistory(text) { text = (text || '').trim(); if (!text) return; DATA.history = DATA.history.filter(h => h.text !== text); DATA.history.unshift({ text, ts: Date.now() }); DATA.history = DATA.history.slice(0, Math.max(1, Math.min(50, SET.historySize | 0 || 10))); saveData(); } function addSaved(text) { text = (text || '').trim(); if (!text) return false; if (DATA.saved.some(s => s.text === text)) return false; DATA.saved.push({ id: uid(), text, ts: Date.now() }); saveData(); return true; } const removeSaved = id => { DATA.saved = DATA.saved.filter(s => s.id !== id); saveData(); }; const removeHistory = text => { DATA.history = DATA.history.filter(h => h.text !== text); saveData(); }; function reorder(srcId, tgtId, before) { if (srcId === tgtId) return; const a = DATA.saved, si = a.findIndex(s => s.id === srcId); if (si < 0) return; const [it] = a.splice(si, 1); let ti = a.findIndex(s => s.id === tgtId); if (ti < 0) { a.splice(si, 0, it); return; } a.splice(before ? ti : ti + 1, 0, it); saveData(); } // #304: scaling helpers for big note lists. const togglePinNote = id => { const s = DATA.saved.find(x => x.id === id); if (s) { s.pinned = !s.pinned; saveData(); } }; // record that a saved note was used (drives the "Most used" / "Recent" sort) function bumpUse(id) { const s = DATA.saved.find(x => x.id === id); if (!s) return; s.uses = (s.uses | 0) + 1; s.lastUsed = Date.now(); saveData(); } // display order for the Saved list — pinned never reorder the list (they get their // own quick-button bar); only the chosen sort mode reshuffles. Manual = stored order. function sortedSaved() { const a = DATA.saved.slice(); const mode = SET.noteSort || 'manual'; if (mode === 'uses') a.sort((x, y) => (y.uses | 0) - (x.uses | 0) || (y.lastUsed || y.ts || 0) - (x.lastUsed || x.ts || 0)); else if (mode === 'recent') a.sort((x, y) => (y.lastUsed || y.ts || 0) - (x.lastUsed || x.ts || 0)); return a; } // #304: parse pasted notes — each line a note, or (byBlock) blank-line-separated // blocks so multi-line notes survive. Blank lines are dropped either way, so a // readable (blank-line) export round-trips fine in "1 note per line" mode too. function parseNotes(text, byBlock) { const parts = byBlock ? String(text || '').split(/\r?\n[ \t]*\r?\n/) : String(text || '').split(/\r?\n/); return parts.map(s => s.replace(/^\s+|\s+$/g, '')).filter(Boolean); } // add parsed notes to the edit-note saved list (dedup). Returns the added count. function addSavedNotes(notes) { let added = 0; const have = new Set(DATA.saved.map(s => s.text)); for (const t of notes) { if (have.has(t)) continue; have.add(t); DATA.saved.push({ id: uid(), text: t, ts: Date.now() }); added++; } if (added) saveData(); return added; } // ── insert (React-safe + undoable) ─────────────────────────────────────────── const NATIVE_SET = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; // Set the whole field value via the native edit pipeline so the change joins // the browser's undo stack — ctrl/⌘+Z restores the previous note (#226). // `execCommand` also fires a genuine `input` event, so the React-controlled // edit-note field (release editor) still updates. Falls back to the native // value setter + synthetic events if execCommand is unavailable or no-ops. function setValue(ta, val) { let ok = false; try { ta.focus(); ta.setSelectionRange(0, ta.value.length); // select all → replace as one undoable step ok = val ? document.execCommand('insertText', false, val) : document.execCommand('delete', false, null); if (ok && ta.value !== val) ok = false; // some engines return true but no-op } catch (e) { ok = false; } if (!ok) { NATIVE_SET.call(ta, val); ta.dispatchEvent(new Event('input', { bubbles: true })); } ta.dispatchEvent(new Event('change', { bubbles: true })); } // #304: set when Ctrl/⌘+, un-minimized the panel just to reach search — so we // re-minimize once a note is applied (or the search is cancelled). let restoredFromMin = false; const reMinIfRestored = () => { if (restoredFromMin) { restoredFromMin = false; setMinimized(true); } }; function applyNote(ta, text, replace) { const cur = ta.value || ''; if (!replace && cur.trim()) { // #212: don't append a note already in the field — match whole-field, a // blank-line-separated block, or a single line (handles multi-line notes). const norm = s => s.split('\n').map(l => l.replace(/[ \t]+/g, ' ').trim()).filter(Boolean).join('\n').trim(); const tN = norm(text); const cands = [cur, ...cur.split(/\n{2,}/), ...cur.split('\n')].map(norm); if (tN && cands.includes(tN)) { toast('Already in the note'); reMinIfRestored(); return; } } setValue(ta, (replace || !cur.trim()) ? text : cur.replace(/\s+$/, '') + (SET.appendNewline ? '\n\n' : '\n') + text); ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch (e) {} reMinIfRestored(); } // Toggle `marker` around the selection (GitHub-style): if the text is already // wrapped, strip the markers; otherwise add them. With no selection, act on the // word the caret is in (or just insert/remove an empty pair). function wrapSel(ta, marker) { const v = ta.value; const m = marker, ml = m.length; let s = ta.selectionStart ?? v.length, e = ta.selectionEnd ?? s; if (s === e) { let a = s, b = e; while (a > 0 && /\S/.test(v[a - 1])) a--; while (b < v.length && /\S/.test(v[b])) b++; if (b > a) { s = a; e = b; } } const sel = v.slice(s, e); const reselect = (from, len) => { ta.focus(); try { ta.setSelectionRange(from, from + len); } catch (x) {} }; // #326: already wrapped — markers INSIDE the selection → unwrap if (sel.length >= 2 * ml && sel.startsWith(m) && sel.endsWith(m)) { const inner = sel.slice(ml, sel.length - ml); setValue(ta, v.slice(0, s) + inner + v.slice(e)); reselect(s, inner.length); return; } // #326: already wrapped — markers just OUTSIDE the selection → unwrap if (v.slice(s - ml, s) === m && v.slice(e, e + ml) === m) { setValue(ta, v.slice(0, s - ml) + sel + v.slice(e + ml)); reselect(s - ml, sel.length); return; } // otherwise wrap setValue(ta, v.slice(0, s) + m + sel + m + v.slice(e)); if (sel) reselect(s + ml, sel.length); else { const caret = s + ml; ta.focus(); try { ta.setSelectionRange(caret, caret); } catch (x) {} } } // ── capture on submit ──────────────────────────────────────────────────────── const captureNote = () => document.querySelectorAll('textarea.edit-note').forEach(ta => recordHistory(ta.value)); document.addEventListener('submit', captureNote, true); document.addEventListener('click', e => { const b = e.target.closest && e.target.closest('button, input[type="submit"]'); if (!b) return; if (b.closest('.mmth-side, .mmth-pop')) return; // our own buttons aren't edit submits const t = (b.textContent || b.value || '').trim().toLowerCase(); if (b.id === 'enter-edit' || /^(enter edit|submit|add edit|save)/.test(t) || (b.classList && b.classList.contains('submit'))) captureNote(); }, true); // ── styles ─────────────────────────────────────────────────────────────────── // The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and are // inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never in this block. // <ST-TOKENS> — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // </ST-TOKENS> // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here. // <ST-UI> — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on <html>, which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'<svg…>' | '<img…>', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // o.history opt-in history of past sessions (#705), below // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // viewer appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) : '--:--:--.---'; }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + (e.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button, input, select') || pop.classList.contains('full')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); views = views.filter(function (v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in <body>: a permanent stray node under <html>, outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No <body> yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and <head> and <body> don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } // </ST-UI> const css = MBU_TOKENS + MBU_UI_CSS + ` fieldset.editnote, .editnote { max-width:100% !important; } /* On the release editor the edit note sits in a 540px .half-width column whose only sibling is the changes warning (not a guidelines column), so give that column the full form width when Mammoth is active. The :has() selector scopes it to our column. min-width:0 lets the editnote fieldset (min-content by default) take that width so margin:auto can center it. SCOPED to the release editor (body.mmth-reledit): on entity-creation/edit pages (artist/label/… /create, /edit) the .editnote sits in a genuine half-width column beside the guidelines, and widening it to 100% broke that two-column layout — visibly so alongside scripts that write into it (#268). */ .mmth-reledit .half-width:has(> .editnote.mmth-on), .mmth-reledit .col:has(> .editnote.mmth-on) { width:100% !important; max-width:100% !important; } .editnote.mmth-on { width:100% !important; max-width:100% !important; min-width:0 !important; box-sizing:border-box; } .editnote.mmth-on > .row { width:100% !important; box-sizing:border-box; } /* hide only the redundant inline "Edit note:" label next to the field — keep the section header (the fieldset's legend) visible (#212). */ .editnote.mmth-on > .row > label[for] { display:none !important; } /* align-items:flex-start (not stretch) so the panel keeps its own bounded height. Stretch made the panel grow to match the field, and the #229 floor (field min-height = panel height) then fed back through it — each pass added the field's padding/border, inflating both without bound (#245). The field is still floored to the panel via JS, so it's never shorter. */ .mmth-wrap { display:flex; gap:0; align-items:flex-start; width:100%; max-width:1040px; margin:6px auto; box-sizing:border-box; position:relative; } /* #304: field column = textarea + pinned-quick-buttons bar, stacked */ .mmth-fieldcol { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; } .mmth-fieldcol > textarea.edit-note { width:100% !important; min-width:0; box-sizing:border-box; } /* #288/#290: foreign edit-note error/warning <p>s that MB (and other scripts) insert next to the textarea are RELOCATED out of the flex row by JS (see relocateForeign) so they never sit beside the field. No flex-wrap here — that made the panel itself wrap below the field in a narrow column (#290). */ /* Minimized mode (#265): the panel collapses to a small Mammoth badge in the field's top-right corner; the field takes the full width and the panel floats in only on hover. No width/height coupling, so it can't feed the #245 loop. */ .mmth-min .mmth-vsep { display:none !important; } .mmth-min > .mmth-side { position:absolute; top:30px; right:2px; z-index:60; display:none; box-shadow:0 8px 26px rgba(20,50,35,.22); } .mmth-min > .mmth-side.mmth-open { display:flex; } .mmth-badge { display:none; position:absolute; top:4px; right:5px; z-index:61; width:25px; height:25px; align-items:center; justify-content:center; cursor:pointer; border:1px solid var(--mbu-border); border-radius:7px; background:var(--mbu-bg); box-shadow:0 1px 3px rgba(0,0,0,.12); font-size:15px; line-height:1; user-select:none; } .mmth-badge:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmth-min > .mmth-badge { display:flex; } .mmth-vsep { flex:none; width:9px; align-self:flex-start; cursor:col-resize; position:relative; } /* #304: height synced to the field (not the field+pinbar column) */ .mmth-vsep::before { content:''; position:absolute; left:4px; top:0; bottom:0; width:1px; background:var(--mbu-bg-sunken); } .mmth-vsep:hover::before, .mmth-vsep.mmth-dragv::before { background:#5aa67e; width:3px; left:3px; } .mmth-hidehelp > p { display:none !important; } .mmth-side { flex:0 0 300px; max-width:300px; display:flex; flex-direction:column; border:1px solid var(--mbu-border); border-radius:8px; background:var(--mbu-bg); font:12px/1.35 -apple-system,Segoe UI,Arial,sans-serif; overflow:hidden; } .mmth-ft { display:flex; align-items:center; gap:2px; padding:3px 5px; border-bottom:1px solid var(--mbu-border); background:var(--mbu-bg-raised); } .mmth-fb { cursor:pointer; border:none; background:none; font-size:13px; line-height:1; padding:3px 6px; border-radius:5px; color:var(--mbu-text-dim); } .mmth-fb:hover { background:var(--mbu-ok-bg); } .mmth-fb.on { background:var(--mbu-ok-bg); color:var(--mbu-ok); } .mmth-fb.mmth-spacer { flex:1; pointer-events:none; } .mmth-fb.mmth-grp { margin-left:10px; } /* #309: per-type scope indicator (release / artist / recording / …) */ .mmth-scope { align-self:center; flex:none; font-size:10px; color:var(--mbu-text-dim); background:var(--mbu-bg-raised); border:1px solid var(--mbu-border); border-radius:9px; padding:1px 7px; margin-right:4px; white-space:nowrap; max-width:72px; overflow:hidden; text-overflow:ellipsis; } .mmth-ft { flex-wrap:wrap; } /* #304: never clip toolbar buttons — wrap as a last resort below the min width */ /* #304: opt-in search row (search box + count) between the toolbar and the list */ .mmth-filterrow { display:flex; align-items:center; gap:5px; padding:3px 5px; border-bottom:1px solid var(--mbu-border); background:var(--mbu-bg); } /* width:auto !important defends against MB's form CSS (#content input), which otherwise forces a fixed width and squashes the flex layout (#304) */ /* display:block !important beats MB's ".add-edit-note input { display:none }" (which otherwise hides our search box on /edit and /edits pages); width:auto !important beats MB's fixed input width. #304 */ .mmth-filter { display:block !important; flex:1 1 auto; min-width:0; width:auto !important; box-sizing:border-box; border:1px solid var(--mbu-border); border-radius:5px; padding:2px 6px; font:12px -apple-system,Segoe UI,Arial,sans-serif; } .mmth-filter:focus { outline:none; border-color:var(--mbu-ok); } .mmth-count { flex:none; font-size:11px; color:var(--mbu-text-weak); white-space:nowrap; } /* #304: pinned saved notes as quick-insert buttons BELOW the field (like baby-field bars) */ :root { --mmth-fur:#7a4a1f; --mmth-tusk:#efe7d2; } :root[data-mbu-theme="dark"] { --mmth-fur:#c98a44; --mmth-tusk:#f6efdd; } .mmth-pinbar { display:flex; flex-wrap:wrap; gap:5px; margin:5px 0 2px; } .mmth-segb { border:1px solid var(--mbu-border) !important; background:var(--mbu-bg); border-radius:7px; padding:3px 10px !important; font:12px/1.2 -apple-system,Segoe UI,Arial,sans-serif !important; color:var(--mbu-ok); cursor:pointer; max-width:200px; height:auto !important; min-height:0 !important; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.06); } .mmth-segb:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmth-row.mmth-pinned .mmth-txt::before { content:'★'; color:var(--mbu-warn); margin-right:4px; font-size:10px; vertical-align:1px; } /* #304: tabbed config window (Settings / Import-Export) */ .mmth-cfgtabs { display:flex; gap:4px; margin:0 0 8px; border-bottom:1px solid var(--mbu-border); } .mmth-cfgtab { border:none; background:none; padding:4px 9px; font-size:12px; color:var(--mbu-text-dim); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; } .mmth-cfgtab:hover { color:var(--mbu-ok); } .mmth-cfgtab.on { color:var(--mbu-ok); border-bottom-color:var(--mbu-ok); font-weight:600; } .mmth-cfgsec { font-weight:600; font-size:11px; color:var(--mbu-text-dim); text-transform:uppercase; letter-spacing:.04em; margin:10px 0 4px; padding-bottom:2px; border-bottom:1px solid var(--mbu-border); } .mmth-cfgpane > .mmth-cfgsec:first-child { margin-top:2px; } /* custom baby fields (config) */ /* Custom fields — flat, borderless grid */ .mmth-cf-top { display:flex; align-items:center; gap:6px; margin:2px 0 8px; } .mmth-cf-total { margin-left:auto; font-size:11px; color:var(--mbu-text-dim); white-space:nowrap; } .mmth-cf-total b { font-weight:700; color:var(--mbu-ok); } .mmth-cf-total .mmth-cf-off-n { color:var(--mbu-warn); } .mmth-cf-list { display:flex; flex-direction:column; gap:0; margin:0 0 4px; max-height:46vh; overflow-y:auto; overflow-x:hidden; } .mmth-cf-list::-webkit-scrollbar { width:8px; } .mmth-cf-list::-webkit-scrollbar-thumb { background:var(--mbu-bg-sunken); border-radius:4px; } .mmth-cf-row { display:flex; align-items:center; gap:5px; flex-wrap:nowrap; padding:0 3px; border-radius:5px; } .mmth-cf-row:hover { background:var(--mbu-bg-raised); } .mmth-cf-in { border:none; border-bottom:1px solid var(--mbu-border); border-radius:0; background:transparent; padding:2px 3px; font:11.5px -apple-system,Segoe UI,Arial,sans-serif; box-sizing:border-box; min-width:0; } .mmth-cf-in:focus { outline:none; border-bottom-color:var(--mbu-ok); } .mmth-cf-in::placeholder { color:var(--mbu-text-weak); } .mmth-cf-match { flex:1 1 auto; min-width:90px; } .mmth-cf-in[type=number] { border:none; border-bottom:1px solid var(--mbu-border); -moz-appearance:textfield; text-align:center; } /* -moz-appearance:textfield hides FF spinners; keep the flat border (appearance:textfield re-added a native box in Chromium) */ .mmth-cf-in[type=number]::-webkit-inner-spin-button, .mmth-cf-in[type=number]::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; } .mmth-cf-sub { display:inline-flex; align-items:center; gap:3px; font-size:11px; color:var(--mbu-text-weak); white-space:nowrap; cursor:pointer; } /* matches count — a compact green pill; grey when 0, red when the selector is invalid */ .mmth-cf-cnt { min-width:18px; text-align:center; font:700 10px/16px -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-ok); background:var(--mbu-ok-bg); border-radius:9px; padding:0 5px; } .mmth-cf-cnt:empty { background:none; } .mmth-cf-cnt.mmth-cf-zero { color:var(--mbu-text-weak); background:var(--mbu-bg-raised); } .mmth-cf-cnt.mmth-cf-bad { color:var(--mbu-text-on-accent); background:#c0392b; } .mmth-cf-del { border:none; background:none; cursor:pointer; font-size:13px; padding:2px 4px; border-radius:4px; visibility:hidden; } .mmth-cf-row:hover .mmth-cf-del { visibility:visible; } .mmth-cf-del:hover { background:var(--mbu-error-bg); } /* enable/disable toggle — shown on row hover, and always on a disabled row so it can be switched back */ .mmth-cf-tog { border:none; background:none; cursor:pointer; font-size:11px; line-height:1; padding:2px 3px; border-radius:4px; color:var(--mbu-ok); visibility:hidden; } .mmth-cf-tog:hover { background:var(--mbu-ok-bg); } .mmth-cf-row:hover .mmth-cf-tog, .mmth-cf-row.mmth-cf-off .mmth-cf-tog { visibility:visible; } .mmth-cf-row.mmth-cf-off { background:var(--mbu-bg); } .mmth-cf-row.mmth-cf-off .mmth-cf-in, .mmth-cf-row.mmth-cf-off .mmth-cf-cnt, .mmth-cf-row.mmth-cf-off .mmth-cf-sub { opacity:.4; } .mmth-cf-row.mmth-cf-off .mmth-cf-tog { color:var(--mbu-text-weak); } .mmth-cf-add { border:1px dashed var(--mbu-border); background:var(--mbu-bg); border-radius:var(--mbu-radius); padding:5px 12px; cursor:pointer; color:var(--mbu-ok); font-size:12px;font-family:inherit; } .mmth-cf-add:hover { background:var(--mbu-ok-bg); } .mmth-cf-empty { color:var(--mbu-text-weak); font-style:italic; font-size:12px; padding:2px 0; } .mmth-cf-reset { margin-left:6px; border:1px solid var(--mbu-border); background:var(--mbu-bg); border-radius:5px; padding:2px 9px; font:11px -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-ok); cursor:pointer; text-transform:none; letter-spacing:normal; } .mmth-cf-reset:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmth-cf-mode { border:1px solid var(--mbu-border); background:var(--mbu-bg); border-radius:5px; padding:2px 9px; font:11px -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-ok); cursor:pointer; text-transform:none; letter-spacing:normal; } .mmth-cf-mode:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmth-cf-json { width:100%; box-sizing:border-box; min-height:150px; font:12px/1.4 ui-monospace,Consolas,monospace; border:1px solid var(--mbu-border); border-radius:var(--mbu-radius); padding:7px; resize:vertical; } .mmth-cf-json:focus { outline:none; border-color:var(--mbu-ok); } .mmth-cf-jsonrow { display:flex; align-items:center; gap:9px; margin-top:6px; } .mmth-cf-jsonapply { border:1px dashed var(--mbu-border); background:var(--mbu-bg); border-radius:var(--mbu-radius); padding:5px 12px; cursor:pointer; color:var(--mbu-ok); font-size:12px;font-family:inherit; } .mmth-cf-jsonapply:hover { background:var(--mbu-ok-bg); } .mmth-cf-jsonmsg { font-size:11px; color:var(--mbu-text-weak); } .mmth-cf-jsonmsg.mmth-cf-bad { color:var(--mbu-error); } /* #304: import/export pane (the pane IS the flex column — no inner .mmth-io wrapper) */ .mmth-cfgpane[data-pane="io"] { display:flex; flex-direction:column; gap:8px; } .mmth-io-modes { display:flex; flex-flow:row wrap; gap:6px 18px; font-size:12px; align-items:center; } .mmth-io-modes label { margin:0; display:inline-flex; align-items:center; gap:6px; } /* width/height !important to beat MB's #content textarea form CSS (#304) */ .mmth-io-ta { width:100% !important; box-sizing:border-box; height:160px !important; min-height:120px; resize:vertical; border:1px solid var(--mbu-border); border-radius:5px; padding:6px 8px; font:13px/1.55 -apple-system,Segoe UI,Arial,sans-serif; } .mmth-io-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; } .mmth-io-btn { cursor:pointer; border:1px solid var(--mbu-border); background:var(--mbu-bg); border-radius:5px; padding:2px 8px; font-size:12px; color:var(--mbu-ok); } .mmth-io-btn:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmth-io-msg { font-size:11px; color:var(--mbu-text-weak); } .mmth-list { flex:1 1 auto; overflow-y:auto; scrollbar-width:none; } .mmth-list::-webkit-scrollbar { width:0; height:0; } .mmth-row { display:flex; align-items:center; gap:4px; padding:4px 6px; border-top:1px solid var(--mbu-border); cursor:pointer; } .mmth-row:first-child { border-top:none; } .mmth-row:hover { background:var(--mbu-ok-bg); } .mmth-row.mmth-cyc { background:var(--mbu-ok-bg); } .mmth-row.mmth-drop-before { box-shadow:inset 0 2px 0 #2c7a51; } .mmth-row.mmth-drop-after { box-shadow:inset 0 -2px 0 #2c7a51; } .mmth-row.mmth-dragging { opacity:.45; } .mmth-grab { flex:none; cursor:grab; color:var(--mbu-text-weak); font-size:12px; user-select:none; opacity:0; } .mmth-row:hover .mmth-grab { opacity:1; } .mmth-grab:active { cursor:grabbing; } .mmth-txt { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--mbu-text); } .mmth-rowacts { flex:none; display:flex; gap:1px; opacity:0; } .mmth-row:hover .mmth-rowacts { opacity:1; } .mmth-ra { cursor:pointer; border:none; background:none; color:var(--mbu-text-dim); font-size:11px; line-height:1; padding:1px 2px; border-radius:3px; } .mmth-ra:hover { background:var(--mbu-ok-bg); color:var(--mbu-ok); } .mmth-empty { padding:12px 8px; color:var(--mbu-text-weak); font-style:italic; text-align:center; } .mmth-pop { position:fixed; z-index:2147483647; background:var(--mbu-bg); border:1px solid var(--mbu-border); border-radius:8px; box-shadow:0 8px 26px rgba(20,50,35,.2); /* max — the config/syntax popup must clear any host modal (GT/ISRC live near max) */ padding:10px 12px; font:13px/1.45 -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-text); width:280px; } .mmth-cfg { width:360px; max-height:90vh; overflow:hidden; } /* #304 wider config window; cap height — the Babies list scrolls internally */ .mmth-cfg.mmth-cfg-wide { width:min(660px,94vw); } /* Fields tab: roomier so each custom-field row fits one line */ .mmth-pop h4 { margin:-10px -12px 8px; padding:6px 10px; font-size:13px; display:flex; align-items:center; gap:6px; background:var(--mbu-bg-raised); border-bottom:1px solid var(--mbu-border); border-radius:8px 8px 0 0; } .mmth-tip { color:var(--mbu-text-weak); font-size:11px; margin:0 0 4px 22px; } .mmth-pop h4 .mmth-ver { color:var(--mbu-text-weak); font-weight:400; font-size:11px; } .mmth-pop h4 a { margin-left:auto; font-size:11px; color:var(--mbu-ok); text-decoration:none; font-weight:600; } .mmth-pop h4 .mmth-h4ic { display:inline-flex; width:18px; height:18px; flex:none; } /* #308 vector mammoth */ .mmth-badge svg { width:19px; height:19px; } /* #308 vector mammoth */ .mmth-pop label { display:flex; align-items:center; gap:6px; margin:5px 0; cursor:pointer; } .mmth-pop input[type="number"]:not(.mmth-cf-in) { width:46px; border:1px solid var(--mbu-border); border-radius:4px; padding:1px 4px; } .mmth-pop select:not(.mmth-cf-in) { border:1px solid var(--mbu-border); border-radius:4px; padding:1px 4px; } .mmth-pop code { background:var(--mbu-bg-raised); border-radius:3px; padding:0 3px; font-size:12px; } .mmth-pop .mmth-syn { display:grid; grid-template-columns:auto 1fr; gap:3px 10px; margin:4px 0; } .mmth-pop .mmth-sub { font-weight:600; font-size:12px; margin:8px 0 2px; } /* While an MB dialog/popover is open, hide Mammoth's panel (incl. the minimized badge) and its popovers — MB's dialogs carry no z-index, so our UI would float on top of them. visibility keeps the docked panel's layout (no jump). #313 Uses mmthf-anydialog (set for ANY open dialog), not mmthf-dialog (blocking only): a dialog that HOSTS babies is non-blocking so its own pins stay, but the main panel/badge must still hide under it (else it floats over — #400). */ html.mmthf-anydialog .mmth-side, html.mmthf-anydialog .mmth-badge, html.mmthf-anydialog .mmth-pop { visibility:hidden !important; pointer-events:none !important; } `; (function () { const s = document.createElement('style'); s.textContent = css; (document.head || document.documentElement).appendChild(s); })(); // #268: only the release editor wants its edit-note .half-width column widened to // full width. Tag it so the widening rule above is scoped to it and never disturbs // the two-column layout of entity create/edit pages (artist, label, work, …). if (/^\/release\/(?:add|[0-9a-f-]{36}\/edit)(?:[/?#]|$)/.test(location.pathname)) document.documentElement.classList.add('mmth-reledit'); // Show a toast near where the user is acting (the Mammoth panel / button they just // clicked) instead of pinned to the top of the page, which reads as unrelated (#268 // follow-up). Falls back to top-centre when there's no recent Mammoth interaction. let _toastPt = null; document.addEventListener('pointerdown', e => { const t = e.target.closest && e.target.closest('.mmth-side, .mmth-pop, .mmth-wrap, .mmth-badge'); if (t) _toastPt = { x: e.clientX, y: e.clientY }; }, true); // #563: the shared toast. Mammoth was the only script anchoring a toast to the // click point, so that became the standard component's `at` option rather than // being dropped — the 1.5s duration is kept too, it is deliberately quick. function toast(msg) { return mbuToast(msg, { ms: 1500, at: _toastPt || undefined }); } // #305: dismiss our popovers on the outside *click* (capture), NOT mousedown. // Tearing the popover down on mousedown removed what was under the cursor, so the // trailing mouseup/click fell through to whatever sat beneath it (e.g. Apollo's // cover-art thumbnail under the field) and activated it — "cover art opened when // selecting a label". Handling the click lets us swallow that exact event so the // dismiss can never double as activating something below. // ── popovers (settings + syntax help) ──────────────────────────────────────── let pop = null; function closePop() { if (pop) { pop.remove(); pop = null; document.removeEventListener('click', onPopDown, true); document.removeEventListener('keydown', onPopKey, true); } } function onPopDown(e) { if (pop && !pop.contains(e.target) && !e.target.closest('.mmth-pop-anchor')) { e.preventDefault(); e.stopPropagation(); closePop(); } } function onPopKey(e) { if (pop && e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closePop(); } } // #347: ESC closes the settings / syntax popup function placePop(p, anchor) { const W = p.offsetWidth, H = p.offsetHeight, vw = window.innerWidth, vh = window.innerHeight; const r = anchor && anchor.getBoundingClientRect(); if (!r || (!r.width && !r.height)) { // anchor gone/hidden (e.g. a baby pin that re-hid) — sensible centred fallback p.style.left = Math.max(6, Math.round((vw - W) / 2)) + 'px'; p.style.top = '70px'; } else { // #304: open just BELOW the trigger and clamp into the viewport, so the popover // stays next to what opened it instead of jumping above (which clamped to the // top-left corner when opened from a baby pin low on a tall page). const left = Math.max(6, Math.min(vw - W - 6, r.left)); let top = r.bottom + 4; // below the trigger if (top + H > vh - 6) { // would overflow the bottom const above = r.top - H - 6; // try above if it fits, else clamp top = above >= 6 ? above : (vh - H - 6); } // …and never below the viewport: an anchor already off screen (the page grew, or was // scrolled, since it was clicked) put "above it" off screen too, out of reach. top = Math.min(top, vh - H - 6); p.style.left = left + 'px'; p.style.top = Math.max(6, top) + 'px'; } setTimeout(() => { document.addEventListener('click', onPopDown, true); document.addEventListener('keydown', onPopKey, true); }, 0); } // #304: drag the popover by its header (so it can be moved out of the way). function makeDraggable(popEl, handle) { // A missing handle means "not draggable", not "throw". This threw for real: // losing the drag affordance is a small thing, taking the rest of the caller // down with it is not. if (!handle) return; handle.classList.add('mmth-draghandle'); let sx = 0, sy = 0, sl = 0, st = 0, on = false; const move = e => { if (!on) return; let l = sl + (e.clientX - sx), t = st + (e.clientY - sy); l = Math.max(6, Math.min(window.innerWidth - popEl.offsetWidth - 6, l)); t = Math.max(6, Math.min(window.innerHeight - popEl.offsetHeight - 6, t)); popEl.style.left = l + 'px'; popEl.style.top = t + 'px'; }; const up = () => { on = false; document.removeEventListener('mousemove', move, true); document.removeEventListener('mouseup', up, true); }; handle.addEventListener('mousedown', e => { if (e.target.closest('a, button, input, select, textarea')) return; // don't hijack the Help link etc. on = true; popEl.dataset.moved = '1'; const r = popEl.getBoundingClientRect(); sl = r.left; st = r.top; sx = e.clientX; sy = e.clientY; e.preventDefault(); document.addEventListener('mousemove', move, true); document.addEventListener('mouseup', up, true); }); } // #304/#309: tabbed config window — Settings + Import / Export. `io` lets the // caller scope import/export to a specific field (e.g. a baby field's values). function openSettings(anchor, tab, io) { closePop(); const p = document.createElement('div'); p.className = 'mmth-pop mmth-cfg'; p.innerHTML = ` ${mbuCfgHeader({ script: 'mammoth', name: 'Mammoth', version: scriptVersion(), icon: MAMMOTH_SVG })} <div class="mmth-cfgtabs"> <button type="button" class="mmth-cfgtab" data-tab="settings">Settings</button> <button type="button" class="mmth-cfgtab" data-tab="fields">Babies</button> <button type="button" class="mmth-cfgtab" data-tab="io">Import / Export</button> </div> <div class="mmth-cfgpane" data-pane="settings"> <div class="mmth-cfgsec">Edit note settings</div> <label title="Keep saved notes & history separate per edit-note type (release / artist / recording / …)"><input type="checkbox" class="mmth-s-scope"> Scope per resource</label> <label><input type="checkbox" class="mmth-s-help"> Hide help text</label> <label>Sort saved notes <select class="mmth-s-sort"><option value="manual">Manual</option><option value="uses">Most used</option><option value="recent">Recent</option></select> </label> <label title="Right-click does the other action">Default click action <select class="mmth-s-ins"><option value="replace">replace</option><option value="append">append</option></select> </label> <label><input type="checkbox" class="mmth-s-nl"> Insert empty line when appending</label> <label>Items shown <input type="number" class="mmth-s-rows" min="1" max="30"></label> <label>History size <input type="number" class="mmth-s-hist" min="1" max="50"></label> <div class="mmth-cfgsec">General</div> <label><input type="checkbox" class="mmth-s-babies"> Show mammoth babies</label> <label>Button label length <input type="number" class="mmth-s-btnchars" min="4" max="80"></label> </div> <div class="mmth-cfgpane" data-pane="fields" style="display:none"> <div class="mmth-cf-top"><button type="button" class="mmth-cf-add">+ Add field</button><button type="button" class="mmth-cf-reset" title="Re-add any missing built-in fields (catalog №, status, label, artist, Task…)">↺ Defaults</button><span class="mmth-cf-total"></span><button type="button" class="mmth-cf-mode">{ } JSON</button></div> <div class="mmth-cf-list"></div> <textarea class="mmth-cf-json" spellcheck="false" style="display:none" placeholder='[{ "selector": "div.instrument input", "label": "Instrument", "deltax": 16 }]'></textarea> <div class="mmth-cf-jsonrow" style="display:none"><button type="button" class="mmth-cf-jsonapply">Apply</button><span class="mmth-cf-jsonmsg"></span></div> </div> <div class="mmth-cfgpane" data-pane="io" style="display:none"> <div class="mmth-io-modes"> <label><input type="radio" name="mmth-iomode" value="line" checked> 1 note per line</label> <label><input type="radio" name="mmth-iomode" value="block"> empty line separates notes</label> </div> <textarea class="mmth-io-ta" placeholder="Paste notes to import, or press Export to fill this box."></textarea> <div class="mmth-io-row"> <button type="button" class="mmth-io-btn mmth-io-import">Import</button> <button type="button" class="mmth-io-btn mmth-io-export">Export all</button> <span class="mmth-io-msg"></span> </div> <div class="mmth-tip mmth-io-help" style="margin-left:0"></div> </div>`; document.body.appendChild(p); pop = p; // #304: movable config window. The handle is the SHARED config header // (.mbu-cfg-h) — #563 replaced the old <h4> with it, and this line still asked // for the <h4>. It got null, makeDraggable threw on handle.classList, and // every line of openSettings after this one — the tab wiring, all the option // handlers, the close button — never ran. The window opened and did nothing. makeDraggable(p, p.querySelector('.mbu-cfg-h, h4')); // tab switching — only toggles the pane; position stays put (re-placing here made // the window jump because the two tabs differ in height). #304 const tabs = [...p.querySelectorAll('.mmth-cfgtab')], panes = [...p.querySelectorAll('.mmth-cfgpane')]; // Fields (one row per field) and Import/Export (long "name <mbid>" lines) need more horizontal room → // widen the window on those tabs. Re-place only when the wide state actually flips, so it doesn't jump. const showTab = name => { const wasWide = p.classList.contains('mmth-cfg-wide'); tabs.forEach(t => t.classList.toggle('on', t.dataset.tab === name)); panes.forEach(pn => { pn.style.display = pn.dataset.pane === name ? '' : 'none'; }); const wide = name === 'fields' || name === 'io'; p.classList.toggle('mmth-cfg-wide', wide); if (wide !== wasWide) placePop(p, anchor); }; tabs.forEach(t => t.onclick = () => { showTab(t.dataset.tab); SET.lastTab = t.dataset.tab; persistSet(); }); // remember the tab across opens // ── Settings pane ── const scope = p.querySelector('.mmth-s-scope'); scope.checked = SET.scopePerResource === true; scope.onchange = () => { SET.scopePerResource = scope.checked; persistSet(); useScope(); updateScopeChips(); render(); }; const help = p.querySelector('.mmth-s-help'); help.checked = !!SET.hideHelp; const ins = p.querySelector('.mmth-s-ins'); ins.value = SET.defaultInsert; const nl = p.querySelector('.mmth-s-nl'); nl.checked = SET.appendNewline !== false; const sort = p.querySelector('.mmth-s-sort'); sort.value = SET.noteSort || 'manual'; const btnc = p.querySelector('.mmth-s-btnchars'); btnc.value = SET.btnChars || 24; const rows = p.querySelector('.mmth-s-rows'); rows.value = SET.visibleRows; const hist = p.querySelector('.mmth-s-hist'); hist.value = SET.historySize; help.onchange = () => { SET.hideHelp = help.checked; saveSet(); }; ins.onchange = () => { SET.defaultInsert = ins.value; saveSet(); }; nl.onchange = () => { SET.appendNewline = nl.checked; saveSet(); }; sort.onchange = () => { SET.noteSort = sort.value; persistSet(); render(); }; btnc.onchange = () => { SET.btnChars = Math.max(4, Math.min(80, parseInt(btnc.value, 10) || 24)); btnc.value = SET.btnChars; saveSet(); babyMammoths.relabel(); }; rows.onchange = () => { SET.visibleRows = Math.max(1, Math.min(30, parseInt(rows.value, 10) || 6)); rows.value = SET.visibleRows; saveSet(); }; hist.onchange = () => { SET.historySize = Math.max(1, Math.min(50, parseInt(hist.value, 10) || 10)); hist.value = SET.historySize; saveSet(); recordHistory(''); }; const babies = p.querySelector('.mmth-s-babies'); babies.checked = SET.showBabies !== false; babies.onchange = () => { SET.showBabies = babies.checked; persistSet(); babyMammoths.toggle(babies.checked); }; // ── Fields pane ── custom baby fields: a user-editable list of {match,label,key,dx,entity}. Persisted in // SET; each edit re-scans the page (debounced) so pins appear/disappear live. Bad/empty selectors show a // "bad selector" / match count so a typo is caught before it litters pins. const cfList = p.querySelector('.mmth-cf-list'), cfTotal = p.querySelector('.mmth-cf-total'); const cfCount = sel => { try { return document.querySelectorAll(sel).length; } catch (e) { return -1; } }; let cfRefreshT = 0; const cfApply = () => { persistSet(); clearTimeout(cfRefreshT); cfRefreshT = setTimeout(() => { try { babyMammoths.refresh(); } catch (e) {} }, 400); }; function renderFields() { SET.customFields = SET.customFields || []; cfList.textContent = ''; if (cfTotal) { const n = SET.customFields.length, off = SET.customFields.filter(c => c && c.enable === false).length; cfTotal.innerHTML = `<b>${n}</b> ${n === 1 ? 'field' : 'fields'}` + (off ? `, <span class="mmth-cf-off-n">${off} disabled</span>` : ''); } if (!SET.customFields.length) { const e = document.createElement('div'); e.className = 'mmth-cf-empty'; e.textContent = 'No custom fields yet.'; cfList.appendChild(e); } SET.customFields.forEach((cf, i) => { const row = document.createElement('div'); row.className = 'mmth-cf-row' + (cf.enable === false ? ' mmth-cf-off' : ''); const cnt = document.createElement('span'); cnt.className = 'mmth-cf-cnt'; const paint = () => { if (!cf.match) { cnt.textContent = ''; cnt.className = 'mmth-cf-cnt'; cnt.title = ''; return; } const n = cfCount(cf.match); cnt.textContent = n < 0 ? '!' : String(n); cnt.title = n < 0 ? 'invalid selector' : `matches ${n} field(s) now`; cnt.className = 'mmth-cf-cnt' + (n < 0 ? ' mmth-cf-bad' : n === 0 ? ' mmth-cf-zero' : ''); }; const mk = (ph, key, w, type) => { const inp = document.createElement('input'); inp.type = type || 'text'; inp.placeholder = ph; inp.className = 'mmth-cf-in mmth-cf-' + key; inp.value = cf[key] != null ? cf[key] : ''; if (w) inp.style.width = w; inp.oninput = () => { cf[key] = type === 'number' ? (inp.value === '' ? '' : +inp.value) : inp.value; if (key === 'match') paint(); cfApply(); }; return inp; }; const dvIn = mk('lvl', 'dv', '34px', 'number'); dvIn.title = 'Bar level (deltav) — attach the pinned-button bar in-flow after the Nth ancestor of the field (0 = floating; raise it so the bar pushes the UI below instead of overlapping)'; dvIn.min = '0'; const subCb = document.createElement('input'); subCb.type = 'checkbox'; subCb.checked = !!cf.submit; subCb.onchange = () => { cf.submit = subCb.checked; cfApply(); }; const subL = document.createElement('label'); subL.className = 'mmth-cf-sub'; subL.title = 'Submit the field’s form ~200ms after a value is recalled — commits a tag, runs a header search, etc. (like pressing Enter). Only enable it on fields whose form is safe to submit on recall.'; subL.append(subCb, Object.assign(document.createElement('span'), { textContent: '↵' })); const tog = document.createElement('button'); tog.type = 'button'; tog.className = 'mmth-cf-tog'; const off0 = cf.enable === false; tog.textContent = off0 ? '○' : '◉'; tog.title = off0 ? 'Field is off — click to enable' : 'Field is on — click to disable'; tog.onclick = () => { cf.enable = (cf.enable === false); renderFields(); cfApply(); }; const del = document.createElement('button'); del.type = 'button'; del.className = 'mmth-cf-del'; del.textContent = '🗑'; del.title = 'Remove this field'; del.onclick = () => { SET.customFields.splice(i, 1); renderFields(); cfApply(); }; row.append(mk('CSS selector — comma-separate for several', 'match', '', 'text'), mk('Label', 'label', '84px'), mk('px', 'dx', '34px', 'number'), dvIn, subL, cnt, tog, del); cfList.appendChild(row); paint(); }); } p.querySelector('.mmth-cf-add').onclick = () => { SET.customFields = SET.customFields || []; SET.customFields.unshift({ match: '', label: '', dx: '', dv: '', submit: false }); renderFields(); const first = cfList.querySelector('.mmth-cf-row .mmth-cf-match'); if (first) first.focus(); }; p.querySelector('.mmth-cf-reset').onclick = () => { SET.customFields = SET.customFields || []; const have = new Set(SET.customFields.map(c => c && c.match)); const missing = BUILTIN_FIELDS.filter(b => !have.has(b.match)).map(b => ({ ...b })); if (missing.length) { SET.customFields = [...missing, ...SET.customFields]; renderFields(); cfApply(); } }; renderFields(); // JSON text mode — the same list as an editable/copy-pasteable JSON blob (friendly keys: selector / // label / key / deltax). Doubles as export (copy the box) and import (paste + Apply). const jsonTa = p.querySelector('.mmth-cf-json'), jsonMsg = p.querySelector('.mmth-cf-jsonmsg'), jsonRow = p.querySelector('.mmth-cf-jsonrow'), modeBtn = p.querySelector('.mmth-cf-mode'), addBtn = p.querySelector('.mmth-cf-add'), resetBtn = p.querySelector('.mmth-cf-reset'); const cfOut = cf => { const o = { selector: cf.match || '' }; if (cf.label) o.label = cf.label; if (cf.dx != null && cf.dx !== '') o.deltax = +cf.dx; if (cf.dv) o.deltav = +cf.dv; if (cf.submit) o.submit = true; if (cf.mbid) o.mbid = true; if (cf.enable === false) o.enable = false; return o; }; // key is derived from the label; `submit` submits the field's form after recall (row ↵ checkbox); `mbid` JSON-only; `enable:false` disables const cfToJson = () => JSON.stringify((SET.customFields || []).map(cfOut), null, 2); const cfFromJson = txt => { const arr = JSON.parse(txt.replace(/,(\s*[}\]])/g, '$1')); // tolerate trailing commas if (!Array.isArray(arr)) throw new Error('expected a JSON array'); return arr.filter(o => o && typeof o === 'object').map(o => ({ match: String(o.selector != null ? o.selector : (o.match || '')).trim(), label: o.label ? String(o.label) : '', dx: (o.deltax != null && o.deltax !== '') ? +o.deltax : ((o.dx != null && o.dx !== '') ? +o.dx : ''), dv: (o.deltav != null && o.deltav !== '') ? +o.deltav : ((o.dv != null && o.dv !== '') ? +o.dv : ''), submit: !!(o.submit || o.enter), mbid: !!o.mbid, enable: o.enable !== false, // legacy `enter` → submit })).filter(c => c.match); }; const applyJson = () => { try { SET.customFields = cfFromJson(jsonTa.value); jsonMsg.textContent = `${SET.customFields.length} field(s) applied`; jsonMsg.classList.remove('mmth-cf-bad'); renderFields(); cfApply(); return true; } catch (e) { jsonMsg.textContent = 'Invalid JSON: ' + (e.message || e); jsonMsg.classList.add('mmth-cf-bad'); return false; } }; const setJsonMode = on => { if (on) { const h = cfList.offsetHeight; if (h > 0) jsonTa.style.height = h + 'px'; } // match the visual list's height (measure while it's still shown) cfList.style.display = addBtn.style.display = resetBtn.style.display = on ? 'none' : ''; jsonTa.style.display = jsonRow.style.display = on ? '' : 'none'; modeBtn.textContent = on ? '▤ Visual' : '{ } JSON'; if (on) { jsonTa.value = cfToJson(); jsonMsg.textContent = ''; jsonMsg.classList.remove('mmth-cf-bad'); } }; modeBtn.onclick = () => { const toJson = modeBtn.textContent.indexOf('JSON') >= 0; if (toJson) setJsonMode(true); else if (applyJson()) setJsonMode(false); }; p.querySelector('.mmth-cf-jsonapply').onclick = applyJson; // ── Import / Export pane ── (#304/#309: scoped to `io` — edit-note notes by // default, or a specific field's values when opened from a baby field) const ctx = io || { items: () => DATA.saved.map(s => s.text), add: notes => addSavedNotes(notes), help: 'Import adds to your saved notes; Export copies them all to the clipboard.' }; const ioTa = p.querySelector('.mmth-io-ta'), ioMsg = p.querySelector('.mmth-io-msg'); const ioBlock = () => p.querySelector('input[name="mmth-iomode"]:checked').value === 'block'; p.querySelector('.mmth-io-help').textContent = ctx.help || ''; p.querySelector('.mmth-io-import').onclick = () => { const notes = parseNotes(ioTa.value, ioBlock()); if (!notes.length) { ioMsg.textContent = 'Paste some notes first'; return; } const added = ctx.add(notes); ioMsg.textContent = `Added ${added} of ${notes.length}` + (added < notes.length ? ' (rest were duplicates)' : ''); if (added) ioTa.value = ''; }; p.querySelector('.mmth-io-export').onclick = async () => { const items = ctx.items(); const text = items.join(ioBlock() ? '\n\n' : '\n'); ioTa.value = text; ioTa.focus(); ioTa.select(); let copied = false; try { await navigator.clipboard.writeText(text); copied = true; } catch (e) { try { copied = document.execCommand('copy'); } catch (x) {} } ioMsg.textContent = `${items.length} item(s)` + (copied ? ' — copied to clipboard' : ' — select & copy'); }; // open on the explicitly-requested tab (baby field → 'io'), else the last tab used, else Settings const TABS = ['settings', 'fields', 'io']; showTab(TABS.includes(tab) ? tab : (TABS.includes(SET.lastTab) ? SET.lastTab : 'settings')); placePop(p, anchor); // position once; tab switches no longer move it } function openSyntax(anchor) { closePop(); const p = document.createElement('div'); p.className = 'mmth-pop'; p.innerHTML = ` <h4>Edit-note syntax<a href="${SYNTAX_URL}" target="_blank" rel="noopener" title="Full documentation">doc ↗</a></h4> <div class="mmth-syn"> <code>''italic''</code><span><i>italic</i></span> <code>'''bold'''</code><span><b>bold</b></span> <code>edit #123456</code><span>link to an edit</span> <code>doc:Page</code><span>or <code>[Page_Name]</code> — wiki doc link</span> </div> <div style="color:var(--mbu-text-dim)">URLs become links automatically. HTML is not supported.</div> <div class="mmth-sub">Shortcuts</div> <div class="mmth-syn"> <code>Ctrl/⌘ B</code><span>bold the selection / word</span> <code>Ctrl/⌘ I</code><span>italicise the selection / word</span> <code>Ctrl/⌘ ↑/↓</code><span>cycle saved notes</span> </div>`; document.body.appendChild(p); pop = p; placePop(p, anchor); } // ── sidebars (one per edit-note textarea) ──────────────────────────────────── const instances = []; function applyHelp() { document.querySelectorAll('.editnote').forEach(en => mbuCls(en, 'mmth-hidehelp', !!SET.hideHelp)); } const after = (e, el) => (e.clientY - el.getBoundingClientRect().top) > el.offsetHeight / 2; const clearMarks = host => host && host.querySelectorAll('.mmth-drop-before,.mmth-drop-after').forEach(r => r.classList.remove('mmth-drop-before', 'mmth-drop-after')); let _drag = null; const MIN_PANEL = 300; // #304: keep the toolbar (incl. ⚙) from clipping when narrow function setSideWidth(side, w) { w = Math.max(MIN_PANEL, Math.min(640, Math.round(w))); side.style.flex = '0 0 ' + w + 'px'; side.style.maxWidth = w + 'px'; return w; } // #263: never let the panel be wider than the field — cap it to half the row so // the ratio is at most 1:1 (was up to ~1:10 in a narrow Art Station modal). The // cap reads ONLY the wrap's width, which the container fixes and setting the // panel never changes, so this can't oscillate (unlike a field-width-based cap, // which would: shrinking the panel grows the field, re-raising the cap…). function capPanel(wrap, vsep, side) { if (SET.minimized) return; // panel is out of flow when minimized const row = wrap.clientWidth - (vsep ? vsep.offsetWidth : 0); if (!(row > 0)) return; const max = Math.floor(row / 2); const want = Math.max(MIN_PANEL, Math.min(SET.sideWidth || 300, max)); if (Math.round(side.getBoundingClientRect().width) !== want) { side.style.flex = '0 0 ' + want + 'px'; side.style.maxWidth = want + 'px'; } } // ── minimized mode (#265) ───────────────────────────────────────────────────── // A less-intrusive mode: the panel collapses to a small Mammoth badge in the // field's top-right corner and floats back in on hover. Persisted, so it stays // minimized across edit pages. WIDTH/position only — never touches the field's // height, so it can't reintroduce the #245 growth loop. function applyMinState(inst) { const wrap = inst.ta && inst.ta.closest('.mmth-wrap'); if (!wrap) return; const on = !!SET.minimized; wrap.classList.toggle('mmth-min', on); if (inst.minBtn) { inst.minBtn.textContent = on ? '⤢' : '–'; inst.minBtn.title = on ? 'Restore the panel' : 'Minimize to corner'; } if (on) { try { inst.ta.style.minHeight = ''; } catch (x) {} } // drop the panel-height floor — panel is out of flow now if (!on) { if (inst.unpin) inst.unpin(); if (inst.side) inst.side.classList.remove('mmth-open'); } } function setMinimized(on) { SET.minimized = !!on; persistSet(); instances.forEach(i => { applyMinState(i); if (i.recap) i.recap(); }); } // drag the separator to resize the panel vs. the field (persisted) function wireResize(vsep, side) { let startX = 0, startW = 0, on = false; vsep.addEventListener('pointerdown', e => { on = true; startX = e.clientX; startW = side.getBoundingClientRect().width; try { vsep.setPointerCapture(e.pointerId); } catch (x) {} vsep.classList.add('mmth-dragv'); document.body.style.userSelect = 'none'; e.preventDefault(); }); vsep.addEventListener('pointermove', e => { // panel is on the right → drag left widens it; cap at half the row (#263) if (!on) return; const wrap = side.parentNode, row = (wrap ? wrap.clientWidth : 0) - vsep.offsetWidth; const max = row > 0 ? Math.floor(row / 2) : 640; setSideWidth(side, Math.min(max, startW - (e.clientX - startX))); }); const end = e => { if (!on) return; on = false; vsep.classList.remove('mmth-dragv'); document.body.style.userSelect = ''; SET.sideWidth = setSideWidth(side, side.getBoundingClientRect().width); saveSet(); try { vsep.releasePointerCapture(e.pointerId); } catch (x) {} }; vsep.addEventListener('pointerup', end); vsep.addEventListener('pointercancel', end); } function buildSide(ta) { const side = document.createElement('div'); side.className = 'mmth-side'; setSideWidth(side, SET.sideWidth || 300); const ft = document.createElement('div'); ft.className = 'mmth-ft'; // toolbar ON TOP (#212) const filterRow = document.createElement('div'); filterRow.className = 'mmth-filterrow'; // #304 search (opt-in) const list = document.createElement('div'); list.className = 'mmth-list'; const pinbar = document.createElement('div'); pinbar.className = 'mmth-pinbar'; // #304 pinned quick-buttons — placed BELOW the field (in injectAll) side.appendChild(ft); side.appendChild(filterRow); side.appendChild(list); const inst = { ta, list, side, pinbar, filterRow, view: 'saved', cycId: null, filter: '', viewItems: [] }; instances.push(inst); // search box + N/total count (always shown) const fInput = document.createElement('input'); fInput.type = 'text'; fInput.className = 'mmth-filter'; fInput.placeholder = 'Search notes…'; fInput.addEventListener('input', () => { inst.filter = fInput.value; inst.cycId = null; renderInst(inst); }); fInput.addEventListener('keydown', e => { if (e.key === 'Escape') { fInput.value = ''; inst.filter = ''; inst.cycId = null; renderInst(inst); ta.focus(); reMinIfRestored(); return; } // #304: backing out re-minimizes if we'd auto-restored // #304: ↑/↓ move the highlighted match; Enter uses the highlighted one (or the first) if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { const items = inst.viewItems || []; if (!items.length) return; e.preventDefault(); const dir = e.key === 'ArrowDown' ? 1 : -1; let i = items.findIndex(x => x.id === inst.cycId); if (i < 0) i = dir > 0 ? -1 : 0; i = (i + dir + items.length) % items.length; inst.cycId = items[i].id; renderInst(inst); const cur = inst.list.querySelector('.mmth-cyc'); // keep the selection visible within the list if (cur) { const lr = inst.list.getBoundingClientRect(), cr = cur.getBoundingClientRect(); if (cr.top < lr.top) inst.list.scrollTop -= (lr.top - cr.top); else if (cr.bottom > lr.bottom) inst.list.scrollTop += (cr.bottom - lr.bottom); } return; } if (e.key === 'Enter') { e.preventDefault(); const items = inst.viewItems || []; const it = items.find(x => x.id === inst.cycId) || items[0]; if (it) { applyNote(ta, it.text, SET.defaultInsert === 'replace'); if (inst.view === 'saved') bumpUse(it.id); } } }); const fCount = document.createElement('span'); fCount.className = 'mmth-count'; inst.countEl = fCount; filterRow.appendChild(fInput); filterRow.appendChild(fCount); inst.filterInput = fInput; const fb = (glyph, title, cls, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-fb' + (cls ? ' ' + cls : ''); b.textContent = glyph; b.title = title; b.onclick = fn; return b; }; ft.appendChild(fb('+', 'Save current edit note', '', () => { const v = (ta.value || '').trim(); if (!v) return toast('Edit note is empty'); toast(addSaved(v) ? 'Saved' : 'Already saved'); })); const bSaved = fb('★', 'Saved notes', 'mmth-grp', () => { inst.view = 'saved'; renderInst(inst); }); const bHist = fb('🕘', 'History (last used)', '', () => { inst.view = 'history'; renderInst(inst); }); ft.appendChild(bSaved); ft.appendChild(bHist); ft.appendChild(fb('✕', 'Clear the edit note', 'mmth-grp', () => { setValue(ta, ''); ta.focus(); })); const sp = document.createElement('span'); sp.className = 'mmth-fb mmth-spacer'; ft.appendChild(sp); // #309: which edit-note type these notes belong to (notes are kept separate per type) const scopeChip = document.createElement('span'); scopeChip.className = 'mmth-scope'; scopeChip.textContent = scopeLabel(SCOPE); scopeChip.title = 'Saved notes & history are kept separate per edit-note type — these are your "' + scopeLabel(SCOPE) + '" notes (#309)'; scopeChip.style.display = SET.scopePerResource ? '' : 'none'; // only shown when scoping is on ft.appendChild(scopeChip); inst.minBtn = fb('–', 'Minimize to corner', 'mmth-min-btn', () => { restoredFromMin = false; setMinimized(!SET.minimized); }); // #265: left of the ? button ft.appendChild(inst.minBtn); ft.appendChild(fb('?', 'Edit-note syntax', 'mmth-pop-anchor', e => openSyntax(e.currentTarget))); ft.appendChild(fb(MBU_CFG_ICON, 'Settings', 'mmth-pop-anchor', e => openSettings(e.currentTarget))); inst.tabs = { saved: bSaved, history: bHist }; ta.addEventListener('keydown', e => { if (!(e.ctrlKey || e.metaKey)) return; const k = e.key.toLowerCase(); // Ctrl/⌘+B / +I wrap the selection in MB edit-note bold / italic markup if (k === 'b' || k === 'i') { e.preventDefault(); wrapSel(ta, k === 'b' ? "'''" : "''"); return; } // Ctrl/⌘+↑/↓ cycle through saved notes, replacing the field (focus stays here) if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; if (inst.view !== 'saved') { inst.view = 'saved'; inst.tabs && inst.tabs.saved.classList.add('on'); inst.tabs && inst.tabs.history.classList.remove('on'); renderInst(inst); } // cycle through exactly what's shown (respects the current sort + filter) #304 const items = inst.viewItems; if (!items || !items.length) return; e.preventDefault(); const dir = e.key === 'ArrowDown' ? 1 : -1; let i = items.findIndex(x => x.id === inst.cycId); if (i < 0) i = dir > 0 ? -1 : 0; i = (i + dir + items.length) % items.length; inst.cycId = items[i].id; setValue(ta, items[i].text); renderInst(inst); // keep the highlighted item visible — scroll WITHIN the list only (not the page) const cur = inst.list.querySelector('.mmth-cyc'); if (cur) { const lr = inst.list.getBoundingClientRect(), cr = cur.getBoundingClientRect(); if (cr.top < lr.top) inst.list.scrollTop -= (lr.top - cr.top); else if (cr.bottom > lr.bottom) inst.list.scrollTop += (cr.bottom - lr.bottom); } // setValue can trigger a React re-render on the release editor that steals // focus; re-assert it now AND after the re-render so the editor stays focused. const refocus = () => { try { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } catch (x) {} }; refocus(); requestAnimationFrame(refocus); setTimeout(refocus, 0); }); renderInst(inst); return inst; } // #304: pinned saved notes shown as one-click quick-insert buttons BELOW the // edit-note field (like baby-field bars) — always available, independent of view. function renderPinbar(inst) { const bar = inst.pinbar; bar.innerHTML = ''; const pinned = DATA.saved.filter(s => s.pinned); if (!pinned.length) { bar.style.display = 'none'; return; } bar.style.display = 'flex'; const n = Math.max(4, Math.min(80, SET.btnChars | 0 || 24)); const cap = t => { t = t.replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t; }; const dflt = SET.defaultInsert; pinned.forEach(it => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-segb'; b.textContent = cap(it.text); b.style.maxWidth = (n + 1) + 'ch'; b.title = it.text + `\n\n(click: ${dflt} · right-click: ${dflt === 'replace' ? 'append' : 'replace'} · Ctrl/⌘-click: replace + submit)`; b.onclick = e => { e.preventDefault(); // #328: Ctrl/⌘-click = replace the note and submit the edit (parity with Ctrl/⌘+Enter; // shift was prone to text selection). Same as a list row. if (e.ctrlKey || e.metaKey) { applyNote(inst.ta, it.text, true); bumpUse(it.id); const sb = findSubmitBtn(inst.ta); if (sb) sb.click(); return; } applyNote(inst.ta, it.text, dflt === 'replace'); bumpUse(it.id); }; b.oncontextmenu = e => { e.preventDefault(); applyNote(inst.ta, it.text, dflt !== 'replace'); bumpUse(it.id); }; bar.appendChild(b); }); } function renderInst(inst) { const { ta, list } = inst; list.style.maxHeight = (Math.max(1, Math.min(30, SET.visibleRows | 0 || 6)) * 26) + 'px'; // show N items, then scroll (#212) list.innerHTML = ''; if (inst.tabs) { inst.tabs.saved.classList.toggle('on', inst.view === 'saved'); inst.tabs.history.classList.toggle('on', inst.view === 'history'); } const saved = inst.view === 'saved'; renderPinbar(inst); // the search box is always shown inst.filterRow.style.display = 'flex'; // sort (Saved, from config) then search-filter on the full note text; show N/total const all = saved ? sortedSaved() : DATA.history; const q = (inst.filter || '').trim().toLowerCase(); const items = q ? all.filter(it => it.text.toLowerCase().includes(q)) : all; inst.viewItems = items; // Ctrl+↑/↓ and Enter-in-search use exactly what's shown if (inst.countEl) inst.countEl.textContent = q ? (items.length + ' / ' + all.length) : (all.length ? String(all.length) : ''); // drag-reorder only makes sense in the unfiltered manual order const manual = saved && (SET.noteSort || 'manual') === 'manual' && !q; if (!items.length) { const e = document.createElement('div'); e.className = 'mmth-empty'; e.textContent = all.length ? 'No notes match the search' : (saved ? 'No saved notes — + saves the current one' : 'No history yet'); list.appendChild(e); return; } items.forEach((it) => { const row = document.createElement('div'); row.className = 'mmth-row'; if (saved && it.pinned) row.classList.add('mmth-pinned'); if (it.id === inst.cycId) row.classList.add('mmth-cyc'); // arrow-key / cycle selection (both views) #304 const dflt = SET.defaultInsert; row.title = it.text + `\n\n(click: ${dflt} · right-click: ${dflt === 'replace' ? 'append' : 'replace'} · Ctrl/⌘-click: set + submit)`; const txt = document.createElement('span'); txt.className = 'mmth-txt'; txt.textContent = it.text.replace(/\s+/g, ' ').trim(); row.appendChild(txt); // right-side hover actions: pin/unpin + delete (saved); save + remove (history) const acts = document.createElement('div'); acts.className = 'mmth-rowacts'; const ra = (glyph, title, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-ra'; b.textContent = glyph; b.title = title; b.onclick = e => { e.stopPropagation(); fn(); }; acts.appendChild(b); }; if (saved) { ra(it.pinned ? '★' : '☆', it.pinned ? 'Unpin from quick buttons' : 'Pin as a quick button', () => togglePinNote(it.id)); ra('🗑', 'Delete', () => removeSaved(it.id)); } else { ra('★', 'Save (pin to Saved)', () => { if (addSaved(it.text)) toast('Saved'); }); ra('🗑', 'Remove', () => removeHistory(it.text)); } row.appendChild(acts); if (manual) { const grab = document.createElement('span'); grab.className = 'mmth-grab'; grab.textContent = '⠿'; grab.title = 'Drag to reorder'; grab.draggable = true; grab.addEventListener('dragstart', e => { _drag = { id: it.id }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', 'row'); } catch (x) {} row.classList.add('mmth-dragging'); }); grab.addEventListener('dragend', () => { row.classList.remove('mmth-dragging'); clearMarks(list); _drag = null; }); row.appendChild(grab); } row.onclick = e => { // #289/#328: Ctrl/⌘-click sets the note (replace) AND submits the edit — parity // with Ctrl/⌘+Enter (reuses findSubmitBtn); shift was prone to text selection. if (e.ctrlKey || e.metaKey) { applyNote(ta, it.text, true); if (saved) bumpUse(it.id); const b = findSubmitBtn(ta); if (b) b.click(); return; } applyNote(ta, it.text, SET.defaultInsert === 'replace'); if (saved) bumpUse(it.id); }; row.oncontextmenu = e => { e.preventDefault(); applyNote(ta, it.text, SET.defaultInsert !== 'replace'); if (saved) bumpUse(it.id); }; if (manual) { row.addEventListener('dragover', e => { if (!_drag) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearMarks(list); row.classList.add(after(e, row) ? 'mmth-drop-after' : 'mmth-drop-before'); }); row.addEventListener('dragleave', () => row.classList.remove('mmth-drop-before', 'mmth-drop-after')); row.addEventListener('drop', e => { if (!_drag) return; e.preventDefault(); reorder(_drag.id, it.id, !after(e, row)); clearMarks(list); _drag = null; }); } list.appendChild(row); }); } function render() { instances.forEach(i => { if (i.list.isConnected) renderInst(i); }); } // ── attach ─────────────────────────────────────────────────────────────────── function injectAll() { applyHelp(); document.querySelectorAll('textarea.edit-note').forEach(ta => { if (ta.dataset.mmth) return; ta.dataset.mmth = '1'; const en = ta.closest('.editnote'); if (en) en.classList.add('mmth-on'); // hides the redundant inline "Edit note:" label (#212) const wrap = document.createElement('div'); wrap.className = 'mmth-wrap'; ta.parentNode.insertBefore(wrap, ta); // #304: the textarea and its pinned-quick-buttons bar share one column, so the // bar always sits directly below the field and tracks its left edge / width, // whatever the surrounding form layout does. const fieldCol = document.createElement('div'); fieldCol.className = 'mmth-fieldcol'; wrap.appendChild(fieldCol); fieldCol.appendChild(ta); // remember the textarea height the user sets with the native resize grip (vertical); // the splitter (below) remembers the field/panel split (horizontal). if (SET.taHeight) ta.style.height = SET.taHeight + 'px'; // Persist ONLY a deliberate user resize (height changes between mouse down // and up on the field). The old ResizeObserver fired on any layout-driven // size change too, so visiting a differently-sized edit page (e.g. the // full-width release editor) silently overwrote the saved height. let _downH = null; ta.addEventListener('mousedown', () => { _downH = ta.offsetHeight; }); window.addEventListener('mouseup', () => { if (_downH == null) return; const h = ta.offsetHeight; const was = _downH; _downH = null; if (h > 40 && h !== was && h !== SET.taHeight) { SET.taHeight = h; ta.style.height = h + 'px'; persistSet(); } }); const vsep = document.createElement('div'); vsep.className = 'mmth-vsep'; vsep.title = 'Drag to resize'; wrap.appendChild(vsep); // resizable separator between field & panel (#212) const inst = buildSide(ta); const side = inst.side; wrap.appendChild(side); fieldCol.appendChild(inst.pinbar); // #304: pinned quick-buttons directly below the field, in its column wireResize(vsep, side); // #265 minimized mode: badge in the field's top-right corner; hover (or click // to pin) floats the panel back in. mouseleave closes after a short grace. const badge = document.createElement('div'); badge.className = 'mmth-badge'; badge.title = 'Mammoth — saved notes (click or hover)'; badge.innerHTML = MAMMOTH_SVG; // #308 vector, not the 🦣 emoji wrap.appendChild(badge); inst.badge = badge; // #288/#290: MB (and other scripts, e.g. jesus2099's MERGE HELPOR) insert // edit-note error/warning <p>s as siblings of the textarea — which now lives // inside our flex .mmth-wrap, so they'd be laid out BESIDE the field. Keep the // wrap a clean row (field | sep | panel | badge) and relocate any foreign child // OUT to normal flow: warnings above the wrap, validation/other notices below. // (Doing this in JS instead of flex-wrap avoids the panel itself wrapping below // the field in a narrow left-column layout — #290.) // #304: the textarea now lives in .mmth-fieldcol, so MB inserts those <p>s into // fieldCol (not wrap) — observe both, and treat fieldCol/ta/pinbar as ours. const isOurs = el => el === ta || el === inst.pinbar || (el.classList && (el.classList.contains('mmth-fieldcol') || el.classList.contains('mmth-vsep') || el.classList.contains('mmth-side') || el.classList.contains('mmth-badge'))); const relocateForeign = node => { if (!node || node.nodeType !== 1 || isOurs(node) || !wrap.parentNode) return; const above = node.classList && node.classList.contains('error') && !node.classList.contains('invalid'); wrap.parentNode.insertBefore(node, above ? wrap : wrap.nextSibling); }; [...wrap.children, ...fieldCol.children].forEach(relocateForeign); const fObs = muts => muts.forEach(m => m.addedNodes.forEach(relocateForeign)); new MutationObserver(fObs).observe(wrap, { childList: true }); new MutationObserver(fObs).observe(fieldCol, { childList: true }); let closeT = null, pinned = false; const openFloat = () => { clearTimeout(closeT); if (SET.minimized) side.classList.add('mmth-open'); }; const closeFloat = () => { clearTimeout(closeT); if (pinned) return; closeT = setTimeout(() => side.classList.remove('mmth-open'), 220); }; badge.addEventListener('mouseenter', openFloat); badge.addEventListener('mouseleave', closeFloat); side.addEventListener('mouseenter', openFloat); side.addEventListener('mouseleave', closeFloat); // click the badge to pin the panel open (so it survives mouse-out); click again to unpin badge.addEventListener('click', () => { if (!SET.minimized) return; pinned = !pinned; pinned ? openFloat() : side.classList.remove('mmth-open'); }); inst.unpin = () => { pinned = false; }; applyMinState(inst); // #263: keep the panel ≤ half the row (never wider than the field). Driven by // the WRAP's width only — stable, no feedback loop. const cap = () => capPanel(wrap, vsep, side); cap(); requestAnimationFrame(cap); setTimeout(cap, 200); try { new ResizeObserver(cap).observe(wrap); } catch (x) {} inst.recap = cap; // The saved-notes panel's height (driven by the Items Shown setting) is the // field's floor, so it's never shorter than the sidebar. With no user-saved // height the field STARTS at exactly that height too — so its initial size // tracks Items Shown — until the user drags the grip (which is remembered). const syncFloor = () => { try { // Minimized: the panel floats out of flow, so the field needs no floor — // applying one here while the panel shows on hover would couple field // height to panel height (the #245 loop). Clear it and bail. if (SET.minimized) { if (ta.style.minHeight) ta.style.minHeight = ''; return; } const h = side.offsetHeight; if (!(h > 0)) return; if ((parseInt(ta.style.minHeight, 10) || 0) !== h) ta.style.minHeight = h + 'px'; if (!SET.taHeight && (parseInt(ta.style.height, 10) || 0) !== h) ta.style.height = h + 'px'; vsep.style.height = ta.offsetHeight + 'px'; // #304: separator spans only the field, not the field+pinbar column } catch (x) {} }; syncFloor(); requestAnimationFrame(syncFloor); setTimeout(syncFloor, 150); setTimeout(syncFloor, 600); // catch the sidebar's final layout try { new ResizeObserver(syncFloor).observe(side); new ResizeObserver(syncFloor).observe(ta); } catch (x) {} }); } // Toggle the "an MB dialog is open" flag so Mammoth's floating UI (main panel // badge/pop + babies) hides under it — MB's dialogs/popovers carry no z-index // and no backdrop, so they can't otherwise win the stack. Global (runs even // when babies are off). #313 // #333/#397/#config: a dialog (.dialog.popover / .relationship-dialog) whose fields we pin must NOT be // treated as blocking, or we'd hide the very baby it hosts. That was a hardcoded allowlist (artist-credit // rows, then the "Task" field) — generalise it: a dialog blocks only if it contains NONE of our baby // fields (`[data-mmthf]`, set by scan() on every pinned field, custom ones included). This is what let // instrument-type relationship dialogs — bass/guitar/… have no Task field — wrongly hide a custom // "Credited as" baby while producer/mixer (which DO have Task) worked. Fields BEHIND a dialog are already // hidden per-pin by fieldOnTop (elementFromPoint), so keeping non-blocking here can't float them over it. const syncDialog = () => { const dlgs = [...document.querySelectorAll('.dialog.popover, .relationship-dialog')]; const blocking = dlgs.some(d => !d.querySelector('[data-mmthf]')); // #569: guarded. These fire from a document-wide subtree observer, so an // unconditional toggle on <html> re-triggers every other script's root // watcher, which mutates, which brings us back here — the loop chaban // measured at 12 root mutations a second on an idle page. mbuCls(document.documentElement, 'mmthf-dialog', blocking); // baby pins: hide only when a dialog hosts none of ours mbuCls(document.documentElement, 'mmthf-anydialog', dlgs.length > 0); // main panel/badge: hide under ANY open dialog (#400) }; // #462: MB's jQuery-UI autocomplete menu (ul.ui-autocomplete) opens by toggling `display` // — a style mutation the childList observer above never sees — so watch each menu's attrs // directly and flag `mmthf-acopen` while any is visible, which hides overlapping babies. const syncAc = () => { const open = [...document.querySelectorAll('ul.ui-autocomplete')].some(u => u.offsetParent !== null && getComputedStyle(u).display !== 'none'); mbuCls(document.documentElement, 'mmthf-acopen', open); }; const acObs = new MutationObserver(syncAc); const watchAcMenus = () => { document.querySelectorAll('ul.ui-autocomplete').forEach(u => { if (!u._mmthfAc) { u._mmthfAc = 1; acObs.observe(u, { attributes: true, attributeFilter: ['style', 'class'] }); } }); syncAc(); }; // #623 (sweep, M1): this observer sees every node on every MusicBrainz page, and each // mutation re-queried the edit notes, dialogs and autocomplete menus (a // getComputedStyle per menu) — hundreds of times while a page renders in bursts. Now a // burst runs it once: the first mutation schedules a run 60 ms later, the rest join // it. A timer, not requestAnimationFrame, which never fires in a background tab — and // background edit tabs (other scripts' "add in the background") need the defaults too. let _mmthPending = 0; const onPageMutation = () => { if (_mmthPending) return; _mmthPending = setTimeout(() => { _mmthPending = 0; injectAll(); syncDialog(); watchAcMenus(); }, 60); }; new MutationObserver(onPageMutation).observe(document.documentElement, { childList: true, subtree: true }); syncDialog(); watchAcMenus(); // #252 Ctrl/Cmd+Enter submits the edit. The submit control differs per page, so // look in order: the release editor's "Enter edit" button, then the edit form's // own submit, then a visible button labelled Enter edit / Submit / Finish. Only // act when focus is in the edit-note field or Mammoth's panel (or nowhere), so it // never hijacks Ctrl+Enter in some unrelated field. const isVisible = b => !!(b && b.offsetParent !== null && !b.disabled); function findSubmitBtn(ta) { const re = document.getElementById('enter-edit'); if (isVisible(re)) return re; const form = ta && ta.closest('form'); if (form) { const s = form.querySelector('button.submit, button[type="submit"], button.positive'); if (isVisible(s)) return s; } return [...document.querySelectorAll('button')].find(b => isVisible(b) && /^\s*(enter edit|submit|finish)\b/i.test(b.textContent || '')) || null; } document.addEventListener('keydown', e => { if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; const ta = document.querySelector('textarea.edit-note'); if (!ta) return; const t = e.target; if (t !== ta && !(t && t.closest && t.closest('.mmth-wrap, .mmth-side, .mmth-pop')) && t !== document.body) return; const btn = findSubmitBtn(ta); if (btn) { e.preventDefault(); btn.click(); } }); // #304: Ctrl/Cmd+, focuses the note search box (the panel nearest the focused // field, else the first visible one). When the panel is minimized, restore it // first so the box is reachable. No-op when search is off. document.addEventListener('keydown', e => { if (e.key !== ',' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; const onScreen = () => [...document.querySelectorAll('.mmth-filter')].filter(i => i.offsetParent !== null); let visible = onScreen(); if (!visible.length) { // search box is hidden because the panel is minimized — restore and retry if (SET.minimized) { setMinimized(false); restoredFromMin = true; visible = onScreen(); } if (!visible.length) return; } const wrap = e.target && e.target.closest && e.target.closest('.mmth-wrap'); const near = wrap && wrap.querySelector('.mmth-filter'); const target = (near && near.offsetParent !== null) ? near : visible[0]; e.preventDefault(); target.focus(); target.select(); }); injectAll(); if (SET.showBabies !== false) babyMammoths.start(); // ════════════════════════════════════════════════════════════════════════════ // BABY MAMMOTHS — field memory for arbitrary input controls // Self-contained (own storage key, own CSS, own DOM), gated by SET.showBabies. // start()/stop() let the ⚙ toggle add/remove it cleanly at runtime. // ════════════════════════════════════════════════════════════════════════════ function createBabyMammoths() { const FKEY = 'mammoth-fields:data'; // { [fieldKey]: [{ v, label, ts, pinned?, default? }] } const MAX_PER_FIELD = 25; // #296 follow-up — capture the selected ENTITY's MBID for autocomplete fields, // so a saved Label/Artist resolves the real entity on recall (writeField pastes // the MBID, which MB resolves) instead of being text-only. The gid comes from // the live release editor model; unsafeWindow reaches the page's MB from the // userscript sandbox. Falls back to text when nothing is selected / off-editor. const PAGEWIN = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; const relEntity = () => { try { return PAGEWIN.MB.releaseEditor.rootField.release(); } catch (e) { return null; } }; const labelGid = el => { const i = +((String(el.id).match(/-(\d+)$/) || [])[1] || 0); const r = relEntity(); const labs = r && r.labels && r.labels(); const L = labs && labs[i] && labs[i].label && labs[i].label(); return (L && L.gid) || null; }; const _unwrap = x => (typeof x === 'function' ? x() : x); const artistGid = () => { // single-artist box only: exactly one real artist (names has a trailing blank) const r = relEntity(); const ac = r && r.artistCredit && r.artistCredit(); let ns = ac && ac.names; ns = _unwrap(ns); if (!ns) return null; const real = [...ns].map(n => _unwrap(n.artist)).filter(a => a && a.gid); return real.length === 1 ? real[0].gid : null; }; // a specific row of the artist-credit editor bubble (ac-source-artist-<i>) const artistRowGid = el => { const i = +((String(el.id).match(/-(\d+)$/) || [])[1] || 0); const r = relEntity(); const ac = r && r.artistCredit && r.artistCredit(); const ns = _unwrap(ac && ac.names); const a = ns && ns[i] && _unwrap(ns[i].artist); return (a && a.gid) || null; }; // MBID capture for the built-in Label/Artist fields (`mbid:true`). Reads the release editor's model for // the known label/artist inputs; a no-op on any other field (→ text). The generic probe is gone; this is // the specific labelGid/artistGid readers, exposed behind the flag only for the two fields where it works. const builtinGid = el => { const id = String(el.id || ''); if (/^label-\d+$/.test(id)) return labelGid(el); if (id === 'ac-source-single-artist') return artistGid(el); if (/^ac-source-artist-/.test(id)) return artistRowGid(el); return null; }; // Every baby field — built-in or user — comes from SET.customFields. The storage key is derived from the // LABEL (namespaced 'cf:'), so any two same-label fields share one saved list. `mbid:true` (JSON only) // wires the Label/Artist MBID reader; `submit:true` submits the field's form after a recall (see recallInto). const customDefs = () => (SET.customFields || []).filter(c => c && c.match && c.enable !== false).map(c => { // enable:false → not scanned (no pin) const key = String(c.label || '').trim() ? 'cf:' + String(c.label).trim() : null; // no label → keyFor derives one from the element return { match: c.match, key, label: c.label || '', dx: (c.dx != null && c.dx !== '') ? +c.dx : undefined, deltav: (c.dv | 0) || 0, submit: !!c.submit, gid: c.mbid ? builtinGid : null }; }); const loadF = () => { try { return JSON.parse(GM_getValue(FKEY, '{}') || '{}'); } catch (e) { return {}; } }; const saveF = () => { try { GM_setValue(FKEY, JSON.stringify(FDATA)); } catch (e) {} }; let FDATA = loadF(); const listFor = key => (FDATA[key] = FDATA[key] || []); function rememberValue(key, rec) { if (!rec || !rec.v) return false; const a = listFor(key); const e = a.find(x => x.v === rec.v); if (e) { e.label = rec.label || e.label; e.ts = Date.now(); } // already saved → keep it (and its ★ / default / order) else { a.unshift({ v: rec.v, label: rec.label || rec.v, ts: Date.now() }); FDATA[key] = a.slice(0, MAX_PER_FIELD); } saveF(); return true; } const forgetValue = (key, v) => { FDATA[key] = listFor(key).filter(x => x.v !== v); saveF(); }; // edit a saved value in place (keeps its ★/default/order) — used to paste/append an MBID onto a value function editValue(key, oldV, newV) { newV = String(newV == null ? '' : newV).trim(); if (!newV) return false; const a = listFor(key); const e = a.find(x => x.v === oldV); if (!e) return false; if (newV !== oldV) { const other = a.findIndex(x => x.v === newV); if (other >= 0 && a[other] !== e) a.splice(other, 1); } // dedup a collision e.v = newV; e.label = newV; e.ts = Date.now(); saveF(); return true; } const togglePin = (key, v) => { const e = listFor(key).find(x => x.v === v); if (e) { e.pinned = !e.pinned; saveF(); } }; function setDefault(key, v) { const a = listFor(key); const e = a.find(x => x.v === v); if (!e) return; const was = e.default; a.forEach(x => x.default = false); e.default = !was; saveF(); } const defaultOf = key => listFor(key).find(x => x.default); // drag-reorder a saved value relative to another (like the edit-note panel's ⠿) function reorder(key, srcV, tgtV, before) { if (srcV === tgtV) return; const a = listFor(key); const si = a.findIndex(x => x.v === srcV); if (si < 0) return; const [it] = a.splice(si, 1); let ti = a.findIndex(x => x.v === tgtV); if (ti < 0) { a.splice(si, 0, it); return; } a.splice(before ? ti : ti + 1, 0, it); saveF(); } // #304: caption = the full label, truncated with an ellipsis at the configured // length (⚙ "Button label length") — no longer just the first word. const btnChars = () => Math.max(4, Math.min(80, SET.btnChars | 0 || 24)); // Strip an MBID (a bare GUID, or a full musicbrainz.org URL ending in one) from a value for DISPLAY only. // The stored value + export keep it, because saving "name <mbid>" lets an autocomplete resolve the entity // straight away (no search / no Enter) — but the raw id is ugly, so hide it in the rows and the buttons. const MMTH_MBID_RE = /\s*\(?(?:https?:\/\/(?:beta\.)?musicbrainz\.org\/[a-z-]+\/)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\)?\/?\s*/ig; const cleanLabel = s => { const raw = String(s == null ? '' : s); const t = raw.replace(MMTH_MBID_RE, ' ').replace(/\s{2,}/g, ' ').trim(); return t || raw.trim(); }; const captionOf = it => { if (it.cap) return it.cap; const n = btnChars(); const t = cleanLabel(it.label || it.v); return t.length > n ? t.slice(0, n) + '…' : t; }; const isSelect = el => el.tagName === 'SELECT'; const isAuto = el => el.classList.contains('ui-autocomplete-input') || el.classList.contains('lookup-performed'); function setNative(el, val) { const proto = isSelect(el) ? HTMLSelectElement.prototype : el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const d = Object.getOwnPropertyDescriptor(proto, 'value'); if (d && d.set) d.set.call(el, val); else el.value = val; } function readField(el) { if (isSelect(el)) { const o = el.options[el.selectedIndex]; return { v: el.value, label: o ? o.textContent.trim() : el.value }; } const v = el.value || ''; return { v, label: v.trim() }; } // What to STORE for the current value. Entity fields (p.gid) store the selected // MBID as the value with the visible name as the label, so recall resolves the // real entity; everything else (and unresolved/empty entity fields) stores text. function captureField(p) { const t = readField(p.el); if (!t.v || !p.gid) return t; let g; try { g = p.gid(p.el); } catch (e) {} return g ? { v: g, label: t.label || g } : t; } function writeField(el, rec) { if (isSelect(el)) { let opt = [...el.options].find(o => o.value === rec.v); if (!opt && rec.label) opt = [...el.options].find(o => o.textContent.trim() === rec.label.trim()); if (!opt) return false; setNative(el, opt.value); el.dispatchEvent(new Event('change', { bubbles: true })); return true; } setNative(el, rec.v); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); if (isAuto(el)) { el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'a' })); el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'a' })); } return true; } // Submit the field's enclosing <form> — the only reliable way to commit fields the browser only // submits on a real Enter (the tag box `<form id="tag-form">`, the header search form, …). A synthetic // KeyboardEvent can't do it (untrusted). requestSubmit() runs the form's submit handler + validation. const submitField = el => { const form = el.closest('form'); if (!form) return; // nothing to submit if (form.requestSubmit) { form.requestSubmit(); return; } const btn = form.querySelector('button[type="submit"], input[type="submit"]'); if (btn) { btn.click(); return; } try { form.submit(); } catch (e) {} }; // recall a saved value into a field. writeField pastes it ("name <mbid>" resolves the entity via the id). // A field flagged `submit` (row ↵ checkbox / JSON "submit": true) then submits its <form> ~200ms later. function recallInto(p, rec) { const ok = writeField(p.el, rec); if (ok && p.submit) { const el = p.el; setTimeout(() => { if (el.isConnected) { try { el.focus(); } catch (e) {} try { submitField(el); } catch (e) {} } }, 200); } return ok; } function clearField(el) { if (isSelect(el)) { const o = [...el.options].find(o => o.value === ''); if (!o) return; setNative(el, ''); el.dispatchEvent(new Event('change', { bubbles: true })); } else { setNative(el, ''); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } try { el.focus(); } catch (e) {} } // return focus to the field after an interactive apply (caret at end for text inputs) const focusField = el => { try { el.focus(); if (!isSelect(el) && el.setSelectionRange) { const n = (el.value || '').length; el.setSelectionRange(n, n); } } catch (e) {} }; const fLabelText = el => { const l = el.id && document.querySelector(`label[for="${CSS.escape(el.id)}"]`); return (l && l.textContent.trim().replace(/:$/, '')) || el.getAttribute('aria-label') || el.placeholder || ''; }; function keyFor(el, def) { if (def && def.key) return def.key; if (el.dataset.mmthKey) return 'k:' + el.dataset.mmthKey; const base = el.id ? el.id.replace(/-\d+$/, '') : (el.name || ''); return 'auto:' + (base || fLabelText(el).toLowerCase().replace(/\s+/g, '-') || 'field'); } const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const after = (e, el) => (e.clientY - el.getBoundingClientRect().top) > el.offsetHeight / 2; const clearMarks = host => host && host.querySelectorAll('.mmthf-drop-before,.mmthf-drop-after').forEach(r => r.classList.remove('mmthf-drop-before', 'mmthf-drop-after')); let pins = [], pop = null, mo = null, raf = 0, running = false, _fdrag = null, settleCap = 0; const listeners = []; function injectCss() { if (document.getElementById('mmthf-css')) return; const s = document.createElement('style'); s.id = 'mmthf-css'; s.textContent = ` .mmthf-pin { position:absolute; z-index:9998; width:16px; height:16px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:none; background:none; box-shadow:none; padding:0; font-size:13px; line-height:1; user-select:none; opacity:.35; transition:opacity .12s; filter:grayscale(.3); } .mmthf-pin:hover { opacity:1; filter:none; } .mmthf-pin.has { opacity:.8; filter:none; } /* #296: keep the overlays invisible while the release editor is still reflowing on load (so they don't visibly jump), then FADE them in once the layout goes quiet — instead of bumping into place. */ html.mmthf-settling .mmthf-pin, html.mmthf-settling .mmthf-bar { opacity:0 !important; pointer-events:none !important; } html.mmthf-fadein .mmthf-pin, html.mmthf-fadein .mmthf-bar { transition:opacity .4s ease !important; } /* MB's dialogs/popovers (Add/Edit relationship, …) carry no z-index, so our high-z babies would float on top of them and their dropdowns. Hide the babies while any MB dialog is open. */ html.mmthf-dialog .mmthf-pin, html.mmthf-dialog .mmthf-bar, html.mmthf-dialog .mmthf-pop { opacity:0 !important; pointer-events:none !important; } /* #462: MB's autocomplete menu (ul.ui-autocomplete @ z-index:100) drops down over the field's own pin/bar — our high-z babies would cover the results. Hide them while any lookup dropdown is open (it reappears the moment the menu closes). */ html.mmthf-acopen .mmthf-pin, html.mmthf-acopen .mmthf-bar { opacity:0 !important; pointer-events:none !important; } .mmthf-hl { outline:2px solid #5aa67e !important; outline-offset:1px; } .mmthf-bar { position:absolute; z-index:9996; display:none; } /* deltav>0: the bar is injected in-flow after an ancestor, so it takes real layout space (pushes the UI below it down) instead of floating over it. */ .mmthf-bar.mmthf-bar-inflow { position:static !important; z-index:auto; max-width:none !important; margin:5px 0 3px; } /* #304: individual rounded "tag" buttons that wrap to new rows — matches the main edit-note pins */ .mmthf-seg { display:flex; flex-wrap:wrap; gap:5px; max-width:100%; } .mmthf-segb { border:1px solid var(--mbu-border) !important; background:var(--mbu-bg) !important; border-radius:7px !important; padding:3px 10px !important; font:12px/1.2 -apple-system,Segoe UI,Arial,sans-serif !important; color:var(--mbu-ok) !important; cursor:pointer; max-width:200px; height:auto !important; min-height:0 !important; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.06); } .mmthf-segb:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmthf-pop { position:fixed; z-index:9999; background:var(--mbu-bg); border:1px solid var(--mbu-border); border-radius:8px; box-shadow:0 8px 26px rgba(20,50,35,.2); font:12px/1.35 -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-text); width:260px; max-width:calc(100vw - 12px); overflow:hidden; } .mmthf-ft { display:flex; align-items:center; gap:1px; padding:3px 5px; background:var(--mbu-bg-raised); border-bottom:1px solid var(--mbu-border); } .mmthf-fb { cursor:pointer; border:none; background:none; font-size:14px; line-height:1; padding:3px 7px; border-radius:5px; color:var(--mbu-text-dim); } .mmthf-fb:hover { background:var(--mbu-ok-bg); } .mmthf-fb[aria-disabled="true"] { color:var(--mbu-text-weak); cursor:default; background:none; } .mmthf-ft-title { flex:1 1 auto; min-width:0; text-align:center; font-weight:700; font-size:13px; color:var(--mbu-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; padding:0 4px; } .mmthf-filterrow { padding:5px 6px; border-bottom:1px solid var(--mbu-border); background:var(--mbu-bg); } .mmthf-filter { display:block; width:100%; box-sizing:border-box; border:1px solid var(--mbu-border); border-radius:5px; padding:3px 7px; font:12px -apple-system,Segoe UI,Arial,sans-serif; } .mmthf-filter:focus { outline:none; border-color:var(--mbu-ok); } .mmthf-row.mmthf-sel { background:var(--mbu-ok-bg); } .mmthf-list { max-height:240px; overflow-y:auto; } .mmthf-row { position:relative; display:flex; align-items:center; gap:6px; padding:5px 10px; border-top:1px solid var(--mbu-border); cursor:pointer; } .mmthf-row:first-child { border-top:none; } .mmthf-row:hover { background:var(--mbu-ok-bg); } .mmthf-rtxt { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; padding-right:42px; } /* stationary indicators at the right edge (★ rightmost so it stays aligned; ◉ when default). Non-interactive — no reserved hover slots. */ .mmthf-ind { position:absolute; right:10px; top:0; height:100%; display:flex; align-items:center; gap:5px; font-size:12px; color:var(--mbu-ok); pointer-events:none; } /* the full action toolbar OVERLAYS the right on hover — reserves no space when idle */ .mmthf-acts { position:absolute; right:5px; top:2px; bottom:2px; display:none; align-items:center; gap:1px; padding:0 3px 0 12px; border-radius:5px; background:var(--mbu-ok-bg); } .mmthf-row:hover .mmthf-acts { display:flex; } .mmthf-row:hover .mmthf-ind { display:none; } .mmthf-ra { width:18px; box-sizing:border-box; text-align:center; border:none; background:none; color:var(--mbu-text-dim); cursor:pointer; font-size:11px; padding:1px 0; border-radius:3px; } .mmthf-ra:hover { background:var(--mbu-ok-bg); color:var(--mbu-ok); } .mmthf-edit-in { flex:1 1 auto; min-width:0; box-sizing:border-box; border:1px solid var(--mbu-ok); border-radius:4px; padding:1px 5px; font:12px -apple-system,Segoe UI,Arial,sans-serif; } .mmthf-edit-in:focus { outline:none; } .mmthf-row.mmthf-editing .mmthf-acts, .mmthf-row.mmthf-editing .mmthf-ind { display:none !important; } .mmthf-grab { width:14px; text-align:center; cursor:grab; color:var(--mbu-text-weak); font-size:12px; user-select:none; } .mmthf-grab:active { cursor:grabbing; } .mmthf-row.mmthf-dragging { opacity:.45; } .mmthf-row.mmthf-drop-before { box-shadow:inset 0 2px 0 #2c7a51; } .mmthf-row.mmthf-drop-after { box-shadow:inset 0 -2px 0 #2c7a51; } .mmthf-empty { padding:10px; color:var(--mbu-text-weak); font-style:italic; text-align:center; } tr.mmthf-rrow > td { vertical-align:top; } /* keep sibling cells from dropping when a cell reserves strip space */ `; (document.head || document.documentElement).appendChild(s); } // highest z-index among a field's positioned ancestors — so a pin inside a high-z modal can sit just // above it rather than behind. 0 ⇒ keep the CSS default. function fieldStackZ(el) { let z = 0; for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) { const cs = getComputedStyle(n); if (cs.position !== 'static') { const v = parseInt(cs.zIndex, 10); if (!isNaN(v) && v > z) z = v; } } return z; } function scan() { const map = new Map(); const add = (el, def) => { if (el && !map.has(el)) map.set(el, def || {}); }; for (const d of customDefs()) { try { document.querySelectorAll(d.match).forEach(el => add(el, d)); } catch (e) {} } // built-ins + user fields, all from SET.customFields (invalid selectors ignored) document.querySelectorAll('.mmth-pin').forEach(el => add(el, { key: el.dataset.mmthKey ? 'k:' + el.dataset.mmthKey : null, label: el.dataset.mmthLabel || '', submit: el.dataset.mmthSubmit != null })); for (const [el, def] of map) { if (el.dataset.mmthf || !el.matches('input, select, textarea')) continue; el.dataset.mmthf = '1'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'mmthf-pin'; btn.innerHTML = MAMMOTH_SVG; // #308 vector, not the 🦣 emoji const sel = isSelect(el); // shift the pin left of a native affordance: the <select> arrow (~22) or an // autocomplete magnifier that sits INSIDE the box (~24 — label, release group; // class ui-autocomplete-input). The artist field is `lookup-performed` only — // its magnifier is OUTSIDE the box — so it needs no shift. `dx` (def.dx / // data-mmth-dx) overrides per target. const innerIcon = el.classList.contains('ui-autocomplete-input'); const dxRaw = def.dx != null ? def.dx : (el.dataset.mmthDx != null ? +el.dataset.mmthDx : null); const dx = dxRaw != null ? dxRaw : (sel ? 22 : innerIcon ? 24 : 3); if (!sel) try { const need = dx + 18; const pr = parseInt(getComputedStyle(el).paddingRight, 10) || 0; if (pr < need) el.style.paddingRight = need + 'px'; } catch (e) {} const bar = document.createElement('div'); bar.className = 'mmthf-bar'; // deltav (def.deltav / data-mmth-deltav): 0 = the default FLOATING bar (absolute, may overlap UI // below); N>0 = inject the pinned-button bar IN-FLOW right after the Nth ancestor of the field, so // it takes real layout space and pushes the UI below it down instead of overlapping. const dv = def.deltav != null ? (+def.deltav || 0) : (el.dataset.mmthDeltav != null ? (+el.dataset.mmthDeltav || 0) : 0); const p = { el, key: keyFor(el, def), label: def.label || fLabelText(el) || 'Field', btn, bar, sel, dx, gid: def.gid || null, deltav: dv, submit: !!def.submit, z: fieldStackZ(el) }; btn.title = `Mammoth field memory — ${p.label}`; btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); togglePop(p); }); btn.addEventListener('mouseenter', () => el.classList.add('mmthf-hl')); btn.addEventListener('mouseleave', () => el.classList.remove('mmthf-hl')); document.body.appendChild(btn); if (dv > 0) { let a = el; for (let k = 0; k < dv && a.parentElement; k++) a = a.parentElement; bar.classList.add('mmthf-bar-inflow'); (a.parentNode || document.body).insertBefore(bar, a.nextSibling); } else document.body.appendChild(bar); // a field inside a high-z-index modal (e.g. ISRC Scout's #ii-modal @ 999999) would bury the pin — lift // the overlays just above that modal's stacking context. In-flow bars (deltav) inherit it, so skip them. if (p.z) { const Z = 2147483647; btn.style.zIndex = String(Math.min(Z, p.z + 2)); if (dv <= 0) bar.style.zIndex = String(Math.min(Z, p.z + 1)); } pins.push(p); refreshState(p); applyDefault(p); } layout(); } // refresh every field sharing this key (e.g. the single-artist box + the // artist-credit bubble rows all use release.artist), so a pin/default/save in // one reflects on the others. function refreshState(p) { for (const q of pins) if (q.key === p.key) { q.btn.classList.toggle('has', listFor(q.key).length > 0); renderBar(q); } } function setReserve(p, on) { if (p.deltav > 0) return; // in-flow bar takes its own space — no strip to reserve const host = p.el.closest('td') || p.el; const prop = host === p.el ? 'marginBottom' : 'paddingBottom'; // When the host is a <td> in a MULTI-cell row (artist-credit bubble: Artist / // as-credited / join-phrase share one <tr>), padding it taller would drop the // sibling cells (they're vertically centred). Top-align the row's cells so the // padding grows downward into the strip's gap and the siblings stay put. const tr = prop === 'paddingBottom' ? host.parentElement : null; if (on) { if (!p._rh) { p._rh = host; p._rp = prop; p._ro = host.style[prop] || ''; p._rbase = parseFloat(getComputedStyle(host)[prop]) || 0; // base padding to add the strip onto if (tr && tr.children.length > 1) { p._rtr = tr; tr.classList.add('mmthf-rrow'); } } } else if (p._rh) { p._rh.style[p._rp] = p._ro; p._rh = null; if (p._rtr) { p._rtr.classList.remove('mmthf-rrow'); p._rtr = null; } } } // #304: size the reserved strip to the bar's ACTUAL height, so a bar that wraps to // several rows pushes content down by the right amount (not a fixed one-row guess). function sizeReserve(p) { if (!p._rh) return; const h = (p.bar.style.display !== 'none') ? p.bar.offsetHeight : 0; p._rh.style[p._rp] = (p._rbase + (h ? h + 6 : 0)) + 'px'; } function renderBar(p) { const items = listFor(p.key).filter(x => x.pinned); p.bar.innerHTML = ''; setReserve(p, items.length > 0); if (!items.length) { p.bar.style.display = 'none'; return; } const seg = document.createElement('div'); seg.className = 'mmthf-seg'; items.forEach(it => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmthf-segb'; b.textContent = captionOf(it); b.style.maxWidth = (btnChars() + 1) + 'ch'; b.title = `${cleanLabel(it.label || it.v)} → click to set`; b.addEventListener('click', e => { e.preventDefault(); recallInto(p, it); }); seg.appendChild(b); }); p.bar.appendChild(seg); } function applyDefault(p) { const d = defaultOf(p.key); if (d && !readField(p.el).v) recallInto(p, d); } function fieldOnTop(el, r) { const x = r.left + r.width / 2, y = r.top + r.height / 2; if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return false; const t = document.elementFromPoint(x, y); return !!t && (t === el || el.contains(t) || t.contains(el)); } function gapClear(el, bar, r) { const x = r.left + Math.min(20, r.width / 2), y = r.bottom + 6; if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return true; const prev = bar.style.display; bar.style.display = 'none'; const t = document.elementFromPoint(x, y); bar.style.display = prev; if (!t || t.closest('.mmthf-bar,.mmthf-pin,.mmthf-pop')) return true; // a positioned overlay over the gap hides the strip — UNLESS it's the field's // OWN positioned container (e.g. the artist-credit editor bubble), which isn't // covering the field, it holds it. Only flag overlays that don't contain el. for (let n = t; n && n !== document.body; n = n.parentElement) { const pos = getComputedStyle(n).position; if ((pos === 'absolute' || pos === 'fixed' || pos === 'sticky') && !n.contains(el)) return false; } return true; } function layout() { for (const p of pins) { if (!p.el.isConnected) { p.btn.remove(); p.bar.remove(); p.dead = true; continue; } const el = p.el, r = el.getBoundingClientRect(); let vis = r.width > 0 && r.height > 0 && el.offsetParent !== null && !el.disabled; if (vis) vis = fieldOnTop(el, r); p.btn.style.display = vis ? 'flex' : 'none'; // in-flow bar (deltav>0): the browser lays it out — just show/hide it with the field. if (p.deltav > 0) { p.bar.style.display = (vis && !!p.bar.firstChild) ? 'block' : 'none'; } else { const hasBar = vis && !!p.bar.firstChild && gapClear(el, p.bar, r); p.bar.style.display = hasBar ? 'block' : 'none'; } if (!vis) continue; // position in DOCUMENT coords (position:absolute) so the overlays scroll WITH // the page natively — no per-frame JS reposition, so no scroll lag. getBounding // ClientRect is viewport-relative, so add the scroll offset back. const sx = window.scrollX, sy = window.scrollY; p.btn.style.top = (r.top + sy + (r.height - 16) / 2) + 'px'; p.btn.style.left = (r.right + sx - 16 - p.dx) + 'px'; if (p.deltav > 0) continue; // in-flow bar needs no absolute placement / strip reserve if (p.bar.style.display !== 'none') { p.bar.style.top = (r.bottom + sy + 3) + 'px'; p.bar.style.left = (r.left + sx) + 'px'; p.bar.style.maxWidth = Math.max(140, r.width) + 'px'; } sizeReserve(p); // #304: match the field's reserved strip to the (possibly multi-row) bar height } if (pins.some(p => p.dead)) pins = pins.filter(p => !p.dead); } function closePop() { if (pop) { pop.remove(); pop = null; document.removeEventListener('click', onDown, true); document.removeEventListener('keydown', onKey, true); } } function onDown(e) { if (pop && !pop.contains(e.target) && !e.target.classList.contains('mmthf-pin')) { e.preventDefault(); e.stopPropagation(); closePop(); } } // #305: swallow the outside click (see closePop note above) function onKey(e) { if (pop && e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); const f = pop._field; closePop(); if (f) focusField(f); } } // #346: ESC kills the popover (no leak to MB) and returns focus to the field function place(el, anchor) { const r = anchor.getBoundingClientRect(), b = el.getBoundingClientRect(); // b: the popover's actual rendered size const w = b.width || el.offsetWidth || 260, h = b.height || el.offsetHeight || 0; el.style.left = Math.max(6, Math.min(innerWidth - w - 6, r.left)) + 'px'; // keep both edges on-screen el.style.top = Math.max(6, Math.min(innerHeight - h - 6, r.bottom + 4)) + 'px'; } function togglePop(p, atField) { const open = pop && pop._key === p.key && pop._anchor === p.btn; closePop(); if (open) return; openPop(p, atField); } function openPop(p, atField) { const cur = captureField(p); // entity fields capture the selected MBID (#296) const items = listFor(p.key); // raw order — drag (⠿) reorders it freely, like the edit-note panel const el = document.createElement('div'); el.className = 'mmthf-pop'; el._key = p.key; el._anchor = p.btn; el._field = p.el; if (p.z) el.style.zIndex = String(Math.min(2147483647, p.z + 3)); // sit above the field's modal (see fieldStackZ) const rowHtml = (it, i) => { const star = `<button class="mmthf-ra mmthf-star" title="${it.pinned ? 'Unpin from buttons' : 'Pin as a button'}">${it.pinned ? '★' : '☆'}</button>`; const def = `<button class="mmthf-ra mmthf-def" title="${it.default ? 'Default — auto-fills an empty field (click to unset)' : 'Make default (auto-fills an empty field)'}">${it.default ? '◉' : '◯'}</button>`; const acts = `<div class="mmthf-acts">${star}${def}<button class="mmthf-ra mmthf-edit" title="Edit value (paste/append its MBID)">✎</button><button class="mmthf-ra mmthf-del" title="Forget">🗑</button><span class="mmthf-grab" title="Drag to reorder" draggable="true">⠿</span></div>`; const ind = `<span class="mmthf-ind">${it.default ? '<span>◉</span>' : ''}${it.pinned ? '<span>★</span>' : ''}</span>`; return `<div class="mmthf-row" data-i="${i}" title="${esc(it.label || it.v)}"><span class="mmthf-rtxt">${esc(cleanLabel(it.label || it.v))}</span>${ind}${acts}</div>`; }; // the filter box is always shown (whenever there's a saved value to filter) const searchOn = items.length > 0; el.innerHTML = `<div class="mmthf-ft"> <button class="mmthf-fb mmthf-save" ${cur.v ? '' : 'aria-disabled="true"'} title="${cur.v ? 'Save current value: ' + esc(cur.label) : 'Field is empty'}">+</button> <button class="mmthf-fb mmthf-clear" title="Clear the field">✕</button> <span class="mmthf-ft-title"></span> <button class="mmthf-fb mmthf-cfg" title="Mammoth settings">⚙︎</button> </div> ${searchOn ? '<div class="mmthf-filterrow"><input class="mmthf-filter" type="text" placeholder="Filter…" spellcheck="false"></div>' : ''} <div class="mmthf-list">${items.map(rowHtml).join('') || '<div class="mmthf-empty">No saved values yet</div>'}</div>`; document.body.appendChild(el); pop = el; const list = el.querySelector('.mmthf-list'); if (searchOn) { const fin = el.querySelector('.mmthf-filter'); let hl = -1; // highlighted row among the currently-visible ones const vis = () => [...el.querySelectorAll('.mmthf-row')].filter(r => r.style.display !== 'none'); const paint = () => { const rows = vis(); rows.forEach((r, i) => r.classList.toggle('mmthf-sel', i === hl)); if (rows[hl]) rows[hl].scrollIntoView({ block: 'nearest' }); }; const applyFilter = () => { const q = (fin.value || '').trim().toLowerCase(); el.querySelectorAll('.mmthf-row').forEach(r => { const t = (r.querySelector('.mmthf-rtxt') || {}).textContent || ''; r.style.display = (!q || t.toLowerCase().includes(q)) ? '' : 'none'; }); hl = vis().length ? 0 : -1; paint(); }; fin.addEventListener('input', applyFilter); fin.addEventListener('keydown', e => { const rows = vis(); if (e.key === 'Escape') { e.stopPropagation(); if (fin.value) { fin.value = ''; applyFilter(); } else closePop(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); if (rows.length) { hl = (hl + 1) % rows.length; paint(); } return; } if (e.key === 'ArrowUp') { e.preventDefault(); if (rows.length) { hl = (hl - 1 + rows.length) % rows.length; paint(); } return; } if (e.key === 'Enter') { e.preventDefault(); const r = rows[hl] || rows[0]; if (r) { recallInto(p, items[+r.dataset.i]); closePop(); focusField(p.el); } return; } // keyboard apply → refocus the field }); applyFilter(); // highlight the first item so Enter works immediately setTimeout(() => { try { fin.focus(); } catch (e) {} }, 0); } el.querySelector('.mmthf-save').addEventListener('click', () => { if (!cur.v) return; rememberValue(p.key, cur); refreshState(p); reopen(p); }); el.querySelector('.mmthf-clear').addEventListener('click', () => { clearField(p.el); reopen(p); }); // #309: open the config with Import/Export scoped to THIS field's values el.querySelector('.mmthf-cfg').addEventListener('click', () => { const a = p.btn; // #309: entity fields (Artist/Label) store v=MBID, label=name. Export both as // "name<TAB>MBID" so a re-import resolves the real entity (text fields stay plain). const io = { items: () => listFor(p.key).map(x => (x.v && x.label && x.v !== x.label) ? (x.label + '\t' + x.v) : (x.label || x.v)), add: notes => { const arr = listFor(p.key); let added = 0; const have = new Set(arr.map(x => x.v)); for (const line of notes) { const i = line.indexOf('\t'); const label = (i >= 0 ? line.slice(0, i) : line).trim(); const v = (i >= 0 ? line.slice(i + 1) : line).trim(); if (!v || have.has(v)) continue; have.add(v); arr.unshift({ v, label: label || v, ts: Date.now() }); added++; } FDATA[p.key] = arr.slice(0, MAX_PER_FIELD); if (added) { saveF(); refreshState(p); } return added; }, help: 'Import / export the “' + p.label + '” values (entity fields keep their MBID).', }; closePop(); openSettings(a, 'io', io); }); el.querySelectorAll('.mmthf-row').forEach(row => { const it = items[+row.dataset.i]; row.addEventListener('click', e => { if (e.target.closest('.mmthf-grab') || e.target.closest('.mmthf-edit-in')) return; // dragging / mid-edit if (e.target.closest('.mmthf-star')) { togglePin(p.key, it.v); refreshState(p); reopen(p); return; } if (e.target.closest('.mmthf-def')) { setDefault(p.key, it.v); applyDefault(p); reopen(p); return; } if (e.target.closest('.mmthf-edit')) { startEditRow(row, p, it); return; } if (e.target.closest('.mmthf-del')) { forgetValue(p.key, it.v); refreshState(p); reopen(p); return; } recallInto(p, it); closePop(); // don't refocus the field on apply — it re-triggers autocomplete }); const grab = row.querySelector('.mmthf-grab'); grab.addEventListener('dragstart', e => { _fdrag = { v: it.v }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', 'row'); } catch (x) {} row.classList.add('mmthf-dragging'); }); grab.addEventListener('dragend', () => { row.classList.remove('mmthf-dragging'); clearMarks(list); _fdrag = null; }); row.addEventListener('dragover', e => { if (!_fdrag) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearMarks(list); row.classList.add(after(e, row) ? 'mmthf-drop-after' : 'mmthf-drop-before'); }); row.addEventListener('dragleave', () => row.classList.remove('mmthf-drop-before', 'mmthf-drop-after')); row.addEventListener('drop', e => { if (!_fdrag) return; e.preventDefault(); reorder(p.key, _fdrag.v, it.v, !after(e, row)); clearMarks(list); _fdrag = null; refreshState(p); reopen(p); }); }); el._atField = atField; place(el, atField ? p.el : p.btn); // #345: hotkey opens at the field's left edge; a pin CLICK opens at the pin, by the cursor setTimeout(() => { document.addEventListener('click', onDown, true); document.addEventListener('keydown', onKey, true); }, 0); } const reopen = p => { const af = pop ? pop._atField : false; closePop(); openPop(p, af); }; // keep the same anchor across a reopen // inline-edit a saved value (to paste/append its MBID). Enter commits + reopens (stripped rows), Esc // cancels, blur commits silently (no reopen — avoids racing an outside-click close). function startEditRow(row, p, it) { const rtxt = row.querySelector('.mmthf-rtxt'); if (!rtxt || row.querySelector('.mmthf-edit-in')) return; const inp = document.createElement('input'); inp.type = 'text'; inp.className = 'mmthf-edit-in'; inp.spellcheck = false; inp.value = it.v; rtxt.replaceWith(inp); row.classList.add('mmthf-editing'); let done = false; const commit = () => { if (done) return; done = true; if (inp.value.trim() && inp.value.trim() !== it.v) editValue(p.key, it.v, inp.value); }; inp.addEventListener('mousedown', e => e.stopPropagation()); inp.addEventListener('click', e => e.stopPropagation()); inp.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); commit(); refreshState(p); reopen(p); } else if (e.key === 'Escape') { e.preventDefault(); done = true; reopen(p); } }); inp.addEventListener('blur', () => { commit(); refreshState(p); }); setTimeout(() => { try { inp.focus(); inp.select(); } catch (e) {} }, 0); } const relayout = () => { syncDialog(); if (running && !raf) raf = requestAnimationFrame(() => { raf = 0; layout(); }); }; function start() { if (running) return; running = true; injectCss(); const on = (t, ev, fn, cap) => { t.addEventListener(ev, fn, cap); listeners.push([t, ev, fn, cap]); }; on(window, 'scroll', relayout, true); on(window, 'resize', relayout, false); ['focusin', 'focusout', 'click', 'input', 'keyup'].forEach(ev => on(document, ev, relayout, true)); // Ctrl/Cmd+, — if a baby popover is open, focus its filter; else if a baby field is focused, open // its popover at the field's left edge (which auto-focuses the filter). on(document, 'keydown', e => { if (e.key !== ',' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; // #397: when this handler acts on a baby field, swallow the event so the global edit-note // Ctrl+, handler (bubble phase) doesn't ALSO fire and steal focus to the note panel. if (pop) { const f = pop.querySelector('.mmthf-filter'); if (f) { e.preventDefault(); e.stopPropagation(); f.focus(); if (f.select) f.select(); return; } } const el = document.activeElement; const p = el && pins.find(pp => pp.el === el); if (p) { e.preventDefault(); e.stopPropagation(); openPop(p, true); } // hotkey → anchor to the field's left edge }, true); // #296: the release editor keeps reflowing for a few hundred ms after load, so // the absolutely-positioned overlays would chase the moving fields and visibly // jump. Keep them hidden until the DOM goes quiet for 300ms (capped at 1.5s), // then reveal them already in their final spots. const html = document.documentElement; html.classList.add('mmthf-settling'); let revealed = false, quietT = 0; const reveal = () => { if (revealed) return; revealed = true; clearTimeout(quietT); clearTimeout(settleCap); html.classList.remove('mmthf-settling'); html.classList.add('mmthf-fadein'); relayout(); setTimeout(() => html.classList.remove('mmthf-fadein'), 450); // drop the slow transition once faded in }; const bump = () => { if (revealed) return; clearTimeout(quietT); quietT = setTimeout(reveal, 300); }; settleCap = setTimeout(reveal, 1500); let st = 0; mo = new MutationObserver(() => { syncDialog(); clearTimeout(st); st = setTimeout(scan, 150); bump(); }); mo.observe(document.documentElement, { childList: true, subtree: true }); bump(); scan(); } function stop() { if (!running) return; running = false; if (mo) { mo.disconnect(); mo = null; } clearTimeout(settleCap); document.documentElement.classList.remove('mmthf-settling', 'mmthf-fadein'); listeners.forEach(([t, ev, fn, cap]) => t.removeEventListener(ev, fn, cap)); listeners.length = 0; closePop(); pins.forEach(p => { try { setReserve(p, false); } catch (e) {} p.btn.remove(); p.bar.remove(); delete p.el.dataset.mmthf; }); pins = []; } return { start, stop, toggle(on) { on ? start() : stop(); }, relabel() { pins.forEach(p => renderBar(p)); }, refresh() { if (running) { stop(); start(); } } }; // refresh: re-scan after custom fields change } })(); })(); } catch (e) { try { console.error('[String Theory] mammoth failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== mission_control (@run-at document-end) =================================== if (__stOff.indexOf("mission_control") < 0 && __stRuns("mission_control", {"inc":["^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*$"],"exc":[],"noframes":false})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Mission Control*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"One window on the release page that asks the other scripts (Platform Check, ISRC Scout, Art Station, Fusion, Credit Hoarder) what is missing, shows it all in one review, and applies the selected changes in order.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mission_control/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5NaXNzaW9uIENvbnRyb2w8L3RpdGxlPgo8ZGVmcz48ZmlsdGVyIGlkPSJtY28xMy1oIiB4PSItMTAlIiB5PSItMTAlIiB3aWR0aD0iMTIwJSIgaGVpZ2h0PSIxMjAlIj48ZmVNb3JwaG9sb2d5IGluPSJTb3VyY2VBbHBoYSIgb3BlcmF0b3I9ImRpbGF0ZSIgcmFkaXVzPSIxLjUiIHJlc3VsdD0iZCIvPjxmZUZsb29kIGZsb29kLWNvbG9yPSIjZmZmIiBmbG9vZC1vcGFjaXR5PSIuNyIvPjxmZUNvbXBvc2l0ZSBpbjI9ImQiIG9wZXJhdG9yPSJpbiIvPjxmZU1lcmdlPjxmZU1lcmdlTm9kZS8+PGZlTWVyZ2VOb2RlIGluPSJTb3VyY2VHcmFwaGljIi8+PC9mZU1lcmdlPjwvZmlsdGVyPjwvZGVmcz48ZyBmaWx0ZXI9InVybCgjbWNvMTMtaCkiPjxwYXRoIGQ9Ik03NyAzNS41IEw2NCA0MyBMNTEgMzUuNSBMNTEgMjAuNSBMNjQgMTMgTDc3IDIwLjVaIiBmaWxsPSIjN2E1N2U4IiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik01OS43IDc3LjUgTDQ2LjcgODUgTDMzLjcgNzcuNSBMMzMuNyA2Mi41IEw0Ni43IDU1IEw1OS43IDYyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik05NC4zIDc3LjUgTDgxLjMgODUgTDY4LjMgNzcuNSBMNjguMyA2Mi41IEw4MS4zIDU1IEw5NC4zIDYyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik00Mi4zIDEwNy41IEwyOS40IDExNSBMMTYuNCAxMDcuNSBMMTYuNCA5Mi41IEwyOS40IDg1IEw0Mi4zIDkyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik03NyAxMDcuNSBMNjQgMTE1IEw1MSAxMDcuNSBMNTEgOTIuNSBMNjQgODUgTDc3IDkyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0xMTEuNiAxMDcuNSBMOTguNiAxMTUgTDg1LjcgMTA3LjUgTDg1LjcgOTIuNSBMOTguNiA4NSBMMTExLjYgOTIuNVoiIGZpbGw9IiNmZmM5NGEiIHN0cm9rZT0iIzIyMjIzYiIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9nPgo8L3N2Zz4K"}) }) : { script: {"name":"Mission Control*","namespace":"https://musicbrainz.org/","version":"2026.10.16","description":"One window on the release page that asks the other scripts (Platform Check, ISRC Scout, Art Station, Fusion, Credit Hoarder) what is missing, shows it all in one review, and applies the selected changes in order.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mission_control/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5NaXNzaW9uIENvbnRyb2w8L3RpdGxlPgo8ZGVmcz48ZmlsdGVyIGlkPSJtY28xMy1oIiB4PSItMTAlIiB5PSItMTAlIiB3aWR0aD0iMTIwJSIgaGVpZ2h0PSIxMjAlIj48ZmVNb3JwaG9sb2d5IGluPSJTb3VyY2VBbHBoYSIgb3BlcmF0b3I9ImRpbGF0ZSIgcmFkaXVzPSIxLjUiIHJlc3VsdD0iZCIvPjxmZUZsb29kIGZsb29kLWNvbG9yPSIjZmZmIiBmbG9vZC1vcGFjaXR5PSIuNyIvPjxmZUNvbXBvc2l0ZSBpbjI9ImQiIG9wZXJhdG9yPSJpbiIvPjxmZU1lcmdlPjxmZU1lcmdlTm9kZS8+PGZlTWVyZ2VOb2RlIGluPSJTb3VyY2VHcmFwaGljIi8+PC9mZU1lcmdlPjwvZmlsdGVyPjwvZGVmcz48ZyBmaWx0ZXI9InVybCgjbWNvMTMtaCkiPjxwYXRoIGQ9Ik03NyAzNS41IEw2NCA0MyBMNTEgMzUuNSBMNTEgMjAuNSBMNjQgMTMgTDc3IDIwLjVaIiBmaWxsPSIjN2E1N2U4IiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik01OS43IDc3LjUgTDQ2LjcgODUgTDMzLjcgNzcuNSBMMzMuNyA2Mi41IEw0Ni43IDU1IEw1OS43IDYyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik05NC4zIDc3LjUgTDgxLjMgODUgTDY4LjMgNzcuNSBMNjguMyA2Mi41IEw4MS4zIDU1IEw5NC4zIDYyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik00Mi4zIDEwNy41IEwyOS40IDExNSBMMTYuNCAxMDcuNSBMMTYuNCA5Mi41IEwyOS40IDg1IEw0Mi4zIDkyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik03NyAxMDcuNSBMNjQgMTE1IEw1MSAxMDcuNSBMNTEgOTIuNSBMNjQgODUgTDc3IDkyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0xMTEuNiAxMDcuNSBMOTguNiAxMTUgTDg1LjcgMTA3LjUgTDg1LjcgOTIuNSBMOTguNiA4NSBMMTExLjYgOTIuNVoiIGZpbGw9IiNmZmM5NGEiIHN0cm9rZT0iIzIyMjIzYiIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9nPgo8L3N2Zz4K"} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ try { (function(){ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('mission_control', 'Mission Control')) return; const VERSION = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '?'; // the corner launcher wears the script's own icon (the same picture as @icon) const ICON_URL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5NaXNzaW9uIENvbnRyb2w8L3RpdGxlPgo8ZGVmcz48ZmlsdGVyIGlkPSJtY28xMy1oIiB4PSItMTAlIiB5PSItMTAlIiB3aWR0aD0iMTIwJSIgaGVpZ2h0PSIxMjAlIj48ZmVNb3JwaG9sb2d5IGluPSJTb3VyY2VBbHBoYSIgb3BlcmF0b3I9ImRpbGF0ZSIgcmFkaXVzPSIxLjUiIHJlc3VsdD0iZCIvPjxmZUZsb29kIGZsb29kLWNvbG9yPSIjZmZmIiBmbG9vZC1vcGFjaXR5PSIuNyIvPjxmZUNvbXBvc2l0ZSBpbjI9ImQiIG9wZXJhdG9yPSJpbiIvPjxmZU1lcmdlPjxmZU1lcmdlTm9kZS8+PGZlTWVyZ2VOb2RlIGluPSJTb3VyY2VHcmFwaGljIi8+PC9mZU1lcmdlPjwvZmlsdGVyPjwvZGVmcz48ZyBmaWx0ZXI9InVybCgjbWNvMTMtaCkiPjxwYXRoIGQ9Ik03NyAzNS41IEw2NCA0MyBMNTEgMzUuNSBMNTEgMjAuNSBMNjQgMTMgTDc3IDIwLjVaIiBmaWxsPSIjN2E1N2U4IiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik01OS43IDc3LjUgTDQ2LjcgODUgTDMzLjcgNzcuNSBMMzMuNyA2Mi41IEw0Ni43IDU1IEw1OS43IDYyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik05NC4zIDc3LjUgTDgxLjMgODUgTDY4LjMgNzcuNSBMNjguMyA2Mi41IEw4MS4zIDU1IEw5NC4zIDYyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik00Mi4zIDEwNy41IEwyOS40IDExNSBMMTYuNCAxMDcuNSBMMTYuNCA5Mi41IEwyOS40IDg1IEw0Mi4zIDkyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik03NyAxMDcuNSBMNjQgMTE1IEw1MSAxMDcuNSBMNTEgOTIuNSBMNjQgODUgTDc3IDkyLjVaIiBmaWxsPSIjZmZjOTRhIiBzdHJva2U9IiMyMjIyM2IiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0xMTEuNiAxMDcuNSBMOTguNiAxMTUgTDg1LjcgMTA3LjUgTDg1LjcgOTIuNSBMOTguNiA4NSBMMTExLjYgOTIuNVoiIGZpbGw9IiNmZmM5NGEiIHN0cm9rZT0iIzIyMjIzYiIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9nPgo8L3N2Zz4K'; // Only the release overview: MC reads its tracklist, and the subpages (edit, // cover-art, edit-relationships, …) belong to other scripts. const RELEASE = (location.pathname.match(/^\/release\/([0-9a-fA-F-]{36})\/?$/) || [])[1]; // #702: and /release/add#mc=<token>, where First Contact's Consolidate hands over a release MusicBrainz doesn't have yet const CONS = !RELEASE && /^\/release\/add\/?$/.test(location.pathname) ? (location.hash.match(/[#&]mc=([a-z0-9]+)/i) || [])[1] : null; if (!RELEASE && !CONS) return; const Log = mbuLog({ name: 'Mission Control', version: VERSION, header: 'Mission Control — activity log', key: 'mc.logwin', before: () => mcStyle() }); Log.info(mbuStartupInfo('Mission Control')); /* ── settings (GM storage, #501) ───────────────────────────────────────────── */ // fusion / ch: 'auto' fetches during Probe, 'ask' waits for the card's Fetch // button, 'off' drops the step. Both fetches can take minutes (#680). // linkedOpen: the sections whose already-linked are listed as rows (else: icons on its heading), one // key each: a card's id, or PC's 'pc:release' / 'pc:artist' / 'pc:label' // reloadAfter: reload the release page after an Execute without errors // consAfter: after saving a consolidated release (#702), open Mission Control on it and probe // disabled: the providers without a mode (PC, IS, AS) switched off with their step in the header (#753) const DEFAULTS = { fusion: 'ask', ch: 'ask', disabled: [], left: true, right: true, linkedOpen: [], autoProbe: true, reloadAfter: true, consAfter: true, v: 2 }; // a card and the providers whose selections it holds (Tracks: the per-track ones without a card of their own) const CARDS = [['tracks', ['is', 'fusion']], ['pc', ['pc']], ['as', ['as']], ['ch', ['ch']]]; // The cards switched off with the switch in their header: folded, and Execute leaves out what is selected // in them. For this page only, never saved: the next release starts with every card on. const OFF = new Set(); const cardOff = id => OFF.has(id); // and a provider switched off in the header (#753) const providerOff = id => CARDS.some(([c, ps]) => cardOff(c) && ps.includes(id)) || modeOf(PROVIDERS.find(p => p.id === id)) === 'off'; function loadSettings() { let s = {}; try { s = JSON.parse(GM_getValue('mc.settings', '{}')) || {}; } catch (e) { Log.warn('settings unreadable, using defaults: ' + e.message); } // v2 turned autoProbe and reloadAfter on by default; the whole object was saved before, so a // false stored then is the old default, not a choice: drop it once and the new default shows if (!(s.v >= 2)) { if (s.autoProbe === false) delete s.autoProbe; if (s.reloadAfter === false) delete s.reloadAfter; s.v = 2; Log.info('settings v2: probe on open and reload after Execute now default on'); } return Object.assign({}, DEFAULTS, s); } const S = loadSettings(); function saveSettings() { try { GM_setValue('mc.settings', JSON.stringify(S)); } catch (e) { Log.warn('settings not saved: ' + e.message); } Log.debug('settings ' + JSON.stringify(S)); } /* ── execution order ───────────────────────────────────────────────────────── */ // Each step is a provider; a step with `lanes` runs them in parallel (they hit // different APIs with their own rate limits). `mode` names the setting that can // switch an optional step to auto / ask / off. const STEPS = [ { id: 'pc', name: 'Release and entity links', short: 'PC', provider: 'Platform Check', glyph: '🔗' }, { id: 'enrich', name: 'Enrich', glyph: '⇶', lanes: [ { id: 'is', name: 'ISRCs & recording links', short: 'IS', provider: 'ISRC Scout', glyph: '#' }, { id: 'as', name: 'Cover art', short: 'AS', provider: 'Art Station', glyph: '🖼' }, ] }, { id: 'fusion', name: 'Merge suggestions', short: 'Fusion', provider: 'Fusion', glyph: '⚛', mode: 'fusion' }, { id: 'ch', name: 'Credits', short: 'CH', provider: 'Credit Hoarder', glyph: '✎', mode: 'ch', info: true }, ]; const PROVIDERS = STEPS.flatMap(s => s.lanes || [s]); // each provider's own userscript icon, for its badge (embedded: an image from raw.githubusercontent didn't load) const PROVIDER_ICONS = { "pc": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5NQiBQbGF0Zm9ybSBDaGVjazwvdGl0bGU+CjxnIHN0cm9rZT0iIzk0YTNiOCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxwYXRoIGQ9Ik02NCA2NCBMODggMjIuNCIvPjxwYXRoIGQ9Ik02NCA2NCBMMTEyIDY0Ii8+PHBhdGggZD0iTTY0IDY0IEw4OCAxMDUuNiIvPjxwYXRoIGQ9Ik02NCA2NCBMNDAgMTA1LjYiLz48cGF0aCBkPSJNNjQgNjQgTDE2IDY0Ii8+PHBhdGggZD0iTTY0IDY0IEw0MCAyMi40Ii8+PC9nPjxjaXJjbGUgY3g9Ijg4IiBjeT0iMjIuNCIgcj0iMTIiIGZpbGw9IiNmNDcyYjYiLz48Y2lyY2xlIGN4PSIxMTIiIGN5PSI2NCIgcj0iMTIiIGZpbGw9IiNmYWNjMTUiLz48Y2lyY2xlIGN4PSI4OCIgY3k9IjEwNS42IiByPSIxMiIgZmlsbD0iIzRhZGU4MCIvPjxjaXJjbGUgY3g9IjQwIiBjeT0iMTA1LjYiIHI9IjEyIiBmaWxsPSIjMzhiZGY4Ii8+PGNpcmNsZSBjeD0iMTYiIGN5PSI2NCIgcj0iMTIiIGZpbGw9IiNhNzhiZmEiLz48Y2lyY2xlIGN4PSI0MCIgY3k9IjIyLjQiIHI9IjEyIiBmaWxsPSIjZmI5MjNjIi8+PGNpcmNsZSBjeD0iNjQiIGN5PSI2NCIgcj0iMjQiIGZpbGw9IiMwZjE3MmEiLz48cGF0aCBkPSJNNTQuMjIyMjIyMjIyMjIyMjIgNjQgTDYxLjMzMzMzMzMzMzMzMzMzNiA3MS4xMTExMTExMTExMTExMSBMNzQuNjY2NjY2NjY2NjY2NjcgNTYuODg4ODg4ODg4ODg4ODg2IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=", "is": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+DQogIDx0aXRsZT5JU1JDIFNjb3V0PC90aXRsZT4NCiAgICA8cGF0aCBkPSJNNjQgNjQgTDY0IDI0IEE0MCA0MCAwIDAgMSA5OSA4NCBaIiBmaWxsPSIjZTNkOGY3Ii8+DQogIDxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2Ij4NCiAgICA8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSI0MCIvPg0KICAgIDxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjI2IiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZT0iI2I5YTNlOCIvPg0KICAgIDxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjEzIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZT0iI2I5YTNlOCIvPg0KICA8L2c+DQogIDxsaW5lIHgxPSI2NCIgeTE9IjY0IiB4Mj0iNjQiIHkyPSIyNCIgc3Ryb2tlPSIjNmY0MmMxIiBzdHJva2Utd2lkdGg9IjYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPg0KICA8Y2lyY2xlIGN4PSI4NiIgY3k9IjUwIiByPSI3IiBmaWxsPSIjNGIyZTgzIi8+DQo8L3N2Zz4NCg==", "as": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5BcnQgU3RhdGlvbjwvdGl0bGU+CjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjM4IiBmaWxsPSIjMWIwZjNiIiBzdHJva2U9IiNiOTY3ZmYiIHN0cm9rZS13aWR0aD0iMyIvPgo8Y2lyY2xlIGN4PSI4NiIgY3k9IjY0IiByPSIyOC44OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNGEzNDgwIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjIwLjkwMDAwMDAwMDAwMDAwMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNGEzNDgwIiBzdHJva2Utd2lkdGg9IjEuNSIvPgo8Y2lyY2xlIGN4PSI4NiIgY3k9IjY0IiByPSIxMS4wMiIgZmlsbD0iI2ZmNzFjZSIvPjxjaXJjbGUgY3g9Ijg2IiBjeT0iNjQiIHI9IjIuNTA4IiBmaWxsPSIjMWIwZjNiIi8+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhcy1za3ljIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwLjAwIiBzdG9wLWNvbG9yPSIjN2ZlN2ZmIi8+PHN0b3Agb2Zmc2V0PSIwLjUwIiBzdG9wLWNvbG9yPSIjZDlhNmZmIi8+PHN0b3Agb2Zmc2V0PSIxLjAwIiBzdG9wLWNvbG9yPSIjZmY3MWNlIi8+PC9saW5lYXJHcmFkaWVudD48Y2xpcFBhdGggaWQ9ImFzLWNjIj48cmVjdCB4PSIxMCIgeT0iMjIiIHdpZHRoPSI3OCIgaGVpZ2h0PSI4NCIgcng9IjgiLz48L2NsaXBQYXRoPjwvZGVmcz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2FzLWNjKSI+PHJlY3QgeD0iMTAiIHk9IjIyIiB3aWR0aD0iNzgiIGhlaWdodD0iODQiIGZpbGw9InVybCgjYXMtc2t5YykiLz4KPGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhcy1nczIiIHgxPSIwIiB5MT0iMCIgeDI9IjAiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmY2YTgiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNmZjhhNWMiLz48L2xpbmVhckdyYWRpZW50Pgo8bWFzayBpZD0iYXMtbXMyIj48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iI2ZmZiIvPjxyZWN0IHg9IjI1LjYiIHk9IjY3LjIiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuMiIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjY5LjgiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuNSIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjczLjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjEuOCIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijc3LjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjIuMyIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9IjgyLjAiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjIuOSIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijg4LjMiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjMuNiIgZmlsbD0iIzAwMCIvPjxyZWN0IHg9IjI1LjYiIHk9Ijk2LjIiIHdpZHRoPSI0Ni44IiBoZWlnaHQ9IjQuNSIgZmlsbD0iIzAwMCIvPjwvbWFzaz48L2RlZnM+CjxjaXJjbGUgY3g9IjQ5IiBjeT0iNzYuMDgiIHI9IjIzLjQiIGZpbGw9InVybCgjYXMtZ3MyKSIgbWFzaz0idXJsKCNhcy1tczIpIi8+PHJlY3QgeD0iMTAiIHk9Ijc0LjA4IiB3aWR0aD0iNzgiIGhlaWdodD0iMzEuOTIiIGZpbGw9IiNiOTY3ZmYiLz48cGF0aCBkPSJNMTAgNzQuMDhIODhNMTAgNzcuOUg4OE0xMCA4My4ySDg4TTEwIDkxLjRIODhNMTAgMTA0LjJIODhNMzQuOCA3NC4wOEwtNDguNSAxMDZNMzguNCA3NC4wOEwtMjQuMSAxMDZNNDEuOSA3NC4wOEwwLjMgMTA2TTQ1LjUgNzQuMDhMMjQuNiAxMDZNNDkuMCA3NC4wOEw0OS4wIDEwNk01Mi41IDc0LjA4TDczLjQgMTA2TTU2LjEgNzQuMDhMOTcuOCAxMDZNNTkuNiA3NC4wOEwxMjIuMSAxMDZNNjMuMiA3NC4wOEwxNDYuNSAxMDYiIHN0cm9rZT0iI2ZmZmI5NiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvZz48cmVjdCB4PSIxMCIgeT0iMjIiIHdpZHRoPSI3OCIgaGVpZ2h0PSI4NCIgcng9IjgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzJhMTQ1OCIgc3Ryb2tlLXdpZHRoPSIzIi8+Cjwvc3ZnPgo=", "fusion": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPHRpdGxlPkZ1c2lvbjwvdGl0bGU+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjYTE4NGY4IiBzdHJva2Utd2lkdGg9IjUuNSI+CiAgICA8ZWxsaXBzZSBjeD0iNjQiIGN5PSI2NCIgcng9IjUyIiByeT0iMjIiLz4KICAgIDxlbGxpcHNlIGN4PSI2NCIgY3k9IjY0IiByeD0iNTIiIHJ5PSIyMiIgdHJhbnNmb3JtPSJyb3RhdGUoNjAgNjQgNjQpIi8+CiAgICA8ZWxsaXBzZSBjeD0iNjQiIGN5PSI2NCIgcng9IjUyIiByeT0iMjIiIHRyYW5zZm9ybT0icm90YXRlKDEyMCA2NCA2NCkiLz4KICA8L2c+CiAgPGNpcmNsZSBjeD0iNjQiIGN5PSI2NCIgcj0iMTEiIGZpbGw9IiM3YzRkZmYiLz4KPC9zdmc+Cg==", "ch": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xNS41IC0xLjUgMTM2LjUgMTM2LjUiIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4Ij4KPHRpdGxlPkNyZWRpdCBIb2FyZGVyPC90aXRsZT4KPGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJjaGMwNC1nIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZjlhODI2Ii8+PHN0b3Agb2Zmc2V0PSIuNSIgc3RvcC1jb2xvcj0iI2YzNzIyYyIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2QwMDA3MCIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxnIG9wYWNpdHk9Ii43Ij48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2NiA3MCkgcm90YXRlKDApIHNjYWxlKDEuNTUpIHRyYW5zbGF0ZSgtNjYgLTcwKSI+PGcgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuOTM1NDgzODcwOTY3NzQxOCI+PGVsbGlwc2UgY3g9IjMwIiBjeT0iODAiIHJ4PSIxNCIgcnk9IjEwIi8+PGNpcmNsZSBjeD0iNTAiIGN5PSI3NiIgcj0iOCIvPjxjaXJjbGUgY3g9IjY2IiBjeT0iNzAiIHI9IjEwIi8+PC9nPjxnIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI1LjE2MTI5MDMyMjU4MDY0NSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNMjYgODYgbC02IDEyIE0zNCA4OCBsMiAxMiBNNDYgODIgbC00IDE2IE01MiA4NCBsOCAxNCIvPjwvZz48L2c+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjYuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNNzIgNjIgQzc4IDUyIDg2IDQ4IDkyIDQ4IE03MCA2MiBDNzIgNTIgNzAgNDQgNjQgNDAiLz48L2c+PHBhdGggZD0iTTExMy42IDQ1LjAgTDk4LjAgNTQuMCBMODIuNCA0NS4wIEw4Mi40IDI3LjAgTDk4LjAgMTguMCBMMTEzLjYgMjcuMFoiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI2IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB0cmFuc2Zvcm09InJvdGF0ZSgtMTAgOTggMzYpIi8+PC9nPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDY2IDcwKSByb3RhdGUoMCkgc2NhbGUoMS41NSkgdHJhbnNsYXRlKC02NiAtNzApIj48ZyBmaWxsPSIjMjIyMjNiIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMCI+PGVsbGlwc2UgY3g9IjMwIiBjeT0iODAiIHJ4PSIxNCIgcnk9IjEwIi8+PGNpcmNsZSBjeD0iNTAiIGN5PSI3NiIgcj0iOCIvPjxjaXJjbGUgY3g9IjY2IiBjeT0iNzAiIHI9IjEwIi8+PC9nPjxnIHN0cm9rZT0iIzIyMjIzYiIgc3Ryb2tlLXdpZHRoPSIzLjIyNTgwNjQ1MTYxMjkwMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNMjYgODYgbC02IDEyIE0zNCA4OCBsMiAxMiBNNDYgODIgbC00IDE2IE01MiA4NCBsOCAxNCIvPjwvZz48L2c+PGcgc3Ryb2tlPSIjMjIyMjNiIiBzdHJva2Utd2lkdGg9IjMuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48cGF0aCBkPSJNNzIgNjIgQzc4IDUyIDg2IDQ4IDkyIDQ4IE03MCA2MiBDNzIgNTIgNzAgNDQgNjQgNDAiLz48L2c+PHBhdGggZD0iTTExMy42IDQ1LjAgTDk4LjAgNTQuMCBMODIuNCA0NS4wIEw4Mi40IDI3LjAgTDk4LjAgMTguMCBMMTEzLjYgMjcuMFoiIGZpbGw9InVybCgjY2hjMDQtZykiIHN0cm9rZT0iIzIyMjIzYiIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB0cmFuc2Zvcm09InJvdGF0ZSgtMTAgOTggMzYpIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTggMzcpIHNjYWxlKDAuOSkiIGZpbGw9IiNmZmYiPjxjaXJjbGUgY3g9IjAiIGN5PSItNSIgcj0iNSIvPjxwYXRoIGQ9Ik0tOSA5IEMtOSAwIDkgMCA5IDlaIi8+PC9nPgo8L3N2Zz4K" }; /* ── provider bus ──────────────────────────────────────────────────────────── */ // MC asks with 'mc:discover'; each member script that has an adapter answers // with 'mc:provider'. Details are JSON strings, never objects: scripts run in // separate sandboxes, and Firefox's Xray wrappers hide an object's fields from // another realm. Contract: DEVELOP.md. const found = {}; // provider id -> { name, version, capabilities } document.addEventListener('mc:provider', e => { let d; try { d = JSON.parse(e.detail); } catch (x) { Log.warn('mc:provider with unreadable detail: ' + x.message); return; } if (!d || !d.id) { Log.warn('mc:provider without an id'); return; } found[d.id] = d; Log.info('provider ' + d.id + ' answered: ' + (d.name || '?') + ' v' + (d.version || '?') + ' · ' + JSON.stringify(d.capabilities || [])); if (ui) { paintBadges(); paintReleaseCredits(); fitCards(); } }); // Probe: MC sends 'mc:probe' { release, run, only }; a provider answers with // 'mc:progress' { id, run, state, note } while it works and 'mc:findings' // { id, run, findings } when done. Events from an older run are ignored. const results = {}; // provider id -> { state: 'busy' | 'done', note, findings } const picked = {}; // provider id -> Set of finding keys selected for Execute const fxOpen = new Set(); // tracks (recording MBIDs) whose Fusion comparison is open under their row const fxBusy = new Set(); // tracks whose group Fusion is checking (or is queued to check) for ISRCs and AcoustIDs const fxWaiters = new Map(); // track -> resolves when Fusion answers that track's check let fxCheckGen = 0; // a newer Fusion answer stops the check queue of the one before let run = null; function busEvent(e, kind) { let d; try { d = JSON.parse(e.detail); } catch (x) { Log.warn(kind + ' with unreadable detail: ' + x.message); return null; } if (!d || !d.id) { Log.warn(kind + ' without an id'); return null; } if (d.run !== run) { Log.debug(kind + ' from ' + d.id + ' for run ' + d.run + ', not the current ' + run + ' — ignored'); return null; } return d; } document.addEventListener('mc:progress', e => { const d = busEvent(e, 'mc:progress'); if (!d) return; const r = results[d.id]; // during Execute a provider that already answered keeps its findings: the note goes beside them // (a late tick from the probe, after its findings, is dropped: no stuck spinner) if (r && r.state === 'done') { if (executing) { r.working = d.note || 'working'; r.at = Date.now(); if (d.best) r.best = d.best; } } else results[d.id] = Object.assign(r || {}, { state: 'busy', note: d.note || '', at: Date.now() }); Log.debug('progress ' + d.id + ': ' + (d.note || d.state)); paintAll(); }); // #730: MusicBrainz's special-purpose entities — Various Artists, [anonymous], [data], [dialogue], // [no artist], [traditional], [unknown] and the label [no label]. Since MBS-14146 (musicbrainz-server // #3626) a regular editor can't edit any of them: the edit page never loads. The list is MusicBrainz's // own, root/static/scripts/common/constants.js (SPECIAL_ARTIST_GIDS, NOLABEL_GID). const SPECIAL_PURPOSE = new Set([ '89ad4ac3-39f7-470e-963a-56509c546377', 'f731ccc4-e22a-43af-a747-64213329e088', '33cf029c-63b0-41a0-9855-be2a3665fb3b', '314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc', 'eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61', '9be7f096-97ec-4615-8957-8d40b5dcbc41', '125ec42a-7229-4250-afc5-e057484327fe', '157afde4-4bf5-4039-8ad2-5a15acc85176', ]); const isSpecialPurpose = (type, mbid) => (type === 'artist' || type === 'label') && SPECIAL_PURPOSE.has(String(mbid || '').toLowerCase()); // a finding on one is left out (state 'none'), whichever provider sent it: Falcon couldn't apply it function dropSpecialPurpose(x) { if (x && x.entity && isSpecialPurpose(x.entity.type, x.entity.mbid) && x.state !== 'none') { Log.info('left out: ' + x.entity.type + ' ' + (x.entity.name || x.entity.mbid) + ' is special-purpose, not editable (#730)'); x.state = 'none'; x.why = 'special-purpose ' + x.entity.type + ': MusicBrainz lets only a few editors change it'; } return x; } document.addEventListener('mc:findings', e => { const d = busEvent(e, 'mc:findings'); if (!d) return; const findings = (Array.isArray(d.findings) ? d.findings : []).map(dropSpecialPurpose); results[d.id] = { state: 'done', findings, summary: d.summary || '', best: d.best || null, open: Array.isArray(d.open) ? d.open : null, barcode: d.barcode || null }; // selected by default: only what the provider is sure of picked[d.id] = new Set(findings.filter(x => x.state === 'new').map(x => x.key)); afterPicks(d.id); const tally = findings.reduce((t, x) => (t[x.state] = (t[x.state] || 0) + 1, t), {}); Log.ok('findings ' + d.id + ': ' + findings.length + ' ' + JSON.stringify(tally)); noteProbeDone(); if (d.id === 'fusion') fxAutoCheck(); findings.forEach(x => Log.debug(' ' + d.id + ' ' + x.key + ' ' + x.state + (x.why ? ' (' + x.why + ')' : '') + (x.url ? ' ' + x.url : ''))); paintAll(); syncLinks(); }); // a provider sends some findings again (Fusion, after checking one group's ISRCs and AcoustIDs): // each replaces the one with its key and keeps its selection, unless it can't be selected any more document.addEventListener('mc:update', e => { const d = busEvent(e, 'mc:update'); if (!d) return; (d.keys || []).forEach(k => { fxBusy.delete(k); const w = fxWaiters.get(k); if (w) { fxWaiters.delete(k); w(); } }); if (d.error) mbuToast(((PROVIDERS.find(p => p.id === d.id) || {}).provider || d.id) + ': ' + d.error); const r = results[d.id]; if (r && r.state === 'done' && r.findings) { for (const x of (Array.isArray(d.findings) ? d.findings : []).map(dropSpecialPurpose)) { const i = r.findings.findIndex(y => y.key === x.key); if (i === -1) r.findings.push(x); else r.findings[i] = x; if (!PICKABLE[x.state] && picked[d.id]) picked[d.id].delete(x.key); fxBusy.delete(x.key); } Log.ok('update ' + d.id + ': ' + (d.findings || []).length + ' finding(s)'); } paintAll(); }); // ISRC Scout reads the release's album links (Bandcamp, Spotify, Apple…) for its ISRCs and // recording links, Art Station for its cover (#724). The album links selected in Platform Check's // card are not on the release yet (Execute adds them first), so both are probed with them too: // again whenever the selected set changes, once the probe out is answered. Nothing is asked of // either before the first Probe. const laneOpen = new Set(); // PC barcode lanes opened into rows (#680), by barcode digits const LINK_READERS = ['is', 'as']; const linksAsked = {}; // provider id → the album links its current findings were probed with (JSON) function pcAlbumLinks() { const r = results.pc, set = picked.pc; if (!r || r.state !== 'done' || !set) return []; return (r.findings || []).filter(x => !x.entity && x.url && x.state !== 'linked' && x.state !== 'none' && set.has(x.key)).map(x => x.url).sort(); } let _linksTimer = 0; function syncLinks(delay) { clearTimeout(_linksTimer); if (delay) { _linksTimer = setTimeout(syncLinks, delay); return; } if (executing) return; const now = JSON.stringify(pcAlbumLinks()); for (const id of LINK_READERS) { const r = results[id]; if (!found[id] || !r || r.state === 'busy' || now === linksAsked[id]) continue; Log.info('the selected album links changed: asking ' + ((PROVIDERS.find(p => p.id === id) || {}).provider || id) + ' again (was ' + (linksAsked[id] || 'none') + ', now ' + now + ')'); probeOne(id); } } function probe() { run = Date.now().toString(36); fxCheckGen++; // the last answer's check queue stops for (const k in results) delete results[k]; for (const k in picked) delete picked[k]; const ask = PROVIDERS.filter(p => found[p.id] && modeOf(p) === 'auto').map(p => p.id); ask.forEach(id => { results[id] = { state: 'busy', note: '', at: Date.now() }; }); probeAt = Date.now(); probeTook = 0; LINK_READERS.forEach(id => { linksAsked[id] = '[]'; }); // nothing is selected yet Log.info('probe run ' + run + ' · asking ' + (ask.join(', ') || 'nobody') + ''); document.dispatchEvent(new CustomEvent('mc:probe', { detail: JSON.stringify({ release: RELEASE, run, only: ask, links: [], isrcs: afterIsrcs(), cons: afterCons() }) })); paintAll(); return ask.length; } // An Ask-mode provider is probed on its own Fetch button, inside the current run // (a run is started if there is none), leaving the others' results alone. function probeOne(id) { if (!found[id]) return; if (!run) run = Date.now().toString(36); delete picked[id]; if (id === 'fusion') fxCheckGen++; results[id] = { state: 'busy', note: '', at: Date.now() }; probeAt = Date.now(); probeTook = 0; const links = pcAlbumLinks(); if (LINK_READERS.includes(id)) linksAsked[id] = JSON.stringify(links); Log.info('probe ' + id + ' on request · run ' + run + (links.length ? ' · selected album links ' + links.join(' ') : '')); document.dispatchEvent(new CustomEvent('mc:probe', { detail: JSON.stringify({ release: RELEASE, run, only: [id], links, isrcs: afterIsrcs(), cons: afterCons() }) })); paintAll(); } // Execute: the steps in order, the lanes of a parallel step together. Each // provider with selected findings gets 'mc:apply' { id, run, release, keys, dry } // and answers 'mc:applied' { id, run, ok, sent, note }. A provider that doesn't // answer within APPLY_WAIT counts as failed, and the next step still runs. const APPLY_WAIT = 15000; let executing = false; function applyOne(id, dry) { const keys = Array.from(picked[id] || []); return new Promise(resolve => { // a provider still working says so with mc:progress, and each one restarts the wait (#680: a cover upload takes minutes) const done = o => { document.removeEventListener('mc:applied', on); document.removeEventListener('mc:progress', alive); clearTimeout(t); if (results[id]) delete results[id].working; resolve(o); }; const on = e => { const d = busEvent(e, 'mc:applied'); if (d && d.id === id) done(Object.assign({ keys }, d)); }; const timeout = () => setTimeout(() => done({ id, ok: false, sent: 0, note: 'no answer in ' + APPLY_WAIT / 1000 + ' s' }), APPLY_WAIT); let t = timeout(); const alive = e => { let d = null; try { d = JSON.parse(e.detail); } catch (x) { return; } if (d && d.id === id && d.run === run) { clearTimeout(t); t = timeout(); } }; document.addEventListener('mc:applied', on); document.addEventListener('mc:progress', alive); if (results[id]) Object.assign(results[id], { working: dry ? 'queueing' : 'applying', at: Date.now() }); Log.info((dry ? 'dry run' : 'apply') + ' ' + id + ': ' + keys.length + ' item(s) ' + JSON.stringify(keys)); document.dispatchEvent(new CustomEvent('mc:apply', { detail: JSON.stringify({ id, run, release: RELEASE, keys, dry: !!dry, mc: VERSION }) })); }); } // #680: a provider that hands its edits to Falcon tags the batch 'mc:<id>:<run>'. Falcon runs // it with its panel shut and reports each change as falcon:status; the card shows it. document.addEventListener('falcon:status', e => { let d = null; try { d = JSON.parse(e.detail); } catch (x) { return; } const m = d && /^mc:([a-z]+):(.+)$/.exec(d.tag || ''); if (!m || m[2] !== run || !results[m[1]]) return; const r = results[m[1]]; r.falcon = { running: !!d.running, items: d.items || [] }; r.at = Date.now(); // the findings of each item Falcon finished (done, or skipped as already there) are linked now const fin = (d.items || []).filter(i => i.status === 'done' || i.status === 'skipped'); const keys = (r.falconKeys || []).filter(k => { const x = (r.findings || []).find(f => f.key === k); return x && fin.some(i => x.entity ? i.mbid === x.entity.mbid : i.entityType === 'release'); }); if (keys.length) { markApplied(m[1], keys); r.falconKeys = r.falconKeys.filter(k => !keys.includes(k)); } Log.info('falcon ' + m[1] + ': ' + (d.items || []).map(i => i.entityType + ' ' + (i.name || i.mbid) + ' ' + i.status + (i.error ? ' (' + i.error + ')' : '')).join(' · ')); paintAll(); if (reloadPending) reloadWhenClean(); }); // reloadAfter: an Execute with no errors reloads the release page so it shows the new data. A batch // handed to Falcon runs in this page, so the reload waits for it and drops if an item went wrong. let reloadPending = false; function reloadWhenClean() { const fs = Object.values(results).map(r => r.falcon).filter(Boolean); if (fs.some(f => f.running)) return; reloadPending = false; if (fs.some(f => f.items.some(i => !['done', 'skipped'].includes(i.status)))) { Log.warn('reload after Execute dropped: Falcon reported a problem'); return; } Log.info('Execute done without errors: reloading the release page'); setTimeout(() => location.reload(), 1200); } const FALCON_MARK = { queued: ['…', 'waiting'], active: ['⟳', 'running'], done: ['✓', 'done'], skipped: ['✓', 'already there'], partial: ['!', 'partly done'], failed: ['✕', 'failed'], manual: ['✋', 'needs you'] }; // #680: the cover Art Station picked (headless), shown before Execute enters it // with the current front beside it when there is one, and what Execute would do function bestHtml(b) { if (!b) return ''; const kb = n => n ? (n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB') : ''; // a click on a cover opens it full screen, the other one a ← / → away (coverViewer) let n = 0; // `alt`: the full image, for a thumbnail that fails (a cover in an open edit has no CAA thumbnails yet) const fig = (src, alt, title, lines) => '<div class="mc-best-c">' + (src ? '<button type="button" class="mc-cov" data-act="cover" data-i="' + (n++) + '" title="Show full screen (← → to compare)"><img alt="" src="' + esc(src) + '"' + (alt && alt !== src ? ' data-alt="' + esc(alt) + '"' : '') + '></button>' : '<span class="mc-best-no">🖼</span>') + '<div class="mc-best-t"><b>' + esc(title) + '</b>' + lines.filter(Boolean).map(l => '<span>' + esc(l) + '</span>').join('') + '</div></div>'; const c = b.current; // the same size to the pixel and the byte: the front already is the best cover (imported earlier), not a comparison if (c && c.bytes && c.bytes === b.bytes && c.w === b.w && c.h === b.h) return '<div class="mc-best no"><div class="mc-best-row">' + fig(c.thumb || b.thumb, c.full || b.full, 'Front cover', [c.w + ' × ' + c.h + ' · ' + kb(c.bytes), 'the same image as the best found (' + (b.provider || '?') + ')']) + '</div><div class="mc-best-what">Already the best cover: nothing to do</div></div>'; // a front still 0×0 couldn't be measured: the Cover Art Archive hasn't processed it yet (#715) const unknown = c && !c.w; const what = !c ? 'Execute enters it as the front cover' : unknown ? "Size unknown: the Cover Art Archive hasn't processed the current front yet; probe again later (select a source to add it anyway)" : b.replace ? 'Larger than the current front: Execute enters it and removes the current one' : b.larger ? 'Larger than the current fronts: Execute adds it beside them' : 'Not larger than the current front: nothing to gain (select a source to add it anyway)'; return '<div class="mc-best' + (c && !b.larger ? ' no' : '') + '"><div class="mc-best-row">' + fig(b.thumb, b.full, 'Best cover', [b.provider || '?', b.w + ' × ' + b.h + (b.bytes ? ' · ' + kb(b.bytes) : ''), b.of > 1 ? 'the largest of ' + b.of + ' found' : 'the only one found']) + (c ? '<span class="mc-best-vs">' + (unknown ? '?' : b.larger ? '>' : '≤') + '</span>' + fig(c.thumb, c.full, 'Current front', [(unknown ? '? × ?' : c.w + ' × ' + c.h) + (c.bytes ? ' · ' + kb(c.bytes) : ''), unknown ? 'not processed yet' : '']) : '') + '</div><div class="mc-best-what">' + esc(what) + '</div></div>'; } // The covers of AS's card, full size: the best one found and the current front (one when they are // the same image). AS sends the best as a data URL (the file lives in its hidden frame) and the // front as the archive's own image; the previews stand in when an older AS sends neither. function coverList(b) { if (!b) return []; const kb = n => n ? (n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB') : ''; const c = b.current, same = c && c.bytes && c.bytes === b.bytes && c.w === b.w && c.h === b.h; const one = (src, title, w, h, bytes, from) => src ? { src, title, info: [from, w && h ? w + ' × ' + h : '', kb(bytes)].filter(Boolean).join(' · ') } : null; if (same) return [one(c.full || b.full || c.thumb || b.thumb, 'Front cover', c.w, c.h, c.bytes, b.provider)].filter(Boolean); return [one(b.full || b.thumb, 'Best cover', b.w, b.h, b.bytes, b.provider || ''), c ? one(c.full || c.thumb, 'Current front', c.w, c.h, c.bytes, 'Cover Art Archive') : null].filter(Boolean); } function coverViewer(list, at) { if (!list.length) return; document.getElementById('mc-cover')?.remove(); let i = Math.min(at || 0, list.length - 1); const v = el('div', ''); v.id = 'mc-cover'; v.setAttribute('role', 'dialog'); v.setAttribute('aria-label', 'Cover, full screen'); v.innerHTML = mbuHtml('<div class="mc-cov-top"><span class="mc-cov-t"></span><span class="mc-cov-i"></span><span class="mc-cov-n"></span><button type="button" class="mc-cov-x" title="Close (Esc)">✕</button></div>' + '<div class="mc-cov-stage"><img alt=""></div>' + (list.length > 1 ? '<button type="button" class="mc-cov-go prev" title="Previous (←)">‹</button><button type="button" class="mc-cov-go next" title="Next (→)">›</button>' : '')); const show = () => { const x = list[i]; v.querySelector('img').src = x.src; v.querySelector('.mc-cov-t').textContent = x.title; v.querySelector('.mc-cov-i').textContent = x.info; v.querySelector('.mc-cov-n').textContent = list.length > 1 ? (i + 1) + ' / ' + list.length : ''; }; const go = d => { i = (i + d + list.length) % list.length; show(); }; const done = () => { v.remove(); document.removeEventListener('keydown', key, true); }; const key = e => { if (e.key === 'Escape') done(); else if (e.key === 'ArrowLeft') go(-1); else if (e.key === 'ArrowRight') go(1); else return; e.preventDefault(); e.stopPropagation(); }; v.addEventListener('click', e => { if (e.target.closest('.mc-cov-go')) go(e.target.closest('.prev') ? -1 : 1); else if (e.target.closest('.mc-cov-x') || !e.target.closest('img, .mc-cov-top')) done(); }); document.addEventListener('keydown', key, true); (ui || document.body).appendChild(v); show(); } function falconHtml(f) { if (!f || !f.items.length) return ''; const settled = f.items.filter(i => !['queued', 'active'].includes(i.status)).length; const bad = f.items.filter(i => ['failed', 'partial', 'manual'].includes(i.status)).length; const head = 'Falcon ' + (settled < f.items.length ? 'is running: ' + settled + ' of ' + f.items.length + ' finished' : bad ? 'finished, ' + bad + ' need' + (bad === 1 ? 's' : '') + ' a look' : 'finished'); return '<div class="mc-falcon' + (bad ? ' bad' : settled === f.items.length ? ' ok' : '') + '"><div class="mc-falcon-h">' + esc(head) + (bad ? ' <button type="button" class="mc-falcon-open" data-act="falcon-open" title="Open Falcon\'s panel to see why and retry">Open Falcon</button>' : '') + '</div>' + f.items.map(i => { const k = FALCON_MARK[i.status] || ['?', i.status]; return '<div class="mc-falcon-i st-' + esc(i.status) + '" title="' + esc(k[1] + (i.error ? ': ' + i.error : '')) + '"><span class="mk">' + k[0] + '</span><span class="ty">' + esc(i.entityType.replace('_', ' ')) + '</span><span class="nm">' + esc(i.name || i.mbid) + '</span><span class="ct">' + esc([i.urls ? i.urls + ' link' + (i.urls === 1 ? '' : 's') : '', i.cover ? 'cover' : ''].filter(Boolean).join(' + ')) + '</span>' + (i.error ? '<span class="er">' + esc(i.error) + '</span>' : '') + '</div>'; }).join('') + '</div>'; } // #680: what a provider applied shows as linked from then on: its rows move to the linked icons, // a found link joins the track's linked icons, a found ISRC becomes the track's. Not picked any more. function markApplied(id, keys) { const r = results[id]; if (!r || !r.findings) return; const set = new Set(keys); if (id === 'is') { r.findings = r.findings.filter(x => { if (!set.has(x.key)) return true; if (x.kind === 'link') { const main = r.findings.find(f => f.track === x.track && !f.kind); if (main) { main.linkUrls = (main.linkUrls || []).concat(x.url); main.links = main.linkUrls.length; } return false; } if (x.isrc) { x.existing = (x.existing || []).concat(x.isrc); x.state = 'linked'; } return true; }); } else r.findings.forEach(x => { if (set.has(x.key) && PICKABLE[x.state]) { x.state = 'linked'; delete x.why; } }); keys.forEach(k => picked[id] && picked[id].delete(k)); Log.info('applied ' + id + ': ' + keys.length + ' item(s) now shown as linked'); } async function execute(dry) { if (executing) return; executing = true; paintExec(); let failed = 0, sent = 0; try { for (const step of STEPS) { const ids = (step.lanes || [step]).map(p => p.id) .filter(id => picked[id] && picked[id].size && !providerOff(id) && found[id] && (found[id].capabilities || []).includes('apply')); if (!ids.length) continue; Log.info('step ' + step.id + ': ' + ids.join(' ∥ ')); const outs = await Promise.all(ids.map(id => applyOne(id, dry))); outs.forEach(o => { results[o.id] = Object.assign(results[o.id] || {}, { applied: o }); // a batch handed to Falcon turns linked item by item, as Falcon reports it done (falcon:status) if (o.ok && !dry && o.sent && o.via !== 'falcon') markApplied(o.id, o.keys || []); // #748: a step that failed in part says what went through (doneKeys): that is done, and leaves Execute's count else if (!o.ok && !dry && Array.isArray(o.doneKeys) && o.doneKeys.length) markApplied(o.id, o.doneKeys); if (o.via === 'falcon') results[o.id].falconKeys = o.keys || []; if (o.ok) { sent += o.sent || 0; Log.ok(o.id + ': ' + (o.note || 'done')); } else { failed++; Log.err(o.id + ': ' + (o.note || 'failed')); } }); paintAll(); } } finally { executing = false; lastRun = { at: Date.now(), sent, failed, dry: !!dry }; Log.info('run done · ' + sent + ' sent · ' + failed + ' failed' + (dry ? ' · dry' : '')); paintExec(); paintFoot(); } mbuToast(failed ? failed + ' step' + (failed === 1 ? '' : 's') + ' failed: see the cards and the log' : (dry ? 'Dry run done: nothing was written' : 'Done: ' + sent + ' change' + (sent === 1 ? '' : 's') + ' applied or handed over'), { kind: failed ? 'error' : 'ok' }); if (S.reloadAfter && !dry && !failed && sent) { reloadPending = true; reloadWhenClean(); } } // #741: one provider applied again (its card's Retry), as Execute applies it async function retryOne(id) { if (executing || !picked[id] || !picked[id].size) return; executing = true; paintExec(); paintCards(); Log.info('retry ' + id + ': ' + picked[id].size + ' item(s)'); try { const o = await applyOne(id, false); results[id] = Object.assign(results[id] || {}, { applied: o }); if (o.ok && o.sent && o.via !== 'falcon') markApplied(id, o.keys || []); else if (!o.ok && Array.isArray(o.doneKeys) && o.doneKeys.length) markApplied(id, o.doneKeys); // #748 if (o.ok) Log.ok(id + ': ' + (o.note || 'done')); else Log.err(id + ': ' + (o.note || 'failed')); mbuToast(o.ok ? 'Done: ' + (o.note || id) : 'Failed again: ' + (o.note || id), { kind: o.ok ? 'ok' : 'error' }); } finally { executing = false; paintAll(); } } function discover() { Log.info('discover: asking providers for release ' + RELEASE); document.dispatchEvent(new CustomEvent('mc:discover', { detail: JSON.stringify({ release: RELEASE, mc: VERSION }) })); setTimeout(() => { const missing = PROVIDERS.filter(p => !found[p.id]).map(p => p.provider); if (missing.length) Log.info('no adapter yet: ' + missing.join(', ')); paintBadges(); }, 500); } /* ── release from the page ─────────────────────────────────────────────────── */ // Read once from the rendered page, no request: the header and the tracklist // are already there. function readRelease() { const h = document.querySelector('.releaseheader'); // the h1's own link: other scripts append to the h1 (ISRC Scout's "ISRC ✓ 12/12") const h1 = h && h.querySelector('h1'); const tl = h1 && (h1.querySelector('a[href*="/release/"] bdi') || h1.querySelector('a[href*="/release/"]')); const title = tl ? tl.textContent.trim() : h1 ? h1.textContent.trim() : document.title; const artist = h && h.querySelector('.subheader') ? Array.from(h.querySelectorAll('.subheader a[href*="/artist/"]')).map(a => a.textContent.trim()).join(', ') : ''; // the release group's versions, from the header's own "see all versions" link (as Apollo's zen title) const va = h && (h.querySelector('.subheader .small a[href*="/release-group/"]') || Array.from(h.querySelectorAll('.subheader a')).find(x => /version/i.test(x.textContent || ''))); // #722 by its address const vn = va && (va.textContent.match(/(\d+)/) || [])[1]; const versions = va ? { href: va.href, text: vn ? vn + (vn === '1' ? ' version' : ' versions') : 'all versions' } : null; const cover = document.querySelector('.cover-art img'); const fmt = document.querySelector('#sidebar dd.format'); const format = fmt ? fmt.textContent.trim() : ''; const tracks = []; let medium = 0; document.querySelectorAll('#content table.medium').forEach(tbl => { medium++; tbl.querySelectorAll('tbody tr').forEach(tr => { const rec = tr.querySelector('a[href*="/recording/"]'); if (!rec) return; const pos = tr.querySelector('td.pos') || tr.cells[0]; const len = Array.from(tr.cells).map(c => c.textContent.trim()).reverse().find(t => /^\d+:\d\d(:\d\d)?$/.test(t)) || ''; tracks.push({ medium, pos: pos ? pos.textContent.trim() : '', title: rec.textContent.trim(), len, rec: (rec.getAttribute('href').match(/[0-9a-f-]{36}/) || [])[0], }); }); }); Log.info('release: "' + title + '" by ' + (artist || '?') + ' · ' + location.origin + '/release/' + RELEASE + ' · ' + tracks.length + ' tracks on ' + medium + ' medium(s) · format ' + (format || '?')); return { title, artist, cover: cover ? cover.src : '', tracks, media: medium, format, versions }; } /* ── UI ────────────────────────────────────────────────────────────────────── */ const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = mbuHtml(html); return e; }; const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); let ui = null, rel = null, selected = null; function mcStyle() { if (document.getElementById('mc-style')) return; const s = document.createElement('style'); s.id = 'mc-style'; s.textContent = MBU_TOKENS + MBU_UI_CSS + '.mc-launch{position:fixed;z-index:2147483000;width:40px;height:40px;border-radius:50%;border:none;padding:0;display:flex;align-items:center;justify-content:center;cursor:pointer;' + 'background:transparent;box-shadow:none;opacity:.85;transition:opacity .15s,transform .1s}.mc-launch img{width:34px;height:34px;display:block;pointer-events:none}' + '.mc-launch:hover{opacity:1;transform:scale(1.08)}' + '#mc-root{position:fixed;inset:0;z-index:var(--mbu-z-modal);display:flex;flex-direction:column;background:var(--mbu-bg-sunken);color:var(--mbu-text);font:13px/1.4 var(--mbu-font)}' + '#mc-root *{box-sizing:border-box}' + '#mc-root .mono{font-family:var(--mbu-font-mono)}#mc-root .weak{color:var(--mbu-text-weak)}' // header: actions | album (center) | actions — Apollo's layout // an opacity pulse on a layer under the header's contents: the compositor runs it, so it stays // smooth while the page's thread is busy (a box-shadow pulse froze and jumped: flashing) + '.mc-hdr{position:relative;isolation:isolate}.mc-hdr::after{content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;opacity:0;background:color-mix(in srgb, var(--mbu-info) 18%, transparent)}' + '@keyframes mc-busy-pulse{0%,100%{opacity:0}50%{opacity:1}}.mc-hdr.mc-busy::after{animation:mc-busy-pulse 1.4s ease-in-out infinite;will-change:opacity}' + '@media (prefers-reduced-motion: reduce){.mc-hdr.mc-busy::after{animation:none;opacity:.5}}' + '.mc-sep{width:1px;height:20px;background:var(--mbu-border);margin:0 4px}' + '#mc-root .mc-btn{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--mbu-border);background:var(--mbu-bg);color:var(--mbu-text);padding:4px 10px;border-radius:var(--mbu-radius);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}' + '#mc-root .mc-btn:hover:not(:disabled){background:var(--mbu-bg-hover);border-color:var(--mbu-border-strong)}' + '#mc-root .mc-btn:disabled{opacity:.5;cursor:default}' + '#mc-root .mc-btn.ghost{background:transparent;border-color:transparent;color:var(--mbu-text-dim)}' + '#mc-root .mc-btn.primary{background:var(--mbu-accent);border-color:var(--mbu-accent);color:var(--mbu-accent-fg);font-weight:600}' + '#mc-root .mc-btn.primary:hover:not(:disabled){background:var(--mbu-accent-hover)}' + '#mc-root .mc-btn.tog{color:var(--mbu-text-weak)}#mc-root .mc-btn.tog.on{background:var(--mbu-accent-soft);border-color:var(--mbu-border-strong);color:var(--mbu-accent-text)}' + '#mc-root .mc-btn.lg{padding:7px 16px;font-size:13px}' // #680: one header row — the release, the steps (C, on one line), Execute (N), ⚙ and ✕ + '.mc-hdr{display:flex;align-items:center;gap:14px;height:52px;padding:0 10px 0 12px;background:var(--mbu-bg);border-bottom:1px solid var(--mbu-border);box-shadow:var(--mbu-shadow)}' + '.mc-fmt{flex:none;display:grid;place-items:center;width:30px;height:30px;color:var(--mbu-text-dim)}.mc-fmt svg{width:28px;height:28px;display:block}' + '.mc-rel{display:flex;flex-direction:column;min-width:0;flex:0 1 auto;max-width:40%;padding-right:20px;line-height:1.2}.mc-ttl{font-weight:700;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mc-art{font-size:12px;color:var(--mbu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#mc-root .mc-art .mc-ver{color:var(--mbu-text-weak);text-decoration:none}#mc-root .mc-art .mc-ver:hover{color:var(--mbu-accent-text);text-decoration:underline}' + '.mc-hdr .mc-act{display:flex;align-items:center;gap:4px;flex:none}#mc-root .mc-act .mc-exec{justify-content:center;min-width:96px;height:28px;padding:0 12px;font-size:12.5px;font-weight:600;font-variant-numeric:tabular-nums;background:var(--mbu-accent-soft);border-color:var(--mbu-border-strong);color:var(--mbu-accent-text)}#mc-root .mc-act .mc-exec:hover:not(:disabled){background:var(--mbu-accent);border-color:var(--mbu-accent);color:var(--mbu-accent-fg)}#mc-root .mc-act .mc-exec:disabled{background:var(--mbu-bg);border-color:var(--mbu-border);color:var(--mbu-text-weak);cursor:default}' // the steps: a ring, then the name and the state beside it, all on one centre line; a click switches the step off or on (#753) + '.mc-steps{display:flex;align-items:center;justify-content:center;gap:6px;flex:1 1 auto;min-width:0;height:100%}' + '#mc-root .mc-re{flex:none;width:26px;height:26px;margin-right:4px;padding:0;border-radius:50%;border:1px solid transparent;background:none;color:var(--mbu-text-dim);font-size:14px;line-height:1;cursor:pointer}#mc-root .mc-re:hover{border-color:var(--mbu-border);background:var(--mbu-bg-hover);color:var(--mbu-accent-text)}' + '.mc-step{display:inline-flex;align-items:center;gap:7px;min-width:0;flex:0 1 auto;padding:4px 6px;border-radius:var(--mbu-radius);cursor:pointer}.mc-step:hover{background:var(--mbu-bg-hover)}.mc-hdr .mc-fmt{cursor:pointer;border-radius:var(--mbu-radius)}.mc-hdr .mc-fmt:hover{background:var(--mbu-bg-hover)}' // a step's words take a fixed width (cut with …, the whole of it in the tooltip): changing words and // ticking seconds must not move the steps; only the window's width does + '.mc-step .tx{display:flex;flex-direction:column;flex:0 1 130px;width:130px;min-width:0;line-height:1.2}.mc-step b{font-size:12.5px;color:var(--mbu-text)}.mc-step i{font-style:normal;font-size:11.5px;color:var(--mbu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}' // #680: no ring: the provider's own icon, bigger, so it reads; the state rides on the badge and the text + '.mc-ring{position:relative;flex:none;display:grid;place-items:center;width:28px;height:28px}' + '.mc-ring img{width:28px;height:28px;object-fit:contain;display:block;margin:0;vertical-align:0}' + '.mc-ring sup{position:absolute;right:-7px;top:-6px;min-width:15px;height:15px;padding:0 3px;box-sizing:border-box;border-radius:8px;background:var(--mbu-accent);color:var(--mbu-text-on-accent);font-size:10px;font-weight:700;line-height:15px;text-align:center;box-shadow:0 0 0 2px var(--mbu-bg)}' + '.mc-step.ok sup{background:var(--mbu-ok)}' + '.mc-step.add i{color:var(--mbu-accent-text);font-weight:600}' + '.mc-step.busy .mc-ring::before{content:"";position:absolute;left:2px;right:2px;bottom:-6px;height:2px;border-radius:1px;background:linear-gradient(90deg,transparent,var(--mbu-info),transparent);background-size:200% 100%;animation:mc-sweep 1s linear infinite}@keyframes mc-sweep{from{background-position:100% 0}to{background-position:-100% 0}}.mc-step.busy i{color:var(--mbu-info)}' + '.mc-step.stalled sup{background:var(--mbu-warn)}.mc-step.stalled i,.mc-step.stalled b{color:var(--mbu-warn)}' + '.mc-step.err sup{background:var(--mbu-error)}.mc-step.err i{color:var(--mbu-error)}' + '.mc-step.wait .mc-ring img{filter:grayscale(1);opacity:.5}.mc-step.wait b{color:var(--mbu-text-weak)}.mc-step.off{opacity:.45}.mc-step.off .mc-ring img{filter:grayscale(1)}.mc-step.off b{text-decoration:line-through}' + '.mc-steps .mc-arrow{color:var(--mbu-text-weak);flex:none}.mc-steps .mc-par{display:inline-flex;align-items:center;gap:4px;height:40px;margin:0;background:none;padding:0 6px;border:1px dashed var(--mbu-border);border-radius:10px;box-sizing:border-box;min-width:0}' + '@media (prefers-reduced-motion: reduce){.mc-step.busy .mc-ring::before{animation:none}}' // the corner launchers (Falcon, Fusion, ours) share MC's z-index; hide them while MC is open + 'html.mc-open [data-mb-corner]{display:none!important}' // three panes + '.mc-main{flex:1;display:grid;grid-template-columns:300px 1fr 300px;min-height:0}' + '#mc-root.no-left .mc-main{grid-template-columns:1fr 300px}#mc-root.no-right .mc-main{grid-template-columns:300px 1fr}#mc-root.no-left.no-right .mc-main{grid-template-columns:1fr}' + '#mc-root.no-left .mc-side.left,#mc-root.no-right .mc-side.right{display:none}' + '.mc-side{background:var(--mbu-bg);border-right:1px solid var(--mbu-border);padding:12px;overflow:auto;min-width:0}.mc-side.right{border-right:0;border-left:1px solid var(--mbu-border)}' + '.mc-center{overflow:auto;padding:12px 14px;display:flex;flex-direction:column;gap:10px;min-width:0}' // flex items shrink by default: a long card squeezed the track list down to 3 rows + '.mc-center>*{flex:none}' + '.mc-sec{font-size:10.5px;letter-spacing:.8px;text-transform:uppercase;color:var(--mbu-text-weak);margin:0 0 8px;font-weight:700;display:flex;align-items:center;gap:6px}' + '.mc-x{margin-left:auto;cursor:pointer;font-size:14px;letter-spacing:0;color:var(--mbu-text-weak);background:none;border:0;padding:0 4px}' // B's vertical execution order (#680): the provider's icon on the rail with the step's number, what the // step does and its state chip, the script on its own small line, then only data; a footer with the runs + '.mc-stage{position:relative;display:grid;grid-template-columns:30px minmax(0,1fr);column-gap:10px;padding-bottom:14px}' + '.mc-stage::before{content:"";position:absolute;left:14px;top:36px;bottom:4px;width:2px;border-radius:1px;background:var(--mbu-border)}.mc-stage.last::before{display:none}' + '.mc-node{position:relative;width:30px;height:30px;display:grid;place-items:center}.mc-node .mc-sic{width:30px;height:30px;margin:0}.mc-node .par{font-size:18px;color:var(--mbu-accent-text)}' + '.mc-sic{width:15px;height:15px;object-fit:contain;vertical-align:-3px;margin-right:4px;flex:0 0 auto}' + '.mc-node .num{position:absolute;left:-4px;top:-4px;width:14px;height:14px;display:grid;place-items:center;border-radius:50%;border:1px solid var(--mbu-border);background:var(--mbu-bg);font:700 9px var(--mbu-font-mono);color:var(--mbu-text-dim)}' + '.mc-stage .nm{font-weight:600;font-size:13px;display:flex;align-items:center;gap:6px;line-height:1.25;min-height:18px}' + '.mc-pv{font-size:9.5px;letter-spacing:.5px;text-transform:uppercase;color:var(--mbu-text-weak);font-weight:600;line-height:1.3;margin-top:1px}' + '.mc-stage .meta{font-size:11.5px;color:var(--mbu-text-dim);line-height:1.35;margin-top:1px;overflow-wrap:anywhere}' + '.mc-stage.off .nm,.mc-stage.off .mc-pv,.mc-stage.off .meta,.mc-stage.off .mc-node{opacity:.45}' + '.mc-stage .tag{font:600 9.5px var(--mbu-font);letter-spacing:.5px;text-transform:uppercase;color:var(--mbu-accent-text)}' + '.mc-chip{margin-left:auto;flex:none;font:600 10.5px var(--mbu-font);padding:0 7px;border-radius:10px;white-space:nowrap;border:1px solid transparent;line-height:16px;font-variant-numeric:tabular-nums}.mc-chip:empty{display:none}' + '.mc-chip.ok{background:var(--mbu-ok-bg);color:var(--mbu-ok);border-color:var(--mbu-ok-border)}.mc-chip.info{background:var(--mbu-info-bg);color:var(--mbu-info);border-color:var(--mbu-info-border)}' + '.mc-chip.add{background:var(--mbu-accent-soft);color:var(--mbu-accent-text);border-color:var(--mbu-border-strong)}.mc-chip.busy{color:var(--mbu-info);border-color:var(--mbu-info-border)}' + '.mc-chip.err,.mc-chip.stalled{background:var(--mbu-warn-bg);color:var(--mbu-warn);border-color:var(--mbu-warn-border)}' // a parallel step's scripts: rows of their own on the same line, a smaller icon, no number + '.mc-stage.head{padding-bottom:8px}.mc-stage.sub{padding-bottom:10px}.mc-stage.sub .mc-node .mc-sic{width:24px;height:24px}.mc-stage.sub::before{top:31px}' + '.mc-stage.sub .nm{font-weight:500;font-size:12.5px}' + '.mc-covbar{height:4px;border-radius:2px;background:var(--mbu-bg-sunken);overflow:hidden;margin-top:5px;display:flex}.mc-covbar:empty{display:none}.mc-covbar i{display:block;height:100%}' + '.mc-covbar .linked{background:var(--mbu-ok)}.mc-covbar .new{background:var(--mbu-accent)}.mc-covbar .held{background:var(--mbu-warn)}.mc-covbar .none{background:var(--mbu-border-strong)}' + '.mc-hint{font-size:10.5px;color:var(--mbu-text-weak);margin-top:3px}' + '.mc-runs{border-top:1px solid var(--mbu-border);margin-top:2px;padding-top:10px;font-size:11.5px;color:var(--mbu-text-dim);display:grid;gap:4px}' + '.mc-runs>div{display:flex;justify-content:space-between;gap:8px}.mc-runs b{color:var(--mbu-text);font-weight:600;text-align:right}' + '#mc-root .mc-runs .mc-loglink{background:none;border:0;padding:0;font:inherit;color:var(--mbu-accent-text);cursor:pointer}#mc-root .mc-runs .mc-loglink:hover{text-decoration:underline}' + '.mc-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex:none;background:var(--mbu-border-strong)}.mc-dot.ready{background:var(--mbu-accent)}' + '.mc-seg{display:inline-flex;border:1px solid var(--mbu-border);border-radius:20px;overflow:hidden;margin-top:4px}' + '#mc-root .mc-seg button{font:600 10px var(--mbu-font);padding:2px 9px;color:var(--mbu-text-weak);cursor:pointer;background:none;border:0;border-radius:0}' + '#mc-root .mc-seg button.on{background:var(--mbu-accent);color:var(--mbu-accent-fg)}' // sections and the track matrix + '.mc-sect{border:1px solid var(--mbu-border);border-radius:var(--mbu-radius-lg);background:var(--mbu-bg);box-shadow:var(--mbu-shadow);overflow:hidden}' + '.mc-sect-h{display:flex;align-items:center;gap:8px;padding:7px 10px;background:var(--mbu-bg-raised);border-bottom:1px solid var(--mbu-border-soft)}' // a provider card's state, as its step's colour: a 4px stripe on its edge, its header tinted (#680) + '.mc-sect[data-st]{--st:transparent;--st-bg:var(--mbu-bg-raised)}.mc-sect[data-st=add]{--st:var(--mbu-accent);--st-bg:var(--mbu-accent-soft)}.mc-sect[data-st=ok]{--st:var(--mbu-ok);--st-bg:var(--mbu-ok-bg)}' + '.mc-sect[data-st=busy]{--st:var(--mbu-info);--st-bg:var(--mbu-info-bg)}.mc-sect[data-st=stalled]{--st:var(--mbu-warn);--st-bg:var(--mbu-warn-bg)}.mc-sect[data-st=err]{--st:var(--mbu-error);--st-bg:var(--mbu-error-bg)}.mc-sect[data-st=off]{--st:var(--mbu-text-weak);--st-bg:var(--mbu-bg-sunken)}' + '.mc-sect[data-st]{box-shadow:inset 4px 0 0 var(--st),var(--mbu-shadow)}.mc-sect[data-st]>.mc-sect-h{background:var(--st-bg)}' + '.mc-st{font-size:10.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--st)}.mc-st:empty{display:none}.mc-sect[data-st=add] .mc-st{color:var(--mbu-accent-text)}' + BC_CSS + '.mc-sect-h .ic{width:22px;height:22px;border-radius:6px;background:var(--mbu-accent-soft);display:grid;place-items:center;font-size:12px;flex:none}' + '.mc-sect-h .ic img{width:16px;height:16px;object-fit:contain;display:block}.mc-sect-h .ic.img{width:28px;height:28px;background:none}.mc-sect-h .ic.img img{width:28px;height:28px}.mc-sect-h .t{font-weight:700;font-size:12.5px}.mc-sect-h .p{font-size:10.5px;color:var(--mbu-text-weak)}' + '.mc-empty{padding:10px;color:var(--mbu-text-weak);font-size:12px}' + '.mc-line{display:grid;grid-template-columns:16px 16px 1fr auto;gap:8px;align-items:center;padding:4px 10px;border-bottom:1px solid var(--mbu-divider)}' // #751: Art Station's rows, the cover's size and the pill in columns of fixed width, so the rows line up + '.mc-line.mc-hassize{grid-template-columns:16px 16px minmax(0,1fr) 96px 76px}.mc-line.mc-hassize>.mc-pill{justify-self:center}' + '#mc-root a.mc-csize{justify-self:end;font:11px/1.4 ui-monospace,Consolas,monospace;font-variant-numeric:tabular-nums;color:var(--mbu-accent-text);text-decoration:none;padding:0 6px;border:1px solid var(--mbu-border);border-radius:5px;white-space:nowrap}' + '#mc-root a.mc-csize:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)}#mc-root span.mc-csize{justify-self:end;font:11px/1.4 var(--mbu-font);color:var(--mbu-text-weak);padding:0 7px;white-space:nowrap}' + '.mc-bc{display:inline-block;font:600 12px/1.5 ui-monospace,Consolas,monospace;color:var(--bc);background:color-mix(in srgb,var(--bc) 12%,transparent);border:1px solid color-mix(in srgb,var(--bc) 45%,transparent);border-radius:5px;padding:1px 6px;white-space:nowrap}' // #740: a lane's catalog number, a toggle of its own + '.mc-bcl.mc-hascat{grid-template-columns:10px max-content max-content minmax(0,1fr) auto auto}.mc-cats{display:flex;gap:4px;font:600 12px/1.5 ui-monospace,Consolas,monospace}.mc-cat{display:inline-block;font:inherit;color:var(--mbu-text);border:1px dotted var(--mbu-border-strong);border-radius:5px;padding:1px 6px;white-space:nowrap;cursor:pointer}' + '.mc-cat:hover{background:var(--mbu-bg-hover)}.mc-cat.on{border-style:solid;border-color:var(--mbu-accent);color:var(--mbu-accent-text);background:var(--mbu-accent-soft)}' + '.mc-bcl{display:grid;grid-template-columns:10px max-content minmax(0,1fr) auto auto;gap:8px;align-items:center;padding:4px 10px;border-bottom:1px solid var(--mbu-divider);cursor:pointer}.mc-bcl:hover{background:var(--mbu-bg-hover)}.mc-bcl.empty{cursor:default;opacity:.75}.mc-bcl.empty:hover{background:none}' + '.mc-tri{font-size:9px;color:var(--mbu-text-weak)}.mc-bcl-ic{display:flex;flex-wrap:wrap;gap:3px;min-width:0}.mc-bcl-no{font-size:11px;color:var(--mbu-text-weak)}' + '.mc-ti{display:inline-grid;place-items:center;width:24px;height:24px;border-radius:6px;border:1px solid transparent;opacity:.55}.mc-ti.mc-pick{cursor:pointer}.mc-ti.mc-pick:hover{opacity:1;border-color:var(--mbu-border-strong)}.mc-ti.on{opacity:1;border-color:var(--mbu-accent);background:var(--mbu-accent-soft)}.mc-ti.linked{opacity:1;position:relative}.mc-ti.linked::after{content:"✓";position:absolute;right:-1px;bottom:-4px;font-size:10px;font-weight:700;color:var(--mbu-ok)}' + '.mc-ti.off{position:relative}.mc-ti.off::before{content:"";position:absolute;right:-2px;top:-2px;width:8px;height:8px;border-radius:50%;background:var(--mbu-warn);border:1.5px solid var(--mbu-bg)}#mc-root .mc-line .s .mc-off{color:var(--mbu-warn);font-weight:600}.mc-bcn{font-family:var(--mbu-font-mono);font-weight:600}' + '#mc-root .mc-all{font:600 11px inherit;padding:2px 9px;border-radius:12px;border:1px solid var(--mbu-border-strong);background:var(--mbu-bg);color:var(--mbu-accent-text);cursor:pointer;white-space:nowrap}#mc-root .mc-all:hover{border-color:var(--mbu-accent)}' + '.mc-line.mc-in{padding-left:28px}.mc-bchint{padding:6px 10px;font-size:11px;color:var(--mbu-text-dim);background:var(--mbu-bg-raised);border-bottom:1px solid var(--mbu-divider)}' + '#mc-root .mc-sect-h .mc-icsw{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;gap:8px;min-width:0;cursor:pointer;padding:3px 8px 3px 3px;margin:-3px -8px -3px -3px;border-radius:8px;transition:background .12s}#mc-root .mc-icsw .ic{position:relative}' + '#mc-root .mc-sect-h .mc-icsw:hover{background:var(--mbu-bg-hover)}#mc-root .mc-sect-h .mc-icsw:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}' + '.mc-sect.mc-off .mc-icsw .ic{background:var(--mbu-bg-sunken)}.mc-sect.mc-off .mc-icsw .ic.img{background:none}.mc-sect.mc-off .mc-icsw .ic>*{filter:grayscale(1);opacity:.55}.mc-sect.mc-off .mc-icsw .t{opacity:.45}' + '.mc-retry{margin-left:8px;font:600 11px var(--mbu-font);padding:1px 10px;border-radius:999px;border:1px solid var(--mbu-error);background:var(--mbu-bg);color:var(--mbu-error);cursor:pointer}.mc-retry:hover{background:var(--mbu-error-bg)}' + '.mc-sect.mc-off>:not(.mc-sect-h){display:none}.mc-sect.mc-off .mc-sect-h{border-bottom:0}.mc-sect.mc-off .mc-sect-h>:not(.mc-icsw){opacity:.45}.mc-bc.none{--bc:var(--mbu-text-weak);border-style:dashed;background:none}.mc-bcl .mc-bc{display:inline-grid;justify-items:center}.mc-bcl .mc-bc>*{grid-area:1/1;font:inherit}.mc-bcl .mc-bc>i{visibility:hidden}.mc-line:last-child{border-bottom:0}' + '.mc-sect-h .end{margin-left:auto;display:flex;align-items:center;gap:6px}.mc-tapplied{display:flex;align-items:center;gap:6px}.mc-chlbl{font-size:13px;font-weight:700;color:var(--mbu-text)}' // CH's own toolbar look: an icon per source, its box only on hover, then an orange ⚛ All (#680) + '.mc-chopen{flex-wrap:wrap;justify-content:flex-end;gap:2px}#mc-root a.mc-chsrc{width:24px;height:24px;padding:0;justify-content:center;color:var(--mbu-text-dim);text-decoration:none;background:none;border-color:transparent}' + '#mc-root a.mc-chsrc:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-warn);color:var(--mbu-warn)}' + '#mc-root a.mc-chall{width:auto;height:26px;padding:0 9px;gap:5px;margin-left:8px;border-color:var(--mbu-warn);color:var(--mbu-warn);font-size:13.5px;font-weight:600}#mc-root a.mc-chall b{font-size:16px;font-weight:400;line-height:1}' + '#mc-root a.mc-chall:hover{background:var(--mbu-warn);color:var(--mbu-text-on-accent)}' + '.mc-sect-h .mc-applied{border:0;border-radius:20px;padding:1px 9px}' + '#mc-root .mc-linked{display:inline-flex;align-items:center;gap:3px;padding:2px 7px;border:1px solid transparent;border-radius:20px;background:none;cursor:pointer;opacity:.75}' + '#mc-root .mc-linked:hover,#mc-root .mc-linked.on{opacity:1;border-color:var(--mbu-ok-border);background:var(--mbu-ok-bg)}' + '.mc-sub .mc-linked{margin:-2px 0;text-transform:none;letter-spacing:0}.mc-lk{font-size:10.5px;font-weight:700;color:var(--mbu-ok);margin-left:3px}' + '#mc-root .mc-fetch{font:600 10px var(--mbu-font);text-transform:none;letter-spacing:0;padding:1px 8px;margin-left:4px;border:1px solid var(--mbu-border-strong);border-radius:20px;background:var(--mbu-accent-soft);color:var(--mbu-accent-text);cursor:pointer}' + '.mc-add{color:var(--mbu-accent-text);font-weight:600}.mc-warn{color:var(--mbu-warn);font-weight:600}.mc-err{color:var(--mbu-error);font-weight:600}.mc-why{font-size:11px;color:var(--mbu-warn);margin:2px 0 4px}' // #680: no tick boxes; a pickable row/cell is the toggle, tinted with a ✓ once taken in + '.mc-line.mc-pick{cursor:pointer}.mc-line.mc-pick:hover{background:var(--mbu-bg-hover)}' + '.mc-line.on{background:var(--mbu-accent-soft);box-shadow:inset 3px 0 0 var(--mbu-accent)}.mc-line.on:hover{background:var(--mbu-accent-soft)}' + '.mc-tick{display:block;min-height:16px;line-height:16px;color:var(--mbu-accent-text);font-weight:700;text-align:center}' + '.mc-tbl .mc-pick{display:inline-block;cursor:pointer;padding:1px 6px;margin:-1px -6px;border-radius:var(--mbu-radius);border:1px dashed transparent}' + '.mc-tbl .mc-pick:hover{border-color:var(--mbu-border-strong)}.mc-tbl .mc-pick.on{background:var(--mbu-accent-soft);border:1px solid var(--mbu-border-strong)}' + '.mc-tbl .mc-pick:not(.on) .mc-add{opacity:.55;text-decoration:line-through}' + '.mc-sub{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:28px;box-sizing:border-box;font-size:10px;letter-spacing:.6px;text-transform:uppercase;font-weight:700;color:var(--mbu-text-weak);padding:6px 10px 3px;background:var(--mbu-bg-raised);border-bottom:1px solid var(--mbu-divider)}' + '.mc-tbl .mc-lnk{text-decoration:none;display:inline-flex;align-items:center;gap:1px;margin:0;padding:1px 4px;font-weight:700;color:var(--mbu-accent-text)}.mc-tbl .mc-lnk:not(.on){opacity:.45}' + '.mc-have{opacity:.8}.mc-isep{width:1px;height:14px;background:var(--mbu-border);margin:0 2px}' + '.mc-icons{display:inline-flex;gap:4px;align-items:center}.mc-icons a.mc-pico:hover{transform:scale(1.15)}' + '.mc-summary{padding:5px 10px;font-size:11.5px;color:var(--mbu-text-dim);border-bottom:1px solid var(--mbu-divider)}' + '.mc-best{padding:8px 10px;border-bottom:1px solid var(--mbu-divider)}.mc-best-row{display:flex;gap:10px;align-items:center}.mc-best-c{flex:1 1 0;min-width:0}.mc-best-c{display:flex;gap:10px;align-items:flex-start}.mc-best-vs{font-size:20px;font-weight:700;color:var(--mbu-text-weak)}' + '.mc-best-what{margin-top:6px;font-size:11.5px;font-weight:600;color:var(--mbu-ok)}.mc-best.no .mc-best-what{color:var(--mbu-text-weak)}.mc-cov{all:unset;cursor:zoom-in;display:block;flex:0 0 auto;border-radius:var(--mbu-radius)}.mc-cov:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:2px}' + '#mc-cover{position:fixed;inset:0;z-index:1;display:flex;flex-direction:column;background:rgba(10,8,16,.94);color:#fff}' + '.mc-cov-top{display:flex;align-items:center;gap:14px;padding:10px 16px;font-size:14px}.mc-cov-t{font-weight:700;font-size:15px}.mc-cov-i{color:rgba(255,255,255,.75)}.mc-cov-n{margin-left:auto;color:rgba(255,255,255,.6);font-variant-numeric:tabular-nums}' + '#mc-cover button{all:unset;cursor:pointer;color:#fff;display:flex;align-items:center;justify-content:center;border-radius:50%;background:rgba(255,255,255,.12)}#mc-cover button:hover{background:rgba(255,255,255,.26)}' + '.mc-cov-x{width:36px;height:36px;font-size:18px}.mc-cov-stage{flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;padding:0 72px 16px}' + '.mc-cov-stage img{max-width:100%;max-height:100%;object-fit:contain;box-shadow:0 6px 40px rgba(0,0,0,.6);cursor:default}' + '#mc-cover .mc-cov-go{position:absolute;top:50%;transform:translateY(-50%);width:52px;height:52px;font-size:34px;line-height:1}#mc-cover .mc-cov-go.prev{left:12px}#mc-cover .mc-cov-go.next{right:12px}' + '.mc-best img{width:72px;height:72px;object-fit:contain;border-radius:var(--mbu-radius);background:var(--mbu-bg-sunken);border:1px solid var(--mbu-border-soft);flex:0 0 auto}' + '.mc-best-no{width:72px;height:72px;display:flex;align-items:center;justify-content:center;font-size:32px;background:var(--mbu-bg-sunken);border-radius:var(--mbu-radius);flex:0 0 auto}.mc-best-t{display:flex;flex-direction:column;gap:2px;font-size:12px;min-width:0}.mc-best-t .dim{color:var(--mbu-text-weak);font-size:11px}' + '.mc-working{display:flex;align-items:center;gap:7px;padding:5px 10px;font-size:11.5px;color:var(--mbu-accent-text);border-bottom:1px solid var(--mbu-divider)}' + '.mc-spin{width:10px;height:10px;border:2px solid var(--mbu-accent-soft);border-top-color:var(--mbu-accent);border-radius:50%;animation:mc-spin .8s linear infinite;flex:0 0 auto}@keyframes mc-spin{to{transform:rotate(360deg)}}' + '.mc-pico sub{font-size:9px;font-weight:700;margin-left:1px}.mc-pico.mc-ent{width:auto}' + '.mc-falcon{border-bottom:1px solid var(--mbu-divider);padding:5px 10px;font-size:11.5px}.mc-falcon-h{display:flex;align-items:center;gap:8px;font-weight:600;color:var(--mbu-text-dim);margin-bottom:3px}.mc-falcon.ok .mc-falcon-h{color:var(--mbu-ok)}.mc-falcon.bad .mc-falcon-h{color:var(--mbu-warn)}' + '.mc-falcon-open{margin-left:auto;font:inherit;font-weight:600;padding:1px 8px;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);cursor:pointer}' + '.mc-falcon-i{display:flex;align-items:baseline;gap:7px;padding:1px 0;min-width:0}.mc-falcon-i .mk{flex:0 0 14px;text-align:center;font-weight:700}.mc-falcon-i .ty{flex:0 0 auto;color:var(--mbu-text-weak);text-transform:capitalize}.mc-falcon-i .nm{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mc-falcon-i .ct{flex:0 0 auto;color:var(--mbu-text-weak)}.mc-falcon-i .er{flex:1 1 0;min-width:0;color:var(--mbu-error);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + '.mc-falcon-i.st-done .mk,.mc-falcon-i.st-skipped .mk{color:var(--mbu-ok)}.mc-falcon-i.st-failed .mk{color:var(--mbu-error)}.mc-falcon-i.st-partial .mk,.mc-falcon-i.st-manual .mk{color:var(--mbu-warn)}.mc-falcon-i.st-active .mk{color:var(--mbu-accent-text)}' + '.mc-applied{padding:5px 10px;font-size:11.5px;font-weight:600;border-bottom:1px solid var(--mbu-divider)}.mc-applied.ok{color:var(--mbu-ok);background:var(--mbu-ok-bg)}.mc-applied.err{color:var(--mbu-error);background:var(--mbu-error-bg)}' + '.mc-none{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px 10px;font-size:11px;color:var(--mbu-text-weak)}.mc-none span:first-child{margin-right:4px}.mc-none .mc-pico{opacity:.6}' + '.mc-line.linked,.mc-line.none{opacity:.7}.mc-line .mc-lt{min-width:0}.mc-line .t{font-size:12px}' + '#mc-root .mc-line .s{display:block;width:fit-content;max-width:100%;font-size:10.5px;color:var(--mbu-text-weak);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none}#mc-root .mc-line a.s:hover{color:var(--mbu-accent-text);text-decoration:underline}' + '.mc-pico{display:inline-flex;width:16px;height:16px;align-items:center;justify-content:center}.mc-pico svg{display:block}' + '.mc-pill{font-size:10px;font-weight:700;padding:1px 7px;border-radius:20px;white-space:nowrap;border:1px solid var(--mbu-border)}' + '.mc-pill.add{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border-strong)}.mc-pill.ok{color:var(--mbu-ok);background:var(--mbu-ok-bg);border-color:var(--mbu-ok-border)}' + '.mc-pill.warn{color:var(--mbu-warn);background:var(--mbu-warn-bg);border-color:var(--mbu-warn-border)}.mc-pill.idle{color:var(--mbu-text-weak);border-style:dashed}.mc-pill.err{color:var(--mbu-error);border-color:var(--mbu-error)}' + '.mc-dot.busy{background:var(--mbu-info)}.mc-dot.add{background:var(--mbu-accent)}.mc-dot.ok{background:var(--mbu-ok)}' + '.mc-row2{display:flex;gap:10px;align-items:flex-start}.mc-col{flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:10px}.mc-col>.mc-sect{margin:0}' + '.mc-tbl{width:100%;border-collapse:collapse;font-size:12px}' + '.mc-tbl th{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--mbu-text-weak);text-align:left;padding:6px 8px;background:var(--mbu-bg-raised);border-bottom:1px solid var(--mbu-border);position:sticky;top:0;white-space:nowrap}' + '.mc-tbl td{padding:4px 8px;border-bottom:1px solid var(--mbu-divider);white-space:nowrap}' + '.mc-tbl td.n{color:var(--mbu-text-weak);font-family:var(--mbu-font-mono);width:32px}' + '.mc-tbl td.ttl{white-space:normal}' + '.mc-tbl td.pend{color:var(--mbu-border-strong)}' + '.mc-tbl tbody tr{cursor:pointer}.mc-tbl tbody tr:hover td{background:var(--mbu-bg-hover)}.mc-tbl tbody tr.sel td{background:color-mix(in srgb, var(--mbu-accent) 22%, var(--mbu-bg))}.mc-tbl tbody tr.chg td:first-child{box-shadow:inset 3px 0 0 var(--mbu-accent)}.mc-sect-h .p.mc-tpend.on{color:var(--mbu-accent-text);font-weight:700}' + '.mc-tbl tr.med td{background:var(--mbu-bg-raised);font-size:10.5px;font-weight:700;color:var(--mbu-text-weak);cursor:default}' + '.mc-mode{font-size:9.5px;font-weight:700;padding:0 6px;border-radius:20px;border:1px dashed var(--mbu-border-strong);color:var(--mbu-text-weak);margin-left:4px;text-transform:none;letter-spacing:0}' + '.mc-mini{display:flex;align-items:center;gap:6px;font-size:11.5px;padding:3px 0;border-bottom:1px solid var(--mbu-divider)}.mc-mini:last-child{border-bottom:0}' + '.mc-mini .n{margin-left:auto;font-family:var(--mbu-font-mono);font-size:10.5px;color:var(--mbu-text-dim);white-space:nowrap;flex:none;padding-left:6px}.mc-mini a:not(.n){min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + '.mc-insp-block{margin-bottom:12px}' + '.mc-fxo{display:inline-flex;align-items:center;gap:4px;font:inherit;font-size:11px;font-weight:700;padding:1px 8px;border-radius:20px;cursor:pointer;color:var(--mbu-warn);background:var(--mbu-warn-bg);border:1px solid var(--mbu-warn-border)}' + '.mc-fxgo{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border-radius:8px;cursor:pointer;background:transparent;border:1px solid transparent}.mc-fxgo:hover,.mc-fxgo:focus-visible{background:var(--mbu-bg);border-color:var(--mbu-accent)}.mc-fxgo img{width:20px;height:20px;display:block}' + '.mc-fxo:hover{border-color:var(--mbu-warn)}.mc-fxp{font-size:11px;font-weight:700;color:var(--mbu-warn);text-decoration:none}.mc-fxp:hover{text-decoration:underline}' + '.mc-fxo .car{display:inline-block;transition:transform .15s}.mc-fxo[aria-expanded="true"] .car{transform:rotate(90deg)}' + '.mc-tbl tr.mc-fxd{cursor:default}.mc-tbl tr.mc-fxd>td,.mc-tbl tr.mc-fxd:hover>td{background:var(--mbu-bg-sunken);white-space:normal;padding:10px 12px 12px 40px}' + '.mc-fxh{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px;font-size:11.5px}.mc-fxh .sp{flex:1}' + '.mc-fxc{font-size:11px;border-radius:5px;padding:1px 7px;border:1px solid var(--mbu-border);color:var(--mbu-text-weak);background:var(--mbu-bg)}' + '.mc-fxc.on{background:var(--mbu-ok-bg);color:var(--mbu-ok);border-color:var(--mbu-ok-border);font-weight:700}.mc-fxc.some{color:var(--mbu-ok);border-color:var(--mbu-ok-border)}.mc-fxc.unk{border-style:dashed}' + '.mc-fxt{font-size:11px;font-weight:700;border-radius:5px;padding:0 7px;border:1px solid var(--mbu-border);color:var(--mbu-text-weak);font-family:var(--mbu-font)}' + '.mc-fxt.strict{color:var(--mbu-ok);background:var(--mbu-ok-bg);border-color:var(--mbu-ok-border)}.mc-fxt.normal{color:var(--mbu-info);background:var(--mbu-info-bg);border-color:var(--mbu-info-border)}.mc-fxt.loose{color:var(--mbu-warn);background:var(--mbu-warn-bg);border-color:var(--mbu-warn-border)}' + '.mc-fxwrap{overflow-x:auto}.mc-fxtbl{border-collapse:collapse;background:var(--mbu-bg);border:1px solid var(--mbu-border);font-size:12px}' + '.mc-tbl .mc-fxtbl th{position:static;background:var(--mbu-bg-raised)}.mc-tbl .mc-fxtbl tr,.mc-tbl .mc-fxtbl tr:hover td{cursor:default;background:var(--mbu-bg)}' + '.mc-tbl .mc-fxtbl td{padding:5px 8px;vertical-align:top;background:var(--mbu-bg)}.mc-tbl .mc-fxtbl tr:last-child td{border-bottom:0}' + '.mc-fxtbl .who{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--mbu-text-weak)}' + '.mc-fxdiff{background:var(--mbu-warn-bg);color:var(--mbu-warn);border-radius:4px;padding:0 4px}.mc-fxs::after{content:" ✓";color:var(--mbu-ok)}' + '.mc-fxn{margin-top:6px;font-size:11.5px;color:var(--mbu-text-weak)}' + '.mc-fxm{border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:2px 8px 6px;margin:6px 0;background:var(--mbu-bg-raised)}' + '.mc-kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1px 10px;font-size:11.5px}.mc-kv>span:nth-child(odd){color:var(--mbu-text-weak)}' + '@media (prefers-reduced-motion:reduce){.mc-fxo .car{transition:none}}' + '.mc-cfg label{display:flex;align-items:center;gap:10px;margin:8px 0;font-size:13px}.mc-cfg label span{min-width:150px}'; document.head.appendChild(s); } function modeOf(p) { return p.mode ? S[p.mode] : (S.disabled || []).includes(p.id) ? 'off' : 'auto'; } // #753: a step in the header switches its provider off (not probed, its column, card and Execute // left out) and on again: Fusion and CH back to the mode they had, the others to Auto const onMode = {}; function toggleProvider(id) { const p = PROVIDERS.find(x => x.id === id); if (!p) return; const on = modeOf(p) === 'off'; if (p.mode) { if (!on) onMode[id] = S[p.mode]; setMode(p.mode, on ? onMode[id] || DEFAULTS[p.mode] : 'off'); return; } Log.info(id + ' ' + (on ? 'on' : 'off')); S.disabled = (S.disabled || []).filter(x => x !== id).concat(on ? [] : [id]); saveSettings(); if (ui) rebuildBody(); } function stateOf(p) { if (modeOf(p) === 'off') return 'off'; if (!found[p.id]) return 'idle'; const r = results[p.id]; if (!r) return 'ready'; if (r.state === 'busy') return 'busy'; return countNew(p.id) ? 'add' : 'ok'; } // an info-only provider (CH) counts what it found instead of what it would add function infoCount(id) { const r = results[id]; return r && r.findings ? r.findings.reduce((n, x) => n + (x.credits || 0), 0) : 0; } // a 'searched' row (AS's sources other than the best cover's) is in the pool, not something entered function countNew(id) { const r = results[id]; return r && r.findings ? r.findings.filter(x => x.state === 'new' && x.role !== 'searched').length : 0; } function stateText(p) { const st = stateOf(p), r = results[p.id]; if (st === 'off') return 'off'; if (st === 'idle') return 'no adapter yet'; if (st === 'ready') return 'connected · not probed'; if (st === 'busy') return 'probing' + (r.note ? ': ' + r.note : '') + '…'; if (r.summary && !r.findings.some(x => PICKABLE[x.state])) return r.summary; const t = r.findings.reduce((a, x) => { const k = x.role === 'searched' ? 'searched' : x.state; a[k] = (a[k] || 0) + 1; return a; }, {}); return [t.new && t.new + ' new', t.searched && t.searched + ' searched', t.linked && t.linked + ' linked', t.withheld && t.withheld + ' withheld', t.unsure && t.unsure + ' unsure', t.blocked && t.blocked + ' blocked', t.none && t.none + ' not found'].filter(Boolean).join(' · ') || 'nothing'; } function paintAll() { if (!ui) return; paintBadges(); paintCards(); paintReleaseCredits(); fitCards(); paintMatrix(); paintInspector(); paintExec(); } function header() { const h = el('header', 'mc-hdr'); h.addEventListener('animationiteration', e => { if (e.target === h && e.pseudoElement === '::after' && !isBusy()) h.classList.remove('mc-busy'); }); // #680: the release where Probe was (the cover is the one in the Cover art card), the steps, then // Execute with its count; logo, version and Auto probe live in ⚙ h.innerHTML = mbuHtml(fmtIcon(rel.format) + '<div class="mc-rel"><div class="mc-ttl" title="' + esc(rel.title) + '">' + esc(rel.title) + '</div>' + '<div class="mc-art" title="' + esc(rel.artist) + '">' + esc(rel.artist || '') + (rel.versions ? ' <a class="mc-ver" href="' + esc(rel.versions.href) + '" target="_blank" rel="noopener" title="All versions of this release">(' + esc(rel.versions.text) + ')</a>' : '') + '</div></div>' + '<div class="mc-act"><button type="button" class="mc-btn mc-exec" data-act="exec" disabled title="Apply the selected changes, step by step">Execute</button>' + '<button type="button" class="mc-btn ghost" data-act="cfg" title="Settings">' + MBU_CFG_ICON + '</button>' + '<button type="button" class="mc-btn ghost" data-act="close" title="Close (Esc)">✕</button></div>'); h.insertBefore(steps(), h.querySelector('.mc-act')); return h; } // #680: the release's format as an icon left of the title, its name in the tooltip. Only the // first medium counts ("2×CD + DVD" is a CD); a format with no icon of its own is a disc. const FORMAT_ICONS = { digital: '<path d="M8 25h16a6 6 0 0 0 .8-11.95A9 9 0 0 0 7.4 15.2 5 5 0 0 0 8 25z"/>', // a record sliding out of its sleeve, so it doesn't read as a CD vinyl: '<circle cx="19" cy="16" r="10.5"/><circle cx="19" cy="16" r="6.5" stroke-width="1" opacity=".5"/><circle cx="19" cy="16" r="2.4" fill="currentColor"/><rect x="3" y="4" width="14" height="24" rx="1.5" fill="var(--mbu-bg)"/>', cassette: '<rect x="3" y="7" width="26" height="18" rx="2.5"/><circle cx="11" cy="15" r="2.6"/><circle cx="21" cy="15" r="2.6"/><path d="M11 12.4h10M9 25l2-4h10l2 4"/>', reel: '<circle cx="9" cy="12" r="6.5"/><circle cx="23" cy="12" r="6.5"/><circle cx="9" cy="12" r="1.6" fill="currentColor"/><circle cx="23" cy="12" r="1.6" fill="currentColor"/><path d="M9 18.5l3 8.5h8l3-8.5"/>', video: '<rect x="2.5" y="8" width="27" height="16" rx="1.5"/><rect x="10" y="12" width="12" height="7" rx="1"/><circle cx="13" cy="15.5" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="15.5" r="1.4" fill="currentColor" stroke="none"/><path d="M6 21h20" stroke-width="1.4" opacity=".55"/>', usb: '<rect x="10" y="12" width="12" height="17" rx="2.5"/><path d="M12 12V4h8v8"/><path d="M14.5 7.5h0M17.5 7.5h0" stroke-width="2.4"/>', card: '<path d="M10 3h12a2 2 0 0 1 2 2v22a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7z"/><path d="M12 4v5M15.5 4v5M19 4v5" stroke-width="1.6"/>', floppy: '<path d="M5 5h18l4 4v18H5z"/><path d="M10 5v7h11V5"/><path d="M18 7.5v2.5"/><rect x="9" y="17" width="14" height="10"/>', other: '<rect x="4" y="4" width="24" height="24" rx="5"/><path d="M11 16h0M16 16h0M21 16h0" stroke-width="3"/>', disc: '<circle cx="16" cy="16" r="13"/><circle cx="16" cy="16" r="3.5"/><path d="M16 7a9 9 0 0 1 9 9" opacity=".55"/>', }; const FORMAT_KINDS = [ ['digital', /digital|download/i], ['vinyl', /vinyl|"|shellac|flexi|acetate|phonograph/i], ['reel', /reel/i], ['video', /vhs|betamax|video8|u-matic|\bhi8\b|\bvhd\b/i], ['cassette', /cassette|tape|cartridge|\bDAT\b|\bDCC\b/i], ['usb', /usb/i], ['card', /\bcard\b/i], ['floppy', /floppy|zip disk/i], ['other', /^\s*other\s*$/i], ]; // #753: a click on it shows or hides the execution order, so it is there with no format too function fmtIcon(format) { const first = (format || '').split('+')[0]; const k = (format && FORMAT_KINDS.find(([, re]) => re.test(first)) || ['disc'])[0]; return '<span class="mc-fmt" title="' + esc((format || 'Format unknown') + ' · click: show or hide the execution order') + '"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round">' + FORMAT_ICONS[k] + '</svg></span>'; } function seg(p) { return '<div class="mc-seg" data-mode="' + p.mode + '" title="Auto: fetch during Probe · Ask: fetch on a button · Off: skip">' + ['auto', 'ask', 'off'].map(m => '<button type="button" data-v="' + m + '"' + (S[p.mode] === m ? ' class="on"' : '') + '>' + m[0].toUpperCase() + m.slice(1) + '</button>').join('') + '</div>'; } function orderSidebar() { const a = el('aside', 'mc-side left'); let html = '<h2 class="mc-sec">Execution order <button type="button" class="mc-x" data-close="left" title="Hide">×</button></h2>'; const pv = p => '<div class="mc-pv">' + esc(p.provider) + '</div>'; STEPS.forEach((s, k) => { const node = '<span class="mc-node">' + (s.lanes ? '<span class="par" title="run in parallel">⇶</span>' : sIcon(s.id)) + '<span class="num">' + (k + 1) + '</span></span>'; const last = k === STEPS.length - 1 ? ' last' : ''; if (s.lanes) { html += '<div class="mc-stage head">' + node + '<div><div class="nm">' + esc(s.name) + ' <span class="tag">parallel</span></div></div></div>' + s.lanes.map((l, n) => '<div class="mc-stage sub' + (n === s.lanes.length - 1 ? last : '') + (modeOf(l) === 'off' ? ' off' : '') + '" data-p="' + l.id + '"><span class="mc-node">' + sIcon(l.id) + '</span>' + '<div><div class="nm">' + esc(l.name) + '<span class="mc-chip"></span></div>' + pv(l) + '<div class="meta st"></div></div></div>').join(''); } else { html += '<div class="mc-stage' + last + (modeOf(s) === 'off' ? ' off' : '') + '" data-p="' + s.id + '">' + node + '<div><div class="nm">' + esc(s.name) + (s.info ? ' <span class="mc-mode">info</span>' : '') + '<span class="mc-chip"></span></div>' + pv(s) + '<div class="meta st"></div>' + (s.id === 'pc' ? '<div class="mc-covbar"></div>' : '') + (s.mode ? seg(s) + '<div class="mc-hint">' + modeHint(s) + '</div>' : '') + '</div></div>'; } }); html += '<div class="mc-runs"><div><span>Probed</span><b class="f-probe"></b></div><div><span>On Execute</span><b class="f-exec"></b></div>' + '<div><span>Last run</span><b><span class="f-run"></span> · <button type="button" class="mc-loglink" data-act="log" title="Mission Control’s log">Log</button></b></div></div>'; a.innerHTML = mbuHtml(html); return a; } function modeHint(p) { return { auto: 'fetches during Probe', ask: 'waits for ' + (FETCH_LABEL[p.id] || 'Fetch'), off: 'left out' }[modeOf(p)] || ''; } // the sidebar's words for a step: only data (the script has its own line) function sideText(p) { const st = stateOf(p), r = results[p.id]; if (st === 'off') return 'off'; if (st === 'idle') return 'not installed'; if (st === 'ready') return modeOf(p) === 'ask' ? 'not fetched yet' : 'not probed yet'; let t = stateText(p); // CH: "Qobuz + Deezer: 99 credits on 18 of 18 tracks" → the count first, the sources after const m = p.id === 'ch' && /^([^:]+): (.+)$/.exec(t); if (m) t = m[2] + ', from ' + m[1]; // IS: how many tracks already have an ISRC, before what it found if (p.id === 'is' && st !== 'busy' && r.findings) { const has = r.findings.filter(x => x.kind !== 'link' && (x.state === 'linked' || (x.existing || []).length)).length; t = has + ' of ' + rel.tracks.length + ' tracks have an ISRC · ' + t; } return t; } // [chip class, chip text] from the header's state function chipOf(p) { const [cls, , mark] = stepState(p); if (cls === 'add') return ['add', mark]; if (cls === 'ok') return p.info ? ['info', String(infoCount(p.id))] : ['ok', '✓']; if (cls === 'busy') return ['busy', '…']; if (cls === 'err' || cls === 'stalled') return [cls, '!']; return ['', '']; } // PC's links as one bar: linked, to add, held back, not found function paintCoverage(n) { const bar = n.querySelector('.mc-covbar'); if (!bar) return; const r = results.pc, xs = r && r.state === 'done' ? r.findings || [] : []; const t = { linked: 0, new: 0, held: 0, none: 0 }; xs.forEach(x => { t[x.state === 'withheld' || x.state === 'unsure' ? 'held' : x.state in t ? x.state : 'none']++; }); bar.innerHTML = mbuHtml(xs.length ? Object.keys(t).filter(k => t[k]).map(k => '<i class="' + k + '" style="width:' + (100 * t[k] / xs.length).toFixed(2) + '%"></i>').join('') : ''); bar.title = xs.length ? t.linked + ' linked · ' + t.new + ' to add · ' + t.held + ' held back · ' + t.none + ' not found' : ''; } // the footer: when the last probe ran and how long it took, what Execute would send, the last run let probeAt = 0, probeTook = 0, lastRun = null; function ago(t) { const s = Math.round((Date.now() - t) / 1000); return s < 10 ? 'just now' : s < 60 ? s + ' s ago' : s < 3600 ? Math.round(s / 60) + ' min ago' : Math.round(s / 3600) + ' h ago'; } function isProbing() { return Object.values(results).some(r => r.state === 'busy'); } function noteProbeDone() { if (probeAt && !probeTook && !isProbing()) { probeTook = Date.now() - probeAt; Log.debug('probe took ' + probeTook + ' ms'); } } function paintFoot() { const f = ui && ui.querySelector('.mc-runs'); if (!f) return; f.querySelector('.f-probe').textContent = !probeAt ? 'not yet' : isProbing() ? 'probing · ' + Math.round((Date.now() - probeAt) / 1000) + ' s' : ago(probeAt) + (probeTook ? ' · took ' + Math.max(1, Math.round(probeTook / 1000)) + ' s' : ''); const n = changeCount(); f.querySelector('.f-exec').textContent = executing ? 'executing…' : n ? n + ' selected' : 'nothing selected'; f.querySelector('.f-run').textContent = !lastRun ? 'none' : ago(lastRun.at) + ' · ' + (lastRun.dry ? 'dry run' : lastRun.sent + ' applied') + (lastRun.failed ? ' · ' + lastRun.failed + ' failed' : ''); } // a provider's own userscript icon (#680: in the execution order, not a glyph) function sIcon(id) { return PROVIDER_ICONS[id] ? '<img class="mc-sic" alt="" src="' + PROVIDER_ICONS[id] + '">' : ''; } // C (#680): the execution order as a stepper under the header, a ring per provider and what it // is doing in words: the step it is on and for how long, amber when no word came for STALL_MS // (what is stuck), or what it found. ↻ probes again; a click on a step switches it off or on (#753). let STALL_MS = 20000; function steps() { const d = el('div', 'mc-steps'); const node = p => '<span class="mc-step" data-step="' + p.id + '"><span class="mc-ring">' + sIcon(p.id) + '<sup hidden></sup></span><span class="tx"><b>' + esc(p.provider) + '</b><i></i></span></span>'; d.innerHTML = mbuHtml('<button type="button" class="mc-re" data-act="probe" title="Probe: ask every provider what is missing (⚙: probe on open)">↻</button>' + STEPS.map(s => s.lanes ? '<span class="mc-par" title="run in parallel">' + s.lanes.map(node).join('') + '</span>' : node(s)).join('<span class="mc-arrow">→</span>')); return d; } function addText(p) { const xs = ((results[p.id] || {}).findings || []).filter(x => x.state === 'new' && x.role !== 'searched'); const n = (k, w) => k + ' ' + w + (k === 1 ? '' : 's'); if (p.id === 'is') { const l = xs.filter(x => x.kind === 'link').length, i = xs.length - l; return [i && n(i, 'ISRC'), l && n(l, 'link')].filter(Boolean).join(' · ') + ' to add'; } if (p.id === 'pc') return n(xs.length, 'link') + ' to add'; if (p.id === 'as' && xs.some(x => x.role === 'best')) return '1 cover to enter'; return xs.length + ' new'; } // [class, words, mark on the ring] function stepState(p) { const st = stateOf(p), r = results[p.id] || {}; if (st === 'off') return ['off', 'off', '']; if (st === 'idle') return ['wait', 'not installed', '']; const f = r.falcon, falcon = f && f.running ? 'Falcon: ' + f.items.filter(i => !['queued', 'active'].includes(i.status)).length + ' of ' + f.items.length + ' done' : null; const doing = st === 'busy' ? (r.note || 'starting') : (executing && r.working) || falcon; if (doing) { const s = Math.max(0, Math.round((Date.now() - (r.at || Date.now())) / 1000)); return Date.now() - (r.at || Date.now()) >= STALL_MS ? ['stalled', 'no word for ' + s + ' s · ' + doing, '!'] : ['busy', doing + (s >= 2 ? ' · ' + s + ' s' : ''), '']; } if (r.applied && !r.applied.ok) return ['err', r.applied.note || 'failed', '!']; if (st === 'ready') return ['wait', modeOf(p) === 'ask' ? 'waits for ' + (FETCH_LABEL[p.id] || 'Fetch') : 'not probed', '']; if (st === 'add') return ['add', addText(p), String(countNew(p.id))]; if (p.info) return ['ok', infoCount(p.id) + ' credit' + (infoCount(p.id) === 1 ? '' : 's') + ' found', '✓']; return ['ok', r.summary && !(r.findings || []).some(x => PICKABLE[x.state]) ? r.summary : 'nothing new', '✓']; } function paintSteps() { if (!ui) return; PROVIDERS.forEach(p => { const n = ui.querySelector('.mc-step[data-step="' + p.id + '"]'); if (!n) return; const [cls, words, mark] = stepState(p); n.className = 'mc-step ' + cls; n.title = p.provider + ' — ' + (cls === 'busy' || cls === 'stalled' ? words : stateText(p)) + (cls === 'off' ? '. Click to switch it on' : '. Click to switch it off'); n.querySelector('i').textContent = words; const sup = n.querySelector('sup'); sup.hidden = !mark; sup.textContent = mark; // the provider's card shows the same state: a stripe on its edge, a tint and a word in its header (#680) const sec = ui.querySelector('.mc-sect[data-sect="' + p.id + '"]'); if (!sec) return; sec.dataset.st = cls; const h = sec.querySelector('.mc-sect-h'); let tag = h.querySelector('.mc-st'); if (!tag) { tag = el('span', 'mc-st'); const sw = h.querySelector('.mc-icsw'); sw ? sw.after(tag) : h.prepend(tag); } tag.textContent = cls === 'wait' ? '' : cls === 'err' ? 'error' : cls; }); } // the seconds while something works, and the switch to stalled, need a clock let stepClock = 0; // One row per track, a column per track-level provider. Values arrive with // the providers' probe; until then a cell says why it is empty. // A track-level provider's finding carries the recording MBID in `track`; a column // renders it. `pick`: the column holds the tick box for that provider's finding. const COLS = [ { id: 'isrc', p: 'is', head: 'ISRC · IS', pick: true, cell: x => x.state === 'new' ? '<span class="mc-add mono">+ ' + esc(x.isrc) + '</span>' : x.state === 'unsure' ? '<span class="mc-warn mono" title="' + esc(x.why || '') + '">+ ' + esc(x.isrc) + ' ⚠</span>' // a provider's hard stop (#680: an ISRC on two recordings): shown, never tickable : x.state === 'blocked' ? '<span class="mc-err mono" title="' + esc('Blocked: ' + (x.why || '')) + '">⊘ ' + esc(x.isrc) + '</span>' : x.state === 'linked' ? '<span class="weak mono" title="' + esc((x.existing || []).join(', ')) + '">' + esc((x.existing || [])[0] || x.isrc || '') + ((x.existing || []).length > 1 ? ' +' + (x.existing.length - 1) : '') + '</span>' : '<span class="pend">none found</span>' }, // rendered by linksCell: IS's linked icons plus the links Find links found (#680) { id: 'links', p: 'is', head: 'Rec links · IS', cell: () => '' }, // the count (fxPill, before the cell) opens Fusion's comparison under the row; the release names are the toggle { id: 'fusion', p: 'fusion', head: 'RG duplicates · Fusion', pick: true, cell: x => FX_SHOWN[x.state] ? '<span class="weak">' + esc(fxReleases(x)) + '</span>' // as Fusion's pool badge: this recording has an open edit, so Fusion groups it with nothing : x.pending ? '<a class="mc-fxp" target="_blank" href="/recording/' + esc(x.key) + '/open_edits" title="This recording has an open edit: Fusion leaves it out of auto-match until the edit closes">⏳ pending</a>' : '<span class="pend">—</span>' }, { id: 'ch', p: 'ch', head: 'Credits · CH', cell: x => x.credits ? '<span title="' + esc((x.list || []).map(c => c.name + ' — ' + c.role).join('\n')) + '">' + x.credits + '</span>' : '<span class="pend">—</span>' }, ]; const FETCH_LABEL = { fusion: 'Fetch RG', ch: 'Fetch credits' }; function colHead(c) { const p = PROVIDERS.find(x => x.id === c.p); if (!p.mode) return esc(c.head); const r = results[p.id]; const ask = S[p.mode] === 'ask' && found[p.id] && (!r || r.state !== 'busy'); return esc(c.head) + (ask && FETCH_LABEL[p.id] ? ' <button type="button" class="mc-fetch" data-fetch="' + p.id + '" title="Run this slow fetch now">' + (r ? '↻ ' : '') + FETCH_LABEL[p.id] + '</button>' : '<span class="mc-mode">' + S[p.mode] + '</span>'); } function trackFinding(pid, rec) { const r = results[pid]; return r && r.state === 'done' && r.findings ? r.findings.find(x => x.track === rec && !x.kind) || null : null; } // IS's recording links to add (#680: Find links runs in the probe): its `kind: 'link'` findings function trackLinks(rec) { const r = results.is; return r && r.state === 'done' && r.findings ? r.findings.filter(x => x.track === rec && x.kind === 'link') : []; } // the links column: what's linked (plain icons), then what Find links found (each its own toggle) function linksCell(t) { const x = trackFinding('is', t.rec), add = trackLinks(t.rec); const have = (x && x.linkUrls || []).map(u => '<a class="mc-pico mc-have" target="_blank" rel="noopener" href="' + esc(u) + '" title="' + esc('Linked: ' + u) + '">' + stIcon(urlIcon(u), 14) + '</a>'); const neu = add.map(l => { const on = picked.is && picked.is.has(l.key); // a found link opens on click like a linked one; right-click takes it in or leaves it out const ico = urlIcon(l.url); return '<a class="mc-pick mc-lnk' + (on ? ' on' : '') + '" data-prov="is" data-col="links" data-ico="' + ico + '" data-key="' + esc(l.key) + '" target="_blank" rel="noopener" href="' + esc(l.url) + '" title="' + esc(l.name + ': ' + l.url + '\nClick to open · right-click to ' + (on ? 'leave out (taken in)' : 'take in') + '\nCtrl+right-click: the whole track · Alt+right-click: ' + l.name + ' on every track · Ctrl+Alt+right-click: everything') + '">+' + stIcon(ico, 14) + '</a>'; }); if (!have.length && !neu.length) return '<span class="pend">none</span>'; return '<span class="mc-icons">' + have.join('') + (have.length && neu.length ? '<span class="mc-isep"></span>' : '') + neu.join('') + '</span>'; } const PICKABLE = { new: 1, withheld: 1, unsure: 1 }; // what a lane's "take all in" takes: not a link whose track count or format says it is another release (#709) const laneTakes = x => PICKABLE[x.state] && !x.mismatch && !['barcode', 'catno', 'label'].includes(x.kind); // #740: never a catalog number or a label function cellHtml(c, t) { const p = PROVIDERS.find(x => x.id === c.p), r = results[c.p]; if (r && r.state === 'busy') return '<span class="pend">…</span>'; if (c.id === 'links') return r && r.state === 'done' ? linksCell(t) : '<span class="pend">—</span>'; const x = trackFinding(c.p, t.rec); if (!x) return '<span class="pend">—</span>'; const lead = c.id === 'fusion' ? fxPill(x) : ''; // a pickable cell is its own toggle, no tick box (#680): a click takes it in or leaves it out if (!c.pick || !PICKABLE[x.state]) return lead + c.cell(x); const on = picked[p.id] && picked[p.id].has(x.key); return lead + '<span class="mc-pick' + (on ? ' on' : '') + '" data-prov="' + p.id + '" data-col="' + c.id + '" data-key="' + esc(x.key) + '" title="' + (on ? 'Taken in: click to leave out' : 'Click to take in') + '\nCtrl-click: the whole track · Alt-click: the whole column · Ctrl+Alt-click: everything · drag: down the column">' + c.cell(x) + '</span>'; } // Fusion's comparison (#680), the way a Fusion group shows it: the track's recording and each // match, with artist, release and track, length, ISRCs, AcoustIDs and open edits, what differs // marked, and the signal chips. A chip is lit when every pair agrees (Fusion's signalsAll), half // lit when some do, dashed when it wasn't looked up. Fusion looks those up by itself once it answers // (fxAutoCheck): ISRCs and AcoustIDs are what a merge stands on, so no button per group. const FX_SHOWN = { new: 1 }; const FX_SIGNALS = [['isrc', 'ISRC'], ['acoustid', 'AcoustID'], ['length', 'Length'], ['title', 'Title'], ['artist', 'Artist']]; const FX_TIER = { strict: 'holds at the strict cutoff: ISRC or AcoustID', normal: 'holds at the normal cutoff: title and artist, with the length', loose: 'holds only at the loose cutoff: title with the artist or the length', manual: 'no cutoff forms it' }; const fxReleases = x => (x.matches || []).map(m => m.release || '').filter((v, i, a) => v && a.indexOf(v) === i).slice(0, 2).join(', '); const fxRel = m => (m.release || '?') + (m.pos ? ' · ' + m.pos + (m.of ? '/' + m.of : '') : '') + (m.more ? ' +' + m.more : ''); const fxLenDiff = (me, m) => { const d = me && me.ms != null && m.ms != null ? Math.round((m.ms - me.ms) / 1000) : 0; return d ? ' <span class="mc-fxdiff">' + (d > 0 ? '+' : '−') + Math.abs(d) + 's</span>' : ''; }; const fxCan = cap => !!(found.fusion && (found.fusion.capabilities || []).includes(cap)); // Fusion's icon, not a labelled button: one sits on every comparison and the label was noise const fxOpenBtn = x => fxCan('open') ? '<button type="button" class="mc-fxgo" data-act="fx-fusion" data-key="' + esc(x.key) + '" title="Open in Fusion: Fusion\'s window with this group on the board" aria-label="Open in Fusion"><img alt="" src="' + PROVIDER_ICONS.fusion + '"></button>' : ''; function fxPill(x) { if (!FX_SHOWN[x.state]) return ''; const open = fxOpen.has(x.key), n = (x.matches || []).length; return '<button type="button" class="mc-fxo" data-act="fx" data-key="' + esc(x.key) + '" aria-expanded="' + open + '" title="' + (open ? 'Hide' : 'Show') + ' what Fusion compared">' + '<span class="car">▸</span>' + n + ' match' + (n === 1 ? '' : 'es') + '</button> '; } function fxDetail(x) { const me = x.self || null, ck = x.checked || {}, all = x.signals || [], any = x.any || []; const need = [!ck.isrc && 'ISRC', !ck.acoustid && 'AcoustID'].filter(Boolean); const chips = FX_SIGNALS.map(([k, label]) => { const lit = all.includes(k), some = !lit && any.includes(k), unk = !lit && !some && ((k === 'isrc' && !ck.isrc) || (k === 'acoustid' && !ck.acoustid)); const tip = lit ? 'Every pair in the group agrees' : some ? 'Some pairs agree, not every one' : unk ? (fxBusy.has(x.key) ? 'Being looked up' : 'Not looked up') : 'No pair agrees'; return '<span class="mc-fxc' + (lit ? ' on' : some ? ' some' : unk ? ' unk' : '') + '" title="' + tip + '">' + label + (unk ? ' ?' : '') + '</span>'; }).join(''); const ids = (list, mine, known, short) => list == null || (!known && !list.length) ? '<span class="weak">not checked</span>' : list.map(v => '<span' + (mine && mine.includes(v) ? ' class="mc-fxs"' : '') + ' title="' + esc(v) + '">' + esc(short ? v.slice(0, 8) + '…' : v) + '</span>').join('<br>') || '<span class="weak">none</span>'; const row = (who, m, other) => { const diff = (a, b, v) => other && a !== b ? '<span class="mc-fxdiff">' + v + '</span>' : v; return '<tr><td class="who">' + who + '</td>' + '<td><a target="_blank" href="/recording/' + esc(m.gid) + '">' + diff(m.title, me && me.title, esc(m.title || '?')) + '</a> <span class="mono weak">' + esc((m.gid || '').slice(0, 8)) + '</span></td>' + '<td>' + diff(m.artist, me && me.artist, esc(m.artist || '?')) + '</td>' + '<td>' + esc(fxRel(m)) + '</td>' + '<td class="mono">' + esc(m.len || '?') + (other ? fxLenDiff(me, m) : '') + '</td>' + '<td class="mono">' + ids(m.isrcs, other && me && me.isrcs, ck.isrc) + '</td>' + '<td class="mono">' + ids(m.acoustids, other && me && me.acoustids, ck.acoustid, true) + '</td>' + '<td>' + (m.pending ? '<a class="mc-err" target="_blank" href="/recording/' + esc(m.gid) + '/open_edits">open edit</a>' : '<span class="weak">none</span>') + '</td></tr>'; }; const busy = fxBusy.has(x.key); return '<div class="mc-fxh">' + (x.tier ? '<span class="mc-fxt ' + esc(x.tier) + '" title="' + esc('Fusion\'s tier: the group ' + (FX_TIER[x.tier] || '')) + '">' + esc(x.tier) + '</span>' : '') + '<span class="weak">matched on</span>' + chips + '<span class="sp"></span>' + (need.length && busy ? '<span class="weak">checking ' + need.join(' & ') + '…</span>' : '') + fxOpenBtn(x) + '</div>' + '<div class="mc-fxwrap"><table class="mc-fxtbl"><thead><tr><th></th><th>Recording</th><th>Artist</th><th>Release · track</th><th>Length</th><th>ISRCs</th><th>AcoustID</th><th>Edits</th></tr></thead><tbody>' + (me ? row('this', me, false) : '') + (x.matches || []).map(m => row('match', m, !!me)).join('') + '</tbody></table></div>' + (x.cutoff ? '<div class="mc-fxn">Matched at Fusion\'s <b>' + esc(x.cutoff) + '</b> cutoff, the one set in Fusion\'s window.</div>' : ''); } // Once Fusion answers, each group missing ISRCs or AcoustIDs is checked, one group at a time (a few // requests each), so the comparisons fill in as they come. A track in a group already asked for is // covered by that check. A newer Fusion answer stops the queue and starts its own. async function fxAutoCheck() { const gen = ++fxCheckGen; fxBusy.clear(); const r = results.fusion; if (S.fusion === 'off' || !fxCan('check') || !r || !r.findings) return; const covered = new Set(), queue = []; for (const x of r.findings) { const ck = x.checked || {}; if (!FX_SHOWN[x.state] || (ck.isrc && ck.acoustid) || covered.has(x.key)) continue; const group = [x.key].concat((x.matches || []).map(m => m.gid)); group.forEach(k => covered.add(k)); queue.push(x.key); r.findings.forEach(y => { if (group.includes(y.key)) fxBusy.add(y.key); }); } if (!queue.length) return; Log.info('asking Fusion to check ISRCs and AcoustIDs of ' + queue.length + ' group(s)'); paintMatrix(); for (const k of queue) { if (gen !== fxCheckGen) return; const answered = await new Promise(res => { const done = () => { clearTimeout(t); res(true); }; const t = setTimeout(() => { if (fxWaiters.get(k) === done) fxWaiters.delete(k); res(false); }, 120000); fxWaiters.set(k, done); document.dispatchEvent(new CustomEvent('mc:check', { detail: JSON.stringify({ id: 'fusion', run, release: RELEASE, key: k }) })); }); if (!answered && gen === fxCheckGen) { Log.warn('Fusion did not answer the check of ' + k); fxBusy.delete(k); paintAll(); } } if (gen !== fxCheckGen) return; // what no answer cleared (a track whose group the answer didn't name) isn't being checked any more if (fxBusy.size) { fxBusy.clear(); paintAll(); } } // the Tracks header's Expand all / Collapse all opens or folds every Fusion comparison const fxOpenable = () => { const r = results.fusion; return S.fusion !== 'off' && r && r.findings ? rel.tracks.map(t => trackFinding('fusion', t.rec)).filter(x => x && FX_SHOWN[x.state]).map(x => x.key) : []; }; function fxAllBtn() { const ks = fxOpenable(); if (!ks.length) return ''; const all = ks.every(k => fxOpen.has(k)); return '<button type="button" class="mc-all" data-act="fx-all" title="' + (all ? 'Fold every track\'s Fusion comparison' : 'Open every track\'s Fusion comparison') + '">' + (all ? 'Collapse all' : 'Expand all') + '</button>'; } function fxAct(b) { const k = b.dataset.key; if (b.dataset.act === 'fx') { if (fxOpen.has(k)) fxOpen.delete(k); else fxOpen.add(k); paintMatrix(); return; } if (b.dataset.act === 'fx-all') { const ks = fxOpenable(), all = ks.every(x => fxOpen.has(x)); ks.forEach(x => { if (all) fxOpen.delete(x); else fxOpen.add(x); }); paintMatrix(); return; } if (b.dataset.act === 'fx-fusion') { Log.info('opening the group of ' + k + ' in Fusion'); document.dispatchEvent(new CustomEvent('mc:open', { detail: JSON.stringify({ id: 'fusion', release: RELEASE, key: k }) })); } } // the track changes taken in for Execute, per track: a picked ISRC, Fusion group or found link function trackPicks(t) { let n = 0; COLS.forEach(c => { if (providerOff(c.p) || modeOf(PROVIDERS.find(p => p.id === c.p)) === 'off' || !picked[c.p]) return; if (c.id === 'links') { n += trackLinks(t.rec).filter(l => picked.is.has(l.key)).length; return; } const x = c.pick && trackFinding(c.p, t.rec); if (x && PICKABLE[x.state] && picked[c.p].has(x.key)) n++; }); return n; } function paintMatrix() { ui.querySelectorAll('.mc-tbl th[data-colh]').forEach(th => { th.innerHTML = mbuHtml(colHead(COLS.find(c => c.id === th.dataset.colh))); }); // track-level providers have no card: their apply outcome goes in the Tracks header const fa = ui.querySelector('.mc-fxall'); if (fa) fa.innerHTML = mbuHtml(fxAllBtn()); const slot = ui.querySelector('.mc-tapplied'); if (slot) slot.innerHTML = mbuHtml(COLS.map(c => c.p).filter((p, i, a) => a.indexOf(p) === i).map(p => results[p] && results[p].applied) .filter(Boolean).map(a => '<span class="mc-applied ' + (a.ok ? 'ok' : 'err') + '" title="' + esc(a.id) + '">' + (a.ok ? '✓ ' : '✕ ') + esc(PROVIDERS.find(x => x.id === a.id).provider + ': ' + (a.note || '')) + '</span>' // #748: a per-track provider's failure that can be tried again offers Retry here, as a card's does (#741) + (!a.ok && a.retry ? '<button type="button" class="mc-retry" data-act="retry-apply" data-prov="' + esc(a.id) + '" title="Apply ' + esc(PROVIDERS.find(x => x.id === a.id).provider) + ' again, with what is still taken in">Retry</button>' : '')).join('')); let pend = 0, rows = 0; ui.querySelectorAll('.mc-tbl tbody tr[data-i]').forEach(tr => { const t = rel.tracks[+tr.dataset.i], n = trackPicks(t); pend += n; if (n) rows++; tr.classList.toggle('chg', !!n); tr.querySelectorAll('td[data-col]').forEach(td => { td.innerHTML = mbuHtml(cellHtml(COLS.find(c => c.id === td.dataset.col), t)); }); }); const tp = ui.querySelector('.mc-tpend'); if (tp) { tp.textContent = pend ? pend + ' pending change' + (pend === 1 ? '' : 's') + ' on ' + rows + ' of ' + rel.tracks.length + ' track' + (rel.tracks.length === 1 ? '' : 's') : 'no pending track changes'; tp.classList.toggle('on', !!pend); } ui.querySelectorAll('.mc-tbl tr.mc-fxd').forEach(tr => { const t = rel.tracks[+tr.dataset.fx], x = S.fusion !== 'off' && t ? trackFinding('fusion', t.rec) : null; const show = !!(x && FX_SHOWN[x.state] && fxOpen.has(x.key)); tr.hidden = !show; tr.firstElementChild.innerHTML = mbuHtml(show ? fxDetail(x) : ''); }); } function matrix() { const sec = el('section', 'mc-sect'); sec.dataset.sect = 'tracks'; const cols = COLS.filter(c => modeOf(PROVIDERS.find(p => p.id === c.p)) !== 'off'); let html = '<div class="mc-sect-h">' + cardTitle('tracks', '≡', 'Tracks') + '<span class="p mc-tpend"></span><span class="end"><span class="mc-tapplied"></span><span class="mc-fxall"></span></span></div>' + '<table class="mc-tbl"><thead><tr><th>#</th><th>Title</th><th>Len</th>' + cols.map(c => '<th data-colh="' + c.id + '">' + colHead(c) + '</th>').join('') + '</tr></thead><tbody>'; let lastMed = 0; rel.tracks.forEach((t, i) => { if (rel.media > 1 && t.medium !== lastMed) { lastMed = t.medium; html += '<tr class="med"><td colspan="' + (3 + cols.length) + '">Medium ' + t.medium + '</td></tr>'; } html += '<tr data-i="' + i + '"' + (selected === i ? ' class="sel"' : '') + '><td class="n">' + esc(t.pos) + '</td><td class="ttl">' + esc(t.title) + '</td><td class="mono">' + esc(t.len) + '</td>' + cols.map(c => '<td data-col="' + c.id + '"></td>').join('') + '</tr>' + '<tr class="mc-fxd" data-fx="' + i + '" hidden><td colspan="' + (3 + cols.length) + '"></td></tr>'; }); if (!rel.tracks.length) html += '<tr><td colspan="' + (3 + cols.length) + '" class="weak">No tracklist on this page.</td></tr>'; sec.innerHTML = mbuHtml(html + '</tbody></table>'); return sec; } // The cards go in reading order (release and entity links, cover art, release credits) into as many // columns as fit at 520 px or more, each card into the column that is shortest when its turn comes // (#680: fixed columns, and then wrapping, left a hole beside the tall links card). A card is as tall // as what it holds. A card whose provider isn't on the page is left out, and the rest share the width. function releaseCards() { const row = el('div', 'mc-row2'); const card = (id, t) => { const p = PROVIDERS.find(x => x.id === id); return '<section class="mc-sect" data-sect="' + id + '"><div class="mc-sect-h">' + cardTitle(id, '<img alt="" src="' + PROVIDER_ICONS[id] + '">', t) + '<span class="end"></span></div>' + '<div class="mc-card-body" data-card="' + id + '"></div></section>'; }; row.innerHTML = mbuHtml(card('pc', 'Release and entity links') + card('as', 'Cover art')); if (S.ch !== 'off') row.append(releaseCredits()); row._cards = [...row.children]; let w = 0; new ResizeObserver(() => { if (row.clientWidth !== w) { w = row.clientWidth; layoutCards(row); } }).observe(row); return row; } function fitCards() { const row = ui && ui.querySelector('.mc-row2'); if (!row) return; row._cards.forEach(s => { const id = s.querySelector('[data-card]') ? s.querySelector('[data-card]').dataset.card : 'ch'; s.hidden = ['idle', 'off'].includes(stateOf(PROVIDERS.find(x => x.id === id))); }); paintOnOff(); row.hidden = row._cards.every(s => s.hidden); layoutCards(row); } const CARD_MIN = 520, CARD_GAP = 10; function layoutCards(row) { if (row.hidden || !row.clientWidth) return; const shown = row._cards.filter(s => !s.hidden); const n = Math.max(1, Math.min(shown.length, Math.floor((row.clientWidth + CARD_GAP) / (CARD_MIN + CARD_GAP)))); let cols = [...row.children].filter(c => c.classList.contains('mc-col')); while (cols.length < n) { const c = el('div', 'mc-col'); row.append(c); cols.push(c); } // heights as they are now; a card that changes column changes width, and the next paint settles it const tall = new Map(shown.map(s => [s, s.offsetHeight])), used = cols.slice(0, n).map(() => 0); shown.forEach(s => { const k = used.indexOf(Math.min(...used)); if (s.parentNode !== cols[k]) cols[k].append(s); used[k] += tall.get(s) + CARD_GAP; }); // a hidden card stays in a column, out of the way; empty columns go row._cards.filter(s => s.hidden && !s.parentNode.classList.contains('mc-col')).forEach(s => cols[0].append(s)); cols.slice(n).forEach(c => { [...c.children].forEach(s => cols[0].append(s)); c.remove(); }); // the cards keep their reading order within a column cols.slice(0, n).forEach(c => row._cards.forEach(s => { if (s.parentNode === c) c.append(s); })); } // CH's release-level credits (#680): its finding with no `track`. Info only, like its column. function releaseCredits() { const sec = el('section', 'mc-sect'); sec.dataset.sect = 'ch'; sec.innerHTML = mbuHtml('<div class="mc-sect-h">' + cardTitle('ch', '<img alt="" src="' + PROVIDER_ICONS.ch + '">', 'Release credits') + '<span class="mc-mode">info</span><span class="end mc-chopen"></span></div><div class="mc-chrel"></div>'); return sec; } function paintReleaseCredits() { const box = ui.querySelector('.mc-chrel'); if (!box) return; const p = PROVIDERS.find(x => x.id === 'ch'), r = results.ch; // CH's import sources: each opens edit-relationships, where CH presses that source's button // and its pre-flight starts (the same choice as CH's toolbar, #680) const open = (r && r.open) || (found.ch && found.ch.open) || [], slot = box.parentNode.querySelector('.mc-chopen'); // drawn as CH's toolbar draws them: an icon per source, All (no icon) as ⚛ All const openHtml = open.length ? '<span class="mc-chlbl">Import credits:</span>' + open.map(o => '<a class="mc-btn mc-chsrc' + (o.icon ? '' : ' mc-chall') + '" href="' + esc(o.url) + '" title="' + esc(o.title || o.label) + '" aria-label="' + esc(o.label) + '">' + (o.icon ? stIcon(o.icon, 14) : '<b>⚛</b>' + esc(o.label)) + '</a>').join('') : ''; if (slot && slot._mcHtml !== openHtml) { slot._mcHtml = openHtml; slot.innerHTML = mbuHtml(openHtml); } if (!r || r.state !== 'done') { box.innerHTML = mbuHtml('<div class="mc-empty">Credit Hoarder: ' + esc(stateText(p)) + (r ? '' : '. Fetch credits fills this in.') + '</div>'); return; } const x = (r.findings || []).find(f => !f.track); if (!x) { box.innerHTML = mbuHtml('<div class="mc-empty">' + esc(r.summary ? r.summary + '. ' : '') + 'No release-level credits in the source.</div>'); return; } box.innerHTML = mbuHtml('<div class="mc-summary">' + esc(x.source || '') + ': ' + x.list.length + ' credit' + (x.list.length === 1 ? '' : 's') + ' on the release</div>' + '<div style="padding:4px 10px 8px">' + x.list.map(c => '<div class="mc-mini">' + esc(c.name) + '<span class="n">' + esc(c.role) + '</span></div>').join('') + '</div>'); } // A provider's release-level findings: one row each; selected rows go to Execute. const PILL = { new: ['add', 'new'], linked: ['ok', 'linked'], withheld: ['warn', 'withheld'], unsure: ['warn', 'unsure'], blocked: ['err', 'blocked'], none: ['idle', 'not found'] }; const ORDER = { new: 0, withheld: 1, unsure: 2, blocked: 3, linked: 4, none: 5 }; // a link's platform, as its ST-ICONS key (globe when MC has no icon for it) const HOST_ICON = [[/discogs\.com$/, 'discogs'], [/spotify\.com$/, 'spotify'], [/apple\.com$/, 'apple'], [/deezer\.com$/, 'deezer'], [/tidal\.com$/, 'tidal'], [/qobuz\.com$/, 'qobuz'], [/beatport\.com$/, 'beatport'], [/bandcamp\.com$/, 'bandcamp'], [/volumo\.com$/, 'volumo'], [/hdtracks\.com$/, 'hdtracks'], [/soundcloud\.com$/, 'soundcloud'], [/audiomack\.com$/, 'audiomack'], [/7digital\.com$/, 'sevendigital'], [/music\.youtube\.com$/, 'ytmusic'], [/amazon\.[a-z.]+$/, 'amazonmusic'], [/musicbrainz\.org$/, 'musicbrainz']]; function urlIcon(u) { let h = ''; try { h = new URL(u, location.href).hostname; } catch (e) { return 'globe'; } const m = HOST_ICON.find(x => x[0].test(h)); return m ? m[1] : 'globe'; } function shortUrl(u) { try { const x = new URL(u); return x.hostname.replace(/^www\./, '') + x.pathname.replace(/\/$/, ''); } catch (e) { return u; } } // the linked icons, one per platform with a count (three Discogs artists are one Discogs ×3, #680), // as the toggle that lists them as rows: the release's in the card's header, the artists' and // labels' on their sub-heading const isEnt = x => x.entity && x.entity.type !== 'release_group'; const linkedOpen = k => Array.isArray(S.linkedOpen) && S.linkedOpen.includes(k); function linkedBtn(linked, k) { if (!linked.length) return ''; const open = linkedOpen(k); const by = new Map(); linked.forEach(x => { const k = x.icon || x.key; (by.get(k) || by.set(k, []).get(k)).push(x); }); const nm = x => (isEnt(x) ? (x.entity.name || '') + ' · ' : '') + (x.name || x.key); return '<button type="button" class="mc-linked' + (open ? ' on' : '') + '" data-act="linked" data-lk="' + esc(k) + '" title="' + esc('Already linked: ' + linked.map(nm).join(', ') + (open ? '. Click to fold them back.' : '. Click to list them as rows.')) + '">' + [...by].map(([k, xs]) => '<span class="mc-pico' + (xs[0].entity ? ' mc-ent' : '') + '" title="' + esc(xs.map(nm).join(', ')) + '">' + stIcon(k, 14) + (xs.length > 1 ? '<sub>' + xs.length + '</sub>' : '') + '</span>').join('') + '<span class="mc-lk">✓ ' + linked.length + '</span></button>'; } // a repaint rebuilds the card, which reloads its images: the cover thumbnails blinked on each // Falcon or progress tick (#680). Skip an unchanged card and carry the loaded images over. function setCard(box, html) { const s = String(html); if (box._mcHtml === s) return; box._mcHtml = s; // an image that fell back to its full one (data-from) is matched by the src it was given const old = new Map([...box.querySelectorAll('img')].map(i => [i.dataset.from || i.getAttribute('src'), i])); box.innerHTML = html; box.querySelectorAll('img').forEach(i => { const o = old.get(i.getAttribute('src')); if (o) { old.delete(i.getAttribute('src')); i.replaceWith(o); } }); } function paintCards() { ui.querySelectorAll('[data-card]').forEach(box => { const id = box.dataset.card, p = PROVIDERS.find(x => x.id === id), r = results[id]; if (!r || r.state !== 'done') { setCard(box, mbuHtml('<div class="mc-empty">' + esc(p.provider) + ': ' + esc(stateText(p)) + (r ? '' : '. Probe fills this in.') + '</div>')); return; } if (!r.findings.length) { setCard(box, mbuHtml((r.summary ? '<div class="mc-summary">' + esc(r.summary) + '</div>' : '') + '<div class="mc-empty">Nothing to report.</div>')); return; } const sum = r.summary ? '<div class="mc-summary">' + esc(r.summary) + '</div>' : ''; const ap = (r.applied ? '<div class="mc-applied ' + (r.applied.ok ? 'ok' : 'err') + '">' + (r.applied.ok ? '✓ ' : '✕ ') + esc(r.applied.note || (r.applied.ok ? 'done' : 'failed')) // #741: a provider that says it can try again (retry) gets a button that applies it alone, with what is still taken in + (!r.applied.ok && r.applied.retry ? ' <button type="button" class="mc-retry" data-act="retry-apply" data-prov="' + esc(id) + '" title="Apply ' + esc(p.provider) + ' again, with what is taken in">Retry</button>' : '') + '</div>' : '') + (r.working ? '<div class="mc-working"><span class="mc-spin"></span>' + esc(r.working) + '</div>' : '') + falconHtml(r.falcon) + bestHtml(r.best); // 'not found' is one line of icons, not a row each: it's most of the list and needs no action. // 'linked' needs none either: icons on their section's sub-heading (Release, Artists, Labels), // so the rows that need a decision lead; clicking them lists them as rows instead, each section on its own (S.linkedOpen). // #709: PC's barcodes (kind 'barcode') are rows of their own at the top of Release, linked ones too ('added'); // so are the catalog numbers and labels a consolidation brings (kinds 'catno' and 'label', #727) const isBc = x => x.kind === 'barcode' || x.kind === 'catno' || x.kind === 'label'; const linked = r.findings.filter(x => x.state === 'linked' && !isBc(x)); // PC's card is in sections, each with its own linked icons; another card's are in its header const pc = id === 'pc'; const lkKey = x => pc ? 'pc:' + (isEnt(x) ? x.entity.type : 'release') : id; const rows = r.findings.filter(x => !isBc(x) && x.state !== 'none' && (x.state !== 'linked' || linkedOpen(lkKey(x)))).sort((a, b) => ORDER[a.state] - ORDER[b.state]); // #740: a catalog number a platform gives (not a consolidation's) sits beside the barcode of each lane it is // in, as a chip of its own; one whose lane isn't shown (no lanes) stays a row const isLaneCat = x => x.kind === 'catno' && !x.cons && Array.isArray(x.barcodes); const placedCats = new Set(); const bcRowsOf = () => r.findings.filter(x => isBc(x) && !placedCats.has(x.key)); const none = r.findings.filter(x => x.state === 'none'); const slot = box.parentNode.querySelector('.mc-sect-h .end'); if (slot) slot.innerHTML = mbuHtml(pc ? '' : linkedBtn(linked, id)); // the row is the toggle, no tick box (#680); a taken-in row is tinted and marked ✓ // PC's barcodes (#680): the release's links grouped by barcode, a lane each (leading zeros // aside, as PC compares them): the release's own first, in green, then the others by size, // then the platforms that gave none. A lane is one line: its platforms as icons that toggle // one by one, and take all in; ▸ opens it into rows. const bcNorm = b => String(b || '').replace(/\D/g, '').replace(/^0+/, ''); // its reasons, one line: what says it is another release (PC's mismatch: track count, format) in // amber (#709); in a lane, not the barcode, which the lane already shows const whyHtml = (x, lane) => { const parts = String(x.why || '').split(' · ').filter(w => w && !(lane && /barcode/.test(w))); return parts.length ? '<div class="s">' + parts.map(w => (x.mismatch || []).includes(w) ? '<b class="mc-off">' + esc(w) + '</b>' : esc(w)).join(' · ') + '</div>' : ''; }; const coverChip = x => { const c = x.cover, dims = c.w ? c.w + '×' + c.h : ''; if (!c.url) return '<span class="mc-csize">' + (dims || 'no cover') + '</span>'; return '<a class="mc-csize" target="_blank" rel="noopener" href="' + esc(c.url) + '" title="' + esc('Open the cover on ' + (x.name || x.key) + (dims ? ' (' + dims + ')' : '')) + '">' + (dims || 'cover') + ' ↗</a>'; }; const line = (x, lane) => { const pick = !!PICKABLE[x.state]; const on = pick && picked[id] && picked[id].has(x.key); const pill = x.role === 'searched' ? ['idle', 'searched'] : x.role === 'best' && x.state === 'new' ? ['add', 'enters'] : isBc(x) && x.state === 'linked' ? ['ok', 'added'] : PILL[x.state] || ['idle', x.state]; // in a lane, the barcode and its reason are on the lane: only a differently written barcode stays return '<div class="mc-line ' + esc(x.state) + (x.cover ? ' mc-hassize' : '') + (x.entity ? ' mc-ent' : '') + (lane ? ' mc-in' : '') + (pick ? ' mc-pick' : '') + (on ? ' on' : '') + '"' + (pick ? ' data-prov="' + id + '" data-key="' + esc(x.key) + '" title="' + (on ? 'Taken in: click to leave out' : 'Click to take in') + '"' : '') + '>' + '<span class="mc-tick">' + (on ? '✓' : '') + '</span>' + '<span class="mc-pico">' + (x.kind === 'catno' || x.kind === 'label' ? CATNO_SVG : isBc(x) ? BARCODE_SVG : stIcon(x.icon || x.key, 14)) + '</span>' + '<div class="mc-lt"><div class="t">' + (x.kind === 'label' ? 'Label <b>' + esc(x.label && x.label.name || '') + '</b>' + (x.code ? ' · cat. no. <b class="mc-bcn">' + esc(x.code) + '</b>' : '') : x.kind === 'catno' ? 'Cat. no. <b class="mc-bcn">' + esc(x.code) + '</b> · ' + esc(x.label && x.label.name || '') : isBc(x) ? 'Barcode <b class="mc-bcn">' + esc(x.code) + '</b>' : esc(x.name || x.key)) + '</div>' + (x.url ? '<a class="s" target="_blank" rel="noopener" href="' + esc(x.url) + '" title="' + esc(x.url) + '">' + esc(shortUrl(x.url)) + '</a>' : '') + whyHtml(x, lane) + '</div>' // #751: Art Station's row, the size of the platform's own cover in a column of its own, a link to the cover + (x.cover ? coverChip(x) : '') + (lane && x.state !== 'linked' ? '<span></span>' : '<span class="mc-pill ' + pill[0] + '">' + pill[1] + '</span>') + '</div>'; }; // PC's links in sections: the release's, then the artists', then the labels', each under its // sub-heading and shown only when it has something (the Discogs master is the release // group's, but reads as the release's own and leads) const rgRows = rows.filter(x => x.entity && x.entity.type === 'release_group'), relRows = rows.filter(x => !x.entity), entRows = rows.filter(x => x.entity && x.entity.type !== 'release_group'); const grouped = !!r.barcode || relRows.some(x => x.barcode); let relHtml = relRows.map(x => line(x)).join(''), hint = ''; if (grouped) { // a linked link with a barcode joins its lane too (a ✓ icon), so the release's own lane // shows what the release already has: its Discogs release, say (#680) const laneRows = r.findings.filter(x => !x.entity && !isBc(x) && x.state !== 'none' && (x.state !== 'linked' || x.barcode || linkedOpen('pc:release'))).sort((a, b) => ORDER[a.state] - ORDER[b.state]); // one form for a barcode however a platform writes it: 12 digits (UPC) when it fits, else 13 (EAN) const bcShow = k => k.length <= 12 ? k.padStart(12, '0') : k; const lanes = new Map(), relK = bcNorm(r.barcode); if (relK) lanes.set(relK, { k: relK, rows: [], color: 'var(--mbu-ok)', tip: "The release's barcode" }); laneRows.forEach(x => { const k = bcNorm(x.barcode); if (!lanes.has(k)) lanes.set(k, k ? { k, rows: [], tip: "Not the release's barcode" } : { k, rows: [], tip: "Empty barcode: the platform doesn't give one" }); lanes.get(k).rows.push(x); }); const others = [...lanes.values()].filter(l => l.k && l.k !== relK).sort((a, b) => b.rows.length - a.rows.length); others.forEach((l, i) => { l.color = BC_COLORS[i % BC_COLORS.length]; }); const order = [relK && lanes.get(relK), ...others, lanes.get('')].filter(Boolean); // a release without a barcode has no lane of its own: '' is the empty-barcode lane // the catalog numbers are a cell of their own after the barcode, as wide on every lane as the widest, so // the platform icons still line up const catsOf = l => r.findings.filter(x => isLaneCat(x) && x.barcodes.includes(l.k)); const catW = Math.max(0, ...order.map(l => catsOf(l).reduce((n, x) => n + String(x.code).length + 4, 0))); const catCell = l => catW ? '<span class="mc-cats" style="width:' + catW + 'ch">' + catChips(l) + '</span>' : ''; const catChips = l => catsOf(l).map(x => { placedCats.add(x.key); const on = picked[id] && picked[id].has(x.key); return '<span class="mc-cat mc-pick ' + esc(x.state) + (on ? ' on' : '') + '" data-prov="' + id + '" data-key="' + esc(x.key) + '" title="' + esc('Catalog number ' + x.code + ' for ' + x.label.name + ' · ' + (x.why || '') + (on ? '. Taken in: click to leave out' : '. Click to take in')) + '">' + (on ? '✓ ' : '') + esc(x.code) + '</span>'; }).join(''); relHtml = order.map(l => { const open = laneOpen.has(l.k), picks = l.rows.filter(laneTakes), n = picks.filter(x => picked[id] && picked[id].has(x.key)).length; const states = [...new Set(l.rows.filter(x => x.state !== 'linked').map(x => x.state))].concat(l.rows.every(x => x.state === 'linked') ? ['linked'] : []), pill = states.length === 1 ? PILL[states[0]] || ['idle', states[0]] : null; // every chip as wide as a 13-digit barcode, the digits centred: the icons line up in every lane const chip = '<span class="mc-bc' + (l.k ? '' : ' none') + '"' + (l.k ? ' style="--bc:' + l.color + '"' : '') + ' title="' + esc(l.tip) + '"><i>0000000000000</i>' + (l.k ? '<b>' + bcShow(l.k) + '</b>' : '') + '</span>'; const icons = l.rows.length ? l.rows.map(x => { const pk = !!PICKABLE[x.state], on = pk && picked[id] && picked[id].has(x.key); return '<span class="mc-ti ' + esc(x.state) + (x.mismatch ? ' off' : '') + (pk ? ' mc-pick' : '') + (on ? ' on' : '') + '"' + (pk ? ' data-prov="' + id + '" data-key="' + esc(x.key) + '"' : '') + ' title="' + esc((x.name || x.key) + (x.url ? ' — ' + x.url : '') + (x.why ? ' (' + x.why + ')' : '') + (x.state === 'linked' ? '. Already linked' : '') + (pk ? (on ? '. Taken in: click to leave out' : '. Click to take in') : '')) + '">' + stIcon(x.icon || x.key, 16) + '</span>'; }).join('') : '<span class="mc-bcl-no">no platform found it</span>'; const all = picks.length ? '<button type="button" class="mc-all" data-act="lane-all" data-lane="' + esc(l.k) + '" title="' + esc('Take every link in this group in, or leave them all out' + (l.rows.some(x => PICKABLE[x.state] && x.mismatch) ? '. Not the ones marked amber: their track count or format is not the release\'s' : '')) + '">' + (n === picks.length ? '✓ all taken in' : n ? n + ' of ' + picks.length + ' · take all' : 'take all in') + '</button>' : '<span></span>'; return '<div class="mc-bcl' + (open ? ' open' : '') + (catW ? ' mc-hascat' : '') + (l.rows.length ? '' : ' empty') + '" data-act="lane" data-lane="' + esc(l.k) + '"' + (l.rows.length ? ' title="' + (open ? 'Fold into one line' : 'Open into rows') + '"' : '') + '>' + '<span class="mc-tri">' + (l.rows.length ? (open ? '▾' : '▸') : '') + '</span>' + chip + catCell(l) + '<span class="mc-bcl-ic">' + icons + '</span>' + all + (pill ? '<span class="mc-pill ' + pill[0] + '">' + pill[1] + '</span>' : '<span></span>') + '</div>' + (open ? l.rows.map(x => line(x, l)).join('') : ''); }).join(''); // what the lanes suggest: most platforms agree on a barcode the release doesn't have const top = others[0], total = laneRows.filter(x => x.barcode || x.state === 'withheld').length; if (relK && !lanes.get(relK).rows.length && top && top.rows.length > 1) hint = '<div class="mc-bchint">ⓘ ' + top.rows.length + ' of ' + total + ' platforms agree on <b>' + bcShow(top.k) + '</b>; none has the release\'s <b>' + bcShow(relK) + '</b>. Likely a different edition.</div>'; } const sect = (sub, title, body, lk) => !pc ? body : body || lk.length ? '<div class="mc-sub" data-sub="' + sub + '"><span>' + title + '</span>' + linkedBtn(lk, 'pc:' + sub) + '</div>' + body : ''; const entSect = (type, title) => sect(type, title, entRows.filter(x => x.entity.type === type).map(x => line(x)).join(''), linked.filter(x => isEnt(x) && x.entity.type === type)); setCard(box, mbuHtml(sum + ap + sect('release', 'Release', bcRowsOf().map(x => line(x)).join('') + rgRows.map(x => line(x)).join('') + relHtml + hint, linked.filter(x => !isEnt(x))) + entSect('artist', 'Artists') + entSect('label', 'Labels') + (none.length ? '<div class="mc-none" title="' + esc('Not found: ' + none.map(x => x.name || x.key).join(', ')) + '"><span>Not found</span>' + none.map(x => '<span class="mc-pico" title="' + esc(x.name || x.key) + '">' + stIcon(x.icon || x.key, 14) + '</span>').join('') + '</div>' : ''))); }); } // #727: a catalog number's icon, a tag const CATNO_SVG = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" style="color:var(--mbu-text-dim)"><path d="M1.5 7.6V1.5h6.1l5 5-6.1 6.1z"/><circle cx="4.6" cy="4.6" r="1.1"/></svg>'; const BARCODE_SVG = '<svg width="14" height="14" viewBox="0 0 14 10" fill="currentColor" style="color:var(--mbu-text-dim)"><rect x="0" width="1.4" height="10"/><rect x="2.4" width="0.8" height="10"/><rect x="4.2" width="1.8" height="10"/><rect x="7" width="0.8" height="10"/><rect x="8.8" width="1.4" height="10"/><rect x="11.2" width="0.8" height="10"/><rect x="12.6" width="1.4" height="10"/></svg>'; // another barcode lane's colour: a token per lane, lighter on the dark theme (majkinetor: "hardly visible on dark theme") const BC_LIGHT = ['#2563eb', '#c2410c', '#9333ea', '#0e7490', '#be185d', '#a16207', '#4d7c0f', '#6d28d9']; const BC_DARK = ['#7ab4ff', '#fb923c', '#d0a2ff', '#3dd6f0', '#f78bc4', '#facc15', '#a3e635', '#b9a5ff']; const BC_COLORS = BC_LIGHT.map((c, i) => 'var(--mc-bc-' + i + ')'); const BC_CSS = '#mc-root,#mcc-root{' + BC_LIGHT.map((c, i) => '--mc-bc-' + i + ':' + c).join(';') + '}' + ':root[data-mbu-theme=dark] #mc-root,:root[data-mbu-theme=dark] #mcc-root{' + BC_DARK.map((c, i) => '--mc-bc-' + i + ':' + c).join(';') + '}'; // AS's selected rows are the pool its one cover comes from, so they count once, not once each const isPool = id => ((results[id] || {}).findings || []).some(x => x.role === 'searched' || x.role === 'best'); function changeCount() { return Object.entries(picked).reduce((n, [id, set]) => n + (providerOff(id) ? 0 : isPool(id) ? Math.min(1, set.size) : set.size), 0); } // Probe or Execute in flight, or a Falcon batch that Execute handed over still running: the // header pulses, as Apollo's bar does while an edit saves (#412). function isBusy() { return executing || Object.values(results).some(r => r.state === 'busy' || (r.falcon && r.falcon.running)); } function paintExec() { // on at once (the pulse starts from nothing); off only where a cycle ends, so it fades out and a // gap between two answers doesn't flash (header() ends it on animationiteration) const hdr = ui.querySelector('.mc-hdr'); if (hdr && isBusy()) hdr.classList.add('mc-busy'); else if (hdr && matchMedia('(prefers-reduced-motion: reduce)').matches) hdr.classList.remove('mc-busy'); const ex = ui.querySelector('[data-act="exec"]'); if (!ex) return; const n = changeCount(); ex.disabled = executing || !n; ex.textContent = executing ? 'Executing…' : n ? 'Execute (' + n + ')' : 'Execute'; } function inspector() { const a = el('aside', 'mc-side right'); a.innerHTML = mbuHtml('<h2 class="mc-sec"><span class="mc-insp-t">Inspector</span> <button type="button" class="mc-x" data-close="right" title="Hide">×</button></h2><div class="mc-insp"></div>'); return a; } function paintInspector() { if (!ui) return; const t = rel.tracks[selected]; const box = ui.querySelector('.mc-insp'), ttl = ui.querySelector('.mc-insp-t'); if (!t) { ttl.textContent = 'Inspector'; box.innerHTML = mbuHtml('<div class="weak" style="font-size:12px">Select a track to see what each provider found for it.</div>'); return; } ttl.textContent = 'Track ' + t.pos + ' · ' + t.title; const blk = (h, p, body) => { const x = trackFinding(p, t.rec); const inner = x ? body(x) : '<div class="weak" style="font-size:11.5px">' + esc(stateText(PROVIDERS.find(y => y.id === p))) + '</div>'; return '<div class="mc-insp-block"><h2 class="mc-sec">' + h + '</h2>' + inner + '</div>'; }; const mini = (a, b) => '<div class="mc-mini">' + a + '<span class="n">' + (b || '') + '</span></div>'; const isrcBody = x => (x.existing || []).map(i => mini('<span class="mono">' + esc(i) + '</span>', 'on MB')).join('') + (x.isrc && !(x.existing || []).includes(x.isrc) ? mini('<span class="mono mc-add">+ ' + esc(x.isrc) + '</span>', esc(x.source || '')) : '') + (x.why ? '<div class="mc-why">' + esc(x.why) + '</div>' : '') + (!x.isrc && !(x.existing || []).length ? '<div class="weak" style="font-size:11.5px">none found</div>' : ''); const linksBody = x => (x.linkUrls || []).map(u => mini('<span class="mc-pico">' + stIcon(urlIcon(u), 14) + '</span> <a target="_blank" rel="noopener" href="' + esc(u) + '">' + esc(shortUrl(u)) + '</a>', 'on MB')).join('') + trackLinks(t.rec).map(l => mini('<span class="mc-pico">' + stIcon(urlIcon(l.url), 14) + '</span> <a class="mc-add" target="_blank" rel="noopener" href="' + esc(l.url) + '">+ ' + esc(shortUrl(l.url)) + '</a>', esc(l.name))).join('') || '<div class="weak" style="font-size:11.5px">none</div>'; const fusionBody = x => !(x.matches || []).length ? '<div class="weak" style="font-size:11.5px">no duplicates</div>' : mini('Fusion group', x.tier ? '<span class="mc-fxt ' + esc(x.tier) + '">' + esc(x.tier) + '</span>' : '') + x.matches.map(m => '<div class="mc-fxm">' + mini('<a target="_blank" href="/recording/' + esc(m.gid) + '"><b>' + esc(m.title || m.gid.slice(0, 8)) + '</b></a>', esc(m.gid.slice(0, 8))) + '<div class="mc-kv"><span>release</span><span>' + esc(fxRel(m)) + '</span>' + '<span>length</span><span>' + esc(m.len || '?') + fxLenDiff(x.self, m) + '</span>' + (m.artist ? '<span>artist</span><span>' + esc(m.artist) + '</span>' : '') + (m.isrcs ? '<span>ISRCs</span><span class="mono">' + (esc(m.isrcs.join(', ')) || 'none') + '</span>' : '') + '<span>AcoustIDs</span><span class="mono">' + (m.acoustids == null ? '<span class="weak">' + (fxBusy.has(x.key) ? 'checking…' : 'not checked') + '</span>' : m.acoustids.map(v => '<span title="' + esc(v) + '"' + (x.self && (x.self.acoustids || []).includes(v) ? ' class="mc-fxs"' : '') + '>' + esc(v.slice(0, 8)) + '…</span>').join(', ') || 'none') + '</span>' + '<span>edits</span><span>' + (m.pending ? '<span class="mc-err">open edit</span>' : 'none') + '</span></div></div>').join('') + (x.signals ? '<div class="weak" style="font-size:11px;margin:4px 0">every pair agrees on: ' + esc(x.signals.join(', ') || 'nothing') + '</div>' : '') + fxOpenBtn(x); // each credit names the sources that give it, when more than one source was read (#680) const chBody = x => (x.list || []).map(c => mini(esc(c.name), esc(c.role) + (c.sources && /,/.test(x.source || '') ? ' <span class="weak">· ' + esc(c.sources.join(', ')) + '</span>' : ''))).join('') || '<div class="weak" style="font-size:11.5px">no credits</div>'; box.innerHTML = mbuHtml('<div class="mc-mini">Recording<a class="n" target="_blank" href="/recording/' + esc(t.rec) + '">' + esc((t.rec || '').slice(0, 8)) + '</a></div>' + '<div class="mc-mini">Length<span class="n">' + esc(t.len || '?') + '</span></div><div style="height:10px"></div>' + blk('ISRCs', 'is', isrcBody) + blk('Recording links', 'is', linksBody) + (S.fusion !== 'off' ? blk('Fusion matches', 'fusion', fusionBody) : '') + (S.ch !== 'off' ? blk('Credits', 'ch', chBody) : '')); } function paintBadges() { if (!ui) return; paintSteps(); ui.querySelectorAll('[data-p]').forEach(n => { const p = PROVIDERS.find(x => x.id === n.dataset.p); if (!p) return; const dot = n.querySelector('.mc-dot'); if (dot) dot.className = 'mc-dot ' + stateOf(p); const st = n.querySelector('.st'); if (st) { st.textContent = sideText(p); st.title = stateText(p); } const chip = n.querySelector('.mc-chip'); if (chip) { const [c, t] = chipOf(p); chip.className = 'mc-chip ' + c; chip.textContent = t; } if (p.id === 'pc') paintCoverage(n); }); paintFoot(); } function paintSides() { ui.classList.toggle('no-left', !S.left); ui.classList.toggle('no-right', !S.right); } // Rebuild the body when a setting changes what's on screen (a step turned off // drops its column); the header and the open/closed state stay. function rebuildBody() { const main = ui.querySelector('.mc-main'); main.replaceWith(body()); paintAll(); paintInspector(); } function body() { const m = el('div', 'mc-main'); const c = el('main', 'mc-center'); c.append(matrix(), releaseCards()); m.append(orderSidebar(), c, inspector()); return m; } function open() { if (ui) return; mcStyle(); rel = readRelease(); watchTracks(); ui = el('div', 'mbu-ui'); ui.id = 'mc-root'; ui.append(header(), body()); document.body.appendChild(ui); document.documentElement.style.overflow = 'hidden'; document.documentElement.classList.add('mc-open'); paintSides(); paintAll(); paintInspector(); ui.addEventListener('click', onClick); ui.addEventListener('contextmenu', onContextMenu); ui.addEventListener('mousedown', onPickDown); ui.addEventListener('mouseover', onPickOver); document.addEventListener('mouseup', onPickUp); // a cover thumbnail that fails falls back to its full image once (errors don't bubble: capture) ui.addEventListener('error', e => { const i = e.target; if (i.tagName === 'IMG' && i.dataset.alt) { const a = i.dataset.alt; delete i.dataset.alt; i.dataset.from = i.getAttribute('src'); i.src = a; } }, true); document.addEventListener('keydown', onKey); stepClock = setInterval(() => { if (isBusy()) paintSteps(); paintFoot(); }, 1000); Log.info('opened · sidebars ' + (S.left ? 'order ' : '') + (S.right ? 'inspector' : '') + ' · fusion ' + S.fusion + ' · ch ' + S.ch + ' · auto probe ' + (S.autoProbe ? 'on' : 'off')); discover(); // Auto: probe once the providers had discover's moment to answer if (S.autoProbe) setTimeout(() => { if (ui && !run) { Log.info('auto probe'); probe(); } }, 700); } let trackObs = null, trackT = 0; function watchTracks() { const root = document.querySelector('#content') || document.body; trackObs = new MutationObserver(muts => { if (!ui || muts.every(m => ui.contains(m.target))) return; clearTimeout(trackT); trackT = setTimeout(() => { const n = document.querySelectorAll('#content table.medium tbody tr a[href*="/recording/"]').length; if (n === rel.tracks.length) return; Log.info('tracklist changed on the page: ' + rel.tracks.length + ' → ' + n + ' tracks, re-reading'); rel = readRelease(); if (selected != null && selected >= rel.tracks.length) selected = null; rebuildBody(); }, 300); }); trackObs.observe(root, { childList: true, subtree: true }); } function close() { if (!ui) return; if (trackObs) { trackObs.disconnect(); trackObs = null; } ui.remove(); ui = null; document.documentElement.style.overflow = ''; document.documentElement.classList.remove('mc-open'); document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onPickUp); clearInterval(stepClock); Log.info('closed'); } function onKey(e) { if (e.key !== 'Escape' || document.querySelector('.mbu-ov') || document.getElementById('mbu-logpop') || document.getElementById('mc-cover')) return; close(); } function selectTrack(i) { selected = i; ui.querySelectorAll('.mc-tbl tr.sel').forEach(r => r.classList.remove('sel')); const row = ui.querySelector('.mc-tbl tbody tr[data-i="' + i + '"]'); if (row) row.classList.add('sel'); if (!S.right) { S.right = true; saveSettings(); paintSides(); } paintInspector(); Log.debug('selected track ' + rel.tracks[selected].pos + ' (' + rel.tracks[selected].rec + ')'); } const isPicked = pk => !!(picked[pk.dataset.prov] && picked[pk.dataset.prov].has(pk.dataset.key)); // takes every pick in `pks` in (on) or leaves it out, then paints once function setPicks(pks, on) { if (!pks.length) return; pks.forEach(pk => { const set = picked[pk.dataset.prov] || (picked[pk.dataset.prov] = new Set()); if (on) set.add(pk.dataset.key); else set.delete(pk.dataset.key); // #709: a release has one barcode, so taking one in leaves the others out if (on && /^barcode:/.test(pk.dataset.key)) [...set].forEach(k => { if (k !== pk.dataset.key && /^barcode:/.test(k)) set.delete(k); }); // #740: a label has one catalog number, so taking one in leaves the label's others out const cat = on && (pk.dataset.key.match(/^catno:([0-9a-f-]{36}):/) || [])[0]; if (cat) [...set].forEach(k => { if (k !== pk.dataset.key && k.startsWith(cat)) set.delete(k); }); }); Log.debug((on ? 'taken in ' : 'left out ') + pks.map(pk => pk.dataset.prov + ' ' + pk.dataset.key).join(', ')); if (pks.some(pk => pk.closest('.mc-tbl'))) paintMatrix(); if (pks.some(pk => !pk.closest('.mc-tbl'))) paintCards(); paintExec(); if (pks.some(pk => pk.dataset.prov === 'pc')) syncLinks(700); } function togglePick(pk) { setPicks([pk], !isPicked(pk)); } // #714: in the track matrix, as in Apollo and ISRC Scout, Ctrl takes the whole row (the track), // Alt the whole column (for a found link, that platform on every track), Ctrl+Alt everything in // the table. All follow the clicked one: taken in when it wasn't, left out when it was. function bulkPick(pk, e) { const ctrl = e.ctrlKey || e.metaKey, alt = e.altKey; if (!(ctrl || alt) || !pk.closest('.mc-tbl')) return false; const all = [...ui.querySelectorAll('.mc-tbl .mc-pick[data-col]')]; const pks = ctrl && alt ? all : ctrl ? [...pk.closest('tr[data-i]').querySelectorAll('.mc-pick[data-col]')] : all.filter(x => x.dataset.col === pk.dataset.col && x.dataset.ico === pk.dataset.ico); setPicks(pks, !isPicked(pk)); return true; } // #714: a press on an ISRC or a merge, dragged up or down its column, takes in (or leaves out) // every one it passes, as the press would have; the click that ends a drag toggles nothing more let drag = null, dragged = false; const DRAG_SEL = '.mc-tbl span.mc-pick[data-col]'; function onPickDown(e) { dragged = false; if (e.button !== 0 || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; const pk = e.target.closest(DRAG_SEL); if (!pk || e.target.closest('a, button')) return; e.preventDefault(); // no text selection while dragging drag = { col: pk.dataset.col, key: pk.dataset.key, on: !isPicked(pk), moved: false }; } function onPickOver(e) { if (!drag) return; const pk = e.target.closest(DRAG_SEL); if (!pk || pk.dataset.col !== drag.col || (!drag.moved && pk.dataset.key === drag.key)) return; const pks = [pk]; // the first move takes the pressed one along (a paint replaces the elements: find it again) if (!drag.moved) { drag.moved = true; pks.push(...[...ui.querySelectorAll(DRAG_SEL)].filter(x => x.dataset.col === drag.col && x.dataset.key === drag.key)); } setPicks(pks.filter(x => isPicked(x) !== drag.on), drag.on); } function onPickUp() { if (drag && drag.moved) dragged = true; drag = null; } // a found track link is an <a> (a click opens it): right-click takes it in or leaves it out function onContextMenu(e) { const pk = e.target.closest('a.mc-lnk.mc-pick'); if (!pk) return; e.preventDefault(); if (!bulkPick(pk, e)) togglePick(pk); } function onClick(e) { const t = e.target; const side = t.closest('[data-side]'); if (side) { S[side.dataset.side] = true; saveSettings(); paintSides(); return; } const x = t.closest('[data-close]'); if (x) { S[x.dataset.close] = false; saveSettings(); paintSides(); return; } const m = t.closest('.mc-seg button'); if (m) { setMode(m.closest('.mc-seg').dataset.mode, m.dataset.v); return; } const fx = t.closest('[data-act^="fx"]'); if (fx) { const row = fx.dataset.act === 'fx' && fx.closest('.mc-tbl tbody tr[data-i]'); fxAct(fx); if (row) selectTrack(Number(row.dataset.i)); // the count also selects its track return; } // a pickable row or cell toggles on click (#680); a link inside it still just opens const pk = !t.closest('a') && t.closest('.mc-pick'); if (pk) { if (dragged) dragged = false; // the drag already did it else if (!bulkPick(pk, e)) togglePick(pk); if (!pk.closest('.mc-tbl')) return; // in the matrix the click also selects the track } const row = t.closest('.mc-tbl tbody tr[data-i]'); if (row && !t.closest('a')) { selectTrack(Number(row.dataset.i)); return; } const fetchBtn = t.closest('[data-fetch]'); if (fetchBtn) { probeOne(fetchBtn.dataset.fetch); return; } const act = t.closest('[data-act]'); // #753: a step switches its provider off and on; the format icon shows or hides the execution order const stp = !act && t.closest('.mc-step'); if (stp) { toggleProvider(stp.dataset.step); return; } if (t.closest('.mc-hdr .mc-fmt')) { S.left = !S.left; saveSettings(); paintSides(); return; } if (!act) return; switch (act.dataset.act) { case 'probe': { if (!probe()) mbuToast('No provider to ask: none is installed with a Mission Control adapter yet.'); break; } case 'linked': { // each section's linked icons open that section only const k = act.dataset.lk, open = Array.isArray(S.linkedOpen) ? S.linkedOpen.filter(x => x !== k) : []; if (!linkedOpen(k)) open.push(k); S.linkedOpen = open; saveSettings(); paintCards(); break; } case 'lane': { if (act.classList.contains('empty')) break; const k = act.dataset.lane; if (laneOpen.has(k)) laneOpen.delete(k); else laneOpen.add(k); paintCards(); break; } case 'lane-all': { const r = results.pc, k = act.dataset.lane, norm = b => String(b || '').replace(/\D/g, '').replace(/^0+/, ''); if (!r || !r.findings) break; const keys = r.findings.filter(x => !x.entity && laneTakes(x) && norm(x.barcode) === k).map(x => x.key); const set = picked.pc || (picked.pc = new Set()), all = keys.every(x => set.has(x)); keys.forEach(x => all ? set.delete(x) : set.add(x)); Log.debug((all ? 'left out ' : 'taken in ') + 'pc lane ' + (k || '(no barcode)') + ': ' + keys.join(', ')); paintCards(); paintExec(); syncLinks(700); break; } case 'falcon-open': document.dispatchEvent(new CustomEvent('falcon:show')); break; case 'card-onoff': setCardOff(act.dataset.sect, !cardOff(act.dataset.sect)); break; case 'cover': coverViewer(coverList(results.as && results.as.best), Number(act.dataset.i) || 0); break; case 'exec': execute(false); break; case 'retry-apply': retryOne(act.dataset.prov); break; case 'log': Log.open(); break; case 'cfg': settingsWindow(); break; case 'close': close(); break; } } function setMode(key, v) { if (S[key] === v) return; Log.info(key + ' mode ' + S[key] + ' → ' + v); S[key] = v; saveSettings(); if (ui) rebuildBody(); } // A card's icon and title are its On / Off switch, one button: nothing more in the header. Off: the // icon greyed, the title faded. function cardTitle(id, icon, title) { return '<button type="button" class="mc-icsw" data-act="card-onoff" data-sect="' + id + '" aria-pressed="true"><span class="ic' + (/^<img/.test(icon) ? ' img' : '') + '">' + icon + '</span><span class="t">' + title + '</span></button>'; } function paintOnOff() { ui.querySelectorAll('.mc-sect[data-sect]').forEach(s => { const id = s.dataset.sect, off = cardOff(id), b = s.querySelector('.mc-icsw'); s.classList.toggle('mc-off', off); if (!b) return; b.setAttribute('aria-pressed', String(!off)); b.title = off ? 'Left out: Execute skips this card. Click to take it in' : 'Click to leave this card out of Execute'; }); } function setCardOff(id, off) { if (off) OFF.add(id); else OFF.delete(id); Log.info('the ' + id + ' card is ' + (off ? 'off: Execute leaves it out' : 'on')); if (ui) { fitCards(); paintExec(); } } function settingsWindow() { const ov = el('div', 'mbu-ov'); const panel = el('div', 'mbu-ov-panel mc-cfg'); panel.style.width = 'min(460px, 94vw)'; const chk = (key, label) => '<label><input type="checkbox" data-k="' + key + '"' + (S[key] ? ' checked' : '') + '>' + label + '</label>'; panel.innerHTML = mbuHtml('<div class="mbu-ov-body">' + mbuCfgHeader({ script: 'mission_control', name: 'Mission Control', version: VERSION, icon: '<img src="' + ICON_URL + '" alt="">', log: true }) + chk('autoProbe', 'Probe as soon as Mission Control opens') + chk('reloadAfter', 'Reload the release page after an Execute without errors') + chk('consAfter', 'After saving a consolidated release, open Mission Control on it') + '</div>'); ov.appendChild(panel); document.body.appendChild(ov); const done = () => { ov.remove(); }; const off = mbuDismissOn(panel, done); // #744: the settings close before the log opens: open under it, they took the log's first ✕ or Esc for themselves panel.querySelector('.mbu-cfg-log').onclick = () => { off(); Log.open(); }; panel.addEventListener('change', e => { const k = e.target.dataset.k; if (!k) return; S[k] = e.target.checked; saveSettings(); }); } /* ── consolidation of a new release (#702) ────────────────────────────────── */ // On /release/add#mc=<token>, MusicBrainz has no release yet. First Contact's Consolidate read an // album on a platform and stored it for this page; Mission Control covers the empty editor and: // 1. asks Platform Check to find the album on the other platforms (a seed probe: barcode, title, // artist, track count), and groups what it finds by barcode into lanes, as on a release page; // 2. asks First Contact to read each platform of the source's lane (mc:read), and of the lanes // ticked by hand: another barcode is another release, so it is read only when ticked; // 3. lines the platforms' releases up, field by field and track by track, and takes the value // most of them give (a tie goes to Platform Check's platform order); a click takes another; // 4. seeds the release editor with what is taken (fc:seed, in this tab), with every taken // platform's link and each artist's links from all of them, for Apollo to match by. // After saving, the new release page opens Mission Control and probes it (an option, on by // default): ISRC Scout gets the ISRCs taken here, Art Station the cover, Platform Check the links. const CC_PC_KEY = { amazon: 'amazonmusic' }; // First Contact's provider ids → Platform Check's keys (the ST-ICONS names) const ccKey = id => CC_PC_KEY[id] || id; const CC_NAMES = { discogs: 'Discogs', bandcamp: 'Bandcamp', spotify: 'Spotify', apple: 'Apple Music', deezer: 'Deezer', tidal: 'Tidal', ytmusic: 'YouTube Music', amazonmusic: 'Amazon Music', qobuz: 'Qobuz', beatport: 'Beatport', volumo: 'Volumo', hdtracks: 'HDtracks', sevendigital: '7digital', soundcloud: 'SoundCloud', audiomack: 'Audiomack', ototoy: 'OTOTOY' }; const ccName = k => CC_NAMES[k] || k; const CC_UNREAD = { spotify: 'Spotify is read through its web player only: its link is added, its data is not compared' }; const ccIco = (k, size) => stIcon(k, size || 14) || '<span class="mcc-abbr">' + esc(String(k).slice(0, 2).toUpperCase()) + '</span>'; const ccIcons = keys => '<span class="mcc-icos">' + keys.map(k => '<span title="' + esc(ccName(k)) + '">' + ccIco(k, 13) + '</span>').join('') + '</span>'; const ccBc = b => String(b || '').replace(/\D/g, '').replace(/^0+/, ''); const ccNorm = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[\s.,'’"“”!?()[\]\-–—:;/&+]+/g, ' ').trim(); const ccLen = ms => { if (!ms) return ''; const s = Math.round(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }; const ccCredit = c => (c || []).map(a => (a.name || '') + (a.join || '')).join(''); // Discogs's placeholders say there is none: no value to vote with const ccNone = s => /^\[?(none|no label)\]?$/i.test(String(s || '').trim()); const ccCount = rel => (rel.mediums || []).reduce((n, m) => n + m.tracks.length, 0); const CC_AFTER_KEY = 'mc.after'; const CC_AFTER_TTL = 3 * 3600 * 1000; // #702: the consolidation's MusicBrainz lookups go through the shared request gate (#633) // <ST-NET> — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT const MBN_GAP = 1000; const MBN_BURST = 3; const MBN_KEY = "mbu:mb-gate"; const MBN_LOCK = "mbu-mb-gate"; const MBN_MAX_HOLD = 60000; function mbnGated(url) { const s = String(url || ''); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null'); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; // no storage: this page's copy only else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) {} return out; }; // The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is // stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang // every request behind it. let timer = 0; try { if (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === 'function' ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3000); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState(s => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? 'MusicBrainz asked everyone to wait' : 'pacing, one request a second' }; }); let told = false; for (;;) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); // a hold that appeared while we queued if (left <= 0) break; if (!told && until - t0 > 1000 && o.log) { told = true; o.log('info', 'MusicBrainz gate: ' + (o.label || 'request') + ' waits ' + Math.round((until - t0) / 100) / 10 + 's (' + (cool > booked.at ? 'MusicBrainz asked everyone to wait' : booked.why) + ')'); } await new Promise(r => setTimeout(r, Math.min(left, 250))); // in slices, so a cancel lands promptly } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header('Retry-After') : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState(s => { const now = Date.now(); // One throttle, one step: the requests already in flight when it began all answer // 503 too, and each must not stretch the hold again. A throttle long gone doesn't // count at all: `hot` is shared and persists, and would otherwise slow every later // page for good. if (now > (s.cool || 0) + 30000) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1000, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log('warn', 'MusicBrainz gate: HTTP ' + status + (raw != null ? ' (Retry-After: ' + raw + ')' : '') + ' — every script holds ' + Math.round(hold / 100) / 10 + 's'); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState(s => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 30000 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) { const r = await fetch(url, init); if (!mbnWebsite(url) || !(await mbnIsCheck(r))) return r; // #733: MusicBrainz's browser check answered instead: nothing was done (a POST is safe // to repeat), so pass the check and ask again return (await mbnReverify(url, init, o)) ? fetch(url, init) : r; } const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, n => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnHot() { let s; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; } return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over } function mbnRawHeader(raw, name) { const want = String(name).toLowerCase(); for (const line of String(raw || '').split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim(); } return null; } function mbnWebsite(url) { const s = String(url || ''); if (mbnGated(s)) return false; if (/^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org(\/|$)/i.test(s)) return true; return /^\/(?!\/)/.test(s) && typeof location !== 'undefined' && /(^|\.)musicbrainz\.org$/i.test(location.hostname); } async function mbnIsCheck(r) { try { if (!r || !/text\/html/i.test((r.headers && r.headers.get('content-type')) || '')) return false; const head = (await r.clone().text()).slice(0, 3000); return /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(head); } catch (e) { return false; } } function mbnReverify(url, init, o) { o = o || {}; const log = (lvl, m) => { try { if (o.log) o.log(lvl, m); else (lvl === 'warn' ? console.warn : console.info)('[MusicBrainz gate] ' + m); } catch (e) {} }; const g = globalThis.__mbnCheck || (globalThis.__mbnCheck = { busy: null, failedAt: 0 }); if (g.busy) return g.busy; let target; try { target = new URL(String(url), location.href); } catch (e) { return Promise.resolve(false); } if (target.host !== location.host || typeof document === 'undefined') { log('warn', 'MusicBrainz asked to verify the browser — open any MusicBrainz page, then retry'); return Promise.resolve(false); } if (Date.now() - g.failedAt < 60000) return Promise.resolve(false); // just failed: don't loop // a POST's URL can't be loaded: the check is per browser, so any light GET passes it const method = String((init && init.method) || 'GET').toUpperCase(); const src = method === 'GET' ? target.href : location.origin + '/ws/js/entity/89ad4ac3-39f7-470e-963a-56509c546377'; const t0 = Date.now(); log('warn', 'MusicBrainz asked to verify the browser again ("Verifying your browser") — passing it in a hidden frame'); g.busy = new Promise(resolve => { const fr = document.createElement('iframe'); // fresh each time, never re-navigated fr.setAttribute('aria-hidden', 'true'); fr.setAttribute('data-mbn-check', ''); fr.tabIndex = -1; fr.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;top:0;border:0;opacity:0;pointer-events:none'; let loads = 0, done = false, timer = 0; const finish = ok => { if (done) return; done = true; clearTimeout(timer); fr.remove(); g.busy = null; if (ok) log('info', 'browser check passed in ' + (Date.now() - t0) + ' ms — retrying'); else { g.failedAt = Date.now(); const m = 'MusicBrainz wants to verify your browser — open any MusicBrainz page in another tab, then retry'; log('warn', m + ' (the hidden frame did not clear it in 20 s)'); try { if (o.notify) o.notify(m); } catch (e) {} } resolve(ok); }; // the first load is the check; it posts its answer and is redirected to the real URL fr.onload = () => { loads++; let still; try { const d = fr.contentDocument; still = !d || /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(d.documentElement ? d.documentElement.outerHTML.slice(0, 3000) : ''); } catch (e) { still = loads < 2; } if (!still) finish(true); }; timer = setTimeout(() => finish(false), 20000); fr.src = src; (document.body || document.documentElement).appendChild(fr); }); return g.busy; } // </ST-NET> const CC = { token: null, src: null, error: null, run: 'c' + Date.now().toString(36), pc: { state: 'wait', note: '', findings: [], at: 0 }, sources: [], // { key, url, barcode, tracks, format, mismatch, why, role: 'source' | 'found', take, read: { state, rel, provider, error, n, total, ms, at } } pick: {}, // field → the value taken by hand rowPick: {}, // row id → the platform whose version is taken by hand extraTake: {}, // extra row id → taken in open: new Set(), // rows opened into their versions sel: null, // the row in the inspector ticks: { is: true, as: true, pc: true }, ui: null, seeded: false, inMb: { state: 'none', urls: '', releases: [] }, // the releases MusicBrainz links any of these albums to }; function ccStyle() { if (document.getElementById('mcc-style')) return; const s = document.createElement('style'); s.id = 'mcc-style'; s.textContent = '#mcc-root{position:fixed;inset:0;z-index:var(--mbu-z-modal);display:flex;flex-direction:column;background:var(--mbu-bg-sunken);color:var(--mbu-text);font:13px/1.4 var(--mbu-font)}' + '#mcc-root *{box-sizing:border-box}#mcc-root .mono{font-family:var(--mbu-font-mono)}#mcc-root .weak{color:var(--mbu-text-weak)}#mcc-root .dim{color:var(--mbu-text-dim)}' + '#mcc-root a{color:var(--mbu-accent-text)}' + '.mcc-hdr{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--mbu-bg);border-bottom:1px solid var(--mbu-border)}' + '.mcc-rel{min-width:0;flex:0 1 auto}.mcc-ttl{font-weight:700;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mcc-sub{font-size:12px;color:var(--mbu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' + '.mcc-steps{flex:1;display:flex;align-items:center;justify-content:center;gap:6px;min-width:0;overflow:hidden}' + '.mcc-step{display:flex;align-items:center;gap:6px;padding:3px 8px;border:1px solid transparent;border-radius:6px;white-space:nowrap;font-size:12px}' + '.mcc-step b{display:block;font-size:12px}.mcc-step span.n{display:block;color:var(--mbu-accent-text)}.mcc-step.busy{border:1px dashed var(--mbu-border-strong)}.mcc-step.err span.n{color:var(--mbu-error)}' + '.mcc-arrow{color:var(--mbu-text-weak)}' + '.mcc-btn{all:unset;box-sizing:border-box;cursor:pointer;display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:6px;border:1px solid var(--mbu-border);background:var(--mbu-bg);color:var(--mbu-text);font-weight:600;white-space:nowrap}' + '.mcc-btn:hover{background:var(--mbu-bg-hover)}.mcc-btn.go{background:var(--mbu-accent);border-color:var(--mbu-accent);color:var(--mbu-text-on-accent)}.mcc-btn.go:hover{background:var(--mbu-accent-hover)}' + '.mcc-btn[disabled]{opacity:.5;cursor:default}.mcc-btn.ghost{border-color:transparent;font-weight:400;padding:6px 8px}' + '.mcc-main{flex:1;display:flex;min-height:0}' + '.mcc-side{width:300px;flex:0 0 300px;overflow:auto;padding:12px 14px;background:var(--mbu-bg);border-right:1px solid var(--mbu-border)}' + '.mcc-insp{width:300px;flex:0 0 300px;overflow:auto;padding:12px 14px;background:var(--mbu-bg);border-left:1px solid var(--mbu-border)}' + '.mcc-center{flex:1;overflow:auto;padding:12px 14px;min-width:0}' + '.mcc-h{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--mbu-text-dim);margin:2px 0 8px}' + '.mcc-lane{margin:0 0 14px}.mcc-bc{display:inline-block;font-family:var(--mbu-font-mono);font-size:12px;padding:1px 7px;border-radius:5px;border:1px solid var(--mbu-border);margin-bottom:4px}' + '.mcc-bc.own{border-color:var(--mbu-ok-border);background:var(--mbu-ok-bg);color:var(--mbu-ok)}.mcc-lane-note{font-size:12px;color:var(--mbu-text-dim);margin:2px 0 4px}' + '.mcc-src{display:flex;align-items:center;gap:7px;padding:3px 4px;border-radius:5px;cursor:pointer}.mcc-src:hover{background:var(--mbu-bg-hover)}' + '.mcc-src input{margin:0;cursor:pointer;flex:none}.mcc-src>:not(.nm):not(.meta){flex:none}.mcc-src .nm{flex:1 0 auto;max-width:60%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mcc-src .meta{flex:0 1 auto;min-width:0;margin-left:auto;overflow:hidden;text-overflow:ellipsis;font-size:11px;color:var(--mbu-text-weak);white-space:nowrap}' + '.mcc-src.off .nm{color:var(--mbu-text-weak)}.mcc-src .meta.err{color:var(--mbu-error)}.mcc-src .meta.busy{color:var(--mbu-info)}.mcc-src .meta.warn{color:var(--mbu-warn)}' + '.mcc-src.source{cursor:default}' + '.mcc-icos{display:inline-flex;gap:2px;align-items:center;vertical-align:middle}.mcc-icos svg{display:block}.mcc-abbr{font-size:9px;font-weight:700;border:1px solid var(--mbu-border);border-radius:3px;padding:0 2px;line-height:12px}' + '.mcc-sect{background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:var(--mbu-radius-lg);margin:0 0 14px;overflow:hidden}' + '.mcc-sect-h{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--mbu-accent-soft);border-bottom:1px solid var(--mbu-border)}.mcc-sect-h b{font-size:14px}.mcc-sect-h .hint{flex:1;font-size:12px;color:var(--mbu-text-dim)}' + '.mcc-chip{font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--mbu-border);white-space:nowrap}.mcc-chip.ok{background:var(--mbu-ok-bg);border-color:var(--mbu-ok-border);color:var(--mbu-ok)}' + '.mcc-chip-btn{font:inherit;font-size:11px;cursor:pointer}.mcc-chip-btn:hover{filter:brightness(1.15)}.mcc-chip-btn .mcc-caret{display:inline-block;min-width:10px;text-align:center}' + '.mcc-chip.warn{background:var(--mbu-warn-bg);border-color:var(--mbu-warn-border);color:var(--mbu-warn)}' + '.mcc-fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:8px;padding:10px}' + '.mcc-f{border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:8px 10px;min-width:0}.mcc-f.differ{background:var(--mbu-warn-bg);border-color:var(--mbu-warn-border)}' + '.mcc-f-h{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-text-dim);display:flex;gap:6px;align-items:center}' + '.mcc-n{font-size:11px;min-width:18px;text-align:center;padding:0 5px;border-radius:999px;border:1px solid var(--mbu-warn-border);color:var(--mbu-warn);background:var(--mbu-bg)}' + '.mcc-win{font-weight:600;margin:3px 0 2px;word-break:break-word}.mcc-alt{display:flex;gap:6px;align-items:flex-start;font-size:12px;color:var(--mbu-text-dim);cursor:pointer;padding:2px 0}.mcc-alt:hover{color:var(--mbu-text)}' + '.mcc-alt .r{flex:0 0 auto;width:11px;height:11px;border:1px solid var(--mbu-border-strong);border-radius:50%;margin-top:3px}.mcc-alt .v{word-break:break-word}.mcc-f .d{display:inline-block;margin-right:4px}.mcc-alts.two .d,.mcc-win.lined .d{font-variant-numeric:tabular-nums;font-size:13px;font-weight:600}.mcc-alts.two .d>span{font-size:12px;font-weight:400}.mcc-win.lined{padding-left:17px}.mcc-alts.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-flow:column;column-gap:10px}' + '.mcc-f .none{font-size:12px;color:var(--mbu-text-weak)}.mcc-none-sep{display:inline-block;width:1px;height:12px;background:var(--mbu-border);margin:0 5px;vertical-align:middle}.mcc-none-icos>span{filter:grayscale(1);opacity:.38;transition:filter .15s,opacity .15s}.mcc-none-icos>span:hover{filter:none;opacity:1}' + '.mcc-tbl{width:100%;border-collapse:collapse}.mcc-tbl th{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-text-dim);text-align:left;padding:6px 8px;border-bottom:1px solid var(--mbu-border)}' + '.mcc-tbl td{padding:5px 8px;border-bottom:1px solid var(--mbu-divider);vertical-align:middle}.mcc-tbl tr.row{cursor:pointer}.mcc-tbl tr.row:hover td{background:var(--mbu-bg-hover)}' + '.mcc-tbl tr.sel td{background:var(--mbu-accent-soft)}.mcc-tbl td.pos{color:var(--mbu-text-weak);width:34px}.mcc-tbl td.len,.mcc-tbl td.isrc{font-family:var(--mbu-font-mono);font-size:12px;white-space:nowrap}' + '.mcc-tbl tr.ver td{background:var(--mbu-bg-raised);font-size:12px}.mcc-tbl tr.ver td:first-child{border-left:3px solid var(--mbu-accent)}.mcc-tbl tr.ver .r{display:inline-block;width:11px;height:11px;border:1px solid var(--mbu-border-strong);border-radius:50%;margin-right:6px;vertical-align:-1px}' + '.mcc-tbl tr.ver.on .r{background:var(--mbu-accent);border-color:var(--mbu-accent);box-shadow:inset 0 0 0 2px var(--mbu-bg)}.mcc-tbl tr.ver{cursor:pointer}' + '.mcc-tbl .hl{background:var(--mbu-warn-bg);border-radius:3px;padding:0 3px}.mcc-tbl tr.extra td.ttl{text-decoration:line-through;color:var(--mbu-text-weak)}.mcc-tbl tr.extra.on td.ttl{text-decoration:none;color:var(--mbu-text)}' + '.mcc-tbl .why{color:var(--mbu-text-weak)}.mcc-tbl td.car{width:20px;color:var(--mbu-text-weak);text-align:right}' + '.mcc-inmb{border:1px solid var(--mbu-warn-border);background:var(--mbu-warn-bg);border-radius:var(--mbu-radius-lg);padding:10px 12px;margin:0 0 14px}.mcc-inmb ul{margin:6px 0 0;padding-left:18px}.mcc-inmb li{margin:2px 0}.mcc-inmb.other{border-color:var(--mbu-border);background:var(--mbu-bg)}.mcc-inmb .mcc-upd{padding:2px 10px;margin-left:6px;font-size:12px}' + '.mcc-fmt-slot{display:inline-flex;flex:none}' + '.mcc-note{border:1px dashed var(--mbu-border-strong);border-radius:var(--mbu-radius-lg);padding:10px 12px;font-size:12px;color:var(--mbu-text-dim);background:var(--mbu-bg)}' + '.mcc-insp dl{display:grid;grid-template-columns:auto 1fr;gap:3px 10px;margin:0 0 12px;font-size:12px}.mcc-insp dt{color:var(--mbu-text-weak)}.mcc-insp dd{margin:0;word-break:break-word}' + '.mcc-vh{display:flex;justify-content:space-between;gap:8px}.mcc-vh .weak{text-transform:none;letter-spacing:0;font-weight:400}' + '.mcc-vers{margin:0 0 12px;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);overflow:hidden}' + '.mcc-ver{display:grid;grid-template-columns:16px 1fr auto;column-gap:6px;row-gap:2px;padding:7px 9px;font-size:12px;border-top:1px solid var(--mbu-divider)}.mcc-ver:first-child{border-top:0}' + '.mcc-ver[data-cc]{cursor:pointer}.mcc-ver[data-cc]:hover{background:var(--mbu-bg-hover)}.mcc-ver.on{background:var(--mbu-accent-soft)}' + '.mcc-ver .r{grid-row:1;width:11px;height:11px;border:1px solid var(--mbu-border-strong);border-radius:50%;margin-top:3px}.mcc-ver.on .r{background:var(--mbu-accent);border-color:var(--mbu-accent);box-shadow:inset 0 0 0 2px var(--mbu-bg)}' + '.mcc-ver .t{font-weight:600;word-break:break-word}.mcc-ver .n{color:var(--mbu-text-weak);white-space:nowrap;text-align:right}' + '.mcc-ver .m{grid-column:2/4;display:flex;align-items:center;gap:8px;color:var(--mbu-text-dim);min-width:0}.mcc-ver .m .mono{white-space:nowrap}.mcc-ver .i{margin-left:auto;display:flex;justify-content:flex-end;min-width:0}.mcc-ver .i .mcc-icos{flex-wrap:wrap;justify-content:flex-end}' + '.mcc-ver .hl{background:var(--mbu-warn-bg);color:var(--mbu-warn);border-radius:3px;padding:0 2px}.mcc-ver .hl.gap{display:inline-block;width:6px;height:1em;vertical-align:-2px;padding:0;border:1px dashed var(--mbu-warn-border)}' + '.mcc-insp p{font-size:12px;color:var(--mbu-text-dim);margin:0 0 12px}.mcc-tick{display:flex;gap:8px;align-items:flex-start;font-size:12px;margin:0 0 6px;cursor:pointer}.mcc-tick input{margin:2px 0 0}' + '.mcc-split{position:relative;display:inline-flex}.mcc-split>.mcc-btn:first-child{border-top-right-radius:0;border-bottom-right-radius:0}' + '.mcc-split>.mcc-more{border-top-left-radius:0;border-bottom-left-radius:0;border-left:1px solid var(--mbu-accent-hover);padding:6px 10px;min-width:32px;justify-content:center}' + '.mcc-menu{position:absolute;right:0;top:calc(100% + 4px);z-index:2;min-width:180px;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);box-shadow:0 6px 18px rgba(0,0,0,.25);padding:4px}' + '.mcc-menu button{all:unset;box-sizing:border-box;display:block;width:100%;padding:7px 10px;border-radius:5px;cursor:pointer;color:var(--mbu-text);white-space:nowrap}.mcc-menu button:hover{background:var(--mbu-bg-hover)}' + '.mcc-err{margin:40px auto;max-width:520px;background:var(--mbu-bg);border:1px solid var(--mbu-error-border);border-radius:var(--mbu-radius-lg);padding:16px 18px}' + '.mcc-spin{display:inline-block;width:10px;height:10px;border:2px solid var(--mbu-border);border-top-color:var(--mbu-info);border-radius:50%;animation:mcc-spin .8s linear infinite;vertical-align:-1px}@keyframes mcc-spin{to{transform:rotate(360deg)}}'; document.head.appendChild(s); } /* the sources and their lanes */ function ccSeed() { const r = CC.src.rel; return { barcode: r.barcode || null, title: r.title, artist: ccCredit(r.credit), tracks: ccCount(r), format: (r.mediums[0] && r.mediums[0].format) || 'Digital Media', year: r.date && r.date.year, label: r.labels && r.labels[0] && r.labels[0].name }; } function ccBuildSources() { const src = CC.src, sk = ccKey(src.source), was = new Map(CC.sources.map(s => [s.key, s])); const out = [{ key: sk, url: src.url, barcode: src.rel.barcode || null, tracks: ccCount(src.rel), role: 'source', take: true, read: { state: 'done', rel: src.rel, provider: src.platform } }]; for (const f of CC.pc.findings) { if (!f.url || out.some(s => s.key === f.key)) continue; // the source's own platform: the album read stands const old = was.get(f.key); out.push({ key: f.key, url: f.url, barcode: f.barcode || null, tracks: f.tracks == null ? null : f.tracks, format: f.format || null, links: f.links || null, mismatch: f.mismatch || null, why: f.why || null, state: f.state, role: 'found', take: old ? old.take : null, read: old && old.url === f.url ? old.read : { state: 'none' } }); } CC.sources = out; // the source's lane is taken; a platform with no barcode joins it when it has as many tracks const own = ccBc(out[0].barcode), n = out[0].tracks; out.forEach(s => { if (s.take != null) return; const bc = ccBc(s.barcode); s.take = !s.mismatch && (bc ? bc === own : (s.tracks === n || s.tracks == null)); }); Log.info('consolidation sources: ' + out.map(s => s.key + (s.take ? '✓' : '·') + (s.barcode ? ' ' + s.barcode : '') + (s.tracks != null ? ' ' + s.tracks + 't' : '') + (s.mismatch ? ' [' + s.mismatch.join('; ') + ']' : '')).join(' | ')); } function ccLanes() { const own = ccBc(CC.sources[0] && CC.sources[0].barcode); const by = new Map(); CC.sources.forEach(s => { const b = ccBc(s.barcode); if (!by.has(b)) by.set(b, []); by.get(b).push(s); }); return [...by.entries()].map(([bc, list]) => { // one form of the barcode: the one most of them give const forms = {}; list.forEach(s => { if (s.barcode) forms[s.barcode] = (forms[s.barcode] || 0) + 1; }); const shown = Object.keys(forms).sort((a, b) => forms[b] - forms[a] || b.length - a.length)[0] || ''; return { bc, list, own: bc === own, shown }; }).sort((a, b) => (b.own - a.own) || ((a.bc === '') - (b.bc === '')) || (b.list.length - a.list.length)); } /* reading the platforms (First Contact, headless) */ const CC_READS = 3, CC_READ_WAIT = 90000; const ccReadable = k => !CC_UNREAD[k]; function ccReadTaken() { CC.sources.forEach(s => { if (s.take && s.read.state === 'none' && ccReadable(s.key)) s.read = { state: 'queued' }; }); ccPump(); } function ccPump() { let active = CC.sources.filter(s => s.read.state === 'reading').length; for (const s of CC.sources) { if (active >= CC_READS) break; if (s.read.state !== 'queued') continue; s.read = { state: 'reading', at: Date.now() }; active++; Log.info('reading ' + ccName(s.key) + ': ' + s.url); document.dispatchEvent(new CustomEvent('mc:read', { detail: JSON.stringify({ url: s.url, run: CC.run }) })); } ccPaint(); } document.addEventListener('mc:read-progress', e => { let d; try { d = JSON.parse(e.detail); } catch (x) { return; } const s = d && d.run === CC.run && CC.sources.find(x => x.url === d.url && x.read.state === 'reading'); if (!s) return; Object.assign(s.read, { n: d.n, total: d.total, at: Date.now() }); ccPaintSide(); ccPaintSteps(); }); document.addEventListener('mc:read-result', e => { let d; try { d = JSON.parse(e.detail); } catch (x) { Log.warn('mc:read-result with unreadable detail: ' + x.message); return; } const s = d && d.run === CC.run && CC.sources.find(x => x.url === d.url && x.read.state === 'reading'); if (!s) return; if (d.ok && d.rel) { s.read = { state: 'done', rel: d.rel, provider: d.provider, ms: d.ms }; Log.ok('read ' + ccName(s.key) + ' in ' + d.ms + ' ms: "' + d.rel.title + '" · ' + ccCount(d.rel) + ' track(s) · barcode ' + (d.rel.barcode || ('—' + (s.barcode ? ', Platform Check found ' + s.barcode : '')))); } else { s.read = { state: 'failed', error: d.error || 'failed' }; Log.warn('read ' + ccName(s.key) + ' failed: ' + s.read.error); } ccPump(); }); /* consolidation: fields by vote, tracks by version */ function ccRank(k) { const order = [CC.sources[0] && CC.sources[0].key].concat(CC.pc.findings.map(f => f.key)); const i = order.indexOf(k); return i < 0 ? 99 : i; } const ccRead = () => CC.sources.filter(s => s.take && s.read.state === 'done'); // One field: the values the platforms give, grouped; the group most give wins (a tie: the platform // order), unless another was taken by hand. A platform that gives nothing doesn't vote. function ccVote(field, items) { const groups = []; items.forEach(it => { if (it.val == null || it.val === '') return; let g = groups.find(x => x.val === it.val); if (!g) groups.push(g = { val: it.val, show: it.show, data: it.data, keys: [], soft: true }); g.keys.push(it.key); if (!it.soft) g.soft = false; }); groups.forEach(g => g.keys.sort((a, b) => ccRank(a) - ccRank(b))); // a guessed value (a type FC guessed) only counts when nobody gives one const hard = g => (g.soft ? 0 : 1); groups.sort((a, b) => hard(b) - hard(a) || b.keys.length - a.keys.length || ccRank(a.keys[0]) - ccRank(b.keys[0])); const byHand = CC.pick[field] != null && groups.find(g => g.val === CC.pick[field]); const none = items.filter(it => it.val == null || it.val === '').map(it => it.key); return { field, groups, win: byHand || groups[0] || null, hand: !!byHand, none }; } const CC_FIELDS = [ ['title', 'Title'], ['artist', 'Artist'], ['date', 'Date'], ['label', 'Label'], ['catno', 'Cat. no.'], ['type', 'Type'], ['barcode', 'Barcode'], ]; function ccFields(reads) { const at = (fn) => reads.map(s => Object.assign({ key: s.key }, fn(s.read.rel, s))); const date = d => (d && d.year ? [d.year, d.month && String(d.month).padStart(2, '0'), d.day && String(d.day).padStart(2, '0')].filter(Boolean).join('-') : ''); // #746: a platform whose page has no barcode (Qobuz's newer albums) gives the one Platform Check found for it const bcOf = s => s.read.rel.barcode || s.barcode || null; const bc = {}; reads.forEach(s => { const b = bcOf(s); if (b) { const k = ccBc(b); (bc[k] = bc[k] || {})[b] = ((bc[k] || {})[b] || 0) + 1; } }); const bcShow = k => Object.keys(bc[k] || {}).sort((a, b) => bc[k][b] - bc[k][a] || b.length - a.length)[0] || k; return { title: ccVote('title', at(r => ({ val: r.title, show: r.title }))), artist: ccVote('artist', at(r => ({ val: ccCredit(r.credit), show: ccCredit(r.credit), data: r.credit }))), date: ccVote('date', at(r => ({ val: date(r.date), show: date(r.date), data: r.date }))), label: ccVote('label', at(r => { const ls = (r.labels || []).filter(l => l.name && !ccNone(l.name)); const v = ls.map(l => l.name).join(' / '); return { val: v, show: v, data: ls }; })), catno: ccVote('catno', at(r => { const v = (r.labels || []).map(l => l.catno).filter(c => c && !ccNone(c)).join(' / '); return { val: v, show: v }; })), type: ccVote('type', at(r => { const v = (r.types || []).join(' + '); return { val: v, show: r.typeGuessed ? v + ' (guessed)' : v, data: r.types, soft: !!r.typeGuessed }; })), barcode: ccVote('barcode', at((r, s) => { const b = bcOf(s); return { val: ccBc(b), show: b ? bcShow(ccBc(b)) : '' }; })), }; } // The tracks: each platform's flattened tracklist, lined up. The track count most platforms give // (a tie: the source's) is the tracklist; a platform with that count lines up by position, one with // another count by ISRC, then by a title only one track has. What lines up with nothing is an extra // row, left out unless taken. function ccTracks(reads) { const flats = reads.map(s => ({ key: s.key, list: s.read.rel.mediums.flatMap((m, mi) => m.tracks.map((t, ti) => ({ mi, ti, t }))) })); if (!flats.length) return { rows: [], extras: [], n: 0 }; const counts = {}; flats.forEach(f => { counts[f.list.length] = (counts[f.list.length] || 0) + 1; }); const srcN = flats[0].list.length; const n = +Object.keys(counts).sort((a, b) => counts[b] - counts[a] || (+b === srcN) - (+a === srcN))[0]; const base = flats.find(f => f.list.length === n); const rows = base.list.map((x, i) => ({ id: 'r' + i, i, mi: x.mi, ti: x.ti, tracks: [] })); const extras = []; flats.filter(f => f.list.length === n).forEach(f => f.list.forEach((x, i) => rows[i].tracks.push({ key: f.key, t: x.t }))); flats.filter(f => f.list.length !== n).forEach(f => { const used = new Set(); f.list.forEach(x => { const isrc = x.t.isrc && x.t.isrc.toUpperCase(); let r = isrc ? rows.find(r => !used.has(r.i) && r.tracks.some(y => y.t.isrc && y.t.isrc.toUpperCase() === isrc)) : null; if (!r) { const nt = ccNorm(x.t.title); const hits = rows.filter(r => !used.has(r.i) && r.tracks.some(y => ccNorm(y.t.title) === nt)); if (hits.length === 1) r = hits[0]; } if (r) { used.add(r.i); r.tracks.push({ key: f.key, t: x.t }); return; } const nt = ccNorm(x.t.title); let e = extras.find(e => ccNorm(e.tracks[0].t.title) === nt); if (!e) extras.push(e = { id: 'x' + extras.length, tracks: [] }); e.tracks.push({ key: f.key, t: x.t }); }); }); rows.concat(extras).forEach(r => { r.versions = ccVersions(r); r.win = ccRowWin(r); }); return { rows, extras, n }; } // Platforms agree on a track when the title is the same (case and punctuation count: they matter // for the style guidelines), and so are the ISRC and the length within 1 s where both give one. // Lengths chain: 4:34, 4:35 and 4:36 are one version, as each is within 1 s of another. function ccVersions(row) { const vs = []; row.tracks.slice().sort((a, b) => ccRank(a.key) - ccRank(b.key)).forEach(x => { const t = x.t, len = t.lengthMs || null, isrc = t.isrc ? t.isrc.toUpperCase() : null; let v = vs.find(v => v.title === t.title && (!isrc || !v.isrc || v.isrc === isrc) && (!len || !v.lens.length || v.lens.some(l => Math.abs(l - len) <= 1000))); if (!v) vs.push(v = { title: t.title, isrc: null, len: null, lens: [], keys: [], tracks: [] }); if (len) v.lens.push(len); v.keys.push(x.key); v.tracks.push(t); v.isrc = v.isrc || isrc; v.len = v.len || len; }); vs.sort((a, b) => b.keys.length - a.keys.length || ccRank(a.keys[0]) - ccRank(b.keys[0])); return vs; } function ccRowWin(r) { const k = CC.rowPick[r.id]; return (k && r.versions.find(v => v.keys.includes(k))) || r.versions[0]; } // what sets a version apart from the one taken // a title against the one taken, the part that differs highlighted: what's left once the // common start and end are set aside ("Doin[']" · "Doin That Thing[ (LP Version)]") function ccTitleDiff(a, b) { a = String(a || ''); b = String(b || ''); if (a === b) return esc(a); let p = 0; while (p < a.length && p < b.length && a[p] === b[p]) p++; let q = 0; while (q < a.length - p && q < b.length - p && a[a.length - 1 - q] === b[b.length - 1 - q]) q++; const mid = a.slice(p, a.length - q); return esc(a.slice(0, p)) + (mid ? '<span class="hl">' + esc(mid) + '</span>' : '<span class="hl gap" title="missing here: ' + esc(b.slice(p, b.length - q)) + '"></span>') + esc(a.slice(a.length - q)); } function ccDiff(v, w) { const out = []; if (v.title !== w.title) out.push(ccNorm(v.title) === ccNorm(w.title) ? 'the title\'s spelling' : 'the title'); if (v.len && w.len && Math.abs(v.len - w.len) > 1000) out.push((v.len > w.len ? '+' : '−') + Math.round(Math.abs(v.len - w.len) / 1000) + ' s'); if (v.isrc && w.isrc && v.isrc !== w.isrc) out.push('the ISRC'); return out; } function ccModel() { const reads = ccRead(); return { reads, fields: reads.length ? ccFields(reads) : null, tracks: ccTracks(reads) }; } /* the release the editor is seeded with */ // Each artist of a credit with its links on every platform read: the source's link stays the // credit's own (url); the others go in alt, with their platform, for Apollo to try. function ccLinked(credit, credits) { const srcKey = CC.sources[0].key; return (credit || []).map(a => { const links = []; credits.forEach(({ key, provider, credit: c }) => (c || []).forEach(b => { if (b.url && ccNorm(b.name) === ccNorm(a.name) && !links.some(l => l.url === b.url)) links.push({ key, url: b.url, urlForms: b.urlForms, platform: { abbr: provider.abbr, name: provider.name, artistLinkType: provider.artistLinkType || undefined } }); })); const own = links.find(l => l.key === srcKey); const alt = links.filter(l => l !== own).map(l => ({ url: l.url, urlForms: l.urlForms, platform: l.platform })); return { name: a.name, artistName: a.artistName || a.name, join: a.join || '', mbid: a.mbid || null, url: own ? own.url : null, urlForms: own ? own.urlForms : undefined, alt: alt.length ? alt : undefined }; }); } function ccSeedRel(m) { const f = m.fields, src = CC.src.rel, srcKey = CC.sources[0].key; const provOf = k => { const s = CC.sources.find(x => x.key === k); return (s && s.read.provider) || CC.src.platform; }; const relOf = k => { const s = CC.sources.find(x => x.key === k); return s && s.read.rel; }; const releaseCredits = m.reads.map(s => ({ key: s.key, provider: s.read.provider || CC.src.platform, credit: s.read.rel.credit })); const winRel = g => (g ? relOf(g.keys[0]) : null) || src; const credit = ccLinked(f.artist.win ? f.artist.win.data : src.credit, releaseCredits); const dateRel = winRel(f.date.win); const labelData = f.label.win ? f.label.win.data : []; const catnos = f.catno.win ? f.catno.win.val.split(' / ') : []; const labels = (labelData || []).map((l, i) => ({ name: l.name, catno: catnos[i] || (i === 0 ? catnos.join(' / ') : '') || '', url: l.url || null, urlForms: l.urlForms, mbid: l.mbid || null })); if (!labels.length && catnos.length) labels.push({ name: '', catno: catnos.join(' / ') }); const typeG = f.type.win; // the tracklist in the medium layout of the platform the tracklist came from const t = m.tracks, mediums = []; const baseRel = (() => { const s = m.reads.find(s => ccCount(s.read.rel) === t.n); return (s && s.read.rel) || src; })(); t.rows.forEach(r => { while (mediums.length <= r.mi) mediums.push({ format: (baseRel.mediums[mediums.length] || {}).format || 'Digital Media', name: (baseRel.mediums[mediums.length] || {}).name || '', tracks: [] }); mediums[r.mi].tracks.push(ccSeedTrack(r)); }); t.extras.filter(x => CC.extraTake[x.id]).forEach(x => { if (!mediums.length) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[mediums.length - 1].tracks.push(ccSeedTrack(x)); }); mediums.forEach(md => md.tracks.forEach((x, i) => { x.number = i + 1; })); // every link of every platform taken, read or not (a platform read gives its own link types) const urls = []; CC.sources.filter(s => s.take).forEach(s => { const own = s.read.state === 'done' && s.read.rel.urls && s.read.rel.urls.length ? s.read.rel.urls : (s.links || [{ url: s.url }]).map(l => ({ url: l.url, linkType: l.linkTypeId || undefined })); // unread: as Platform Check would add it if (s.read.state !== 'done') Log.debug('link of ' + ccName(s.key) + ' (' + s.read.state + '): type ' + (own.map(u => u.linkType || 'MusicBrainz picks').join('+')) + ' · ' + s.url); own.forEach(u => { if (!urls.some(x => x.url === u.url && x.linkType === u.linkType)) urls.push({ url: u.url, linkType: u.linkType }); }); }); function ccSeedTrack(r) { const v = r.win, first = r.tracks.find(x => v.keys.includes(x.key) && x.t.title === v.title) || r.tracks[0]; const credits = r.tracks.map(x => ({ key: x.key, provider: provOf(x.key), credit: x.t.credit })); const srcT = r.tracks.find(x => x.key === srcKey); return { title: v.title, lengthMs: v.len, isrc: v.isrc, url: srcT ? srcT.t.url : first.t.url, credit: ccLinked(first.t.credit, credits) }; } return { title: f.title.win ? f.title.win.val : src.title, credit, types: typeG ? typeG.data || [] : src.types || [], status: 'official', packaging: 'None', date: f.date.win ? f.date.win.data : dateRel.date, country: src.country || 'XW', barcode: f.barcode.win ? f.barcode.win.show : null, labels, urls, mediums, annotation: src.annotation || null, script: src.script || null, url: src.url, }; } function ccEditNote(m) { const lines = ['Consolidated by Mission Control from ' + m.reads.length + ' platform' + (m.reads.length === 1 ? '' : 's') + ':']; m.reads.forEach(s => lines.push(ccName(s.key) + (s.role === 'source' ? ' (source, read by First Contact)' : '') + ': ' + s.url)); const unread = CC.sources.filter(s => s.take && s.read.state !== 'done'); if (unread.length) lines.push('Linked, not read: ' + unread.map(s => ccName(s.key) + ' ' + s.url).join(', ')); return lines.join('\n') + '\n\nMission Control v' + VERSION + ' by majkinetor - https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mission_control/README.md'; } function ccOpenEditor() { if (CC.seeded) return; const m = ccModel(); if (!m.fields) { mbuToast('Nothing read yet', { kind: 'warn' }); return; } const rel = ccSeedRel(m); const n = ccCount(rel); Log.info('open in the release editor: "' + rel.title + '" · ' + ccCredit(rel.credit) + ' · ' + n + ' track(s) · ' + rel.urls.length + ' link(s) · barcode ' + (rel.barcode || '—') + ' · ' + (rel.types || []).join('+')); rel.mediums.forEach((md, mi) => md.tracks.forEach((t, ti) => Log.debug(' ' + (mi + 1) + '.' + (ti + 1) + ' "' + t.title + '" ' + ccLen(t.lengthMs) + ' ' + (t.isrc || '—') + ' · ' + ccCredit(t.credit) + ' · ' + t.credit.reduce((k, a) => k + (a.url ? 1 : 0) + (a.alt ? a.alt.length : 0), 0) + ' artist link(s)'))); if (S.consAfter && (CC.ticks.is || CC.ticks.as || CC.ticks.pc)) { const isrcs = rel.mediums.flatMap(md => md.tracks.map(t => (CC.ticks.is && t.isrc) || null)); try { sessionStorage.setItem(CC_AFTER_KEY, JSON.stringify({ created: Date.now(), title: rel.title, tracks: n, isrcs, ticks: CC.ticks })); } catch (e) { Log.warn('after saving: not noted (' + e.message + ')'); } Log.info('after saving: Mission Control opens on the new release · ' + Object.keys(CC.ticks).filter(k => CC.ticks[k]).join(', ') + ' · ' + isrcs.filter(Boolean).length + ' ISRC(s) taken'); } else { try { sessionStorage.removeItem(CC_AFTER_KEY); } catch (e) { /* storage blocked */ } } let got = false; const on = e => { let d = null; try { d = JSON.parse(e.detail); } catch (x) { return; } if (d && d.run === CC.run) { got = true; document.removeEventListener('fc:seeded', on); Log.ok('First Contact took the seed (handoff ' + d.token + ')'); } }; document.addEventListener('fc:seeded', on); CC.seeded = true; ccPaintHeader(); document.dispatchEvent(new CustomEvent('fc:seed', { detail: JSON.stringify({ rel, platform: CC.src.platform, editNote: ccEditNote(m), run: CC.run, dry: !!CC.dry }) })); setTimeout(() => { if (!got) { CC.seeded = false; ccPaintHeader(); Log.err('First Contact did not take the seed: is it running on this page?'); mbuToast('First Contact did not answer: it seeds the release editor', { kind: 'error' }); } }, 3000); if (mbuTestHooks()) window.__mccLastSeed = { rel, note: ccEditNote(m) }; } /* Copy as Markdown */ function ccMarkdown() { const m = ccModel(); if (!m.fields) return ''; const cell = s => String(s == null ? '' : s).replace(/\|/g, '\\|').replace(/\n/g, ' '); const who = keys => keys.map(ccName).join(', '); const out = ['## ' + cell(m.fields.title.win ? m.fields.title.win.val : CC.src.rel.title) + ': ' + m.reads.length + ' platforms compared', '', m.reads.map(s => '[' + ccName(s.key) + '](' + s.url + ')').join(' · '), '', '| Field | Taken | Others |', '| --- | --- | --- |']; CC_FIELDS.forEach(([k, label]) => { const v = m.fields[k]; out.push('| ' + label + ' | ' + (v.win ? cell(v.win.show) + ' (' + who(v.win.keys) + ')' : '—') + ' | ' + v.groups.filter(g => g !== v.win).map(g => cell(g.show) + ' (' + who(g.keys) + ')').join('<br>') + ' |'); }); out.push('', '| # | Title | Length | ISRC | Platforms | Others |', '| --- | --- | --- | --- | --- | --- |'); m.tracks.rows.forEach((r, i) => { const w = r.win; out.push('| ' + (i + 1) + ' | ' + cell(w.title) + ' | ' + ccLen(w.len) + ' | ' + (w.isrc || '') + ' | ' + who(w.keys) + ' | ' + r.versions.filter(v => v !== w).map(v => cell(v.title) + ' ' + ccLen(v.len) + ' ' + (v.isrc || '') + ' (' + who(v.keys) + ')').join('<br>') + ' |'); }); m.tracks.extras.forEach(x => out.push('| + | ' + cell(x.versions[0].title) + ' | ' + ccLen(x.versions[0].len) + ' | ' + (x.versions[0].isrc || '') + ' | ' + who(x.versions[0].keys) + ' | only there' + (CC.extraTake[x.id] ? ', taken' : ', left out') + ' |')); return out.join('\n'); } /* UI */ function ccStepHtml(cls, title, n, ico) { return '<div class="mcc-step ' + cls + '">' + (ico || '') + '<div><b>' + esc(title) + '</b><span class="n">' + n + '</span></div></div>'; } function ccPaintSteps() { const box = CC.ui && CC.ui.querySelector('.mcc-steps'); if (!box) return; const src = CC.sources[0]; const lanes = ccLanes().filter(l => l.bc).length; const pcN = CC.sources.length - 1; const pc = CC.pc.state === 'busy' ? ccStepHtml('busy', 'Platform Check', '<span class="mcc-spin"></span> searching · ' + Math.round((Date.now() - CC.pc.at) / 1000) + ' s', '<img class="mc-sic" alt="" src="' + PROVIDER_ICONS.pc + '">') : CC.pc.state === 'none' ? ccStepHtml('err', 'Platform Check', 'not running on this page', '<img class="mc-sic" alt="" src="' + PROVIDER_ICONS.pc + '">') : CC.pc.state === 'wait' ? ccStepHtml('', 'Platform Check', 'waiting', '<img class="mc-sic" alt="" src="' + PROVIDER_ICONS.pc + '">') : ccStepHtml('', 'Platform Check', pcN + ' found · ' + lanes + ' barcode' + (lanes === 1 ? '' : 's'), '<img class="mc-sic" alt="" src="' + PROVIDER_ICONS.pc + '">'); const want = CC.sources.filter(s => s.take && ccReadable(s.key)), done = want.filter(s => s.read.state === 'done').length, busy = want.some(s => s.read.state === 'reading' || s.read.state === 'queued'); const failed = want.filter(s => s.read.state === 'failed').length; const fc = ccStepHtml(busy ? 'busy' : failed ? 'err' : '', 'First Contact', (busy ? '<span class="mcc-spin"></span> ' : '') + 'read ' + done + ' of ' + want.length + (failed ? ' · ' + failed + ' failed' : '')); box.innerHTML = mbuHtml(ccStepHtml('', 'Source', src ? esc(ccName(src.key)) + ' · ' + src.tracks + ' tracks' : '—', src ? ccIco(src.key, 16) : '') + '<span class="mcc-arrow">→</span>' + pc + '<span class="mcc-arrow">→</span>' + fc); } // the format the release would get: the source's media, as the seed gives them function ccFormat() { const md = CC.src && CC.src.rel && Array.isArray(CC.src.rel.mediums) ? CC.src.rel.mediums : []; return md.map(m => m.format || 'Digital Media').join(' + ') || 'Digital Media'; } function ccPaintHeader() { if (!CC.ui) return; const m = ccModel(); const t = CC.ui.querySelector('.mcc-ttl'), sub = CC.ui.querySelector('.mcc-sub'); const title = m.fields && m.fields.title.win ? m.fields.title.win.val : CC.src ? CC.src.rel.title : 'Consolidate'; const artist = m.fields && m.fields.artist.win ? m.fields.artist.win.val : CC.src ? ccCredit(CC.src.rel.credit) : ''; if (t) { t.textContent = title; t.title = title; } const slot = CC.ui.querySelector('.mcc-fmt-slot'), fmt = ccFormat(); if (slot && slot.dataset.fmt !== fmt) { slot.dataset.fmt = fmt; slot.innerHTML = mbuHtml(fmtIcon(fmt) || '<img src="' + ICON_URL + '" alt="" width="28" height="28">'); } if (sub) { sub.textContent = artist; sub.title = artist; } // majkinetor: only the artist; the Source step links the platform CC.ui.querySelectorAll('[data-cc="open"]').forEach(b => { b.disabled = !m.fields || CC.seeded; b.textContent = CC.seeded ? 'Opening the release editor…' : 'Add release →'; }); CC.ui.querySelectorAll('[data-cc="more"]').forEach(b => { b.disabled = !m.fields || CC.seeded; }); ccPaintSteps(); } // PC's reasons, short for a source row: "5 tracks, the release has 6" → "5 tracks, not 6", // "Digital, the release is CD" → "Digital, not CD" const ccMismatch = list => list.map(x => String(x).replace(/, the release (?:has|is) /, ', not ')).join(' · '); function ccSideHtml() { if (!CC.src) return ''; const lanes = ccLanes(); const srcTracks = CC.sources[0].tracks; let h = '<div class="mcc-h">Sources</div>'; lanes.forEach(l => { h += '<div class="mcc-lane" data-bc="' + esc(l.bc) + '">' + (l.bc ? '<span class="mcc-bc' + (l.own ? ' own' : '') + '" title="' + (l.own ? 'The source\'s barcode' : 'Another barcode: another release') + '">' + esc(l.shown) + '</span>' : '<div class="mcc-lane-note">no barcode</div>'); if (!l.own && l.bc) h += '<div class="mcc-lane-note">' + esc(l.list.map(s => ccName(s.key) + (s.format ? ': ' + s.format : '')).join(', ')) + ' — another release, not read unless ticked</div>'; else if (!l.bc && !l.own) h += '<div class="mcc-lane-note">no barcode shown: taken when the track count is the source\'s (' + srcTracks + ')</div>'; l.list.forEach(s => { const r = s.read; const meta = s.role === 'source' ? ['', 'source · ' + s.tracks + ' tracks'] : !s.take ? ['', s.mismatch && s.mismatch.length ? ccMismatch(s.mismatch) : s.tracks != null ? s.tracks + ' tracks' : ''] : !ccReadable(s.key) ? ['warn', 'link only'] : r.state === 'reading' ? ['busy', 'reading' + (r.total ? ' ' + r.n + '/' + r.total : '…')] : r.state === 'queued' ? ['busy', 'waiting'] : r.state === 'failed' ? ['err', 'failed'] : r.state === 'done' ? ['', ccCount(r.rel) + ' tracks' + (r.rel.mediums.some(md => md.tracks.some(t => t.isrc)) ? ' · ISRCs' : '')] : ['', s.tracks != null ? s.tracks + ' tracks' : '']; const tip = s.role === 'source' ? 'The album First Contact read: ' + s.url : (meta[1] ? meta[1] + '\n' : '') + (CC_UNREAD[s.key] || (r.state === 'failed' ? 'Reading it failed: ' + r.error : s.why || '')) + '\n' + s.url; // the note in full: the row may cut it short h += '<label class="mcc-src' + (s.take ? '' : ' off') + (s.role === 'source' ? ' source' : '') + '" title="' + esc(tip.trim()) + '">' + '<input type="checkbox" data-cc="take" data-k="' + esc(s.key) + '"' + (s.take ? ' checked' : '') + (s.role === 'source' ? ' disabled' : '') + '>' + ccIco(s.key, 15) + '<span class="nm"><a href="' + esc(s.url) + '" target="_blank" rel="noopener">' + esc(ccName(s.key)) + '</a></span><span class="meta ' + meta[0] + '">' + esc(meta[1]) + '</span></label>'; }); h += '</div>'; }); if (CC.pc.state === 'busy') h += '<div class="mcc-lane-note"><span class="mcc-spin"></span> Platform Check is searching the other platforms…</div>'; if (CC.pc.state === 'none') h += '<div class="mcc-lane-note">Platform Check isn\'t running on this page, so only the source is compared.</div>'; h += '<div class="mcc-lane-note">A platform in another barcode lane is another release: it is read only when ticked. Untick one to leave its values out; its link goes with it.</div>'; return h; } function ccPaintSide() { const s = CC.ui && CC.ui.querySelector('.mcc-side'); if (s) s.innerHTML = mbuHtml(ccSideHtml()); } function ccFieldsHtml(m) { const f = m.fields; const differ = CC_FIELDS.filter(([k]) => f[k].groups.length > 1).length; let h = '<section class="mcc-sect" data-sect="release"><div class="mcc-sect-h"><b>Release</b><span class="hint">the value most platforms give is taken; click another to take it instead</span>' + (differ ? '<span class="mcc-chip warn">' + differ + ' differ</span>' : '<span class="mcc-chip ok">all agree</span>') + '</div><div class="mcc-fields">'; CC_FIELDS.forEach(([k, label]) => { const v = f[k], n = v.groups.length; h += '<div class="mcc-f' + (n > 1 ? ' differ' : '') + '" data-field="' + k + '"><div class="mcc-f-h">' + esc(label) + (n > 1 ? '<span class="mcc-n">' + n + '</span>' : '') + (v.hand ? '<span class="weak" style="text-transform:none;letter-spacing:0;font-weight:400">taken by hand</span>' : '') + '</div>'; if (!v.win) h += '<div class="none">no platform gives one</div>'; else { // majkinetor: the dates earliest first, in two columns (they took a lot of space), the icons on the // value's line and lined up: each date takes the longest one's width, so a short one keeps its space const alts = v.groups.filter(g => g !== v.win); const w = k === 'date' ? ' style="min-width:' + (Math.max(...v.groups.map(g => String(g.show).length)) + 1) + 'ch"' : ''; const val = g => '<span class="d"' + w + '><span>' + esc(g.show) + '</span></span>' + ccIcons(g.keys); // the slot in one font for all, so the taken (larger) date lines up too // majkinetor: the platforms that give none follow on the value's line, greyed like Platform Check's strip const none = v.none.length ? '<span class="mcc-none-sep"></span><span class="mcc-icos mcc-none-icos">' + v.none.map(k2 => '<span title="none from ' + esc(ccName(k2)) + '">' + ccIco(k2, 13) + '</span>').join('') + '</span>' : ''; h += '<div class="mcc-win' + (k === 'date' ? ' lined' : '') + '">' + val(v.win) + none + '</div>'; if (k === 'date') alts.sort((a, b) => String(a.val).localeCompare(String(b.val))); h += '<div class="mcc-alts' + (k === 'date' ? ' two" style="grid-template-rows:repeat(' + Math.ceil(alts.length / 2) + ',auto)' : '') + '">'; // down the first column, then the second alts.forEach(g => { h += '<div class="mcc-alt" data-cc="field" data-f="' + k + '" data-v="' + esc(g.val) + '" title="Take this instead"><span class="r"></span><span class="v">' + val(g) + '</span></div>'; }); h += '</div>'; } h += '</div>'; }); return h + '</div></section>'; } function ccTracksHtml(m) { const t = m.tracks; const agree = t.rows.filter(r => r.versions.length === 1).length, differ = t.rows.length - agree; let h = '<section class="mcc-sect" data-sect="tracks"><div class="mcc-sect-h"><b>Tracks</b><span class="hint">one row per track, the version most platforms have · a row that differs opens into its versions</span>' + '<span class="mcc-chip ok">' + agree + ' agree</span>' + (differ ? (() => { const all = t.rows.every(r => r.versions.length === 1 || CC.open.has(r.id)); return '<button type="button" class="mcc-chip warn mcc-chip-btn" data-cc="differ" aria-expanded="' + all + '" title="' + (all ? 'Close' : 'Open') + ' every track that differs">' + differ + ' differ <span class="mcc-caret">' + (all ? '▾' : '▸') + '</span></button>'; })() : '') + (t.extras.length ? '<span class="mcc-chip">' + t.extras.length + ' only on one</span>' : '') + '</div>' + '<table class="mcc-tbl"><thead><tr><th>#</th><th>Title</th><th>Len</th><th>ISRC</th><th>Agree</th><th></th></tr></thead><tbody>'; const nM = new Set(t.rows.map(r => r.mi)).size; t.rows.forEach((r, i) => { const w = r.win, many = r.versions.length > 1, op = CC.open.has(r.id); h += '<tr class="row' + (CC.sel === r.id ? ' sel' : '') + '" data-cc="row" data-r="' + r.id + '"><td class="pos">' + (nM > 1 ? (r.mi + 1) + '.' + (r.ti + 1) : i + 1) + '</td><td class="ttl">' + esc(w.title) + '</td><td class="len">' + ccLen(w.len) + '</td><td class="isrc">' + esc(w.isrc || '—') + '</td>' + '<td>' + (many ? '<span class="mcc-chip warn">' + r.versions.length + ' versions</span>' : ccIcons(w.keys)) + '</td><td class="car">' + (many ? (op ? '▾' : '▸') : '') + '</td></tr>'; if (many && op) r.versions.forEach(v => { const d = v === w ? [] : ccDiff(v, w); h += '<tr class="ver' + (v === w ? ' on' : '') + '" data-cc="ver" data-r="' + r.id + '" data-k="' + esc(v.keys[0]) + '"><td></td><td><span class="r"></span>' + (v.title !== w.title ? '<span class="hl">' + esc(v.title) + '</span>' : esc(v.title)) + (d.length ? ' <span class="why">· ' + esc(d.join(', ')) + '</span>' : '') + '</td>' + '<td class="len">' + (v.len && w.len && Math.abs(v.len - w.len) > 1000 ? '<span class="hl">' + ccLen(v.len) + '</span>' : ccLen(v.len)) + '</td><td class="isrc">' + (v.isrc && w.isrc && v.isrc !== w.isrc ? '<span class="hl">' + esc(v.isrc) + '</span>' : esc(v.isrc || 'none')) + '</td><td>' + ccIcons(v.keys) + '</td><td></td></tr>'; }); }); t.extras.forEach(x => { const v = x.versions[0], on = !!CC.extraTake[x.id]; h += '<tr class="row extra' + (on ? ' on' : '') + '" data-cc="extra" data-x="' + x.id + '" title="' + (on ? 'Taken in, at the end: click to leave it out' : 'Only ' + v.keys.map(ccName).join(', ') + ' has it: click to take it in') + '"><td class="pos">+</td><td class="ttl">' + esc(v.title) + '</td><td class="len">' + ccLen(v.len) + '</td><td class="isrc">' + esc(v.isrc || '—') + '</td><td><span class="mcc-chip">only ' + esc(v.keys.map(ccName).join(', ')) + '</span></td><td></td></tr>'; }); return h + '</tbody></table></section>'; } function ccPaintCenter() { const c = CC.ui && CC.ui.querySelector('.mcc-center'); if (!c) return; if (CC.error) { c.innerHTML = mbuHtml('<div class="mcc-err"><b>Nothing to consolidate</b><p>' + esc(CC.error) + '</p><p>Use <b>Consolidate</b> on the album\'s page on a platform (First Contact) to start again.</p></div>'); return; } if (!CC.src) { c.innerHTML = mbuHtml('<div class="mcc-note"><span class="mcc-spin"></span> Waiting for First Contact to hand over the album…</div>'); return; } const m = ccModel(); if (!m.fields) { c.innerHTML = mbuHtml('<div class="mcc-note">Nothing read yet.</div>'); return; } c.innerHTML = mbuHtml(ccInMbHtml() + ccFieldsHtml(m) + ccTracksHtml(m) + '<div class="mcc-note"><b>What Add release does:</b> MusicBrainz\'s release editor opens in this tab, seeded with what is taken here: the fields, the tracklist, every link of the platforms ticked on the left, and, for Apollo to match artists by, each artist\'s links on all the platforms read. ' + (S.consAfter ? 'Once you save it, Mission Control opens on the new release for what the editor can\'t take: the boxes on the right.' : 'Mission Control doesn\'t open after saving (⚙).') + '</div>'); } function ccPaintInspector() { const box = CC.ui && CC.ui.querySelector('.mcc-insp'); if (!box) return; const m = CC.src ? ccModel() : null; let h = ''; const r = m && m.fields && m.tracks.rows.concat(m.tracks.extras).find(x => x.id === CC.sel); if (r) { const w = r.win; // majkinetor: "this report kinda looks messy". A version a card of the same two lines: the title, // then length and ISRC; what sets it apart from the one taken highlighted; the platforms as icons // (their names in the tooltip) with a count. A click takes it, as in the Tracks table. h += '<div class="mcc-h mcc-vh"><span>Track ' + (r.i != null ? r.i + 1 : '+') + ' · ' + esc(w.title) + '</span>' + (r.versions.length > 1 ? '<span class="weak">' + r.versions.length + ' versions</span>' : '') + '</div><div class="mcc-vers">'; const hl = x => '<span class="hl">' + esc(x) + '</span>'; r.versions.forEach(v => { const on = v === w, title = on ? esc(v.title) : ccTitleDiff(v.title, w.title); const len = !on && v.len && w.len && Math.abs(v.len - w.len) > 1000 ? hl(ccLen(v.len)) : esc(ccLen(v.len) || '?:??'); const isrc = !v.isrc ? (on || !w.isrc ? 'no ISRC' : hl('no ISRC')) : !on && w.isrc && v.isrc !== w.isrc ? hl(v.isrc) : esc(v.isrc); h += '<div class="mcc-ver' + (on ? ' on' : '') + '"' + (on || r.versions.length < 2 ? '' : ' data-cc="ver" data-r="' + esc(r.id) + '" data-k="' + esc(v.keys[0]) + '" title="Take this version"') + '>' + '<span class="r"></span><div class="t">' + title + '</div><span class="n">' + (on ? 'taken · ' : '') + v.keys.length + '</span>' + '<div class="m"><span class="mono">' + len + ' · ' + isrc + '</span><span class="i" title="' + esc(v.keys.map(ccName).join(', ')) + '">' + ccIcons(v.keys) + '</span></div></div>'; }); h += '</div>'; const cr = r.tracks.map(x => x.key + ': ' + ccCredit(x.t.credit)).filter((s, i, a) => a.findIndex(y => y.split(': ')[1] === s.split(': ')[1]) === i); h += '<div class="mcc-h">Artist</div><p>' + esc(cr.map(s => { const [k, c] = s.split(': '); return c + ' (' + ccName(k) + ')'; }).join(' · ')) + '</p>'; } else if (m && m.fields) h += '<div class="mcc-h">Track</div><p>Click a track to see what each platform gives for it.</p>'; const nIsrc = m && m.fields ? m.tracks.rows.filter(x => x.win.isrc).length : 0; h += '<div class="mcc-h">After saving</div>'; if (!S.consAfter) h += '<p>Off in the settings (⚙): Mission Control doesn\'t open on the new release.</p>'; else { const tick = (k, text) => '<label class="mcc-tick"><input type="checkbox" data-cc="tick" data-t="' + k + '"' + (CC.ticks[k] ? ' checked' : '') + '><span>' + text + '</span></label>'; h += '<p>Mission Control opens on the new release and probes it; these start taken in:</p>' + tick('is', 'ISRCs (' + nIsrc + ' taken here) and recording links, through ISRC Scout') + tick('as', 'Cover: the best one of the linked platforms, through Art Station') + tick('pc', 'Artist and label links, through Platform Check and Falcon'); } box.innerHTML = mbuHtml(h); } function ccPaint() { ccPaintHeader(); ccPaintSide(); ccPaintCenter(); ccPaintInspector(); } // Add release's ▾ menu (Copy as Markdown). A mousedown outside closes it and swallows the click it // starts, so that click doesn't also tick or open what is under it; Escape closes it too. function ccMenu(open) { const menu = CC.ui && CC.ui.querySelector('.mcc-menu'), more = CC.ui && CC.ui.querySelector('[data-cc="more"]'); if (!menu) return; const show = open === undefined ? menu.hidden : open; menu.hidden = !show; more.setAttribute('aria-expanded', String(show)); document.removeEventListener('mousedown', ccMenuOutside, true); document.removeEventListener('keydown', ccMenuKey, true); if (show) { document.addEventListener('mousedown', ccMenuOutside, true); document.addEventListener('keydown', ccMenuKey, true); } } function ccMenuOutside(e) { if (e.target.closest && e.target.closest('.mcc-split')) return; ccMenu(false); const swallow = ev => { ev.stopPropagation(); ev.preventDefault(); }; document.addEventListener('click', swallow, { capture: true, once: true }); setTimeout(() => document.removeEventListener('click', swallow, true), 400); } function ccMenuKey(e) { if (e.key === 'Escape') { e.stopPropagation(); ccMenu(false); } } function ccOnClick(e) { const t = e.target; const a = t.closest('[data-cc]'); if (!a) return; switch (a.dataset.cc) { case 'take': { const s = CC.sources.find(x => x.key === a.dataset.k); if (!s || s.role === 'source') return; s.take = a.checked; Log.info(ccName(s.key) + (s.take ? ' taken in' : ' left out')); if (s.take) ccReadTaken(); else ccPaint(); return; } case 'field': CC.pick[a.dataset.f] = a.dataset.v; Log.info('field ' + a.dataset.f + ' taken by hand: ' + a.dataset.v); ccPaint(); return; case 'row': { const id = a.dataset.r; if (t.closest('a')) return; CC.sel = id; if (CC.open.has(id)) CC.open.delete(id); else CC.open.add(id); ccPaintCenter(); ccPaintInspector(); return; } case 'differ': { const rows = ccModel().tracks.rows.filter(r => r.versions.length > 1), all = rows.every(r => CC.open.has(r.id)); rows.forEach(r => all ? CC.open.delete(r.id) : CC.open.add(r.id)); Log.info((all ? 'closed ' : 'opened ') + rows.length + ' track(s) that differ'); ccPaintCenter(); return; } case 'ver': CC.rowPick[a.dataset.r] = a.dataset.k; CC.sel = a.dataset.r; Log.info('track ' + a.dataset.r + ': ' + ccName(a.dataset.k) + '\'s version taken'); ccPaint(); return; case 'extra': CC.extraTake[a.dataset.x] = !CC.extraTake[a.dataset.x]; CC.sel = a.dataset.x; ccPaint(); return; case 'tick': CC.ticks[a.dataset.t] = a.checked; Log.info('after saving: ' + a.dataset.t + ' ' + (a.checked ? 'on' : 'off')); ccPaintInspector(); return; case 'open': ccOpenEditor(); return; case 'update': ccUpdateRelease(a.dataset.id); return; case 'more': ccMenu(); return; case 'md': { ccMenu(false); const md = ccMarkdown(); if (mbuTestHooks()) window.__mccMarkdown = md; navigator.clipboard.writeText(md).then(() => mbuToast('Copied as Markdown', { kind: 'ok' }), x => mbuToast('Copy failed: ' + x.message, { kind: 'error' })); return; } case 'cfg': ccSettings(); return; case 'close': ccClose(); return; case 'log': Log.open(); return; } } function ccSettings() { const ov = el('div', 'mbu-ov'); const panel = el('div', 'mbu-ov-panel mc-cfg'); panel.style.width = 'min(460px, 94vw)'; panel.innerHTML = mbuHtml('<div class="mbu-ov-body">' + mbuCfgHeader({ script: 'mission_control', name: 'Mission Control', version: VERSION, icon: '<img src="' + ICON_URL + '" alt="">', log: true }) + '<label><input type="checkbox" data-k="consAfter"' + (S.consAfter ? ' checked' : '') + '>After saving a consolidated release, open Mission Control on it</label></div>'); ov.appendChild(panel); document.body.appendChild(ov); const off = mbuDismissOn(panel, () => ov.remove()); panel.querySelector('.mbu-cfg-log').onclick = () => { off(); Log.open(); }; // #744, as in settingsWindow panel.addEventListener('change', e => { const k = e.target.dataset.k; if (!k) return; S[k] = e.target.checked; saveSettings(); ccPaint(); }); } function ccClose() { if (!CC.ui) return; CC.ui.remove(); CC.ui = null; document.documentElement.style.overflow = ''; ccLauncher(); Log.info('consolidation closed (the launcher opens it again)'); } function ccLauncher() { if (document.getElementById('mc-launch')) return; mcStyle(); const b = el('button', 'mc-launch'); b.id = 'mc-launch'; b.type = 'button'; const img = document.createElement('img'); img.src = ICON_URL; img.alt = ''; b.appendChild(img); b.title = 'Mission Control: the consolidation'; b.dataset.mbCorner = 'br'; b.dataset.mbCornerOrder = '5'; b.onclick = () => { b.remove(); mbRestackCorner('br'); ccOpen(); }; document.body.appendChild(b); mbRestackCorner('br'); } let ccClock = 0; function ccOpen() { if (CC.ui) return; mcStyle(); ccStyle(); const ui = el('div', 'mbu-ui'); ui.id = 'mcc-root'; ui.innerHTML = mbuHtml('<header class="mcc-hdr"><span class="mcc-fmt-slot"></span><div class="mcc-rel"><div class="mcc-ttl"></div><div class="mcc-sub"></div></div><div class="mcc-steps"></div>' + '<div class="mcc-split"><button type="button" class="mcc-btn go" data-cc="open" disabled title="Open MusicBrainz\'s release editor in this tab, seeded with what is taken here">Add release →</button>' + '<button type="button" class="mcc-btn go mcc-more" data-cc="more" disabled aria-haspopup="menu" aria-expanded="false" title="More">▾</button>' + '<div class="mcc-menu" role="menu" hidden><button type="button" role="menuitem" data-cc="md" title="The whole comparison as Markdown tables, for an issue or an edit note">Copy as Markdown</button></div></div>' + '<button type="button" class="mcc-btn ghost" data-cc="cfg" title="Settings">' + MBU_CFG_ICON + '</button><button type="button" class="mcc-btn ghost" data-cc="close" title="Close: the empty release editor is under it">✕</button></header>' + '<div class="mcc-main"><aside class="mcc-side"></aside><main class="mcc-center"></main><aside class="mcc-insp"></aside></div>'); document.body.appendChild(ui); CC.ui = ui; document.documentElement.style.overflow = 'hidden'; ui.addEventListener('click', ccOnClick); clearInterval(ccClock); ccClock = setInterval(() => { if (!CC.ui) return; // a read that hasn't been heard from in a while has failed (First Contact gone, or a request that hangs) CC.sources.forEach(s => { if (s.read.state === 'reading' && Date.now() - s.read.at > CC_READ_WAIT) { s.read = { state: 'failed', error: 'no answer in ' + CC_READ_WAIT / 1000 + ' s' }; Log.warn('read ' + ccName(s.key) + ': no answer'); ccPump(); } }); if (CC.pc.state === 'busy') ccPaintSteps(); }, 1000); ccPaint(); } // the album First Contact stored for this page function ccTake(json, from) { let h = null; try { h = JSON.parse(json || 'null'); } catch (e) { Log.warn('consolidation unreadable: ' + e.message); return; } if (!h || h.token !== CC.token || CC.src) return; if (h.error) { CC.error = h.error; Log.warn('consolidation ' + CC.token + ': ' + h.error); ccPaint(); return; } CC.src = h; CC.error = null; Log.info('consolidation ' + CC.token + ' (' + from + '): ' + h.sourceName + ' "' + h.rel.title + '" by ' + ccCredit(h.rel.credit) + ' · ' + ccCount(h.rel) + ' track(s) · barcode ' + (h.rel.barcode || '—') + ' · ' + h.url); ccBuildSources(); ccPaint(); ccAskPc(); } // Platform Check's seed probe, as soon as both the album and Platform Check are here function ccAskPc() { if (!CC.src || CC.pc.state !== 'wait' || !found.pc) return; CC.pc = { state: 'busy', note: '', findings: [], at: Date.now() }; const seed = ccSeed(); Log.info('asking Platform Check (seed probe ' + CC.run + '): ' + JSON.stringify(seed)); document.dispatchEvent(new CustomEvent('mc:probe', { detail: JSON.stringify({ run: CC.run, only: ['pc'], seed }) })); ccPaint(); } document.addEventListener('mc:findings', e => { if (!CC.token) return; let d; try { d = JSON.parse(e.detail); } catch (x) { return; } if (!d || d.id !== 'pc' || d.run !== CC.run) return; CC.pc = { state: 'done', note: '', findings: Array.isArray(d.findings) ? d.findings : [], at: CC.pc.at }; Log.ok('Platform Check answered in ' + Math.round((Date.now() - CC.pc.at) / 1000) + ' s: ' + CC.pc.findings.filter(f => f.url).length + ' platform(s) found'); ccBuildSources(); ccReadTaken(); ccCheckInMb(); }); // majkinetor approved this request on #702: one /ws/2/url lookup (inc=release-rels) of every album // link found, 50 to a request, so a release MusicBrainz already has gets a banner, not a second copy. // Several links answer { urls: [...] } and leave out the ones MB doesn't know; one answers the url itself, or 404. async function ccCheckInMb() { const urls = [...new Set(CC.sources.map(s => s.url).filter(Boolean))]; const key = urls.join(' '); if (!urls.length || CC.inMb.urls === key) return; CC.inMb = { state: 'busy', urls: key, releases: [] }; Log.info('MusicBrainz: does it have this album? looking up ' + urls.length + ' link(s)'); try { const by = new Map(); for (let i = 0; i < urls.length; i += 50) { const part = urls.slice(i, i + 50); const r = await mbnFetch('/ws/2/url?' + part.map(u => 'resource=' + encodeURIComponent(u)).join('&') + '&inc=release-rels&fmt=json', { headers: { Accept: 'application/json' } }); if (!r) throw new Error('the request gate gave up (MusicBrainz is throttling)'); if (r.status === 404) continue; if (!r.ok) throw new Error('HTTP ' + r.status); const j = await r.json(); (Array.isArray(j.urls) ? j.urls : [j]).forEach(u => (u.relations || []).forEach(rel => { const x = rel.release; if (!x || !x.id) return; const e = by.get(x.id) || { id: x.id, title: x.title || '', disamb: x.disambiguation || '', date: x.date || '', barcode: x.barcode || '', urls: [] }; if (!e.urls.includes(u.resource)) e.urls.push(u.resource); by.set(x.id, e); })); } if (CC.inMb.urls !== key) return; // majkinetor: "MB has it, but check format too". The url lookup gives no media, so each release // found (one or two, as a rule) is looked up for its format and track count. for (const e of by.values()) { const r = await mbnFetch('/ws/2/release/' + e.id + '?inc=media&fmt=json', { headers: { Accept: 'application/json' } }); if (!r || !r.ok) { Log.warn('MusicBrainz: release ' + e.id + ' answered ' + (r ? 'HTTP ' + r.status : 'nothing (throttled)') + ': its format is unknown'); continue; } const j = await r.json(); e.format = (j.media || []).map(m => m.format || '(unknown format)').join(' + '); e.tracks = (j.media || []).reduce((n, m) => n + (m['track-count'] || 0), 0); } if (CC.inMb.urls !== key) return; by.forEach(e => { e.same = ccSameRelease(e); }); CC.inMb = { state: 'done', urls: key, releases: [...by.values()].sort((a, b) => b.same - a.same || b.urls.length - a.urls.length) }; if (CC.inMb.releases.length) Log[CC.inMb.releases.some(e => e.same) ? 'warn' : 'info']('MusicBrainz has ' + CC.inMb.releases.length + ' release(s) these links belong to: ' + CC.inMb.releases.map(e => e.id + ' "' + e.title + '" ' + (e.format || '?') + ', ' + (e.tracks == null ? '?' : e.tracks) + ' tracks, barcode ' + (e.barcode || 'none') + ' (' + e.urls.length + ' link(s)) → ' + (e.same ? 'this album' : 'another edition')).join(', ')); else Log.ok('MusicBrainz has none of the ' + urls.length + ' link(s): a new release'); } catch (e) { if (CC.inMb.urls !== key) return; CC.inMb = { state: 'failed', urls: key, releases: [], error: e.message }; Log.warn('MusicBrainz lookup of the album links failed: ' + e.message); } ccPaintCenter(); } // A release is this album when nothing tells it apart from the source: its barcode (leading zeros // aside), its formats (Digital Media, CD, vinyl, … in kind) and its track count, each where both give one. const ccFmtKind = f => String(f || '').split('+').map(x => { x = x.trim(); return /digital/i.test(x) ? 'digital' : /\bcd\b|compact disc|hdcd|sacd/i.test(x) ? 'cd' : /vinyl|"|\blp\b/i.test(x) ? 'vinyl' : /cassette/i.test(x) ? 'cassette' : x.toLowerCase(); }).sort().join('+'); function ccSameRelease(e) { const src = CC.sources[0] || {}; const bc = x => String(x || '').replace(/^0+/, ''); if (e.barcode && src.barcode && bc(e.barcode) !== bc(src.barcode)) return false; if (e.format && !/unknown/.test(e.format) && ccFmtKind(e.format) !== ccFmtKind(ccFormat())) return false; if (e.tracks && src.tracks && e.tracks !== src.tracks) return false; return true; } // #727: the consolidation goes to a release MusicBrainz already has. Noted in this tab's sessionStorage // as for Add release, with the release's MBID: its page opens Mission Control and probes, ISRC Scout // gets the ISRCs, and Platform Check the barcode, the catalog numbers and the platforms' links (cons), // each offered only where the release lacks it. function ccUpdateRelease(mbid) { const m = ccModel(); if (!m.fields) { mbuToast('Nothing read yet', { kind: 'warn' }); return; } const e = CC.inMb.releases.find(x => x.id === mbid); const rel = ccSeedRel(m), n = ccCount(rel); const isrcs = rel.mediums.flatMap(md => md.tracks.map(t => t.isrc || null)); const cons = { barcode: rel.barcode ? String(rel.barcode).replace(/\D/g, '') : null, labels: rel.labels.filter(l => l.name || l.mbid).map(l => ({ name: l.name || '', mbid: l.mbid || null, catno: l.catno || '', url: l.url || null })), urls: CC.sources.filter(s => s.take && s.url).map(s => ({ key: s.key, url: s.url })), from: m.reads.map(s => ccName(s.key)), }; const note = { created: Date.now(), mbid, title: (e && e.title) || rel.title, tracks: n, isrcs, ticks: CC.ticks, cons }; Log.info('update release ' + mbid + ': barcode ' + (cons.barcode || '—') + ' · labels ' + (cons.labels.map(l => (l.name || l.mbid) + (l.catno ? ' ' + l.catno : '')).join(', ') || '—') + ' · ' + isrcs.filter(Boolean).length + ' ISRC(s) · ' + cons.urls.length + ' link(s) · taken in: ' + (Object.keys(CC.ticks).filter(k => CC.ticks[k]).join(', ') || 'none')); if (mbuTestHooks()) window.__mccLastUpdate = note; try { sessionStorage.setItem(CC_AFTER_KEY, JSON.stringify(note)); } catch (x) { Log.err('the update is not noted (' + x.message + ')'); mbuToast('Could not note the update: ' + x.message, { kind: 'error' }); return; } if (CC.dry) { Log.info('dry run: the release is not opened'); return; } location.assign('/release/' + mbid); } function ccInMbHtml() { const rs = CC.inMb.releases; if (CC.inMb.state !== 'done' || !rs.length) return ''; const plat = u => { const s = CC.sources.find(x => x.url === u); return s ? ccName(s.key) : u; }; const li = e => '<li><a href="/release/' + esc(e.id) + '" target="_blank" rel="noopener">' + esc(e.title) + '</a>' + (e.disamb ? ' <span class="weak">(' + esc(e.disamb) + ')</span>' : '') + (e.date ? ' · ' + esc(e.date) : '') + (e.format ? ' · ' + esc(e.format) : '') + (e.tracks ? ' · ' + e.tracks + ' tracks' : '') + (e.barcode ? ' · <span class="mono">' + esc(e.barcode) + '</span>' : '') + ' <span class="weak">· linked from ' + esc(e.urls.map(plat).join(', ')) + '</span></li>'; const same = rs.filter(e => e.same), other = rs.filter(e => !e.same); let h = ''; // #727: Update this release takes what is consolidated here to it, for what it lacks const upd = e => ' <button type="button" class="mcc-btn mcc-upd" data-cc="update" data-id="' + esc(e.id) + '" title="Open this release in this tab with what is taken here: Mission Control probes it and offers what it lacks (the barcode, catalog numbers, ISRCs, links)">Update this release →</button>'; if (same.length) h += '<div class="mcc-inmb"><b>MusicBrainz already has this album.</b> Update it with what it lacks, rather than adding the album again.<ul>' + same.map(e => li(e).replace(/<\/li>$/, upd(e) + '</li>')).join('') + '</ul></div>'; if (other.length) h += '<div class="mcc-inmb other"><b>Other editions in MusicBrainz</b> <span class="dim">(another barcode, format or track count than ' + esc(ccFormat()) + (CC.sources[0] && CC.sources[0].barcode ? ' ' + esc(CC.sources[0].barcode) : '') + '):</span><ul>' + other.map(li).join('') + '</ul></div>'; return h; } function consolidateMain(token) { CC.token = token; Log.info('consolidation page, token ' + token); const start = () => { ccOpen(); document.addEventListener('fc:consolidate', e => ccTake(e.detail, 'event')); if (document.documentElement.dataset.fcConsolidate) ccTake(document.documentElement.dataset.fcConsolidate, 'page'); else document.dispatchEvent(new CustomEvent('fc:consolidate-request')); document.addEventListener('mc:provider', () => ccAskPc()); document.dispatchEvent(new CustomEvent('mc:discover', { detail: JSON.stringify({ release: null, mc: VERSION }) })); // nobody handed anything over: First Contact isn't running here setTimeout(() => { if (!CC.src && !CC.error) { CC.error = 'First Contact isn\'t running on this page, or it has no album for it.'; Log.warn(CC.error); ccPaint(); } }, 4000); // no Platform Check: compare the source alone setTimeout(() => { if (CC.src && CC.pc.state === 'wait') { CC.pc.state = 'none'; Log.warn('Platform Check isn\'t running on this page: only the source is compared'); ccPaint(); ccCheckInMb(); } }, 6000); }; if (document.body) start(); else document.addEventListener('DOMContentLoaded', start, { once: true }); } if (mbuTestHooks()) window.__mccTest = { state: () => CC, model: ccModel, seedRel: () => ccSeedRel(ccModel()), markdown: ccMarkdown, open: ccOpenEditor, setDry: v => { CC.dry = v; }, lanes: ccLanes, versions: ccVersions, inMb: ccCheckInMb, paint: () => ccPaint(), same: ccSameRelease, fmtKind: ccFmtKind }; /* after saving: Mission Control on the new release (#702) */ // Add release notes, in this tab's sessionStorage, what Mission Control should do once // the release is saved: the new release page then opens it, probes, and takes in only what was // ticked; ISRC Scout gets the ISRCs taken in the consolidation, by track. let AFTER = null; function afterSave() { let a = null; try { a = JSON.parse(sessionStorage.getItem(CC_AFTER_KEY) || 'null'); } catch (e) { return; } if (!a) return; try { sessionStorage.removeItem(CC_AFTER_KEY); } catch (e) { /* storage blocked */ } if (Date.now() - a.created > CC_AFTER_TTL) { Log.info('a consolidation\'s note for after saving is older than 3 hours: dropped'); return; } // #727: an update of a release MusicBrainz already had names it; a new release is known by its title if (a.mbid) { if (a.mbid !== RELEASE) { Log.info('a consolidation\'s update is for release ' + a.mbid + ', not this one: dropped'); return; } } else { const title = (document.querySelector('.releaseheader h1') || {}).textContent || ''; if (ccNorm(title).indexOf(ccNorm(a.title)) === -1) { Log.info('a consolidation\'s note for after saving is for "' + a.title + '", not this release: dropped'); return; } } AFTER = a; Log.info((a.mbid ? 'a consolidation updates this release' : 'the consolidated release is saved') + ': Mission Control opens and probes · ' +Object.keys(a.ticks).filter(k => a.ticks[k]).join(', ') + ' · ' + (a.isrcs || []).filter(Boolean).length + ' ISRC(s) from the consolidation'); open(); setTimeout(() => { if (ui && !run) probe(); }, 700); } // what wasn't ticked for after saving starts left out function afterPicks(id) { if (!AFTER || !picked[id] || !(id in AFTER.ticks) || AFTER.ticks[id]) return; picked[id].clear(); Log.info('after saving: ' + id + ' left out, as ticked in the consolidation'); } // the ISRCs taken in the consolidation, by track, for ISRC Scout (#702) function afterIsrcs() { return AFTER && AFTER.ticks.is && Array.isArray(AFTER.isrcs) ? AFTER.isrcs : undefined; } // #727: what a consolidation brings to a release MusicBrainz already had, for Platform Check function afterCons() { return AFTER && AFTER.cons ? AFTER.cons : undefined; } /* ── launcher ──────────────────────────────────────────────────────────────── */ function launcher() { if (document.getElementById('mc-launch')) return; mcStyle(); const b = el('button', 'mc-launch'); b.id = 'mc-launch'; b.type = 'button'; const img = document.createElement('img'); img.src = ICON_URL; img.alt = ''; b.appendChild(img); b.title = 'Mission Control\nRight-click: settings'; b.dataset.mbCorner = 'br'; b.dataset.mbCornerOrder = '5'; // highest priority: closest to the corner b.onclick = () => open(); b.oncontextmenu = e => { e.preventDefault(); settingsWindow(); }; // #695: right-click on every corner launcher opens its settings document.body.appendChild(b); mbRestackCorner('br'); } if (mbuTestHooks()) window.__mcTest = { open, close, execute, results: () => results, after: () => AFTER, picked: () => Object.fromEntries(Object.entries(picked).map(([k, v]) => [k, Array.from(v)])), settings: () => Object.assign({}, S), off: () => [...OFF], found: () => Object.assign({}, found), release: () => rel, bestHtml, coverList, setStall: ms => { STALL_MS = ms; }, fxChecking: () => fxBusy.size }; // <ST-ICONS> — generated by dev/ui/sync-icons.mjs from dev/ui/platform-icons.mjs — DO NOT EDIT const ST_ICONS = {"musicbrainz":{"color":"#eb743b","svg":"<svg viewBox=\"0.75 0.75 28.5 28.5\" xmlns=\"http://www.w3.org/2000/svg\"><g transform=\"translate(1.5)\"><path d=\"m13 1-12 7v14l12 7z\" fill=\"#ba478f\"/><path d=\"m14 1 12 7v14l-12 7z\" fill=\"#eb743b\"/></g></svg>"},"discogs":{"color":"#333333","svg":"<svg viewBox=\"71 71 882 882\" xmlns=\"http://www.w3.org/2000/svg\"><g transform=\"translate(512 512) scale(0.86) translate(-512 -512)\"><circle cx=\"512\" cy=\"512\" r=\"496\" fill=\"#333\" stroke=\"#9a9a9a\" stroke-width=\"32\"/><path fill=\"#fff\" d=\"M439.84 511.58A72.58 72.58 0 0 1 512.41 439 72.54 72.54 0 0 1 585 511.58a72.56 72.56 0 0 1-72.57 72.56 72.56 72.56 0 0 1-72.57-72.56zm3.18 0A69.48 69.48 0 0 0 512.41 581a69.4 69.4 0 0 0 69.4-69.38 69.49 69.49 0 0 0-69.4-69.43A69.44 69.44 0 0 0 443 511.58zm69.42-11.44a11.43 11.43 0 1 0 11.47 11.45 11.45 11.45 0 0 0-11.48-11.45zm-131.08 11.43a130.68 130.68 0 0 0 40.3 94.43l24.68-26.69.33.3a94.59 94.59 0 0 1 113.08-149.95l17.51-31.95a130.23 130.23 0 0 0-64.82-17.22c-72.27.01-131.08 58.81-131.08 131.08zm225.73 0a94.6 94.6 0 0 1-138.64 83.79l-17.83 31.74a130.26 130.26 0 0 0 61.82 15.53c72.28 0 131.08-58.8 131.08-131.08a130.63 130.63 0 0 0-37.73-91.9L581 446.39a94.3 94.3 0 0 1 26.1 65.2zm-267.34 0a172.17 172.17 0 0 0 53.68 125l25-27.07a135.38 135.38 0 0 1-41.82-97.89c0-74.88 60.92-135.8 135.8-135.8a134.92 134.92 0 0 1 67.08 17.8l17.73-32.34a171.57 171.57 0 0 0-84.81-22.35c-95.19-.03-172.66 77.43-172.66 172.65zm308.49 0c0 74.88-60.92 135.8-135.8 135.8a135 135 0 0 1-64.14-16.14l-18.07 32.17a171.62 171.62 0 0 0 82.21 20.86c95.22 0 172.69-77.47 172.69-172.69a172.15 172.15 0 0 0-51-122.4l-25.12 27a135.35 135.35 0 0 1 39.23 95.4zm41.61 0c0 97.83-79.58 177.43-177.41 177.43a176.32 176.32 0 0 1-84.52-21.46l-18.18 32.36a213.21 213.21 0 0 0 102.7 26.23C630.74 726.11 727 629.87 727 511.57a213.87 213.87 0 0 0-64.38-153l-25.26 27.18a176.85 176.85 0 0 1 52.49 125.82zm-392 0A213.9 213.9 0 0 0 365 667.24L390.23 640A176.88 176.88 0 0 1 335 511.57c0-97.82 79.59-177.41 177.41-177.41a176.26 176.26 0 0 1 87.08 22.93l17.84-32.55A213.14 213.14 0 0 0 512.44 297c-118.3 0-214.54 96.28-214.54 214.57zm392.55-183-24.64 26.49a218.57 218.57 0 0 1 65.94 156.51c0 120.9-98.36 219.26-219.26 219.26a217.9 217.9 0 0 1-105-26.84l-18.24 32.47A255.43 255.43 0 0 0 512 768c141.39 0 256-114.64 256-256a255.23 255.23 0 0 0-77.55-183.41zm-397.27 183c0-120.9 98.36-219.26 219.26-219.26a217.84 217.84 0 0 1 107.19 28.09L637 288.65A254.46 254.46 0 0 0 516.12 256H512c-140.54.22-254.42 113.26-256 253.5v2.5a255.69 255.69 0 0 0 80.51 186.08l25.31-27.36a218.61 218.61 0 0 1-68.64-159.15z\"/></g></svg>"},"spotify":{"color":"#1DB954","svg":"<svg viewBox=\"1.25 1.375 21.25 21.25\" fill=\"#1DB954\"><path transform=\"translate(12 12) scale(.875) translate(-12 -12)\" d=\"M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.42 1.56-.299.421-1.02.599-1.559.3z\"/></svg>"},"apple":{"color":"#FA243C","svg":"<svg viewBox=\"1.625 1.25 20 20\" fill=\"#FA243C\"><path d=\"M17.05 12.04c-.03-2.5 2.04-3.7 2.13-3.76-1.16-1.7-2.97-1.93-3.61-1.96-1.54-.16-3 .9-3.78.9-.78 0-1.97-.88-3.24-.86-1.67.03-3.21.97-4.07 2.46-1.73 3.01-.44 7.47 1.24 9.92.82 1.2 1.8 2.54 3.08 2.49 1.24-.05 1.71-.8 3.21-.8 1.5 0 1.92.8 3.23.77 1.33-.02 2.18-1.22 3-2.42.94-1.39 1.33-2.73 1.35-2.8-.03-.01-2.59-.99-2.62-3.93zM14.6 4.59c.68-.83 1.14-1.97 1.01-3.11-.98.04-2.17.65-2.87 1.47-.63.73-1.18 1.9-1.03 3.02 1.09.08 2.21-.55 2.89-1.38z\"/></svg>"},"deezer":{"color":"#A238FF","svg":"<svg viewBox=\"3 3 18 18\"><path transform=\"translate(12 12) scale(.74) translate(-12 -12)\" d=\"M4 2h6v2h-6zM14 2h6v2h-6zM2 4h20v2h-20zM0 6h24v2h-24zM0 8h24v2h-24zM0 10h24v2h-24zM2 12h20v2h-20zM4 14h16v2h-16zM6 16h12v2h-12zM8 18h8v2h-8zM10 20h4v2h-4z\" fill=\"#A238FF\"/></svg>"},"tidal":{"color":"#000000","svg":"<svg viewBox=\"3 3 18 18\"><path d=\"M6 6l3 3-3 3-3-3zM12 6l3 3-3 3-3-3zM18 6l3 3-3 3-3-3zM12 12l3 3-3 3-3-3z\" style=\"fill:var(--mbu-text,currentColor)\"/></svg>"},"qobuz":{"color":"#0070ef","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#0070ef\"/><circle cx=\"12\" cy=\"12\" r=\"5\" fill=\"none\" stroke=\"#fff\" stroke-width=\"2.2\"/><path d=\"M14.5 14.5 19 19\" stroke=\"#fff\" stroke-width=\"2.2\" stroke-linecap=\"round\"/></svg>"},"beatport":{"color":"#01FF95","svg":"<svg viewBox=\"0.75 0.75 22.5 22.5\"><circle cx=\"12\" cy=\"12\" r=\"11\" fill=\"#000\"/><g transform=\"translate(12 12) scale(0.84) translate(-12 -12)\" fill=\"none\" stroke=\"#01FF95\" stroke-width=\"2.5\"><path d=\"M10.9 3V8.3c0 1.2-.4 1.9-1.1 2.6L5.6 15.1\"/><circle cx=\"13.9\" cy=\"15.8\" r=\"4.05\" stroke-width=\"2.35\"/></g></svg>"},"bandcamp":{"color":"#629AA9","svg":"<svg viewBox=\"2.25 2.25 19.5 19.5\" fill=\"#629AA9\"><path transform=\"translate(12 12) scale(.8) translate(-12 -12)\" d=\"M0 18.75l7.437-13.5H24l-7.438 13.5z\"/></svg>"},"volumo":{"color":"#7c4dff","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#7c4dff\"/><path d=\"M7 8h2.2l2.8 6 2.8-6H17l-4 9h-2z\" fill=\"#fff\"/></svg>"},"hdtracks":{"color":"#e63329","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#e63329\"/><path d=\"M5 7.5h1.7v3.1h2.6V7.5H11v8H9.3v-3.2H6.7v3.2H5zm7.2 0h2.9c2 0 3.4 1.6 3.4 4s-1.4 4-3.4 4h-2.9zm1.7 1.5v5h1.1c1.1 0 1.8-1 1.8-2.5s-.7-2.5-1.8-2.5z\" fill=\"#fff\"/></svg>"},"soundcloud":{"color":"#ff5500","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#ff5500\"/><g fill=\"#fff\"><rect x=\"6\" y=\"12\" width=\"1.4\" height=\"4\" rx=\".6\"/><rect x=\"8.5\" y=\"10\" width=\"1.4\" height=\"6\" rx=\".6\"/><rect x=\"11\" y=\"8.5\" width=\"1.4\" height=\"7.5\" rx=\".6\"/><rect x=\"13.5\" y=\"10.5\" width=\"1.4\" height=\"5.5\" rx=\".6\"/><rect x=\"16\" y=\"11.5\" width=\"1.4\" height=\"4.5\" rx=\".6\"/></g></svg>"},"audiomack":{"color":"#FFA200","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#FFA200\"/><path d=\"M5 13.5l2-2 1.6 2.4 2.2-5.4 2.4 6.6 2.2-4 1.6 2.4H19\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.6\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/></svg>"},"sevendigital":{"color":"#07606E","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#07606E\"/><path d=\"M7.8 6.8h8.4v1.9l-4.5 8.9H9.4l4.4-8.7h-6z\" fill=\"#fff\"/></svg>"},"ytmusic":{"color":"#FF0000","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#FF0000\"/><circle cx=\"12\" cy=\"12\" r=\"5.6\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.4\"/><path d=\"M10.4 9.5v5l4.2-2.5z\" fill=\"#fff\"/></svg>"},"amazonmusic":{"color":"#25D1DA","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#25D1DA\"/><path d=\"M5.8 11.2c3.5 3.2 8.9 3.5 12.4.9\" fill=\"none\" stroke=\"#0F1111\" stroke-width=\"1.9\" stroke-linecap=\"round\"/><path d=\"M15.5 10.7l3 1.3-.9 3.1\" fill=\"none\" stroke=\"#0F1111\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>"},"soundexchange":{"color":"#6f42c1","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#6f42c1\"/><path d=\"M6.5 12h1.3l1-3 1.6 6 1.6-9 1.6 12 1.4-6h1.5\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.4\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/></svg>"},"globe":{"color":"#6f7d75","svg":"<svg viewBox=\"2 2 20 20\" fill=\"none\" stroke=\"#6f7d75\" stroke-width=\"1.8\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18\"/></svg>"}}; function stIcon(name, size) { var i = ST_ICONS[name]; if (!i) return ''; size = size || 16; return i.svg.replace(/<svg\b([^>]*)>/, function (m, a) { a = a.replace(/\s(?:width|height)="[^"]*"/g, ''); var ns = /\bxmlns=/.test(a) ? '' : ' xmlns="http://www.w3.org/2000/svg"'; return '<svg' + a + ns + ' width="' + size + '" height="' + size + '">'; }); } function stColor(name) { return (ST_ICONS[name] && ST_ICONS[name].color) || ''; } // </ST-ICONS> // <ST-TOKENS> — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // </ST-TOKENS> // <ST-UI> — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on <html>, which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'<svg…>' | '<img…>', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // o.history opt-in history of past sessions (#705), below // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // viewer appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) : '--:--:--.---'; }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + (e.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button, input, select') || pop.classList.contains('full')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); views = views.filter(function (v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in <body>: a permanent stray node under <html>, outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No <body> yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and <head> and <body> don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } // </ST-UI> if (CONS) consolidateMain(CONS); else { launcher(); afterSave(); } })(); })(); } catch (e) { try { console.error('[String Theory] mission_control failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== platform_check (@run-at document-end) ==================================== if (__stOff.indexOf("platform_check") < 0 && __stRuns("platform_check", {"inc":["^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release/.*$","^https:\\/\\/(?:[^/]*\\.)?musicbrainz\\.org(?::\\d+)?/release-group/.*$"],"exc":[],"noframes":false})) (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Platform Check*","namespace":"http://tampermonkey.net/","version":"2026.10.16","description":"Find a MusicBrainz release on online platforms like Spotify, Discogs, Bandcamp, HDtracks etc.. Uses existing URL relationships when present, otherwise searches for release online using several methods.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/platform_check/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5NQiBQbGF0Zm9ybSBDaGVjazwvdGl0bGU+CjxnIHN0cm9rZT0iIzk0YTNiOCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxwYXRoIGQ9Ik02NCA2NCBMODggMjIuNCIvPjxwYXRoIGQ9Ik02NCA2NCBMMTEyIDY0Ii8+PHBhdGggZD0iTTY0IDY0IEw4OCAxMDUuNiIvPjxwYXRoIGQ9Ik02NCA2NCBMNDAgMTA1LjYiLz48cGF0aCBkPSJNNjQgNjQgTDE2IDY0Ii8+PHBhdGggZD0iTTY0IDY0IEw0MCAyMi40Ii8+PC9nPjxjaXJjbGUgY3g9Ijg4IiBjeT0iMjIuNCIgcj0iMTIiIGZpbGw9IiNmNDcyYjYiLz48Y2lyY2xlIGN4PSIxMTIiIGN5PSI2NCIgcj0iMTIiIGZpbGw9IiNmYWNjMTUiLz48Y2lyY2xlIGN4PSI4OCIgY3k9IjEwNS42IiByPSIxMiIgZmlsbD0iIzRhZGU4MCIvPjxjaXJjbGUgY3g9IjQwIiBjeT0iMTA1LjYiIHI9IjEyIiBmaWxsPSIjMzhiZGY4Ii8+PGNpcmNsZSBjeD0iMTYiIGN5PSI2NCIgcj0iMTIiIGZpbGw9IiNhNzhiZmEiLz48Y2lyY2xlIGN4PSI0MCIgY3k9IjIyLjQiIHI9IjEyIiBmaWxsPSIjZmI5MjNjIi8+PGNpcmNsZSBjeD0iNjQiIGN5PSI2NCIgcj0iMjQiIGZpbGw9IiMwZjE3MmEiLz48cGF0aCBkPSJNNTQuMjIyMjIyMjIyMjIyMjIgNjQgTDYxLjMzMzMzMzMzMzMzMzMzNiA3MS4xMTExMTExMTExMTExMSBMNzQuNjY2NjY2NjY2NjY2NjcgNTYuODg4ODg4ODg4ODg4ODg2IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo="}) }) : { script: {"name":"Platform Check*","namespace":"http://tampermonkey.net/","version":"2026.10.16","description":"Find a MusicBrainz release on online platforms like Spotify, Discogs, Bandcamp, HDtracks etc.. Uses existing URL relationships when present, otherwise searches for release online using several methods.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/platform_check/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+Cjx0aXRsZT5NQiBQbGF0Zm9ybSBDaGVjazwvdGl0bGU+CjxnIHN0cm9rZT0iIzk0YTNiOCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxwYXRoIGQ9Ik02NCA2NCBMODggMjIuNCIvPjxwYXRoIGQ9Ik02NCA2NCBMMTEyIDY0Ii8+PHBhdGggZD0iTTY0IDY0IEw4OCAxMDUuNiIvPjxwYXRoIGQ9Ik02NCA2NCBMNDAgMTA1LjYiLz48cGF0aCBkPSJNNjQgNjQgTDE2IDY0Ii8+PHBhdGggZD0iTTY0IDY0IEw0MCAyMi40Ii8+PC9nPjxjaXJjbGUgY3g9Ijg4IiBjeT0iMjIuNCIgcj0iMTIiIGZpbGw9IiNmNDcyYjYiLz48Y2lyY2xlIGN4PSIxMTIiIGN5PSI2NCIgcj0iMTIiIGZpbGw9IiNmYWNjMTUiLz48Y2lyY2xlIGN4PSI4OCIgY3k9IjEwNS42IiByPSIxMiIgZmlsbD0iIzRhZGU4MCIvPjxjaXJjbGUgY3g9IjQwIiBjeT0iMTA1LjYiIHI9IjEyIiBmaWxsPSIjMzhiZGY4Ii8+PGNpcmNsZSBjeD0iMTYiIGN5PSI2NCIgcj0iMTIiIGZpbGw9IiNhNzhiZmEiLz48Y2lyY2xlIGN4PSI0MCIgY3k9IjIyLjQiIHI9IjEyIiBmaWxsPSIjZmI5MjNjIi8+PGNpcmNsZSBjeD0iNjQiIGN5PSI2NCIgcj0iMjQiIGZpbGw9IiMwZjE3MmEiLz48cGF0aCBkPSJNNTQuMjIyMjIyMjIyMjIyMjIgNjQgTDYxLjMzMzMzMzMzMzMzMzMzNiA3MS4xMTExMTExMTExMTExMSBMNzQuNjY2NjY2NjY2NjY2NjcgNTYuODg4ODg4ODg4ODg4ODg2IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo="} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ try { (function(){ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('platform_check', 'Platform Check')) return; // the shared MusicBrainz request gate (#633) — first, so nothing can call it before its consts exist // <ST-NET> — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT const MBN_GAP = 1000; const MBN_BURST = 3; const MBN_KEY = "mbu:mb-gate"; const MBN_LOCK = "mbu-mb-gate"; const MBN_MAX_HOLD = 60000; function mbnGated(url) { const s = String(url || ''); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null'); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; // no storage: this page's copy only else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) {} return out; }; // The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is // stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang // every request behind it. let timer = 0; try { if (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === 'function' ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3000); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState(s => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? 'MusicBrainz asked everyone to wait' : 'pacing, one request a second' }; }); let told = false; for (;;) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); // a hold that appeared while we queued if (left <= 0) break; if (!told && until - t0 > 1000 && o.log) { told = true; o.log('info', 'MusicBrainz gate: ' + (o.label || 'request') + ' waits ' + Math.round((until - t0) / 100) / 10 + 's (' + (cool > booked.at ? 'MusicBrainz asked everyone to wait' : booked.why) + ')'); } await new Promise(r => setTimeout(r, Math.min(left, 250))); // in slices, so a cancel lands promptly } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header('Retry-After') : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState(s => { const now = Date.now(); // One throttle, one step: the requests already in flight when it began all answer // 503 too, and each must not stretch the hold again. A throttle long gone doesn't // count at all: `hot` is shared and persists, and would otherwise slow every later // page for good. if (now > (s.cool || 0) + 30000) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1000, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log('warn', 'MusicBrainz gate: HTTP ' + status + (raw != null ? ' (Retry-After: ' + raw + ')' : '') + ' — every script holds ' + Math.round(hold / 100) / 10 + 's'); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState(s => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 30000 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) { const r = await fetch(url, init); if (!mbnWebsite(url) || !(await mbnIsCheck(r))) return r; // #733: MusicBrainz's browser check answered instead: nothing was done (a POST is safe // to repeat), so pass the check and ask again return (await mbnReverify(url, init, o)) ? fetch(url, init) : r; } const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, n => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnHot() { let s; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; } return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over } function mbnRawHeader(raw, name) { const want = String(name).toLowerCase(); for (const line of String(raw || '').split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim(); } return null; } function mbnWebsite(url) { const s = String(url || ''); if (mbnGated(s)) return false; if (/^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org(\/|$)/i.test(s)) return true; return /^\/(?!\/)/.test(s) && typeof location !== 'undefined' && /(^|\.)musicbrainz\.org$/i.test(location.hostname); } async function mbnIsCheck(r) { try { if (!r || !/text\/html/i.test((r.headers && r.headers.get('content-type')) || '')) return false; const head = (await r.clone().text()).slice(0, 3000); return /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(head); } catch (e) { return false; } } function mbnReverify(url, init, o) { o = o || {}; const log = (lvl, m) => { try { if (o.log) o.log(lvl, m); else (lvl === 'warn' ? console.warn : console.info)('[MusicBrainz gate] ' + m); } catch (e) {} }; const g = globalThis.__mbnCheck || (globalThis.__mbnCheck = { busy: null, failedAt: 0 }); if (g.busy) return g.busy; let target; try { target = new URL(String(url), location.href); } catch (e) { return Promise.resolve(false); } if (target.host !== location.host || typeof document === 'undefined') { log('warn', 'MusicBrainz asked to verify the browser — open any MusicBrainz page, then retry'); return Promise.resolve(false); } if (Date.now() - g.failedAt < 60000) return Promise.resolve(false); // just failed: don't loop // a POST's URL can't be loaded: the check is per browser, so any light GET passes it const method = String((init && init.method) || 'GET').toUpperCase(); const src = method === 'GET' ? target.href : location.origin + '/ws/js/entity/89ad4ac3-39f7-470e-963a-56509c546377'; const t0 = Date.now(); log('warn', 'MusicBrainz asked to verify the browser again ("Verifying your browser") — passing it in a hidden frame'); g.busy = new Promise(resolve => { const fr = document.createElement('iframe'); // fresh each time, never re-navigated fr.setAttribute('aria-hidden', 'true'); fr.setAttribute('data-mbn-check', ''); fr.tabIndex = -1; fr.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;top:0;border:0;opacity:0;pointer-events:none'; let loads = 0, done = false, timer = 0; const finish = ok => { if (done) return; done = true; clearTimeout(timer); fr.remove(); g.busy = null; if (ok) log('info', 'browser check passed in ' + (Date.now() - t0) + ' ms — retrying'); else { g.failedAt = Date.now(); const m = 'MusicBrainz wants to verify your browser — open any MusicBrainz page in another tab, then retry'; log('warn', m + ' (the hidden frame did not clear it in 20 s)'); try { if (o.notify) o.notify(m); } catch (e) {} } resolve(ok); }; // the first load is the check; it posts its answer and is redirected to the real URL fr.onload = () => { loads++; let still; try { const d = fr.contentDocument; still = !d || /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(d.documentElement ? d.documentElement.outerHTML.slice(0, 3000) : ''); } catch (e) { still = loads < 2; } if (!still) finish(true); }; timer = setTimeout(() => finish(false), 20000); fr.src = src; (document.body || document.documentElement).appendChild(fr); }); return g.busy; } // </ST-NET> // MusicBrainz origin of the current page (musicbrainz.org or beta.musicbrainz.org), // so the script's own MB API calls + edit-page links stay on the same server. const MB_ORIGIN = location.origin; // #464: cross-tab signal for the background "right-click add" flow — the opener // listens for the background tab's "committed" postMessage so it can close that // tab and refresh, mirroring Credit Hoarder / Apollo Editor's own add-link channel. const PC_CHANNEL = ('BroadcastChannel' in window) ? new BroadcastChannel('platform-check-inject') : null; // (#623: the two one-time #501 migrations that used to run here on every load — // sweeping cache entries out of GM storage, and copying the old bare prov_* toggles // to pc:prov_* — are retired; every install has long since run them.) // ─── Release editor sub-pages (/edit, /edit-relationships) ──────────────── // + click on /release stashes OK URLs in `pc:pending:<mbid>` (localStorage) // and opens the release's /edit page. We fill the "Add another link" input then set the // type chooser in the next-sibling <tr.relationship-item>'s <select.link-type>. if (/\/release\/[0-9a-f-]{36}\/edit(?:[?#/]|$)/.test(window.location.pathname)) { runInjectHelper('release'); return; } if (/\/release-group\/[0-9a-f-]{36}\/edit(?:[?#/]|$)/.test(window.location.pathname)) { runInjectHelper('release-group'); return; } // #464: a background-add tab (right-click on +) marks itself before auto-submitting // so that once MB redirects here to the clean release page, it can post "committed" // back to the opener and close itself — the opener never has to poll or refocus. if (/^\/release\/[0-9a-f-]{36}\/?(?:[?#]|$)/.test(window.location.pathname)) { let closeMbid = null; try { closeMbid = sessionStorage.getItem('pc:autocommit-close'); } catch (e) {} if (closeMbid) { try { sessionStorage.removeItem('pc:autocommit-close'); } catch (e) {} pcMark('landed back on the release page — edit committed, closing the tab'); try { sessionStorage.removeItem('pc:bg-t0'); } catch (e) {} if (PC_CHANNEL) { try { PC_CHANNEL.postMessage({ type: 'pc-edit-committed', mbid: closeMbid }); } catch (e) {} } setTimeout(() => { try { window.close(); } catch (e) {} }, 80); return; } } // #559 (majkinetor): "Discogs master link should be also added in the background // and tab auto closed when right click is issued on [+] button." The master goes // onto the release GROUP, and the background flow above never covered it — a // right-click add opened the release-group editor in a normal, focused tab and // left it there. Same three-part shape as the release flow: the edit tab marks // itself, submits, and this landing branch (MB redirects here after a successful // release-group edit) posts "committed" back and closes the tab. // // ⚠ This is why the @match now covers /release-group/* and not just its two edit // forms — the landing page has to run the script for the tab to close itself. // Nothing else changes on a release-group page: the guard below stops the // dashboard mounting anywhere but a release. if (/^\/release-group\/[0-9a-f-]{36}\/?(?:[?#]|$)/.test(window.location.pathname)) { let closeRg = null; try { closeRg = sessionStorage.getItem('pc:autocommit-close-rg'); } catch (e) {} if (closeRg) { try { sessionStorage.removeItem('pc:autocommit-close-rg'); } catch (e) {} pcMark('landed back on the release-group page — edit committed, closing the tab'); try { sessionStorage.removeItem('pc:bg-t0'); } catch (e) {} if (PC_CHANNEL) { try { PC_CHANNEL.postMessage({ type: 'pc-rg-edit-committed', mbid: closeRg }); } catch (e) {} } setTimeout(() => { try { window.close(); } catch (e) {} }, 80); return; } } // The dashboard belongs on a release page and nowhere else. Every branch above is // a helper for the other pages the @match list covers (the edit forms and the // post-commit landing pages) and each returns on its own — but the panel below is // built at top level, and `mbid` is read positionally from the path, so a // release-group MBID would have sailed straight through and mounted a dashboard // there. Making the assumption explicit rather than implicit in the @match. #559 // #702: seed mode. On /release/add#mc=<token>, Mission Control consolidates a release MusicBrainz // doesn't have yet: there is no release to read, so the scan waits for MC's probe, which carries // what First Contact read (seed: barcode, title, artist, tracks, format, year, label). The panel is // built as on a release page but kept out of sight; MC shows what it finds. Nothing is added from here. const PC_SEED = /^\/release\/add\/?$/.test(window.location.pathname) && /[#&]mc=[a-z0-9]+/i.test(window.location.hash); let PC_SEED_DATA = null; if (!PC_SEED && !/^\/release\/[0-9a-f-]{36}/.test(window.location.pathname)) return; // Safe setTimeout wrapper. Firefox throws NS_ERROR_NOT_INITIALIZED from // setTimeout when the script context is being torn down — the Promise // constructor turns that into an unhandled rejection that aborts everything. // Catch + fall back to a requestAnimationFrame-driven busy wait so the // caller still gets a real time delay (not a zero-delay microtask burst // that would make polling loops exit instantly with "never appeared"). function pcWait(ms) { return new Promise(resolve => { try { setTimeout(resolve, ms); return; } catch (_) {} // Fallback: RAF-based wait. Each frame is ~16ms; loop until elapsed. const start = Date.now(); const tick = () => { if (Date.now() - start >= ms) { resolve(); return; } try { requestAnimationFrame(tick); } catch (_) { resolve(); } }; tick(); }); } // #556 (majkinetor): "It seems to work now but it takes around 30s. If I visit // the tab, the links are set and it looks idle (and finishes immediately)." // // A background add is a chain of six or seven waits across TWO documents, and // until now the console said only that it had finished. "Takes 30s" and "hung // on step 4 for 28s then flew" are the same line of output, and I have twice // gone looking in the wrong half of the flow because of it. // // So: one timeline, elapsed from the moment the helper starts, carried across // the submit redirect in sessionStorage so the landing page's entries continue // the same clock rather than starting a second one. Every entry records whether // the tab was hidden at the time, because that is the axis the whole bug lives // on. // ⚠ the key is a literal, NOT a module-level const: the landing-page branch near // the top of the IIFE calls pcMark and returns before any `const` down here has // been initialised, so referencing one would throw from the temporal dead zone. // The same trap is documented on injectInto's TYPE_FORCE. function pcT0() { try { const v = Number(sessionStorage.getItem('pc:bg-t0')); if (v > 0) return v; const now = Date.now(); sessionStorage.setItem('pc:bg-t0', String(now)); return now; } catch (e) { return Date.now(); } } // ⚠ #556 (majkinetor): "how am I supposed to fetch this log if both original and // new tab gets killed (first one killed, other one reloaded)". Quite right — a // console timeline in a tab that closes itself is a timeline nobody can read. // So every mark goes three places: // // 1. the console of the tab it happened in (useful only if you are watching); // 2. localStorage, which OUTLIVES both the closed tab and the opener's reload; // 3. the BroadcastChannel, so the opener's activity log shows it LIVE, while // the background add is still running. // // The dashboard drains (2) when it mounts — i.e. right after the post-commit // reload — and listens for (3), deduplicating on the entry id so a mark that // arrives both ways is logged once. Between them, the whole timeline is in the // Log window by the time the tab is gone, whether or not it was open at the time. // ⚠ the sequence counter lives on the FUNCTION, not in a module-level `let`. // runInjectHelper is called from the top of the IIFE and reaches its first mark // before anything declared down here is initialised, so a `let` here is in the // temporal dead zone and reading it throws — which is not a broken log line but // a dead background add, caught by the helper's outer catch and reported as // "inject helper crashed". Cost me a run to rediscover the trap the comment two // functions up already warns about. Function declarations are hoisted whole, so // a property on one is always safe. // // The whole body is wrapped, too: diagnostics must never be able to break the // thing they are diagnosing. function pcMark(stage, extra) { try { const ms = Date.now() - pcT0(); const line = `+${(ms / 1000).toFixed(1)}s ${stage}${extra === undefined ? '' : ' ' + extra}${document.hidden ? ' (tab hidden)' : ''}`; try { console.info('[Platform Check] bg ' + line); } catch (e) {} // The id must be unique ACROSS DOCUMENTS. The timeline deliberately spans // two — the editor tab and the page MusicBrainz redirects to — and the // second one starts a fresh script with the counter back at 0 while // pcT0() still reads the same start time out of sessionStorage. `t0-seq` // therefore made the landing page's entry collide with the first one, and // the reader's dedupe threw away the "edit committed" line: the single // most useful entry, missing from both delivery paths. Elapsed ms plus a // per-document tag separates them. pcMark.seq = (pcMark.seq || 0) + 1; pcMark.tag = pcMark.tag || Math.random().toString(36).slice(2, 8); const entry = { id: `${pcT0()}-${ms}-${pcMark.seq}-${pcMark.tag}`, line }; // Capped: a run is a dozen entries, but a loop that never finishes must // not grow this without bound. try { const prev = JSON.parse(localStorage.getItem('pc:bg-log') || '[]'); const next = (Array.isArray(prev) ? prev : []).concat(entry).slice(-120); localStorage.setItem('pc:bg-log', JSON.stringify(next)); } catch (e) {} try { if (PC_CHANNEL) PC_CHANNEL.postMessage({ type: 'pc-bg-log', entry }); } catch (e) {} } catch (e) { /* a timeline is never worth failing an edit over */ } } // #556 — keeping a background-add tab out of Firefox's timer throttling. // // Why at all: majkinetor's timelines of the SAME two-link add, tab never shown // in any of them, are the controlled trial. // // throttled field 5.7s · Enter edit -> committed 14.5s · total 20.2s // inaudible tone field 1.5s · Enter edit -> committed 2.1s · total 3.7s // webrtc loopback field 4.6s · Enter edit -> committed 16.8s · total 21.4s // // 5.5x for the tone, and not only on the submit — MusicBrainz renders the // External links field 3.8x faster too, because the throttling was never about // our code. A background tab has its timers held to one step per second, and // once it has spent its execution budget individual ones stretch to as much as // fifteen (measured in his log: a setTimeout(…,1500) that fired 8.3s late). // // ⚠ THE WEBRTC LOOPBACK DOES NOT WORK HERE, so do not re-add it. chaban // suggested it and it is much the nicer trade on paper — a data-channel-only // loopback between two peers in the same document needs no getUserMedia, so no // permission, no prompt and no tab indicator. It connects in under a second // (measured: `webrtc loopback open` at +0.8s) and then makes no difference at // all: 16.8s to commit against 16.9s with nothing running. Whatever Firefox // exempts for an active peer connection, it is not the timer throttling — at // least not for a data channel carrying no media. // // So the tone it is. An audible tab IS exempt, and that is measured, not // assumed. Two costs, both real: // - a tab opened by GM_openInTab has no user activation, so Firefox's autoplay // policy leaves the context `suspended` until the user grants the site // Autoplay > Allow Audio by hand. Hence off by default, and the state is // reported into the timeline rather than left to be guessed at; // - the tab shows the speaker icon while it runs. Nothing to be done: being // audible is the exemption. // // Neither exemption can be tested here — Playwright will not produce a genuinely // backgrounded tab (which is why e2e-556's --suspend models the constraints // instead) and it relaxes autoplay besides. What the harness DOES check is that // the mechanism engages and reports itself; the elapsed time in a real run is // the rest of the answer. function pcKeepAwake() { try { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) { pcMark('keep-awake', 'no AudioContext in this browser — the tab will be throttled'); return null; } const ctx = new AC(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); // Non-zero on purpose: a truly silent stream may not mark the tab // audible, which is the entire point. 30Hz at this level is below // anything a speaker will reproduce. gain.gain.value = 0.0008; osc.frequency.value = 30; osc.connect(gain); gain.connect(ctx.destination); osc.start(); try { const p = ctx.resume && ctx.resume(); if (p && p.catch) p.catch(() => {}); } catch (e) {} pcMark('keep-awake audio started', `state=${ctx.state}`); // The state right after construction is not the final answer; autoplay // blocking shows up a beat later as 'suspended'. try { setTimeout(() => pcMark('keep-awake audio', ctx.state === 'running' ? 'state=running — the tab is audible, so it should not be throttled' : `state=${ctx.state} — BLOCKED by the autoplay policy, so this setting is doing nothing. ` + 'Allow Audio for musicbrainz.org in the padlock menu to let it try.'), 1500); } catch (e) {} return ctx; } catch (e) { pcMark('keep-awake audio failed', String((e && e.message) || e)); return null; } } // Called once at the start of a background add, so the Log shows THIS run rather // than this one appended to every previous one. function pcMarkReset() { try { localStorage.removeItem('pc:bg-log'); } catch (e) {} try { sessionStorage.removeItem('pc:bg-t0'); } catch (e) {} pcMark.seq = 0; } // MutationObserver-backed waiter — resolves the moment `predicate()` // returns truthy, falls back to a slow poll if observers aren't usable. // Safer than fixed-cadence polling because it doesn't rely on setTimeout // running on time, and it picks up the target as soon as MB inserts it. function pcWaitFor(predicate, timeoutMs = 10000) { return new Promise(resolve => { const found = predicate(); if (found) return resolve(found); let done = false; const finish = result => { if (done) return; done = true; obs?.disconnect(); resolve(result); }; let obs = null; try { obs = new MutationObserver(() => { const r = predicate(); if (r) finish(r); }); obs.observe(document.documentElement, { childList: true, subtree: true, attributes: true }); } catch (_) { /* observer broken — rely on RAF poll below */ } // #556 (majkinetor): "It never happens when not invoked as background task" // and "when it fails, the links are NEVER added". Both are true of this loop // and of nothing else in the flow. // // It polled with requestAnimationFrame. A background-add tab is opened with // GM_openInTab(..., { active:false }), i.e. HIDDEN — and browsers suspend rAF // outright in hidden tabs (measured here at 14x throttling even for a merely // unfocused one). So the poll stops running while the deadline, being wall // clock, keeps advancing. The tab is then either stuck with nothing settled, // or — the moment it is looked at — resumes, finds the elapsed time already // past the timeout, and gives up on its very first tick. That is exactly an // all-or-nothing failure that cannot happen in the foreground. // // So: poll on a TIMER instead. setTimeout is clamped to ~1s in a hidden tab // but it still runs, so the wait observes the element appearing either way. // That one change is the whole fix. // // What is deliberately NOT here, having been tried and reverted: excusing a // hidden tab from the deadline (accumulating only "visible" time, with an // absolute cap as a backstop). It sounds right — a hidden tab is stopped, // not slow — but a wait that legitimately never succeeds then runs to the // cap every time, and with the cap at minutes that is far worse than giving // up. majkinetor got multi-minute hangs in BOTH foreground and background // out of it. The deadline is plain wall clock, as it was. // // Nor is a "be patient while the page is still mutating" budget, which I // built and threw away on the day the real cause turned up: it made the // hidden case merely slower to fail, because what the wait was waiting for // had been RENAMED (see findAddLinkInput) and was never going to match. const started = Date.now(); const poll = () => { if (done) return; const r = predicate(); if (r) return finish(r); if (Date.now() - started >= timeoutMs) return finish(null); try { setTimeout(poll, 100); } catch (_) { finish(null); } }; poll(); }); } // #556: MusicBrainz sometimes serves a "Verifying your browser" challenge instead // of the page. The URL is unchanged, so the inject helper would run against the // challenge, find no "Add another link" input, and burn the queued payload. Falcon // hit exactly this and had to add the same guard (#551). Detected the same way. // ⚠ The patterns live inside the function. This runs on the editor page, above the // script's early return, where a const declared out here is never initialised: as // module consts they threw, the catch below answered "no", and the guard never fired. function pcIsVerifyInterstitial(doc) { const d = doc || document; const TITLE = /^\s*Verifying your browser\s*$/i, NOSCRIPT = /JavaScript is required to access this page/i; try { if (TITLE.test(d.title || '')) return true; return [...d.querySelectorAll('noscript')].some(n => NOSCRIPT.test(n.textContent || '')); } catch (e) { return false; } } async function runInjectHelper(entityType) { // #556: declared OUT here, not inside the try — the catch below is a // different block and could not see a `const` from inside it, so the crash // path would have thrown a ReferenceError trying to stop the tone instead of // stopping it. See pcKeepAwake for what this is. let awake = null; const hush = () => { try { awake && awake.close && awake.close(); } catch (e) {} }; try { // Stand down entirely on the browser-check page: the real page loads a // moment later and this runs again there, with the payload still intact. #556 if (pcIsVerifyInterstitial()) { try { console.info('[Platform Check] MusicBrainz served its "Verifying your browser" challenge instead of the editor — standing down, the queued links are untouched.'); } catch (e) {} return; } const re = new RegExp(`/${entityType}/([0-9a-f-]{36})`); const mbid = (window.location.pathname.match(re) || [])[1]; if (!mbid) return; const key = entityType === 'release-group' ? `pc:pending:rg:${mbid}` : `pc:pending:${mbid}`; const raw = localStorage.getItem(key); // No pending payload means the user navigated to /edit themselves, // not via the panel's + button. Stay silent — banner noise on every // direct edit-page visit is worse than the diagnostic value (the // 2nd-click-doesn't-work bug it was diagnosing has been fixed). if (!raw) return; let pending; try { pending = JSON.parse(raw); } catch (e) { showInjectBanner(`Platform Check: pending payload not JSON: ${e.message}`, [], { fail: true }); return; } const urls = Object.values(pending || {}).filter(Boolean); if (urls.length === 0) return; // #556: this lookup used to run ONCE, immediately, and was a no-op besides. // Measured live on the release editor: there is no "External links" TAB at // all — the wizard's steps are Release information / Tracklist / Recordings // / Edit note, and External links is a <legend> inside the first of them. // So this never matched anything and the click never happened; the input was // reachable only because its step is the default one. // // It is called once, NOT awaited. Awaiting it was a mistake of mine: on the // release editor the tab does not exist, so the wait could only ever run to // its full timeout before injectInto even started — pure dead time added to // every single background add. pcMark(`helper start on ${entityType}`, `${urls.length} url(s) queued, readyState=${document.readyState}`); // #556: read the hash HERE rather than after the inject — the keep-awake // tone has to start before MusicBrainz's throttled work does, not after. const autoCommit = /pc-autocommit/.test(location.hash); // On the happy path the tone dies with the document when MusicBrainz // redirects, a second or two later. Every OTHER path leaves this tab open // on purpose — nothing landed, no submit button, a crash — and a tab left // open would be a tab humming away with the audio indicator lit until // someone closes it. hush() is called explicitly on each of those. // #556: say when it is OFF, too. A run with the setting disabled logged // NOTHING about keep-awake, so "it didn't work again" and "the mechanism // failed" produced identical output and we both guessed. Silence is not // a diagnosis. const wantAwake = autoCommit && GM_getValue('pc:bg-audio', false); if (autoCommit && !wantAwake) pcMark('keep-awake', 'OFF — enable "Keep background-add tabs awake" in settings to skip the throttling'); awake = wantAwake ? pcKeepAwake() : null; pcOpenExternalLinks(); await pcWait(200); const result = await injectInto(urls, key) || { injected: 0 }; pcMark('inject finished', `${result.injected} confirmed`); // #464: right-click "add in background" — auto-submit once the URLs are in, // and mark this tab so the redirect back to the clean /release/<mbid> page // (only that landing page is @match'd; release-group has no such page) can // post "committed" + close itself. Only the release flow supports this. // #559: the release-group editor takes the same route now, for the Discogs // master URL. Its form is NOT the release wizard, so the button differs — // see findSubmit below. (`autoCommit` is read at the top of the helper — // the keep-awake tone needs it before the inject, not after.) // #556 (majkinetor): NEVER submit a run that added nothing. This used to // fire unconditionally, and the release editor's submit is enabled by ANY // pending change — so with Apollo's auto search-and-replace configured, its // edit made the form dirty, Platform Check submitted it as if the links had // gone in (edit 152603580: an S&R edit, no link), then closed the tab and // reloaded the opener. The user got an edit they did not ask for at that // moment, and every diagnostic went with the closed tab. Failing here means // the tab stays open with its banner and console intact, and the queued // links survive for a retry — which is what actually worked on his 2nd try. if (autoCommit && !result.injected) { try { console.warn('[Platform Check] background add: nothing landed — NOT submitting. The tab is left open and the links stay queued; press ↻ or the + again to retry.'); } catch (e) {} hush(); showInjectBanner('Platform Check: no link could be added — not submitting. The links are still queued, so you can retry; this tab is left open on purpose.', [], { fail: true }); } else if (autoCommit) { // #465 (chaban-mb): the release editor is the multi-step wizard, not the // simple artist/label/place form — its submit button is #enter-edit. Same // finder Apollo's compact nav bar already uses for this exact form. // #559: the release-GROUP editor is the plain entity form and has no // #enter-edit at all (measured on a live page: `#enter-edit` absent, // `button.submit.positive` reading "Enter edit" present), so each entity // type gets the finder that actually matches its own form. const findSubmit = entityType === 'release-group' ? () => document.querySelector('button.submit.positive') || [...document.querySelectorAll('button')].find(b => /enter edit/i.test(b.textContent || '')) : () => document.querySelector('#enter-edit') || [...document.querySelectorAll('button')].find(b => /enter edit/i.test(b.textContent || '')); // #556: wait for it to be ENABLED, not merely present. MusicBrainz // disables Enter edit while the editor is validating, and the old // wait resolved on the disabled button the instant it appeared — // then took the "no submit button found" branch and gave up without // ever clicking. Same 5s budget; it just spends it on the right // condition. A disabled one that never enables is reported as that, // rather than as "not found", which is a different bug entirely. let sawDisabled = false; const btn = await pcWaitFor(() => { const b = findSubmit(); if (!b) return null; if (b.disabled) { sawDisabled = true; return null; } return b; }, 5000); pcMark('submit button', btn ? 'enabled' : (sawDisabled ? 'STILL DISABLED after 5s' : 'not found')); if (btn) { try { sessionStorage.setItem(entityType === 'release-group' ? 'pc:autocommit-close-rg' : 'pc:autocommit-close', mbid); } catch (e) {} btn.click(); pcMark('Enter edit clicked — waiting for MusicBrainz to submit and redirect'); } else { hush(); showInjectBanner(sawDisabled ? 'Platform Check: background add — MusicBrainz kept "Enter edit" disabled, review manually' : 'Platform Check: background add — no submit button found, review manually', [], { fail: true }); } } } catch (e) { // Last-resort surface so the user sees *something* on the page when // a Firefox-specific exception kills the inject path silently. A banner // is no use in a BACKGROUND add tab, which nobody looks at — so say it // in the console too, where it survives for a bug report. try { hush(); } catch (_) {} try { console.error('[Platform Check] inject helper crashed —', e); } catch (_) {} try { showInjectBanner(`Platform Check: inject helper crashed — ${e.name || 'Error'}: ${e.message || e}`, [], { fail: true }); } catch (_) { /* nothing else we can do here */ } } } // #556: open the release editor's "External links" step. Returns the tab element // once it exists (so it can be awaited with pcWaitFor), null while it does not. // Clicking an already-active step is a no-op, so this is safe to call repeatedly. function pcOpenExternalLinks() { // #722 the release editor's links sit on its first step, the tab whose anchor is // #information; a text match ("External links") only where there is no such tab const tab = document.querySelector('#release-editor a.ui-tabs-anchor[href="#information"]') || [...document.querySelectorAll('a, button, li')].find(el => /^external\s+links$/i.test(el.textContent?.trim() || '')); if (!tab) return null; try { tab.click(); } catch (e) { /* not clickable yet — the caller keeps polling */ } return tab; } // ⚠ #556 — THE bug, after five wrong diagnoses. This used to identify the field // by its PLACEHOLDER TEXT alone, and Apollo Editor rewrites that text. // // Apollo's multi-link paste (majkinetor: "make a hint show that it can be // multiple links") replaces MusicBrainz's "Add another link" with // "Paste one or more links", re-applying it from a MutationObserver on // #external-links-editor so it survives every React re-render. From that moment // this function matched nothing, Platform Check waited out its 25s and reported // no "Add another link" input ever appeared (25s, External links step never rendered) // — no link added, nothing submitted, exactly what he saw. // // Which is a RACE between two of our own scripts, and that is why it looked like // a hidden-tab bug for a fortnight: whoever gets to the field first wins. In a // foreground tab Platform Check usually arrives before Apollo has installed its // observer; in a background tab, where our poll runs on a ~1s clamp, Apollo has // always won by the time we look. Hence "every time I switch to the tab and // watch it, it works" — and hence, too, "I repeated it and it worked". // // Measured on the sandbox (probe-556-who-eats-the-field.mjs), each script alone // on the release editor: // (none) YES · platform_check YES · art_station YES · credit_hoarder YES // group_therapy YES · isrc_scout YES · mammoth YES // apollo_editor NO · string_theory NO // // So: find the field STRUCTURALLY — the empty url input inside MusicBrainz's own // #external-links-editor, which is what the row actually is and what Apollo's own // CSS keys on. Placeholder text is a label for humans; no script should route on // another script's cosmetics. The old text match stays as a fallback for forms // that have no #external-links-editor. function findAddLinkInput() { // The editor renders one input[type=url] per link plus a trailing EMPTY one: // that empty one is the "add" row, whatever it happens to say today. const ed = document.getElementById('external-links-editor'); if (ed) { const empty = [...ed.querySelectorAll('input[type="url"]')].filter(i => !i.value); if (empty.length) return empty[empty.length - 1]; } // Fallback: /release/<mbid>/edit uses placeholder "Add another link", // /release-group/<rg>/edit uses "Add link" (no "another"). const all = [...document.querySelectorAll('input[type="text"], input[type="url"], input:not([type])')]; const RE = /^(?:add (?:another )?link|add another url|paste one or more links)$/i; return all.find(i => RE.test((i.placeholder || '').trim()) && !i.value) || all.find(i => RE.test((i.placeholder || '').trim())) || null; } async function injectInto(urls, storageKey) { // Force-type providers: MB's URL auto-classifier leaves the type empty // for Apple Music + Bandcamp ("Please select a link type" warning). Map // host pattern → ordered list of preferred link_type_ids. We pick the // first one whose value actually appears as an <option> in the row's // <select.link-type>, because MB's chooser only offers types that are // applicable to that URL host (Apple Music offers 980 'streaming page' // but not 85; Bandcamp offers 85 'stream for free' but not 980). IDs // verified live on MB via probe. The list is pcTypeForce, below. const TYPE_FORCE = pcTypeForce(); const setVal = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; const setSel = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, 'value').set; const reports = []; let injected = 0; for (const url of urls) { // One URL must never be able to end the run. Before this, anything thrown // mid-loop (a TDZ ReferenceError, a DOM shape MB changed) aborted every // remaining URL and surfaced only as a banner — invisible in a background // add tab, which is where this flow actually lives. Now a URL that blows // up is reported like any other failure and the next one still gets a go. try { // MutationObserver-backed wait — resolves the moment MB renders an // empty "Add another link" input, regardless of how long the page // takes to mount the External Links section. // #556 (majkinetor, from the console log the new diagnostics produced): // inject FAILED: <beatport url> — no "Add another link" input ever appeared // // The 10s budget was simply too short. The External links fieldset is part // of the release editor's own async boot, and his page had plenty to get // through first: Apollo's log has the editor still loading tracks 6s in, a // /ws/2 request timing out outright, and String Theory running seven scripts // on the same main thread alongside two other userscripts. Nothing was // broken — Platform Check just gave up before MusicBrainz finished. // // 25s, and the step is re-asserted between polls in case the editor // re-renders back to a different one. Throttled so repeated clicking cannot // fight the editor's own rendering. let _lastOpen = 0, _t0 = Date.now(), _said = 0; const input0 = await pcWaitFor(() => { const el = findAddLinkInput(); if (el) return el; const now = Date.now(); if (now - _lastOpen > 700) { _lastOpen = now; pcOpenExternalLinks(); } // #556: say so while still waiting. Without this the console is silent // until the end, so "gave up at 25s" and "hung for minutes" look the // same in a bug report — and I could not tell them apart in his. const secs = Math.round((now - _t0) / 1000); if (secs >= 10 && secs - _said >= 10) { _said = secs; try { console.info(`[Platform Check] inject: still waiting for MusicBrainz to render the External links field (${secs}s, hidden=${!!document.hidden})`); } catch (e) {} } return null; }, 25000); if (input0) pcMark('External links field found', url); if (!input0) { const secs = Math.round((Date.now() - _t0) / 1000); reports.push({ url, ok: false, miss: `no "Add another link" input ever appeared (${secs}s, External links step never rendered)` }); break; } // ⚠ #556: `injected` is NOT bumped for a dispatched keystroke. It gates // whether the pending payload is consumed below, and counting the // keystroke rather than a confirmed row meant a run where nothing landed // still ate the queue — so the failure could not retry and did not // self-heal. It is incremented once the row is actually confirmed. const typeAndEnter = (el) => { el.focus(); setVal.call(el, url); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true })); el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true })); el.blur(); }; // a queued link already on the release (an earlier run landed it, or it was there all along) is done: // re-adding it made a duplicate row ("already exists at position #N") and counted as a failure, so // the payload never drained and every later plain edit of the release re-injected it and showed the banner const urlKey = url.match(/music\.apple\.com\/[^/]+\/album\/(?:[^/?#]+\/)?(\d+)\b/)?.[1]; const already = [...document.querySelectorAll('tr.external-link-item a[href]')].some(a => { const h = a.getAttribute('href') || ''; return h === url || pcSameUrl(h, url) || (urlKey && new RegExp(`/album/(?:[^/]+/)?${urlKey}\\b`).test(h)); }); if (already) { try { console.info(`[Platform Check] inject: ${url} is already on the release — dropped from the queue`); } catch (e) {} reports.push({ url, ok: true, present: true, note: 'already on the release' }); continue; } typeAndEnter(input0); // Wait for the URL row + type row to materialise. const appleId = url.match(/music\.apple\.com\/[^/]+\/album\/(?:[^/?#]+\/)?(\d+)\b/)?.[1]; // #556: MusicBrainz REWRITES the URL as it creates the row (locale segment, // www., trailing slash, stripped query). An exact `h === url` therefore // missed rows that had been created perfectly well, reported them as "URL // row never appeared", and — because the payload was consumed regardless — // left the tab open with nothing retryable. Compare on resource identity. // The appleId clause is kept as a belt-and-braces fallback. const matchRowByUrl = r => { const a = r.querySelector('a[href]'); const h = a?.getAttribute('href') || ''; return h === url || pcSameUrl(h, url) || (appleId && new RegExp(`/album/(?:[^/]+/)?${appleId}\\b`).test(h)); }; const findRow = () => [...document.querySelectorAll('tr.external-link-item')].find(matchRowByUrl); // #556 (majkinetor): "tab opened, nothing in tab […] I repeated it and it // worked" — the same URL that fails once succeeds on a plain retry, which // is the signature of losing the keystroke to a React re-render rather // than of MusicBrainz rejecting the link. So retry it here instead of // making the user do it: re-query the input (the old node is often gone by // now) and type again. Three attempts, ~4s each. let urlRow = await pcWaitFor(findRow, 4000); for (let attempt = 2; !urlRow && attempt <= 3; attempt++) { const again = findAddLinkInput(); if (!again) break; try { console.warn(`[Platform Check] inject: no row for ${url} yet — retry ${attempt}/3`); } catch (e) {} typeAndEnter(again); urlRow = await pcWaitFor(findRow, 4000); } if (!urlRow) { reports.push({ url, ok: false, miss: 'URL row never appeared after Enter (3 attempts)' }); continue; } injected++; // #556: a CONFIRMED row — see the note where Enter is dispatched pcMark('row confirmed', url); // Type-force if applicable. The Type chooser lives in the NEXT // sibling <tr.relationship-item> (verified live on MB). const force = TYPE_FORCE.find(t => t.test(url)); if (!force) { reports.push({ url, ok: true, note: 'auto-typed' }); continue; } const typeRow = await pcWaitFor(() => { const s = urlRow.nextElementSibling; return (s && s.classList?.contains('relationship-item')) ? s : null; }, 3000); const select = typeRow?.querySelector('select.link-type'); if (!select) { reports.push({ url, ok: false, miss: `no <select.link-type> in next sibling (got ${typeRow?.tagName}.${typeRow?.className || ''})` }); continue; } const opt = force.ids.map(id => [...select.options].find(o => o.value === id)).find(Boolean); if (!opt) { const optsList = [...select.options].map(o => `${o.value}=${o.textContent.trim()}`).join(', ').slice(0, 400); reports.push({ url, ok: false, miss: `none of ids=[${force.ids.join(',')}] in options. Options: ${optsList}` }); continue; } setSel.call(select, opt.value); select.dispatchEvent(new Event('change', { bubbles: true })); const report = { url, ok: true, type: opt.textContent.trim(), linkTypeId: opt.value }; reports.push(report); // #423: a Bandcamp album that also has a DIGITAL release is both streamable AND // purchasable, so it deserves a SECOND relationship on the same URL — 74 'purchase // for download' next to 85 'stream for free' — via MB's own "Add another // relationship" row. Gated on the format the Bandcamp scan parsed from the page's // JSON-LD musicReleaseFormat ("Digital", "Digital, CD", …), so a physical-only // page doesn't get a bogus download rel. cacheGet/cacheKey are hoisted function // declarations, safe to call from this pre-return path. // An Apple Music album the iTunes Store sells gets the same second rel, next to 980 'streaming page'. if (pcExtraType(url, opt.value, (storageKey.match(/^pc:pending:([0-9a-f-]{36})$/) || [])[1])) { const addBtn = await pcWaitFor(() => { const s = typeRow.nextElementSibling; return (s && s.classList?.contains('add-relationship')) ? s.querySelector('button.add-item') : null; }, 3000); let ok2 = false; if (addBtn) { addBtn.click(); // the fresh relationship row lands right after the first one const sel2 = await pcWaitFor(() => { const s = typeRow.nextElementSibling; if (!s || !s.classList?.contains('relationship-item')) return null; const el = s.querySelector('select.link-type'); return (el && el !== select && !el.value) ? el : null; }, 3000); if (sel2 && [...sel2.options].some(o => o.value === '74')) { setSel.call(sel2, '74'); sel2.dispatchEvent(new Event('change', { bubbles: true })); report.type += ' + purchase for download'; ok2 = true; } } if (!ok2) report.note = 'sold as a download, but the second rel (purchase for download) could not be added'; } } catch (e) { reports.push({ url, ok: false, miss: `threw — ${e && e.name || 'Error'}: ${e && e.message || e}` }); } } // #556: a background add tab has no panel and therefore no log — "it did // nothing and said nothing" was most of what made this hard to diagnose. // Leave a trace in the tab's own console either way, naming each URL and what // became of it, so a failing run in the wild can actually be read back. const okUrls = reports.filter(r => r.ok).map(r => r.url); try { console.info(`[Platform Check] inject: ${okUrls.length}/${reports.length} link(s) landed on ${storageKey}`); reports.filter(r => !r.ok).forEach(r => console.warn(`[Platform Check] inject FAILED: ${r.url} — ${r.miss}`)); if (!injected) console.warn('[Platform Check] inject: nothing was confirmed — the queued links are kept for a retry'); } catch (e) {} // #556 (majkinetor): consume ONLY what actually landed. `injected > 0` used to // drop the whole payload, so a partial run silently lost every URL that had // failed — the 2-of-3 case, unrecoverable in exactly the same way the 0-of-3 // case was. What's left stays queued and the next attempt picks it up. try { const raw = localStorage.getItem(storageKey); const pend = raw ? JSON.parse(raw) : null; if (pend && typeof pend === 'object') { const left = {}; for (const [k, v] of Object.entries(pend)) { if (v && !okUrls.some(u => pcSameUrl(u, v) || u === v)) left[k] = v; } if (Object.keys(left).length) localStorage.setItem(storageKey, JSON.stringify(left)); else localStorage.removeItem(storageKey); } else if (injected > 0) { localStorage.removeItem(storageKey); } } catch (e) { if (injected > 0) { try { localStorage.removeItem(storageKey); } catch (_) {} } } // #641: links added by a middle click, over link confidence ({ url: reason }), so the note // doesn't claim they passed it. Read once, then dropped with the queue. let forced = {}; const forcedKey = storageKey.replace(/^pc:pending:/, 'pc:forced:'); try { forced = JSON.parse(localStorage.getItem(forcedKey) || '{}') || {}; if (injected > 0) localStorage.removeItem(forcedKey); } catch (_) {} // Set the edit note (as the script used to), and report the result quietly // inline next to the External links heading instead of a centred popup. const addedUrls = reports.filter(r => r.ok && !r.present).map(r => r.url); // #673: a barcode pasted on the release page goes in with the links const relMbid = (storageKey.match(/^pc:pending:([0-9a-f-]{36})$/) || [])[1]; const barcode = relMbid && okUrls.length ? await pcFillBarcode(relMbid) : null; // #740: so do the catalog numbers used there const catnos = relMbid && okUrls.length ? await pcFillCatnos(relMbid) : []; if (addedUrls.length || barcode || catnos.length) setEditNote(pcEditNote(addedUrls, forced, barcode, null, { catnos: catnos.map(c => ({ catno: c.catno, label: c.name })) })); // nothing actually attempted (every queued link was already there): stay silent — no banner on a plain edit if (reports.some(r => !r.present)) showInlineSummary(reports.filter(r => !r.present)); return { injected, reports, okUrls }; } // injectInto's TYPE_FORCE, also read by Mission Control's apply (which hands Falcon a // link type per url). A function declaration, so it is hoisted: the IIFE's early-return // path calls injectInto before any module-level const down here is initialised. function pcTypeForce() { return [ { test: u => /music\.apple\.com\/.*\/album\//i.test(u), ids: ['980', '85'], name: 'streaming page' }, { test: u => /[a-z0-9-]+\.bandcamp\.com\/album\//i.test(u), ids: ['85', '980'], name: 'stream for free' }, // HDtracks (MBS-9023) and Volumo have no dedicated MB link type, so MB's // classifier leaves both blank on insert. Both are paid download stores → // 74 'purchase for download' (verified live: MB auto-types Deezer/Spotify/ // Tidal/Beatport but leaves hdtracks.com and volumo.com unset). { test: u => /hdtracks\.com\//i.test(u), ids: ['74'], name: 'purchase for download' }, { test: u => /volumo\.com\/album\//i.test(u), ids: ['74'], name: 'purchase for download' }, // Qobuz: MB's URLCleanup recognises it but allows BOTH 'purchase for download' // and 'streaming page' (paid) — so MB can't auto-pick one and leaves the type // blank ("Please select a link type", chaban-mb #201). Qobuz is a hi-res download // store first (like HDtracks/Volumo), so prefer 74; fall back to 980 if that's the // only one MB offers for the row. { test: u => /qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\//i.test(u), ids: ['74', '980'], name: 'purchase for download' }, // SoundCloud (#469, chaban-mb): MB offers the whole "get the music" family // for a SoundCloud URL (73/74/75/85/980) and picks none of them, so the row // is left with a blank REQUIRED select and "Please select a link type for // the URL you've entered" — which blocks the release editor's submit // entirely (and therefore also #465's auto-submit), not just this one link. // Verified live: both example sets from the issue render exactly this. // 85 'stream for free' is the right default — a SoundCloud set is free // streaming unless it's Go-only. Picking between 85 and 980 *correctly* // needs the per-track monetization_model/policy signal chaban described // (policy 'SNIP' + truncated durations => Go-gated => 980); that's a // refinement, deliberately out of scope here since the reported problem is // the blocked submission, not the choice of type. { test: u => /^https?:\/\/(?:www\.|m\.)?soundcloud\.com\//i.test(u), ids: ['85', '980'], name: 'stream for free' }, // YouTube Music (#639): MB offers 85 and 980 and picks neither — the same blocked // submit as SoundCloud (verified on the sandbox). Its free tier plays every album, with ads. { test: u => /^https?:\/\/music\.youtube\.com\//i.test(u), ids: ['85', '980'], name: 'stream for free' }, // Audiomack (#664): MB offers 85 and 980 and picks neither, as for YouTube Music. Free with ads. { test: u => /^https?:\/\/(?:www\.)?audiomack\.com\//i.test(u), ids: ['85', '980'], name: 'stream for free' }, ]; } // #673: the barcode pasted on the release page (pc:pending-barcode:<mbid>), typed into the // release editor's Barcode field. Returns it once the field has it, null otherwise. The key // is a literal: this runs on the editor page, before any module const is initialised. async function pcFillBarcode(relMbid) { const key = `pc:pending-barcode:${relMbid}`; let bc = null; try { bc = localStorage.getItem(key); } catch (e) {} if (!bc) return null; const r = await pcFillBarcodeField(document, bc); if (r.other) { try { console.warn(`[Platform Check] inject: the release has barcode ${r.other} by now — pasted ${bc} dropped`); } catch (e) {} try { localStorage.removeItem(key); } catch (e) {} return null; } if (!r.ok) { try { console.warn(`[Platform Check] inject: ${r.why} — barcode ${bc} not added, kept queued`); } catch (e) {} return null; } try { localStorage.removeItem(key); } catch (e) {} pcMark('barcode filled', bc); return bc; } // #740: the catalog numbers used on the release page (pc:pending-catno:<mbid>, [{ mbid, name, catno }]), each // typed into the empty Cat. No. field of its label's row in the release editor (the row whose Label field reads // the label's name). Returns those that took. Self-contained: it runs on the editor page, before any module const. async function pcFillCatnos(relMbid) { const key = `pc:pending-catno:${relMbid}`; let want = []; try { want = JSON.parse(localStorage.getItem(key) || '[]') || []; } catch (e) {} if (!want.length) return []; const sleep = ms => new Promise(r => setTimeout(r, ms)); const t0 = Date.now(); while (!(document.getElementById('catno-0') && document.getElementById('external-links-editor'))) { if (Date.now() - t0 > 15000) return []; await sleep(100); } await sleep(300); const low = v => String(v || '').trim().toLowerCase(); const setVal = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; const done = []; for (const c of want) { let i = 0, cat = null; for (; document.getElementById(`label-${i}`); i++) if (low(document.getElementById(`label-${i}`).value) === low(c.name)) { cat = document.getElementById(`catno-${i}`); break; } if (!cat) { try { console.warn(`[Platform Check] inject: no label row for ${c.name} — catalog number ${c.catno} not added`); } catch (e) {} continue; } if (cat.value.trim()) { try { console.warn(`[Platform Check] inject: ${c.name} has catalog number ${cat.value} by now — ${c.catno} left out`); } catch (e) {} continue; } for (let a = 0; a < 20 && cat.value !== c.catno; a++) { cat.focus(); setVal.call(cat, c.catno); cat.dispatchEvent(new Event('input', { bubbles: true })); cat.dispatchEvent(new Event('change', { bubbles: true })); cat.blur(); await sleep(150); } await sleep(1200); if (cat.value === c.catno) done.push(c); } try { localStorage.removeItem(key); localStorage.removeItem(`pc:picked-catno:${relMbid}`); } catch (e) {} if (done.length) pcMark('catalog numbers filled', done.map(c => c.catno).join(', ')); return done; } // Types a barcode into the release editor's Barcode field, unticking "This release does not have // a barcode" first (#713). The editor binds late and resets an early write (the box ticked again, // the field emptied), so this waits for the bound editor, then keeps the barcode there until it // holds. { ok: true } once it holds, { ok: false, other } when the release has another barcode, // { ok: false, why } when the field never takes it. Falcon has the same for its release items. async function pcFillBarcodeField(doc, bc, { timeout = 15000, hold = 1200 } = {}) { const sleep = ms => new Promise(r => setTimeout(r, ms)); const digits = v => String(v || '').replace(/\D/g, ''); const t0 = Date.now(); // bound: the external links editor is the last part of the page to render. It can fail to // (a MusicBrainz request that errors): then go on 5 s after the field is there, and let the // hold below catch a late reset let seen = 0; while (!(doc.getElementById('barcode') && (doc.getElementById('external-links-editor') || (seen && Date.now() - seen > 5000)))) { if (!seen && doc.getElementById('barcode')) seen = Date.now(); if (Date.now() - t0 > timeout) return { ok: false, why: 'no Barcode field in the editor' }; await sleep(100); } await sleep(300); const win = doc.defaultView; const setVal = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value').set; let since = 0; while (Date.now() - t0 < timeout) { const input = doc.getElementById('barcode'), none = doc.getElementById('no-barcode'); const cur = digits(input.value); if (cur && cur !== bc) return { ok: false, other: cur }; if (cur === bc && !(none && none.checked) && !input.disabled) { if (!since) since = Date.now(); else if (Date.now() - since >= hold) return { ok: true }; } else { since = 0; if (none && none.checked) none.click(); input.focus(); setVal.call(input, bc); input.dispatchEvent(new win.Event('input', { bubbles: true })); input.dispatchEvent(new win.Event('change', { bubbles: true })); input.blur(); } await sleep(150); } return { ok: false, why: 'the Barcode field kept resetting' }; } // Build the edit note: a header line (name/version/author/homepage from GM_info, // with fallbacks) + the links that were added — same shape as the other scripts. // forced (#641): { url: reason } for links added by a middle click over link confidence — each is // marked in the list, so the note never claims they passed it. A link that passed is listed plain. function pcEditNote(urls, forced, barcode, barcodeFrom, extra) { const s = (typeof GM_info !== 'undefined' && GM_info.script) || {}; const homepage = s.homepageURL || s.homepage || 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/platform_check/README.md'; const header = (s.name || 'MB Platform Check') + ' v' + (s.version || '?') + ' by ' + (s.author || 'majkinetor') + ' - ' + homepage; // Record the link-confidence settings that gated which links were added, so a reviewer can see why // (e.g. strict barcode/format withholding). Lists each enabled check with its mode; 'off' when none. const modeWord = k => GM_getValue(k, 'strict') === 'strict' ? 'strictly' : 'if they exist'; const conf = []; if (GM_getValue('pc:respect-format', true)) conf.push('formats ' + modeWord('pc:format-mode')); if (GM_getValue('pc:respect-barcode', true)) conf.push('barcodes ' + modeWord('pc:barcode-mode')); const confLine = 'Link confidence: ' + (conf.length ? conf.join(', ') : 'off'); const lines = [header, confLine]; // #673: the platform links were matched on this barcode // #709: or, through Mission Control, found on the platforms (barcodeFrom says where) if (barcode) lines.push('', 'Added barcode ' + barcode + (barcodeFrom && barcodeFrom !== 'pasted by hand' ? ', ' + barcodeFrom : ' (the links were matched on it)')); // #737: no list of every url: MB copies the note onto each link's edit, which shows its own url. // Only the links added by hand over link confidence are named, with the reason. const why = u => { const k = Object.keys(forced || {}).find(x => pcSameUrl(x, u) || x === u); return k ? forced[k] : null; }; const overridden = urls.filter(why); if (urls.length) lines.push('', 'Added ' + urls.length + ' external link' + (urls.length === 1 ? '' : 's') + (overridden.length ? '; added by hand over link confidence:' : '')); overridden.forEach(u => lines.push(u + ' (' + why(u) + ')')); // #727: catalog numbers, and where a Mission Control consolidation found what is added const catnos = (extra && extra.catnos) || []; if (catnos.length) lines.push('', ...catnos.map(c => 'Added catalog number ' + c.catno + ' for ' + c.label)); const added = (extra && extra.added) || []; if (added.length) lines.push('', ...added.map(l => 'Added label ' + l.name + (l.catno ? ', catalog number ' + l.catno : ''))); if (extra && extra.consFrom && extra.consFrom.length) lines.push('', 'Consolidated by Mission Control from ' + extra.consFrom.join(', ')); return lines.join('\n'); } // Fill MB's edit-note textarea via the native setter so React picks it up. // Appends to anything already typed there. function setEditNote(text) { const ta = document.querySelector( 'textarea.edit-note, textarea[name="edit-note"], textarea[name="edit_note"], #id-edit-note, .edit-note textarea'); if (!ta) return false; try { const setVal = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; const existing = (ta.value || '').trim(); setVal.call(ta, existing ? existing + '\n' + text : text); ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); return true; } catch (_) { return false; } } // Quiet inline confirmation placed next to the "External links" heading (replaces // the old intrusive centred banner). Failures still surface here so nothing's lost. function showInlineSummary(reports) { const ok = reports.filter(r => r.ok).length; const fail = reports.length - ok; let el = document.getElementById('pc-inline-summary'); if (!el) { el = document.createElement('span'); el.id = 'pc-inline-summary'; el.style.cssText = 'margin-left: 10px; font-size: 12px; font-weight: 600; font-family: sans-serif; ' + 'padding: 1px 8px; border-radius: 10px; vertical-align: middle; display: inline-block;'; } el.style.color = fail ? 'var(--mbu-warn)' : 'var(--mbu-ok)'; el.style.background = fail ? 'var(--mbu-warn-bg)' : 'var(--mbu-ok-bg)'; el.style.border = '1px solid ' + (fail ? '#FFC107' : '#81C784'); el.title = reports.map(r => ((r.url || '').replace(/^https?:\/\//, '')) + ' — ' + (r.ok ? 'OK' + (r.type ? ' · ' + r.type : '') : 'FAIL · ' + r.miss)).join('\n'); el.textContent = '✓ Platform Check added ' + ok + (fail ? ', ' + fail + ' failed' : '') + ' — edit note set, review & Enter edit'; const re = /^\s*external links\s*$/i; // #722 the links editor's own legend, by structure; the English text elsewhere const heading = document.getElementById('external-links-editor')?.closest('fieldset')?.querySelector(':scope > legend') || [...document.querySelectorAll('h2, h3, legend, label')].find(h => re.test(h.textContent || '')); if (heading) { heading.appendChild(el); return; } const tab = [...document.querySelectorAll('a, button, li')].find(h => re.test(h.textContent || '')); if (tab) tab.after(el); else document.body.appendChild(el); } function showInjectBanner(text, reports = [], opts = {}) { document.getElementById('pc-inject-status')?.remove(); const overlay = document.createElement('div'); overlay.id = 'pc-inject-status'; const fail = !!opts.fail; const palette = fail ? { bg: '#FFF8E1', border: '#FFB300', accent: '#7B5E00', title: '#5D4400', tagOk: '#1B5E20', tagFail: '#B71C1C' } : { bg: '#E8F5E9', border: '#66BB6A', accent: '#1B5E20', title: '#0E4814', tagOk: '#1B5E20', tagFail: '#B71C1C' }; overlay.style.cssText = ` position: fixed; top: 16px; left: 50%; transform: translateX(-50%); z-index: 999999; padding: 12px 36px 12px 14px; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; line-height: 1.4; max-width: 520px; min-width: 280px; background: ${palette.bg}; border: 1px solid ${palette.border}; color: ${palette.accent}; box-shadow: 0 6px 20px rgba(0,0,0,0.18), 0 2px 4px rgba(0,0,0,0.08);`; const closeBtn = `<button id="pc-inject-close" type="button" aria-label="Close" style=" position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: ${palette.accent}; font-size: 16px; line-height: 1; cursor: pointer; opacity: 0.6;">×</button>`; let body = `<div style="font-weight: 600; color: ${palette.title}; margin-right: 6px;">${text}</div>`; if (reports.length) { body += '<ul style="margin: 8px 0 0 0; padding: 0; list-style: none; font-size: 12px;">'; for (const r of reports) { const host = (r.url || '').match(/^https?:\/\/([^/]+)/)?.[1] || ''; const tail = r.ok ? `<span style="color: ${palette.tagOk}; font-weight: 600;">OK</span>${r.type ? ` <span style="color:var(--mbu-text-dim);">· ${r.type}</span>` : ''}${r.note ? ` <span style="color:var(--mbu-text-weak);">· ${r.note}</span>` : ''}` : `<span style="color: ${palette.tagFail}; font-weight: 600;">FAIL</span> <span style="color:var(--mbu-text-dim);">· ${r.miss}</span>`; body += `<li style="padding: 2px 0; display: flex; gap: 6px;"><span style="color:var(--mbu-text-weak); min-width: 0;">${host}</span><span style="color:var(--mbu-text-dim);">·</span><span style="flex: 1;">${tail}</span></li>`; } body += '</ul>'; body += `<div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid ${palette.border}; font-size: 11px; color: var(--mbu-text-dim);">Remember to set an edit note and click <strong style="color: ${palette.accent};">Enter edit</strong> to save.</div>`; } overlay.innerHTML = body + closeBtn; document.body.appendChild(overlay); const close = document.getElementById('pc-inject-close'); close?.addEventListener('mouseenter', () => { close.style.opacity = '1'; close.style.background = 'rgba(0,0,0,0.06)'; }); close?.addEventListener('mouseleave', () => { close.style.opacity = '0.6'; close.style.background = 'transparent'; }); close?.addEventListener('click', () => overlay.remove()); } // ─── UI ──────────────────────────────────────────────────────────────────── // The dashboard is for a /release/<mbid> page ONLY. Standalone the @match scopes it; in the String // Theory bundle this script runs on every MB page (Mammoth matches /*), so guard the entity type before // building any UI — `\/release\/` won't match `/release-group/`, so the RG overview no longer mounts it. (#343) if (!PC_SEED && !/^\/release\/[0-9a-f-]{36}/i.test(window.location.pathname)) return; const sidebar = PC_SEED ? (() => { const d = document.createElement('div'); d.id = 'mb-pc-seed-host'; d.hidden = true; (document.body || document.documentElement).appendChild(d); return d; })() : document.querySelector('#sidebar'); if (!sidebar) return; const container = document.createElement('div'); container.id = 'mb-pc-panel'; container.className = 'online-search-box'; // min-width:0 + max-width + overflow:hidden so a long, no-wrap meta line (e.g. an // Apple Music licence string) can't stretch the panel — and the whole sidebar — wide. container.style.cssText = 'margin-bottom: 12px; padding: 8px 6px; background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 6px; font-size: 13px; font-family: sans-serif; box-shadow: 0 1px 3px rgba(0,0,0,0.05); min-width: 0; max-width: 100%; box-sizing: border-box; overflow: hidden;'; // MB's site CSS adds an external-link icon to every `target="_blank"` anchor // (`a[rel~="external"]::after` / similar). On the dark-themed sidebar it // renders as a missing-image red square next to each platform name. Suppress // it on our anchors via inline ::after override scoped to the panel. const iconBtn = 'cursor: pointer; user-select: none; color: var(--mbu-text-dim); padding: 2px 6px; border-radius: 4px; line-height: 1; font-size: 14px;'; // Provider order is user-configurable from the providers modal. Stored as a // JSON array under `pc:provider-order`. getProviderOrder() always returns // every known platform — unknown stored values are dropped, missing ones // are appended — so adding a new platform (like Apple Music) doesn't break // stored preferences. // Default visual order — Discogs first since it's the most reliable rich // metadata source, then the streaming services, with Deezer last because it // has the worst catalogue coverage of the four. Users can override via the // providers panel (drag-and-drop) and the choice persists in pc:provider-order. const ALL_PROVIDERS = ['discogs', 'bandcamp', 'spotify', 'apple', 'deezer', 'tidal', 'ytmusic', 'amazonmusic', 'qobuz', 'beatport', 'volumo', 'hdtracks', 'sevendigital', 'soundcloud', 'audiomack']; function getProviderOrder() { const raw = GM_getValue('pc:provider-order', null); if (!raw) return ALL_PROVIDERS.slice(); try { const arr = JSON.parse(raw); const out = []; for (const p of arr) if (ALL_PROVIDERS.includes(p) && !out.includes(p)) out.push(p); for (const p of ALL_PROVIDERS) if (!out.includes(p)) out.push(p); return out; } catch { return ALL_PROVIDERS.slice(); } } const PROVIDER_ORDER = getProviderOrder(); // A provider the user has unchecked in Settings is hidden from the dash, but its // result may still be cached from an earlier enabled run. Anything that consumes the // cache (inject +, open-all ↗) must skip disabled providers or it queues/opens links // for a provider the user turned off. (#255) const providerEnabled = p => GM_getValue(`pc:prov_${p}`, true); const PROVIDER_NAME = { spotify:'Spotify', discogs:'Discogs', bandcamp:'Bandcamp', deezer:'Deezer', apple:'Apple', tidal:'Tidal', qobuz:'Qobuz', beatport:'Beatport', volumo:'Volumo', hdtracks:'HDtracks', sevendigital:'7digital', soundcloud:'SoundCloud', audiomack:'Audiomack', ytmusic:'YouTube Music', amazonmusic:'Amazon Music' }; // Tidal and Discogs are black-and-white brands: their names take the theme's text colour, so they read on a dark theme too const PROVIDER_COLOR = { spotify:'#1DB954', discogs:'var(--mbu-text)', bandcamp:'#629AA9', deezer:'#A238FF', apple:'#FA243C', tidal:'var(--mbu-text)', qobuz:'#0070ef', beatport:'#0a8754', volumo:'#7c4dff', hdtracks:'#e63329', sevendigital:'#07606E', soundcloud:'#ff5500', audiomack:'#FFA200', ytmusic:'#FF0000', amazonmusic:'#1FA8B0' }; // Shared platform icons (#404) — `stIcon(name, size)` / `stColor(name)`. Source of truth is // dev/ui/platform-icons.mjs; the block below is generated by dev/ui/sync-icons.mjs (pre-commit hook). // <ST-ICONS> — generated by dev/ui/sync-icons.mjs from dev/ui/platform-icons.mjs — DO NOT EDIT const ST_ICONS = {"musicbrainz":{"color":"#eb743b","svg":"<svg viewBox=\"0.75 0.75 28.5 28.5\" xmlns=\"http://www.w3.org/2000/svg\"><g transform=\"translate(1.5)\"><path d=\"m13 1-12 7v14l12 7z\" fill=\"#ba478f\"/><path d=\"m14 1 12 7v14l-12 7z\" fill=\"#eb743b\"/></g></svg>"},"discogs":{"color":"#333333","svg":"<svg viewBox=\"71 71 882 882\" xmlns=\"http://www.w3.org/2000/svg\"><g transform=\"translate(512 512) scale(0.86) translate(-512 -512)\"><circle cx=\"512\" cy=\"512\" r=\"496\" fill=\"#333\" stroke=\"#9a9a9a\" stroke-width=\"32\"/><path fill=\"#fff\" d=\"M439.84 511.58A72.58 72.58 0 0 1 512.41 439 72.54 72.54 0 0 1 585 511.58a72.56 72.56 0 0 1-72.57 72.56 72.56 72.56 0 0 1-72.57-72.56zm3.18 0A69.48 69.48 0 0 0 512.41 581a69.4 69.4 0 0 0 69.4-69.38 69.49 69.49 0 0 0-69.4-69.43A69.44 69.44 0 0 0 443 511.58zm69.42-11.44a11.43 11.43 0 1 0 11.47 11.45 11.45 11.45 0 0 0-11.48-11.45zm-131.08 11.43a130.68 130.68 0 0 0 40.3 94.43l24.68-26.69.33.3a94.59 94.59 0 0 1 113.08-149.95l17.51-31.95a130.23 130.23 0 0 0-64.82-17.22c-72.27.01-131.08 58.81-131.08 131.08zm225.73 0a94.6 94.6 0 0 1-138.64 83.79l-17.83 31.74a130.26 130.26 0 0 0 61.82 15.53c72.28 0 131.08-58.8 131.08-131.08a130.63 130.63 0 0 0-37.73-91.9L581 446.39a94.3 94.3 0 0 1 26.1 65.2zm-267.34 0a172.17 172.17 0 0 0 53.68 125l25-27.07a135.38 135.38 0 0 1-41.82-97.89c0-74.88 60.92-135.8 135.8-135.8a134.92 134.92 0 0 1 67.08 17.8l17.73-32.34a171.57 171.57 0 0 0-84.81-22.35c-95.19-.03-172.66 77.43-172.66 172.65zm308.49 0c0 74.88-60.92 135.8-135.8 135.8a135 135 0 0 1-64.14-16.14l-18.07 32.17a171.62 171.62 0 0 0 82.21 20.86c95.22 0 172.69-77.47 172.69-172.69a172.15 172.15 0 0 0-51-122.4l-25.12 27a135.35 135.35 0 0 1 39.23 95.4zm41.61 0c0 97.83-79.58 177.43-177.41 177.43a176.32 176.32 0 0 1-84.52-21.46l-18.18 32.36a213.21 213.21 0 0 0 102.7 26.23C630.74 726.11 727 629.87 727 511.57a213.87 213.87 0 0 0-64.38-153l-25.26 27.18a176.85 176.85 0 0 1 52.49 125.82zm-392 0A213.9 213.9 0 0 0 365 667.24L390.23 640A176.88 176.88 0 0 1 335 511.57c0-97.82 79.59-177.41 177.41-177.41a176.26 176.26 0 0 1 87.08 22.93l17.84-32.55A213.14 213.14 0 0 0 512.44 297c-118.3 0-214.54 96.28-214.54 214.57zm392.55-183-24.64 26.49a218.57 218.57 0 0 1 65.94 156.51c0 120.9-98.36 219.26-219.26 219.26a217.9 217.9 0 0 1-105-26.84l-18.24 32.47A255.43 255.43 0 0 0 512 768c141.39 0 256-114.64 256-256a255.23 255.23 0 0 0-77.55-183.41zm-397.27 183c0-120.9 98.36-219.26 219.26-219.26a217.84 217.84 0 0 1 107.19 28.09L637 288.65A254.46 254.46 0 0 0 516.12 256H512c-140.54.22-254.42 113.26-256 253.5v2.5a255.69 255.69 0 0 0 80.51 186.08l25.31-27.36a218.61 218.61 0 0 1-68.64-159.15z\"/></g></svg>"},"spotify":{"color":"#1DB954","svg":"<svg viewBox=\"1.25 1.375 21.25 21.25\" fill=\"#1DB954\"><path transform=\"translate(12 12) scale(.875) translate(-12 -12)\" d=\"M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.42 1.56-.299.421-1.02.599-1.559.3z\"/></svg>"},"apple":{"color":"#FA243C","svg":"<svg viewBox=\"1.625 1.25 20 20\" fill=\"#FA243C\"><path d=\"M17.05 12.04c-.03-2.5 2.04-3.7 2.13-3.76-1.16-1.7-2.97-1.93-3.61-1.96-1.54-.16-3 .9-3.78.9-.78 0-1.97-.88-3.24-.86-1.67.03-3.21.97-4.07 2.46-1.73 3.01-.44 7.47 1.24 9.92.82 1.2 1.8 2.54 3.08 2.49 1.24-.05 1.71-.8 3.21-.8 1.5 0 1.92.8 3.23.77 1.33-.02 2.18-1.22 3-2.42.94-1.39 1.33-2.73 1.35-2.8-.03-.01-2.59-.99-2.62-3.93zM14.6 4.59c.68-.83 1.14-1.97 1.01-3.11-.98.04-2.17.65-2.87 1.47-.63.73-1.18 1.9-1.03 3.02 1.09.08 2.21-.55 2.89-1.38z\"/></svg>"},"deezer":{"color":"#A238FF","svg":"<svg viewBox=\"3 3 18 18\"><path transform=\"translate(12 12) scale(.74) translate(-12 -12)\" d=\"M4 2h6v2h-6zM14 2h6v2h-6zM2 4h20v2h-20zM0 6h24v2h-24zM0 8h24v2h-24zM0 10h24v2h-24zM2 12h20v2h-20zM4 14h16v2h-16zM6 16h12v2h-12zM8 18h8v2h-8zM10 20h4v2h-4z\" fill=\"#A238FF\"/></svg>"},"tidal":{"color":"#000000","svg":"<svg viewBox=\"3 3 18 18\"><path d=\"M6 6l3 3-3 3-3-3zM12 6l3 3-3 3-3-3zM18 6l3 3-3 3-3-3zM12 12l3 3-3 3-3-3z\" style=\"fill:var(--mbu-text,currentColor)\"/></svg>"},"qobuz":{"color":"#0070ef","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#0070ef\"/><circle cx=\"12\" cy=\"12\" r=\"5\" fill=\"none\" stroke=\"#fff\" stroke-width=\"2.2\"/><path d=\"M14.5 14.5 19 19\" stroke=\"#fff\" stroke-width=\"2.2\" stroke-linecap=\"round\"/></svg>"},"beatport":{"color":"#01FF95","svg":"<svg viewBox=\"0.75 0.75 22.5 22.5\"><circle cx=\"12\" cy=\"12\" r=\"11\" fill=\"#000\"/><g transform=\"translate(12 12) scale(0.84) translate(-12 -12)\" fill=\"none\" stroke=\"#01FF95\" stroke-width=\"2.5\"><path d=\"M10.9 3V8.3c0 1.2-.4 1.9-1.1 2.6L5.6 15.1\"/><circle cx=\"13.9\" cy=\"15.8\" r=\"4.05\" stroke-width=\"2.35\"/></g></svg>"},"bandcamp":{"color":"#629AA9","svg":"<svg viewBox=\"2.25 2.25 19.5 19.5\" fill=\"#629AA9\"><path transform=\"translate(12 12) scale(.8) translate(-12 -12)\" d=\"M0 18.75l7.437-13.5H24l-7.438 13.5z\"/></svg>"},"volumo":{"color":"#7c4dff","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#7c4dff\"/><path d=\"M7 8h2.2l2.8 6 2.8-6H17l-4 9h-2z\" fill=\"#fff\"/></svg>"},"hdtracks":{"color":"#e63329","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#e63329\"/><path d=\"M5 7.5h1.7v3.1h2.6V7.5H11v8H9.3v-3.2H6.7v3.2H5zm7.2 0h2.9c2 0 3.4 1.6 3.4 4s-1.4 4-3.4 4h-2.9zm1.7 1.5v5h1.1c1.1 0 1.8-1 1.8-2.5s-.7-2.5-1.8-2.5z\" fill=\"#fff\"/></svg>"},"soundcloud":{"color":"#ff5500","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#ff5500\"/><g fill=\"#fff\"><rect x=\"6\" y=\"12\" width=\"1.4\" height=\"4\" rx=\".6\"/><rect x=\"8.5\" y=\"10\" width=\"1.4\" height=\"6\" rx=\".6\"/><rect x=\"11\" y=\"8.5\" width=\"1.4\" height=\"7.5\" rx=\".6\"/><rect x=\"13.5\" y=\"10.5\" width=\"1.4\" height=\"5.5\" rx=\".6\"/><rect x=\"16\" y=\"11.5\" width=\"1.4\" height=\"4.5\" rx=\".6\"/></g></svg>"},"audiomack":{"color":"#FFA200","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#FFA200\"/><path d=\"M5 13.5l2-2 1.6 2.4 2.2-5.4 2.4 6.6 2.2-4 1.6 2.4H19\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.6\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/></svg>"},"sevendigital":{"color":"#07606E","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#07606E\"/><path d=\"M7.8 6.8h8.4v1.9l-4.5 8.9H9.4l4.4-8.7h-6z\" fill=\"#fff\"/></svg>"},"ytmusic":{"color":"#FF0000","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#FF0000\"/><circle cx=\"12\" cy=\"12\" r=\"5.6\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.4\"/><path d=\"M10.4 9.5v5l4.2-2.5z\" fill=\"#fff\"/></svg>"},"amazonmusic":{"color":"#25D1DA","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#25D1DA\"/><path d=\"M5.8 11.2c3.5 3.2 8.9 3.5 12.4.9\" fill=\"none\" stroke=\"#0F1111\" stroke-width=\"1.9\" stroke-linecap=\"round\"/><path d=\"M15.5 10.7l3 1.3-.9 3.1\" fill=\"none\" stroke=\"#0F1111\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>"},"soundexchange":{"color":"#6f42c1","svg":"<svg viewBox=\"1.75 1.75 20.5 20.5\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#6f42c1\"/><path d=\"M6.5 12h1.3l1-3 1.6 6 1.6-9 1.6 12 1.4-6h1.5\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.4\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/></svg>"},"globe":{"color":"#6f7d75","svg":"<svg viewBox=\"2 2 20 20\" fill=\"none\" stroke=\"#6f7d75\" stroke-width=\"1.8\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18\"/></svg>"}}; function stIcon(name, size) { var i = ST_ICONS[name]; if (!i) return ''; size = size || 16; return i.svg.replace(/<svg\b([^>]*)>/, function (m, a) { a = a.replace(/\s(?:width|height)="[^"]*"/g, ''); var ns = /\bxmlns=/.test(a) ? '' : ' xmlns="http://www.w3.org/2000/svg"'; return '<svg' + a + ns + ' width="' + size + '" height="' + size + '">'; }); } function stColor(name) { return (ST_ICONS[name] && ST_ICONS[name].color) || ''; } // </ST-ICONS> // The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and are // inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never in this block. // <ST-TOKENS> — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // </ST-TOKENS> // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here. // <ST-UI> — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{width:auto;margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;width:auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(860px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug>*{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.mbu-logemb,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on <html>, which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'<svg…>' | '<img…>', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // o.history opt-in history of past sessions (#705), below // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // viewer appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) : '--:--:--.---'; }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + (e.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; document.body.appendChild(pop); var v = view(pop, true); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button, input, select') || pop.classList.contains('full')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); views = views.filter(function (v) { return v !== win.v; }); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in <body>: a permanent stray node under <html>, outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No <body> yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and <head> and <body> don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } // </ST-UI> container.innerHTML = ` <style> ${MBU_TOKENS}${MBU_UI_CSS} /* MB's site CSS marks any outbound link with a red external-link ::after icon * via selectors that beat our specificity unless we anchor on #sidebar. The * ID-prefixed selector (specificity 1,2,2) beats anything class-only. */ #sidebar .online-search-box a::before, #sidebar .online-search-box a::after { content: none !important; display: none !important; background: none !important; background-image: none !important; } #sidebar .online-search-box a img.external, #sidebar .online-search-box a img[src*="external"] { display: none !important; } .online-search-box .pc-icon-btn:hover { background: var(--mbu-bg-sunken); color: var(--mbu-text); } #mb-links-btn.pc-links-on { color: var(--mbu-text); } /* #674: the barcodes the platforms report, on a release that has none */ #mb-found-bc { border: 1px dashed currentColor; color: var(--mbu-accent-text); padding: 1px 5px; } #mb-found-cat { border: 1px dashed currentColor; color: var(--mbu-accent-text); padding: 1px 5px; } #mb-picked-cat { font-family: var(--mbu-font-mono); } :is(#pc-bc-pop, #pc-cat-pop) { position: fixed; overflow-y: auto; box-sizing: border-box; z-index: var(--mbu-z-pop); width: 250px; padding: 6px; background: var(--mbu-bg); color: var(--mbu-text); border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); box-shadow: var(--mbu-shadow-lg); font: 12px var(--mbu-font); } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-h { margin: 0 4px 4px; font-size: 10px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--mbu-text-dim); } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-opt { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; padding: 5px 6px; border-radius: var(--mbu-radius); cursor: pointer; } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-opt:hover { background: var(--mbu-bg-hover); } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-code { font: 12px var(--mbu-font-mono); } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-use { font-size: 11px; color: var(--mbu-accent-text); } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-who, #pc-bc-pop .pc-bc-fmt { grid-column: 1 / -1; font-size: 11px; color: var(--mbu-text-weak); } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-plat svg { vertical-align: -2px; margin-right: 2px; } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-same { color: var(--mbu-ok); } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-other { color: var(--mbu-warn); } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-mb a { color: inherit; text-decoration: underline; } :is(#pc-bc-pop, #pc-cat-pop) .pc-bc-hint { margin: 4px 4px 0; padding-top: 5px; border-top: 1px solid var(--mbu-divider); font-size: 11px; color: var(--mbu-text-weak); } #mb-links-btn .pc-links-n { display: inline-block; min-width: 16px; margin-left: 4px; padding: 1px 5px; border-radius: 8px; background: var(--mbu-accent); color: var(--mbu-accent-fg); font-size: 10px; font-weight: 700; line-height: 1.3; text-align: center; vertical-align: 1px; } /* icons mode (toggle "Show platform icons"): the brand glyph REPLACES the ✓/×/~ text and carries the * state via a per-row class (pc-st-*): RING = already in MB (the only thing a ring means) · full colour = * clean match · GRAY icon+name = found but track-count mismatch · faint = not found. Match vs mismatch is * also on the right via the count colour. */ /* fixed-size box (keeps names aligned); NO frame by default — only in-MB draws a blue circle */ .pc-plat-ico { display: none; align-items: center; justify-content: center; flex: none; width: var(--pc-icon-size, 22px); height: var(--pc-icon-size, 22px); border-radius: 50%; box-sizing: border-box; } .pc-plat-ico svg { display: block; width: 100%; height: 100%; } #mb-pc-panel.pc-icons-mode .pc-plat-ico { display: inline-flex; } #mb-pc-panel.pc-icons-mode .pc-ico-slot { display: none; } /* in-MB marker (independent of match/mismatch) — per the "MB marker" option (#642) */ /* #642: the marker is a choice (Settings › MB marker); the glow is gone — it was barely visible */ /* 3px: the shared icons fill their box (#680), so a square-cornered mark (Deezer, Bandcamp, Tidal) needs the room to stay inside the ring */ #mb-pc-panel.pc-icons-mode.pc-mark-circle .pc-inmb .pc-plat-ico { border: 1px solid var(--mbu-info); padding: 3px; } /* the ring and square are drawn INSIDE the icon box (border + padding shrink the icon), never outside it: an outline with an offset overflowed into the next row and the panel edge at larger icon sizes (majkinetor) */ #mb-pc-panel.pc-icons-mode.pc-mark-bold .pc-inmb .pc-plat-ico { border: 2px solid var(--mbu-info); padding: 3px; } #mb-pc-panel.pc-icons-mode.pc-mark-square .pc-inmb .pc-plat-ico { border: 1.5px solid var(--mbu-info); padding: 3px; border-radius: 6px; } #mb-pc-panel.pc-icons-mode:is(.pc-mark-badge, .pc-mark-dot) .pc-inmb .pc-plat-ico { position: relative; overflow: visible; } #mb-pc-panel.pc-icons-mode:is(.pc-mark-badge, .pc-mark-dot) .pc-inmb .pc-plat-ico::after { content: ""; position: absolute; pointer-events: none; background: var(--mbu-info); } #mb-pc-panel.pc-icons-mode.pc-mark-badge .pc-inmb .pc-plat-ico::after { content: "✓"; right: -5px; bottom: -4px; width: 12px; height: 12px; border-radius: 50%; color: #fff; font: bold 9px/12px sans-serif; text-align: center; box-shadow: 0 0 0 1.5px var(--mbu-bg); } #mb-pc-panel.pc-icons-mode.pc-mark-dot .pc-inmb .pc-plat-ico::after { right: -3px; top: -3px; width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 0 1.5px var(--mbu-bg); } /* presence — fades/grays the icon + name regardless of in-MB */ #mb-pc-panel.pc-icons-mode .pc-st-mismatch .pc-plat-ico svg { filter: grayscale(1); opacity: .6; } /* found but wrong */ #mb-pc-panel.pc-icons-mode .pc-st-mismatch a[id^="mb-online"] { color: var(--mbu-text-weak) !important; } #mb-pc-panel.pc-icons-mode .pc-st-notfound .pc-plat-ico svg { filter: grayscale(1); opacity: .3; } /* not found */ /* Compact unmatched providers (#355): when on, every provider starts compact — a strip of dimmed brand icons — and rises into its full row when it matches; the unmatched ones stay in the strip (except Discogs/Bandcamp, which always keep their full rows). Clicking a strip icon runs that provider's search, like clicking its row. Full rows are hidden via a class so the per-provider enable/disable (inline display) is left untouched. */ #mb-pc-panel .pc-row.pc-compacted { display: none !important; } /* the strip sits under the full rows with a clear gap; its icons are a touch smaller than the row icons (they're a secondary, collapsed representation). */ .pc-compact-strip { display: none; grid-column: 1 / -1; flex-wrap: wrap; align-items: center; gap: 7px; padding: 1rem 0 1px; } #mb-pc-panel .pc-compact-strip.pc-has-icons { display: flex; } .pc-compact-ico { display: inline-flex; align-items: center; justify-content: center; width: calc(var(--pc-icon-size, 22px) * 0.8); height: calc(var(--pc-icon-size, 22px) * 0.8); cursor: pointer; border-radius: 50%; box-sizing: border-box; filter: grayscale(1); opacity: .38; transition: opacity .12s, filter .12s; } .pc-compact-ico svg, .pc-compact-ico img { display: block; width: 100%; height: 100%; } .pc-compact-ico:hover { filter: none; opacity: 1; } /* a folded MISMATCH (found but wrong barcode/format) keeps an amber ring so the "found but a different release" signal survives the collapse. */ .pc-compact-ico.pc-compact-mismatch { box-shadow: 0 0 0 2px #e0892a; opacity: .55; } /* subtle rise as a provider leaves the strip on a match — a small fade + lift so the panel doesn't jump/flash as results stream in. */ @keyframes pcRise { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } } #mb-pc-panel .pc-row.pc-rise { animation: pcRise .28s ease; } /* (#422) the ↻ refresh button doubles as the progress indicator: it spins while scans run (a click stops them, #674); the scan duration lands in its tooltip afterwards */ @keyframes pcSpin { to { transform: rotate(360deg); } } #mb-refresh-btn.pc-scanning { display: inline-block; animation: pcSpin 1s linear infinite; color: var(--mbu-info); } /* barcode mismatch (#182): a thin amber bar on the row's left edge — the barcode itself is never shown in the dash, only in the row tooltip + the log. */ #mb-pc-panel .pc-row.pc-barcode-diff { box-shadow: inset 3px 0 0 #e0892a; } /* format incompatibility (#182): a thin violet bar; only shown while the "Use format for link confidence" option is on. Stacks beside the amber barcode bar when a row is both. */ /* setup panel (#188): section headers + nav-button hovers */ #mb-provider-modal-card .pc-setup-sec { font-weight: 700; color: var(--mbu-text); font-size: 11px; letter-spacing: .05em; text-transform: uppercase; margin: 14px 0 4px; padding-bottom: 3px; border-bottom: 1px solid var(--mbu-divider); } #mb-provider-modal-card .pc-setup-nav:hover { background: var(--mbu-bg-raised); } /* main view: a checkbox per row above, label · control under Appearance */ #mb-provider-modal-card .pc-set-row { display: flex; align-items: center; gap: 8px; margin: 5px 4px; } #mb-setup-main label { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; } #mb-provider-modal-card .pc-set-row input[type=checkbox], #mb-provider-modal-card .pc-set-grid input[type=checkbox] { margin: 0; width: 16px; height: 16px; } #mb-setup-main select { font-size: 12px; padding: 1px 3px; } #mb-provider-modal-card .pc-set-grid { display: grid; grid-template-columns: 104px 1fr; align-items: center; column-gap: 10px; row-gap: 6px; margin: 6px 4px 0; } #mb-provider-modal-card .pc-set-k { color: var(--mbu-text-dim); white-space: nowrap; } #mb-provider-modal-card .pc-set-v { display: flex; align-items: center; gap: 8px; min-height: 26px; } #mb-provider-modal-card .pc-set-v > label + label, #mb-provider-modal-card .pc-set-v > .pc-step + .pc-set-lbl, #mb-provider-modal-card .pc-set-v > label + .pc-set-lbl { margin-left: 6px; } #mb-provider-modal-card .pc-set-lbl { font-size: 12px; color: var(--mbu-text-weak); } #mb-provider-modal-card .pc-seg { display: inline-flex; border: 1px solid var(--mbu-border); border-radius: 6px; overflow: hidden; } #mb-provider-modal-card .pc-seg label { gap: 0; } #mb-provider-modal-card .pc-seg input { position: absolute; opacity: 0; pointer-events: none; } #mb-provider-modal-card .pc-seg span { display: block; padding: 3px 10px; font-size: 12px; background: var(--mbu-bg-sunken); color: var(--mbu-text-dim); } #mb-provider-modal-card .pc-seg label + label span { border-left: 1px solid var(--mbu-border); } #mb-provider-modal-card .pc-seg input:checked + span { background: var(--mbu-accent); color: var(--mbu-accent-fg); } #mb-provider-modal-card .pc-seg input:focus-visible + span { outline: 2px solid var(--mbu-accent); outline-offset: -2px; } #mb-provider-modal-card .pc-step { display: inline-flex; align-items: center; border: 1px solid var(--mbu-border); border-radius: 6px; overflow: hidden; } #mb-provider-modal-card .pc-step button { width: 22px; height: 24px; padding: 0; border: 0; border-radius: 0; background: var(--mbu-bg-sunken); color: var(--mbu-text-dim); cursor: pointer; font: 13px var(--mbu-font); } #mb-provider-modal-card .pc-step button:hover { background: var(--mbu-bg-hover); } #mb-provider-modal-card .pc-step input { width: 30px; margin: 0; padding: 0; border: 0; background: transparent; color: var(--mbu-text); text-align: center; font: 12px var(--mbu-font); font-variant-numeric: tabular-nums; -moz-appearance: textfield; appearance: textfield; } #mb-provider-modal-card .pc-step input::-webkit-inner-spin-button, #mb-provider-modal-card .pc-step input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } #mb-provider-modal-card .pc-step.pc-off { opacity: .4; pointer-events: none; } #mb-provider-modal-card .pc-setup-back:hover { text-decoration: underline; } #mb-pc-panel .pc-row.pc-format-diff { box-shadow: inset 3px 0 0 #7e57c2; } #mb-pc-panel .pc-row.pc-format-diff.pc-barcode-diff { box-shadow: inset 3px 0 0 #e0892a, inset 6px 0 0 #7e57c2; } /* withheld by barcode/format confidence (#182): grayed + non-clickable, so it reads as "not added" like other mismatches rather than a clickable ✓ that does nothing. */ #mb-pc-panel .pc-row.pc-blocked .pc-ico-slot, #mb-pc-panel .pc-row.pc-blocked a[id^="mb-online"], #mb-pc-panel .pc-row.pc-blocked [id^="val-"] { color: var(--mbu-text-weak) !important; } #mb-pc-panel.pc-icons-mode .pc-row.pc-blocked .pc-plat-ico svg { filter: grayscale(1); opacity: .5; } #mb-pc-panel.pc-icons-mode .pc-st-notfound a[id^="mb-online"] { color: var(--mbu-text-weak) !important; opacity: .6; } /* Circled ✓ — applied when the platform URL came from an MB url-relationship * (existing rel), as distinct from a found-via-Wikidata/search result. Layered * on top of the colour-tint (green = fresh, steel-blue = cache hit). */ .online-search-box .pc-ico-circled { border: 1.5px solid currentColor; border-radius: 50%; width: 13px; height: 13px; line-height: 11px; text-align: center; font-size: 9px; box-sizing: border-box; display: inline-block; } /* ── Row layout (issue #173): compact 1-row vs 2-row stacked ─────────────── * 1-row packs each provider onto one line: full name, then year · format · * label flowing right after it, with the track count pinned to the right * edge. Names stay fully visible (they identify the row); the label is the * only thing that truncates (full text in its tooltip). 2-row keeps the * legacy stacked look (name line + meta line below). */ .pc-cell-ico { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 22px; width: 22px; cursor: default; } .pc-cell-ico img { display: block; } .pc-cell-name { color: inherit; text-decoration: none; font-weight: 600; font-size: var(--pc-name-size, 12px); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pc-cell-year, .pc-cell-format, .pc-cell-label { font-size: 10px; color: var(--mbu-text-weak); font-family: sans-serif; white-space: nowrap; overflow: hidden; } .pc-cell-label { text-overflow: ellipsis; min-width: 0; } .pc-cell-cat { font-family: var(--mbu-font-mono); color: var(--mbu-text); flex: none; white-space: nowrap; } /* #740: a label cell with a catalog number: the label text is cut, the number stays whole */ #mb-pc-panel .pc-cell-label.pc-has-cat { display: flex; gap: 4px; align-items: baseline; } #mb-pc-panel .pc-cell-label.pc-has-cat .pc-lbl-t { overflow: hidden; text-overflow: ellipsis; min-width: 0; } /* format-family quadrant marker (#350) — vertically centered against the meta text */ /* #564 (majkinetor): "PC vinyl is black in format circle and hardly visible". Vinyl is black because vinyl IS black — which works on a white page and disappears on a dark one. The lit quadrant colours are tokens now, with a dark set that lifts each one; the unlit quadrant and the hairline were already tokenised in an earlier pass. */ :root { --pc-fmt-vinyl:#2b2b2b; --pc-fmt-cassette:#9a6b3f; --pc-fmt-cd:#7d8894; --pc-fmt-digital:#4a90d9; } :root[data-mbu-theme="dark"] { --pc-fmt-vinyl:#c3bdd0; --pc-fmt-cassette:#c79a6b; --pc-fmt-cd:#aab3bd; --pc-fmt-digital:#7bb3e8; } .pc-cell-format .pc-fmt { vertical-align: -2px; } #mb-pc-panel.pc-fmt-text .pc-cell-format .pc-fmt { display: none; } #mb-pc-panel:not(.pc-fmt-text) .pc-cell-format .pc-fmt-txt { display: none; } .pc-cell-val { font-size: 12px; font-weight: bold; font-family: monospace; color: var(--mbu-text-dim); text-align: right; } .pc-cell-master { font-size: 11px; min-width: 14px; } /* 1-row — the ENTIRE provider list is ONE grid (rows are display:contents, so every cell becomes a grid item of .pc-rows). Columns are content-sized, so they're as wide as their widest cell across all rows and line up as a true table — an empty format still reserves its column, so labels start at the same x whether or not a row has a format. Order: icon name year format label master tracks. */ #mb-pc-panel.pc-layout-1row .pc-rows { display: grid; align-items: center; column-gap: var(--pc-col-gap, 5px); row-gap: var(--pc-row-gap, 5px); grid-template-columns: 22px max-content max-content max-content minmax(0,1fr) max-content max-content; } /* each row is a subgrid box spanning all columns: it shares the parent's column tracks (so cells still align as a table) AND is a single element, so the whole row — gaps and empty cells included — is one click target. */ #mb-pc-panel.pc-layout-1row .pc-row { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: center; } #mb-pc-panel.pc-layout-1row .pc-meta { display: contents; } #mb-pc-panel.pc-layout-1row .pc-cell-ico { grid-column: 1; } #mb-pc-panel.pc-layout-1row .pc-cell-name { grid-column: 2; } #mb-pc-panel.pc-layout-1row .pc-cell-year { grid-column: 3; } #mb-pc-panel.pc-layout-1row .pc-cell-format { grid-column: 4; } #mb-pc-panel.pc-layout-1row .pc-cell-label { grid-column: 5; min-width: 0; } /* fills + truncates */ #mb-pc-panel.pc-layout-1row .pc-cell-master { grid-column: 6; text-align: center; } #mb-pc-panel.pc-layout-1row .pc-cell-val { grid-column: 7; } #mb-pc-panel.pc-layout-1row .pc-rows-sep { grid-column: 1 / -1; border-bottom: 1px solid var(--mbu-divider); } /* 2-row — legacy stacked look: name line, then a meta line below */ #mb-pc-panel.pc-layout-2row .pc-rows { display: flex; flex-direction: column; gap: var(--pc-row-gap, 5px); } #mb-pc-panel.pc-layout-2row .pc-rows-sep { border-bottom: 1px solid var(--mbu-divider); } #mb-pc-panel.pc-layout-2row .pc-row { display: grid; align-items: center; column-gap: 4px; grid-template-columns: 22px minmax(0,1fr) auto 24px; } #mb-pc-panel.pc-layout-2row .pc-cell-ico { grid-area: 1 / 1; } #mb-pc-panel.pc-layout-2row .pc-cell-name { grid-area: 1 / 2; } #mb-pc-panel.pc-layout-2row .pc-cell-master { grid-area: 1 / 3; text-align: center; } #mb-pc-panel.pc-layout-2row .pc-cell-val { grid-area: 1 / 4; } #mb-pc-panel.pc-layout-2row .pc-meta { grid-column: 2 / -1; grid-row: 2; line-height: 1.2; padding-top: 0.3rem; font-size: 10px; color: var(--mbu-text-weak); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } #mb-pc-panel.pc-layout-2row .pc-meta .pc-cell-year, #mb-pc-panel.pc-layout-2row .pc-meta .pc-cell-format, #mb-pc-panel.pc-layout-2row .pc-meta .pc-cell-label { display: inline; } /* #740: the two-row meta line, when its label has a catalog number: a flex line, so the label is cut, not the number */ #mb-pc-panel.pc-layout-2row .pc-meta:has(.pc-has-cat) { display: flex; align-items: baseline; } #mb-pc-panel.pc-layout-2row .pc-meta:has(.pc-has-cat) > :not(.pc-cell-label) { flex: none; } #mb-pc-panel.pc-layout-2row .pc-meta .pc-cell-label.pc-has-cat { display: inline-flex; min-width: 0; } #mb-pc-panel.pc-layout-2row .pc-meta .pc-cell-year:not(:empty), #mb-pc-panel.pc-layout-2row .pc-meta .pc-cell-format:not(:empty) { margin-right: 4px; } /* names toggle (pc:show-names) */ #mb-pc-panel.pc-no-names .pc-cell-name { display: none; } /* MusicBrainz reference row: always visible mark + brand-violet name, no fade */ #mb-pc-panel .pc-row-mb .pc-plat-ico { display: inline-flex; } </style> <div style="margin-bottom: 6px;"> <div style="display: flex; align-items: center; justify-content: space-between;"> <div style="display: flex; align-items: center; gap: 4px;"> <h3 style="margin: 0; font-size: 12px; font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; color: var(--mbu-text-dim); white-space: nowrap;">Platform Check</h3> <span id="mb-refresh-btn" class="pc-icon-btn" title="Refresh — clear cache and re-scan" style="${iconBtn}">↻</span> </div> </div> <!-- #740: what the platforms offer (barcodes, catalog numbers) on a line of their own, so the title line never moves; shown only while one of them is (pcSyncOffers) --> <div id="pc-offers" style="display: none; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 4px;"> <span id="mb-pasted-bc" class="pc-icon-btn" title="" style="${iconBtn} display: none; font-size: 10px; white-space: nowrap;"></span> <span id="mb-found-bc" class="pc-icon-btn" title="" style="${iconBtn} display: none; font-size: 10px; white-space: nowrap;"></span> <span id="mb-picked-cat" class="pc-icon-btn" title="" style="${iconBtn} display: none; font-size: 10px; white-space: nowrap;"></span> <span id="mb-found-cat" class="pc-icon-btn" title="" style="${iconBtn} display: none; font-size: 10px; white-space: nowrap;"></span> </div> </div> <div class="pc-rows" style="margin-bottom: 6px;"> <div id="row-mb" class="pc-row pc-row-mb"> <span class="pc-cell-ico"><span class="pc-plat-ico" title="MusicBrainz">${stIcon('musicbrainz', 16)}</span></span> <a id="mb-mb-name" class="pc-cell-name" href="${MB_ORIGIN}/release/" target="_blank" rel="noopener" title="MusicBrainz" style="color: var(--mbu-accent-text);">MB</a> <span class="pc-meta"> <span id="mb-mb-year" class="pc-cell-year"></span> <span id="mb-mb-format" class="pc-cell-format"></span> <span id="mb-mb-label" class="pc-cell-label"></span> </span> <span class="pc-cell-master"></span> <span id="mb-mb-tracks" class="pc-cell-val" style="color: var(--mbu-warn);"></span> </div> <div class="pc-rows-sep"></div> ${PROVIDER_ORDER.map(p => ` <div id="row-${p}" class="pc-row pc-st-notfound"> <span class="pc-cell-ico"> <span id="ico-${p}" class="pc-ico-slot" style="font-size: 11px; text-align: center; color: var(--mbu-text-weak);">⚪</span> <span id="plat-${p}" class="pc-plat-ico" title="${PROVIDER_NAME[p]}">${stIcon(p, 16)}</span> </span> <a id="mb-online-${p}" class="pc-cell-name" href="#" target="_blank" rel="noopener" style="color: ${PROVIDER_COLOR[p] || 'var(--mbu-text)'};">${PROVIDER_NAME[p]}</a> <span class="pc-meta"> <span id="year-${p}" class="pc-cell-year"></span> <span id="format-${p}" class="pc-cell-format"></span> <span id="label-${p}" class="pc-cell-label"></span> </span> <span id="master-${p}" class="pc-master-slot pc-cell-master" style="cursor: default;"></span> <span id="val-${p}" class="pc-cell-val">—</span> </div>`).join('')} <div id="pc-compact-strip" class="pc-compact-strip"></div> </div> <div style="display: flex; justify-content: space-between; align-items: center; padding-top: 6px; border-top: 1px solid var(--mbu-border);"> <div style="display: flex; align-items: center; gap: 6px;"> <span id="mb-inject-btn" class="pc-icon-btn" title="Open the release editor and queue OK URLs to add · right-click: add them silently in the background · middle-click or Alt+click: add them, including the ones strict barcode/format settings withhold (with Ctrl: in the background)" style="${iconBtn}">+</span> <span id="mb-openall-btn" class="pc-icon-btn" title="Open found platform pages not yet in MB (non-circled) in new tabs" style="${iconBtn}">↗</span> <span id="mb-links-btn" class="pc-icon-btn" title="Artists & labels: the artist and label pages the matched platforms name, for the release's MusicBrainz artists and labels" style="${iconBtn} font-size: 11px; white-space: nowrap;">Artists & labels</span> </div> <div style="display: flex; align-items: center; gap: 6px;"> <span id="mb-log-open-btn" class="pc-icon-btn" title="Diagnostic log" style="${iconBtn} font-size: 11px;">log</span> <span id="mb-token-setup-btn" class="pc-icon-btn" title="Settings" style="${iconBtn}">${MBU_CFG_ICON}</span> </div> </div> `; // The diagnostic log is the shared activity log (#697); each line's platform is its // category, so the window's filters pick one platform out of a scan. const PC_LOG = mbuLog({ name: 'Platform Check', version: () => (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) || '?', header: 'Platform Check log', key: 'pc:logwin' }); if (mbuTestHooks()) window.__pcLog = PC_LOG; // the specs read the log through it // The shared components' stylesheet (the log window, the settings header, the toast), // plus the settings modal's narrow-screen rules. const pcSharedStyle = document.createElement('style'); pcSharedStyle.textContent = `${MBU_TOKENS}${MBU_UI_CSS} /* MOBILE / NARROW VIEWPORTS: the setup modal drops its fixed 420px width to fit a phone. */ @media (max-width: 640px) { #mb-provider-modal-card { width: auto !important; left: 8px !important; right: 8px !important; transform: translateY(-50%) !important; max-height: 92vh !important; overflow-y: auto !important; padding: 18px !important; box-sizing: border-box !important; } }`; // Provider toggles const providerModal = document.createElement('div'); providerModal.id = 'mb-provider-modal-overlay'; providerModal.style.cssText = 'display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: transparent; z-index: 100000; font-family: sans-serif;'; providerModal.innerHTML = ` <div id="mb-provider-modal-card" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 420px; background: var(--mbu-bg); padding: 20px 22px; border-radius: 8px; box-shadow: 0 20px 40px rgba(0,0,0,0.3); border: 1px solid var(--mbu-border); font-size: 13px; color: var(--mbu-text);"> ${mbuCfgHeader({ script: 'platform_check', name: 'Platform Check', version: (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) || '?' })} <!-- ───────── MAIN view ───────── --> <div id="mb-setup-main"> <div style="display: flex; gap: 8px; margin: 14px 0 4px;"> <button class="pc-setup-nav" id="mb-view-order" type="button" style="flex: 1; text-align: left; padding: 8px 10px; background: var(--mbu-bg-raised); border: 1px solid var(--mbu-border); border-radius: 6px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px;">⋮⋮ <b>Platforms</b><span id="mb-order-badge" style="margin-left: auto; font-size: 12px; color: var(--mbu-text-weak);">›</span></button> <button class="pc-setup-nav" id="mb-view-auth" type="button" style="flex: 1; text-align: left; padding: 8px 10px; background: var(--mbu-bg-raised); border: 1px solid var(--mbu-border); border-radius: 6px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px;">🔑 <b>Logins</b><span id="mb-auth-badge" style="margin-left: auto; font-size: 12px; color: var(--mbu-text-weak);">›</span></button> </div> <div class="pc-setup-sec">Link confidence</div> <div class="pc-set-row"><label title="When on, found links whose barcode doesn't match MB's are withheld from + / ↗ (MB treats a different barcode as a different release). A subtle amber left bar marks known mismatches regardless of this setting."><input type="checkbox" id="mb-respect-barcode"> <span>Use <b>barcodes</b></span></label> <select id="mb-barcode-mode" title="strictly: only add barcode-confirmed links (also withholds links whose barcode can't be checked, e.g. Apple/Spotify). · if they exist: only withhold links whose barcode is known and differs."><option value="exists">if they exist</option><option value="strict">strictly</option></select></div> <div class="pc-set-row"><label title="When on, found links whose format is incompatible with MB's (e.g. a Digital-only platform on a CD release) are withheld from + / ↗. Digital-only platforms (Spotify, Apple, Tidal…) count as Digital; Bandcamp/Discogs use their actual format. A subtle violet left bar marks mismatches."><input type="checkbox" id="mb-respect-format"> <span>Use <b>formats</b></span></label> <select id="mb-format-mode" title="strictly: also withhold links whose format can't be determined. · if they exist: only withhold links whose format is known and incompatible."><option value="exists">if they exist</option><option value="strict">strictly</option></select></div> <div class="pc-setup-sec">Adding links</div> <div class="pc-set-row"><label title="On: + opens the release editor in a new tab, leaving this one on the panel. Off: it navigates this tab instead. Either way, right-click on + (or a platform icon) always adds silently in a background tab that submits and closes itself."><input type="checkbox" id="mb-open-new-tab"> <span>Open the editor in a <b>new tab</b></span></label></div> <div class="pc-set-row"><label title="#556. Firefox throttles timers in a background tab - to one step per second, and worse once the tab has spent its execution budget - and MusicBrainz's submit is a chain of them. A tab that is playing audio is exempt, so this plays an inaudible tone (30Hz, gain 0.0008) for the few seconds the background-add tab is alive. Measured on the same 2-link add: 20.2s without it, 3.7s with. REQUIRES allowing audio for musicbrainz.org (padlock menu > Autoplay > Allow Audio) - without it Firefox blocks the tone and the setting does nothing, which the Log says - and it lights the speaker icon while it runs. A WebRTC loopback was tried instead and does NOT work: it connects, and changes nothing."><input type="checkbox" id="mb-bg-audio"> <span>Keep background tabs <b>awake</b></span></label></div> <div class="pc-setup-sec">Artists & labels</div> <div class="pc-set-row"><label title="On: once the scans finish, MusicBrainz is asked which of the artist and label pages it already has (one request per 100 links), and Artists & labels shows how many would be added. Off: it is asked only when you open the table."><input type="checkbox" id="mb-links-count"> <span><b>Count</b> the links to add</span></label></div> <div class="pc-set-row"><label title="On: after Run in Falcon from Artists & labels, Falcon closes its panel once the run finishes with every link added. A run with a failed or partial edit leaves it open, to read why. Send only, and a Falcon opened in a new tab, are not affected."><input type="checkbox" id="mb-links-close-falcon"> <span><b>Close Falcon</b> after a successful import</span></label></div> <div class="pc-setup-sec">Appearance</div> <div class="pc-set-grid"> <div class="pc-set-k">Icon</div> <div class="pc-set-v"><label><input type="checkbox" id="mb-show-icons"> Show</label><span class="pc-set-lbl">size</span><span class="pc-step"><button type="button" data-step="-1">−</button><input type="number" id="mb-icon-size" min="14" max="30" step="1"><button type="button" data-step="1">+</button></span></div> <div class="pc-set-k">Name</div> <div class="pc-set-v"><label><input type="checkbox" id="mb-show-names"> Show</label><span class="pc-set-lbl">size</span><span class="pc-step"><button type="button" data-step="-1">−</button><input type="number" id="mb-name-size" min="8" max="14" step="1"><button type="button" data-step="1">+</button></span></div> <div class="pc-set-k">Compact</div> <div class="pc-set-v"><label title="A provider that found nothing (or is still searching) shrinks to a dimmed icon in a strip under the rows, and rises into a full row once it finds something. Discogs and Bandcamp always stay full rows. Click a strip icon to search that platform, just like clicking its row."><input type="checkbox" id="mb-compact-unmatched"> Unmatched</label><label title="A provider that found a release which isn't a clean match (a different barcode or format, or withheld by the strict barcode/format settings) also shrinks to an icon in the strip, with an amber ring. Off: it keeps its full row, so its tracks, year and label show."><input type="checkbox" id="mb-compact-nonstrict"> Low-confidence</label></div> <div class="pc-set-k">In MusicBrainz</div> <div class="pc-set-v"><select id="mb-marker" title="How an icon shows that its link is already on the release (icons mode)"> <option value="circle">Ring</option> <option value="bold">Bold ring</option> <option value="badge">✓ badge</option> <option value="dot">Dot</option> <option value="square">Rounded square</option> </select></div> <div class="pc-set-k">Format</div> <div class="pc-set-v" title="Show each release's format as a compact 4-quadrant circle (Vinyl · Cassette · CD · Digital) or as text."><span class="pc-seg"><label><input type="radio" name="format-marker" value="circle"><span>◔ Circle</span></label><label><input type="radio" name="format-marker" value="text"><span>Text</span></label></span></div> <div class="pc-set-k">Layout</div> <div class="pc-set-v"><span class="pc-seg"><label><input type="radio" name="mb-layout" value="1row"><span>1 row</span></label><label><input type="radio" name="mb-layout" value="2row"><span>2 rows</span></label></span></div> <div class="pc-set-k">Spacing</div> <div class="pc-set-v"><span class="pc-set-lbl">rows</span><span class="pc-step"><button type="button" data-step="-1">−</button><input type="number" id="mb-row-gap" min="0" max="10" step="1"><button type="button" data-step="1">+</button></span> <span class="pc-set-lbl" title="Column spacing is only used in the 1-row layout">columns</span><span class="pc-step"><button type="button" data-step="-1">−</button><input type="number" id="mb-col-gap" min="0" max="10" step="1"><button type="button" data-step="1">+</button></span></div> </div> </div> <!-- ───────── ORDER & VISIBILITY sub-view ───────── --> <div id="mb-setup-order" style="display: none;"> <div class="pc-setup-sec" style="margin-top: 14px; display: flex; align-items: center;">Platforms<button class="pc-setup-back" type="button" style="margin-left: auto; padding: 0; background: none; border: none; color: var(--mbu-ok); font-size: 12px; font-weight: 600; text-transform: none; letter-spacing: 0; cursor: pointer;">‹ Back</button></div> <p style="font-size: 12px; color: var(--mbu-text-weak); margin: 4px 0 10px;">Drag to reorder; uncheck to skip a service. All results come from public endpoints.</p> <!-- majkinetor: "make PC platform settings more compact vertically as it will soon require scroll … also add platform icons there": two columns, read across, each platform with its icon --> <div id="mb-provider-list" style="display: grid; grid-template-columns: 1fr 1fr; gap: 2px 4px;"> ${PROVIDER_ORDER.map(p => ` <div class="pc-prov-row" data-provider="${p}" draggable="true" style="display: flex; align-items: center; min-width: 0; font-size: 13px; padding: 2px 6px; border-radius: 4px; background: var(--mbu-bg-raised); border: 1px solid transparent; cursor: grab; user-select: none;"> <span class="pc-prov-grip" style="color: var(--mbu-text-weak); font-size: 12px; margin-right: 5px; letter-spacing: -2px;" title="Drag to reorder">⋮⋮</span> <input type="checkbox" id="mb-toggle-${p}" checked style="margin: 0 7px 0 0; width: 14px; height: 14px; flex: 0 0 auto;"> <span style="display: inline-flex; flex: 0 0 auto; margin-right: 6px;">${stIcon(p, 15)}</span> <span style="font-weight: 500; flex-grow: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--mbu-text);">${PROVIDER_NAME[p]}</span> </div>`).join('')} </div> </div> <!-- ───────── AUTH sub-view ───────── --> <div id="mb-setup-auth" style="display: none;"> <div class="pc-setup-sec" style="margin-top: 14px; display: flex; align-items: center;">Authentication<button class="pc-setup-back" type="button" style="margin-left: auto; padding: 0; background: none; border: none; color: var(--mbu-ok); font-size: 12px; font-weight: 600; text-transform: none; letter-spacing: 0; cursor: pointer;">‹ Back</button></div> <div id="mb-bp-acct" style="padding: 4px;"> <div style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--mbu-text);"> <span style="font-weight: 600;">Beatport account</span> <span id="mb-bp-status" style="font-size: 11px; color: var(--mbu-text-weak); margin-left: auto;"></span> </div> <div style="font-size: 11px; color: var(--mbu-text-weak); margin: 3px 0 7px;">Optional — enables <b>verified</b> Beatport matching (and the + insert) and lets ISRC Scout import Beatport ISRCs. Only the login token is stored, never your password.</div> <div id="mb-bp-form" style="display: flex; gap: 6px; flex-wrap: wrap; align-items: center;"> <input id="mb-bp-user" type="text" placeholder="email / username" autocomplete="off" style="flex: 1 1 120px; min-width: 0; padding: 6px 8px; border: 1px solid var(--mbu-border); border-radius: 4px; font-size: 12px;"> <input id="mb-bp-pass" type="password" placeholder="password" autocomplete="off" style="flex: 1 1 100px; min-width: 0; padding: 6px 8px; border: 1px solid var(--mbu-border); border-radius: 4px; font-size: 12px;"> <button id="mb-bp-login" style="padding: 6px 12px; background: #0a8754; border: none; border-radius: 4px; color: #FFF; font-size: 12px; cursor: pointer;">Log in</button> </div> <button id="mb-bp-logout" style="display: none; padding: 5px 12px; background: var(--mbu-bg-sunken); border: none; border-radius: 4px; font-size: 12px; cursor: pointer; margin-top: 6px;">Sign out</button> </div> <div id="mb-qb-acct" style="padding: 4px; margin-top: 10px; border-top: 1px solid var(--mbu-border); padding-top: 10px;"> <div style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--mbu-text);"> <span style="font-weight: 600;">Qobuz account</span> <span id="mb-qb-status" style="font-size: 11px; color: var(--mbu-text-weak); margin-left: auto;"></span> </div> <div style="font-size: 11px; color: var(--mbu-text-weak); margin: 3px 0 7px;">Optional — unlocks Qobuz's session-gated API: <b>reliable</b> Qobuz matching here (track count + barcode via <code>album/get</code>, no store-page scrape), plus Qobuz ISRC import in ISRC Scout and roled Qobuz credits in Credit Hoarder. Only the login token is stored, never your password.</div> <div id="mb-qb-form" style="display: flex; gap: 6px; flex-wrap: wrap; align-items: center;"> <input id="mb-qb-user" type="text" placeholder="email" autocomplete="off" style="flex: 1 1 120px; min-width: 0; padding: 6px 8px; border: 1px solid var(--mbu-border); border-radius: 4px; font-size: 12px;"> <input id="mb-qb-pass" type="password" placeholder="password" autocomplete="off" style="flex: 1 1 100px; min-width: 0; padding: 6px 8px; border: 1px solid var(--mbu-border); border-radius: 4px; font-size: 12px;"> <button id="mb-qb-login" style="padding: 6px 12px; background: #0070ef; border: none; border-radius: 4px; color: #FFF; font-size: 12px; cursor: pointer;">Log in</button> </div> <button id="mb-qb-logout" style="display: none; padding: 5px 12px; background: var(--mbu-bg-sunken); border: none; border-radius: 4px; font-size: 12px; cursor: pointer; margin-top: 6px;">Sign out</button> </div> </div> </div>`; document.head.appendChild(pcSharedStyle); document.body.appendChild(providerModal); const coverArt = sidebar.querySelector('.cover-art'); if (coverArt) sidebar.insertBefore(container, coverArt); else sidebar.prepend(container); // Belt-and-suspenders: pin the panel to the sidebar's natural width *now* — measured // synchronously, before the async scans fill in long meta lines AND before the cover // art <img> finishes loading — so a content-sized sidebar can't later be stretched // wide by either. (Measuring later would risk capturing an already-widened sidebar.) const naturalW = sidebar.clientWidth; // #355: pin a STABLE width (not just a max) so the panel doesn't visibly grow/reflow // as async scan results stream longer meta lines in — the "epileptic" resize. At the // sidebar's full width the panel starts at its final size; the label column // (minmax(0,1fr) + ellipsis) absorbs long text instead of widening the box. if (naturalW > 40) { container.style.maxWidth = naturalW + 'px'; container.style.width = naturalW + 'px'; } const providerRows = Object.fromEntries(PROVIDER_ORDER.map(p => [p, document.getElementById(`row-${p}`)])); PROVIDER_ORDER.forEach(p => { const enabled = GM_getValue(`pc:prov_${p}`, true); if (providerRows[p]) providerRows[p].style.display = enabled ? '' : 'none'; // '' → CSS grid layout applies }); // platform brand icons (default on) — class on the panel hides them all via CSS container.classList.toggle('pc-icons-mode', GM_getValue('pc:show-icons', true)); container.classList.toggle('pc-compact-unmatched', GM_getValue('pc:compact-unmatched', true)); // #355 (on by default) container.classList.toggle('pc-compact-nonstrict', GM_getValue('pc:compact-nonstrict', false)); // #653 (off: a found mismatch keeps its row) container.classList.toggle('pc-no-names', !GM_getValue('pc:show-names', false)); // names hidden by default (#173) — the brand icon identifies the row // row layout — 1-row aligned grid (default) vs 2-row stacked (issue #173) container.classList.add(GM_getValue('pc:layout', '1row') === '2row' ? 'pc-layout-2row' : 'pc-layout-1row'); // How the in-MB marker is drawn (#642). The glow is retired: a stored 'glow' becomes the ring; // 'gap' was the bold ring's first name, and anything else (the dropped underline) is the ring. const PC_MARKERS = ['circle', 'bold', 'badge', 'dot', 'square']; function pcMarker() { const v = GM_getValue('pc:mb-marker', 'circle'); return v === 'gap' ? 'bold' : PC_MARKERS.includes(v) ? v : 'circle'; } if (GM_getValue('pc:mb-marker', 'circle') === 'glow') GM_setValue('pc:mb-marker', 'circle'); function pcApplyMarker(m) { for (const x of PC_MARKERS) container.classList.toggle('pc-mark-' + x, x === m); } pcApplyMarker(pcMarker()); container.classList.toggle('pc-fmt-text', GM_getValue('pc:format-marker', 'circle') === 'text'); // format as text instead of the quadrant circle // compact-view spacing + name font size (#178) — applied as CSS variables the // layout rules read (row/column gap in 1-row, row gap = flex gap in 2-row). container.style.setProperty('--pc-row-gap', `${GM_getValue('pc:row-gap', 5)}px`); container.style.setProperty('--pc-col-gap', `${GM_getValue('pc:col-gap', 5)}px`); container.style.setProperty('--pc-name-size', `${GM_getValue('pc:name-size', 12)}px`); container.style.setProperty('--pc-icon-size', `${GM_getValue('pc:icon-size', 22)}px`); // #188 platform icon size refreshCompactStrip(); // #355: start everything compact (rows rise out as they match) // Provider-reorder controls in the providers modal — drag-and-drop. Each row // is draggable; the list is two columns read across, so dragover on a sibling // reorders via the cursor's X-midpoint (left half = insert before, right half = // insert after). Save reads the // resulting DOM order, persists to pc:provider-order, and reloads so the // sidebar re-renders rows in the new sequence. { let dragged = null; const list = providerModal.querySelector('#mb-provider-list'); // Persist the modal's current row order and reorder the sidebar rows LIVE — // no page reload (#175). Moving the (display:contents) row divs reorders // their cells in the grid; row-mb + the separator stay pinned at the top. const commitProviderOrder = () => { const order = [...list.querySelectorAll('.pc-prov-row')].map(r => r.dataset.provider); GM_setValue('pc:provider-order', JSON.stringify(order)); const rowsContainer = container.querySelector('.pc-rows'); if (rowsContainer) order.forEach(p => { if (providerRows[p]) rowsContainer.appendChild(providerRows[p]); }); }; for (const row of list.querySelectorAll('.pc-prov-row')) { row.addEventListener('dragstart', e => { dragged = row; row.style.opacity = '0.4'; e.dataTransfer.effectAllowed = 'move'; // Some browsers require setData() for drag to start. try { e.dataTransfer.setData('text/plain', row.dataset.provider); } catch (_) {} }); row.addEventListener('dragend', () => { row.style.opacity = ''; for (const r of list.querySelectorAll('.pc-prov-row')) r.style.borderColor = 'transparent'; dragged = null; }); row.addEventListener('dragover', e => { if (!dragged || dragged === row) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = row.getBoundingClientRect(); const after = e.clientX > rect.left + rect.width / 2; // Visual hint: highlight which edge will receive the drop. row.style.borderColor = 'transparent'; row.style.borderLeftColor = after ? 'transparent' : 'var(--mbu-ok)'; row.style.borderRightColor = after ? 'var(--mbu-ok)' : 'transparent'; }); row.addEventListener('dragleave', () => { row.style.borderColor = 'transparent'; }); row.addEventListener('drop', e => { e.preventDefault(); if (!dragged || dragged === row) return; const rect = row.getBoundingClientRect(); const after = e.clientX > rect.left + rect.width / 2; row.style.borderColor = 'transparent'; list.insertBefore(dragged, after ? row.nextSibling : row); commitProviderOrder(); }); } } // Pin a modal to the VISUAL viewport on narrow/zoomed screens. On tablets where // MusicBrainz's desktop layout overflows the browser's layout viewport, the page // overflows and the browser zooms to fit; a position:fixed modal is then anchored // to the narrow layout viewport and lands in a corner. window.visualViewport // reflects what's actually on screen (zoom scale + offset), so we size the overlay // to it and centre the card within it. No-op on desktop / without VisualViewport. const provCardEl = () => document.getElementById('mb-provider-modal-card'); let _pcActive = null, _pcVVSync = null; // The overlay + card keep their base layout (width:100vw, top/left, the card's // centering + width) in their INLINE cssText. Pinning overrides a subset of those // with !important, so unpinning must RESTORE the base — not removeProperty(), which // would also delete the base values that share the inline declaration (that bug // collapsed the modals to a corner on desktop, where pin no-ops straight through // unpin on the first open). So snapshot cssText once and restore it verbatim. function pcSnapshotBase(el) { if (el && el._pcBaseCss == null) el._pcBaseCss = el.style.cssText; } function pcPinModal(overlay, card, maxW, fill) { pcSnapshotBase(overlay); pcSnapshotBase(card); const vv = window.visualViewport; if (!vv || !window.matchMedia('(max-width: 640px)').matches) { pcUnpinModal(overlay, card); return; } const imp = (el, o) => { for (const k in o) el.style.setProperty(k, o[k], 'important'); }; const w = Math.min(vv.width * 0.96, maxW), h = vv.height * 0.96; imp(overlay, { left: vv.offsetLeft + 'px', top: vv.offsetTop + 'px', width: vv.width + 'px', height: vv.height + 'px', padding: '0' }); const c = { position: 'fixed', left: (vv.offsetLeft + vv.width / 2) + 'px', top: (vv.offsetTop + vv.height / 2) + 'px', transform: 'translate(-50%, -50%)', margin: '0', 'max-width': 'none', width: w + 'px', 'max-height': h + 'px' }; if (fill) c.height = h + 'px'; imp(card, c); } function pcUnpinModal(overlay, card) { if (overlay && overlay._pcBaseCss != null) overlay.style.cssText = overlay._pcBaseCss; if (card && card._pcBaseCss != null) card.style.cssText = card._pcBaseCss; } function pcOpenModal(overlay, card, maxW, fill) { overlay.style.display = 'block'; _pcActive = { overlay, card, maxW, fill }; pcPinModal(overlay, card, maxW, fill); if (window.visualViewport && !_pcVVSync) { _pcVVSync = () => { if (_pcActive) pcPinModal(_pcActive.overlay, _pcActive.card, _pcActive.maxW, _pcActive.fill); }; window.visualViewport.addEventListener('resize', _pcVVSync); window.visualViewport.addEventListener('scroll', _pcVVSync); } } const closeAllModals = () => { if (_pcVVSync && window.visualViewport) { window.visualViewport.removeEventListener('resize', _pcVVSync); window.visualViewport.removeEventListener('scroll', _pcVVSync); _pcVVSync = null; } // restore the base cssText FIRST (it carries display:block from the snapshot), // then force display:none last so the modal actually ends up hidden pcUnpinModal(providerModal, provCardEl()); providerModal.style.display = 'none'; _pcActive = null; }; providerModal.addEventListener('click', e => { if (!document.getElementById('mb-provider-modal-card').contains(e.target)) closeAllModals(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAllModals(); }); document.getElementById('mb-log-open-btn').addEventListener('click', () => { if (PC_LOG.isOpen()) PC_LOG.close(); else PC_LOG.open(); }); // Beatport-account UI state (logged in → show "Sign out"; logged out → show login form) // the counts on the Platforms and Logins buttons function pcSetupBadges() { const o = document.getElementById('mb-order-badge'), a = document.getElementById('mb-auth-badge'); if (o) o.textContent = `${PROVIDER_ORDER.filter(p => GM_getValue(`pc:prov_${p}`, true)).length} on ›`; if (a) a.textContent = `${[bpLoggedIn(), qbLoggedIn()].filter(Boolean).length} of 2 ›`; } function bpRefreshSetupUI(msg, isErr) { const st = document.getElementById('mb-bp-status'), form = document.getElementById('mb-bp-form'), out = document.getElementById('mb-bp-logout'); if (!st) return; const inOk = bpLoggedIn(); pcSetupBadges(); form.style.display = inOk ? 'none' : 'flex'; out.style.display = inOk ? 'inline-block' : 'none'; st.textContent = msg || (inOk ? 'signed in' : 'not signed in'); st.style.color = isErr ? 'var(--mbu-error)' : (inOk ? 'var(--mbu-ok)' : 'var(--mbu-text-weak)'); } document.getElementById('mb-bp-login').addEventListener('click', async () => { const u = document.getElementById('mb-bp-user').value.trim(), p = document.getElementById('mb-bp-pass').value; if (!u || !p) { bpRefreshSetupUI('enter email + password', true); return; } bpRefreshSetupUI('signing in…'); const r = await beatportLogin(u, p); document.getElementById('mb-bp-pass').value = ''; bpRefreshSetupUI(r.ok ? 'signed in ✓ — ↻ to re-scan Beatport' : `failed: ${r.error}`, !r.ok); }); document.getElementById('mb-bp-logout').addEventListener('click', () => { bpWrite(null); document.getElementById('mb-bp-user').value = ''; bpRefreshSetupUI('signed out'); }); // Qobuz-account UI state (mirrors Beatport) function qbRefreshSetupUI(msg, isErr) { const st = document.getElementById('mb-qb-status'), form = document.getElementById('mb-qb-form'), out = document.getElementById('mb-qb-logout'); if (!st) return; const inOk = qbLoggedIn(); pcSetupBadges(); form.style.display = inOk ? 'none' : 'flex'; out.style.display = inOk ? 'inline-block' : 'none'; st.textContent = msg || (inOk ? 'signed in' : 'not signed in'); st.style.color = isErr ? 'var(--mbu-error)' : (inOk ? 'var(--mbu-ok)' : 'var(--mbu-text-weak)'); } document.getElementById('mb-qb-login').addEventListener('click', async () => { const u = document.getElementById('mb-qb-user').value.trim(), p = document.getElementById('mb-qb-pass').value; if (!u || !p) { qbRefreshSetupUI('enter email + password', true); return; } qbRefreshSetupUI('signing in…'); const r = await qobuzLogin(u, p); document.getElementById('mb-qb-pass').value = ''; qbRefreshSetupUI(r.ok ? 'signed in ✓ — ↻ to re-scan Qobuz here; ISRC Scout & Credit Hoarder can use it too' : `failed: ${r.error}`, !r.ok); }); document.getElementById('mb-qb-logout').addEventListener('click', () => { qbWrite(null); document.getElementById('mb-qb-user').value = ''; qbRefreshSetupUI('signed out'); }); document.getElementById('mb-token-setup-btn').addEventListener('click', () => { PROVIDER_ORDER.forEach(p => { document.getElementById(`mb-toggle-${p}`).checked = GM_getValue(`pc:prov_${p}`, true); }); document.getElementById('mb-show-icons').checked = GM_getValue('pc:show-icons', true); document.getElementById('mb-show-names').checked = GM_getValue('pc:show-names', false); document.getElementById('mb-respect-barcode').checked = GM_getValue('pc:respect-barcode', true); document.getElementById('mb-barcode-mode').value = GM_getValue('pc:barcode-mode', 'strict'); document.getElementById('mb-barcode-mode').disabled = !GM_getValue('pc:respect-barcode', true); document.getElementById('mb-respect-format').checked = GM_getValue('pc:respect-format', true); document.getElementById('mb-format-mode').value = GM_getValue('pc:format-mode', 'strict'); document.getElementById('mb-format-mode').disabled = !GM_getValue('pc:respect-format', true); document.getElementById('mb-open-new-tab').checked = GM_getValue('pc:open-new-tab', true); document.getElementById('mb-bg-audio').checked = GM_getValue('pc:bg-audio', false); document.getElementById('mb-links-count').checked = GM_getValue('pc:links-count', true); document.getElementById('mb-links-close-falcon').checked = GM_getValue('pc:links-close-falcon', true); const layout = GM_getValue('pc:layout', '1row'); providerModal.querySelectorAll('input[name="mb-layout"]').forEach(r => { r.checked = r.value === layout; }); document.getElementById('mb-compact-unmatched').checked = GM_getValue('pc:compact-unmatched', true); document.getElementById('mb-compact-nonstrict').checked = GM_getValue('pc:compact-nonstrict', false); providerModal.querySelector('#mb-marker').value = pcMarker(); const fmtMarkerMode = GM_getValue('pc:format-marker', 'circle'); providerModal.querySelectorAll('input[name="format-marker"]').forEach(r => { r.checked = r.value === fmtMarkerMode; }); bpRefreshSetupUI(); qbRefreshSetupUI(); pcSetupSync(); showSetupView('main'); // #188 always open on the main view pcOpenModal(providerModal, provCardEl(), 440, false); }); // #188 setup is split into a compact main view + "Order & visibility" and "Auth" // sub-views that replace the card content, each with a ‹ Back to the main view. function showSetupView(name) { ['main', 'order', 'auth'].forEach(v => { const el = document.getElementById('mb-setup-' + v); if (el) el.style.display = v === name ? '' : 'none'; }); } document.getElementById('mb-view-order').addEventListener('click', () => showSetupView('order')); document.getElementById('mb-view-auth').addEventListener('click', () => showSetupView('auth')); providerModal.querySelectorAll('.pc-setup-back').forEach(b => b.addEventListener('click', () => showSetupView('main'))); // Real-time config (#175): each control applies + persists the instant it // changes — no Save button, no reload, and the sidebar updates live behind the // (now backdrop-free) settings card. PROVIDER_ORDER.forEach(p => { document.getElementById(`mb-toggle-${p}`).addEventListener('change', e => { GM_setValue(`pc:prov_${p}`, e.target.checked); pcSetupBadges(); if (providerRows[p]) providerRows[p].style.display = e.target.checked ? '' : 'none'; }); }); document.getElementById('mb-show-icons').addEventListener('change', e => { GM_setValue('pc:show-icons', e.target.checked); container.classList.toggle('pc-icons-mode', e.target.checked); }); document.getElementById('mb-compact-unmatched').addEventListener('change', e => { GM_setValue('pc:compact-unmatched', e.target.checked); // #355 container.classList.toggle('pc-compact-unmatched', e.target.checked); refreshCompactStrip(); }); document.getElementById('mb-compact-nonstrict').addEventListener('change', e => { GM_setValue('pc:compact-nonstrict', e.target.checked); // #653 container.classList.toggle('pc-compact-nonstrict', e.target.checked); refreshCompactStrip(); }); document.getElementById('mb-show-names').addEventListener('change', e => { GM_setValue('pc:show-names', e.target.checked); container.classList.toggle('pc-no-names', !e.target.checked); }); document.getElementById('mb-respect-barcode').addEventListener('change', e => { GM_setValue('pc:respect-barcode', e.target.checked); // (#182) gate + / ↗ on barcode match document.getElementById('mb-barcode-mode').disabled = !e.target.checked; }); document.getElementById('mb-barcode-mode').addEventListener('change', e => { GM_setValue('pc:barcode-mode', e.target.value); // 'exists' (known mismatch only) | 'strict' (also unconfirmable) }); document.getElementById('mb-respect-format').addEventListener('change', e => { GM_setValue('pc:respect-format', e.target.checked); // (#182) gate + / ↗ on format compatibility document.getElementById('mb-format-mode').disabled = !e.target.checked; }); document.getElementById('mb-format-mode').addEventListener('change', e => { GM_setValue('pc:format-mode', e.target.value); // 'exists' (known incompatible only) | 'strict' (also undeterminable) }); document.getElementById('mb-links-count').addEventListener('change', e => { GM_setValue('pc:links-count', e.target.checked); // #671 — count the links to add once the scans finish if (e.target.checked) pcCountLinks(); else pcShowLinksCount(null); }); document.getElementById('mb-links-close-falcon').addEventListener('change', e => { GM_setValue('pc:links-close-falcon', e.target.checked); // Falcon closes its panel after a Run with nothing failed }); document.getElementById('mb-open-new-tab').addEventListener('change', e => { GM_setValue('pc:open-new-tab', e.target.checked); // #464 — off navigates the same tab instead of opening one }); document.getElementById('mb-bg-audio').addEventListener('change', e => { // key kept as pc:bg-audio although it now means "keep awake by any means": // renaming it would silently switch the setting back OFF for anyone who has // already turned it on. GM_setValue('pc:bg-audio', e.target.checked); // #556 — WebRTC loopback (or a tone) so Firefox stops throttling the background-add tab }); providerModal.querySelectorAll('input[name="mb-layout"]').forEach(r => r.addEventListener('change', () => { const layout = (providerModal.querySelector('input[name="mb-layout"]:checked') || {}).value || '1row'; GM_setValue('pc:layout', layout); container.classList.toggle('pc-layout-1row', layout !== '2row'); container.classList.toggle('pc-layout-2row', layout === '2row'); })); providerModal.querySelector('#mb-marker').addEventListener('change', (e) => { const marker = PC_MARKERS.includes(e.target.value) ? e.target.value : 'circle'; GM_setValue('pc:mb-marker', marker); pcApplyMarker(marker); }); providerModal.querySelectorAll('input[name="format-marker"]').forEach(r => r.addEventListener('change', () => { const mode = (providerModal.querySelector('input[name="format-marker"]:checked') || {}).value || 'circle'; GM_setValue('pc:format-marker', mode); container.classList.toggle('pc-fmt-text', mode === 'text'); // live — both are already in the DOM, CSS just swaps which shows })); // A size is greyed out while its icon or name is hidden, and the column spacing in the // 2-row layout, where it isn't used. function pcSetupSync() { const off = (id, on) => document.getElementById(id).closest('.pc-step').classList.toggle('pc-off', !on); off('mb-icon-size', document.getElementById('mb-show-icons').checked); off('mb-name-size', document.getElementById('mb-show-names').checked); off('mb-col-gap', (providerModal.querySelector('input[name="mb-layout"]:checked') || {}).value !== '2row'); } ['mb-show-icons', 'mb-show-names'].forEach(id => document.getElementById(id).addEventListener('change', pcSetupSync)); providerModal.querySelectorAll('input[name="mb-layout"]').forEach(r => r.addEventListener('change', pcSetupSync)); // − / + beside a number: one step, kept within its min and max, applied as typing would be providerModal.querySelectorAll('.pc-step button').forEach(b => b.addEventListener('click', () => { const el = b.parentElement.querySelector('input'); if (b.dataset.step === '1') el.stepUp(); else el.stepDown(); el.dispatchEvent(new Event('input', { bubbles: true })); })); // Compact-view sizes and spacing (#178): set the matching CSS variable live + persist. [['mb-row-gap', 'pc:row-gap', '--pc-row-gap', 5], ['mb-col-gap', 'pc:col-gap', '--pc-col-gap', 5], ['mb-name-size', 'pc:name-size', '--pc-name-size', 12], ['mb-icon-size', 'pc:icon-size', '--pc-icon-size', 22]].forEach(([id, key, prop, def]) => { const el = document.getElementById(id); const valEl = document.getElementById(`${id}-val`); const apply = v => { container.style.setProperty(prop, `${v}px`); if (valEl) valEl.textContent = String(v); }; el.value = GM_getValue(key, def); apply(el.value); el.addEventListener('input', () => { const v = Math.min(Number(el.max), Math.max(Number(el.min), Math.round(Number(el.value)))); if (!Number.isFinite(v) || el.value === '') return; // mid-typing GM_setValue(key, v); apply(v); }); el.addEventListener('change', () => { el.value = GM_getValue(key, def); }); // a typed value out of range snaps back }); // Every line goes through here, its platform as the line's category (#697). function appendLog(platform, msg, kind = 'info') { PC_LOG.cat(platform).add(kind, msg); } appendLog('System', mbuStartupInfo('Platform Check') + ' — startup'); // #556: the background add's stage timeline, surfaced HERE — see pcMark. The // tab it happens in closes itself and this page reloads underneath it, so the // console it was written to is unreachable by the time anyone would look. // Drained from localStorage on mount (which is the reload right after a // commit), and streamed live over the channel while a run is in progress. { const seen = new Set(); const show = (e) => { if (!e || !e.id || seen.has(e.id)) return; seen.add(e.id); const kind = /DISABLED|not found/.test(e.line) ? 'warn' : /edit committed/.test(e.line) ? 'ok' : 'info'; appendLog('System', 'background add: ' + e.line, kind); }; try { (JSON.parse(localStorage.getItem('pc:bg-log') || '[]') || []).forEach(show); } catch (e) {} if (PC_CHANNEL) { try { PC_CHANNEL.addEventListener('message', (ev) => { if (ev && ev.data && ev.data.type === 'pc-bg-log') show(ev.data.entry); }); } catch (e) {} } } // ─── GM_xmlhttpRequest wrapper that returns a Promise ────────────────────── // MusicBrainz's /ws/2 goes through the shared request gate (#633): its slot first, and its // hold after a 429/503 (one retry, which waits the hold out); everything else goes straight. const pcGateLog = (lv, m) => appendLog('MusicBrainz', m, lv === 'warn' ? 'warn' : undefined); // A cancelled scan (#674: a barcode picked or pasted mid-scan) is dropped where it waits: every // request it has in flight is aborted and never answers, so the scan's code never runs on to // write a row the next scan owns. A request is the scan's when it was made while one ran; it // carries that scan's number (PC_SCAN.n), and one made after the cancel by a scan still on its // way to the network (behind the MusicBrainz gate) is never sent. const PC_SCAN = { n: 0, busy: false, live: new Set() }; const pcScanOf = () => (PC_SCAN.busy ? PC_SCAN.n : null); const pcStale = n => n != null && n !== PC_SCAN.n; function pcCancelScan() { PC_SCAN.n++; PC_SCAN.live.forEach(req => { try { req.abort(); } catch (e) {} }); PC_SCAN.live.clear(); // what the scans share would wait on a dropped request for good: asked again instead // (a token or session already fetched stays: _appleTok, _sdKey; Amazon's is asked again) _appleTokP = null; _sdKeyP = null; _amzSession = null; searchChain = Promise.resolve(); } // GM_xmlhttpRequest for a scan's request: resolve() is never called once the scan is cancelled function pcGmRequest(opts, resolve, n) { if (pcStale(n)) return; let req = null; const done = f => (...a) => { PC_SCAN.live.delete(req); if (!pcStale(n)) resolve(f(...a)); }; opts.onload = done(opts.onload); opts.onerror = done(opts.onerror); opts.ontimeout = done(opts.ontimeout); req = GM_xmlhttpRequest(opts); if (n != null && req && req.abort) PC_SCAN.live.add(req); } async function gmGet(url, o) { if (!mbnGated(url)) return gmGetRaw(url, o); const n = o && 'scan' in o ? o.scan : pcScanOf(); // { scan: null }: not a scan's, never dropped for (let attempt = 1; ; attempt++) { await mbnSlot({ label: '/ws/2/' + url.split('/ws/2/').pop(), log: pcGateLog }); const r = await gmGetRaw(url, { ...o, scan: n }); const a = await mbnAnswer(r.status, n => mbnRawHeader(r.responseHeaders, n), { log: pcGateLog }); if (!a.throttled || attempt >= 2) return r; } } function gmGetRaw(url, { responseType, headers, timeout = 15000, anonymous, scan = pcScanOf() } = {}) { return new Promise((resolve) => { const t0 = Date.now(); const opts = { method: 'GET', url, headers: { // MB's API rejects bare "Mozilla/5.0" with 403; it expects an app // string. DDG's anti-bot meanwhile rejects detailed Chrome UAs — // searchWeb() overrides to "Mozilla/5.0" for its calls only. 'User-Agent': 'PlatformCheck/12 (https://github.com/majkinetor/mb-userscripts)', 'Accept-Language': 'en-US,en;q=0.9', ...headers, }, timeout, onload(res) { const ms = Date.now() - t0; return { ok: res.status >= 200 && res.status < 400, status: res.status, finalUrl: res.finalUrl || url, responseText: res.responseText || '', responseHeaders: res.responseHeaders || '', ms }; }, onerror(err) { return { ok: false, status: 0, finalUrl: url, responseText: '', error: String(err?.error || err?.statusText || err), ms: Date.now() - t0 }; }, ontimeout() { return { ok: false, status: 0, finalUrl: url, responseText: '', error: 'timeout', ms: Date.now() - t0 }; }, }; if (responseType) opts.responseType = responseType; if (anonymous) opts.anonymous = true; // omit the browser's ambient cookies pcGmRequest(opts, resolve, scan); }); } // POST counterpart of gmGet, used for the Tidal client-credentials token grant. function gmPost(url, data, { headers, timeout = 15000, anonymous } = {}) { const scan = pcScanOf(); return new Promise((resolve) => { const t0 = Date.now(); const opts = { method: 'POST', url, data, headers: { 'Accept-Language': 'en-US,en;q=0.9', ...headers }, timeout, onload(res) { return { ok: res.status >= 200 && res.status < 400, status: res.status, finalUrl: res.finalUrl || url, responseText: res.responseText || '', responseHeaders: res.responseHeaders || '', ms: Date.now() - t0 }; }, onerror(err) { return { ok: false, status: 0, responseText: '', error: String(err?.error || err?.statusText || err), ms: Date.now() - t0 }; }, ontimeout() { return { ok: false, status: 0, responseText: '', error: 'timeout', ms: Date.now() - t0 }; }, }; if (anonymous) opts.anonymous = true; pcGmRequest(opts, resolve, scan); }); } // ─── Tidal API (client-credentials app token; baked-in shared installed app) ── const TIDAL = { clientId: 'cRhhDJDpYXXBn82U', clientSecret: 'K7UX40jDOZ5p4y4JMYZgoiwKi7jymTHWcLMb4gkewKs=', tokenUrl: 'https://auth.tidal.com/v1/oauth2/token', api: 'https://openapi.tidal.com/v2', country: 'US', }; let _tidalTok = null, _tidalTokExp = 0; async function tidalToken() { if (_tidalTok && Date.now() < _tidalTokExp - 60000) return _tidalTok; const basic = btoa(`${TIDAL.clientId}:${TIDAL.clientSecret}`); const r = await gmPost(TIDAL.tokenUrl, 'grant_type=client_credentials', { headers: { 'Authorization': `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' } }); if (!r.ok) { appendLog('Tidal', `token grant failed (${r.status})`, 'error'); return null; } try { const j = JSON.parse(r.responseText); if (!j.access_token) { appendLog('Tidal', `token grant: no access_token`, 'error'); return null; } _tidalTok = j.access_token; _tidalTokExp = Date.now() + ((j.expires_in || 14400) * 1000); return _tidalTok; } catch (e) { appendLog('Tidal', `token JSON parse: ${e.message}`, 'error'); return null; } } // ── Beatport API auth (PKCE; token shared with ISRC Scout via musicbrainz.org localStorage) ── // Beatport's catalog API needs an OAuth2 Bearer. No app registration exists, but the web // app's *public* client_id + the user's own login (PKCE authorization_code) yields a token // that returns track counts — enabling verified matches and the + insert. We store ONLY the // tokens (never the password), renew silently via the refresh token, and share them with // ISRC Scout through a localStorage key on this origin (config/login lives here in PC). const BEATPORT = { clientId: '0GIvkCltVIuPkkwSJHp6NDb3s0potTjLBQr388Dd', // Beatport web-app client (public, from their docs JS) redirect: 'https://api.beatport.com/v4/auth/o/post-message/', api: 'https://api.beatport.com/v4', origin: 'https://api.beatport.com', store: 'mbtools:beatport', }; // Beatport's API runs Django CSRF protection that engages whenever a valid // beatport.com session cookie rides along. GM_xmlhttpRequest shares the browser // cookie jar, so any lingering beatport.com session (from a prior login on this // browser) makes Django session-authenticate the request and then demand a CSRF // token it can't supply — failing the login. Rather than read/echo that cookie // (GM_cookie is domain-restricted to the page origin on some managers and can't // see beatport.com at all), the login flow runs the requests `anonymous` (no // ambient cookies) and threads the fresh session it creates itself — so the // outcome never depends on the browser's existing Beatport state. // Static origin/referer still need to be the API's OWN origin (Django always // trusts a request's own origin; www.beatport.com is NOT in its trusted set). const bpHeaders = () => ({ Referer: BEATPORT.origin + '/', Origin: BEATPORT.origin }); const bpRead = () => { try { return JSON.parse(localStorage.getItem(BEATPORT.store) || 'null'); } catch { return null; } }; const bpWrite = t => { try { t ? localStorage.setItem(BEATPORT.store, JSON.stringify(t)) : localStorage.removeItem(BEATPORT.store); } catch {} }; const bpLoggedIn = () => { const t = bpRead(); return !!(t && t.refresh_token); }; const bpB64url = buf => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); async function bpPkce() { const v = bpB64url(crypto.getRandomValues(new Uint8Array(48))); const c = bpB64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(v))); return { v, c }; } // Full login → token. Returns { ok } or { ok:false, error }. Used by PC's setup UI. // Two cases, both ending at a non-anonymous authorize GET that consumes a // session cookie from the shared jar: // • Fresh browser — the normal login POST mints that session in the jar. // • Browser already holds a beatport.com session — that ambient cookie makes // Django enforce CSRF on the login POST (which we can't satisfy: GM_cookie // is domain-restricted and Set-Cookie is hidden on some managers). But the // existing session is itself valid and is accepted by the authorize GET, so // we just validate the typed credentials out-of-band (anonymous → no // session → no CSRF) and reuse that session. async function beatportLogin(username, password) { let lr = await gmPost(`${BEATPORT.api}/auth/login/`, JSON.stringify({ username, password }), { headers: { 'Content-Type': 'application/json', ...bpHeaders() } }); if (!lr.ok && /csrf/i.test(lr.responseText || '')) { appendLog('Beatport', 'login blocked by an existing browser Beatport session — validating credentials and reusing that session…'); lr = await gmPost(`${BEATPORT.api}/auth/login/`, JSON.stringify({ username, password }), { anonymous: true, headers: { 'Content-Type': 'application/json', ...bpHeaders() } }); } if (!lr.ok) { let m = 'incorrect username or password'; try { const j = JSON.parse(lr.responseText); m = (typeof j === 'string' ? j : j.detail) || m; } catch {} appendLog('Beatport', `login rejected — HTTP ${lr.status}: ${m}`, 'error'); return { ok: false, error: m }; } const { v, c } = await bpPkce(); const au = `${BEATPORT.api}/auth/o/authorize/?response_type=code&client_id=${BEATPORT.clientId}&redirect_uri=${encodeURIComponent(BEATPORT.redirect)}&code_challenge=${c}&code_challenge_method=S256`; const ar = await gmGet(au, { headers: bpHeaders() }); // non-anonymous: uses the jar session (fresh-from-login or existing browser) const code = ((ar.finalUrl || '').match(/[?&#]code=([^&]+)/) || [])[1]; if (!code) { appendLog('Beatport', `authorize returned no code — HTTP ${ar.status}; no usable Beatport session in the browser. Log in at beatport.com in this browser, then retry.`, 'error'); return { ok: false, error: 'no authorization code returned' }; } const tr = await gmPost(`${BEATPORT.api}/auth/o/token/`, new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: BEATPORT.redirect, client_id: BEATPORT.clientId, code_verifier: v }).toString(), { anonymous: true, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...bpHeaders() } }); let j; try { j = JSON.parse(tr.responseText); } catch { appendLog('Beatport', `token exchange — unparseable response (HTTP ${tr.status})`, 'error'); return { ok: false, error: 'token response parse failed' }; } if (!j.access_token) { const e = j.error_description || j.error || 'no access token'; appendLog('Beatport', `token exchange failed — ${e}`, 'error'); return { ok: false, error: e }; } bpWrite({ access_token: j.access_token, refresh_token: j.refresh_token, exp: Date.now() + ((j.expires_in || 36000) * 1000) }); appendLog('Beatport', `logged in (token scope: ${j.scope || '?'})`, 'ok'); return { ok: true }; } // Valid access token, renewing via refresh_token when needed; null if not logged in / refresh failed. async function beatportToken() { const t = bpRead(); if (!t || !t.refresh_token) return null; if (t.access_token && Date.now() < t.exp - 60000) return t.access_token; const tr = await gmPost(`${BEATPORT.api}/auth/o/token/`, new URLSearchParams({ grant_type: 'refresh_token', refresh_token: t.refresh_token, client_id: BEATPORT.clientId }).toString(), { anonymous: true, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...bpHeaders() } }); let j; try { j = JSON.parse(tr.responseText); } catch { j = {}; } if (!j.access_token) { appendLog('Beatport', 'token refresh failed — log in again in ⚙ setup', 'warn'); return null; } bpWrite({ access_token: j.access_token, refresh_token: j.refresh_token || t.refresh_token, exp: Date.now() + ((j.expires_in || 36000) * 1000) }); return j.access_token; } // ── Qobuz API auth (email + password login; token shared with ISRC Scout + Credit Hoarder) ── // Qobuz's per-track data (ISRCs + roled `performers`) lives behind `album/get`, which is // session-gated — anonymous requests 404/401 regardless of app_id (see #353). A logged-in // user_auth_token unlocks it. We log in with the user's own credentials, store ONLY the token // (never the password), and share it — like the Beatport token — via a localStorage key on this // origin, so ISRC Scout (ISRCs) and Credit Hoarder (roled credits) can read it without re-auth. const QOBUZ = { appId: '712109809', // anonymous web-player app_id (accepted for login + album/get with a token) api: 'https://www.qobuz.com/api.json/0.2', store: 'mbtools:qobuz', }; const qbRead = () => { try { return JSON.parse(localStorage.getItem(QOBUZ.store) || 'null'); } catch { return null; } }; const qbWrite = t => { try { t ? localStorage.setItem(QOBUZ.store, JSON.stringify(t)) : localStorage.removeItem(QOBUZ.store); } catch {} }; const qbLoggedIn = () => { const t = qbRead(); return !!(t && t.token); }; // Compact MD5 (public-domain, Joseph Myers) — Qobuz's user/login wants the password as an MD5 hex // digest, which SubtleCrypto can't produce. Only used to sign the login request; nothing is stored. function md5(str) { function rl(n, c) { return (n << c) | (n >>> (32 - c)); } function au(x, y) { const l = (x & 0xFFFF) + (y & 0xFFFF); return (((x >> 16) + (y >> 16) + (l >> 16)) << 16) | (l & 0xFFFF); } function cmn(q, a, b, x, s, t) { return au(rl(au(au(a, q), au(x, t)), s), b); } const ff = (a, b, c, d, x, s, t) => cmn((b & c) | (~b & d), a, b, x, s, t); const gg = (a, b, c, d, x, s, t) => cmn((b & d) | (c & ~d), a, b, x, s, t); const hh = (a, b, c, d, x, s, t) => cmn(b ^ c ^ d, a, b, x, s, t); const ii = (a, b, c, d, x, s, t) => cmn(c ^ (b | ~d), a, b, x, s, t); const bytes = new TextEncoder().encode(str), n = bytes.length, x = []; // UTF-8 for (let i = 0; i < n; i++) x[i >> 2] |= bytes[i] << ((i % 4) * 8); x[n >> 2] |= 0x80 << ((n % 4) * 8); x[(((n + 8) >> 6) * 16) + 14] = n * 8; let a = 1732584193, b = -271733879, c = -1732584194, d = 271733878; for (let i = 0; i < x.length; i += 16) { const oa = a, ob = b, oc = c, od = d, g = j => x[i + j] | 0; a = ff(a,b,c,d,g(0),7,-680876936); d = ff(d,a,b,c,g(1),12,-389564586); c = ff(c,d,a,b,g(2),17,606105819); b = ff(b,c,d,a,g(3),22,-1044525330); a = ff(a,b,c,d,g(4),7,-176418897); d = ff(d,a,b,c,g(5),12,1200080426); c = ff(c,d,a,b,g(6),17,-1473231341); b = ff(b,c,d,a,g(7),22,-45705983); a = ff(a,b,c,d,g(8),7,1770035416); d = ff(d,a,b,c,g(9),12,-1958414417); c = ff(c,d,a,b,g(10),17,-42063); b = ff(b,c,d,a,g(11),22,-1990404162); a = ff(a,b,c,d,g(12),7,1804603682); d = ff(d,a,b,c,g(13),12,-40341101); c = ff(c,d,a,b,g(14),17,-1502002290); b = ff(b,c,d,a,g(15),22,1236535329); a = gg(a,b,c,d,g(1),5,-165796510); d = gg(d,a,b,c,g(6),9,-1069501632); c = gg(c,d,a,b,g(11),14,643717713); b = gg(b,c,d,a,g(0),20,-373897302); a = gg(a,b,c,d,g(5),5,-701558691); d = gg(d,a,b,c,g(10),9,38016083); c = gg(c,d,a,b,g(15),14,-660478335); b = gg(b,c,d,a,g(4),20,-405537848); a = gg(a,b,c,d,g(9),5,568446438); d = gg(d,a,b,c,g(14),9,-1019803690); c = gg(c,d,a,b,g(3),14,-187363961); b = gg(b,c,d,a,g(8),20,1163531501); a = gg(a,b,c,d,g(13),5,-1444681467); d = gg(d,a,b,c,g(2),9,-51403784); c = gg(c,d,a,b,g(7),14,1735328473); b = gg(b,c,d,a,g(12),20,-1926607734); a = hh(a,b,c,d,g(5),4,-378558); d = hh(d,a,b,c,g(8),11,-2022574463); c = hh(c,d,a,b,g(11),16,1839030562); b = hh(b,c,d,a,g(14),23,-35309556); a = hh(a,b,c,d,g(1),4,-1530992060); d = hh(d,a,b,c,g(4),11,1272893353); c = hh(c,d,a,b,g(7),16,-155497632); b = hh(b,c,d,a,g(10),23,-1094730640); a = hh(a,b,c,d,g(13),4,681279174); d = hh(d,a,b,c,g(0),11,-358537222); c = hh(c,d,a,b,g(3),16,-722521979); b = hh(b,c,d,a,g(6),23,76029189); a = hh(a,b,c,d,g(9),4,-640364487); d = hh(d,a,b,c,g(12),11,-421815835); c = hh(c,d,a,b,g(15),16,530742520); b = hh(b,c,d,a,g(2),23,-995338651); a = ii(a,b,c,d,g(0),6,-198630844); d = ii(d,a,b,c,g(7),10,1126891415); c = ii(c,d,a,b,g(14),15,-1416354905); b = ii(b,c,d,a,g(5),21,-57434055); a = ii(a,b,c,d,g(12),6,1700485571); d = ii(d,a,b,c,g(3),10,-1894986606); c = ii(c,d,a,b,g(10),15,-1051523); b = ii(b,c,d,a,g(1),21,-2054922799); a = ii(a,b,c,d,g(8),6,1873313359); d = ii(d,a,b,c,g(15),10,-30611744); c = ii(c,d,a,b,g(6),15,-1560198380); b = ii(b,c,d,a,g(13),21,1309151649); a = ii(a,b,c,d,g(4),6,-145523070); d = ii(d,a,b,c,g(11),10,-1120210379); c = ii(c,d,a,b,g(2),15,718787259); b = ii(b,c,d,a,g(9),21,-343485551); a = au(a, oa); b = au(b, ob); c = au(c, oc); d = au(d, od); } const hex = n => { let s = ''; for (let i = 0; i < 4; i++) s += ((n >> (i * 8 + 4)) & 0xF).toString(16) + ((n >> (i * 8)) & 0xF).toString(16); return s; }; return hex(a) + hex(b) + hex(c) + hex(d); } // Full login → { ok } or { ok:false, error }. Stores only the user_auth_token (+ app_id) on success. async function qobuzLogin(email, password) { const url = `${QOBUZ.api}/user/login?email=${encodeURIComponent(email)}&password=${md5(password)}&app_id=${QOBUZ.appId}`; const r = await gmGet(url, { headers: { 'X-App-Id': QOBUZ.appId, Accept: 'application/json' } }); let j; try { j = JSON.parse(r.responseText || 'null'); } catch { j = null; } const tok = j && j.user_auth_token; if (!tok) { const m = (j && (j.message || j.code)) || (r.status === 401 ? 'invalid email or password' : `HTTP ${r.status}`); appendLog('Qobuz', `login rejected — ${m}`, 'error'); return { ok: false, error: String(m) }; } qbWrite({ token: tok, appId: QOBUZ.appId }); appendLog('Qobuz', 'logged in — token stored for ISRC Scout + Credit Hoarder', 'ok'); return { ok: true }; } async function beatportApi(path) { const tok = await beatportToken(); if (!tok) return null; const r = await gmGet(`${BEATPORT.api}${path}`, { headers: { Authorization: `Bearer ${tok}` } }); appendLog('Beatport', `API ${path}: status=${r.status} ${(r.responseText || '').length}b in ${r.ms}ms`, r.ok ? undefined : 'warn'); if (!r.ok) return null; try { return JSON.parse(r.responseText); } catch (e) { appendLog('Beatport', `API ${path}: JSON parse error: ${e.message}`, 'error'); return null; } } // ─── UI updater ──────────────────────────────────────────────────────────── // `source` is the original origin of the URL ('MB rels' / 'Wikidata' / 'search' / // 'native' / 'API search'). `fromCache` is independent — true when this row was // resolved from the local cache, regardless of which path originally found it. // Two visual signals get layered: // colour tint — green when fromCache=false, steel-blue when fromCache=true // circled icon — when source includes 'MB rels' (the URL was put there by // an MB editor, not discovered by us) // The MB release's barcode for the current scan (#182) — set by runScans so // updateRow can compare a found item's barcode against it. let MB_BARCODE = null; // #673: the release's own barcode, without one pasted on the page (MB_BARCODE has that too) let MB_OWN_BARCODE = null; // #674: platform → { code, format } for each match that reports a barcode const PC_FOUND_BC = new Map(); const PC_FOUND_CAT = new Map(); // #740: platform → [{ label, catno }] // Normalise a barcode for comparison: digits only, leading zeros stripped (a // 12-digit UPC-A and its 13-digit EAN form "0…" are the same barcode). function normBarcode(b) { return String(b || '').replace(/\D/g, '').replace(/^0+/, ''); } // #354: stores index the same GTIN under different zero-paddings — a 12-digit UPC-A, // a 13-digit EAN with a leading 0, or a 14-digit form — so an exact-UPC lookup can // miss purely on padding. gtinVariants returns the distinct forms to try (the raw MB // barcode first, then the stripped core and the 12/13/14 zero-padded forms). Leading // zeros are insignificant in a GTIN, so these are all the SAME barcode — no wrong-match // risk. `upcTry` runs a lookup across them and stops at the first hit, so the extra // requests only happen on the no-match path. function gtinVariants(barcode) { const raw = String(barcode || '').replace(/\D/g, ''); if (!raw) return []; const core = raw.replace(/^0+/, '') || raw; const out = []; const push = v => { if (v && !out.includes(v)) out.push(v); }; push(raw); // the exact MB barcode (as stored) first push(core); // leading zeros stripped if (core.length >= 11 && core.length <= 14) [12, 13, 14].forEach(n => { if (core.length <= n) push(core.padStart(n, '0')); }); return out; } async function upcTry(barcode, fn) { for (const v of gtinVariants(barcode)) { const r = await fn(v); if (r) return r; } return null; } let MB_FORMAT = null; // Format-confidence (#182). Only Bandcamp and Discogs carry a real parsed // format string (Digital / CD / Vinyl …); every other provider is a digital- // only storefront that never exposes a physical edition, so an absent format // means "Digital" for the check (otherwise strict mode would withhold every // streaming link). Those two are excluded and judged on their actual format. const DIGITAL_ONLY_PROVIDERS = new Set(['spotify', 'apple', 'deezer', 'tidal', 'qobuz', 'beatport', 'volumo', 'hdtracks', 'sevendigital', 'soundcloud', 'audiomack', 'ytmusic', 'amazonmusic']); // The media a format string names: CD, vinyl, cassette, MiniDisc or digital. A different medium // is a different MB release, so vinyl on a CD release is a mismatch (#709); DVD and Blu-ray count // as CD, as MB files them. A multi-format string ("2×CD + DVD", "Digital, CD") yields each one; // an unknown/empty string yields none, so nothing is judged. function formatCategories(s) { const t = String(s || '').toLowerCase(); const cats = new Set(); if (/\b(cd|cd-?r|sacd|hdcd|shm|blu-?spec|dualdisc|compact disc|dvd|blu-?ray)\b/.test(t)) cats.add('CD'); if (/\b(vinyl|lp|flexi(-?disc)?)\b|(^|[^\d])(7|10|12)("|''|″| ?inch)/.test(t)) cats.add('vinyl'); if (/\b(cassette|tape)\b/.test(t)) cats.add('cassette'); if (/\b(minidisc|md)\b/.test(t)) cats.add('MiniDisc'); if (/\b(digital|file|stream|lossless|web|wav|flac|mp3|aac|hi-?res)\b/.test(t)) cats.add('digital'); return cats; } // Remote format categories with the digital-only default applied. function remoteFormatCategories(platform, fmt) { const cats = formatCategories(fmt); if (cats.size === 0 && DIGITAL_ONLY_PROVIDERS.has(platform)) cats.add('digital'); return cats; } // A *known* format incompatibility (used for the row marker — shown only when // the option is on). Unknown remote format is not a known mismatch. function formatMismatch(platform, fmt) { const mbCats = formatCategories(MB_FORMAT); if (mbCats.size === 0) return false; const remote = remoteFormatCategories(platform, fmt); if (remote.size === 0) return false; return ![...remote].some(x => mbCats.has(x)); } // Whole row is clickable (#173): anywhere from after the icon to the track // count — empty cells and the gaps included (the row is one subgrid box). // LEFT-click opens the found platform page, or the provider's SEARCH page when // nothing was found (so even completely empty rows work); RIGHT-click always // opens the SEARCH page for a manual check. The icon keeps "add to MB" and the // name <a> opens natively (both excluded). // // This used to live inside updateRow, which meant a row only became clickable // once its scan had REPORTED. While a scan is running — or after ↻ reset the // rows — Discogs and Bandcamp sit there as full rows (they never fold into the // compact strip) with no handler at all, so clicking them did nothing, even // though the compact icons beside them were already clickable. Wiring is now // its own function, called as soon as the search-fallback URLs exist, so a // pending row behaves exactly like a compact icon: click → provider search. // // The destination is read from the anchor at click time rather than captured, // so the same wiring serves a pending row and a resolved one: updateRow points // the anchor at the found URL, and anything that isn't the stashed search URL // counts as "found". function wireRowOpen(p) { const row = document.getElementById(`row-${p}`); const a = document.getElementById(`mb-online-${p}`); if (!row || !a) return; const open = (e, preferSearch) => { if (e.target.closest('.pc-cell-ico') || e.target.closest('a')) return; // #682: a clickable Discogs master ✓ adds the master; the row must not // also open the Discogs page (or its search, on a right-click) beside it if (e.target.closest('.pc-cell-master')?.dataset.pcAddMaster) return; const search = a.dataset.searchUrl || null; const href = a.getAttribute('href'); const found = href && href !== search && /^https?:\/\//.test(href) ? href : null; const dest = preferSearch ? (search || found) : (found || search); if (dest) { e.preventDefault(); window.open(dest, '_blank', 'noopener'); } }; row.onclick = (e) => open(e, false); row.oncontextmenu = (e) => open(e, true); // Only claim to be clickable when there IS somewhere to go — before the // search fallbacks are seeded there isn't. const href = a.getAttribute('href'); row.style.cursor = (a.dataset.searchUrl || (href && /^https?:\/\//.test(href))) ? 'pointer' : ''; } // #641: middle click (button 1) runs `fn`, or nothing when fn is null. The mousedown is // swallowed, so the browser doesn't start autoscrolling instead. #653: so does Alt+click, for a // laptop's touchpad that has no middle button; it is caught before the element's own click and // the row's, which would add normally or open the page. #641 (majkinetor): "I miss the background // option with middle click. Lets use CTRL middle click … ctrl + alt + click forces to background": // with Ctrl (⌘ on a Mac) held, `fn(true)` adds in the background. const pcBgKey = e => !!(e.ctrlKey || e.metaKey); function pcWireForce(el, fn) { el._pcForce = fn || null; el.onmousedown = fn ? (e) => { if (e.button === 1) e.preventDefault(); } : null; el.onauxclick = fn ? (e) => { if (e.button !== 1) return; e.preventDefault(); fn(pcBgKey(e)); } : null; if (!el._pcForceAlt) { el._pcForceAlt = true; el.addEventListener('click', (e) => { if (!e.altKey || !el._pcForce) return; e.preventDefault(); e.stopImmediatePropagation(); el._pcForce(pcBgKey(e)); }, true); } } function updateRow(p, { url, mbTracks, remoteTracks, year, label, source, fromCache, format, masterState, hiddenTracks, unoffered, barcode }) { const a = document.getElementById(`mb-online-${p}`); const ico = document.getElementById(`ico-${p}`); const val = document.getElementById(`val-${p}`); if (url) a.href = url; const fromMbRels = source === 'MB rels'; // Barcode mismatch (#182): MB has a barcode, the found item exposes one, and // they differ → a different release per MB guidelines. Surfaced as a subtle // left bar (CSS) + tooltip/log only; the barcode itself is never shown in the // dashboard. Providers whose API hides the barcode (Apple, Bandcamp) pass none. const bcDiff = !!(url && MB_BARCODE && barcode && normBarcode(barcode) !== normBarcode(MB_BARCODE)); // Format incompatibility (#182) — only marked when the option is on, since // "digital link on a physical release" is common enough to be noise otherwise. const fmtDiff = !!(url && GM_getValue('pc:respect-format', true) && formatMismatch(p, format)); pcNoteFoundBarcode(p, url ? barcode : null, format); // #674 // Source-on-hover: tooltip on the provider name. "via MB rels", "via // Wikidata", "via API search · cached", etc. Replaces the visible badge // that used to live next to the track count. if (url) { const parts = []; if (source) parts.push(`via ${source}`); if (fromCache) parts.push('cached'); a.title = parts.length ? `${PROVIDER_NAME[p]} URL — ${parts.join(' · ')}` : ''; } else { a.title = `No ${PROVIDER_NAME[p]} URL found`; } // val is the platform's track count as a bare number, coloured by match // against the MB-side number (shown in the header). No more "(N/M trks)". if (remoteTracks != null) { // Bandcamp hidden download-only tracks (#183): mark the count with a "ⁿ" // superscript + tooltip so the editor knows N of the tracks aren't // streamable (the count itself already includes them). // An Apple album with tracks the storefront doesn't offer (#684): its API leaves them out, so the // count is what's offered, and the album's own count follows it ("20/24"). It matches on the first. val.textContent = String(remoteTracks) + (hiddenTracks > 0 ? 'ⁿ' : '') + (unoffered > 0 ? '/' + (parseInt(remoteTracks, 10) + unoffered) : ''); const hiddenNote = (hiddenTracks > 0 ? `\n${hiddenTracks} download-only track(s) hidden from streaming on Bandcamp` : '') + (unoffered > 0 ? `\nThe album has ${parseInt(remoteTracks, 10) + unoffered} tracks: ${unoffered} not offered by ${PROVIDER_NAME[p]}, so missing from its track list` : ''); if (parseInt(remoteTracks, 10) === parseInt(mbTracks, 10)) { ico.textContent = '✓'; const tone = fromCache ? '#5B82B0' : '#008000'; ico.style.color = tone; val.style.color = tone; val.title = `${remoteTracks} tracks, matches MB — ${fromCache ? 'blue: from the local cache' : 'green: fetched live this scan'}` + hiddenNote; } else { ico.textContent = '~'; ico.style.color = 'var(--mbu-warn)'; val.style.color = 'var(--mbu-warn)'; val.title = `${remoteTracks} tracks, MB has ${mbTracks}${fromCache ? ' (from the local cache)' : ''}` + hiddenNote; } } else if (url) { ico.textContent = '?'; ico.style.color = 'var(--mbu-text-weak)'; val.textContent = '?'; val.style.color = 'var(--mbu-text-weak)'; val.title = 'Track count could not be read'; } else { ico.textContent = '×'; ico.style.color = 'var(--mbu-error)'; val.textContent = '—'; val.style.color = 'var(--mbu-error)'; val.title = `No ${PROVIDER_NAME[p]} release found`; } // Circle MB-rels rows regardless of glyph — circle says "URL is in MB". ico.classList.toggle('pc-ico-circled', fromMbRels); // Withheld by barcode/format confidence (#182): present it like a mismatch // (grayed, not clickable) instead of a ✓ that silently does nothing on click. const blocked = !!(url && !fromMbRels && (barcodeBlocks(p) || formatBlocks(p))); // Click-to-add on the main icon for verified ✓ + not-already-in-MB (and not withheld). const canAdd = url && ico.textContent === '✓' && !fromMbRels && !blocked; // #641: middle click adds a confirmed (✓) link anyway — over a strict barcode/format // withholding, which sometimes keeps back a legitimate find — in the foreground const canForce = !!(url && ico.textContent === '✓' && !fromMbRels); const forceTip = blocked ? ' · middle-click or Alt+click: add it anyway (with Ctrl: in the background)' : ' · middle-click or Alt+click: add it even if strict settings would withhold it (with Ctrl: in the background)'; ico.style.cursor = canAdd || (blocked && canForce) ? 'pointer' : ''; ico.title = canAdd ? `Click to add ${PROVIDER_NAME[p]} URL to MB · right-click: add it silently in the background${forceTip}` : (blocked ? `Withheld from + / ↗ — barcode/format confidence is on (see the coloured bar)${canForce ? forceTip : ''}` : ''); ico.onclick = canAdd ? () => addSingleUrl(p) : null; ico.oncontextmenu = canAdd ? (e) => { e.preventDefault(); addSingleUrl(p, true); } : null; pcWireForce(ico, canForce ? (bg) => addSingleUrl(p, bg, true) : null); // Icons-mode encoding — TWO INDEPENDENT dimensions: // presence (pc-st-*) drives the icon fade + name colour: match = full · mismatch = gray · notfound = faint // in-MB (pc-inmb) draws the marker (circle/glow) — orthogonal, so an in-MB row still grays on a mismatch const row = document.getElementById(`row-${p}`); if (row) { const g = ico.textContent; // '✓' match · '~' mismatch · '?' found-no-count · '×' not found // a withheld (barcode/format-blocked) link reads as a mismatch, not a clean match (#182) const presence = blocked ? 'mismatch' : g === '×' ? 'notfound' : g === '~' ? 'mismatch' : 'match'; row.classList.remove('pc-st-notfound', 'pc-st-mismatch', 'pc-st-match'); row.classList.add('pc-st-' + presence); row.classList.toggle('pc-blocked', blocked); row.classList.toggle('pc-inmb', fromMbRels); // subtle left bar when the found release's barcode differs from MB's, or // (when the format option is on) its format is incompatible with MB's (#182) row.classList.toggle('pc-barcode-diff', bcDiff); row.dataset.barcodeDiff = bcDiff ? '1' : ''; row.classList.toggle('pc-format-diff', fmtDiff); const diffTips = []; if (bcDiff) diffTips.push(`Different barcode than MB (MB ${MB_BARCODE} · ${PROVIDER_NAME[p]} ${barcode}) — likely a different release`); if (fmtDiff) diffTips.push(`Format incompatible with MB release (MB ${MB_FORMAT} · ${PROVIDER_NAME[p]} ${normalizeFormat(format) || 'Digital'}) — likely a different release`); // strict-mode withholding with no known mismatch (barcode/format couldn't be confirmed) if (blocked && !diffTips.length) diffTips.push(`Withheld from + / ↗ — couldn't confirm barcode/format (strict mode)`); if (diffTips.length) row.title = diffTips.join(' · '); else if (row.title && /Different barcode|Format incompatible|Withheld from/.test(row.title)) row.title = ''; wireRowOpen(p); // #173 — see below; re-wired here so the cursor tracks the new state } const plat = document.getElementById(`plat-${p}`); if (plat) { plat.style.cursor = canAdd || (blocked && canForce) ? 'pointer' : 'default'; plat.onclick = canAdd ? () => addSingleUrl(p) : null; // click-to-add works on the brand icon too plat.oncontextmenu = canAdd ? (e) => { e.preventDefault(); addSingleUrl(p, true); } : null; pcWireForce(plat, canForce ? (bg) => addSingleUrl(p, bg, true) : null); plat.title = canAdd ? `Click to add ${PROVIDER_NAME[p]} URL to MB · right-click: add it silently in the background${forceTip}` : (url ? a.title + (blocked && canForce ? forceTip : '') : `No ${PROVIDER_NAME[p]} URL found`); } // Discogs gets a master state in the left slot. Other platforms have an // empty slot of the same width so the rows still align vertically. const masterEl = document.getElementById(`master-${p}`); if (masterEl) { if (p === 'discogs' && masterState) { applyMasterIcon(masterEl, masterState); } else { masterEl.innerHTML = ''; masterEl.onclick = null; masterEl.oncontextmenu = null; delete masterEl.dataset.pcAddMaster; masterEl.style.cursor = 'default'; masterEl.title = ''; masterEl.classList.remove('pc-ico-circled'); } } // Meta cells: year · format · label, each its own grid cell so the columns // align across providers (1-row layout) — see setMetaCells. // re-stripped here too, so a label cached before a stripCopyright fix shows clean (#644) setMetaCells(`year-${p}`, `format-${p}`, `label-${p}`, year, format, label ? stripCopyright(label) : label); // #740: the catalog number after the label, as the platform gives it const catnos = url ? (cacheGet(mbid, p)?.catnos || []) : []; const lEl = document.getElementById(`label-${p}`); if (lEl) lEl.classList.toggle('pc-has-cat', !!catnos.length); if (lEl && catnos.length) { // the label shrinks with "…", the catalog number never does const t = document.createElement('span'); t.className = 'pc-lbl-t'; t.textContent = lEl.textContent; const c = document.createElement('span'); c.className = 'pc-cell-cat'; c.textContent = catnos[0].catno + (catnos.length > 1 ? ` +${catnos.length - 1}` : ''); // the rest in the tooltip lEl.textContent = ''; lEl.append(t, c); lEl.title = `${lEl.title || ''}${lEl.title ? ' · ' : ''}catalog number ${catnos.map(x => `${x.catno} (${x.label})`).join(', ')}`; } pcNoteFoundCatnos(p, catnos); refreshCompactStrip(); // #355 } // #355: rebuild the "compact unmatched providers" strip from the rows' current state. // Model: everything starts COMPACT (a strip of dimmed icons) and each provider RISES // into a full row once it MATCHES — so pending + not-found stay in the strip, only a // found row is shown in full. Discogs and Bandcamp always keep their full rows; // disabled providers (prov_<p> off) are skipped. A row that leaves the strip gets a // subtle rise animation so the panel doesn't jump as results stream in. Cheap enough // to run after every updateRow. function refreshCompactStrip() { const strip = document.getElementById('pc-compact-strip'); if (!strip) return; const unmatched = container.classList.contains('pc-compact-unmatched'); const lowConf = container.classList.contains('pc-compact-nonstrict'); // #653 strip.textContent = ''; if (unmatched || lowConf) PROVIDER_ORDER.forEach(p => { if (p === 'discogs' || p === 'bandcamp') return; const row = document.getElementById(`row-${p}`); if (!row) return; // #653: two options. Unmatched: pending and not-found rows stay in the strip. // Low-confidence: a found-but-mismatched (wrong barcode/format, or withheld) row too; // with it off that row keeps its full row, so its tracks/year/label show. A clean // match always rises into a full row, and so does a link that's already IN MB // (pc-inmb) — you added it, so show it. const isMismatch = row.classList.contains('pc-st-mismatch'); const isMatch = row.classList.contains('pc-st-match'); const foldable = isMismatch ? lowConf : !isMatch && unmatched; const compact = foldable && !row.classList.contains('pc-inmb') && GM_getValue(`pc:prov_${p}`, true); const was = row.classList.contains('pc-compacted'); row.classList.toggle('pc-compacted', compact); if (!compact) { if (was) { row.classList.remove('pc-rise'); void row.offsetWidth; row.classList.add('pc-rise'); } // rose out on a match return; } const a = document.getElementById(`mb-online-${p}`); // a mismatch (found but wrong release) keeps a subtle amber ring so the // "found but wrong" signal isn't lost when it's folded into the strip. const mismatch = isMismatch; const ico = document.createElement('span'); ico.className = 'pc-compact-ico' + (mismatch ? ' pc-compact-mismatch' : ''); // #641: a withheld match (✓, folded into the strip as a mismatch) takes the same // middle click as its full row's icon: add it anyway, in the foreground const canForce = !!(a && /^https?:\/\//.test(a.getAttribute('href') || '') && document.getElementById(`ico-${p}`)?.textContent.trim() === '✓' && !row.classList.contains('pc-inmb')); ico.title = `${PROVIDER_NAME[p]} — ${mismatch ? 'found but a different release · click to open it' : 'click to search'}${canForce ? ' · middle-click or Alt+click: add it anyway (with Ctrl: in the background)' : ''}`; pcWireForce(ico, canForce ? (bg) => addSingleUrl(p, bg, true) : null); ico.innerHTML = stIcon(p, 16); ico.addEventListener('click', () => { // behave exactly like clicking the (uncompacted) row: open what was FOUND // when there is one (a mismatch has a found URL), else the provider search. const href = a && a.getAttribute('href'); const found = href && /^https?:\/\//.test(href) ? href : null; const dest = found || (a && a.dataset.searchUrl) || null; if (dest) window.open(dest, '_blank', 'noopener'); else row.click(); // last-resort fallback (e.g. a pending row) }); strip.appendChild(ico); }); else PROVIDER_ORDER.forEach(p => { const r = document.getElementById(`row-${p}`); if (r) r.classList.remove('pc-compacted'); }); strip.classList.toggle('pc-has-icons', strip.children.length > 0); } // Fill the three meta cells (year / format / label) for a row. The "·" // separators TRAIL year and format, but only when a later field follows — so // the same cell content reads correctly in both layouts: aligned columns in // 1-row ("2026 · | Digital · | Selections Lab"), and a joined line in 2-row // ("2026 · Digital · Selections Lab"). Format is normalized (Digital Media → // Digital); the full label is set as a tooltip so a truncated label (CSS // ellipsis) is still readable on hover. function setMetaCells(yearId, formatId, labelId, year, format, label) { const yEl = document.getElementById(yearId); const fEl = document.getElementById(formatId); const lEl = document.getElementById(labelId); const fmt = format ? normalizeFormat(format) : ''; const hasY = !!year, hasF = !!fmt, hasL = !!label; // 2-digit year to save space in the compact row (#173); full year in tooltip. const y2 = hasY ? String(year).slice(-2) : ''; if (yEl) { yEl.textContent = hasY ? (hasF || hasL ? `${y2} ·` : y2) : ''; yEl.title = hasY ? String(year) : ''; } if (fEl) { fEl.textContent = ''; fEl.title = hasF ? fmt : ''; if (hasF) { const marker = fmtMarker(fmt); fEl.appendChild(marker); // recognized formats render BOTH the circle and a text span; the "Format marker" option toggles // which shows via a panel class, so switching needs no re-scan. Unknown formats stay text-only. if (marker.nodeType === 1) { const t = document.createElement('span'); t.className = 'pc-fmt-txt'; t.textContent = fmt; fEl.appendChild(t); } if (hasL) fEl.appendChild(document.createTextNode(' ·')); } } if (lEl) { lEl.textContent = hasL ? label : ''; lEl.title = hasL ? label : ''; } } // MB's "Digital Media" is the spec name, but in a tight sidebar "Digital" // communicates the same thing. Applied at display time so the cache and // scan paths can keep MB's canonical value. function normalizeFormat(s) { return String(s || '').replace(/\bDigital\s*Media\b/i, 'Digital'); } // Format-family quadrant marker (#350): collapse any format(s) to Digital / Vinyl / CD / Cassette and draw // them as a single circle split into 4 coloured quadrants (present family = its colour). Compact (1 glyph) // for multi-format releases where the format text (Discogs/Bandcamp) would otherwise eat horizontal space. const PC_FMT_COLOR = { Vinyl: '#2b2b2b', Cassette: '#9a6b3f', CD: '#7d8894', Digital: '#4a90d9' }; function pcFormatFamily(f) { f = String(f || '').toLowerCase(); if (/cassette|tape/.test(f)) return 'Cassette'; if (/vinyl|shellac|flexi|\blp\b|7"|10"|12"/.test(f)) return 'Vinyl'; if (/cd|sacd|dvd|blu-?ray|hd-?dvd|minidisc|umd|disc/.test(f)) return 'CD'; if (/digital|file|stream|web|download|lossless|flac|mp3|wav|aac|hi-?res/.test(f)) return 'Digital'; return ''; } const pcFormatFamilies = fmt => [...new Set(String(fmt || '').split(/[,+/]|\s+&\s+/).map(pcFormatFamily).filter(Boolean))]; function fmtMarker(fmt) { const fams = pcFormatFamilies(fmt); if (!fams.length) return document.createTextNode(normalizeFormat(fmt) || ''); // unknown → keep the text const NS = 'http://www.w3.org/2000/svg', svg = document.createElementNS(NS, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', '13'); svg.setAttribute('height', '13'); svg.setAttribute('class', 'pc-fmt'); // quadrants: Vinyl top-right · Cassette top-left · CD bottom-left · Digital bottom-right const QUAD = { Vinyl: [-90, 0], Cassette: [180, 270], CD: [90, 180], Digital: [0, 90] }; const cx = 12, cy = 12, r = 11, rad = d => d * Math.PI / 180; for (const fam in QUAD) { const [a0, a1] = QUAD[fam]; const x0 = cx + r * Math.cos(rad(a0)), y0 = cy + r * Math.sin(rad(a0)), x1 = cx + r * Math.cos(rad(a1)), y1 = cy + r * Math.sin(rad(a1)); const path = document.createElementNS(NS, 'path'); path.setAttribute('d', `M${cx},${cy} L${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 0 1 ${x1.toFixed(2)},${y1.toFixed(2)} Z`); // #564: an unlit quadrant and the hairline between quadrants were a fixed // pale grey and a fixed near-white, so the marker read as a white disc on a // dark panel. Set through .style rather than setAttribute: a presentation // attribute is not a reliable place for var(), an inline style is. // #564: through .style and a token, for the same reason the unlit one is — // a presentation attribute cannot carry var(), and a fixed #2b2b2b vinyl // is invisible on a dark panel. if (fams.includes(fam)) path.style.fill = `var(--pc-fmt-${fam.toLowerCase()}, ${PC_FMT_COLOR[fam]})`; else path.style.fill = 'var(--mbu-bg-sunken)'; path.style.stroke = 'var(--mbu-bg)'; path.setAttribute('stroke-width', '0.8'); svg.appendChild(path); } return svg; } // Strip leading copyright noise from a label string so the meta line doesn't // show the year twice (year · label). Handles ℗/© or "(P)/(C)", a BARE leading // year (Tidal often does "2026 Label" with no symbol), and repeated/stacked // prefixes ("℗ 2017 © 2017 Label"), and marks run together (Amazon Music's "℗© 2025 Label", #644). // Returns null when nothing's left. function stripCopyright(text) { let t = String(text || '').trim(), prev; do { prev = t; t = t.replace(/^\s*(?:(?:[℗©]|\([pc]\))\s*)*(?:19|20)\d{2}\s*|^\s*(?:(?:[℗©]|\([pc]\))\s*)+/i, ''); } while (t !== prev); return t.trim() || null; } // Apply Discogs master-state to the master slot. State shape: // { glyph: '✓'|'~'|'×', circled: bool, clickable: bool, title: str, addMasterUrl?: str } function applyMasterIcon(el, state) { el.textContent = state.glyph; el.style.color = state.muted ? 'var(--mbu-text-weak)' : 'var(--mbu-info)'; el.title = state.title; el.classList.toggle('pc-ico-circled', !!state.circled); if (state.clickable && state.addMasterUrl) { el.style.cursor = 'pointer'; el.dataset.pcAddMaster = '1'; // #682: the row's open handler stands aside el.onclick = () => addMasterUrl(state.addMasterUrl); el.oncontextmenu = (e) => { e.preventDefault(); addMasterUrl(state.addMasterUrl, true); }; // #682 } else { el.style.cursor = 'default'; delete el.dataset.pcAddMaster; el.onclick = null; el.oncontextmenu = null; } } // Build the Discogs master-state object given the URL Discogs found + the // URL MB has on the release-group already. Drives applyMasterIcon(). function discogsMasterState(cachedMasterUrl, existingDiscogsMaster) { if (!cachedMasterUrl) { // No master from a resolved Discogs release — but if the MB // release-group already carries a Discogs master, use it (it's the // authoritative RG-level link, same as honouring any existing MB rel). if (existingDiscogsMaster) { return { glyph: '✓', circled: true, clickable: false, masterUrl: existingDiscogsMaster, title: `Discogs master on the release-group: ${existingDiscogsMaster}` }; } // Discogs returns no master_id when the release simply isn't part of a // master group (one-off pressings, niche labels). Show a muted "—" so // the user can distinguish "release has no master" from "we haven't // scanned yet" without the slot looking like a bug. return { glyph: '—', circled: false, clickable: false, title: 'Discogs release has no master entry', muted: true }; } if (existingDiscogsMaster === cachedMasterUrl) { return { glyph: '✓', circled: true, clickable: false, masterUrl: cachedMasterUrl, title: 'Discogs master URL is on the release-group' }; } if (existingDiscogsMaster) { return { glyph: '~', circled: true, clickable: false, masterUrl: existingDiscogsMaster, title: `MB has a different Discogs master on the release-group: ${existingDiscogsMaster}` }; } return { glyph: '✓', circled: false, clickable: true, masterUrl: cachedMasterUrl, title: 'Click to add this Discogs master URL to the release-group · right-click: add it silently in the background', addMasterUrl: cachedMasterUrl }; } // ─── Helpers ─────────────────────────────────────────────────────────────── // DDG redirects search hits through /l/?uddg=<encoded-url>. Decode it. function decodeDdgRedirect(href) { const m = href.match(/[?&]uddg=([^&]+)/); if (!m) return null; try { return decodeURIComponent(m[1]); } catch { return null; } } // Search Brave first (handles concurrent calls cleanly, returns the canonical // open.spotify.com / *.bandcamp.com URLs verbatim); fall back to DDG HTML if // Brave is blocked. Returns up to `maxN` unique URLs that pass `urlFilter` — // callers verify each candidate by fetching its server-side metadata, since // the first search hit is often a different album by the same artist (e.g. // MMW returns "20" for the Stone-series query). // // Engines are sometimes rate-limited per IP. We try them in order and stop // at the first one that yields any usable result. async function searchWeb(query, urlFilter, label, maxN = 5) { const engines = [ { name: 'Brave', url: `https://search.brave.com/search?q=${encodeURIComponent(query)}`, // Brave's anti-bot keys on a detailed Chrome UA being PRESENT, not absent. headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' }, extract(html) { // Brave embeds raw result URLs directly inside <a href="…"> — easy to // pluck. We collect every external link and let `urlFilter` triage. return [...html.matchAll(/href="(https?:\/\/[^"]+)"/g)].map(m => m[1]); }, }, { name: 'DDG', url: `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, // Counter-intuitive: DDG's anti-bot blocks specific Chrome UAs but lets // through a bare "Mozilla/5.0". Verified 2026-05-28. headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://duckduckgo.com/' }, extract(html) { const out = []; for (const m of html.matchAll(/href="(\/\/duckduckgo\.com\/l\/\?uddg=[^"]+)"/g)) { const decoded = decodeDdgRedirect(m[1]); if (decoded) out.push(decoded); } // Also fall through to direct URL matches (some result layouts). for (const m of html.matchAll(/https?:\/\/[^\s"<>]+/g)) out.push(m[0]); return out; }, }, ]; for (const eng of engines) { appendLog(label, `${eng.name}: ${eng.url}`); const res = await queueSearch(() => gmGet(eng.url, { headers: eng.headers })); const sizeHint = res.responseText.length; appendLog(label, `${eng.name}: status=${res.status} ${sizeHint}b in ${res.ms}ms`); // Engines distinguish rate-limit from real success differently: // Brave -> HTTP 429 ("Too Many Requests") // DDG -> HTTP 202 with a tiny <14 KB anti-bot page // Surface both clearly in the log so the user knows when to wait. if (res.status === 429 || res.status === 503) { appendLog(label, `${eng.name}: rate-limited (HTTP ${res.status}) — try again later`, 'warn'); continue; } if (!res.ok || !res.responseText) { appendLog(label, `${eng.name}: HTTP failure (status=${res.status})`, 'warn'); continue; } if (sizeHint < 5000 || res.status === 202) { appendLog(label, `${eng.name}: response too small (${sizeHint}b) — likely anti-bot block`, 'warn'); continue; } const all = eng.extract(res.responseText); const matches = []; const seen = new Set(); for (const u of all) { if (!urlFilter(u)) continue; // Strip query strings and "/intl-xx/" locale segments before dedup — // the same album often surfaces 5+ times with different locale prefixes. const norm = u.replace(/\/intl-[a-z-]+\//, '/').replace(/\?.*$/, '').replace(/#.*$/, ''); if (seen.has(norm)) continue; seen.add(norm); matches.push(norm); if (matches.length >= maxN) break; } appendLog(label, `${eng.name}: ${all.length} total hrefs, ${matches.length} unique candidates after filter`); if (matches.length) return matches; appendLog(label, `${eng.name}: no candidate matched filter`, 'warn'); } appendLog(label, `All search engines failed to match`, 'error'); return []; } // ─── Candidate scoring ────────────────────────────────────────────────────── // When the first search hit is wrong (e.g. MMW "20" instead of MMW // "The Stone: Issue Four"), we need a way to pick the right one from a few // candidates. Strategy: fetch each candidate's server-side metadata and score // against the MB release's track count + title. // #623 (sweep, X10): any script. Keeping only [a-z0-9] turned a non-Latin title into "" // — an empty search, and no title or artist signal in scoring. Diacritics still fold on // Latin, Greek and Cyrillic letters; other scripts keep their marks (voiced kana). function normName(s) { return (s || '').toLowerCase().normalize('NFKD').replace(/(?<=[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}])\p{M}+/gu, '').normalize('NFC').replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ').trim(); } // Punctuation-stripped form for use in search-engine queries. Quoting exact // phrases is too rigid: MB might have "Space Echo: The Mystery…!" while the // Bandcamp page renders it "Space Echo - The Mystery…" (no colon, no bang). // Stripping punctuation to spaces lets the engine token-match either form; // the verifier later picks the right candidate by track count + normName. function searchTerms(s) { return (s || '').normalize('NFKD').replace(/(?<=[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}])\p{M}+/gu, '').normalize('NFC').replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ').replace(/\s+/g, ' ').trim(); } // Token-set overlap. `mode` controls strictness: // 'max' — ratio against the larger side (default). Strict. Right for // album/title comparison: rejects "Self (The Remixes)" vs // "Jaiye Omo (The Remixes)" (share only "the" + "remixes"). // 'min' — ratio against the smaller side. Lenient. Right for artist // comparison: MB credits often shorter than streaming-platform // credits (e.g. MB "Hamad Kalkaba" vs Apple "Hamad Kalkaba & // The Golden Sounds" — all of MB's tokens are in Apple's, so // a min-based 0.8 threshold accepts it). function tokenMatch(a, b, mode = 'max', threshold = 0.6) { const na = normName(a), nb = normName(b); if (!na || !nb) return false; if (na === nb) return true; const tokensOf = s => new Set(s.split(' ').filter(t => t.length >= 2)); const ta = tokensOf(na), tb = tokensOf(nb); if (!ta.size || !tb.size) return false; let common = 0; for (const t of ta) if (tb.has(t)) common++; const denom = mode === 'min' ? Math.min(ta.size, tb.size) : Math.max(ta.size, tb.size); return common / denom >= threshold; } const titleSimilar = (a, b) => tokenMatch(a, b, 'max', 0.6); // #324: min-mode leniency is for the intended direction only — the MB credit being // SHORTER than the platform's (e.g. "Hamad Kalkaba" ⊂ "Hamad Kalkaba & The Golden // Sounds"). When the *candidate* is the shorter side it has dropped MB tokens — often // a disambiguator like the "61" in "UFO 61" vs a bare "UFO!" — so be strict (max), // or a wrong same-prefixed release scores a full artist match. (Root cause of #314.) const artistTokenCount = s => normName(s).split(' ').filter(t => t.length >= 2).length; const artistSimilar = (cand, mb) => tokenMatch(cand, mb, artistTokenCount(mb) <= artistTokenCount(cand) ? 'min' : 'max', 0.8); // Score: tracks +100 / ±2 +30, title +60, artist +80 (non-VA). // // Two subtleties: // // - The artist-mismatch penalty (-80) only applies when the title also // fails to match. Compilation reissues commonly credit the curator // label as `byArtist` on Bandcamp (e.g. "Analog Africa") while MB // attributes the recording to the actual artist ("Hamad Kalkaba"). // If the title matches strongly, accept the mismatch as a likely // label/series credit. // // - Pickable threshold (≥120) means a candidate needs at least one // non-tracks signal (title OR artist) to be trusted. Tracks alone // (100) is not enough — that was the bug behind picking the Jaiye // Omo album for Om Unit's "Self (The Remixes)" purely because both // happened to be 4-track EPs. // Two non-empty titles that share ZERO significant tokens = a clearly different // album (e.g. "Mambo Loco" vs "Para Bailar A Millón"). #324: an artist with many // same-track-count albums (tracks 100 + artist 80) would otherwise be picked on a // totally different title; a disjoint title is a strong negative signal. function titleDisjoint(a, b) { const na = normName(a), nb = normName(b); if (!na || !nb) return false; const T = s => new Set(s.split(' ').filter(t => t.length >= 2)); const ta = T(na), tb = T(nb); if (!ta.size || !tb.size) return false; for (const t of ta) if (tb.has(t)) return false; return true; } function scoreCandidate(meta, mbTracks, mbAlbum, mbArtist, isVA) { if (!meta) return -1; let s = 0; if (meta.tracks != null) { if (meta.tracks === mbTracks) s += 100; else if (Math.abs(meta.tracks - mbTracks) <= 2) s += 30; } const titleMatch = meta.title && titleSimilar(meta.title, mbAlbum); if (titleMatch) s += 60; // #324: a clearly different title outweighs tracks+artist (an album the same // artist also made, with the same track count, is a common false positive). else if (meta.title && mbAlbum && titleDisjoint(meta.title, mbAlbum)) s -= 100; if (!isVA && mbArtist && meta.artist) { if (artistSimilar(meta.artist, mbArtist)) s += 80; else if (!titleMatch) s -= 80; } return s; } // Drive a per-candidate verifier loop. Returns the best { url, meta, score } // across the candidate list, plus a per-candidate log table for diagnostics. // `fetchMeta(url)` returns `{ tracks, title, year, label }` (any field may be null). async function pickBestCandidate(candidates, fetchMeta, mbTracks, mbAlbum, label, mbArtist, isVA) { const scored = []; for (const url of candidates) { const meta = await fetchMeta(url); const score = scoreCandidate(meta, mbTracks, mbAlbum, mbArtist, isVA); scored.push({ url, meta, score }); appendLog(label, ` cand score=${score} tracks=${meta?.tracks ?? '?'} artist="${meta?.artist || '?'}" title="${meta?.title || '?'}" url=${url}`); // Short-circuit on a high-confidence match (tracks + (title or artist)). if (score >= 150) break; } if (!scored.length) return null; scored.sort((a, b) => b.score - a.score); return scored[0]; } // Per-platform-per-MBID cache. Stores the full resolved row so a return visit // to a release page does ZERO network calls: no MB-rels-driven detail fetch, // no Wikidata SPARQL, no search, no embed/album-page parse. The ↻ button // clears entries when the user wants to force a re-scan. // // Schema (JSON-encoded value): { url, tracks, year, label, source } where // `url` may be null when we've definitively concluded "no match exists on // this platform" (so we don't keep re-searching for niche releases that // genuinely aren't on Spotify/Bandcamp). // #501 follow-up (majkinetor, live: his script-manager config was cluttered // with pc:cache:v2:*/pc:mbdata:*/pc:pending:* entries riding along in a sync // backup) — these are same-origin (musicbrainz.org-only @match, no // cross-domain handoff need, unlike e.g. falcon's Harmony bridge or // isrc_scout's Beatport OAuth handoff), so localStorage is the right layer: // it's still shared across every musicbrainz.org tab, just not swept into a // script-manager sync/backup the way a real setting should be. // #556: "the same album" identity for two URL strings. // // MusicBrainz stores whatever URL the editor was given, and the provider // searches here produce a different spelling of the same page all the time — // `open.spotify.com/intl-de/album/X` vs `/album/X`, `music.apple.com/us/album/ // slug/123` vs `/album/123`, `qobuz.com/us-en/album/…`, `listen.tidal.com` vs // `tidal.com/browse/album/…`. The `existing` map above already anticipates every // one of those variants; the comparison that consumed it did not, and used // `===`. A missed match leaves a link that IS on the release looking un-added, // so the panel re-queues it and the editor no-ops on a duplicate. // // Compare on a provider id where there is one (they are stable and unambiguous), // otherwise on a host+path normalised for the noise MB and the providers add. // ⚠ The provider table lives INSIDE pcUrlKey, memoised on the function object, // and must stay there. As a module-level `const` it sat below the IIFE's // early-return for /release/<mbid>/edit, so injectInto — which runs on exactly // that path — hit it in the temporal dead zone: `ReferenceError: Cannot access // 'PC_URL_ID' before initialization`, thrown out of the whole inject run. // It only fired for a URL MusicBrainz had rewritten, because matchRowByUrl // short-circuits on `h === url` first, so identically-spelled links (Deezer, // Tidal) landed and the run then died on the first rewritten one (Qobuz) — // taking every remaining URL with it. Same hazard TYPE_FORCE is inlined for. function pcUrlKey(u) { const ID = pcUrlKey._id || (pcUrlKey._id = [ [/open\.spotify\.com\/(?:intl-[a-z-]+\/)?album\/([a-z0-9]+)/i, 'spotify'], [/music\.apple\.com\/(?:[a-z]{2}\/)?album\/(?:[^/?#]+\/)?(\d+)/i, 'apple'], [/(?:www\.)?deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/i, 'deezer'], [/(?:listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/i, 'tidal'], [/(?:www\.)?discogs\.com\/(?:[a-z-]+\/)?release\/(\d+)/i, 'discogs'], [/(?:www\.)?discogs\.com\/(?:[a-z-]+\/)?master\/(\d+)/i, 'discogsmaster'], [/(?:www\.)?beatport\.com\/release\/[^/]+\/(\d+)/i, 'beatport'], // #639: the id is in the query string, which the normalisation below drops, so // without this every YouTube Music album would count as the same link [/(?:(?:music|www|m)\.)?youtube\.com\/(?:playlist\?(?:[^#]*&)?list=|browse\/)(OLAK5uy_[\w-]+|MPREb_[\w-]+)/i, 'ytmusic'], [/music\.amazon\.[a-z.]+\/albums\/([A-Z0-9]{10})/i, 'amazonmusic'], // #644: any country's domain, one album [/(?:7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\/(?:[^/?#]*-)?(\d{3,})/i, 'sevendigital'], // #669: any country's store, one release ]); const s = String(u || '').trim(); if (!s) return ''; for (const [re, name] of ID) { const m = s.match(re); if (m) return name + ':' + m[1].toLowerCase(); } // no stable id (bandcamp, qobuz, volumo, hdtracks, soundcloud, audiomack) — normalise the // spelling instead: scheme, common host prefixes, locale segment, query and // fragment, trailing slash. try { const url = new URL(s); const host = url.hostname.toLowerCase().replace(/^(?:www|m|listen|play|open)\./, ''); let path = url.pathname.replace(/\/+$/, ''); path = path.replace(/^\/(?:[a-z]{2}-[a-z]{2}|[a-z]{2}|intl-[a-z-]+|browse)(?=\/)/i, ''); return host + path.toLowerCase(); } catch (e) { return s.toLowerCase().replace(/[?#].*$/, '').replace(/\/+$/, ''); } } function pcSameUrl(a, b) { return !!a && !!b && pcUrlKey(a) === pcUrlKey(b); } /* #623 (sweep, X11): both caches below grew by one entry per platform for every release ever visited, and never expired. A cached "no match" was permanent, so a release that reached a platform later was never searched there again; and once musicbrainz.org's ~5 MB of localStorage filled up, every write failed silently — the platform logins' token refreshes included. Entries now carry their time (_t): a "not found" is searched again after 14 days, a found link after 90, the release's MusicBrainz data after 30. A daily prune drops what expired and keeps the newest 500 releases. ⚠ Functions, not consts: the release editor's helper reads the cache (injectInto → cacheGet, #423) and runs above the script's early return, where a const declared down here is still in its temporal dead zone — the trap PC_URL_ID fell into (#556). */ function pcTtlOf(key, v) { const TTL = { miss: 14 * 864e5, hit: 90 * 864e5, mbdata: 30 * 864e5 }; return key.startsWith('pc:mbdata:') ? TTL.mbdata : (v && v.url ? TTL.hit : TTL.miss); } function pcMaxReleases() { return 500; } function pcLsGet(key) { // the entry, or null when absent, unreadable or expired (then removed) const raw = localStorage.getItem(key); if (!raw) return null; let v; try { v = JSON.parse(raw); } catch { return null; } if (v && v._t && Date.now() - v._t > pcTtlOf(key, v)) { try { localStorage.removeItem(key); } catch (e) {} return null; } return v; } function pcLsSet(key, entry) { if (!entry) return; try { localStorage.setItem(key, JSON.stringify({ ...entry, _t: Date.now() })); } catch (e) {} } function pcPruneCache() { try { const now = Date.now(), today = new Date(now).toISOString().slice(0, 10); if (localStorage.getItem('pc:cache:pruned') === today) return; localStorage.setItem('pc:cache:pruned', today); const keys = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && (k.startsWith('pc:cache:v2:') || k.startsWith('pc:mbdata:'))) keys.push(k); } const newest = new Map(); // release mbid → its newest entry's time let expired = 0, stamped = 0, evicted = 0; for (const k of keys) { let v; try { v = JSON.parse(localStorage.getItem(k)); } catch (e) { v = null; } if (!v) { localStorage.removeItem(k); expired++; continue; } // an entry from before #623 has no time: its age starts now, rather than // the whole cache expiring (and being searched again) at once if (!v._t) { v._t = now; localStorage.setItem(k, JSON.stringify(v)); stamped++; } if (now - v._t > pcTtlOf(k, v)) { localStorage.removeItem(k); expired++; continue; } const mbid = k.slice(k.lastIndexOf(':') + 1); newest.set(mbid, Math.max(newest.get(mbid) || 0, v._t)); } if (newest.size > pcMaxReleases()) { const oldest = [...newest].sort((a, b) => a[1] - b[1]).slice(0, newest.size - pcMaxReleases()); oldest.forEach(([mbid]) => cacheClear(mbid)); evicted = oldest.length; } appendLog('System', `Cache prune: ${keys.length} entr${keys.length === 1 ? 'y' : 'ies'} for ${newest.size} release(s) — ${expired} expired, ${evicted} release(s) over the ${pcMaxReleases()} cap dropped${stamped ? `, ${stamped} older entr${stamped === 1 ? 'y' : 'ies'} dated today` : ''}`); } catch (e) { appendLog('System', `Cache prune failed: ${e.message}`, 'warn'); } } function cacheKey(mbid, platform) { return `pc:cache:v2:${platform}:${mbid}`; } // v2: entries now carry `barcode` (#182) function cacheGet(mbid, platform) { return pcLsGet(cacheKey(mbid, platform)); } function cacheSet(mbid, platform, entry) { pcLsSet(cacheKey(mbid, platform), entry); } // the platforms whose album answer names artist or label pages (Spotify's, Tidal's, Amazon's, // 7digital's, Volumo's and HDtracks' don't, or not without another request) const PC_CREDIT_PROVIDERS = ['discogs', 'deezer', 'apple', 'qobuz', 'bandcamp', 'beatport', 'ytmusic', 'soundcloud', 'audiomack']; // A scanner's cache read (#671): a match cached before artist and label pages were kept has // no `credits`, and serving it would leave Artists & labels empty until ↻. It's read again // instead, once, and the new entry carries them. function cacheGetScan(mbid, platform, label) { const c = cacheGet(mbid, platform); if (c && c.url && !('credits' in c) && PC_CREDIT_PROVIDERS.includes(platform)) { appendLog(label || platform, 'Cached match has no artist and label pages (cached before they were kept) — reading it again', 'info'); return null; } // #757: an Apple match cached before its songs' artists were read is read again if (platform === 'apple' && c && c.url && !c.songArtists) { appendLog(label || platform, 'Cached match has no track artists (cached before they were read) — reading it again', 'info'); return null; } // #704: a Qobuz match cached before its artist and label pages got an ASCII slug is read again const cr = c && c.credits; if (platform === 'qobuz' && cr && [...(cr.artists || []), ...(cr.labels || []), ...(cr.tracks || []).flat()] .some(x => x && /^https:\/\/www\.qobuz\.com\/[a-z]{2}-[a-z]{2}\/(?:interpreter|label)\/[^/]*[^\w/-]/.test(x.url))) { appendLog(label || platform, 'Cached match has a Qobuz page MusicBrainz would refuse — reading it again', 'info'); return null; } return c; } function cacheClear(mbid) { for (const p of ALL_PROVIDERS) localStorage.removeItem(cacheKey(mbid, p)); // all providers — not a stale hardcoded subset (else ↻ leaves Tidal/Beatport/Volumo cached) localStorage.removeItem(mbDataKey(mbid)); } // MB-level metadata cache (artist, album, mbTracks, etc.) — written once per // MBID after a successful release fetch. When MB returns 503 (rate-limited) // or otherwise fails, we fall back to this cache so a tab switch to a // previously-scanned release still renders its cached rows instead of // halting on "Halted: API status 503". function mbDataKey(mbid) { return `pc:mbdata:${mbid}`; } function mbDataGet(mbid) { return pcLsGet(mbDataKey(mbid)); } function mbDataSet(mbid, entry) { pcLsSet(mbDataKey(mbid), entry); } pcPruneCache(); // once a day (#623) // Apply a cached row to the UI and log the hit. Preserves the cache entry's // original source (e.g. 'MB rels', 'Wikidata', 'search') so updateRow can // still decide whether to circle the ✓; cache-state is conveyed separately // via the `fromCache: true` flag (which drives the steel-blue tint). function applyCachedRow(platform, label, cached, mbTracks, masterState) { appendLog(label, `Cache hit: url=${cached.url || '(no match)'} tracks=${cached.tracks ?? '?'} year=${cached.year || '?'} label=${cached.label || '?'} src=${cached.source || '?'}`, 'ok'); updateRow(platform, { url: cached.url, mbTracks, remoteTracks: cached.tracks ?? null, year: cached.year ?? null, label: cached.label ?? null, format: cached.format ?? null, source: cached.source || null, fromCache: true, masterState: masterState ?? null, hiddenTracks: cached.hiddenTracks ?? 0, unoffered: cached.unoffered ?? 0, barcode: cached.barcode ?? null, }); } // (Old discogsMasterExtra pill replaced by the master-slot state object — // see discogsMasterState() / applyMasterIcon() above.) // ─── Artist and label pages (#671) ────────────────────────────────────────── // The album a scanner reads usually names its artists' pages on that platform, and // some name the label's. Each scanner keeps them with its match, as `credits`: // { artists: [{ name, url }], labels: [{ name, url }], tracks: [[{ name, url }], …] or null } // — read from the answer it already has, never with a request of its own. `tracks` is // the platform's tracklist in order, each with its own artists; `alt` is another name // to match on (Discogs' credited name), `uncertain` an account that may be the artist // or the label (Bandcamp, SoundCloud, Audiomack). The Artists & labels table pairs them // with the release's MusicBrainz artists and labels. function pcCr(name, url, opts) { if (!url) return null; const c = { name: String(name || '').trim(), url }; if (opts && opts.alt && opts.alt !== c.name) c.alt = String(opts.alt).trim(); if (opts && opts.uncertain) c.uncertain = true; return c; } function pcCredits(artists, labels, tracks) { const uniq = l => (l || []).filter(Boolean).filter((c, i, a) => a.findIndex(x => x.url === c.url) === i); const out = { artists: uniq(artists), labels: uniq(labels), tracks: Array.isArray(tracks) ? tracks.map(uniq) : null }; return out.artists.length || out.labels.length || (out.tracks && out.tracks.some(t => t.length)) ? out : null; } // One line for the platform's log: what the album named. function pcCreditsSummary(c) { if (!c) return 'Artist and label pages: none in this album'; const names = l => l.map(x => `${x.name || '?'}${x.uncertain ? ' (artist or label)' : ''}`).join(', '); const tr = c.tracks ? c.tracks.filter(t => t.length).length : 0; return `Artist and label pages: ${c.artists.length ? `artists ${names(c.artists)}` : 'no artist'}; ${c.labels.length ? `labels ${names(c.labels)}` : 'no label'}${c.tracks ? `; track artists on ${tr} of ${c.tracks.length} track(s)` : ''}`; } // What a scanner keeps (#671): its log line, then the credits themselves. function pcKeep(label, credits) { appendLog(label, pcCreditsSummary(credits || null)); return credits || null; } // "Name (2)": Discogs' own way of telling two artists of one name apart const pcDiscogsName = s => String(s || '').replace(/\s+\(\d+\)$/, '').trim(); function pcCreditsDiscogs(d) { if (!d) return null; const art = a => (a && a.id && a.id !== 194) ? pcCr(pcDiscogsName(a.name), `https://www.discogs.com/artist/${a.id}`, { alt: a.anv }) : null; // 194: Various const lbl = l => (l && l.id && !/^not on label\b/i.test(l.name || '')) ? pcCr(pcDiscogsName(l.name), `https://www.discogs.com/label/${l.id}`) : null; // the tracks as the scanner counts them: headings aren't tracks, a medley's index is one const trk = (d.tracklist || []).filter(t => t.type_ === 'track' || t.type_ === 'index' || !t.type_); return pcCredits((d.artists || []).map(art), (d.labels || []).map(lbl), trk.map(t => (t.artists || []).map(art))); } function pcCreditsDeezer(d) { if (!d) return null; const art = a => (a && a.id && !VA_NAME_RE.test(a.name || '')) ? pcCr(a.name, `https://www.deezer.com/artist/${a.id}`) : null; const main = (d.contributors || []).filter(c => !c.role || c.role === 'Main'); const list = (d.tracks && d.tracks.data) || []; // a tracklist cut short (paged) can't be paired by position return pcCredits((main.length ? main : [d.artist]).map(art), [], list.length && list.length === d.nb_tracks ? list.map(t => [art(t.artist)]) : null); } // Apple names the album's artists by id only; their names come from the credit's text, // split into as many parts as there are ids (otherwise they're matched by position). // Each song names its own artists, with their names, when the album is read with // APPLE_ALBUM_INC (#757: a compilation's are all there is). Songs only, as the row counts // them; a track list cut short (paged) can't be paired by position. function pcCreditsApple(a, storefront) { const sf = storefront || 'us'; const ids = ((a && a.relationships && a.relationships.artists && a.relationships.artists.data) || []).map(x => x.id).filter(Boolean); const credit = String((a && a.attributes && a.attributes.artistName) || ''); const parts = credit.split(/\s*(?:,|&|\band\b)\s*/i).filter(Boolean); const names = ids.length === 1 ? [credit] : parts.length === ids.length ? parts : ids.map(() => ''); const album = ids.length === 1 && VA_NAME_RE.test(credit) ? [] : ids.map((id, i) => pcCr(names[i], `https://music.apple.com/${sf}/artist/${id}`)); const rel = a && a.relationships && a.relationships.tracks; const songs = rel && !rel.next && Array.isArray(rel.data) ? rel.data.filter(t => t.type === 'songs') : null; const art = x => (x && x.id && !VA_NAME_RE.test((x.attributes && x.attributes.name) || '')) ? pcCr(x.attributes && x.attributes.name, `https://music.apple.com/${sf}/artist/${x.id}`) : null; const tracks = songs && songs.some(t => t.relationships && t.relationships.artists) ? songs.map(t => ((t.relationships && t.relationships.artists && t.relationships.artists.data) || []).map(art)) : null; return pcCredits(album, [], tracks); } const pcSlug = s => String(s || '').toLowerCase().normalize('NFKD').replace(/\p{M}+/gu, '').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') || '-'; // MusicBrainz takes a Qobuz URL only with an ASCII slug ([\w-]+, no %): a Cyrillic or other // non-Latin one is refused under every link type. Qobuz finds the page by its id, whatever the slug. function pcQobuzSlug(slug, name) { const ascii = pcSlug(name).replace(/[^\w-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); return [slug, ascii].find(x => x && /^[\w-]+$/.test(x)) || '-'; } function pcCreditsQobuzApi(d) { if (!d || !d.id) return null; const art = a => (a && a.id && !VA_NAME_RE.test(a.name || '')) ? pcCr(a.name, `https://www.qobuz.com/us-en/interpreter/${pcQobuzSlug(a.slug, a.name)}/${a.id}`) : null; const main = (d.artists || []).filter(a => !a.roles || a.roles.includes('main-artist')); const l = d.label; const items = (d.tracks && d.tracks.items) || []; return pcCredits((main.length ? main : [d.artist]).map(art), [l && l.id ? pcCr(l.name, `https://www.qobuz.com/us-en/label/${pcQobuzSlug(l.slug, l.name)}/download-streaming-albums/${l.id}`) : null], items.length && items.length === d.tracks_count ? items.map(t => [art(t.performer)]) : null); } // the store page links the album's artists and its label function pcCreditsQobuzPage(html) { const artists = [], labels = []; for (const m of String(html || '').matchAll(/<a\b[^>]*\bhref="(\/[a-z]{2}-[a-z]{2}\/(interpreter|label)\/[^"]+)"[^>]*>([^<]{1,120})<\/a>/g)) { const name = qzDec(m[3]).trim(); if (!name || (m[2] === 'interpreter' && VA_NAME_RE.test(name))) continue; const path = m[1].replace(/^(\/[a-z]{2}-[a-z]{2}\/(?:interpreter|label)\/)([^/]+)/, (_, pre, slug) => { try { slug = decodeURIComponent(slug); } catch (e) { /* keep it as it is */ } return pre + pcQobuzSlug(slug, name); }); (m[2] === 'label' ? labels : artists).push(pcCr(name, 'https://www.qobuz.com' + path)); } return pcCredits(artists, labels, null); } // The album's account: the band's own, or its label's (a label page puts every artist's // album under its own name), so it's offered for either and left unchecked. function pcCreditsBandcamp(html) { const m = String(html || '').match(/"byArtist"\s*:\s*\{[^}]*?"@id"\s*:\s*"(https?:\/\/[a-z0-9-]+\.bandcamp\.com)\/?"/i) || String(html || '').match(/"publisher"\s*:\s*\{[^}]*?"@id"\s*:\s*"(https?:\/\/[a-z0-9-]+\.bandcamp\.com)\/?"/i); if (!m) return null; const near = String(html).slice(m.index, m.index + 400); const name = (near.match(/"name"\s*:\s*"([^"]+)"/) || [])[1] || ''; const acc = pcCr(name, m[1].replace(/^http:/, 'https:') + '/', { uncertain: true }); return pcCredits([acc], [acc], null); } function pcCreditsBeatport(d) { if (!d) return null; const art = a => (a && a.id) ? pcCr(a.name, `https://www.beatport.com/artist/${a.slug || pcSlug(a.name)}/${a.id}`) : null; const l = d.label; return pcCredits((d.artists || []).map(art), [l && l.id ? pcCr(l.name, `https://www.beatport.com/label/${l.slug || pcSlug(l.name)}/${l.id}`) : null], null); } // YouTube Music: the header's artist links, and each track's when it names its own // (a compilation's); a track that names none is the album's artists'. function pcCreditsYtm(j) { const runsOf = t => ((t && t.runs) || []).map(r => { const b = r.navigationEndpoint && r.navigationEndpoint.browseEndpoint; const type = b && b.browseEndpointContextSupportedConfigs && b.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig && b.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType; return b && /^UC/.test(b.browseId || '') && (!type || type === 'MUSIC_PAGE_TYPE_ARTIST') ? pcCr(r.text, `https://music.youtube.com/channel/${b.browseId}`) : null; }); let h = null, shelf = null; ytmWalk(j, o => { if (!h && o.musicResponsiveHeaderRenderer) h = o.musicResponsiveHeaderRenderer; if (!shelf && o.musicShelfRenderer) shelf = o.musicShelfRenderer; }); if (!h) return null; const rows = ((shelf && shelf.contents) || []).map(c => c.musicResponsiveListItemRenderer).filter(Boolean); const tracks = rows.map(r => runsOf(r.flexColumns && r.flexColumns[1] && r.flexColumns[1].musicResponsiveListItemFlexColumnRenderer && r.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text)); return pcCredits(runsOf(h.straplineTextOne), [], rows.length ? tracks : null); } function pcCreditsSoundcloud(pl) { const u = pl && pl.user; return u && u.permalink_url ? pcCredits([pcCr(u.username, u.permalink_url, { uncertain: true })], [pcCr(u.username, u.permalink_url, { uncertain: true })], null) : null; } function pcCreditsAudiomack(it) { const u = it && it.uploader; const slug = (u && u.url_slug) || (it && it.uploader_url_slug); if (!slug) return null; const acc = pcCr((u && u.name) || it.artist || slug, `https://audiomack.com/${slug}`, { uncertain: true }); return pcCredits([acc], [acc], null); } // ─── Wikidata fast path ───────────────────────────────────────────────────── // Wikidata curates external IDs (Spotify P2205, Apple Music P5121, AllMusic // P1729) against MB release-group IDs (P436) and release IDs (P5813). When a // release has a Wikidata entity, the data is human-edited and effectively // 100% precise — much better than ranking 5 search-engine candidates by // track count. Recall is the trade-off: niche / very-recent releases usually // have no Wikidata entry, in which case we fall back to web search. async function lookupWikidata(releaseGroupMbid, releaseMbid) { if (!releaseGroupMbid && !releaseMbid) return null; // Union of release-group and release lookups in one query — avoids two // round-trips when one or the other might be the indexed entity. const sparql = `SELECT ?spotify ?apple ?allmusic ?tidal ?beatport WHERE { ${releaseGroupMbid ? ` { ?item wdt:P436 "${releaseGroupMbid}" }` : ''} ${releaseGroupMbid && releaseMbid ? ' UNION' : ''} ${releaseMbid ? ` { ?item wdt:P5813 "${releaseMbid}" }` : ''} OPTIONAL { ?item wdt:P2205 ?spotify } OPTIONAL { ?item wdt:P5121 ?apple } OPTIONAL { ?item wdt:P1729 ?allmusic } OPTIONAL { ?item wdt:P4577 ?tidal } OPTIONAL { ?item wdt:P11312 ?beatport } } LIMIT 5`; const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparql)}&format=json`; appendLog('Wikidata', `SPARQL lookup rg=${releaseGroupMbid || '-'} rel=${releaseMbid || '-'}`); // 8 s, not gmGet's 15 s: query.wikidata.org often stalls, and the providers // that use this answer (Spotify/Apple/Tidal/Beatport) wait for it — on a // timeout they just fall through to their own search. const res = await gmGet(url, { headers: { 'Accept': 'application/sparql-results+json' }, timeout: 8000 }); appendLog('Wikidata', `status=${res.status} ${res.responseText.length}b in ${res.ms}ms`); if (!res.ok) { appendLog('Wikidata', res.error === 'timeout' ? `timed out after ${res.ms}ms — continuing without it` : `lookup failed`, 'warn'); return null; } let data; try { data = JSON.parse(res.responseText); } catch (e) { appendLog('Wikidata', `JSON parse: ${e.message}`, 'error'); return null; } const bindings = data.results?.bindings || []; if (!bindings.length) { appendLog('Wikidata', `no entity matches release-group/release MBID — falling back to search`, 'warn'); return null; } // Pick first binding that has any populated field; if multiple bindings // disagree (rare), trust the first row. const b = bindings.find(r => r.spotify || r.apple || r.allmusic || r.tidal || r.beatport) || bindings[0]; const out = { spotifyId: b.spotify?.value || null, appleId: b.apple?.value || null, allmusicId: b.allmusic?.value || null, tidalId: b.tidal?.value || null, beatportId: b.beatport?.value || null, }; appendLog('Wikidata', `match: spotify=${out.spotifyId || '-'} apple=${out.appleId || '-'} allmusic=${out.allmusicId || '-'} tidal=${out.tidalId || '-'} beatport=${out.beatportId || '-'}`, 'ok'); return out; } // Wallstream (tools.wallstream.com, #602) proxies Spotify's catalog search with // its own app token, so `q=upc:<code>` resolves a barcode → exact-UPC Spotify // album — the only unauthenticated UPC route for Spotify (the embed/API don't // expose a UPC without an app token). Replaced SAMBL, which stopped returning // Spotify results and blocked every scanner while it ran. No CORS header → // GM_xmlhttpRequest (@connect tools.wallstream.com). // Spotify's upc: filter matches only the exact string Spotify stored, leading // zeros included (0859381157694 hits, 859381157694 doesn't), so try the MB // barcode verbatim, then its 12/13/14-digit zero-padded forms, first hit wins. // Count with items.length — the response's `total` can be 0 with items present. function spotifyUpcVariants(barcode) { const raw = String(barcode || '').trim(); if (!raw) return []; const out = [raw]; if (/^\d+$/.test(raw)) { const core = raw.replace(/^0+/, ''); for (const n of [12, 13, 14]) if (core.length <= n) out.push(core.padStart(n, '0')); } return [...new Set(out)]; } async function lookupWallstreamSpotify(barcode) { const variants = spotifyUpcVariants(barcode); if (!variants.length) return null; appendLog('Wallstream', `Spotify barcode lookup ${barcode} (forms: ${variants.join(', ')})`); for (const code of variants) { const url = `https://tools.wallstream.com/api/spotify/isrc?q=${encodeURIComponent('upc:' + code)}`; const r = await gmGet(url, { timeout: 8000, anonymous: true }); appendLog('Wallstream', `upc:${code} → status=${r.status} ${r.responseText.length}b in ${r.ms}ms${r.error ? ' (' + r.error + ')' : ''}`); if (!r.ok) { appendLog('Wallstream', `lookup failed — falling back to Wikidata/search`, 'warn'); return null; } let j; try { j = JSON.parse(r.responseText); } catch (e) { appendLog('Wallstream', `JSON parse: ${e.message}`, 'error'); return null; } const albums = j?.items?.albums?.items || []; appendLog('Wallstream', `upc:${code} → ${albums.length} album(s)${albums.length ? ': ' + albums.map(x => `"${x.name}" ${x.external_urls?.spotify || x.id}`).join(' | ') : ''}`); const hit = albums.find(x => x?.external_urls?.spotify || x?.id); if (hit) { const albumUrl = hit.external_urls?.spotify || `https://open.spotify.com/album/${hit.id}`; appendLog('Wallstream', `exact-barcode Spotify album (upc:${code}): ${albumUrl}`, 'ok'); return albumUrl; } } appendLog('Wallstream', `no Spotify album for any barcode form`, 'warn'); return null; } // Concurrent search-engine queries from the same IP can trip anti-bot pages. // Serialize them on one chain so two scanners never hit the same engine at once. let searchChain = Promise.resolve(); function queueSearch(fn) { const p = searchChain.then(fn, fn); searchChain = p.catch(() => {}); return p; } // ─── Per-platform scanners ───────────────────────────────────────────────── // Fetch Spotify album metadata via the server-rendered /embed/album/<id> page. // The embed ships an inline JSON blob; we extract title + track count + year + // label by regex (lighter and more tolerant than full-tree parsing). async function fetchSpotifyMeta(albumUrl) { const idMatch = albumUrl.match(/album\/([a-zA-Z0-9]{22})/); if (!idMatch) return null; const embedUrl = `https://open.spotify.com/embed/album/${idMatch[1]}`; const er = await gmGet(embedUrl); if (!er.ok) return null; const html = er.responseText; const trackUris = [...html.matchAll(/"uri":"spotify:track:[a-zA-Z0-9]+"/g)]; const titleMatch = html.match(/"name"\s*:\s*"([^"]+)"/); const yearMatch = html.match(/"releaseDate":"(\d{4})-/) || html.match(/"year"\s*:\s*(\d{4})/); const labelMatch = html.match(/"label":"([^"]+)"/) || html.match(/"copyrights":\s*\[\s*\{\s*"text"\s*:\s*"([^"]+)"/); // Spotify embed stores the album-level artist credit as the first // `"subtitle":"…"` field (subsequent subtitles are per-track artist // credits). The bigger Spotify metadata schemas use `"artists":[…]` // but the embed compresses them all to `subtitle`. const subtitleMatch = html.match(/"subtitle"\s*:\s*"([^"]+)"/); const artist = subtitleMatch?.[1] || null; return { tracks: trackUris.length || null, title: titleMatch?.[1] || null, year: yearMatch?.[1] || null, label: labelMatch?.[1] || null, artist, }; } async function scanSpotify({ artist, album, mbTracks, existingUrl, mbid, wikidataSpotifyId, isVariousArtists, barcode }) { const label = 'Spotify'; // Cache hit WITH URL → use it and skip everything else. A cached "no // match" (url:null) is NOT a short-circuit — a fresh Wikidata answer // can still override it. Only if Wikidata also has nothing AND no // existing rel do we fall back to rendering the cached no-match // (without rerunning search engines; ↻ forces full retry). const cached = cacheGet(mbid, 'spotify'); if (cached?.url && (!existingUrl || existingUrl === cached.url)) { appendLog('Wallstream', `skipped — Spotify URL cached from a previous scan (↻ to re-run): ${cached.url}`); applyCachedRow('spotify', label, cached, mbTracks); return; } let albumUrl = existingUrl; let source = null; let bestMeta = null; let exactBarcode = false; // (#182) true when the URL was resolved by exact UPC // Barcode lookup (#602) runs here, inside the Spotify scanner, so it no // longer delays the other providers' scans. Every skip is logged, so an // empty Wallstream log never leaves you guessing whether it ran. let barcodeUrl = null; if (albumUrl) appendLog('Wallstream', `skipped — Spotify already linked in MB: ${albumUrl}`); else if (!barcode) appendLog('Wallstream', `skipped — release has no barcode`); else barcodeUrl = await lookupWallstreamSpotify(barcode); if (albumUrl) { appendLog(label, `Using existing MB URL: ${albumUrl}`, 'ok'); source = 'MB rels'; } else if (barcodeUrl) { // Wallstream resolved the exact-barcode Spotify album. It beats // Wikidata/search, which can point at a different-barcode edition. albumUrl = barcodeUrl; source = 'Wallstream (barcode)'; exactBarcode = true; appendLog(label, `Wallstream barcode match → ${albumUrl}`, 'ok'); } else if (wikidataSpotifyId) { albumUrl = `https://open.spotify.com/album/${wikidataSpotifyId}`; appendLog(label, `Wikidata answer: ${albumUrl}`, 'ok'); source = 'Wikidata'; } else if (cached) { // Cache says "no match", no MB rel, Wikidata had no answer — surface // the cached state without re-running web search (the user already // saw this; they have ↻ to force a fresh search). appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('spotify', label, cached, mbTracks); return; } else { // Restrict the `site:` filter to the /album/ path so artist pages, // playlists, tracks, and shows never enter the candidate list. Use // punctuation-stripped tokens (not a quoted exact phrase) so the // engine matches "Space Echo - The Mystery..." against MB's // "Space Echo: The Mystery...!". For VA compilations drop the artist // term because the literal phrase "Various Artists" doesn't appear // on streaming pages — labels host compilations under their own name. const albumT = searchTerms(album); const artistT = searchTerms(artist); const q = isVariousArtists ? `site:open.spotify.com/album/ ${albumT}` : `site:open.spotify.com/album/ ${artistT} ${albumT}`; const candidates = await searchWeb(q, u => /open\.spotify\.com\/(?:intl-[a-z-]+\/)?album\/[a-zA-Z0-9]{22}/.test(u), label); if (!candidates.length) { // Cache "no match" so a refresh-less page re-visit doesn't re-search. cacheSet(mbid, 'spotify', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('spotify', { url: null, mbTracks, remoteTracks: null }); return; } appendLog(label, `Verifying ${candidates.length} candidate(s) by tracks + title + artist…`); const best = await pickBestCandidate(candidates, fetchSpotifyMeta, mbTracks, album, label, artist, isVariousArtists); if (!best || best.score < 120) { appendLog(label, `No verifiable match (best score=${best?.score ?? 'n/a'}) — leaving URL unset`, 'warn'); cacheSet(mbid, 'spotify', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('spotify', { url: null, mbTracks, remoteTracks: null }); return; } albumUrl = best.url; bestMeta = best.meta; appendLog(label, `Picked best (score=${best.score}): ${albumUrl}`, best.score >= 150 ? 'ok' : 'warn'); source = 'search'; } const meta = bestMeta || await fetchSpotifyMeta(albumUrl); if (meta) { appendLog(label, `Embed parsed: tracks=${meta.tracks} title="${meta.title}" year=${meta.year || '?'} label=${meta.label || '?'}`, meta.tracks ? 'ok' : 'warn'); } else { appendLog(label, `Embed fetch failed`, 'error'); } // The embed strips year/label entirely. Fetch the public album page once // we've settled on a URL to enrich year for display — the JSON-LD blob // includes `datePublished`. Label is still unobtainable without OAuth. let displayYear = meta?.year ?? null; if (!displayYear) { try { const pr = await gmGet(albumUrl); if (pr.ok) { const m = pr.responseText.match(/"datePublished"\s*:\s*"(\d{4})/); if (m) { displayYear = m[1]; appendLog(label, `Album page year: ${displayYear}`); } } } catch (_) { /* enrichment is best-effort */ } } const tracks = meta?.tracks ?? null; const lbl = meta?.label ?? null; const sbc = exactBarcode ? MB_BARCODE : null; // exact UPC → MB barcode (no mismatch); else unknown cacheSet(mbid, 'spotify', { url: albumUrl, tracks, year: displayYear, label: lbl, source, barcode: sbc }); updateRow('spotify', { url: albumUrl, mbTracks, remoteTracks: tracks, year: displayYear, label: lbl, source, barcode: sbc }); } // ─── Qobuz ─────────────────────────────────────────────────────────────────── // Qobuz's catalogue API (api.json/0.2) with app_id `712109809`: `album/search` // works unauthenticated (barcode-first search + UPC capture, like the other API // providers), but `album/get` — which also carries `tracks_count` + `upc` for a // direct verify — is session-gated (404/401 anonymously, #353). So when the user // has logged in via ⚙ Setup → Auth (token in mbtools:qobuz), we attach it and the // API path becomes reliable; otherwise Qobuz falls back to scraping the // server-rendered store page. (#201/#353, shapes per chaban-mb / Harmony.) const QOBUZ_APP_ID = '712109809'; function qobuzApi(path) { const headers = { Accept: 'application/json', 'X-App-Id': QOBUZ_APP_ID }; const t = qbRead(); // #353 the user's stored token unlocks album/get if (t && t.token) headers['X-User-Auth-Token'] = t.token; return gmGet(`https://www.qobuz.com/api.json/0.2/${path}${path.includes('?') ? '&' : '?'}app_id=${QOBUZ_APP_ID}`, { headers }) .then(r => { if (!r.ok) return null; try { return JSON.parse(r.responseText); } catch { return null; } }); } // the album slug-id is the API's album_id (e.g. .../album/<slug>/vft3hpnx5c3lc) function qobuzAlbumId(url) { const m = String(url || '').match(/qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/?#]+\/)?([a-z0-9]+)/i); return m ? m[1] : null; } const qobuzMetaFromApi = d => (d && d.id) ? { tracks: d.tracks_count ?? null, title: d.title || null, year: String(d.release_date_original || d.release_date_stream || '').slice(0, 4) || null, label: (d.label && d.label.name) || null, artist: (d.artist && d.artist.name) || null, barcode: d.upc || null, credits: pcCreditsQobuzApi(d), // #671 } : null; const qzDec = s => String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/�?39;/g, "'").replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n)); // Normalise any Qobuz album URL to the server-rendered www store page. open./play. // are SPA shells with no credits in the HTML, and an MB rel is often the slug-less // open form — a wrong-slug www URL 301-redirects to the canonical page, so this // always lands somewhere scrapeable. (URL shapes per Harmony; #201, chaban-mb) function qobuzStoreUrl(albumUrl) { const m = String(albumUrl || '').match(/qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/?#]+\/)?([a-z0-9]+)/i); if (!m) return albumUrl; if (/^https?:\/\/www\.qobuz\.com\/[a-z]{2}-[a-z]{2}\/album\/[^/]+\/[a-z0-9]+/i.test(albumUrl)) return albumUrl; // already a www store page return `https://www.qobuz.com/us-en/album/x/${m[1]}`; } async function fetchQobuzMeta(albumUrl) { // Prefer the API (structured + carries the UPC); fall back to scraping the // store page where the API returns nothing (geo) or 429s. #201/#353 const id = qobuzAlbumId(albumUrl); if (id) { const signedIn = qbLoggedIn(); const m = qobuzMetaFromApi(await qobuzApi(`album/get?album_id=${encodeURIComponent(id)}`)); if (m && m.tracks) { m._via = 'api'; return m; } // tagged so scanQobuz can log the real source (see below) // #353 say WHY we're scraping so a logged-in user isn't left wondering: not signed in on // THIS origin → prompt; signed in but the API gave nothing → note it (geo / stale session). appendLog('Qobuz', signedIn ? `album/get returned no data despite being signed in — scraping instead (Qobuz may be geo-restricting this session)` : `not signed in to Qobuz on this site — scraping. Log in via ⚙ Setup → Auth for the reliable album/get path (track count + barcode)`, signedIn ? 'warn' : 'info'); } const scraped = await fetchQobuzScrape(albumUrl); if (scraped) scraped._via = 'page'; return scraped; } async function fetchQobuzScrape(albumUrl) { let r = await gmGet(qobuzStoreUrl(albumUrl)); // Qobuz rate-limits very aggressively (429 after only a few requests, #201). // One polite retry honouring Retry-After; if it persists, report it distinctly // so the scanner can leave the row retryable instead of caching a false "no match". if (r.status === 429) { const ra = parseInt((String(r.responseHeaders || '').match(/retry-after:\s*(\d+)/i) || [])[1], 10); await new Promise(z => setTimeout(z, Math.min((ra > 0 ? ra : 3) * 1000, 10000))); r = await gmGet(qobuzStoreUrl(albumUrl)); } if (r.status === 429) return { rateLimited: true }; if (!r.ok || !r.responseText) return null; const html = r.responseText; // Track count: the page renders an empty duplicate of every track row for // its responsive layout, so the `track__info` blocks double-count. Each // real track instead carries a unique add-to-cart marker — count those. const trackIds = new Set([...html.matchAll(/popinAddToCartBtnPlayerTrack(\d+)/g)].map(m => m[1])); // Album name + year from the JSON-LD MusicAlbum block (clean, unlike og:title). let title = null, year = null; for (const m of html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)) { try { const j = JSON.parse(m[1]); if (/MusicAlbum/.test(j['@type'] || '')) { if (j.name) title = qzDec(j.name); if (j.datePublished) year = String(j.datePublished).slice(0, 4); } } catch (e) { /* skip */ } } // Artist: og:title is "<Album>, <Artist> - Qobuz"; strip the suffix and the // album-name prefix to leave the credit (VA pages read "Various Artists"). const og = qzDec((html.match(/<meta property="og:title" content="([^"]*)"/) || [])[1] || '').replace(/\s*-\s*Qobuz\s*$/i, ''); let artist = null; if (title && og.startsWith(title)) artist = og.slice(title.length).replace(/^[,\s]+/, '').trim() || null; else if (og.includes(',')) artist = og.slice(og.lastIndexOf(',') + 1).trim() || null; const label = qzDec((html.match(/href="\/[a-z]{2}-[a-z]{2}\/label\/[^"]+"[^>]*>\s*([^<]{1,80})/) || [])[1] || '').trim() || null; // `url` = the page we actually landed on after redirects — i.e. Qobuz's REAL // canonical /album/<slug>/<id> (the slug includes the artist, so we can't // reconstruct it from the title; #201 chaban-mb). The caller uses it as the URL // to hand MB instead of the /album/x/<id> placeholder we fetched with. return { tracks: trackIds.size || null, title: title || og || null, year, label, artist, barcode: null, url: r.finalUrl || null, credits: pcCreditsQobuzPage(html) }; } async function scanQobuz({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, barcode }) { const label = 'Qobuz'; const cached = cacheGetScan(mbid, 'qobuz', label); if (cached?.url && (!existingUrl || existingUrl === cached.url)) { applyCachedRow('qobuz', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl && !barcode) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('qobuz', label, cached, mbTracks); return; } // Barcode-first: the API matches an exact UPC with no text-search ambiguity. // Qobuz indexes the UPC in its stored form, usually the 13-digit EAN with a // leading zero (e.g. MB's 199257198605 is "0199257198605" there), so a query // with MB's bare barcode misses — try the other GTIN paddings too (#354 shared // gtinVariants: raw · stripped · 12/13/14). Geo-dependent, so it falls through // to the normal search on no hit. #201 (chaban-mb) if (!existingUrl && barcode) { const queries = gtinVariants(barcode); let items = []; for (const q of queries) { const s = await qobuzApi(`album/search?query=${encodeURIComponent(q)}&limit=10`); items = s?.albums?.items || []; if (items.some(a => normBarcode(a.upc) === normBarcode(barcode))) break; } const hit = items.find(a => normBarcode(a.upc) === normBarcode(barcode)) || items[0]; if (hit && hit.id) { // Qobuz's slug includes the artist, so it can't be rebuilt from the title // (#201 chaban-mb). Use the REAL slug the API returns (`slug` / `relative_url`); // if geo-stripped from the response, fall back to fetching /album/x/<id> // (Qobuz 301s it to the canonical URL) and keeping that landed URL. const fetchUrl = `https://www.qobuz.com/us-en/album/x/${hit.id}`; const meta = await fetchQobuzMeta(fetchUrl); const apiUrl = hit.slug ? `https://www.qobuz.com/us-en/album/${hit.slug}/${hit.id}` : (hit.relative_url ? `https://www.qobuz.com/us-en${hit.relative_url}` : null); const albumUrl = apiUrl || meta?.url || fetchUrl; appendLog(label, `Barcode ${barcode} → ${albumUrl}`, 'ok'); const bc = meta?.barcode || hit.upc || barcode; cacheSet(mbid, 'qobuz', { url: albumUrl, tracks: meta?.tracks ?? null, year: meta?.year ?? null, label: meta?.label ?? null, source: 'barcode', barcode: bc, credits: pcKeep(label, meta?.credits) }); updateRow('qobuz', { url: albumUrl, mbTracks, remoteTracks: meta?.tracks ?? null, year: meta?.year ?? null, label: meta?.label ?? null, source: 'barcode', barcode: bc }); return; } appendLog(label, `Barcode ${barcode}: no API hit — falling back to search`); } let albumUrl = existingUrl; let source = null; let bestMeta = null; if (albumUrl) { appendLog(label, `Using existing MB URL: ${albumUrl}`, 'ok'); source = 'MB rels'; } else if (cached) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('qobuz', label, cached, mbTracks); return; } else { const albumT = searchTerms(album); const artistT = searchTerms(artist); // Qobuz album URLs are /<store>/album/<slug>/<id>; the store prefix varies, // so we site-restrict to qobuz.com and keep only /album/.../<id> hits. const q = isVariousArtists ? `site:qobuz.com ${albumT} album` : `site:qobuz.com ${artistT} ${albumT}`; const candidates = await searchWeb(q, u => /qobuz\.com\/[a-z]{2}-[a-z]{2}\/album\/[^/]+\/[a-z0-9]+/i.test(u), label); if (!candidates.length) { cacheSet(mbid, 'qobuz', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('qobuz', { url: null, mbTracks, remoteTracks: null }); return; } appendLog(label, `Verifying ${candidates.length} candidate(s) by tracks + title + artist…`); const best = await pickBestCandidate(candidates, fetchQobuzMeta, mbTracks, album, label, artist, isVariousArtists); if (!best || best.score < 120) { appendLog(label, `No verifiable match (best score=${best?.score ?? 'n/a'}) — leaving URL unset`, 'warn'); cacheSet(mbid, 'qobuz', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('qobuz', { url: null, mbTracks, remoteTracks: null }); return; } albumUrl = best.url; bestMeta = best.meta; appendLog(label, `Picked best (score=${best.score}): ${albumUrl}`, best.score >= 150 ? 'ok' : 'warn'); source = 'search'; } const meta = bestMeta || await fetchQobuzMeta(albumUrl); if (meta && meta.rateLimited) { // Don't cache — Qobuz throttled us, not a real "no match". ↻ retries. (#201) appendLog(label, `Rate-limited by Qobuz (429) — the link is set, track-count unverified; use ↻ to retry`, 'warn'); updateRow('qobuz', { url: albumUrl, mbTracks, remoteTracks: null, source }); return; } if (meta) { // #353 name the real source — "API album/get" (signed in; carries the barcode) vs "store page" scrape const via = meta._via === 'api' ? 'API album/get' : 'store page'; appendLog(label, `Verified via ${via}: tracks=${meta.tracks} title="${meta.title}" year=${meta.year || '?'} label=${meta.label || '?'}${meta.barcode ? ` barcode=${meta.barcode}` : ''}`, meta.tracks ? 'ok' : 'warn'); } else { appendLog(label, `Album fetch failed`, 'error'); } const tracks = meta?.tracks ?? null; const year = meta?.year ?? null; const lbl = meta?.label ?? null; const bc = meta?.barcode ?? null; // UPC from the API (null on the scrape fallback) — feeds barcode-confidence #201 cacheSet(mbid, 'qobuz', { url: albumUrl, tracks, year, label: lbl, source, barcode: bc, credits: pcKeep(label, meta?.credits) }); updateRow('qobuz', { url: albumUrl, mbTracks, remoteTracks: tracks, year, label: lbl, source, barcode: bc }); } // MB's media[].format strings → Discogs API's `format` query value. Other // formats (DVD/Blu-ray/SACD/etc.) we just leave unfiltered — those are rare // enough on Discogs that adding a filter hurts more than it helps. function mbFormatToDiscogs(mbFormat) { if (!mbFormat) return null; const f = String(mbFormat).toLowerCase(); if (f.includes('vinyl')) return 'Vinyl'; if (f.includes('cassette')) return 'Cassette'; if (f.includes('digital') || f === 'file') return 'File'; if (f === 'cd' || f.includes('cd')) return 'CD'; return null; } async function scanDiscogs({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, format, barcode, existingDiscogsMaster }) { const label = 'Discogs'; appendLog(label, `Existing RG master: ${existingDiscogsMaster || 'none'}`); // Surface the existing release-group Discogs master immediately — it's the // authoritative RG-level link (use it, same as any existing MB rel), and it // shouldn't depend on whether we manage to resolve a Discogs *release*. if (existingDiscogsMaster) { const mEl = document.getElementById('master-discogs'); if (mEl) applyMasterIcon(mEl, discogsMasterState(null, existingDiscogsMaster)); } // Positive cache hit short-circuits before any API call. Cached "no // match" only short-circuits when there's no MB rel either — we still // want a freshly-added MB rel to replace a stale no-match. // // Format-mismatch invalidation: if the cached release's format differs // from the MB release format (e.g. cache holds a Vinyl pressing but MB // says CD — possible when a prior scan ran without format extraction), // re-search so format-aware ranking can pick the matching edition. const cached = cacheGetScan(mbid, 'discogs', label); const wantFmt = mbFormatToDiscogs(format); const haveFmt = mbFormatToDiscogs(cached?.format); const formatMismatch = !!(wantFmt && haveFmt && wantFmt !== haveFmt && cached?.url && !existingUrl); if (formatMismatch) { appendLog(label, `Cached match is ${haveFmt} but MB format is ${wantFmt} — re-scanning`, 'warn'); } else if (cached?.url && (!existingUrl || existingUrl === cached.url)) { applyCachedRow('discogs', label, cached, mbTracks, discogsMasterState(cached.masterUrl, existingDiscogsMaster)); return; } if (cached && !cached.url && !existingUrl) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('discogs', label, cached, mbTracks, discogsMasterState(cached.masterUrl, existingDiscogsMaster)); return; } let releaseUrl = existingUrl; let releaseId = null; let source = null; if (releaseUrl) { appendLog(label, `Using existing MB URL: ${releaseUrl}`, 'ok'); source = 'MB rels'; const m = releaseUrl.match(/\/release\/(\d+)/); if (m) releaseId = m[1]; } else { // Barcode-first (#182): Discogs' search API supports `barcode=<UPC>` — an exact // match, preferred over the text search (which can pick a different pressing of // the same title). Falls through to the text search when there's no UPC hit. if (barcode) { const bu = `https://api.discogs.com/database/search?barcode=${encodeURIComponent(barcode)}&type=release&per_page=5`; appendLog(label, `Barcode ${barcode}: ${bu}`); const br = await gmGet(bu); if (br.ok) { try { const bres = JSON.parse(br.responseText).results || []; let bbest = null; for (const r of bres.slice(0, 8)) { const [artistPart, ...albumParts] = (r.title || '').split(' - '); const sc = scoreCandidate({ title: albumParts.join(' - '), artist: artistPart }, mbTracks, album, artist, isVariousArtists); if (!bbest || sc > bbest.score) bbest = { score: sc, item: r }; } if (bbest && bbest.score >= 50) { releaseId = String(bbest.item.id); releaseUrl = `https://www.discogs.com/release/${releaseId}`; source = 'barcode'; appendLog(label, `Barcode ${barcode} → ${releaseUrl}`, 'ok'); } else appendLog(label, `Barcode ${barcode}: ${bres.length ? 'no confident match' : 'no result'} — falling back to text search`); } catch (e) { appendLog(label, `Barcode search parse error: ${e.message}`, 'error'); } } else appendLog(label, `Barcode ${barcode} search failed — falling back to text search`, 'warn'); } if (!releaseUrl) { // (#182) text search only when barcode-first didn't resolve // Discogs HTTP UI is Cloudflare-protected (403); the public API works // without an auth token for search + detail (~25 req/min unauth'd). // For VA compilations drop the artist term — Discogs doesn't credit // compilations to a literal "Various Artists" string, so including // it in the query produces 0 results. const apiQ = isVariousArtists ? album : `${artist} ${album}`; const discogsFmt = mbFormatToDiscogs(format); // Format-aware first try: a CD release shouldn't pick a vinyl Discogs // entry when a CD edition exists. If the format-filtered search // returns 0 results, retry without the format filter and accept // whatever's available. const trySearch = async withFormat => { const u = `https://api.discogs.com/database/search?q=${encodeURIComponent(apiQ)}&type=release&per_page=5${withFormat ? `&format=${encodeURIComponent(discogsFmt)}` : ''}`; appendLog(label, `API search${withFormat ? ` (format=${discogsFmt})` : ''}: ${u}`); const r = await gmGet(u); appendLog(label, `API search: status=${r.status} ${r.responseText.length}b in ${r.ms}ms`); return r; }; let sr = await trySearch(!!discogsFmt); if (sr.ok) { try { let data = JSON.parse(sr.responseText); if ((data.results || []).length === 0 && discogsFmt) { appendLog(label, `0 results with format=${discogsFmt} — retrying without format filter`, 'warn'); sr = await trySearch(false); data = sr.ok ? JSON.parse(sr.responseText) : { results: [] }; } // Score every result by parsing its `title` ("Artist - Album") // and matching against MB artist + album. Discogs search // results don't expose track count, so we score on title + // artist only — the detail fetch later will catch a track // mismatch separately. Picking the *best* candidate (rather // than blindly taking results[0]) fixes the "13-track Mista // Savona release picked for a 4-track Om Unit album" bug. const candidates = (data.results || []).slice(0, 8); let best = null; for (const r of candidates) { const [artistPart, ...albumParts] = (r.title || '').split(' - '); const albumPart = albumParts.join(' - '); const sc = scoreCandidate({ title: albumPart, artist: artistPart }, mbTracks, album, artist, isVariousArtists); appendLog(label, ` cand score=${sc} id=${r.id} artist="${artistPart}" album="${albumPart}" format=${(r.format || []).join(',')}`); if (!best || sc > best.score) best = { score: sc, item: r }; if (sc >= 150) break; } if (best && best.score >= 50) { releaseId = String(best.item.id); releaseUrl = `https://www.discogs.com/release/${releaseId}`; source = discogsFmt && (best.item.format || []).some(f => mbFormatToDiscogs(f) === discogsFmt) ? `API search (format=${discogsFmt})` : 'API search'; appendLog(label, `Found via API (score=${best.score}): ${releaseUrl}`, 'ok'); } else if (candidates.length) { appendLog(label, `Top candidate score=${best?.score ?? 'n/a'} below 50 threshold — no confident match`, 'warn'); } else { appendLog(label, `API search returned 0 results`, 'warn'); } } catch (e) { appendLog(label, `API JSON parse error: ${e.message}`, 'error'); } } else { appendLog(label, `API search failed`, 'error'); } } // (#182) end of barcode-first guard around the text search if (!releaseUrl) { // searchWeb returns an ARRAY of filtered candidate URLs (not a single string). The old code // did `if (fallback) { fallback.match(...) }` — but `[]` is truthy, so an empty result threw a // TypeError that aborted the whole scan before the not-found render below. Take the first hit. const candidates = await searchWeb(`site:discogs.com release ${artist} ${album}`, u => /www\.discogs\.com\/release\/\d+/.test(u) || /www\.discogs\.com\/.*\/release\/\d+/.test(u), label); const hit = candidates[0]; if (hit) { releaseUrl = hit; const m = hit.match(/\/release\/(\d+)/); if (m) releaseId = m[1]; source = 'web search'; } } } if (!releaseUrl) { cacheSet(mbid, 'discogs', { url: null, tracks: null, year: null, label: null, source: 'search' }); // Even with no resolved release, surface the existing RG-level Discogs master. updateRow('discogs', { url: null, mbTracks, remoteTracks: null, masterState: discogsMasterState(null, existingDiscogsMaster) }); return; } let tracks = null, year = null, lbl = null, fmt = null, masterUrl = null, foundBarcode = null, credits = null, catnos = null; if (releaseId) { const detailUrl = `https://api.discogs.com/releases/${releaseId}`; appendLog(label, `API detail: ${detailUrl}`); const dr = await gmGet(detailUrl); appendLog(label, `API detail: status=${dr.status} ${dr.responseText.length}b in ${dr.ms}ms`); if (dr.ok) { try { const data = JSON.parse(dr.responseText); // Discogs tracklist entries have type_ values "track" (normal), // "index" (a parent of sub_tracks, used for medleys like the // 9a/9b/9c entries on Essiebons Special — a single MB // position 9 with 3 songs underneath), and "heading" (section // dividers). MB counts the medley parent as one position, so // include both "track" and "index" — exclude only "heading". const trk = (data.tracklist || []).filter(t => t.type_ === 'track' || t.type_ === 'index' || !t.type_); tracks = trk.length || null; year = data.year || null; lbl = (data.labels || []).map(l => l.name).join(', ') || null; // Discogs `formats` is an array of {name, qty, descriptions}. // We only want the headline — qty + name (e.g. "2×Vinyl") — and // skip the pressing-detail descriptions ('12"', '33 ⅓ RPM', // 'Mini-Album', etc.) that bloat the meta line without adding // useful info for verifying it's the right release. fmt = (data.formats || []).map(f => { return (f.qty && f.qty !== '1' ? `${f.qty}×` : '') + (f.name || ''); }).join(', ') || null; // Master URL — points at the release-group equivalent on Discogs. // Stored so the + flow can offer to add it to MB's release-group // url-rels (a separate edit page from the release). if (data.master_id) masterUrl = `https://www.discogs.com/master/${data.master_id}`; // Barcode (#182): Discogs publishes it in `identifiers` ([{type:"Barcode", value}]). // Capturing it lets the barcode-confidence check compare/confirm Discogs like the // other barcode-exposing providers (previously it was treated as unconfirmable). foundBarcode = ((data.identifiers || []).find(i => /^barcode$/i.test(i.type || '')) || {}).value || null; if (foundBarcode) foundBarcode = String(foundBarcode).replace(/\s+/g, ''); credits = pcKeep(label, pcCreditsDiscogs(data)); // #671 // #740: each label's catalog number; Discogs writes "none" for a release without one catnos = (data.labels || []).filter(l => l.name && l.catno && !/^\[?none\]?$/i.test(l.catno.trim())).map(l => ({ label: l.name.replace(/\s*\(\d+\)$/, ''), catno: l.catno.trim() })); if (catnos.length) appendLog(label, `API detail: catalog number ${catnos.map(c => `${c.catno} (${c.label})`).join(', ')}`); appendLog(label, `API detail parsed: tracks=${tracks} year=${year || '?'} label=${lbl || '?'} format=${fmt || '?'} barcode=${foundBarcode || '-'} master=${masterUrl || '-'}`, 'ok'); } catch (e) { appendLog(label, `API detail parse error: ${e.message}`, 'error'); } } else { appendLog(label, `API detail failed`, 'error'); } } cacheSet(mbid, 'discogs', { url: releaseUrl, tracks, year, label: lbl, format: fmt, masterUrl, source, barcode: foundBarcode, catnos, credits }); updateRow('discogs', { url: releaseUrl, mbTracks, remoteTracks: tracks, year, label: lbl, format: fmt, source, barcode: foundBarcode, masterState: discogsMasterState(masterUrl, existingDiscogsMaster), }); } // Fetch Bandcamp album metadata via the standard album page. Bandcamp ships // a Schema.org JSON-LD block with numTracks/name/datePublished/recordLabel as // native JSON (not HTML-escaped) — the cleanest source. The legacy `data-tralbum` // attribute has the same data but with " entities and requires decoding. async function fetchBandcampMeta(albumUrl) { const ar = await gmGet(albumUrl); if (!ar.ok || !ar.responseText) return null; const html = ar.responseText; const numTracksMatch = html.match(/"numTracks"\s*:\s*(\d+)/); const titleMatch = html.match(/"@type"\s*:\s*"MusicAlbum"[\s\S]{0,200}?"name"\s*:\s*"([^"]+)"/) || html.match(/<meta\s+name="title"\s+content="([^"|]+)/); const yMatch = html.match(/"datePublished"\s*:\s*"[^"]*?(\d{4})\b/); const lMatch = html.match(/"recordLabel"\s*:\s*\{[^}]*?"name"\s*:\s*"([^"]+)"/); // Album artist comes from JSON-LD `"byArtist":{"name":"…"}` (the band/ // label that hosts the page may differ from the album artist on // multi-artist labels, so prefer byArtist). const artistMatch = html.match(/"byArtist"\s*:\s*\{[^}]*?"name"\s*:\s*"([^"]+)"/) || html.match(/<meta\s+name="Description"\s+content="from\s+([^"|,]+)/i); const formats = [...new Set( [...html.matchAll(/"musicReleaseFormat"\s*:\s*"(\w+)"/g)] .map(m => m[1].replace(/Format$/, '')) )]; // Hidden download-only tracks (#183): the JSON-LD `numTracks` (and the player's // trackinfo) only count STREAMABLE tracks, but the og:description meta tag — // "N track album" — carries the REAL total including bonus tracks that are only // in the download. So total − streaming = hidden. (Only trust the tag when it // matches "N track album"; an artist-set custom description won't, and we just // fall back to numTracks.) Same signal Harmony / bandcamp_importer use. const streaming = numTracksMatch ? parseInt(numTracksMatch[1], 10) : null; const ogTag = html.match(/<meta[^>]+property=["']og:description["'][^>]*>/i)?.[0] || ''; const ogTotal = (ogTag.match(/content=["']\s*(\d+)\s+track\b/i) || [])[1]; const total = ogTotal ? parseInt(ogTotal, 10) : null; const hiddenTracks = (total != null && streaming != null && total > streaming) ? total - streaming : 0; // Barcode (#194): the UPC lives in TralbumData.current.upc — embedded in the // page's `data-tralbum` attribute (entity-encoded JSON), not the JSON-LD. // Often null (Bandcamp barcodes are hand-entered). Per Harmony (kellnerd/harmony#42) // the digital `current.upc` can coincide with a physical package's barcode — // when it does, it's the package's, not the digital release's, so we drop it. let barcode = null, barcodeIsPackage = false; const trm = html.match(/data-tralbum="([^"]*)"/); if (trm) { try { const tr = JSON.parse(trm[1].replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&').replace(/>/g, '>').replace(/</g, '<')); const upc = tr && tr.current && tr.current.upc; if (upc) { const pkgUpcs = (tr.packages || []).map(pp => normBarcode(pp && pp.upc)).filter(Boolean); if (pkgUpcs.includes(normBarcode(upc))) barcodeIsPackage = true; // physical package's barcode — not the digital release's else barcode = String(upc).trim(); } } catch (e) { /* malformed data-tralbum — no barcode */ } } return { // report the true total (incl. hidden) as the track count — that's what the // Bandcamp release actually contains, and what MB compares against. tracks: total != null ? total : streaming, streamingTracks: streaming, hiddenTracks, title: titleMatch?.[1] || null, year: yMatch?.[1] || null, label: lMatch?.[1] || null, format: formats.length ? formats.join(', ') : null, artist: artistMatch?.[1]?.trim() || null, barcode, barcodeIsPackage, credits: pcCreditsBandcamp(html), // #671 }; } // Bandcamp's own search at /search?item_type=a returns a server-rendered HTML // list of album results — works without any token in a real browser (the // browser carries Bandcamp's CF clearance cookie). Try this BEFORE the generic // web-search engines: when both are available it's faster, lower-noise (only // album results), and not subject to Brave/DDG rate-limits. Falls through to // `searchWeb` if Cloudflare's bot challenge fires from a cookie-less context. async function searchBandcampNative(query, label) { const url = `https://bandcamp.com/search?q=${encodeURIComponent(query)}&item_type=a`; appendLog(label, `Native: ${url}`); const res = await gmGet(url, { headers: { 'Accept': 'text/html,application/xhtml+xml' } }); appendLog(label, `Native: status=${res.status} ${res.responseText.length}b in ${res.ms}ms`); if (!res.ok) return []; // Cloudflare's interstitial is ~3 KB and titled "Client Challenge". When we // see it, log it and let the caller fall through to web search — in a real // browser with prior Bandcamp cookies this branch is skipped. if (/<title>\s*Just a moment/i.test(res.responseText) || /<title>\s*Client Challenge/i.test(res.responseText) || res.responseText.length < 5000) { appendLog(label, `Native: blocked by Cloudflare challenge (cookie-less request) — falling through`, 'warn'); return []; } // Bandcamp's search-result anchors point at full *.bandcamp.com/album/<slug> // URLs. Strip query strings and dedupe. const urls = [...res.responseText.matchAll(/href="(https?:\/\/[a-z0-9-]+\.bandcamp\.com\/album\/[^"?#]+)/gi)].map(m => m[1]); const unique = [...new Set(urls)]; appendLog(label, `Native: ${unique.length} unique album link(s)`, unique.length ? 'ok' : 'warn'); return unique.slice(0, 5); } async function scanBandcamp({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists }) { const label = 'Bandcamp'; const cached = cacheGetScan(mbid, 'bandcamp', label); if (cached?.url && (!existingUrl || existingUrl === cached.url)) { applyCachedRow('bandcamp', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('bandcamp', label, cached, mbTracks); return; } let albumUrl = existingUrl; let source = null; let bestMeta = null; if (albumUrl) { appendLog(label, `Using existing MB URL: ${albumUrl}`, 'ok'); source = 'MB rels'; } else { // Priority 1: Bandcamp's own search (works when the browser has CF // clearance — common after any prior Bandcamp visit). Skip when // blocked. VA compilations: search by album only — Bandcamp credits // them to the label, not "Various Artists". const albumT = searchTerms(album); const artistT = searchTerms(artist); const nativeQ = isVariousArtists ? albumT : `${artistT} ${albumT}`; let candidates = await searchBandcampNative(nativeQ, label); let candidateSource = 'native'; if (!candidates.length) { // Priority 2: generic web search. Punctuation-stripped tokens, no // exact-phrase quotes — MB's "Foo: Bar!" and Bandcamp's // "Foo - Bar" still hit each other this way; the verifier handles // the rest. Bandcamp candidate pages are ~300 KB so cap at 3. const q = isVariousArtists ? `site:bandcamp.com/album/ ${albumT}` : `site:bandcamp.com/album/ ${artistT} ${albumT}`; candidates = await searchWeb(q, u => /^https?:\/\/[a-z0-9-]+\.bandcamp\.com\/album\//i.test(u), label, 3); candidateSource = 'search'; } if (!candidates.length) { cacheSet(mbid, 'bandcamp', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('bandcamp', { url: null, mbTracks, remoteTracks: null }); return; } appendLog(label, `Verifying ${candidates.length} candidate(s) by tracks + title + artist…`); const best = await pickBestCandidate(candidates, fetchBandcampMeta, mbTracks, album, label, artist, isVariousArtists); if (!best || best.score < 120) { appendLog(label, `No verifiable match (best score=${best?.score ?? 'n/a'}) — leaving URL unset`, 'warn'); cacheSet(mbid, 'bandcamp', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('bandcamp', { url: null, mbTracks, remoteTracks: null }); return; } albumUrl = best.url; bestMeta = best.meta; appendLog(label, `Picked best (score=${best.score}): ${albumUrl}`, best.score >= 150 ? 'ok' : 'warn'); source = candidateSource; } const meta = bestMeta || await fetchBandcampMeta(albumUrl); const hidden = meta?.hiddenTracks || 0; if (meta) { const trk = hidden > 0 ? `${meta.tracks} (${meta.streamingTracks} streaming + ${hidden} download-only hidden)` : `${meta.tracks}`; appendLog(label, `Album parsed: tracks=${trk} title="${meta.title}" year=${meta.year || '?'} label=${meta.label || '?'} format=${meta.format || '?'} barcode=${meta.barcode || (meta.barcodeIsPackage ? 'package-only (ignored)' : '-')}`, meta.tracks ? 'ok' : 'warn'); if (hidden > 0) appendLog(label, `${hidden} download-only track(s) hidden from streaming — Bandcamp release has ${meta.tracks}, not ${meta.streamingTracks}`, 'warn'); if (meta.barcodeIsPackage) appendLog(label, `current.upc matches a physical package's barcode — not the digital release's, so ignored (harmony#42)`, 'warn'); } else { appendLog(label, `Album page failed`, 'error'); } const tracks = meta?.tracks ?? null; const year = meta?.year ?? null; const lbl = meta?.label ?? null; const fmt = meta?.format ?? null; const bc = meta?.barcode ?? null; // #194: digital release UPC (null when absent or package-only) cacheSet(mbid, 'bandcamp', { url: albumUrl, tracks, year, label: lbl, format: fmt, source, hiddenTracks: hidden, barcode: bc, credits: pcKeep(label, meta?.credits) }); updateRow('bandcamp', { url: albumUrl, mbTracks, remoteTracks: tracks, year, label: lbl, format: fmt, source, hiddenTracks: hidden, barcode: bc }); } // ─── SoundCloud (#439) ────────────────────────────────────────────────────── // Unlike every other provider, SoundCloud can't be *searched by barcode* — the // release UPC lives per-track inside a SET's api-v2 metadata // (publisher_metadata.upc_or_ean, the same value across the set on distributed // releases; self-uploads omit it). So this provider is LINK-DERIVED like // Bandcamp: when the release links a SoundCloud SET, read its barcode + track // count from the anonymous api-v2 (public client_id lifted from the web-player // JS — no login) and feed the barcode-confidence check. A conservative native // set search (title + track-count verified) covers the no-link case. let _pcScClientId = null; async function soundcloudClientId() { if (_pcScClientId) return _pcScClientId; const home = await gmGet('https://soundcloud.com/discover', { headers: { Accept: 'text/html' } }); const assets = [...(home.responseText || '').matchAll(/https:\/\/a-v2\.sndcdn\.com\/assets\/[^"']+\.js/g)].map(m => m[0]); for (const a of assets.reverse()) { const js = await gmGet(a); const m = (js.responseText || '').match(/client_id\s*[:=]\s*"([a-zA-Z0-9]{20,40})"/); if (m) { _pcScClientId = m[1]; return _pcScClientId; } } throw new Error('no client_id in the web-player JS'); } const SC_API_V2 = 'https://api-v2.soundcloud.com'; async function fetchSoundcloudSet(setUrl) { const cid = await soundcloudClientId(); const rj = async (u) => { const r = await gmGet(u, { headers: { Accept: 'application/json' } }); return r.ok ? JSON.parse(r.responseText || 'null') : null; }; const pl = await rj(`${SC_API_V2}/resolve?url=${encodeURIComponent(setUrl)}&client_id=${cid}`); // a bare track URL is a single-track release (#439, chaban-mb) — its own publisher_metadata carries the barcode if (pl && pl.kind === 'track') { const pm = pl.publisher_metadata || {}; const pl1 = (pm.p_line || '').replace(/^\s*©?℗?\s*\d{4}\s*/, '').trim(); return { title: pl.title || '', tracks: 1, barcode: String(pm.upc_or_ean || '').trim() || null, year: (pl.release_date || pl.display_date || pl.created_at || '').slice(0, 4) || null, label: pl1 || null, credits: pcCreditsSoundcloud(pl) }; } if (!pl || pl.kind !== 'playlist') return null; const stubs = (pl.tracks || []).filter(t => t && t.id); const byId = new Map(); (pl.tracks || []).forEach(t => { if (t && t.title) byId.set(t.id, t); }); const missing = stubs.filter(t => !byId.has(t.id)).map(t => t.id); for (let i = 0; i < missing.length; i += 50) { const b = await rj(`${SC_API_V2}/tracks?ids=${encodeURIComponent(missing.slice(i, i + 50).join(','))}&client_id=${cid}`); (b || []).forEach(t => { if (t && t.id != null) byId.set(t.id, t); }); } const tracks = stubs.map(s => byId.get(s.id)).filter(Boolean); const upcs = [...new Set(tracks.map(t => String((t.publisher_metadata || {}).upc_or_ean || '').trim()).filter(Boolean))]; const pLine = (tracks.map(t => (t.publisher_metadata || {}).p_line || '').find(Boolean) || '').replace(/^\s*©?℗?\s*\d{4}\s*/, '').trim(); return { title: pl.title || '', tracks: pl.track_count || tracks.length || null, // only trust a barcode the WHOLE set agrees on — a mixed-UPC compilation gives none barcode: upcs.length === 1 ? upcs[0] : null, year: (pl.release_date || pl.display_date || pl.created_at || '').slice(0, 4) || null, label: pLine || pl.label_name || null, credits: pcCreditsSoundcloud(pl), // #671 // #527 follow-up (majkinetor, live): "SC shows as digital, although it // is assumed" — SoundCloud never actually tells us the RELEASE's // format (a set page says nothing about whether the release also // exists on CD/vinyl), so hardcoding 'Digital Media' here rendered // the format-confidence circle as if it were a CONFIRMED value, same // visual weight as Bandcamp/Discogs' real parsed format. SoundCloud // is a digital-only storefront exactly like Spotify/Apple/Tidal/etc — // it now relies on the same DIGITAL_ONLY_PROVIDERS fallback they use // (still correctly treated as "digital" for the mismatch check, just // not displayed as if it were confirmed). }; } // #527 follow-up (majkinetor, live): "Soundcloud logs seem insufficient... // Not matched via what route? So make logs telling without us having to // deep dive userscript docs." — brought up to the same verbosity every // other provider already uses (see scanDeezer just below for the // reference shape): log the client_id fetch, the search request + status, // the candidate count, and EVERY candidate considered with why it did or // didn't match — not just the final pick or a bare "no match". async function scanSoundcloud({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists }) { const label = 'SoundCloud'; const cached = cacheGetScan(mbid, 'soundcloud', label); if (cached?.url && (!existingUrl || existingUrl === cached.url)) { applyCachedRow('soundcloud', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('soundcloud', label, cached, mbTracks); return; } // #527 (majkinetor, live): a track-level SoundCloud relationship (one of // the release's own recordings linking to its own track page) got swept // up and used as if it were the RELEASE's SoundCloud URL on an 11-track // release. "existingUrl" is whatever the DOM/API scrape considered a // release-level rel, but that scrape can't always tell a genuine // release-level link from a per-track one that merely rendered // somewhere in scope — so verify it before trusting it: fetch it and // check its own track count. A bare-track result (tracks=1) is only // plausibly the release's own URL when the release ITSELF is // single-track (#439's documented case); on a multi-track release it's // almost certainly a mismatched/track-level link, not the release's — // fall through to a native search instead of caching a wrong URL as // "confirmed in MB". let setUrl = null, source = null, meta = null; if (existingUrl) { appendLog(label, `Checking existing MB URL: ${existingUrl}`); const existingMeta = await fetchSoundcloudSet(existingUrl).catch(e => { appendLog(label, `Existing URL fetch failed: ${e && e.message}`, 'error'); return null; }); if (existingMeta && !(mbTracks > 1 && existingMeta.tracks === 1)) { setUrl = existingUrl; source = 'MB rels'; meta = existingMeta; appendLog(label, `Using existing MB URL: ${setUrl} (tracks=${existingMeta.tracks})`, 'ok'); } else if (existingMeta) { appendLog(label, `Existing MB URL is a single track (tracks=1) on a ${mbTracks}-track release — likely a track-level relationship, not the release's; searching instead`, 'warn'); } else { appendLog(label, `Existing MB URL didn't resolve to a real track/set — searching instead`, 'warn'); } } if (!setUrl) { // No usable MB link → conservative native SET search (SoundCloud is // track-centric, so verify by title + track count before trusting a // set's barcode). const q = isVariousArtists ? album : `${artist} ${album}`; const cid = await soundcloudClientId().catch(e => { appendLog(label, `Couldn't get a SoundCloud client_id (web-player JS parse failed: ${e && e.message}) — search unavailable`, 'error'); return null; }); if (cid) { const searchUrl = `${SC_API_V2}/search/playlists?q=${encodeURIComponent(q)}&limit=5&client_id=${cid}`; appendLog(label, `Search: q="${q}"`); const r = await gmGet(searchUrl, { headers: { Accept: 'application/json' } }); appendLog(label, `Search: status=${r.status} ${(r.responseText || '').length}b in ${r.ms}ms`); let hits = []; try { hits = (JSON.parse(r.responseText || 'null')?.collection) || []; } catch (e) { appendLog(label, `Search: JSON parse error: ${e.message}`, 'error'); } appendLog(label, `Search: ${hits.length} candidate(s)`); const norm = s => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); const want = norm(album); hits.forEach(h => { const titleMatch = h.kind === 'playlist' && norm(h.title).includes(want); const trackMatch = mbTracks && h.track_count === mbTracks; appendLog(label, ` cand kind=${h.kind} tracks=${h.track_count ?? '?'} title="${h.title || ''}" titleMatch=${titleMatch} trackCountMatch=${trackMatch} url=${h.permalink_url || '?'}`); }); const pick = hits.find(h => h.kind === 'playlist' && norm(h.title).includes(want) && mbTracks && h.track_count === mbTracks) || hits.find(h => h.kind === 'playlist' && norm(h.title).includes(want)); if (pick && pick.permalink_url) { setUrl = pick.permalink_url; source = 'search'; appendLog(label, `Search picked: ${setUrl} (tracks=${pick.track_count})`, 'ok'); } else appendLog(label, hits.length ? `Search: none of the ${hits.length} candidate(s) matched the album title` : `Search: no playlist results for this query`, 'warn'); } if (!setUrl) { appendLog(label, `No matching set found`, 'warn'); cacheSet(mbid, 'soundcloud', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('soundcloud', { url: null, mbTracks, remoteTracks: null }); return; } } if (!meta) meta = await fetchSoundcloudSet(setUrl).catch(e => { appendLog(label, `Set fetch failed: ${e && e.message}`, 'error'); return null; }); if (meta) appendLog(label, `Set parsed: tracks=${meta.tracks} title="${meta.title}" year=${meta.year || '?'} label=${meta.label || '?'} barcode=${meta.barcode || '-'}`, meta.tracks ? 'ok' : 'warn'); else appendLog(label, `Set parse returned nothing — resolve() likely failed for ${setUrl}`, 'error'); const entry = { url: setUrl, tracks: meta?.tracks ?? null, year: meta?.year ?? null, label: meta?.label ?? null, format: meta?.format ?? null, source, barcode: meta?.barcode ?? null, credits: pcKeep(label, meta?.credits) }; cacheSet(mbid, 'soundcloud', entry); updateRow('soundcloud', { url: setUrl, mbTracks, remoteTracks: meta?.tracks ?? null, year: meta?.year ?? null, label: meta?.label ?? null, format: meta?.format ?? null, source, barcode: meta?.barcode ?? null }); } // ─── Audiomack (#664) ─────────────────────────────────────────────────────── // Audiomack's API (api.audiomack.com/v1) wants every call OAuth 1.0a-signed, but // the web player's own consumer key + secret are public, so no login is needed. // The album and song endpoints carry the release barcode (`upc`), track count, // release date and the ℗ line. Search finds albums by text only (no barcode // index) and its hits carry no barcode, so each plausible hit is fetched and a // hit whose barcode equals MB's wins; without one, title + track count decide. // Re-uploads by fans are common there, so a hit that has no barcode at all is // never picked over one that has. const AM_API = 'https://api.audiomack.com/v1/'; const AM_KEY = 'audiomack-web', AM_SECRET = 'bd8a07e9f23fbe9d808646b730f89b8e'; const amEnc = s => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); async function audiomackGet(endpoint, params = {}) { const url = AM_API + endpoint; const p = { ...params, oauth_consumer_key: AM_KEY, oauth_nonce: Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), oauth_signature_method: 'HMAC-SHA1', oauth_timestamp: String(Math.floor(Date.now() / 1000)), oauth_version: '1.0' }; const qs = Object.keys(p).sort().map(k => `${amEnc(k)}=${amEnc(p[k])}`).join('&'); const base = ['GET', amEnc(url), amEnc(qs)].join('&'); const enc = new TextEncoder(); const key = await crypto.subtle.importKey('raw', enc.encode(amEnc(AM_SECRET) + '&'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); const sig = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(base))))); const r = await gmGet(`${url}?${qs}&oauth_signature=${amEnc(sig)}`, { headers: { Accept: 'application/json' } }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { /* not JSON */ } return { status: r.status, ms: r.ms, results: (r.ok && j) ? j.results : null }; } // audiomack.com/<artist>/album/<slug> or /<artist>/song/<slug> → the API path function audiomackRef(u) { const m = String(u || '').match(/^https?:\/\/(?:www\.)?audiomack\.com\/([^/?#]+)\/(album|song)\/([^/?#]+)/i); return m ? { artist: m[1], kind: m[2].toLowerCase(), slug: m[3] } : null; } function audiomackMeta(it, url) { const ts = Number(it.original_release_date || it.released || 0); // "℗ Spaceship/ Bad Habit/ Atlantic Records, 2025 Spaceship ..." → the part before ", <year>" const pl = String(it.pline || '').replace(/^[\s©℗]*(?:\(P\)\s*)?(?:\d{4}\s+)?/i, '').split(/,\s+\d{4}\b/)[0].trim(); return { url, title: it.title || '', artist: it.artist || '', tracks: it.type === 'album' ? (Array.isArray(it.tracks) ? it.tracks.length : null) : 1, barcode: String(it.upc || '').replace(/\D/g, '') || null, year: ts ? String(new Date(ts * 1000).getUTCFullYear()) : null, label: pl || it.uploader?.label || null, credits: pcCreditsAudiomack(it), // #671 }; } async function fetchAudiomack(url) { const ref = audiomackRef(url); if (!ref) return null; const r = await audiomackGet(`music/${ref.kind}/${ref.artist}/${ref.slug}`); return r.results ? audiomackMeta(r.results, `https://audiomack.com/${ref.artist}/${ref.kind}/${ref.slug}`) : null; } async function scanAudiomack({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, barcode }) { const label = 'Audiomack'; const cached = cacheGetScan(mbid, 'audiomack', label); if (cached?.url && (!existingUrl || pcSameUrl(existingUrl, cached.url))) { applyCachedRow('audiomack', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('audiomack', label, cached, mbTracks); return; } let meta = null, source = null; if (existingUrl) { appendLog(label, `Checking existing MB URL: ${existingUrl}`); const m = await fetchAudiomack(existingUrl).catch(e => { appendLog(label, `Existing URL fetch failed: ${e && e.message}`, 'error'); return null; }); // a song link on a multi-track release is a track-level relationship, not the release's (as with SoundCloud, #527) if (m && !(mbTracks > 1 && m.tracks === 1)) { meta = m; source = 'MB rels'; appendLog(label, `Using existing MB URL: ${m.url} (tracks=${m.tracks})`, 'ok'); } else if (m) appendLog(label, `Existing MB URL is a single song on a ${mbTracks}-track release — likely a track-level relationship; searching instead`, 'warn'); else appendLog(label, `Existing MB URL didn't resolve to an Audiomack album or song — searching instead`, 'warn'); } if (!meta) { const q = isVariousArtists ? album : `${artist} ${album}`; const show = mbTracks === 1 ? 'songs' : 'albums'; appendLog(label, `Search: q="${q}" show=${show}`); const r = await audiomackGet('search', { q, show, limit: '10' }).catch(e => { appendLog(label, `Search failed: ${e && e.message}`, 'error'); return null; }); const hits = (r && Array.isArray(r.results)) ? r.results : []; if (r) appendLog(label, `Search: status=${r.status} in ${r.ms}ms — ${hits.length} candidate(s)`); const norm = s => (s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); const want = norm(album); const plausible = hits.filter(h => { const n = norm(h.title); const ok = n === want || n.startsWith(want + ' ') || (want && n.includes(want)); const ref = h.uploader?.url_slug && h.url_slug ? `https://audiomack.com/${h.uploader.url_slug}/${h.type === 'album' ? 'album' : 'song'}/${h.url_slug}` : '?'; appendLog(label, ` cand type=${h.type} tracks=${Array.isArray(h.tracks) ? h.tracks.length : (h.type === 'song' ? 1 : '?')} title="${h.title || ''}" artist="${h.artist || ''}" titleMatch=${ok} url=${ref}`); return ok && ref !== '?'; }).slice(0, 5); const metas = []; for (const h of plausible) { const m = await fetchAudiomack(`https://audiomack.com/${h.uploader.url_slug}/${h.type === 'album' ? 'album' : 'song'}/${h.url_slug}`).catch(() => null); if (!m) continue; appendLog(label, ` fetched ${m.url}: tracks=${m.tracks} barcode=${m.barcode || '-'}${barcode && m.barcode === barcode ? ' (= MB barcode)' : ''}`); metas.push(m); } const pick = (barcode && metas.find(m => m.barcode === barcode)) || metas.find(m => m.barcode && m.tracks === mbTracks) || metas.find(m => m.barcode && scoreCandidate(m, mbTracks, album, artist, isVariousArtists) >= 100); if (pick) { meta = pick; source = 'search'; appendLog(label, `Search picked: ${pick.url}${barcode && pick.barcode === barcode ? ' (barcode match)' : ' (title + track count)'}`, 'ok'); } else { appendLog(label, metas.length ? `Search: none of the ${metas.length} fetched candidate(s) matched by barcode or track count` : `No matching album found`, 'warn'); cacheSet(mbid, 'audiomack', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('audiomack', { url: null, mbTracks, remoteTracks: null }); return; } } appendLog(label, `Parsed: tracks=${meta.tracks} title="${meta.title}" year=${meta.year || '?'} label=${meta.label || '?'} barcode=${meta.barcode || '-'}`, meta.tracks ? 'ok' : 'warn'); const entry = { url: meta.url, tracks: meta.tracks, year: meta.year, label: meta.label, format: null, source, barcode: meta.barcode, credits: pcKeep(label, meta.credits) }; cacheSet(mbid, 'audiomack', entry); updateRow('audiomack', { url: meta.url, mbTracks, remoteTracks: meta.tracks, year: meta.year, label: meta.label, format: null, source, barcode: meta.barcode }); } // ─── YouTube Music (#639) ─────────────────────────────────────────────────── // Read through the API YouTube Music's own web player uses (youtubei/v1), which answers // anonymous requests. There is no barcode lookup as such, but searching the barcode as // plain text finds most albums (11 of 12 in #639's test), and artist + album with the // "albums" filter finds nearly all the rest (19 of 20). Neither is proof: a barcode // search can land on a sibling edition (Moderat's III Deluxe → III (Instrumentals)), and // the album page shows no barcode to check. So every candidate, the barcode's too, has to // pass scoreCandidate (tracks, title, artist) like a search hit. It is an internal API, // not a public one: when it changes, the log says so, and the row is simply not found. const YTM_API = 'https://music.youtube.com/youtubei/v1/'; const YTM_ALBUMS_FILTER = 'EgWKAQIYAWoKEAkQBRAKEAMQBA=='; // the search's "Albums" chip const YTM_CLIENT = { clientName: 'WEB_REMIX', clientVersion: '1.20250101.01.00', hl: 'en', gl: 'US' }; async function ytmCall(endpoint, body, label = 'YouTube Music') { const r = await gmPost(`${YTM_API}${endpoint}?prettyPrint=false`, JSON.stringify({ context: { client: YTM_CLIENT }, ...body }), { headers: { 'Content-Type': 'application/json' }, anonymous: true }); let j = null; try { j = JSON.parse(r.responseText); } catch {} const what = body.browseId || JSON.stringify(body.query); if (!r.ok || !j) appendLog(label, `${endpoint} ${what}: HTTP ${r.status}${r.error ? ' (' + r.error + ')' : ''}${r.ok && !j ? ', not JSON' : ''} in ${r.ms}ms — YouTube Music's API may have changed`, 'error'); else appendLog(label, `${endpoint} ${what}: ${r.responseText.length}b in ${r.ms}ms`); return r.ok ? j : null; } const ytmText = t => (t && t.runs ? t.runs.map(x => x.text).join('') : (t && t.simpleText) || ''); function ytmWalk(o, fn) { if (!o || typeof o !== 'object') return; fn(o); for (const k in o) ytmWalk(o[k], fn); } // The albums a search returned, in order: { id: 'MPREb_…', title, artist, year, kind }. // A row reads "Migration" | "Album • Bonobo • 2017". function ytmAlbumResults(j) { const out = []; ytmWalk(j, o => { const r = o.musicResponsiveListItemRenderer; const id = r && r.navigationEndpoint && r.navigationEndpoint.browseEndpoint && r.navigationEndpoint.browseEndpoint.browseId; if (!id || !/^MPREb_/.test(id) || out.some(x => x.id === id)) return; const cols = (r.flexColumns || []).map(c => ytmText(c.musicResponsiveListItemFlexColumnRenderer && c.musicResponsiveListItemFlexColumnRenderer.text)); const sub = (cols[1] || '').split(' • '); const year = /^\d{4}$/.test(sub[sub.length - 1] || '') ? sub.pop() : null; out.push({ id, title: cols[0] || '', kind: sub[0] || '', artist: sub.slice(1).join(' • '), year }); }); return out; } // An album page (browse MPREb_…): its link, song count, title, artist and year. The count is // the header's "13 songs • 1 hour, 14 minutes", else the tracklist's length. async function fetchYtmAlbum(id, label = 'YouTube Music') { const j = await ytmCall('browse', { browseId: id }, label); if (!j) return null; let h = null, shelf = null; ytmWalk(j, o => { if (!h && o.musicResponsiveHeaderRenderer) h = o.musicResponsiveHeaderRenderer; if (!shelf && o.musicShelfRenderer) shelf = o.musicShelfRenderer; }); if (!h) { appendLog(label, `browse ${id}: no album header in the answer — YouTube Music's page may have changed`, 'error'); return null; } const sub = ytmText(h.subtitle).split(' • '); // "Album • 2013" const m = ytmText(h.secondSubtitle).match(/^([\d,.]+)\s+(?:song|track)s?\b/i); // "13 songs • …" const tracks = m ? parseInt(m[1].replace(/\D/g, ''), 10) : (shelf && shelf.contents ? shelf.contents.length : null); const canon = (j.microformat && j.microformat.microformatDataRenderer && j.microformat.microformatDataRenderer.urlCanonical) || ''; const list = (canon.match(/[?&]list=(OLAK5uy_[\w-]+)/) || JSON.stringify(j).match(/"(OLAK5uy_[\w-]+)"/) || [])[1]; // The page's "Other versions" shelf: editions YouTube Music groups with this one (#639, // rinsuki). A barcode belonging to one of them can bring up this album instead, and they // can share title and tracklist — so the row names them, for you to check. const otherVersions = []; ytmWalk(j, o => { const c = o.musicCarouselShelfRenderer; if (!c || !/^other versions$/i.test(ytmText(c.header && c.header.musicCarouselShelfBasicHeaderRenderer && c.header.musicCarouselShelfBasicHeaderRenderer.title))) return; for (const it of c.contents || []) { const r = it.musicTwoRowItemRenderer; if (r) otherVersions.push({ title: ytmText(r.title), sub: ytmText(r.subtitle), id: (r.navigationEndpoint && r.navigationEndpoint.browseEndpoint && r.navigationEndpoint.browseEndpoint.browseId) || null }); } }); const songIds = ytmSongIds(shelf); // whose credits name the label (#649) return { url: list ? `https://music.youtube.com/playlist?list=${list}` : `https://music.youtube.com/browse/${id}`, tracks, title: ytmText(h.title) || null, artist: ytmText(h.straplineTextOne) || null, year: sub.find(s => /^\d{4}$/.test(s)) || null, kind: sub[0] || null, otherVersions, songIds, credits: pcCreditsYtm(j), // #671 }; } // The songs a list links to, in order. Only a song (an "ATV" video) has credits; an album's // tracks can link to the official audio or music video instead ("OMV": every track of Random // Access Memories), which answers with an empty Credits dialog (#649). function ytmSongIds(o) { const ids = []; ytmWalk(o, x => { const w = x.watchEndpoint; const type = w && w.watchEndpointMusicSupportedConfigs && w.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig && w.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType; if (type === 'MUSIC_VIDEO_TYPE_ATV' && w.videoId && !ids.includes(w.videoId)) ids.push(w.videoId); }); return ids; } // A song's credits (browse MPTC<videoId>, the ⋮ → Credits dialog), as { 'Performed by': [names], … }. function ytmCreditSections(j) { const out = {}; ytmWalk(j, o => { const s = o.dismissableDialogContentSectionRenderer; if (s) out[ytmText(s.title)] = ((s.subtitle && s.subtitle.runs) || []).map(r => r.text.trim()).filter(Boolean); }); return out; } // The album's label (#649). The album page names none; every song's credits end with "Music // metadata provided by", whoever delivered the metadata — the label, sometimes a distributor or // a combined "Darkroom/Interscope Records", like other platforms' ℗ lines. Read for the matched // album only, from its first song: the album page's, else the album playlist's, which lists the // songs where the album page links videos. One or two requests; the row's cache keeps the label. async function ytmAlbumLabel(meta, label = 'YouTube Music') { if (!meta) return null; let ids = meta.songIds || []; const list = (String(meta.url).match(/[?&]list=(OLAK5uy_[\w-]+)/) || [])[1]; if (!ids.length && list) { appendLog(label, `Label: the album page links no song (only videos) — reading its playlist ${list}`); ids = ytmSongIds(await ytmCall('browse', { browseId: 'VL' + list }, label)); } if (!ids.length) { appendLog(label, `Label: no song to read the credits of`, 'warn'); return null; } for (const id of ids.slice(0, 2)) { const j = await ytmCall('browse', { browseId: 'MPTC' + id }, label); if (!j) return null; const sec = ytmCreditSections(j); const names = sec['Music metadata provided by']; appendLog(label, `Label: credits of ${id} have ${Object.keys(sec).map(k => `"${k}" (${sec[k].length})`).join(', ') || 'no sections'} → ${names && names.length ? '"' + names.join(' / ') + '"' : 'none'}`, names && names.length ? 'ok' : 'warn'); if (names && names.length) return names.join(' / '); } return null; } // The row's tooltip names the album's other versions (#639); the ✓ stays — Platform Check finds // the same or a close release, and you decide (majkinetor). Kept in the cache, so a cached row says it too. function ytmNoteOtherVersions(v, label = 'YouTube Music') { if (!v || !v.length) return; const names = v.map(x => `"${x.title}"${x.sub ? ' (' + x.sub + ')' : ''}`).join('; '); appendLog(label, `The album has ${v.length} other version(s) on YouTube Music: ${names} — check the match is the right one`, 'warn'); const a = document.getElementById('mb-online-ytmusic'); if (a && a.href && /music\.youtube\.com/.test(a.href)) a.title = (a.title ? a.title + '\n' : '') + `Also on YouTube Music as ${v.length} other version(s): ${names}. They can share title and tracks — check it's this one.`; } // The album behind a YouTube Music link: …/browse/MPREb_… directly, …/playlist?list=OLAK5uy_… // through the playlist's page, which names its album. async function ytmAlbumIdOf(url, label = 'YouTube Music') { const b = String(url).match(/music\.youtube\.com\/browse\/(MPREb_[\w-]+)/i); if (b) return b[1]; const l = String(url).match(/[?&]list=(OLAK5uy_[\w-]+)/); if (!l) return null; const j = await ytmCall('browse', { browseId: 'VL' + l[1] }, label); const id = j && (JSON.stringify(j).match(/"(MPREb_[\w-]+)"/) || [])[1]; if (j && !id) appendLog(label, `playlist ${l[1]}: its page names no album`, 'warn'); return id || null; } async function scanYtmusic({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, barcode }) { const label = 'YouTube Music'; const cached = cacheGetScan(mbid, 'ytmusic', label); if (cached?.url && (!existingUrl || pcSameUrl(existingUrl, cached.url))) { applyCachedRow('ytmusic', label, cached, mbTracks); ytmNoteOtherVersions(cached.otherVersions, label); return; } if (cached && !cached.url && !existingUrl) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('ytmusic', label, cached, mbTracks); return; } const none = source => { cacheSet(mbid, 'ytmusic', { url: null, tracks: null, year: null, label: null, source }); updateRow('ytmusic', { url: null, mbTracks, remoteTracks: null }); }; if (existingUrl) { appendLog(label, `Using existing MB URL: ${existingUrl}`, 'ok'); const id = await ytmAlbumIdOf(existingUrl, label); const meta = id ? await fetchYtmAlbum(id, label) : null; if (meta) appendLog(label, `Album parsed: tracks=${meta.tracks ?? '?'} title="${meta.title}" artist="${meta.artist || '?'}" year=${meta.year || '?'}`, meta.tracks ? 'ok' : 'warn'); else appendLog(label, `Couldn't read the linked album — shown unverified`, 'warn'); const lbl = await ytmAlbumLabel(meta, label); cacheSet(mbid, 'ytmusic', { url: existingUrl, tracks: meta?.tracks ?? null, year: meta?.year ?? null, label: lbl, source: 'MB rels', otherVersions: meta?.otherVersions || [], credits: pcKeep(label, meta?.credits) }); updateRow('ytmusic', { url: existingUrl, mbTracks, remoteTracks: meta?.tracks ?? null, year: meta?.year ?? null, label: lbl, source: 'MB rels' }); ytmNoteOtherVersions(meta?.otherVersions, label); return; } // Candidates are checked in order, each by its album page, until one scores as a match. // Up to 3 per search: the right album is nearly always first (#639), and each costs a request. const tried = new Set(); const verify = async (cands, how) => { let best = null; for (const c of cands.filter(c => !tried.has(c.id)).slice(0, 3)) { tried.add(c.id); // a clearly different title and artist aren't worth a page fetch if (scoreCandidate({ tracks: null, title: c.title, artist: c.artist }, mbTracks, album, artist, isVariousArtists) < 0) { appendLog(label, ` skip "${c.title}" by ${c.artist || '?'} (${c.kind}) — title and artist don't match`); continue; } const meta = await fetchYtmAlbum(c.id, label); const sc = scoreCandidate(meta, mbTracks, album, artist, isVariousArtists); appendLog(label, ` cand (${how}) score=${sc} tracks=${meta?.tracks ?? '?'} artist="${meta?.artist || '?'}" title="${meta?.title || '?'}" ${meta?.kind || ''} url=${meta?.url || c.id}`); if (meta && (!best || sc > best.score)) best = { score: sc, meta }; if (sc >= 150) break; } return best && best.score >= 120 ? best : null; }; let pick = null, source = null; if (barcode) { const found = await upcTry(barcode, async u => { const res = ytmAlbumResults(await ytmCall('search', { query: u, params: YTM_ALBUMS_FILTER }, label)); appendLog(label, `Barcode ${u}: ${res.length} album(s)${res.length ? ' — ' + res.slice(0, 3).map(a => `"${a.title}" by ${a.artist}`).join(', ') : ''}`); return res.length ? res : null; }); pick = found ? await verify(found, 'barcode') : null; if (pick) source = 'barcode search'; else appendLog(label, `Barcode ${barcode}: ${found ? 'no album found for it matches the release' : 'no album'} — falling back to search`); } if (!pick) { const q = isVariousArtists ? album : `${artist} ${album}`; const res = ytmAlbumResults(await ytmCall('search', { query: q, params: YTM_ALBUMS_FILTER }, label)); appendLog(label, `Search "${q}": ${res.length} album(s)`); pick = res.length ? await verify(res, 'search') : null; if (pick) source = 'search'; } if (!pick) { appendLog(label, `No verifiable match — leaving URL unset`, 'warn'); none('search'); return; } const { meta } = pick; // No barcode is claimed, not even for a barcode search's hit with the same songs and title: // YouTube Music groups editions ("Other versions"), and a barcode that belongs to one of them // brings up the group's primary album, whose barcode differs (rinsuki, #639). Nothing on the // page tells them apart, so strict barcode mode withholds YouTube Music like any platform // whose barcode can't be read. appendLog(label, `Picked (score=${pick.score}, ${source}): ${meta.url}`, pick.score >= 150 ? 'ok' : 'warn'); const lbl = await ytmAlbumLabel(meta, label); cacheSet(mbid, 'ytmusic', { url: meta.url, tracks: meta.tracks, year: meta.year, label: lbl, source, otherVersions: meta.otherVersions, credits: pcKeep(label, meta.credits) }); updateRow('ytmusic', { url: meta.url, mbTracks, remoteTracks: meta.tracks, year: meta.year, label: lbl, source }); ytmNoteOtherVersions(meta.otherVersions, label); } // ─── Amazon Music (#644) ──────────────────────────────────────────────────── // Read through the API Amazon Music's own web player uses (na.mesk.skill.music.a2z.com), // which answers a guest: music.amazon.com/config.json hands out a session (CSRF token, // device and session id, no login), and each request carries it as a JSON "headers" string // in its body. Amazon Music has no barcode search and shows no barcode or ISRC, so the row // is found by name only — artist + album, every hit checked on its album page (song count, // title, artist; the release year breaks a tie between editions) — and never claims the // barcode: strict barcode mode withholds it. The guest session is amazon.com's catalogue // (US). It is an internal API: when it changes, the log says so and the row isn't found. const AMZ_API = 'https://na.mesk.skill.music.a2z.com/api/'; let _amzSession = null; function amzSession(label = 'Amazon Music') { if (!_amzSession) _amzSession = (async () => { const r = await gmPost('https://music.amazon.com/config.json', '', { anonymous: true }); let j = null; try { j = JSON.parse(r.responseText); } catch {} if (!r.ok || !j || !j.csrf || !j.deviceId) { appendLog(label, `config.json: HTTP ${r.status}${r.error ? ' (' + r.error + ')' : ''} in ${r.ms}ms — no guest session; Amazon Music's player may have changed`, 'error'); return null; } appendLog(label, `guest session in ${r.ms}ms (marketplace ${j.marketplaceId || '?'}, player ${j.version || '?'})`); return j; })().then(s => { if (!s) _amzSession = null; return s; }); // a failed session is asked for again next time return _amzSession; } async function amzCall(endpoint, body, label = 'Amazon Music') { const s = await amzSession(label); if (!s) return null; const headers = JSON.stringify({ 'x-amzn-authentication': JSON.stringify({ interface: 'ClientAuthenticationInterface.v1_0.ClientTokenElement', accessToken: s.accessToken || '' }), 'x-amzn-device-model': 'WEBPLAYER', 'x-amzn-device-width': '1920', 'x-amzn-device-family': 'WebPlayer', 'x-amzn-device-id': s.deviceId, 'x-amzn-user-agent': navigator.userAgent, 'x-amzn-session-id': s.sessionId, 'x-amzn-device-height': '1080', 'x-amzn-request-id': (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)), 'x-amzn-device-language': 'en_US', 'x-amzn-currency-of-preference': 'USD', 'x-amzn-os-version': '1.0', 'x-amzn-application-version': s.version, 'x-amzn-device-time-zone': 'UTC', 'x-amzn-timestamp': String(Date.now()), 'x-amzn-csrf': JSON.stringify({ interface: 'CSRFInterface.v1_0.CSRFHeaderElement', token: s.csrf.token, timestamp: s.csrf.ts, rndNonce: s.csrf.rnd }), 'x-amzn-music-domain': 'music.amazon.com', 'x-amzn-referer': '', 'x-amzn-affiliate-tags': '', 'x-amzn-ref-marker': '', 'x-amzn-page-url': 'https://music.amazon.com/', 'x-amzn-weblab-id-overrides': '', 'x-amzn-video-player-token': '', 'x-amzn-feature-flags': '', 'x-amzn-has-profile-id': '', 'x-amzn-age-band': '', }); const r = await gmPost(AMZ_API + endpoint, JSON.stringify({ ...body, headers }), { headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, anonymous: true }); let j = null; try { j = JSON.parse(r.responseText); } catch {} const tpl = j && j.methods && (j.methods.find(m => m.template) || {}).template; const what = body.id || body.suggestedKeyword; if (!r.ok || !tpl) appendLog(label, `${endpoint} ${JSON.stringify(what)}: HTTP ${r.status}${r.error ? ' (' + r.error + ')' : ''}${r.ok && !tpl ? ', no page in the answer' : ''} in ${r.ms}ms — Amazon Music's API may have changed`, 'error'); else appendLog(label, `${endpoint} ${JSON.stringify(what)}: ${r.responseText.length}b in ${r.ms}ms`); return tpl || null; } const amzText = t => (t && typeof t === 'object' ? t.text : t) || ''; // The albums a search returned, in order: { id: 'B00CRMWMZ0', title, artist, year } — from its // "Top Results" and "Albums" shelves (a song's link carries ?trackAsin= and is left out). function amzAlbumResults(tpl) { const out = []; for (const w of (tpl && tpl.widgets) || []) for (const it of w.items || []) { const m = String((it.primaryLink && it.primaryLink.deeplink) || '').match(/^\/albums\/([A-Z0-9]{10})$/); if (!m || out.some(x => x.id === m[1])) continue; out.push({ id: m[1], title: amzText(it.primaryText), artist: amzText(it.secondaryText), year: /^\d{4}$/.test(amzText(it.tertiaryText)) ? amzText(it.tertiaryText) : null }); } return out; } // An album page: its link, song count, title, artist, year and kind ("Album", "EP", …). The header // reads "13 SONGS • 1 HOUR AND 14 MINUTES • MAY 17 2013"; without it, the tracklist's length. async function fetchAmzAlbum(id, label = 'Amazon Music') { const tpl = await amzCall('showCatalogAlbum', { id, userHash: JSON.stringify({ level: 'LIBRARY_MEMBER' }) }, label); if (!tpl) return null; const title = amzText(tpl.headerText); if (!title) { appendLog(label, `album ${id}: no album header in the answer — Amazon Music's page may have changed`, 'error'); return null; } const sub = String(tpl.headerTertiaryText || '').split(/\s*•\s*/); const m = (sub[0] || '').match(/^([\d,.]+)\s+(?:song|track)s?\b/i); const rows = ((tpl.widgets || []).find(w => (w.items || []).some(it => /trackAsin=/.test((it.primaryLink && it.primaryLink.deeplink) || ''))) || {}).items || []; const date = sub.find(x => /\b\d{4}$/.test(x)) || ''; return { url: `https://music.amazon.com/albums/${id}`, id, tracks: m ? parseInt(m[1].replace(/\D/g, ''), 10) : (rows.length || null), title, artist: amzText(tpl.headerPrimaryText) || null, year: (date.match(/(\d{4})$/) || [])[1] || null, kind: amzText(tpl.headerLabel) || null, date: date || null, label: stripCopyright(String(tpl.footer || '')) || null, // the page's ℗ line: "(P) 2013 Daft Life Limited under exclusive license to Columbia Records…" }; } async function scanAmazon({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, mbYear }) { const label = 'Amazon Music'; const cached = cacheGet(mbid, 'amazonmusic'); if (cached?.url && (!existingUrl || pcSameUrl(existingUrl, cached.url))) { applyCachedRow('amazonmusic', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('amazonmusic', label, cached, mbTracks); return; } const none = source => { cacheSet(mbid, 'amazonmusic', { url: null, tracks: null, year: null, label: null, source }); updateRow('amazonmusic', { url: null, mbTracks, remoteTracks: null }); }; if (existingUrl) { appendLog(label, `Using existing MB URL: ${existingUrl}`, 'ok'); const id = (String(existingUrl).match(/\/albums\/([A-Z0-9]{10})/i) || [])[1]; const meta = id ? await fetchAmzAlbum(id.toUpperCase(), label) : null; if (meta) appendLog(label, `Album parsed: tracks=${meta.tracks ?? '?'} title="${meta.title}" artist="${meta.artist || '?'}" date=${meta.date || '?'} label="${meta.label || '?'}"`, meta.tracks ? 'ok' : 'warn'); else appendLog(label, `Couldn't read the linked album — shown unverified`, 'warn'); cacheSet(mbid, 'amazonmusic', { url: existingUrl, tracks: meta?.tracks ?? null, year: meta?.year ?? null, label: meta?.label ?? null, source: 'MB rels' }); updateRow('amazonmusic', { url: existingUrl, mbTracks, remoteTracks: meta?.tracks ?? null, year: meta?.year ?? null, label: meta?.label ?? null, source: 'MB rels' }); return; } // Candidates are checked in order, each by its album page, up to 3. Editions often share // title and song count (Random Access Memories has two of 13 songs); the one from the // release's year is preferred, so a match from another year doesn't stop the search. const q = isVariousArtists ? album : `${artist} ${album}`; const res = amzAlbumResults(await amzCall('showSearch', { filter: JSON.stringify({ IsLibrary: ['false'] }), keyword: JSON.stringify({ interface: 'Web.TemplatesInterface.v1_0.Touch.SearchTemplateInterface.SearchKeywordClientInformation', keyword: q }), suggestedKeyword: q, userHash: JSON.stringify({ level: 'LIBRARY_MEMBER' }), }, label)); appendLog(label, `Search "${q}": ${res.length} album(s)${res.length ? ' — ' + res.slice(0, 4).map(a => `"${a.title}" by ${a.artist}${a.year ? ' (' + a.year + ')' : ''}`).join(', ') : ''}`); let best = null, checked = 0; for (const c of res) { if (checked >= 3) break; // a clearly different title and artist aren't worth a page fetch if (scoreCandidate({ tracks: null, title: c.title, artist: c.artist }, mbTracks, album, artist, isVariousArtists) < 0) { appendLog(label, ` skip "${c.title}" by ${c.artist || '?'} — title and artist don't match`); continue; } checked++; const meta = await fetchAmzAlbum(c.id, label); const sc = scoreCandidate(meta, mbTracks, album, artist, isVariousArtists); const sameYear = !!(meta && mbYear && meta.year === String(mbYear)); appendLog(label, ` cand score=${sc}${sameYear ? ' +year' : ''} tracks=${meta?.tracks ?? '?'} artist="${meta?.artist || '?'}" title="${meta?.title || '?'}" ${meta?.kind || ''} ${meta?.date || ''} url=${meta?.url || c.id}`); // found by name only, so the title and the song count must both match: the same artist and song count alone can be // another album (Philip Cohran's self-titled 6 songs vs Amazon's "The Zulu 45s Collection", 6 songs) if (meta && !titleSimilar(meta.title || '', album)) { appendLog(label, ` not taken: the title differs from "${album}"`); continue; } if (meta && meta.tracks !== mbTracks) { appendLog(label, ` not taken: ${meta.tracks ?? '?'} songs, the release has ${mbTracks}`); continue; } const rank = sc + (sameYear ? 10 : 0); if (meta && (!best || rank > best.rank)) best = { score: sc, rank, meta }; if (sc >= 150 && (sameYear || !mbYear)) break; } if (!best || best.score < 120) { appendLog(label, `No verifiable match — leaving URL unset`, 'warn'); none('search'); return; } const { meta } = best; // found by name: no barcode is claimed (Amazon Music shows none), so strict barcode mode withholds it appendLog(label, `Picked (score=${best.score}, search): ${meta.url}`, best.score >= 150 ? 'ok' : 'warn'); cacheSet(mbid, 'amazonmusic', { url: meta.url, tracks: meta.tracks, year: meta.year, label: meta.label, source: 'search' }); updateRow('amazonmusic', { url: meta.url, mbTracks, remoteTracks: meta.tracks, year: meta.year, label: meta.label, source: 'search' }); } // ─── Deezer ───────────────────────────────────────────────────────────────── // Deezer's public API (api.deezer.com) is unauthenticated and structured — // search returns album id + title + artist + nb_tracks; detail adds release_date // + label. No CAPTCHA, no anti-bot, ~50 req / 5 sec / IP. Use it for both // the search step and the detail step; no need for any HTML scraping. async function fetchDeezerMeta(albumUrl) { const m = albumUrl.match(/deezer\.com\/(?:[a-z]+\/)?album\/(\d+)/i); if (!m) return null; const r = await gmGet(`https://api.deezer.com/album/${m[1]}`); if (!r.ok) return null; try { const d = JSON.parse(r.responseText); return { tracks: d.nb_tracks ?? null, title: d.title || null, year: d.release_date ? d.release_date.slice(0, 4) : null, label: d.label || null, artist: d.artist?.name || null, barcode: d.upc || null, credits: pcCreditsDeezer(d), // #671 }; } catch { return null; } } async function scanDeezer({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, barcode }) { const label = 'Deezer'; const cached = cacheGetScan(mbid, 'deezer', label); if (cached?.url && (!existingUrl || existingUrl === cached.url)) { applyCachedRow('deezer', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl && !barcode) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('deezer', label, cached, mbTracks); return; } // Barcode-first: Deezer's `album/upc:<UPC>`. NOTE it is NOT reliably exact — for a // barcode it doesn't have, Deezer sometimes returns an UNRELATED album instead of an // error (e.g. upc:827565029525 → "Ayamma", whose real UPC is 885686990360). So trust // the hit only when the returned album's OWN upc matches what we asked for; otherwise // treat it as no barcode match and fall through to the artist+album search. if (!existingUrl && barcode) { // #354: try the raw barcode first, then the other GTIN paddings on a miss. Each // hit is accepted only when the returned album's OWN upc matches (Deezer's // album/upc: can hand back an unrelated album for a barcode it lacks — #356). const bd = await upcTry(barcode, async (u) => { const br = await gmGet(`https://api.deezer.com/album/upc:${u}`); let d = null; try { d = JSON.parse(br.responseText); } catch {} if (!(d && d.id && !d.error)) return null; if (normBarcode(d.upc) === normBarcode(barcode)) return d; appendLog(label, `Barcode ${u}: Deezer returned a different-barcode album (upc ${d.upc || '?'}, "${d.title || ''}") — ignoring`, 'warn'); return null; }); if (bd) { const albumUrl = bd.link || `https://www.deezer.com/album/${bd.id}`; appendLog(label, `Barcode ${barcode} → ${albumUrl}`, 'ok'); const meta = await fetchDeezerMeta(albumUrl); const bc = bd.upc || meta?.barcode || barcode; cacheSet(mbid, 'deezer', { url: albumUrl, tracks: meta?.tracks ?? null, year: meta?.year ?? null, label: meta?.label ?? null, source: 'barcode', barcode: bc, credits: pcKeep(label, meta?.credits) }); updateRow('deezer', { url: albumUrl, mbTracks, remoteTracks: meta?.tracks ?? null, year: meta?.year ?? null, label: meta?.label ?? null, source: 'barcode', barcode: bc }); return; } appendLog(label, `Barcode ${barcode}: no UPC match — falling back to search`); } let albumUrl = existingUrl; let source = null; if (albumUrl) { appendLog(label, `Using existing MB URL: ${albumUrl}`, 'ok'); source = 'MB rels'; } else { // Deezer search-query syntax supports field-prefix matching, so we can // narrow exactly to artist + album. VA compilations: query by album only // (Deezer credits compilations to the label/aggregator, not to a // literal "Various Artists" string). const q = isVariousArtists ? `album:"${album}"` : `artist:"${artist}" album:"${album}"`; const searchUrl = `https://api.deezer.com/search/album?q=${encodeURIComponent(q)}&limit=10`; appendLog(label, `API search: ${searchUrl}`); const sr = await gmGet(searchUrl); appendLog(label, `API search: status=${sr.status} ${sr.responseText.length}b in ${sr.ms}ms`); if (!sr.ok) { appendLog(label, `API search failed`, 'error'); cacheSet(mbid, 'deezer', { url: null, tracks: null, year: null, label: null, source: 'API search' }); updateRow('deezer', { url: null, mbTracks, remoteTracks: null }); return; } let results = []; try { const data = JSON.parse(sr.responseText); results = data.data || []; } catch (e) { appendLog(label, `API JSON parse error: ${e.message}`, 'error'); updateRow('deezer', { url: null, mbTracks, remoteTracks: null }); return; } appendLog(label, `API search: ${results.length} candidate(s)`); if (!results.length) { cacheSet(mbid, 'deezer', { url: null, tracks: null, year: null, label: null, source: 'API search' }); updateRow('deezer', { url: null, mbTracks, remoteTracks: null }); return; } // Search results already carry nb_tracks + title + artist so no // per-candidate detail fetch needed at this stage. let best = null; for (const it of results) { const sc = scoreCandidate({ tracks: it.nb_tracks, title: it.title, artist: it.artist?.name }, mbTracks, album, artist, isVariousArtists); appendLog(label, ` cand score=${sc} tracks=${it.nb_tracks ?? '?'} artist="${it.artist?.name || '?'}" title="${it.title}" url=${it.link}`); if (!best || sc > best.score) best = { score: sc, item: it }; if (sc >= 150) break; } if (!best || best.score < 120) { appendLog(label, `No verifiable match (best score=${best?.score ?? 'n/a'}) — leaving URL unset`, 'warn'); cacheSet(mbid, 'deezer', { url: null, tracks: null, year: null, label: null, source: 'API search' }); updateRow('deezer', { url: null, mbTracks, remoteTracks: null }); return; } albumUrl = best.item.link; source = 'API search'; appendLog(label, `Picked best (score=${best.score}): ${albumUrl}`, best.score >= 150 ? 'ok' : 'warn'); } const meta = await fetchDeezerMeta(albumUrl); if (meta) { appendLog(label, `Album parsed: tracks=${meta.tracks} title="${meta.title}" year=${meta.year || '?'} label=${meta.label || '?'}`, meta.tracks ? 'ok' : 'warn'); } else { appendLog(label, `Detail fetch failed`, 'error'); } const tracks = meta?.tracks ?? null; const year = meta?.year ?? null; const lbl = meta?.label ?? null; cacheSet(mbid, 'deezer', { url: albumUrl, tracks, year, label: lbl, source, barcode: meta?.barcode ?? null, credits: pcKeep(label, meta?.credits) }); updateRow('deezer', { url: albumUrl, mbTracks, remoteTracks: tracks, year, label: lbl, source, barcode: meta?.barcode ?? null }); } // ─── Apple Music ──────────────────────────────────────────────────────────── // iTunes Search API (itunes.apple.com/{search,lookup}) is unauthenticated and // returns structured JSON — same shape as Deezer. trackCount, releaseDate, and // `copyright` (label name on most releases) are exposed directly. URL field is // `collectionViewUrl` which points at music.apple.com/<country>/album/<slug>/<id>. async function fetchAppleMeta(albumUrl) { const m = albumUrl.match(/\/album\/(?:[^/]+\/)?(\d+)/); if (!m) return null; const r = await gmGet(`https://itunes.apple.com/lookup?id=${m[1]}&entity=album`); if (!r.ok) return null; try { const d = JSON.parse(r.responseText); const a = d.results?.[0]; if (!a) return null; return { tracks: a.trackCount ?? null, title: a.collectionName || null, year: a.releaseDate ? a.releaseDate.slice(0, 4) : null, label: stripCopyright(a.copyright), artist: a.artistName || null, }; } catch { return null; } } // #627: Apple Music's own catalogue (amp-api.music.apple.com), read anonymously with the // bearer token the web player's public JS bundle carries — the token ISRC Scout and Credit // Hoarder use, shared through one cache. Unlike the iTunes Search API below it returns each // album's UPC, so a barcode lookup takes the album whose UPC it is (iTunes put a different // album first for 808391067776, Harmony #196), and Apple links can pass strict barcode mode. // Its track count is songs only: amp's own trackCount also counts music videos, and iTunes' // counted digital booklets (Harmony #192). The iTunes API stays, as the fallback when the // token or amp-api fails. const APPLE_AMP_V1 = 'https://amp-api.music.apple.com/v1'; const APPLE_TOKEN_KEY = 'mbtools:apple-token'; // shared with ISRC Scout and Credit Hoarder let _appleTok = null, _appleTokP = null; // one fetch at a time: the storefronts are read in parallel, and each would otherwise fetch its own function appleToken(fresh) { if (_appleTok && !fresh) return Promise.resolve(_appleTok); if (!_appleTokP) _appleTokP = appleTokenFetch(fresh).finally(() => { _appleTokP = null; }); return _appleTokP; } async function appleTokenFetch(fresh) { if (_appleTok && !fresh) return _appleTok; if (!fresh) { try { const c = JSON.parse(localStorage.getItem(APPLE_TOKEN_KEY) || 'null'); if (c && c.t && c.at && Date.now() - c.at < 12 * 3600e3) return (_appleTok = c.t); } catch (e) {} } appendLog('Apple', 'amp-api: fetching the web player\'s token'); const home = await gmGet('https://music.apple.com/us/browse', { headers: { Accept: 'text/html' } }); const asset = (home.responseText.match(/\/assets\/index-legacy~[a-z0-9]+\.js/i) || home.responseText.match(/\/assets\/index~[a-z0-9]+\.js/i) || [])[0]; if (!asset) throw new Error('the web player\'s JS was not found'); const js = await gmGet('https://music.apple.com' + asset); const tok = (js.responseText.match(/eyJ[A-Za-z0-9._-]{80,}/) || [])[0]; if (!tok) throw new Error('no token in the web player\'s JS'); try { localStorage.setItem(APPLE_TOKEN_KEY, JSON.stringify({ t: tok, at: Date.now() })); } catch (e) {} return (_appleTok = tok); } // One amp-api read: its JSON, or throws (the caller falls back to iTunes). A 401 is a rotated // token: a new one is fetched, once. const appleAmp = path => appleAmpV1('catalog/' + path); async function appleAmpV1(path) { for (let attempt = 0; attempt < 2; attempt++) { const tok = await appleToken(attempt > 0); const r = await gmGet(`${APPLE_AMP_V1}/${path}${path.includes('?') ? '&' : '?'}l=en-US`, { headers: { Authorization: 'Bearer ' + tok, Origin: 'https://music.apple.com', Accept: 'application/json' } }); if (r.status === 401 && attempt === 0) { appendLog('Apple', 'amp-api: the token was refused (401) — fetching a new one', 'warn'); continue; } if (r.status === 404) return { data: [] }; if (!r.ok) throw new Error(`amp-api HTTP ${r.status || r.error || '?'}`); return JSON.parse(r.responseText); } throw new Error('amp-api refused a fresh token'); } // the album whose UPC it is — amp-api lists others first too (808391067776: the single 808391068360 comes first) const applePickByUpc = (items, upc) => (items || []).find(a => normBarcode(a.attributes?.upc) === normBarcode(upc)) || null; // what every album read asks for besides the album: each song's artists, for Artists & labels (#757) const APPLE_ALBUM_INC = 'include[songs]=artists'; const appleStorefront = url => ((String(url || '').match(/music\.apple\.com\/([a-z]{2})\//i) || [])[1] || 'us').toLowerCase(); // Apple's catalogue is per storefront: an album is only in the ones it is licensed to (majkinetor, // #627: "better to find any release in any store than no release in particular store"). The short // list is read first, all at once: eight storefronts in parallel answered in 83 ms. Only when it has // nothing is every storefront read, again all at once: 172 in about a second. (Apple's own site // checks a release's availability one storefront at a time, about a second each.) const APPLE_SHORTLIST = ['us', 'gb', 'de', 'fr', 'jp', 'br', 'au']; async function appleAllStorefronts() { try { const c = JSON.parse(localStorage.getItem('pc:apple-storefronts') || 'null'); if (c && c.ids && c.ids.length && Date.now() - c.at < 7 * 864e5) return c.ids; } catch (e) {} const ids = []; for (let next = 'storefronts?limit=200'; next;) { const j = await appleAmpV1(next); (j.data || []).forEach(s => ids.push(s.id)); next = j.next ? String(j.next).replace(/^\/v1\//, '') : null; } try { localStorage.setItem('pc:apple-storefronts', JSON.stringify({ ids, at: Date.now() })); } catch (e) {} appendLog('Apple', `amp-api: ${ids.length} storefronts`); return ids; } // One catalogue read in several storefronts at once: [{ sf, j }] for each that answered. Throws when // none did (the caller falls back to iTunes); a few that fail are only logged. async function appleEach(sfs, path) { await appleToken(); // once, before the parallel reads const res = await Promise.all(sfs.map(s => appleAmp(`${s}/${path}`).then(j => ({ sf: s, j }), e => ({ sf: s, err: e.message })))); const ok = res.filter(r => r.j), bad = res.filter(r => !r.j); if (!ok.length) throw new Error(bad.length ? bad[0].err : 'no storefront to ask'); if (bad.length) appendLog('Apple', `amp-api: ${bad.length} of ${res.length} storefront(s) didn't answer (${bad.slice(0, 5).map(b => b.sf).join(', ')}${bad.length > 5 ? '…' : ''})`, 'warn'); return ok; } // An amp-api album → what the row shows. Songs only: the videos are in trackCount too. function appleAlbumMeta(a) { const at = (a && a.attributes) || {}, rel = a && a.relationships && a.relationships.tracks; const list = rel && !rel.next && Array.isArray(rel.data) ? rel.data : null; // a paged list isn't all of it const songs = list ? list.filter(t => t.type === 'songs').length : null; return { url: String(at.url || '').split('?')[0], title: at.name || null, artist: at.artistName || null, tracks: songs ?? at.trackCount ?? null, tracksNote: (songs == null ? 'its trackCount, which may include videos' : list.length > songs ? `${list.length - songs} video(s) left out` : 'songs') + (list && at.trackCount > list.length ? `; the album has ${at.trackCount - list.length} more the storefront doesn't offer` : ''), // tracks the album lists but the storefront doesn't offer: the API leaves them out (#684) unoffered: list && at.trackCount > list.length ? at.trackCount - list.length : 0, year: at.releaseDate ? at.releaseDate.slice(0, 4) : null, label: at.recordLabel || null, barcode: at.upc || null, credits: pcCreditsApple(a, appleStorefront(at.url)), // #671 }; } async function scanApple(args) { await scanAppleFind(args); await pcAppleSale(args.mbid); } // The iTunes Store sells the album when its lookup gives a price (a streaming-only one has none): // its link then also gets 'purchase for download' (pcExtraType). Asked once per match, kept in the cache. async function pcAppleSale(mbid) { const c = cacheGet(mbid, 'apple'); if (!c?.url || 'forSale' in c) return; const m = c.url.match(/\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/i); if (!m) return; const r = await gmGet(`https://itunes.apple.com/lookup?id=${m[2]}&country=${m[1]}`).catch(() => null); if (!r?.ok) { appendLog('Apple', 'iTunes Store lookup failed — the link stays a streaming page only', 'warn'); return; } let a = null; try { a = (JSON.parse(r.responseText).results || []).find(x => x.wrapperType === 'collection'); } catch {} const forSale = !!(a && a.collectionPrice > 0); appendLog('Apple', `iTunes Store: ${forSale ? `for sale (${a.collectionPrice} ${a.currency || ''}) — also purchase for download` : 'not for sale — streaming page only'}`); cacheSet(mbid, 'apple', { ...cacheGet(mbid, 'apple'), forSale }); } async function scanAppleFind(args) { const { mbTracks, existingUrl, mbid, barcode } = args; const label = 'Apple'; const cached = cacheGetScan(mbid, 'apple', label); if (cached?.url && (!existingUrl || existingUrl === cached.url)) { applyCachedRow('apple', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl && !barcode) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('apple', label, cached, mbTracks); return; } try { if (await scanAppleAmp(args)) return; } catch (e) { appendLog(label, `amp-api unavailable (${e.message}) — falling back to the iTunes API`, 'warn'); } return scanAppleItunes(args); } // true once it has set the row async function scanAppleAmp({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, wikidataAppleId, barcode }) { const label = 'Apple', sf = appleStorefront(existingUrl); const done = (meta, source) => { appendLog(label, `Album: "${meta.title}" — ${meta.tracks ?? '?'} track(s) (${meta.tracksNote}), ${meta.year || '?'}, ${meta.label || '?'}, UPC ${meta.barcode || '?'}`, meta.tracks ? 'ok' : 'warn'); cacheSet(mbid, 'apple', { url: meta.url, tracks: meta.tracks, unoffered: meta.unoffered, year: meta.year, label: meta.label, source, barcode: meta.barcode, credits: pcKeep(label, meta.credits), songArtists: true }); updateRow('apple', { url: meta.url, mbTracks, remoteTracks: meta.tracks, unoffered: meta.unoffered, year: meta.year, label: meta.label, source, barcode: meta.barcode }); return true; }; const none = source => { cacheSet(mbid, 'apple', { url: null, tracks: null, year: null, label: null, source }); updateRow('apple', { url: null, mbTracks, remoteTracks: null }); return true; }; const shortSfs = [...new Set([sf, ...APPLE_SHORTLIST])]; // the rest of the storefronts, for a second round when the short list had nothing const otherSfs = async () => (await appleAllStorefronts()).filter(s => !shortSfs.includes(s)); // Barcode first: the album whose UPC it is, not the first one Apple lists. Every zero-padding // of the barcode goes in the one request per storefront (filter[upc] takes a list). if (!existingUrl && !wikidataAppleId && barcode) { const forms = gtinVariants(barcode); const upcIn = async sfs => { const got = await appleEach(sfs, `albums?filter[upc]=${forms.map(encodeURIComponent).join(',')}&${APPLE_ALBUM_INC}`); const seen = new Set(); for (const { sf: s, j } of got) for (const a of (j.data || [])) { if (seen.has(a.id)) continue; seen.add(a.id); appendLog(label, ` UPC ${forms.join('/')} in "${s}": "${a.attributes?.name}" has UPC ${a.attributes?.upc || '?'} — ${a.attributes?.url}`); } for (const { sf: s, j } of got) for (const u of forms) { const hit = applePickByUpc(j.data, u); if (hit) return { hit, sf: s }; } return null; }; let found = await upcIn(shortSfs); if (!found) { const rest = await otherSfs(); appendLog(label, `Barcode ${barcode}: no album with that UPC in ${shortSfs.join(', ')} — asking the other ${rest.length} storefronts`); found = await upcIn(rest); } if (found) { const meta = appleAlbumMeta(found.hit); appendLog(label, `Barcode ${barcode} → ${meta.url} (its UPC, in the "${found.sf}" storefront)`, 'ok'); return done(meta, 'barcode'); } appendLog(label, `Barcode ${barcode}: no album with that UPC in any storefront — falling back to search`); } // A link MusicBrainz or Wikidata already has: read the album itself let id = null, source = null; if (existingUrl) { id = (existingUrl.match(/\/album\/(?:[^/?#]+\/)?(?:id)?(\d+)/) || [])[1] || null; source = 'MB rels'; appendLog(label, `Using existing MB URL: ${existingUrl}`, 'ok'); } else if (wikidataAppleId) { id = String(wikidataAppleId); source = 'Wikidata'; appendLog(label, `Wikidata answer: album ${id}`, 'ok'); } if (id) { const a = ((await appleAmp(`${sf}/albums/${id}?${APPLE_ALBUM_INC}`)).data || [])[0]; const url = existingUrl ? existingUrl.split('?')[0] : `https://music.apple.com/${sf}/album/${id}`; if (!a) { appendLog(label, `amp-api: album ${id} is not in the "${sf}" storefront — keeping the link, details unknown`, 'warn'); cacheSet(mbid, 'apple', { url, tracks: null, year: null, label: null, source, credits: null, songArtists: true }); updateRow('apple', { url, mbTracks, remoteTracks: null, source }); return true; } const meta = appleAlbumMeta(a); meta.url = url; return done(meta, source); } // Search. VA compilations: the album title alone (Apple doesn't credit a literal "Various Artists"). const term = isVariousArtists ? album : `${artist} ${album}`; // every storefront's candidates, pooled: one album is one candidate, from the first storefront that has it const searchIn = async sfs => { const got = await appleEach(sfs, `search?term=${encodeURIComponent(term)}&types=albums&limit=10`); const byId = new Map(); for (const { sf: s, j } of got) for (const a of ((j.results && j.results.albums && j.results.albums.data) || [])) if (!byId.has(a.id)) byId.set(a.id, { a, sf: s }); return [...byId.values()]; }; let pool = await searchIn(shortSfs); appendLog(label, `amp-api search "${term}" in ${shortSfs.join(', ')}: ${pool.length} candidate(s)`); if (!pool.length) { const rest = await otherSfs(); pool = await searchIn(rest); appendLog(label, `amp-api search "${term}" in the other ${rest.length} storefronts: ${pool.length} candidate(s)${pool.length ? ' (' + [...new Set(pool.map(p => p.sf))].slice(0, 8).join(', ') + ')' : ''}`); } if (!pool.length) return none('API search'); const results = pool.map(p => Object.assign(p.a, { _sf: p.sf })); let best = null; for (const a of results) { const at = a.attributes || {}; // a candidate carrying the release's own barcode is the answer, whatever its score const upcHit = !!(barcode && at.upc && normBarcode(at.upc) === normBarcode(barcode)); const sc = upcHit ? 1000 : scoreCandidate({ tracks: at.trackCount, title: at.name, artist: at.artistName }, mbTracks, album, artist, isVariousArtists); appendLog(label, ` cand ${upcHit ? 'UPC match' : 'score=' + sc} tracks=${at.trackCount ?? '?'} artist="${at.artistName || '?'}" title="${at.name}" upc=${at.upc || '?'} url=${at.url}`); if (!best || sc > best.score) best = { score: sc, a }; if (upcHit) break; } if (best.score < 120) { appendLog(label, `No verifiable match (best score=${best.score}) — leaving URL unset`, 'warn'); return none('API search'); } // the album itself, for the songs-only count (a search result has no track list) const full = ((await appleAmp(`${best.a._sf || sf}/albums/${best.a.id}?${APPLE_ALBUM_INC}`)).data || [])[0] || best.a; const meta = appleAlbumMeta(full); appendLog(label, `Picked best (${best.score >= 1000 ? 'UPC match' : 'score=' + best.score}): ${meta.url}`, best.score >= 150 ? 'ok' : 'warn'); return done(meta, 'API search'); } // The iTunes Search API: the fallback when amp-api is unavailable (#627). async function scanAppleItunes({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, wikidataAppleId, barcode }) { const label = 'Apple'; const cached = cacheGetScan(mbid, 'apple', label); if (cached?.url && (!existingUrl || existingUrl === cached.url)) { applyCachedRow('apple', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl && !barcode) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('apple', label, cached, mbTracks); return; } // Barcode-first: iTunes `lookup?upc=<UPC>` is an exact match. Falls through // to the term search (and Wikidata) when there's no UPC hit. if (!existingUrl && !wikidataAppleId && barcode) { // #354: try the raw barcode first, then the other GTIN paddings on a miss. const hit = await upcTry(barcode, async (u) => { const br = await gmGet(`https://itunes.apple.com/lookup?upc=${u}&entity=album`); let d = null; try { d = JSON.parse(br.responseText); } catch {} return (d && (d.results || []).find(r => r.collectionViewUrl)) || null; }); if (hit) { const albumUrl = hit.collectionViewUrl.split('?')[0]; appendLog(label, `Barcode ${barcode} → ${albumUrl}`, 'ok'); const meta = await fetchAppleMeta(albumUrl); cacheSet(mbid, 'apple', { url: albumUrl, tracks: meta?.tracks ?? null, year: meta?.year ?? null, label: meta?.label ?? null, source: 'barcode', credits: null }); updateRow('apple', { url: albumUrl, mbTracks, remoteTracks: meta?.tracks ?? null, year: meta?.year ?? null, label: meta?.label ?? null, source: 'barcode' }); return; } appendLog(label, `Barcode ${barcode}: no UPC match — falling back to search`); } let albumUrl = existingUrl; let source = null; if (albumUrl) { appendLog(label, `Using existing MB URL: ${albumUrl}`, 'ok'); source = 'MB rels'; } else if (wikidataAppleId) { // P5121 stores the bare numeric Apple Music album ID. Construct the // canonical /us/album/<id> URL (no slug needed for resolution). albumUrl = `https://music.apple.com/us/album/${wikidataAppleId}`; appendLog(label, `Wikidata answer: ${albumUrl}`, 'ok'); source = 'Wikidata'; } else { // iTunes Search API. VA compilations: query album-only (the API // doesn't credit compilations to a literal "Various Artists" string). const term = isVariousArtists ? album : `${artist} ${album}`; const searchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&entity=album&limit=10`; appendLog(label, `API search: ${searchUrl}`); const sr = await gmGet(searchUrl); appendLog(label, `API search: status=${sr.status} ${sr.responseText.length}b in ${sr.ms}ms`); let results = []; if (sr.ok) { try { results = (JSON.parse(sr.responseText).results) || []; } catch (e) { appendLog(label, `API JSON parse error: ${e.message}`, 'error'); } } appendLog(label, `API search: ${results.length} candidate(s)`); if (!results.length) { cacheSet(mbid, 'apple', { url: null, tracks: null, year: null, label: null, source: 'API search' }); updateRow('apple', { url: null, mbTracks, remoteTracks: null }); return; } let best = null; for (const it of results) { const sc = scoreCandidate({ tracks: it.trackCount, title: it.collectionName, artist: it.artistName }, mbTracks, album, artist, isVariousArtists); appendLog(label, ` cand score=${sc} tracks=${it.trackCount ?? '?'} artist="${it.artistName || '?'}" title="${it.collectionName}" url=${it.collectionViewUrl}`); if (!best || sc > best.score) best = { score: sc, item: it }; if (sc >= 150) break; } if (!best || best.score < 120) { appendLog(label, `No verifiable match (best score=${best?.score ?? 'n/a'}) — leaving URL unset`, 'warn'); cacheSet(mbid, 'apple', { url: null, tracks: null, year: null, label: null, source: 'API search' }); updateRow('apple', { url: null, mbTracks, remoteTracks: null }); return; } // Strip the `?uo=4` affiliate tail that iTunes Search returns on // collectionViewUrl — MB normalises to the clean form. albumUrl = best.item.collectionViewUrl.split('?')[0]; source = 'API search'; appendLog(label, `Picked best (score=${best.score}): ${albumUrl}`, best.score >= 150 ? 'ok' : 'warn'); } const meta = await fetchAppleMeta(albumUrl); if (meta) { appendLog(label, `Album parsed: tracks=${meta.tracks} title="${meta.title}" year=${meta.year || '?'} label=${meta.label || '?'}`, meta.tracks ? 'ok' : 'warn'); } else { appendLog(label, `Detail fetch failed`, 'error'); } const tracks = meta?.tracks ?? null; const year = meta?.year ?? null; const lbl = meta?.label ?? null; cacheSet(mbid, 'apple', { url: albumUrl, tracks, year, label: lbl, source, credits: null }); updateRow('apple', { url: albumUrl, mbTracks, remoteTracks: tracks, year, label: lbl, source }); } // ─── Tidal ────────────────────────────────────────────────────────────────── // Official API (JSON:API). All calls need an app token (client-credentials), so // catalog reads are anonymous to the user. Existing MB URL → Wikidata P4577 → // searchResults API. Each candidate album carries title + numberOfItems inline, // so scoring needs no per-candidate fetch (like Deezer); the picked album is // then fetched once for year/label. async function fetchTidalAlbumMeta(albumId, token) { const r = await gmGet(`${TIDAL.api}/albums/${albumId}?countryCode=${TIDAL.country}`, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.api+json' } }); if (!r.ok) return null; try { const a = JSON.parse(r.responseText)?.data?.attributes; if (!a) return null; const lbl = stripCopyright(a.copyright?.text); return { tracks: a.numberOfItems ?? null, title: a.title || null, year: a.releaseDate ? a.releaseDate.slice(0, 4) : null, label: lbl, barcode: a.barcodeId || null }; } catch { return null; } } // Barcode-first (#182): Tidal v2 /albums?filter[barcodeId] is an exact match. async function tidalAlbumByBarcode(barcode, token) { const r = await gmGet(`${TIDAL.api}/albums?countryCode=${TIDAL.country}&filter%5BbarcodeId%5D=${encodeURIComponent(barcode)}`, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.api+json' } }); if (!r.ok) return null; try { const a = (JSON.parse(r.responseText).data || [])[0]; return a ? { id: a.id, barcode: a.attributes?.barcodeId || barcode } : null; } catch { return null; } } async function scanTidal({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, wikidataTidalId, barcode }) { const label = 'Tidal'; const cached = cacheGet(mbid, 'tidal'); if (cached?.url && (!existingUrl || existingUrl === cached.url)) { applyCachedRow('tidal', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl && !wikidataTidalId) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('tidal', label, cached, mbTracks); return; } const idFromUrl = u => (String(u || '').match(/tidal\.com\/(?:browse\/)?album\/(\d+)/) || [])[1]; let albumId = null, source = null, foundBarcode = null; // Barcode-first (#182): an exact-UPC hit beats Wikidata/search (which may // point at a different-barcode edition). Only when there's no MB rel. if (!existingUrl && barcode) { const tok = await tidalToken(); if (tok) { const hit = await tidalAlbumByBarcode(barcode, tok); if (hit) { albumId = hit.id; source = 'barcode'; foundBarcode = hit.barcode || barcode; appendLog(label, `Barcode ${barcode} → album ${albumId}`, 'ok'); } else appendLog(label, `Barcode ${barcode}: no Tidal UPC match — falling back`); } } if (!albumId && existingUrl) { albumId = idFromUrl(existingUrl); source = 'MB rels'; appendLog(label, `Using existing MB URL: ${existingUrl}`, 'ok'); } else if (!albumId && wikidataTidalId) { albumId = wikidataTidalId; source = 'Wikidata'; appendLog(label, `Wikidata answer: album ${albumId}`, 'ok'); } else if (!albumId) { // Only the SEARCH path needs a token — an existing/Wikidata URL is shown // (and circled, for MB rels) even if the token grant fails. const token = await tidalToken(); if (!token) { updateRow('tidal', { url: null, mbTracks, remoteTracks: null }); return; } const q = isVariousArtists ? album : `${artist} ${album}`; const searchUrl = `${TIDAL.api}/searchResults/${encodeURIComponent(q)}?countryCode=${TIDAL.country}&include=albums`; appendLog(label, `API search: ${searchUrl}`); const sr = await gmGet(searchUrl, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.api+json' } }); appendLog(label, `API search: status=${sr.status} ${sr.responseText.length}b in ${sr.ms}ms`); if (!sr.ok) { cacheSet(mbid, 'tidal', { url: null, tracks: null, year: null, label: null, source: 'API search' }); updateRow('tidal', { url: null, mbTracks, remoteTracks: null }); return; } let albums = []; try { const data = JSON.parse(sr.responseText); const order = (data.data?.relationships?.albums?.data || []).map(d => d.id); const byId = {}; (data.included || []).forEach(x => { if (x.type === 'albums') byId[x.id] = x.attributes || {}; }); albums = order.map(id => ({ id, attr: byId[id] })).filter(x => x.attr); } catch (e) { appendLog(label, `API JSON parse error: ${e.message}`, 'error'); updateRow('tidal', { url: null, mbTracks, remoteTracks: null }); return; } appendLog(label, `API search: ${albums.length} candidate(s)`); let best = null; for (const it of albums) { const sc = scoreCandidate({ tracks: it.attr.numberOfItems, title: it.attr.title, artist: null }, mbTracks, album, artist, isVariousArtists); appendLog(label, ` cand score=${sc} tracks=${it.attr.numberOfItems ?? '?'} title="${it.attr.title}" id=${it.id}`); if (!best || sc > best.score) best = { score: sc, id: it.id }; if (sc >= 150) break; } if (!best || best.score < 120) { appendLog(label, `No verifiable match (best score=${best?.score ?? 'n/a'}) — leaving URL unset`, 'warn'); cacheSet(mbid, 'tidal', { url: null, tracks: null, year: null, label: null, source: 'API search' }); updateRow('tidal', { url: null, mbTracks, remoteTracks: null }); return; } albumId = best.id; source = 'API search'; appendLog(label, `Picked best (score=${best.score}): album ${albumId}`, best.score >= 150 ? 'ok' : 'warn'); } if (!albumId) { updateRow('tidal', { url: null, mbTracks, remoteTracks: null }); return; } const albumUrl = `https://tidal.com/album/${albumId}`; // Track-count/year/label are best-effort — needs a token, but its absence // must NOT drop an existing/Wikidata link (it just stays count-unverified). const metaTok = await tidalToken(); const meta = metaTok ? await fetchTidalAlbumMeta(albumId, metaTok) : null; if (meta) appendLog(label, `Album parsed: tracks=${meta.tracks} title="${meta.title}" year=${meta.year || '?'} label=${meta.label || '?'}`, meta.tracks ? 'ok' : 'warn'); else appendLog(label, `Detail unavailable (no token / fetch failed) — showing URL without track count`, 'warn'); const tracks = meta?.tracks ?? null, year = meta?.year ?? null, lbl = meta?.label ?? null; const bc = foundBarcode || meta?.barcode || null; cacheSet(mbid, 'tidal', { url: albumUrl, tracks, year, label: lbl, source, barcode: bc }); updateRow('tidal', { url: albumUrl, mbTracks, remoteTracks: tracks, year, label: lbl, source, barcode: bc }); } // ─── Beatport ───────────────────────────────────────────────────────────────── // Logged in (⚙ setup → Beatport account): the official API gives track counts, so // matches are VERIFIED (→ the + insert works) — resolved by barcode, then artist+album // search, and an existing MB-rel/Wikidata id is verified too. Not logged in: Beatport's // pages are Cloudflare-walled (unfetchable), so we fall back to MB rel → Wikidata (P11312) // → web search, surfaced UNVERIFIED (no track count). // #740: Beatport's release detail names its catalog number, for its one label function pcCatnosBeatport(d) { const c = String(d?.catalog_number || '').trim(); return c && d.label?.name ? [{ label: d.label.name, catno: c }] : null; } async function scanBeatport({ artist, album, existingUrl, mbTracks, mbid, isVariousArtists, wikidataBeatportId, barcode }) { const label = 'Beatport'; const cached = cacheGetScan(mbid, 'beatport', label); const idFromUrl = u => (String(u || '').match(/beatport\.com\/release\/[^/]+\/(\d+)/) || [])[1]; // A cached hit short-circuits — EXCEPT when we're now logged in and the // cached entry was never track-count verified (e.g. a web-search result // cached while logged out, tracks=null). In that case fall through to the // authed API below to upgrade it to a verified match, reusing the cached // URL's release id, instead of leaving it UNVERIFIED forever (#168). const cachedUnverified = !!(cached?.url && cached.tracks == null && bpLoggedIn()); if (cached?.url && (!existingUrl || existingUrl === cached.url) && !cachedUnverified) { applyCachedRow('beatport', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl && !wikidataBeatportId && !bpLoggedIn()) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('beatport', label, cached, mbTracks); return; } if (cachedUnverified) appendLog(label, `Cached match is unverified — re-checking via API now that you're logged in`); let relId = existingUrl ? idFromUrl(existingUrl) : (wikidataBeatportId || (cachedUnverified ? idFromUrl(cached.url) : null)); let url = existingUrl || (cachedUnverified ? cached.url : null); let source = existingUrl ? 'MB rels' : (wikidataBeatportId ? 'Wikidata' : (cachedUnverified ? (cached.source || 'cache') : null)); // ── Authed: official API → verified track count ── if (bpLoggedIn()) { let rel = null; if (!relId) { if (barcode) { const s = await beatportApi(`/catalog/search/?q=${encodeURIComponent(barcode)}&type=releases&per_page=5`); rel = (s && (s.releases || []).find(r => String(r.upc) === String(barcode))) || null; if (rel) { source = 'barcode'; appendLog(label, `Barcode ${barcode} → release ${rel.id} "${rel.name}"`, 'ok'); } } if (!rel) { const q = isVariousArtists ? album : `${artist} ${album}`; const s = await beatportApi(`/catalog/search/?q=${encodeURIComponent(q)}&type=releases&per_page=10`); const cands = (s && s.releases) || []; appendLog(label, `API search: ${cands.length} candidate(s)`); let best = null; for (const it of cands) { const sc = scoreCandidate({ tracks: it.track_count, title: it.name, artist: (it.artists || []).map(a => a.name).join(' ') }, mbTracks, album, artist, isVariousArtists); appendLog(label, ` cand score=${sc} tracks=${it.track_count ?? '?'} artist="${(it.artists || []).map(a => a.name).join(' ') || '?'}" title="${it.name || ''}" id=${it.id}`); if (!best || sc > best.score) best = { score: sc, it }; if (sc >= 150) break; } if (best && best.score >= 120) { rel = best.it; source = 'API search'; appendLog(label, `Picked best (score=${best.score}): release ${rel.id}`, best.score >= 150 ? 'ok' : 'warn'); } else if (cands.length) appendLog(label, `No verifiable match via authed API (best score=${best?.score ?? 'n/a'}) — falling back to unauthed resolvers`, 'warn'); } } // Verify via detail — covers an MB-rel / Wikidata id, and fills the count for a search hit. const detail = rel || (relId ? await beatportApi(`/catalog/releases/${relId}/`) : null); if (detail) { const slug = detail.slug || (url && (url.match(/\/release\/([^/]+)\//) || [])[1]) || '-'; url = `https://www.beatport.com/release/${slug}/${detail.id}`; const tracks = detail.track_count != null ? detail.track_count : ((detail.tracks || []).length || null); const year = String(detail.new_release_date || detail.publish_date || '').slice(0, 4) || null; const lbl = detail.label?.name || null; const bc = detail.upc || (source === 'barcode' ? barcode : null); // (#182) Beatport exposes the UPC appendLog(label, `Verified: tracks=${tracks} year=${year || '?'} label=${lbl || '?'}${bc ? ` upc=${bc}` : ''}`, tracks ? 'ok' : 'warn'); cacheSet(mbid, 'beatport', { url, tracks, year, label: lbl, source, barcode: bc, catnos: pcCatnosBeatport(detail), credits: pcKeep(label, pcCreditsBeatport(detail)) }); updateRow('beatport', { url, mbTracks, remoteTracks: tracks, year, label: lbl, source, barcode: bc }); return; } // authed but the API found nothing & no rel id → fall through to the no-auth resolvers } // ── Not authed (or API miss): MB rel → Wikidata → web search, UNVERIFIED ── if (!url && wikidataBeatportId) { url = `https://www.beatport.com/release/-/${wikidataBeatportId}`; source = 'Wikidata'; appendLog(label, `Wikidata answer: release ${wikidataBeatportId}`, 'ok'); } if (!url) { const q = `site:beatport.com/release/ ${searchTerms(artist)} ${searchTerms(album)}`; const hits = await searchWeb(q, u => /^https?:\/\/(?:www\.)?beatport\.com\/release\/[^/]+\/\d+/i.test(u), label, 5); if (!hits.length) { appendLog(label, `No match — no MB rel, no Wikidata P11312, no search hit`, 'warn'); cacheSet(mbid, 'beatport', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('beatport', { url: null, mbTracks, remoteTracks: null }); return; } // #380 DDG often returns unrelated releases (its fuzzy match), and repeating it trips Beatport's // protections — so the hit is untrustworthy. REQUIRE the release slug to actually match the album // (don't blindly take hits[0], which was returning e.g. "hard-trance-sessions-vol-1" for an African // comp). If none match, report no-match rather than a wrong release. const slugOf = u => decodeURIComponent((u.match(/\/release\/([^/]+)\//) || [])[1] || '').replace(/-/g, ' '); // min-mode: most of the (short) slug's tokens must appear in the album title — lenient enough for a // short official slug vs a long MB title, but rejects an unrelated release that shares ~nothing. let pick = null; for (const h of hits) { if (tokenMatch(slugOf(h), album, 'min', 0.6)) { pick = h; break; } } if (!pick) { appendLog(label, `Search hits didn't match “${album}” (top: ${slugOf(hits[0])}) — skipping to avoid a wrong release (#380)`, 'warn'); cacheSet(mbid, 'beatport', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('beatport', { url: null, mbTracks, remoteTracks: null, source: 'search' }); return; } url = pick.split(/[?#]/)[0]; source = 'search'; // #380 if we happen to be logged in, verify the search hit against the API before trusting it if (bpLoggedIn()) { const vid = idFromUrl(url), detail = vid ? await beatportApi(`/catalog/releases/${vid}/`) : null; if (detail) { const sc = scoreCandidate({ tracks: detail.track_count, title: detail.name, artist: (detail.artists || []).map(a => a.name).join(' ') }, mbTracks, album, artist, isVariousArtists); if (sc < 100) { appendLog(label, `Search hit "${detail.name}" failed API verification (score=${sc}) — rejecting (#380)`, 'warn'); cacheSet(mbid, 'beatport', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('beatport', { url: null, mbTracks, remoteTracks: null, source: 'search' }); return; } const tracks = detail.track_count != null ? detail.track_count : ((detail.tracks || []).length || null); const year = String(detail.new_release_date || detail.publish_date || '').slice(0, 4) || null; const lbl = detail.label?.name || null, bc = detail.upc || null; appendLog(label, `Search hit verified via API (score=${sc}): tracks=${tracks} year=${year || '?'}`, 'ok'); cacheSet(mbid, 'beatport', { url, tracks, year, label: lbl, source: 'search+api', barcode: bc, catnos: pcCatnosBeatport(detail), credits: pcKeep(label, pcCreditsBeatport(detail)) }); updateRow('beatport', { url, mbTracks, remoteTracks: tracks, year, label: lbl, source: 'search+api', barcode: bc }); return; } } appendLog(label, `Found via search (UNVERIFIED — log in via ⚙ for track-count verification): ${url}`, 'warn'); } else if (source === 'MB rels') { appendLog(label, `Using existing MB URL: ${url}`, 'ok'); } cacheSet(mbid, 'beatport', { url, tracks: null, year: null, label: null, source, credits: null }); updateRow('beatport', { url, mbTracks, remoteTracks: null, source }); } // ─── Volumo ───────────────────────────────────────────────────────────────── // Electronic-music store with a clean, unauthenticated JSON API // (volumo.com/api/v1) — no Cloudflare, no captcha, no token. A release lookup // returns the full tracklist (with ISRCs) in one call. We resolve by existing // MB rel → barcode (ICPN) → artist+album search. Volumo's canonical page is // /album/{icpn}-{slug}, but the bare /album/{id} form 308-redirects to it. // #202: MB doesn't normalize Volumo URLs yet (MBS-14369), so we emit the // clean, slug-less /album/{id} form ourselves — drop the human-readable // "-{slug}" tail (it's purely cosmetic and only invites duplicate-link noise). // {id} is the ICPN (barcode) when known, else Volumo's internal album id. const volumoUrl = a => a && a.icpn ? `https://volumo.com/album/${a.icpn}` : (a && a.id ? `https://volumo.com/album/${a.id}` : null); // Normalize any Volumo album URL to that slug-less form so a slugged MB rel and // our clean URL compare equal (avoids a false "differs" / re-add). const volumoClean = u => { const m = String(u || '').match(/\/album\/(\d+)/i); return m ? `https://volumo.com/album/${m[1]}` : (u || null); }; async function volumoApi(path) { const r = await gmGet('https://volumo.com/api/v1' + path); if (!r.ok) return null; try { const j = JSON.parse(r.responseText); return Array.isArray(j) ? j[0] : (j.album || j); } catch { return null; } } async function scanVolumo({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, barcode }) { const label = 'Volumo'; const cached = cacheGet(mbid, 'volumo'); if (cached?.url && (!existingUrl || volumoClean(existingUrl) === volumoClean(cached.url))) { applyCachedRow('volumo', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl && !barcode) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('volumo', label, cached, mbTracks); return; } let a = null, source = null; if (existingUrl) { source = 'MB rels'; appendLog(label, `Using existing MB URL: ${existingUrl}`, 'ok'); const m = existingUrl.match(/\/album\/(\d{8,14})(?:-|$)/) || existingUrl.match(/\/album\/(\d+)/); if (m) a = m[1].length >= 8 ? await volumoApi('/album_by_icpn/' + m[1]) : await volumoApi('/albums/' + m[1]); } else if (barcode) { a = await volumoApi('/album_by_icpn/' + barcode); if (a) { source = 'barcode'; appendLog(label, `Barcode ${barcode} → album ${a.id} "${a.title}"`, 'ok'); } } if (!a && !existingUrl) { const q = isVariousArtists ? album : `${artist} ${album}`; const searchUrl = `https://volumo.com/api/v1/search?query=${encodeURIComponent(q)}&limit=10`; appendLog(label, `API search: ${searchUrl}`); const j = await gmGet(searchUrl); appendLog(label, `API search: status=${j.status} ${(j.responseText || '').length}b in ${j.ms}ms`); let albums = []; // Volumo returns each bucket as an array when populated, but an empty {} when there are no hits — coerce. try { const ab = JSON.parse(j.responseText).albums; albums = Array.isArray(ab) ? ab : []; } catch (e) { appendLog(label, `API search: JSON parse error: ${e.message}`, 'error'); } appendLog(label, `API search: ${albums.length} candidate(s)`); let best = null; for (const it of albums) { const sc = scoreCandidate({ tracks: (it.tracks || []).length || null, title: it.title, artist: (it.artists || []).map(x => x.name).join(' ') }, mbTracks, album, artist, isVariousArtists); appendLog(label, ` cand score=${sc} tracks=${(it.tracks || []).length || '?'} artist="${(it.artists || []).map(x => x.name).join(' ') || '?'}" title="${it.title || ''}" id=${it.id}`); if (!best || sc > best.score) best = { score: sc, a: it }; if (sc >= 150) break; } if (best && best.score >= 120) { a = best.a; source = 'API search'; appendLog(label, `Picked best (score=${best.score}): album ${a.id}`, best.score >= 150 ? 'ok' : 'warn'); } else appendLog(label, `No verifiable match (best score=${best?.score ?? 'n/a'}) — leaving URL unset`, 'warn'); } if (!a) { cacheSet(mbid, 'volumo', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('volumo', { url: null, mbTracks, remoteTracks: null }); return; } // search hits may omit the full tracklist — refetch by ICPN for an accurate count if (!(a.tracks && a.tracks.length) && a.icpn) { const full = await volumoApi('/album_by_icpn/' + a.icpn); if (full) a = full; } const tracks = (a.tracks || []).length || null; const year = String(a.original_release_date || '').slice(0, 4) || null; const lbl = a.recordlabel?.name || (typeof a.recordlabel === 'string' ? a.recordlabel : null); // recordlabel is an object {id,name,…} const url = volumoUrl(a); const vbc = a.icpn || a.upc || null; appendLog(label, `Album: tracks=${tracks} title="${a.title}" year=${year || '?'}`, tracks ? 'ok' : 'warn'); cacheSet(mbid, 'volumo', { url, tracks, year, label: lbl, source, barcode: vbc }); updateRow('volumo', { url, mbTracks, remoteTracks: tracks, year, label: lbl, source, barcode: vbc }); } // ─── HDtracks ──────────────────────────────────────────────────────────────── // High-resolution download store (#176) with a clean, unauthenticated, CORS-open // JSON API (hdtracks.azurewebsites.net/api/v1) — no Cloudflare, no captcha, no // token. The album id is a 24-char hex ObjectId; a barcode/UPC is resolved to it // via the search endpoint. /album/<id> embeds the full tracklist + per-track // ISRCs in one call. Bad ids / unknown GTINs return HTTP 200 with an empty body, // so presence is read from the JSON, not the status code. The 5009 legacy MB rels // use old URL forms (valbum_code=<UPC>, slug-id, artist page); the new canonical // web URL is https://www.hdtracks.com/#/album/<id>. We resolve by existing MB rel // → barcode → artist+album search. const HD_API = 'https://hdtracks.azurewebsites.net/api/v1'; const hdtracksUrl = id => id ? `https://www.hdtracks.com/#/album/${id}` : null; async function hdtracksAlbum(id) { const r = await gmGet(`${HD_API}/album/${id}`); if (!r.ok) return null; try { const j = JSON.parse(r.responseText); return j && j.id ? j : null; } catch { return null; } } async function hdtracksSearch(q) { const url = `${HD_API}/albums/search?q=${encodeURIComponent(q)}`; appendLog('HDtracks', `API search: ${url}`); const r = await gmGet(url); appendLog('HDtracks', `API search: status=${r.status} ${(r.responseText || '').length}b in ${r.ms}ms`); if (!r.ok) return []; try { const j = JSON.parse(r.responseText); return Array.isArray(j.albums) ? j.albums : []; } catch (e) { appendLog('HDtracks', `API search: JSON parse error: ${e.message}`, 'error'); return []; } } async function scanHDtracks({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, barcode }) { const label = 'HDtracks'; const cached = cacheGet(mbid, 'hdtracks'); if (cached?.url && (!existingUrl || existingUrl === cached.url)) { applyCachedRow('hdtracks', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl && !barcode) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('hdtracks', label, cached, mbTracks); return; } let a = null, source = null; if (existingUrl) { source = 'MB rels'; appendLog(label, `Using existing MB URL: ${existingUrl}`, 'ok'); let m = existingUrl.match(/\/album\/([a-f0-9]{24})/i); if (m) a = await hdtracksAlbum(m[1]); else if ((m = existingUrl.match(/[?&]valbum_code=(\d{8,})/i))) { // legacy URL: valbum_code is the UPC const hits = await hdtracksSearch(m[1]); if (hits[0]?.id) a = await hdtracksAlbum(hits[0].id); } // legacy slug-id / artist-page rels have no clean id mapping — fall through to barcode/search } if (!a && barcode) { const hits = await hdtracksSearch(barcode); if (hits[0]?.id) { a = await hdtracksAlbum(hits[0].id); if (a) { source = 'barcode'; appendLog(label, `Barcode ${barcode} → album ${a.id} "${a.name}"`, 'ok'); } } } if (!a && !existingUrl) { const q = isVariousArtists ? album : `${artist} ${album}`; const albums = await hdtracksSearch(q); appendLog(label, `API search: ${albums.length} candidate(s)`); let best = null; for (const it of albums) { const sc = scoreCandidate({ tracks: it.tracksCount || null, title: it.name, artist: (it.artists || []).join(' ') || it.mainArtist }, mbTracks, album, artist, isVariousArtists); appendLog(label, ` cand score=${sc} tracks=${it.tracksCount ?? '?'} artist="${(it.artists || []).join(' ') || it.mainArtist || '?'}" title="${it.name || ''}" id=${it.id}`); if (!best || sc > best.score) best = { score: sc, a: it }; if (sc >= 150) break; } if (best && best.score >= 120) { a = (await hdtracksAlbum(best.a.id)) || best.a; source = 'API search'; appendLog(label, `Picked best (score=${best.score}): album ${best.a.id}`, best.score >= 150 ? 'ok' : 'warn'); } else appendLog(label, `No verifiable match (best score=${best?.score ?? 'n/a'}) — leaving URL unset`, 'warn'); } if (!a) { cacheSet(mbid, 'hdtracks', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('hdtracks', { url: null, mbTracks, remoteTracks: null }); return; } const tracks = (a.tracks && a.tracks.length) || a.tracksCount || null; const year = String(a.release || a.originalRelease || '').slice(0, 4) || null; const lbl = a.label || null; const url = hdtracksUrl(a.id); const hbc = a.upc || null; appendLog(label, `Album: tracks=${tracks} title="${a.name}" year=${year || '?'}`, tracks ? 'ok' : 'warn'); cacheSet(mbid, 'hdtracks', { url, tracks, year, label: lbl, source, barcode: hbc }); updateRow('hdtracks', { url, mbTracks, remoteTracks: tracks, year, label: lbl, source, barcode: hbc }); } // ─── 7digital (#669) ───────────────────────────────────────────────────────── // 7digital's catalogue API (api.7digital.com/1.2), with the key its own web store // ships in its app.js. That key may search (release/search) but not read a // release's details; a search hit carries everything the row needs anyway: // barcode, track count, year, label. A barcode is searched as text and only // matches as 7digital stores it (12, 13 or 14 digits: KIWANUKA is // 00602508436901), so every GTIN form is tried. The key in hand is replaced by // the store's current one when the API refuses it, and kept for every MB tab // (ISRC Scout reads the same). The catalogue is per country: GB, then US. const SD_API = 'https://api.7digital.com/1.2/'; const SD_KEY_STORE = 'mbtools:7digital-key'; // shared with ISRC Scout const SD_KEY_DEFAULT = '7drfpc993qp5'; // the store's key when this was written; a refused one is replaced const SD_COUNTRIES = ['GB', 'US']; let _sdKey = null, _sdKeyP = null; function sdKey(fresh) { if (_sdKey && !fresh) return Promise.resolve(_sdKey); if (!_sdKeyP) _sdKeyP = sdKeyFetch(fresh).finally(() => { _sdKeyP = null; }); return _sdKeyP; } async function sdKeyFetch(fresh) { if (!fresh) { let k = null; try { k = localStorage.getItem(SD_KEY_STORE); } catch (e) {} return (_sdKey = k || SD_KEY_DEFAULT); } appendLog('7digital', 'API: fetching the key from the store\'s JS'); const home = await gmGet('https://us.7digital.com/', { headers: { Accept: 'text/html' } }); const js = (home.responseText.match(/\/\/js-cdn\.7digital\.com\/static\/build\/javascript\/app\.[a-z0-9]+\.js/i) || [])[0]; if (!js) throw new Error(`the store's JS was not found (home page HTTP ${home.status})`); const r = await gmGet('https:' + js); const k = (r.responseText.match(/oauth_consumer_key="([A-Za-z0-9]+)"/) || [])[1]; if (!k) throw new Error('no key in the store\'s JS'); try { localStorage.setItem(SD_KEY_STORE, k); } catch (e) {} return (_sdKey = k); } // One API read: its JSON, or throws. A refused key (HTTP 401, error 4001) is replaced, once. async function sdApi(path, params) { for (let attempt = 0; attempt < 2; attempt++) { const key = await sdKey(attempt > 0); const qs = new URLSearchParams({ ...params, usageTypes: 'download', oauth_consumer_key: key }); const r = await gmGet(`${SD_API}${path}?${qs}`, { headers: { Accept: 'application/json' } }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { /* "OAuth authentication error: …" is plain text */ } const code = j && j.error ? String(j.error.code) : ''; if ((r.status === 401 || code === '4001') && attempt === 0) { appendLog('7digital', `API: the key was refused (HTTP ${r.status}${code ? ', error ' + code : ''}) — fetching the store's current one`, 'warn'); continue; } if (!r.ok || !j || j.status !== 'ok') throw new Error(`${path}: HTTP ${r.status || r.error || '?'}${j && j.error ? ' — ' + j.error.message : ''}`); return j; } throw new Error('the store\'s current key was refused too'); } async function sdReleaseSearch(q, country, pageSize = 50) { const j = await sdApi('release/search', { q, country, pageSize: String(pageSize) }); return ((j.searchResults || {}).searchResult || []).map(x => x.release).filter(Boolean); } // A 7digital release link → { id, country }. uk.7digital.com is GB's store; www (or none) leaves the country open. function sdRef(u) { const m = String(u || '').match(/^https?:\/\/(?:([a-z]{2})\.|www\.)?(7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\/(?:[^/?#]*-)?(\d{3,})\/?(?:[?#]|$)/i); if (!m) return null; const sub = (m[1] || '').toLowerCase(); return { id: m[3], country: /zdigital/i.test(m[2]) ? 'AU' : sub === 'uk' ? 'GB' : sub ? sub.toUpperCase() : null }; } const sdStore = country => (country === 'GB' ? 'uk' : String(country || 'us').toLowerCase()); function sdMeta(r, country) { const date = String((r.download || {}).releaseDate || '').slice(0, 10); return { id: String(r.id), country, url: `https://${sdStore(country)}.7digital.com/artist/${r.artist.slug}/release/${r.slug}`, title: r.title || '', artist: (r.artist || {}).name || '', tracks: r.trackCount || null, year: r.year ? String(r.year) : (date.slice(0, 4) || null), label: (r.label || {}).name || null, barcode: r.barcode || null, }; } // The release whose barcode is MB's, in the first country that has it async function sdByBarcode(barcode) { for (const country of SD_COUNTRIES) { const hit = await upcTry(barcode, async v => (await sdReleaseSearch(v, country, 10)).find(r => normBarcode(r.barcode) === normBarcode(barcode)) || null); if (hit) return sdMeta(hit, country); } return null; } // A linked release, found again by search (the store's key can't read it by id): its // barcode first, then its title, alone and after the artist, in the link's country first. async function sdById(ref, { artist, album, barcode }) { const countries = [...new Set([ref.country, ...SD_COUNTRIES].filter(Boolean))]; const queries = [...(barcode ? gtinVariants(barcode) : []), album, `${artist} ${album}`].filter(Boolean); for (const country of countries) { for (const q of queries) { const hit = (await sdReleaseSearch(q, country)).find(r => String(r.id) === ref.id); if (hit) { appendLog('7digital', `Release ${ref.id} found by searching "${q}" (${country})`); return sdMeta(hit, country); } } } return null; } // majkinetor: "PC reads PDF as track" (Origins (Deluxe): 15 songs and a digital booklet, trackCount 16). // A release's trackCount counts its booklet; its tracks in track/search are only the audio ones. When // 7digital counts more than MB, the release's audio tracks are counted by searching its title (one search // usually brings up the whole album), and that count stands when it is MB's: the rest is the booklet. async function sdAudioTracks(meta, mbTracks) { if (!meta || !meta.id || !(meta.tracks > mbTracks)) return meta; const found = new Set(); for (const q of [`${meta.artist} ${meta.title}`, meta.title]) { const j = await sdApi('track/search', { q, country: meta.country || 'GB', pageSize: '50' }).catch(() => null); for (const x of ((j || {}).searchResults || {}).searchResult || []) if (x.track && x.track.release && String(x.track.release.id) === meta.id && (x.track.type || 'audio') === 'audio') found.add(x.track.id); if (found.size >= mbTracks) break; } if (found.size === mbTracks) { appendLog('7digital', `Release ${meta.id}: ${meta.tracks} items, ${found.size} of them audio tracks (the rest a booklet or video)`); return { ...meta, tracks: found.size }; } appendLog('7digital', `Release ${meta.id}: ${meta.tracks} items; ${found.size} audio track(s) found by search, so the count stays`); return meta; } async function scanSevendigital({ artist, album, mbTracks, existingUrl, mbid, isVariousArtists, barcode }) { const label = '7digital'; const cached = cacheGet(mbid, 'sevendigital'); if (cached?.url && (!existingUrl || pcSameUrl(existingUrl, cached.url))) { applyCachedRow('sevendigital', label, cached, mbTracks); return; } if (cached && !cached.url && !existingUrl && !barcode) { appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn'); applyCachedRow('sevendigital', label, cached, mbTracks); return; } let meta = null, source = null; try { if (existingUrl) { source = 'MB rels'; appendLog(label, `Using existing MB URL: ${existingUrl}`, 'ok'); const ref = sdRef(existingUrl); if (ref) meta = await sdById(ref, { artist, album, barcode }); if (!meta) { appendLog(label, ref ? `Release ${ref.id} wasn't found by search — its details are unknown` : `The link names no 7digital release id — its details are unknown`, 'warn'); meta = { url: existingUrl, tracks: null, year: null, label: null, barcode: null }; } } else { if (barcode) { meta = await sdByBarcode(barcode); if (meta) { source = 'barcode'; appendLog(label, `Barcode ${barcode} → release ${meta.id} "${meta.title}" (${meta.url})`, 'ok'); } else appendLog(label, `Barcode ${barcode}: not found — falling back to search`); } if (!meta) { const q = isVariousArtists ? album : `${artist} ${album}`; let best = null; for (const country of SD_COUNTRIES) { const hits = await sdReleaseSearch(q, country, 10); appendLog(label, `API search (${country}): q="${q}" — ${hits.length} candidate(s)`); for (const r of hits) { const sc = scoreCandidate({ tracks: r.trackCount || null, title: r.title, artist: (r.artist || {}).name }, mbTracks, album, artist, isVariousArtists); appendLog(label, ` cand score=${sc} tracks=${r.trackCount ?? '?'} artist="${(r.artist || {}).name || '?'}" title="${r.title || ''}" barcode=${r.barcode || '-'} id=${r.id}`); if (!best || sc > best.score) best = { score: sc, r, country }; } if (best && best.score >= 150) break; } if (best && best.score >= 120) { meta = sdMeta(best.r, best.country); source = 'API search'; appendLog(label, `Picked best (score=${best.score}): release ${meta.id}`, best.score >= 150 ? 'ok' : 'warn'); } else appendLog(label, `No verifiable match (best score=${best?.score ?? 'n/a'}) — leaving URL unset`, 'warn'); } } } catch (e) { appendLog(label, `API failed: ${e && e.message}`, 'error'); if (!existingUrl) { updateRow('sevendigital', { url: null, mbTracks, remoteTracks: null }); return; } // not cached: a failure isn't a "not there" meta = { url: existingUrl, tracks: null, year: null, label: null, barcode: null }; } if (!meta) { cacheSet(mbid, 'sevendigital', { url: null, tracks: null, year: null, label: null, source: 'search' }); updateRow('sevendigital', { url: null, mbTracks, remoteTracks: null }); return; } meta = await sdAudioTracks(meta, mbTracks).catch(() => meta); appendLog(label, `Release: tracks=${meta.tracks ?? '?'} title="${meta.title || ''}" year=${meta.year || '?'} label=${meta.label || '?'} barcode=${meta.barcode || '-'}`, meta.tracks ? 'ok' : 'warn'); const entry = { url: meta.url, tracks: meta.tracks, year: meta.year, label: meta.label, format: null, source, barcode: meta.barcode }; cacheSet(mbid, 'sevendigital', entry); updateRow('sevendigital', { url: meta.url, mbTracks, remoteTracks: meta.tracks, year: meta.year, label: meta.label, format: null, source, barcode: meta.barcode }); } // ─── Main entry ──────────────────────────────────────────────────────────── const mbid = PC_SEED ? 'seed-' + (window.location.hash.match(/[#&]mc=([a-z0-9]+)/i) || [])[1] : window.location.pathname.split('/')[2]; if (!mbid || mbid.length < 10) { appendLog('System', `No valid MBID parsed from URL`, 'error'); return; } // Reset each platform row back to its initial ⚪ / -- state. Used by the // refresh button before re-running the scans. function resetRows() { for (const p of ALL_PROVIDERS) { const ico = document.getElementById(`ico-${p}`); const val = document.getElementById(`val-${p}`); const a = document.getElementById(`mb-online-${p}`); if (ico) { ico.textContent = '⚪'; ico.style.color = 'var(--mbu-text-dim)'; ico.style.fontWeight = 'normal'; ico.onclick = null; ico.style.cursor = ''; ico.classList.remove('pc-ico-circled'); } const plat = document.getElementById(`plat-${p}`); if (plat) { plat.onclick = null; plat.style.cursor = 'default'; pcWireForce(plat, null); } if (ico) pcWireForce(ico, null); const row = document.getElementById(`row-${p}`); if (row) { row.classList.remove('pc-inmb', 'pc-st-mismatch', 'pc-st-match', 'pc-rise'); row.classList.add('pc-st-notfound'); } // back to "not found" — refreshCompactStrip re-folds it (#355) if (val) { val.textContent = '—'; val.style.color = 'var(--mbu-error)'; } // neutral dash while re-scanning setMetaCells(`year-${p}`, `format-${p}`, `label-${p}`, null, null, null); // Reset the anchor href to its search-fallback so parseMbFromDom on // a subsequent refresh doesn't see the previous /album/<id> result // as an "existing MB rel" — covered by the #mb-pc-panel exclusion // in parseMbFromDom too, but defensive cleanup either way. if (a) { a.href = '#'; a.title = ''; } // The row stays clickable across a reset — dataset.searchUrl survives, so // a re-scanning row still opens the provider's search (it's the same // release). runScans re-seeds and re-wires a moment later anyway. wireRowOpen(p); } refreshCompactStrip(); // #355: clear the strip while re-scanning PC_FOUND_BC.clear(); // #674 pcShowFoundBarcodes(); PC_FOUND_CAT.clear(); // #740 pcShowFoundCatnos(); } // VA detection used by both the DOM and API parse paths. Hoisted so both // can share the regex without duplication. const VA_MBID = '89ad4ac3-39f7-470e-963a-56509c546377'; const VA_NAME_RE = /^various(\s+artists?)?$/i; // Scrape the MB release record from the *currently-rendered page DOM* — we're // already running on /release/<mbid>, so the data is in front of us. This // avoids the typical 10s /ws/2 round-trip when MB is under load. Returns the // same shape as parseMbData() or null when the DOM doesn't have what we need // (in which case the caller falls back to the API). Selectors are defensive: // MB occasionally shuffles its markup; the API fallback covers regressions. function parseMbFromDom() { try { // Album title. MB wraps the title in <bdi> inside the release header's // h1. Several layouts exist — use a chain of fallbacks. const titleNode = document.querySelector( '.releaseheader h1 bdi, .release-information h1 bdi, h1 bdi' ); const album = titleNode?.textContent?.trim() || (document.title.match(/^Release\s+["“]([^"”]+)["”]/) || [])[1] || ''; // Artist credit. Anchors to /artist/<mbid> appearing in the release // header (or its `.subheader` / `.artist-credit` span). VA detection // by MBID or by literal name. const headerScope = document.querySelector('.releaseheader, .release-information, #content') || document; const artistAnchors = headerScope.querySelectorAll( '.artist-credit a[href^="/artist/"], .subheader a[href^="/artist/"], h1 ~ p a[href^="/artist/"]' ); const artistNames = [...artistAnchors].map(a => a.textContent.trim()).filter(Boolean); const artistIds = [...artistAnchors].map(a => (a.getAttribute('href') || '').match(/\/artist\/([0-9a-f-]{36})/)?.[1]).filter(Boolean); const artist = artistNames[0] || ''; // Track count. Current MB markup renders each track as a bare <tr> // inside `table.tbl.medium tbody`, the first row being `<tr class="subh">` // (header). Tracks have a `<td class="pos">` cell — counting those // skips the header automatically and works for multi-disc releases // (one table per medium, all summed). The older `tr.track` selector // is kept as a fallback for any legacy renderer. const mbTracks = document.querySelectorAll('table.tbl.medium tbody tr > td.pos').length || document.querySelectorAll('tr.track').length; // Release-group MBID — usually present as a /release-group/<mbid> // anchor in the release-information sidebar block. Some skins or // partially-rendered pages have it only in an inline data attribute // or a meta tag; cast a wider net. const rgFromHref = [...document.querySelectorAll('a[href*="/release-group/"]')] .map(a => a.getAttribute('href').match(/release-group\/([0-9a-f-]{36})/)?.[1]) .find(Boolean); const rgFromText = document.body.innerHTML.match(/release-group\/([0-9a-f-]{36})/)?.[1]; const releaseGroupMbid = rgFromHref || rgFromText || null; // Existing URL rels. MB renders the release's URL relationships in // different places depending on layout state — sometimes under // "External links" in #sidebar, sometimes inline in the main #content // under a "Credits" / "External links" section (verified on the // "Mambo loco" release where Discogs / Spotify / Bandcamp all appear // in the main content's credits table). Search both. The platform // URL patterns below are specific enough that we won't false-positive // on unrelated outbound links. // // Some MB anchors use protocol-relative `href="//host/path"` so we // query every `a[href]` and filter on the *resolved* `.href` property // (always absolute) rather than the attribute selector. const scope = document.querySelector('#content, #wrap, body') || document; const sidebar = document.querySelector('#sidebar'); // Exclude our own panel — after the first scan finishes, its provider // <a> tags hold the URLs we *found* (e.g. open.spotify.com/album/…) // which look identical to real MB rel anchors. Without this filter a // subsequent ↻ refresh sees every row's discovered URL as "existing // in MB", flips source to 'MB rels', and the entire panel circles // even when MB has no rels at all. (Witnessed on the Threads release // with zero external links.) const pcPanel = document.getElementById('mb-pc-panel'); const allAnchors = [ ...(sidebar ? sidebar.querySelectorAll('a[href]') : []), ...scope.querySelectorAll('a[href]'), ].filter(a => !pcPanel || !pcPanel.contains(a)); const externalHrefs = allAnchors.map(a => a.href).filter(u => /^https?:\/\//.test(u)); const existing = { spotify: externalHrefs.find(u => /^https?:\/\/open\.spotify\.com\/(?:intl-[a-z-]+\/)?album\//i.test(u)) || null, discogs: externalHrefs.find(u => /^https?:\/\/www\.discogs\.com\/(?:[a-z-]+\/)?release\/\d+/i.test(u)) || null, bandcamp: externalHrefs.find(u => /^https?:\/\/[a-z0-9-]+\.bandcamp\.com\/album\//i.test(u)) || null, deezer: externalHrefs.find(u => /^https?:\/\/(?:www\.)?deezer\.com\/(?:[a-z]+\/)?album\/\d+/i.test(u)) || null, apple: externalHrefs.find(u => /^https?:\/\/music\.apple\.com\/(?:[a-z]{2}\/)?album\/(?:[^/]+\/)?\d+/i.test(u)) || null, tidal: externalHrefs.find(u => /^https?:\/\/(?:listen\.)?tidal\.com\/(?:browse\/)?album\/\d+/i.test(u)) || null, qobuz: externalHrefs.find(u => /^https?:\/\/(?:www\.|play\.|open\.)?qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\//i.test(u)) || null, beatport: externalHrefs.find(u => /^https?:\/\/(?:www\.)?beatport\.com\/release\/[^/]+\/\d+/i.test(u)) || null, volumo: externalHrefs.find(u => /^https?:\/\/(?:www\.)?volumo\.com\/album\//i.test(u)) || null, hdtracks: externalHrefs.find(u => /^https?:\/\/(?:www\.)?hdtracks\.com\//i.test(u)) || null, ytmusic: externalHrefs.find(u => /^https?:\/\/(?:(?:music|www|m)\.)?youtube\.com\/(?:playlist\?(?:[^#]*&)?list=OLAK5uy_|browse\/MPREb_)/i.test(u)) || null, amazonmusic: externalHrefs.find(u => /^https?:\/\/music\.amazon\.[a-z.]+\/albums\/[A-Z0-9]{10}/i.test(u)) || null, soundcloud: externalHrefs.find(u => /^https?:\/\/(?:www\.|m\.)?soundcloud\.com\/[^/?#]+\/(?:sets\/)?[^/?#]+/i.test(u)) || null, audiomack: externalHrefs.find(u => /^https?:\/\/(?:www\.)?audiomack\.com\/[^/?#]+\/(?:album|song)\/[^/?#]+/i.test(u)) || null, sevendigital: externalHrefs.find(u => /^https?:\/\/(?:[a-z]+\.)?(?:7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\//i.test(u)) || null, discogsMaster: externalHrefs.find(u => /^https?:\/\/www\.discogs\.com\/(?:[a-z-]+\/)?master\/\d+/i.test(u)) || null, }; const isVariousArtists = artistIds.includes(VA_MBID) || artistNames.some(n => VA_NAME_RE.test(n)); // Shared key matcher for "Date:" / "Label:" / "Format:" header // detection across the various skins MB renders. const matchKey = (el, ...wanted) => { const txt = (el.textContent || '').trim().replace(/:$/, '').toLowerCase(); return wanted.includes(txt); }; const FORMAT_RE = /\b(CD|Vinyl|Cassette|Digital\s*Media|File|SACD|DVD|Blu-?ray|Flexi-?disc|Minidisc|12"\s*Vinyl|7"\s*Vinyl|10"\s*Vinyl)\b/i; // Release format. MB renders it in several places: a sidebar // dl/dt/dd "Format:" row, a table th/td variant, or a medium-table // header above each tracklist. Try them all. // #722 First from MB's own tracklist data (a <script type="application/json">): // the medium's format by its database name, which is English in every // interface language, where the page's labels and names are translated. let format = null; for (const s of document.querySelectorAll('script[type="application/json"]')) { const fm = /"format":\x7b[^\x7b\x7d]*?"name":"([^"\\]*)"/.exec(s.textContent || ''); // \x7b \x7d: { }, unbalanced braces confuse brace-counting readers of this file if (!fm) continue; const m = fm[1].match(FORMAT_RE); if (m) format = m[1]; break; } for (const dt of format ? [] : document.querySelectorAll('dl.properties dt, dl dt')) { const dd = dt.nextElementSibling; if (!dd || dd.tagName !== 'DD') continue; if (matchKey(dt, 'format', 'formats')) { const m = dd.textContent.match(FORMAT_RE); if (m) { format = m[1]; break; } } } if (!format) { for (const th of document.querySelectorAll('table th')) { const td = th.nextElementSibling; if (!td) continue; if (matchKey(th, 'format', 'formats')) { const m = td.textContent.match(FORMAT_RE); if (m) { format = m[1]; break; } } } } if (!format) { for (const t of document.querySelectorAll('table.tbl.medium, table.medium-list')) { const head = (t.previousElementSibling?.textContent || t.querySelector('caption, thead')?.textContent || '').trim(); const m = head.match(FORMAT_RE); if (m) { format = m[1]; break; } } } // Pattern D: sidebar text scan for `Format: <FMT>`. Catches the // <strong>Format:</strong> CD layout MB renders inside the // "Release information" block on many releases. if (!format) { const sb = document.querySelector('#sidebar'); if (sb) { const m = (sb.textContent || '').match(/Format:?\s+(CD|Vinyl|Cassette|Digital\s*Media|File|SACD|DVD|Blu-?ray|Flexi-?disc|Minidisc)\b/i); if (m) format = m[1]; } } // Year + label for the header subtitle. Same multi-pattern approach. let year = null, releaseLabel = null; // #722 First by structure, in any interface language: the release events' // own data (the JSON beside h2.release-events) and the labels list. try { const evJson = document.querySelector('#sidebar h2.release-events + * script[type="application/json"], #sidebar h2.release-events + script[type="application/json"]'); const years = evJson ? (JSON.parse(evJson.textContent).events || []).map(e => e && e.date && e.date.year).filter(Boolean) : []; if (years.length) year = String(Math.min(...years)); } catch (e) { /* unexpected data: the patterns below */ } { const aLabels = [...document.querySelectorAll('#sidebar h2.labels + ul.links a[href*="/label/"]')].map(a => a.textContent.trim()).filter(Boolean); if (aLabels.length) releaseLabel = [...new Set(aLabels)].join(', '); } // Pattern A: dl/dt/dd. for (const dt of document.querySelectorAll('dl.properties dt, dl dt')) { const dd = dt.nextElementSibling; if (!dd || dd.tagName !== 'DD') continue; if (!year && matchKey(dt, 'date', 'release date')) { const m = dd.textContent.match(/\b(19\d{2}|20\d{2})\b/); if (m) year = m[1]; } if (!releaseLabel && matchKey(dt, 'label', 'labels')) { const aLabels = [...dd.querySelectorAll('a[href*="/label/"]')].map(a => a.textContent.trim()).filter(Boolean); if (aLabels.length) releaseLabel = [...new Set(aLabels)].join(', '); } } // Pattern B: table th/td (some MB skins use this for release info). if (!year || !releaseLabel) { for (const th of document.querySelectorAll('table th')) { const td = th.nextElementSibling; if (!td) continue; if (!year && matchKey(th, 'date', 'release date')) { const m = td.textContent.match(/\b(19\d{2}|20\d{2})\b/); if (m) year = m[1]; } if (!releaseLabel && matchKey(th, 'label', 'labels')) { const aLabels = [...td.querySelectorAll('a[href*="/label/"]')].map(a => a.textContent.trim()).filter(Boolean); if (aLabels.length) releaseLabel = [...new Set(aLabels)].join(', '); } } } // Fallback: any /label/ anchor in #sidebar; year from the rendered // sidebar text (with `<script type="application/json">` embeds and // `<style>` content stripped — otherwise the first match is often // a country entity's "last_updated" timestamp inside MB's React // bootstrap blob, not the release year). const sb = document.querySelector('#sidebar'); if (sb) { if (!releaseLabel) { const aLabels = [...sb.querySelectorAll('a[href*="/label/"]')].map(a => a.textContent.trim()).filter(Boolean); if (aLabels.length) releaseLabel = [...new Set(aLabels)].join(', '); } // Helper: read element textContent with <script>/<style>/<noscript> // stripped. The Release events section embeds a React bootstrap // blob whose first year is the country entity's `last_updated` // timestamp (e.g. "2013-08-28" for the [Worldwide] entity), not // the release date — only the rendered "[Worldwide]2017-06-15" // sibling carries the actual year we want. const cleanText = el => { const c = el.cloneNode(true); c.querySelectorAll('script, style, noscript').forEach(n => n.remove()); return c.textContent || ''; }; if (!year) { // Prefer the "Release events" section directly when available — // it's the canonical home of the release date. // // Two subtleties learned the hard way: // // (1) Skip <script> siblings ENTIRELY. The cleanText helper // strips descendant scripts from a cloned subtree, but // if the sibling element itself is a <script>, its own // content is preserved. MB embeds a JSON bootstrap blob // as the first Release-events sibling whose JSON order // varies: sometimes `{"date":{"year":2021},…}` is first // (we'd luckily land on the right year), sometimes the // country entity is first with `"last_updated":"2013-…"` // (we'd extract Germany's last-updated year, not the // release year). Safer to ignore the script outright. // // (2) Drop the leading \b from the date regex. The rendered // date sits inside text like "Germany2021-12-03" — no // word boundary between the country name and the year. // A trailing \b still rejects the `last_updated` // "2013-05-27T…" case (T is word-class, no boundary). const reHeader = sb.querySelector('h2.release-events') || [...sb.querySelectorAll('h2, h3')].find(h => /release events?/i.test(h.textContent)); if (reHeader) { let dateYear = null, anyYear = null; for (let n = reHeader.nextElementSibling; n && !/^h[1-6]$/i.test(n.tagName); n = n.nextElementSibling) { if (/^(script|style|noscript)$/i.test(n.tagName)) continue; const t = cleanText(n); if (!dateYear) { const d = t.match(/(19\d{2}|20\d{2})-(?:0\d|1[0-2])-(?:0\d|[12]\d|3[01])\b/); if (d) dateYear = d[1]; } if (!anyYear) { const m = t.match(/(19\d{2}|20\d{2})\b/); if (m) anyYear = m[1]; } if (dateYear) break; } year = dateYear || anyYear || null; } } if (!year) { const clone = sb.cloneNode(true); clone.querySelectorAll('script, style, noscript').forEach(n => n.remove()); const m = (clone.textContent || '').match(/\b(19\d{2}|20\d{2})\b/); if (m) year = m[1]; } } // Barcode (UPC/EAN) — MB renders it as a "Barcode:" dt/dd, th/td, or in // the sidebar text. Strip to digits. Used to resolve Volumo (and, as an // exact match, Deezer/Apple) by UPC. Scraped from the page here; the WS2 // `data.barcode` is only a fallback (parseMbData). let barcode = null; { const dd = document.querySelector('#sidebar dd.barcode'); const m = dd && (dd.textContent || '').replace(/\s+/g, '').match(/\d{8,14}/); if (m) barcode = m[0]; } // #722 by its class for (const dt of barcode ? [] : document.querySelectorAll('dl.properties dt, dl dt, table th')) { const dd = dt.nextElementSibling; if (!dd) continue; if (matchKey(dt, 'barcode')) { const m = (dd.textContent || '').replace(/\s+/g, '').match(/\d{8,14}/); if (m) { barcode = m[0]; break; } } } if (!barcode && sidebar) { const m = (sidebar.textContent || '').match(/Barcode:?\s*([\d\s]{8,})/i); if (m) barcode = m[1].replace(/\D/g, '') || null; } // Sanity gate: we need artist + album + at least one track row. Anything // less is an unrendered or unfamiliar layout — bail to API. if (!artist || !album || mbTracks < 1) return null; return { artist, album, mbTracks, releaseGroupMbid, isVariousArtists, existing, format, year, releaseLabel, barcode }; } catch (e) { appendLog('System', `parseMbFromDom threw: ${e.message}`, 'error'); return null; } } // Parse a successful MB release-API payload into the lean record we cache and // pass to the scanners. Returns null when the payload is missing the artist // or album (treated as a fatal-for-this-MBID parse error upstream). function parseMbData(data) { const artist = data['artist-credit']?.[0]?.name || data['artist-credit']?.[0]?.artist?.name || ''; const album = data.title || ''; if (!artist || !album) return null; const mbTracks = data.media?.reduce((s, m) => s + (m['track-count'] || 0), 0) || 0; const releaseGroupMbid = data['release-group']?.id || null; // MB's "Various Artists" entity. Compilations on Bandcamp / Spotify // typically aren't credited to literally "Various Artists" — they go under // the label (e.g. "Analog Africa", "Soul Jazz Records"). Detect via the // special MBID OR a literal "Various"/"Various Artists" string match so // we can drop the artist term from the search query; the verifier picks // the right candidate from the wider net. const isVA = c => c.artist?.id === VA_MBID || VA_NAME_RE.test(c.artist?.name || '') || VA_NAME_RE.test(c.name || ''); const isVariousArtists = !!data['artist-credit']?.some(isVA); const relUrls = (data.relations || []) .filter(r => r['target-type'] === 'url' && r.url?.resource) .map(r => r.url.resource); const existing = { spotify: relUrls.find(u => /^https?:\/\/open\.spotify\.com\/(?:intl-[a-z-]+\/)?album\//i.test(u)) || null, discogs: relUrls.find(u => /^https?:\/\/www\.discogs\.com\/(?:[a-z-]+\/)?release\/\d+/i.test(u)) || null, bandcamp: relUrls.find(u => /^https?:\/\/[a-z0-9-]+\.bandcamp\.com\/album\//i.test(u)) || null, deezer: relUrls.find(u => /^https?:\/\/(?:www\.)?deezer\.com\/(?:[a-z]+\/)?album\/\d+/i.test(u)) || null, apple: relUrls.find(u => /^https?:\/\/music\.apple\.com\/(?:[a-z]{2}\/)?album\/(?:[^/]+\/)?\d+/i.test(u)) || null, tidal: relUrls.find(u => /^https?:\/\/(?:listen\.)?tidal\.com\/(?:browse\/)?album\/\d+/i.test(u)) || null, qobuz: relUrls.find(u => /^https?:\/\/(?:www\.|play\.|open\.)?qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\//i.test(u)) || null, beatport: relUrls.find(u => /^https?:\/\/(?:www\.)?beatport\.com\/release\/[^/]+\/\d+/i.test(u)) || null, volumo: relUrls.find(u => /^https?:\/\/(?:www\.)?volumo\.com\/album\//i.test(u)) || null, hdtracks: relUrls.find(u => /^https?:\/\/(?:www\.)?hdtracks\.com\//i.test(u)) || null, ytmusic: relUrls.find(u => /^https?:\/\/(?:(?:music|www|m)\.)?youtube\.com\/(?:playlist\?(?:[^#]*&)?list=OLAK5uy_|browse\/MPREb_)/i.test(u)) || null, amazonmusic: relUrls.find(u => /^https?:\/\/music\.amazon\.[a-z.]+\/albums\/[A-Z0-9]{10}/i.test(u)) || null, soundcloud: relUrls.find(u => /^https?:\/\/(?:www\.|m\.)?soundcloud\.com\/[^/?#]+\/(?:sets\/)?[^/?#]+/i.test(u)) || null, audiomack: relUrls.find(u => /^https?:\/\/(?:www\.)?audiomack\.com\/[^/?#]+\/(?:album|song)\/[^/?#]+/i.test(u)) || null, sevendigital: relUrls.find(u => /^https?:\/\/(?:[a-z]+\.)?(?:7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\//i.test(u)) || null, discogsMaster: relUrls.find(u => /^https?:\/\/www\.discogs\.com\/(?:[a-z-]+\/)?master\/\d+/i.test(u)) || null, }; const format = data.media?.[0]?.format || null; const year = data.date ? String(data.date).slice(0, 4) : null; const barcode = String(data.barcode || '').replace(/\D/g, '') || null; // WS2 fallback (DOM scrape preferred) const releaseLabel = (data['label-info'] || []) .map(li => li.label?.name) .filter(Boolean) .filter((v, i, a) => a.indexOf(v) === i) .join(', ') || null; return { artist, album, mbTracks, releaseGroupMbid, isVariousArtists, existing, format, year, releaseLabel, barcode }; } // (#422, per maintainer review) the ↻ button ITSELF is the progress indicator — it spins // while scans run (no separate icon, no ticking seconds, no end-state noise), and a click // then stops them (#674); the total scan time goes into the button's tooltip when done. let _scanT0 = 0; const REFRESH_TITLE = 'Refresh — clear cache and re-scan'; function setScanStatus(state) { const btn = document.getElementById('mb-refresh-btn'); if (!btn) return; if (state === 'busy') { _scanT0 = Date.now(); btn.classList.add('pc-scanning'); btn.title = 'Scanning platforms… · click to stop'; return; } btn.classList.remove('pc-scanning'); const secs = ((Date.now() - _scanT0) / 1000).toFixed(1); btn.title = state === 'done' ? `${REFRESH_TITLE} (last scan: ${secs}s)` : state === 'stopped' ? `${REFRESH_TITLE} (last scan stopped after ${secs}s)` : `${REFRESH_TITLE} (last scan halted — see the log)`; } async function runScans() { // (#422) thin status wrapper — the scan body lives in runScansInner; `false` = halted. // A scan cancelled by pcCancelScan (#674) never gets here past its await: the next one owns the status. const n = PC_SCAN.n; PC_SCAN.busy = true; setScanStatus('busy'); let ok = false; try { ok = await runScansInner() !== false; } catch (e) { appendLog('System', `Scan failed: ${e && e.message}`, 'error'); } if (pcStale(n)) return; PC_SCAN.busy = false; setScanStatus(ok ? 'done' : 'halt'); } async function runScansInner() { // Source precedence: DOM (instant, no network) > /ws/2 API (~10s when MB is // hot) > mbDataCache (transient MB outage). DOM is identical data to API // for our purposes — both give artist/album/tracks/rg/url-rels — and we're // already running on the page so it's free. if (!PC_SEED) appendLog('MusicBrainz', `Release ${MB_ORIGIN}/release/${mbid}`); let mbData = PC_SEED ? PC_SEED_DATA : parseMbFromDom(); let dataSource = PC_SEED ? 'seed' : 'dom'; if (PC_SEED) { if (!mbData) { appendLog('System', 'Mission Control: no release to scan for yet (waiting for its probe)'); return false; } appendLog('MusicBrainz', `Mission Control seed — "${mbData.album}" by ${mbData.artist}, ${mbData.mbTracks} track(s), barcode ${mbData.barcode || '—'}`, 'ok'); } else if (mbData) { appendLog('MusicBrainz', `Parsed from page DOM — skipping API call`, 'ok'); } else { appendLog('MusicBrainz', `DOM scrape incomplete — falling back to /ws/2 API`); const mb = await gmGet(`${MB_ORIGIN}/ws/2/release/${mbid}?inc=artists+media+url-rels+release-groups+labels&fmt=json`); appendLog('MusicBrainz', `status=${mb.status} ${mb.responseText.length}b in ${mb.ms}ms`); if (mb.ok) { try { const data = JSON.parse(mb.responseText); mbData = parseMbData(data); if (mbData) dataSource = 'api'; else appendLog('MusicBrainz', `Missing artist/album in API payload`, 'error'); } catch (e) { appendLog('MusicBrainz', `JSON parse failed: ${e.message}`, 'error'); } } if (!mbData) { const cached = mbDataGet(mbid); if (cached) { appendLog('MusicBrainz', `Falling back to cached release data (no fresh fetch this load)`, 'warn'); mbData = cached; dataSource = 'cache'; } else { appendLog('MusicBrainz', `Halted: no DOM, no API, no cache (status ${mb.status})`, 'error'); return false; // (#422) wrapper shows the halt state } } } // Persist DOM- and API-sourced records to the long-term cache so a later // MB 503 can still render. Don't re-persist when we're already inside the // cache-fallback branch. // #567 (majkinetor): "The Discogs master checkmark is always circled only on // Overview page and it mostly isn't on other tabs." His logs are decisive: // // overview Existing RG master: https://www.discogs.com/master/284062 // cover art Existing RG master: none // // The release-group's Discogs master is read out of the page DOM, and only the // Overview tab renders it early enough (it appears twice there — once in the // body, once in the sidebar; every other tab has the sidebar copy alone). So on // the other tabs the parse can run before it exists, records null, and CACHES // that null — which is why pressing the refresh button does not help either. // // The release group is the authority on its own links, so ask it rather than // hoping the page has rendered. One cheap request, only when the DOM came up // empty, and never on the Overview path where the DOM already answered. if (!mbData.existing?.discogsMaster && mbData.releaseGroupMbid) { try { const rgRes = await gmGet(`${MB_ORIGIN}/ws/2/release-group/${mbData.releaseGroupMbid}?inc=url-rels&fmt=json`); const rgJson = rgRes && rgRes.status === 200 ? JSON.parse(rgRes.responseText) : null; const found = (rgJson?.relations || []) .map(r => r?.url?.resource) .find(u => u && /^https?:\/\/www\.discogs\.com\/(?:[a-z-]+\/)?master\/\d+/i.test(u)); if (found) { mbData.existing = { ...(mbData.existing || {}), discogsMaster: found }; appendLog('MusicBrainz', `RG Discogs master resolved from the API (the page DOM had none): ${found}`); } } catch (e) { appendLog('MusicBrainz', `RG master lookup failed — ${(e && e.message) || e}`, 'warn'); } } if (dataSource !== 'cache') mbDataSet(mbid, mbData); // 2nd-line under the MusicBrainz source-info log: clickable link straight // to the release page. Convenient when the user opens the diagnostic log // from a different tab and wants to jump back to MB without re-typing. appendLog('MusicBrainz', `Release: ${MB_ORIGIN}/release/${mbid}`); const { artist, album, mbTracks, releaseGroupMbid, isVariousArtists, existing, format, year, releaseLabel, barcode } = mbData; /* majkinetor: "still can't click first 2 rows in PC but only on initial load. If I click refresh, its there." This block used to sit ~70 lines further down, after the release-group lookup, the cache-upgrade pass and the Wikidata SPARQL — all network. The rows are wired here, so on a fresh load they were dead for as long as those took, which is precisely the window in which you look at a panel that just appeared. ↻ felt instant only because the rows were still wired from the previous run. It only ever needed the artist and the album, and both are on the line above, parsed from the page DOM with no request at all. */ // Seed search fallback URLs. Each provider link starts pointed at the // provider's native search results — overridden to the resolved album // URL once a scan finds a confident match. The original search URL is // stashed on the anchor's dataset so right-click can re-open it even // after a positive match (convenient for cross-checking). const searchUrls = { spotify: `https://open.spotify.com/search/${encodeURIComponent(`${artist} ${album}`)}`, discogs: `https://www.discogs.com/search/?q=${encodeURIComponent(`${artist} ${album}`)}&type=release`, bandcamp: `https://bandcamp.com/search?q=${encodeURIComponent(`${artist} ${album}`)}&item_type=a`, deezer: `https://www.deezer.com/search/${encodeURIComponent(`${artist} ${album}`)}`, apple: `https://music.apple.com/us/search?term=${encodeURIComponent(`${artist} ${album}`)}`, tidal: `https://tidal.com/search?q=${encodeURIComponent(`${artist} ${album}`)}`, qobuz: `https://www.qobuz.com/us-en/search/albums/${encodeURIComponent(`${artist} ${album}`)}`, beatport: `https://www.beatport.com/search?q=${encodeURIComponent(`${artist} ${album}`)}`, volumo: `https://volumo.com/releases?search=${encodeURIComponent(`${artist} ${album}`)}`, hdtracks: `https://www.hdtracks.com/#/search?q=${encodeURIComponent(`${artist} ${album}`)}`, ytmusic: `https://music.youtube.com/search?q=${encodeURIComponent(`${artist} ${album}`)}`, amazonmusic: `https://music.amazon.com/search/${encodeURIComponent(`${artist} ${album}`)}`, soundcloud: `https://soundcloud.com/search/sets?q=${encodeURIComponent(`${artist} ${album}`)}`, audiomack: `https://audiomack.com/search?q=${encodeURIComponent(`${artist} ${album}`)}&tab=albums`, sevendigital: `https://us.7digital.com/search?q=${encodeURIComponent(`${artist} ${album}`)}`, }; for (const [p, u] of Object.entries(searchUrls)) { const a = document.getElementById(`mb-online-${p}`); if (!a) continue; a.href = u; a.dataset.searchUrl = u; // Right-click → open native search. Preserves the browser's own // copy-link affordance on middle-click / shift-click; only the // bare-right-click is intercepted. if (!a.dataset.pcContextMenuWired) { a.addEventListener('contextmenu', e => { e.preventDefault(); const search = a.dataset.searchUrl; if (search) window.open(search, '_blank', 'noopener'); }); a.dataset.pcContextMenuWired = '1'; } // Make the whole row clickable NOW, not when the scan reports — a pending // Discogs/Bandcamp row (they never fold into the compact strip) was dead // to clicks for the whole scan otherwise. wireRowOpen(p); } // Header subtitle: year · label · format (left-aligned), and the MB // track count right-aligned so it sits in the same column as the // platform vals below. setMetaCells('mb-mb-year', 'mb-mb-format', 'mb-mb-label', year, format, releaseLabel); const trkEl = document.getElementById('mb-mb-tracks'); if (trkEl) trkEl.textContent = `${mbTracks}`; const nameEl = document.getElementById('mb-mb-name'); if (nameEl) nameEl.href = `${MB_ORIGIN}/release/${mbid}`; appendLog('MusicBrainz', `Artist: "${artist}"${isVariousArtists ? ' (Various Artists — search by album only)' : ''} Album: "${album}" Tracks: ${mbTracks} rg=${releaseGroupMbid || '(none)'}`); appendLog('MusicBrainz', `Header meta — year=${year || '?'} format=${format || '?'} label=${releaseLabel || '?'} source=${dataSource}`); // #527 follow-up (majkinetor, live): this line used to hardcode only 5 of // the 11 providers (spotify/discogs/bandcamp/deezer/apple) — soundcloud // (among others) was silently left out of the summary even though // `existing.soundcloud` was populated and used later in the very same // run, which read as "MB has no existing rel" when it actually did. // Drive it off ALL_PROVIDERS so nothing gets silently omitted again. appendLog('MusicBrainz', `Existing rels — ${ALL_PROVIDERS.map(p => `${p}=${existing[p] ? 'YES' : 'no'}`).join(' ')}`); // Cache upgrade: if MB has acquired a URL rel matching a cached URL (the // user just added the URL via + and came back), promote the cached row's // source from search/Wikidata to "MB rels" so the circled icon shows // immediately on the cache short-circuit — without it the user has to // hit ↻ to see the circle, even though MB now considers it an // editor-added rel. // // …and the downgrade counterpart: if a cache entry claims source='MB rels' // but MB no longer has that URL relationship (or never did — possible // since the panel-self-contamination bug used to write 'MB rels' for // every platform whenever the user hit ↻), demote to 'search' so the // row uncircles and click-to-add re-enables. Without this, users who // hit a buggy build keep seeing every row as circled+unclickable until // they manually ↻ refresh. for (const p of ALL_PROVIDERS) { const cached = cacheGet(mbid, p); if (!cached?.url) continue; // #556: pcSameUrl, not === . MB rewrites URLs on insert, so an exact // comparison missed links that ARE on the release — the row stayed an // un-circled ✓ and the + button kept re-queueing a duplicate. if (pcSameUrl(existing[p], cached.url) && cached.source !== 'MB rels') { cacheSet(mbid, p, { ...cached, source: 'MB rels' }); appendLog('MusicBrainz', `Cache upgrade: ${p} URL now in MB rels — source bumped to "MB rels"${existing[p] === cached.url ? '' : ` (MB spells it ${existing[p]})`}`, 'ok'); } else if (!pcSameUrl(existing[p], cached.url) && cached.source === 'MB rels') { cacheSet(mbid, p, { ...cached, source: 'search' }); appendLog('MusicBrainz', `Cache downgrade: ${p} URL no longer in MB rels (stale 'MB rels' tag) — source bumped to "search"`, 'warn'); } } // Wikidata lookup — fires whenever we don't already have a positive Spotify // URL. A cached "no match" (url:null) doesn't block Wikidata: it's a cheap // independent data source curated by humans, and the cache may have been // written before Wikidata had this album indexed (or before the user // hit ↻ themselves). // Wikidata also curates Tidal (P4577) and Beatport (P11312) IDs, so run it // unless EVERY Wikidata-backed provider is already resolved. Beatport in // particular has no other reliable resolver (it's Cloudflare-walled), so // Wikidata is its primary path. const spotifyCache = cacheGet(mbid, 'spotify'); const spotifyKnown = !!(existing.spotify || spotifyCache?.url); const tidalKnown = !!(existing.tidal || cacheGet(mbid, 'tidal')?.url); const beatportKnown = !!(existing.beatport || cacheGet(mbid, 'beatport')?.url); const tidalWanted = providerEnabled('tidal') && !tidalKnown; const beatportWanted = providerEnabled('beatport') && !beatportKnown; // Non-blocking: the lookup runs alongside the scans instead of in front of // them. Only a Wikidata-backed provider that could still USE the answer // waits for it (no MB link yet — and for Spotify, no cached URL, which it // prefers over Wikidata); every other provider starts straight away. let wdP = Promise.resolve(null); if (spotifyKnown && !tidalWanted && !beatportWanted) { appendLog('Wikidata', `skipped — Spotify/Tidal/Beatport already resolved`); } else { wdP = lookupWikidata(releaseGroupMbid, PC_SEED ? null : mbid).catch(e => { appendLog('Wikidata', `lookup error: ${e.message}`, 'warn'); return null; }); } const wdFor = (p, field) => { if (existing[p] || (p === 'spotify' && spotifyCache?.url)) return Promise.resolve(null); return wdP.then(wd => wd?.[field] || null); }; // #673: a barcode pasted on a release that has none is searched and checked as if it were the release's MB_OWN_BARCODE = barcode || null; let pasted = pcPastedBarcode(); if (pasted && barcode) { appendLog('MusicBrainz', `The release has its own barcode now (${barcode}) — the pasted ${pasted} is dropped`, 'warn'); pcSetPastedBarcode(null); pasted = null; } if (pasted) appendLog('MusicBrainz', `Using the pasted barcode ${pasted} — links you add will add it to the release`, 'ok'); pcShowPastedBarcode(pasted); const scanBarcode = barcode || pasted || null; MB_BARCODE = scanBarcode; // (#182) for the barcode-mismatch indicator MB_FORMAT = format || null; // (#182) for the format-confidence check const ctx = { artist, album, mbTracks, mbid, isVariousArtists, format, barcode: scanBarcode, mbYear: year || null, existingDiscogsMaster: existing.discogsMaster || null }; const tasks = []; if (providerEnabled('spotify')) tasks.push(wdFor('spotify', 'spotifyId').then(id => scanSpotify({ ...ctx, existingUrl: existing.spotify, wikidataSpotifyId: id }))); if (providerEnabled('discogs')) tasks.push(scanDiscogs ({ ...ctx, existingUrl: existing.discogs })); if (providerEnabled('bandcamp')) tasks.push(scanBandcamp({ ...ctx, existingUrl: existing.bandcamp })); if (providerEnabled('deezer')) tasks.push(scanDeezer ({ ...ctx, existingUrl: existing.deezer })); if (providerEnabled('apple')) tasks.push(wdFor('apple', 'appleId').then(id => scanApple({ ...ctx, existingUrl: existing.apple, wikidataAppleId: id }))); if (providerEnabled('tidal')) tasks.push(wdFor('tidal', 'tidalId').then(id => scanTidal({ ...ctx, existingUrl: existing.tidal, wikidataTidalId: id }))); if (providerEnabled('qobuz')) tasks.push(scanQobuz ({ ...ctx, existingUrl: existing.qobuz })); if (providerEnabled('beatport')) tasks.push(wdFor('beatport', 'beatportId').then(id => scanBeatport({ ...ctx, existingUrl: existing.beatport, wikidataBeatportId: id }))); if (providerEnabled('volumo')) tasks.push(scanVolumo ({ ...ctx, existingUrl: existing.volumo })); if (providerEnabled('hdtracks')) tasks.push(scanHDtracks({ ...ctx, existingUrl: existing.hdtracks })); if (providerEnabled('ytmusic')) tasks.push(scanYtmusic({ ...ctx, existingUrl: existing.ytmusic })); if (providerEnabled('amazonmusic')) tasks.push(scanAmazon({ ...ctx, existingUrl: existing.amazonmusic })); if (providerEnabled('soundcloud')) tasks.push(scanSoundcloud({ ...ctx, existingUrl: existing.soundcloud })); if (providerEnabled('audiomack')) tasks.push(scanAudiomack({ ...ctx, existingUrl: existing.audiomack })); if (providerEnabled('sevendigital')) tasks.push(scanSevendigital({ ...ctx, existingUrl: existing.sevendigital })); await Promise.allSettled(tasks); appendLog('System', 'All scans completed', 'ok'); pcCountLinks(); // #671 } // ─── Artists & labels table (#671) ────────────────────────────────────────── // The platform pages the scanners kept (credits, above), paired with this release's // MusicBrainz artists and labels: the release's artist credit, each track's (a // compilation's), and its labels. One row per MusicBrainz artist or label, one column // per platform; Falcon submits the new links as one batch. // A name as both sides spell it: case, accents and "&"/"and" don't count function pcNameKey(s) { return String(s || '').toLowerCase().normalize('NFKD').replace(/\p{M}+/gu, '') .replace(/\s*[&+]\s*/g, ' and ').replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); } // Pairs one platform credit list with one MusicBrainz list: by name first; a platform // artist whose name matches none is paired by position only when both lists are the // same length (and never an account that may be the label instead, nor a page whose name // lists several artists: Deezer and Qobuz keep pages like "Samrai & SCAPA & Vindya" for a // track's whole credit, which MusicBrainz credits to Samrai alone). The rest is left out, // with why. const PC_MULTI_NAME_RE = /\s(?:&|\+|x|×|and|feat\.?|ft\.?|featuring|with|vs\.?)\s|,\s/i; // A label's name without its company form (#756): "Arts & Crafts Productions Inc." is // MusicBrainz's "Arts & Crafts", "Sub Pop Records" its "Sub Pop" const PC_LABEL_SUFFIX_RE = /\s+(?:inc|incorporated|ltd|limited|llc|llp|gmbh|ag|co|corp|corporation|company|plc|pty|bv|nv|sa|sas|srl|sl|ab|as|oy|kg|ltda|records|recordings|record|productions|production|music|musique|musik|entertainment|group|label|labels|media|publishing)$/; function pcLabelKey(s) { let k = pcNameKey(s).replace(/^the\s+/, ''); while (PC_LABEL_SUFFIX_RE.test(k)) k = k.replace(PC_LABEL_SUFFIX_RE, ''); return k || pcNameKey(s); } function pcPairCredits(mb, plat, type) { const key = type === 'label' ? pcLabelKey : pcNameKey; const keys = e => [e.name, e.alt].map(key).filter(Boolean); const pairs = [], left = [], named = new Set(), rest = []; plat.forEach((p, i) => { const pk = keys(p); const j = pk.length ? mb.findIndex(m => keys(m).some(k => pk.includes(k))) : -1; if (j >= 0) { pairs.push({ mb: mb[j], plat: p, by: 'name' }); named.add(j); } else rest.push(i); }); for (const i of rest) { const p = plat[i]; const multi = type !== 'label' && PC_MULTI_NAME_RE.test(p.name || ''); // a label named "A & B" is one label if (mb.length === plat.length && !p.uncertain && !multi && !named.has(i)) pairs.push({ mb: mb[i], plat: p, by: 'position' }); else left.push({ plat: p, why: p.uncertain ? 'no MusicBrainz name matches it (an account that may be the artist or the label is paired by name only)' : multi ? 'no MusicBrainz name matches it, and its name lists several artists (a page for the whole credit)' : `no MusicBrainz name matches it, and the credits differ in length (${plat.length} on the platform, ${mb.length} in MusicBrainz)` }); } return { pairs, left }; } // The table's rows. mb: { artists, labels, tracks (each track's credit, or null for the // release's), va }; byProvider: { <provider>: credits }. Each row is // { type: 'artist'|'label', mbid, name, cells: { <provider>: [{ url, name, uncertain, by }] } }. function pcLinkRows(mb, byProvider) { const rows = new Map(), notes = []; const notVa = l => (l || []).filter(c => !VA_NAME_RE.test(c.name || '')); const mbArtists = l => (l || []).filter(a => a.mbid !== VA_MBID); const add = (type, p, { mb: m, plat, by }) => { const key = `${type}:${m.mbid}`; let r = rows.get(key); if (!r) rows.set(key, r = { type, mbid: m.mbid, name: m.name, cells: {} }); const cell = r.cells[p] || (r.cells[p] = []); if (!cell.some(x => pcSameUrl(x.url, plat.url))) cell.push({ url: plat.url, name: plat.name, uncertain: !!plat.uncertain, by }); }; for (const [p, cr] of Object.entries(byProvider)) { if (!cr) continue; const said = new Set(); const pair = (type, mbList, platList, where) => { const { pairs, left } = pcPairCredits(mbList, platList, type); pairs.forEach(x => add(type, p, x)); for (const l of left) { const line = `${p}: ${type} "${l.plat.name || l.plat.url}" (${where}) left out: ${l.why}`; if (!said.has(line)) { said.add(line); notes.push(line); } } }; const releaseArtists = mbArtists(mb.artists); if (!mb.va && releaseArtists.length && cr.artists.length) pair('artist', releaseArtists, notVa(cr.artists), 'release artist'); if (cr.tracks && cr.tracks.some(t => t.length)) { if (!mb.tracks) notes.push(`${p}: track artists not paired — the page doesn't show every track's artist credit (a medium not loaded?)`); else if (cr.tracks.length !== mb.tracks.length) notes.push(`${p}: track artists not paired — ${cr.tracks.length} track(s) on the platform, ${mb.tracks.length} in MusicBrainz`); else cr.tracks.forEach((t, i) => { const mbT = mbArtists(mb.tracks[i] || (mb.va ? [] : mb.artists)); const pt = notVa(t); if (mbT.length && pt.length) pair('artist', mbT, pt, `track ${i + 1}`); }); } if (cr.labels.length && (mb.labels || []).length) pair('label', mb.labels, cr.labels, 'label'); } // an account that may be either is only offered where its name matched; a label paired by // position has another name, so is likely another label (#756: MusicBrainz's "Universal Music", // the platform's "Arts & Crafts Productions Inc."): offered, but added only once taken in for (const r of rows.values()) for (const p of Object.keys(r.cells)) { r.cells[p] = r.cells[p].filter(c => !c.uncertain || c.by === 'name'); if (r.type === 'label') r.cells[p].forEach(c => { if (c.by === 'position') c.differs = true; }); if (!r.cells[p].length) delete r.cells[p]; } const out = [...rows.values()].filter(r => Object.keys(r.cells).length); out.sort((a, b) => (a.type === b.type ? 0 : a.type === 'artist' ? -1 : 1)); return { rows: out, notes }; } // This release's MusicBrainz artists and labels, from the page itself (no request). // A track whose row names no artist has the release's credit; when the page shows fewer // tracks than the release has (a medium not loaded), tracks is null. function pcMbCredits(doc, mbTracks) { const credit = scope => { const out = []; for (const a of scope.querySelectorAll('a[href*="/artist/"]')) { // relationship credits, not the track's artists; nor the title cell's "Recording artist:" // line, which MusicBrainz shows when the recording is credited differently (#671: a // same-named artist there was paired first and shown ⚠) if (a.closest('.ars, dl.ars, div.ars, .release-rels, #mb-pc-panel, td.title')) continue; const m = (a.getAttribute('href') || '').match(/\/artist\/([0-9a-f-]{36})(?:[/?#]|$)/); if (m && !out.some(x => x.mbid === m[1])) out.push({ mbid: m[1], name: a.textContent.trim(), alt: (a.getAttribute('title') || '').trim() }); } return out; }; const header = doc.querySelector('.releaseheader, .release-information') || doc; const artists = []; for (const el of header.querySelectorAll('.artist-credit, .subheader, h1 ~ p')) for (const a of credit(el)) if (!artists.some(x => x.mbid === a.mbid)) artists.push(a); const labels = []; for (const a of (doc.querySelector('#sidebar') || doc).querySelectorAll('a[href*="/label/"]')) { const m = (a.getAttribute('href') || '').match(/\/label\/([0-9a-f-]{36})(?:[/?#]|$)/); if (m && !labels.some(x => x.mbid === m[1])) labels.push({ mbid: m[1], name: a.textContent.trim() }); } const rows = [...doc.querySelectorAll('table.tbl.medium tbody tr')].filter(tr => tr.querySelector(':scope > td.pos')); const tracks = rows.length && rows.length === mbTracks ? rows.map(tr => { const c = credit(tr); return c.length ? c : null; }) : null; return { artists, labels, tracks, va: artists.some(a => a.mbid === VA_MBID) }; } // Which of these URLs MusicBrainz already has, and on whom: one /ws/2/url request per // 100 URLs. Asked only when the table opens. → Map(pcUrlKey → [{ type, mbid, name }]) // The other forms MusicBrainz may hold one page under (#671): it keeps a Qobuz or Apple Music // link in whatever locale it was entered (gb-en, /gb/, open.qobuz.com, itunes.apple.com), and // /ws/2/url only answers for the exact url. The common ones are asked for in the same request. const PC_QOBUZ_LOCALES = ['us-en', 'gb-en', 'fr-fr', 'de-de']; const PC_APPLE_STOREFRONTS = ['us', 'gb']; function pcUrlForms(url) { let m; if ((m = String(url).match(/^https:\/\/www\.qobuz\.com\/[a-z]{2}-[a-z]{2}\/(interpreter|label)\/(.+?)\/?$/))) { const id = (m[2].match(/(\d+)$/) || [])[1]; return PC_QOBUZ_LOCALES.map(l => `https://www.qobuz.com/${l}/${m[1]}/${m[2]}`) .concat(id ? [`https://open.qobuz.com/${m[1] === 'label' ? 'label' : 'artist'}/${id}`] : []).filter(u => u !== url); } if ((m = String(url).match(/^https:\/\/music\.apple\.com\/[a-z]{2}\/(artist|label)\/(?:[^/]+\/)?(\d+)$/))) { return PC_APPLE_STOREFRONTS.flatMap(s => [`https://music.apple.com/${s}/${m[1]}/${m[2]}`, `https://itunes.apple.com/${s}/${m[1]}/id${m[2]}`]).filter(u => u !== url); } return []; } async function pcLinkedTo(urls) { const out = new Map(); // each url, and the other forms of it, each pointing back at the url asked about const back = new Map(); for (const u of new Set(urls)) for (const f of [u, ...pcUrlForms(u)]) { if (!back.has(f)) back.set(f, new Set()); back.get(f).add(u); } const uniq = [...back.keys()]; for (let i = 0; i < uniq.length; i += 100) { const part = uniq.slice(i, i + 100); const r = await gmGet(`${MB_ORIGIN}/ws/2/url?${part.map(u => 'resource=' + encodeURIComponent(u)).join('&')}&inc=artist-rels+label-rels&fmt=json`, { headers: { Accept: 'application/json' } }); if (r.status === 404) continue; // one URL asked, and MusicBrainz doesn't have it if (!r.ok) throw new Error(`MusicBrainz answered ${r.status || r.error || '?'}`); const j = JSON.parse(r.responseText); for (const u of (j.urls || (j.resource ? [j] : []))) { const who = (u.relations || []).filter(x => x['target-type'] === 'artist' || x['target-type'] === 'label') .map(x => ({ type: x['target-type'], mbid: (x.artist || x.label || {}).id, name: (x.artist || x.label || {}).name || '', disambiguation: (x.artist || x.label || {}).disambiguation || '' })); // a page MusicBrainz holds under another form counts for the url asked about for (const asked of back.get(u.resource) || [u.resource]) { const k = pcUrlKey(asked), had = out.get(k) || []; out.set(k, had.concat(who.filter(w => !had.some(h => h.type === w.type && h.mbid === w.mbid)))); if (asked !== u.resource) appendLog('System', `Artists & labels: ${asked} is in MusicBrainz as ${u.resource}`); } } } return out; } // A cell's links, each marked: 'linked' (already on this entity), 'other' (MusicBrainz has // it on someone else: not added), 'new', or 'unsure' (new, but may be the label's, or a label // of another name) function pcMarkCell(row, cell, linked) { return cell.map(c => { const who = linked.get(pcUrlKey(c.url)) || []; if (who.some(w => w.type === row.type && w.mbid === row.mbid)) return { ...c, state: 'linked', who }; if (who.length) return { ...c, state: 'other', who }; return { ...c, state: c.uncertain || c.differs ? 'unsure' : 'new', who }; }); } // why a found link is unsure, or paired only by position, naming the platform's own name function pcCellWhy(c) { const as = c.name ? ` ("${c.name}" on the platform)` : ''; return c.uncertain ? 'an account that may be the artist or the label' : c.differs ? `another name${as}, paired only by position: maybe another label` : c.by === 'position' ? `matched by position, not name${as}` : ''; } // The link type for an artist's or label's page where MusicBrainz offers several and picks none // (Qobuz: download or streaming; Apple Music; Audiomack: free or paid streaming), the same picks // as Apollo's (verified on the sandbox) and the album rows' TYPE_FORCE. Others MusicBrainz types itself. const PC_ENTITY_LINK_TYPE = [ { re: /(^|\.)qobuz\.com$/i, artist: 176, label: 959 }, // purchase music for download { re: /^(music|itunes)\.apple\.com$/i, artist: 978, label: 1005 }, // streaming page { re: /(^|\.)audiomack\.com$/i, artist: 194, label: 997 }, // stream for free ]; function pcLinkTypeFor(type, url) { let h = ''; try { h = new URL(url).hostname; } catch (e) { return null; } const t = PC_ENTITY_LINK_TYPE.find(x => x.re.test(h)); return (t && t[type]) || null; } // The batch for Falcon's ?falcon= handoff, in Falcon's JSON model (what its Import reads): // one item per artist or label with its new links, the batch's edit note, and its name (the // session name in Falcon's history, #671). closeWhenDone: Falcon closes its panel once the // run it starts finishes with nothing failed (read only with falcon:run). function pcFalconJson(rows, note, name, closeWhenDone) { const items = rows.map(r => ({ entityType: r.type, mbid: r.mbid, name: r.name, urls: r.urls.map(url => ({ url, linkTypeId: pcLinkTypeFor(r.type, url) })) })); return JSON.stringify(Object.assign(name ? { name, note } : { note }, closeWhenDone ? { closeWhenDone: true } : {}, { items })); } function pcFalconPayload(rows, note, name) { return btoa(String.fromCharCode(...new TextEncoder().encode(pcFalconJson(rows, note, name)))); } // Falcon runs on this page too: hand it the batch in place (a DOM event, which every // userscript sandbox hears), to run (falcon:run) or only to queue (falcon:import). False // when no Falcon answered, so the caller opens it in a tab. function pcSendToFalconHere(json, run) { let ok = false; const ack = () => { ok = true; }; document.addEventListener('falcon:import-ok', ack); try { document.dispatchEvent(new CustomEvent(run ? 'falcon:run' : 'falcon:import', { detail: json })); } finally { document.removeEventListener('falcon:import-ok', ack); } return ok; } // MusicBrainz's edit page for one artist or label, with its new links filled in, for you to submit function pcSeededEditUrl(type, mbid, urls, note) { const q = urls.flatMap((u, i) => { const t = pcLinkTypeFor(type, u); return [`edit-${type}.url.${i}.text=${encodeURIComponent(u)}`].concat(t ? [`edit-${type}.url.${i}.link_type_id=${t}`] : []); }); q.push(`edit-${type}.edit_note=${encodeURIComponent(note)}`); return `${MB_ORIGIN}/${type}/${mbid}/edit?${q.join('&')}`; } // The credits of the platforms whose album is a match (a ✓), or a link the release already has. // Unlike +, a ✓ that barcode or format confidence withholds counts: another edition of the // album still has the same artists and labels. function pcConfirmedCredits() { const out = {}, stale = []; for (const p of PROVIDER_ORDER) { if (!providerEnabled(p)) continue; const c = cacheGet(mbid, p); if (!c || !c.url) continue; const ok = c.source === 'MB rels' || (document.getElementById(`ico-${p}`)?.textContent?.trim() === '✓'); // link confidence stays out of it: another edition still has the same artists and labels if (!ok) continue; if (!('credits' in c)) { if (PC_CREDIT_PROVIDERS.includes(p)) stale.push(p); continue; } // cached before #671: ↻ reads it again if (c.credits) out[p] = c.credits; } return { byProvider: out, stale }; } // the batch's name in Falcon: the release, and what the batch is function pcLinksName() { const mb = mbDataGet(mbid) || {}; return `${mb.album || mbid} — artist and label links`; } function pcLinksNote() { const mb = mbDataGet(mbid) || {}; return `Artist and label links from Platform Check's platform matches for the release "${mb.album || ''}" by ${mb.artist || '?'}: ${MB_ORIGIN}/release/${mbid}`; } // The Artists & labels count (#671): the links the table would add as it opens (its + marks), on // the footer button once the scans finish — when the setting is on. Its /ws/2/url answer is kept // for the table, so opening it right after asks nothing again. let _pcLinked = null; // { key, map }: the last lookup, keyed by the urls it asked about const pcLinkedKey = urls => [...new Set(urls)].sort().join('\n'); async function pcLinkedFresh(urls) { const map = await pcLinkedTo(urls); _pcLinked = { key: pcLinkedKey(urls), map }; return map; } const pcLinkedCached = urls => (_pcLinked && _pcLinked.key === pcLinkedKey(urls)) ? Promise.resolve(_pcLinked.map) : pcLinkedFresh(urls); const pcNewCount = (rows, linked) => rows.reduce((s, r) => s + Object.values(r.cells).reduce((t, cell) => t + pcMarkCell(r, cell, linked).filter(c => c.state === 'new').length, 0), 0); function pcShowLinksCount(n) { const b = document.getElementById('mb-links-btn'); if (!b) return; // the count as a badge, like an unread count, so a release with links to add stands out b.innerHTML = n ? `Artists & labels<span class="pc-links-n">${n}</span>` : 'Artists & labels'; b.classList.toggle('pc-links-on', !!n); } async function pcCountLinks() { if (!GM_getValue('pc:links-count', true)) return; const mbRec = mbDataGet(mbid) || {}; const { rows } = pcLinkRows(pcMbCredits(document, mbRec.mbTracks || 0), pcConfirmedCredits().byProvider); const urls = rows.flatMap(r => Object.values(r.cells).flat().map(c => c.url)); if (!urls.length) { pcShowLinksCount(0); appendLog('System', 'Artists & labels: no artist or label pages to count'); return; } try { const n = pcNewCount(rows, await pcLinkedFresh(urls)); pcShowLinksCount(n); appendLog('System', `Artists & labels: ${n} link(s) to add, of ${urls.length} page(s) found`); } catch (e) { appendLog('System', `Artists & labels: couldn't count the links to add (${e.message})`, 'warn'); } } async function pcOpenLinksTable(btn) { document.getElementById('pc-links-ov')?.remove(); const mbRec = mbDataGet(mbid) || {}; const mb = pcMbCredits(document, mbRec.mbTracks || 0); const { byProvider, stale } = pcConfirmedCredits(); const { rows, notes } = pcLinkRows(mb, byProvider); appendLog('System', `Artists & labels: ${rows.length} MusicBrainz artist(s)/label(s) with platform pages, from ${Object.keys(byProvider).join(', ') || 'no platform'}${stale.length ? `; cached before artist pages were kept: ${stale.join(', ')} (↻ reads them again)` : ''}${mb.tracks ? '' : '; track credits: not all on the page'}`); notes.forEach(n => appendLog('System', `Artists & labels: ${n}`, 'warn')); if (!rows.length) { flashInfo(btn, stale.length ? 'Nothing kept yet — press ↻' : 'No artist or label pages'); return; } const ov = document.createElement('div'); ov.id = 'pc-links-ov'; ov.className = 'mbu-ov'; ov.innerHTML = mbuHtml(`<style>${MBU_TOKENS}${MBU_UI_CSS} #pc-links-ov .mbu-ov-panel { min-width: 420px; font: 13px var(--mbu-font); } #pc-links-ov table { border-collapse: collapse; } #pc-links-ov th, #pc-links-ov td { padding: 3px 6px; border-bottom: 1px solid var(--mbu-divider); text-align: center; white-space: nowrap; } #pc-links-ov th { font-weight: 600; color: var(--mbu-text-dim); } #pc-links-ov td.pc-lk-name { text-align: left; max-width: 260px; overflow: hidden; text-overflow: ellipsis; } #pc-links-ov td.pc-lk-name a { color: var(--mbu-accent-text); } #pc-links-ov .pc-lk-type { color: var(--mbu-text-weak); font-size: 11px; margin-right: 4px; } #pc-links-ov td.pc-lk-cell { cursor: pointer; font-weight: 700; min-width: 22px; user-select: none; } #pc-links-ov td.pc-lk-cell:hover { background: var(--mbu-bg-hover); } #pc-links-ov .pc-lk-linked { color: var(--mbu-ok); } #pc-links-ov .pc-lk-new { color: var(--mbu-accent-text); } #pc-links-ov .pc-lk-unsure { color: var(--mbu-warn); } #pc-links-ov .pc-lk-other { color: var(--mbu-error); } #pc-links-ov .pc-lk-on { background: var(--mbu-warn-bg); } #pc-links-ov .pc-lk-off { color: var(--mbu-text-weak); opacity: .55; text-decoration: line-through; } #pc-links-ov .pc-lk-none { color: var(--mbu-text-weak); font-weight: 400; cursor: default; } #pc-links-ov .pc-lk-edit { cursor: pointer; color: var(--mbu-accent-text); background: none; border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); padding: 0 6px; font: inherit; } #pc-links-ov .pc-lk-foot { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--mbu-border-soft); } #pc-links-ov .pc-lk-status { flex: 1 1 auto; color: var(--mbu-text-weak); font-size: 12px; } #pc-links-ov .pc-lk-send { cursor: pointer; background: var(--mbu-accent); color: var(--mbu-accent-fg); border: none; border-radius: var(--mbu-radius); padding: 5px 12px; font: 600 13px var(--mbu-font); } #pc-links-ov .pc-lk-send:disabled { opacity: .5; cursor: default; } #pc-links-ov .pc-lk-split { position: relative; display: inline-flex; } #pc-links-ov .pc-lk-split .pc-lk-send:first-child { border-radius: var(--mbu-radius) 0 0 var(--mbu-radius); } #pc-links-ov .pc-lk-more { border-left: 1px solid var(--mbu-accent-fg) !important; border-radius: 0 var(--mbu-radius) var(--mbu-radius) 0 !important; padding: 5px 8px !important; } #pc-links-ov .pc-lk-menu { position: absolute; right: 0; bottom: calc(100% + 4px); background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); box-shadow: 0 4px 14px rgba(0,0,0,.3); padding: 4px 0; white-space: nowrap; z-index: 1; } #pc-links-ov .pc-lk-menu[hidden] { display: none; } #pc-links-ov .pc-lk-menu button { display: block; width: 100%; text-align: left; background: none; border: none; color: var(--mbu-text); padding: 5px 14px; font: 13px var(--mbu-font); cursor: pointer; } #pc-links-ov .pc-lk-menu button:hover { background: var(--mbu-bg-hover); } </style> <div class="mbu-ov-panel"><div class="mbu-ov-h"><span class="mbu-ov-title">Artists & labels</span><button type="button" class="mbu-ov-x" title="Close">✕</button></div> <div class="mbu-ov-body"></div> <div class="pc-lk-foot"><span class="pc-lk-status">Checking MusicBrainz for links already there…</span><span class="pc-lk-split"><button type="button" class="pc-lk-send" disabled title="Queue the new links in Falcon and start it">Run in Falcon</button><button type="button" class="pc-lk-send pc-lk-more" disabled title="More">▾</button><span class="pc-lk-menu" hidden><button type="button" class="pc-lk-queue" title="Queue the new links in Falcon without starting it">Send only</button></span></span></div></div>`); document.body.appendChild(ov); const close = () => { ov.remove(); document.removeEventListener('keydown', onKey, true); }; const onKey = e => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; document.addEventListener('keydown', onKey, true); ov.addEventListener('mousedown', e => { if (e.target === ov) close(); }); ov.querySelector('.mbu-ov-x').onclick = close; const status = ov.querySelector('.pc-lk-status'), send = ov.querySelector('.pc-lk-send'), more = ov.querySelector('.pc-lk-more'), menu = ov.querySelector('.pc-lk-menu'); let linked = new Map(), checked = false; try { linked = await pcLinkedCached(rows.flatMap(r => Object.values(r.cells).flat().map(c => c.url))); // the count's answer, when it has one checked = true; } catch (e) { appendLog('System', `Artists & labels: couldn't ask MusicBrainz which links it already has (${e.message})`, 'error'); } if (!ov.isConnected) return; const provs = PROVIDER_ORDER.filter(p => rows.some(r => r.cells[p])); // a link is in the batch when it's new (an uncertain one only once you include it) const marked = rows.map(r => { const cells = {}; for (const p of provs) if (r.cells[p]) cells[p] = pcMarkCell(r, r.cells[p], linked).map(c => ({ ...c, on: c.state === 'new' })); return { ...r, cells }; }); if (checked && GM_getValue('pc:links-count', true)) pcShowLinksCount(pcNewCount(rows, linked)); const batch = () => marked.map(r => ({ type: r.type, mbid: r.mbid, name: r.name, urls: Object.values(r.cells).flat().filter(c => c.on).map(c => c.url) })).filter(r => r.urls.length); const MARK = { linked: '✓', new: '+', unsure: '+?', other: '⚠' }; const cellMark = cs => { const lead = cs.find(c => c.on) || cs.find(c => c.state === 'new' || c.state === 'unsure') || cs.find(c => c.state === 'other') || cs[0]; // a + taken out is struck; a +? is amber, and highlighted once taken in return { text: MARK[lead.state], cls: lead.state === 'new' && !lead.on ? 'pc-lk-off' : `pc-lk-${lead.state}${lead.state === 'unsure' && lead.on ? ' pc-lk-on' : ''}` }; }; // a ⚠ cell: the MusicBrainz artist or label that already has its link (#671: open it to // compare, and merge it if it is a duplicate) const otherOf = cs => { const c = cellMark(cs).text === MARK.other && cs.find(x => x.state === 'other'); return c && c.who[0] ? c.who[0] : null; }; const cellTitle = (cs, row) => cs.map(c => `${c.url}\n ${c.state === 'linked' ? 'already linked in MusicBrainz' : c.state === 'other' ? `linked to ${c.who.map(w => `${w.type} ${w.name || w.mbid}${w.disambiguation ? ` (${w.disambiguation})` : ''}`).join(', ')} in MusicBrainz — not added${c.who.some(w => pcNameKey(w.name) === pcNameKey(row.name)) ? '; same name: a duplicate to merge, or the wrong one credited' : ''}` : `${c.on ? 'will be added' : 'not added'}${pcCellWhy(c) ? `: ${pcCellWhy(c)}` : ''}`}`).join('\n') + (otherOf(cs) ? `\nclick: open ${otherOf(cs).type} ${otherOf(cs).name || ''} in MusicBrainz · middle-click: the platform page` : '\nclick: open · right-click: include or leave out'); const tbl = document.createElement('table'); const head = tbl.createTHead().insertRow(); head.appendChild(document.createElement('th')).textContent = 'MusicBrainz'; for (const p of provs) { const th = head.appendChild(document.createElement('th')); th.title = PROVIDER_NAME[p]; th.innerHTML = mbuHtml(stIcon(p, 16)); } head.appendChild(document.createElement('th')); const body = tbl.createTBody(); const refresh = () => { const b = batch(), n = b.reduce((s, r) => s + r.urls.length, 0); send.textContent = `Run ${n} in Falcon`; send.disabled = more.disabled = !n; if (!n) menu.hidden = true; status.textContent = checked ? `${n} new link(s) on ${b.length} artist(s)/label(s)` : `MusicBrainz couldn't be asked which links it already has — check before sending`; }; for (const r of marked) { const tr = body.insertRow(); const name = tr.insertCell(); name.className = 'pc-lk-name'; name.appendChild(Object.assign(document.createElement('span'), { className: 'pc-lk-type', textContent: r.type === 'artist' ? 'art' : 'lbl' })); name.appendChild(Object.assign(document.createElement('a'), { href: `${MB_ORIGIN}/${r.type}/${r.mbid}`, target: '_blank', rel: 'noopener', textContent: r.name || r.mbid })); for (const p of provs) { const td = tr.insertCell(); const cs = r.cells[p]; if (!cs) { td.className = 'pc-lk-none'; td.textContent = '·'; continue; } const paint = () => { const m = cellMark(cs); td.className = `pc-lk-cell ${m.cls}`; td.textContent = m.text; td.title = cellTitle(cs, r); }; paint(); td.onclick = () => { const o = otherOf(cs); window.open(o ? `${MB_ORIGIN}/${o.type}/${o.mbid}` : cs[0].url, '_blank', 'noopener'); }; td.onmousedown = e => { if (e.button === 1 && otherOf(cs)) e.preventDefault(); }; // no autoscroll td.onauxclick = e => { if (e.button === 1 && otherOf(cs)) { e.preventDefault(); window.open(cs[0].url, '_blank', 'noopener'); } }; td.oncontextmenu = e => { e.preventDefault(); const can = cs.filter(c => c.state === 'new' || c.state === 'unsure'); if (!can.length) return; const on = !can.some(c => c.on); can.forEach(c => { c.on = on; }); paint(); refresh(); }; } const act = tr.insertCell(); const edit = Object.assign(document.createElement('button'), { type: 'button', className: 'pc-lk-edit', textContent: '✎', title: `Open the ${r.type}'s edit page with its new links filled in, for you to submit` }); edit.onclick = () => { const urls = Object.values(r.cells).flat().filter(c => c.on).map(c => c.url); if (!urls.length) { flashInfo(edit, 'No new links in this row'); return; } window.open(pcSeededEditUrl(r.type, r.mbid, urls, pcLinksNote()), '_blank'); }; act.appendChild(edit); } ov.querySelector('.mbu-ov-body').appendChild(tbl); refresh(); // Run: queue and start. Send only: queue, Falcon waits for Start. A Falcon opened in a // new tab (none on this page) only queues: ?falcon= never starts on its own. const toFalcon = run => { const b = batch(); if (!b.length) return; const here = pcSendToFalconHere(pcFalconJson(b, pcLinksNote(), pcLinksName(), run && GM_getValue('pc:links-close-falcon', true)), run); appendLog('System', `Artists & labels: ${run && here ? 'running' : 'sent'} ${b.reduce((s, r) => s + r.urls.length, 0)} link(s) on ${b.length} artist(s)/label(s) ${run && here ? 'in' : 'to'} Falcon${here ? '' : ` (in a new tab: no Falcon on this page${run ? '; press Start there' : ''})`}`, 'ok'); if (!here) window.open(`${MB_ORIGIN}/?falcon=${encodeURIComponent(pcFalconPayload(b, pcLinksNote(), pcLinksName()))}`, '_blank'); // the links are on their way: the count and the lookup behind it no longer hold _pcLinked = null; pcShowLinksCount(null); close(); }; send.onclick = () => toFalcon(true); more.onclick = e => { e.stopPropagation(); menu.hidden = !menu.hidden; }; ov.querySelector('.pc-lk-queue').onclick = () => toFalcon(false); ov.addEventListener('click', e => { if (!menu.hidden && !e.target.closest('.pc-lk-split')) menu.hidden = true; }); } document.getElementById('mb-links-btn').addEventListener('click', e => pcOpenLinksTable(e.currentTarget)); // ↻ REFRESH button: clear cached URLs for this MBID, blank the rows, re-run. // ↻ while it spins: stop the scan (#674) — what it found stays, the platforms still out are left unchecked document.getElementById('mb-refresh-btn').addEventListener('click', () => { if (!PC_SCAN.busy) { pcRescan('Refresh requested'); return; } pcCancelScan(); PC_SCAN.busy = false; setScanStatus('stopped'); appendLog('System', 'Scan stopped — the platforms not done yet are left unchecked (↻ scans again)', 'warn'); }); // ─── Pasted barcode (#673) ────────────────────────────────────────────────── // Ctrl+V of a barcode anywhere on a release page that has none: the platforms are // searched and checked with it as if it were on the release, and a link added from // the panel (icon, +, in the foreground or the background) puts it on the release too. // Kept per release in localStorage until it's removed (× on the header chip) or the // release has a barcode of its own. function pcPastedBarcode() { try { return localStorage.getItem(`pc:pasted-barcode:${mbid}`) || null; } catch (e) { return null; } } function pcSetPastedBarcode(code) { try { if (code) localStorage.setItem(`pc:pasted-barcode:${mbid}`, code); else { localStorage.removeItem(`pc:pasted-barcode:${mbid}`); localStorage.removeItem(`pc:pending-barcode:${mbid}`); } } catch (e) {} } // Queued with the links for the release editor; a stale one from an earlier add is cleared. function pcQueuePastedBarcode() { const code = MB_OWN_BARCODE ? null : pcPastedBarcode(); try { if (code) localStorage.setItem(`pc:pending-barcode:${mbid}`, code); else localStorage.removeItem(`pc:pending-barcode:${mbid}`); } catch (e) {} if (code) appendLog('System', `Inject: barcode ${code} goes in with the links`, 'ok'); } function pcShowPastedBarcode(code) { const chip = document.getElementById('mb-pasted-bc'); if (!chip) return; chip.style.display = code ? '' : 'none'; chip.textContent = code ? `${code} ×` : ''; chip.title = code ? `Pasted barcode ${code}: the platforms are checked against it, and a link you add adds it to the release too · click to remove it` : ''; pcShowFoundBarcodes(); // #674: the found ones give way to the pasted one } // ─── Found barcodes (#674) ────────────────────────────────────────────────── // The barcodes the matched platforms report, offered on a release that has none: a // button next to ↻ opens a list of them, and picking one is the same as pasting it. // updateRow records each platform's (PC_FOUND_BC); resetRows clears them. function pcNoteFoundBarcode(p, barcode, format) { const code = barcode && (pcBarcodeOf(String(barcode)) || pcBarcodeOf(normBarcode(barcode).padStart(12, '0'))); if (code) PC_FOUND_BC.set(p, { code, format: format || null }); else PC_FOUND_BC.delete(p); pcShowFoundBarcodes(); } // One entry per barcode (UPC-A and its EAN-13 form are one), the platforms that report // it, and how its format sits with the release's (its medium, as link confidence // sees it): 'same' when one of them is in a format the release has, 'other' when every one // is in a format it hasn't, else 'unknown'. The ones in the release's format first, then // the most reported. function pcFoundBarcodes() { const mbCats = formatCategories(MB_FORMAT); const by = new Map(); for (const p of PROVIDER_ORDER) { const f = PC_FOUND_BC.get(p); if (!f) continue; const k = normBarcode(f.code); if (!by.has(k)) by.set(k, { code: f.code, platforms: [], fits: [], kinds: new Set() }); const e = by.get(k); e.platforms.push(p); const remote = remoteFormatCategories(p, f.format); remote.forEach(x => e.kinds.add(x)); e.fits.push(!mbCats.size || !remote.size ? null : [...remote].some(x => mbCats.has(x))); } const rank = { same: 0, unknown: 1, other: 2 }; return [...by.values()].map(e => ({ code: e.code, platforms: e.platforms, kinds: [...e.kinds], format: e.fits.includes(true) ? 'same' : e.fits.length && e.fits.every(x => x === false) ? 'other' : 'unknown', })).sort((a, b) => rank[a.format] - rank[b.format] || b.platforms.length - a.platforms.length); } const PC_BARCODE_SVG = '<svg width="13" height="9" viewBox="0 0 13 9" fill="currentColor" style="vertical-align: -1px;"><rect x="0" width="1.4" height="9"/><rect x="2.4" width="0.8" height="9"/><rect x="4.2" width="1.8" height="9"/><rect x="7" width="0.8" height="9"/><rect x="8.8" width="1.4" height="9"/><rect x="11.2" width="0.8" height="9"/><rect x="12.2" width="0.8" height="9"/></svg>'; function pcShowFoundBarcodes() { const btn = document.getElementById('mb-found-bc'); if (!btn) return; const list = MB_OWN_BARCODE || pcPastedBarcode() ? [] : pcFoundBarcodes(); btn.style.display = list.length ? '' : 'none'; btn.innerHTML = mbuHtml(list.length ? `${PC_BARCODE_SVG} ${list.length}` : ''); btn.title = list.length === 1 ? `The release has no barcode; ${list[0].platforms.map(p => PROVIDER_NAME[p]).join(', ')} report${list[0].platforms.length === 1 ? 's' : ''} ${list[0].code} · click to choose` : list.length ? `The release has no barcode; the platforms report ${list.length} different ones · click to choose` : ''; if (!list.length) document.getElementById('pc-bc-pop')?._pcClose?.(); } // The MusicBrainz releases that already have this barcode, other than this one: [{ id, title, // format, date }], [] for none, null when the search failed. Asked once per barcode per page; // not a scan's request, so stopping a scan never leaves it waiting. const PC_BC_ON_MB = new Map(); function pcBarcodeOnMb(code) { if (!PC_BC_ON_MB.has(code)) PC_BC_ON_MB.set(code, (async () => { const n = normBarcode(code); const forms = [...new Set([code, n, n.padStart(12, '0'), n.padStart(13, '0')])].filter(f => f.length >= 8); const r = await gmGet(`${MB_ORIGIN}/ws/2/release?query=${encodeURIComponent(`barcode:(${forms.join(' OR ')})`)}&limit=10&fmt=json`, { headers: { Accept: 'application/json' }, scan: null }); let j = null; try { j = JSON.parse(r.responseText); } catch (e) {} if (!r.ok || !j) { appendLog('MusicBrainz', `Barcode ${code}: search failed (HTTP ${r.status})`, 'warn'); PC_BC_ON_MB.delete(code); return null; } const on = (j.releases || []).filter(x => x.id !== mbid && normBarcode(x.barcode) === n) .map(x => ({ id: x.id, title: x.title || '', date: x.date || '', format: [...new Set((x.media || []).map(m => m.format).filter(Boolean))].join(' + ') })); appendLog('MusicBrainz', `Barcode ${code}: ${on.length ? 'already on ' + on.map(x => x.id).join(', ') : 'on no other release'}`); return on; })()); return PC_BC_ON_MB.get(code); } const pcEsc = t => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); function pcOpenFoundBarcodes(btn) { const old = document.getElementById('pc-bc-pop'); if (old) { old._pcClose(); return; } const list = pcFoundBarcodes(); if (!list.length) return; const mbKind = [...formatCategories(MB_FORMAT)].join(' and '); const pop = document.createElement('div'); pop.id = 'pc-bc-pop'; pop.innerHTML = mbuHtml(`<div class="pc-bc-h">Barcodes on the platforms</div>` + list.map(c => ` <div class="pc-bc-opt" data-code="${c.code}" title="Use ${c.code} as if it were pasted · right-click: copy it"> <span class="pc-bc-code">${c.code}</span><span class="pc-bc-use">use</span> <span class="pc-bc-who">${c.platforms.map(p => `<span class="pc-bc-plat" title="${PROVIDER_NAME[p]}">${stIcon(p, 12)}</span>`).join('')} ${c.platforms.map(p => PROVIDER_NAME[p]).join(', ')}</span> ${c.format === 'same' ? `<span class="pc-bc-fmt pc-bc-same">✓ ${mbKind}, like this release</span>` : c.format === 'other' ? `<span class="pc-bc-fmt pc-bc-other">⚠ ${c.kinds.join(' and ')}: this release is ${mbKind}</span>` : ''} ${pcGtinValid(c.code) ? '' : '<span class="pc-bc-fmt pc-bc-other">⚠ wrong check digit</span>'} <span class="pc-bc-fmt pc-bc-mb">checking MusicBrainz…</span> </div>`).join('') + `<div class="pc-bc-hint">Right-click to copy · Ctrl+V a barcode from anywhere else</div>`); document.body.appendChild(pop); const r = btn.getBoundingClientRect(); pop.style.top = `${r.bottom + 4}px`; // clientWidth leaves out the page's scrollbar, which innerWidth counts pop.style.left = `${Math.max(8, Math.min(r.left, document.documentElement.clientWidth - pop.offsetWidth - 8))}px`; pop.style.maxHeight = `${Math.max(120, document.documentElement.clientHeight - r.bottom - 12)}px`; const off = mbuDismissOn(pop, () => pop.remove(), { ignore: '#mb-found-bc' }); pop._pcClose = off; // #674: a barcode already on another release is most likely that release's, not this one's pop.querySelectorAll('.pc-bc-opt').forEach(opt => pcBarcodeOnMb(opt.dataset.code).then(on => { const line = opt.querySelector('.pc-bc-mb'); if (!line) return; if (on === null) { line.textContent = "couldn't check MusicBrainz"; return; } if (!on.length) { line.remove(); return; } const r = on[0], more = on.length > 1 ? ` and ${on.length - 1} more` : ''; line.classList.add('pc-bc-other'); line.innerHTML = mbuHtml(`⚠ already on <a href="${MB_ORIGIN}/release/${r.id}" target="_blank" rel="noopener" title="${pcEsc([r.format, r.date].filter(Boolean).join(' · '))}">${pcEsc(r.title)}</a>${more}`); opt.title = `${opt.dataset.code} is already on another MusicBrainz release — probably that edition's, not this one's · click to use it anyway · right-click: copy it`; })); pop.addEventListener('click', e => { if (e.target.closest('a')) return; // the release it is already on: opened, not picked const opt = e.target.closest('.pc-bc-opt'); if (!opt) return; off(); pcPasteBarcode(opt.dataset.code); }); pop.addEventListener('contextmenu', e => { const opt = e.target.closest('.pc-bc-opt'); if (!opt) return; e.preventDefault(); navigator.clipboard.writeText(opt.dataset.code) .then(() => mbuToast(`Copied ${opt.dataset.code}`), () => mbuToast(`✗ Couldn't copy ${opt.dataset.code}`, { kind: 'error' })); }); } // ─── Found catalog numbers (#740) ─────────────────────────────────────────── // The catalog numbers the matched platforms give (Beatport, Discogs), each for a label the release has // without one, matched by the platform's label name, loosely ("Artoffact" is "Artoffact Records"). A button // next to the barcode's opens their list; "use" queues one for its label, and a link added with + fills it in // the release editor, as a used barcode is. updateRow records each platform's (PC_FOUND_CAT). function pcNoteFoundCatnos(p, catnos) { if (catnos && catnos.length) PC_FOUND_CAT.set(p, catnos); else PC_FOUND_CAT.delete(p); pcShowFoundCatnos(); } // One entry per label and catalog number (written with or without spaces, one): the platforms that give // it, the barcodes they give (normalised, '' for none), and whether it is sure: given by a platform with the // release's own barcode, or by a link the release already has whose track count and medium match. function pcFoundCatnos() { const rows = pcPageLabels().filter(r => r.mbid && !r.catno); if (!rows.length) return []; const by = new Map(), own = normBarcode(MB_BARCODE || ''); for (const p of PROVIDER_ORDER) for (const c of PC_FOUND_CAT.get(p) || []) { const r = rows.find(x => pcLabelNorm(x.name) && pcLabelNorm(x.name) === pcLabelNorm(c.label)); if (!r) continue; const k = r.mbid + '|' + pcCatnoNorm(c.catno); if (!by.has(k)) by.set(k, { catno: c.catno, forms: new Map(), label: { mbid: r.mbid, name: r.name }, platforms: [], barcodes: new Set(), sure: false, kinds: new Set(), fits: [] }); const e = by.get(k), cached = cacheGet(mbid, p) || {}, bc = normBarcode(cached.barcode || ''); if (e.platforms.includes(p)) continue; e.platforms.push(p); e.barcodes.add(bc); e.forms.set(c.catno, (e.forms.get(c.catno) || 0) + 1); // #740: a platform whose track count or medium says it is another release (a Discogs CD with the digital // release's barcode) never makes it sure, though it is in the release's own lane const off = pcMcMismatch(p, cached).mismatch; if (off.length) { e.off = e.off || []; e.off.push(`${PROVIDER_NAME[p]}: ${off.join(', ')}`); } else if ((own && bc === own) || pcIsLinked(p)) e.sure = true; const mbCats = formatCategories(MB_FORMAT), remote = remoteFormatCategories(p, cached.format); remote.forEach(x => e.kinds.add(x)); e.fits.push(!mbCats.size || !remote.size ? null : [...remote].some(x => mbCats.has(x))); } // written as most of its platforms write it; a tie, the longer ("AOF 146" over "AOF146") const form = e => [...e.forms].sort((a, b) => b[1] - a[1] || b[0].length - a[0].length)[0][0]; return [...by.values()].map(({ forms, ...e }) => ({ ...e, catno: form({ forms }), barcodes: [...e.barcodes], kinds: [...e.kinds], format: e.fits.includes(true) ? 'same' : e.fits.length && e.fits.every(x => x === false) ? 'other' : 'unknown' })) .sort((a, b) => b.sure - a.sure || b.platforms.length - a.platforms.length); } // The catalog numbers used ("use"), one per label: { <label mbid>: { name, catno } }. Kept per release in // localStorage until removed (× on the header chip) or the label has one of its own. function pcPickedCatnos() { let o = {}; try { o = JSON.parse(localStorage.getItem(`pc:picked-catno:${mbid}`) || '{}') || {}; } catch (e) {} const rows = pcPageLabels(); for (const k of Object.keys(o)) { const r = rows.find(x => x.mbid === k); if (!r || r.catno) delete o[k]; } return o; } function pcSetPickedCatnos(o) { try { if (Object.keys(o).length) localStorage.setItem(`pc:picked-catno:${mbid}`, JSON.stringify(o)); else { localStorage.removeItem(`pc:picked-catno:${mbid}`); localStorage.removeItem(`pc:pending-catno:${mbid}`); } } catch (e) {} pcShowFoundCatnos(); } const PC_CATNO_SVG = '<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" style="vertical-align: -1px;"><path d="M1.5 7.6V1.5h6.1l5 5-6.1 6.1z"/><circle cx="4.6" cy="4.6" r="1.1"/></svg>'; function pcShowFoundCatnos() { const picked = pcPickedCatnos(), chip = document.getElementById('mb-picked-cat'), btn = document.getElementById('mb-found-cat'); if (chip) { const v = Object.values(picked); chip.style.display = v.length ? '' : 'none'; chip.textContent = v.length ? `${v.map(x => x.catno).join(', ')} ×` : ''; chip.title = v.length ? `Catalog number ${v.map(x => `${x.catno} for ${x.name}`).join(', ')}: a link you add adds it to the release too · click to remove it` : ''; } if (!btn) return; const list = pcFoundCatnos().filter(e => !picked[e.label.mbid]); btn.style.display = list.length ? '' : 'none'; btn.innerHTML = mbuHtml(list.length ? `${PC_CATNO_SVG} ${list.length}` : ''); btn.title = list.length === 1 ? `${list[0].label.name} has no catalog number; ${list[0].platforms.map(x => PROVIDER_NAME[x]).join(', ')} give${list[0].platforms.length === 1 ? 's' : ''} ${list[0].catno} · click to choose` : list.length ? `The release's labels have no catalog number; the platforms give ${list.length} · click to choose` : ''; if (!list.length) document.getElementById('pc-cat-pop')?._pcClose?.(); } // Queued with the links for the release editor; a stale one from an earlier add is cleared. function pcQueuePickedCatnos() { const v = Object.entries(pcPickedCatnos()).map(([k, x]) => ({ mbid: k, name: x.name, catno: x.catno })); try { if (v.length) localStorage.setItem(`pc:pending-catno:${mbid}`, JSON.stringify(v)); else localStorage.removeItem(`pc:pending-catno:${mbid}`); } catch (e) {} if (v.length) appendLog('System', `Inject: catalog number ${v.map(x => `${x.catno} (${x.name})`).join(', ')} goes in with the links`, 'ok'); } function pcOpenFoundCatnos(btn) { const old = document.getElementById('pc-cat-pop'); if (old) { old._pcClose(); return; } const list = pcFoundCatnos().filter(e => !pcPickedCatnos()[e.label.mbid]); if (!list.length) return; const mbKind = [...formatCategories(MB_FORMAT)].join(' and '); const pop = document.createElement('div'); pop.id = 'pc-cat-pop'; pop.className = 'pc-bc-pop'; pop.innerHTML = mbuHtml(`<div class="pc-bc-h">Catalog numbers on the platforms</div>` + list.map((c, i) => ` <div class="pc-bc-opt" data-i="${i}" title="Add ${pcEsc(c.catno)} to ${pcEsc(c.label.name)} with the links you add · right-click: copy it"> <span class="pc-bc-code">${pcEsc(c.catno)}</span><span class="pc-bc-use">use</span> <span class="pc-bc-who">for ${pcEsc(c.label.name)} · ${c.platforms.map(p => `<span class="pc-bc-plat" title="${PROVIDER_NAME[p]}">${stIcon(p, 12)}</span>`).join('')} ${c.platforms.map(p => PROVIDER_NAME[p]).join(', ')}</span> ${c.sure ? '<span class="pc-bc-fmt pc-bc-same">✓ from links like this release</span>' : c.off ? `<span class="pc-bc-fmt pc-bc-other">⚠ ${pcEsc(c.off.join(' · '))}</span>` : c.format === 'other' ? `<span class="pc-bc-fmt pc-bc-other">⚠ ${c.kinds.join(' and ')}: this release is ${mbKind}</span>` : c.barcodes.every(b => !b) ? '<span class="pc-bc-fmt pc-bc-other">⚠ from platforms that give no barcode</span>' : '<span class="pc-bc-fmt pc-bc-other">⚠ from another barcode than this release\'s</span>'} </div>`).join('') + `<div class="pc-bc-hint">Right-click to copy</div>`); document.body.appendChild(pop); const r = btn.getBoundingClientRect(); pop.style.top = `${r.bottom + 4}px`; pop.style.left = `${Math.max(8, Math.min(r.left, document.documentElement.clientWidth - pop.offsetWidth - 8))}px`; pop.style.maxHeight = `${Math.max(120, document.documentElement.clientHeight - r.bottom - 12)}px`; const off = mbuDismissOn(pop, () => pop.remove(), { ignore: '#mb-found-cat' }); pop._pcClose = off; pop.addEventListener('click', e => { const opt = e.target.closest('.pc-bc-opt'); if (!opt) return; off(); const c = list[+opt.dataset.i]; pcSetPickedCatnos({ ...pcPickedCatnos(), [c.label.mbid]: { name: c.label.name, catno: c.catno } }); appendLog('System', `Catalog number ${c.catno} used for ${c.label.name} (from ${c.platforms.map(p => PROVIDER_NAME[p]).join(', ')})`, 'ok'); mbuToast(`✓ Catalog number ${c.catno}: added to ${c.label.name} with the links you add`, { kind: 'ok' }); }); pop.addEventListener('contextmenu', e => { const opt = e.target.closest('.pc-bc-opt'); if (!opt) return; e.preventDefault(); const c = list[+opt.dataset.i]; navigator.clipboard.writeText(c.catno).then(() => mbuToast(`Copied ${c.catno}`), () => mbuToast(`✗ Couldn't copy ${c.catno}`, { kind: 'error' })); }); } // A pasted text that is a barcode: EAN-8, UPC-A, EAN-13 or GTIN-14, spaces and dashes allowed function pcBarcodeOf(text) { const t = String(text || '').trim(); if (!/^[\d][\d\s-]*$/.test(t)) return null; const d = t.replace(/\D/g, ''); return /^(?:\d{8}|\d{12,14})$/.test(d) ? d : null; } // GTIN check digit: weights 3,1,3,… from the right, the check digit excluded function pcGtinValid(code) { const d = String(code).split('').map(Number); const check = d.pop(); const sum = d.reverse().reduce((a, n, i) => a + n * (i % 2 ? 1 : 3), 0); return (10 - sum % 10) % 10 === check; } function pcRescan(why) { // #674: a scan still running was for the barcode before: it is dropped, not waited for if (PC_SCAN.busy) { pcCancelScan(); appendLog('System', `${why} — the scan running is cancelled`, 'warn'); } appendLog('System', `${why} — clearing cache for ${mbid}`, 'warn'); cacheClear(mbid); resetRows(); pcScan(); } function pcPasteBarcode(code) { if (MB_OWN_BARCODE) { mbuToast(`✗ This release already has a barcode (${MB_OWN_BARCODE})`, { kind: 'error' }); appendLog('System', `Pasted barcode ${code}: the release already has ${MB_OWN_BARCODE} — not used`, 'warn'); return; } if (pcPastedBarcode() === code) { mbuToast(`Barcode ${code} is already in use`); return; } pcSetPastedBarcode(code); mbuToast(pcGtinValid(code) ? `✓ Barcode ${code}: checking the platforms with it` : `⚠ Barcode ${code}: its check digit is wrong — checking the platforms with it anyway`, { kind: pcGtinValid(code) ? 'ok' : 'warn' }); pcRescan(`Barcode ${code} pasted`); } document.addEventListener('paste', e => { try { const t = e.target; if (t && (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable)) return; // typing somewhere const code = pcBarcodeOf(e.clipboardData && e.clipboardData.getData('text')); if (!code) return; e.preventDefault(); pcPasteBarcode(code); } catch (err) { appendLog('System', `Paste: ${err && err.message}`, 'warn'); } }); document.getElementById('mb-found-bc').addEventListener('click', e => pcOpenFoundBarcodes(e.currentTarget)); document.getElementById('mb-found-cat').addEventListener('click', e => pcOpenFoundCatnos(e.currentTarget)); // #740: the offers' line shows while any of them does function pcSyncOffers() { const box = document.getElementById('pc-offers'); if (!box) return; const any = [...box.children].some(c => c.style.display !== 'none'); if ((box.style.display !== 'none') !== any) box.style.display = any ? 'flex' : 'none'; } new MutationObserver(pcSyncOffers).observe(document.getElementById('pc-offers'), { subtree: true, attributes: true, attributeFilter: ['style'] }); pcSyncOffers(); document.getElementById('mb-picked-cat').addEventListener('click', () => { pcSetPickedCatnos({}); mbuToast('Catalog number removed'); }); document.getElementById('mb-pasted-bc').addEventListener('click', () => { const code = pcPastedBarcode(); pcSetPastedBarcode(null); pcShowPastedBarcode(null); mbuToast(`Barcode ${code} removed`); pcRescan(`Pasted barcode ${code} removed`); }); // + INJECT button: collect every confirmed (✓) URL that ISN'T already in MB's // url-rels, stash it under `pc:pending:<mbid>` in localStorage, and open the // edit-relationships page in a new tab. The companion handler that runs on // that page (same script, @match'd against /edit-relationships) reads the // pending entry and dispatches each URL into MB's relationship editor. // Small floating toast near a target element, auto-fades after ~1.5 s. // Reused as inline feedback for the + button when there's nothing to do. // The visible per-row anchor for flashInfo. In icon mode the text glyph (`ico-…`) // is display:none, so anchoring a tooltip to it lands at the page's top-left corner // (zero-rect). Use the brand favicon (`plat-…`) when icons are shown. (#182) function rowAnchor(platform) { const panel = document.getElementById('mb-pc-panel'); const iconMode = panel && panel.classList.contains('pc-icons-mode'); return (iconMode ? document.getElementById(`plat-${platform}`) : document.getElementById(`ico-${platform}`)) || document.getElementById(`plat-${platform}`) || document.getElementById(`ico-${platform}`) || document.getElementById(`row-${platform}`) || document.body; } function flashInfo(targetEl, text, bg = '#5B82B0') { document.getElementById('pc-flash-info')?.remove(); let rect = targetEl.getBoundingClientRect(); // a hidden anchor reports a zero rect → the tip would jump to the top-left corner; // fall back to the panel so it stays in view (#182) if (!rect.width && !rect.height) { const panel = document.getElementById('mb-pc-panel'); if (panel) rect = panel.getBoundingClientRect(); } const tip = document.createElement('div'); tip.id = 'pc-flash-info'; tip.textContent = text; tip.style.cssText = `position:absolute;left:${rect.left + window.scrollX}px;top:${rect.bottom + window.scrollY + 4}px;background:${bg};color:#FFF;padding:4px 8px;border-radius:3px;font-size:11px;font-family:sans-serif;white-space:nowrap;z-index:99999;pointer-events:none;box-shadow:0 2px 6px rgba(0,0,0,0.2);transition:opacity .3s;`; document.body.appendChild(tip); setTimeout(() => { tip.style.opacity = '0'; }, 1500); setTimeout(() => { tip.remove(); }, 1850); } // Single-row click-to-add (icon click) — queues just one platform's URL and // opens /release/<mbid>/edit. The bulk + button at the bottom still queues // every ✓ row at once. // (#182) "Check barcodes for link confidence" gates the + / ↗ actions. Two modes: // 'exists' — withhold only links whose barcode is KNOWN and DIFFERS from MB's. // 'strict' — also withhold links we couldn't barcode-confirm (provider exposes // no UPC, e.g. Apple/Spotify) — i.e. only barcode-confirmed links pass. // On by default in "if they exist" mode; the subtle mismatch bar still shows known mismatches regardless. function barcodeBlocks(platform) { if (!GM_getValue('pc:respect-barcode', true)) return false; const c = cacheGet(mbid, platform); if (!c || !c.url) return false; // no found URL → nothing to withhold const strict = GM_getValue('pc:barcode-mode', 'strict') === 'strict'; // MB has no barcode (missing OR [none]) → nothing here can be barcode-confirmed. // strict: withhold (only confirmed links pass); if-they-exist: allow. (#206 chaban-mb — // previously returned false unconditionally, so strict mode silently ignored the absence.) if (!MB_BARCODE) return strict; if (c.barcode) return normBarcode(c.barcode) !== normBarcode(MB_BARCODE); // known → block iff differs return strict; // provider exposes no barcode → block only in strict mode } // (#182) "Use format for link confidence" gates + / ↗ when the matched edition's // format is incompatible with the MB release format (a different medium is a // different MB release). Mirrors the barcode modes: // 'exists' — withhold only when the remote format is KNOWN and incompatible. // 'strict' — also withhold links whose format can't be determined. // Digital-only platforms count as Digital (so streaming links pass on a digital // release, withheld on a physical one); Bandcamp/Discogs use their parsed // format. Off by default. function formatBlocks(platform) { if (!GM_getValue('pc:respect-format', true)) return false; const mbCats = formatCategories(MB_FORMAT); if (mbCats.size === 0) return false; // MB format unknown → can't judge const c = cacheGet(mbid, platform); if (!c || !c.url) return false; const remote = remoteFormatCategories(platform, c.format); if (remote.size === 0) return GM_getValue('pc:format-mode', 'strict') === 'strict'; // unknown → block only in strict return ![...remote].some(x => mbCats.has(x)); // known → block iff no shared category } // #464: how the release editor opens for a queued add. // - foreground: honors the "Add links in a new tab" setting (default on) — off // navigates the current tab instead of spawning one chaban has to close by hand. // - background (right-click): mirrors Credit Hoarder / Apollo Editor — an inactive // GM tab injects the URL(s), auto-submits, and closes itself; runInjectHelper's // autoCommit branch does the submit+close, we just wait for its postMessage here. // Only release edits support this (release-group has no plain landing @match to // detect the redirect on), so callers must pass a release mbid. function openReleaseEditTab(mbid_, { background = false } = {}) { const url = `${MB_ORIGIN}/release/${mbid_}/edit`; if (background) { if (typeof GM_openInTab !== 'function' || !PC_CHANNEL) { appendLog('System', `Background add needs GM_openInTab — opening in a normal tab instead`, 'warn'); window.open(url, '_blank'); return; } // #556: start this run's timeline clean, so the Log shows THIS background // add rather than every one since the page loaded. pcMarkReset(); const bgTab = GM_openInTab(`${url}#pc-autocommit`, { active: false, insert: true }); const onCommitted = (e) => { if (!e.data || e.data.type !== 'pc-edit-committed' || e.data.mbid !== mbid_) return; PC_CHANNEL.removeEventListener('message', onCommitted); try { if (bgTab && typeof bgTab.close === 'function') bgTab.close(); } catch (x) {} appendLog('System', `Background add committed for ${mbid_} — refreshing`, 'ok'); location.reload(); }; PC_CHANNEL.addEventListener('message', onCommitted); return; } if (GM_getValue('pc:open-new-tab', true)) { const w = window.open(url, '_blank'); if (!w) appendLog('System', `window.open returned null — popup blocked?`, 'error'); } else { location.href = url; } } // #559: the same three steps for the release-GROUP editor, which is where the // Discogs master URL goes. Kept as its own function rather than a parameter on // openReleaseEditTab: the entity path, the channel message and the "open in this // tab instead" rule all differ, and folding them together made every line a // conditional. function openRgEditTab(rgMbid, { background = false, sameTabAllowed = false } = {}) { const url = `${MB_ORIGIN}/release-group/${rgMbid}/edit`; if (background) { if (typeof GM_openInTab !== 'function' || !PC_CHANNEL) { appendLog('System', `Background master add needs GM_openInTab — opening in a normal tab instead`, 'warn'); window.open(url, '_blank'); return; } // #556: start this run's timeline clean, so the Log shows THIS background // add rather than every one since the page loaded. pcMarkReset(); const bgTab = GM_openInTab(`${url}#pc-autocommit`, { active: false, insert: true }); const onCommitted = (e) => { if (!e.data || e.data.type !== 'pc-rg-edit-committed' || e.data.mbid !== rgMbid) return; PC_CHANNEL.removeEventListener('message', onCommitted); try { if (bgTab && typeof bgTab.close === 'function') bgTab.close(); } catch (x) {} appendLog('System', `Background master add committed for release-group ${rgMbid}`, 'ok'); }; PC_CHANNEL.addEventListener('message', onCommitted); return; } // Same-tab navigation only when this is the ONLY tab being opened by this // click — the release bucket may already be claiming the current tab. if (sameTabAllowed && !GM_getValue('pc:open-new-tab', true)) location.href = url; else window.open(url, '_blank'); } // Test hook only (#464) — no behavior change; lets verify-464.mjs exercise the // tab-open decision + background-commit channel without driving the full row UI // (which would mean faking a live ✓ match render for no added coverage). if (mbuTestHooks()) window.__pcTest464 = { openReleaseEditTab, openRgEditTab, PC_CHANNEL, applyMasterIcon, discogsMasterState }; // #682: the master slot's clicks // #556 test hook — URL identity + the inject helper, so the cache-staleness and // payload-preservation paths can be driven without a live ✓ match render. // #627 test hook — the amp-api pieces, driven against the live API without a row render if (mbuTestHooks()) window.__pcTest627 = { appleAmp, appleToken, appleAlbumMeta, APPLE_ALBUM_INC, updateRow, applePickByUpc, appleStorefront, appleEach, appleAllStorefronts, APPLE_SHORTLIST, setAppleToken: t => { _appleTok = t; } }; if (mbuTestHooks()) window.__pcTest639 = { ytmCall, ytmAlbumResults, fetchYtmAlbum, ytmAlbumIdOf, ytmAlbumLabel, ytmCreditSections, YTM_ALBUMS_FILTER }; if (mbuTestHooks()) window.__pcTest644 = { amzCall, amzAlbumResults, fetchAmzAlbum }; if (mbuTestHooks()) window.__pcTest664 = { audiomackGet, fetchAudiomack, audiomackRef, scanAudiomack, cacheGet }; if (mbuTestHooks()) window.__pcTest669 = { sdApi, sdKey, sdRef, sdByBarcode, sdById, scanSevendigital, cacheGet, setSdKey: k => { _sdKey = k; } }; if (mbuTestHooks()) window.__pcTest556 = { pcUrlKey, pcSameUrl, pcIsVerifyInterstitial, injectInto, runInjectHelper, cacheGet, cacheSet, mbDataGet }; // Why link confidence withholds this platform's link, for the edit note (#641); null when it doesn't. function pcWithheldWhy(p) { if (barcodeBlocks(p)) return cacheGet(mbid, p)?.barcode ? `barcode ${cacheGet(mbid, p).barcode} differs from the release's` : 'barcode not confirmed'; if (formatBlocks(p)) return "format differs from the release's"; return null; } // force (#641, middle click): add it even when barcode/format confidence would withhold it. function addSingleUrl(platform, background, force) { const cached = cacheGet(mbid, platform); if (!cached?.url) { appendLog('System', `Inject (click): no cached URL for ${platform} — abort`, 'warn'); return; } if (force && (barcodeBlocks(platform) || formatBlocks(platform))) { appendLog('System', `Inject (middle-click): ${platform} is withheld by barcode/format confidence — added anyway, as asked`, 'warn'); } else if (barcodeBlocks(platform)) { const why = cacheGet(mbid, platform)?.barcode ? 'barcode differs from MB' : 'barcode not confirmed'; appendLog('System', `Inject (click): ${platform} ${why} — blocked (barcode-confidence is on)`, 'warn'); flashInfo(rowAnchor(platform), cacheGet(mbid, platform)?.barcode ? 'Different barcode — not added' : 'Barcode not confirmed — not added'); return; } if (formatBlocks(platform)) { appendLog('System', `Inject (click): ${platform} format incompatible with MB (${MB_FORMAT}) — blocked (format-confidence is on)`, 'warn'); flashInfo(rowAnchor(platform), 'Format mismatch — not added'); return; } localStorage.setItem(`pc:pending:${mbid}`, JSON.stringify({ [platform]: cached.url })); pcQueuePastedBarcode(); // #673 pcQueuePickedCatnos(); // #740 // #641: a forced add is recorded for the edit note; any other add clears an old record const forcedWhy = force ? pcWithheldWhy(platform) : null; if (forcedWhy) localStorage.setItem(`pc:forced:${mbid}`, JSON.stringify({ [cached.url]: forcedWhy })); else localStorage.removeItem(`pc:forced:${mbid}`); appendLog('System', `Inject (${force ? 'forced, ' : ''}${background ? 'background' : 'click'}): queued ${platform} URL — opening release editor`, 'ok'); openReleaseEditTab(mbid, { background }); } // Click-to-add on the Discogs master slot — queues the master URL for the // release-group's edit page (different target than the release URLs). // #682: right-click adds it in the background, as the + does since #559 — the // only background route for a master the + won't queue (Discogs release not ✓). function addMasterUrl(masterUrl, background = false) { const mb = mbDataGet(mbid); const rgMbid = mb?.releaseGroupMbid; if (!rgMbid) { appendLog('System', `Master add: no release-group MBID known for this release`, 'error'); return; } localStorage.setItem(`pc:pending:rg:${rgMbid}`, JSON.stringify({ 'discogs-master': masterUrl })); appendLog('System', `Inject (master${background ? ', background' : ''}): queued ${masterUrl} for release-group ${rgMbid}`, 'ok'); // #559: same opener as the + button's release-group bucket, so there is one // place that knows how to reach the release-group editor. It is always the // only tab being opened, so a foreground add may take this one. openRgEditTab(rgMbid, { background, sameTabAllowed: true }); } // force (#641, middle click on +): barcode/format confidence doesn't withhold anything. async function runInjectBtn(e, background, force) { const triggerBtn = e.currentTarget; // Bucket 1: URLs going onto the release. const pendingRelease = {}; let barcodeBlocked = 0; let formatBlocked = 0; let alreadyInMb = 0; const forcedUrls = {}; // #641: { url: reason } of the withheld links a middle click adds anyway // #556: the live rels parsed from THIS page load are the authority on what the // release already carries — the cached `source` is only a hint, and a stale one // after a previous add. Queueing a link MusicBrainz already has is a no-op in // the editor that leaves the tab sitting open with nothing to submit. const liveExisting = (mbDataGet(mbid) || {}).existing || {}; for (const p of PROVIDER_ORDER) { if (!providerEnabled(p)) continue; // #255 disabled in Settings — never queue its (possibly stale) cache const cached = cacheGet(mbid, p); if (!cached?.url) continue; if (cached.source === 'MB rels') continue; if (pcSameUrl(liveExisting[p], cached.url)) { alreadyInMb++; appendLog('System', `Inject: ${p} is already on the release (MB has ${liveExisting[p]}) — skipped`, 'warn'); continue; } const icoText = document.getElementById(`ico-${p}`)?.textContent?.trim(); if (icoText !== '✓') continue; if (force && (barcodeBlocks(p) || formatBlocks(p))) { forcedUrls[cached.url] = pcWithheldWhy(p); appendLog('System', `Inject (middle-click): ${p} is withheld by barcode/format confidence — added anyway, as asked`, 'warn'); } else if (barcodeBlocks(p)) { barcodeBlocked++; appendLog('System', `Inject: ${p} ${cached.barcode ? 'barcode differs from MB' : 'barcode not confirmed'} — skipped (barcode-confidence on)`, 'warn'); continue; } else if (formatBlocks(p)) { formatBlocked++; appendLog('System', `Inject: ${p} format incompatible with MB (${MB_FORMAT}) — skipped (format-confidence on)`, 'warn'); continue; } pendingRelease[p] = cached.url; } // Bucket 2: Discogs master URL → goes onto the release-group, not the // release. Skip if MB already has it: check `existing.discogsMaster` // which parseMbFromDom / parseMbData populated from the release page // DOM (any /discogs.com/master/<id> anchor visible on the page). No // extra /ws/2 round-trip. const pendingRG = {}; const discogsCache = cacheGet(mbid, 'discogs'); const masterUrl = discogsCache?.masterUrl; const mbCached = mbDataGet(mbid); const rgMbid = mbCached?.releaseGroupMbid; const existingMaster = mbCached?.existing?.discogsMaster; // #255 disabled provider → skip. #256 only queue the master when the Discogs RELEASE is a // confirmed match (✓): if text/Brave search landed on an unrelated album, its master isn't // this release-group's master. Barcode/format withholding deliberately does NOT apply here // (#416): a master is the whole release GROUP — it spans every edition's format and // barcode, so a different-pressing Discogs hit still shares this album's master. const discogsConfirmed = providerEnabled('discogs') && document.getElementById('ico-discogs')?.textContent?.trim() === '✓'; if (!discogsConfirmed) { if (masterUrl) appendLog('System', `Inject: Discogs release isn't a confirmed match — not queueing master ${masterUrl}`, 'warn'); } else if (masterUrl && rgMbid && !existingMaster) { pendingRG['discogs-master'] = masterUrl; appendLog('System', `Inject: queueing Discogs master ${masterUrl} for release-group ${rgMbid}`); } else if (masterUrl && existingMaster) { appendLog('System', `Inject: Discogs master already in MB rels (${existingMaster}) — skipping`); } const releaseCount = Object.keys(pendingRelease).length; const rgCount = Object.keys(pendingRG).length; if (releaseCount + rgCount === 0) { // Explain WHY nothing is queued instead of the misleading "all OK URLs already in MB" (#117): // • unmatched — a link WAS found but isn't a confirmed match (✓); shown as ~ / ? because its // track count / format differs from this release, so it's NOT added (and is NOT in MB) // • inMb — a confirmed match exists but is already a relationship on the release let unmatched = 0, inMb = 0; for (const p of PROVIDER_ORDER) { if (!providerEnabled(p)) continue; // #255 disabled providers don't factor into "why nothing queued" const c = cacheGet(mbid, p); if (!c?.url) continue; // #556: count a link the page already carries as "in MB" even when the // cached source still says 'search' — otherwise the explanation read // "no new links found" for a release whose links were all added a // moment ago, which is the opposite of what happened. if (c.source === 'MB rels' || pcSameUrl(liveExisting[p], c.url)) inMb++; else unmatched++; } const msg = unmatched > 0 ? `Inject: nothing to add — ${unmatched} found link(s) aren't a confirmed match (✓) for this release (track count / format differs)${inMb ? `; ${inMb} already in MB` : ''}` : inMb > 0 ? `Inject: nothing to add — all confirmed links are already in MB` : `Inject: nothing to add — no new links found`; const blockedNote = [barcodeBlocked > 0 ? `${barcodeBlocked} blocked by barcode mismatch` : '', formatBlocked > 0 ? `${formatBlocked} blocked by format mismatch` : '', alreadyInMb > 0 ? `${alreadyInMb} already on the release` : ''].filter(Boolean).join('; '); appendLog('System', blockedNote ? msg + `; ${blockedNote}` : msg, 'warn'); flashInfo(triggerBtn, barcodeBlocked > 0 ? `${barcodeBlocked} blocked — different barcode` : formatBlocked > 0 ? `${formatBlocked} blocked — format mismatch` : unmatched > 0 ? "Found links don't match" : inMb > 0 ? 'Already in MB' : 'Nothing to add'); return; } if (releaseCount > 0) { localStorage.setItem(`pc:pending:${mbid}`, JSON.stringify(pendingRelease)); pcQueuePastedBarcode(); // #673 pcQueuePickedCatnos(); // #740 if (Object.keys(forcedUrls).length) localStorage.setItem(`pc:forced:${mbid}`, JSON.stringify(forcedUrls)); else localStorage.removeItem(`pc:forced:${mbid}`); appendLog('System', `Inject (${background ? 'background' : 'click'}): queued ${releaseCount} release URL(s) — opening release editor`, 'ok'); openReleaseEditTab(mbid, { background }); } if (rgCount > 0 && rgMbid) { localStorage.setItem(`pc:pending:rg:${rgMbid}`, JSON.stringify(pendingRG)); // #559: the master now takes the background route too, instead of the // "background add doesn't cover the release-group master URL" tab it used // to leave open and focused. appendLog('System', `Inject (${background ? 'background' : 'click'}): queued ${rgCount} release-group URL(s) — opening release-group editor`, 'ok'); openRgEditTab(rgMbid, { background, sameTabAllowed: releaseCount === 0 }); } } document.getElementById('mb-inject-btn').addEventListener('click', (e) => runInjectBtn(e, e.altKey && pcBgKey(e), e.altKey)); // #653/#641: Alt+click = middle click; Ctrl+Alt: in the background document.getElementById('mb-inject-btn').addEventListener('contextmenu', (e) => { e.preventDefault(); runInjectBtn(e, true); }); document.getElementById('mb-inject-btn').addEventListener('mousedown', (e) => { if (e.button === 1) e.preventDefault(); }); document.getElementById('mb-inject-btn').addEventListener('auxclick', (e) => { if (e.button !== 1) return; e.preventDefault(); runInjectBtn(e, pcBgKey(e), true); }); // #641: Ctrl+middle: in the background // "↗" — open found platform pages that are NOT already in MB (non-circled links, // source != 'MB rels') in their own new tabs. Circled = already an MB relationship. document.getElementById('mb-openall-btn').addEventListener('click', (e) => { const urls = []; for (const p of PROVIDER_ORDER) { if (!providerEnabled(p)) continue; // #255 disabled in Settings — don't open its (possibly stale) cache const cached = cacheGet(mbid, p); if (!cached?.url) continue; if (cached.source === 'MB rels') continue; // circled — already in MB, skip // Only confirmed matches (✓). Skip '~' (track-count mismatch) and '?' // (found-but-unverifiable, e.g. Beatport) — same bar as the + inject button. if (document.getElementById(`ico-${p}`)?.textContent?.trim() !== '✓') continue; if (barcodeBlocks(p)) continue; // (#182) barcode-confidence on + mismatch if (formatBlocks(p)) continue; // (#182) format-confidence on + incompatible urls.push(cached.url); } // Discogs master: only if found and not already on the release-group (non-circled) const masterUrl = cacheGet(mbid, 'discogs')?.masterUrl; const existingMaster = mbDataGet(mbid)?.existing?.discogsMaster; // #255 skip if disabled; #256 only when the Discogs release is a confirmed match const discogsConfirmed = providerEnabled('discogs') && document.getElementById('ico-discogs')?.textContent?.trim() === '✓' && !barcodeBlocks('discogs') && !formatBlocks('discogs'); if (discogsConfirmed && masterUrl && !existingMaster) urls.push(masterUrl); const uniq = [...new Set(urls)]; if (!uniq.length) { appendLog('System', 'Open all: nothing new — all found links are already in MB', 'warn'); flashInfo(e.currentTarget, 'Nothing new'); return; } appendLog('System', `Open all: opening ${uniq.length} non-circled link(s) in new tabs`, 'ok'); uniq.forEach(u => window.open(u, '_blank', 'noopener')); flashInfo(e.currentTarget, `Opened ${uniq.length}`); }); // ─── Mission Control adapter (#680) ───────────────────────────────────────── // Mission Control asks over document events, with JSON-string details (see // userscripts/mission_control/DEVELOP.md). PC already scans on load, so a probe // waits for the scan running (or the last one) and reports what it found — it // never starts a second scan. Apply hands the ticked links to Falcon. function pcScan() { PC_SCAN.last = runScans(); return PC_SCAN.last; } const PC_MC_VERSION = (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) || '?'; // One platform's state, as MC shows it: // linked — the release already has the link // new — a confirmed match (the ✓ that + would add) // withheld — found, but barcode/format confidence holds it back (why says which) // unsure — found, but not a confident match // none — nothing found // A found link also carries its track count (tracks, against the release's mbTracks) and format, // and why gives every reason against it, the strongest first: a different track count, then a // format that isn't the release's, then barcode or format confidence (#709). mismatch lists the // reasons that say it is another release, which MC marks and leaves out of "take all in". // What says a platform's match is another release: a different track count, another medium. function pcMcMismatch(p, c) { const mbTracks = parseInt(mbDataGet(mbid)?.mbTracks, 10) || null, tracks = c.tracks != null ? parseInt(c.tracks, 10) : null; const mismatch = [], fmtOff = formatMismatch(p, c.format); if (tracks != null && mbTracks && tracks !== mbTracks) mismatch.push(`${tracks} tracks, the release has ${mbTracks}`); if (fmtOff) mismatch.push(`${c.format || 'Digital'}, the release is ${MB_FORMAT}`); return { mismatch, fmtOff, tracks, mbTracks }; } const pcIsLinked = p => !!(mbDataGet(mbid)?.existing?.[p] || cacheGet(mbid, p)?.source === 'MB rels'); // #727: what a Mission Control consolidation brings to this release (mc:probe's cons): its barcode, // catalog numbers and the platforms' links it took in. Kept from the last probe, for Apply. let _pcMcCons = null; const pcConsUrl = p => (_pcMcCons?.urls || []).find(u => u.key === p)?.url || null; // A platform's finding, with the consolidation's word on it: the link it took in is new, though PC holds it // back or found none; a link of PC's own that isn't the consolidation's stays as PC sees it. function pcMcFinding(p) { const f = pcMcFindingOwn(p), cu = pcConsUrl(p); if (!cu || f.state === 'linked') return f; if (f.state === 'none') return { ...f, url: cu, source: 'consolidation', state: 'new', why: 'taken in the consolidation', cons: true }; if (!pcSameUrl(f.url, cu) && f.url !== cu) return f; if (f.state === 'new') return { ...f, cons: true }; return { ...f, state: 'new', why: ['taken in the consolidation', f.why].filter(Boolean).join(' · '), cons: true }; } function pcMcFindingOwn(p) { const c = cacheGet(mbid, p) || {}; const existing = mbDataGet(mbid)?.existing?.[p] || null; const base = { key: p, name: PROVIDER_NAME[p], url: c.url || existing || null, source: c.source || null, barcode: c.url && c.barcode || null }; if (existing || c.source === 'MB rels') return { ...base, state: 'linked' }; if (!c.url) return { ...base, state: 'none' }; const { mismatch, fmtOff, tracks, mbTracks } = pcMcMismatch(p, c); const bcHeld = barcodeBlocks(p), fmtHeld = formatBlocks(p); const why = mismatch.concat(bcHeld ? [c.barcode ? `barcode ${c.barcode} differs from the release's` : 'barcode not confirmed'] : [], fmtHeld && !fmtOff ? ['format not confirmed'] : []).join(' · ') || undefined; const facts = { ...base, tracks, mbTracks, format: c.format || null, mismatch: mismatch.length ? mismatch : undefined, why }; if (bcHeld || fmtHeld) return { ...facts, state: 'withheld' }; const sure = document.getElementById(`ico-${p}`)?.textContent?.trim() === '✓'; return { ...facts, state: sure ? 'new' : 'unsure' }; } // #709: the barcodes MC can add, when the release has none: one finding each (key `barcode:<code>`, // kind 'barcode'), from a pasted barcode and the ones the platforms report. Taken in (new) only // when it is sure: the barcode pasted by hand, or one a link the release already has reports, whose // track count and medium are the release's. The rest are unsure (another medium, more than one // such barcode) or withheld (a wrong check digit). At most one is new. function pcMcBarcodeFindings() { if (MB_OWN_BARCODE) return []; const pasted = pcPastedBarcode(); const found = pcFoundBarcodes().filter(e => !pasted || normBarcode(e.code) !== normBarcode(pasted)); const out = pasted ? [{ key: `barcode:${pasted}`, kind: 'barcode', name: 'Barcode', code: pasted, state: 'new', why: 'pasted by hand', platforms: [] }] : []; for (const e of found) { const sure = e.platforms.filter(p => pcIsLinked(p) && !pcMcMismatch(p, cacheGet(mbid, p) || {}).mismatch.length); const from = 'from ' + e.platforms.map(p => PROVIDER_NAME[p] + (pcIsLinked(p) ? ' (linked)' : '')).join(', '); const other = e.format === 'other' ? `${e.kinds.join(' and ')}, the release is ${MB_FORMAT}` : null; const f = { key: `barcode:${e.code}`, kind: 'barcode', name: 'Barcode', code: e.code, platforms: e.platforms }; if (!pcGtinValid(e.code)) out.push({ ...f, state: 'withheld', why: [from, 'wrong check digit'].join(' · ') }); else out.push({ ...f, state: !pasted && sure.length && !other ? 'new' : 'unsure', why: [from, other].filter(Boolean).join(' · '), mismatch: other ? [other] : undefined }); } // #727: a consolidation's barcode is taken in (a pasted one still wins); the others are unsure beside it const cb = String(_pcMcCons?.barcode || '').replace(/\D/g, ''); if (cb && pcGtinValid(cb) && !pasted) { const why = 'consolidated by Mission Control from ' + ((_pcMcCons.from || []).join(', ') || 'the platforms'); let f = out.find(x => normBarcode(x.code) === normBarcode(cb)); if (f) { f.state = 'new'; f.why = [why, f.why].filter(Boolean).join(' · '); f.mismatch = undefined; } else out.unshift(f = { key: `barcode:${cb}`, kind: 'barcode', name: 'Barcode', code: cb, state: 'new', why, platforms: [] }); f.cons = true; out.forEach(x => { if (x !== f && x.state === 'new') { x.state = 'unsure'; x.why += ' · not the consolidation\'s'; } }); return out; } const sure = out.filter(f => f.state === 'new'); if (sure.length > 1) sure.forEach(f => { f.state = 'unsure'; f.why += ' · the linked platforms report more than one barcode'; }); return out; } // #727: the release's labels as its page lists them, each with its catalog number function pcPageLabels() { return [...document.querySelectorAll('#sidebar h2.labels + ul.links > li')].map(li => { const a = li.querySelector('a[href*="/label/"]'); return { mbid: (a?.getAttribute('href').match(/\/label\/([0-9a-f-]{36})/i) || [])[1] || null, name: a ? a.textContent.trim() : '', catno: (li.querySelector('.catalog-number')?.textContent || '').trim() }; }); } // a label's name compared loosely: "Artoffact" is "Artoffact Records" const pcLabelNorm = n => String(n || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '') .replace(/\b(records?|recordings?|music|label|ltd|inc|llc|gmbh|entertainment)\b/g, '').replace(/[^\p{L}\p{N}]+/gu, ''); const pcCatnoNorm = c => String(c || '').toLowerCase().replace(/[\s.\-_/]+/g, ''); // #727: a consolidation's label the release hasn't, as a MusicBrainz label: its MBID when the consolidation // knows it, else the label's platform page MusicBrainz has on one label, else the one label whose name or alias is // the name (exactly, ignoring case), when the search shows every match. { mbid, name, via } or null. async function pcResolveLabel(l) { if (l.mbid) return { mbid: l.mbid, name: l.name, via: 'mbid' }; if (l.url) { try { const hits = ((await pcLinkedTo([l.url])).get(pcUrlKey(l.url)) || []).filter(w => w.type === 'label'); if (hits.length === 1) return { mbid: hits[0].mbid, name: hits[0].name, via: 'link' }; appendLog('System', `Mission Control: ${hits.length} MusicBrainz label(s) have ${l.url}`); } catch (x) { appendLog('System', `Mission Control: couldn't look up the label's link ${l.url} (${x.message})`, 'warn'); } } if (!l.name) return null; try { const q = `label:"${l.name.replace(/["\\]/g, ' ')}" OR alias:"${l.name.replace(/["\\]/g, ' ')}"`; // asked right after the scan's burst, it can meet MusicBrainz's throttling: a few more tries, spaced out let r; for (let a = 0; a < 4; a++) { if (a) await new Promise(res => setTimeout(res, 2000 * a)); r = await gmGet(`${MB_ORIGIN}/ws/2/label?query=${encodeURIComponent(q)}&limit=50&fmt=json`, { headers: { Accept: 'application/json' }, scan: null }); if (r.status !== 503) break; } if (!r.ok) throw new Error(`MusicBrainz answered ${r.status || r.error || '?'}`); const j = JSON.parse(r.responseText), all = j.labels || []; const low = x => String(x || '').toLowerCase().trim(), want = low(l.name); const exact = all.filter(x => low(x.name) === want || (x.aliases || []).some(al => low(al.name) === want)); if (exact.length === 1 && (j.count || 0) <= all.length) return { mbid: exact[0].id, name: exact[0].name, via: 'name' }; appendLog('System', `Mission Control: ${exact.length} MusicBrainz label(s) named "${l.name}" among ${all.length} of ${j.count} found`); } catch (x) { appendLog('System', `Mission Control: couldn't search MusicBrainz for the label "${l.name}" (${x.message})`, 'warn'); } return null; } // #727: the labels a consolidation brings, as MC findings, from the last probe. A label the release has (by MBID, else // by name, loosely) gets the catalog number when it has none (kind 'catno', key catno:<label mbid>:<catno>): linked // when it has this one, left out (logged) when it has another. A label the release hasn't is added (kind 'label', // key label:<mbid>:<catno>) once it is a MusicBrainz label: new when found by its MBID or link, unsure by its name; // one MusicBrainz hasn't is left out. Apply only fills an empty catalog number and adds a label. let _pcMcLab = []; // #740: the catalog numbers the platforms give, as MC findings (kind 'catno', key catno:<label mbid>:<catno>) with // `barcodes`, the lanes they are in (normalised, '' for none). New when sure (pcFoundCatnos), unsure otherwise; one // a consolidation brings too is left to that one. Kept from the last probe, for Apply. let _pcMcCat = []; function pcMcCatnoFindings(cons) { const taken = new Set((cons || []).map(f => f.key)); return (_pcMcCat = pcFoundCatnos().map(e => ({ key: `catno:${e.label.mbid}:${e.catno}`, kind: 'catno', name: 'Cat. no.', code: e.catno, label: e.label, barcodes: e.barcodes, platforms: e.platforms, state: e.sure ? 'new' : 'unsure', why: `from ${e.platforms.map(p => PROVIDER_NAME[p] + (pcIsLinked(p) ? ' (linked)' : '')).join(', ')}${e.off && !e.sure ? ` · ${e.off.join(' · ')}` : e.format === 'other' ? ` · ${e.kinds.join(' and ')}, the release is ${MB_FORMAT}` : ''}`, mismatch: e.off && !e.sure ? e.off : undefined })) .filter(f => !taken.has(f.key))); } async function pcMcLabelFindings() { const want = (_pcMcCons?.labels || []).filter(l => l && (l.name || l.mbid)); if (!want.length) return (_pcMcLab = []); const rows = pcPageLabels().filter(r => r.mbid), out = []; const from = 'consolidated by Mission Control from ' + ((_pcMcCons.from || []).join(', ') || 'the platforms'); const catnoOn = (r, l) => { const f = { key: `catno:${r.mbid}:${l.catno}`, kind: 'catno', name: 'Cat. no.', code: l.catno, label: { mbid: r.mbid, name: r.name }, cons: true }; if (r.catno && pcCatnoNorm(r.catno) === pcCatnoNorm(l.catno)) return out.push({ ...f, state: 'linked' }); if (r.catno) return appendLog('System', `Mission Control: ${r.name} has catalog number ${r.catno} here, the consolidation ${l.catno} — left as it is`, 'warn'); out.push({ ...f, state: 'new', why: [from, l.name && l.name !== r.name ? `as "${l.name}"` : ''].filter(Boolean).join(' · ') }); }; for (const l of want) { let r = rows.find(x => l.mbid && x.mbid === l.mbid) || rows.find(x => pcLabelNorm(l.name) && pcLabelNorm(x.name) === pcLabelNorm(l.name)); if (r) { if (l.catno) catnoOn(r, l); continue; } const m = await pcResolveLabel(l); if (!m) { appendLog('System', `Mission Control: the label "${l.name}" isn't one MusicBrainz label — left out`, 'warn'); continue; } r = rows.find(x => x.mbid === m.mbid); if (r) { if (l.catno) catnoOn(r, l); continue; } if (out.some(f => f.kind === 'label' && f.label.mbid === m.mbid)) continue; const via = m.via === 'link' ? `found by its link ${l.url}` : m.via === 'name' ? 'found by its name only' : ''; out.push({ key: `label:${m.mbid}:${l.catno || ''}`, kind: 'label', name: 'Label', code: l.catno || '', label: { mbid: m.mbid, name: m.name || l.name }, cons: true, state: m.via === 'name' ? 'unsure' : 'new', why: [from, via, l.name && m.name && l.name !== m.name ? `as "${l.name}"` : ''].filter(Boolean).join(' · ') }); } return (_pcMcLab = out); } // The Artists & labels links (#671) as MC findings, one per link: key `ent:<type>:<mbid>:<url>`, // with `entity` { type, mbid, name } and `icon` the platform. MusicBrainz is asked which links // it already has through the same cached lookup as the count; a link on someone else is // withheld. Kept from the last probe so Apply needn't ask again. let _pcMcEnt = []; async function pcMcEntityFindings() { const mbRec = mbDataGet(mbid) || {}; const { rows } = pcLinkRows(pcMbCredits(document, mbRec.mbTracks || 0), pcConfirmedCredits().byProvider); const urls = rows.flatMap(r => Object.values(r.cells).flat().map(c => c.url)); let linked = new Map(), asked = false; if (urls.length) { try { linked = await pcLinkedCached(urls); asked = true; } catch (x) { appendLog('System', `Mission Control: couldn't ask MusicBrainz which artist and label links it has (${x.message})`, 'warn'); } } const out = []; for (const r of rows) for (const [p, cell] of Object.entries(r.cells)) for (const c of pcMarkCell(r, cell, linked)) { const who = (c.who || []).map(w => `${w.type} ${w.name || w.mbid}`).join(', '); const state = c.state === 'linked' ? 'linked' : c.state === 'other' ? 'withheld' : !asked ? 'unsure' : c.state === 'unsure' ? 'unsure' : 'new'; const why = c.state === 'other' ? `already linked to ${who}` : !asked ? 'MusicBrainz couldn\'t be asked whether it has this link' : pcCellWhy(c); out.push({ key: `ent:${r.type}:${r.mbid}:${c.url}`, name: `${r.name} · ${PROVIDER_NAME[p]}`, icon: p, url: c.url, state, why: why || undefined, entity: { type: r.type, mbid: r.mbid, name: r.name } }); } _pcMcEnt = out; appendLog('System', `Mission Control: ${out.length} artist/label link(s) from ${rows.length} artist(s)/label(s)`); return out; } // The Discogs master the found Discogs release belongs to, for the release group (#680): what PC's // master slot would add. Its trust follows the Discogs release's; a different master already on the // release group is withheld. Null when there is neither a found master nor one on the release group. function pcMcMasterFinding() { const rec = mbDataGet(mbid) || {}, have = rec.existing?.discogsMaster || null, url = cacheGet(mbid, 'discogs')?.masterUrl || null; if (!url && !have) return null; const base = { key: 'discogsmaster', name: 'Discogs master', icon: 'discogs', url: url || have, entity: { type: 'release_group', mbid: rec.releaseGroupMbid || null, name: rec.album || 'release group' } }; if (have && (!url || pcSameUrl(have, url))) return { ...base, url: have, state: 'linked' }; if (have) return { ...base, state: 'withheld', why: `the release group has another Discogs master: ${have}` }; if (!rec.releaseGroupMbid) return { ...base, state: 'unsure', why: 'the release group is not known' }; const rel = pcMcFinding('discogs').state; return { ...base, state: rel === 'new' || rel === 'linked' ? 'new' : 'unsure', why: rel === 'new' || rel === 'linked' ? 'for the release group' : 'from a Discogs release that is not a sure match' }; } // #745: First Contact reads a Beatport release headless (Mission Control's consolidation), and Beatport's page refuses it // (Cloudflare). It asks here instead: the release and its tracks through the API, with the login. { req, release, tracks } // or { req, error }. document.addEventListener('pc:beatport-release', async e => { let d = {}; try { d = JSON.parse(e.detail) || {}; } catch (x) { return; } const reply = o => document.dispatchEvent(new CustomEvent('pc:beatport-release-result', { detail: JSON.stringify(Object.assign({ req: d.req }, o)) })); if (!/^\d+$/.test(String(d.id || ''))) { reply({ error: 'no release id' }); return; } if (!(await beatportToken())) { appendLog('Beatport', `First Contact asked for release ${d.id}: not logged in`, 'warn'); reply({ error: 'Platform Check isn\'t logged in to Beatport' }); return; } const release = await beatportApi(`/catalog/releases/${d.id}/`); if (!release) { reply({ error: `Beatport's API has no release ${d.id}` }); return; } const tracks = []; for (let page = 1; page <= 10; page++) { const j = await beatportApi(`/catalog/releases/${d.id}/tracks/?per_page=100&page=${page}`); if (!j) break; tracks.push(...(j.results || [])); if (!j.next) break; } appendLog('Beatport', `First Contact asked for release ${d.id}: "${release.name}", ${tracks.length} track(s)`, 'ok'); reply({ release, tracks }); }); function pcMcSend(type, detail) { document.dispatchEvent(new CustomEvent(type, { detail: JSON.stringify(detail) })); } function pcMcHello() { pcMcSend('mc:provider', { id: 'pc', name: 'Platform Check', version: PC_MC_VERSION, release: mbid, capabilities: ['probe', 'apply'] }); } document.addEventListener('mc:discover', () => { appendLog('System', 'Mission Control asked — answering as provider pc'); pcMcHello(); }); document.addEventListener('mc:probe', async e => { let d = {}; try { d = JSON.parse(e.detail) || {}; } catch (x) { appendLog('System', `Mission Control probe with unreadable detail: ${x.message}`, 'warn'); return; } if (PC_SEED !== !!d.seed) { appendLog('System', `Mission Control probe ${d.seed ? 'with a seed on a release page' : 'for a release on the seed page'} — ignored`, 'warn'); return; } if (!PC_SEED && d.release && d.release !== mbid) { appendLog('System', `Mission Control probe for ${d.release}, not this release (${mbid}) — ignored`, 'warn'); return; } if (d.only && !d.only.includes('pc')) return; if (PC_SEED) { await pcMcSeedProbe(d); return; } _pcMcCons = d.cons && typeof d.cons === 'object' ? d.cons : null; if (_pcMcCons) appendLog('System', `Mission Control brings a consolidation: barcode ${_pcMcCons.barcode || '—'} · ${(_pcMcCons.labels || []).length} catalog number(s) · ${(_pcMcCons.urls || []).length} link(s)`); appendLog('System', `Mission Control probe ${d.run || ''}: ${PC_SCAN.busy ? 'waiting for the scan running' : 'reporting the last scan'}`); pcMcSend('mc:progress', { id: 'pc', run: d.run, state: 'busy', note: PC_SCAN.busy ? 'scanning platforms' : '' }); // a rescan (pasted barcode, ↻) replaces the scan we waited for: wait for that one instead try { let p; do { p = PC_SCAN.last || pcScan(); await p; } while (p !== PC_SCAN.last); } catch (x) { appendLog('System', `scan failed for Mission Control: ${x.message}`, 'error'); } const labs = await pcMcLabelFindings(); const findings = pcMcBarcodeFindings().concat(labs, pcMcCatnoFindings(labs), PROVIDER_ORDER.filter(providerEnabled).map(pcMcFinding), providerEnabled('discogs') ? [pcMcMasterFinding()].filter(Boolean) : [], await pcMcEntityFindings()); const tally = findings.reduce((t, f) => (t[f.state] = (t[f.state] || 0) + 1, t), {}); appendLog('System', `Mission Control probe ${d.run || ''} answered: ${JSON.stringify(tally)}`, 'ok'); // the release's barcode beside each platform's: MC shows them in a column, one colour per barcode pcMcSend('mc:findings', { id: 'pc', run: d.run, release: mbid, findings, barcode: MB_BARCODE || null }); }); // #702: a seed probe. The seed stands for the release PC reads on a release page; the scan runs on // it from scratch (each probe), and the answer is the album links only: no artist or label links, // no barcode to add, no master, nothing linked. A link's barcode, track count and format go with it, // for MC's barcode lanes. async function pcMcSeedProbe(d) { const s = d.seed || {}; const tracks = Number(s.tracks) || 0; PC_SEED_DATA = { artist: s.artist || '', album: s.title || '', mbTracks: tracks, releaseGroupMbid: null, isVariousArtists: /^various(\s+artists?)?$/i.test(s.artist || ''), existing: {}, format: s.format || 'Digital Media', year: s.year || null, releaseLabel: s.label || null, barcode: s.barcode || null, }; appendLog('System', `Mission Control seed probe ${d.run || ''}: "${PC_SEED_DATA.album}" by ${PC_SEED_DATA.artist} · ${tracks} track(s) · ${PC_SEED_DATA.format} · ${PC_SEED_DATA.year || '?'} · barcode ${PC_SEED_DATA.barcode || '—'}`); pcMcSend('mc:progress', { id: 'pc', run: d.run, state: 'busy', note: 'scanning platforms' }); if (PC_SCAN.busy) pcCancelScan(); cacheClear(mbid); resetRows(); try { let p; do { p = pcScan(); await p; } while (p !== PC_SCAN.last); } catch (x) { appendLog('System', `seed scan failed: ${x.message}`, 'error'); } // each link with the type(s) PC's own + would give it: MC adds them as they are when First Contact can't read the platform const findings = PROVIDER_ORDER.filter(providerEnabled).map(pcMcFinding).map(f => f.url ? { ...f, links: pcMcReleaseLinkTypes(f.url) } : f); const tally = findings.reduce((t, f) => (t[f.state] = (t[f.state] || 0) + 1, t), {}); appendLog('System', `Mission Control seed probe ${d.run || ''} answered: ${JSON.stringify(tally)}`, 'ok'); findings.filter(f => f.url).forEach(f => appendLog('System', ` ${f.key}: ${f.url} · barcode ${f.barcode || '—'} · ${f.tracks ?? '?'} track(s) · ${f.format || '?'} · ${f.state}${f.why ? ' (' + f.why + ')' : ''} · type ${f.links.map(l => l.linkTypeId || 'MusicBrainz picks').join('+')}`)); pcMcSend('mc:findings', { id: 'pc', run: d.run, release: mbid, seed: true, findings, barcode: PC_SEED_DATA.barcode }); } // A release link for Falcon with the type PC's own + would pick (pcTypeForce): where MusicBrainz // offers several and picks none (Bandcamp, Apple Music, Qobuz, ...), Falcon has no type to give // the row and drops the url as ambiguous. A Bandcamp album with a digital release also gets 74 // 'purchase for download' beside 85 'stream for free', like injectInto (#423). function pcMcReleaseLinkTypes(url) { const force = pcTypeForce().find(t => t.test(url)); const out = [{ url, linkTypeId: force ? Number(force.ids[0]) : null }]; if (pcExtraType(url, force?.ids[0], mbid)) out.push({ url, linkTypeId: 74 }); return out; } // '74' (purchase for download) when a link's first type is streaming and the album is also sold: // a Bandcamp album with a digital release (#423), an Apple Music album the iTunes Store sells. // A hoisted declaration: injectInto's early path calls it. function pcExtraType(url, firstId, relMbid) { if (!relMbid) return null; if (firstId === '85' && /[a-z0-9-]+\.bandcamp\.com\/album\//i.test(url)) return /\b(digital|file)\b/i.test(cacheGet(relMbid, 'bandcamp')?.format || '') ? '74' : null; if (firstId === '980' && /music\.apple\.com\/.*\/album\//i.test(url)) return cacheGet(relMbid, 'apple')?.forSale ? '74' : null; return null; } // Apply: the ticked platforms' links go to Falcon as one release item, with PC's // own edit note; Falcon runs it. A withheld link ticked by hand is noted as forced, // like a middle-click + (#641). Answers mc:applied — ok:false when no Falcon heard it. document.addEventListener('mc:apply', e => { let d = {}; try { d = JSON.parse(e.detail) || {}; } catch (x) { appendLog('System', `Mission Control apply with unreadable detail: ${x.message}`, 'warn'); return; } if (d.id !== 'pc' || (d.release && d.release !== mbid)) return; const keys = d.keys || []; const picked = keys.filter(k => !k.startsWith('ent:') && !k.startsWith('barcode:') && !k.startsWith('catno:') && !k.startsWith('label:')).map(pcMcFinding).filter(f => f.url && f.state !== 'linked' && f.state !== 'none'); // #709: the barcode taken in, if any (only while the release has none); Falcon types it into the release editor const bcFinding = MB_OWN_BARCODE ? null : pcMcBarcodeFindings().find(f => keys.includes(f.key)) || null; const barcode = bcFinding ? String(bcFinding.code).replace(/\D/g, '') : null; // #727: the catalog numbers and labels taken in, from the last probe; Falcon fills an empty catalog number of a // label the release has, and adds a label it hasn't const labels = _pcMcLab.concat(_pcMcCat).filter(f => keys.includes(f.key) && f.state !== 'linked').map(f => ({ mbid: f.label.mbid, name: f.label.name, catno: f.code, ...(f.kind === 'label' ? { add: true } : {}) })) .filter((l, i, a) => l.add || a.findIndex(x => !x.add && x.mbid === l.mbid) === i); // one catalog number per label // artist and label links: from the last probe, grouped per entity (a link on someone else only when ticked by hand) const ents = new Map(); for (const f of _pcMcEnt) { if (!keys.includes(f.key) || f.state === 'linked') continue; const k = `${f.entity.type}:${f.entity.mbid}`; if (!ents.has(k)) ents.set(k, { type: f.entity.type, mbid: f.entity.mbid, name: f.entity.name, urls: [] }); ents.get(k).urls.push(f.url); } // the Discogs master goes on the release group, typed 90 'discogs' const master = keys.includes('discogsmaster') ? pcMcMasterFinding() : null; if (master && master.state !== 'linked' && master.entity.mbid) ents.set('release_group', { type: 'release_group', mbid: master.entity.mbid, name: master.entity.name, urls: [master.url] }); const reply = o => pcMcSend('mc:applied', Object.assign({ id: 'pc', run: d.run, release: mbid }, o)); const entN = [...ents.values()].reduce((n, r) => n + r.urls.length, 0); if (!picked.length && !entN && !barcode && !labels.length) { appendLog('System', 'Mission Control apply: nothing left to add', 'warn'); reply({ ok: true, sent: 0, note: 'nothing left to add' }); return; } const urls = picked.map(f => f.url); const forced = Object.fromEntries(picked.filter(f => f.state === 'withheld').map(f => [f.url, f.why])); const album = mbDataGet(mbid)?.album || mbid; const relItem = !!(urls.length || barcode || labels.length); const items = (relItem ? [{ entityType: 'release', mbid, name: album, urls: urls.flatMap(pcMcReleaseLinkTypes), ...(barcode ? { barcode } : {}), ...(labels.length ? { labels } : {}) }] : []) .concat([...ents.values()].map(r => ({ entityType: r.type, mbid: r.mbid, name: r.name, urls: r.urls.map(url => ({ url, linkTypeId: r.type === 'release_group' ? 90 : pcLinkTypeFor(r.type, url) })) }))); // one edit note for the batch: the release's links note, or the artist/label one when there are none const consFrom = _pcMcCons && (labels.length || bcFinding?.cons || picked.some(f => f.cons)) ? _pcMcCons.from || [] : null; const note = (relItem ? pcEditNote(urls, forced, barcode, bcFinding && (bcFinding.cons ? 'from the consolidation' : bcFinding.why), { catnos: labels.filter(l => l.catno && !l.add).map(l => ({ catno: l.catno, label: l.name })), added: labels.filter(l => l.add), consFrom }) + (entN ? `\n\n${pcLinksNote()}` : '') : pcLinksNote()) + '\n' + 'Via Mission Control' + (d.mc ? ' v' + d.mc : '') + ': ' + location.origin + '/release/' + mbid; // headless: Falcon keeps its panel shut and reports the batch as falcon:status, tagged, for MC's card const json = JSON.stringify({ name: `${album} — platform links`, note, items, headless: !d.dry, tag: `mc:pc:${d.run}` }); const ok = pcSendToFalconHere(json, !d.dry); const n = urls.length + entN + (barcode ? 1 : 0) + labels.length; if (ok && entN) { _pcLinked = null; pcShowLinksCount(null); } appendLog('System', `Mission Control apply${d.dry ? ' (dry run: queued, not run)' : ''}: ${barcode ? `barcode ${barcode}, ` : ''}${urls.length} release link(s), ${entN} artist/label link(s) ${ok ? 'handed to Falcon' : 'NOT taken — no Falcon on this page'}: ${urls.concat([...ents.values()].flatMap(r => r.urls)).join(' ')}`, ok ? 'ok' : 'error'); const nAdd = labels.filter(l => l.add).length, nCat = labels.length - nAdd; const what = (barcode ? ['the barcode'] : []).concat(nCat ? [nCat === 1 ? 'the catalog number' : nCat + ' catalog numbers'] : [], nAdd ? [nAdd === 1 ? 'a label' : nAdd + ' labels'] : [], urls.length + entN ? [`${urls.length + entN} link${urls.length + entN === 1 ? '' : 's'}`] : []).join(' and '); reply(ok ? { ok: true, sent: n, via: 'falcon', tag: `mc:pc:${d.run}`, note: `${what} ${d.dry ? 'queued in' : 'sent to'} Falcon` } : { ok: false, sent: 0, note: 'Falcon is not running on this page' }); }); pcMcHello(); // MC may have asked before PC loaded if (mbuTestHooks()) window.__pcTest702 = { seed: () => PC_SEED, seedData: () => PC_SEED_DATA, pcMcSeedProbe }; if (mbuTestHooks()) window.__pcTest680 = { pcMcFinding, pcMcMasterFinding, pcScan, pcMcReleaseLinkTypes, cacheGet, cacheSet, mbDataGet, mbFormat: () => MB_FORMAT, pcMcBarcodeFindings, pcMcLabelFindings, pcResolveLabel, pcPageLabels, pcMcCatnoFindings, pcFoundCatnos, pcNoteFoundCatnos, pcCatnosBeatport, pcPickedCatnos, pcQueuePickedCatnos, updateRow, scanDone: () => PC_SCAN.last, pcLabelNorm, setCons: c => { _pcMcCons = c; }, pcNoteFoundBarcode, pcSetPastedBarcode, setOwnBarcode: b => { MB_OWN_BARCODE = b; }, setMbBarcode: b => { MB_BARCODE = b; }, clearFound: () => PC_FOUND_BC.clear() }; if (!PC_SEED) pcScan(); })(); })(); } catch (e) { try { console.error('[String Theory] platform_check failed while starting — the other scripts carry on:', e); } catch (x) {} } }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); try { if (__stSkipped.length) console.debug('String Theory: not for this page (their own @match' + (__stInFrame ? ' or @noframes' : '') + ') — ' + __stSkipped.join(', ')); } catch (e) {}