diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000..2bc8cbb479 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/tray-paused.png b/assets/tray-paused.png new file mode 100644 index 0000000000..e57f713f16 Binary files /dev/null and b/assets/tray-paused.png differ diff --git a/assets/tray.png b/assets/tray.png new file mode 100644 index 0000000000..0d81690eab Binary files /dev/null and b/assets/tray.png differ diff --git a/electron-builder.yml b/electron-builder.yml index 59905f5da6..d38235ab19 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -23,7 +23,7 @@ mac: icon: assets/generated/icons/mac/icon.icns compression: maximum win: - icon: assets/generated/icons/win/icon.ico + icon: assets/icon.png target: - target: nsis-web arch: diff --git a/src/plugins/last-fm/index.ts b/src/plugins/last-fm/index.ts new file mode 100644 index 0000000000..dd9e56c64d --- /dev/null +++ b/src/plugins/last-fm/index.ts @@ -0,0 +1,83 @@ +import prompt from 'custom-electron-prompt'; + +import { createPlugin } from '@/utils'; +import promptOptions from '@/providers/prompt-options'; + +import { backend } from './main'; + +/** + * Configuration interface for the Last.fm plugin. + */ +export interface LastFmConfig { + enabled: boolean; + token?: string; // Request token for authentication + sessionKey?: string; // Session key obtained after user approval + apiRoot: string; // Base URL for Last.fm API + apiKey: string; // Application API Key + secret: string; // Application API Secret +} + +/** + * Default configuration values. + * Includes a default API Key and Secret for immediate use. + */ +export const defaultConfig: LastFmConfig = { + enabled: false, + apiRoot: 'https://ws.audioscrobbler.com/2.0/', + apiKey: '04d76faaac8726e60988e14c105d421a', + secret: 'a5d2a36fdf64819290f6982481eaffa2', +}; + +export default createPlugin({ + name: () => 'Last.fm', + description: () => 'Scrobble your music to Last.fm', + restartNeeded: true, + config: defaultConfig, + menu: async ({ getConfig, setConfig, window }) => { + const config = await getConfig(); + return [ + { + label: 'Last.fm API Settings', + async click() { + const output = await prompt( + { + title: 'Last.fm API Settings', + label: 'Configure API Key and Secret', + type: 'multiInput', + multiInputOptions: [ + { + label: 'API Key', + value: config.apiKey, + inputAttrs: { + type: 'text', + }, + }, + { + label: 'API Secret', + value: config.secret, + inputAttrs: { + type: 'text', + }, + }, + ], + resizable: true, + height: 360, + ...promptOptions(), + }, + window, + ); + + if (output) { + if (output[0]) { + setConfig({ apiKey: output[0] }); + } + if (output[1]) { + setConfig({ secret: output[1] }); + } + } + }, + }, + ]; + }, + backend, +}); diff --git a/src/plugins/last-fm/main.ts b/src/plugins/last-fm/main.ts new file mode 100644 index 0000000000..985d3e6e51 --- /dev/null +++ b/src/plugins/last-fm/main.ts @@ -0,0 +1,306 @@ +import { BrowserWindow, net } from 'electron'; +import crypto from 'node:crypto'; + +import { createBackend } from '@/utils'; +import { + registerCallback, + SongInfo, + SongInfoEvent, +} from '@/providers/song-info'; + +import type { LastFmConfig } from './index'; + +/** + * Interface representing the data sent to Last.fm API. + * Keys are dynamic because Last.fm API parameters vary by method. + */ +interface LastFmApiParams extends Record { + method: string; + api_key: string; + sk?: string; + format: 'json'; + api_sig?: string; +} + +/** + * Generates the API signature required by Last.fm. + * The signature is an MD5 hash of all parameters (sorted alphabetically) + the API secret. + * + * @param params - The parameters to sign. + * @param secret - The Last.fm API secret. + * @returns The MD5 hash signature. + */ +const createApiSig = (params: Record, secret: string) => { + let sig = ''; + Object.entries(params) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([key, value]) => { + // 'format' and 'callback' are not included in the signature + if (key === 'format' || key === 'callback') return; + sig += key + String(value); + }); + sig += secret; + return crypto.createHash('md5').update(sig, 'utf-8').digest('hex'); +}; + +/** + * Creates a query string from parameters, including the generated signature. + * + * @param params - The parameters to include in the query string. + * @param apiSignature - The generated API signature. + * @returns The formatted query string (e.g., "?key=value&api_sig=..."). + */ +const createQueryString = ( + params: Record, + apiSignature: string, +) => { + const queryParams = { ...params, api_sig: apiSignature }; + const queryData = Object.entries(queryParams).map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`, + ); + return '?' + queryData.join('&'); +}; + +/** + * Creates a FormData object for POST requests. + * + * @param params - The parameters to append to the form data. + * @returns The populated URLSearchParams object. + */ +const createFormData = (params: Record) => { + const formData = new URLSearchParams(); + for (const key in params) { + if (params[key] !== undefined) { + formData.append(key, String(params[key])); + } + } + return formData; +}; + +export const backend = createBackend<{ + config?: LastFmConfig; + window?: BrowserWindow; + scrobbleTimer?: NodeJS.Timeout; + + startAuth(config: LastFmConfig): Promise; + createSession(config: LastFmConfig): Promise; + scrobble(songInfo: SongInfo, config: LastFmConfig): Promise; + updateNowPlaying(songInfo: SongInfo, config: LastFmConfig): Promise; +}>({ + async start({ getConfig, setConfig, window }) { + this.config = await getConfig(); + this.window = window; + + // If enabled but no session key, start the authentication flow + if (this.config.enabled && !this.config.sessionKey) { + await this.startAuth(this.config); + await setConfig(this.config); + } + + // Register a callback to listen for song changes + registerCallback((songInfo: SongInfo, event) => { + // Ignore time updates, we only care about track changes or pause/play + if (event === SongInfoEvent.TimeChanged) return; + + // Clear any pending scrobble timer to prevent duplicate scrobbles + clearTimeout(this.scrobbleTimer); + + if ( + !songInfo.isPaused && + this.config?.enabled && + this.config.sessionKey + ) { + // 1. Update "Now Playing" status on Last.fm + this.updateNowPlaying(songInfo, this.config); + + // 2. Schedule the Scrobble + // Rule: Scrobble at 33% of the song duration OR 4 minutes, whichever comes first. + const scrobbleThreshold = Math.min( + Math.ceil(songInfo.songDuration * 0.33), + 4 * 60, // 4 minutes in seconds + ); + + const elapsed = songInfo.elapsedSeconds ?? 0; + + if (scrobbleThreshold > elapsed) { + const timeToWait = (scrobbleThreshold - elapsed) * 1000; + this.scrobbleTimer = setTimeout(() => { + if (this.config) { + this.scrobble(songInfo, this.config); + } + }, timeToWait); + } + } + }); + }, + + async onConfigChange(newConfig) { + this.config = newConfig; + // Re-authenticate if the plugin is enabled but lacks a session key + if (this.config.enabled && !this.config.sessionKey) { + await this.startAuth(this.config); + } + }, + + /** + * Starts the Last.fm authentication process. + * 1. Fetches a request token. + * 2. Opens a browser window for the user to approve the application. + * 3. Creates a session after approval. + */ + async startAuth(config: LastFmConfig) { + // Step 1: Get a Request Token + const tokenParams = { + method: 'auth.gettoken', + api_key: config.apiKey, + format: 'json', + }; + const tokenSig = createApiSig(tokenParams, config.secret); + const tokenRes = await net.fetch( + `${config.apiRoot}${createQueryString(tokenParams, tokenSig)}`, + ); + const tokenJson = (await tokenRes.json()) as { token?: string }; + + if (!tokenJson.token) { + console.error('Last.fm: Failed to get authentication token.'); + return; + } + config.token = tokenJson.token; + + // Step 2: Request User Approval via Browser Window + const authUrl = `https://www.last.fm/api/auth/?api_key=${config.apiKey}&token=${config.token}`; + + const authWindow = new BrowserWindow({ + width: 500, + height: 600, + parent: this.window, + modal: true, + show: false, + autoHideMenuBar: true, + }); + + authWindow.loadURL(authUrl); + authWindow.show(); + + // Wait for the user to approve the app in the opened window + return new Promise((resolve) => { + authWindow.webContents.on('did-navigate', async (_, newUrl) => { + const url = new URL(newUrl); + // Last.fm redirects to this URL after approval + if (url.hostname.endsWith('last.fm') && url.pathname === '/api/auth') { + // Check if the approval was successful by looking for the confirmation element + // This is a heuristic; ideally we'd use a callback URL but this is a desktop app + const isApproveScreen = await authWindow.webContents.executeJavaScript( + "!!document.getElementsByName('confirm').length", + ); + + // If we are past the confirmation screen (or it didn't show), assume success + if (!isApproveScreen) { + authWindow.close(); + await this.createSession(config); + resolve(); + } + } + }); + + // Handle window close by user (cancellation) + authWindow.on('closed', () => { + resolve(); + }); + }); + }, + + /** + * Exchanges the request token for a session key. + */ + async createSession(config: LastFmConfig) { + if (!config.token) return; + + const params = { + api_key: config.apiKey, + format: 'json', + method: 'auth.getsession', + token: config.token, + }; + const sig = createApiSig(params, config.secret); + const res = await net.fetch( + `${config.apiRoot}${createQueryString(params, sig)}`, + ); + const json = (await res.json()) as { session?: { key: string } }; + + if (json.session) { + config.sessionKey = json.session.key; + console.log('Last.fm: Session created successfully.'); + } else { + console.error('Last.fm: Failed to create session.', json); + } + }, + + /** + * Updates the "Now Playing" track on Last.fm. + */ + async updateNowPlaying(songInfo: SongInfo, config: LastFmConfig) { + if (!config.sessionKey) return; + + const params: LastFmApiParams = { + method: 'track.updateNowPlaying', + track: songInfo.title, + artist: songInfo.artist, + duration: songInfo.songDuration, + api_key: config.apiKey, + sk: config.sessionKey, + format: 'json', + }; + + if (songInfo.album) { + params.album = songInfo.album; + } + + const sig = createApiSig(params, config.secret); + const formData = createFormData({ ...params, api_sig: sig }); + + try { + await net.fetch(config.apiRoot, { + method: 'POST', + body: formData, + }); + } catch (error) { + console.error('Last.fm: Failed to update Now Playing.', error); + } + }, + + /** + * Scrobbles a track to Last.fm. + */ + async scrobble(songInfo: SongInfo, config: LastFmConfig) { + if (!config.sessionKey) return; + + const params: LastFmApiParams = { + method: 'track.scrobble', + track: songInfo.title, + artist: songInfo.artist, + timestamp: Math.floor(Date.now() / 1000), + api_key: config.apiKey, + sk: config.sessionKey, + format: 'json', + }; + + if (songInfo.album) { + params.album = songInfo.album; + } + + const sig = createApiSig(params, config.secret); + const formData = createFormData({ ...params, api_sig: sig }); + + try { + await net.fetch(config.apiRoot, { + method: 'POST', + body: formData, + }); + console.log(`Last.fm: Scrobble successful for ${songInfo.artist} - ${songInfo.title}`); + } catch (error) { + console.error('Last.fm: Failed to scrobble.', error); + } + }, +});