Supraphonline foobar2000 tagging support

Kopíruje metadata alba ve formátu pro aplikaci tagů ve foobar2000

目前為 2020-09-07 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Supraphonline foobar2000 tagging support
// @name:cs      Supraphonline podpora tagování ve foobar2000
// @name:en      Supraphonline foobar2000 tagging support
// @namespace    https://greasyfork.org/cs/users/321857-anakunda
// @version      1.3
// @description  Kopíruje metadata alba ve formátu pro aplikaci tagů ve foobar2000
// @description:cs  Kopíruje metadata alba ve formátu pro aplikaci tagů ve foobar2000
// @description:en  Copies album metadata to clipboard in machine parseable format
// @author       Já, Osobně
// @copyright    2020, Anakunda (https://greasyfork.org/cs/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @iconURL      https://www.supraphonline.cz/favicon.ico
// @match        http*://*.supraphonline.cz/album/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @grant        GM_getValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @require      https://greasyfork.org/scripts/408084-xhrlib/code/xhrLib.js

// ==/UserScript==

// Výraz pro 'Automatically Fill Values' funkci ve foobaru2000:
//   %album artist%%album%%date%%releasedate%%genre%%label%%catalog%%discnumber%%totaldiscs%%discsubtitle%%tracknumber%%totaltracks%%artist%%title%%performer%%composer%%media%%comment%%url%

'use strict';

Array.prototype.pushUnique = function(...items) {
  if (Array.isArray(items) && items.length > 0) items.forEach(it => { if (!this.includes(it)) this.push(it) });
  return this.length;
};
Array.prototype.pushUniqueCaseless = function(...items) {
  if (Array.isArray(items) && items.length > 0) items.forEach(it => { if (!this.includesCaseless(it)) this.push(it) });
  return this.length;
};
Array.prototype.equalTo = function(arr) {
  return Array.isArray(arr) && arr.length == this.length
  	&& Array.from(arr).sort().toString() == Array.from(this).sort().toString();
};
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();
};

var hTimer = setInterval(function() {
  var ref = document.querySelector('form.table-action');
  if (ref == null) return;
  clearInterval(hTimer);
  var child = document.createElement('button');
  child.id = 'copy-info-to-clipboard';
  child.textContent = 'Kopírovat do schránky';
  child.type = 'button';
  child.name = 'copy-info-to-clipboard';
  child.className = 'btn btn-danger topframe_login';
  child.style.marginRight = '10px';
  child.onclick = fetchAlbum;
  ref.prepend(child);
}, 1000);

function fetchAlbum(evt) {
  var original_text = evt.target.textContent;
  evt.target.disabled = true;
  evt.target.textContent = 'Pracuji...';

  let tracks = [], discNumber, discSubtitle, domParser = new DOMParser(), ref, media, encoding, format, bitdepth,
	  trackIdentifiers, releaseDate, totalDiscs, samplerate, catalogue, imgUrl, album, totalTracks, albumYear,
	  label, identifiers = {};
  const vaParser = /^(?:Various(?:\s+Artists)?|VA|\<various\s+artists\>|Různí(?:\s+interpreti)?)$/i;
  const pseudoArtistParsers = [
	/^(?:#??N[\/\-]?A|[JS]r\.?)$/i,
	/^(?:traditional|trad\.|lidová)$/i,
	/\b(?:traditional|trad\.|lidová)$/,
	/^(?:tradiční|lidová)\s+/,
	/^(?:[Aa]nonym)/,
	/^(?:[Ll]iturgical\b|[Ll]iturgick[áý])/,
	/^(?:auditorium|[Oo]becenstvo|[Pp]ublikum)$/,
	/^(?:Various\s+Composers)$/i,
	/^(?:Guests|Friends)$/i,
  ];
  const VA = 'Various Artists';

  if (/\/album\/(\d+)\b/i.test(document.URL)) identifiers.SUPRAPHONLINE_ID = parseInt(RegExp.$1);
  let artist = Array.from(document.querySelectorAll('div.visible-lg-block > h2.album-artist > a'))
	.map(a => a.title || a.textContent.trim());
  let isVA = (ref = document.querySelector('span[itemprop="byArtist"] > meta[itemprop="name"]')) != null ?
	vaParser.test(ref.content) : artist.length <= 0;
  if ((ref = document.querySelector('h1[itemprop="name"]')) != null) album = ref.firstChild.data.trim();
  if ((ref = document.querySelector('meta[itemprop="numTracks"]')) != null) totalTracks = parseInt(ref.content);
  let genres = (ref = document.querySelector('meta[itemprop="genre"]')) != null ? ref.content : undefined;
  if ((ref = document.querySelector('li.album-version > div.selected > div')) != null) {
	if (/\b(?:MP3)\b/.test(ref.textContent)) {
	  media = 'WEB'; encoding = 'lossy'; format = 'MP3';
	}
	if (/\b(?:FLAC)\b/.test(ref.textContent)) {
	  media = 'WEB'; encoding = 'lossless'; format = 'FLAC'; bitdepth = 16;
	}
	if (/\b(?:Hi[\s\-]*Res)\b/.test(ref.textContent)) {
	  media = 'WEB'; encoding = 'lossless'; format = 'FLAC'; bitdepth = 24;
	}
	if (/\b(?:CD)\b/.test(ref.textContent)) media = 'CD';
	if (/\b(?:LP)\b/.test(ref.textContent)) media = 'Vinyl';
  }
  const copyrightParser = /^(?:\([PC]\)|℗|©)$/i;
  document.querySelectorAll('ul.summary > li').forEach(function(li) {
	if (li.childElementCount <= 0) return;
	let key = li.firstElementChild.textContent, value = li.lastChild.textContent.trim();
	if (key.includes('Nosič')) media = value;
	if (key.includes('Datum vydání')) releaseDate = normalizeDate(value, 'cs');
	if (key.includes('První vydání')) albumYear = extractYear(value);
	if (key.includes('Žánr')) genres = translateGenre(value);
	if (key.includes('Vydavatel')) label = value;
	if (key.includes('Katalogové číslo')) catalogue = value;
	if (key.includes('Formát')) {
	  if (/\b(?:FLAC|WAV|AIFF?)\b/.test(value)) { encoding = 'lossless'; format = 'FLAC' }
	  if (/\b(\d+)[\-\s]?bits?\b/i.test(value)) bitdepth = parseInt(RegExp.$1);
	  if (/\b([\d\.\,]+)[\-\s]?kHz\b/.test(value)) samplerate = parseFloat(RegExp.$1.replace(',', '.')) * 1000;
	}
	//if (key.includes('Celková stopáž')) totalTime = timeStringToTime(value);
	if (copyrightParser.test(key) && !albumYear) albumYear = extractYear(value);
  });
  const creators = ['autoři', 'interpreti', 'tělesa', 'digitalizace'];
  let artists = [], ndx;
  for (let i = 0; i < creators.length; ++i) artists[i] = {};
  document.querySelectorAll('ul.sidebar-artist > li').forEach(function(it) {
	if ((ref = it.querySelector('h3')) != null) {
	  ndx = undefined;
	  creators.forEach((it, _ndx) => { if (ref.textContent.includes(it)) ndx = _ndx });
	} else {
	  if (typeof ndx != 'number') return;
	  if (ndx == 2) var role = 'ensemble';
		else if ((ref = it.querySelector('span')) != null) role = translateRole(ref);
	  if ((ref = it.querySelector('a')) != null) {
		if (!Array.isArray(artists[ndx][role])) artists[ndx][role] = [];
		artists[ndx][role].pushUnique([ref.textContent.trim(), origin + ref.pathname]);
	  }
	}
  });
  let description = Array.from(document.querySelectorAll('div[itemprop="description"] p'))
  	.map(p => p.textContent.trim()).join('\n\n').replace(/\s+/g, ' ');
  let performers = [], composer = [], conductor = [], DJs = [], volMedia;
  for (let i = 1; i < 3; ++i) Object.keys(artists[i]).forEach(function(role) { // performers
	var a = artists[i][role].map(a => a[0]);
	artist.pushUnique(...a);
	([
	  'conductor',
	  'choirmaster',
	  'director',
	].includes(role) ? conductor : role == 'DJ' ? DJs : performers).pushUnique(...a);
  });
  Object.keys(artists[0]).forEach(function(role) { // composers
	composer.pushUnique(...artists[0][role].map(it => it[0])
		.filter(it => !pseudoArtistParsers.some(rx => rx.test(it))));
  });
  if ((ref = document.querySelector('meta[itemprop="image"]')) != null) imgUrl = ref.content.replace(/\?.*$/, '');
  document.querySelectorAll('table.table-tracklist > tbody > tr').forEach(function(tr, index) {
	if (tr.classList.contains('cd-header') && (ref = tr.querySelector('td > h3')) != null
		&& /\b(?:(\S*?)\s*)?(\d+)\b/.test(ref.textContent)) {
	  volMedia = RegExp.$1 ? RegExp.lastMatch : undefined;
	  discNumber = parseInt(RegExp.$2) || undefined;
	}
	if (tr.classList.contains('song-header') && (ref = tr.querySelector('td')) != null)
	  discSubtitle = ref.title || ref.textContent.trim();
	if (tr.classList.contains('track') && tr.id) {
	  trackIdentifiers = {
		TRACK_ID: /^(?:track)-(\d+)$/i.test(tr.id) ? parseInt(RegExp.$1) : undefined,
	  };
	  if (volMedia) trackIdentifiers.VOL_MEDIA = volMedia;
	  let track = {
		artist: isVA ? VA : undefined,
		artists: !isVA ? artist : undefined,
		album: album,
		album_year: /*trackYear || */albumYear || undefined,
		release_date: releaseDate,
		label: label,
		catalog: catalogue,
		encoding: encoding,
		codec: format,
		bitdepth: bitdepth,
		samplerate: samplerate || undefined,
		media: media,
		genre: genres,
		disc_number: discNumber,
		total_discs: totalDiscs,
		disc_subtitle: discSubtitle,
		track_number: /^\s*(\d+)\.?\s*$/.test(tr.children[0].firstChild.textContent) ?
			parseInt(RegExp.$1) || RegExp.$1 : undefined,
		total_tracks: totalTracks,
		title: (ref = tr.querySelector('meta[itemprop="name"][content]')) != null ? ref.content
			: (ref = tr.querySelector('td > a.trackdetail')) != null ? ref.textContent.trim() : undefined,
		performers: performers.length > 0 ? performers : undefined,
		composers: composer.length > 0 ? composer : undefined,
		conductors: conductor.length > 0 ? conductor : undefined,
		compilers: DJs.length > 0 ? DJs : undefined,
		duration: durationFromMeta(tr),
		url: document.location.origin + document.location.pathname,
		description: description,
		identifiers: mergeIds(),
		cover_url: imgUrl,
	  };
	  tracks.push((function() {
		if ((ref = tr.querySelector('td > a.trackdetail')) == null) return Promise.reject('link not found');
		return globalFetch(origin + ref.pathname + ref.search).then(function(response) {
		  var detail = response.document.querySelector('div[data-swap="trackdetail-' +
			track.identifiers.TRACK_ID + '"] > div > div.row');
		  if (detail == null) return Promise.reject('element not found');
		  detail.querySelectorAll('div[class]:nth-of-type(1) > ul > li').forEach(function(li) {
			var key = li.querySelector('span'), value = li.lastChild.textContent.trim();
			if (key != null && value) key = key.textContent.trim(); else return;
			if (key.startsWith('Žánr')) track.genre = value;
			if (key.startsWith('Nahrávka dokončena')) track.rec_year = extractYear(value);
			if (key.startsWith('Místo nahrání')) track.venue = value;
			if (key.startsWith('Rok prvního vydání')) track.pub_year = extractYear(value);
			if (copyrightParser.test(key)) track.copyright = value;
		  });
		  let trackArtists = [];
		  for (let i = 0; i < 8; ++i) trackArtists[i] = [];
		  detail.querySelectorAll('div[class]:nth-of-type(2) > ul > li').forEach(function(li) {
			var key = li.querySelector('span');
			var artists = Array.from(li.getElementsByTagName('a')).map(a => a.textContent.trim())
				.filter(artist => !pseudoArtistParsers.some(rx => rx.test(artist)));
			if (key != null && artists.length > 0) key = translateRole(key); else return;
			function oneOf(...arr) { return arr.some(role => key == role) }
			if (key.startsWith('remix'))
			  trackArtists[2].pushUnique(...artists);
			else if (oneOf('music', 'lyrics', 'music+lyrics', 'original lyrics', 'czech lyrics', 'libreto', 'music improvisation', 'author'))
			  trackArtists[3].pushUnique(...artists);
			else if (key == 'DJ')
			  trackArtists[5].pushUnique(...artists);
			else if (key == 'produced by')
			  trackArtists[6].pushUnique(...artists);
			else if (key == 'recorded by') {
			} else {
			  if (oneOf('ensemble') || /\b(?:vocals)\b/.test(key))
				trackArtists[0].pushUnique(...artists);
			  else if (oneOf('conductor', 'choirmaster', 'director'))
			  	trackArtists[4].pushUnique(...artists);
			  trackArtists[7].pushUnique(...artists.map(artist => `${artist} (${key})`));
			}
		  });
		  if (trackArtists[0].length > 0 && (isVA || !trackArtists[0].equalCaselessTo(artist)))
			track.track_artists = trackArtists[0];
		  [
			[3, 'composer'],
			[4, 'conductor'],
			[2, 'remixer'],
			[5, 'compiler'],
			[6, 'producer'],
			[7, 'performer'],
		  ].forEach(def => { if (trackArtists[def[0]].length > 0) track[def[1] + 's'] = trackArtists[def[0]] })
		  return track;
		});
	  })().catch(function(reason) {
		console.error('Supraphonline parser failed to get track', index + 1, 'detail:', reason);
		return Promise.resolve(track);
	  }));
	} // track
  });
  Promise.all(tracks).then(tracks => tracks.map(track => [
	isVA ? VA : joinArtists(track.artists) || '',
	track.album || '',
	track.album_year || track.pub_year || '',
	track.release_date || '',
	track.genre || '',
	track.label || '',
	track.catalog || '',
	track.disc_number || '',
	track.total_discs > 1 ? track.total_discs : '',
	track.disc_subtitle || '',
	track.track_number || '',
	track.total_tracks || '',
	(Array.isArray(track.track_artists) && track.track_artists.length > 0 ? joinArtists(track.track_artists)
		: Array.isArray(track.artists) && track.artists.length > 0 ? joinArtists(track.artists) : track.artist) || '',
	track.title || '',
	(track.performers || []).join(', '),
	(track.composers || []).join(', '),
	track.media,
	track.description,
	track.url,
  ].join('\x1E')).join('\n')).then(clipBoard => { GM_setClipboard(clipBoard, 'text') }).catch(e => { alert(e) }).then(function() {
	evt.target.disabled = false;
	evt.target.textContent = original_text;
  });

  function translateGenre(genre) {
	if (!genre || typeof genre != 'string') return undefined;
	[
	  ['Orchestrální hudba', 'Orchestral Music'],
	  ['Komorní hudba', 'Chamber Music'],
	  ['Vokální', 'Classical, Vocal'],
	  ['Klasická hudba', 'Classical'],
	  ['Melodram', 'Classical, Melodram'],
	  ['Symfonie', 'Symphony'],
	  ['Vánoční hudba', 'Christmas Music'],
	  [/^(?:Alternativ(?:ní|a))$/i, 'Alternative'],
	  ['Dechová hudba', 'Brass Music'],
	  ['Elektronika', 'Electronic'],
	  ['Folklor', 'Folclore, World Music'],
	  ['Instrumentální hudba', 'Instrumental'],
	  ['Latinské rytmy', 'Latin'],
	  ['Meditační hudba', 'Meditative'],
	  ['Vojenská hudba', 'Military Music'],
	  ['Pro děti', 'Children'],
	  ['Pro dospělé', 'Adult'],
	  ['Mluvené slovo', 'Spoken Word'],
	  ['Audiokniha', 'audiobook'],
	  ['Humor', 'humour'],
	  ['Pohádka', 'Fairy-Tale'],
	].forEach(function(subst) {
	  if (typeof subst[0] == 'string' && genre.toLowerCase() == subst[0].toLowerCase()
		 || subst[0] instanceof RegExp && subst[0].test(genre)) genre = subst[1];
	});
	return genre;
  }

  function translateRole(elem) {
	return elem instanceof HTMLElement ? [
	  [/\b(?:klavír)\b/, 'piano'],
	  [/\b(?:housle)\b/, 'violin'],
	  [/\b(?:varhany)\b/, 'organ'],
	  [/\b(?:cembalo)\b/, 'harpsichord'],
	  [/\b(?:trubka)\b/, 'trumpet'],
	  [/\b(?:soprán)\b/, 'soprano'],
	  [/\b(?:alt)\b/, 'alto'],
	  [/\b(?:baryton)\b/, 'baritone'],
	  [/\b(?:bas)\b/, 'basso'],
	  [/\b(?:syntezátor)\b/, 'synthesizer'],
	  [/\b(?:zpěv)\b/, 'vocals'],
	  [/\b(?:čte|četba)\b/, 'narration'],
	  [/\b(?:akustická kytara)\b/, 'acoustic guitar'],
	  [/\b(?:kytara)\b/, 'guitar'],
	  ['hudební těleso', 'ensemble'],
	  ['vypravuje', 'narration'],
	  ['komentář', 'commentary'],
	  ['hovoří a zpívá', 'speaks and sings'],
	  ['hovoří', 'spoken by'],
	  ['improvizace', 'improvisation'],
	  ['původní text', 'original lyrics'],
	  ['hudba+text', 'music+lyrics'],
	  ['český text', 'czech lyrics'],
	  ['text', 'lyrics'],
	  ['hudba', 'music'],
	  ['hudební improvizace', 'music improvisation'],
	  ['autor', 'author'],
	  ['účinkuje', 'participating'],
	  ['dirigent', 'conductor'],
	  ['sbormistr', 'choirmaster'],
	  ['řídí', 'director'],
	  ['produkce', 'produced by'],
	  ['nahrál', 'recorded by'],
	  ['digitální přepis', 'A/D transfer'],
	].reduce((r, def) => r.replace(...def), elem.textContent.trim().toLowerCase().replace(/\s*:.*$/, '')) : undefined;
  }

  function mergeIds() {
	var r = Object.assign({}, identifiers, trackIdentifiers);
	trackIdentifiers = {};
	return r;
  }
}

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 timeStringToTime(str) {
  if (!/(-\s*)?\b(\d+(?::\d{2})*(?:\.\d+)?)\b/.test(str)) return null;
  var t = 0, a = RegExp.$2.split(':');
  while (a.length > 0) t = t * 60 + parseFloat(a.shift());
  return RegExp.$1 ? -t : t;
}

function normalizeDate(str, countryCode = undefined) {
  if (typeof str != 'string') return null;
  var 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}|\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);
  var d = new Date(expr);
  return parseInt(isNaN(d) ? expr : d.getFullYear());
}

function durationFromMeta(elem) {
  if (!(elem instanceof HTMLElement)) return undefined;
  let meta = elem.querySelector('meta[itemprop="duration"][content]');
  if (meta == null) return undefined;
  let m = /^PT?(?:(?:(\d+)H)?(\d+)M)?(\d+)S$/i.exec(meta.content);
  if (m != null)
	return (parseInt(RegExp.$1) || 0) * 60**2 + (parseInt(RegExp.$2) || 0) * 60 + (parseInt(RegExp.$3) || 0);
  m = timeStringToTime(meta.content);
  return m != null ? m : undefined;
}