mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-14 03:41:46 +00:00
feat: Better Scrobbler Plugin (#1640)
Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
@ -6,6 +6,39 @@ import defaults from './defaults';
|
|||||||
import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
|
import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
|
||||||
|
|
||||||
const migrations = {
|
const migrations = {
|
||||||
|
'>=3.3.0'(store: Conf<Record<string, unknown>>) {
|
||||||
|
const lastfmConfig = store.get('plugins.lastfm') as {
|
||||||
|
enabled?: boolean;
|
||||||
|
token?: string;
|
||||||
|
session_key?: string;
|
||||||
|
api_root?: string;
|
||||||
|
api_key?: string;
|
||||||
|
secret?: string;
|
||||||
|
};
|
||||||
|
if (lastfmConfig) {
|
||||||
|
const scrobblerConfig = store.get(
|
||||||
|
'plugins.scrobbler',
|
||||||
|
) as {
|
||||||
|
enabled?: boolean;
|
||||||
|
scrobblers: {
|
||||||
|
lastfm: {
|
||||||
|
enabled?: boolean;
|
||||||
|
token?: string;
|
||||||
|
session_key?: string;
|
||||||
|
api_root?: string;
|
||||||
|
api_key?: string;
|
||||||
|
secret?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lastfmConfig) {
|
||||||
|
scrobblerConfig.enabled = lastfmConfig.enabled;
|
||||||
|
scrobblerConfig.scrobblers.lastfm = lastfmConfig;
|
||||||
|
store.set('plugins.scrobbler', scrobblerConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
'>=3.0.0'(store: Conf<Record<string, unknown>>) {
|
'>=3.0.0'(store: Conf<Record<string, unknown>>) {
|
||||||
const discordConfig = store.get('plugins.discord') as Record<
|
const discordConfig = store.get('plugins.discord') as Record<
|
||||||
string,
|
string,
|
||||||
|
|||||||
@ -407,10 +407,6 @@
|
|||||||
"hide-dom-window-controls": "Skrýt DOM window controls"
|
"hide-dom-window-controls": "Skrýt DOM window controls"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Přidat scrobbling podporu pro Last.fm",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Přidává Lumia Stream podporu",
|
"description": "Přidává Lumia Stream podporu",
|
||||||
"name": "Lumia Stream [Beta]"
|
"name": "Lumia Stream [Beta]"
|
||||||
|
|||||||
@ -416,10 +416,6 @@
|
|||||||
},
|
},
|
||||||
"name": "In-App Menü"
|
"name": "In-App Menü"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Scrobbling-Unterstützung für Last.fm hinzufügen",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Fügt Unterstützung für Lumia Stream hinzu",
|
"description": "Fügt Unterstützung für Lumia Stream hinzu",
|
||||||
"name": "Lumia Stream [Beta]"
|
"name": "Lumia Stream [Beta]"
|
||||||
|
|||||||
@ -199,9 +199,6 @@
|
|||||||
"button": "Download"
|
"button": "Download"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"name": "Navigation"
|
"name": "Navigation"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -417,9 +417,29 @@
|
|||||||
},
|
},
|
||||||
"name": "In-App Menu"
|
"name": "In-App Menu"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
"scrobbler": {
|
||||||
"description": "Add scrobbling support for Last.fm",
|
"description": "Add scrobbling support",
|
||||||
"name": "Last.fm"
|
"name": "Scrobbler",
|
||||||
|
"menu": {
|
||||||
|
"lastfm": {
|
||||||
|
"api_settings": "Last.fm API Settings"
|
||||||
|
},
|
||||||
|
"listenbrainz": {
|
||||||
|
"token": "Enter ListenBrainz user token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"lastfm": {
|
||||||
|
"api_key": "Last.fm API key",
|
||||||
|
"api_secret": "Last.fm API secret"
|
||||||
|
},
|
||||||
|
"listenbrainz": {
|
||||||
|
"token": {
|
||||||
|
"label": "Enter your ListenBrainz user token:",
|
||||||
|
"title": "ListenBrainz token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Adds Lumia Stream support",
|
"description": "Adds Lumia Stream support",
|
||||||
|
|||||||
@ -417,10 +417,6 @@
|
|||||||
},
|
},
|
||||||
"name": "Menú de aplicación"
|
"name": "Menú de aplicación"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Añade soporte de scrobbling para Last.fm",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Agrega soporte para Lumia Stream",
|
"description": "Agrega soporte para Lumia Stream",
|
||||||
"name": "Lumia Stream [Beta]"
|
"name": "Lumia Stream [Beta]"
|
||||||
|
|||||||
@ -413,10 +413,6 @@
|
|||||||
},
|
},
|
||||||
"name": "Menu intégré à l'application"
|
"name": "Menu intégré à l'application"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Ajouter le support du scrobbling pour Last.fm",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Ajoute la prise en charge de Lumia Stream",
|
"description": "Ajoute la prise en charge de Lumia Stream",
|
||||||
"name": "Lumia Stream [Bêta]"
|
"name": "Lumia Stream [Bêta]"
|
||||||
|
|||||||
@ -417,10 +417,6 @@
|
|||||||
},
|
},
|
||||||
"name": "Menu In-App"
|
"name": "Menu In-App"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Aggiungi supporto per lo scrobbling su Last.fm",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Aggiungi supporto per Lumia Stream",
|
"description": "Aggiungi supporto per Lumia Stream",
|
||||||
"name": "Lumia Stream [Beta]"
|
"name": "Lumia Stream [Beta]"
|
||||||
|
|||||||
@ -408,10 +408,6 @@
|
|||||||
},
|
},
|
||||||
"name": "アプリ内メニュー"
|
"name": "アプリ内メニュー"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Last.fmのscrobblingサポートを追加",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Lumia Streamのサポートを追加",
|
"description": "Lumia Streamのサポートを追加",
|
||||||
"name": "Lumia Stream [ベータ]"
|
"name": "Lumia Stream [ベータ]"
|
||||||
|
|||||||
@ -417,10 +417,6 @@
|
|||||||
},
|
},
|
||||||
"name": "인앱 메뉴"
|
"name": "인앱 메뉴"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Last.fm에 대한 스크러블 지원을 추가합니다",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Lumia Stream 지원을 추가합니다",
|
"description": "Lumia Stream 지원을 추가합니다",
|
||||||
"name": "Lumia Stream [베타]"
|
"name": "Lumia Stream [베타]"
|
||||||
|
|||||||
@ -408,10 +408,6 @@
|
|||||||
},
|
},
|
||||||
"name": "Programos Meniu"
|
"name": "Programos Meniu"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Pridėkite Last.fm scrobble palaikymą",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Prideda \"Lumia Stream\" palaikymą",
|
"description": "Prideda \"Lumia Stream\" palaikymą",
|
||||||
"name": "Lumia Stream [Beta]"
|
"name": "Lumia Stream [Beta]"
|
||||||
|
|||||||
@ -406,10 +406,6 @@
|
|||||||
},
|
},
|
||||||
"name": "Meny i programmet"
|
"name": "Meny i programmet"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Legg til lyttestatistikkstøtte for Last.fm",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Legger til Lumia Stream-støtte",
|
"description": "Legger til Lumia Stream-støtte",
|
||||||
"name": "Lumia Stream [Beta]"
|
"name": "Lumia Stream [Beta]"
|
||||||
|
|||||||
@ -417,10 +417,6 @@
|
|||||||
},
|
},
|
||||||
"name": "Menu w aplikacji"
|
"name": "Menu w aplikacji"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Dodanie obsługi scrobblingu dla Last.fm",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Dodaje obsługę Lumia Stream",
|
"description": "Dodaje obsługę Lumia Stream",
|
||||||
"name": "Lumia Stream [Beta]"
|
"name": "Lumia Stream [Beta]"
|
||||||
|
|||||||
@ -417,10 +417,6 @@
|
|||||||
},
|
},
|
||||||
"name": "Menu no aplicativo"
|
"name": "Menu no aplicativo"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Adiciona suporte de scrobbling para Last.fm",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Adiciona suporte Lumia Stream",
|
"description": "Adiciona suporte Lumia Stream",
|
||||||
"name": "Lumia Stream [Beta]"
|
"name": "Lumia Stream [Beta]"
|
||||||
|
|||||||
@ -417,10 +417,6 @@
|
|||||||
},
|
},
|
||||||
"name": "Меню в приложении"
|
"name": "Меню в приложении"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Добавить сбор информации (скробблинг) для Last.fm",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Добавляет поддержку Lumia Stream",
|
"description": "Добавляет поддержку Lumia Stream",
|
||||||
"name": "Lumia Stream [бета]"
|
"name": "Lumia Stream [бета]"
|
||||||
|
|||||||
@ -417,10 +417,6 @@
|
|||||||
},
|
},
|
||||||
"name": "Uygulama İçi Menü"
|
"name": "Uygulama İçi Menü"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Last.fm için scrobbling desteği ekler",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Lumia Stream desteği ekler",
|
"description": "Lumia Stream desteği ekler",
|
||||||
"name": "Lumia Stream [Beta]"
|
"name": "Lumia Stream [Beta]"
|
||||||
|
|||||||
@ -417,10 +417,6 @@
|
|||||||
},
|
},
|
||||||
"name": "Меню в програмі"
|
"name": "Меню в програмі"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "Додати підтримку скроблінгу для Last.fm",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "Додано підтримку для Lumia Stream",
|
"description": "Додано підтримку для Lumia Stream",
|
||||||
"name": "Lumia Stream [Бета]"
|
"name": "Lumia Stream [Бета]"
|
||||||
|
|||||||
@ -417,10 +417,6 @@
|
|||||||
},
|
},
|
||||||
"name": "应用内菜单"
|
"name": "应用内菜单"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "添加 Last.fm 记录支持",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "添加 Lumia Stream 支持",
|
"description": "添加 Lumia Stream 支持",
|
||||||
"name": "Lumia Stream [测试]"
|
"name": "Lumia Stream [测试]"
|
||||||
|
|||||||
@ -408,10 +408,6 @@
|
|||||||
},
|
},
|
||||||
"name": "程式內選單列"
|
"name": "程式內選單列"
|
||||||
},
|
},
|
||||||
"last-fm": {
|
|
||||||
"description": "新增對Last.fm的scrobbling支援",
|
|
||||||
"name": "Last.fm"
|
|
||||||
},
|
|
||||||
"lumiastream": {
|
"lumiastream": {
|
||||||
"description": "新增對 Lumia Stream 的支援",
|
"description": "新增對 Lumia Stream 的支援",
|
||||||
"name": "Lumia Stream [Beta]"
|
"name": "Lumia Stream [Beta]"
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
import { createPlugin } from '@/utils';
|
|
||||||
import registerCallback from '@/providers/song-info';
|
|
||||||
import { addScrobble, getAndSetSessionKey, setNowPlaying } from './main';
|
|
||||||
import { t } from '@/i18n';
|
|
||||||
|
|
||||||
export interface LastFmPluginConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
/**
|
|
||||||
* Token used for authentication
|
|
||||||
*/
|
|
||||||
token?: string;
|
|
||||||
/**
|
|
||||||
* Session key used for scrabbling
|
|
||||||
*/
|
|
||||||
session_key?: string;
|
|
||||||
/**
|
|
||||||
* Root of the Last.fm API
|
|
||||||
*
|
|
||||||
* @default 'http://ws.audioscrobbler.com/2.0/'
|
|
||||||
*/
|
|
||||||
api_root: string;
|
|
||||||
/**
|
|
||||||
* Last.fm api key registered by @semvis123
|
|
||||||
*
|
|
||||||
* @default '04d76faaac8726e60988e14c105d421a'
|
|
||||||
*/
|
|
||||||
api_key: string;
|
|
||||||
/**
|
|
||||||
* Last.fm api secret registered by @semvis123
|
|
||||||
*
|
|
||||||
* @default 'a5d2a36fdf64819290f6982481eaffa2'
|
|
||||||
*/
|
|
||||||
secret: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createPlugin({
|
|
||||||
name: () => t('plugins.last-fm.name'),
|
|
||||||
description: () => t('plugins.last-fm.description'),
|
|
||||||
restartNeeded: true,
|
|
||||||
config: {
|
|
||||||
enabled: false,
|
|
||||||
api_root: 'http://ws.audioscrobbler.com/2.0/',
|
|
||||||
api_key: '04d76faaac8726e60988e14c105d421a',
|
|
||||||
secret: 'a5d2a36fdf64819290f6982481eaffa2',
|
|
||||||
} as LastFmPluginConfig,
|
|
||||||
async backend({ getConfig, setConfig }) {
|
|
||||||
let config = await getConfig();
|
|
||||||
// This will store the timeout that will trigger addScrobble
|
|
||||||
let scrobbleTimer: number | undefined;
|
|
||||||
|
|
||||||
if (!config.api_root) {
|
|
||||||
config.enabled = true;
|
|
||||||
setConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.session_key) {
|
|
||||||
// Not authenticated
|
|
||||||
config = await getAndSetSessionKey(config, setConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
// Set remove the old scrobble timer
|
|
||||||
clearTimeout(scrobbleTimer);
|
|
||||||
if (!songInfo.isPaused) {
|
|
||||||
setNowPlaying(songInfo, config, setConfig);
|
|
||||||
// Scrobble when the song is halfway through, or has passed the 4-minute mark
|
|
||||||
const scrobbleTime = Math.min(
|
|
||||||
Math.ceil(songInfo.songDuration / 2),
|
|
||||||
4 * 60,
|
|
||||||
);
|
|
||||||
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
|
|
||||||
// Scrobble still needs to happen
|
|
||||||
const timeToWait =
|
|
||||||
(scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
|
||||||
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
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<string, unknown>,
|
|
||||||
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<string, string>;
|
|
||||||
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<Omit<LastFmPluginConfig, 'enabled'>>,
|
|
||||||
) => void | Promise<void>;
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
91
src/plugins/scrobbler/index.ts
Normal file
91
src/plugins/scrobbler/index.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import { onMenu } from './menu';
|
||||||
|
import { backend } from './main';
|
||||||
|
|
||||||
|
export interface ScrobblerPluginConfig {
|
||||||
|
enabled: boolean,
|
||||||
|
scrobblers: {
|
||||||
|
lastfm: {
|
||||||
|
/**
|
||||||
|
* Enable Last.fm scrobbling
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
enabled: boolean,
|
||||||
|
/**
|
||||||
|
* Token used for authentication
|
||||||
|
*/
|
||||||
|
token: string | undefined,
|
||||||
|
/**
|
||||||
|
* Session key used for scrobbling
|
||||||
|
*/
|
||||||
|
session_key: string | undefined,
|
||||||
|
/**
|
||||||
|
* Root of the Last.fm API
|
||||||
|
*
|
||||||
|
* @default 'http://ws.audioscrobbler.com/2.0/'
|
||||||
|
*/
|
||||||
|
api_root: string,
|
||||||
|
/**
|
||||||
|
* Last.fm api key registered by @semvis123
|
||||||
|
*
|
||||||
|
* @default '04d76faaac8726e60988e14c105d421a'
|
||||||
|
*/
|
||||||
|
api_key: string,
|
||||||
|
/**
|
||||||
|
* Last.fm api secret registered by @semvis123
|
||||||
|
*
|
||||||
|
* @default 'a5d2a36fdf64819290f6982481eaffa2'
|
||||||
|
*/
|
||||||
|
secret: string,
|
||||||
|
},
|
||||||
|
listenbrainz: {
|
||||||
|
/**
|
||||||
|
* Enable ListenBrainz scrobbling
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
enabled: boolean,
|
||||||
|
/**
|
||||||
|
* Listenbrainz user token
|
||||||
|
*/
|
||||||
|
token: string | undefined,
|
||||||
|
/**
|
||||||
|
* Root of the ListenBrainz API
|
||||||
|
*
|
||||||
|
* @default 'https://api.listenbrainz.org/1/'
|
||||||
|
*/
|
||||||
|
api_root: string,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultConfig: ScrobblerPluginConfig = {
|
||||||
|
enabled: false,
|
||||||
|
scrobblers: {
|
||||||
|
lastfm: {
|
||||||
|
enabled: false,
|
||||||
|
token: undefined,
|
||||||
|
session_key: undefined,
|
||||||
|
api_root: 'http://ws.audioscrobbler.com/2.0/',
|
||||||
|
api_key: '04d76faaac8726e60988e14c105d421a',
|
||||||
|
secret: 'a5d2a36fdf64819290f6982481eaffa2',
|
||||||
|
},
|
||||||
|
listenbrainz: {
|
||||||
|
enabled: false,
|
||||||
|
token: undefined,
|
||||||
|
api_root: 'https://api.listenbrainz.org/1/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: () => t('plugins.scrobbler.name'),
|
||||||
|
description: () => t('plugins.scrobbler.description'),
|
||||||
|
restartNeeded: true,
|
||||||
|
config: defaultConfig,
|
||||||
|
menu: onMenu,
|
||||||
|
backend,
|
||||||
|
});
|
||||||
77
src/plugins/scrobbler/main.ts
Normal file
77
src/plugins/scrobbler/main.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import registerCallback, { type SongInfo } from '@/providers/song-info';
|
||||||
|
import { createBackend } from '@/utils';
|
||||||
|
|
||||||
|
import { ScrobblerPluginConfig } from './index';
|
||||||
|
import { LastFmScrobbler } from './services/lastfm';
|
||||||
|
import { ListenbrainzScrobbler } from './services/listenbrainz';
|
||||||
|
import { ScrobblerBase } from './services/base';
|
||||||
|
|
||||||
|
export type SetConfType = (
|
||||||
|
conf: Partial<Omit<ScrobblerPluginConfig, 'enabled'>>,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
export const backend = createBackend<{
|
||||||
|
config?: ScrobblerPluginConfig;
|
||||||
|
enabledScrobblers: Map<string, ScrobblerBase>;
|
||||||
|
toggleScrobblers(config: ScrobblerPluginConfig): void;
|
||||||
|
}, ScrobblerPluginConfig>({
|
||||||
|
enabledScrobblers: new Map(),
|
||||||
|
|
||||||
|
toggleScrobblers(config: ScrobblerPluginConfig) {
|
||||||
|
if (config.scrobblers.lastfm && config.scrobblers.lastfm.enabled) {
|
||||||
|
this.enabledScrobblers.set('lastfm', new LastFmScrobbler());
|
||||||
|
} else {
|
||||||
|
this.enabledScrobblers.delete('lastfm');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.scrobblers.listenbrainz && config.scrobblers.listenbrainz.enabled) {
|
||||||
|
this.enabledScrobblers.set('listenbrainz', new ListenbrainzScrobbler());
|
||||||
|
} else {
|
||||||
|
this.enabledScrobblers.delete('listenbrainz');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async start({
|
||||||
|
getConfig,
|
||||||
|
setConfig,
|
||||||
|
}) {
|
||||||
|
const config = this.config = await getConfig();
|
||||||
|
// This will store the timeout that will trigger addScrobble
|
||||||
|
let scrobbleTimer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
this.toggleScrobblers(config);
|
||||||
|
for (const [, scrobbler] of this.enabledScrobblers) {
|
||||||
|
if (!scrobbler.isSessionCreated(config)) {
|
||||||
|
await scrobbler.createSession(config, setConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCallback((songInfo: SongInfo) => {
|
||||||
|
// Set remove the old scrobble timer
|
||||||
|
clearTimeout(scrobbleTimer);
|
||||||
|
if (!songInfo.isPaused) {
|
||||||
|
const configNonnull = this.config!;
|
||||||
|
// Scrobble when the song is halfway through, or has passed the 4-minute mark
|
||||||
|
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
|
||||||
|
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
|
||||||
|
// Scrobble still needs to happen
|
||||||
|
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
||||||
|
scrobbleTimer = setTimeout((info, config) => {
|
||||||
|
this.enabledScrobblers.forEach((scrobbler) => scrobbler.addScrobble(info, config, setConfig));
|
||||||
|
}, timeToWait, songInfo, configNonnull);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.enabledScrobblers.forEach((scrobbler) => scrobbler.setNowPlaying(songInfo, configNonnull, setConfig));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfigChange(newConfig: ScrobblerPluginConfig) {
|
||||||
|
this.enabledScrobblers.clear();
|
||||||
|
|
||||||
|
this.config = newConfig;
|
||||||
|
|
||||||
|
this.toggleScrobblers(this.config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
125
src/plugins/scrobbler/menu.ts
Normal file
125
src/plugins/scrobbler/menu.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
|
import { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import promptOptions from '@/providers/prompt-options';
|
||||||
|
|
||||||
|
import { ScrobblerPluginConfig } from './index';
|
||||||
|
import { SetConfType, backend } from './main';
|
||||||
|
|
||||||
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
|
||||||
|
async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: SetConfType, window: BrowserWindow) {
|
||||||
|
const output = await prompt(
|
||||||
|
{
|
||||||
|
title: t('plugins.scrobbler.menu.lastfm.api_settings'),
|
||||||
|
label: t('plugins.scrobbler.menu.lastfm.api_settings'),
|
||||||
|
type: 'multiInput',
|
||||||
|
multiInputOptions: [
|
||||||
|
{
|
||||||
|
label: t('plugins.scrobbler.prompt.lastfm.api_key'),
|
||||||
|
value: options.scrobblers.lastfm?.api_key,
|
||||||
|
inputAttrs: {
|
||||||
|
type: 'text'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.scrobbler.prompt.lastfm.api_secret'),
|
||||||
|
value: options.scrobblers.lastfm?.secret,
|
||||||
|
inputAttrs: {
|
||||||
|
type: 'text'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
resizable: true,
|
||||||
|
height: 360,
|
||||||
|
...promptOptions(),
|
||||||
|
},
|
||||||
|
window,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
if (output[0]) {
|
||||||
|
options.scrobblers.lastfm.api_key = output[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output[1]) {
|
||||||
|
options.scrobblers.lastfm.secret = output[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptListenbrainzOptions(options: ScrobblerPluginConfig, setConfig: SetConfType, window: BrowserWindow) {
|
||||||
|
const output = await prompt(
|
||||||
|
{
|
||||||
|
title: t('plugins.scrobbler.prompt.listenbrainz.token.title'),
|
||||||
|
label: t('plugins.scrobbler.prompt.listenbrainz.token.label'),
|
||||||
|
type: 'input',
|
||||||
|
value: options.scrobblers.listenbrainz?.token,
|
||||||
|
...promptOptions(),
|
||||||
|
},
|
||||||
|
window,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
options.scrobblers.listenbrainz.token = output;
|
||||||
|
setConfig(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onMenu = async ({
|
||||||
|
window,
|
||||||
|
getConfig,
|
||||||
|
setConfig,
|
||||||
|
}: MenuContext<ScrobblerPluginConfig>): Promise<MenuTemplate> => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Last.fm',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: t('main.menu.plugins.enabled'),
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: Boolean(config.scrobblers.lastfm?.enabled),
|
||||||
|
click(item) {
|
||||||
|
backend.toggleScrobblers(config);
|
||||||
|
config.scrobblers.lastfm.enabled = item.checked;
|
||||||
|
setConfig(config);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.scrobbler.menu.lastfm.api_settings'),
|
||||||
|
click() {
|
||||||
|
promptLastFmOptions(config, setConfig, window);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ListenBrainz',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: t('main.menu.plugins.enabled'),
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: Boolean(config.scrobblers.listenbrainz?.enabled),
|
||||||
|
click(item) {
|
||||||
|
backend.toggleScrobblers(config);
|
||||||
|
config.scrobblers.listenbrainz.enabled = item.checked;
|
||||||
|
setConfig(config);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.scrobbler.menu.listenbrainz.token'),
|
||||||
|
click() {
|
||||||
|
promptListenbrainzOptions(config, setConfig, window);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
14
src/plugins/scrobbler/services/base.ts
Normal file
14
src/plugins/scrobbler/services/base.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ScrobblerPluginConfig } from '../index';
|
||||||
|
import { SetConfType } from '../main';
|
||||||
|
|
||||||
|
import type { SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
|
export abstract class ScrobblerBase {
|
||||||
|
public abstract isSessionCreated(config: ScrobblerPluginConfig): boolean;
|
||||||
|
|
||||||
|
public abstract createSession(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<ScrobblerPluginConfig>;
|
||||||
|
|
||||||
|
public abstract setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void;
|
||||||
|
|
||||||
|
public abstract addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void;
|
||||||
|
}
|
||||||
216
src/plugins/scrobbler/services/lastfm.ts
Normal file
216
src/plugins/scrobbler/services/lastfm.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import { net, shell } from 'electron';
|
||||||
|
|
||||||
|
import { ScrobblerBase } from './base';
|
||||||
|
|
||||||
|
import { ScrobblerPluginConfig } from '../index';
|
||||||
|
import { SetConfType } from '../main';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LastFmScrobbler extends ScrobblerBase {
|
||||||
|
isSessionCreated(config: ScrobblerPluginConfig): boolean {
|
||||||
|
return !!config.scrobblers.lastfm.session_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<ScrobblerPluginConfig> {
|
||||||
|
// Get and store the session key
|
||||||
|
const data = {
|
||||||
|
api_key: config.scrobblers.lastfm.api_key,
|
||||||
|
format: 'json',
|
||||||
|
method: 'auth.getsession',
|
||||||
|
token: config.scrobblers.lastfm.token,
|
||||||
|
};
|
||||||
|
const apiSignature = createApiSig(data, config.scrobblers.lastfm.secret);
|
||||||
|
const response = await net.fetch(
|
||||||
|
`${config.scrobblers.lastfm.api_root}${createQueryString(data, apiSignature)}`,
|
||||||
|
);
|
||||||
|
const json = (await response.json()) as {
|
||||||
|
error?: string;
|
||||||
|
session?: {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (json.error) {
|
||||||
|
config.scrobblers.lastfm.token = await createToken(config);
|
||||||
|
await authenticate(config);
|
||||||
|
setConfig(config);
|
||||||
|
}
|
||||||
|
if (json.session) {
|
||||||
|
config.scrobblers.lastfm.session_key = json.session.key;
|
||||||
|
}
|
||||||
|
setConfig(config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void {
|
||||||
|
if (!config.scrobblers.lastfm.session_key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the now playing status in last.fm
|
||||||
|
const data = {
|
||||||
|
method: 'track.updateNowPlaying',
|
||||||
|
};
|
||||||
|
this.postSongDataToAPI(songInfo, config, data, setConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void {
|
||||||
|
if (!config.scrobblers.lastfm.session_key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This adds one scrobbled song to last.fm
|
||||||
|
const data = {
|
||||||
|
method: 'track.scrobble',
|
||||||
|
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
|
||||||
|
};
|
||||||
|
this.postSongDataToAPI(songInfo, config, data, setConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
async postSongDataToAPI(
|
||||||
|
songInfo: SongInfo,
|
||||||
|
config: ScrobblerPluginConfig,
|
||||||
|
data: LastFmData,
|
||||||
|
setConfig: SetConfType,
|
||||||
|
): Promise<void> {
|
||||||
|
// This sends a post request to the api, and adds the common data
|
||||||
|
if (!config.scrobblers.lastfm.session_key) {
|
||||||
|
await this.createSession(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.scrobblers.lastfm.api_key,
|
||||||
|
sk: config.scrobblers.lastfm.session_key,
|
||||||
|
format: 'json',
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
postData.api_sig = createApiSig(postData, config.scrobblers.lastfm.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.scrobblers.lastfm.session_key = undefined;
|
||||||
|
config.scrobblers.lastfm.token = await createToken(config);
|
||||||
|
await authenticate(config);
|
||||||
|
setConfig(config);
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
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 ({
|
||||||
|
scrobblers: {
|
||||||
|
lastfm: {
|
||||||
|
api_key: apiKey,
|
||||||
|
api_root: apiRoot,
|
||||||
|
secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}: ScrobblerPluginConfig) => {
|
||||||
|
// 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<string, string>;
|
||||||
|
return json?.token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const authenticate = async (config: ScrobblerPluginConfig) => {
|
||||||
|
// Asks the user for authentication
|
||||||
|
await shell.openExternal(
|
||||||
|
`https://www.last.fm/api/auth/?api_key=${config.scrobblers.lastfm.api_key}&token=${config.scrobblers.lastfm.token}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
92
src/plugins/scrobbler/services/listenbrainz.ts
Normal file
92
src/plugins/scrobbler/services/listenbrainz.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { net } from 'electron';
|
||||||
|
|
||||||
|
import { ScrobblerBase } from './base';
|
||||||
|
|
||||||
|
import { SetConfType } from '../main';
|
||||||
|
|
||||||
|
import type { SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
|
import type { ScrobblerPluginConfig } from '../index';
|
||||||
|
|
||||||
|
interface ListenbrainzRequestBody {
|
||||||
|
listen_type?: string;
|
||||||
|
payload: {
|
||||||
|
track_metadata?: {
|
||||||
|
artist_name?: string;
|
||||||
|
track_name?: string;
|
||||||
|
release_name?: string;
|
||||||
|
additional_info?: {
|
||||||
|
media_player?: string;
|
||||||
|
submission_client?: string;
|
||||||
|
origin_url?: string;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listened_at?: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListenbrainzScrobbler extends ScrobblerBase {
|
||||||
|
isSessionCreated(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(config: ScrobblerPluginConfig, _setConfig: SetConfType): Promise<ScrobblerPluginConfig> {
|
||||||
|
return Promise.resolve(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void {
|
||||||
|
if (!config.scrobblers.listenbrainz.api_root || !config.scrobblers.listenbrainz.token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = createRequestBody('playing_now', songInfo);
|
||||||
|
submitListen(body, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void {
|
||||||
|
if (!config.scrobblers.listenbrainz.api_root || !config.scrobblers.listenbrainz.token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = createRequestBody('single', songInfo);
|
||||||
|
body.payload[0].listened_at = Math.trunc(Date.now() / 1000);
|
||||||
|
|
||||||
|
submitListen(body, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestBody(listenType: string, songInfo: SongInfo): ListenbrainzRequestBody {
|
||||||
|
const trackMetadata = {
|
||||||
|
artist_name: songInfo.artist,
|
||||||
|
track_name: songInfo.title,
|
||||||
|
release_name: songInfo.album ?? undefined,
|
||||||
|
additional_info: {
|
||||||
|
media_player: 'YouTube Music Desktop App',
|
||||||
|
submission_client: 'YouTube Music Desktop App - Scrobbler Plugin',
|
||||||
|
origin_url: songInfo.url,
|
||||||
|
duration: songInfo.songDuration,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
listen_type: listenType,
|
||||||
|
payload: [
|
||||||
|
{
|
||||||
|
track_metadata: trackMetadata,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitListen(body: ListenbrainzRequestBody, config: ScrobblerPluginConfig) {
|
||||||
|
net.fetch(config.scrobblers.listenbrainz.api_root + 'submit-listens',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Token ' + config.scrobblers.listenbrainz.token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user