mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 10:31:47 +00:00
feat(api-server): Add queue api (#2767)
This commit is contained in:
@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@ -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';
|
||||
|
||||
15
src/plugins/api-server/backend/scheme/queue.ts
Normal file
15
src/plugins/api-server/backend/scheme/queue.ts
Normal 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(),
|
||||
});
|
||||
5
src/plugins/api-server/backend/scheme/search.ts
Normal file
5
src/plugins/api-server/backend/scheme/search.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { z } from '@hono/zod-openapi';
|
||||
|
||||
export const SearchSchema = z.object({
|
||||
query: z.string(),
|
||||
});
|
||||
@ -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);
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
100
src/renderer.ts
100
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<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);
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user