snippets/tampermonkey/rtbf_scrobbler.js

213 lines
8.4 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.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 <main> 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;i<retries;i++){
try {
const res = await fetch(url);
if(!res.ok) throw new Error(res.status);
const { results:{ previous = [], now } } = await res.json();
return [...previous, now].pop() || {};
} catch(err){
console.error(`[Scrobbler] fetchTrack failed (${i+1}/${retries})`,err);
if(i<retries-1) await new Promise(r=>setTimeout(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;i<retries;i++){
try {
const res = await gmRequest(opts);
console.log(`[Scrobbler] ${opts.url} ${res.status}`, res.responseText);
return res;
} catch(err){
console.error(`[Scrobbler] request failed (${i+1}/${retries})`,err);
if(i<retries-1) await new Promise(r=>setTimeout(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);
})();