- add config panel with force-scrobble button - clearer error UI - improve Last.fm session flow
173 lines
8.8 KiB
JavaScript
173 lines
8.8 KiB
JavaScript
// ==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);
|
||
})();
|