213 lines
8.4 KiB
JavaScript
213 lines
8.4 KiB
JavaScript
// ==UserScript==
|
||
// @name RTBF Scrobbler v1.4
|
||
// @namespace https://gitea.zoemp.be/sansguidon
|
||
// @version 1.4
|
||
// @description Auto-scrobble RTBF → Last.fm & ListenBrainz; top-frame only, main container indicator, omit empty release_name
|
||
// @match https://www.rtbf.be/radio/liveradio/*
|
||
// @grant GM_xmlhttpRequest
|
||
// @grant GM_setValue
|
||
// @grant GM_getValue
|
||
// @grant GM_registerMenuCommand
|
||
// @grant GM_openInTab
|
||
// @run-at document-idle
|
||
// @connect ws.audioscrobbler.com
|
||
// @connect api.listenbrainz.org
|
||
// @require https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.19.0/js/md5.min.js
|
||
// ==/UserScript==
|
||
|
||
(function(){
|
||
if (window.self !== window.top) return;
|
||
'use strict';
|
||
|
||
GM_registerMenuCommand('Scrobbler Config', configure);
|
||
GM_registerMenuCommand('Get Last.fm Session Key', generateSession);
|
||
|
||
function configure(){
|
||
GM_setValue('lfm_apiKey', prompt('Last.fm API Key', GM_getValue('lfm_apiKey','')));
|
||
GM_setValue('lfm_apiSecret', prompt('Last.fm API Secret', GM_getValue('lfm_apiSecret','')));
|
||
GM_setValue('lfm_sessionKey',prompt('Last.fm Session Key',GM_getValue('lfm_sessionKey','')));
|
||
GM_setValue('lb_token', prompt('ListenBrainz Token', GM_getValue('lb_token','')));
|
||
console.log('[Scrobbler] config saved');
|
||
alert('Config saved.');
|
||
}
|
||
|
||
function generateSession(){
|
||
const apiKey = GM_getValue('lfm_apiKey');
|
||
const apiSecret = GM_getValue('lfm_apiSecret');
|
||
if(!apiKey||!apiSecret) return alert('Configure API key & secret first.');
|
||
const cb = 'http://localhost/';
|
||
const url = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${encodeURIComponent(cb)}`;
|
||
typeof GM_openInTab==='function'
|
||
? GM_openInTab(url,{active:true,insert:true})
|
||
: window.open(url,'_blank');
|
||
setTimeout(()=>{
|
||
const token = prompt('Paste the “token” from callback URL:');
|
||
if(!token) return;
|
||
const sig = md5(`api_key${apiKey}methodauth.getSessiontoken${token}${apiSecret}`);
|
||
const sUrl = `https://ws.audioscrobbler.com/2.0/?method=auth.getSession&api_key=${apiKey}&token=${token}&api_sig=${sig}&format=json`;
|
||
GM_xmlhttpRequest({
|
||
method:'GET', url:sUrl,
|
||
onload(res){
|
||
try {
|
||
const d = JSON.parse(res.responseText);
|
||
const sk = d.session && d.session.key;
|
||
if(sk){
|
||
GM_setValue('lfm_sessionKey',sk);
|
||
console.log('[Scrobbler] session key saved',sk);
|
||
return alert('Last.fm session key saved.');
|
||
}
|
||
throw new Error(res.responseText);
|
||
} catch(e){
|
||
console.error('[Scrobbler] session error',e);
|
||
alert('Failed to get session key.');
|
||
}
|
||
},
|
||
onerror(err){
|
||
console.error('[Scrobbler] request error',err);
|
||
alert('Network error.');
|
||
}
|
||
});
|
||
},1000);
|
||
}
|
||
|
||
// indicator in <main> if present
|
||
const container = document.querySelector('main') || document.body;
|
||
if (container !== document.body) container.style.position = 'relative';
|
||
const ind = document.createElement('div');
|
||
Object.assign(ind.style,{
|
||
position: container===document.body ? 'fixed' : 'absolute',
|
||
bottom: '10px',
|
||
right: '10px',
|
||
padding: '6px 10px',
|
||
background: '#555',
|
||
color: '#fff',
|
||
fontFamily: 'monospace',
|
||
fontSize: '12px',
|
||
whiteSpace: 'pre',
|
||
borderRadius:'4px',
|
||
zIndex: '9999'
|
||
});
|
||
ind.id = 'scrobble-indicator';
|
||
ind.textContent = 'idle';
|
||
container.appendChild(ind);
|
||
|
||
function ui(msg, ok = true){
|
||
ind.textContent = msg;
|
||
ind.style.background = ok ? '#27ae60' : '#e74c3c';
|
||
}
|
||
|
||
function getCfg(){
|
||
const apiKey = GM_getValue('lfm_apiKey');
|
||
const apiSecret = GM_getValue('lfm_apiSecret');
|
||
const sk = GM_getValue('lfm_sessionKey');
|
||
const lbToken = GM_getValue('lb_token');
|
||
if(!apiKey||!apiSecret||!sk||!lbToken){
|
||
console.log('[Scrobbler] not configured');
|
||
ui('not configured', false);
|
||
return null;
|
||
}
|
||
return { apiKey, apiSecret, sk, lbToken };
|
||
}
|
||
|
||
async function fetchTrack(retries = 3){
|
||
const cfg = document.body.dataset.config;
|
||
if(!cfg) throw new Error('no config');
|
||
const { v2Config:{ id: rpId } } = JSON.parse(cfg);
|
||
const url = `https://core-search.radioplayer.cloud/056/qp/v4/events/?rpId=${rpId}`;
|
||
for(let i=0;i<retries;i++){
|
||
try {
|
||
const res = await fetch(url);
|
||
if(!res.ok) throw new Error(res.status);
|
||
const { results:{ previous = [], now } } = await res.json();
|
||
return [...previous, now].pop() || {};
|
||
} catch(err){
|
||
console.error(`[Scrobbler] fetchTrack failed (${i+1}/${retries})`,err);
|
||
if(i<retries-1) await new Promise(r=>setTimeout(r,2000*(i+1)));
|
||
else throw err;
|
||
}
|
||
}
|
||
}
|
||
|
||
function gmRequest(opts){
|
||
return new Promise((resolve,reject)=>{
|
||
GM_xmlhttpRequest(Object.assign({},opts,{
|
||
onload: xhr=>resolve(xhr),
|
||
onerror: err=>reject(err)
|
||
}));
|
||
});
|
||
}
|
||
|
||
async function postWithRetry(opts, retries=2){
|
||
for(let i=0;i<retries;i++){
|
||
try {
|
||
const res = await gmRequest(opts);
|
||
console.log(`[Scrobbler] ${opts.url} ${res.status}`, res.responseText);
|
||
return res;
|
||
} catch(err){
|
||
console.error(`[Scrobbler] request failed (${i+1}/${retries})`,err);
|
||
if(i<retries-1) await new Promise(r=>setTimeout(r,2000*(i+1)));
|
||
else ui('scrobble failed', false);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function scrobble(track={}, creds){
|
||
const artist = track.artistName;
|
||
const name = track.name;
|
||
const ts = Math.floor(Date.now()/1000);
|
||
|
||
ui(`scrobbling\n${artist} – ${name}`, true);
|
||
|
||
// Last.fm
|
||
const p = { method:'track.scrobble', api_key:creds.apiKey, artist, track:name, album:'', timestamp:ts, sk:creds.sk };
|
||
const sig = md5(Object.keys(p).sort().map(k=>k+p[k]).join('')+creds.apiSecret);
|
||
const body = Object.entries({...p, api_sig:sig, format:'json'})
|
||
.map(([k,v])=>`${k}=${encodeURIComponent(v)}`).join('&');
|
||
await postWithRetry({
|
||
method:'POST',
|
||
url:'https://ws.audioscrobbler.com/2.0/',
|
||
headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
||
data:body
|
||
});
|
||
ui(`Last.fm OK\n…`, true);
|
||
|
||
// ListenBrainz: omit empty release_name
|
||
const metadata = { artist_name:artist, track_name:name };
|
||
const payload = {
|
||
listen_type:'single',
|
||
payload:[{
|
||
track_metadata: metadata,
|
||
listened_at: ts
|
||
}]
|
||
};
|
||
await postWithRetry({
|
||
method:'POST',
|
||
url:'https://api.listenbrainz.org/1/submit-listens',
|
||
headers:{'Content-Type':'application/json','Authorization':'Token '+creds.lbToken},
|
||
data:JSON.stringify(payload)
|
||
});
|
||
ui(`Last.fm OK\nLB OK`, true);
|
||
}
|
||
|
||
function isPlaying(){
|
||
const a = document.querySelector('audio');
|
||
return a && !a.paused;
|
||
}
|
||
|
||
let last = GM_getValue('lastTrack','');
|
||
setInterval(async ()=>{
|
||
const creds = getCfg();
|
||
if(!creds) return;
|
||
let t;
|
||
try { t = await fetchTrack(); }
|
||
catch { console.log('[Scrobbler] fetch error'); return; }
|
||
if(!t.artistName){ console.log('[Scrobbler] no track'); return; }
|
||
if(!isPlaying()){ console.log('[Scrobbler] paused'); return; }
|
||
const title = `${t.artistName} – ${t.name}`;
|
||
if(title === last){ console.log('[Scrobbler] waiting'); return; }
|
||
last = title; GM_setValue('lastTrack', last);
|
||
try { await scrobble(t, creds); }
|
||
catch(e){ console.error('[Scrobbler] scrobble error',e); }
|
||
}, 15000);
|
||
|
||
})(); |