From 9ba8913da739311047434e6dc7cc2e4e3a6b0335 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 13 Oct 2024 19:10:12 +0900 Subject: [PATCH] feat(api-server): remote control api (#1909) Co-authored-by: JellyBrick Co-authored-by: Angelos Bouklis Co-authored-by: Angelos Bouklis Co-authored-by: Angelos Bouklis <53124886+ArjixWasTaken@users.noreply.github.com> --- package.json | 8 +- pnpm-lock.yaml | 108 ++++ src/i18n/resources/en.json | 43 ++ src/plugins/api-server/backend/index.ts | 1 + src/plugins/api-server/backend/main.ts | 99 ++++ src/plugins/api-server/backend/routes/auth.ts | 90 ++++ .../api-server/backend/routes/control.ts | 474 ++++++++++++++++++ .../api-server/backend/routes/index.ts | 2 + src/plugins/api-server/backend/scheme/auth.ts | 13 + .../api-server/backend/scheme/go-back.ts | 5 + .../api-server/backend/scheme/go-forward.ts | 5 + .../api-server/backend/scheme/index.ts | 8 + .../backend/scheme/set-fullscreen.ts | 5 + .../api-server/backend/scheme/set-volume.ts | 5 + .../api-server/backend/scheme/song-info.ts | 26 + .../backend/scheme/switch-repeat.ts | 5 + src/plugins/api-server/backend/types.ts | 18 + src/plugins/api-server/config.ts | 19 + src/plugins/api-server/index.ts | 17 + src/plugins/api-server/menu.ts | 85 ++++ src/providers/song-controls.ts | 2 + src/renderer.ts | 6 + 22 files changed, 1043 insertions(+), 1 deletion(-) create mode 100644 src/plugins/api-server/backend/index.ts create mode 100644 src/plugins/api-server/backend/main.ts create mode 100644 src/plugins/api-server/backend/routes/auth.ts create mode 100644 src/plugins/api-server/backend/routes/control.ts create mode 100644 src/plugins/api-server/backend/routes/index.ts create mode 100644 src/plugins/api-server/backend/scheme/auth.ts create mode 100644 src/plugins/api-server/backend/scheme/go-back.ts create mode 100644 src/plugins/api-server/backend/scheme/go-forward.ts create mode 100644 src/plugins/api-server/backend/scheme/index.ts create mode 100644 src/plugins/api-server/backend/scheme/set-fullscreen.ts create mode 100644 src/plugins/api-server/backend/scheme/set-volume.ts create mode 100644 src/plugins/api-server/backend/scheme/song-info.ts create mode 100644 src/plugins/api-server/backend/scheme/switch-repeat.ts create mode 100644 src/plugins/api-server/backend/types.ts create mode 100644 src/plugins/api-server/config.ts create mode 100644 src/plugins/api-server/index.ts create mode 100644 src/plugins/api-server/menu.ts diff --git a/package.json b/package.json index 2784d9f8..1007597a 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,10 @@ "@ffmpeg.wasm/main": "0.12.0", "@floating-ui/dom": "1.6.11", "@foobar404/wave": "2.0.5", + "@hono/node-server": "1.13.2", + "@hono/swagger-ui": "0.4.1", + "@hono/zod-openapi": "0.16.4", + "@hono/zod-validator": "0.4.1", "@jellybrick/electron-better-web-request": "1.0.4", "@jellybrick/mpris-service": "2.1.4", "@skyra/jaro-winkler": "^1.1.1", @@ -186,6 +190,7 @@ "fast-average-color": "9.4.0", "fast-equals": "5.0.1", "filenamify": "6.0.0", + "hono": "4.6.4", "howler": "2.2.4", "html-to-text": "9.0.5", "i18next": "23.15.2", @@ -204,7 +209,8 @@ "ts-morph": "24.0.0", "vudio": "2.1.1", "x11": "2.3.0", - "youtubei.js": "10.5.0" + "youtubei.js": "10.5.0", + "zod": "3.23.8" }, "devDependencies": { "@eslint/js": "^9.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c4a9470..5cb27190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,18 @@ importers: '@foobar404/wave': specifier: 2.0.5 version: 2.0.5 + '@hono/node-server': + specifier: 1.13.2 + version: 1.13.2(hono@4.6.4) + '@hono/swagger-ui': + specifier: 0.4.1 + version: 0.4.1(hono@4.6.4) + '@hono/zod-openapi': + specifier: 0.16.4 + version: 0.16.4(hono@4.6.4)(zod@3.23.8) + '@hono/zod-validator': + specifier: 0.4.1 + version: 0.4.1(hono@4.6.4)(zod@3.23.8) '@jellybrick/electron-better-web-request': specifier: 1.0.4 version: 1.0.4 @@ -111,6 +123,9 @@ importers: filenamify: specifier: 6.0.0 version: 6.0.0 + hono: + specifier: 4.6.4 + version: 4.6.4 howler: specifier: 2.2.4 version: 2.2.4 @@ -168,6 +183,9 @@ importers: youtubei.js: specifier: 10.5.0 version: 10.5.0 + zod: + specifier: 3.23.8 + version: 3.23.8 devDependencies: '@eslint/js': specifier: ^9.10.0 @@ -306,6 +324,11 @@ packages: '@assemblyscript/loader@0.17.14': resolution: {integrity: sha512-+PVTOfla/0XMLRTQLJFPg4u40XcdTfon6GGea70hBGi8Pd7ZymIXyVUR+vK8wt5Jb4MVKTKPIz43Myyebw5mZA==} + '@asteasolutions/zod-to-openapi@7.2.0': + resolution: {integrity: sha512-Va+Fq1QzKkSgmiYINSp3cASFhMsbdRH/kmCk2feijhC+yNjGoC056CRqihrVFhR8MY8HOZHdlYm2Ns2lmszCiw==} + peerDependencies: + zod: ^3.20.2 + '@babel/code-frame@7.24.7': resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} @@ -826,6 +849,36 @@ packages: '@foobar404/wave@2.0.5': resolution: {integrity: sha512-V/ydadtv5ObCw8aEg+Qy3YSq1eyinEWzJfRI43Ovmj7VmAvEdWAdL7MatoMbiIVYPATkNDVF7GOxX1xirxM9dA==} + '@hono/node-server@1.13.2': + resolution: {integrity: sha512-0w8nEmAyx0Ul0CQp8BL2VtAG4YVdpzXd/mvvM+l0G5Oq22pUyHS+KeFFPSY+czLOF5NAiV3MUNPD1n14Ol5svg==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@hono/swagger-ui@0.4.1': + resolution: {integrity: sha512-kPaJatHffeYQ3yVkHo878hCqwfapqx54FczJVJ+eRWt8J4biyVVMIdCAJb6MyA8bcnHUoTmUpPc7OJAV1VTg2g==} + peerDependencies: + hono: '*' + + '@hono/zod-openapi@0.16.4': + resolution: {integrity: sha512-mnF6GthBaKex0D5PsY/4lYNtkaGJNE38bjeUI//EUqq7Ee4TNm2su35IUiFH4HcmJp5fWYMLyOJOpjnkClzEGw==} + engines: {node: '>=16.0.0'} + peerDependencies: + hono: '>=4.3.6' + zod: 3.* + + '@hono/zod-validator@0.3.0': + resolution: {integrity: sha512-7XcTk3yYyk6ldrO/VuqsroE7stvDZxHJQcpATRAyha8rUxJNBPV3+6waDrARfgEqxOVlzIadm3/6sE/dPseXgQ==} + peerDependencies: + hono: '>=3.9.0' + zod: ^3.19.1 + + '@hono/zod-validator@0.4.1': + resolution: {integrity: sha512-I8LyfeJfvVmC5hPjZ2Iij7RjexlgSBT7QJudZ4JvNPLxn0JQ3sqclz2zydlwISAnw21D2n4LQ0nfZdoiv9fQQA==} + peerDependencies: + hono: '>=3.9.0' + zod: ^3.19.1 + '@humanfs/core@0.19.0': resolution: {integrity: sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==} engines: {node: '>=18.18.0'} @@ -2458,6 +2511,10 @@ packages: resolution: {integrity: sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==} hasBin: true + hono@4.6.4: + resolution: {integrity: sha512-T5WqBkTOcIQblqBKB5mpzaH/A+dSpvVe938xZJCHOmOuYfF7DSwE/9/10+BMvwSPq9N/f6LiQ38HxrZSQOsXKw==} + engines: {node: '>=16.9.0'} + hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -3135,6 +3192,9 @@ packages: resolution: {integrity: sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==} engines: {node: '>=18'} + openapi3-ts@4.4.0: + resolution: {integrity: sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==} + optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -4026,6 +4086,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.5.1: + resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} + engines: {node: '>= 14'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4048,6 +4113,9 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + snapshots: 7zip-bin@5.2.0: {} @@ -4063,6 +4131,11 @@ snapshots: '@assemblyscript/loader@0.17.14': {} + '@asteasolutions/zod-to-openapi@7.2.0(zod@3.23.8)': + dependencies: + openapi3-ts: 4.4.0 + zod: 3.23.8 + '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 @@ -4530,6 +4603,31 @@ snapshots: '@foobar404/wave@2.0.5': {} + '@hono/node-server@1.13.2(hono@4.6.4)': + dependencies: + hono: 4.6.4 + + '@hono/swagger-ui@0.4.1(hono@4.6.4)': + dependencies: + hono: 4.6.4 + + '@hono/zod-openapi@0.16.4(hono@4.6.4)(zod@3.23.8)': + dependencies: + '@asteasolutions/zod-to-openapi': 7.2.0(zod@3.23.8) + '@hono/zod-validator': 0.3.0(hono@4.6.4)(zod@3.23.8) + hono: 4.6.4 + zod: 3.23.8 + + '@hono/zod-validator@0.3.0(hono@4.6.4)(zod@3.23.8)': + dependencies: + hono: 4.6.4 + zod: 3.23.8 + + '@hono/zod-validator@0.4.1(hono@4.6.4)(zod@3.23.8)': + dependencies: + hono: 4.6.4 + zod: 3.23.8 + '@humanfs/core@0.19.0': {} '@humanfs/node@0.16.5': @@ -6553,6 +6651,8 @@ snapshots: hexy@0.2.11: {} + hono@4.6.4: {} + hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 @@ -7191,6 +7291,10 @@ snapshots: is-inside-container: 1.0.0 is-wsl: 3.1.0 + openapi3-ts@4.4.0: + dependencies: + yaml: 2.5.1 + optionator@0.9.3: dependencies: '@aashutoshrathi/word-wrap': 1.2.6 @@ -8107,6 +8211,8 @@ snapshots: yallist@4.0.0: {} + yaml@2.5.1: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -8138,3 +8244,5 @@ snapshots: archiver-utils: 3.0.4 compress-commons: 4.1.2 readable-stream: 3.6.2 + + zod@3.23.8: {} diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 20aa7d41..9ce1bf41 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -279,6 +279,49 @@ }, "name": "Ambient Mode" }, + "api-server": { + "description": "Adds an API server to control the player", + "name": "API Server [Beta]", + "dialog": { + "request": { + "title": "API authorization request", + "message": "Allow {{ID}} ({{origin}}) to access the API?", + "buttons": { + "allow": "Allow", + "deny": "Deny" + } + } + }, + "menu": { + "hostname": { + "label": "Hostname" + }, + "port": { + "label": "Port" + }, + "auth-strategy": { + "label": "Authorization strategy", + "submenu": { + "auth-at-first": { + "label": "Authorize at first request" + }, + "none": { + "label": "No authorization" + } + } + } + }, + "prompt": { + "hostname": { + "title": "Hostname", + "label": "Enter the hostname (like 0.0.0.0) for the API server:" + }, + "port": { + "title": "Port", + "label": "Enter the port for the API server:" + } + } + }, "audio-compressor": { "description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)", "name": "Audio Compressor" diff --git a/src/plugins/api-server/backend/index.ts b/src/plugins/api-server/backend/index.ts new file mode 100644 index 00000000..aad1ca83 --- /dev/null +++ b/src/plugins/api-server/backend/index.ts @@ -0,0 +1 @@ +export * from './main'; diff --git a/src/plugins/api-server/backend/main.ts b/src/plugins/api-server/backend/main.ts new file mode 100644 index 00000000..83c63767 --- /dev/null +++ b/src/plugins/api-server/backend/main.ts @@ -0,0 +1,99 @@ +import { jwt } from 'hono/jwt'; +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 registerCallback from '@/providers/song-info'; +import { createBackend } from '@/utils'; + +import { JWTPayloadSchema } from './scheme'; +import { registerAuth, registerControl } from './routes'; + +import type { APIServerConfig } from '../config'; +import type { BackendType } from './types'; + +export const backend = createBackend({ + async start(ctx) { + const config = await ctx.getConfig(); + + this.init(ctx); + registerCallback((songInfo) => { + this.songInfo = songInfo; + }); + + this.run(config.hostname, config.port); + }, + stop() { + this.end(); + }, + onConfigChange(config) { + if (this.oldConfig?.hostname === config.hostname && this.oldConfig?.port === config.port) { + this.oldConfig = config; + return; + } + + this.end(); + this.run(config.hostname, config.port); + this.oldConfig = config; + }, + + // Custom + async init(ctx) { + const config = await ctx.getConfig(); + this.app = new Hono(); + + this.app.use('*', cors()); + + // middlewares + this.app.use( + '/api/*', + jwt({ + secret: config.secret, + }), + ); + this.app.use('/api/*', async (ctx, next) => { + const result = await JWTPayloadSchema.spa(await ctx.get('jwtPayload')); + + const isAuthorized = result.success && config.authorizedClients.includes(result.data.id); + if (!isAuthorized) { + ctx.status(401); + return ctx.body('Unauthorized'); + } + + return await next(); + }); + + // routes + registerControl(this.app, ctx, () => this.songInfo); + registerAuth(this.app, ctx); + + // swagger + this.app.doc('/doc', { + openapi: '3.1.0', + info: { + version: '1.0.0', + title: 'Youtube Music API Server', + }, + }); + + this.app.get('/swagger', swaggerUI({ url: '/doc' })); + }, + run(hostname, port) { + if (!this.app) return; + + try { + this.server = serve({ + fetch: this.app.fetch.bind(this.app), + port, + hostname, + }); + } catch (err) { + console.error(err); + } + }, + end() { + this.server?.close(); + this.server = undefined; + }, +}); diff --git a/src/plugins/api-server/backend/routes/auth.ts b/src/plugins/api-server/backend/routes/auth.ts new file mode 100644 index 00000000..b3a3e64b --- /dev/null +++ b/src/plugins/api-server/backend/routes/auth.ts @@ -0,0 +1,90 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import { dialog } from 'electron'; +import { sign } from 'hono/jwt'; + +import { t } from '@/i18n'; +import { getConnInfo } from '@hono/node-server/conninfo'; + +import { APIServerConfig } from '../../config'; +import { JWTPayload } from '../scheme'; + +import type { HonoApp } from '../types'; +import type { BackendContext } from '@/types/contexts'; + +const routes = { + request: createRoute({ + method: 'post', + path: '/auth/{id}', + summary: '', + description: '', + request: { + params: z.object({ + id: z.string(), + }) + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: z.object({ + accessToken: z.string(), + }), + }, + }, + }, + 403: { + description: 'Forbidden', + }, + }, + }), +}; + +export const register = (app: HonoApp, { getConfig, setConfig }: BackendContext) => { + app.openapi(routes.request, async (ctx) => { + const config = await getConfig(); + const { id } = ctx.req.param(); + + if (config.authorizedClients.includes(id)) { + // SKIP CHECK + } else if (config.authStrategy === 'AUTH_AT_FIRST') { + const result = await dialog.showMessageBox({ + title: t('plugins.api-server.dialog.request.title'), + message: t('plugins.api-server.dialog.request.message', { + origin: getConnInfo(ctx).remote.address, + id, + }), + buttons: [t('plugins.api-server.dialog.request.buttons.allow'), t('plugins.api-server.dialog.request.deny')], + defaultId: 1, + cancelId: 1, + }); + + if (result.response === 1) { + ctx.status(403); + return ctx.body(null); + } + } else if (config.authStrategy === 'NONE') { + // SKIP CHECK + } + + setConfig({ + authorizedClients: [ + ...config.authorizedClients, + id, + ], + }); + + const token = await sign( + { + id, + iat: ~~(Date.now() / 1000), + } satisfies JWTPayload, + config.secret, + ); + + ctx.status(200); + return ctx.json({ + accessToken: token, + }); + }); +}; diff --git a/src/plugins/api-server/backend/routes/control.ts b/src/plugins/api-server/backend/routes/control.ts new file mode 100644 index 00000000..f8231e29 --- /dev/null +++ b/src/plugins/api-server/backend/routes/control.ts @@ -0,0 +1,474 @@ +import { createRoute, z } from '@hono/zod-openapi'; + +import { ipcMain } from 'electron'; + +import getSongControls from '@/providers/song-controls'; + +import { + AuthHeadersSchema, + type ResponseSongInfo, + SongInfoSchema, + GoForwardScheme, + GoBackSchema, + SwitchRepeatSchema, + SetVolumeSchema, + SetFullscreenSchema, +} from '../scheme'; + +import type { SongInfo } from '@/providers/song-info'; +import type { BackendContext } from '@/types/contexts'; +import type { APIServerConfig } from '../../config'; +import type { HonoApp } from '../types'; +import type { QueueResponse } from '@/types/youtube-music-desktop-internal'; + +const API_VERSION = 'v1'; + +const routes = { + previous: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/previous`, + summary: 'play previous song', + description: 'Plays the previous song in the queue', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + next: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/next`, + summary: 'play next song', + description: 'Plays the next song in the queue', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + play: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/play`, + summary: 'Play', + description: 'Change the state of the player to play', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + pause: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/pause`, + summary: 'Pause', + description: 'Change the state of the player to pause', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + togglePlay: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/toggle-play`, + summary: 'Toggle play/pause', + description: 'Change the state of the player to play if paused, or pause if playing', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + like: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/like`, + summary: 'like song', + description: 'Set the current song as liked', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + dislike: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/dislike`, + summary: 'dislike song', + description: 'Set the current song as disliked', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + + goBack: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/go-back`, + summary: 'go back', + description: 'Move the current song back by a number of seconds', + request: { + headers: AuthHeadersSchema, + body: { + description: 'seconds to go back', + content: { + 'application/json': { + schema: GoBackSchema, + }, + }, + }, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + + goForward: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/go-forward`, + summary: 'go forward', + description: 'Move the current song forward by a number of seconds', + request: { + headers: AuthHeadersSchema, + body: { + description: 'seconds to go forward', + content: { + 'application/json': { + schema: GoForwardScheme, + }, + }, + }, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + + shuffle: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/shuffle`, + summary: 'shuffle', + description: 'Shuffle the queue', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + switchRepeat: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/switch-repeat`, + summary: 'switch repeat', + description: 'Switch the repeat mode', + request: { + headers: AuthHeadersSchema, + body: { + description: 'number of times to click the repeat button', + content: { + 'application/json': { + schema: SwitchRepeatSchema, + }, + }, + }, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + setVolume: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/volume`, + summary: 'set volume', + description: 'Set the volume of the player', + request: { + headers: AuthHeadersSchema, + body: { + description: 'volume to set', + content: { + 'application/json': { + schema: SetVolumeSchema, + }, + }, + }, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + setFullscreen: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/fullscreen`, + summary: 'set fullscreen', + description: 'Set the fullscreen state of the player', + request: { + headers: AuthHeadersSchema, + body: { + description: 'fullscreen state', + content: { + 'application/json': { + schema: SetFullscreenSchema, + }, + }, + }, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + toggleMute: createRoute({ + method: 'post', + path: `/api/${API_VERSION}/toggle-mute`, + summary: 'toggle mute', + description: 'Toggle the mute state of the player', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 204: { + description: 'Success', + }, + }, + }), + + getFullscreenState: createRoute({ + method: 'get', + path: `/api/${API_VERSION}/fullscreen`, + summary: 'get fullscreen state', + description: 'Get the current fullscreen state', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: z.object({ + state: z.boolean(), + }), + } + }, + }, + }, + }), + queueInfo: createRoute({ + method: 'get', + path: `/api/${API_VERSION}/queue-info`, + summary: 'get current queue info', + description: 'Get the current queue info', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: z.object({}), + } + }, + }, + 204: { + description: 'No queue info', + }, + }, + }), + songInfo: createRoute({ + method: 'get', + path: `/api/${API_VERSION}/song-info`, + summary: 'get current song info', + description: 'Get the current song info', + request: { + headers: AuthHeadersSchema, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: SongInfoSchema, + } + }, + }, + 204: { + description: 'No song info', + }, + }, + }), +}; + +export const register = (app: HonoApp, { window }: BackendContext, songInfoGetter: () => SongInfo | undefined) => { + const controller = getSongControls(window); + + app.openapi(routes.previous, (ctx) => { + controller.previous(); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.previous, (ctx) => { + controller.previous(); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.play, (ctx) => { + controller.play(); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.pause, (ctx) => { + controller.pause(); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.togglePlay, (ctx) => { + controller.playPause(); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.like, (ctx) => { + controller.like(); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.dislike, (ctx) => { + controller.dislike(); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.goBack, (ctx) => { + const { seconds } = ctx.req.valid('json'); + controller.goBack(seconds); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.goForward, (ctx) => { + const { seconds } = ctx.req.valid('json'); + controller.goForward(seconds); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.shuffle, (ctx) => { + controller.shuffle(); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.switchRepeat, (ctx) => { + const { iteration } = ctx.req.valid('json'); + controller.switchRepeat(iteration); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.setVolume, (ctx) => { + const { volume } = ctx.req.valid('json'); + controller.setVolume(volume); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.setFullscreen, (ctx) => { + const { state } = ctx.req.valid('json'); + controller.setFullscreen(state); + + ctx.status(204); + return ctx.body(null); + }); + app.openapi(routes.toggleMute, (ctx) => { + controller.muteUnmute(); + + ctx.status(204); + return ctx.body(null); + }); + + app.openapi(routes.getFullscreenState, async (ctx) => { + const stateResponsePromise = new Promise((resolve) => { + ipcMain.once('ytmd:set-fullscreen', (_, isFullscreen: boolean | undefined) => { + return resolve(!!isFullscreen); + }); + + controller.requestFullscreenInformation(); + }); + + const fullscreen = await stateResponsePromise; + + ctx.status(200); + return ctx.json({ state: fullscreen }); + }); + app.openapi(routes.queueInfo, async (ctx) => { + const queueResponsePromise = new Promise((resolve) => { + ipcMain.once('ytmd:get-queue-response', (_, queue: QueueResponse) => { + return resolve(queue); + }); + + controller.requestQueueInformation(); + }); + + const info = await queueResponsePromise; + + if (!info) { + ctx.status(204); + return ctx.body(null); + } + + ctx.status(200); + return ctx.json(info); + }); + app.openapi(routes.songInfo, (ctx) => { + const info = songInfoGetter(); + + if (!info) { + ctx.status(204); + return ctx.body(null); + } + + const body = { ...info }; + delete body.image; + + ctx.status(200); + return ctx.json(body satisfies ResponseSongInfo); + }); +}; diff --git a/src/plugins/api-server/backend/routes/index.ts b/src/plugins/api-server/backend/routes/index.ts new file mode 100644 index 00000000..e13f8e66 --- /dev/null +++ b/src/plugins/api-server/backend/routes/index.ts @@ -0,0 +1,2 @@ +export { register as registerControl } from './control'; +export { register as registerAuth } from './auth'; diff --git a/src/plugins/api-server/backend/scheme/auth.ts b/src/plugins/api-server/backend/scheme/auth.ts new file mode 100644 index 00000000..9e685c0d --- /dev/null +++ b/src/plugins/api-server/backend/scheme/auth.ts @@ -0,0 +1,13 @@ +import { z } from '@hono/zod-openapi'; + +export const AuthHeadersSchema = z.object({ + authorization: z.string().openapi({ + example: 'Bearer token', + }), +}); + +export type JWTPayload = z.infer; +export const JWTPayloadSchema = z.object({ + id: z.string(), + iat: z.number(), +}); diff --git a/src/plugins/api-server/backend/scheme/go-back.ts b/src/plugins/api-server/backend/scheme/go-back.ts new file mode 100644 index 00000000..15472a4b --- /dev/null +++ b/src/plugins/api-server/backend/scheme/go-back.ts @@ -0,0 +1,5 @@ +import { z } from '@hono/zod-openapi'; + +export const GoBackSchema = z.object({ + seconds: z.number(), +}); diff --git a/src/plugins/api-server/backend/scheme/go-forward.ts b/src/plugins/api-server/backend/scheme/go-forward.ts new file mode 100644 index 00000000..8d4d5836 --- /dev/null +++ b/src/plugins/api-server/backend/scheme/go-forward.ts @@ -0,0 +1,5 @@ +import { z } from '@hono/zod-openapi'; + +export const GoForwardScheme = z.object({ + seconds: z.number(), +}); diff --git a/src/plugins/api-server/backend/scheme/index.ts b/src/plugins/api-server/backend/scheme/index.ts new file mode 100644 index 00000000..305f55af --- /dev/null +++ b/src/plugins/api-server/backend/scheme/index.ts @@ -0,0 +1,8 @@ +export * from './auth'; +export * from './song-info'; +export * from './go-back'; +export * from './go-forward'; +export * from './switch-repeat'; +export * from './set-volume'; +export * from './set-fullscreen'; + diff --git a/src/plugins/api-server/backend/scheme/set-fullscreen.ts b/src/plugins/api-server/backend/scheme/set-fullscreen.ts new file mode 100644 index 00000000..6c318538 --- /dev/null +++ b/src/plugins/api-server/backend/scheme/set-fullscreen.ts @@ -0,0 +1,5 @@ +import { z } from '@hono/zod-openapi'; + +export const SetFullscreenSchema = z.object({ + state: z.boolean(), +}); diff --git a/src/plugins/api-server/backend/scheme/set-volume.ts b/src/plugins/api-server/backend/scheme/set-volume.ts new file mode 100644 index 00000000..41effe9f --- /dev/null +++ b/src/plugins/api-server/backend/scheme/set-volume.ts @@ -0,0 +1,5 @@ +import { z } from '@hono/zod-openapi'; + +export const SetVolumeSchema = z.object({ + volume: z.number(), +}); diff --git a/src/plugins/api-server/backend/scheme/song-info.ts b/src/plugins/api-server/backend/scheme/song-info.ts new file mode 100644 index 00000000..8d81181b --- /dev/null +++ b/src/plugins/api-server/backend/scheme/song-info.ts @@ -0,0 +1,26 @@ +import { z } from '@hono/zod-openapi'; + +import { MediaType } from '@/providers/song-info'; + +export type ResponseSongInfo = z.infer; +export const SongInfoSchema = z.object({ + title: z.string(), + artist: z.string(), + views: z.number(), + uploadDate: z.string().optional(), + imageSrc: z.string().nullable().optional(), + isPaused: z.boolean().optional(), + songDuration: z.number(), + elapsedSeconds: z.number().optional(), + url: z.string().optional(), + album: z.string().nullable().optional(), + videoId: z.string(), + playlistId: z.string().optional(), + mediaType: z.enum([ + MediaType.Audio, + MediaType.OriginalMusicVideo, + MediaType.UserGeneratedContent, + MediaType.PodcastEpisode, + MediaType.OtherVideo, + ]), +}); diff --git a/src/plugins/api-server/backend/scheme/switch-repeat.ts b/src/plugins/api-server/backend/scheme/switch-repeat.ts new file mode 100644 index 00000000..d82c065f --- /dev/null +++ b/src/plugins/api-server/backend/scheme/switch-repeat.ts @@ -0,0 +1,5 @@ +import { z } from '@hono/zod-openapi'; + +export const SwitchRepeatSchema = z.object({ + iteration: z.number(), +}); diff --git a/src/plugins/api-server/backend/types.ts b/src/plugins/api-server/backend/types.ts new file mode 100644 index 00000000..105921d4 --- /dev/null +++ b/src/plugins/api-server/backend/types.ts @@ -0,0 +1,18 @@ +import { OpenAPIHono as Hono } from '@hono/zod-openapi'; +import { serve } from '@hono/node-server'; + +import type { BackendContext } from '@/types/contexts'; +import type { SongInfo } from '@/providers/song-info'; +import type { APIServerConfig } from '../config'; + +export type HonoApp = Hono; +export type BackendType = { + app?: HonoApp; + server?: ReturnType; + oldConfig?: APIServerConfig; + songInfo?: SongInfo; + + init: (ctx: BackendContext) => void; + run: (hostname: string, port: number) => void; + end: () => void; +}; diff --git a/src/plugins/api-server/config.ts b/src/plugins/api-server/config.ts new file mode 100644 index 00000000..da2b372b --- /dev/null +++ b/src/plugins/api-server/config.ts @@ -0,0 +1,19 @@ +export interface APIServerConfig { + enabled: boolean; + hostname: string; + port: number; + authStrategy: 'AUTH_AT_FIRST' | 'NONE'; + secret: string; + + authorizedClients: string[]; +} + +export const defaultAPIServerConfig: APIServerConfig = { + enabled: true, + hostname: '0.0.0.0', + port: 26538, + authStrategy: 'AUTH_AT_FIRST', + secret: Date.now().toString(36), + + authorizedClients: [], +}; diff --git a/src/plugins/api-server/index.ts b/src/plugins/api-server/index.ts new file mode 100644 index 00000000..5c05c451 --- /dev/null +++ b/src/plugins/api-server/index.ts @@ -0,0 +1,17 @@ +import { createPlugin } from '@/utils'; +import { t } from '@/i18n'; + +import { defaultAPIServerConfig } from './config'; +import { onMenu } from './menu'; +import { backend } from './backend'; + +export default createPlugin({ + name: () => t('plugins.api-server.name'), + description: () => t('plugins.api-server.description'), + restartNeeded: false, + config: defaultAPIServerConfig, + addedVersion: '3.6.X', + menu: onMenu, + + backend, +}); diff --git a/src/plugins/api-server/menu.ts b/src/plugins/api-server/menu.ts new file mode 100644 index 00000000..46f571b3 --- /dev/null +++ b/src/plugins/api-server/menu.ts @@ -0,0 +1,85 @@ +import prompt from 'custom-electron-prompt'; + +import { t } from '@/i18n'; +import promptOptions from '@/providers/prompt-options'; + +import { APIServerConfig, defaultAPIServerConfig } from './config'; + +import type { MenuContext } from '@/types/contexts'; +import type { MenuTemplate } from '@/menu'; + +export const onMenu = async ({ + getConfig, + setConfig, + window, +}: MenuContext): Promise => { + const config = await getConfig(); + + return [ + { + label: t('plugins.api-server.menu.hostname.label'), + type: 'normal', + async click() { + const config = await getConfig(); + + const newHostname = await prompt( + { + title: t('plugins.api-server.prompt.hostname.title'), + label: t('plugins.api-server.prompt.hostname.label'), + value: config.hostname, + type: 'input', + width: 380, + ...promptOptions(), + }, + window, + ) ?? defaultAPIServerConfig.hostname; + + setConfig({ ...config, hostname: newHostname }); + }, + }, + { + label: t('plugins.api-server.menu.port'), + type: 'normal', + async click() { + const config = await getConfig(); + + const newPort = await prompt( + { + title: t('plugins.api-server.prompt.port.title'), + label: t('plugins.api-server.prompt.port.label'), + value: config.port, + type: 'counter', + counterOptions: { minimum: 0, maximum: 65565, }, + width: 380, + ...promptOptions(), + }, + window, + ) ?? defaultAPIServerConfig.port; + + setConfig({ ...config, port: newPort }); + }, + }, + { + label: t('plugins.api-server.menu.auth-strategy'), + type: 'submenu', + submenu: [ + { + label: t('plugins.api-server.menu.auth-strategy.submenu.auth-at-first'), + type: 'radio', + checked: config.authStrategy === 'AUTH_AT_FIRST', + click() { + setConfig({ ...config, authStrategy: 'AUTH_AT_FIRST' }); + }, + }, + { + label: t('plugins.api-server.menu.auth-strategy.submenu.none'), + type: 'radio', + checked: config.authStrategy === 'NONE', + click() { + setConfig({ ...config, authStrategy: 'NONE' }); + }, + }, + ], + }, + ]; +}; diff --git a/src/providers/song-controls.ts b/src/providers/song-controls.ts index 9bb03eb3..b427235d 100644 --- a/src/providers/song-controls.ts +++ b/src/providers/song-controls.ts @@ -29,6 +29,8 @@ export default (win: BrowserWindow) => { // Playback previous: () => win.webContents.send('ytmd:previous-video'), next: () => win.webContents.send('ytmd:next-video'), + play: () => win.webContents.send('ytmd:play'), + pause: () => win.webContents.send('ytmd:pause'), playPause: () => win.webContents.send('ytmd:toggle-play'), like: () => win.webContents.send('ytmd:update-like', 'LIKE'), dislike: () => win.webContents.send('ytmd:update-like', 'DISLIKE'), diff --git a/src/renderer.ts b/src/renderer.ts index 800d50d0..51ed7c97 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -53,6 +53,12 @@ async function onApiLoaded() { window.ipcRenderer.on('ytmd:next-video', () => { document.querySelector('.next-button.ytmusic-player-bar')?.click(); }); + window.ipcRenderer.on('ytmd:play', (_) => { + api?.playVideo(); + }); + window.ipcRenderer.on('ytmd:pause', (_) => { + api?.pauseVideo(); + }); window.ipcRenderer.on('ytmd:toggle-play', (_) => { if (api?.getPlayerState() === 2) api?.playVideo(); else api?.pauseVideo();