mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 10:31:47 +00:00
feat(api-server): Add websocket as /api/v1/ws route (#3707)
Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
@ -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
19
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
1
src/plugins/api-server/backend/api-version.ts
Normal file
1
src/plugins/api-server/backend/api-version.ts
Normal file
@ -0,0 +1 @@
|
||||
export const API_VERSION = 'v1';
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export { register as registerControl } from './control';
|
||||
export { register as registerAuth } from './auth';
|
||||
export { register as registerWebsocket } from './websocket';
|
||||
|
||||
137
src/plugins/api-server/backend/routes/websocket.ts
Normal file
137
src/plugins/api-server/backend/routes/websocket.ts
Normal 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>,
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -17,7 +17,8 @@ import {
|
||||
sendFeedback as sendFeedback_,
|
||||
setBadge,
|
||||
} from './utils';
|
||||
import registerCallback, {
|
||||
import {
|
||||
registerCallback,
|
||||
cleanupName,
|
||||
getImage,
|
||||
MediaType,
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { type BrowserWindow } from 'electron';
|
||||
|
||||
import registerCallback, {
|
||||
import {
|
||||
registerCallback,
|
||||
MediaType,
|
||||
type SongInfo,
|
||||
SongInfoEvent,
|
||||
|
||||
@ -14,7 +14,8 @@ import MprisPlayer, {
|
||||
type Track,
|
||||
} from '@jellybrick/mpris-service';
|
||||
|
||||
import registerCallback, {
|
||||
import {
|
||||
registerCallback,
|
||||
type SongInfo,
|
||||
SongInfoEvent,
|
||||
} from '@/providers/song-info';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
Reference in New Issue
Block a user