');
document.body.appendChild(panel);
placePanel(panel, anchor.closest('#fc-root') || anchor);
panel.querySelector('.fc-reset-pos').addEventListener('click', () => {
const root = document.getElementById('fc-root');
storePos(root.dataset.fcProvider, null);
setScrollsWithPage(root.dataset.fcProvider, false);
placeButton(root);
closePanel();
Log.info(`button back in the corner on ${root.dataset.fcProvider}`);
});
panel.querySelector('.fc-reset-all').addEventListener('click', () => {
const root = document.getElementById('fc-root');
const had = Object.keys(GM_getValue(POS_KEY, {}) || {});
GM_setValue(POS_KEY, {});
GM_setValue(SCROLL_KEY, {});
placeButton(root);
closePanel();
Log.info(`button back in the corner on every platform (it had been moved on ${had.join(', ') || 'none'})`);
});
panel.querySelector('.mbu-cfg-log').addEventListener('click', () => Log.open());
panel.querySelector('.fc-server').addEventListener('change', e => {
const next = Object.assign(settings(), { server: e.target.value });
saveSettings(next);
Log.info(`server set to ${next.server}`);
});
panel.querySelector('.fc-scroll-opt').addEventListener('change', e => {
setScrollsWithPage(here, e.target.checked);
const root = document.getElementById('fc-root');
if (root) placeButton(root);
Log.info(`moved button on ${here}: ${e.target.checked ? 'scrolls with the page' : 'stays on the screen'}`);
});
panel.querySelector('.fc-gear-hover-opt').addEventListener('change', e => {
const next = Object.assign(settings(), { gearOnHover: e.target.checked });
saveSettings(next);
const root = document.getElementById('fc-root');
if (root) root.classList.toggle('fc-gear-hover', next.gearOnHover);
Log.info(`settings button: ${next.gearOnHover ? 'only on hover' : 'always'}`);
});
panel.querySelector('.fc-iconly-opt').addEventListener('change', e => {
const next = Object.assign(settings(), { iconOnly: e.target.checked });
saveSettings(next);
const root = document.getElementById('fc-root');
if (root) { root.classList.toggle('fc-iconly', next.iconOnly); mbRestackCorner('br'); }
Log.info(`button: ${next.iconOnly ? 'icon only' : 'icon and text'}`);
});
panel.querySelector('.fc-annotation').addEventListener('change', e => {
setAnnotationOn(here, e.target.checked);
Log.info(`annotation from ${here}'s notes: ${e.target.checked ? 'on' : 'off'}`);
});
panel.querySelector('.fc-close-after').addEventListener('change', e => {
const next = Object.assign(settings(), { closeAfter: e.target.checked });
saveSettings(next);
Log.info(`close the page after the import: ${next.closeAfter ? 'on' : 'off'}`);
});
panel.querySelector('.fc-archive').addEventListener('change', e => {
const next = Object.assign(settings(), { archive: e.target.checked });
saveSettings(next);
panel.querySelector('.fc-ia').classList.toggle('fc-off', !next.archive);
Log.info(`archive on the Internet Archive: ${next.archive ? 'on' : 'off'}`);
});
const saveKeys = () => {
const next = Object.assign(settings(), { iaKey: panel.querySelector('.fc-ia-key').value.trim(), iaSecret: panel.querySelector('.fc-ia-secret').value.trim() });
saveSettings(next);
Log.info(`archive.org keys: ${next.iaKey && next.iaSecret ? 'set (Save Page Now 2)' : next.iaKey || next.iaSecret ? 'only one of the two is set, so saves stay anonymous' : 'none (anonymous saves)'}`);
};
panel.querySelector('.fc-ia-key').addEventListener('change', saveKeys);
panel.querySelector('.fc-ia-secret').addEventListener('change', saveKeys);
panelDismiss = mbuDismissOn(panel, () => { panelDismiss = null; panel.remove(); });
}
async function importCurrent() {
if (busy || !current) return;
const { provider, id } = current;
const go = document.querySelector('#fc-root .fc-go');
const label = go && go.querySelector('span');
const server = settings().server;
const token = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
// Open the target tab now, inside the click: a window opened after the fetches would be
// a popup the browser blocks. The form posts into it once the data is in.
const target = 'fc-' + token;
const win = window.open('about:blank', target);
Log.info(`import ${location.href}`);
Log.info(`import ${provider.name} album ${id} → ${server} (token ${token}, tab ${win ? 'opened' : 'BLOCKED: posting in this tab'})`);
busy = true;
if (go) go.setAttribute('aria-busy', 'true');
const t0 = Date.now();
try {
const rel = await provider.fetchRelease(id, (n, total) => { if (label) label.textContent = `Reading ${provider.name}… ${n}/${total}`; });
{
const tracks = [].concat(...rel.mediums.map(m => m.tracks));
const g = guessReleaseType(rel.title, tracks);
Log.info(`type: ${provider.name} says ${rel.types.join(' + ') || 'nothing'}; the guess is ${g.type || 'none'} (${g.why})`);
// the platform's own type stands, unless it's a plain Album and the title says EP / Single
if (g.type && (!rel.types.length || (g.explicit && rel.types[0] === 'Album' && g.type !== 'Album'))) {
rel.types = [g.type].concat(rel.types.slice(1));
Log.info(`type set to ${rel.types.join(' + ')}`);
}
}
rel.labels = splitLabels(rel.labels);
const va = variousArtistsWhy(rel);
if (va) {
Log.info(`release artist: Various Artists, not ${rel.credit.map(c => c.name + c.join).join('')} (${va})`);
rel.credit = [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }];
if (!rel.types.includes('Compilation')) rel.types = (rel.types.length ? rel.types : ['Album']).concat('Compilation');
}
if (rel.annotation && annotationOn(provider.name)) {
rel.annotation += `\n\nFrom ${provider.name}: ${rel.url}`;
Log.info(`annotation: ${rel.annotation.length} characters of ${provider.name}'s notes`);
} else {
if (rel.annotation) Log.info(`annotation: ${provider.name} has notes (${rel.annotation.length} characters); off for ${provider.name} in the settings`);
rel.annotation = null;
}
rel.script = guessScript([rel.title].concat(...rel.mediums.map(m => m.tracks.map(t => t.title))));
const nTracks = rel.mediums.reduce((n, m) => n + m.tracks.length, 0);
Log.info(`release read in ${Date.now() - t0} ms: "${rel.title}" · ${rel.credit.map(c => c.name + c.join).join('')} · ${rel.mediums.length} medium(s), ${nTracks} track(s) · types ${rel.types.join('+') || '—'} · script ${rel.script || '—'}`);
if (!nTracks) throw new Error(`${provider.name} returned no tracks`);
pruneHandoffs();
// archiving is extra: whatever goes wrong with it, the import goes on without it
let archive = null;
try { archive = archivePlan(rel, provider, id); } catch (e) { Log.warn(`archive: skipped, it failed: ${e && e.message || e}`); }
GM_setValue(HANDOFF_PREFIX + token, Object.assign(handoffFor(rel, provider, token), { archive }));
const params = seedParams(rel, editNoteFor(rel, provider, archive));
Log.debug(`seed: ${params.length} parameters`);
if (mbuTestHooks()) window.__fcLastSeed = { token, server, params, rel };
const form = document.createElement('form');
form.method = 'POST';
form.action = `https://${server}/release/add?first_contact=${token}`;
form.target = win ? target : '_self';
form.acceptCharset = 'UTF-8';
form.style.display = 'none';
for (const [k, v] of params) {
const inp = document.createElement('input');
inp.type = 'hidden'; inp.name = k; inp.value = v;
form.appendChild(inp);
}
document.body.appendChild(form);
form.submit();
form.remove();
Log.ok(`seeded ${server}/release/add with ${nTracks} track(s)`);
if (settings().closeAfter) {
if (win) closeSourceTab();
else Log.info('close after the import: skipped, the editor opened in this tab');
}
} catch (e) {
Log.err(`import failed: ${e.message}`);
if (win) try { win.close(); } catch (_) { /* already gone */ }
mbuToast(`✗ ${NAME}: ${e.message}`, { kind: 'warn', action: { label: 'Copy log', onClick: b => Log.copy(b) } });
} finally {
busy = false;
if (go) go.removeAttribute('aria-busy');
if (label) label.textContent = 'Import to MusicBrainz';
}
}
// majkinetor: an option to close the page once the import is clicked. Only after the POST has
// gone into the editor's tab (closing sooner could take the submission with it), and only with
// @grant window.close: a tab the user opened can't close itself otherwise.
function closeSourceTab() {
Log.info('closing this page (Close this page after the import)');
if (mbuTestHooks()) { window.__fcClosed = true; return; }
setTimeout(() => {
try { window.close(); } catch (e) { Log.warn(`could not close this page: ${e.message}`); }
setTimeout(() => { Log.warn('this page is still open: the userscript manager did not let it close (it needs @grant window.close)'); }, 1000);
}, 500);
}
function platformMain() {
const provider = PROVIDERS.find(p => p.host.test(location.hostname));
if (!provider) return;
Log.info(`${NAME} ${VERSION} on ${provider.name}`);
// The platforms are single-page apps: watch the address, not the load.
let last = null;
const check = () => {
if (location.href === last) return;
last = location.href;
const id = provider.albumId(location);
current = id ? { provider, id } : null;
if (id) { if (document.body) mountButton(provider, id); }
else unmountButton();
};
check();
setInterval(check, 700);
}
/* ── MusicBrainz side: publish the handoff for Apollo ────────────────────── */
// Apollo (or anything else) reads the seed's platform links from
// document.documentElement.dataset.firstContact (a JSON string), or
// the 'first-contact:seed' event on document (detail: the same JSON string),
// and can ask for it again with a 'first-contact:request' event on document.
function mbMain() {
const token = new URLSearchParams(location.search).get('first_contact');
if (!token) return;
pruneHandoffs();
const key = HANDOFF_PREFIX + token;
let h = GM_getValue(key, null), from = 'storage';
if (!h) { try { h = JSON.parse(sessionStorage.getItem(key) || 'null'); from = 'this tab'; } catch (e) { h = null; } }
if (!h) { Log.warn(`release editor opened with handoff ${token}, but it is not stored (expired, or another browser)`); return; }
Log.debug(`handoff ${token} read from ${from}`);
// MusicBrainz's "Continue" page (a POST from another site) comes first, at the same address:
// the handoff stays stored until the release editor itself is here
const consume = () => {
if (!document.getElementById('release-editor')) { Log.debug(`handoff ${token} kept: not the release editor yet (the confirmation page)`); return; }
try { sessionStorage.setItem(key, JSON.stringify(h)); } catch (e) { Log.debug(`handoff ${token}: no copy for this tab (${e.message})`); }
GM_deleteValue(key);
Log.debug(`handoff ${token} taken out of the script's storage`);
if (h.archive) archiveSources(h.archive, token);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', consume, { once: true }); else consume();
const json = JSON.stringify(h);
const publish = why => {
document.documentElement.dataset.firstContact = json;
document.dispatchEvent(new CustomEvent('first-contact:seed', { detail: json }));
Log.debug(`handoff ${token} published (${why})`);
};
publish('load');
document.addEventListener('first-contact:request', () => publish('request'));
const nArtists = [h.credit].concat(...h.mediums.map(m => m.tracks.map(t => t.credit))).reduce((n, c) => n + c.filter(a => a.url).length, 0);
Log.info(`release editor seeded from ${h.sourceName} (${h.url}); ${nArtists} artist credit(s) carry a platform link`);
if (mbuTestHooks()) window.__fcHandoff = h;
}
/* ── shared blocks ───────────────────────────────────────────────────────── */
//