From dfa1c11032d98c4ab4e61b4a5bc55ce435f899f5 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Sun, 19 Jul 2026 22:54:04 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E6=8E=A8=E8=8D=90=E5=AF=BC=E5=85=A5=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipc/index.ts | 2 + electron/main/ipc/recommendations.ts | 59 +++++++++++++++++++ electron/main/server/routes.ts | 46 +++++++++++++++ electron/preload/index.d.ts | 2 + electron/preload/index.ts | 10 ++++ shared/types/recommendation.ts | 52 ++++++++++++++++ src/core/player/index.ts | 12 ++++ src/services/recommendations.ts | 67 +++++++++++++++++++++ src/stores/playlist.ts | 88 ++++++++++++++++++++++++++-- src/types/collection.ts | 2 + 10 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 electron/main/ipc/recommendations.ts create mode 100644 shared/types/recommendation.ts create mode 100644 src/services/recommendations.ts diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index c6b8beb5..5a6aa37c 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -18,6 +18,7 @@ import { registerStatsIpc } from "./stats"; import { registerUpdateIpc } from "./update"; import { registerCloudIpc } from "./cloud"; import { registerCommentsIpc } from "./comments"; +import { registerRecommendationsIpc } from "./recommendations"; /** 注册所有 IPC 处理 */ export const registerIpcHandlers = (): void => { @@ -31,6 +32,7 @@ export const registerIpcHandlers = (): void => { registerApisIpc(); registerCloudIpc(); registerCommentsIpc(); + registerRecommendationsIpc(); registerLyricsIpc(); registerHotkeyIpc(); registerThemeIpc(); 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..38b5fc05 100644 --- a/electron/main/server/routes.ts +++ b/electron/main/server/routes.ts @@ -10,6 +10,40 @@ 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 type { RecommendationImportRequest } from "@shared/types/recommendation"; + +const MAX_RECOMMENDATION_ITEMS = 50; + +const isImportRequest = (value: unknown): 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) || + 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)) + ); + }); +}; export const buildRoutes = (): Hono => { const api = new Hono(); @@ -86,5 +120,17 @@ 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); + } + }); + return api; }; diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index c1ef31bf..89c57198 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -21,6 +21,7 @@ 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"; declare global { interface Window { @@ -56,6 +57,7 @@ declare global { taskbarLyric: TaskbarLyricApi; nowPlaying: NowPlayingApi; plugins: PluginsApi; + recommendations: RecommendationsApi; apis: ApisApi; cloud: CloudUploadApi; lyrics: LyricsApi; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 1fcf4b2d..c488db6a 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -16,6 +16,7 @@ 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"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -328,6 +329,15 @@ 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), + }, apis: { // 调用任意平台的任意接口 call: (platform: string, name: string, params?: Record) => diff --git a/shared/types/recommendation.ts b/shared/types/recommendation.ts new file mode 100644 index 00000000..6f2e3b4d --- /dev/null +++ b/shared/types/recommendation.ts @@ -0,0 +1,52 @@ +/** 外部推荐提供者 */ +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 提交给 SPlayer 的推荐导入请求 */ +export interface RecommendationImportRequest { + provider: RecommendationProvider; + mode: RecommendationImportMode; + /** playlist 模式的歌单名称 */ + name?: string; + items: RecommendationInput[]; +} + +/** 未能导入的推荐曲目 */ +export interface RecommendationImportSkipped { + sourceId: string; + reason: "invalid" | "notFound"; +} + +/** 推荐导入结果 */ +export interface RecommendationImportResult { + requested: number; + imported: number; + skipped: RecommendationImportSkipped[]; + 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/core/player/index.ts b/src/core/player/index.ts index 55c72a0e..532cc86a 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 { importRecommendations } from "@/services/recommendations"; import * as fm from "./fm"; import * as playback from "@/services/playback"; import * as lyricLoader from "@/services/lyricLoader"; @@ -965,6 +966,17 @@ 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), + ), + ); + }); // 安装播放统计累加器 installPlayStats(); // 订阅主进程下发的歌词偏移变化 diff --git a/src/services/recommendations.ts b/src/services/recommendations.ts new file mode 100644 index 00000000..0845a3d8 --- /dev/null +++ b/src/services/recommendations.ts @@ -0,0 +1,67 @@ +import type { + RecommendationImportRequest, + RecommendationImportResult, + RecommendationImportSkipped, +} from "@shared/types/recommendation"; +import type { Track } from "@shared/types/player"; +import { searchSongs } from "@/apis/search"; +import { usePlaylistStore } from "@/stores/playlist"; + +const SEARCH_CONCURRENCY = 3; + +const getSearchKeyword = (title: string, artists: string[]): string => + [title, ...artists].filter(Boolean).join(" "); + +/** 将外部推荐按给定顺序解析为网易云曲目 */ +const resolveTracks = async ( + request: RecommendationImportRequest, +): Promise<{ tracks: Track[]; skipped: RecommendationImportSkipped[] }> => { + const tracks = new Array(request.items.length).fill(null); + const skipped: RecommendationImportSkipped[] = []; + let nextIndex = 0; + + const worker = async (): Promise => { + while (nextIndex < request.items.length) { + const index = nextIndex++; + const item = request.items[index]; + try { + const result = await searchSongs("netease", getSearchKeyword(item.title, item.artists), 0, 1); + const track = result.items[0]; + if (track) tracks[index] = track; + else skipped.push({ sourceId: item.sourceId, reason: "notFound" }); + } catch { + skipped.push({ sourceId: item.sourceId, reason: "notFound" }); + } + } + }; + + await Promise.all(Array.from({ length: Math.min(SEARCH_CONCURRENCY, request.items.length) }, worker)); + return { tracks: tracks.filter((track): track is Track => track !== null), skipped }; +}; + +/** 导入 Bridge 提供的推荐曲目 */ +export const importRecommendations = async ( + request: RecommendationImportRequest, + actions: { + append: (tracks: readonly Track[]) => number; + replace: (tracks: readonly Track[]) => Promise; + }, +): Promise => { + const { tracks, skipped } = await resolveTracks(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, + playlistId, + }; +}; diff --git a/src/stores/playlist.ts b/src/stores/playlist.ts index 9ab8ad34..0a06fbd7 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 }; } @@ -69,7 +72,7 @@ export const usePlaylistStore = defineStore("playlist", () => { 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 }; }; @@ -101,6 +104,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 +150,51 @@ 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 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 +214,16 @@ 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, + }; }); 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; From edba0391739b7d489f7fe884a0d67683ec54eb82 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 10:29:48 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=E5=A4=96=E9=83=A8=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E4=BF=A1=E6=81=AF=E5=A2=9E=E5=8A=A0=E6=94=B6=E8=97=8F?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipc/player.ts | 7 ++++++- electron/preload/index.ts | 4 +++- shared/types/player.ts | 4 ++++ src/composables/useFavorite.ts | 1 + src/core/player/index.ts | 5 ++++- src/stores/media.ts | 12 +++++++++++- 6 files changed, 29 insertions(+), 4 deletions(-) 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/preload/index.ts b/electron/preload/index.ts index c488db6a..e2998e31 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -9,7 +9,7 @@ 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"; @@ -104,6 +104,8 @@ 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), // 订阅主进程推送的播放事件 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/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 532cc86a..86b8ea43 100644 --- a/src/core/player/index.ts +++ b/src/core/player/index.ts @@ -985,7 +985,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/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, }; From ba5f36910d2ace45c44834b6d49cf6d6b42b7e7e Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 10:45:09 +0800 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=E6=94=B6=E8=97=8F=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=8F=98=E5=8C=96=E6=9B=B4=E6=96=B0=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/services/nowPlaying.ts | 2 ++ 1 file changed, 2 insertions(+) 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) { From c5f8461701b68fa9fd6c74a3a28b8b36ff6bc5ff Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 10:30:02 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20=E8=87=AA=E5=8A=A8=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8E=9F=E7=94=9F=E6=9E=84=E5=BB=BA=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust-toolchain.toml | 3 ++ scripts/build-native.ts | 88 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 rust-toolchain.toml 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; From 1331e2cc39759ee9c03b6e08a57074cd0ed37987 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 19:56:26 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=94=AF=E6=8C=81=E6=AD=8C=E5=8D=95=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipc/externalPlaylists.ts | 59 +++++++++++++ electron/main/ipc/index.ts | 2 + electron/main/server/routes.ts | 118 ++++++++++++++++++++++++- electron/preload/index.d.ts | 2 + electron/preload/index.ts | 18 +++- shared/types/externalPlaylist.ts | 46 ++++++++++ src/core/player/index.ts | 13 ++- src/services/recommendations.ts | 88 +++++++++++++++++- src/stores/playlist.ts | 21 +++++ 9 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 electron/main/ipc/externalPlaylists.ts create mode 100644 shared/types/externalPlaylist.ts diff --git a/electron/main/ipc/externalPlaylists.ts b/electron/main/ipc/externalPlaylists.ts new file mode 100644 index 00000000..00bf0a3b --- /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 = 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 5a6aa37c..91496747 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -19,6 +19,7 @@ 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 => { @@ -33,6 +34,7 @@ export const registerIpcHandlers = (): void => { registerCloudIpc(); registerCommentsIpc(); registerRecommendationsIpc(); + registerExternalPlaylistsIpc(); registerLyricsIpc(); registerHotkeyIpc(); registerThemeIpc(); diff --git a/electron/main/server/routes.ts b/electron/main/server/routes.ts index 38b5fc05..878b57b7 100644 --- a/electron/main/server/routes.ts +++ b/electron/main/server/routes.ts @@ -11,15 +11,17 @@ 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 = 50; +const MAX_RECOMMENDATION_ITEMS = 500; const isImportRequest = (value: unknown): 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 (request.mode !== "playlist" && request.mode !== "append" && request.mode !== "replace") + return false; if ( !Array.isArray(request.items) || request.items.length === 0 || @@ -45,6 +47,15 @@ const isImportRequest = (value: unknown): value is RecommendationImportRequest = }); }; +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; + export const buildRoutes = (): Hono => { const api = new Hono(); @@ -132,5 +143,108 @@ export const buildRoutes = (): Hono => { } }); + 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); + if (body?.description !== undefined && description === undefined) { + return c.json({ error: "description (non-empty string, <=2000) required" }, 400); + } + try { + return c.json( + await requestExternalPlaylist({ operation: "create", title, description }), + 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); + if ( + (title === undefined && description === undefined) || + (body?.title !== undefined && title === undefined) || + (body?.description !== undefined && description === undefined) + ) { + return c.json({ error: "title or description required" }, 400); + } + try { + const result = await requestExternalPlaylist({ + operation: "update", + playlistId, + title, + description, + }); + 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); + } + }); + return api; }; diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 89c57198..43e45d88 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -22,6 +22,7 @@ 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 { @@ -58,6 +59,7 @@ declare global { 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 e2998e31..932bd2e2 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -16,7 +16,11 @@ 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 { + RecommendationImportResult, + RecommendationImportTask, +} from "@shared/types/recommendation"; +import type { ExternalPlaylistResult, ExternalPlaylistTask } from "@shared/types/externalPlaylist"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -105,7 +109,8 @@ const api = { // 同步当前歌曲喜欢状态到主进程(供托盘菜单显示) syncLikeState: (liked: boolean) => ipcRenderer.send("player:syncLikeState", liked), // 通知外部控制端当前歌曲收藏状态变更 - notifyLike: (track: Track, liked: boolean) => ipcRenderer.send("player:notifyLike", track, liked), + notifyLike: (track: Track, liked: boolean) => + ipcRenderer.send("player:notifyLike", track, liked), // 广播播放控制事件到所有渲染进程 dispatch: (type: string) => ipcRenderer.send("player:dispatch", type), // 订阅主进程推送的播放事件 @@ -340,6 +345,15 @@ const api = { 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/shared/types/externalPlaylist.ts b/shared/types/externalPlaylist.ts new file mode 100644 index 00000000..580e1491 --- /dev/null +++ b/shared/types/externalPlaylist.ts @@ -0,0 +1,46 @@ +import type { RecommendationImportSkipped, RecommendationInput } 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 } + | { operation: "update"; playlistId: string; title?: string; description?: string | null } + | { operation: "remove"; playlistId: string } + | { + operation: "replaceTracks"; + playlistId: string; + items: RecommendationInput[]; + }; + +/** 外部歌单操作任务 */ +export type ExternalPlaylistTask = ExternalPlaylistOperation & { requestId: string }; + +/** 外部歌单操作结果 */ +export interface ExternalPlaylistResult { + found?: boolean; + playlist?: ExternalPlaylist; + playlists?: ExternalPlaylist[]; + requested?: number; + imported?: number; + skipped?: RecommendationImportSkipped[]; +} + +/** 预加载层暴露给渲染进程的外部歌单操作 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/src/core/player/index.ts b/src/core/player/index.ts index 86b8ea43..b337ae9e 100644 --- a/src/core/player/index.ts +++ b/src/core/player/index.ts @@ -10,7 +10,7 @@ import { usePluginsStore } from "@/stores/plugins"; import { useHistoryStore } from "@/stores/history"; import { useLibraryStore } from "@/stores/library"; import * as queue from "@/stores/queue"; -import { importRecommendations } from "@/services/recommendations"; +import { handleExternalPlaylistTask, importRecommendations } from "@/services/recommendations"; import * as fm from "./fm"; import * as playback from "@/services/playback"; import * as lyricLoader from "@/services/lyricLoader"; @@ -977,6 +977,17 @@ export const initPlayer = async (): Promise => { ), ); }); + 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(); // 订阅主进程下发的歌词偏移变化 diff --git a/src/services/recommendations.ts b/src/services/recommendations.ts index 0845a3d8..002627d8 100644 --- a/src/services/recommendations.ts +++ b/src/services/recommendations.ts @@ -3,6 +3,11 @@ import type { RecommendationImportResult, RecommendationImportSkipped, } 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 { usePlaylistStore } from "@/stores/playlist"; @@ -13,7 +18,7 @@ const getSearchKeyword = (title: string, artists: string[]): string => [title, ...artists].filter(Boolean).join(" "); /** 将外部推荐按给定顺序解析为网易云曲目 */ -const resolveTracks = async ( +export const resolveRecommendationTracks = async ( request: RecommendationImportRequest, ): Promise<{ tracks: Track[]; skipped: RecommendationImportSkipped[] }> => { const tracks = new Array(request.items.length).fill(null); @@ -25,7 +30,12 @@ const resolveTracks = async ( const index = nextIndex++; const item = request.items[index]; try { - const result = await searchSongs("netease", getSearchKeyword(item.title, item.artists), 0, 1); + const result = await searchSongs( + "netease", + getSearchKeyword(item.title, item.artists), + 0, + 1, + ); const track = result.items[0]; if (track) tracks[index] = track; else skipped.push({ sourceId: item.sourceId, reason: "notFound" }); @@ -35,7 +45,9 @@ const resolveTracks = async ( } }; - await Promise.all(Array.from({ length: Math.min(SEARCH_CONCURRENCY, request.items.length) }, worker)); + await Promise.all( + Array.from({ length: Math.min(SEARCH_CONCURRENCY, request.items.length) }, worker), + ); return { tracks: tracks.filter((track): track is Track => track !== null), skipped }; }; @@ -47,7 +59,7 @@ export const importRecommendations = async ( replace: (tracks: readonly Track[]) => Promise; }, ): Promise => { - const { tracks, skipped } = await resolveTracks(request); + const { tracks, skipped } = await resolveRecommendationTracks(request); if (request.mode === "append") actions.append(tracks); if (request.mode === "replace" && tracks.length > 0) await actions.replace(tracks); @@ -65,3 +77,71 @@ export const importRecommendations = async ( 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); + 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 }), + }); + 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 } = await resolveRecommendationTracks({ + provider: "youtube-music", + mode: "playlist", + items: task.items, + }); + const playlist = await store.replaceTracks(task.playlistId, tracks); + return { + found: playlist !== null, + playlist: playlist ? toExternalPlaylist(playlist) : undefined, + requested: task.items.length, + imported: tracks.length, + skipped, + }; +}; diff --git a/src/stores/playlist.ts b/src/stores/playlist.ts index 0a06fbd7..72a24aa6 100644 --- a/src/stores/playlist.ts +++ b/src/stores/playlist.ts @@ -171,6 +171,26 @@ export const usePlaylistStore = defineStore("playlist", () => { 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; + 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); @@ -225,5 +245,6 @@ export const usePlaylistStore = defineStore("playlist", () => { addTracks, removeTracks, saveSnapshot, + replaceTracks, }; }); From 3a4f986df0a077955c7dd3d2b694ad99db551ecb Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 21:35:35 +0800 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=E5=A4=96=E9=83=A8=E6=AD=8C?= =?UTF-8?q?=E5=8D=95=E6=94=AF=E6=8C=81=E5=B0=81=E9=9D=A2=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/server/routes.ts | 27 +++++++++++++++++++++++---- shared/types/externalPlaylist.ts | 10 ++++++++-- src/services/recommendations.ts | 3 ++- src/stores/playlist.ts | 11 ++++++++--- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/electron/main/server/routes.ts b/electron/main/server/routes.ts index 878b57b7..06223b32 100644 --- a/electron/main/server/routes.ts +++ b/electron/main/server/routes.ts @@ -56,6 +56,17 @@ const getText = (value: unknown, maxLength: number): string | 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(); @@ -168,12 +179,16 @@ export const buildRoutes = (): Hono => { 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 }), + await requestExternalPlaylist({ operation: "create", title, description, cover }), 201, ); } catch (err) { @@ -192,12 +207,15 @@ export const buildRoutes = (): Hono => { : 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) || + (title === undefined && description === undefined && cover === undefined) || (body?.title !== undefined && title === undefined) || - (body?.description !== undefined && description === undefined) + (body?.description !== undefined && description === undefined) || + (body?.cover !== undefined && cover === undefined) ) { - return c.json({ error: "title or description required" }, 400); + return c.json({ error: "title, description, or cover required" }, 400); } try { const result = await requestExternalPlaylist({ @@ -205,6 +223,7 @@ export const buildRoutes = (): Hono => { playlistId, title, description, + cover, }); return result.found === false ? c.json({ error: "playlist not found" }, 404) : c.json(result); } catch (err) { diff --git a/shared/types/externalPlaylist.ts b/shared/types/externalPlaylist.ts index 580e1491..0bddbe91 100644 --- a/shared/types/externalPlaylist.ts +++ b/shared/types/externalPlaylist.ts @@ -15,8 +15,14 @@ export interface ExternalPlaylist { export type ExternalPlaylistOperation = | { operation: "list" } | { operation: "get"; playlistId: string } - | { operation: "create"; title: string; description?: string } - | { operation: "update"; playlistId: string; title?: string; description?: string | null } + | { 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"; diff --git a/src/services/recommendations.ts b/src/services/recommendations.ts index 002627d8..5c9594c0 100644 --- a/src/services/recommendations.ts +++ b/src/services/recommendations.ts @@ -113,7 +113,7 @@ export const handleExternalPlaylistTask = async ( }; } if (task.operation === "create") { - const playlist = await store.create(task.title, task.description); + const playlist = await store.create(task.title, task.description, task.cover); return { playlist: toExternalPlaylist(playlist) }; } if (!existing) return { found: false }; @@ -123,6 +123,7 @@ export const handleExternalPlaylistTask = async ( ...(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 }; diff --git a/src/stores/playlist.ts b/src/stores/playlist.ts index 72a24aa6..2a6e2ea7 100644 --- a/src/stores/playlist.ts +++ b/src/stores/playlist.ts @@ -58,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(), @@ -66,6 +70,7 @@ export const usePlaylistStore = defineStore("playlist", () => { source: "local", title, description, + cover, trackIds: [], trackCount: 0, createTime: now, @@ -80,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; @@ -178,7 +183,7 @@ export const usePlaylistStore = defineStore("playlist", () => { record.trackIds = []; record.tracks = tracks; record.trackCount = tracks.length; - record.cover = tracks.find((track) => track.cover)?.cover; + 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); From 172c6f9dabe88e9cf17a5098260bb09c47a5114c Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 21:57:35 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=E9=99=90=E9=80=9F=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=A4=96=E9=83=A8=E6=8E=A8=E8=8D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipc/externalPlaylists.ts | 2 +- shared/types/recommendation.ts | 6 +++ src/services/recommendations.ts | 75 +++++++++++++++++--------- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/electron/main/ipc/externalPlaylists.ts b/electron/main/ipc/externalPlaylists.ts index 00bf0a3b..6a539005 100644 --- a/electron/main/ipc/externalPlaylists.ts +++ b/electron/main/ipc/externalPlaylists.ts @@ -5,7 +5,7 @@ import type { } from "@shared/types/externalPlaylist"; import { sendToMain } from "@main/utils/broadcast"; -const REQUEST_TIMEOUT = 60_000; +const REQUEST_TIMEOUT = 10 * 60_000; interface PendingRequest { resolve: (result: ExternalPlaylistResult) => void; diff --git a/shared/types/recommendation.ts b/shared/types/recommendation.ts index 6f2e3b4d..a79134b3 100644 --- a/shared/types/recommendation.ts +++ b/shared/types/recommendation.ts @@ -27,6 +27,12 @@ export interface RecommendationImportRequest { export interface RecommendationImportSkipped { sourceId: string; reason: "invalid" | "notFound"; + title?: string; + artists?: string[]; + album?: string; + durationMs?: number; + keyword?: string; + error?: string; } /** 推荐导入结果 */ diff --git a/src/services/recommendations.ts b/src/services/recommendations.ts index 5c9594c0..8a549df0 100644 --- a/src/services/recommendations.ts +++ b/src/services/recommendations.ts @@ -12,7 +12,7 @@ import type { Track } from "@shared/types/player"; import { searchSongs } from "@/apis/search"; import { usePlaylistStore } from "@/stores/playlist"; -const SEARCH_CONCURRENCY = 3; +const SEARCH_DELAY_MS = 500; const getSearchKeyword = (title: string, artists: string[]): string => [title, ...artists].filter(Boolean).join(" "); @@ -21,34 +21,57 @@ const getSearchKeyword = (title: string, artists: string[]): string => export const resolveRecommendationTracks = async ( request: RecommendationImportRequest, ): Promise<{ tracks: Track[]; skipped: RecommendationImportSkipped[] }> => { - const tracks = new Array(request.items.length).fill(null); + const tracks: Track[] = []; const skipped: RecommendationImportSkipped[] = []; - let nextIndex = 0; - - const worker = async (): Promise => { - while (nextIndex < request.items.length) { - const index = nextIndex++; - const item = request.items[index]; - try { - const result = await searchSongs( - "netease", - getSearchKeyword(item.title, item.artists), - 0, - 1, - ); - const track = result.items[0]; - if (track) tracks[index] = track; - else skipped.push({ sourceId: item.sourceId, reason: "notFound" }); - } catch { - skipped.push({ sourceId: item.sourceId, reason: "notFound" }); + for (const [index, item] of request.items.entries()) { + const keyword = getSearchKeyword(item.title, item.artists); + try { + const result = await searchSongs("netease", keyword, 0, 1); + const track = result.items[0]; + if (track) tracks.push(track); + else { + console.warn("[recommendations] 网易云未找到曲目", { + sourceId: item.sourceId, + title: item.title, + artists: item.artists, + keyword, + }); + skipped.push({ + sourceId: item.sourceId, + reason: "notFound", + title: item.title, + artists: item.artists, + album: item.album, + durationMs: item.durationMs, + keyword, + }); } + } catch (error) { + 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 : String(error), + }); + 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 : String(error), + }); } - }; - - await Promise.all( - Array.from({ length: Math.min(SEARCH_CONCURRENCY, request.items.length) }, worker), - ); - return { tracks: tracks.filter((track): track is Track => track !== null), skipped }; + if (index < request.items.length - 1) { + await new Promise((resolve) => setTimeout(resolve, SEARCH_DELAY_MS)); + } + } + return { tracks, skipped }; }; /** 导入 Bridge 提供的推荐曲目 */ From 72713d8b3b9295cb8a6837e774acafb70d6c0a42 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 22:02:57 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=E9=99=90=E6=B5=81=E6=97=B6?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=8E=A8=E8=8D=90=E9=9F=B3=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/recommendations.ts | 77 +++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/src/services/recommendations.ts b/src/services/recommendations.ts index 8a549df0..88b52a13 100644 --- a/src/services/recommendations.ts +++ b/src/services/recommendations.ts @@ -9,14 +9,21 @@ import type { ExternalPlaylistTask, } from "@shared/types/externalPlaylist"; import type { Track } from "@shared/types/player"; +import type { Platform } from "@shared/types/platform"; import { searchSongs } from "@/apis/search"; 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, @@ -25,36 +32,69 @@ export const resolveRecommendationTracks = async ( const skipped: RecommendationImportSkipped[] = []; for (const [index, item] of request.items.entries()) { const keyword = getSearchKeyword(item.title, item.artists); + let track: Track | undefined; + let neteaseError: unknown; try { const result = await searchSongs("netease", keyword, 0, 1); - const track = result.items[0]; - if (track) tracks.push(track); - else { - console.warn("[recommendations] 网易云未找到曲目", { - sourceId: item.sourceId, - title: item.title, - artists: item.artists, - keyword, - }); - skipped.push({ + track = result.items[0]; + } catch (error) { + neteaseError = error; + if (isNeteaseRateLimited(error)) { + console.warn("[recommendations] 网易云搜索限流,3 秒后重试", { sourceId: item.sourceId, - reason: "notFound", title: item.title, artists: item.artists, - album: item.album, - durationMs: item.durationMs, keyword, }); + await wait(NETEASE_RETRY_DELAY_MS); + try { + const result = await searchSongs("netease", keyword, 0, 1); + track = result.items[0]; + neteaseError = undefined; + } catch (retryError) { + neteaseError = retryError; + } } - } catch (error) { - console.error("[recommendations] 网易云解析失败", { + } + if (!track) { + for (const platform of ["qqmusic", "kugou"] as const satisfies readonly Platform[]) { + try { + const result = await searchSongs(platform, keyword, 0, 1); + track = result.items[0]; + if (track) { + console.info("[recommendations] 已使用备用来源解析曲目", { + sourceId: item.sourceId, + title: item.title, + artists: item.artists, + keyword, + platform, + }); + break; + } + } catch (error) { + console.warn("[recommendations] 备用来源搜索失败", { + sourceId: item.sourceId, + title: item.title, + artists: item.artists, + keyword, + platform, + error: error instanceof Error ? error.stack || error.message : String(error), + }); + } + } + } + if (track) { + tracks.push(track); + } else { + 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 : String(error), + error: + neteaseError instanceof Error ? neteaseError.stack || neteaseError.message : undefined, }); skipped.push({ sourceId: item.sourceId, @@ -64,11 +104,12 @@ export const resolveRecommendationTracks = async ( album: item.album, durationMs: item.durationMs, keyword, - error: error instanceof Error ? error.stack || error.message : String(error), + error: + neteaseError instanceof Error ? neteaseError.stack || neteaseError.message : undefined, }); } if (index < request.items.length - 1) { - await new Promise((resolve) => setTimeout(resolve, SEARCH_DELAY_MS)); + await wait(SEARCH_DELAY_MS); } } return { tracks, skipped }; From 8ff0dc5922e06bf9279f6aad78c59d61dafaa2d4 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 22:26:13 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20=E9=99=90=E6=B5=81=E6=97=B6?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E5=88=87=E6=8D=A2=E6=90=9C=E7=B4=A2=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/recommendations.ts | 86 ++++++++++++--------------------- 1 file changed, 32 insertions(+), 54 deletions(-) diff --git a/src/services/recommendations.ts b/src/services/recommendations.ts index 88b52a13..fae10769 100644 --- a/src/services/recommendations.ts +++ b/src/services/recommendations.ts @@ -9,20 +9,23 @@ import type { ExternalPlaylistTask, } from "@shared/types/externalPlaylist"; import type { Track } from "@shared/types/player"; -import type { Platform } from "@shared/types/platform"; +import { ALL_PLATFORMS, type Platform } from "@shared/types/platform"; import { searchSongs } from "@/apis/search"; 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)); +const isRateLimited = (error: unknown): boolean => + /\b(405|429)\b|操作频繁|rate.?limit/i.test( + error instanceof Error ? error.message : String(error), + ); + +const pickRandom = (items: readonly T[]): T => items[Math.floor(Math.random() * items.length)]; /** 将外部推荐按给定顺序解析为网易云曲目 */ export const resolveRecommendationTracks = async ( @@ -30,71 +33,45 @@ export const resolveRecommendationTracks = async ( ): Promise<{ tracks: Track[]; skipped: RecommendationImportSkipped[] }> => { const tracks: Track[] = []; const skipped: RecommendationImportSkipped[] = []; + const availablePlatforms = new Set(ALL_PLATFORMS); + let platform = pickRandom(ALL_PLATFORMS); for (const [index, item] of request.items.entries()) { const keyword = getSearchKeyword(item.title, item.artists); let track: Track | undefined; - let neteaseError: unknown; - try { - const result = await searchSongs("netease", keyword, 0, 1); - track = result.items[0]; - } catch (error) { - neteaseError = error; - if (isNeteaseRateLimited(error)) { - console.warn("[recommendations] 网易云搜索限流,3 秒后重试", { + let lastError: unknown; + while (!track) { + try { + const result = await searchSongs(platform, keyword, 0, 1); + track = result.items[0]; + } catch (error) { + lastError = error; + if (!isRateLimited(error)) break; + availablePlatforms.delete(platform); + const fallback = [...availablePlatforms]; + if (fallback.length === 0) break; + const previous = platform; + platform = pickRandom(fallback); + console.warn("[recommendations] 搜索来源限流,已切换后续请求来源", { sourceId: item.sourceId, title: item.title, artists: item.artists, keyword, + previous, + next: platform, }); - await wait(NETEASE_RETRY_DELAY_MS); - try { - const result = await searchSongs("netease", keyword, 0, 1); - track = result.items[0]; - neteaseError = undefined; - } catch (retryError) { - neteaseError = retryError; - } + continue; } + break; } if (!track) { - for (const platform of ["qqmusic", "kugou"] as const satisfies readonly Platform[]) { - try { - const result = await searchSongs(platform, keyword, 0, 1); - track = result.items[0]; - if (track) { - console.info("[recommendations] 已使用备用来源解析曲目", { - sourceId: item.sourceId, - title: item.title, - artists: item.artists, - keyword, - platform, - }); - break; - } - } catch (error) { - console.warn("[recommendations] 备用来源搜索失败", { - sourceId: item.sourceId, - title: item.title, - artists: item.artists, - keyword, - platform, - error: error instanceof Error ? error.stack || error.message : String(error), - }); - } - } - } - if (track) { - tracks.push(track); - } else { - console.error("[recommendations] 所有来源均未能解析曲目", { + console.error("[recommendations] 当前搜索来源未能解析曲目", { sourceId: item.sourceId, title: item.title, artists: item.artists, album: item.album, durationMs: item.durationMs, keyword, - error: - neteaseError instanceof Error ? neteaseError.stack || neteaseError.message : undefined, + error: lastError instanceof Error ? lastError.stack || lastError.message : undefined, }); skipped.push({ sourceId: item.sourceId, @@ -104,9 +81,10 @@ export const resolveRecommendationTracks = async ( album: item.album, durationMs: item.durationMs, keyword, - error: - neteaseError instanceof Error ? neteaseError.stack || neteaseError.message : undefined, + error: lastError instanceof Error ? lastError.stack || lastError.message : undefined, }); + } else { + tracks.push(track); } if (index < request.items.length - 1) { await wait(SEARCH_DELAY_MS); From 74fae7d2dd8c6810fbcdd5fe18fd4ba61ee773e3 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 22:31:48 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix:=20=E5=8D=95=E6=9B=B2=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=94=AF=E6=8C=81=E5=A4=87=E7=94=A8=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/recommendations.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/services/recommendations.ts b/src/services/recommendations.ts index fae10769..c0b15b0b 100644 --- a/src/services/recommendations.ts +++ b/src/services/recommendations.ts @@ -27,6 +27,8 @@ const isRateLimited = (error: unknown): boolean => const pickRandom = (items: readonly T[]): T => items[Math.floor(Math.random() * items.length)]; +const shuffled = (items: readonly T[]): T[] => [...items].sort(() => Math.random() - 0.5); + /** 将外部推荐按给定顺序解析为网易云曲目 */ export const resolveRecommendationTracks = async ( request: RecommendationImportRequest, @@ -63,6 +65,37 @@ export const resolveRecommendationTracks = async ( } break; } + if (!track) { + for (const fallback of shuffled( + [...availablePlatforms].filter((item) => item !== platform), + )) { + try { + const result = await searchSongs(fallback, keyword, 0, 1); + track = result.items[0]; + if (track) { + console.info("[recommendations] 已使用单曲备用来源解析", { + sourceId: item.sourceId, + title: item.title, + artists: item.artists, + keyword, + platform: fallback, + }); + break; + } + } catch (error) { + lastError = error; + if (isRateLimited(error)) availablePlatforms.delete(fallback); + console.warn("[recommendations] 单曲备用来源搜索失败", { + sourceId: item.sourceId, + title: item.title, + artists: item.artists, + keyword, + platform: fallback, + error: error instanceof Error ? error.stack || error.message : String(error), + }); + } + } + } if (!track) { console.error("[recommendations] 当前搜索来源未能解析曲目", { sourceId: item.sourceId, From 16628c4cad0e3e72255f530a559404af81955561 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 23:15:09 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20=E6=8E=A8=E8=8D=90=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E4=BB=85=E4=BD=BF=E7=94=A8=E7=BD=91=E6=98=93=E4=BA=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/recommendations.ts | 79 ++++++++------------------------- 1 file changed, 19 insertions(+), 60 deletions(-) diff --git a/src/services/recommendations.ts b/src/services/recommendations.ts index c0b15b0b..62c96eda 100644 --- a/src/services/recommendations.ts +++ b/src/services/recommendations.ts @@ -9,25 +9,19 @@ import type { ExternalPlaylistTask, } from "@shared/types/externalPlaylist"; import type { Track } from "@shared/types/player"; -import { ALL_PLATFORMS, type Platform } from "@shared/types/platform"; import { searchSongs } from "@/apis/search"; 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 isRateLimited = (error: unknown): boolean => - /\b(405|429)\b|操作频繁|rate.?limit/i.test( - error instanceof Error ? error.message : String(error), - ); - -const pickRandom = (items: readonly T[]): T => items[Math.floor(Math.random() * items.length)]; - -const shuffled = (items: readonly T[]): T[] => [...items].sort(() => Math.random() - 0.5); +const isNeteaseRateLimited = (error: unknown): boolean => + /netease 405\b|\b405:/.test(error instanceof Error ? error.message : String(error)); /** 将外部推荐按给定顺序解析为网易云曲目 */ export const resolveRecommendationTracks = async ( @@ -35,76 +29,41 @@ export const resolveRecommendationTracks = async ( ): Promise<{ tracks: Track[]; skipped: RecommendationImportSkipped[] }> => { const tracks: Track[] = []; const skipped: RecommendationImportSkipped[] = []; - const availablePlatforms = new Set(ALL_PLATFORMS); - let platform = pickRandom(ALL_PLATFORMS); for (const [index, item] of request.items.entries()) { const keyword = getSearchKeyword(item.title, item.artists); let track: Track | undefined; - let lastError: unknown; - while (!track) { - try { - const result = await searchSongs(platform, keyword, 0, 1); - track = result.items[0]; - } catch (error) { - lastError = error; - if (!isRateLimited(error)) break; - availablePlatforms.delete(platform); - const fallback = [...availablePlatforms]; - if (fallback.length === 0) break; - const previous = platform; - platform = pickRandom(fallback); - console.warn("[recommendations] 搜索来源限流,已切换后续请求来源", { + let error: unknown; + try { + const result = await searchSongs("netease", keyword, 0, 1); + track = result.items[0]; + } catch (caught) { + error = caught; + if (isNeteaseRateLimited(caught)) { + console.warn("[recommendations] 网易云搜索限流,3 秒后重试", { sourceId: item.sourceId, title: item.title, artists: item.artists, keyword, - previous, - next: platform, }); - continue; - } - break; - } - if (!track) { - for (const fallback of shuffled( - [...availablePlatforms].filter((item) => item !== platform), - )) { + await wait(NETEASE_RETRY_DELAY_MS); try { - const result = await searchSongs(fallback, keyword, 0, 1); + const result = await searchSongs("netease", keyword, 0, 1); track = result.items[0]; - if (track) { - console.info("[recommendations] 已使用单曲备用来源解析", { - sourceId: item.sourceId, - title: item.title, - artists: item.artists, - keyword, - platform: fallback, - }); - break; - } - } catch (error) { - lastError = error; - if (isRateLimited(error)) availablePlatforms.delete(fallback); - console.warn("[recommendations] 单曲备用来源搜索失败", { - sourceId: item.sourceId, - title: item.title, - artists: item.artists, - keyword, - platform: fallback, - error: error instanceof Error ? error.stack || error.message : String(error), - }); + error = undefined; + } catch (retryError) { + error = retryError; } } } if (!track) { - console.error("[recommendations] 当前搜索来源未能解析曲目", { + console.error("[recommendations] 网易云未能解析曲目", { sourceId: item.sourceId, title: item.title, artists: item.artists, album: item.album, durationMs: item.durationMs, keyword, - error: lastError instanceof Error ? lastError.stack || lastError.message : undefined, + error: error instanceof Error ? error.stack || error.message : undefined, }); skipped.push({ sourceId: item.sourceId, @@ -114,7 +73,7 @@ export const resolveRecommendationTracks = async ( album: item.album, durationMs: item.durationMs, keyword, - error: lastError instanceof Error ? lastError.stack || lastError.message : undefined, + error: error instanceof Error ? error.stack || error.message : undefined, }); } else { tracks.push(track); From 4619a306b0e4399b077d985f9b090907070c615a Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Mon, 20 Jul 2026 23:51:36 +0800 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=A1=A5?= =?UTF-8?q?=E6=8E=A5=E5=A2=9E=E9=87=8F=E6=8E=A8=E8=8D=90=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/socket.md | 40 ++++++++++++++++++++------------ electron/main/server/routes.ts | 40 +++++++++++++++++++++++++++++--- electron/main/server/ws.ts | 38 ++++++++++++++++++++++++++++++ shared/types/externalPlaylist.ts | 13 ++++++++++- shared/types/recommendation.ts | 9 +++++++ src/services/recommendations.ts | 30 +++++++++++++++++------- src/stores/playlist.ts | 29 +++++++++++++++++++++++ 7 files changed, 171 insertions(+), 28 deletions(-) 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/server/routes.ts b/electron/main/server/routes.ts index 06223b32..a8073157 100644 --- a/electron/main/server/routes.ts +++ b/electron/main/server/routes.ts @@ -16,7 +16,10 @@ import type { RecommendationImportRequest } from "@shared/types/recommendation"; const MAX_RECOMMENDATION_ITEMS = 500; -const isImportRequest = (value: unknown): value is RecommendationImportRequest => { +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; @@ -24,7 +27,7 @@ const isImportRequest = (value: unknown): value is RecommendationImportRequest = return false; if ( !Array.isArray(request.items) || - request.items.length === 0 || + (!allowEmptyItems && request.items.length === 0) || request.items.length > MAX_RECOMMENDATION_ITEMS ) { return false; @@ -42,7 +45,11 @@ const isImportRequest = (value: unknown): value is RecommendationImportRequest = (item.durationMs === undefined || (typeof item.durationMs === "number" && Number.isFinite(item.durationMs) && - item.durationMs >= 0)) + item.durationMs >= 0)) && + (item.neteaseId === undefined || + (typeof item.neteaseId === "string" && + item.neteaseId.length > 0 && + item.neteaseId.length <= 30)) ); }); }; @@ -265,5 +272,32 @@ export const buildRoutes = (): Hono => { } }); + 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/shared/types/externalPlaylist.ts b/shared/types/externalPlaylist.ts index 0bddbe91..e662246f 100644 --- a/shared/types/externalPlaylist.ts +++ b/shared/types/externalPlaylist.ts @@ -1,4 +1,8 @@ -import type { RecommendationImportSkipped, RecommendationInput } from "./recommendation"; +import type { + RecommendationImportSkipped, + RecommendationInput, + RecommendationResolved, +} from "./recommendation"; /** 外部 API 可见的歌单元数据 */ export interface ExternalPlaylist { @@ -28,6 +32,12 @@ export type ExternalPlaylistOperation = operation: "replaceTracks"; playlistId: string; items: RecommendationInput[]; + } + | { + operation: "patchTracks"; + playlistId: string; + items: RecommendationInput[]; + removeTrackIds: string[]; }; /** 外部歌单操作任务 */ @@ -41,6 +51,7 @@ export interface ExternalPlaylistResult { requested?: number; imported?: number; skipped?: RecommendationImportSkipped[]; + resolved?: RecommendationResolved[]; } /** 预加载层暴露给渲染进程的外部歌单操作 API */ diff --git a/shared/types/recommendation.ts b/shared/types/recommendation.ts index a79134b3..bdda3341 100644 --- a/shared/types/recommendation.ts +++ b/shared/types/recommendation.ts @@ -12,6 +12,14 @@ export interface RecommendationInput { artists: string[]; album?: string; durationMs?: number; + /** Bridge 已缓存的网易云 songId,存在时跳过关键词搜索 */ + neteaseId?: string; +} + +/** 外部曲目与网易云 songId 的解析结果 */ +export interface RecommendationResolved { + sourceId: string; + neteaseId: string; } /** Bridge 提交给 SPlayer 的推荐导入请求 */ @@ -40,6 +48,7 @@ export interface RecommendationImportResult { requested: number; imported: number; skipped: RecommendationImportSkipped[]; + resolved: RecommendationResolved[]; playlistId?: string; } diff --git a/src/services/recommendations.ts b/src/services/recommendations.ts index 62c96eda..744dd0bc 100644 --- a/src/services/recommendations.ts +++ b/src/services/recommendations.ts @@ -2,6 +2,7 @@ import type { RecommendationImportRequest, RecommendationImportResult, RecommendationImportSkipped, + RecommendationResolved, } from "@shared/types/recommendation"; import type { ExternalPlaylist, @@ -10,6 +11,7 @@ import type { } 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; @@ -26,16 +28,21 @@ const isNeteaseRateLimited = (error: unknown): boolean => /** 将外部推荐按给定顺序解析为网易云曲目 */ export const resolveRecommendationTracks = async ( request: RecommendationImportRequest, -): Promise<{ tracks: Track[]; skipped: RecommendationImportSkipped[] }> => { +): 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 { - const result = await searchSongs("netease", keyword, 0, 1); - track = result.items[0]; + 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)) { @@ -47,8 +54,7 @@ export const resolveRecommendationTracks = async ( }); await wait(NETEASE_RETRY_DELAY_MS); try { - const result = await searchSongs("netease", keyword, 0, 1); - track = result.items[0]; + track = (await searchSongs("netease", keyword, 0, 1)).items[0]; error = undefined; } catch (retryError) { error = retryError; @@ -77,12 +83,13 @@ export const resolveRecommendationTracks = async ( }); } 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 }; + return { tracks, skipped, resolved }; }; /** 导入 Bridge 提供的推荐曲目 */ @@ -93,7 +100,7 @@ export const importRecommendations = async ( replace: (tracks: readonly Track[]) => Promise; }, ): Promise => { - const { tracks, skipped } = await resolveRecommendationTracks(request); + 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); @@ -108,6 +115,7 @@ export const importRecommendations = async ( requested: request.items.length, imported: tracks.length, skipped, + resolved, playlistId, }; }; @@ -166,17 +174,21 @@ export const handleExternalPlaylistTask = async ( await store.remove(task.playlistId); return { found: true }; } - const { tracks, skipped } = await resolveRecommendationTracks({ + const { tracks, skipped, resolved } = await resolveRecommendationTracks({ provider: "youtube-music", mode: "playlist", items: task.items, }); - const playlist = await store.replaceTracks(task.playlistId, tracks); + 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/playlist.ts b/src/stores/playlist.ts index 2a6e2ea7..c65f1ec2 100644 --- a/src/stores/playlist.ts +++ b/src/stores/playlist.ts @@ -196,6 +196,34 @@ export const usePlaylistStore = defineStore("playlist", () => { 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); @@ -251,5 +279,6 @@ export const usePlaylistStore = defineStore("playlist", () => { removeTracks, saveSnapshot, replaceTracks, + patchTracks, }; });