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

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