// ==UserScript== // @name Art Station // @namespace https://musicbrainz.org/ // @version 2026.10.3 // @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