diff --git a/docs/socket.md b/docs/socket.md index 02e56dd8..c469e69a 100644 --- a/docs/socket.md +++ b/docs/socket.md @@ -19,12 +19,13 @@ const ws = new WebSocket("ws://127.0.0.1:14558/ws"); 所有下行消息都带有 `kind` 字段: -| `kind` | 形态 | 说明 | -| ------- | ------------------------------------- | ---------------------------------- | -| `hello` | `{ "kind": "hello", "clients": N }` | 连接建立时发送,附当前连接数 | -| `event` | `{ "kind": "event", "type", "data" }` | 播放事件推送(状态、进度、切歌等) | -| `ack` | `{ "kind": "ack", "op" }` | 命令执行成功的回执 | -| `error` | `{ "kind": "error", "op", "error" }` | 命令失败,`error` 为原因 | +| `kind` | 形态 | 说明 | +| -------- | ---------------------------------------------------------- | ---------------------------------- | +| `hello` | `{ "kind": "hello", "clients": N }` | 连接建立时发送,附当前连接数 | +| `event` | `{ "kind": "event", "type", "data" }` | 播放事件推送(状态、进度、切歌等) | +| `ack` | `{ "kind": "ack", "op" }` | 命令执行成功的回执 | +| `error` | `{ "kind": "error", "op", "error" }` | 命令失败,`error` 为原因 | +| `result` | `{ "kind": "result", "op", "platform", "status", "body" }` | 查询结果;`body` 为网易云原始响应 | ## 客户端 → 服务器 @@ -34,18 +35,22 @@ const ws = new WebSocket("ws://127.0.0.1:14558/ws"); { "op": "play" } ``` -| `op` | 附加字段 | 说明 | -| ----------- | -------------------------- | ---------------- | -| `play` | — | 播放 | -| `pause` | — | 暂停 | -| `stop` | — | 停止 | -| `next` | — | 下一曲 | -| `prev` | — | 上一曲 | -| `seek` | `{ "positionMs": number }` | 跳转(毫秒,≥0) | -| `setVolume` | `{ "volume": number }` | 音量(0 ~ 1) | +| `op` | 附加字段 | 说明 | +| ------------- | ------------------------------------------------------------ | --------------------------------------------------------------- | +| `play` | — | 播放 | +| `pause` | — | 暂停 | +| `stop` | — | 停止 | +| `next` | — | 下一曲 | +| `prev` | — | 上一曲 | +| `seek` | `{ "positionMs": number }` | 跳转(毫秒,≥0) | +| `setVolume` | `{ "volume": number }` | 音量(0 ~ 1) | +| `searchSongs` | `{ "keyword": string, "offset"?: number, "limit"?: number }` | 搜索歌曲,固定使用网易云;默认 `offset=0`、`limit=30`,最大 100 | 非法 JSON 或未知 `op` 会收到 `{ "kind": "error", ... }`。 +`searchSongs` 成功时返回网易云 `cloudsearch` 的原始响应体。网易云返回 `405` 时同样通过 +`result` 原样返回其 `status` 和 `body`,不会切换到其它音源或改写错误内容。 + ## 示例 ```javascript @@ -56,6 +61,8 @@ ws.onopen = () => { ws.send(JSON.stringify({ op: "pause" })); // 跳转到 1 分钟处 ws.send(JSON.stringify({ op: "seek", positionMs: 60000 })); + // 搜索歌曲 + ws.send(JSON.stringify({ op: "searchSongs", keyword: "Daft Punk", limit: 10 })); }; ws.onmessage = (evt) => { @@ -73,6 +80,9 @@ ws.onmessage = (evt) => { case "error": console.warn("命令失败:", msg.op, msg.error); break; + case "result": + console.log("网易云搜索结果:", msg.status, msg.body); + break; } }; ``` diff --git a/electron/main/ipc/externalPlaylists.ts b/electron/main/ipc/externalPlaylists.ts new file mode 100644 index 00000000..6a539005 --- /dev/null +++ b/electron/main/ipc/externalPlaylists.ts @@ -0,0 +1,59 @@ +import { ipcMain } from "electron"; +import type { + ExternalPlaylistOperation, + ExternalPlaylistResult, +} from "@shared/types/externalPlaylist"; +import { sendToMain } from "@main/utils/broadcast"; + +const REQUEST_TIMEOUT = 10 * 60_000; + +interface PendingRequest { + resolve: (result: ExternalPlaylistResult) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +const pending = new Map(); +let rendererReady = false; + +/** 请求渲染进程执行 IndexedDB 中的歌单操作 */ +export const requestExternalPlaylist = ( + task: ExternalPlaylistOperation, +): Promise => { + if (!rendererReady) return Promise.reject(new Error("renderer unavailable")); + const requestId = crypto.randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(requestId); + reject(new Error("external playlist request timed out")); + }, REQUEST_TIMEOUT); + pending.set(requestId, { resolve, reject, timer }); + sendToMain("externalPlaylists:request", { requestId, ...task }); + }); +}; + +/** 注册外部歌单操作 IPC */ +export const registerExternalPlaylistsIpc = (): void => { + ipcMain.on("externalPlaylists:ready", () => { + rendererReady = true; + }); + + ipcMain.handle( + "externalPlaylists:complete", + (_event, requestId: string, result: ExternalPlaylistResult) => { + const entry = pending.get(requestId); + if (!entry) return; + clearTimeout(entry.timer); + pending.delete(requestId); + entry.resolve(result); + }, + ); + + ipcMain.handle("externalPlaylists:fail", (_event, requestId: string, message: string) => { + const entry = pending.get(requestId); + if (!entry) return; + clearTimeout(entry.timer); + pending.delete(requestId); + entry.reject(new Error(message)); + }); +}; diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index c6b8beb5..91496747 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -18,6 +18,8 @@ import { registerStatsIpc } from "./stats"; import { registerUpdateIpc } from "./update"; import { registerCloudIpc } from "./cloud"; import { registerCommentsIpc } from "./comments"; +import { registerRecommendationsIpc } from "./recommendations"; +import { registerExternalPlaylistsIpc } from "./externalPlaylists"; /** 注册所有 IPC 处理 */ export const registerIpcHandlers = (): void => { @@ -31,6 +33,8 @@ export const registerIpcHandlers = (): void => { registerApisIpc(); registerCloudIpc(); registerCommentsIpc(); + registerRecommendationsIpc(); + registerExternalPlaylistsIpc(); registerLyricsIpc(); registerHotkeyIpc(); registerThemeIpc(); diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index ee393c8f..712f7fcf 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -27,7 +27,7 @@ import * as songCache from "@main/services/songCache"; import { parseArtists, parseAlbum, formatArtists } from "@main/utils/metadata"; import { playerLog } from "@main/utils/logger"; import { ErrorCode } from "@shared/types/errors"; -import type { LoadOptions, RepeatMode, ShuffleMode, PlayerState } from "@shared/types/player"; +import type { LoadOptions, RepeatMode, ShuffleMode, PlayerState, Track } from "@shared/types/player"; import type { MediaEvent } from "@main/services/media"; import { JsPlayerEvent } from "@splayer/audio-engine"; @@ -603,6 +603,11 @@ export const registerPlayerIpc = (): void => { getThumbar()?.updateLike(liked); }); + // 渲染进程在收藏成功后通知外部控制端 + ipcMain.on("player:notifyLike", (_event, track: Track, liked: boolean) => { + wsBroadcast({ type: "like", data: { track, liked } }); + }); + // 转发渲染端发起的播放控制 ipcMain.on("player:dispatch", (_event, type: string) => { sendToMain("player:event", { type }); diff --git a/electron/main/ipc/recommendations.ts b/electron/main/ipc/recommendations.ts new file mode 100644 index 00000000..b10626ed --- /dev/null +++ b/electron/main/ipc/recommendations.ts @@ -0,0 +1,59 @@ +import { ipcMain } from "electron"; +import type { + RecommendationImportRequest, + RecommendationImportResult, +} from "@shared/types/recommendation"; +import { sendToMain } from "@main/utils/broadcast"; + +const IMPORT_TIMEOUT = 60_000; + +interface PendingImport { + resolve: (result: RecommendationImportResult) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +const pending = new Map(); +let rendererReady = false; + +/** 请求渲染进程解析并导入外部推荐 */ +export const importRecommendations = ( + request: RecommendationImportRequest, +): Promise => { + if (!rendererReady) return Promise.reject(new Error("renderer unavailable")); + const requestId = crypto.randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(requestId); + reject(new Error("renderer import timed out")); + }, IMPORT_TIMEOUT); + pending.set(requestId, { resolve, reject, timer }); + sendToMain("recommendations:import", { requestId, request }); + }); +}; + +/** 注册推荐导入结果回传 IPC */ +export const registerRecommendationsIpc = (): void => { + ipcMain.on("recommendations:ready", () => { + rendererReady = true; + }); + + ipcMain.handle( + "recommendations:complete", + (_event, requestId: string, result: RecommendationImportResult) => { + const entry = pending.get(requestId); + if (!entry) return; + clearTimeout(entry.timer); + pending.delete(requestId); + entry.resolve(result); + }, + ); + + ipcMain.handle("recommendations:fail", (_event, requestId: string, message: string) => { + const entry = pending.get(requestId); + if (!entry) return; + clearTimeout(entry.timer); + pending.delete(requestId); + entry.reject(new Error(message)); + }); +}; diff --git a/electron/main/server/routes.ts b/electron/main/server/routes.ts index 2c6b8e59..a8073157 100644 --- a/electron/main/server/routes.ts +++ b/electron/main/server/routes.ts @@ -10,6 +10,69 @@ import { toMs } from "@main/utils/time"; import * as nowPlaying from "@main/services/nowPlaying"; import { playerControl } from "@main/services/playerControl"; import { getWsClientCount } from "./broadcast"; +import { importRecommendations } from "@main/ipc/recommendations"; +import { requestExternalPlaylist } from "@main/ipc/externalPlaylists"; +import type { RecommendationImportRequest } from "@shared/types/recommendation"; + +const MAX_RECOMMENDATION_ITEMS = 500; + +const isImportRequest = ( + value: unknown, + allowEmptyItems = false, +): value is RecommendationImportRequest => { + if (!value || typeof value !== "object") return false; + const request = value as Partial; + if (request.provider !== "youtube-music") return false; + if (request.mode !== "playlist" && request.mode !== "append" && request.mode !== "replace") + return false; + if ( + !Array.isArray(request.items) || + (!allowEmptyItems && request.items.length === 0) || + request.items.length > MAX_RECOMMENDATION_ITEMS + ) { + return false; + } + return request.items.every((item) => { + if (!item || typeof item !== "object") return false; + return ( + typeof item.sourceId === "string" && + item.sourceId.length > 0 && + typeof item.title === "string" && + item.title.trim().length > 0 && + Array.isArray(item.artists) && + item.artists.every((artist) => typeof artist === "string") && + (item.album === undefined || typeof item.album === "string") && + (item.durationMs === undefined || + (typeof item.durationMs === "number" && + Number.isFinite(item.durationMs) && + item.durationMs >= 0)) && + (item.neteaseId === undefined || + (typeof item.neteaseId === "string" && + item.neteaseId.length > 0 && + item.neteaseId.length <= 30)) + ); + }); +}; + +const getText = (value: unknown, maxLength: number): string | undefined => { + if (typeof value !== "string") return undefined; + const text = value.trim(); + return text && text.length <= maxLength ? text : undefined; +}; + +const getPlaylistId = (value: string): string | undefined => + value.startsWith("pl_") && value.length <= 100 ? value : undefined; + +const getCoverUrl = (value: unknown): string | undefined => { + const url = getText(value, 2_000); + if (!url) return undefined; + try { + const parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:" ? url : undefined; + } catch { + return undefined; + } +}; export const buildRoutes = (): Hono => { const api = new Hono(); @@ -86,5 +149,155 @@ export const buildRoutes = (): Hono => { return c.json({ ok: true }); }); + api.post("/recommendations/import", async (c) => { + const body = await c.req.json().catch(() => null); + if (!isImportRequest(body)) { + return c.json({ error: "invalid recommendation import request" }, 400); + } + try { + return c.json(await importRecommendations(body)); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 503); + } + }); + + api.get("/playlists", async (c) => { + try { + return c.json(await requestExternalPlaylist({ operation: "list" })); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 503); + } + }); + + api.get("/playlists/:id", async (c) => { + const playlistId = getPlaylistId(c.req.param("id")); + if (!playlistId) return c.json({ error: "invalid playlist id" }, 400); + try { + const result = await requestExternalPlaylist({ operation: "get", playlistId }); + return result.found === false ? c.json({ error: "playlist not found" }, 404) : c.json(result); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 503); + } + }); + + api.post("/playlists", async (c) => { + const body = (await c.req.json().catch(() => null)) as Record | null; + const title = getText(body?.title, 200); + if (!title) return c.json({ error: "title (non-empty string, <=200) required" }, 400); + const description = + body?.description === undefined ? undefined : getText(body.description, 2_000); + const cover = body?.cover === undefined ? undefined : getCoverUrl(body.cover); + if (body?.description !== undefined && description === undefined) { + return c.json({ error: "description (non-empty string, <=2000) required" }, 400); + } + if (body?.cover !== undefined && cover === undefined) { + return c.json({ error: "cover (http(s) URL, <=2000) required" }, 400); + } + try { + return c.json( + await requestExternalPlaylist({ operation: "create", title, description, cover }), + 201, + ); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 503); + } + }); + + api.patch("/playlists/:id", async (c) => { + const playlistId = getPlaylistId(c.req.param("id")); + const body = (await c.req.json().catch(() => null)) as Record | null; + if (!playlistId) return c.json({ error: "invalid playlist id" }, 400); + const title = body?.title === undefined ? undefined : getText(body.title, 200); + const description = + body?.description === null + ? null + : body?.description === undefined + ? undefined + : getText(body.description, 2_000); + const cover = + body?.cover === null ? null : body?.cover === undefined ? undefined : getCoverUrl(body.cover); + if ( + (title === undefined && description === undefined && cover === undefined) || + (body?.title !== undefined && title === undefined) || + (body?.description !== undefined && description === undefined) || + (body?.cover !== undefined && cover === undefined) + ) { + return c.json({ error: "title, description, or cover required" }, 400); + } + try { + const result = await requestExternalPlaylist({ + operation: "update", + playlistId, + title, + description, + cover, + }); + return result.found === false ? c.json({ error: "playlist not found" }, 404) : c.json(result); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 503); + } + }); + + api.delete("/playlists/:id", async (c) => { + const playlistId = getPlaylistId(c.req.param("id")); + if (!playlistId) return c.json({ error: "invalid playlist id" }, 400); + try { + const result = await requestExternalPlaylist({ operation: "remove", playlistId }); + return result.found === false + ? c.json({ error: "playlist not found" }, 404) + : c.json({ ok: true }); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 503); + } + }); + + api.put("/playlists/:id/tracks", async (c) => { + const playlistId = getPlaylistId(c.req.param("id")); + const body = (await c.req + .json() + .catch(() => null)) as Partial | null; + const request = { ...(body ?? {}), mode: "playlist" }; + if (!playlistId || !isImportRequest(request)) { + return c.json({ error: "invalid playlist track replacement request" }, 400); + } + try { + const result = await requestExternalPlaylist({ + operation: "replaceTracks", + playlistId, + items: request.items, + }); + return result.found === false ? c.json({ error: "playlist not found" }, 404) : c.json(result); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 503); + } + }); + + api.patch("/playlists/:id/tracks", async (c) => { + const playlistId = getPlaylistId(c.req.param("id")); + const body = (await c.req.json().catch(() => null)) as + (Partial & { removeTrackIds?: unknown }) | null; + const request = { ...(body ?? {}), mode: "playlist" }; + const removeTrackIds = body?.removeTrackIds; + if ( + !playlistId || + !isImportRequest(request, true) || + !Array.isArray(removeTrackIds) || + !removeTrackIds.every((id) => typeof id === "string" && id.length > 0) + ) { + return c.json({ error: "invalid playlist track patch request" }, 400); + } + try { + const result = await requestExternalPlaylist({ + operation: "patchTracks", + playlistId, + items: request.items, + removeTrackIds, + }); + return result.found === false ? c.json({ error: "playlist not found" }, 404) : c.json(result); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 503); + } + }); + return api; }; diff --git a/electron/main/server/ws.ts b/electron/main/server/ws.ts index 34ec66ee..a6f2e23f 100644 --- a/electron/main/server/ws.ts +++ b/electron/main/server/ws.ts @@ -5,11 +5,14 @@ * - 连接建立:`{ kind: "hello", clients: N }` * - player 事件:`{ kind: "event", type, data }`(由 wsBroadcast 推) * - 命令 ack:`{ kind: "ack", op }` / `{ kind: "error", op, error }` + * - 搜索结果:`{ kind: "result", op: "searchSongs", platform, status, body }` * * Client → Server:`{ op: "play" | "pause" | "stop" | "next" | "prev" | "seek" | "setVolume", ... }` */ import type { WSContext } from "hono/ws"; +import { callNetease } from "@main/apis/netease"; +import { NeteaseRequestError } from "@main/apis/netease/core/request"; import { serverLog } from "@main/utils/logger"; import { playerControl } from "@main/services/playerControl"; import { addWsClient, removeWsClient, getWsClientCount } from "./broadcast"; @@ -18,6 +21,9 @@ interface ClientMessage { op: string; positionMs?: number; volume?: number; + keyword?: string; + offset?: number; + limit?: number; } const ack = (ws: WSContext, op: string): void => { @@ -28,6 +34,10 @@ const fail = (ws: WSContext, op: string, error: string): void => { ws.send(JSON.stringify({ kind: "error", op, error })); }; +const result = (ws: WSContext, op: string, status: number, body: unknown): void => { + ws.send(JSON.stringify({ kind: "result", op, platform: "netease", status, body })); +}; + const dispatchCommand = async (ws: WSContext, msg: ClientMessage): Promise => { try { switch (msg.op) { @@ -62,6 +72,34 @@ const dispatchCommand = async (ws: WSContext, msg: ClientMessage): Promise playerControl.setVolume(volume); return ack(ws, msg.op); } + case "searchSongs": { + const keyword = typeof msg.keyword === "string" ? msg.keyword.trim() : ""; + const offset = msg.offset ?? 0; + const limit = msg.limit ?? 30; + if (!keyword || keyword.length > 200) { + return fail(ws, msg.op, "keyword (non-empty string, <=200) required"); + } + if (!Number.isInteger(offset) || offset < 0) { + return fail(ws, msg.op, "offset (integer, >=0) required"); + } + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + return fail(ws, msg.op, "limit (integer, 1..100) required"); + } + try { + const response = await callNetease("cloudsearch", { + keywords: keyword, + type: 1, + offset, + limit, + }); + return result(ws, msg.op, response.status, response.body); + } catch (err) { + if (err instanceof NeteaseRequestError && err.response.status === 405) { + return result(ws, msg.op, err.response.status, err.response.body); + } + throw err; + } + } default: return fail(ws, msg.op ?? "?", "unknown op"); } diff --git a/electron/main/services/nowPlaying.ts b/electron/main/services/nowPlaying.ts index dd94bddf..06a4ae8b 100644 --- a/electron/main/services/nowPlaying.ts +++ b/electron/main/services/nowPlaying.ts @@ -75,6 +75,7 @@ let currentOffsetKey = ""; */ export const update = (track: Track | null, lyric: LyricLine[], source: LyricData): void => { const trackChanged = (currentTrack?.id ?? null) !== (track?.id ?? null); + const likedChanged = currentTrack?.liked !== track?.liked; currentTrack = track; currentLyric = lyric; currentSource = source; @@ -84,6 +85,7 @@ export const update = (track: Track | null, lyric: LyricLine[], source: LyricDat lastPositionAt = Date.now(); emitter.emit("track-change", { track }); } + if (!trackChanged && likedChanged) emitter.emit("track-change", { track }); // 曲目或歌词源任一变化都重读偏移并广播 const key = track?.id ? offsetKey(track.id, source) : ""; if (trackChanged || key !== currentOffsetKey) { diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index c1ef31bf..43e45d88 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -21,6 +21,8 @@ import { StatsApi } from "@shared/types/stats"; import { UpdateApi } from "@shared/types/update"; import { CloudUploadApi } from "@shared/types/cloudUpload"; import { CommentsApi } from "@shared/types/comment"; +import { RecommendationsApi } from "@shared/types/recommendation"; +import { ExternalPlaylistsApi } from "@shared/types/externalPlaylist"; declare global { interface Window { @@ -56,6 +58,8 @@ declare global { taskbarLyric: TaskbarLyricApi; nowPlaying: NowPlayingApi; plugins: PluginsApi; + recommendations: RecommendationsApi; + externalPlaylists: ExternalPlaylistsApi; apis: ApisApi; cloud: CloudUploadApi; lyrics: LyricsApi; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 1fcf4b2d..932bd2e2 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -9,13 +9,18 @@ import type { PluginMatchCoverArgs, } from "@shared/types/plugin"; import type { HotkeyActionId, HotkeyBinding, HotkeyConflict } from "@shared/types/hotkey"; -import type { LoadOptions, TrackSource } from "@shared/types/player"; +import type { LoadOptions, Track, TrackSource } from "@shared/types/player"; import type { StreamingServerConfig } from "@shared/types/streaming"; import type { PlayEventInput, FavoriteEventInput } from "@shared/types/stats"; import type { TagEditRequest } from "@shared/types/tagEditor"; import type { UpdateEvent } from "@shared/types/update"; import type { CloudUploadProgress } from "@shared/types/cloudUpload"; import type { MusicCommentQuery } from "@shared/types/comment"; +import type { + RecommendationImportResult, + RecommendationImportTask, +} from "@shared/types/recommendation"; +import type { ExternalPlaylistResult, ExternalPlaylistTask } from "@shared/types/externalPlaylist"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -103,6 +108,9 @@ const api = { ipcRenderer.send("player:syncPlayMode", repeatMode, shuffleMode), // 同步当前歌曲喜欢状态到主进程(供托盘菜单显示) syncLikeState: (liked: boolean) => ipcRenderer.send("player:syncLikeState", liked), + // 通知外部控制端当前歌曲收藏状态变更 + notifyLike: (track: Track, liked: boolean) => + ipcRenderer.send("player:notifyLike", track, liked), // 广播播放控制事件到所有渲染进程 dispatch: (type: string) => ipcRenderer.send("player:dispatch", type), // 订阅主进程推送的播放事件 @@ -328,6 +336,24 @@ const api = { onStatus: (callback: (info: PluginInfo) => void) => subscribe("plugin:status", callback), }, + recommendations: { + ready: () => ipcRenderer.send("recommendations:ready"), + onImport: (callback: (task: RecommendationImportTask) => void) => + subscribe("recommendations:import", callback), + complete: (requestId: string, result: RecommendationImportResult) => + ipcRenderer.invoke("recommendations:complete", requestId, result), + fail: (requestId: string, error: string) => + ipcRenderer.invoke("recommendations:fail", requestId, error), + }, + externalPlaylists: { + ready: () => ipcRenderer.send("externalPlaylists:ready"), + onRequest: (callback: (task: ExternalPlaylistTask) => void) => + subscribe("externalPlaylists:request", callback), + complete: (requestId: string, result: ExternalPlaylistResult) => + ipcRenderer.invoke("externalPlaylists:complete", requestId, result), + fail: (requestId: string, error: string) => + ipcRenderer.invoke("externalPlaylists:fail", requestId, error), + }, apis: { // 调用任意平台的任意接口 call: (platform: string, name: string, params?: Record) => diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..71cbab06 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.95.0" +profile = "minimal" diff --git a/scripts/build-native.ts b/scripts/build-native.ts index 8e19a769..06e50c37 100644 --- a/scripts/build-native.ts +++ b/scripts/build-native.ts @@ -1,5 +1,8 @@ -import { spawnSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import process from "node:process"; +import os from "node:os"; interface NativeModule { name: string; @@ -31,6 +34,86 @@ const isRustAvailable = () => { return !result.error && !result.signal && result.status === 0; }; +/** Windows 下加载 MSVC + Windows SDK 环境,避免 C/C++ 依赖找不到 errno.h。 */ +const configureMsvcEnvironment = () => { + if (process.platform !== "win32") return; + const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; + const candidates = [ + join( + programFiles, + "Microsoft Visual Studio", + "18", + "Insiders", + "VC", + "Auxiliary", + "Build", + "vcvars64.bat", + ), + join( + programFiles, + "Microsoft Visual Studio", + "2022", + "Community", + "VC", + "Auxiliary", + "Build", + "vcvars64.bat", + ), + join( + programFiles, + "Microsoft Visual Studio", + "2022", + "BuildTools", + "VC", + "Auxiliary", + "Build", + "vcvars64.bat", + ), + ]; + const vcvars = candidates.find((candidate) => existsSync(candidate)); + if (!vcvars || process.env.VCToolsInstallDir) return; + const tempDir = mkdtempSync(join(os.tmpdir(), "splayer-vcvars-")); + const batchFile = join(tempDir, "env.bat"); + try { + writeFileSync(batchFile, `@call "${vcvars}"\r\n@set\r\n`, "utf8"); + const output = execFileSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", batchFile], { + encoding: "utf8", + windowsHide: true, + }); + for (const line of output.split(/\r?\n/)) { + const separator = line.indexOf("="); + if (separator <= 0) continue; + process.env[line.slice(0, separator)] = line.slice(separator + 1); + } + console.log(`[BuildNative] 使用 MSVC: ${vcvars}`); + } catch (error) { + console.warn(`[BuildNative] 加载 MSVC 环境失败: ${String(error)}`); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}; + +/** Windows 下自动寻找 bindgen 所需的 libclang,优先尊重用户显式配置。 */ +const configureLibclang = () => { + if (process.platform !== "win32") return; + const roots = [process.env.MSYS2_ROOT, "C:\\msys64", "C:\\msys2"].filter((root): root is string => + Boolean(root), + ); + const candidates = roots.map((root) => join(root, "clang64", "bin")); + const libclangPath = + process.env.LIBCLANG_PATH ?? + candidates.find((candidate) => existsSync(join(candidate, "libclang.dll"))); + if (libclangPath) { + process.env.LIBCLANG_PATH = libclangPath; + const pathEntries = (process.env.PATH ?? "").split(";"); + if (!pathEntries.some((entry) => entry.toLowerCase() === libclangPath.toLowerCase())) { + // 放在 PATH 末尾,避免 MSYS2 clang 抢走 MSVC/FFmpeg 的 C/C++ 工具链。 + process.env.PATH = `${process.env.PATH ?? ""};${libclangPath}`; + } + console.log(`[BuildNative] 使用 libclang: ${libclangPath}`); + } +}; + if (process.env.SKIP_NATIVE_BUILD === "true" || process.env.SKIP_NATIVE_BUILD === "1") { console.log("[BuildNative] SKIP_NATIVE_BUILD 已设置,跳过原生模块构建"); process.exit(0); @@ -45,6 +128,9 @@ if (!isRustAvailable()) { process.exit(1); } +configureMsvcEnvironment(); +configureLibclang(); + const parseArgs = () => { const options: { isDev: boolean; diff --git a/shared/types/externalPlaylist.ts b/shared/types/externalPlaylist.ts new file mode 100644 index 00000000..e662246f --- /dev/null +++ b/shared/types/externalPlaylist.ts @@ -0,0 +1,63 @@ +import type { + RecommendationImportSkipped, + RecommendationInput, + RecommendationResolved, +} from "./recommendation"; + +/** 外部 API 可见的歌单元数据 */ +export interface ExternalPlaylist { + id: string; + title: string; + description?: string; + trackCount: number; + cover?: string; + createTime?: number; + updateTime?: number; +} + +/** 外部歌单操作 */ +export type ExternalPlaylistOperation = + | { operation: "list" } + | { operation: "get"; playlistId: string } + | { operation: "create"; title: string; description?: string; cover?: string } + | { + operation: "update"; + playlistId: string; + title?: string; + description?: string | null; + cover?: string | null; + } + | { operation: "remove"; playlistId: string } + | { + operation: "replaceTracks"; + playlistId: string; + items: RecommendationInput[]; + } + | { + operation: "patchTracks"; + playlistId: string; + items: RecommendationInput[]; + removeTrackIds: string[]; + }; + +/** 外部歌单操作任务 */ +export type ExternalPlaylistTask = ExternalPlaylistOperation & { requestId: string }; + +/** 外部歌单操作结果 */ +export interface ExternalPlaylistResult { + found?: boolean; + playlist?: ExternalPlaylist; + playlists?: ExternalPlaylist[]; + requested?: number; + imported?: number; + skipped?: RecommendationImportSkipped[]; + resolved?: RecommendationResolved[]; +} + +/** 预加载层暴露给渲染进程的外部歌单操作 API */ +export interface ExternalPlaylistsApi { + ready: () => void; + onRequest: (callback: (task: ExternalPlaylistTask) => void) => () => void; + complete: (requestId: string, result: ExternalPlaylistResult) => Promise; + fail: (requestId: string, error: string) => Promise; +} diff --git a/shared/types/player.ts b/shared/types/player.ts index 69791ac8..bd0c0344 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -114,6 +114,8 @@ export interface Track { fee?: TrackFee; /** 云盘歌曲 */ cloud?: boolean; + /** 当前账户是否已收藏 */ + liked?: boolean; } /** 歌曲详细信息 */ @@ -251,6 +253,8 @@ export interface PlayerApi { syncPlayMode: (repeatMode: string, shuffleMode: string) => void; /** 同步当前歌曲喜欢状态到托盘 */ syncLikeState: (liked: boolean) => void; + /** 通知外部控制端当前歌曲收藏状态已变更 */ + notifyLike: (track: Track, liked: boolean) => void; /** 广播播放控制事件 */ dispatch: (type: string) => void; /** 订阅播放事件 */ diff --git a/shared/types/recommendation.ts b/shared/types/recommendation.ts new file mode 100644 index 00000000..bdda3341 --- /dev/null +++ b/shared/types/recommendation.ts @@ -0,0 +1,67 @@ +/** 外部推荐提供者 */ +export type RecommendationProvider = "youtube-music"; + +/** 推荐导入方式 */ +export type RecommendationImportMode = "playlist" | "append" | "replace"; + +/** 外部推荐曲目 */ +export interface RecommendationInput { + /** 外部提供者曲目 ID,仅用于关联导入结果 */ + sourceId: string; + title: string; + artists: string[]; + album?: string; + durationMs?: number; + /** Bridge 已缓存的网易云 songId,存在时跳过关键词搜索 */ + neteaseId?: string; +} + +/** 外部曲目与网易云 songId 的解析结果 */ +export interface RecommendationResolved { + sourceId: string; + neteaseId: string; +} + +/** Bridge 提交给 SPlayer 的推荐导入请求 */ +export interface RecommendationImportRequest { + provider: RecommendationProvider; + mode: RecommendationImportMode; + /** playlist 模式的歌单名称 */ + name?: string; + items: RecommendationInput[]; +} + +/** 未能导入的推荐曲目 */ +export interface RecommendationImportSkipped { + sourceId: string; + reason: "invalid" | "notFound"; + title?: string; + artists?: string[]; + album?: string; + durationMs?: number; + keyword?: string; + error?: string; +} + +/** 推荐导入结果 */ +export interface RecommendationImportResult { + requested: number; + imported: number; + skipped: RecommendationImportSkipped[]; + resolved: RecommendationResolved[]; + playlistId?: string; +} + +/** 渲染进程收到的推荐导入任务 */ +export interface RecommendationImportTask { + requestId: string; + request: RecommendationImportRequest; +} + +/** 预加载层暴露给渲染进程的推荐导入 API */ +export interface RecommendationsApi { + ready: () => void; + onImport: (callback: (task: RecommendationImportTask) => void) => () => void; + complete: (requestId: string, result: RecommendationImportResult) => Promise; + fail: (requestId: string, error: string) => Promise; +} diff --git a/src/composables/useFavorite.ts b/src/composables/useFavorite.ts index ff34b458..61d29d82 100644 --- a/src/composables/useFavorite.ts +++ b/src/composables/useFavorite.ts @@ -12,6 +12,7 @@ import i18n from "@/i18n"; */ const recordFavoriteChange = (track: Track, liked: boolean): void => { window.api.stats.recordFavorite({ track, action: liked ? "add" : "remove" }); + window.api.player.notifyLike(track, liked); }; /** diff --git a/src/core/player/index.ts b/src/core/player/index.ts index 55c72a0e..b337ae9e 100644 --- a/src/core/player/index.ts +++ b/src/core/player/index.ts @@ -10,6 +10,7 @@ import { usePluginsStore } from "@/stores/plugins"; import { useHistoryStore } from "@/stores/history"; import { useLibraryStore } from "@/stores/library"; import * as queue from "@/stores/queue"; +import { handleExternalPlaylistTask, importRecommendations } from "@/services/recommendations"; import * as fm from "./fm"; import * as playback from "@/services/playback"; import * as lyricLoader from "@/services/lyricLoader"; @@ -965,6 +966,28 @@ export const initPlayer = async (): Promise => { // 先订阅事件,确保 load 触发播放后 position 事件能被接收 if (unsubscribe) unsubscribe(); unsubscribe = window.api.player.onEvent(handleEvent); + window.api.recommendations.ready(); + window.api.recommendations.onImport(({ requestId, request }) => { + void importRecommendations(request, { append: insertManyToQueue, replace: playFrom }) + .then((result) => window.api.recommendations.complete(requestId, result)) + .catch((error) => + window.api.recommendations.fail( + requestId, + error instanceof Error ? error.message : String(error), + ), + ); + }); + window.api.externalPlaylists.ready(); + window.api.externalPlaylists.onRequest(({ requestId, ...task }) => { + void handleExternalPlaylistTask({ requestId, ...task }) + .then((result) => window.api.externalPlaylists.complete(requestId, result)) + .catch((error) => + window.api.externalPlaylists.fail( + requestId, + error instanceof Error ? error.message : String(error), + ), + ); + }); // 安装播放统计累加器 installPlayStats(); // 订阅主进程下发的歌词偏移变化 @@ -973,7 +996,10 @@ export const initPlayer = async (): Promise => { const fav = useFavorite(); watch( () => fav.isLiked(media.track), - (liked) => window.api.player.syncLikeState(liked), + (liked) => { + window.api.player.syncLikeState(liked); + media.syncToMain(); + }, { immediate: true }, ); window.api.nowPlaying.onLyricOffsetChange(({ offsetMs }) => { diff --git a/src/services/recommendations.ts b/src/services/recommendations.ts new file mode 100644 index 00000000..744dd0bc --- /dev/null +++ b/src/services/recommendations.ts @@ -0,0 +1,194 @@ +import type { + RecommendationImportRequest, + RecommendationImportResult, + RecommendationImportSkipped, + RecommendationResolved, +} from "@shared/types/recommendation"; +import type { + ExternalPlaylist, + ExternalPlaylistResult, + ExternalPlaylistTask, +} from "@shared/types/externalPlaylist"; +import type { Track } from "@shared/types/player"; +import { searchSongs } from "@/apis/search"; +import { songsByIds } from "@/apis/song/netease"; +import { usePlaylistStore } from "@/stores/playlist"; + +const SEARCH_DELAY_MS = 500; +const NETEASE_RETRY_DELAY_MS = 3_000; + +const getSearchKeyword = (title: string, artists: string[]): string => + [title, ...artists].filter(Boolean).join(" "); + +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const isNeteaseRateLimited = (error: unknown): boolean => + /netease 405\b|\b405:/.test(error instanceof Error ? error.message : String(error)); + +/** 将外部推荐按给定顺序解析为网易云曲目 */ +export const resolveRecommendationTracks = async ( + request: RecommendationImportRequest, +): Promise<{ + tracks: Track[]; + skipped: RecommendationImportSkipped[]; + resolved: RecommendationResolved[]; +}> => { + const tracks: Track[] = []; + const skipped: RecommendationImportSkipped[] = []; + const resolved: RecommendationResolved[] = []; + for (const [index, item] of request.items.entries()) { + const keyword = getSearchKeyword(item.title, item.artists); + let track: Track | undefined; + let error: unknown; + try { + if (item.neteaseId) track = (await songsByIds([item.neteaseId]))[0]; + else track = (await searchSongs("netease", keyword, 0, 1)).items[0]; + } catch (caught) { + error = caught; + if (isNeteaseRateLimited(caught)) { + console.warn("[recommendations] 网易云搜索限流,3 秒后重试", { + sourceId: item.sourceId, + title: item.title, + artists: item.artists, + keyword, + }); + await wait(NETEASE_RETRY_DELAY_MS); + try { + track = (await searchSongs("netease", keyword, 0, 1)).items[0]; + error = undefined; + } catch (retryError) { + error = retryError; + } + } + } + if (!track) { + console.error("[recommendations] 网易云未能解析曲目", { + sourceId: item.sourceId, + title: item.title, + artists: item.artists, + album: item.album, + durationMs: item.durationMs, + keyword, + error: error instanceof Error ? error.stack || error.message : undefined, + }); + skipped.push({ + sourceId: item.sourceId, + reason: "notFound", + title: item.title, + artists: item.artists, + album: item.album, + durationMs: item.durationMs, + keyword, + error: error instanceof Error ? error.stack || error.message : undefined, + }); + } else { + tracks.push(track); + resolved.push({ sourceId: item.sourceId, neteaseId: track.id }); + } + if (index < request.items.length - 1) { + await wait(SEARCH_DELAY_MS); + } + } + return { tracks, skipped, resolved }; +}; + +/** 导入 Bridge 提供的推荐曲目 */ +export const importRecommendations = async ( + request: RecommendationImportRequest, + actions: { + append: (tracks: readonly Track[]) => number; + replace: (tracks: readonly Track[]) => Promise; + }, +): Promise => { + const { tracks, skipped, resolved } = await resolveRecommendationTracks(request); + if (request.mode === "append") actions.append(tracks); + if (request.mode === "replace" && tracks.length > 0) await actions.replace(tracks); + + let playlistId: string | undefined; + if (request.mode === "playlist") { + const title = request.name?.trim() || `YouTube Music · ${new Date().toLocaleDateString()}`; + const playlist = await usePlaylistStore().saveSnapshot(title, tracks); + playlistId = playlist.id; + } + + return { + requested: request.items.length, + imported: tracks.length, + skipped, + resolved, + playlistId, + }; +}; + +const toExternalPlaylist = (playlist: { + id: string; + title: string; + description?: string; + trackCount?: number; + cover?: string; + createTime?: number; + updateTime?: number; +}): ExternalPlaylist => ({ + id: playlist.id, + title: playlist.title, + description: playlist.description, + trackCount: playlist.trackCount ?? 0, + cover: playlist.cover, + createTime: playlist.createTime, + updateTime: playlist.updateTime, +}); + +/** 执行外部 API 发起的歌单操作 */ +export const handleExternalPlaylistTask = async ( + task: ExternalPlaylistTask, +): Promise => { + const store = usePlaylistStore(); + if (!store.initialized) await store.load(); + if (task.operation === "list") { + return { playlists: store.playlists.map(toExternalPlaylist) }; + } + const existing = task.operation === "create" ? null : await store.get(task.playlistId); + if (task.operation === "get") { + return { + found: existing !== null, + playlist: existing ? toExternalPlaylist(existing) : undefined, + }; + } + if (task.operation === "create") { + const playlist = await store.create(task.title, task.description, task.cover); + return { playlist: toExternalPlaylist(playlist) }; + } + if (!existing) return { found: false }; + if (task.operation === "update") { + await store.update(task.playlistId, { + ...(task.title === undefined ? {} : { title: task.title }), + ...(task.description === undefined + ? {} + : { description: task.description === null ? undefined : task.description }), + ...(task.cover === undefined ? {} : { cover: task.cover === null ? undefined : task.cover }), + }); + const playlist = await store.get(task.playlistId); + return { found: true, playlist: playlist ? toExternalPlaylist(playlist) : undefined }; + } + if (task.operation === "remove") { + await store.remove(task.playlistId); + return { found: true }; + } + const { tracks, skipped, resolved } = await resolveRecommendationTracks({ + provider: "youtube-music", + mode: "playlist", + items: task.items, + }); + const playlist = + task.operation === "patchTracks" + ? await store.patchTracks(task.playlistId, tracks, task.removeTrackIds) + : await store.replaceTracks(task.playlistId, tracks); + return { + found: playlist !== null, + playlist: playlist ? toExternalPlaylist(playlist) : undefined, + requested: task.items.length, + imported: tracks.length, + skipped, + resolved, + }; +}; diff --git a/src/stores/media.ts b/src/stores/media.ts index 8b6b7b14..4805b5b1 100644 --- a/src/stores/media.ts +++ b/src/stores/media.ts @@ -1,7 +1,9 @@ import type { MediaInfo, Track, TrackDetail } from "@shared/types/player"; import type { LyricData, LyricFormat, LyricInput, LyricLine } from "@shared/types/lyrics"; import { findLyricIndex } from "@shared/utils/lyric"; +import { useLibraryStore } from "@/stores/library"; import { useSettingsStore } from "@/stores/settings"; +import { useUserStore } from "@/stores/user"; import { watchLyricPreference } from "@/services/lyricLoader"; import { parseLyric } from "@/utils/lyric/parse"; import { extractLyricAuthors } from "@/utils/lyric/author"; @@ -42,8 +44,15 @@ export const useMediaStore = defineStore("media", () => { /** 同步当前歌词源到主进程 */ const syncToMain = (): void => { try { + const currentTrack = track.value ? toRaw(track.value) : null; + const liked = + currentTrack?.source === "local" + ? useLibraryStore().isLiked(currentTrack.id) + : currentTrack?.source === "netease" + ? useUserStore().isLiked(currentTrack.id) + : false; const payload = { - track: track.value ? toRaw(track.value) : null, + track: currentTrack ? { ...currentTrack, liked } : null, lyric: toRaw(parsedLyric.value), source: activeLyric.value ? toRaw(activeLyric.value) : null, }; @@ -179,6 +188,7 @@ export const useMediaStore = defineStore("media", () => { patchCover, resetLyricState, setLyric, + syncToMain, updateLyricIndex, clear, }; diff --git a/src/stores/playlist.ts b/src/stores/playlist.ts index 9ab8ad34..c65f1ec2 100644 --- a/src/stores/playlist.ts +++ b/src/stores/playlist.ts @@ -7,14 +7,14 @@ const db = localforage.createInstance({ name: "splayer", storeName: "playlists" const generateId = () => `pl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; export const usePlaylistStore = defineStore("playlist", () => { - const playlists = shallowRef[]>([]); + const playlists = shallowRef[]>([]); const initialized = ref(false); /** 加载所有歌单元数据 */ const load = async (): Promise => { - const items: Omit[] = []; + const items: Omit[] = []; await db.iterate((record) => { - const { trackIds: _, ...meta } = record; + const { trackIds: _, tracks: __, ...meta } = record; items.push(meta); }); items.sort((a, b) => (b.updateTime ?? 0) - (a.updateTime ?? 0)); @@ -28,7 +28,10 @@ export const usePlaylistStore = defineStore("playlist", () => { * @returns 歌单完整数据 */ const resolveCollection = async (record: PlaylistRecord): Promise => { - const { trackIds: _, ...meta } = record; + const { trackIds: _, tracks: savedTracks, ...meta } = record; + if (savedTracks) { + return { ...meta, tracks: savedTracks, trackCount: savedTracks.length }; + } if (record.trackIds.length === 0) { return { ...meta, tracks: [], trackCount: 0 }; } @@ -55,7 +58,11 @@ export const usePlaylistStore = defineStore("playlist", () => { }; /** 创建歌单 */ - const create = async (title: string, description?: string): Promise => { + const create = async ( + title: string, + description?: string, + cover?: string, + ): Promise => { const now = Date.now(); const record: PlaylistRecord = { id: generateId(), @@ -63,13 +70,14 @@ export const usePlaylistStore = defineStore("playlist", () => { source: "local", title, description, + cover, trackIds: [], trackCount: 0, createTime: now, updateTime: now, }; await db.setItem(record.id, record); - const { trackIds: _, ...meta } = record; + const { trackIds: _, tracks: __, ...meta } = record; playlists.value = [meta, ...playlists.value]; return { ...meta, tracks: [], trackCount: 0 }; }; @@ -77,7 +85,7 @@ export const usePlaylistStore = defineStore("playlist", () => { /** 更新歌单信息 */ const update = async ( id: string, - data: Partial>, + data: Partial>, ): Promise => { const record = await db.getItem(id); if (!record) return; @@ -101,6 +109,28 @@ export const usePlaylistStore = defineStore("playlist", () => { const addTracks = async (id: string, tracks: Track[]): Promise => { const record = await db.getItem(id); if (!record) return 0; + if (record.tracks) { + const existing = new Set(record.tracks.map((track) => track.id)); + const fresh = tracks.filter((track) => !existing.has(track.id)); + if (fresh.length === 0) return 0; + record.tracks.push(...fresh); + record.trackCount = record.tracks.length; + record.updateTime = Date.now(); + if (!record.cover) record.cover = fresh.find((track) => track.cover)?.cover; + await db.setItem(id, record); + const idx = playlists.value.findIndex((playlist) => playlist.id === id); + if (idx !== -1) { + const next = [...playlists.value]; + next[idx] = { + ...next[idx], + trackCount: record.trackCount, + cover: record.cover, + updateTime: record.updateTime, + }; + playlists.value = next; + } + return fresh.length; + } const existIds = new Set(record.trackIds); const newIds = tracks.map((t) => t.id).filter((tid) => !existIds.has(tid)); if (newIds.length === 0) return 0; @@ -125,11 +155,99 @@ export const usePlaylistStore = defineStore("playlist", () => { return newIds.length; }; + /** 创建或更新带在线曲目快照的本地歌单 */ + const saveSnapshot = async (title: string, tracks: Track[]): Promise => { + const now = Date.now(); + const record: PlaylistRecord = { + id: generateId(), + type: "playlist", + source: "local", + title, + trackIds: [], + tracks, + trackCount: tracks.length, + cover: tracks.find((track) => track.cover)?.cover, + createTime: now, + updateTime: now, + }; + await db.setItem(record.id, record); + const { trackIds: _, tracks: __, ...meta } = record; + playlists.value = [meta, ...playlists.value]; + return { ...meta, tracks, trackCount: tracks.length }; + }; + + /** 替换歌单中的在线曲目快照 */ + const replaceTracks = async (id: string, tracks: Track[]): Promise => { + const record = await db.getItem(id); + if (!record) return null; + record.trackIds = []; + record.tracks = tracks; + record.trackCount = tracks.length; + if (!record.cover) record.cover = tracks.find((track) => track.cover)?.cover; + record.updateTime = Date.now(); + await db.setItem(id, record); + const idx = playlists.value.findIndex((playlist) => playlist.id === id); + if (idx !== -1) { + const { trackIds: _, tracks: __, ...meta } = record; + const next = [...playlists.value]; + next[idx] = meta; + playlists.value = next; + } + return { ...record, tracks, trackCount: tracks.length }; + }; + + /** 增量更新在线曲目快照 */ + const patchTracks = async ( + id: string, + addTracks: Track[], + removeTrackIds: string[], + ): Promise => { + const record = await db.getItem(id); + if (!record) return null; + const removed = new Set(removeTrackIds); + const tracks = (record.tracks ?? []).filter((track) => !removed.has(track.id)); + const existing = new Set(tracks.map((track) => track.id)); + tracks.push(...addTracks.filter((track) => !existing.has(track.id))); + record.trackIds = []; + record.tracks = tracks; + record.trackCount = tracks.length; + if (!record.cover) record.cover = tracks.find((track) => track.cover)?.cover; + record.updateTime = Date.now(); + await db.setItem(id, record); + const idx = playlists.value.findIndex((playlist) => playlist.id === id); + if (idx !== -1) { + const { trackIds: _, tracks: __, ...meta } = record; + const next = [...playlists.value]; + next[idx] = meta; + playlists.value = next; + } + return { ...record, tracks, trackCount: tracks.length }; + }; + /** 从歌单移除歌曲 */ const removeTracks = async (id: string, trackIds: string[]): Promise => { const record = await db.getItem(id); if (!record) return; const removeSet = new Set(trackIds); + if (record.tracks) { + record.tracks = record.tracks.filter((track) => !removeSet.has(track.id)); + record.trackCount = record.tracks.length; + record.updateTime = Date.now(); + record.cover = record.tracks.find((track) => track.cover)?.cover; + await db.setItem(id, record); + const idx = playlists.value.findIndex((playlist) => playlist.id === id); + if (idx !== -1) { + const next = [...playlists.value]; + next[idx] = { + ...next[idx], + trackCount: record.trackCount, + cover: record.cover, + updateTime: record.updateTime, + }; + playlists.value = next; + } + return; + } record.trackIds = record.trackIds.filter((tid) => !removeSet.has(tid)); record.trackCount = record.trackIds.length; record.updateTime = Date.now(); @@ -149,5 +267,18 @@ export const usePlaylistStore = defineStore("playlist", () => { } }; - return { playlists, initialized, load, get, create, update, remove, addTracks, removeTracks }; + return { + playlists, + initialized, + load, + get, + create, + update, + remove, + addTracks, + removeTracks, + saveSnapshot, + replaceTracks, + patchTracks, + }; }); diff --git a/src/types/collection.ts b/src/types/collection.ts index 1a3540c8..481f66c2 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -15,6 +15,8 @@ export interface PlaylistRecord { description?: string; /** 歌曲 ID 列表 */ trackIds: string[]; + /** 不在本地曲库中的在线曲目快照 */ + tracks?: Track[]; trackCount?: number; cover?: string; createTime?: number;