import crypto from 'node:crypto'; import { net, shell } from 'electron'; import type { LastFmPluginConfig } from './index'; import type { SongInfo } from '@/providers/song-info'; interface LastFmData { method: string, timestamp?: number, } interface LastFmSongData { track?: string, duration?: number, artist?: string, album?: string, api_key: string, sk?: string, format: string, method: string, timestamp?: number, api_sig?: string, } const createFormData = (parameters: LastFmSongData) => { // Creates the body for in the post request const formData = new URLSearchParams(); for (const key in parameters) { formData.append(key, String(parameters[key as keyof LastFmSongData])); } return formData; }; const createQueryString = (parameters: Record, apiSignature: string) => { // Creates a querystring const queryData = []; parameters.api_sig = apiSignature; for (const key in parameters) { queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(parameters[key]))}`); } return '?' + queryData.join('&'); }; const createApiSig = (parameters: LastFmSongData, secret: string) => { // This function creates the api signature, see: https://www.last.fm/api/authspec const keys = Object.keys(parameters); keys.sort(); let sig = ''; for (const key of keys) { if (key === 'format') { continue; } sig += `${key}${parameters[key as keyof LastFmSongData]}`; } sig += secret; sig = crypto.createHash('md5').update(sig, 'utf-8').digest('hex'); return sig; }; const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFmPluginConfig) => { // Creates and stores the auth token const data = { method: 'auth.gettoken', api_key: apiKey, format: 'json', }; const apiSigature = createApiSig(data, secret); const response = await net.fetch(`${apiRoot}${createQueryString(data, apiSigature)}`); const json = await response.json() as Record; return json?.token; }; const authenticate = async (config: LastFmPluginConfig) => { // Asks the user for authentication await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`); }; type SetConfType = (conf: Partial>) => (void | Promise); export const getAndSetSessionKey = async (config: LastFmPluginConfig, setConfig: SetConfType) => { // Get and store the session key const data = { api_key: config.api_key, format: 'json', method: 'auth.getsession', token: config.token, }; const apiSignature = createApiSig(data, config.secret); const response = await net.fetch(`${config.api_root}${createQueryString(data, apiSignature)}`); const json = await response.json() as { error?: string, session?: { key: string, } }; if (json.error) { config.token = await createToken(config); await authenticate(config); setConfig(config); } if (json.session) { config.session_key = json.session.key; } setConfig(config); return config; }; const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, data: LastFmData, setConfig: SetConfType) => { // This sends a post request to the api, and adds the common data if (!config.session_key) { await getAndSetSessionKey(config, setConfig); } const postData: LastFmSongData = { track: songInfo.title, duration: songInfo.songDuration, artist: songInfo.artist, ...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video api_key: config.api_key, sk: config.session_key, format: 'json', ...data, }; postData.api_sig = createApiSig(postData, config.secret); const formData = createFormData(postData); net.fetch('https://ws.audioscrobbler.com/2.0/', { method: 'POST', body: formData }) .catch(async (error: { response?: { data?: { error: number, } } }) => { if (error?.response?.data?.error === 9) { // Session key is invalid, so remove it from the config and reauthenticate config.session_key = undefined; config.token = await createToken(config); await authenticate(config); setConfig(config); } }); }; export const addScrobble = (songInfo: SongInfo, config: LastFmPluginConfig, setConfig: SetConfType) => { // This adds one scrobbled song to last.fm const data = { method: 'track.scrobble', timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000), }; postSongDataToAPI(songInfo, config, data, setConfig); }; export const setNowPlaying = (songInfo: SongInfo, config: LastFmPluginConfig, setConfig: SetConfType) => { // This sets the now playing status in last.fm const data = { method: 'track.updateNowPlaying', }; postSongDataToAPI(songInfo, config, data, setConfig); };