Anakunda / gazelleLib

// ==UserScript==
// ==UserLibrary==
// @name         gazelleLib
// @namespace    https://openuserjs.org/users/Anakunda
// @version      1.114
// @author       Anakunda
// @license      GPL-3.0-or-later
// @copyright    2021, Anakunda (https://openuserjs.org/users/Anakunda)
// @iconURL      https://redacted.ch/favicon.ico
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @connect      geo.ipify.org
// @exclude      *
// ==/UserScript==
// ==/UserLibrary==

const originRED = 'https://redacted.ch';
const originOPS = 'https://orpheus.network';
const originNWCD = 'https://notwhat.cd';
const originQobuz = 'https://www.qobuz.com';
const originHRA = 'https://www.highresaudio.com';
const originEonkyo = 'https://www.e-onkyo.com';
const originMora = 'https://mora.jp';
const size_tolerance = 5; // maximum deviation of torrent size in % that is considered identical
const gazelleApiFrame = 10500;

const qobuzGenresRating = {
  0: [
	'Acid Jazz', 'Acid jazz',
	'Alternative & Indie', 'Alternatif et Indé', 'Alternativ und Indie', 'Alternativa & Indie', 'Musica alternativa e indie', 'Alternative en Indie',
	'Ambient', 'Ambientes',
	'Blues',
	'Blues/Country/Folk', 'Blues/country/folk',
	'Chill-out', 'Downtempo',
	'Crossover',
	'Dance',
	'Disco',
	'Dub',
	'Electronic', 'Electronic/Dance', 'Électronique', 'Electrónica', 'Elettronica',
	'Folk', 'Folk/Americana',
	'Funk',
	'Hard Rock', 'Hard rock', 'Hardrock',
	'Indie Pop', 'Pop indé', 'Indie-Pop', 'Pop indie', 'Indie pop', 'Indiepop',
	'Lounge',
	'Metal',
	'Pop',
	'Pop/Rock',
	'Progressive Rock', 'Rock progressif', 'Rock progresivo', 'Rock progressivo', 'Progressieve rock',
	'Punk / New Wave', 'Punk - New Wave', 'Punk – New Wave', 'Punk/New wave', 'Punk en New Wave',
	'R&B',
	'Reggae',
	'Rock',
	'Rockabilly',
	'Ska & Rocksteady', 'Ska e rocksteady', 'Ska en Rocksteady',
	'Soul',
	'Soul/Funk/R&B', 'R&B/Soul',
  ],
  0.10: [
	//'Afrobeat',
	'Contemporary Jazz', 'Jazz contemporain', 'Modern Jazz', 'Jazz contemporáneo', 'Jazz contemporaneo', 'Moderne jazz',
	'Dancehall',
	'French Artists', 'Interprètes de chanson française', 'Französische Chanson-Sänger', 'Intérpretes de chanson francesa', 'Artisti francesi', 'Zangers van Franse chansons',
	'French Music', 'Chanson française', 'Französischer Chanson', 'Chanson francesa', 'Musica francese', 'Franse chansons', 'Variété francophone',
	'French Rock', 'Rock français', 'Französischer Rock', 'Rock francés', 'Rock francese', 'Franse rock',
	'Irish Celtic', 'Irish celtic', 'Irisch-keltische Musik', 'Música celta irlandesa', 'Musica celtica irlandese', 'Iers Keltisch',
	'Irish Pop Music', 'Irish popmusic', 'Irische Popmusik', 'Música pop irlandesa', 'Musica pop irlandese', 'Ierse popmuziek',
	'Jazz Fusion & Jazz Rock', 'Jazz fusion & Jazz rock', 'Jazz Fusion & Jazzrock', 'Jazz fusión & Jazz rock', 'Fusion & Jazz rock', 'Jazz fusion en jazz rock',
	'Jazz',
	'Latin Jazz', 'Latin jazz',
	'Vocal Jazz', 'Jazz vocal', 'Jazzgesang', 'Vocal jazz', 'Vocale jazz',
  ],
  0.20: [
	'Africa', 'Afrique', 'Afrika', 'África',
	'Asia', 'Asie', 'Asien', 'Azië',
	'Bebop', 'Be Bop',
	'Bossa Nova & Brazil', 'Bossa Nova & Brésil', 'Bossa Nova & brasilianische Musik', 'Bossa nova & Brasil', 'Bossa nova e musica brasiliana ', 'Bossanova en Brazilië',
	'Celtic', 'Celtique', 'Keltische Musik', 'Celta', 'Musica celtica', 'Keltisch',
	'Cool Jazz', 'Cool jazz', 'Cooljazz',
	'Country',
	'Crooners', 'Crooner', 'Musica crooner',
	'Dixieland', 'Dixie',
	'Drum & Bass', 'Drum \'n\' bass',
	'Eastern Europe', 'Europe de l\'Est', 'Osteuropa', 'Europa del Este', 'Europa dell\'est', 'Oost-Europa',
	//'Europe', 'Europa',
	'Fado',
	'Film Soundtracks', 'Bandes originales de films', 'Original Soundtrack', 'Bandas sonoras de cine', 'Colonne sonore', 'Originele soundtracks',
	'Flamenco',
	'Free Jazz & Avant-Garde', 'Free jazz & Avant-garde', 'Free Jazz & Avantgarde', 'Free jazz & Vanguardia', 'Free jazz et jazz d\'avanguardia', 'Free jazz & Avant-garde jazz',
	'Greece', 'Grèce', 'Griechenland', 'Grecia', 'Griekenland',
	'Gypsy', 'Gipsy', 'Musik der Roma', 'Gitano', 'Zigeunermuziek',
	'Gypsy Jazz', 'Jazz manouche', 'Gypsy-Jazz', 'Gipsy jazz',
	'House',
	'Indian Music', 'Musique indienne', 'Indische Musik', 'Música india', 'Musica indiana', 'Indiase muziek',
	'Ireland', 'Irlande', 'Irland', 'Irlanda', 'Ierland',
	'Italy', 'Italie', 'Italien', 'Italia', 'Italië',
	'Latin America', 'Amérique latine', 'Lateinamerika', 'Latinoamérica', 'America latina', 'Latijns-Amerika',
	'Maghreb', 'Magreb', 'Noord-Afrika',
	'North America', 'Amérique du Nord', 'Nordamerika', 'Norteamérica', 'Amercia del nord', 'Noord-Amerika',
	'Oriental Music', 'Orient', 'Oriente', 'Musica orientale', 'Oosters',
	'Portugal', 'Portogallo',
	'Ragtime',
	'Raï',
	'Russia', 'Russie', 'Russland', 'Rusia', 'Rusland',
	'Salsa',
	'Scottish', 'Ecosse', 'Schottland', 'Escocia', 'Scozia', 'Schotland',
	'Soundtracks', 'Film', 'Cine', 'Cinema', 'Soundtrack',
	'Spain', 'Espagne', 'Spanien', 'España', 'Spagna', 'Spanje',
	'Swiss Folk Music', 'Musique folklorique Suisse', 'Schweizer Volksmusik', 'Música folclórica suiza', 'Musica folclorica svizzera', 'Zwitserse volksmuziek',
	'Tango',
	'Traditional Jazz & New Orleans', 'Jazz traditionnel & New Orleans', 'Klassischer Jazz & New-Orleans-Jazz', 'Jazz tradicional & Nueva Orleans', 'Jazz tradizionale & New Orleans', 'Traditionele jazz en dixieland',
	'World', 'Musiques du monde', 'Aus aller Welt', 'World music', 'Wereldmuziek',
	'Yiddish & Klezmer', 'Jiddische Musik & Klezmer', 'Musica yiddish e klezmer', 'Jiddisch en klezmer',
	'Zouk & Antilles', 'Zouk & Musik von den Antillen', 'Zouk & Antillas', 'Musica zouk e Antille', 'Zouk en Antilliaans',
  ],
  0.30: [
	'Ambient/New Age', 'Ambiance', /*'Lounge', 'Ambientes', */'Musica d\'ambiente/New Age', 'Ambient / New Age / Easy Listening',
	'Christmas Music', 'Musiques de Noël', 'Weihnachtsmusik', 'Músicas navideñas', 'Canzoni di Natale', 'Kerstmuziek',
	'International Pop', 'Variété internationale', 'Internationaler Pop', 'Variété internacional', 'Pop internazionale', 'Internationaal variété',
	'Musical Theatre', 'Comédies musicales', 'Musical', 'Comedias musicales', 'Musicals',
	'New Age', 'Musica new Age', 'New age',
	'Relaxation', 'Entspannung', 'Relajación', 'Musica rilassante', 'Ontspanning',
	'Retro French Music', 'Chanson française rétro', 'Französisches Retro-Chanson', 'Chanson francesa retro', 'Musica francese retrò', 'Oude Franse chansons',
	'Trance',
	'Turkey', 'Turquie', 'Türkei', 'Turquía', 'Turchia', 'Turkije',
	'Trip Hop', 'Triphop',
	'TV Series', 'Séries TV', 'TV-Serien', 'Series de televisión', 'Serie TV', 'Tv-series',
	'Video Games', 'Jeux vidéo', 'Computerspiele', 'Vídeojuegos', 'Video Giochi', 'Videogames',
  ],
  0.40: [
	'Gospel',
	'Military Music', 'Musique militaire', 'Militärmusik', 'Música militar', 'Musica militare', 'Militaire muziek',
  ],
  // Hide these
  0.50: [
	'Accordion', 'Accordéon', 'Akkordeon', 'Acordeón', 'Fisarmonica', 'Accordeon',
	'Art Songs', 'Lieder', 'Kunstlieder', 'Liederen',
	'Art Songs, Mélodies & Lieder', 'Mélodies & Lieder', 'Französische Mélodies und Kunstlieder', 'Liederen',
	'Ballets', 'Ballett', 'Balletti', 'Balletten',
	'Bawdy songs', 'Chansons paillardes', 'Canciones gamberras', 'Canzoni licenziose', 'Schuine liedjes',
	'Cantatas (sacred)', 'Cantates sacrées', 'Geistliche Kantaten', 'Cantatas sacras', 'Cantate sacre', 'Religieuze cantates',
	'Cantatas (secular)', 'Cantates (profanes)', 'Kantaten (weltlich)', 'Cantatas (profanas)', 'Cantate (profane)', 'Cantates (wereldlijk)',
	'Cello Concertos', 'Concertos pour violoncelle', 'Cellokonzerte', 'Conciertos para violonchelo', 'Concerti per violoncello', 'Concerten voor cello',
	'Cello Solos', 'Violoncelle solo', 'Cellosolo', 'Violonchelo solo', 'Assoli per violoncello', 'Cello solo',
	'Chamber Music', 'Musique de chambre', 'Kammermusik', 'Música de cámara', 'Musica da camera', 'Kamermuziek',
	'Children', 'Enfants', 'Kinder', 'Infantil', 'Infanzia', 'Kinderen',
	'Choirs (sacred)', 'Chœurs sacrés', 'Geistliche Chormusik', 'Coros sacros', 'Cori sacri', 'Religieuze koormuziek',
	'Choral Music (Choirs)', 'Musique chorale (pour chœur)', 'Chorwerk (für den Chor)', 'Música coral (para coro)', 'Musica corale', 'Koormuziek',
	'Cinema Music', 'Musiques pour le cinéma', 'Filmmusik', 'Bandas sonoras', 'Musiche per il cinema', 'Soundtrack',
	'Classical', 'Classique', 'Klassik', 'Clásica', 'Classica', 'Klassiek',
	'Concertos for trumpet', 'Concertos pour trompette', 'Trompetenkonzerte', 'Conciertos para trompeta', 'Concerti per tromba', 'Concerten voor trompet',
	'Concertos for wind instruments', 'Concertos pour instruments à vent', 'BläserKonzerte', 'Conciertos para instrumentos de viento', 'Concerti per strumenti a fiato', 'Concerten voor blaasinstrumenten',
	'Concertos', 'Musique concertante', 'Instrumentalmusik', 'Música concertante', 'Musica concertante', 'Concertmuziek',
	'Duets', 'Duos', 'Duette', 'Dúos', 'Duetti', 'Duo´s',
	'Dutch', 'Néerlandais', 'Niederländisch', 'Neerlandés', 'Olandese', 'Nederlands',
	'Educational', 'Educatif', 'Bildung', 'Educativa', 'Musica educativa', 'Educatief',
	'Educational', 'Pédagogie', 'Pädagogik', 'Pedagogía', 'Musica educativa', 'Pedagogiek',
	//'Electronic', 'Musique électronique', 'Elektronische Musik', 'Música electrónica', 'Musica elettronica', 'Elektronische muziek',
	'English', 'Anglais', 'Englisch', 'Inglés', 'Inglese', 'Engels',
	//'Experimental', 'Électronique ou concrète', 'Elektronische Musik oder Musique concrète', 'Electrónica o musique concrète', 'Musica elettronica/concreta', 'Elektronische muziek of Musique Concrète',
	'French', 'Français', 'Französisch', 'Francés', 'Francese', 'Frans',
	'Full Operas', 'Intégrales d\'opéra', 'Gesamtaufnahmen von Opern', 'Integrales de ópera', 'Opere integrali', 'Volledige opera\'s',
	'German', 'Allemand', 'Deutsch', 'Alemán', 'Tedesco', 'Duits',
	'Germany', 'Allemagne', 'Deutsche Musik', 'Alemania', 'Germania', 'Duitsland',
	'Historical Documents', 'Documents historiques', 'Historische Dokumente', 'Documentos históricos', 'Documenti storici', 'Historische documenten',
	'Humour', 'Humor', 'Umorismo',
	'Humour/Spoken Word', 'Comedy/Other', 'Diction', 'Hörbücher', 'Audiolibros', 'Spoken Word', 'Cabaret/ Komedie / Luisterboek',
	'Karaoke', 'Karaoké',
	'Keyboard Concertos', 'Concertos pour clavier', 'Klavierkonzerte', 'Conciertos para tecla', 'Concerti per tastiera', 'Concerten voor klavier',
	'Lieder (German)', 'Lieder (Allemagne)', 'Kunstlieder (Deutschland)', 'Lieder (Alemania)', 'Lieder (Germania)', 'Liederen (Duitsland)',
	'Literature', 'Littérature', 'Literatur', 'Literatura', 'Letteratura', 'Literatuur',
	'Masses, Passions, Requiems', 'Messes, Passions, Requiems', 'Messen, Passionen, Requiems', 'Misas, Pasiones, Réquiems', 'Messe, Passioni, Requiem', 'Missen, passies, requiems',
	'Mélodies (England)', 'Mélodies (Angleterre)', 'Mélodies (Inglaterra)', 'Mélodies (Inghilterra)', 'Liederen (Engeland)',
	'Mélodies (French)', 'Mélodies (France)', 'Französische Mélodies (Frankreich)', 'Mélodies (Francia)', 'Liederen (Frankrijk)',
	'Mélodies (Northern Europe)', 'Mélodies (Europe du Nord)', 'Mélodies (Nordeuropa)', 'Mélodies (Europa del Norte)', 'Mélodies (Europa del nord)', 'Liederen (Noord-Europa)',
	'Mélodies', 'Liederen',
	//'Minimal Music', 'Musique minimaliste', 'Música minimalista', 'Musica minimalista', 'Minimalistische muziek',
	'Music by vocal ensembles', 'Musique pour ensembles vocaux', 'Musik für Vokalensembles', 'Música para conjuntos vocales', 'Musica per insiemi vocali', 'Muziek voor vocale ensembles',
	'Musique Concrète', 'Musique concrète', 'Musique concréte', 'Musica concreta',
	'Opera Extracts', 'Extraits d\'opéra', 'Opernauszüge', 'Fragmentos de ópera', 'Estratti d\'opera', 'Operafragmenten',
	'Opera', 'Opéra', 'Oper', 'Ópera',
	'Operettas', 'Opérette', 'Operette', 'Opereta', 'Operetta',
	'Oratorios (secular)', 'Oratorios profanes', 'Weltliche Oratorien', 'Oratorios profanos', 'Oratori profani', 'Wereldlijke oratoria',
	'Overtures', 'Ouvertures', 'Ouvertüren', 'Oberturas', 'Overture',
	'Quartets', 'Quatuors', 'Quartette', 'Cuartetos', 'Quartetti', 'Kwartetten',
	'Quintets', 'Quintettes', 'Quintette', 'Quintetos', 'Quintetti', 'Kwintetten',
	'Rap', 'Hip-Hop', 'Rap/Hip-Hop',
	'Sacred Oratorios', 'Oratorios sacrés', 'Geistliche Oratorien', 'Oratorios sacros', 'Oratori sacri',
	'Sacred Vocal Music', 'Musique vocale sacrée', 'Geistliche Vokalmusik', 'Música vocal sacra', 'Musica vocale sacra', 'Religieuze vocale muziek',
	'Secular Vocal Music', 'Musique vocale profane', 'Weltliche Vokalmusik', 'Música vocal profana', 'Musica vocale profana', 'Wereldlijke vocale muziek',
	'Schlager',
	'Solo Piano', 'Piano solo', 'Klaviersolo', 'Assoli per pianoforte',
	'Stimmungsmusik ', 'Stimmungsmusik',
	'Stories and Nursery Rhymes', 'Contes et comptines', 'Märchen und Kinderreime', 'Cuentos & canciones infantiles', 'Racconti e filastrocche', 'Sprookjes en vertellingen',
	'Symphonic Music', 'Musique symphonique', 'Symphonieorchester', 'Música sinfónica', 'Musica sinfonica', 'Symfonische muziek',
	'Symphonic Poems', 'Poèmes symphoniques', 'Symphonische Dichtung', 'Poemas sinfónicos', 'Poemi sinfonici', 'Symfonische gedichten',
	'Symphonies', 'Symphonien', 'Sinfonías', 'Sinfonie', 'Symfonieën',
	'Techno',
	'Theatre Music', 'Musique de scène', 'Intermezzi', 'Música escénica', 'Musiche di scena', 'Toneelmuziek',
	'Trios', 'Tríos', 'Trii', 'Trio´s',
	'Violin Concertos', 'Concertos pour violon', 'Violinkonzerte', 'Conciertos para violín', 'Concerti per violino', 'Concerten voor viool',
	'Violin Solos', 'Violon solo', 'Violinensolo', 'Violín solo', 'Assoli per violino', 'Viool solo',
	'Vocal Music (Secular and Sacred)', 'Musique vocale (profane et sacrée)', 'Vokalmusik (weltlich und geistlich)', 'Música vocal (profana y sacra)', 'Musica vocale (sacra e profana)', 'Vocale muziek (wereldlijk en religieus)',
	'Vocal Recitals', 'Récitals vocaux', 'Gesangsrezitale', 'Recitales vocales', 'Recital vocali', 'Vocale recitals',
	'Volksmusik',
  ],
};
var gazelleApiTimeFrame = {};

function queryAjaxAPI(hostName, action, params) {
  return !action ? Promise.reject('action missing') : new Promise(function(resolve, reject) {
	const siteApiTimeframeStorageKey = 'AJAX time frame';
	var retryCount = 0;
	params = new URLSearchParams(params || undefined);
	params.set('action', action);
	var url = 'https://'.concat(hostName.toLowerCase(), '/ajax.php?', params);
	switch (hostName.toLowerCase()) {
	  case 'redacted.ch': var apiKey = GM_getValue('redacted_api_key'); break;
	}
	queryInternal();

	function queryInternal() {
	  var now = Date.now();
	  try { var apiTimeFrame = JSON.parse(window.localStorage[siteApiTimeframeStorageKey]) } catch(e) { apiTimeFrame = {} }
	  if (!apiTimeFrame.timeStamp || now > apiTimeFrame.timeStamp + gazelleApiFrame) {
		apiTimeFrame.timeStamp = now;
		apiTimeFrame.requestCounter = 1;
	  } else ++apiTimeFrame.requestCounter;
	  window.localStorage[siteApiTimeframeStorageKey] = JSON.stringify(apiTimeFrame);
	  if (apiTimeFrame.requestCounter <= 5) GM_xmlhttpRequest({
		method: 'GET',
		url: url,
		headers: {
		  'Accept': 'application/json',
		  'Authorization': apiKey || undefined,
		},
		responseType: 'json',
		onload: function(response) {
		  if (response.status == 404) return reject('not found');
		  if (response.status < 200 || response.status >= 400 && response.status != 404) return reject(defaultErrorHandler(response));
		  if (response.response.status == 'success') return resolve(response.response.response);
		  if (response.response.error == 'not found') return reject(response.response.error);
		  console.warn('queryAjaxAPI.queryInternal(...) response:', response, response.response);
		  if (response.response.error == 'rate limit exceeded') {
			console.warn('queryAjaxAPI.queryInternal(...) ' + response.response.error + ':', apiTimeFrame, now, retryCount);
			if (retryCount++ <= 10) return setTimeout(queryInternal, apiTimeFrame.timeStamp + gazelleApiFrame - now);
		  }
		  reject('API '.concat(response.response.status, ': ', response.response.error));
		},
		onerror: response => reject(defaultErrorHandler(response)),
		ontimeout: response => reject(defaultTimeoutHandler(response)),
		timeout: 10000,
	  }); else {
		setTimeout(queryInternal, apiTimeFrame.timeStamp + gazelleApiFrame - now);
		console.debug('AJAX API request quota exceeded: /ajax.php?action=' + action + ' (' + apiTimeFrame.requestCounter + ')');
	  }
	}
  });
}

function computeArticleQuality(article) {
  article.quality = 1.0;
  var tags = [];
  if (article.genre) tags.push(article.genre);
  if (article.style) tags.push(article.style);
  if (Array.isArray(article.genres)) tags.push(...article.genres);
  if (Array.isArray(article.styles)) tags.push(...article.styles);
  let discounts = [];
  for (let discount in qobuzGenresRating) if (discount > 0 && Array.isArray(qobuzGenresRating[discount])
	&& tags.some(genre => qobuzGenresRating[discount].includes(genre))) discounts.push(discount);
  if (tags.some(g => /\b(?:Jazz)\b/i.test(g)) && !tags.some(g => /\b(?:Acid|Pop|Rock|Future)\b/i.test(g))
	 && !(article.bd > 16)) discounts.push(0.10);
  if (tags.some(g => /\b(?:French)\b/i.test(g))) discounts.push(0.15);
  if (/\b(?:World)\b/i.test(article.category)
	  || tags.some(g => /\b(?:World|Latin|African|Asia|Flamenco|Tango|Bossa\s*Nova|Brazil(?:ian)?)\b/i.test(g)))
	discounts.push(0.15);
  if (tags.some(g => /\b(?:Country)\b/i.test(g))) discounts.push(0.20);
  if (tags.some(g => /\b(?:German\s+Music)\b/i.test(g))) discounts.push(0.30);
  if (/^(?:New\s*Age)$/i.test(article.category)
	  || tags.some(g => /\b(?:New\s*Age|Easy\s+Listening|Meditation|Relax\w*|Spiritual|Gospel|Worship|Holiday)\b/i.test(g)))
	discounts.push(0.30);
  if (tags.some(g => /\b(?:Kids|Children)\b/i.test(g))) discounts.push(0.40);
  if (tags.some(g => /\b(?:Humour|Spoken\s+Word)\b/i.test(g))) discounts.push(0.60);
  if (tags.some(g => /\b(?:Hip[\-\s]?Hop|T?Rap)\b/i.test(g))) discounts.push(0.60);
  if (tags.some(g => /\b(?:Techno|Tech(?:\.\s*|-)House)\b/i.test(g))) discounts.push(0.50);
  	else if (tags.some(g => /\b(?:House)\b/i.test(g))) discounts.push(0.20);
  if (tags.some(function(genre) {
	return /\b(?:Classical|Symphonic|Concertos?|Concerten|Chamber|Solo\s+Piano|Choral|Opera|Symphonies|Cantatas|Classique|Klaviersolo|Opéra|Klassik|Duets)\b/i.test(genre)
		&& !/\b(?:Modern\s+Classical|Pop|Symphonic\s+Metal)\b/i.test(genre);
  })) discounts.push(0.50);
  if (article.release_type == 3) discounts.push(0.25); // soundtrack
  if (!(article.bd > 16) && article.category == 'Jazz') discounts.push(0.15);
  if (discounts.length > 0) article.quality -= Math.max(...discounts);
  if (article.release_type == 9) article.quality -= 0.10; // single
  if (is_va(article)) article.quality -= 0.30; // VA compilation
  if (article.size >= 0) {
	//if (article.size < 100) article.quality -= 0.05;
	//else if (article.size < 150) article.quality -= 0.20;
  }
  let year = article.album_date && article.album_date.getFullYear()
  		|| article.release_date && article.release_date.getFullYear() || undefined,
	  thisYear = new Date().getFullYear();
  if (year > 0 && year < thisYear)
	  if (year >= thisYear - 1) article.quality -= 0.08;
	  	else if (year >= thisYear - 2) article.quality -= 0.20;
	  		else article.quality -= 0.30;
  if (['CD'].includes(article.media)) article.quality -= 0.30;
  if (['Vinyl', 'SACD', 'DVD', 'Blu-Ray'].includes(article.media)) article.quality -= 0.40;
  //if (!article.album_date || isNaN(article.album_date)) article.quality -= 0.60;
  if (/\s*\[[^\[\]]*\b(?:MQA)\b[^\[\]]*\]$/.test(article.title)) article.quality = -1;
  if (/\b(?:discography|vinyl\s+collection)\b/i.test(article.title)) article.quality = -1;
  return article.quality;
}

function gazelleSafeReleaseUrl(origin, article) {
  var result = new URL('/torrents.php', origin), params = new URLSearchParams({
	format: 'FLAC',
	order_by: 'time',
	order_way: 'desc',
	action: 'advanced',
	group_results: 1,
  });
  var leftouts = '\b(?:Live|Remastered|Remaster|Remasterizado|Musique originale|Soundtrack|Anniversary|Deluxe|Limited)\b';
  if (article.artist && !is_va(article)) params.set('artistname', article.get_minimal_artist().replace(/[\&\/].*$/, ''));
  if (article.album) {
	let album = article.album;
	if (/\b(\d{4}-\d{2}-\d{2})\b/.test(article.album)) album = RegExp.$1;
	params.set('groupname', album.replace(/\s+\([^\(\)]+\)\s*$/, '').replace(/\s+\[[^\[\]]+\]\s*$/, '').replace(/\s+\{[^\{\}]+\}\s*$/, ''));
  }
  result.search = params;
  return result;
}
function gazelleReleaseUrl(origin, article) {
  var result = gazelleSafeReleaseUrl(origin, article), params = new URLSearchParams(result.search);
  if (article.artist && !is_va(article)) params.set('artistname', article.get_minimal_artist());
  if (article.album) params.set('groupname', article.album);
  if (article.album_date) params.set('year', article.album_date.getFullYear());
  if (article.release_date) params.set('remasteryear', article.release_date.getFullYear());
  if (article.release_type) params.set('releasetype', article.release_type);
  if (article.media) params.set('media', article.media);
  if (article.bd) params.set('format', 'FLAC'); else params.delete('format');
  if (article.bd == 16) params.set('encoding', 'Lossless');
  if (article.bd == 24) params.set('encoding', '24bit Lossless');
  result.search = params;
  return result;
}
function gazelleArtistUrl(origin, article) {
  var result = new URL('/artist.php', origin), params = new URLSearchParams({ artistname: article.get_minimal_artist() });
  result.search = params;
  return result;
}

function qobuzReleaseUrl(article) {
  var result = new URL('/search', originQobuz), params = new URLSearchParams({ s: 'rdc' });
  var term = article.album;
  if (article.artist && !is_va(article)) term = article.get_minimal_artist().replace(/[\&\/].*$/, '') + ' ' + term;
  params.set('q', term);
  result.search = params;
  return result;
}
function qobuzMarketReleaseUrl(article, country) {
  let result = qobuzReleaseUrl(article);
  result.pathname = '/' + country + '/search';
  return result;
}

function hraReleaseUrl(article) {
  let result = new URL('en/search/', originHRA), params = new URLSearchParams({
	album: article.album,
	sort: '-releaseDate',
  });
  if (article.artist && !is_va(article)) params.set('artist', article.get_minimal_artist().replace(/[\&\/].*$/, ''));
  result.search = params;
  return result;
}

function eonkyoReleaseUrl(article) {
  let result = new URL('search/search.aspx', originEonkyo), params = new URLSearchParams(), term = article.album;
  if (article.artist && !is_va(article)) term = article.get_minimal_artist().replace(/[\&\/].*$/, '') + ' ' + term;
  params.set('q', term);
  result.search = params;
  return result;
}

function moraReleaseUrl(article) {
  let result = new URL('search/top', originMora), params = new URLSearchParams(), term = '"' + article.album + '"';
  if (article.artist && !is_va(article)) term = '"' + article.get_minimal_artist().replace(/[\&\/].*$/, '') + '" ' + term;
  params.set('keyWord', term);
  if (article.bd >= 24) params.set('onlyHires', 1);
  result.search = params;
  return result;
}

// Result:
//   -4: same media, higher format
//   -3: same media, same format of same size
//   -2: same media, different/unknown format but same size
//   -1: same media, same format present (size mismatch)
//    1: group exists
//    2: not found (upload available)
function queryReleaseStatus(origin, article) {
  var query = {
 	order_by: 'time',
	order_way: 'desc',
	//format: 'FLAC',
  };
  var leftouts = '\b(?:Live|Remastered|Remaster|Remasterizado|Musique originale|Soundtrack|Anniversary|Deluxe|Limited)\b';
  if (article.artist && !is_va(article)) query.artistname = article.get_minimal_artist().replace(/[\&\/].*$/, '');
  if (article.album) {
	let album = article.album;
	if (/\b(\d{4}-\d{2}-\d{2})\b/.test(article.album)) album = RegExp.$1;
	query.groupname = album.replace(/\s+\([^\(\)]+\)\s*$/, '').replace(/\s+\[[^\[\]]+\]\s*$/, '').replace(/\s+\{[^\{\}]+\}\s*$/, '');
  }
  return queryAjaxAPI(new URL(origin).hostname, 'browse', query).then(function(response) {
	var same_format_present = false, group_exists = false, node, result = { status: 2 };
	response.results.forEach(function(group) {
	  //if (article.redacted_status != undefined) break;
	  if (result.status > 1) result.status = 1;
	  group.torrents.forEach(function(torrent) {
		if (torrent.format != 'FLAC') return; // assertion fail
		if (article.media ? torrent.media != article.media : !['CD', 'WEB'].includes(torrent.media)) return;
		if (article.release_date && article.release_date.getFullYear() > 0
			&& torrent.remasterYear && parseInt(torrent.remasterYear) != article.release_date.getFullYear()) return;
		torrent.size /= 1024 ** 2;
		torrent.bd = /^(\d+)[\s\-]?bits?\sLossless/i.test(torrent.encoding) ? parseInt(RegExp.$1) : 16;
		if (article.bd && torrent.bd > article.bd) return setStatus(-4); // higher format
		if (!article.bd || torrent.bd < article.bd) return testSize(-2);
		setStatus(-1);
		testSize(-3);

		function testSize(status) {
		  if (!article.size) return;
		  var deviation = Math.abs(article.size / torrent.size - 1) * 100;
		  var difference = Math.abs(article.size - torrent.size);
		  if (deviation > size_tolerance) return;
		  setStatus(status, difference, deviation);
		}
		function setStatus(status, difference, deviation) {
		  if (result.status <= status) return;
		  result.status = status;
		  if (difference) result.size_difference = difference; else delete result.size_difference;
		  if (deviation) result.size_deviation = deviation; else delete result.size_deviation;
		}
	  });
	});
	return result;
	//return same_format_present ? -1 : group_exists ? 1 : 2;
  });

//   return new Promise((resolve, reject) => GM_xmlhttpRequest({
// 	method: 'GET',
// 	url: gazelleSafeReleaseUrl(origin, article),
// 	responseType: 'document',
// 	onload: function(response) {
// 	  if (response.status != 200) return reject(defaultErrorHandler(response));
// 	  var parser = new DOMParser(), html = parser.parseFromString(response.responseText, "text/html");
// 	  if (html.querySelector('body#torrents') == null) return reject('not having access to site (not login)');
// 	  var status = {};
// 	  if (findNode('//h2[text()="Your search did not match anything."]', html) != null) {
// 		status.availability = 3;
// 		return resolve(status);
// 	  }
// 	  var same_format_present = false, group_exists = false, node;
// 	  const edition_parser = /\−(?:\s+(?:(\d{4})\s+-|Unconfirmed Release\s+\/))?(?:\s+(.*)\s+\/)?\s+(CD|DVD|Vinyl|Soundboard|SACD|DAT|Cassette|WEB|Blu-Ray)\s*$/;
// 	  const format_parser = /\bFLAC\s*\/\s*(?:(\d+)[\s\-]?bits?\s+)?Lossless\b/i;
// 	  var results = findNodes('//table[@id="torrent_table"]/tbody/tr[@class][td[@class="edition_info"]/strong]', html);
// 	  //var results = html.querySelectorAll('table#torrent_table > tbody > tr[class]:has(:scope > td.edition_info > strong)');
// 	  while (node = results != null && results.iterateNext()) {
// 		//for (node of results) {
// 		group_exists = true;
// 		let matches = edition_parser.exec(node.textContent);
// 		if (matches == null) {
// 		  console.debug('WARNING: unhandled edition header> "' + node.textContent + '"');
// 		  continue;
// 		}
// 		if (article.media ? matches[3] != article.media : !['CD', 'WEB'].includes(matches[3])) continue;
// 		if (article.release_date && article.release_date.getFullYear() > 0
// 			&& matches[1] && parseInt(matches[1]) != article.release_date.getFullYear()) continue;
// 		var upload = node;
// 		while ((upload = upload.nextElementSibling) != null
// 			&& upload.className != 'group' && upload.children[0].className != 'edition_info') {
// 		  if (upload.childElementCount != 7) {
// 			console.log('WARNING: unexpected structure of release> childElementCount=' +
// 				upload.childElementCount + ', row="' + node.textContent + '"');
// 			break;
// 		  }
// 		  let upload_format = upload.children[0].children[1].textContent;
// 		  let upload_size = getSizeFromString(upload.children[3].textContent);
// 		  if (upload_size <= 0) {
// 			console.debug('Unexpected release size for torrents view: ' + upload.children[3].textContent);
// 		  }
// 		  if ((matches = format_parser.exec(upload_format)) == null) continue;
// 		  function test_size() {
// 			if (article.size && upload_size > 0) {
// 			  let deviation = Math.abs(article.size / upload_size - 1) * 100;
// 			  let difference = Math.abs(article.size - upload_size);
// 			  if (deviation < size_tolerance) {
// 				status.size_difference = difference;
// 				status.size_deviation = deviation;
// 				return true;
// 			  }
// 			}
// 			return false;
// 		  }
// 		  if (matches[1] ? article.bd == matches[1] : article.bd <= 16) { // same format & BD
// 			same_format_present = true;
// 			if (test_size()) {
// 			  status.availability = -3;
// 			  return resolve(status);
// 			}
// 		  } else if (matches[1] && (!article.bd || matches[1] > article.bd)) {
// 			status.availability = -3;
// 			return resolve(status);
// 		  } else if ((!article.bd || article.bd > 16) && test_size()) {
// 			status.availability = -1;
// 			return resolve(status);
// 		  }
// 		}
// 	  }
// 	  status.availability = same_format_present ? -1 : group_exists ? 1 : 2;
// 	  resolve(status);
// 	},
// 	onerror: error => reject(defaultErrorHandler(error)),
// 	ontimeout: timeout => reject(defaultTimeoutHandler(timeout)),
//   }));
}

function queryIpregistry(apiKey) {
  if (!apiKey) return Promise.reject('Ipregistry not configured');
  var query = new URLSearchParams({
	key: apiKey,
	hostname: true,
	pretty: true,
  });
  return fetch('https://api.ipregistry.co/?' + query.toString()).then(response => response.json()).then(function(status) {
	if (typeof status.security != 'object' || typeof status.connection != 'object') return Promise.reject(status);
	if (Object.keys(status.security).some(key => status.security[key]) || status.connection.type != 'isp')
	  return Promise.reject('Unsafe host');
	return status;
  });
}

function queryIpify(apiKey, safeISPs) {
  if (!apiKey) return Promise.reject('Ipify not configured');
  if (!Array.isArray(safeISPs) || safeISPs.length <= 0) return Promise.reject('No safe hosts list for Ipify');
  return new Promise(function(resolve, reject) {
	GM_xmlhttpRequest({
	  method: 'GET',
	  url: 'https://geo.ipify.org/api/v1?apiKey=' + apiKey,
	  responseType: 'json',
	  onload: function(response) {
		if (response.response.isp) {
		  if (safeISPs.some(function(isp) {
			if (typeof isp == 'string') return isp.toLowerCase() == response.response.isp.toLowerCase();
			if (isp instanceof 'RegExp') return isp.test(response.response.isp);
			return false;
		  })) resolve(response.response); else reject('ISP not on whitelist');
		} else reject(response.response);
	  },
	  onerror: error => { reject(error) },
	  ontimeout: timeout => { reject(timeout) },
	});
  });
//   return fetch('https://geo.ipify.org/api/v1?apiKey=' + apiKey).then(response => response.json()).then(function(status) {
// 	return Array.isArray(safeISPs) && safeISPs.some(function(isp) {
// 	  if (typeof isp == 'string') return isp.toLowerCase() == status.isp.toLowerCase();
// 	  if (isp instanceof 'RegExp') return isp.test(status.isp);
// 	  return false;
// 	}) ? status : Promise.reject('ISP not on whitelist');
//   });
}

function defaultErrorHandler(response) {
  console.error('XHR error:', response);
  var str = 'XHR error: readyState=' + response.readyState + ', status=' + response.status;
  if (response.statusText) str += ' (' + response.statusText + ')';
  if (response.error) str += ' (' + response.error + ')';
  return str;
}

function defaultTimeoutHandler(response) {
  console.error('XHR timeout:', response);
  return 'XHR timeout';
}

function is_va(param) {
  var rx = /^(?:Various(?: Artists?)?|VA|\<various artists\>)$/;
  return typeof param == 'string' && param.search(rx) >= 0
  || typeof param == 'object' && typeof param.artist == 'string' && param.artist.search(rx) >= 0;
}


function extract_year(expr) {
  if (typeof expr != 'string') return null;
  var year = parseInt(expr);
  if (year > 0) return year;
  var m = expr.match(/\b(\d{4})\b/);
  return m && (year = parseInt(m[1])) > 0 ? year : null;
}

function search_red_external() {
  var red_url = this.href;
  console.log(red_url + "\n");
  open('firefox ' + red_url);
}

function parse_title(article) {
  if (typeof article.title != 'string') return;
  if (article.title.search(/\[[^\[\]]*\bVinyl\b/) >= 0) article.media = 'Vinyl';
  if (article.title.search(/\([^\(\)]*\bVinyl\b/) >= 0) article.media = 'Vinyl';
  if (article.title.search(/\[[^\[\]]*\bCD\b/) >= 0) article.media = 'CD';
  if (article.title.search(/\([^\(\)]*\bCD\b/) >= 0) article.media = 'CD';
  if (article.title.search(/\[[^\[\]]*\bSACD\b/) >= 0) article.media = 'SACD';
  if (article.title.search(/\([^\(\)]*\bSACD\b/) >= 0) article.media = 'SACD';
  if (article.title.search(/\[[^\[\]]*\bBlu[ \-]?Ray\b/) >= 0) article.media = 'Blu-Ray';
  if (article.title.search(/\([^\(\)]*\bBlu[ \-]?Ray\b/) >= 0) article.media = 'Blu-Ray';
  if (article.title.search(/\s+(?:(?:-\s+)?Single|\[Single\]|\(Single\})$/i) >= 0) article.release_type = 9;
  if (article.title.search(/\s+(?:(?:-\s+)?EP|\[EP\]|\(EP\})$/) >= 0) article.release_type = 5;
  if (!article.bd && /\[[^\[\]]*\b(?:24\s*bits?|Hi[\-\s]?Res)\b[^\[\]]*\]/.test(article.title)) article.bd = 24;
}

function parse_album(article) {
  if (!article.album && article.title) {
	article.album = article.title
	  .replace(/^.*?\s+-\s+/, '').replace(/\s*\(Lossless [^\(\)]*\(\d{4}\)$/, '')
	  .replace(/\s*\[[^\[\]]*\b(?:Single|Vinyl Rip|24bit|Hi-Res|MQA)\b[^\[\]]*\]$/, '');
  }
  if (typeof article.album != 'string') return;
  // EP
  var rx = /\s+(?:(?:-\s+)?EP|\[EP\]|\(EP\))$/;
  if (rx.test(article.album)) {
	article.release_type = 5;
	article.album = article.album.replace(rx, '');
  }
  // Single
  rx = /\s+(?:-\s+Single|\[Single\]|\(Single\))$/i;
  if (rx.test(article.album)) {
	article.release_type = 9;
	article.album = article.album.replace(rx, '');
  }
  guess_release('Soundtrack|Score|Motion Picture|Series|Television|Original(?: \w+)? Cast|Music from|Musique originale|Bandes? originales?', 3); // Soundtrack & score
  guess_release('Live|Ao Vivo|En Directo?', 11); // live album
  if (article.album.search(/(?:^Live|^Directo? [Ee]n|\bUnplugged|\bAcoustic Stage)\b/) >= 0 && !article.release_type) {
	article.release_type = 11; // live album
  }
  // Remasters and editions
  rx = '\\b(?:Remaster\\w*|Reissue)/\\b';
  if (new RegExp('\\s+\\[[^\\[\\]]*' + rx + '[^\\[\\]]*\\]', 'i').test(article.album)
	  || new RegExp('\\s+\\([^\\(\\)]*' + rx + '[^\\(\\)]*\\)', 'i').test(article.album))
		  { article.is_remaster = true }
  guess_release('Anniversary|Deluxe|Limited|Edition|Remaster\\w*|Reissue');
  article.album = article.album.replace(/\s+\([^\(\)]*\s+(?:Version|Edition)\)$/i, '');
  article.album = article.album.replace(/\s+\[[^\[\]]*\s+(?:Version|Edition)\]$/i, '');
  // strip confusing shit
  var rx1 = /\s+\((?:\d+\s*CDs?\s+(?:Compilation|Set)?|Anthology|Compilation)\)$/i;
  var rx2 = /\s+\[(?:\d+\s*CDs?\s+(?:Compilation|Set)?|Anthology|Compilation)\]$/i;
  if (rx1.test(article.album) || rx2.test(article.album) && !article.release_type) {
	article.release_type = is_va(article) ? 7 : 6;
  }
  article.album = article.album.replace(rx1, '');
  article.album = article.album.replace(rx2, '');
  rx1 = /\s+\(\d+\s*CDs?\)$/;
  if (rx1.test(article.album)) article.album = article.album.replace(rx1, '');
  rx2 = /\s+\[\d+\s*CDs?\]$/;
  if (rx2.test(article.album)) article.album = article.album.replace(rx2, '');
  article.album = article.album.replace(/\s+feat\.\s.*/i, '');
  article.album = article.album.replace(/\s+\(feat\.\s[^\(\)]+\)/i, '');
  article.album = article.album.replace(/\s+\[feat\.\s[^\(\)]+\]/i, '');
  var artSuf = ' - '.concat(article.artist);
  if (article.album.endsWith(artSuf)) article.album = article.album.slice(0, -artSuf.length);

  function guess_release(expressions, release_type = 0) {
	rx = '\\b(?:' + expressions + ')\\b';
	if (reInParenthesis(rx).test(article.album) || reInBrackets(rx).test(article.album)) {
	  if (release_type > 0 && !article.release_type) article.release_type = release_type;
	  article.album = article.album.replace(reInParenthesis(rx), '');
	  article.album = article.album.replace(reInBrackets(rx), '');
	}
  }
}

function parse_artist(article) {
  if (typeof article.artist != 'string') return;
  article.artist = article.artist.replace(/\s+feat\.\s.*/i, '');
  article.artist = article.artist.replace(/\s+\(feat\.\s[^\(\)]+\)/i, '');
  article.artist = article.artist.replace(/\s+\[feat\.\s[^\(\)]+\]/i, '');
}

function reInParenthesis(expr) { return new RegExp('\\s+\\([^\\(\\)]*'.concat(expr, '[^\\(\\)]*\\)$'), 'i') }
function reInBrackets(expr) { return new RegExp('\\s+\\[[^\\[\\]]*'.concat(expr, '[^\\[\\]]*\\]$'), 'i') }

function makeTimeString(duration) {
  let t = Math.abs(Math.round(duration));
  let H = Math.floor(t / 60 ** 2);
  let M = Math.floor(t / 60 % 60);
  let S = t % 60;
  return (duration < 0 ? '-' : '') + (H > 0 ? H + ':' + M.toString().padStart(2, '0') : M.toString()) +
	':' + S.toString().padStart(2, '0');
}

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 getSizeFromString(str, returnAs = undefined) {
  if (typeof str != 'string') return 0;
  var matches = /\b(\d+(?:\.\d+)?)\s*([KMGTPEZY]?)I?B\b/.exec(str.replace(',', '.').toUpperCase());
  if (matches == null) return 0;
  const prefixes = Array.from('KMGTPEZY');
  var size = parseFloat(matches[1]);
  var fromIndex = prefixes.indexOf(matches[2]);
  var toIndex = /^([KMGTPEZY]?)(?:i?B)?$/i.test(returnAs) ? prefixes.indexOf(RegExp.$1.toUpperCase()) : 1;
  return size * Math.pow(2, (fromIndex - toIndex) * 10);
}