// ==UserScript==
// @name Art Station
// @namespace https://musicbrainz.org/
// @version 2026.10.4
// @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
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @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';
// 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
// — 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)) return fetch(url, init);
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;
}
//
// 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;
// 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
/, 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);
// #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 (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
// 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;
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(`${sizeText(it)}`);
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 — 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 — 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() {
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: 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, 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.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
//
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 (+
) 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