From b7af34c0c3131ca54782ed170d03ffd0cb2845da Mon Sep 17 00:00:00 2001 From: SansGuidon Date: Mon, 12 May 2025 08:37:26 +0000 Subject: [PATCH] feat(userscripts/scrobble: improve config flow and add panel - add config panel with force-scrobble button - clearer error UI - improve Last.fm session flow --- tampermonkey/rtbf_scrobbler.js | 297 ++++++++++++++------------------- 1 file changed, 128 insertions(+), 169 deletions(-) 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); +})();