feat(userscripts/scrobble: improve config flow and add panel

- add config panel with force-scrobble button
- clearer error UI
- improve Last.fm session flow
This commit is contained in:
SansGuidon 2025-05-12 08:37:26 +00:00
parent a109c15faa
commit b7af34c0c3

View File

@ -1,8 +1,8 @@
// ==UserScript== // ==UserScript==
// @name RTBF Scrobbler v1.4 // @name RTBF Scrobbler v1.5
// @namespace https://gitea.zoemp.be/sansguidon // @namespace https://gitea.zoemp.be/SansGuidon
// @version 1.4 // @version 1.5
// @description Auto-scrobble RTBF → Last.fm & ListenBrainz; top-frame only, main container indicator, omit empty release_name // @description Auto-scrobble RTBF → Last.fm & ListenBrainz; config panel, error display, force-scrobble button
// @match https://www.rtbf.be/radio/liveradio/* // @match https://www.rtbf.be/radio/liveradio/*
// @grant GM_xmlhttpRequest // @grant GM_xmlhttpRequest
// @grant GM_setValue // @grant GM_setValue
@ -19,195 +19,154 @@
if (window.self !== window.top) return; if (window.self !== window.top) return;
'use strict'; 'use strict';
GM_registerMenuCommand('Scrobbler Config', configure); // Menu commands
GM_registerMenuCommand('Get Last.fm Session Key', generateSession); GM_registerMenuCommand('Scrobbler Config', showConfigPanel);
function configure(){ // Config panel
GM_setValue('lfm_apiKey', prompt('Last.fm API Key', GM_getValue('lfm_apiKey',''))); function showConfigPanel() {
GM_setValue('lfm_apiSecret', prompt('Last.fm API Secret', GM_getValue('lfm_apiSecret',''))); if (document.getElementById('scrobble-config')) return;
GM_setValue('lfm_sessionKey',prompt('Last.fm Session Key',GM_getValue('lfm_sessionKey',''))); const overlay = document.createElement('div');
GM_setValue('lb_token', prompt('ListenBrainz Token', GM_getValue('lb_token',''))); overlay.id = 'scrobble-config';
console.log('[Scrobbler] config saved'); Object.assign(overlay.style, {
alert('Config saved.'); position:'fixed', top:0, left:0, right:0, bottom:0,
background:'rgba(0,0,0,0.6)', zIndex:10000,
display:'flex', alignItems:'center', justifyContent:'center'
});
const panel = document.createElement('div');
Object.assign(panel.style, {
background:'#fff', padding:'20px', borderRadius:'8px',
width:'350px', boxShadow:'0 0 10px rgba(0,0,0,0.5)',
fontFamily:'sans-serif', fontSize:'14px', color:'#333'
});
panel.innerHTML = `
<h2 style="margin-top:0">Scrobbler Settings</h2>
<p><a href="https://www.last.fm/api/account/create" target="_blank">Last.fm API App </a></p>
<label>API Key:<br><input id="cf-lfm-key" style="width:100%" value="${GM_getValue('lfm_apiKey','')}"/></label>
<label>API Secret:<br><input id="cf-lfm-secret" style="width:100%" value="${GM_getValue('lfm_apiSecret','')}"/></label>
<label>Session Key:<br><input id="cf-lfm-session" style="width:100%" value="${GM_getValue('lfm_sessionKey','')}"/></label>
<button id="cf-gen-session" style="margin:6px 0">Generate LF Session Key</button>
<p><a href="https://listenbrainz.org/settings" target="_blank">ListenBrainz Token </a></p>
<label>LB Token:<br><input id="cf-lb-token" style="width:100%" value="${GM_getValue('lb_token','')}"/></label>
<div style="margin-top:10px; text-align:right">
<button id="cf-force">Force Scrobble</button>
<button id="cf-save">Save</button>
<button id="cf-close">Close</button>
</div>`;
overlay.appendChild(panel);
document.body.appendChild(overlay);
panel.querySelector('#cf-close').onclick = () => overlay.remove();
panel.querySelector('#cf-save').onclick = () => {
GM_setValue('lfm_apiKey', panel.querySelector('#cf-lfm-key').value.trim());
GM_setValue('lfm_apiSecret', panel.querySelector('#cf-lfm-secret').value.trim());
GM_setValue('lfm_sessionKey', panel.querySelector('#cf-lfm-session').value.trim());
GM_setValue('lb_token', panel.querySelector('#cf-lb-token').value.trim());
alert('Settings saved');
overlay.remove();
};
panel.querySelector('#cf-gen-session').onclick = generateSession;
panel.querySelector('#cf-force').onclick = async () => {
const track = await fetchTrack();
const cfg = getCfg();
if (track.artistName && cfg) await scrobble(track, cfg);
};
} }
// Generate Last.fm session key flow
function generateSession(){ function generateSession(){
const apiKey = GM_getValue('lfm_apiKey'); const apiKey = GM_getValue('lfm_apiKey','').trim();
const apiSecret = GM_getValue('lfm_apiSecret'); const apiSecret = GM_getValue('lfm_apiSecret','').trim();
if(!apiKey||!apiSecret) return alert('Configure API key & secret first.'); if (!apiKey || !apiSecret) return ui('⚠ configure LF API first', false);
const cb = 'http://localhost/'; const callback = 'http://localhost/';
const url = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${encodeURIComponent(cb)}`; const authUrl = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${encodeURIComponent(callback)}`;
typeof GM_openInTab==='function' (typeof GM_openInTab==='function'? GM_openInTab(authUrl,{active:true}): window.open(authUrl,'_blank'));
? GM_openInTab(url,{active:true,insert:true})
: window.open(url,'_blank');
setTimeout(()=>{ setTimeout(()=>{
const token = prompt('Paste the “token” from callback URL:'); const token = prompt('Paste LAST.FM token from callback URL');
if(!token) return; if (!token) return;
const sig = md5(`api_key${apiKey}methodauth.getSessiontoken${token}${apiSecret}`); 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`; const url = `https://ws.audioscrobbler.com/2.0/?method=auth.getSession&api_key=${apiKey}&token=${token}&api_sig=${sig}&format=json`;
GM_xmlhttpRequest({ GM_xmlhttpRequest({ method:'GET', url,
method:'GET', url:sUrl,
onload(res){ onload(res){
try { try {
const d = JSON.parse(res.responseText); const d = JSON.parse(res.responseText);
const sk = d.session && d.session.key; const sk = d.session.key;
if(sk){ GM_setValue('lfm_sessionKey', sk);
GM_setValue('lfm_sessionKey',sk); ui('✔ LF session saved', true);
console.log('[Scrobbler] session key saved',sk); } catch(e){ ui('⚠ session invalid', false); console.error(e); }
return alert('Last.fm session key saved.'); }, onerror(err){ ui('⚠ network error', false); console.error(err); }
}
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); }, 500);
} }
// indicator in <main> if present // UI indicator
const container = document.querySelector('main') || document.body; const container = document.querySelector('main')||document.body;
if (container !== document.body) container.style.position = 'relative'; if (container!==document.body) container.style.position='relative';
const ind = document.createElement('div'); const ind = document.createElement('div');
Object.assign(ind.style,{ Object.assign(ind.style,{
position: container===document.body ? 'fixed' : 'absolute', position:container===document.body?'fixed':'absolute', bottom:'10px', right:'10px',
bottom: '10px', padding:'6px 10px', background:'#555', color:'#fff', fontFamily:'monospace',
right: '10px', fontSize:'12px', whiteSpace:'pre-wrap', borderRadius:'4px', zIndex:'9999'
padding: '6px 10px',
background: '#555',
color: '#fff',
fontFamily: 'monospace',
fontSize: '12px',
whiteSpace: 'pre',
borderRadius:'4px',
zIndex: '9999'
}); });
ind.id = 'scrobble-indicator'; ind.id='scrobble-indicator'; ind.textContent='idle';
ind.textContent = 'idle';
container.appendChild(ind); container.appendChild(ind);
function ui(msg, ok=true){ ind.textContent=msg; ind.style.background=ok?'#27ae60':'#e74c3c'; }
function ui(msg, ok = true){ // Config fetch
ind.textContent = msg;
ind.style.background = ok ? '#27ae60' : '#e74c3c';
}
function getCfg(){ function getCfg(){
const apiKey = GM_getValue('lfm_apiKey'); const a=GM_getValue('lfm_apiKey','').trim();
const apiSecret = GM_getValue('lfm_apiSecret'); const s=GM_getValue('lfm_apiSecret','').trim();
const sk = GM_getValue('lfm_sessionKey'); const k=GM_getValue('lfm_sessionKey','').trim();
const lbToken = GM_getValue('lb_token'); const l=GM_getValue('lb_token','').trim();
if(!apiKey||!apiSecret||!sk||!lbToken){ if (!a||!s||!k||!l) { ui('⚠ not configured', false); return null; }
console.log('[Scrobbler] not configured'); return { apiKey:a, apiSecret:s, sk:k, lbToken:l };
ui('not configured', false);
return null;
}
return { apiKey, apiSecret, sk, lbToken };
} }
async function fetchTrack(retries = 3){ // Fetch current track
const cfg = document.body.dataset.config; async function fetchTrack(){
if(!cfg) throw new Error('no config'); const cfg = JSON.parse(document.body.dataset.config||'{}');
const { v2Config:{ id: rpId } } = JSON.parse(cfg); const id = cfg.v2Config && cfg.v2Config.id;
const url = `https://core-search.radioplayer.cloud/056/qp/v4/events/?rpId=${rpId}`; if (!id) throw 'no config';
for(let i=0;i<retries;i++){ const res = await fetch(`https://core-search.radioplayer.cloud/056/qp/v4/events/?rpId=${id}`);
try { if (!res.ok) throw res.status;
const res = await fetch(url); const { results:{previous=[], now} } = await res.json();
if(!res.ok) throw new Error(res.status); return [...previous, now].pop()||{};
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){ // XHR helper
return new Promise((resolve,reject)=>{ function gmReq(o){ return new Promise((rs,rj)=> GM_xmlhttpRequest(Object.assign(o,{ onload:x=>rs(x), onerror:e=>rj(e) })) ); }
GM_xmlhttpRequest(Object.assign({},opts,{ async function post(url, opts){ const x = await gmReq(Object.assign({url,method:'POST'},opts)); return {status:x.status,text:x.responseText}; }
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);
// Scrobble
async function scrobble(track, cfg){
const art=track.artistName, nm=track.name, ts=Math.floor(Date.now()/1e3);
ui(`${art} ${nm}`);
// Last.fm // Last.fm
const p = { method:'track.scrobble', api_key:creds.apiKey, artist, track:name, album:'', timestamp:ts, sk:creds.sk }; const p={method:'track.scrobble',api_key:cfg.apiKey,artist:art,track:nm,album:'',timestamp:ts,sk:cfg.sk};
const sig = md5(Object.keys(p).sort().map(k=>k+p[k]).join('')+creds.apiSecret); const sig=md5(Object.keys(p).sort().map(k=>k+p[k]).join('')+cfg.apiSecret);
const body = Object.entries({...p, api_sig:sig, format:'json'}) const body=Object.entries({...p,api_sig:sig,format:'json'}).map(([k,v])=>`${k}=${encodeURIComponent(v)}`).join('&');
.map(([k,v])=>`${k}=${encodeURIComponent(v)}`).join('&'); const lf = await post('https://ws.audioscrobbler.com/2.0/',{headers:{'Content-Type':'application/x-www-form-urlencoded'},data:body});
await postWithRetry({ console.log('[LF]',lf.status,lf.text);
method:'POST', if (lf.status===403) { ui('⚠ invalid LF session', false); setTimeout(showConfigPanel,300); return; }
url:'https://ws.audioscrobbler.com/2.0/', ui(`Last.fm: ${lf.status}`);
headers:{'Content-Type':'application/x-www-form-urlencoded'}, // LB
data:body const payload={listen_type:'single',payload:[{track_metadata:{artist_name:art,track_name:nm},listened_at:ts}]};
}); const lb = await post('https://api.listenbrainz.org/1/submit-listens',{headers:{'Content-Type':'application/json','Authorization':'Token '+cfg.lbToken},data:JSON.stringify(payload)});
ui(`Last.fm OK\n`, true); console.log('[LB]',lb.status,lb.text);
ui(`LF:${lf.status} LB:${lb.status}`, lb.status===200);
// 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(){ // Playback check
const a = document.querySelector('audio'); function isPlaying(){ const a=document.querySelector('audio'); return a&&!a.paused; }
return a && !a.paused; let last=GM_getValue('lastTrack','');
}
let last = GM_getValue('lastTrack','');
setInterval(async ()=>{ setInterval(async ()=>{
const creds = getCfg(); const cfg=getCfg(); if(!cfg) return;
if(!creds) return; let tr;
let t; try{ tr=await fetchTrack(); } catch(e){ ui('⚠ fetch error',false); console.error(e); return; }
try { t = await fetchTrack(); } if(!tr.artistName){ ui('⏹ no track'); return; }
catch { console.log('[Scrobbler] fetch error'); return; } if(!isPlaying()){ ui('⏸ paused'); return; }
if(!t.artistName){ console.log('[Scrobbler] no track'); return; } const title=`${tr.artistName} ${tr.name}`;
if(!isPlaying()){ console.log('[Scrobbler] paused'); return; } if(title===last){ ui('⏳ waiting'); return; }
const title = `${t.artistName} ${t.name}`; last=title; GM_setValue('lastTrack', last);
if(title === last){ console.log('[Scrobbler] waiting'); return; } await scrobble(tr, cfg);
last = title; GM_setValue('lastTrack', last); },15000);
try { await scrobble(t, creds); }
catch(e){ console.error('[Scrobbler] scrobble error',e); }
}, 15000);
})(); })();