From f424ee5170d79b4be6b9ff939369d020c484ddda Mon Sep 17 00:00:00 2001 From: Alex <69764315+Serial-ATA@users.noreply.github.com> Date: Tue, 16 Jan 2024 02:36:27 -0500 Subject: [PATCH] feat: Better Scrobbler Plugin (#1640) Co-authored-by: JellyBrick --- src/config/store.ts | 33 +++ src/i18n/resources/cs.json | 4 - src/i18n/resources/de.json | 4 - src/i18n/resources/el.json | 3 - src/i18n/resources/en.json | 26 ++- src/i18n/resources/es.json | 4 - src/i18n/resources/fr.json | 4 - src/i18n/resources/it.json | 4 - src/i18n/resources/ja.json | 4 - src/i18n/resources/ko.json | 4 - src/i18n/resources/lt.json | 4 - src/i18n/resources/nb.json | 4 - src/i18n/resources/pl.json | 4 - src/i18n/resources/pt.json | 4 - src/i18n/resources/ru.json | 4 - src/i18n/resources/tr.json | 4 - src/i18n/resources/uk.json | 4 - src/i18n/resources/zh-CN.json | 4 - src/i18n/resources/zh-TW.json | 4 - src/plugins/last-fm/index.ts | 80 ------- src/plugins/last-fm/main.ts | 207 ----------------- src/plugins/scrobbler/index.ts | 91 ++++++++ src/plugins/scrobbler/main.ts | 77 +++++++ src/plugins/scrobbler/menu.ts | 125 ++++++++++ src/plugins/scrobbler/services/base.ts | 14 ++ src/plugins/scrobbler/services/lastfm.ts | 216 ++++++++++++++++++ .../scrobbler/services/listenbrainz.ts | 92 ++++++++ 27 files changed, 671 insertions(+), 357 deletions(-) delete mode 100644 src/plugins/last-fm/index.ts delete mode 100644 src/plugins/last-fm/main.ts create mode 100644 src/plugins/scrobbler/index.ts create mode 100644 src/plugins/scrobbler/main.ts create mode 100644 src/plugins/scrobbler/menu.ts create mode 100644 src/plugins/scrobbler/services/base.ts create mode 100644 src/plugins/scrobbler/services/lastfm.ts create mode 100644 src/plugins/scrobbler/services/listenbrainz.ts diff --git a/src/config/store.ts b/src/config/store.ts index 68934241..ca96385b 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -6,6 +6,39 @@ import defaults from './defaults'; import { DefaultPresetList, type Preset } from '@/plugins/downloader/types'; const migrations = { + '>=3.3.0'(store: Conf>) { + 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>) { const discordConfig = store.get('plugins.discord') as Record< string, diff --git a/src/i18n/resources/cs.json b/src/i18n/resources/cs.json index f538a90a..5e44ab4f 100644 --- a/src/i18n/resources/cs.json +++ b/src/i18n/resources/cs.json @@ -407,10 +407,6 @@ "hide-dom-window-controls": "Skrýt DOM window controls" } }, - "last-fm": { - "description": "Přidat scrobbling podporu pro Last.fm", - "name": "Last.fm" - }, "lumiastream": { "description": "Přidává Lumia Stream podporu", "name": "Lumia Stream [Beta]" diff --git a/src/i18n/resources/de.json b/src/i18n/resources/de.json index f9ef106d..e843885a 100644 --- a/src/i18n/resources/de.json +++ b/src/i18n/resources/de.json @@ -416,10 +416,6 @@ }, "name": "In-App Menü" }, - "last-fm": { - "description": "Scrobbling-Unterstützung für Last.fm hinzufügen", - "name": "Last.fm" - }, "lumiastream": { "description": "Fügt Unterstützung für Lumia Stream hinzu", "name": "Lumia Stream [Beta]" diff --git a/src/i18n/resources/el.json b/src/i18n/resources/el.json index c3317a0c..1d6f1fc8 100644 --- a/src/i18n/resources/el.json +++ b/src/i18n/resources/el.json @@ -199,9 +199,6 @@ "button": "Download" } }, - "last-fm": { - "name": "Last.fm" - }, "navigation": { "name": "Navigation" }, diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 143725c1..b0ef8720 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -417,9 +417,29 @@ }, "name": "In-App Menu" }, - "last-fm": { - "description": "Add scrobbling support for Last.fm", - "name": "Last.fm" + "scrobbler": { + "description": "Add scrobbling support", + "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": { "description": "Adds Lumia Stream support", diff --git a/src/i18n/resources/es.json b/src/i18n/resources/es.json index efd8e09e..2af645ce 100644 --- a/src/i18n/resources/es.json +++ b/src/i18n/resources/es.json @@ -417,10 +417,6 @@ }, "name": "Menú de aplicación" }, - "last-fm": { - "description": "Añade soporte de scrobbling para Last.fm", - "name": "Last.fm" - }, "lumiastream": { "description": "Agrega soporte para Lumia Stream", "name": "Lumia Stream [Beta]" diff --git a/src/i18n/resources/fr.json b/src/i18n/resources/fr.json index 6580ef69..4d3fee7d 100644 --- a/src/i18n/resources/fr.json +++ b/src/i18n/resources/fr.json @@ -413,10 +413,6 @@ }, "name": "Menu intégré à l'application" }, - "last-fm": { - "description": "Ajouter le support du scrobbling pour Last.fm", - "name": "Last.fm" - }, "lumiastream": { "description": "Ajoute la prise en charge de Lumia Stream", "name": "Lumia Stream [Bêta]" diff --git a/src/i18n/resources/it.json b/src/i18n/resources/it.json index 3b8aa6d7..64c5f189 100644 --- a/src/i18n/resources/it.json +++ b/src/i18n/resources/it.json @@ -417,10 +417,6 @@ }, "name": "Menu In-App" }, - "last-fm": { - "description": "Aggiungi supporto per lo scrobbling su Last.fm", - "name": "Last.fm" - }, "lumiastream": { "description": "Aggiungi supporto per Lumia Stream", "name": "Lumia Stream [Beta]" diff --git a/src/i18n/resources/ja.json b/src/i18n/resources/ja.json index 729d97c1..1b7557de 100644 --- a/src/i18n/resources/ja.json +++ b/src/i18n/resources/ja.json @@ -408,10 +408,6 @@ }, "name": "アプリ内メニュー" }, - "last-fm": { - "description": "Last.fmのscrobblingサポートを追加", - "name": "Last.fm" - }, "lumiastream": { "description": "Lumia Streamのサポートを追加", "name": "Lumia Stream [ベータ]" diff --git a/src/i18n/resources/ko.json b/src/i18n/resources/ko.json index a8d50e25..22130aef 100644 --- a/src/i18n/resources/ko.json +++ b/src/i18n/resources/ko.json @@ -417,10 +417,6 @@ }, "name": "인앱 메뉴" }, - "last-fm": { - "description": "Last.fm에 대한 스크러블 지원을 추가합니다", - "name": "Last.fm" - }, "lumiastream": { "description": "Lumia Stream 지원을 추가합니다", "name": "Lumia Stream [베타]" diff --git a/src/i18n/resources/lt.json b/src/i18n/resources/lt.json index ff109c86..33f69355 100644 --- a/src/i18n/resources/lt.json +++ b/src/i18n/resources/lt.json @@ -408,10 +408,6 @@ }, "name": "Programos Meniu" }, - "last-fm": { - "description": "Pridėkite Last.fm scrobble palaikymą", - "name": "Last.fm" - }, "lumiastream": { "description": "Prideda \"Lumia Stream\" palaikymą", "name": "Lumia Stream [Beta]" diff --git a/src/i18n/resources/nb.json b/src/i18n/resources/nb.json index 57800b9b..354568e6 100644 --- a/src/i18n/resources/nb.json +++ b/src/i18n/resources/nb.json @@ -406,10 +406,6 @@ }, "name": "Meny i programmet" }, - "last-fm": { - "description": "Legg til lyttestatistikkstøtte for Last.fm", - "name": "Last.fm" - }, "lumiastream": { "description": "Legger til Lumia Stream-støtte", "name": "Lumia Stream [Beta]" diff --git a/src/i18n/resources/pl.json b/src/i18n/resources/pl.json index 518331fa..cd91cc7c 100644 --- a/src/i18n/resources/pl.json +++ b/src/i18n/resources/pl.json @@ -417,10 +417,6 @@ }, "name": "Menu w aplikacji" }, - "last-fm": { - "description": "Dodanie obsługi scrobblingu dla Last.fm", - "name": "Last.fm" - }, "lumiastream": { "description": "Dodaje obsługę Lumia Stream", "name": "Lumia Stream [Beta]" diff --git a/src/i18n/resources/pt.json b/src/i18n/resources/pt.json index 9943686c..86f94a31 100644 --- a/src/i18n/resources/pt.json +++ b/src/i18n/resources/pt.json @@ -417,10 +417,6 @@ }, "name": "Menu no aplicativo" }, - "last-fm": { - "description": "Adiciona suporte de scrobbling para Last.fm", - "name": "Last.fm" - }, "lumiastream": { "description": "Adiciona suporte Lumia Stream", "name": "Lumia Stream [Beta]" diff --git a/src/i18n/resources/ru.json b/src/i18n/resources/ru.json index d513fbe3..7b047d70 100644 --- a/src/i18n/resources/ru.json +++ b/src/i18n/resources/ru.json @@ -417,10 +417,6 @@ }, "name": "Меню в приложении" }, - "last-fm": { - "description": "Добавить сбор информации (скробблинг) для Last.fm", - "name": "Last.fm" - }, "lumiastream": { "description": "Добавляет поддержку Lumia Stream", "name": "Lumia Stream [бета]" diff --git a/src/i18n/resources/tr.json b/src/i18n/resources/tr.json index 7bfe2dfe..1947f93d 100644 --- a/src/i18n/resources/tr.json +++ b/src/i18n/resources/tr.json @@ -417,10 +417,6 @@ }, "name": "Uygulama İçi Menü" }, - "last-fm": { - "description": "Last.fm için scrobbling desteği ekler", - "name": "Last.fm" - }, "lumiastream": { "description": "Lumia Stream desteği ekler", "name": "Lumia Stream [Beta]" diff --git a/src/i18n/resources/uk.json b/src/i18n/resources/uk.json index 1a73d1be..5502ebbb 100644 --- a/src/i18n/resources/uk.json +++ b/src/i18n/resources/uk.json @@ -417,10 +417,6 @@ }, "name": "Меню в програмі" }, - "last-fm": { - "description": "Додати підтримку скроблінгу для Last.fm", - "name": "Last.fm" - }, "lumiastream": { "description": "Додано підтримку для Lumia Stream", "name": "Lumia Stream [Бета]" diff --git a/src/i18n/resources/zh-CN.json b/src/i18n/resources/zh-CN.json index 6f359098..753db057 100644 --- a/src/i18n/resources/zh-CN.json +++ b/src/i18n/resources/zh-CN.json @@ -417,10 +417,6 @@ }, "name": "应用内菜单" }, - "last-fm": { - "description": "添加 Last.fm 记录支持", - "name": "Last.fm" - }, "lumiastream": { "description": "添加 Lumia Stream 支持", "name": "Lumia Stream [测试]" diff --git a/src/i18n/resources/zh-TW.json b/src/i18n/resources/zh-TW.json index 6e6c3f3b..0de64152 100644 --- a/src/i18n/resources/zh-TW.json +++ b/src/i18n/resources/zh-TW.json @@ -408,10 +408,6 @@ }, "name": "程式內選單列" }, - "last-fm": { - "description": "新增對Last.fm的scrobbling支援", - "name": "Last.fm" - }, "lumiastream": { "description": "新增對 Lumia Stream 的支援", "name": "Lumia Stream [Beta]" diff --git a/src/plugins/last-fm/index.ts b/src/plugins/last-fm/index.ts deleted file mode 100644 index 7d9ab83d..00000000 --- a/src/plugins/last-fm/index.ts +++ /dev/null @@ -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); - } - } - }); - }, -}); diff --git a/src/plugins/last-fm/main.ts b/src/plugins/last-fm/main.ts deleted file mode 100644 index 4f956b61..00000000 --- a/src/plugins/last-fm/main.ts +++ /dev/null @@ -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, - 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; - 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>, -) => void | Promise; - -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); -}; diff --git a/src/plugins/scrobbler/index.ts b/src/plugins/scrobbler/index.ts new file mode 100644 index 00000000..26c84a9c --- /dev/null +++ b/src/plugins/scrobbler/index.ts @@ -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, +}); diff --git a/src/plugins/scrobbler/main.ts b/src/plugins/scrobbler/main.ts new file mode 100644 index 00000000..8ce86028 --- /dev/null +++ b/src/plugins/scrobbler/main.ts @@ -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>, +) => void | Promise; + +export const backend = createBackend<{ + config?: ScrobblerPluginConfig; + enabledScrobblers: Map; + 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); + } +}); + diff --git a/src/plugins/scrobbler/menu.ts b/src/plugins/scrobbler/menu.ts new file mode 100644 index 00000000..879f892b --- /dev/null +++ b/src/plugins/scrobbler/menu.ts @@ -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): Promise => { + 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); + }, + }, + ], + }, + ]; +}; diff --git a/src/plugins/scrobbler/services/base.ts b/src/plugins/scrobbler/services/base.ts new file mode 100644 index 00000000..ba988b3a --- /dev/null +++ b/src/plugins/scrobbler/services/base.ts @@ -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; + + public abstract setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void; + + public abstract addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void; +} diff --git a/src/plugins/scrobbler/services/lastfm.ts b/src/plugins/scrobbler/services/lastfm.ts new file mode 100644 index 00000000..a8e9e011 --- /dev/null +++ b/src/plugins/scrobbler/services/lastfm.ts @@ -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 { + // 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 { + // 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, + 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; + 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}`, + ); +}; diff --git a/src/plugins/scrobbler/services/listenbrainz.ts b/src/plugins/scrobbler/services/listenbrainz.ts new file mode 100644 index 00000000..f70da60e --- /dev/null +++ b/src/plugins/scrobbler/services/listenbrainz.ts @@ -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 { + 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); +}