From 1d6251baea386014070f83bdca6e4227ade8ab54 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Wed, 25 Dec 2024 18:55:24 +0900 Subject: [PATCH] feat(api-server): Add queue api (#2767) --- .../api-server/backend/routes/control.ts | 252 ++++++++++++++++-- .../api-server/backend/scheme/index.ts | 2 + .../api-server/backend/scheme/queue.ts | 15 ++ .../api-server/backend/scheme/search.ts | 5 + src/providers/song-controls.ts | 52 +++- src/renderer.ts | 100 +++++++ src/types/queue.ts | 7 +- 7 files changed, 407 insertions(+), 26 deletions(-) create mode 100644 src/plugins/api-server/backend/scheme/queue.ts create mode 100644 src/plugins/api-server/backend/scheme/search.ts diff --git a/src/plugins/api-server/backend/routes/control.ts b/src/plugins/api-server/backend/routes/control.ts index 7ffbeb29..8a1d4e8d 100644 --- a/src/plugins/api-server/backend/routes/control.ts +++ b/src/plugins/api-server/backend/routes/control.ts @@ -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((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); }); }; diff --git a/src/plugins/api-server/backend/scheme/index.ts b/src/plugins/api-server/backend/scheme/index.ts index 7b3e9379..8c8ecaab 100644 --- a/src/plugins/api-server/backend/scheme/index.ts +++ b/src/plugins/api-server/backend/scheme/index.ts @@ -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'; diff --git a/src/plugins/api-server/backend/scheme/queue.ts b/src/plugins/api-server/backend/scheme/queue.ts new file mode 100644 index 00000000..3520885f --- /dev/null +++ b/src/plugins/api-server/backend/scheme/queue.ts @@ -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(), +}); diff --git a/src/plugins/api-server/backend/scheme/search.ts b/src/plugins/api-server/backend/scheme/search.ts new file mode 100644 index 00000000..5dc8c5c3 --- /dev/null +++ b/src/plugins/api-server/backend/scheme/search.ts @@ -0,0 +1,5 @@ +import { z } from '@hono/zod-openapi'; + +export const SearchSchema = z.object({ + query: z.string(), +}); diff --git a/src/providers/song-controls.ts b/src/providers/song-controls.ts index 94455018..dfbbc6be 100644 --- a/src/providers/song-controls.ts +++ b/src/providers/song-controls.ts @@ -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 | string[] | undefined; @@ -24,6 +24,16 @@ const parseBooleanFromArgsType = (args: ArgsType) => { } }; +const parseStringFromArgsType = (args: ArgsType) => { + 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, + toIndex: ArgsType, + ) => { + 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) => { + const indexValue = parseNumberFromArgsType(index); + if (indexValue === null) return; + + win.webContents.send('ytmd:remove-from-queue', indexValue); + }, + setQueueIndex: (index: ArgsType) => { + 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); + }), }; }; diff --git a/src/renderer.ts b/src/renderer.ts index bdffd265..3dfbe854 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -43,6 +43,13 @@ async function listenForApiLoad() { interface YouTubeMusicAppElement extends HTMLElement { navigate(page: string): void; + networkManager: { + fetch: (url: string, data: unknown) => Promise; + }; +} + +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('#queue'); + const app = document.querySelector('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('#queue'); + queue?.dispatch({ + type: 'MOVE_ITEM', + payload: { + fromIndex, + toIndex, + }, + }); + }, + ); + window.ipcRenderer.on('ytmd:remove-from-queue', (_, index: number) => { + const queue = document.querySelector('#queue'); + queue?.dispatch({ + type: 'REMOVE_ITEM', + payload: index, + }); + }); + window.ipcRenderer.on('ytmd:set-queue-index', (_, index: number) => { + const queue = document.querySelector('#queue'); + queue?.dispatch({ + type: 'SET_INDEX', + payload: index, + }); + }); + window.ipcRenderer.on('ytmd:clear-queue', () => { + const queue = document.querySelector('#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('ytmusic-app'); + const searchBox = + document.querySelector('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); diff --git a/src/types/queue.ts b/src/types/queue.ts index 514db000..067983d1 100644 --- a/src/types/queue.ts +++ b/src/types/queue.ts @@ -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;