mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-15 04:11:47 +00:00
Compare commits
20 Commits
c3700e0e59
...
a5af233683
| Author | SHA1 | Date | |
|---|---|---|---|
| a5af233683 | |||
| 27e3796622 | |||
| 5843e85c4d | |||
| 92a943c755 | |||
| 58a19cdaa2 | |||
| b1d2112bfc | |||
| d229bc7f00 | |||
| 033a4d3122 | |||
| 8d252b6375 | |||
| 9453c0ca8f | |||
| ce073b30d9 | |||
| 8f63e5e3a3 | |||
| 23853b66c6 | |||
| 62a322f10d | |||
| 9627dd2202 | |||
| 3383926faa | |||
| 8179664064 | |||
| 1a5e417f4f | |||
| ceb6da9bc9 | |||
| fdafb2dd07 |
26
package.json
26
package.json
@ -45,11 +45,11 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.1.8",
|
||||
"vite": "npm:rolldown-vite@7.3.0",
|
||||
"node-gyp": "11.4.2",
|
||||
"xml2js": "0.6.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"@electron/universal": "3.0.1",
|
||||
"@electron/universal": "3.0.2",
|
||||
"@babel/runtime": "7.28.4"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
@ -72,7 +72,7 @@
|
||||
"@foobar404/wave": "2.0.5",
|
||||
"@ghostery/adblocker-electron": "2.11.6",
|
||||
"@ghostery/adblocker-electron-preload": "2.11.6",
|
||||
"@hono/node-server": "1.19.1",
|
||||
"@hono/node-server": "1.19.7",
|
||||
"@hono/node-ws": "1.2.0",
|
||||
"@hono/swagger-ui": "0.5.2",
|
||||
"@hono/zod-openapi": "1.1.0",
|
||||
@ -105,8 +105,8 @@
|
||||
"fflate": "0.8.2",
|
||||
"filenamify": "6.0.0",
|
||||
"hanja": "1.1.5",
|
||||
"happy-dom": "18.0.1",
|
||||
"hono": "4.9.6",
|
||||
"happy-dom": "20.0.2",
|
||||
"hono": "4.10.3",
|
||||
"howler": "2.2.4",
|
||||
"html-to-text": "9.0.5",
|
||||
"i18next": "25.5.2",
|
||||
@ -131,11 +131,11 @@
|
||||
"solid-transition-group": "0.3.0",
|
||||
"tiny-pinyin": "1.3.2",
|
||||
"tinyld": "1.3.4",
|
||||
"virtua": "0.42.3",
|
||||
"virtua": "0.48.2",
|
||||
"vudio": "2.1.1",
|
||||
"x11": "2.3.0",
|
||||
"youtubei.js": "^16.0.1",
|
||||
"zod": "4.1.5"
|
||||
"zod": "4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "1.0.1",
|
||||
@ -153,8 +153,8 @@
|
||||
"builtin-modules": "5.0.0",
|
||||
"cross-env": "10.0.0",
|
||||
"del-cli": "6.0.0",
|
||||
"discord-api-types": "0.38.23",
|
||||
"electron": "38.2.0",
|
||||
"discord-api-types": "0.38.37",
|
||||
"electron": "38.7.2",
|
||||
"electron-builder": "26.0.12",
|
||||
"electron-builder-squirrel-windows": "26.0.12",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
@ -166,14 +166,14 @@
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"eslint-plugin-solid": "0.14.5",
|
||||
"glob": "11.0.3",
|
||||
"glob": "11.1.0",
|
||||
"node-gyp": "11.4.2",
|
||||
"playwright": "1.55.0",
|
||||
"ts-morph": "27.0.0",
|
||||
"playwright": "1.55.1",
|
||||
"ts-morph": "27.0.2",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "8.43.0",
|
||||
"utf-8-validate": "6.0.5",
|
||||
"vite": "npm:rolldown-vite@7.1.8",
|
||||
"vite": "npm:rolldown-vite@7.3.0",
|
||||
"vite-plugin-inspect": "11.3.3",
|
||||
"vite-plugin-resolve": "2.5.2",
|
||||
"vite-plugin-solid": "2.11.8",
|
||||
|
||||
723
pnpm-lock.yaml
generated
723
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -323,6 +323,22 @@
|
||||
},
|
||||
"port": {
|
||||
"label": "Port"
|
||||
},
|
||||
"https": {
|
||||
"label": "HTTPS & Certificates",
|
||||
"submenu": {
|
||||
"enable-https": {
|
||||
"label": "Enable HTTPS"
|
||||
},
|
||||
"cert": {
|
||||
"label": "Certificate file (.crt/.pem)",
|
||||
"dialogTitle": "Select HTTPS certificate file"
|
||||
},
|
||||
"key": {
|
||||
"label": "Private key file (.key/.pem)",
|
||||
"dialogTitle": "Select HTTPS private key file"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": "API Server [Beta]",
|
||||
|
||||
@ -150,6 +150,13 @@
|
||||
"visual-tweaks": {
|
||||
"label": "भिजुअल ट्वीक्स",
|
||||
"submenu": {
|
||||
"custom-window-title": {
|
||||
"label": "अनुकूलन विन्डो शीर्षक",
|
||||
"prompt": {
|
||||
"label": "अनुकूलन विन्डो शीर्षक प्रविष्ट गर्नुहोस्: (असक्षम पार्न खाली छोड्नुहोस्)",
|
||||
"placeholder": "उदाहरण: पियर डेस्कटप"
|
||||
}
|
||||
},
|
||||
"like-buttons": {
|
||||
"default": "पूर्वनिर्धारित",
|
||||
"force-show": "देखाउनुहोस",
|
||||
@ -179,7 +186,7 @@
|
||||
"plugins": {
|
||||
"enabled": "सक्षम गरियो",
|
||||
"label": "प्लगइनहरू",
|
||||
"new": "NEW"
|
||||
"new": "नयाँ"
|
||||
},
|
||||
"view": {
|
||||
"label": "हेर्नुहोस्",
|
||||
|
||||
@ -1 +1,15 @@
|
||||
{}
|
||||
{
|
||||
"common": {
|
||||
"console": {
|
||||
"plugins": {
|
||||
"execute-failed": "Pluginta mana ruwayta atirqanchu {{pluginName}}::{{contextName}}",
|
||||
"executed-at-ms": "Plugin nisqa {{pluginName}}::{{contextName}} ejecutado en {{ms}}ms",
|
||||
"initialize-failed": "Plugin qallariyta mana atirqanchu \"{{pluginName}}\"",
|
||||
"load-all": "Llapanta cargaspa",
|
||||
"load-failed": "Pluginta mana kargayta atirqanchu \"{{pluginName}}\"",
|
||||
"loaded": "Plugin nisqa \"{{pluginName}}\" cargado",
|
||||
"unload-failed": "Pluginta mana uraykachiyta atirqanchu \"{{pluginName}}\""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,7 +86,7 @@
|
||||
"copy-current-url": "Kopiera nuvarande länk",
|
||||
"go-back": "Gå tillbaka",
|
||||
"go-forward": "Gå framåt",
|
||||
"quit": "Avsluta",
|
||||
"quit": "Stäng",
|
||||
"restart": "Starta om appen"
|
||||
}
|
||||
},
|
||||
@ -237,7 +237,8 @@
|
||||
"submenu": {
|
||||
"percent": "{{ratio}} %"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enable-seekbar": "Aktivera temaanpassning av uppspelningsreglaget"
|
||||
},
|
||||
"name": "Albumfärgtema"
|
||||
},
|
||||
@ -462,8 +463,8 @@
|
||||
"label": "Statusmeddelande",
|
||||
"submenu": {
|
||||
"artist": "Lyssnar på {artist}",
|
||||
"title": "Lyssnar på {song title}",
|
||||
"pear-desktop": "Lyssnar på Pear Desktop"
|
||||
"pear-desktop": "Lyssnar på Pear Desktop",
|
||||
"title": "Lyssnar på {song title}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import { createServer as createHttpServer } from 'node:http';
|
||||
import { createServer as createHttpsServer } from 'node:https';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { jwt } from 'hono/jwt';
|
||||
import { OpenAPIHono as Hono } from '@hono/zod-openapi';
|
||||
import { cors } from 'hono/cors';
|
||||
@ -48,22 +52,26 @@ export const backend = createBackend<BackendType, APIServerConfig>({
|
||||
(newVolumeState: VolumeState) => (this.volumeState = newVolumeState),
|
||||
);
|
||||
|
||||
this.run(config.hostname, config.port);
|
||||
this.run(config);
|
||||
},
|
||||
stop() {
|
||||
this.end();
|
||||
},
|
||||
onConfigChange(config) {
|
||||
const old = this.oldConfig;
|
||||
if (
|
||||
this.oldConfig?.hostname === config.hostname &&
|
||||
this.oldConfig?.port === config.port
|
||||
old?.hostname === config.hostname &&
|
||||
old?.port === config.port &&
|
||||
old?.useHttps === config.useHttps &&
|
||||
old?.certPath === config.certPath &&
|
||||
old?.keyPath === config.keyPath
|
||||
) {
|
||||
this.oldConfig = config;
|
||||
return;
|
||||
}
|
||||
|
||||
this.end();
|
||||
this.run(config.hostname, config.port);
|
||||
this.run(config);
|
||||
this.oldConfig = config;
|
||||
},
|
||||
|
||||
@ -153,15 +161,30 @@ export const backend = createBackend<BackendType, APIServerConfig>({
|
||||
|
||||
this.injectWebSocket = ws.injectWebSocket.bind(this);
|
||||
},
|
||||
run(hostname, port) {
|
||||
run(config) {
|
||||
if (!this.app) return;
|
||||
|
||||
try {
|
||||
this.server = serve({
|
||||
fetch: this.app.fetch.bind(this.app),
|
||||
port,
|
||||
hostname,
|
||||
});
|
||||
const serveOptions =
|
||||
config.useHttps && config.certPath && config.keyPath
|
||||
? {
|
||||
fetch: this.app.fetch.bind(this.app),
|
||||
port: config.port,
|
||||
hostname: config.hostname,
|
||||
createServer: createHttpsServer,
|
||||
serverOptions: {
|
||||
key: readFileSync(config.keyPath),
|
||||
cert: readFileSync(config.certPath),
|
||||
},
|
||||
}
|
||||
: {
|
||||
fetch: this.app.fetch.bind(this.app),
|
||||
port: config.port,
|
||||
hostname: config.hostname,
|
||||
createServer: createHttpServer,
|
||||
};
|
||||
|
||||
this.server = serve(serveOptions);
|
||||
|
||||
if (this.injectWebSocket && this.server) {
|
||||
this.injectWebSocket(this.server);
|
||||
|
||||
@ -411,6 +411,26 @@ const routes = {
|
||||
},
|
||||
},
|
||||
}),
|
||||
nextSongInfo: createRoute({
|
||||
method: 'get',
|
||||
path: `/api/${API_VERSION}/queue/next`,
|
||||
summary: 'get next song info',
|
||||
description:
|
||||
'Get information about the next song in the queue (relative index +1)',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Success',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: SongInfoSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
204: {
|
||||
description: 'No next song in queue',
|
||||
},
|
||||
},
|
||||
}),
|
||||
queueInfo: createRoute({
|
||||
method: 'get',
|
||||
path: `/api/${API_VERSION}/queue`,
|
||||
@ -748,6 +768,63 @@ export const register = (
|
||||
app.openapi(routes.oldQueueInfo, queueInfo);
|
||||
app.openapi(routes.queueInfo, queueInfo);
|
||||
|
||||
app.openapi(routes.nextSongInfo, async (ctx) => {
|
||||
const queueResponsePromise = new Promise<QueueResponse>((resolve) => {
|
||||
ipcMain.once('peard:get-queue-response', (_, queue: QueueResponse) => {
|
||||
return resolve(queue);
|
||||
});
|
||||
|
||||
controller.requestQueueInformation();
|
||||
});
|
||||
|
||||
const queue = await queueResponsePromise;
|
||||
|
||||
if (!queue?.items || queue.items.length === 0) {
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
}
|
||||
|
||||
// Find the currently selected song
|
||||
const currentIndex = queue.items.findIndex((item) => {
|
||||
const renderer =
|
||||
item.playlistPanelVideoRenderer ||
|
||||
item.playlistPanelVideoWrapperRenderer?.primaryRenderer
|
||||
?.playlistPanelVideoRenderer;
|
||||
return renderer?.selected === true;
|
||||
});
|
||||
|
||||
// Get the next song (currentIndex + 1)
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= queue.items.length) {
|
||||
// No next song available
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
}
|
||||
|
||||
const nextItem = queue.items[nextIndex];
|
||||
const nextRenderer =
|
||||
nextItem.playlistPanelVideoRenderer ||
|
||||
nextItem.playlistPanelVideoWrapperRenderer?.primaryRenderer
|
||||
?.playlistPanelVideoRenderer;
|
||||
|
||||
if (!nextRenderer) {
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
}
|
||||
|
||||
// Extract relevant information similar to SongInfo format
|
||||
const nextSongInfo = {
|
||||
title: nextRenderer.title?.runs?.[0]?.text,
|
||||
videoId: nextRenderer.videoId,
|
||||
thumbnail: nextRenderer.thumbnail,
|
||||
lengthText: nextRenderer.lengthText,
|
||||
shortBylineText: nextRenderer.shortBylineText,
|
||||
};
|
||||
|
||||
ctx.status(200);
|
||||
return ctx.json(nextSongInfo);
|
||||
});
|
||||
|
||||
app.openapi(routes.addSongToQueue, (ctx) => {
|
||||
const { videoId, insertPosition } = ctx.req.valid('json');
|
||||
controller.addSongToQueue(videoId, insertPosition);
|
||||
|
||||
@ -17,6 +17,6 @@ export type BackendType = {
|
||||
injectWebSocket?: (server: ReturnType<typeof serve>) => void;
|
||||
|
||||
init: (ctx: BackendContext<APIServerConfig>) => void;
|
||||
run: (hostname: string, port: number) => void;
|
||||
run: (config: APIServerConfig) => void;
|
||||
end: () => void;
|
||||
};
|
||||
|
||||
@ -11,6 +11,9 @@ export interface APIServerConfig {
|
||||
secret: string;
|
||||
|
||||
authorizedClients: string[];
|
||||
useHttps: boolean;
|
||||
certPath: string;
|
||||
keyPath: string;
|
||||
}
|
||||
|
||||
export const defaultAPIServerConfig: APIServerConfig = {
|
||||
@ -21,4 +24,7 @@ export const defaultAPIServerConfig: APIServerConfig = {
|
||||
secret: Date.now().toString(36),
|
||||
|
||||
authorizedClients: [],
|
||||
useHttps: false,
|
||||
certPath: '',
|
||||
keyPath: '',
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { dialog } from 'electron';
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
@ -93,5 +94,51 @@ export const onMenu = async ({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('plugins.api-server.menu.https.label'),
|
||||
type: 'submenu',
|
||||
submenu: [
|
||||
{
|
||||
label: t('plugins.api-server.menu.https.submenu.enable-https.label'),
|
||||
type: 'checkbox',
|
||||
checked: config.useHttps,
|
||||
click(menuItem) {
|
||||
setConfig({ ...config, useHttps: menuItem.checked });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.api-server.menu.https.submenu.cert.label'),
|
||||
type: 'normal',
|
||||
async click() {
|
||||
const config = await getConfig();
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
title: t(
|
||||
'plugins.api-server.menu.https.submenu.cert.dialogTitle',
|
||||
),
|
||||
filters: [{ name: 'Certificate', extensions: ['crt', 'pem'] }],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setConfig({ ...config, certPath: result.filePaths[0] });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.api-server.menu.https.submenu.key.label'),
|
||||
type: 'normal',
|
||||
async click() {
|
||||
const config = await getConfig();
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
title: t('plugins.api-server.menu.https.submenu.key.dialogTitle'),
|
||||
filters: [{ name: 'Private Key', extensions: ['key', 'pem'] }],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setConfig({ ...config, keyPath: result.filePaths[0] });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@ -9,9 +9,11 @@ import delay from 'delay';
|
||||
import type { Permission, Profile, VideoData } from './types';
|
||||
|
||||
export type ConnectionEventMap = {
|
||||
CLEAR_QUEUE: {};
|
||||
ADD_SONGS: { videoList: VideoData[]; index?: number };
|
||||
REMOVE_SONG: { index: number };
|
||||
MOVE_SONG: { fromIndex: number; toIndex: number };
|
||||
SET_INDEX: { index: number };
|
||||
IDENTIFY: { profile: Profile } | undefined;
|
||||
SYNC_PROFILE: { profiles: Record<string, Profile> } | undefined;
|
||||
SYNC_QUEUE: { videoList: VideoData[] } | undefined;
|
||||
@ -171,9 +173,10 @@ export class Connection {
|
||||
public async broadcast<Event extends keyof ConnectionEventMap>(
|
||||
type: Event,
|
||||
payload: ConnectionEventMap[Event],
|
||||
after?: ConnectionEventUnion[],
|
||||
) {
|
||||
await Promise.all(
|
||||
this.getConnections().map((conn) => conn.send({ type, payload })),
|
||||
this.getConnections().map((conn) => conn.send({ type, payload, after })),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -215,6 +215,25 @@ export default createPlugin<
|
||||
this.ignoreChange = true;
|
||||
|
||||
switch (event.type) {
|
||||
case 'CLEAR_QUEUE': {
|
||||
if (conn && this.permission === 'host-only') {
|
||||
await this.connection?.broadcast('SYNC_QUEUE', {
|
||||
videoList: this.queue?.videoList ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.queue?.clear();
|
||||
await this.connection?.broadcast('CLEAR_QUEUE', {});
|
||||
break;
|
||||
}
|
||||
case 'SET_INDEX': {
|
||||
this.queue?.setIndex(event.payload.index);
|
||||
await this.connection?.broadcast('SET_INDEX', {
|
||||
index: event.payload.index,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'ADD_SONGS': {
|
||||
if (conn && this.permission === 'host-only') {
|
||||
await this.connection?.broadcast('SYNC_QUEUE', {
|
||||
@ -234,7 +253,15 @@ export default createPlugin<
|
||||
await this.connection?.broadcast('ADD_SONGS', {
|
||||
...event.payload,
|
||||
videoList,
|
||||
});
|
||||
},
|
||||
event.after,
|
||||
);
|
||||
|
||||
const afterevent = event.after?.at(0);
|
||||
if (afterevent?.type === 'SET_INDEX') {
|
||||
this.queue?.setIndex(afterevent.payload.index);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
@ -385,6 +412,16 @@ export default createPlugin<
|
||||
const queueListener = async (event: ConnectionEventUnion) => {
|
||||
this.ignoreChange = true;
|
||||
switch (event.type) {
|
||||
case 'CLEAR_QUEUE': {
|
||||
await this.connection?.broadcast('CLEAR_QUEUE', {});
|
||||
break;
|
||||
}
|
||||
case 'SET_INDEX': {
|
||||
await this.connection?.broadcast('SET_INDEX', {
|
||||
index: event.payload.index,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'ADD_SONGS': {
|
||||
await this.connection?.broadcast('ADD_SONGS', {
|
||||
...event.payload,
|
||||
@ -392,7 +429,9 @@ export default createPlugin<
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.connection!.id,
|
||||
})),
|
||||
});
|
||||
},
|
||||
event.after,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
@ -420,6 +459,14 @@ export default createPlugin<
|
||||
const listener = async (event: ConnectionEventUnion) => {
|
||||
this.ignoreChange = true;
|
||||
switch (event.type) {
|
||||
case 'CLEAR_QUEUE': {
|
||||
this.queue?.clear();
|
||||
break;
|
||||
}
|
||||
case 'SET_INDEX': {
|
||||
this.queue?.setIndex(event.payload.index);
|
||||
break;
|
||||
}
|
||||
case 'ADD_SONGS': {
|
||||
const videoList: VideoData[] = event.payload.videoList.map(
|
||||
(it) => ({
|
||||
@ -429,6 +476,13 @@ export default createPlugin<
|
||||
);
|
||||
|
||||
await this.queue?.addVideos(videoList, event.payload.index);
|
||||
|
||||
const afterevent = event.after?.at(0);
|
||||
if (afterevent?.type === 'SET_INDEX') {
|
||||
this.queue?.setIndex(afterevent.payload.index);
|
||||
}
|
||||
|
||||
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
|
||||
@ -314,6 +314,11 @@ export class Queue {
|
||||
if (!this.internalDispatch) {
|
||||
if (event.type === 'CLEAR') {
|
||||
this.ignoreFlag = true;
|
||||
this.broadcast({
|
||||
type: 'CLEAR_QUEUE',
|
||||
payload: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.type === 'ADD_ITEMS') {
|
||||
if (this.ignoreFlag) {
|
||||
@ -347,7 +352,7 @@ export class Queue {
|
||||
},
|
||||
after: [
|
||||
{
|
||||
type: 'SYNC_PROGRESS',
|
||||
type: 'SET_INDEX',
|
||||
payload: {
|
||||
index,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user