// ==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 // @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'; GM_registerMenuCommand('Scrobbler Config', configure); GM_registerMenuCommand('Get Last.fm Session Key', generateSession); 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.'); } 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'); setTimeout(()=>{ const token = prompt('Paste the “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, 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.'); } }); },1000); } // indicator in
if present 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' }); 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 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 }; } 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; } } } 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); // 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); } 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); })();