Skip to content

Commit 8aeb55c

Browse files
committed
Added last.fm plugin
1 parent bef8252 commit 8aeb55c

File tree

6 files changed

+390
-1
lines changed

6 files changed

+390
-1
lines changed

assets/icon.png

363 KB
Loading

assets/tray-paused.png

386 KB
Loading

assets/tray.png

376 KB
Loading

electron-builder.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ mac:
2323
icon: assets/generated/icons/mac/icon.icns
2424
compression: maximum
2525
win:
26-
icon: assets/generated/icons/win/icon.ico
26+
icon: assets/icon.png
2727
target:
2828
- target: nsis-web
2929
arch:

src/plugins/last-fm/index.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import prompt from 'custom-electron-prompt';
2+
3+
import { createPlugin } from '@/utils';
4+
import promptOptions from '@/providers/prompt-options';
5+
6+
import { backend } from './main';
7+
8+
/**
9+
* Configuration interface for the Last.fm plugin.
10+
*/
11+
export interface LastFmConfig {
12+
enabled: boolean;
13+
token?: string; // Request token for authentication
14+
sessionKey?: string; // Session key obtained after user approval
15+
apiRoot: string; // Base URL for Last.fm API
16+
apiKey: string; // Application API Key
17+
secret: string; // Application API Secret
18+
}
19+
20+
/**
21+
* Default configuration values.
22+
* Includes a default API Key and Secret for immediate use.
23+
*/
24+
export const defaultConfig: LastFmConfig = {
25+
enabled: false,
26+
apiRoot: 'https://ws.audioscrobbler.com/2.0/',
27+
apiKey: '04d76faaac8726e60988e14c105d421a',
28+
secret: 'a5d2a36fdf64819290f6982481eaffa2',
29+
};
30+
31+
export default createPlugin({
32+
name: () => 'Last.fm',
33+
description: () => 'Scrobble your music to Last.fm',
34+
restartNeeded: true,
35+
config: defaultConfig,
36+
menu: async ({ getConfig, setConfig, window }) => {
37+
const config = await getConfig();
38+
return [
39+
{
40+
label: 'Last.fm API Settings',
41+
async click() {
42+
const output = await prompt(
43+
{
44+
title: 'Last.fm API Settings',
45+
label: 'Configure API Key and Secret',
46+
type: 'multiInput',
47+
multiInputOptions: [
48+
{
49+
label: 'API Key',
50+
value: config.apiKey,
51+
inputAttrs: {
52+
type: 'text',
53+
},
54+
},
55+
{
56+
label: 'API Secret',
57+
value: config.secret,
58+
inputAttrs: {
59+
type: 'text',
60+
},
61+
},
62+
],
63+
resizable: true,
64+
height: 360,
65+
...promptOptions(),
66+
},
67+
window,
68+
);
69+
70+
if (output) {
71+
if (output[0]) {
72+
setConfig({ apiKey: output[0] });
73+
}
74+
if (output[1]) {
75+
setConfig({ secret: output[1] });
76+
}
77+
}
78+
},
79+
},
80+
];
81+
},
82+
backend,
83+
});

src/plugins/last-fm/main.ts

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import { BrowserWindow, net } from 'electron';
2+
import crypto from 'node:crypto';
3+
4+
import { createBackend } from '@/utils';
5+
import {
6+
registerCallback,
7+
SongInfo,
8+
SongInfoEvent,
9+
} from '@/providers/song-info';
10+
11+
import type { LastFmConfig } from './index';
12+
13+
/**
14+
* Interface representing the data sent to Last.fm API.
15+
* Keys are dynamic because Last.fm API parameters vary by method.
16+
*/
17+
interface LastFmApiParams extends Record<string, string | number | undefined> {
18+
method: string;
19+
api_key: string;
20+
sk?: string;
21+
format: 'json';
22+
api_sig?: string;
23+
}
24+
25+
/**
26+
* Generates the API signature required by Last.fm.
27+
* The signature is an MD5 hash of all parameters (sorted alphabetically) + the API secret.
28+
*
29+
* @param params - The parameters to sign.
30+
* @param secret - The Last.fm API secret.
31+
* @returns The MD5 hash signature.
32+
*/
33+
const createApiSig = (params: Record<string, unknown>, secret: string) => {
34+
let sig = '';
35+
Object.entries(params)
36+
.sort(([a], [b]) => a.localeCompare(b))
37+
.forEach(([key, value]) => {
38+
// 'format' and 'callback' are not included in the signature
39+
if (key === 'format' || key === 'callback') return;
40+
sig += key + String(value);
41+
});
42+
sig += secret;
43+
return crypto.createHash('md5').update(sig, 'utf-8').digest('hex');
44+
};
45+
46+
/**
47+
* Creates a query string from parameters, including the generated signature.
48+
*
49+
* @param params - The parameters to include in the query string.
50+
* @param apiSignature - The generated API signature.
51+
* @returns The formatted query string (e.g., "?key=value&api_sig=...").
52+
*/
53+
const createQueryString = (
54+
params: Record<string, unknown>,
55+
apiSignature: string,
56+
) => {
57+
const queryParams = { ...params, api_sig: apiSignature };
58+
const queryData = Object.entries(queryParams).map(
59+
([key, value]) =>
60+
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
61+
);
62+
return '?' + queryData.join('&');
63+
};
64+
65+
/**
66+
* Creates a FormData object for POST requests.
67+
*
68+
* @param params - The parameters to append to the form data.
69+
* @returns The populated URLSearchParams object.
70+
*/
71+
const createFormData = (params: Record<string, unknown>) => {
72+
const formData = new URLSearchParams();
73+
for (const key in params) {
74+
if (params[key] !== undefined) {
75+
formData.append(key, String(params[key]));
76+
}
77+
}
78+
return formData;
79+
};
80+
81+
export const backend = createBackend<{
82+
config?: LastFmConfig;
83+
window?: BrowserWindow;
84+
scrobbleTimer?: NodeJS.Timeout;
85+
86+
startAuth(config: LastFmConfig): Promise<void>;
87+
createSession(config: LastFmConfig): Promise<void>;
88+
scrobble(songInfo: SongInfo, config: LastFmConfig): Promise<void>;
89+
updateNowPlaying(songInfo: SongInfo, config: LastFmConfig): Promise<void>;
90+
}>({
91+
async start({ getConfig, setConfig, window }) {
92+
this.config = await getConfig();
93+
this.window = window;
94+
95+
// If enabled but no session key, start the authentication flow
96+
if (this.config.enabled && !this.config.sessionKey) {
97+
await this.startAuth(this.config);
98+
await setConfig(this.config);
99+
}
100+
101+
// Register a callback to listen for song changes
102+
registerCallback((songInfo: SongInfo, event) => {
103+
// Ignore time updates, we only care about track changes or pause/play
104+
if (event === SongInfoEvent.TimeChanged) return;
105+
106+
// Clear any pending scrobble timer to prevent duplicate scrobbles
107+
clearTimeout(this.scrobbleTimer);
108+
109+
if (
110+
!songInfo.isPaused &&
111+
this.config?.enabled &&
112+
this.config.sessionKey
113+
) {
114+
// 1. Update "Now Playing" status on Last.fm
115+
this.updateNowPlaying(songInfo, this.config);
116+
117+
// 2. Schedule the Scrobble
118+
// Rule: Scrobble at 33% of the song duration OR 4 minutes, whichever comes first.
119+
const scrobbleThreshold = Math.min(
120+
Math.ceil(songInfo.songDuration * 0.33),
121+
4 * 60, // 4 minutes in seconds
122+
);
123+
124+
const elapsed = songInfo.elapsedSeconds ?? 0;
125+
126+
if (scrobbleThreshold > elapsed) {
127+
const timeToWait = (scrobbleThreshold - elapsed) * 1000;
128+
this.scrobbleTimer = setTimeout(() => {
129+
if (this.config) {
130+
this.scrobble(songInfo, this.config);
131+
}
132+
}, timeToWait);
133+
}
134+
}
135+
});
136+
},
137+
138+
async onConfigChange(newConfig) {
139+
this.config = newConfig;
140+
// Re-authenticate if the plugin is enabled but lacks a session key
141+
if (this.config.enabled && !this.config.sessionKey) {
142+
await this.startAuth(this.config);
143+
}
144+
},
145+
146+
/**
147+
* Starts the Last.fm authentication process.
148+
* 1. Fetches a request token.
149+
* 2. Opens a browser window for the user to approve the application.
150+
* 3. Creates a session after approval.
151+
*/
152+
async startAuth(config: LastFmConfig) {
153+
// Step 1: Get a Request Token
154+
const tokenParams = {
155+
method: 'auth.gettoken',
156+
api_key: config.apiKey,
157+
format: 'json',
158+
};
159+
const tokenSig = createApiSig(tokenParams, config.secret);
160+
const tokenRes = await net.fetch(
161+
`${config.apiRoot}${createQueryString(tokenParams, tokenSig)}`,
162+
);
163+
const tokenJson = (await tokenRes.json()) as { token?: string };
164+
165+
if (!tokenJson.token) {
166+
console.error('Last.fm: Failed to get authentication token.');
167+
return;
168+
}
169+
config.token = tokenJson.token;
170+
171+
// Step 2: Request User Approval via Browser Window
172+
const authUrl = `https://www.last.fm/api/auth/?api_key=${config.apiKey}&token=${config.token}`;
173+
174+
const authWindow = new BrowserWindow({
175+
width: 500,
176+
height: 600,
177+
parent: this.window,
178+
modal: true,
179+
show: false,
180+
autoHideMenuBar: true,
181+
});
182+
183+
authWindow.loadURL(authUrl);
184+
authWindow.show();
185+
186+
// Wait for the user to approve the app in the opened window
187+
return new Promise<void>((resolve) => {
188+
authWindow.webContents.on('did-navigate', async (_, newUrl) => {
189+
const url = new URL(newUrl);
190+
// Last.fm redirects to this URL after approval
191+
if (url.hostname.endsWith('last.fm') && url.pathname === '/api/auth') {
192+
// Check if the approval was successful by looking for the confirmation element
193+
// This is a heuristic; ideally we'd use a callback URL but this is a desktop app
194+
const isApproveScreen = await authWindow.webContents.executeJavaScript(
195+
"!!document.getElementsByName('confirm').length",
196+
);
197+
198+
// If we are past the confirmation screen (or it didn't show), assume success
199+
if (!isApproveScreen) {
200+
authWindow.close();
201+
await this.createSession(config);
202+
resolve();
203+
}
204+
}
205+
});
206+
207+
// Handle window close by user (cancellation)
208+
authWindow.on('closed', () => {
209+
resolve();
210+
});
211+
});
212+
},
213+
214+
/**
215+
* Exchanges the request token for a session key.
216+
*/
217+
async createSession(config: LastFmConfig) {
218+
if (!config.token) return;
219+
220+
const params = {
221+
api_key: config.apiKey,
222+
format: 'json',
223+
method: 'auth.getsession',
224+
token: config.token,
225+
};
226+
const sig = createApiSig(params, config.secret);
227+
const res = await net.fetch(
228+
`${config.apiRoot}${createQueryString(params, sig)}`,
229+
);
230+
const json = (await res.json()) as { session?: { key: string } };
231+
232+
if (json.session) {
233+
config.sessionKey = json.session.key;
234+
console.log('Last.fm: Session created successfully.');
235+
} else {
236+
console.error('Last.fm: Failed to create session.', json);
237+
}
238+
},
239+
240+
/**
241+
* Updates the "Now Playing" track on Last.fm.
242+
*/
243+
async updateNowPlaying(songInfo: SongInfo, config: LastFmConfig) {
244+
if (!config.sessionKey) return;
245+
246+
const params: LastFmApiParams = {
247+
method: 'track.updateNowPlaying',
248+
track: songInfo.title,
249+
artist: songInfo.artist,
250+
duration: songInfo.songDuration,
251+
api_key: config.apiKey,
252+
sk: config.sessionKey,
253+
format: 'json',
254+
};
255+
256+
if (songInfo.album) {
257+
params.album = songInfo.album;
258+
}
259+
260+
const sig = createApiSig(params, config.secret);
261+
const formData = createFormData({ ...params, api_sig: sig });
262+
263+
try {
264+
await net.fetch(config.apiRoot, {
265+
method: 'POST',
266+
body: formData,
267+
});
268+
} catch (error) {
269+
console.error('Last.fm: Failed to update Now Playing.', error);
270+
}
271+
},
272+
273+
/**
274+
* Scrobbles a track to Last.fm.
275+
*/
276+
async scrobble(songInfo: SongInfo, config: LastFmConfig) {
277+
if (!config.sessionKey) return;
278+
279+
const params: LastFmApiParams = {
280+
method: 'track.scrobble',
281+
track: songInfo.title,
282+
artist: songInfo.artist,
283+
timestamp: Math.floor(Date.now() / 1000),
284+
api_key: config.apiKey,
285+
sk: config.sessionKey,
286+
format: 'json',
287+
};
288+
289+
if (songInfo.album) {
290+
params.album = songInfo.album;
291+
}
292+
293+
const sig = createApiSig(params, config.secret);
294+
const formData = createFormData({ ...params, api_sig: sig });
295+
296+
try {
297+
await net.fetch(config.apiRoot, {
298+
method: 'POST',
299+
body: formData,
300+
});
301+
console.log(`Last.fm: Scrobble successful for ${songInfo.artist} - ${songInfo.title}`);
302+
} catch (error) {
303+
console.error('Last.fm: Failed to scrobble.', error);
304+
}
305+
},
306+
});

0 commit comments

Comments
 (0)