Qobuz - Copy album info

Copy metadata to parseable format

当前为 2020-12-07 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Qobuz - Copy album info
// @version      1.14
// @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
// @require      https://greasyfork.org/scripts/408084-xhrlib/code/xhrLib.js
// @require      https://greasyfork.org/scripts/406257-qobuzlib/code/QobuzLib.js
// ==/UserScript==

// patter 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%

createButton();

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();
};

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/plain');
	}, 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)?|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) {
			var 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
		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)/.test(countryCode))) return formatOutput(3, 2, 1); // BE, IT
		if ((match = /\b(\d{1,2})\/(\d{1,2})\/(\d{2})\b/.exec(str)) != null) return formatOutput(3, 1, 2); // US
		if ((match = /\b(\d{1,2})\/(\d{1,2})\/(\d{4})\b/.exec(str)) != null) return formatOutput(3, 2, 1); // UK, IE, FR, ES
		if ((match = /\b(\d{1,2})-(\d{1,2})-(\d{2})\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) {
		Array.prototype.push.apply(tracks, Array.from(dom.querySelectorAll('div.player__item > div.player__tracks > div.track > div.track__items')).map(function(tr, index) {
			let TRACK_ID = tr.parentNode.dataset.track, trackArtists = [];
			let trackTitle = (ref = tr.querySelector('div.track__item--name[itemprop="name"] > span')
				|| tr.querySelector('span.track__item--name')) != null ? ref.textContent.trim().replace(/\s+/g, ' ') : undefined;
			for (let n = 0; n < qobuzArtistLabels.length; ++n) trackArtists[n] = [];
			if ((ref = tr.parentNode.querySelector('p.track__info:first-of-type')) != null) {
				ref.textContent.trim().split(/\s+-\s+/).map(it => it.split(/\s*,\s*/)).forEach(function(it) {
					if (it.length > 1) qobuzArtistLabels.forEach(function(artistLabels, index) {
						if (artistLabels.some(role => it.slice(1).includes(role))) trackArtists[index].pushUnique(it[0]);
					}); else trackArtists[0].pushUnique(it[0]);
				});
			}
			if (trackArtists[0].length <= 0 && trackArtists[1].length > 0) trackArtists[0] = trackArtists[1];
			featArtistParsers.slice(1, 6).forEach(function(rx, index) {
				let matches = rx.exec(trackTitle);
				if (matches == null) return;
				Array.prototype.pushUniqueCaseless.apply(trackArtists[2], splitAmpersands(matches[2]));
				if (index < 5) trackTitle = trackTitle.replace(rx, '');
			});
			//trackArtists[0] = trackArtists[0].filter(artist => !trackArtists[5].includes(artist));
			if ((ref = tr.querySelector('div.track__item--performer > span')
					|| tr.querySelector('div.track__item--name[itemprop="performer"] > span')) != null) {
				let performer = ref.textContent.trim();
				if (performer) {
					if (trackArtists[0].length <= 0) trackArtists[0] = [performer];
					if (trackArtists[3].length <= 0) trackArtists[3] = [performer];
				}
			}
			for (let index = 0; index < trackArtists.length; ++index)
				if (index != 4) trackArtists[index] = trackArtists[index].filter(realArtistName);
			trackArtists[2] = trackArtists[2].filter(artist => ![0, 5].some(index => trackArtists[index].includes(artist)));
			if (trackArtists[0].length > 0) var trackArtist = joinArtists(trackArtists[0]);
			if (!trackArtist) if (!isVA) trackArtist = artist;
				else console.warn('Qobuz: track main artist missing for track', index + 1, tr);
			if (trackArtist && trackArtists[2].length > 0) trackArtist += ' feat. ' + joinArtists(trackArtists[2]);
			//console.debug('\tFiltered:', trackArtists[0], trackArtists[2]);
			let trackGenres = [];
			if (tr.parentNode.dataset.gtm) try {
				let gtm = JSON.parse(tr.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 = tr.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 = tr.querySelector('div.track__item--number > span')
					|| tr.querySelector('span[itemprop="position"]')) != null ? parseInt(ref.textContent) : undefined,
				/* 09 */ totalTracks,
				/* 10 */ trackArtist,
				/* 11 */ trackTitle,
				/* 12 */ trackArtists[4].length > 0 ? trackArtists[4].join(', ') : composer,
				/* 13 */ trackArtists[3].join(', '),
				/* 14 */ joinArtists(trackArtists[5]), // conductors
				//joinArtists(trackArtists[6]),
				//joinArtists(trackArtists[7]),
				/* 15 */ '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;
	}
}

function createButton() {
	let button = document.querySelector('button.player-share__button');
	if (button != null) button.onclick = copyTracks;
}