// ==UserScript==
// @name Qobuz - Copy album info
// @version 1.18.2
// @author Anakunda
// @license GPL-3.0-or-later
// @copyright 2019, Anakunda (https://greasyfork.org/cs/users/321857-anakunda)
// @namespace https://greasyfork.org/users/321857-anakunda
// @description Copy metadata to parseable format
// @match https://www.qobuz.com/*/album/*
// @match https://www.qobuz.com/album/*
// @iconURL https://www.qobuz.com/assets-static/img/icons/favicon/favicon-32x32.png
// @grant GM_setClipboard
// @grant GM_registerMenuCommand
// @require https://greasyfork.org/scripts/408084-xhrlib/code/xhrLib.js
// @require https://greasyfork.org/scripts/406257-qobuzlib/code/QobuzLib.js
// ==/UserScript==
// expression for 'Automatically Fill Values' in foobaru200:
// %album artist%%album%%releasedate%%genre%%label%%discnumber%%discsubtitle%%totaldiscs%%tracknumber%%totaltracks%%artist%%title%%composer%%performer%%conductor%%media%%url%%comment%%releasetype%
Array.prototype.includesCaseless = function(str) {
if (typeof str != 'string') return false;
str = str.toLowerCase();
return this.some(elem => typeof elem == 'string' && elem.toLowerCase() == str);
};
Array.prototype.pushUnique = function(...items) {
items.forEach(it => { if (!this.includes(it)) this.push(it) });
return this.length;
};
Array.prototype.pushUniqueCaseless = function(...items) {
items.forEach(it => { if (!this.includesCaseless(it)) this.push(it) });
return this.length;
};
Array.prototype.equalCaselessTo = function(arr) {
function adjust(elem) { return typeof elem == 'string' ? elem.toLowerCase() : elem }
return Array.isArray(arr) && arr.length == this.length
&& arr.map(adjust).sort().toString() == this.map(adjust).sort().toString();
};
Array.prototype.distinctValues = function() {
return this.filter((elem, index, arrRef) => arrRef.indexOf(elem) == index);
};
Array.prototype.flatten = function() {
return this.reduce(function(flat, toFlatten) {
return flat.concat(Array.isArray(toFlatten) ? toFlatten.flatten() : toFlatten);
}, [ ]);
};
String.prototype.toASCII = function() {
return this.normalize("NFKD").replace(/[\x00-\x1F\u0080-\uFFFF]/g, '');
};
String.prototype.flatten = function() {
return this.replace(/\n/g, '\x1C').replace(/\r/g, '\x1D');
};
String.prototype.collapseGaps = function() {
return this.replace(/(?:[ \t\xA0]*\r?\n){3,}/g, '\n\n').replace(/\[(\w+)\]\[\/\1\]/ig,'').trim();
};
function copyTracks(evt) {
getTracks().then(function(tracks) {
GM_setClipboard(tracks.map(track => track.map(field => field !== undefined ? field : '')
.join('\x1E')).join('\n'), 'text');
}, reason => { alert(reason) });
//return false;
}
function getTracks() {
const discParser = /^(?:CD|DIS[CK]\s+|VOLUME\s+|DISCO\s+|DISQUE\s+)(\d+)(?:\s+of\s+(\d+))?$/i;
const vaParser = /^(?:Various(?:\s+Artists)?|Varios(?:\s+Artistas)?|V\/?A|\<various\s+artists\>|Různí(?:\s+interpreti)?)$/i;
const VA = 'Various Artists';
const multiArtistParsers = [
/\s*[\,\;\u3001](?!\s*(?:[JjSs]r)\b)(?:\s*(?:[Aa]nd|\&)\s+)?\s*/,
/\s+[\/\|\×|meets]\s+/i,
];
const ampersandParsers = [
/\s+(?:meets|vs\.?|X)\s+(?!\s*(?:[\&\/\+\,\;]|and))/i,
/\s*[;\/\|\×]\s*(?!\s*(?:\s*[\&\/\+\,\;]|and))/i,
/(?:\s*,)?\s+(?:[\&\+]|and)\s+(?!his\b|her\b|Friends$|Strings$)/i, // /\s+(?:[\&\+]|and)\s+(?!(?:The|his|her|Friends)\b)/i,
/\s*\+\s*(?!(?:his\b|her\b|Friends$|Strings$))/i,
];
const featArtistParsers = [
///\s+(?:meets)\s+(.+?)\s*$/i,
/* 0 */ /\s+(?:[Ww](?:ith|\.?\/)|[Aa]vec)\s+(?!his\b|her\b|Friends$|Strings$)(.+?)\s*$/,
/* 1 */ /(?:\s+[\-\−\—\–\_])?\s+(?:[Ff]eaturing\s+|(?:[Ff]eat|[Ff]t|FT)\.\s*|[Ff]\.?\/\s+)([^\(\)\[\]\{\}]+?)(?=\s*(?:[\(\[\{].*)?$)/,
/* 2 */ /\s+\[\s*f(?:eat(?:\.|uring)|t\.|\.?\/)\s+([^\[\]]+?)\s*\]/i,
/* 3 */ /\s+\(\s*f(?:eat(?:\.|uring)|t\.|\.?\/)\s+([^\(\)]+?)\s*\)/i,
/* 4 */ /\s+\[\s*(?:(?:en\s+)?duo\s+)?avec\s+([^\[\]]+?)\s*\]/i,
/* 5 */ /\s+\(\s*(?:(?:en\s+)?duo\s+)?avec\s+([^\(\)]+?)\s*\)/i,
/* 6 */ /\s+\[\s*(?:with|[Ww]\.?\/)\s+(?![Hh]is\b|[Hh]er\b|Friends$|Strings$)([^\[\]]+?)\s*\]/,
/* 7 */ /\s+\(\s*(?:with|[Ww]\.?\/)\s+(?![Hh]is\b|[Hh]er\b|Friends$|Strings$)([^\(\)]+?)\s*\)/,
];
const featTest = /\b(?:feat(?:uring|\.)|ft\.)/i;
const pseudoArtistParsers = [
/* 0 */ vaParser,
/* 1 */ /^(?:#??N[\/\-]?A|[JS]r\.?)$/i,
/* 2 */ /^(?:auditorium|[Oo]becenstvo|[Pp]ublikum)$/,
/* 3 */ /^(?:(Special\s+)??Guests?|Friends|(?:Studio\s+)?Orchestra)$/i,
/* 4 */ /^(?:Various\s+Composers)$/i,
/* 5 */ /^(?:[Aa]nonym)/,
/* 6 */ /^(?:traditional|trad\.|lidová)$/i,
/* 7 */ /\b(?:traditional|trad\.|lidová)$/,
/* 8 */ /^(?:tradiční|lidová)\s+/,
/* 9 */ /^(?:[Ll]iturgical\b|[Ll]iturgick[áý])/,
];
const tailingBracketStripper = /(?:\s+(?:\([^\(\)]+\)|\[[^\[\]]+\]|\{[^\{\}]+\}))+\s*$/;
const error = new Error('Failed to parse Qobus release page');
function normalizeDate(str, countryCode = undefined) {
if (typeof str != 'string') return null;
let match;
function formatOutput(yearIndex, montHindex, dayIndex) {
let year = parseInt(match[yearIndex]), month = parseInt(match[montHindex]), day = parseInt(match[dayIndex]);
if (year < 30) year += 2000; else if (year < 100) year += 1900;
if (year < 1000 || year > 9999 || month < 1 || month > 12 || day < 0 || day > 31) return null;
return year.toString() + '-' + month.toString().padStart(2, '0') + '-' + day.toString().padStart(2, '0');
}
if ((match = /\b(\d{4})-(\d{1,2})-(\d{1,2})\b/.exec(str)) != null) return formatOutput(1, 2, 3); // US, SE
if ((match = /\b(\d{4})\/(\d{1,2})\/(\d{1,2})\b/.exec(str)) != null) return formatOutput(1, 2, 3);
if ((match = /\b(\d{1,2})\/(\d{1,2})\/(\d{2})\b/.exec(str)) != null
&& (parseInt(match[1]) > 12 || /\b(?:be|it|au|nz)\b/i.test(countryCode))) return formatOutput(3, 2, 1); // BE, IT, AU, NZ
if ((match = /\b(\d{1,2})\/(\d{1,2})\/(\d{2})\b/.exec(str)) != null) return formatOutput(3, 1, 2); // US, MO
if ((match = /\b(\d{1,2})\/(\d{1,2})\/(\d{4})\b/.exec(str)) != null) return formatOutput(3, 2, 1); // UK, IE, FR, ES, FI, DK
if ((match = /\b(\d{1,2})-(\d{1,2})-((?:\d{2}|\d{4}))\b/.exec(str)) != null) return formatOutput(3, 2, 1); // NL
if ((match = /\b(\d{1,2})\. *(\d{1,2})\. *(\d{4})\b/.exec(str)) != null) return formatOutput(3, 2, 1); // CZ, DE
if ((match = /\b(\d{1,2})\. *(\d{1,2})\. *(\d{2})\b/.exec(str)) != null) return formatOutput(3, 2, 1); // AT, CH, DE, LU
if ((match = /\b(\d{4})\. *(\d{1,2})\. *(\d{1,2})\b/.exec(str)) != null) return formatOutput(1, 2, 3); // JP
return extractYear(str);
}
function extractYear(expr) {
if (typeof expr == 'number') return Math.round(expr);
if (typeof expr != 'string') return null;
if (/\b(\d{4})\b/.test(expr)) return parseInt(RegExp.$1);
let d = new Date(expr);
return parseInt(isNaN(d) ? expr : d.getFullYear());
}
function twoOrMore(artist) { return artist.length >= 2 && !pseudoArtistParsers.some(rx => rx.test(artist)) };
function looksLikeTrueName(artist, index = 0) {
return twoOrMore(artist) && (index == 0 || !/^(?:his\b|her\b|Friends$|Strings$)/i.test(artist))
&& artist.split(/\s+/).length >= 2 && !pseudoArtistParsers.some(rx => rx.test(artist));
}
function realArtistName(artist) {
return ![
pseudoArtistParsers[0],
pseudoArtistParsers[1],
pseudoArtistParsers[4],
].some(rx => rx.test(artist));
}
function splitArtists(str, parsers = multiArtistParsers) {
var result = [str];
parsers.forEach(function(parser) {
for (let i = result.length; i > 0; --i) {
let j = result[i - 1].split(parser).map(strip);
if (j.length > 1 && j.every(twoOrMore) && !j.some(artist => pseudoArtistParsers.some(rx => rx.test(artist))))
result.splice(i - 1, 1, ...j);
}
});
return result;
}
function splitAmpersands(artists) {
if (typeof artists == 'string') var result = splitArtists(artists);
else if (Array.isArray(artists)) result = Array.from(artists); else return [];
ampersandParsers.forEach(function(ampersandParser) {
for (let i = result.length; i > 0; --i) {
let j = result[i - 1].split(ampersandParser).map(strip);
if (j.length <= 1 || !j.every(looksLikeTrueName)) continue;
result.splice(i - 1, 1, ...j.filter(function(artist) {
return !result.includesCaseless(artist) && !pseudoArtistParsers.some(rx => rx.test(artist));
}));
}
});
return result;
}
function strip(art) {
return [
///\s+(?:aka|AKA)\.?\s+(.*)$/g,
tailingBracketStripper,
].reduce((acc, rx, ndx) => ndx != 1 || rx.test(acc)/* && !notMonospaced(RegExp.$1)*/ ? acc.replace(rx, '') : acc, art);
}
function joinArtists(arr, decorator = artist => artist) {
if (!Array.isArray(arr)) return null;
if (arr.some(artist => artist.includes('&'))) return arr.map(decorator).join(', ');
if (arr.length < 3) return arr.map(decorator).join(' & ');
return arr.slice(0, -1).map(decorator).join(', ') + ' & ' + decorator(arr.slice(-1).pop());
}
function guessDiscNumber() {
if (discParser.test(discSubtitle)) {
discSubtitle = undefined;
discNumber = parseInt(RegExp.$1);
}
}
let ref, artist, album, releaseDate, totalDiscs, totalTracks, isVA, label, composer, discSubtitle, discNumber,
url, tracks = [], genres = [], featArtists = [],
QOBUZ_ID = document.location.pathname.replace(/^.*\//, '');
if ((ref = document.querySelector('section.album-item[data-gtm]')) != null) try {
let gtm = JSON.parse(ref.dataset.gtm);
//if (gtm.shop.category) genres.push(gtm.shop.category);
if (gtm.shop.subCategory) var subCategory = gtm.shop.subCategory.replace(/-/g, ' ');
//if (gtm.type) var releaseType = gtm.type;
} catch(e) { console.warn(e) }
if ((ref = document.querySelector('div.album-meta > h2.album-meta__artist')) != null)
artist = ref.title || ref.textContent.trim();
isVA = vaParser.test(artist);
album = (ref = document.querySelector('div.album-meta > h1.album-meta__title')) != null ?
ref.title || ref.textContent.trim() : undefined;
featArtistParsers.slice(1, 6).forEach(function(rx, index) {
var matches = rx.exec(album);
if (matches == null) return;
Array.prototype.pushUniqueCaseless.apply(featArtists, splitAmpersands(matches[1]));
if (index < 5) album = album.replace(rx, '');
});
if ((featArtists = featArtists.filter(realArtistName)).length > 0 && !featTest.test(artist))
artist += ' feat. ' + joinArtists(featArtists);
releaseDate = (ref = document.querySelector('div.album-meta > ul > li:first-of-type')) != null ?
normalizeDate(ref.textContent) : undefined;
let mainArtist = (ref = document.querySelector('div.album-meta > ul > li:nth-of-type(2) > a')) != null ?
ref.title || ref.textContent.trim() : undefined;
if (mainArtist && featArtists.length > 0 && !featTest.test(mainArtist))
mainArtist += ' feat. ' + joinArtists(featArtists);
//ref = document.querySelector('p.album-about__copyright');
//if (ref != null) albumYear = extractYear(ref.textContent);
document.querySelectorAll('section#about > ul > li').forEach(function(it) {
function matchLabel(lbl) { return it.textContent.trimLeft().startsWith(lbl) }
if (/\b(\d+)\s*(?:dis[ck]|disco|disque)/i.test(it.textContent)) totalDiscs = parseInt(RegExp.$1);
if (/\b(\d+)\s*(?:track|pist[ae]|tracce|traccia)/i.test(it.textContent)) totalTracks = parseInt(RegExp.$1);
if (['Label', 'Etichetta', 'Sello'].some(l => it.textContent.trimLeft().startsWith(l)))
label = it.firstElementChild.textContent.replace(/\s+/g, ' ').trim();
else if (['Composer', 'Compositeur', 'Komponist', 'Compositore', 'Compositor'].some(matchLabel)) {
composer = it.firstElementChild.textContent.trim();
//if (pseudoArtistParsers.some(rx => rx.test(composer))) composer = undefined;
} else if (['Genre', 'Genere', 'Género'].some(g => it.textContent.startsWith(g)) && it.childElementCount > 0
&& genres.length <= 0) {
genres = Array.from(it.querySelectorAll('a')).map(elem => elem.textContent.trim());
/*
if (genres.length >= 1 && ['Pop/Rock'].includes(genres[0])) genres.shift();
if (genres.length >= 2 && ['Alternative & Indie'].includes(genres[genres.length - 1])) genres.shift();
if (genres.length >= 1 && ['Metal', 'Heavy Metal'].some(genre => genres.includes(genre)))
while (genres.length > 1) genres.shift();
*/
while (genres.length > 1) genres.shift();
}
});
let description = Array.from(document.querySelectorAll('section#description > p'))
.map(p => p.textContent.trim()).filter(Boolean).join('\n\n').flatten();
url = (ref = document.querySelector('meta[property="og:url"]')) != null ? ref.content : document.URL;
addTracks(document);
if (totalTracks <= 50) return Promise.resolve(finalizeTracks());
let params = new URLSearchParams({
albumId: QOBUZ_ID,
offset: 50,
limit: 999,
store: /\/(\w{2}-\w{2})\/album\//i.test(document.location.pathname) ? RegExp.$1 : 'fr-fr',
});
return localXHR('/v4/ajax/album/load-tracks?' + params).then(dom => { addTracks(dom) }, function(reason) {
console.error('localXHR() failed:', reason);
}).then(() => finalizeTracks());
function addTracks(dom) {
const roleNormalizer = role => role.toASCII().replace(/[\W]+/g, '').toLowerCase();
Array.prototype.push.apply(tracks, Array.from(dom.querySelectorAll('div.player__item > div.player__tracks > div.track > div.track__items')).map(function(div, index) {
let TRACK_ID = div.parentNode.dataset.track, personnel = [ ];
let trackTitle = (ref = [
'div.track__item--name > span', 'div.track__item--name--track > span', 'span.track__item--name',
].reduce((acc, sel) => acc || div.querySelector(sel), null)) != null ? ref.textContent.trim().replace(/\s+/g, ' ') : undefined;
for (let n = 0; n <= qobuzArtistLabels.length; ++n) personnel[n] = [ ];
if ((ref = div.parentNode.querySelector('p.track__info:first-of-type')) != null) {
ref.textContent.trim().split(/\s+[\-]\s+/).map(it => it.split(/\s*,\s+/)).forEach(function(it, ndx) {
// ========================================== EXPERIMENTAL ==========================================
if (it.length > 2) {
let ndx = it.findIndex((s, ndx) => ndx > 0
&& qobuzArtistLabels.some(artistLabels => artistLabels.includes(s)));
if (ndx > 1) it.splice(0, ndx, it.slice(0, ndx).join(', '));
//else if (ndx < 0) it = [it.join(', ')];
}
// ==================================================================================================
if (it.length > 1) qobuzArtistLabels.forEach(function(roles, index) {
if (roles.some(function(role) {
role = roleNormalizer(role);
return it.slice(1).some(it => roleNormalizer(it) == role);
})) personnel[index].pushUniqueCaseless(it[0]);
}); else {
personnel[qobuzArtistLabels.length].pushUniqueCaseless(it[0]);
console.debug('Qobuz uncategorized personnel:', it[0]);
}
});
}
//Array.prototype.push.apply(personnel[0], personnel[1]);
for (let ndx of [qobuzArtistLabels.length, 1, 2, 3, 4, 5])
if (personnel[0].length <= 0 && personnel[ndx].length > 0) personnel[0] = personnel[ndx];
featArtistParsers.slice(1, 6).forEach(function(rx, index) {
let matches = rx.exec(trackTitle);
if (matches == null) return;
Array.prototype.pushUniqueCaseless.apply(personnel[6], splitAmpersands(matches[2]));
if (index < 5) trackTitle = trackTitle.replace(rx, '');
});
//personnel[0] = personnel[0].filter(artist => !personnel[5].includes(artist));
if ((ref = div.querySelector('div.track__item--performer > span')
|| div.querySelector('div.track__item--name[itemprop="performer"] > span')) != null) {
const performer = ref.textContent.trim();
if (performer) for (let ndx of [0, 7]) if (personnel[ndx].length <= 0) personnel[ndx] = [performer];
}
for (let index = 0; index < personnel.length; ++index)
if (index != 8) personnel[index] = personnel[index].filter(realArtistName);
personnel[6] = personnel[6].filter(artist => ![0, 9].some(index => personnel[index].includes(artist)));
if (personnel[0].length > 0) var trackArtist = joinArtists(personnel[0]);
if (!trackArtist) if (!isVA) trackArtist = artist;
else console.warn('Qobuz: track main artist missing for track', index + 1, div);
if (trackArtist && personnel[6].length > 0) trackArtist += ' feat. ' + joinArtists(personnel[6]);
//console.debug('\tFiltered:', personnel[0], personnel[6]);
let trackGenres = [ ];
if (div.parentNode.dataset.gtm) try {
let gtm = JSON.parse(div.parentNode.dataset.gtm);
//if (gtm.product.id) QOBUZ_ID = gtm.product.id;
if (gtm.product.subCategory) trackGenres.pushUniqueCaseless(gtm.product.subCategory.replace(/-/g, ' '));
if (gtm.product.type) var releaseType = gtm.product.type;
} catch(e) { console.warn(e) }
trackGenres = trackGenres.map(function(genre) {
genre = genre.replace(/-/g, ' ');
qobuzTranslations.forEach(function(it) { if (genre.toLowerCase() == it[0].toLowerCase()) genre = it[1] });
return genre.split(/\s+/).map(word => word[0].toUpperCase() + word.slice(1).toLowerCase()).join(' ');
});
if ((ref = div.parentNode.parentNode.parentNode.querySelector('p.player__work:first-child')) != null) {
discSubtitle = ref.textContent.replace(/\s+/g, ' ').trim();
guessDiscNumber();
}
return [
/* 00 */ isVA ? VA : artist,
/* 01 */ album,
/* 02 */ releaseDate,
/* 03 */ genres.map(function(genre) {
qobuzTranslations.forEach(function(it) { if (genre.toLowerCase() == it[0].toLowerCase()) genre = it[1] });
return genre;
}).join(', '),
/* 04 */ label,
/* 05 */ totalDiscs > 1 ? discNumber || 1 : undefined,
/* 06 */ discSubtitle,
/* 07 */ totalDiscs > 1 ? totalDiscs : undefined,
/* 08 */ (ref = div.querySelector('div.track__item--number > span')
|| div.querySelector('span[itemprop="position"]')) != null ? parseInt(ref.textContent) : undefined,
/* 09 */ totalTracks,
/* 10 */ trackArtist,
/* 11 */ trackTitle,
/* 12 */ personnel[8].length > 0 ? personnel[8].join(', ') : composer,
/* 13 */ [personnel[0], personnel[qobuzArtistLabels.length], personnel.slice(1, 8)]
.flatten().distinctValues().join(', '),
/* 14 */ joinArtists(personnel[9]), // conductors
//joinArtists(personnel[10]),
//joinArtists(personnel[11]),
/* 15 */ 'Digital Media', // WEB
/* 16 */ url,
/* 17 */ description,
/* 18 */ releaseType && releaseType.toLowerCase() != 'album' ? releaseType : undefined,
];
}));
}
function finalizeTracks() {
if (!isVA && tracks.every(track => track[10] && track[10] == tracks[0][10]))
tracks.forEach(track => { track[0] = track[10] });
return tracks;
}
}
let button = document.querySelector('button.player-share__button');
if (button != null) {
button.onclick = copyTracks;
button.classList.remove('pct-share');
button.style = 'font: 700 small "Segoe UI", Tahome, sans-serif; padding: 3px; background-color: lightgray; width: 12em;';
button.textContent = 'Copy album metadata';
}
if (typeof GM_registerMenuCommand == 'function' && typeof GM_setClipboard == 'function')
GM_registerMenuCommand('Store foobar2000\'s parsing string to clipboard', function() {
GM_setClipboard([
/* 00 */ 'album artist', 'album', 'releasedate', 'genre', 'label', 'discnumber', 'discsubtitle', 'totaldiscs',
/* 08 */ 'tracknumber', 'totaltracks', 'artist', 'title', 'composer', 'performer', 'conductor', 'media', 'url',
/* 17 */ 'comment', 'releasetype',
].map(tagName => '%' + tagName + '%').join('\x1E'), 'text');
});