diff --git a/tampermonkey/rtbf_scrobbler.js b/tampermonkey/rtbf_scrobbler.js
index 1743658..a7e5ce5 100644
--- a/tampermonkey/rtbf_scrobbler.js
+++ b/tampermonkey/rtbf_scrobbler.js
@@ -1,8 +1,8 @@
// ==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
+// @name RTBF Scrobbler v1.5
+// @namespace https://gitea.zoemp.be/SansGuidon
+// @version 1.5
+// @description Auto-scrobble RTBF → Last.fm & ListenBrainz; config panel, error display, force-scrobble button
// @match https://www.rtbf.be/radio/liveradio/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
@@ -19,195 +19,154 @@
if (window.self !== window.top) return;
'use strict';
- GM_registerMenuCommand('Scrobbler Config', configure);
- GM_registerMenuCommand('Get Last.fm Session Key', generateSession);
+ // Menu commands
+ GM_registerMenuCommand('Scrobbler Config', showConfigPanel);
- 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.');
+ // Config panel
+ function showConfigPanel() {
+ if (document.getElementById('scrobble-config')) return;
+ const overlay = document.createElement('div');
+ overlay.id = 'scrobble-config';
+ Object.assign(overlay.style, {
+ 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 = `
+
Scrobbler Settings
+ Last.fm API App →
+
+
+
+
+ ListenBrainz Token →
+
+
+
+
+
+
`;
+ 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(){
- 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');
+ const apiKey = GM_getValue('lfm_apiKey','').trim();
+ const apiSecret = GM_getValue('lfm_apiSecret','').trim();
+ if (!apiKey || !apiSecret) return ui('⚠ configure LF API first', false);
+ const callback = 'http://localhost/';
+ const authUrl = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${encodeURIComponent(callback)}`;
+ (typeof GM_openInTab==='function'? GM_openInTab(authUrl,{active:true}): window.open(authUrl,'_blank'));
setTimeout(()=>{
- const token = prompt('Paste the “token” from callback URL:');
- if(!token) return;
+ const token = prompt('Paste LAST.FM 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,
+ const url = `https://ws.audioscrobbler.com/2.0/?method=auth.getSession&api_key=${apiKey}&token=${token}&api_sig=${sig}&format=json`;
+ GM_xmlhttpRequest({ method:'GET', url,
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.');
- }
+ const sk = d.session.key;
+ GM_setValue('lfm_sessionKey', sk);
+ ui('✔ LF session saved', true);
+ } catch(e){ ui('⚠ session invalid', false); console.error(e); }
+ }, onerror(err){ ui('⚠ network error', false); console.error(err); }
});
- },1000);
+ }, 500);
}
- // indicator in if present
- const container = document.querySelector('main') || document.body;
- if (container !== document.body) container.style.position = 'relative';
+ // UI indicator
+ 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'
+ position:container===document.body?'fixed':'absolute', bottom:'10px', right:'10px',
+ padding:'6px 10px', background:'#555', color:'#fff', fontFamily:'monospace',
+ fontSize:'12px', whiteSpace:'pre-wrap', borderRadius:'4px', zIndex:'9999'
});
- ind.id = 'scrobble-indicator';
- ind.textContent = 'idle';
+ 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 ui(msg, ok = true){
- ind.textContent = msg;
- ind.style.background = ok ? '#27ae60' : '#e74c3c';
- }
-
+ // Config fetch
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 };
+ const a=GM_getValue('lfm_apiKey','').trim();
+ const s=GM_getValue('lfm_apiSecret','').trim();
+ const k=GM_getValue('lfm_sessionKey','').trim();
+ const l=GM_getValue('lb_token','').trim();
+ if (!a||!s||!k||!l) { ui('⚠ not configured', false); return null; }
+ return { apiKey:a, apiSecret:s, sk:k, lbToken:l };
}
- 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;isetTimeout(r,2000*(i+1)));
- else throw err;
- }
- }
+ // Fetch current track
+ async function fetchTrack(){
+ const cfg = JSON.parse(document.body.dataset.config||'{}');
+ const id = cfg.v2Config && cfg.v2Config.id;
+ if (!id) throw 'no config';
+ const res = await fetch(`https://core-search.radioplayer.cloud/056/qp/v4/events/?rpId=${id}`);
+ if (!res.ok) throw res.status;
+ const { results:{previous=[], now} } = await res.json();
+ return [...previous, now].pop()||{};
}
- 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;isetTimeout(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);
+ // XHR helper
+ function gmReq(o){ return new Promise((rs,rj)=> GM_xmlhttpRequest(Object.assign(o,{ onload:x=>rs(x), onerror:e=>rj(e) })) ); }
+ async function post(url, opts){ const x = await gmReq(Object.assign({url,method:'POST'},opts)); return {status:x.status,text:x.responseText}; }
+ // Scrobble
+ async function scrobble(track, cfg){
+ const art=track.artistName, nm=track.name, ts=Math.floor(Date.now()/1e3);
+ ui(`▶ ${art} – ${nm}`);
// 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);
+ 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('')+cfg.apiSecret);
+ const body=Object.entries({...p,api_sig:sig,format:'json'}).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});
+ console.log('[LF]',lf.status,lf.text);
+ if (lf.status===403) { ui('⚠ invalid LF session', false); setTimeout(showConfigPanel,300); return; }
+ ui(`Last.fm: ${lf.status}`);
+ // LB
+ 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)});
+ console.log('[LB]',lb.status,lb.text);
+ ui(`LF:${lf.status} LB:${lb.status}`, lb.status===200);
}
- function isPlaying(){
- const a = document.querySelector('audio');
- return a && !a.paused;
- }
-
- let last = GM_getValue('lastTrack','');
+ // Playback check
+ 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);
-
-})();
\ No newline at end of file
+ const cfg=getCfg(); if(!cfg) return;
+ let tr;
+ try{ tr=await fetchTrack(); } catch(e){ ui('⚠ fetch error',false); console.error(e); return; }
+ if(!tr.artistName){ ui('⏹ no track'); return; }
+ if(!isPlaying()){ ui('⏸ paused'); return; }
+ const title=`${tr.artistName} – ${tr.name}`;
+ if(title===last){ ui('⏳ waiting'); return; }
+ last=title; GM_setValue('lastTrack', last);
+ await scrobble(tr, cfg);
+ },15000);
+})();