diff --git a/package.json b/package.json index f4a7a552df..5a83c2cbda 100644 --- a/package.json +++ b/package.json @@ -256,6 +256,7 @@ "@skyra/jaro-winkler": "1.1.1", "@xhayper/discord-rpc": "1.2.1", "async-mutex": "0.5.0", + "axios": "^1.8.4", "bgutils-js": "3.2.0", "butterchurn": "3.0.0-beta.4", "butterchurn-presets": "3.0.0-beta.4", @@ -273,6 +274,7 @@ "fast-average-color": "9.5.0", "fast-equals": "5.2.2", "filenamify": "6.0.0", + "form-data": "^4.0.2", "hanja": "1.1.4", "happy-dom": "17.4.4", "hono": "4.7.6", @@ -311,6 +313,7 @@ "@stylistic/eslint-plugin-js": "4.2.0", "@total-typescript/ts-reset": "0.6.1", "@types/electron-localshortcut": "3.1.3", + "@types/form-data": "^2.5.2", "@types/howler": "2.2.12", "@types/html-to-text": "9.0.4", "@types/semver": "7.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1f251c800..d2770a5582 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: async-mutex: specifier: 0.5.0 version: 0.5.0 + axios: + specifier: ^1.8.4 + version: 1.8.4 bgutils-js: specifier: 3.2.0 version: 3.2.0 @@ -135,6 +138,9 @@ importers: filenamify: specifier: 6.0.0 version: 6.0.0 + form-data: + specifier: ^4.0.2 + version: 4.0.2 hanja: specifier: 1.1.4 version: 1.1.4 @@ -244,6 +250,9 @@ importers: '@types/electron-localshortcut': specifier: 3.1.3 version: 3.1.3 + '@types/form-data': + specifier: ^2.5.2 + version: 2.5.2 '@types/howler': specifier: 2.2.12 version: 2.2.12 @@ -1233,6 +1242,10 @@ packages: '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@types/form-data@2.5.2': + resolution: {integrity: sha512-tfmcyHn1Pp9YHAO5r40+UuZUPAZbUEgqTel3EuEKpmF9hPkXgR4l41853raliXnb4gwyPNoQOfvgGGlHN5WSog==} + deprecated: This is a stub types definition. form-data provides its own type definitions, so you do not need this installed. + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} @@ -1596,6 +1609,9 @@ packages: resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} engines: {node: '>=6.0.0'} + axios@1.8.4: + resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==} + babel-plugin-jsx-dom-expressions@0.39.7: resolution: {integrity: sha512-8GzVmFla7jaTNWW8W+lTMl9YGva4/06CtwJjySnkYtt8G1v9weCzc2SuF1DfrudcCNb2Doetc1FRg33swBYZCA==} peerDependencies: @@ -2487,6 +2503,15 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3738,6 +3763,9 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} @@ -5614,6 +5642,10 @@ snapshots: '@types/estree@1.0.7': {} + '@types/form-data@2.5.2': + dependencies: + form-data: 4.0.2 + '@types/fs-extra@9.0.13': dependencies: '@types/node': 22.13.5 @@ -6018,6 +6050,14 @@ snapshots: await-to-js@3.0.0: {} + axios@1.8.4: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + babel-plugin-jsx-dom-expressions@0.39.7(@babel/core@7.26.10): dependencies: '@babel/core': 7.26.10 @@ -7183,6 +7223,8 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.9: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -8440,6 +8482,8 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + proxy-from-env@1.1.0: {} + pump@3.0.2: dependencies: end-of-stream: 1.4.4 diff --git a/src/plugins/scrobbler/index.ts b/src/plugins/scrobbler/index.ts index ceed9d7776..5cc0d9b0d8 100644 --- a/src/plugins/scrobbler/index.ts +++ b/src/plugins/scrobbler/index.ts @@ -71,6 +71,28 @@ export interface ScrobblerPluginConfig { */ apiRoot: string; }; + slack: { + /** + * Enable Slack scrobbling + * + * @default false + */ + enabled: boolean; + /** + * Slack OAuth token + */ + token: string | undefined; + /** + * Slack cookie token (d cookie value) + */ + cookieToken: string | undefined; + /** + * Name to use for the custom emoji in Slack + * + * @default 'my-album-art' + */ + emojiName: string; + }; }; } @@ -92,6 +114,12 @@ export const defaultConfig: ScrobblerPluginConfig = { token: undefined, apiRoot: 'https://api.listenbrainz.org/1/', }, + slack: { + enabled: false, + token: undefined, + cookieToken: undefined, + emojiName: 'my-album-art', + }, }, }; diff --git a/src/plugins/scrobbler/main.ts b/src/plugins/scrobbler/main.ts index 91a02a75ef..2f8b1aa242 100644 --- a/src/plugins/scrobbler/main.ts +++ b/src/plugins/scrobbler/main.ts @@ -9,6 +9,7 @@ import { createBackend } from '@/utils'; import { LastFmScrobbler } from './services/lastfm'; import { ListenbrainzScrobbler } from './services/listenbrainz'; +import { SlackScrobbler } from './services/slack'; import type { ScrobblerPluginConfig } from './index'; import type { ScrobblerBase } from './services/base'; @@ -51,6 +52,12 @@ export const backend = createBackend< } else { this.enabledScrobblers.delete('listenbrainz'); } + + if (config.scrobblers.slack && config.scrobblers.slack.enabled) { + this.enabledScrobblers.set('slack', new SlackScrobbler(window)); + } else { + this.enabledScrobblers.delete('slack'); + } }, async createSessions(config: ScrobblerPluginConfig, setConfig: SetConfType) { diff --git a/src/plugins/scrobbler/menu.ts b/src/plugins/scrobbler/menu.ts index 08a5702d64..29020a4137 100644 --- a/src/plugins/scrobbler/menu.ts +++ b/src/plugins/scrobbler/menu.ts @@ -79,6 +79,63 @@ async function promptListenbrainzOptions( } } +async function promptSlackOptions( + options: ScrobblerPluginConfig, + setConfig: SetConfType, + window: BrowserWindow, +) { + const output = await prompt( + { + title: 'Slack Settings', + label: 'Slack Settings', + type: 'multiInput', + multiInputOptions: [ + { + label: 'Slack OAuth Token', + value: options.scrobblers.slack?.token, + inputAttrs: { + type: 'text', + }, + }, + { + label: 'Slack Cookie Token (d cookie value)', + value: options.scrobblers.slack?.cookieToken, + inputAttrs: { + type: 'text', + }, + }, + { + label: 'Emoji Name (for album art upload)', + value: options.scrobblers.slack?.emojiName, + inputAttrs: { + type: 'text', + }, + }, + ], + resizable: true, + height: 360, + ...promptOptions(), + }, + window, + ); + + if (output) { + if (output[0]) { + options.scrobblers.slack.token = output[0]; + } + + if (output[1]) { + options.scrobblers.slack.cookieToken = output[1]; + } + + if (output[2]) { + options.scrobblers.slack.emojiName = output[2]; + } + + setConfig(options); + } +} + export const onMenu = async ({ window, getConfig, @@ -147,5 +204,26 @@ export const onMenu = async ({ }, ], }, + { + label: 'Slack', + submenu: [ + { + label: t('main.menu.plugins.enabled'), + type: 'checkbox', + checked: Boolean(config.scrobblers.slack?.enabled), + click(item) { + backend.toggleScrobblers(config, window); + config.scrobblers.slack.enabled = item.checked; + setConfig(config); + }, + }, + { + label: 'Slack Settings', + click() { + promptSlackOptions(config, setConfig, window); + }, + }, + ], + }, ]; }; diff --git a/src/plugins/scrobbler/services/slack-api-client.ts b/src/plugins/scrobbler/services/slack-api-client.ts new file mode 100644 index 0000000000..3b6e82bf2a --- /dev/null +++ b/src/plugins/scrobbler/services/slack-api-client.ts @@ -0,0 +1,51 @@ +import axios, { AxiosResponse } from 'axios'; + +/** + * Centralized Slack API client for all requests + */ +export class SlackApiClient { + readonly token: string; + readonly cookie: string; + + constructor(token: string, cookie: string) { + this.token = token; + this.cookie = cookie; + } + + private getBaseHeaders(): Record { + return { + 'Cookie': `d=${this.cookie}`, + }; + } + + /** + * POST to a Slack API endpoint + */ + async post(endpoint: string, data: any, formData = false): Promise { + const url = `https://slack.com/api/${endpoint}`; + let headers = this.getBaseHeaders(); + let payload = data; + if (formData) { + headers = { ...headers, ...data.getHeaders() }; + } else { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + payload = new URLSearchParams(data).toString(); + } + return axios.post(url, payload, { headers, maxBodyLength: Infinity, validateStatus: () => true }); + } + + /** + * GET from a Slack API endpoint + */ + async get(endpoint: string, params: Record = {}): Promise { + const url = `https://slack.com/api/${endpoint}`; + const headers = this.getBaseHeaders(); + return axios.get(url, { headers, params, validateStatus: () => true }); + } +} + +export interface SlackApiResponse { + ok: boolean; + error?: string; + [key: string]: any; +} diff --git a/src/plugins/scrobbler/services/slack.ts b/src/plugins/scrobbler/services/slack.ts new file mode 100644 index 0000000000..4e86c1f902 --- /dev/null +++ b/src/plugins/scrobbler/services/slack.ts @@ -0,0 +1,224 @@ +import { BrowserWindow, net } from 'electron'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { SlackApiClient, SlackApiResponse } from './slack-api-client'; +import FormData from 'form-data'; + +import { ScrobblerBase } from './base'; + +import type { ScrobblerPluginConfig } from '../index'; +import type { SetConfType } from '../main'; +import type { SongInfo } from '@/providers/song-info'; + +interface SlackProfileData { + status_text: string; + status_emoji: string; + status_expiration?: number; +} + +interface SlackProfileUpdateData { + token: string; + profile: string; // JSON stringified profile data +} + +/** + * SlackScrobbler: Handles Slack status and emoji updates for the scrobbler plugin + */ +export class SlackScrobbler extends ScrobblerBase { + mainWindow: BrowserWindow; + defaultEmojis = [':cd:', ':headphones:', ':musical_note:', ':notes:', ':radio:']; + + constructor(mainWindow: BrowserWindow) { + super(); + this.mainWindow = mainWindow; + } + + /** + * Validates that all required Slack config values are present. + */ + private static validateConfig(config: ScrobblerPluginConfig): asserts config is ScrobblerPluginConfig & { + scrobblers: { slack: { token: string; cookieToken: string; emojiName: string } } + } { + const slack = config.scrobblers.slack; + if (!slack.token || !slack.cookieToken || !slack.emojiName) { + throw new Error('Missing Slack config values'); + } + } + + override isSessionCreated(config: ScrobblerPluginConfig): boolean { + try { + SlackScrobbler.validateConfig(config); + return true; + } catch { + return false; + } + } + + override async createSession( + config: ScrobblerPluginConfig, + setConfig: SetConfType, + ): Promise { + // Session creation is not required for Slack + setConfig(config); + return config; + } + + override setNowPlaying( + songInfo: SongInfo, + config: ScrobblerPluginConfig, + _setConfig: SetConfType, + ): void { + if (!this.isSessionCreated(config)) return; + const title = config.alternativeTitles && songInfo.alternativeTitle !== undefined + ? songInfo.alternativeTitle + : songInfo.title; + const artistPart = songInfo.artist || 'Unknown Artist'; + const truncatedArtist = artistPart.length > 50 ? artistPart.substring(0, 50) + '...' : artistPart; + let statusText = `Now Playing: ${truncatedArtist} - ${title}`; + if (statusText.length > 97) statusText = statusText.substring(0, 97) + '...'; + // Calculate expiration time (current time + remaining song duration) + const elapsed = songInfo.elapsedSeconds ?? 0; + const remaining = Math.max(0, Math.floor(songInfo.songDuration - elapsed)); + const expirationTime = Math.floor(Date.now() / 1000) + remaining; + this.updateSlackStatusWithEmoji(statusText, expirationTime, songInfo, config); + } + + override addScrobble( + _songInfo: SongInfo, + _config: ScrobblerPluginConfig, + _setConfig: SetConfType, + ): void { + // No action needed; status is managed by setNowPlaying + } + + /** + * Deletes an existing custom emoji from Slack. + */ + private async deleteExistingEmoji(config: ScrobblerPluginConfig): Promise { + SlackScrobbler.validateConfig(config); + const slack = config.scrobblers.slack; + const client = new SlackApiClient(slack.token, slack.cookieToken); + const data = { token: slack.token, name: slack.emojiName }; + const res = await client.post('emoji.remove', data); + const json = res.data as SlackApiResponse; + if (json.ok || json.error === 'emoji_not_found') return true; + console.error(`[SlackScrobbler] Error deleting emoji: ${json.error}`); + return false; + } + + /** + * Ensures the custom emoji does not exist (deletes if present). + */ + private async ensureEmojiDoesNotExist(config: ScrobblerPluginConfig): Promise { + SlackScrobbler.validateConfig(config); + const slack = config.scrobblers.slack; + const client = new SlackApiClient(slack.token, slack.cookieToken); + const res = await client.get('emoji.list', { token: slack.token }); + const json = res.data as SlackApiResponse; + if (json.ok) { + if (json.emoji && json.emoji[slack.emojiName]) { + return await this.deleteExistingEmoji(config); + } else { + return true; + } + } else { + console.error(`[SlackScrobbler] Error checking emoji list: ${json.error}`); + return false; + } + } + + /** + * Uploads a new custom emoji to Slack. + */ + private async uploadEmojiToSlack(filePath: string, config: ScrobblerPluginConfig): Promise { + SlackScrobbler.validateConfig(config); + const slack = config.scrobblers.slack; + const client = new SlackApiClient(slack.token, slack.cookieToken); + const emojiDeleted = await this.ensureEmojiDoesNotExist(config); + if (!emojiDeleted) return false; + const formData = new FormData(); + formData.append('token', slack.token); + formData.append('mode', 'data'); + formData.append('name', slack.emojiName); + const fileBuffer = fs.readFileSync(filePath); + formData.append('image', fileBuffer, { + filename: 'album-art.jpg', + contentType: 'image/jpeg', + }); + const res = await client.post('emoji.add', formData, true); + const json = res.data as SlackApiResponse; + if (json.ok) return true; + console.error(`[SlackScrobbler] Error uploading emoji: ${json.error}`); + return false; + } + + /** + * Sets the user's Slack status with the current track and emoji. + */ + private async updateSlackStatusWithEmoji( + statusText: string, + expirationTime: number, + songInfo: SongInfo, + config: ScrobblerPluginConfig, + ): Promise { + SlackScrobbler.validateConfig(config); + const slack = config.scrobblers.slack; + const client = new SlackApiClient(slack.token, slack.cookieToken); + const statusEmoji = await this.getStatusEmoji(songInfo, config); + const profileData: SlackProfileData = { + status_text: statusText, + status_emoji: statusEmoji, + status_expiration: expirationTime, + }; + const postData: SlackProfileUpdateData = { + token: slack.token, + profile: JSON.stringify(profileData), + }; + const res = await client.post('users.profile.set', postData); + const json = res.data as SlackApiResponse; + if (!json.ok) { + console.error(`Slack API error: ${json.error}`); + } + } + + /** + * Saves album art to a temporary file for emoji upload. + */ + private async saveAlbumArtToFile(songInfo: SongInfo): Promise { + if (!songInfo.imageSrc) return null; + try { + const tempDir = os.tmpdir(); + const filePath = path.join(tempDir, 'album-art.jpg'); + const response = await net.fetch(songInfo.imageSrc); + const imageBuffer = Buffer.from(await response.arrayBuffer()); + fs.writeFileSync(filePath, imageBuffer); + return filePath; + } catch (error) { + console.error('Error saving album art to file:', error); + return null; + } + } + + /** + * Gets the emoji to use for the current status (uploads album art if possible). + */ + private async getStatusEmoji(songInfo: SongInfo, config: ScrobblerPluginConfig): Promise { + if (songInfo.imageSrc) { + const filePath = await this.saveAlbumArtToFile(songInfo); + if (filePath) { + const uploaded = await this.uploadEmojiToSlack(filePath, config); + if (uploaded) { + return `:${config.scrobblers.slack.emojiName}:`; + } + } + } + return this.getDefaultEmoji(); + } + + private getDefaultEmoji(): string { + // Return a random default emoji + const randomIndex = Math.floor(Math.random() * this.defaultEmojis.length); + return this.defaultEmojis[randomIndex]; + } +}