// ==UserScript== // @name MB: Supercharged Cover Art Edits // @version 2022.10.2 // @description Supercharges reviewing cover art edits. Displays release information on CAA edits. Enables image comparisons on removed and added images. // @author ROpdebee // @license MIT; https://opensource.org/licenses/MIT // @namespace https://github.com/ROpdebee/mb-userscripts // @downloadURL https://raw.github.com/ROpdebee/mb-userscripts/main/mb_supercharged_caa_edits.user.js // @updateURL https://raw.github.com/ROpdebee/mb-userscripts/main/mb_supercharged_caa_edits.user.js // @match *://*.musicbrainz.org/* // @exclude-match *://*.musicbrainz.org/dialog* // @require https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.js // @require https://code.jquery.com/ui/1.12.1/jquery-ui.min.js // @require https://github.com/rsmbl/Resemble.js/raw/v3.2.4/resemble.js // @require https://momentjs.com/downloads/moment-with-locales.min.js // @run-at document-end // @grant none // ==/UserScript== let $ = this.$ = this.jQuery = jQuery.noConflict(true); resemble.outputSettings({ errorColor: {red: 0, green: 0, blue: 0}, errorType: 'movementDifferenceIntensity', transparency: .25, }); const ID_RGX = /release\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\/(\d+)\.\w+/; const DEFAULT_TRIES = 5; const LOADING_GIF = 'data:image/gif;base64,R0lGODlhEAAQAPMPALu7u5mZmTMzM93d3REREQAAAHd3d1VVVWZmZqqqqoiIiO7u7kRERCIiIgARAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQFBwAPACwAAAAAEAAQAEAEcPDJtyg6dUrFetDTIopMoSyFcxxD1krD8AwCkASDIlPaUDQLR6G1Cy0SgqIkE1IQGMrFAKCcGWSBzwPAnAwarcKQ15MpTMJYd1ZyUDXSDGelBY0qIoBh/ZoYGgELCjoxCRRvIQcGD1kzgSAgAACQDxEAIfkEBQcADwAsAAAAAA8AEAAABF3wyfkMkotOJpscRKJJwtI4Q1MAoxQ0RFBw0xEvhGAVRZZJh4JgMAEQW7TWI4EwGFjKR+CAQECjn8DoN0kwDtvBT8FILAKJgfoo1iAGAPNVY9DGJXNMIHN/HJVqIxEAIfkEBQcADwAsAAAAABAADwAABFrwyfmColgiydpaQiY5x9Ith7hURdIl0wBIhpCAjKIIxaAUPQ0hFQsAC7MJALFSFi4SgC4wyHyuCYNWxH3AuhSEotkNGAALAPqqkigG8MWAjAnM4A8594vPUyIAIfkEBQcADwAsAAAAABAAEAAABF3wySkDvdKsddg+APYIWrcg2DIRQAcU6DJICjIsjBEETLEEBYLqYSDdJoCGiHgZwG4LQCCRECEIBAdoF5hdEIWwgBJqDs7DgcKyRHZl3uUwuhm2AbNNW+LV7yd+FxEAIfkEBQcACAAsAAAAABAADgAABEYQyYmMoVgeWQrP3NYhBCgZBdAFRUkdBIAUguVVo1ZsWFcEGB5GMBkEjiCBL2a5ZAi+m2SAURExwKqPiuCafBkvBSCcmiYRACH5BAUHAA4ALAAAAAAQABAAAARs0MnpAKDYrbSWMp0xZIvBKYrXjNmADOhAKBiQDF5gGcICNAyJTwFYTBaDQ0HAkgwSmAUj0OkMrkZM4HBgKK7YTKDRICAo2clAEIheKc9CISjEVTuEQrJASGcSBQcSUFEUDQUXJBgDBW0Zj34RACH5BAUHAA8ALAAAAAAQABAAAARf8Mn5xqBYgrVC4EEmBcOSfAEjSopJMglmcQlgBYjE5NJgZwjCAbO4YBAJjpIjSiAQh5ayyRAIDKvJIbnIagoFRFdkQDQKC0RBsCIUFAWsT7RwG410R8HiiK0WBwJjFBEAIfkEBQcADgAsAQABAA8ADwAABFrQybEWADXJLUHHAMJxIDAgnrOo2+AOibEMh1LN62gIxphzitRoCDAYNcNN6FBLShao4WzwHDQKvVGhoFAwGgtFgQHENhoB7nCwHRAIC0EyUcC8Zw1ha3NIRgAAIfkEBQcADwAsAAAAABAAEAAABGDwyfnWoljaNYYFV+Zx3hCEGEcuypBtMJBISpClAWLfWODymIFiCJwMDMiZBNAAYFqUAaNQ2E0YBIXGURAMCo1AAsFYBBoIScBJEwgSVcmP0li4FwcHz+FpCCQMPCFINxEAIfkEBQcADgAsAAABABAADwAABFzQyemWXYNqaSXY2vVtw3UNmROM4JQowKKlFOsgRI6ASQ8IhSADFAjAMIMAgSYJtByxyQIhcEoaBcSiwegpDgvAwSBJ0AIHBoCQqIAEi/TCIAABGhLG8MbcKBQgEQAh+QQFBwAPACwAAAEAEAAPAAAEXfDJSd+qeK5RB8fDRRWFspyotAAfQBbfNLCVUSSdKDV89gDAwcFBIBgywMRnkWBgcJUDKSZRIKAPQcGwYByAAYTEEJAAJIGbATEQ+B4ExmK9CDhBd8ThdHw/AmUYEQAh+QQFBwAPACwAAAEADwAPAAAEXvBJQIa8+ILSspdHkXxS9wxF4Q3L2aTBeC0sFjhAtuyLIjAMhYc2GBgaSKGuyNoBDp7czFAgeBIKwC6kWCAMxUSAFjtNCAAFGGF5tCQLAaJnWCTqHoREvQuQJAkyGBEAOw==' const STATUSES = { 1: 'Official', 2: 'Promotion', 3: 'Bootleg', 4: 'Pseudo-Release', 5: 'Withdrawn', 6: 'Cancelled', }; const PACKAGING_TYPES = { 1: 'Jewel Case', 2: 'Slim Jewel Case', 3: 'Digipak', 4: 'Cardboard/Paper Sleeve', 5: 'Other', 6: 'Keep Case', 7: 'None', 8: 'Cassette Case', 9: 'Book', 10: 'Fatbox', 11: 'Snap Case', 12: 'Gatefold Cover', 13: 'Discbox Slider', 16: 'Super Jewel Box', 17: 'Digibook', 18: 'Plastic Sleeve', 19: 'Box', 20: 'Slidepack', 21: 'SnapPack', 54: 'Metal Tin', 55: 'Longbox', 56: 'Clamshell Case', }; const NONSQUARE_PACKAGING_TYPES = [ 3, // Digipak 6, // Keep case 8, // Cassette 9, // Book 10, // Fatbox 11, // Snap case 17, // Digibook 55, // Longbox ]; const NONSQUARE_PACKAGING_COVER_TYPES = [ 'Front', 'Back', ]; // Non-exhaustive const LIKELY_DIGITAL_DIMENSIONS = [ '640x640', // Spotify, Tidal (?) '1400x1400', // Deezer, iTunes (?) '3000x3000', // iTunes, Bandcamp (?) ]; const SHADY_REASONS = { releaseDate: 'The release date occurs after the end of the voting period for this edit. The cover art may not be accurate at this time.', incorrectDimensions: 'This packaging is typically non-square, but this cover art is square. It likely belongs to another release.', nonsquareDigital: 'This is a digital media release with non-square cover art. Although this is possible, it is uncommon.', digitalDimensions: 'This is a physical release but the added cover art has dimensions typical of digital store fronts. Care should be taken to ensure the cover matches the actual physical release.', digitalNonFront: 'This type of artwork is very uncommon on digital releases, and might not belong here.', trackOnPhysical: 'Covers of type “track” should not appear on physical releases.', linerOnNonVinyl: 'Covers of type “liner” typically appear on Vinyl releases. Although it can appear on other releases, this is uncommon.', noTypesSet: 'This cover has no types set. This is not ideal.', obiOutsideJapan: 'Covers of type “obi” typically occur on Japanese releases only. JP is not in the release countries.', watermark: 'This cover may contain watermarks, and should ideally be superseded by one without watermarks.', pseudoRelease: 'Pseudo-releases typically should not have cover art attached.', urlInComment: 'The comment appears to contain a URL. This is often unnecessary clutter.', }; const MB_FORMAT_TRANSLATIONS = { '%A': 'dddd', // Monday, Tuesday, ... '%a': 'ddd', // mon, tue, ... '%B': 'MMMM', // January, February, ... '%b': 'MMM', // Jan, Feb, ... '%d': 'DD', // 2-digit day '%e': 'D', // 1/2-digit day '%m': 'MM', // 2-digit month '%Y': 'YYYY', // 4-digit year '%H': 'HH', // 00-23 hour '%M': 'mm', // 00-59 minutes '%c': 'DD/MM/YYYY, hh:mm:ss a', '%x': 'DD/MM/YYYY', '%X': 'hh:mm:ss a', }; // MB and moments' locale for German short month names don't match moment.updateLocale('de', { monthsShort: 'Jan_Feb_Mär_Apr_Mai_Jun_Jul_Aug_Sep_Okt_Nov_Dez'.split('_'), }); const getDimensionsWhenInView = (() => { let actualFn = window.ROpdebee_getDimensionsWhenInView; if (!actualFn) { console.log('Will not be able to get dimensions, script not installed?'); return () => null; } return actualFn; })(); const loadImageDimensions = (() => { let actualFn = window.ROpdebee_loadImageDimensions; if (!actualFn) { // Don't warn here, if we can't find this function, we likely won't have // found the other either return () => Promise.reject('Script unavailable'); } return actualFn; })(); async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // https://forum.freecodecamp.org/t/how-to-make-js-wait-until-dom-is-updated/122067 async function waitUntilRedrawn() { return new Promise(resolve => { window.requestAnimationFrame(() => window.requestAnimationFrame(resolve)); }); } async function fetchWithRetry(url, options, tries = DEFAULT_TRIES) { let lastErr; let i = 0; while (tries-- > 0) { try { let resp = await fetch(url, options); // Only resolve when we get a 200 OK response, everything else is // an error if (resp.ok) return resp; let e = new Error(`Server responded with ${resp.status}: ${resp.statusText}`); e.statusCode = resp.status; throw e; } catch (e) { // Network errors or non-200 status codes. Last error will be used // in rejection if tries run out. lastErr = e; let timeout = Math.random(); // Exponential backoff if statusCode == 429 or 5xx if (e.statusCode == 429 || e.statusCode >= 500) { timeout += 2 ** i++; } await sleep(timeout * 1000); } } throw lastErr; } // Do some promise-driven caching to ensure we don't try to load the same index // multiple times for images from the same release. const caaCache = new Map(); function getAllImages(mbid) { if (!caaCache.has(mbid)) { // Should be fine with CORS // Specifically not using await here since we need to register this // promise in the cache in the same event loop cycle to prevent dupes let prom = fetchWithRetry(`https://coverartarchive.org/release/${mbid}/`) .then(resp => resp.json()) .then(json => json.images); caaCache.set(mbid, prom); } return caaCache.get(mbid); } // Caching for the same reason as above const pendingRemovalCache = new Map(); function getPendingRemovals(gid) { if (!pendingRemovalCache.has(gid)) { pendingRemovalCache.set(gid, _getPendingRemovalsInt(gid)); } return pendingRemovalCache.get(gid); } async function _getPendingRemovalsInt(gid) { async function getPage(pageNo) { const url = `${window.location.origin}/search/edits?conditions.0.field=release&conditions.0.operator=%3D&conditions.0.args.0=${gid}&conditions.1.field=type&conditions.1.operator=%3D&conditions.1.args=315&conditions.2.field=status&conditions.2.operator=%3D&conditions.2.args=1&page=${pageNo}`; let resp = await fetchWithRetry(url); return resp.text(); } function processPage(pageHtml, resultSet) { let parser = new DOMParser(); let dom = parser.parseFromString(pageHtml, 'text/html'); [...dom.querySelectorAll('table.details.remove-cover-art code')] .map(code => code.innerText) .map(filename => filename.split('-')) .filter(parts => parts.length == 7) .map(parts => parts[6].match(/^(\d+)\.\w+/)[1]) .forEach(id => resultSet.add(parseInt(id))); let nextAnchor = dom.querySelector('ul.pagination li:last-child > a'); if (!nextAnchor) return; return nextAnchor.href.match(/page=(\d+)/)[1]; } let curPageNo = 1; let results = new Set(); while (curPageNo) { let pageHtml = await getPage(curPageNo); curPageNo = processPage(pageHtml, results); } return results; } async function getReleaseDetails(mbid) { let resp = await fetchWithRetry(`${window.location.origin}/ws/js/release/${mbid}`); return resp.json(); } function fixCaaUrl(url) { return url.replace(/^http:/, 'https:'); } async function checkAlive(url) { let httpResp; try { httpResp = await fetch(url, {method: 'HEAD'}); } catch (e) { // 404 leads to CORS error. GM_xmlHttpRequest would overcome this, but // would lead us to be unable to use @grant none, so we wouldn't be // able to access the CAA Dimensions handler. return false; } return httpResp.status >= 200 && httpResp.status < 400; } async function selectImage(imageData, use1200) { // Select 1200px thumb, 500px thumb, or full size based on availability let candidates = [ imageData.thumbnails['500'] || imageData.thumbnails['large'], imageData.image]; if (use1200 && imageData.thumbnails['1200']) { candidates.unshift(imageData.thumbnails['1200']); } candidates = candidates.map(fixCaaUrl); for (let candidate of candidates) { if (await checkAlive(candidate)) { return candidate; } } return null; } // Adapted from https://github.com/metabrainz/musicbrainz-server/blob/a632e0a8a5dd88964107a626f500fbb89ba38734/root/static/scripts/common/artworkViewer.js $.widget('ropdebee.artworkCompare', $.ui.dialog, { options: { modal: true, resizable: false, autoOpen: false, width: 'auto', show: true, closeText: '', title: 'Compare images', }, _create: function() { this._super(); this.currentViewMode = localStorage.getItem('ROpdebee_preferredDialogViewMode') || 'sbs'; this.viewModeTexts = { sbs: 'Side-by-side mode', overlay: 'Overlay mode', }; this.$switchViewMode = $('