feat(api-server): Add websocket as /api/v1/ws route (#3707)

Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
Johannes7k75
2025-09-05 18:17:32 +02:00
committed by GitHub
parent 8b10872e83
commit cb85048af4
21 changed files with 194 additions and 20 deletions

View File

@ -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",

19
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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';

View File

@ -0,0 +1 @@
export const API_VERSION = 'v1';

View File

@ -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<BackendType, APIServerConfig>({
});
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<BackendType, APIServerConfig>({
// 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<BackendType, APIServerConfig>({
() => this.volumeState,
);
registerAuth(this.app, backendCtx);
registerWebsocket(this.app, ws);
// swagger
this.app.openAPIRegistry.registerComponent(
@ -142,6 +148,8 @@ export const backend = createBackend<BackendType, APIServerConfig>({
});
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<BackendType, APIServerConfig>({
port,
hostname,
});
if (this.injectWebSocket && this.server) {
this.injectWebSocket(this.server);
}
} catch (err) {
console.error(err);
}

View File

@ -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',

View File

@ -1,2 +1,3 @@
export { register as registerControl } from './control';
export { register as registerAuth } from './auth';
export { register as registerWebsocket } from './websocket';

View File

@ -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<WSContext<WebSocket>>();
const send = (type: DataTypes, state: Partial<PlayerState>) => {
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<WebSocket>` assigned to a parameter of type `WSContext<WebSocket>`. (@typescript-eslint/no-unsafe-argument)" ????? what?
sockets.add(ws as WSContext<WebSocket>);
ws.send(
JSON.stringify({
type: DataTypes.PlayerInfo,
...createPlayerState({
songInfo: lastSongInfo,
volumeState,
repeat,
}),
}),
);
},
onClose(_, ws) {
sockets.delete(ws as WSContext<WebSocket>);
},
})) as (ctx: Context, next: Next) => Promise<Response>,
);
};

View File

@ -14,6 +14,7 @@ export type BackendType = {
songInfo?: SongInfo;
currentRepeatMode?: RepeatMode;
volumeState?: VolumeState;
injectWebSocket?: (server: ReturnType<typeof serve>) => void;
init: (ctx: BackendContext<APIServerConfig>) => void;
run: (hostname: string, port: number) => void;

View File

@ -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';

View File

@ -17,7 +17,8 @@ import {
sendFeedback as sendFeedback_,
setBadge,
} from './utils';
import registerCallback, {
import {
registerCallback,
cleanupName,
getImage,
MediaType,

View File

@ -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 = {

View File

@ -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';

View File

@ -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';

View File

@ -1,6 +1,7 @@
import { type BrowserWindow } from 'electron';
import registerCallback, {
import {
registerCallback,
MediaType,
type SongInfo,
SongInfoEvent,

View File

@ -14,7 +14,8 @@ import MprisPlayer, {
type Track,
} from '@jellybrick/mpris-service';
import registerCallback, {
import {
registerCallback,
type SongInfo,
SongInfoEvent,
} from '@/providers/song-info';

View File

@ -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';

View File

@ -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';

View File

@ -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 {

View File

@ -184,7 +184,7 @@ export type SongInfoCallback = (
const callbacks: Set<SongInfoCallback> = 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;

View File

@ -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';