Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/tray-paused.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/tray.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions src/plugins/last-fm/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
enabled: boolean;
enabled: boolean;

token?: string; // Request token for authentication
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
token?: string; // Request token for authentication
token?: string; // Request token for authentication

sessionKey?: string; // Session key obtained after user approval
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ···· with ··

Suggested change
sessionKey?: string; // Session key obtained after user approval
sessionKey?: string; // Session key obtained after user approval

apiRoot: string; // Base URL for Last.fm API
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
apiRoot: string; // Base URL for Last.fm API
apiRoot: string; // Base URL for Last.fm API

apiKey: string; // Application API Key
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
apiKey: string; // Application API Key
apiKey: string; // Application API Key

secret: string; // Application API Secret
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
secret: string; // Application API Secret
secret: string; // Application API Secret

}

/**
* Default configuration values.
* Includes a default API Key and Secret for immediate use.
*/
export const defaultConfig: LastFmConfig = {
enabled: false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
enabled: false,
enabled: false,

apiRoot: 'https://ws.audioscrobbler.com/2.0/',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
apiRoot: 'https://ws.audioscrobbler.com/2.0/',
apiRoot: 'https://ws.audioscrobbler.com/2.0/',

apiKey: '04d76faaac8726e60988e14c105d421a',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
apiKey: '04d76faaac8726e60988e14c105d421a',
apiKey: '04d76faaac8726e60988e14c105d421a',

secret: 'a5d2a36fdf64819290f6982481eaffa2',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ···· with ··

Suggested change
secret: 'a5d2a36fdf64819290f6982481eaffa2',
secret: 'a5d2a36fdf64819290f6982481eaffa2',

};

export default createPlugin({
name: () => 'Last.fm',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
name: () => 'Last.fm',
name: () => 'Last.fm',

description: () => 'Scrobble your music to Last.fm',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
description: () => 'Scrobble your music to Last.fm',
description: () => 'Scrobble your music to Last.fm',

restartNeeded: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··

Suggested change
restartNeeded: true,
restartNeeded: true,

config: defaultConfig,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ···· with ··

Suggested change
config: defaultConfig,
config: defaultConfig,

menu: async ({ getConfig, setConfig, window }) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ···· with ··

Suggested change
menu: async ({ getConfig, setConfig, window }) => {
menu: async ({ getConfig, setConfig, window }) => {

const config = await getConfig();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ····

Suggested change
const config = await getConfig();
const config = await getConfig();

return [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ····

Suggested change
return [
return [

{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ······

Suggested change
{
{

label: 'Last.fm API Settings',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ········

Suggested change
label: 'Last.fm API Settings',
label: 'Last.fm API Settings',

async click() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ················ with ········

Suggested change
async click() {
async click() {

const output = await prompt(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··········

Suggested change
const output = await prompt(
const output = await prompt(

{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ························ with ············

Suggested change
{
{

title: 'Last.fm API Settings',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ···························· with ··············

Suggested change
title: 'Last.fm API Settings',
title: 'Last.fm API Settings',

label: 'Configure API Key and Secret',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Delete ··············

Suggested change
label: 'Configure API Key and Secret',
label: 'Configure API Key and Secret',

type: 'multiInput',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ···························· with ··············

Suggested change
type: 'multiInput',
type: 'multiInput',

multiInputOptions: [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ···························· with ··············

Suggested change
multiInputOptions: [
multiInputOptions: [

{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ································ with ················

Suggested change
{
{

label: 'API Key',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ···································· with ··················

Suggested change
label: 'API Key',
label: 'API Key',

value: config.apiKey,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ···································· with ··················

Suggested change
value: config.apiKey,
value: config.apiKey,

inputAttrs: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prettier/prettier> reported by reviewdog 🐶
Replace ···································· with ··················

Suggested change
inputAttrs: {
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,
});
306 changes: 306 additions & 0 deletions src/plugins/last-fm/main.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number | undefined> {
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<string, unknown>, 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<string, unknown>,
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<string, unknown>) => {
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<void>;
createSession(config: LastFmConfig): Promise<void>;
scrobble(songInfo: SongInfo, config: LastFmConfig): Promise<void>;
updateNowPlaying(songInfo: SongInfo, config: LastFmConfig): Promise<void>;
}>({
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<void>((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);
}
},
});
Loading