feat(api-server): remote control api (#1909)

Co-authored-by: JellyBrick <shlee1503@naver.com>
Co-authored-by: Angelos Bouklis <angelbouklis.official@gmail.com>
Co-authored-by: Angelos Bouklis <me@arjix.dev>
Co-authored-by: Angelos Bouklis <53124886+ArjixWasTaken@users.noreply.github.com>
This commit is contained in:
Su-Yong
2024-10-13 19:10:12 +09:00
committed by GitHub
parent d07dae2542
commit 9ba8913da7
22 changed files with 1043 additions and 1 deletions

View File

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

108
pnpm-lock.yaml generated
View File

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

View File

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

View File

@ -0,0 +1 @@
export * from './main';

View File

@ -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<BackendType, APIServerConfig>({
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;
},
});

View File

@ -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<APIServerConfig>) => {
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,
});
});
};

View File

@ -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<APIServerConfig>, 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<boolean>((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<QueueResponse>((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);
});
};

View File

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

View File

@ -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<typeof JWTPayloadSchema>;
export const JWTPayloadSchema = z.object({
id: z.string(),
iat: z.number(),
});

View File

@ -0,0 +1,5 @@
import { z } from '@hono/zod-openapi';
export const GoBackSchema = z.object({
seconds: z.number(),
});

View File

@ -0,0 +1,5 @@
import { z } from '@hono/zod-openapi';
export const GoForwardScheme = z.object({
seconds: z.number(),
});

View File

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

View File

@ -0,0 +1,5 @@
import { z } from '@hono/zod-openapi';
export const SetFullscreenSchema = z.object({
state: z.boolean(),
});

View File

@ -0,0 +1,5 @@
import { z } from '@hono/zod-openapi';
export const SetVolumeSchema = z.object({
volume: z.number(),
});

View File

@ -0,0 +1,26 @@
import { z } from '@hono/zod-openapi';
import { MediaType } from '@/providers/song-info';
export type ResponseSongInfo = z.infer<typeof SongInfoSchema>;
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,
]),
});

View File

@ -0,0 +1,5 @@
import { z } from '@hono/zod-openapi';
export const SwitchRepeatSchema = z.object({
iteration: z.number(),
});

View File

@ -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<typeof serve>;
oldConfig?: APIServerConfig;
songInfo?: SongInfo;
init: (ctx: BackendContext<APIServerConfig>) => void;
run: (hostname: string, port: number) => void;
end: () => void;
};

View File

@ -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: [],
};

View File

@ -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,
});

View File

@ -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<APIServerConfig>): Promise<MenuTemplate> => {
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' });
},
},
],
},
];
};

View File

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

View File

@ -53,6 +53,12 @@ async function onApiLoaded() {
window.ipcRenderer.on('ytmd:next-video', () => {
document.querySelector<HTMLElement>('.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();