Skip to content

Commit 5178cc6

Browse files
authored
feat(scrobblers): use BrowserWindow instead of shell.openExternal (#1758)
1 parent d9a27ff commit 5178cc6

File tree

6 files changed

+177
-58
lines changed

6 files changed

+177
-58
lines changed

src/i18n/resources/en.json

+8
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,14 @@
579579
},
580580
"scrobbler": {
581581
"description": "Add scrobbling support (etc. last.fm, Listenbrainz)",
582+
"dialog": {
583+
"lastfm": {
584+
"auth-failed": {
585+
"title": "Authentication Failed",
586+
"message": "Failed to authenticate with Last.fm\nHide the popup until the next restart."
587+
}
588+
}
589+
},
582590
"menu": {
583591
"scrobble-other-media": "Scrobble other media",
584592
"lastfm": {

src/plugins/scrobbler/main.ts

+41-15
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
1+
import { BrowserWindow } from 'electron';
2+
13
import registerCallback, { MediaType, type SongInfo } from '@/providers/song-info';
24
import { createBackend } from '@/utils';
35

4-
import { ScrobblerPluginConfig } from './index';
56
import { LastFmScrobbler } from './services/lastfm';
67
import { ListenbrainzScrobbler } from './services/listenbrainz';
7-
import { ScrobblerBase } from './services/base';
8+
9+
import type { ScrobblerPluginConfig } from './index';
10+
import type { ScrobblerBase } from './services/base';
811

912
export type SetConfType = (
1013
conf: Partial<Omit<ScrobblerPluginConfig, 'enabled'>>,
1114
) => void | Promise<void>;
1215

1316
export const backend = createBackend<{
1417
config?: ScrobblerPluginConfig;
18+
window?: BrowserWindow;
1519
enabledScrobblers: Map<string, ScrobblerBase>;
16-
toggleScrobblers(config: ScrobblerPluginConfig): void;
20+
toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow): void;
21+
createSessions(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<void>;
22+
setConfig?: SetConfType;
1723
}, ScrobblerPluginConfig>({
1824
enabledScrobblers: new Map(),
1925

20-
toggleScrobblers(config: ScrobblerPluginConfig) {
26+
toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow) {
2127
if (config.scrobblers.lastfm && config.scrobblers.lastfm.enabled) {
22-
this.enabledScrobblers.set('lastfm', new LastFmScrobbler());
28+
this.enabledScrobblers.set('lastfm', new LastFmScrobbler(window));
2329
} else {
2430
this.enabledScrobblers.delete('lastfm');
2531
}
@@ -31,28 +37,35 @@ export const backend = createBackend<{
3137
}
3238
},
3339

40+
async createSessions(config: ScrobblerPluginConfig, setConfig: SetConfType) {
41+
for (const [, scrobbler] of this.enabledScrobblers) {
42+
if (!scrobbler.isSessionCreated(config)) {
43+
await scrobbler.createSession(config, setConfig);
44+
}
45+
}
46+
},
47+
3448
async start({
3549
getConfig,
3650
setConfig,
51+
window,
3752
}) {
3853
const config = this.config = await getConfig();
3954
// This will store the timeout that will trigger addScrobble
4055
let scrobbleTimer: NodeJS.Timeout | undefined;
4156

42-
this.toggleScrobblers(config);
43-
for (const [, scrobbler] of this.enabledScrobblers) {
44-
if (!scrobbler.isSessionCreated(config)) {
45-
await scrobbler.createSession(config, setConfig);
46-
}
47-
}
57+
this.window = window;
58+
this.toggleScrobblers(config, window);
59+
await this.createSessions(config, setConfig);
60+
this.setConfig = setConfig;
4861

4962
registerCallback((songInfo: SongInfo) => {
5063
// Set remove the old scrobble timer
5164
clearTimeout(scrobbleTimer);
5265
if (!songInfo.isPaused) {
5366
const configNonnull = this.config!;
5467
// Scrobblers normally have no trouble working with official music videos
55-
if (!configNonnull.scrobble_other_media && (songInfo.mediaType !== MediaType.Audio && songInfo.mediaType !== MediaType.OriginalMusicVideo)) {
68+
if (!configNonnull.scrobbleOtherMedia && (songInfo.mediaType !== MediaType.Audio && songInfo.mediaType !== MediaType.OriginalMusicVideo)) {
5669
return;
5770
}
5871

@@ -71,12 +84,25 @@ export const backend = createBackend<{
7184
});
7285
},
7386

74-
onConfigChange(newConfig: ScrobblerPluginConfig) {
87+
async onConfigChange(newConfig: ScrobblerPluginConfig) {
7588
this.enabledScrobblers.clear();
7689

77-
this.config = newConfig;
90+
this.toggleScrobblers(newConfig, this.window!);
91+
for (const [scrobblerName, scrobblerConfig] of Object.entries(newConfig.scrobblers)) {
92+
if (scrobblerConfig.enabled) {
93+
const scrobbler = this.enabledScrobblers.get(scrobblerName);
94+
if (
95+
this.config?.scrobblers?.[scrobblerName as keyof typeof newConfig.scrobblers]?.enabled !== scrobblerConfig.enabled &&
96+
scrobbler &&
97+
!scrobbler.isSessionCreated(newConfig) &&
98+
this.setConfig
99+
) {
100+
await scrobbler.createSession(newConfig, this.setConfig);
101+
}
102+
}
103+
}
78104

79-
this.toggleScrobblers(this.config);
105+
this.config = newConfig;
80106
}
81107
});
82108

src/plugins/scrobbler/menu.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: Se
2020
multiInputOptions: [
2121
{
2222
label: t('plugins.scrobbler.prompt.lastfm.api-key'),
23-
value: options.scrobblers.lastfm?.api_key,
23+
value: options.scrobblers.lastfm?.apiKey,
2424
inputAttrs: {
2525
type: 'text'
2626
}
@@ -42,7 +42,7 @@ async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: Se
4242

4343
if (output) {
4444
if (output[0]) {
45-
options.scrobblers.lastfm.api_key = output[0];
45+
options.scrobblers.lastfm.apiKey = output[0];
4646
}
4747

4848
if (output[1]) {
@@ -82,9 +82,9 @@ export const onMenu = async ({
8282
{
8383
label: t('plugins.scrobbler.menu.scrobble-other-media'),
8484
type: 'checkbox',
85-
checked: Boolean(config.scrobble_other_media),
85+
checked: Boolean(config.scrobbleOtherMedia),
8686
click(item) {
87-
config.scrobble_other_media = item.checked;
87+
config.scrobbleOtherMedia = item.checked;
8888
setConfig(config);
8989
},
9090
},
@@ -96,7 +96,7 @@ export const onMenu = async ({
9696
type: 'checkbox',
9797
checked: Boolean(config.scrobblers.lastfm?.enabled),
9898
click(item) {
99-
backend.toggleScrobblers(config);
99+
backend.toggleScrobblers(config, window);
100100
config.scrobblers.lastfm.enabled = item.checked;
101101
setConfig(config);
102102
},
@@ -117,7 +117,7 @@ export const onMenu = async ({
117117
type: 'checkbox',
118118
checked: Boolean(config.scrobblers.listenbrainz?.enabled),
119119
click(item) {
120-
backend.toggleScrobblers(config);
120+
backend.toggleScrobblers(config, window);
121121
config.scrobblers.listenbrainz.enabled = item.checked;
122122
setConfig(config);
123123
},

src/plugins/scrobbler/services/base.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { ScrobblerPluginConfig } from '../index';
2-
import { SetConfType } from '../main';
3-
1+
import type { ScrobblerPluginConfig } from '../index';
2+
import type { SetConfType } from '../main';
43
import type { SongInfo } from '@/providers/song-info';
54

65
export abstract class ScrobblerBase {

src/plugins/scrobbler/services/lastfm.ts

+115-27
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import crypto from 'node:crypto';
22

3-
import { net, shell } from 'electron';
3+
import { BrowserWindow, dialog, net } from 'electron';
44

55
import { ScrobblerBase } from './base';
66

7-
import { ScrobblerPluginConfig } from '../index';
8-
import { SetConfType } from '../main';
7+
import { t } from '@/i18n';
98

9+
import type { ScrobblerPluginConfig } from '../index';
10+
import type { SetConfType } from '../main';
1011
import type { SongInfo } from '@/providers/song-info';
1112

1213
interface LastFmData {
@@ -28,11 +29,22 @@ interface LastFmSongData {
2829
}
2930

3031
export class LastFmScrobbler extends ScrobblerBase {
31-
isSessionCreated(config: ScrobblerPluginConfig): boolean {
32+
mainWindow: BrowserWindow;
33+
34+
constructor(mainWindow: BrowserWindow) {
35+
super();
36+
37+
this.mainWindow = mainWindow;
38+
}
39+
40+
override isSessionCreated(config: ScrobblerPluginConfig): boolean {
3241
return !!config.scrobblers.lastfm.sessionKey;
3342
}
3443

35-
async createSession(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<ScrobblerPluginConfig> {
44+
override async createSession(
45+
config: ScrobblerPluginConfig,
46+
setConfig: SetConfType,
47+
): Promise<ScrobblerPluginConfig> {
3648
// Get and store the session key
3749
const data = {
3850
api_key: config.scrobblers.lastfm.apiKey,
@@ -52,8 +64,15 @@ export class LastFmScrobbler extends ScrobblerBase {
5264
};
5365
if (json.error) {
5466
config.scrobblers.lastfm.token = await createToken(config);
55-
await authenticate(config);
56-
setConfig(config);
67+
// If is successful, we need retry the request
68+
authenticate(config, this.mainWindow).then((it) => {
69+
if (it) {
70+
this.createSession(config, setConfig);
71+
} else {
72+
// failed
73+
setConfig(config);
74+
}
75+
});
5776
}
5877
if (json.session) {
5978
config.scrobblers.lastfm.sessionKey = json.session.key;
@@ -62,7 +81,7 @@ export class LastFmScrobbler extends ScrobblerBase {
6281
return config;
6382
}
6483

65-
setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void {
84+
override setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void {
6685
if (!config.scrobblers.lastfm.sessionKey) {
6786
return;
6887
}
@@ -74,7 +93,7 @@ export class LastFmScrobbler extends ScrobblerBase {
7493
this.postSongDataToAPI(songInfo, config, data, setConfig);
7594
}
7695

77-
addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void {
96+
override addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void {
7897
if (!config.scrobblers.lastfm.sessionKey) {
7998
return;
8099
}
@@ -87,7 +106,7 @@ export class LastFmScrobbler extends ScrobblerBase {
87106
this.postSongDataToAPI(songInfo, config, data, setConfig);
88107
}
89108

90-
async postSongDataToAPI(
109+
private async postSongDataToAPI(
91110
songInfo: SongInfo,
92111
config: ScrobblerPluginConfig,
93112
data: LastFmData,
@@ -128,8 +147,14 @@ export class LastFmScrobbler extends ScrobblerBase {
128147
// Session key is invalid, so remove it from the config and reauthenticate
129148
config.scrobblers.lastfm.sessionKey = undefined;
130149
config.scrobblers.lastfm.token = await createToken(config);
131-
await authenticate(config);
132-
setConfig(config);
150+
authenticate(config, this.mainWindow).then((it) => {
151+
if (it) {
152+
this.createSession(config, setConfig);
153+
} else {
154+
// failed
155+
setConfig(config);
156+
}
157+
});
133158
} else {
134159
console.error(error);
135160
}
@@ -168,17 +193,17 @@ const createQueryString = (
168193

169194
const createApiSig = (parameters: LastFmSongData, secret: string) => {
170195
// This function creates the api signature, see: https://www.last.fm/api/authspec
171-
const keys = Object.keys(parameters);
172-
173-
keys.sort();
174196
let sig = '';
175-
for (const key of keys) {
176-
if (key === 'format') {
177-
continue;
178-
}
179197

180-
sig += `${key}${parameters[key as keyof LastFmSongData]}`;
181-
}
198+
Object
199+
.entries(parameters)
200+
.sort(([a], [b]) => a.localeCompare(b))
201+
.forEach(([key, value]) => {
202+
if (key === 'format') {
203+
return;
204+
}
205+
sig += key + value;
206+
});
182207

183208
sig += secret;
184209
sig = crypto.createHash('md5').update(sig, 'utf-8').digest('hex');
@@ -195,7 +220,11 @@ const createToken = async ({
195220
}
196221
}: ScrobblerPluginConfig) => {
197222
// Creates and stores the auth token
198-
const data = {
223+
const data: {
224+
method: string;
225+
api_key: string;
226+
format: string;
227+
} = {
199228
method: 'auth.gettoken',
200229
api_key: apiKey,
201230
format: 'json',
@@ -208,9 +237,68 @@ const createToken = async ({
208237
return json?.token;
209238
};
210239

211-
const authenticate = async (config: ScrobblerPluginConfig) => {
212-
// Asks the user for authentication
213-
await shell.openExternal(
214-
`https://www.last.fm/api/auth/?api_key=${config.scrobblers.lastfm.apiKey}&token=${config.scrobblers.lastfm.token}`,
215-
);
240+
let authWindowOpened = false;
241+
let latestAuthResult = false;
242+
243+
const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWindow) => {
244+
return new Promise<boolean>((resolve) => {
245+
if (!authWindowOpened) {
246+
authWindowOpened = true;
247+
const url = `https://www.last.fm/api/auth/?api_key=${config.scrobblers.lastfm.apiKey}&token=${config.scrobblers.lastfm.token}`;
248+
const browserWindow = new BrowserWindow({
249+
width: 500,
250+
height: 600,
251+
show: false,
252+
webPreferences: {
253+
nodeIntegration: false,
254+
},
255+
autoHideMenuBar: true,
256+
parent: mainWindow,
257+
minimizable: false,
258+
maximizable: false,
259+
paintWhenInitiallyHidden: true,
260+
modal: true,
261+
center: true,
262+
});
263+
browserWindow.loadURL(url).then(() => {
264+
browserWindow.show();
265+
browserWindow.webContents.on('did-navigate', async (_, newUrl) => {
266+
const url = new URL(newUrl);
267+
if (url.hostname.endsWith('last.fm')) {
268+
if (url.pathname === '/api/auth') {
269+
const isApproveScreen = await browserWindow.webContents.executeJavaScript(
270+
'!!document.getElementsByName(\'confirm\').length'
271+
) as boolean;
272+
// successful authentication
273+
if (!isApproveScreen) {
274+
resolve(true);
275+
latestAuthResult = true;
276+
browserWindow.close();
277+
}
278+
} else if (url.pathname === '/api/None') {
279+
resolve(false);
280+
latestAuthResult = false;
281+
browserWindow.close();
282+
}
283+
}
284+
});
285+
browserWindow.on('closed', () => {
286+
if (!latestAuthResult) {
287+
dialog.showMessageBox({
288+
title: t('plugins.scrobbler.dialog.lastfm.auth-failed.title'),
289+
message: t('plugins.scrobbler.dialog.lastfm.auth-failed.message'),
290+
type: 'error'
291+
});
292+
}
293+
authWindowOpened = false;
294+
});
295+
});
296+
} else {
297+
// wait for the previous window to close
298+
while (authWindowOpened) {
299+
// wait
300+
}
301+
resolve(latestAuthResult);
302+
}
303+
});
216304
};

0 commit comments

Comments
 (0)