feat(api-server): Add queue api (#2767)

This commit is contained in:
Su-Yong
2024-12-25 18:55:24 +09:00
committed by GitHub
parent 3ea13a2a22
commit 1d6251baea
7 changed files with 407 additions and 26 deletions

View File

@ -5,15 +5,20 @@ import { ipcMain } from 'electron';
import getSongControls from '@/providers/song-controls';
import {
AddSongToQueueSchema,
AuthHeadersSchema,
type ResponseSongInfo,
SongInfoSchema,
SeekSchema,
GoForwardScheme,
GoBackSchema,
SwitchRepeatSchema,
SetVolumeSchema,
GoForwardScheme,
MoveSongInQueueSchema,
QueueParamsSchema,
SearchSchema,
SeekSchema,
SetFullscreenSchema,
SetQueueIndexSchema,
SetVolumeSchema,
SongInfoSchema,
SwitchRepeatSchema,
type ResponseSongInfo,
} from '../scheme';
import type { RepeatMode } from '@/types/datahost-get-state';
@ -22,6 +27,7 @@ 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';
import type { Context } from 'hono';
const API_VERSION = 'v1';
@ -297,7 +303,8 @@ const routes = {
},
},
}),
queueInfo: createRoute({
oldQueueInfo: createRoute({
deprecated: true,
method: 'get',
path: `/api/${API_VERSION}/queue-info`,
summary: 'get current queue info',
@ -316,7 +323,8 @@ const routes = {
},
},
}),
songInfo: createRoute({
oldSongInfo: createRoute({
deprecated: true,
method: 'get',
path: `/api/${API_VERSION}/song-info`,
summary: 'get current song info',
@ -335,6 +343,164 @@ const routes = {
},
},
}),
songInfo: createRoute({
method: 'get',
path: `/api/${API_VERSION}/song`,
summary: 'get current song info',
description: 'Get the current song info',
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: SongInfoSchema,
},
},
},
204: {
description: 'No song info',
},
},
}),
queueInfo: createRoute({
method: 'get',
path: `/api/${API_VERSION}/queue`,
summary: 'get current queue info',
description: 'Get the current queue info',
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: z.object({}),
},
},
},
204: {
description: 'No queue info',
},
},
}),
addSongToQueue: createRoute({
method: 'post',
path: `/api/${API_VERSION}/queue`,
summary: 'add song to queue',
description: 'Add a song to the queue',
request: {
headers: AuthHeadersSchema,
body: {
description: 'video id of the song to add',
content: {
'application/json': {
schema: AddSongToQueueSchema,
},
},
},
},
responses: {
204: {
description: 'Success',
},
},
}),
moveSongInQueue: createRoute({
method: 'patch',
path: `/api/${API_VERSION}/queue/{index}`,
summary: 'move song in queue',
description: 'Move a song in the queue',
request: {
headers: AuthHeadersSchema,
params: QueueParamsSchema,
body: {
description: 'index to move the song to',
content: {
'application/json': {
schema: MoveSongInQueueSchema,
},
},
},
},
responses: {
204: {
description: 'Success',
},
},
}),
removeSongFromQueue: createRoute({
method: 'delete',
path: `/api/${API_VERSION}/queue/{index}`,
summary: 'remove song from queue',
description: 'Remove a song from the queue',
request: {
headers: AuthHeadersSchema,
params: QueueParamsSchema,
},
responses: {
204: {
description: 'Success',
},
},
}),
setQueueIndex: createRoute({
method: 'patch',
path: `/api/${API_VERSION}/queue`,
summary: 'set queue index',
description: 'Set the current index of the queue',
request: {
headers: AuthHeadersSchema,
body: {
description: 'index to move the song to',
content: {
'application/json': {
schema: SetQueueIndexSchema,
},
},
},
},
responses: {
204: {
description: 'Success',
},
},
}),
clearQueue: createRoute({
method: 'delete',
path: `/api/${API_VERSION}/queue`,
summary: 'clear queue',
description: 'Clear the queue',
responses: {
204: {
description: 'Success',
},
},
}),
search: createRoute({
method: 'post',
path: `/api/${API_VERSION}/search`,
summary: 'search for a song',
description: 'search for a song',
request: {
headers: AuthHeadersSchema,
body: {
description: 'search query',
content: {
'application/json': {
schema: SearchSchema,
},
},
},
},
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: z.object({}),
},
},
},
},
}),
};
export const register = (
@ -464,7 +630,26 @@ export const register = (
ctx.status(200);
return ctx.json({ state: fullscreen });
});
app.openapi(routes.queueInfo, async (ctx) => {
const songInfo = (ctx: Context) => {
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);
};
app.openapi(routes.oldSongInfo, songInfo);
app.openapi(routes.songInfo, songInfo);
// Queue
const queueInfo = async (ctx: Context) => {
const queueResponsePromise = new Promise<QueueResponse>((resolve) => {
ipcMain.once('ytmd:get-queue-response', (_, queue: QueueResponse) => {
return resolve(queue);
@ -482,19 +667,50 @@ export const register = (
ctx.status(200);
return ctx.json(info);
};
app.openapi(routes.oldQueueInfo, queueInfo);
app.openapi(routes.queueInfo, queueInfo);
app.openapi(routes.addSongToQueue, (ctx) => {
const { videoId } = ctx.req.valid('json');
controller.addSongToQueue(videoId);
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.songInfo, (ctx) => {
const info = songInfoGetter();
app.openapi(routes.moveSongInQueue, (ctx) => {
const index = Number(ctx.req.param('index'));
const { toIndex } = ctx.req.valid('json');
controller.moveSongInQueue(index, toIndex);
if (!info) {
ctx.status(204);
return ctx.body(null);
}
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.removeSongFromQueue, (ctx) => {
const index = Number(ctx.req.param('index'));
controller.removeSongFromQueue(index);
const body = { ...info };
delete body.image;
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.setQueueIndex, (ctx) => {
const { index } = ctx.req.valid('json');
controller.setQueueIndex(index);
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.clearQueue, (ctx) => {
controller.clearQueue();
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.search, async (ctx) => {
const { query } = ctx.req.valid('json');
const response = await controller.search(query);
ctx.status(200);
return ctx.json(body satisfies ResponseSongInfo);
return ctx.json(response as object);
});
};

View File

@ -6,3 +6,5 @@ export * from './go-forward';
export * from './switch-repeat';
export * from './set-volume';
export * from './set-fullscreen';
export * from './queue';
export * from './search';

View File

@ -0,0 +1,15 @@
import { z } from '@hono/zod-openapi';
export const QueueParamsSchema = z.object({
index: z.coerce.number().int().nonnegative(),
});
export const AddSongToQueueSchema = z.object({
videoId: z.string(),
});
export const MoveSongInQueueSchema = z.object({
toIndex: z.number(),
});
export const SetQueueIndexSchema = z.object({
index: z.number().int().nonnegative(),
});

View File

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

View File

@ -1,5 +1,5 @@
// This is used for to control the songs
import { BrowserWindow } from 'electron';
import { BrowserWindow, ipcMain } from 'electron';
// see protocol-handler.ts
type ArgsType<T> = T | string[] | undefined;
@ -24,6 +24,16 @@ const parseBooleanFromArgsType = (args: ArgsType<boolean>) => {
}
};
const parseStringFromArgsType = (args: ArgsType<string>) => {
if (typeof args === 'string') {
return args;
} else if (Array.isArray(args)) {
return args[0];
} else {
return null;
}
};
export default (win: BrowserWindow) => {
return {
// Playback
@ -80,11 +90,49 @@ export default (win: BrowserWindow) => {
win.webContents.send('ytmd:get-queue');
},
muteUnmute: () => win.webContents.send('ytmd:toggle-mute'),
search: () => {
openSearchBox: () => {
win.webContents.sendInputEvent({
type: 'keyDown',
keyCode: '/',
});
},
// Queue
addSongToQueue: (videoId: string) => {
const videoIdValue = parseStringFromArgsType(videoId);
if (videoIdValue === null) return;
win.webContents.send('ytmd:add-to-queue', videoIdValue);
},
moveSongInQueue: (
fromIndex: ArgsType<number>,
toIndex: ArgsType<number>,
) => {
const fromIndexValue = parseNumberFromArgsType(fromIndex);
const toIndexValue = parseNumberFromArgsType(toIndex);
if (fromIndexValue === null || toIndexValue === null) return;
win.webContents.send('ytmd:move-in-queue', fromIndexValue, toIndexValue);
},
removeSongFromQueue: (index: ArgsType<number>) => {
const indexValue = parseNumberFromArgsType(index);
if (indexValue === null) return;
win.webContents.send('ytmd:remove-from-queue', indexValue);
},
setQueueIndex: (index: ArgsType<number>) => {
const indexValue = parseNumberFromArgsType(index);
if (indexValue === null) return;
win.webContents.send('ytmd:set-queue-index', indexValue);
},
clearQueue: () => win.webContents.send('ytmd:clear-queue'),
search: (query: string) =>
new Promise((resolve) => {
ipcMain.once('ytmd:search-results', (_, result) => {
resolve(result as string);
});
win.webContents.send('ytmd:search', query);
}),
};
};

View File

@ -43,6 +43,13 @@ async function listenForApiLoad() {
interface YouTubeMusicAppElement extends HTMLElement {
navigate(page: string): void;
networkManager: {
fetch: (url: string, data: unknown) => Promise<unknown>;
};
}
interface SearchBoxElement extends HTMLElement {
getSearchboxStats(): unknown;
}
async function onApiLoaded() {
@ -159,6 +166,99 @@ async function onApiLoaded() {
} satisfies QueueResponse);
});
window.ipcRenderer.on('ytmd:add-to-queue', (_, videoId: string) => {
const queue = document.querySelector<QueueElement>('#queue');
const app = document.querySelector<YouTubeMusicAppElement>('ytmusic-app');
if (!app) return;
const store = queue?.queue.store.store;
if (!store) return;
app.networkManager
.fetch('/music/get_queue', {
queueContextParams: store.getState().queue.queueContextParams,
queueInsertPosition: 'INSERT_AT_END',
videoIds: [videoId],
})
.then((result) => {
if (
result &&
typeof result === 'object' &&
'queueDatas' in result &&
Array.isArray(result.queueDatas)
) {
queue?.dispatch({
type: 'ADD_ITEMS',
payload: {
nextQueueItemId: store.getState().queue.nextQueueItemId,
index: store.getState().queue.items.length ?? 0,
items: result.queueDatas
.map((it) =>
typeof it === 'object' && it && 'content' in it
? it.content
: null,
)
.filter(Boolean),
shuffleEnabled: false,
shouldAssignIds: true,
},
});
}
});
});
window.ipcRenderer.on(
'ytmd:move-in-queue',
(_, fromIndex: number, toIndex: number) => {
const queue = document.querySelector<QueueElement>('#queue');
queue?.dispatch({
type: 'MOVE_ITEM',
payload: {
fromIndex,
toIndex,
},
});
},
);
window.ipcRenderer.on('ytmd:remove-from-queue', (_, index: number) => {
const queue = document.querySelector<QueueElement>('#queue');
queue?.dispatch({
type: 'REMOVE_ITEM',
payload: index,
});
});
window.ipcRenderer.on('ytmd:set-queue-index', (_, index: number) => {
const queue = document.querySelector<QueueElement>('#queue');
queue?.dispatch({
type: 'SET_INDEX',
payload: index,
});
});
window.ipcRenderer.on('ytmd:clear-queue', () => {
const queue = document.querySelector<QueueElement>('#queue');
queue?.queue.store.store.dispatch({
type: 'SET_PLAYER_PAGE_INFO',
payload: { open: false },
});
queue?.dispatch({
type: 'CLEAR',
});
});
window.ipcRenderer.on('ytmd:search', async (_, query: string) => {
const app = document.querySelector<YouTubeMusicAppElement>('ytmusic-app');
const searchBox =
document.querySelector<SearchBoxElement>('ytmusic-search-box');
if (!app || !searchBox) return;
const result = await app.networkManager.fetch('/search', {
query,
suggestStats: searchBox.getSearchboxStats(),
});
window.ipcRenderer.send('ytmd:search-results', result);
});
const video = document.querySelector('video')!;
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaElementSource(video);

View File

@ -3,12 +3,7 @@ import type { GetState, QueueItem } from '@/types/datahost-get-state';
type StoreState = GetState;
type Store = {
dispatch: (obj: {
type: string;
payload?: {
items?: QueueItem[];
};
}) => void;
dispatch: (obj: { type: string; payload?: unknown }) => void;
getState: () => StoreState;
replaceReducer: (param1: unknown) => unknown;