// ==UserScript== // @name Art Station // @namespace https://musicbrainz.org/ // @version 2026.6.18.2 // @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 // @icon https://raw.githubusercontent.com/majkinetor/musicbrainz-userscripts/main/userscripts/art_station/icon.png // @match *://*.musicbrainz.org/release/*/cover-art // @match *://*.musicbrainz.org/event/*/event-art // @grant GM.xmlHttpRequest // @grant GM_xmlhttpRequest // @connect * // @run-at document-start // ==/UserScript== // // 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'; // 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) const M = location.pathname.match(/\/(release|event)\/([0-9a-f-]{36})\/(?:cover|event)-art/i); if (!M) return; const IS_EVENT = M[1].toLowerCase() === 'event'; const MBID = M[2]; 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' }; // append a node to
/, 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 ECAU's async-added Import buttons; // 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); // 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(localStorage.getItem('artstation:settings') || '{}'); } catch (e) {} earlyHide.disabled = !!_savedPrefs.showOrig; footerStyle.disabled = !!_savedPrefs.showOrig || _savedPrefs.hideMbFooter === false; // 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 = 'https://raw.githubusercontent.com/majkinetor/musicbrainz-userscripts/main/userscripts/art_station/icon.png'; 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 const thumb = (id, n) => `${CAA}/${id}-${n}.jpg`; // 250 / 500 / 1200 // 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; 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'; let MODEL = []; // [{ id, types:[], comment, order, w, h, bytes, _del, _new, _file }] const SIZES = new Map(); // CAA image id -> original file size in bytes (from archive.org metadata) const fmtSize = b => b >= 1048576 ? (b / 1048576).toFixed(1) + 'Mb' : Math.max(1, Math.round(b / 1024)) + 'Kb'; // footer line under the image: "1.2Mb 600 × 600" — size first, then resolution, // each half shown once known, separated by a wide gap (em-space). function dimText(it) { const parts = []; if (it.bytes) parts.push(fmtSize(it.bytes)); 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 (it.bytes) parts.push(`${fmtSize(it.bytes)}`); if (it.w && it.h) parts.push(`${it.w} × ${it.h}`); return parts.join('') || '…'; } 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 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.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); } MODEL.forEach(it => { const b = SIZES.get(String(it.id)); if (b) { it.bytes = b; refreshDim(it); } }); } catch (e) { /* size is a nicety — never block the gallery */ } } let SETTINGS = load(); function load() { const d = { tile: 200, group: false, sort: 'type', detailed: false, hideMbFooter: true, showOrig: false }; try { return Object.assign(d, JSON.parse(localStorage.getItem('artstation:settings') || '{}')); } catch (e) { return d; } } function save() { try { localStorage.setItem('artstation:settings', JSON.stringify(SETTINGS)); } catch (e) {} } // ── 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() { 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— parse types from the "Types:"
ONLY (the comment // is a separate
, so reading the whole block grabbed it, e.g. "Types: -test") const ps = [...b.querySelectorAll('p')]; const typeP = ps.find(p => /^\s*Types:/.test(p.textContent)); const raw = typeP ? typeP.textContent.replace(/^\s*Types:\s*/, '').trim() : ''; const types = (raw && raw !== '-') ? raw.split(',').map(s => s.trim()).filter(s => s && s !== '-') : []; const cmtP = ps.find(p => p !== typeP && p.textContent.trim() && !/All sizes:/i.test(p.textContent) && !/Dimensions:/i.test(p.textContent)); const comment = cmtP ? cmtP.textContent.trim() : ''; const orig = [...b.querySelectorAll('a')].find(a => a.textContent.trim().toLowerCase() === 'original'); 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); } async function loadArt() { 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) { /* 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: p.types, 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 || '') })); MODEL = source.map((s, i) => ({ id: s.id, types: s.types.slice(), comment: s.comment, order: i, w: 0, h: 0, _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(); 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.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 changed = it => it._del || it._new || it.comment !== it._origComment || it.order !== it._origOrder || it.types.join('|') !== it._origTypes.join('|'); const stagedCount = () => MODEL.filter(changed).length; const selectable = () => MODEL.filter(it => !it._del); const allSelected = () => { const s = selectable(); return s.length > 0 && s.every(it => it._sel); }; // 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 //
${esc(cap(it))}