From cb85048af4b622e84505c7ec5082aa740d3554eb Mon Sep 17 00:00:00 2001 From: Johannes7k75 <57235955+Johannes7k75@users.noreply.github.com> Date: Fri, 5 Sep 2025 18:17:32 +0200 Subject: [PATCH] feat(api-server): Add websocket as `/api/v1/ws` route (#3707) Co-authored-by: JellyBrick --- package.json | 1 + pnpm-lock.yaml | 19 +++ src/plugins/amuse/backend.ts | 2 +- src/plugins/api-server/backend/api-version.ts | 1 + src/plugins/api-server/backend/main.ts | 16 +- .../api-server/backend/routes/control.ts | 5 +- .../api-server/backend/routes/index.ts | 1 + .../api-server/backend/routes/websocket.ts | 137 ++++++++++++++++++ src/plugins/api-server/backend/types.ts | 1 + src/plugins/discord/main.ts | 2 +- src/plugins/downloader/main/index.ts | 3 +- src/plugins/lumiastream/index.ts | 2 +- src/plugins/notifications/interactive.ts | 3 +- src/plugins/notifications/main.ts | 3 +- src/plugins/scrobbler/main.ts | 3 +- src/plugins/shortcuts/mpris.ts | 3 +- src/plugins/taskbar-mediacontrol/index.ts | 3 +- src/plugins/touchbar/index.ts | 2 +- src/plugins/tuna-obs/index.ts | 2 +- src/providers/song-info.ts | 3 +- src/tray.ts | 2 +- 21 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 src/plugins/api-server/backend/api-version.ts create mode 100644 src/plugins/api-server/backend/routes/websocket.ts diff --git a/package.json b/package.json index 298645cf..33b48497 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@ghostery/adblocker-electron": "2.11.6", "@ghostery/adblocker-electron-preload": "2.11.6", "@hono/node-server": "1.19.1", + "@hono/node-ws": "1.2.0", "@hono/swagger-ui": "0.5.2", "@hono/zod-openapi": "1.1.0", "@hono/zod-validator": "0.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6334116..60b4f84f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@hono/node-server': specifier: 1.19.1 version: 1.19.1(hono@4.9.6) + '@hono/node-ws': + specifier: 1.2.0 + version: 1.2.0(@hono/node-server@1.19.1(hono@4.9.6))(bufferutil@4.0.9)(hono@4.9.6)(utf-8-validate@6.0.5) '@hono/swagger-ui': specifier: 0.5.2 version: 0.5.2(hono@4.9.6) @@ -813,6 +816,13 @@ packages: peerDependencies: hono: ^4 + '@hono/node-ws@1.2.0': + resolution: {integrity: sha512-OBPQ8OSHBw29mj00wT/xGYtB6HY54j0fNSdVZ7gZM3TUeq0So11GXaWtFf1xWxQNfumKIsj0wRuLKWfVsO5GgQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@hono/node-server': ^1.11.1 + hono: ^4.6.0 + '@hono/swagger-ui@0.5.2': resolution: {integrity: sha512-7wxLKdb8h7JTdZ+K8DJNE3KXQMIpJejkBTQjrYlUWF28Z1PGOKw6kUykARe5NTfueIN37jbyG/sBYsbzXzG53A==} peerDependencies: @@ -5260,6 +5270,15 @@ snapshots: dependencies: hono: 4.9.6 + '@hono/node-ws@1.2.0(@hono/node-server@1.19.1(hono@4.9.6))(bufferutil@4.0.9)(hono@4.9.6)(utf-8-validate@6.0.5)': + dependencies: + '@hono/node-server': 1.19.1(hono@4.9.6) + hono: 4.9.6 + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@hono/swagger-ui@0.5.2(hono@4.9.6)': dependencies: hono: 4.9.6 diff --git a/src/plugins/amuse/backend.ts b/src/plugins/amuse/backend.ts index 8f6b56be..b02b9e39 100644 --- a/src/plugins/amuse/backend.ts +++ b/src/plugins/amuse/backend.ts @@ -4,7 +4,7 @@ import { type Context, Hono } from 'hono'; import { cors } from 'hono/cors'; import { serve } from '@hono/node-server'; -import registerCallback, { type SongInfo } from '@/providers/song-info'; +import { registerCallback, type SongInfo } from '@/providers/song-info'; import { createBackend } from '@/utils'; import type { AmuseSongInfo } from './types'; diff --git a/src/plugins/api-server/backend/api-version.ts b/src/plugins/api-server/backend/api-version.ts new file mode 100644 index 00000000..ba1d38d4 --- /dev/null +++ b/src/plugins/api-server/backend/api-version.ts @@ -0,0 +1 @@ +export const API_VERSION = 'v1'; diff --git a/src/plugins/api-server/backend/main.ts b/src/plugins/api-server/backend/main.ts index df731e2f..0e45dca5 100644 --- a/src/plugins/api-server/backend/main.ts +++ b/src/plugins/api-server/backend/main.ts @@ -3,12 +3,13 @@ import { OpenAPIHono as Hono } from '@hono/zod-openapi'; import { cors } from 'hono/cors'; import { swaggerUI } from '@hono/swagger-ui'; import { serve } from '@hono/node-server'; +import { createNodeWebSocket } from '@hono/node-ws'; -import registerCallback from '@/providers/song-info'; +import { registerCallback } from '@/providers/song-info'; import { createBackend } from '@/utils'; import { JWTPayloadSchema } from './scheme'; -import { registerAuth, registerControl } from './routes'; +import { registerAuth, registerControl, registerWebsocket } from './routes'; import { type APIServerConfig, AuthStrategy } from '../config'; @@ -29,6 +30,7 @@ export const backend = createBackend({ }); ctx.ipc.on('ytmd:player-api-loaded', () => { + ctx.ipc.send('ytmd:setup-seeked-listener'); ctx.ipc.send('ytmd:setup-time-changed-listener'); ctx.ipc.send('ytmd:setup-repeat-changed-listener'); ctx.ipc.send('ytmd:setup-like-changed-listener'); @@ -67,6 +69,9 @@ export const backend = createBackend({ // Custom init(backendCtx) { this.app = new Hono(); + const ws = createNodeWebSocket({ + app: this.app, + }); this.app.use('*', cors()); @@ -115,6 +120,7 @@ export const backend = createBackend({ () => this.volumeState, ); registerAuth(this.app, backendCtx); + registerWebsocket(this.app, ws); // swagger this.app.openAPIRegistry.registerComponent( @@ -142,6 +148,8 @@ export const backend = createBackend({ }); this.app.get('/swagger', swaggerUI({ url: '/doc' })); + + this.injectWebSocket = ws.injectWebSocket.bind(this); }, run(hostname, port) { if (!this.app) return; @@ -152,6 +160,10 @@ export const backend = createBackend({ port, hostname, }); + + if (this.injectWebSocket && this.server) { + this.injectWebSocket(this.server); + } } catch (err) { console.error(err); } diff --git a/src/plugins/api-server/backend/routes/control.ts b/src/plugins/api-server/backend/routes/control.ts index 28790729..82583e59 100644 --- a/src/plugins/api-server/backend/routes/control.ts +++ b/src/plugins/api-server/backend/routes/control.ts @@ -1,9 +1,7 @@ import { createRoute, z } from '@hono/zod-openapi'; - import { ipcMain } from 'electron'; import getSongControls from '@/providers/song-controls'; - import { LikeType, type RepeatMode, @@ -25,6 +23,7 @@ import { SwitchRepeatSchema, type ResponseSongInfo, } from '../scheme'; +import { API_VERSION } from '../api-version'; import type { SongInfo } from '@/providers/song-info'; import type { BackendContext } from '@/types/contexts'; @@ -33,8 +32,6 @@ import type { HonoApp } from '../types'; import type { QueueResponse } from '@/types/youtube-music-desktop-internal'; import type { Context } from 'hono'; -const API_VERSION = 'v1'; - const routes = { previous: createRoute({ method: 'post', diff --git a/src/plugins/api-server/backend/routes/index.ts b/src/plugins/api-server/backend/routes/index.ts index e13f8e66..a2467098 100644 --- a/src/plugins/api-server/backend/routes/index.ts +++ b/src/plugins/api-server/backend/routes/index.ts @@ -1,2 +1,3 @@ export { register as registerControl } from './control'; export { register as registerAuth } from './auth'; +export { register as registerWebsocket } from './websocket'; diff --git a/src/plugins/api-server/backend/routes/websocket.ts b/src/plugins/api-server/backend/routes/websocket.ts new file mode 100644 index 00000000..880762e3 --- /dev/null +++ b/src/plugins/api-server/backend/routes/websocket.ts @@ -0,0 +1,137 @@ +import { ipcMain } from 'electron'; +import { createRoute } from '@hono/zod-openapi'; + +import { type NodeWebSocket } from '@hono/node-ws'; + +import { + registerCallback, + type SongInfo, + SongInfoEvent, +} from '@/providers/song-info'; + +import { API_VERSION } from '../api-version'; + +import type { WSContext } from 'hono/ws'; +import type { Context, Next } from 'hono'; +import type { RepeatMode, VolumeState } from '@/types/datahost-get-state'; +import type { HonoApp } from '../types'; + +enum DataTypes { + PlayerInfo = 'PLAYER_INFO', + VideoChanged = 'VIDEO_CHANGED', + PlayerStateChanged = 'PLAYER_STATE_CHANGED', + PositionChanged = 'POSITION_CHANGED', + VolumeChanged = 'VOLUME_CHANGED', + RepeatChanged = 'REPEAT_CHANGED', +} + +type PlayerState = { + song?: SongInfo; + isPlaying: boolean; + muted: boolean; + position: number; + volume: number; + repeat: RepeatMode; +}; + +export const register = (app: HonoApp, nodeWebSocket: NodeWebSocket) => { + let volumeState: VolumeState | undefined = undefined; + let repeat: RepeatMode = 'NONE'; + let lastSongInfo: SongInfo | undefined = undefined; + + const sockets = new Set>(); + + const send = (type: DataTypes, state: Partial) => { + sockets.forEach((socket) => + socket.send(JSON.stringify({ type, ...state })), + ); + }; + + const createPlayerState = ({ + songInfo, + volumeState, + repeat, + }: { + songInfo?: SongInfo; + volumeState?: VolumeState; + repeat: RepeatMode; + }): PlayerState => ({ + song: songInfo, + isPlaying: songInfo ? !songInfo.isPaused : false, + muted: volumeState?.isMuted ?? false, + position: songInfo?.elapsedSeconds ?? 0, + volume: volumeState?.state ?? 100, + repeat, + }); + + registerCallback((songInfo, event) => { + if (event === SongInfoEvent.VideoSrcChanged) { + send(DataTypes.VideoChanged, { song: songInfo, position: 0 }); + } + + if (event === SongInfoEvent.PlayOrPaused) { + send(DataTypes.PlayerStateChanged, { + isPlaying: !(songInfo?.isPaused ?? true), + position: songInfo.elapsedSeconds, + }); + } + + if (event === SongInfoEvent.TimeChanged) { + send(DataTypes.PositionChanged, { position: songInfo.elapsedSeconds }); + } + + lastSongInfo = { ...songInfo }; + }); + + ipcMain.on('ytmd:volume-changed', (_, newVolumeState: VolumeState) => { + volumeState = newVolumeState; + send(DataTypes.VolumeChanged, { + volume: volumeState.state, + muted: volumeState.isMuted, + }); + }); + + ipcMain.on('ytmd:repeat-changed', (_, mode: RepeatMode) => { + repeat = mode; + send(DataTypes.RepeatChanged, { repeat }); + }); + + ipcMain.on('ytmd:seeked', (_, t: number) => { + send(DataTypes.PositionChanged, { position: t }); + }); + + app.openapi( + createRoute({ + method: 'get', + path: `/api/${API_VERSION}/ws`, + summary: 'websocket endpoint', + description: 'WebSocket endpoint for real-time updates', + responses: { + 101: { + description: 'Switching Protocols', + }, + }, + }), + nodeWebSocket.upgradeWebSocket(() => ({ + onOpen(_, ws) { + // "Unsafe argument of type `WSContext` assigned to a parameter of type `WSContext`. (@typescript-eslint/no-unsafe-argument)" ????? what? + sockets.add(ws as WSContext); + + ws.send( + JSON.stringify({ + type: DataTypes.PlayerInfo, + ...createPlayerState({ + songInfo: lastSongInfo, + volumeState, + repeat, + }), + }), + ); + }, + + onClose(_, ws) { + sockets.delete(ws as WSContext); + }, + })) as (ctx: Context, next: Next) => Promise, + ); +}; diff --git a/src/plugins/api-server/backend/types.ts b/src/plugins/api-server/backend/types.ts index ea820c59..2f20dbe9 100644 --- a/src/plugins/api-server/backend/types.ts +++ b/src/plugins/api-server/backend/types.ts @@ -14,6 +14,7 @@ export type BackendType = { songInfo?: SongInfo; currentRepeatMode?: RepeatMode; volumeState?: VolumeState; + injectWebSocket?: (server: ReturnType) => void; init: (ctx: BackendContext) => void; run: (hostname: string, port: number) => void; diff --git a/src/plugins/discord/main.ts b/src/plugins/discord/main.ts index dbd523af..5a892aab 100644 --- a/src/plugins/discord/main.ts +++ b/src/plugins/discord/main.ts @@ -1,6 +1,6 @@ import { app } from 'electron'; -import registerCallback, { SongInfoEvent } from '@/providers/song-info'; +import { registerCallback, SongInfoEvent } from '@/providers/song-info'; import { createBackend } from '@/utils'; import { DiscordService } from './discord-service'; diff --git a/src/plugins/downloader/main/index.ts b/src/plugins/downloader/main/index.ts index fe3e3629..902f41ab 100644 --- a/src/plugins/downloader/main/index.ts +++ b/src/plugins/downloader/main/index.ts @@ -17,7 +17,8 @@ import { sendFeedback as sendFeedback_, setBadge, } from './utils'; -import registerCallback, { +import { + registerCallback, cleanupName, getImage, MediaType, diff --git a/src/plugins/lumiastream/index.ts b/src/plugins/lumiastream/index.ts index 351337f5..82a45588 100644 --- a/src/plugins/lumiastream/index.ts +++ b/src/plugins/lumiastream/index.ts @@ -1,7 +1,7 @@ import { net } from 'electron'; import { createPlugin } from '@/utils'; -import registerCallback from '@/providers/song-info'; +import { registerCallback } from '@/providers/song-info'; import { t } from '@/i18n'; type LumiaData = { diff --git a/src/plugins/notifications/interactive.ts b/src/plugins/notifications/interactive.ts index 33d7e900..26148337 100644 --- a/src/plugins/notifications/interactive.ts +++ b/src/plugins/notifications/interactive.ts @@ -8,7 +8,8 @@ import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpac import { notificationImage, secondsToMinutes, ToastStyles } from './utils'; import getSongControls from '@/providers/song-controls'; -import registerCallback, { +import { + registerCallback, type SongInfo, SongInfoEvent, } from '@/providers/song-info'; diff --git a/src/plugins/notifications/main.ts b/src/plugins/notifications/main.ts index dbf3d94b..ba1831a7 100644 --- a/src/plugins/notifications/main.ts +++ b/src/plugins/notifications/main.ts @@ -5,7 +5,8 @@ import is from 'electron-is'; import { notificationImage } from './utils'; import interactive from './interactive'; -import registerCallback, { +import { + registerCallback, type SongInfo, SongInfoEvent, } from '@/providers/song-info'; diff --git a/src/plugins/scrobbler/main.ts b/src/plugins/scrobbler/main.ts index 5b842f32..32ffa848 100644 --- a/src/plugins/scrobbler/main.ts +++ b/src/plugins/scrobbler/main.ts @@ -1,6 +1,7 @@ import { type BrowserWindow } from 'electron'; -import registerCallback, { +import { + registerCallback, MediaType, type SongInfo, SongInfoEvent, diff --git a/src/plugins/shortcuts/mpris.ts b/src/plugins/shortcuts/mpris.ts index 0baf7be7..b6689013 100644 --- a/src/plugins/shortcuts/mpris.ts +++ b/src/plugins/shortcuts/mpris.ts @@ -14,7 +14,8 @@ import MprisPlayer, { type Track, } from '@jellybrick/mpris-service'; -import registerCallback, { +import { + registerCallback, type SongInfo, SongInfoEvent, } from '@/providers/song-info'; diff --git a/src/plugins/taskbar-mediacontrol/index.ts b/src/plugins/taskbar-mediacontrol/index.ts index f34991e0..5eb3991e 100644 --- a/src/plugins/taskbar-mediacontrol/index.ts +++ b/src/plugins/taskbar-mediacontrol/index.ts @@ -8,7 +8,8 @@ import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpac import { createPlugin } from '@/utils'; import getSongControls from '@/providers/song-controls'; -import registerCallback, { +import { + registerCallback, type SongInfo, SongInfoEvent, } from '@/providers/song-info'; diff --git a/src/plugins/touchbar/index.ts b/src/plugins/touchbar/index.ts index 587ea066..f2cceba5 100644 --- a/src/plugins/touchbar/index.ts +++ b/src/plugins/touchbar/index.ts @@ -2,7 +2,7 @@ import { nativeImage, type NativeImage, TouchBar } from 'electron'; import { createPlugin } from '@/utils'; import getSongControls from '@/providers/song-controls'; -import registerCallback, { SongInfoEvent } from '@/providers/song-info'; +import { registerCallback, SongInfoEvent } from '@/providers/song-info'; import { t } from '@/i18n'; import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack'; diff --git a/src/plugins/tuna-obs/index.ts b/src/plugins/tuna-obs/index.ts index a9759132..2e860819 100644 --- a/src/plugins/tuna-obs/index.ts +++ b/src/plugins/tuna-obs/index.ts @@ -3,7 +3,7 @@ import { net } from 'electron'; import is from 'electron-is'; import { createPlugin } from '@/utils'; -import registerCallback from '@/providers/song-info'; +import { registerCallback } from '@/providers/song-info'; import { t } from '@/i18n'; interface Data { diff --git a/src/providers/song-info.ts b/src/providers/song-info.ts index 4efbc414..e9c73dec 100644 --- a/src/providers/song-info.ts +++ b/src/providers/song-info.ts @@ -184,7 +184,7 @@ export type SongInfoCallback = ( const callbacks: Set = new Set(); // This function will allow plugins to register callback that will be triggered when data changes -const registerCallback = (callback: SongInfoCallback) => { +export const registerCallback = (callback: SongInfoCallback) => { callbacks.add(callback); }; @@ -282,5 +282,4 @@ export function cleanupName(name: string): string { return name; } -export default registerCallback; export const setupSongInfo = registerProvider; diff --git a/src/tray.ts b/src/tray.ts index 6059d0e0..a9a1c7c0 100644 --- a/src/tray.ts +++ b/src/tray.ts @@ -7,7 +7,7 @@ import pausedTrayIconAsset from '@assets/youtube-music-tray-paused.png?asset&asa import config from './config'; import { restart } from './providers/app-controls'; -import registerCallback, { SongInfoEvent } from './providers/song-info'; +import { registerCallback, SongInfoEvent } from './providers/song-info'; import getSongControls from './providers/song-controls'; import { t } from '@/i18n';