// ==UserScript== // @name Art Station // @namespace https://musicbrainz.org/ // @version 2026.6.22.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/release/*/add-cover-art // @match *://*.musicbrainz.org/event/*/event-art // @match *://*.musicbrainz.org/event/*/add-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})\/(add-)?(?:cover|event)-art/i); if (!M) return; 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
/, 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; // #248 the add page: take the whole thing over. Move the native uploader form // OFF-SCREEN (not display:none) so it stays functional — integrations (ECAU / // Harmony) 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 = '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'; // #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, _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, autoType: true, autoComment: true, autoFront: true, autoFrontMode: 'whenNone', clearSelAfterOp: true }; 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() { 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— 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 || '') })); // 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, _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 //
Export date: ${date}${by ? `
Exported by: ${esc(by)}` : ''}
| Position | Cover | Resolution | Size |
|---|---|---|---|
| ${r.pos} | ${esc(r.name)} | ${esc(r.res)} | ${esc(r.size)} |
Report created with Art Station${_gm ? ' v' + esc(_gm.version) : ''}
`); 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 => `${esc(a.name)}`).join(', ') : 'Unknown artist'; out.push(`
${esc(cap(it))}