');
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-cons-opt').addEventListener('change', e => {
const next = Object.assign(settings(), { consolidate: e.target.checked });
saveSettings(next);
const root = document.getElementById('fc-root');
if (root && current) showCons(root, current.provider);
Log.info(`Consolidate button: ${next.consolidate ? 'shown' : 'hidden'}`);
});
panel.querySelector('.fc-harmony-opt').addEventListener('change', e => {
const next = Object.assign(settings(), { harmony: e.target.checked });
saveSettings(next);
const root = document.getElementById('fc-root');
if (root && current) showHarmony(root, current.provider);
Log.info(`Send to Harmony button: ${next.harmony ? 'shown' : 'hidden'}`);
});
panel.querySelector('.fc-gear-hidden-opt').addEventListener('change', e => {
const next = Object.assign(settings(), { gearHidden: e.target.checked });
saveSettings(next);
const root = document.getElementById('fc-root');
if (root) { root.classList.toggle('fc-gear-hidden', next.gearHidden); mbRestackCorner('br'); }
Log.info(`settings button: ${next.gearHidden ? 'hidden (right-click a button for the settings)' : 'shown'}`);
});
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('.mbu-ia').classList.toggle('mbu-ia-off', !next.archive);
Log.info(`archive on the Internet Archive: ${next.archive ? 'on' : 'off'}`);
});
mbaKeysWire(panel, Log);
panelDismiss = mbuDismissOn(panel, () => { panelDismiss = null; panel.remove(); });
}
// What an import does to the release a provider read, before it is seeded: the type guessed where
// the platform gives none, labels split, Various Artists, the annotation, the script. Consolidate and
// Mission Control's reads (#702) do the same, so every platform's release is finished alike.
function finishRelease(rel, provider) {
const tracks = [].concat(...rel.mediums.map(m => m.tracks)).filter(t => !t.placeholder); // an empty track (#684) is no song
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
rel.typeGuessed = false;
if (g.type && (!rel.types.length || (g.explicit && rel.types[0] === 'Album' && g.type !== 'Album'))) {
rel.typeGuessed = !rel.types.length;
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))));
return rel;
}
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);
Log.info(`import ${location.href}`);
Log.info(`import ${provider.name} album ${id} → ${server} (token ${token})`);
busy = true;
if (go) go.setAttribute('aria-busy', 'true');
const t0 = Date.now();
// the button says what it does from the click on, with the tracks read so far (when the platform
// counts them) and the seconds gone, so a slow platform (7digital's search takes 10 s) visibly works
let count = '', phase = `Reading ${provider.name}…`;
const show = () => { if (label) label.textContent = `${phase}${count ? ' ' + count : ''} · ${Math.round((Date.now() - t0) / 1000)} s`; };
show();
const ticker = setInterval(show, 1000);
try {
const rel = await provider.fetchRelease(id, (n, total) => { count = `${n}/${total}`; show(); });
if (rel.missing) {
count = ''; phase = 'Tracks missing'; show();
if (!(await askMissing(rel, provider, go))) { Log.info('import cancelled: tracks missing'); return; }
Log.info('import anyway, with tracks missing');
}
count = ''; phase = 'Opening MusicBrainz…'; show();
finishRelease(rel, provider);
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));
Log.debug(`seed: ${params.length} parameters`);
if (mbuTestHooks()) window.__fcLastSeed = { token, server, params, rel };
const where = sendSeed(server, token, params);
if (rel.hiddenLengths) mbuToast(`⚠ ${rel.hiddenLengths} track length(s) are from ${provider.name}'s app data: the album isn't out yet, so they can still change`, { kind: 'warn' });
Log.ok(`seeded ${server}/release/add with ${nTracks} track(s) (${where === 'here' ? 'in this tab: the browser blocked a new one' : 'in a new tab'})`);
if (settings().closeAfter) {
if (where !== 'here') closeSourceTab();
else Log.info('close after the import: skipped, the editor opened in this tab');
}
} catch (e) {
Log.err(`import failed: ${e.message}`);
mbuToast(`✗ ${NAME}: ${e.message}`, { kind: 'warn', action: { label: 'Copy log', onClick: b => Log.copy(b) } });
} finally {
clearInterval(ticker);
busy = false;
if (go) go.removeAttribute('aria-busy');
if (label) label.textContent = 'Import to MusicBrainz';
}
}
// #687: Send to Harmony. A platform Harmony knows goes by its album link, at once; Harmony finds the
// others by the barcode it reads there. Any other platform goes by the barcode, read here first as
// Import reads the album. YouTube Music and Amazon Music have neither, so the button is greyed out.
function showHarmony(root, provider) {
const b = root.querySelector('.fc-harmony');
if (!b) return;
b.hidden = !settings().harmony;
const none = HARMONY_NONE.has(provider.id);
b.setAttribute('aria-disabled', String(none));
b.setAttribute('aria-label', 'Send to Harmony');
b.title = harmonyWhy(provider);
}
function harmonyWhy(provider) {
if (HARMONY_NONE.has(provider.id)) return `Send to Harmony: Harmony can't look up ${provider.name}, and ${provider.name} shows no barcode`;
if (HARMONY_URLS[provider.id]) return `Send to Harmony: look this ${provider.name} album up on Harmony, with every platform that has its barcode`;
return `Send to Harmony: read the barcode here first (Harmony can't look up ${provider.name}), then look it up on Harmony`;
}
async function sendToHarmony() {
if (busy || !current) return;
const { provider, id } = current;
if (HARMONY_NONE.has(provider.id)) { mbuToast(harmonyWhy(provider).replace(/^Send to Harmony: /, ''), { kind: 'warn' }); return; }
const direct = HARMONY_URLS[provider.id];
if (direct) {
openHarmony(harmonyLookup({ url: direct(id) }));
return;
}
const root = document.getElementById('fc-root');
const go = root && root.querySelector('.fc-go'), hb = root && root.querySelector('.fc-harmony');
const label = go && go.querySelector('span');
busy = true;
for (const b of [go, hb]) if (b) b.setAttribute('aria-busy', 'true');
const t0 = Date.now();
let count = '';
const show = () => { if (label) label.textContent = `Reading ${provider.name} for Harmony…${count ? ' ' + count : ''} · ${Math.round((Date.now() - t0) / 1000)} s`; };
show();
const ticker = setInterval(show, 1000);
try {
Log.info(`Harmony: reading the barcode of ${provider.name} album ${id}`);
const rel = await provider.fetchRelease(id, (n, total) => { count = `${n}/${total}`; show(); });
if (!rel.barcode) throw new Error(`${provider.name} gives no barcode for this album, so Harmony can't look it up`);
openHarmony(harmonyLookup({ gtin: rel.barcode }));
} catch (e) {
Log.err(`Send to Harmony failed: ${e.message}`);
mbuToast(`✗ ${NAME}: ${e.message}`, { kind: 'warn', action: { label: 'Copy log', onClick: b => Log.copy(b) } });
} finally {
clearInterval(ticker);
busy = false;
for (const b of [go, hb]) if (b) b.removeAttribute('aria-busy');
if (label) label.textContent = 'Import to MusicBrainz';
}
}
// A tab the manager opens isn't a blocked popup, even after the read; without it the page opens one
// majkinetor: "Option to close page after import should apply to Harmony too". Only when Harmony got a
// tab of its own: when the browser blocked it, Harmony is in this one.
function openHarmony(url) {
Log.ok(`sent to Harmony: ${url}`);
if (mbuTestHooks()) window.__fcLastHarmony = url;
let here = false;
if (typeof GM_openInTab === 'function') GM_openInTab(url, { active: true, insert: true });
else {
const w = window.open(url, '_blank');
if (w) w.opener = null;
else here = true;
}
if (settings().closeAfter) {
if (!here) closeSourceTab();
else Log.info('close after sending to Harmony: skipped, the browser blocked a new tab and Harmony opens in this one');
}
if (here) location.assign(url);
}
/* ── #702: Consolidate in Mission Control ────────────────────────────────── */
// The album is read as for an import and handed to Mission Control on MusicBrainz's
// /release/add#mc=