onlineServicesAPI

×

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://greasyfork.org/scripts/414545-onlineservicesapi/code/onlineServicesAPI.js?version=861750

// ==UserScript==
// @name         onlineServicesAPI
// @namespace    https://greasyfork.org/cs/users/321857-anakunda
// @version      2.00
// @author       Anakunda
// @require      https://greasyfork.org/scripts/408084-xhrlib/code/xhrLib.js
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

function queryGenericAPI(hostName, endPoint, params, headers) {
  return endPoint ? new Promise(function(resolve, reject) {
	let url = urlParser.test(endPoint) ? new URL(endPoint) : new URL(endPoint, 'https://' + hostName),
		query = new URLSearchParams(params);
	if (Array.from(query).length > 0) url.search = query;
	if (typeof headers != 'object') headers = {};
	headers.Accept = 'application/json';
	//if (prefs.diag_mode) console.debug('queryGenericAPI(...) requesting URL', url.href);
	queryInternal();

	function queryInternal() {
	  GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'json', headers: headers,
		onload: function(response) {
		  //if (prefs.diag_mode) console.debug('queryGenericAPI', domain, key, params, headers, response);
		  if (response.status >= 200 && response.status < 400) resolve(response.response);
		  else reject(defaultErrorHandler(response));
		},
		onerror: response => { reject(defaultErrorHandler(response)) },
		ontimeout: response => { reject(defaultTimeoutHandler(response)) },
	  });
	}
  }) : Promise.reject(new Error('Keyword missing'));
}

function queryItunesAPI(endPoint, params) {
  return endPoint ? queryGenericAPI('itunes.apple.com', endPoint, params) : Promise.reject('No API endpoint');
}

function queryDeezerAPI(endPoint, params) {
  return endPoint ? new Promise(function(resolve, reject) {
	const t0 = Date.now(), safeTimeFrame = 5000 + GM_getValue('deezer_quota_reserve', 500);
	let dzUrl = 'https://api.deezer.com/' + endPoint, retryCounter = 0, quotaCounter = 0;
	if (params && typeof params == 'object') try {
	  params = new URLSearchParams(params);
	  dzUrl += '?' + params.toString();
	} catch(e) { console.error(e, params) } else if (params != undefined) dzUrl += '/' + params.toString();
	//console.debug('Deezer query URL:', url);
	requestInternal();

	function requestInternal() {
	  const requestStart = Date.now();
	  if (!dzApiTimeFrame.timeStamp || requestStart - dzApiTimeFrame.timeStamp > safeTimeFrame) {
		dzApiTimeFrame.timeStamp = requestStart;
		dzApiTimeFrame.requestCounter = 1;
	  } else ++dzApiTimeFrame.requestCounter;
	  const queueSnapshot = {
		frameStart: dzApiTimeFrame.timeStamp,
		position: dzApiTimeFrame.requestCounter,
		timeDistance: requestStart - dzApiTimeFrame.timeStamp,
		frameLength: safeTimeFrame,
	  };
	  if (dzApiTimeFrame.requestCounter <= 50) GM_xmlhttpRequest({
		method: 'GET',
		url: dzUrl,
		responseType: 'json',
		headers: {
		  'Accept': 'application/json',
		  'Accept-Language': 'en-US, en',
		  'X-Requested-With': 'XMLHttpRequest',
		},
		onload: function(response) {
		  if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
		  if (!response.response.error) {
			let dt = Date.now() - t0;
			resolve(response.response);
			if (retryCounter > 0) console.debug('Deezer API request fulfilled after',
				retryCounter, 'retries and', quotaCounter, 'postponements in', dt, 'ms');
		  } else if (response.response.error.code == 4) {
			setTimeout(requestInternal, 100);
			console.warn('Deezer API semaphore failed:', queueSnapshot, dzApiTimeFrame, ++retryCounter);
		  } else reject(response.response.error.message);
		},
		onerror: response => { reject(defaultErrorHandler(response)) },
		ontimeout: response => { reject(defaultTimeoutHandler(response)) },
	  }); else {
		setTimeout(requestInternal, dzApiTimeFrame.timeStamp + safeTimeFrame - requestStart);
		++quotaCounter;
	  }
	}
  }) : Promise.reject('No API endpoint');
}

function queryDiscogsAPI(endPoint, params) {
  return endPoint ? setSession().then(auth => queryGenericAPI('api.discogs.com', endPoint, params, { 'Authorization': auth }))
	: Promise.reject('No API endpoint');

  function setSession() {
	return Promise.resolve('Discogs key="' + discogs_key + '", secret="' + discogs_secret + '"');
	//return Promise.resolve('Discogs token="' + discogs_token + '"');
	const oauthNonce = randomString(64), userAgent = 'Upload Assistant.js/1.0';
	// https://www.discogs.com/developers#page:authentication,header:authentication-discogs-auth-flow
	return globalXHR('https://api.discogs.com/oauth/request_token', { method: 'HEAD', headers: {
	  'Content-Type': 'application/x-www-form-urlencoded',
	  'Authorization': 'OAuth oauth_consumer_key="' + discogs_key + '", oauth_nonce="' + oauthNonce + '", ' +
		  'oauth_signature="' + discogs_secret + '&", oauth_signature_method="PLAINTEXT", ' +
		  'oauth_timestamp="' + Date.now() + '"',
	  'User-Agent': userAgent,
	} }).then(function(response) {
	  if (!/^(?:oauth_token)\s*=\s*(\S+)\b/im.text(response.responseHeaders)) return Promise.reject('invalid header');
	  var accessToken = RegExp.$1;
	  if (!/^(?:oauth_token_secret)\s*=\s*(\S+)\b/im.text(response.responseHeaders))
		  return Promise.reject('invalid header');
	  var accessTokenSecret = RegExp.$1;
	  return new Promise(function(resolve, reject) {
		GM_openInTab('https://discogs.com/oauth/authorize?oauth_token=' + accessToken, {
		  active: true,
		  insert: true,
		  setParent: true,
		}).onclose = function() {
		  // TODO: get verifier code
		  resolve(oauth_verifier);
		};
	  }).then(oauth_verifier => globalXHR('https://api.discogs.com/oauth/access_token', {method: 'POST', headers: {
		'Content-Type': 'application/x-www-form-urlencoded',
		'Authorization': 'OAuth oauth_consumer_key="' + discogs_key + '", oauth_nonce="' + oauthNonce + '", ' +
		'oauth_token="' + accessToken + '", oauth_signature="' + discogs_secret + '&", ' +
		'oauth_signature_method="PLAINTEXT", oauth_timestamp="' + Date.now() + '", ' +
		'oauth_verifier="' + oauth_verifier + '"',
		'User-Agent': userAgent,
	  } })).then(function(response) {
		if (!/^(?:oauth_token)\s*=\s*(\S+)\b/im.text(response.responseHeaders)) return Promise.reject('invalid header');
		accessToken = RegExp.$1;
		if (!/^(?:oauth_token_secret)\s*=\s*(\S+)\b/im.text(response.responseHeaders))
		  return Promise.reject('invalid header');
		accessTokenSecret = RegExp.$1;
		return 'oauth_token="' + accessToken + '", oauth_token_secret="' + accessTokenSecret + '"';
	  });
	});
  }
}

function queryMusicBrainzAPI(endPoint, params) {
  return endPoint ? queryGenericAPI('musicbrainz.org', 'ws/2/' + endPoint + '/', Object.assign({ fmt: 'json' }, params))
	: Promise.reject('No API endpoint');
}

function querySpotifyAPI(endPoint, params) {
  return endPoint ? setOauth2Token().then(credentials => queryGenericAPI('api.spotify.com', 'v1/' + endPoint, params, {
	Authorization: credentials.token_type + ' ' + credentials.access_token,
  })) : Promise.reject('No API endpoint');

  function setOauth2Token() {
	try { var accessToken = JSON.parse(window.localStorage.spotifyAccessToken) } catch(e) { }
	if (isTokenValid(accessToken)) {
	  if (prefs.diag_mode) console.debug('Re-used Spotify access token:', accessToken,
		'expires at', new Date(accessToken.expires_at).toTimeString(),
		'(+' + makeTimeString((accessToken.expires_at - Date.now()) / 1000) + ')');
	  return Promise.resolve(accessToken);
	}
	if (!spotify_clientid || !spotify_clientsecret) return Promise.reject('Spotify credentials not configured');
	const data = new URLSearchParams({
	  'grant_type': 'client_credentials',
	});
	let timeStamp = Date.now();
	return globalXHR('https://accounts.spotify.com/api/token', { responseType: 'json', headers: {
	  Authorization: 'Basic ' + btoa(spotify_clientid + ':' + spotify_clientsecret),
	} }, data).then(function(response) {
	  accessToken = response.response;
	  if (accessToken.timestamp) accessToken.timestamp -= tzOffset;
		else accessToken.timestamp = timeStamp;
	  if (accessToken.expires_at) accessToken.expires_at -= tzOffset;
		else accessToken.expires_at = accessToken.timestamp + accessToken.expires_in * 1000;
	  if (!isTokenValid(accessToken)) {
		console.warn('Received invalid Spotify token:', accessToken);
		return Promise.reject('invalid token received');
	  }
	  window.localStorage.spotifyAccessToken = JSON.stringify(accessToken);
	  if (prefs.diag_mode) console.debug('Spotify access token successfully set:', accessToken,
		(Date.now() - accessToken.timestamp) / 1000);
	  return accessToken;
	});
  }

  function isTokenValid(accessToken) {
	return typeof accessToken == 'object' && accessToken.token_type && accessToken.access_token
	  && accessToken.expires_at >= Date.now() + oauth2timeReserve * 1000;
  }
}

function queryLastFmAPI(method, params) {
  return method ? lastfm_api_key ? queryGenericAPI('ws.audioscrobbler.com', '2.0/', Object.assign({
	method: method,
	api_key: lastfm_api_key,
	format: 'json',
  }, params || {})) : Promise.reject('Last.fm API key not configured') : Promise.reject('No API method');
}

function queryTidalAPI(endPoint, params, countryCode) {
  if (!endPoint) return Promise.reject('No API endpoint');
  const deviceTokens = [
	/*  0 */ 'wdgaB1CilGA-S_s2', // [revoked] browser | Streams HIGH/LOW Quality over RTMP, FLAC and Videos over HTTP, but many Lossless Streams are encrypted.
	/*  1 */ '4zx46pyr9o8qZNRw', // [revoked] browser(?) | other quality
	/*  2 */ 'kgsOOmYk3zShYrNP', // [invalid] Android | All Streams are HTTP Streams. Correct numberOfVideos in Playlists (best Token to use)
	/*  3 */ 'GvFhCVAYp3n43EN3', // [invalid] iOS | Same as Android Token, but uses ALAC instead of FLAC
	/*  4 */ '_DSTon1kC8pABnTw', // [working] iOS | Same as Android Token, but uses ALAC instead of FLAC
	/*  5 */ '4zx46pyr9o8qZNRw', // [revoked] native | Same as Android Token, but FLAC streams are encrypted
	/*  6 */ 'BI218mwp9ERZ3PFI', // [invalid] audirvana | Like Android Token, supports MQA, but returns 'numberOfVideos = 0' in Playlists
	/*  7 */ 'wc8j_yBJd20zOmx0', // [working] amarra | Like Android Token, but returns 'numberOfVideos = 0' in Playlists
	/*  8 */ 'P5Xbeo5LFvESeDy6', // [revoked] Like Android Token, but returns 'numberOfVideos = 0' in Playlists
	/*  9 */ '_KM2HixcUBZtmktH', // [revoked] Same as previous
	/* 10 */ 'oIaGpqT_vQPnTr0Q', // [revoked] Same, but uses RTMP for HIGH/LOW Quality
	/* 11 */ 'PL-KYllTy1qPbCAk', // [working]
  ];
  let tokenIndex = 7;
  if (typeof params != 'object') params = { };
  params.deviceType = 'BROWSER';
  params.countryCode = countryCode;
  return setSession().then(function(session) {
	if (!params.countryCode) params.countryCode = session.countryCode || 'US';
	return { 'X-Tidal-SessionId': session.sessionId };
  }).catch(function(reason) {
	console.warn('Tidal login failed:', reason);
	return setOauth2Token().then(function(token) {
	  if (!params.countryCode) params.countryCode = token.user.countryCode || 'US';
	  return { 'Authorization': token.token_type + ' ' + token.access_token };
	});
  }).then(header => queryGenericAPI('api.tidal.com', 'v1/' + endPoint, params, header), function(reason) {
	console.log('Tidal API: failed to authorize with user credentials, falling back to request using device token only');
	if (!params.countryCode) params.countryCode = 'US';
	params.token = deviceTokens[tokenIndex];
	return queryGenericAPI('api.tidal.com', 'v1/' + endPoint, params);
  });

  function setOauth2Token() {
	if (window.localStorage.tidalAccessToken) try {
	  var accessToken = JSON.parse(window.localStorage.tidalAccessToken);
	  if (isTokenValid(accessToken)) {
		if (prefs.diag_mode) console.debug('Re-used Tidal access token:', accessToken,
			'expires at', new Date(accessToken.expires_at).toTimeString(),
			'(+' + makeTimeString((accessToken.expires_at - Date.now()) / 1000) + ')');
		return Promise.resolve(accessToken);
	  }
	} catch(e) { }
	if (!prefs.tidal_userid || !prefs.tidal_userpassword) return Promise.reject('incomplete account configuration');
	return getClientId().then(function(clientId) {
	  const authOrigin = 'https://login.tidal.com',
			timeStamp = Date.now(),
			scopes = ['r_usr', 'w_usr'],
			clientKey = getClientKey(),
			state = 'TIDAL_' + (timeStamp + tzOffset) + '_' + randomString(64), //urlEncode(btoa(String.fromCharCode.apply(null, crypto.getRandomValues(new Uint8Array(64))))),
			codeVerifier = randomString(128), //urlEncode(btoa(crypto.getRandomValues(new Uint8Array(100)))).slice(0, 128),
			codeChallenge = urlEncode(CryptoJS.SHA256(codeVerifier).toString(CryptoJS.enc.Base64));
	  let params = new URLSearchParams({
		app_mode: 'android', //'desktop' / 'web'
		client_id: clientId,
		client_unique_key: clientKey,
		code_challenge: codeChallenge,
		code_challenge_method: 'S256',
		lang: 'en',
		redirect_uri: 'tidal://login/auth',
		response_type: 'code',
		restrict_signup: false,
		scope: scopes.join(' '),
		state: state,
		//utm_banner: 'na',
		//utm_campaign: 'na',
		//utm_content: 'left_menu',
		//utm_medium: 'desktop_player',
		//utm_source: 'tidal',
	  }), payLoad, referer;
	  return globalXHR(authOrigin + '/authorize?' + params, { method: 'HEAD' }).then(function(response) {
		if (!/\b(?:token)=(.+?)(?=;)/m.test(response.responseHeaders)) return Promise.reject('invalid header');
		referer = response.finalUrl;
		payLoad = new URLSearchParams({
		  _csrf: RegExp.$1,
		  email: prefs.tidal_userid,
		  password: prefs.tidal_userpassword,
		});
		if (/\b(?:sessionV2)=(.+?)(?=;)/m.test(response.responseHeaders)) try {
		  var sessionV2 = JSON.parse(atob(RegExp.$1));
		  if (prefs.diag_mode) console.debug('sessionV2=', sessionV2);
		} catch(e) { console.warn('Failed to decode session v2', e) }
// 			return globalXHR(authOrigin + '/email?' + params, {
// 			  responseType: 'json',
// 			  headers: { 'Referer': referer },
// 			}, payLoad);
// 		  }).then(function(response) {
// 			if (!/^(?:token)=([^;\s$]+)/im.test(response.responseHeaders)) return Promise.reject('invalid header');
// 			payLoad.set('_csrf', RegExp.$1);
		return globalXHR(authOrigin + '/email/user/existing?' + params, {
		  responseType: 'json',
		  headers: { 'Referer': referer },
		}, payLoad);
	  }).then(function(response) {
		var redirectUri = new URL(response.response.redirectUri);
		payLoad = new URLSearchParams({
		  client_id: clientId,
		  client_unique_key: clientKey,
		  code: redirectUri.searchParams.get('code'),
		  code_verifier: codeVerifier,
		  scope: scopes.join(' '),
		  grant_type: 'authorization_code',
		  redirect_uri: redirectUri.href,
		});
		return globalXHR(authOrigin + '/oauth2/token', {
		  responseType: 'json',
		  headers: { 'Referer': referer },
		}, payLoad);
	  }).then(function(response) {
		if (typeof response.response != 'object') return Promise.reject('invalid token');
		accessToken = response.response;
		if (accessToken.timestamp) accessToken.timestamp -= tzOffset;
			else accessToken.timestamp = timeStamp;
		if (accessToken.expires_at) accessToken.expires_at -= tzOffset;
			else accessToken.expires_at = accessToken.timestamp + accessToken.expires_in * 1000;
		if (!isTokenValid(accessToken)) {
		  console.warn('Received invalid Tidal token:', accessToken);
		  return Promise.reject('invalid token received');
		}
		window.sessionStorage.tidalAccessToken = JSON.stringify(accessToken);
		console.debug('Tidal access token successfully set:', accessToken, (Date.now() - accessToken.timestamp) / 1000);
		return accessToken;
	  });
	});
  }
  function setSession() {
	//return Promise.reject('skipped in favour of Oauth2');
	if (window.sessionStorage.tidalSession) try {
	  var session = JSON.parse(window.sessionStorage.tidalSession);
	  if (isSessionValid(session)) {
		if (prefs.diag_mode) console.debug('Re-used Tidal session:', session);
		return Promise.resolve(session);
	  }
	} catch(e) { }
	if (!prefs.tidal_userid || !prefs.tidal_userpassword) return Promise.reject('incomplete account configuration');
	let payLoad = new URLSearchParams({
	  username: prefs.tidal_userid,
	  password: prefs.tidal_userpassword,
	  clientUniqueKey: getClientKey(),
	  clientVersion: '1.0',
	  token: deviceTokens[tokenIndex],
	});
	return globalXHR('https://api.tidal.com/v1/login/username', { // 'https://api.tidalhifi.com/v1/login/username'
	  responseType: 'json',
	  //headers: { 'X-Tidal-Token': xTidalToken },
	}, payLoad).then(function(response) {
	  if (!isSessionValid(session = response.response)) return Promise.reject('invalid session');
	  if (prefs.diag_mode) console.debug('Tidal session successfully established:', session);
	  window.sessionStorage.tidalSession = JSON.stringify(session);
	  return session;
	});
  }
  function uuidv4() {
	return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
	  var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
	  return v.toString(16);
	});
  }
  function urlEncode(b64str) {
	return b64str.replace(/\=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
  }
  function getClientId() {
	if (prefs.tidal_clientid || (prefs.tidal_clientid = GM_getValue('tidal_clientid')))
	  return Promise.resolve(prefs.tidal_clientid);
	return getTidalSecrets().then(function(response) {
	  const rx = /"(\w{40})":"(\w{16})"/g;
	  if ((i = response.responseText.match(rx)) == null || !rx.test(i.shift()))
		return Promise.reject('client id detection fail');
	  GM_setValue('tidal_clientid', prefs.tidal_clientid = RegExp.$2);
	  if (prefs.diag_mode) console.debug('Successfully configured Tidal client Id:', prefs.tidal_clientid);
	  return prefs.tidal_clientid;
	}).catch(function(reason) {
	  reason = `Client Id auto detection failed (${reason}), set it manually (tidal_clientid)`;
	  alert(reason);
	  return Promise.reject(reason);
	});
  }
  function getClientKey() {
	if (!prefs.tidal_clientkey && !(prefs.tidal_clientkey = GM_getValue('tidal_clientkey')))
	  GM_setValue('tidal_clientkey', prefs.tidal_clientkey = uuidv4());
	return prefs.tidal_clientkey;
  }
  function getTidalSecrets() {
	const origin = 'https://listen.tidal.com';
	return globalXHR(origin + '/login').then(function(response) {
	  var appLink = response.document.querySelector('body > script[src]:last-of-type');
	  return appLink == null ? Promise.reject('invalid document format')
		  : globalXHR(origin + appLink.src.replace(document.location.origin, ''), { responseType: 'text' });
	});
  }
  function isTokenValid(accessToken) {
	return typeof accessToken == 'object' && accessToken.token_type && accessToken.access_token
	  && accessToken.expires_at >= Date.now() + oauth2timeReserve * 1000;
  }
  function isSessionValid(session) {
	return session && typeof session == 'object' && session.userId > 0 && session.sessionId;
  }
  function getClientToken() {
	if (prefs.tidal_token || (prefs.tidal_token = GM_getValue('tidal_token'))) return Promise.resolve(prefs.tidal_token);
	return getTidalSecrets().then(function(response) {
	  if (!/"(\w{40})":"(\w{40})"/.test(response.responseText)) return Promise.reject('not found');
	  GM_setValue('tidal_token', prefs.tidal_token = RegExp.$2);
	  if (prefs.diag_mode) console.debug('Successfully configured Tidal token:', prefs.tidal_token);
	  return prefs.tidal_token;
	}).catch(function(reason) {
	  console.warn('Tidal token detection fail (' + reason + ')');
	  return undefined;
	});
  }
}

function queryBeatsourceAPI(endPoint, params, countryCode) {
  if (!endPoint) return Promise.reject('No API endpoint');
  if (!urlParser.test(endPoint)) endPoint = 'v4/catalog/' + endPoint;
  return setOauth2Token().then(token => queryGenericAPI('api.beatsource.com', endPoint, params, {
	'Authorization': token.token_type + ' ' + token.access_token,
  }));

  function setOauth2Token() {
	try {
	  var accessToken = JSON.parse(window.localStorage.beatsourceAccessToken);
	  if (isTokenValid(accessToken)) {
		if (prefs.diag_mode) console.debug('Re-used Beatsource access token:', accessToken,
			'expires at', new Date(accessToken.expires_at).toTimeString(),
			'(+' + makeTimeString((accessToken.expires_at - Date.now()) / 1000) + ')');
		return Promise.resolve(accessToken);
	  }
	} catch(e) { }
	let timeStamp = Date.now();
	return globalXHR('https://www.beatsource.com/').then(function(response) {
	  accessToken = response.document.getElementById('__NEXT_DATA__');
	  if (accessToken == null) return Promise.reject('Beatsource access token is missing');
	  accessToken = JSON.parse(accessToken.text).props.rootStore.authStore.user;
	  if (accessToken.timestamp) accessToken.timestamp -= tzOffset;
		else accessToken.timestamp = timeStamp;
	  if (accessToken.expires_at) accessToken.expires_at -= tzOffset;
		else accessToken.expires_at = accessToken.timestamp + accessToken.expires_in * 1000;
	  if (!isTokenValid(accessToken)) {
		console.warn('Received invalid Beatsource token:', accessToken);
		return Promise.reject('invalid token received');
	  }
	  window.localStorage.beatsourceAccessToken = JSON.stringify(accessToken);
	  console.debug('Beatsource access token successfully set:', accessToken, (Date.now() - accessToken.timestamp) / 1000);
	  return accessToken;
	});
  }
  function isTokenValid(accessToken) {
	return typeof accessToken == 'object' && accessToken.token_type && accessToken.access_token
	  && accessToken.expires_at >= Date.now() + oauth2timeReserve * 1000;
  }
}

function queryNeteaseAPI(endPoint, params) {
  return endPoint ? queryGenericAPI('music.163.com', 'api/' + endPoint, params)
	.then(result => result.code == 200 ? result : Promise.reject(result.msg)) : Promise.reject('No API endpoint');
}

function queryBandcampAPI(endPoint, params) {
  return endPoint ? queryGenericAPI('bandcamp.com', 'api/' + endPoint, params) : Promise.reject('No API endpoint');
}

function loadItunesMetadata(urlOrId) {
  return (function() {
	function getAppleId(urlOrId) {
	  var appleId = parseInt(urlOrId) || itunesRlsParser.test(urlOrId) && parseInt(RegExp.$1);
	  return appleId ? Promise.resolve(appleId) : Promise.reject('Aplpe Id cannot be determined');
	}

	return /^(?:https):\/\/apple\.co\//i.test(urlOrId) ? urlResolver(urlOrId).then(url => getAppleId(url)) : getAppleId(urlOrId);
  })().then(appleId => globalXHR('https://music.apple.com/album/' + appleId).then(function(response) {
	var params = response.document.querySelector('meta[name="desktop-music-app/config/environment"][content]');
	if (params != null) try { params = JSON.parse(decodeURIComponent(params.content)) } catch(e) {
	  console.warn('Apple desktop environment invalid format:', e, params.content);
	  return Promise.reject('Apple desktop environment invalid format');
	} else return Promise.reject('Desktop environment not located');
	if (prefs.diag_mode) console.debug('Got Apple Music desktop environment:', params);
	if (!params.MEDIA_API.token) return Promise.reject('Apple access token not found');
	let query = new URLSearchParams({
	  include: 'tracks,artists',
	  l: 'en-US',
	});
	return globalXHR(params.MUSIC.BASE_URL + '/catalog/us/albums/' + appleId + '?' + query, { responseType: 'json', headers: {
	  'Referer': response.finalUrl,
	  'Authorization': 'Bearer ' + params.MEDIA_API.token,
	} }).then(function(response2) {
	  var album = response2.response.data[0];
	  album.description = response.document.querySelector('div.content-modal__content-container')
		|| response.document.querySelector('div.product-page-header__notes span'),
	  album.url = response.finalUrl;
	  if (album.attributes.artwork) album.attributes.artwork.realUrl = album.attributes.artwork.url
		  .replace('{w}', album.attributes.artwork.width).replace('{h}', album.attributes.artwork.height);
	  if (prefs.diag_mode) console.debug('Apple Music metadata loaded:', album);
// 		  query.set('include', 'artists,albums');
// 		  Promise.all(album.relationships.tracks.data.map(track => globalXHR(params.MUSIC.BASE_URL + '/catalog/us/songs/' + track.id + '?' + query, { responseType: 'json', headers: {
// 			'Referer': response.finalUrl,
// 			'Authorization': 'Bearer ' + params.MEDIA_API.token,
// 		  } }).then(response => response.response))).then(tracks => { console.debug('Apple Music tracks received:', tracks) })
// 		  .catch(reason => { console.error(reason) });
	  return album;
	});
  }));
}
function loadHDtracksMetadata(urlOrId, entity = undefined) {
  if (!urlOrId) return Promise.reject('invalid argument');
  if (/^\w+$/.test(urlOrId)) var id = RegExp.lastMatch.toString();
  if (!id || !entity) try {
	if (!(urlOrId instanceof URL)) urlOrId = new URL(urlOrId);
	if (['hdtracks.com', 'www.hdtracks.com'].some(hostname => urlOrId.hostname == hostname)
		&& /^#\/(\w+)\/(\w+)\b/i.test(urlOrId.hash)) { entity = RegExp.$1; id = RegExp.$2 }
  } catch(e) { console.warn(e) }
  if (!id || !entity) return Promise.reject('invalid arguments');
  return setSession().then(function(session) {
	urlOrId = 'https://hdtracks.azurewebsites.net/api/v1/' + entity + '/' + id;
	if (Object.keys(session).length > 0) urlOrId += '&' + new URLSearchParams(session);
	return fetch(urlOrId).then(response => response.json()).catch(function(reason) {
	  console.warn('fetch(...) failed:', reason);
	  return globalXHR(urlOrId, { responseType: 'json', fetch: true }).then(response => response.response);
	}).then(function(result) {
	  if (result.status.toLowerCase() != 'ok') return Promise.reject(result.status);
	  if (prefs.diag_mode) console.debug('HDtracks', entity, 'info loaded:', result);
	  return result;
	});
  });

  function setSession() {
	return Promise.resolve({
	  //token: 123456789,
	});
  }
}
function loadMoraMetadata(webUrl) {
  return /^(?:https?):\/\/(?:\w+\.)*mora\.jp\/package\//i.test(webUrl) ? globalXHR(webUrl).then(function(response1) {
	var appArguments = response1.document.querySelector('meta[name="msApplication-Arguments"][content]');
	if (appArguments == null) return Promise.reject('Mora.jp: unexpected page format');
	appArguments = JSON.parse(appArguments.content);
	var materialNo = appArguments.materialNo.toString().padStart(10, '0'), offset = 0;
	var packageUrl = 'https://cf.mora.jp/contents/' + [
	  appArguments.type, appArguments.mountPoint, appArguments.labelId,
	].concat([4, 3, 3].map(length => materialNo.slice(offset, offset += length))).join('/') + '/';
	return globalXHR(packageUrl + 'packageMeta.jsonp', { responseType: 'text' }).then(function(response2) {
	  return /^\s*\w+\(\s*(\{[\S\s]+\})\s*\);\s*$/.test(response2.responseText) ? Object.assign(JSON.parse(RegExp.$1), {
		mountPoint: appArguments.mountPoint,
		webUrl: response1.finalUrl.replace(/[\?\#].*$/, ''),
	  }) : Promise.reject('Mora.jp: Unexpected package meta format');
	});
  }) : Promise.reject('Not mora.jp site URL');
}