snippets/tampermonkey/rtbf_scrobbler.js
SansGuidon b7af34c0c3 feat(userscripts/scrobble: improve config flow and add panel
- add config panel with force-scrobble button
- clearer error UI
- improve Last.fm session flow
2025-05-12 08:37:26 +00:00

173 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @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
// @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';
// Menu commands
GM_registerMenuCommand('Scrobbler Config', showConfigPanel);
// 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 = `
<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(){
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 LAST.FM token from callback URL');
if (!token) return;
const sig = md5(`api_key${apiKey}methodauth.getSessiontoken${token}${apiSecret}`);
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.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); }
});
}, 500);
}
// 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-wrap', 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'; }
// Config fetch
function getCfg(){
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 };
}
// 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()||{};
}
// 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: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);
}
// Playback check
function isPlaying(){ const a=document.querySelector('audio'); return a&&!a.paused; }
let last=GM_getValue('lastTrack','');
setInterval(async ()=>{
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);
})();