Qobuz - Copy album info

Copy metadata to parseable format

目前為 2021-04-25 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Qobuz - Copy album info
// @version      1.19.0
// @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_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      play.qobuz.com
// @connect      www.qobuz.com
// @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%%upc%%isrc%%explicit%

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 queryQobuzAPI(endPoint, params) {
	const qobuzApi = (function() {
		try {
			let qobuzApis = JSON.parse(window.sessionStorage.qobuzApis);
			if (qobuzApis.length > 0) return Promise.resolve(qobuzApis[qobuzApis.length - 1]);
		} catch(e) { }
		return globalXHR('https://play.qobuz.com/login').then(function({document}) {
			let script = document.body.querySelector('script[src]:last-of-type');
			if (script == null) return Promise.reject('invalid document structure');
			let url = new URL(script.src);
			url.hostname = 'play.qobuz.com';
			return globalXHR(url, { responseType: 'text' });
		}).then(function({responseText}) {
			let qobuzApis = responseText.match(/\b(?:n\.qobuzapi)=(\{.*?\})/g)
				.map(s => eval('(' + /\b(?:n\.qobuzapi)=(\{.*?\})/.exec(s)[1] + ')'));
			if (qobuzApis.length <= 0) return Promise.reject('invalid format (bundle.js)');
			window.sessionStorage.qobuzApis = JSON.stringify(qobuzApis);
			return qobuzApis[qobuzApis.length - 1];
		});
	})();

	function getUser() {
		try {
			let userInfo = JSON.parse(window.localStorage.qobuzUserInfo);
			if (!userInfo.user_auth_token) throw 'User info invalid';
			return Promise.resolve(userInfo);
		} catch(e) {
			let userId = GM_getValue('userid'), password = GM_getValue('password');
			if (!userId || !password) return Promise.reject('insufficient user credentials');
			return qobuzApi.then(qobuzApi => globalXHR(qobuzApi.base_url + qobuzApi.base_method + 'user/login', {
				responseType: 'json',
				headers: { 'X-App-Id': qobuzApi.app_id }
			}, new URLSearchParams({ username: userId, password:	password }))).then(function({response}) {
				window.localStorage.qobuzUserInfo = JSON.stringify(response);
				if (!response.user_auth_token) throw 'User info invalid';
				return response;
			});
		}
	}

	if (!endPoint) return Promise.reject('No API endpoint');
	return getUser().then(user => qobuzApi.then(function(qobuzApi) {
		let url = new URL(qobuzApi.base_url + qobuzApi.base_method + endPoint);
		if (params && typeof params == 'object') url.search = new URLSearchParams(params);
		return globalXHR(url, {
			responseType: 'json',
			headers: { 'X-App-Id': qobuzApi.app_id, 'X-User-Auth-Token': user.user_auth_token },
		}).then(({response}) => response);
	}));
}


function copyTracks(evt) {
	getTracks().then(function(tracks) {
		GM_setClipboard(tracks.map(track => track.map(field => field !== undefined ? field : '')
			.join('\x1E')).join('\n'), 'text');
		let img = document.createElement('img');
		img.src = 'data:image/png;base64,' +
			'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';
		img.style.height = '18px';
		console.debug(evt);
		evt.target.textContent = '';
		evt.target.append(img);
	}, 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,
			title, url, tracks = [ ], genres = [ ], featArtists = [ ], description, releaseType, trackArtist,
			domParser = new DOMParser, QOBUZ_ID = document.location.pathname.replace(/^.*\//, '');
	return queryQobuzAPI('album/get', { album_id: QOBUZ_ID, offset: 0, limit: 999 }).then(function(response) {
		if (response.tracks.total > response.tracks.limit) throw 'Tracklist length exceeding limit';
		switch (response.release_type || response.product_type) {
				//case 'album': releaseType = 'Album'; break;
			case 'single': releaseType = 'Single'; break;
			case 'ep': case 'epmini': releaseType = 'EP'; break;
		}
		isVA = vaParser.test(response.artist.name);
		artist = joinArtists(response.artists.map(artist => artist.name));
		album = response.title;
		if (response.version) {
			let version = ' (' + response.version + ')';
			if (!album.toLowerCase().endsWith(version.toLowerCase())) album += version;
		}
		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);
		if (response.description) description = domParser.parseFromString(response.description, 'text/html')
			.body.textContent.trim().flatten();
		response.tracks.items.forEach(function(track, index) {
			title = track.title;
			if (track.version) title += ' (' + track.version + ')';
			let personnel = [ ];
			for (let ndx = 0; ndx <= qobuzArtistLabels.length; ++ndx) personnel[ndx] = [ ];
			if (track.performers) {
				track.performers.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.map(roleNormalizer).some(role => it.slice(1).some(it => roleNormalizer(it) == role)))
							personnel[index].pushUniqueCaseless(it[0]);
					}); else {
						personnel[qobuzArtistLabels.length].pushUniqueCaseless(it[0]);
						if (prefs.diag_mode) console.debug('Qobuz uncategorized personnel:', it[0]);
					}
				});
			}
			//personnel[0] = personnel[0].filter(artist => !personnel[9].includes(artist));
			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(title);
				if (matches == null) return;
				Array.prototype.pushUniqueCaseless.apply(personnel[6], splitAmpersands(matches[2]));
				if (index < 5) title = title.replace(rx, '');
			});
			for (let ndx of [0, 7]) if (personnel[ndx].length <= 0) personnel[ndx] = [track.performer.name];
			personnel[6] = personnel[6].filter(artist => ![0, 9].some(index => personnel[index].includes(artist)));
			trackArtist = joinArtists(personnel[0]);
			//if (trackArtist && personnel[9].length > 0) trackArtist += ' under ' + joinArtists(personnel[9]);
			if (trackArtist && personnel[6].length > 0) trackArtist += ' feat. ' + joinArtists(personnel[6]);
			//if (prefs.diag_mode) console.debug('\tFiltered:', personnel[0], personnel[6]);
			tracks.push([
				/* 00 */ isVA ? VA : artist,
				/* 01 */ album,
				/* 02 */ response.release_date_original,
				/* 03 */ response.genre.name,
				/* 04 */ response.label.name,
				/* 05 */ response.media_count > 1 ? track.media_number || 1 : undefined,
				/* 06 */ track.work || undefined,
				/* 07 */ response.media_count > 1 ? response.media_count : undefined,
				/* 08 */ track.track_number || index + 1,
				/* 09 */ response.tracks_count || response.tracks.total,
				/* 10 */ trackArtist,
				/* 11 */ title,
				/* 12 */ personnel[8].length > 0 ? personnel[8].join(', ') : track.composer.name,
				/* 13 */ [personnel[0], personnel[qobuzArtistLabels.length], personnel.slice(1, 8)]
						.flatten().distinctValues().join(', ') || trackArtist || !isVA && artist,
				/* 14 */ joinArtists(personnel[9]), // conductors
				//joinArtists(personnel[10]),
				//joinArtists(personnel[11]),
				/* 15 */ 'Digital Media', // WEB
				/* 16 */ response.url,
				/* 17 */ description,
				/* 18 */ releaseType || undefined,
				/* 19 */ response.upc || undefined,
				/* 20 */ track.isrc || undefined,
				/* 21 */ track.parental_warning ? 1 : undefined,
			]);
		});
		return finalizeTracks();
	}).catch(function(reason) {
		console.info('Qobuz API method failed for the reason', reason);
		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();
			}
		});
		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) {
			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 = [ ];
				title = (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(title);
					if (matches == null) return;
					Array.prototype.pushUniqueCaseless.apply(personnel[6], splitAmpersands(matches[2]));
					if (index < 5) title = title.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) 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) releaseType = gtm.product.type;
				} catch(e) { console.warn(e) }
				trackGenres = trackGenres.map(function(genre) {
					genre = qbGenreToEnglish(genre.replace(/-/g, ' '))
					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(qbGenreToEnglish).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 */ title,
					/* 12 */ personnel[8].length > 0 ? personnel[8].join(', ') : composer,
					/* 13 */ [personnel[0], personnel[qobuzArtistLabels.length], personnel.slice(1, 8)]
						.flatten().distinctValues().join(', ') || trackArtist || !isVA && artist,
					/* 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', 'upc', 'isrc', 'explicit',
		].map(tagName => '%' + tagName + '%').join('\x1E'), 'text');
	});