diff --git a/components.d.ts b/components.d.ts index 3f11b156..eb040d8c 100644 --- a/components.d.ts +++ b/components.d.ts @@ -17,7 +17,6 @@ declare module 'vue' { AutoCloseDialog: typeof import('./src/components/modals/AutoCloseDialog.vue')['default'] BackgroundImagePicker: typeof import('./src/components/settings/custom/BackgroundImagePicker.vue')['default'] BottomSpectrum: typeof import('./src/components/player/FullPlayer/BottomSpectrum.vue')['default'] - CacheManager: typeof import('./src/components/settings/custom/CacheManager.vue')['default'] ComboboxAnchor: typeof import('reka-ui')['ComboboxAnchor'] ComboboxContent: typeof import('reka-ui')['ComboboxContent'] ComboboxEmpty: typeof import('reka-ui')['ComboboxEmpty'] @@ -58,10 +57,13 @@ declare module 'vue' { DropdownMenuTrigger: typeof import('reka-ui')['DropdownMenuTrigger'] EqualizerDialog: typeof import('./src/components/modals/EqualizerDialog.vue')['default'] ExcludeLyricsConfig: typeof import('./src/components/settings/custom/ExcludeLyricsConfig.vue')['default'] + ExternalApiPanel: typeof import('./src/components/settings/custom/ExternalApiPanel.vue')['default'] FileCacheManager: typeof import('./src/components/settings/custom/FileCacheManager.vue')['default'] FontConfig: typeof import('./src/components/settings/custom/FontConfig.vue')['default'] FullPlayer: typeof import('./src/components/player/FullPlayer/index.vue')['default'] HotkeyConfig: typeof import('./src/components/settings/custom/HotkeyConfig.vue')['default'] + IconLucideArrowRight: typeof import('~icons/lucide/arrow-right')['default'] + IconLucideArrowUp: typeof import('~icons/lucide/arrow-up')['default'] IconLucideArrowUpCircle: typeof import('~icons/lucide/arrow-up-circle')['default'] IconLucideCheck: typeof import('~icons/lucide/check')['default'] IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] @@ -71,19 +73,24 @@ declare module 'vue' { IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] IconLucideCircleX: typeof import('~icons/lucide/circle-x')['default'] IconLucideDatabase: typeof import('~icons/lucide/database')['default'] + IconLucideDisc: typeof import('~icons/lucide/disc')['default'] IconLucideDisc3: typeof import('~icons/lucide/disc3')['default'] IconLucideEllipsis: typeof import('~icons/lucide/ellipsis')['default'] IconLucideExternalLink: typeof import('~icons/lucide/external-link')['default'] + IconLucideFlame: typeof import('~icons/lucide/flame')['default'] IconLucideFolder: typeof import('~icons/lucide/folder')['default'] IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default'] IconLucideFolderPlus: typeof import('~icons/lucide/folder-plus')['default'] IconLucideGithub: typeof import('~icons/lucide/github')['default'] IconLucideHardDrive: typeof import('~icons/lucide/hard-drive')['default'] + IconLucideHistory: typeof import('~icons/lucide/history')['default'] IconLucideInfo: typeof import('~icons/lucide/info')['default'] + IconLucideKeyRound: typeof import('~icons/lucide/key-round')['default'] IconLucideLink: typeof import('~icons/lucide/link')['default'] IconLucideListMusic: typeof import('~icons/lucide/list-music')['default'] IconLucideLocate: typeof import('~icons/lucide/locate')['default'] IconLucideLock: typeof import('~icons/lucide/lock')['default'] + IconLucideLogOut: typeof import('~icons/lucide/log-out')['default'] IconLucideMaximize: typeof import('~icons/lucide/maximize')['default'] IconLucideMessageCircle: typeof import('~icons/lucide/message-circle')['default'] IconLucideMic: typeof import('~icons/lucide/mic')['default'] @@ -99,6 +106,7 @@ declare module 'vue' { IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default'] IconLucideRepeat: typeof import('~icons/lucide/repeat')['default'] IconLucideRepeat1: typeof import('~icons/lucide/repeat1')['default'] + IconLucideScanLine: typeof import('~icons/lucide/scan-line')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearchX: typeof import('~icons/lucide/search-x')['default'] IconLucideSettings: typeof import('~icons/lucide/settings')['default'] @@ -115,11 +123,17 @@ declare module 'vue' { IconLucideVolume2: typeof import('~icons/lucide/volume2')['default'] IconLucideVolumeX: typeof import('~icons/lucide/volume-x')['default'] IconLucideX: typeof import('~icons/lucide/x')['default'] + IconLucideZap: typeof import('~icons/lucide/zap')['default'] + IconMaterialSymbolsFavoriteOutlineRounded: typeof import('~icons/material-symbols/favorite-outline-rounded')['default'] IconSpLossless: typeof import('~icons/sp/lossless')['default'] + LoginCookieDialog: typeof import('./src/components/modals/LoginCookieDialog.vue')['default'] + LoginDialog: typeof import('./src/components/modals/LoginDialog.vue')['default'] LyricFormatOrderConfig: typeof import('./src/components/settings/custom/LyricFormatOrderConfig.vue')['default'] Lyrics: typeof import('./src/components/player/Lyrics/index.vue')['default'] LyricSourceOrderConfig: typeof import('./src/components/settings/custom/LyricSourceOrderConfig.vue')['default'] - NavHeader: typeof import('./src/layouts/components/NavHeader.vue')['default'] + NavHeader: typeof import('./src/components/layout/NavHeader.vue')['default'] + NavSearch: typeof import('./src/components/layout/NavSearch.vue')['default'] + NavUser: typeof import('./src/components/layout/NavUser.vue')['default'] NumberFieldDecrement: typeof import('reka-ui')['NumberFieldDecrement'] NumberFieldIncrement: typeof import('reka-ui')['NumberFieldIncrement'] NumberFieldInput: typeof import('reka-ui')['NumberFieldInput'] @@ -129,15 +143,19 @@ declare module 'vue' { PlayerControls: typeof import('./src/components/player/PlayerControls.vue')['default'] PlayerCover: typeof import('./src/components/player/FullPlayer/PlayerCover.vue')['default'] PlayerData: typeof import('./src/components/player/FullPlayer/PlayerData.vue')['default'] + PlaylistCreateDialog: typeof import('./src/components/modals/PlaylistCreateDialog.vue')['default'] PlaylistPanel: typeof import('./src/components/list/PlaylistPanel.vue')['default'] + PlaylistPickerDialog: typeof import('./src/components/modals/PlaylistPickerDialog.vue')['default'] PluginManager: typeof import('./src/components/settings/custom/PluginManager.vue')['default'] PopoverArrow: typeof import('reka-ui')['PopoverArrow'] PopoverContent: typeof import('reka-ui')['PopoverContent'] PopoverPortal: typeof import('reka-ui')['PopoverPortal'] PopoverRoot: typeof import('reka-ui')['PopoverRoot'] PopoverTrigger: typeof import('reka-ui')['PopoverTrigger'] + QualityControl: typeof import('./src/components/player/QualityControl.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + SAlert: typeof import('./src/components/ui/SAlert.vue')['default'] SButton: typeof import('./src/components/ui/SButton.vue')['default'] SCard: typeof import('./src/components/ui/SCard.vue')['default'] SCheckbox: typeof import('./src/components/ui/SCheckbox.vue')['default'] @@ -167,8 +185,8 @@ declare module 'vue' { SettingsSearch: typeof import('./src/components/settings/SettingsSearch.vue')['default'] SettingsSection: typeof import('./src/components/settings/SettingsSection.vue')['default'] SFormItem: typeof import('./src/components/ui/SFormItem.vue')['default'] - SideBar: typeof import('./src/layouts/components/SideBar.vue')['default'] - SideBarLogo: typeof import('./src/layouts/components/SideBarLogo.vue')['default'] + SideBar: typeof import('./src/components/layout/SideBar.vue')['default'] + SideBarLogo: typeof import('./src/components/layout/SideBarLogo.vue')['default'] SImg: typeof import('./src/components/ui/SImg.vue')['default'] SInput: typeof import('./src/components/ui/SInput.vue')['default'] SLoading: typeof import('./src/components/ui/SLoading.vue')['default'] @@ -176,7 +194,6 @@ declare module 'vue' { SMarquee: typeof import('./src/components/ui/SMarquee.vue')['default'] SMenu: typeof import('./src/components/ui/SMenu.vue')['default'] SNumberInput: typeof import('./src/components/ui/SNumberInput.vue')['default'] - SongCacheSizeLimit: typeof import('./src/components/settings/custom/SongCacheSizeLimit.vue')['default'] SongList: typeof import('./src/components/list/SongList.vue')['default'] SpeedDialog: typeof import('./src/components/modals/SpeedDialog.vue')['default'] SPopover: typeof import('./src/components/ui/SPopover.vue')['default'] @@ -198,6 +215,6 @@ declare module 'vue' { SwitchThumb: typeof import('reka-ui')['SwitchThumb'] Toolbar: typeof import('./src/components/player/Toolbar.vue')['default'] TrackInfo: typeof import('./src/components/player/TrackInfo.vue')['default'] - WindowControls: typeof import('./src/layouts/components/WindowControls.vue')['default'] + WindowControls: typeof import('./src/components/layout/WindowControls.vue')['default'] } } diff --git a/docs/plugins-development.md b/docs/plugins-development.md index 625aec35..d8ed8c04 100644 --- a/docs/plugins-development.md +++ b/docs/plugins-development.md @@ -71,7 +71,7 @@ splayer.on("lyric", async (req) => { - **没有** Node 内置模块(`fs` / `net` / `child_process` 等),**没有** `require` / `import` - **没有** DOM / Electron API(`window` 仅在 lx 兼容模式下作为 `{ lx }` 垫片) -- **有** 以下全局:`splayer`、`Buffer`、`URL` / `URLSearchParams`、`TextEncoder` / `TextDecoder`、`Promise`、`setTimeout` / `setInterval` / `clearTimeout` / `clearInterval` / `setImmediate` / `clearImmediate` / `queueMicrotask`、`console`(重定向到 `splayer.log`) +- **有** 以下全局:`splayer`、`Buffer`、`URL` / `URLSearchParams`、`TextEncoder` / `TextDecoder`、`btoa` / `atob`、`Promise`、`setTimeout` / `setInterval` / `clearTimeout` / `clearInterval` / `setImmediate` / `clearImmediate` / `queueMicrotask`、`console`(重定向到 `splayer.log`) 硬性约束: @@ -101,7 +101,7 @@ splayer.register({ sources: { [sourceKey: string]: { name: string; // 展示名 - actions: ("musicUrl" | "lyric" | "pic")[]; + actions: "musicUrl"[]; // 当前 level 1 仅 musicUrl;lyric / pic 计划在 level 2 加入 qualities?: ("lq" | "sq" | "hq" | "lossless" | "hi-res")[]; }; }; @@ -118,29 +118,47 @@ splayer.register({ | `sq` | 有损 ≥ 192kbps | | `lq` | 有损 < 192kbps | -lx 脚本声明的 `128k/192k/320k/flac/flac24bit/ape/wav` 会被垫片自动映射到上面的等级;handler 收到的 `info.type` 也会反向映射为 lx 原生值,老脚本无需改动。 +lx 脚本声明的 `128k/192k/320k/flac/flac24bit/ape/wav` 会被垫片自动映射到上面的等级;**lx 脚本的** handler 经由 `lx.on('request')` 收到的 `info.type` 也会反向映射为 lx 原生值,老脚本无需改动。 -**必须在脚本同步部分调用**。注册完后宿主才知道这个插件能做什么、支持哪些源。 +**建议在脚本同步部分调用**。注册完后 UI 才能显示插件支持的源/动作;handler 真正被分派依赖请求里的 `source` 字段,宿主不会因为没 register 就拒绝调用,但 UI 上会看不到能力声明。 ### 注册动作处理器 ```ts splayer.on("musicUrl", async (req) => res); -splayer.on("lyric", async (req) => res); -splayer.on("pic", async (req) => res); ``` -每个 action 最多一个 handler,重复注册后者覆盖前者。目前仅这三个动作,搜索和元数据由宿主自身负责。 +每个 action 最多一个 handler,重复注册后者覆盖前者。**当前 level 1 只实现了 `musicUrl`,`lyric` / `pic` 在路线图(计划在 level 2 加入)**,搜索与元数据由宿主自身负责。 #### 请求/响应形状 -| Action | 请求 | 响应 | 默认超时 | -| ---------- | -------------------------------- | --------------------------------------- | -------- | -| `musicUrl` | `{ source, quality, musicInfo }` | `{ url, quality?, expire? }` | 20 s | -| `lyric` | `{ source, musicInfo }` | `{ lyric, tlyric?, rlyric?, lxlyric? }` | 15 s | -| `pic` | `{ source, musicInfo }` | `{ url }` | 15 s | +下表仅列 level 1 已实现的 `musicUrl`: -`musicInfo`:宿主会传至少 `{ songmid }`,可能还带 `name` / `singer` 等用于辅助识别。 +| Action | 请求 | 响应 | 默认超时 | +| ---------- | -------------------------------- | ---------------------------- | -------- | +| `musicUrl` | `{ source, quality, musicInfo }` | `{ url, quality?, expire? }` | 20 s | + +`musicInfo` 当前由宿主实际下发的字段集(splayer-native 与 lx 兼容路径共用同一份): + +```ts +{ + id: string, // Track 主键 + songmid: string, // 与 id 同值,兼容旧脚本字段名 + songId: string, // 与 id 同值,兼容更旧的字段名 + name: string, // 歌曲名 + singer: string, // "/" 拼接的艺术家串 + source: string, // "wy" | "tx" | "kg" | ...,与请求外层 source 一致 + interval: string|null,// "mm:ss" 格式时长 + meta: { + songId: string, + albumName: string, + albumId?: string, + picUrl?: string, + }, +} +``` + +如果脚本声明 `@platform lx`(或以 `gz_` 压缩分发),lx 垫片会原样把上述对象交给 `lx.on('request')` 的 handler —— 字段与 `LX.Music.MusicInfo` 形状基本对齐。 handler 抛出的异常会被宿主捕获,错误码透传到上层。超时未返回 → 被主进程 cancel。 @@ -194,7 +212,7 @@ splayer.storage.keys(): Promise; splayer.getSetting(key: string): T | undefined; ``` -同步读取用户在设置界面给此插件配置的值。设置 schema 通过 `register()` 扩展的能力将在后续 API level 加入;当前 level 1 下返回 `undefined`。 +同步读取用户给此插件配置的值(来源是 `settings.json` 里 `plugins.perPlugin.{id}.{key}`)。未配置时返回 `undefined`。设置 schema 通过 `register()` 声明的能力将在后续 API level 加入;当前只能从外部写入配置,UI 编辑入口尚未开放。 ### 工具(`splayer.utils`) @@ -225,22 +243,23 @@ splayer.utils.zlib.gunzip(data) / gzip(data) handler 抛异常时可通过 `err.code` 带上错误码;不带的话宿主默认 `PLUGIN_HANDLER_ERROR`。 -| Code | 含义 | -| --------------------------- | ----------------------- | -| `PLUGIN_NOT_FOUND` | 找不到指定插件 | -| `PLUGIN_DISABLED` | 插件已禁用 | -| `PLUGIN_NOT_READY` | 插件未就绪 / 沙箱未启动 | -| `PLUGIN_ACTION_UNSUPPORTED` | 插件没注册该动作 | -| `PLUGIN_LOAD_TIMEOUT` | 加载超 10 秒 | -| `PLUGIN_SCRIPT_ERROR` | 脚本语法或运行错误 | -| `PLUGIN_INVALID_MANIFEST` | 头部字段缺失或不合法 | -| `PLUGIN_API_LEVEL_MISMATCH` | 声明 apiLevel 高于宿主 | -| `PLUGIN_REQUEST_TIMEOUT` | 动作或 request 超时 | -| `PLUGIN_CANCELLED` | 被上层取消 | -| `PLUGIN_NETWORK_ERROR` | 网络错误 | -| `PLUGIN_URL_NOT_ALLOWED` | URL 协议不在白名单 | -| `PLUGIN_HANDLER_ERROR` | handler 默认错误码 | -| `PLUGIN_WORKER_CRASHED` | 子进程崩溃 | +| Code | 含义 | +| --------------------------- | ------------------------------------------------------------------ | +| `PLUGIN_NOT_FOUND` | 找不到指定插件 | +| `PLUGIN_DISABLED` | 插件已禁用 | +| `PLUGIN_NOT_READY` | 插件未就绪 / 沙箱未启动 | +| `PLUGIN_ACTION_UNSUPPORTED` | 插件没注册该动作 | +| `PLUGIN_LOAD_TIMEOUT` | 加载超 10 秒 | +| `PLUGIN_SCRIPT_ERROR` | 脚本语法或运行错误 | +| `PLUGIN_INVALID_MANIFEST` | 头部字段缺失或不合法 | +| `PLUGIN_API_LEVEL_MISMATCH` | 声明 apiLevel 高于宿主 | +| `PLUGIN_REQUEST_TIMEOUT` | 动作或 request 超时 | +| `PLUGIN_CANCELLED` | 被上层取消 | +| `PLUGIN_NETWORK_ERROR` | 网络错误 | +| `PLUGIN_URL_NOT_ALLOWED` | URL 协议不在白名单 | +| `PLUGIN_HANDLER_ERROR` | handler 默认错误码 | +| `PLUGIN_WORKER_CRASHED` | 子进程崩溃 | +| `PLUGIN_INVALID_RESULT` | 插件返回的结果形状不合法(musicUrl 必须是字符串 URL 或 `{ url }`) | ## 完整示例结构 @@ -253,44 +272,31 @@ handler 抛异常时可通过 `err.code` 带上错误码;不带的话宿主默 * @apiLevel 1 */ -const SOURCES = ["sa", "sb"]; // 你内部的源标识 - splayer.register({ sources: { - sa: { name: "SA 音源", actions: ["musicUrl", "lyric"], qualities: ["lq", "hq"] }, - sb: { name: "SB 音源", actions: ["musicUrl", "pic"], qualities: ["lq", "hq", "lossless"] }, + sa: { name: "SA 音源", actions: ["musicUrl"], qualities: ["lq", "hq"] }, + sb: { name: "SB 音源", actions: ["musicUrl"], qualities: ["lq", "hq", "lossless"] }, }, }); const apis = { - sa: { - musicUrl: async ({ musicInfo, quality }) => { - /* ... */ - }, - lyric: async ({ musicInfo }) => { - /* ... */ - }, + sa: async ({ musicInfo, quality }) => { + /* 调用 SA 接口拿到 url */ + return { url: "https://...", quality }; }, - sb: { - musicUrl: async ({ musicInfo, quality }) => { - /* ... */ - }, - pic: async ({ musicInfo }) => { - /* ... */ - }, + sb: async ({ musicInfo, quality }) => { + /* 调用 SB 接口拿到 url */ + return { url: "https://...", quality }; }, }; -const dispatch = (action) => async (req) => { - const fn = apis[req.source]?.[action]; - if (!fn) +splayer.on("musicUrl", async (req) => { + const fn = apis[req.source]; + if (!fn) { throw Object.assign(new Error("source not supported"), { code: "PLUGIN_ACTION_UNSUPPORTED" }); + } return fn(req); -}; - -splayer.on("musicUrl", dispatch("musicUrl")); -splayer.on("lyric", dispatch("lyric")); -splayer.on("pic", dispatch("pic")); +}); ``` ## 调试 diff --git a/docs/plugins-usage.md b/docs/plugins-usage.md index 1d9deb64..6130220e 100644 --- a/docs/plugins-usage.md +++ b/docs/plugins-usage.md @@ -63,7 +63,7 @@ SPlayer-Next 支持通过插件扩展音乐能力,包括 URL 解析、歌词 我们兼容 lx-music-desktop 的 user_api 脚本。绝大多数 lx 公开脚本可以直接拖入使用,SPlayer 会自动注入 `window.lx` 垫片。两点注意: - lx 脚本默认只一个能启用(跟 lx 本身一致) -- 垫片覆盖了 `lx.request` / `lx.on('request')` / `lx.send('inited')` / `lx.utils`(crypto / buffer / zlib / base64)这些最常用的 API,如果脚本用了非主流或 lx 新版 API,可能出现"插件加载错误" +- 垫片覆盖了 `lx.request` / `lx.on('request')` / `lx.send('inited')` / `lx.utils`(crypto / buffer / zlib)这些最常用的 API,如果脚本用了非主流或 lx 新版 API,可能出现"插件加载错误" ## 常见问题 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ddf84d78..4d2c47a4 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -91,7 +91,7 @@ export default defineConfig({ }, }), Components({ - dirs: ["src/components", "src/layouts/components"], + dirs: ["src/components"], resolvers: [RekaResolver(), IconsResolver({ prefix: "icon", customCollections: ["sp"] })], }), ], diff --git a/electron/main/apis/common/lyric/qqmusic.ts b/electron/main/apis/common/lyric/qqmusic.ts index f7be058b..d621b55e 100644 --- a/electron/main/apis/common/lyric/qqmusic.ts +++ b/electron/main/apis/common/lyric/qqmusic.ts @@ -30,9 +30,9 @@ const pickFormatted = ( }; /** - * 按 QQMusic song id 直取歌词 - * @param id 数字 id(QM 歌词接口必需) - * @param mid 字符串 mid(用于 AMLL TTML DB 等外部映射;可选) + * 按 QQMusic 数字 songID 直取歌词 + * @param id 数字 songID + * @param mid 字符串 mid(用于 AMLL TTML DB 等外部映射) */ export const getByPlatformId = async ( id: string, diff --git a/electron/main/apis/common/lyric/utils.ts b/electron/main/apis/common/lyric/utils.ts index f24f37c2..a7fa656b 100644 --- a/electron/main/apis/common/lyric/utils.ts +++ b/electron/main/apis/common/lyric/utils.ts @@ -27,23 +27,35 @@ export const normalize = (text: string | undefined | null): string => { const bothContains = (left: string, right: string): boolean => left.length > 0 && right.length > 0 && (left.includes(right) || right.includes(left)); -/** 时长是否接近 */ +/** 时长是否在容差内(ms) */ const durationClose = (leftMs?: number, rightMs?: number, tolMs = 5000): boolean => { if (!leftMs || !rightMs) return false; return Math.abs(leftMs - rightMs) <= tolMs; }; +/** + * 时长差是否大到能确认"不是同一首" + * @param leftMs - 左时长(ms) + * @param rightMs - 右时长(ms) + * @param tolMs - 容差(ms) + */ +const durationFar = (leftMs?: number, rightMs?: number, tolMs = 20000): boolean => { + if (!leftMs || !rightMs) return false; + return Math.abs(leftMs - rightMs) > tolMs; +}; + /** * 从候选列表里挑出最匹配 track 的那一个 * + * 硬性条件(不满足直接跳过) + * - name 必须全等或双向 includes + * - 双方都给了 duration 时,差距不能超过 20s + * * 打分规则(分数越高越优先) * - name 全等:+10;name 双向 includes:+4 * - artist 全等:+5;artist 双向 includes:+2 * - album 全等(且 track 有 album):+2 * - duration 接近(±5s):+3 - * - * name 既不等也无 includes 的直接跳过(相关性太差) - * 最终最高分 < 1 视为无匹配,返回 null */ export const pickBestCandidate = ( candidates: LyricCandidate[], @@ -67,6 +79,8 @@ export const pickBestCandidate = ( else if (bothContains(candName, trackName)) score += 4; else continue; + if (durationFar(candidate.duration, trackDuration)) continue; + if (candArtist === trackArtist) score += 5; else if (bothContains(candArtist, trackArtist)) score += 2; diff --git a/electron/main/apis/kugou/core/config.ts b/electron/main/apis/kugou/core/config.ts index a11e1602..d35157d7 100644 --- a/electron/main/apis/kugou/core/config.ts +++ b/electron/main/apis/kugou/core/config.ts @@ -2,7 +2,10 @@ * KG API 通用常量 */ -/** 搜索接口(WebFilter 平台,无需鉴权) */ +/** 主搜索(带封面):mobilecdn 的 /api/v3/search/song,公网无鉴权,响应里 trans_param.union_cover 就是封面 URL 模板 */ +export const KG_MOBILECDN_URL = "http://mobilecdn.kugou.com/api/v3/search/song"; + +/** 兜底搜索:老的 songsearch,无封面,但不依赖任何 KG 服务可达性变化 */ export const KG_SEARCH_URL = "https://songsearch.kugou.com/song_search_v2"; /** 歌词搜索/下载接口(走 lyrics.kugou.com 的 expand_search 通道) */ diff --git a/electron/main/apis/kugou/core/request.ts b/electron/main/apis/kugou/core/request.ts index a6349d6b..a765854b 100644 --- a/electron/main/apis/kugou/core/request.ts +++ b/electron/main/apis/kugou/core/request.ts @@ -2,10 +2,10 @@ * KG 请求层 * * 设计: - * - 搜索走 songsearch.kugou.com(JSON,无鉴权),透传查询参数 - * - 歌词走 lyrics.kugou.com(JSON,需要 KG-RC/KG-THash/UA 伪装 PC 客户端) - * - 成功码约定不统一:songsearch 用 error_code=0,lyrics 用 error_code=200(HTTP 风格) - * - 没有加密 body,纯 fetch GET + * - 主搜索走 mobilecdn.kugou.com/api/v3/search/song(公网无鉴权,含封面) + * - 兜底搜索走 songsearch.kugou.com(无鉴权,无封面) + * - 歌词走 lyrics.kugou.com(需要 KG-RC/KG-THash/UA 伪装 PC 客户端) + * - 成功码约定不统一:songsearch/mobilecdn 用 error_code=0,lyrics 用 error_code=200 */ interface FetchOptions { @@ -35,7 +35,7 @@ export const kgRequest = async ( if (res.status !== 200) throw new Error(`KG HTTP ${res.status}`); const body = (await res.json()) as KGRawBody; - // 0 = songsearch 风格成功码,200 = lyrics.kugou.com 的 HTTP 风格成功码 + // 0 = mobilecdn/songsearch 风格成功码,200 = lyrics.kugou.com 的 HTTP 风格成功码 const code = body.error_code ?? body.errcode ?? body.err_code ?? 0; if (code !== 0 && code !== 200) throw new Error(`KG API error_code=${code}`); diff --git a/electron/main/apis/kugou/index.ts b/electron/main/apis/kugou/index.ts index e96a14ed..3e996694 100644 --- a/electron/main/apis/kugou/index.ts +++ b/electron/main/apis/kugou/index.ts @@ -3,7 +3,8 @@ * * 与 netease 不同之处: * - 无账号体系、无 cookie、无加密 body,纯 fetch GET - * - 搜索走 songsearch.kugou.com;歌词走 lyrics.kugou.com(需 KG-RC/KG-THash/UA 伪装) + * - 搜索主走 mobilecdn.kugou.com(响应里有封面),失败兜底 songsearch.kugou.com(无封面) + * - 歌词走 lyrics.kugou.com(需 KG-RC/KG-THash/UA 伪装 PC 客户端) * - 歌词是 hash + 歌名 + 时长 三元组匹配(KG 特有,不能只凭 ID) * * 统一入口:callKugou(name, params) @@ -68,6 +69,14 @@ export const callKugou = async (name: string, params: KGParams = {}): Promise { + if (!value || typeof value !== "object") return false; + const v = value as Record; + if (Array.isArray(v.songs) && v.songs.length === 0) return true; + return false; +}; diff --git a/electron/main/apis/kugou/modules/search.ts b/electron/main/apis/kugou/modules/search.ts index 29bd53cf..c7cb7720 100644 --- a/electron/main/apis/kugou/modules/search.ts +++ b/electron/main/apis/kugou/modules/search.ts @@ -1,21 +1,169 @@ /** * 搜索歌曲(KG) - * endpoint: https://songsearch.kugou.com/song_search_v2 + * + * 主路径:mobilecdn.kugou.com/api/v3/search/song + * 公网无鉴权 GET,响应字段是小写,含 trans_param.union_cover(封面 URL,带 `{size}` 占位) + * tramhao/termusic、zonemeen/musicn、UnblockNeteaseMusic 等多个开源项目都在用 + * 兜底:songsearch.kugou.com/song_search_v2(无封面,PascalCase 字段) * * params: * - keywords 关键词(必填) * - page 页码,默认 1 * - limit 每页数,默认 30 * - * 返回字段遵循 qqmusic.search 的结构(duration 为毫秒),并额外带上 - * KG 特有的 hash / audioId / 多品质 sizes&hashes,播放/歌词都要用 + * 返回结构沿用 qqmusic.search(duration 为毫秒),并带 KG 特有的 hash + 多品质 sizes/hashes */ -import { KG_SEARCH_URL, decodeName, formatSingerName } from "../core/config"; +import { KG_MOBILECDN_URL, KG_SEARCH_URL, decodeName, formatSingerName } from "../core/config"; import { kgRequest } from "../core/request"; +import { coreLog } from "@main/utils/logger"; import type { KGModule } from "../core/types"; -interface KGSearchSong { +type Quality = "128k" | "320k" | "flac" | "flac24bit"; + +interface NormalizedSong { + id: string; + audioId: number; + hash: string; + name: string; + artist: string; + album: string; + albumId: string | number; + cover?: string; + coverOriginal?: string; + /** 秒 */ + interval: number; + /** 毫秒 */ + duration: number; + qualities: Quality[]; + hashes: Partial>; + sizes: Partial>; +} + +/** trans_param.union_cover 含 `{size}` 占位,按需替换 */ +const fillCover = (url: string | undefined, size: number): string | undefined => { + if (!url) return undefined; + return url.replace(/\{size\}/g, String(size)); +}; + +/** 主路径 mobilecdn 返回的字段都是小写蛇形 */ +interface MobileSong { + hash?: string; + audio_id?: number; + songname?: string; + filename?: string; + singername?: string; + album_id?: string | number; + album_name?: string; + duration?: number; + filesize?: number; + "320hash"?: string; + "320filesize"?: number; + sqhash?: string; + sqfilesize?: number; + /** Hi-Res 字段名不同版本不一,两种都收 */ + hires_hash?: string; + hires_filesize?: number; + reshash?: string; + resfilesize?: number; + /** 翻唱/不同版本聚合 */ + group?: MobileSong[]; + trans_param?: { union_cover?: string }; +} + +interface MobileResp { + status?: number; + error_code?: number; + data?: { + total?: number; + info?: MobileSong[]; + }; +} + +/** singername 是 "A、B" / "A,B" 形式的字符串,规范成 "A / B" */ +const formatMobileArtist = (name: string | undefined): string => { + if (!name) return ""; + return decodeName(name) + .split(/、|,|;|\//) + .map((s) => s.trim()) + .filter(Boolean) + .join(" / "); +}; + +const normalizeFromMobile = (raw: MobileSong): NormalizedSong => { + const sizes: Partial> = {}; + const hashes: Partial> = {}; + + if (raw.filesize && raw.hash) { + sizes["128k"] = raw.filesize; + hashes["128k"] = raw.hash; + } + if (raw["320filesize"] && raw["320hash"]) { + sizes["320k"] = raw["320filesize"]; + hashes["320k"] = raw["320hash"]; + } + if (raw.sqfilesize && raw.sqhash) { + sizes.flac = raw.sqfilesize; + hashes.flac = raw.sqhash; + } + const hrSize = raw.hires_filesize ?? raw.resfilesize; + const hrHash = raw.hires_hash ?? raw.reshash; + if (hrSize && hrHash) { + sizes.flac24bit = hrSize; + hashes.flac24bit = hrHash; + } + + const interval = raw.duration ?? 0; + const coverTpl = raw.trans_param?.union_cover; + return { + id: String(raw.audio_id ?? ""), + audioId: raw.audio_id ?? 0, + hash: raw.hash ?? "", + name: decodeName(raw.songname || raw.filename || ""), + artist: formatMobileArtist(raw.singername), + album: decodeName(raw.album_name ?? ""), + albumId: raw.album_id ?? "", + cover: fillCover(coverTpl, 300), + coverOriginal: fillCover(coverTpl, 480), + interval, + duration: interval * 1000, + qualities: Object.keys(hashes) as Quality[], + hashes, + sizes, + }; +}; + +const searchSongsMobile = async (keywords: string, page: number, limit: number) => { + const url = + `${KG_MOBILECDN_URL}?keyword=${encodeURIComponent(keywords)}` + + `&page=${page}&pagesize=${limit}&format=json&showtype=1`; + + const body = await kgRequest(url); + const raw = body.data?.info ?? []; + + const songs: NormalizedSong[] = []; + const seen = new Set(); + const push = (item: MobileSong) => { + const key = `${item.audio_id ?? ""}_${item.hash ?? ""}`; + if (seen.has(key)) return; + seen.add(key); + songs.push(normalizeFromMobile(item)); + }; + for (const item of raw) { + push(item); + for (const sub of item.group ?? []) push(sub); + } + + return { + code: 200, + total: body.data?.total ?? songs.length, + songs, + }; +}; + +// ── 兜底:旧 songsearch(无封面,PascalCase 字段) ───────────────────────────── + +interface LegacySong { Audioid: number; SongName: string; Singers?: Array<{ name?: string }>; @@ -30,21 +178,19 @@ interface KGSearchSong { SQFileSize?: number; ResFileHash?: string; ResFileSize?: number; - Grp?: KGSearchSong[]; + Grp?: LegacySong[]; } -interface KGSearchResp { +interface LegacyResp { status?: number; error_code?: number; data?: { total?: number; - lists?: KGSearchSong[]; + lists?: LegacySong[]; }; } -type Quality = "128k" | "320k" | "flac" | "flac24bit"; - -const normalizeSong = (raw: KGSearchSong) => { +const normalizeFromLegacy = (raw: LegacySong): NormalizedSong => { const sizes: Partial> = {}; const hashes: Partial> = {}; @@ -73,54 +219,33 @@ const normalizeSong = (raw: KGSearchSong) => { artist: formatSingerName(raw.Singers), album: decodeName(raw.AlbumName ?? ""), albumId: raw.AlbumID ?? "", - /** 秒 */ + cover: undefined, interval: raw.Duration, - /** 毫秒,与其它源对齐 */ duration: raw.Duration * 1000, - /** 支持的品质列表(从高到低) */ qualities: Object.keys(hashes) as Quality[], hashes, sizes, }; }; -const search: KGModule = async (params) => { - const { - keywords, - page = 1, - limit = 30, - } = params as { - keywords?: string; - page?: number; - limit?: number; - }; - - if (!keywords) { - return { code: 400, total: 0, songs: [], message: "keywords required" }; - } - +const searchSongsLegacy = async (keywords: string, page: number, limit: number) => { const url = `${KG_SEARCH_URL}?keyword=${encodeURIComponent(keywords)}` + `&page=${page}&pagesize=${limit}` + `&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`; - const body = await kgRequest(url); - + const body = await kgRequest(url); const raw = body.data?.lists ?? []; - const songs: ReturnType[] = []; - // 去重键:audioId + hash(同一首歌不同品质会出现多条) + const songs: NormalizedSong[] = []; const seen = new Set(); - - const push = (item: KGSearchSong) => { + const push = (item: LegacySong) => { const key = `${item.Audioid}_${item.FileHash}`; if (seen.has(key)) return; seen.add(key); - songs.push(normalizeSong(item)); + songs.push(normalizeFromLegacy(item)); }; - for (const item of raw) { push(item); - // Grp 里是翻唱/不同版本,一并展开 for (const sub of item.Grp ?? []) push(sub); } @@ -131,4 +256,30 @@ const search: KGModule = async (params) => { }; }; +const search: KGModule = async (params) => { + const { + keywords, + page = 1, + limit = 30, + } = params as { + keywords?: string; + page?: number; + limit?: number; + }; + + if (!keywords) { + return { code: 400, total: 0, songs: [], message: "keywords required" }; + } + + // mobilecdn 抛错或空结果都兜底到 songsearch + try { + const result = await searchSongsMobile(keywords, page, limit); + if (result.songs.length > 0) return result; + coreLog.warn("[kg] mobilecdn returned 0, fallback to songsearch"); + } catch (err) { + coreLog.warn("[kg] mobilecdn failed, fallback to songsearch:", err); + } + return await searchSongsLegacy(keywords, page, limit); +}; + export default search; diff --git a/electron/main/apis/netease/core/request.ts b/electron/main/apis/netease/core/request.ts index 286b6252..5b17aee7 100644 --- a/electron/main/apis/netease/core/request.ts +++ b/electron/main/apis/netease/core/request.ts @@ -45,6 +45,19 @@ export interface RequestResponse { cookie: string[]; } +/** 非 200 响应抛出的错误 */ +export class NeteaseRequestError extends Error { + readonly response: RequestResponse; + constructor(response: RequestResponse) { + const body = response.body as { code?: number | string; msg?: string; message?: string }; + const code = body?.code ?? response.status; + const msg = body?.msg ?? body?.message ?? ""; + super(msg ? `netease ${code}: ${msg}` : `netease ${code}`); + this.name = "NeteaseRequestError"; + this.response = response; + } +} + interface NeteaseBody { code?: number | string; [key: string]: unknown; @@ -215,7 +228,7 @@ export const createRequest = async ( } catch (err) { answer.status = 502; answer.body = { code: 502, msg: err instanceof Error ? err.message : String(err) }; - throw answer; + throw new NeteaseRequestError(answer); } // 收集 set-cookie(Node fetch 通过 headers.getSetCookie 暴露原始多值头) @@ -254,7 +267,7 @@ export const createRequest = async ( answer.status = answer.status > 100 && answer.status < 600 ? answer.status : 400; if (answer.status === 200) return answer; - throw answer; + throw new NeteaseRequestError(answer); }; /** 宽松的 boolean 解析:原始 util/index.js 里的 toBoolean */ diff --git a/electron/main/apis/netease/index.ts b/electron/main/apis/netease/index.ts index 6c66c501..840da81d 100644 --- a/electron/main/apis/netease/index.ts +++ b/electron/main/apis/netease/index.ts @@ -31,7 +31,25 @@ const SESSION_MUTATING: ReadonlySet = new Set([ "register_anonimous", ]); -/** 内存缓存:避免每次调用都走 SELECT(SQLite 仅在首次 load 时读一次) */ +/** 不采用缓存的实时接口 */ +const NON_CACHEABLE: ReadonlySet = new Set([ + "like", + "playlist_create", + "playlist_delete", + "playlist_tracks", + "playlist_subscribe", + "playlist_name_update", + "playlist_desc_update", + "playlist_order_update", + "playlist_detail", + "user_playlist", + "user_subcount", + "user_cloud", + "user_cloud_del", + "album_sub", +]); + +/** 内存缓存 */ let sessionCache: Record | null = null; const loadSession = (): Record => { @@ -96,10 +114,13 @@ export const callNetease = async ( const session = loadSession(); - // 读缓存;调用方如不想命中,按原项目惯例在 params 里带 `timestamp: Date.now()` 即可 - const cacheKey = buildCacheKey(name, params); - const hit = cacheGet(cacheKey); - if (hit) return hit; + // 读缓存 + const cacheable = !NON_CACHEABLE.has(name); + const cacheKey = cacheable ? buildCacheKey(name, params) : ""; + if (cacheable) { + const hit = cacheGet(cacheKey); + if (hit) return hit; + } const query = { ...params, @@ -121,7 +142,7 @@ export const callNetease = async ( } const value = { status: res.status, body: res.body }; - if (res.status === 200) cacheSet(cacheKey, value); + if (cacheable && res.status === 200) cacheSet(cacheKey, value); return value; }; diff --git a/electron/main/apis/netease/modules/album.ts b/electron/main/apis/netease/modules/album.ts new file mode 100644 index 00000000..06c8b21f --- /dev/null +++ b/electron/main/apis/netease/modules/album.ts @@ -0,0 +1,16 @@ +/** + * 专辑详情(元数据 + 曲目) + * + * params: + * - id 专辑 id + * + * 响应:`{ code, album: { id, name, picUrl, artists, description, ... }, songs: NeteaseSong[] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const album: NeteaseModule = (query, request) => + request(`/api/v1/album/${query.id}`, {}, createOption(query, "weapi")); + +export default album; diff --git a/electron/main/apis/netease/modules/album_sub.ts b/electron/main/apis/netease/modules/album_sub.ts new file mode 100644 index 00000000..0dfba53d --- /dev/null +++ b/electron/main/apis/netease/modules/album_sub.ts @@ -0,0 +1,20 @@ +/** + * 收藏 / 取消收藏专辑 + * + * params: + * - id 专辑 id + * - t 1 收藏 / 2 取消,默认 1 + * + * 响应:`{ code }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const albumSub: NeteaseModule = (query, request) => { + const path = query.t === 2 ? "/api/album/sub/cancel" : "/api/album/sub"; + const data = { id: query.id }; + return request(path, data, createOption(query, "weapi")); +}; + +export default albumSub; diff --git a/electron/main/apis/netease/modules/album_sublist.ts b/electron/main/apis/netease/modules/album_sublist.ts new file mode 100644 index 00000000..b82f5ee3 --- /dev/null +++ b/electron/main/apis/netease/modules/album_sublist.ts @@ -0,0 +1,22 @@ +/** + * 用户收藏的专辑列表 + * + * params: + * - limit / offset 分页 + * + * 响应:`{ code, count, hasMore, data: [{ id, name, picUrl, artists, ... }] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const albumSublist: NeteaseModule = (query, request) => { + const data = { + limit: query.limit ?? 50, + offset: query.offset ?? 0, + total: true, + }; + return request("/api/album/sublist", data, createOption(query, "weapi")); +}; + +export default albumSublist; diff --git a/electron/main/apis/netease/modules/artist_album.ts b/electron/main/apis/netease/modules/artist_album.ts new file mode 100644 index 00000000..a603ceec --- /dev/null +++ b/electron/main/apis/netease/modules/artist_album.ts @@ -0,0 +1,24 @@ +/** + * 歌手专辑列表 + * + * params: + * - id 歌手 id + * - limit 返回数量,默认 30 + * - offset 偏移 + * + * 响应:`{ code, hotAlbums: [...], more, artist }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const artistAlbum: NeteaseModule = (query, request) => { + const data = { + limit: query.limit ?? 30, + offset: query.offset ?? 0, + total: true, + }; + return request(`/api/artist/albums/${query.id}`, data, createOption(query, "weapi")); +}; + +export default artistAlbum; diff --git a/electron/main/apis/netease/modules/artist_songs.ts b/electron/main/apis/netease/modules/artist_songs.ts new file mode 100644 index 00000000..4eaf0990 --- /dev/null +++ b/electron/main/apis/netease/modules/artist_songs.ts @@ -0,0 +1,28 @@ +/** + * 歌手全部歌曲(分页) + * + * params: + * - id 歌手 id + * - order 排序:hot(热门,默认) / time(时间) + * - limit 返回数量,默认 50 + * - offset 偏移 + * + * 响应:`{ code, songs: NeteaseSong[], more, total }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const artistSongs: NeteaseModule = (query, request) => { + const data = { + id: query.id, + private_cloud: "true", + work_type: 1, + order: query.order ?? "hot", + offset: query.offset ?? 0, + limit: query.limit ?? 50, + }; + return request("/api/v1/artist/songs", data, createOption(query, "weapi")); +}; + +export default artistSongs; diff --git a/electron/main/apis/netease/modules/artist_sublist.ts b/electron/main/apis/netease/modules/artist_sublist.ts new file mode 100644 index 00000000..6f258275 --- /dev/null +++ b/electron/main/apis/netease/modules/artist_sublist.ts @@ -0,0 +1,22 @@ +/** + * 用户收藏的歌手列表 + * + * params: + * - limit / offset 分页 + * + * 响应:`{ code, count, hasMore, data: [{ id, name, picUrl, albumSize, ... }] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const artistSublist: NeteaseModule = (query, request) => { + const data = { + limit: query.limit ?? 50, + offset: query.offset ?? 0, + total: true, + }; + return request("/api/artist/sublist", data, createOption(query, "weapi")); +}; + +export default artistSublist; diff --git a/electron/main/apis/netease/modules/artists.ts b/electron/main/apis/netease/modules/artists.ts new file mode 100644 index 00000000..44e6c425 --- /dev/null +++ b/electron/main/apis/netease/modules/artists.ts @@ -0,0 +1,16 @@ +/** + * 歌手信息与热门歌曲 + * + * params: + * - id 歌手 id + * + * 响应:`{ code, artist: {...}, hotSongs: NeteaseSong[], more }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const artists: NeteaseModule = (query, request) => + request(`/api/v1/artist/${query.id}`, {}, createOption(query, "weapi")); + +export default artists; diff --git a/electron/main/apis/netease/modules/index.ts b/electron/main/apis/netease/modules/index.ts index db5e26fc..cfad0876 100644 --- a/electron/main/apis/netease/modules/index.ts +++ b/electron/main/apis/netease/modules/index.ts @@ -23,6 +23,7 @@ import register_anonimous from "./register_anonimous"; // 用户 import user_account from "./user_account"; import user_cloud from "./user_cloud"; +import user_cloud_del from "./user_cloud_del"; import user_detail from "./user_detail"; import user_detail_new from "./user_detail_new"; import user_followeds from "./user_followeds"; @@ -48,6 +49,35 @@ import lyric from "./lyric"; import lyric_new from "./lyric_new"; import cloud_lyric_get from "./cloud_lyric_get"; +// 播放 +import song_detail from "./song_detail"; +import song_url from "./song_url"; + +// 歌单 / 喜欢 +import playlist_detail from "./playlist_detail"; +import playlist_create from "./playlist_create"; +import playlist_delete from "./playlist_delete"; +import playlist_tracks from "./playlist_tracks"; +import playlist_subscribe from "./playlist_subscribe"; +import playlist_name_update from "./playlist_name_update"; +import playlist_desc_update from "./playlist_desc_update"; +import playlist_order_update from "./playlist_order_update"; +import likelist from "./likelist"; +import like from "./like"; + +// 专辑 +import album from "./album"; +import album_sub from "./album_sub"; + +// 歌手 +import artists from "./artists"; +import artist_album from "./artist_album"; +import artist_songs from "./artist_songs"; + +// 用户收藏 +import album_sublist from "./album_sublist"; +import artist_sublist from "./artist_sublist"; + export const modules: Record = { captcha_sent, captcha_verify, @@ -63,6 +93,7 @@ export const modules: Record = { user_account, user_cloud, + user_cloud_del, user_detail, user_detail_new, user_followeds, @@ -85,4 +116,28 @@ export const modules: Record = { lyric, lyric_new, cloud_lyric_get, + + song_detail, + song_url, + + playlist_detail, + playlist_create, + playlist_delete, + playlist_tracks, + playlist_subscribe, + playlist_name_update, + playlist_desc_update, + playlist_order_update, + likelist, + like, + + album, + album_sub, + + artists, + artist_album, + artist_songs, + + album_sublist, + artist_sublist, }; diff --git a/electron/main/apis/netease/modules/like.ts b/electron/main/apis/netease/modules/like.ts new file mode 100644 index 00000000..fa3da44e --- /dev/null +++ b/electron/main/apis/netease/modules/like.ts @@ -0,0 +1,24 @@ +/** + * 红心 / 取消红心 + * + * params: + * - id 歌曲 id + * - like true 红心,false 取消 + * + * 响应:`{ code, songs: [], playlistId }` + * code !== 200 表示失败(如未登录、歌单未创建等) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const like: NeteaseModule = (query, request) => { + const data = { + trackId: query.id, + like: query.like === true || query.like === "true", + time: 3, + }; + return request("/api/song/like", data, createOption(query, "weapi")); +}; + +export default like; diff --git a/electron/main/apis/netease/modules/likelist.ts b/electron/main/apis/netease/modules/likelist.ts new file mode 100644 index 00000000..1406948e --- /dev/null +++ b/electron/main/apis/netease/modules/likelist.ts @@ -0,0 +1,18 @@ +/** + * 喜欢歌曲 id 列表 + * + * params: + * - uid 用户 id + * + * 响应:`{ code, checkPoint, ids: number[] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const likelist: NeteaseModule = (query, request) => { + const data = { uid: query.uid }; + return request("/api/song/like/get", data, createOption(query, "weapi")); +}; + +export default likelist; diff --git a/electron/main/apis/netease/modules/login_qr_create.ts b/electron/main/apis/netease/modules/login_qr_create.ts index 0f13d57c..63d485f3 100644 --- a/electron/main/apis/netease/modules/login_qr_create.ts +++ b/electron/main/apis/netease/modules/login_qr_create.ts @@ -1,8 +1,5 @@ /** * 生成二维码登录 URL(可选返回 data URL 图像) - * - * 注:原实现依赖 qrcode 包生成图像;这里只返回 URL, - * 渲染端可自行使用现成的 QR 生成器(更贴合 Electron 架构,避免再引入一个 Node 依赖)。 */ import type { NeteaseModule } from "../core/types"; diff --git a/electron/main/apis/netease/modules/playlist_create.ts b/electron/main/apis/netease/modules/playlist_create.ts new file mode 100644 index 00000000..8772ccb6 --- /dev/null +++ b/electron/main/apis/netease/modules/playlist_create.ts @@ -0,0 +1,24 @@ +/** + * 新建歌单 + * + * params: + * - name 歌单名 + * - privacy 0 公开 / 10 私密,默认 0 + * - type NORMAL / VIDEO,默认 NORMAL + * + * 响应:`{ code, id, playlist: { id, name, ... } }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const playlistCreate: NeteaseModule = (query, request) => { + const data = { + name: query.name, + privacy: query.privacy ?? 0, + type: query.type ?? "NORMAL", + }; + return request("/api/playlist/create", data, createOption(query, "weapi")); +}; + +export default playlistCreate; diff --git a/electron/main/apis/netease/modules/playlist_delete.ts b/electron/main/apis/netease/modules/playlist_delete.ts new file mode 100644 index 00000000..c1cb7ee2 --- /dev/null +++ b/electron/main/apis/netease/modules/playlist_delete.ts @@ -0,0 +1,17 @@ +/** + * 删除歌单 + * + * params: + * - id 歌单 id(单个) + * 响应:`{ code }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const playlistDelete: NeteaseModule = (query, request) => { + const data = { ids: `[${query.id}]` }; + return request("/api/playlist/remove", data, createOption(query, "weapi")); +}; + +export default playlistDelete; diff --git a/electron/main/apis/netease/modules/playlist_desc_update.ts b/electron/main/apis/netease/modules/playlist_desc_update.ts new file mode 100644 index 00000000..b216c316 --- /dev/null +++ b/electron/main/apis/netease/modules/playlist_desc_update.ts @@ -0,0 +1,19 @@ +/** + * 更新歌单描述 + * + * params: + * - id 歌单 id + * - desc 新描述(空字符串清空) + * + * 响应:`{ code }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const playlistDescUpdate: NeteaseModule = (query, request) => { + const data = { id: query.id, desc: query.desc ?? "" }; + return request("/api/playlist/desc/update", data, createOption(query, "eapi")); +}; + +export default playlistDescUpdate; diff --git a/electron/main/apis/netease/modules/playlist_detail.ts b/electron/main/apis/netease/modules/playlist_detail.ts new file mode 100644 index 00000000..630dc09c --- /dev/null +++ b/electron/main/apis/netease/modules/playlist_detail.ts @@ -0,0 +1,26 @@ +/** + * 歌单详情 + * + * params: + * - id 歌单 id + * - s 收藏者数量,默认 8 + * + * 响应:`{ code, playlist: { id, name, ..., tracks, trackIds }, privileges }` + * tracks 服务端可能截断;trackIds 是全量,需要时再走 song_detail 拉完整 song + * + * 加密:默认 eapi(对齐 NCM 增强版 playlist_detail 行为) + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const playlistDetail: NeteaseModule = (query, request) => { + const data = { + id: query.id, + n: 100000, + s: query.s ?? 8, + }; + return request("/api/v6/playlist/detail", data, createOption(query)); +}; + +export default playlistDetail; diff --git a/electron/main/apis/netease/modules/playlist_name_update.ts b/electron/main/apis/netease/modules/playlist_name_update.ts new file mode 100644 index 00000000..0fe2f926 --- /dev/null +++ b/electron/main/apis/netease/modules/playlist_name_update.ts @@ -0,0 +1,19 @@ +/** + * 更新歌单名 + * + * params: + * - id 歌单 id + * - name 新名字 + * + * 响应:`{ code }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const playlistNameUpdate: NeteaseModule = (query, request) => { + const data = { id: query.id, name: query.name }; + return request("/api/playlist/update/name", data, createOption(query, "eapi")); +}; + +export default playlistNameUpdate; diff --git a/electron/main/apis/netease/modules/playlist_order_update.ts b/electron/main/apis/netease/modules/playlist_order_update.ts new file mode 100644 index 00000000..9b577bc7 --- /dev/null +++ b/electron/main/apis/netease/modules/playlist_order_update.ts @@ -0,0 +1,18 @@ +/** + * 调整"我的歌单"显示顺序(仅自建) + * + * params: + * - ids 期望顺序的歌单 id 数组(JSON 字符串) + * + * 响应:`{ code }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const playlistOrderUpdate: NeteaseModule = (query, request) => { + const data = { ids: query.ids }; + return request("/api/playlist/order/update", data, createOption(query, "weapi")); +}; + +export default playlistOrderUpdate; diff --git a/electron/main/apis/netease/modules/playlist_subscribe.ts b/electron/main/apis/netease/modules/playlist_subscribe.ts new file mode 100644 index 00000000..5d64514b --- /dev/null +++ b/electron/main/apis/netease/modules/playlist_subscribe.ts @@ -0,0 +1,20 @@ +/** + * 订阅 / 取消订阅歌单 + * + * params: + * - id 歌单 id + * - t 1 订阅 / 2 取消,默认 1 + * + * 响应:`{ code }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const playlistSubscribe: NeteaseModule = (query, request) => { + const action = query.t === 2 ? "unsubscribe" : "subscribe"; + const data = { id: query.id }; + return request(`/api/playlist/${action}`, data, createOption(query, "eapi")); +}; + +export default playlistSubscribe; diff --git a/electron/main/apis/netease/modules/playlist_tracks.ts b/electron/main/apis/netease/modules/playlist_tracks.ts new file mode 100644 index 00000000..fb81f474 --- /dev/null +++ b/electron/main/apis/netease/modules/playlist_tracks.ts @@ -0,0 +1,40 @@ +/** + * 歌单增/删歌曲 + * + * params: + * - op "add" 或 "del" + * - pid 歌单 id + * - tracks 曲目 id 列表(逗号分隔字符串,由 wrapper 拼好) + * + * 响应:`{ code, count, trackIds, cloudCount }` + * + * 走默认 eapi。服务端 512 表示重复/受限,按 NCM 现行实现重试时把 trackIds 翻倍以强制写入 + */ + +import { createOption } from "../core/option"; +import { NeteaseRequestError } from "../core/request"; +import type { NeteaseModule } from "../core/types"; + +const playlistTracks: NeteaseModule = async (query, request) => { + const tracks = String(query.tracks ?? "").split(","); + const buildData = (ids: string[]) => ({ + op: query.op, + pid: query.pid, + trackIds: JSON.stringify(ids), + imme: "true", + }); + try { + return await request("/api/playlist/manipulate/tracks", buildData(tracks), createOption(query)); + } catch (err) { + if (err instanceof NeteaseRequestError && err.response.body?.code === 512) { + return request( + "/api/playlist/manipulate/tracks", + buildData([...tracks, ...tracks]), + createOption(query), + ); + } + throw err; + } +}; + +export default playlistTracks; diff --git a/electron/main/apis/netease/modules/song_detail.ts b/electron/main/apis/netease/modules/song_detail.ts new file mode 100644 index 00000000..d8714a60 --- /dev/null +++ b/electron/main/apis/netease/modules/song_detail.ts @@ -0,0 +1,24 @@ +/** + * 获取歌曲详情 + * + * params: + * - ids 歌曲 id(单个或逗号分隔) + * + * 响应:`{ code, songs: [{ id, name, ar, al, dt, ... }], privileges: [...] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const song_detail: NeteaseModule = (query, request) => { + const ids = String(query.ids ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const data = { + c: `[${ids.map((id) => `{"id":${id}}`).join(",")}]`, + }; + return request("/api/v3/song/detail", data, createOption(query)); +}; + +export default song_detail; diff --git a/electron/main/apis/netease/modules/song_url.ts b/electron/main/apis/netease/modules/song_url.ts new file mode 100644 index 00000000..064abab0 --- /dev/null +++ b/electron/main/apis/netease/modules/song_url.ts @@ -0,0 +1,27 @@ +/** + * 获取歌曲播放地址(v1 端点,level 而非裸 br) + * + * params: + * - id / ids 歌曲 id(单个或逗号分隔) + * - level 音质 level,默认 exhigh(320k MP3) + * standard / exhigh / lossless / hires / jyeffect / sky / jymaster + * + * 响应:`{ code, data: [{ id, url, br, level, freeTrialInfo, ... }] }` + * - url == null:VIP / 版权未开放 + * - freeTrialInfo != null:仅 30s 试听片段 + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const song_url: NeteaseModule = (query, request) => { + const ids = query.id ?? query.ids; + const data = { + ids: `[${String(ids).split(",").join(",")}]`, + level: query.level ?? "exhigh", + encodeType: "flac", + }; + return request("/api/song/enhance/player/url/v1", data, createOption(query)); +}; + +export default song_url; diff --git a/electron/main/apis/netease/modules/user_cloud_del.ts b/electron/main/apis/netease/modules/user_cloud_del.ts new file mode 100644 index 00000000..656060cd --- /dev/null +++ b/electron/main/apis/netease/modules/user_cloud_del.ts @@ -0,0 +1,19 @@ +/** + * 删除云盘歌曲 + * + * params: + * - id 歌曲 id 或 id 列表 + * 响应:`{ code }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const userCloudDel: NeteaseModule = (query, request) => { + const raw = query.id; + const ids = Array.isArray(raw) ? raw : [raw]; + const data = { songIds: JSON.stringify(ids) }; + return request("/api/cloud/del", data, createOption(query, "weapi")); +}; + +export default userCloudDel; diff --git a/electron/main/apis/qqmusic/modules/search.ts b/electron/main/apis/qqmusic/modules/search.ts index 88b9aba0..3575abb7 100644 --- a/electron/main/apis/qqmusic/modules/search.ts +++ b/electron/main/apis/qqmusic/modules/search.ts @@ -1,18 +1,21 @@ /** - * 搜索歌曲 - * module: music.search.SearchCgiService / DoSearchForQQMusicLite + * QM 搜索 * - * params: - * - keywords 关键词(必填) - * - page 页码,默认 1 - * - limit 每页数,默认 30 - * - type 0 单曲 / 1 歌手 / 2 专辑 / 3 歌单 / 4 MV / 7 歌词 / 8 用户,默认 0 + * 端点:musicu.fcg / SearchCgiService / DoSearchForQQMusicMobile */ import { qmRequest } from "../core/request"; import { formatSingerName } from "../core/config"; import type { QMModule } from "../core/types"; +/** 移动端随机 search_id */ +const genSearchId = (): string => + String( + Math.floor(Math.random() * 20) * 18014398509481984 + + Math.floor(Math.random() * 4194304) * 4294967296 + + (Date.now() % 86400000), + ); + interface QMSong { id: number; mid: string; @@ -20,41 +23,35 @@ interface QMSong { interval: number; singer?: Array<{ id?: number; mid?: string; name?: string }>; album?: { id?: number; mid?: string; name?: string }; - file?: { - media_mid?: string; - size_128mp3?: number; - size_320mp3?: number; - size_flac?: number; - size_hires?: number; - }; + file?: { media_mid?: string }; +} +interface SongsResp { + body?: { item_song?: QMSong[] }; + meta?: { sum?: number; estimate_sum?: number; nextpage?: number }; } -/** 搜索 search_id:移动端随机数,用于服务端日志/去重 */ -const genSearchId = (): string => - String( - Math.floor(Math.random() * 20) * 18014398509481984 + - Math.floor(Math.random() * 4194304) * 4294967296 + - (Date.now() % 86400000), +const searchSongs = async (keywords: string, page: number, limit: number) => { + const data = await qmRequest( + "music.search.SearchCgiService", + "DoSearchForQQMusicMobile", + { + search_id: genSearchId(), + remoteplace: "search.android.keyboard", + query: keywords, + search_type: 0, + num_per_page: limit, + page_num: page, + highlight: 0, + nqc_flag: 0, + multi_zhida: 0, + cat: 2, + grp: 1, + sin: 0, + sem: 0, + page_id: 1, + }, ); -const search: QMModule = async (params) => { - const { keywords, page = 1, limit = 30, type = 0 } = params; - - const data = await qmRequest<{ - body?: { item_song?: QMSong[]; meta?: { sum?: number } }; - }>("music.search.SearchCgiService", "DoSearchForQQMusicLite", { - search_id: genSearchId(), - remoteplace: "search.android.keyboard", - query: keywords, - search_type: type, - num_per_page: limit, - page_num: page, - highlight: 0, - nqc_flag: 0, - page_id: 1, - grp: 1, - }); - const songs = (data?.body?.item_song ?? []).map((song) => ({ id: String(song.id), mid: song.mid, @@ -66,11 +63,29 @@ const search: QMModule = async (params) => { mediaMid: song.file?.media_mid ?? "", })); - return { - code: 200, - total: data?.body?.meta?.sum ?? songs.length, - songs, + const total = data?.meta?.estimate_sum ?? data?.meta?.sum ?? songs.length; + return { code: 200, total, songs }; +}; + +const search: QMModule = async (params) => { + const { + keywords, + page = 1, + limit = 30, + type = 0, + } = params as { + keywords?: string; + page?: number; + limit?: number; + type?: number; }; + + if (!keywords) return { code: 400, total: 0, message: "keywords required" }; + + // 仅单曲;其他类型由渲染端返回空 + if (type !== 0) return { code: 200, total: 0 }; + + return searchSongs(keywords, page, limit); }; export default search; diff --git a/electron/main/core/index.ts b/electron/main/core/index.ts index 56078891..ce84e0f6 100644 --- a/electron/main/core/index.ts +++ b/electron/main/core/index.ts @@ -8,6 +8,7 @@ import { initDatabase, closeDatabase } from "@main/database"; import { init as initSongCache } from "@main/services/songCache"; import { pluginRegistry } from "@main/plugins/registry"; import { registerCacheScheme, handleCacheProtocol } from "@main/utils/protocol"; +import { startServer, stopServer } from "@main/server"; import { coreLog, initLogger } from "@main/utils/logger"; /** @@ -73,6 +74,8 @@ export const initApp = (): void => { restoreLyricWindows(); // 注册全局快捷键 initGlobalHotkey(); + // 启动外部 API 服务(fire-and-forget;监听结果通过 getStatus 暴露给渲染端) + void startServer(); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); }); @@ -90,6 +93,7 @@ export const initApp = (): void => { coreLog.info("应用即将退出,清理资源"); shutdownMedia(); closeDatabase(); + void stopServer(); void pluginRegistry.shutdown(); }); }; diff --git a/electron/main/ipc/apis.ts b/electron/main/ipc/apis.ts index e454d436..25cc54ae 100644 --- a/electron/main/ipc/apis.ts +++ b/electron/main/ipc/apis.ts @@ -7,9 +7,11 @@ */ import { ipcMain } from "electron"; -import { callNetease, clearNeteaseCookies } from "@main/apis/netease"; +import { callNetease, clearNeteaseCookies, mergeNeteaseCookies } from "@main/apis/netease"; +import { cookieToJson } from "@main/apis/netease/core/cookie"; import { callQQMusic } from "@main/apis/qqmusic"; import { callKugou } from "@main/apis/kugou"; +import { openNeteaseLoginWindow } from "@main/window/login"; import { coreLog } from "@main/utils/logger"; import type { ApiPlatform } from "@shared/types/apis"; @@ -54,4 +56,27 @@ export const registerApisIpc = (): void => { ipcMain.handle("apis:clearSession", (_evt, platform: ApiPlatform) => { if (platform === "netease") clearNeteaseCookies(); }); + + // 打开 NCM 官方网页登录,成功后把 cookies 合并写入 session + ipcMain.handle("apis:openLoginWeb", async (_evt, platform: ApiPlatform) => { + if (platform !== "netease") return { ok: false, error: "unsupported platform" }; + try { + const cookies = await openNeteaseLoginWindow(); + if (!cookies) return { ok: false, error: "canceled" }; + mergeNeteaseCookies(cookies); + return { ok: true }; + } catch (err) { + coreLog.warn("[apis] openLoginWeb failed:", err); + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }); + + // 手动写入 cookie 登录 + ipcMain.handle("apis:setCookie", (_evt, platform: ApiPlatform, raw: string) => { + if (platform !== "netease") return { ok: false, error: "unsupported platform" }; + const parsed = cookieToJson(raw); + if (!parsed.MUSIC_U) return { ok: false, error: "missing MUSIC_U" }; + mergeNeteaseCookies(parsed); + return { ok: true }; + }); }; diff --git a/electron/main/ipc/externalApi.ts b/electron/main/ipc/externalApi.ts new file mode 100644 index 00000000..647d457d --- /dev/null +++ b/electron/main/ipc/externalApi.ts @@ -0,0 +1,14 @@ +/** + * 外部 API 服务相关 IPC + * + * - restart:重启服务并返回新状态(端口占用/错误经 ExternalApiStatus.error 暴露) + * - getStatus:查询当前运行状态(面板挂载时拉一次) + */ + +import { ipcMain } from "electron"; +import { restartServer, getServerStatus } from "@main/server"; + +export const registerExternalApiIpc = (): void => { + ipcMain.handle("externalApi:restart", () => restartServer()); + ipcMain.handle("externalApi:getStatus", () => getServerStatus()); +}; diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index ba248ec8..57cdc48c 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -11,6 +11,7 @@ import { registerHotkeyIpc } from "./hotkey"; import { registerThemeIpc } from "./theme"; import { registerStreamingIpc } from "./streaming"; import { registerCacheIpc } from "./cache"; +import { registerExternalApiIpc } from "./externalApi"; /** 注册所有 IPC 处理 */ export const registerIpcHandlers = (): void => { @@ -27,4 +28,5 @@ export const registerIpcHandlers = (): void => { registerThemeIpc(); registerStreamingIpc(); registerCacheIpc(); + registerExternalApiIpc(); }; diff --git a/electron/main/ipc/lyrics.ts b/electron/main/ipc/lyrics.ts index 296cb00e..8779b981 100644 --- a/electron/main/ipc/lyrics.ts +++ b/electron/main/ipc/lyrics.ts @@ -37,7 +37,12 @@ const dedup = (key: string, run: () => Promise): Promise => { return promise; }; -/** 按 (platform, id) 直取 */ +/** + * 按 (platform, id) 直取 + * @param platform 平台 + * @param id 平台 id + * @returns 歌词匹配结果 + */ const resolveById = async (platform: Platform, id: string): Promise => { try { switch (platform) { @@ -91,9 +96,11 @@ const resolveTTMLOverlay = async ( }; const fingerprint = buildFingerprint(track); const cached = getMatchedId(fingerprint, platform); - // QM mid 放前面 + // QM mid 放前面(AMLL DB 早期 QM 条目以 mid 为文件名的居多) if (platform === "qqmusic") push(cached?.extra?.mid); - if (track.platform === platform) push(track.id); + if (track.source === platform) push(track.id); + // QM 在线 Track 默认走 byId + if (track.source === platform) push(track.extId); push(cached?.platformId); if (ids.length === 0) return { ok: true, data: null }; return { ok: true, data: await fetchTTML(platform, ids) }; diff --git a/electron/main/ipc/nowPlaying.ts b/electron/main/ipc/nowPlaying.ts index 4baa2a4b..cb533d2f 100644 --- a/electron/main/ipc/nowPlaying.ts +++ b/electron/main/ipc/nowPlaying.ts @@ -1,5 +1,6 @@ import { ipcMain } from "electron"; import { broadcast } from "@main/utils/broadcast"; +import { wsBroadcast } from "@main/server/broadcast"; import * as nowPlaying from "@main/services/nowPlaying"; import type { NowPlayingUpdatePayload } from "@shared/types/nowPlaying"; @@ -17,9 +18,19 @@ export const registerNowPlayingIpc = (): void => { // 窗口拉取当前完整快照 ipcMain.handle("nowPlaying:requestSnapshot", () => nowPlaying.snapshot()); - // 订阅 service 事件并广播给所有窗口 - nowPlaying.onTrackChange((data) => broadcast("nowPlaying:track-change", data)); - nowPlaying.onLyricChange((snap) => broadcast("nowPlaying:lyric-change", snap)); + // 订阅 service 事件:同时广播给渲染端窗口和外部 WS 客户端 + // position-sync 高频(5Hz),WS 端走 HIGH_FREQ_EVENTS 过滤自动跳过 + nowPlaying.onTrackChange((data) => { + broadcast("nowPlaying:track-change", data); + wsBroadcast({ type: "track", data }); + }); + nowPlaying.onLyricChange((snap) => { + broadcast("nowPlaying:lyric-change", snap); + wsBroadcast({ type: "lyric", data: { source: snap.source, lyric: snap.lyric } }); + }); nowPlaying.onPositionSync((data) => broadcast("nowPlaying:position-sync", data)); - nowPlaying.onLyricOffsetChange((data) => broadcast("nowPlaying:lyric-offset-change", data)); + nowPlaying.onLyricOffsetChange((data) => { + broadcast("nowPlaying:lyric-offset-change", data); + wsBroadcast({ type: "lyricOffset", data }); + }); }; diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index e2bac773..8de5aede 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import { app, ipcMain, powerMonitor } from "electron"; import { sendToMain } from "@main/utils/broadcast"; +import { wsBroadcast } from "@main/server/broadcast"; import { toCacheUrl } from "@main/utils/protocol"; import { toMs } from "@main/utils/time"; import * as mediaService from "@main/services/media"; @@ -57,7 +58,7 @@ const registerNativeEvents = (inst: InstanceType 0) setTaskbarProgress(posMs / durMs); break; } case "fftData": { - sendToMain("player:event", { - type: "fftData", - data: event.fftData ?? [], - }); + const fftEvent = { type: "fftData", data: event.fftData ?? [] }; + sendToMain("player:event", fftEvent); + wsBroadcast(fftEvent); break; } case "outputStalled": { @@ -122,11 +134,12 @@ export const registerPlayerIpc = (): void => { ipcMain.handle("player:load", async (_event, source: string, options: LoadOptions = {}) => { const autoPlay = options.autoPlay ?? true; const authoritative = options.meta ?? null; - const isStreaming = authoritative?.source === "streaming"; + // 非本地音源 + const isRemote = authoritative != null && authoritative.source !== "local"; const seq = ++loadSeq; try { const inst = getPlayer(); - sendToMain("player:event", { + const loadingEvent = { type: "status", data: { state: "loading", @@ -135,7 +148,9 @@ export const registerPlayerIpc = (): void => { volume: inst.getVolume(), isFinished: false, }, - }); + }; + sendToMain("player:event", loadingEvent); + wsBroadcast(loadingEvent); // 写一次 SMTC/托盘/标题 const applyDisplay = ( title: string, @@ -170,12 +185,12 @@ export const registerPlayerIpc = (): void => { : formatArtists(parseArtists(meta.artist ?? "")); const displayAlbum = authoritative?.album?.name ?? parseAlbum(meta.album ?? "")?.name ?? ""; // 本地封面 - const localCover = isStreaming ? null : (inst.getCoverRaw() ?? null); + const localCover = isRemote ? null : (inst.getCoverRaw() ?? null); applyDisplay(displayTitle, displayArtist, displayAlbum, localCover ?? undefined, durationMs); - // 流媒体高清封面 - if (isStreaming && authoritative?.cover && /^https?:\/\//i.test(authoritative.cover)) { - const coverUrl = authoritative.cover; - void fetchBytes(coverUrl).then((buf) => { + // 远端高清封面 + const remoteCover = isRemote ? (authoritative?.coverOriginal ?? authoritative?.cover) : null; + if (remoteCover && /^https?:\/\//i.test(remoteCover)) { + void fetchBytes(remoteCover).then((buf) => { if (!buf) return; if (seq !== loadSeq) return; mediaService.setMetadata({ @@ -202,7 +217,7 @@ export const registerPlayerIpc = (): void => { }, mediaInfo: { duration: durationMs, - cover: isStreaming ? undefined : toCacheUrl(meta.cover), + cover: isRemote ? undefined : toCacheUrl(meta.cover), quality, }, }; @@ -371,6 +386,7 @@ export const registerPlayerIpc = (): void => { ipcMain.handle("player:setSpeed", (_event, speed: number) => { try { getPlayer().setSpeed(speed); + nowPlaying.onSpeedChange(speed); return { success: true }; } catch (error) { return fail(ErrorCode.UNKNOWN, error); @@ -545,10 +561,12 @@ export const registerPlayerIpc = (): void => { playerLog.error("重建音频输出全部失败,销毁播放器实例"); resetPlayer(); stopDevicePolling(); - sendToMain("player:event", { + const stoppedEvent = { type: "status", data: { state: "stopped", position: 0, duration: 0, volume: 1, isFinished: false }, - }); + }; + sendToMain("player:event", stoppedEvent); + wsBroadcast(stoppedEvent); }; powerMonitor.on("resume", resumeHandler); // 退出前停止设备轮询 diff --git a/electron/main/plugins/lx-shim.ts b/electron/main/plugins/lx-shim.ts index 829e0fca..ea944a94 100644 --- a/electron/main/plugins/lx-shim.ts +++ b/electron/main/plugins/lx-shim.ts @@ -83,7 +83,7 @@ export interface LxCurrentScriptInfo { rawScript: string; } -/** lx.utils — 对齐 lx-music-desktop 的签名(不同于 splayer.utils) */ +/** lx.utils — 参数签名与 lx-music 老脚本兼容(与自家 splayer.utils 不同) */ const buildLxUtils = (): object => ({ crypto: { // 注意:lx 的参数顺序是 (buffer, mode, key, iv),与我们自家 splayer.utils 不同 @@ -172,19 +172,48 @@ export const installLxShim = ( const o = opts ?? {}; const method = ((o.method as string) ?? "GET").toUpperCase() as "GET" | "POST"; const timeout = typeof o.timeout === "number" ? (o.timeout as number) : undefined; - const headers = (o.headers as Record) ?? {}; - const body = (o.body ?? o.form ?? o.formData) as - | string - | Uint8Array - | ArrayBuffer - | undefined; - + // headers 强制转纯字符串字典,避免脚本传 Proxy / 带函数的对象触发结构化克隆失败 + const headers: Record = {}; + const rawHeaders = (o.headers as Record | undefined) ?? {}; + for (const [k, v] of Object.entries(rawHeaders)) { + if (typeof v === "string") headers[k] = v; + else if (v != null) headers[k] = String(v); + } + // form/formData 在 needle 里会被 urlencode/multipart 序列化 + // 这里 body 优先,其次 form/formData 走 URLSearchParams 序列化并补 content-type + const rawBody = o.body; + const rawForm = (o.form ?? o.formData) as Record | undefined; + let body: string | Uint8Array | ArrayBuffer | undefined; + let formContentType: string | undefined; + if (rawBody != null) { + if (typeof rawBody === "string") body = rawBody; + else if (rawBody instanceof Uint8Array || rawBody instanceof ArrayBuffer) body = rawBody; + else if (typeof rawBody === "object") { + try { + body = JSON.stringify(rawBody); + } catch { + body = undefined; + } + } + } else if (rawForm && typeof rawForm === "object") { + const usp = new URLSearchParams(); + for (const [field, value] of Object.entries(rawForm)) { + if (value == null) continue; + usp.append(field, String(value)); + } + body = usp.toString(); + formContentType = "application/x-www-form-urlencoded"; + } + // 默认 content-type 放前面,让用户传的 headers 仍能覆盖 + const finalHeaders: Record = formContentType + ? { "content-type": formContentType, ...headers } + : headers; let aborted = false; splayer .request(url, { method, - headers, + headers: finalHeaders, body, timeout, responseType: "text", @@ -269,8 +298,10 @@ export const installLxShim = ( if (host) mapped.add(host); } const actions = (cap.actions ?? []).filter( - (a): a is PluginAction => a === "musicUrl", + (action): action is PluginAction => action === "musicUrl", ); + // 不支持任何宿主已识别动作的源直接丢弃,避免 audioSource 误把 + // lyric-only 脚本当成 musicUrl 候选去调,结果走到归一校验抛 PLUGIN_INVALID_RESULT if (actions.length === 0) continue; normalized[key] = { name: cap.name ?? key, @@ -326,6 +357,7 @@ export const installLxShim = ( const registerAction = (action: PluginAction): void => { handlers.set(action, async (req: unknown) => { if (!requestHandler) { + splayer.log.warn("[lx-shim] no request handler registered for action", action); throw Object.assign(new Error("lx plugin has not registered request handler"), { code: "PLUGIN_NOT_READY", }); @@ -334,14 +366,21 @@ export const installLxShim = ( const source = (reqObj.source as string) ?? ""; // lx 期待 128k/320k/flac/... 音质字符串,宿主的 quality 做一次翻译 const hostQuality = reqObj.quality as PluginQuality | undefined; + const lxType = hostQuality ? mapHostQualityToLx(hostQuality) : undefined; const info: Record = { - type: hostQuality ? mapHostQualityToLx(hostQuality) : undefined, + type: lxType, musicInfo: reqObj.musicInfo ?? {}, }; - const raw = await Promise.resolve(requestHandler({ source, action, info })); + // 严格归一为 { url },避免脚本回包夹带闭包/Proxy 导致 postMessage 克隆失败 if (typeof raw === "string") return { url: raw } as MusicUrlRes; - return raw; + if (raw && typeof raw === "object") { + const url = (raw as Record).url; + if (typeof url === "string") return { url } as MusicUrlRes; + } + throw Object.assign(new Error("lx plugin returned invalid musicUrl result"), { + code: "PLUGIN_INVALID_RESULT", + }); }); }; diff --git a/electron/main/plugins/registry.ts b/electron/main/plugins/registry.ts index b0f37fcb..0dfa5ccc 100644 --- a/electron/main/plugins/registry.ts +++ b/electron/main/plugins/registry.ts @@ -292,6 +292,12 @@ class PluginRegistry extends EventEmitter { // 沿当前状态再广播一次,渲染端就能拿到 updateInfo 字段 this.setStatus(rt, rt.status); }, + onSourcesUpdate: (sources) => { + // 异步 lx.send('inited') / splayer.register 触发 + if (rt.status.state !== "ready") return; + const merged = { ...rt.status.sources, ...sources }; + this.setStatus(rt, { state: "ready", sources: merged }); + }, onFatal: (error) => { // 同时记录到主日志,避免错误只在 UI 卡片里可见 coreLog.error(`[plugin:${rt.manifest.id}] fatal ${error.code}: ${error.message}`); diff --git a/electron/main/plugins/router.ts b/electron/main/plugins/router.ts index 960b7eb0..4c676848 100644 --- a/electron/main/plugins/router.ts +++ b/electron/main/plugins/router.ts @@ -11,10 +11,10 @@ import type { MusicUrlRes, PluginAction, PluginResolveUrlArgs, - SourceCapability, } from "@shared/types/plugin"; import { ACTION_TIMEOUTS, PluginErrorCodes } from "@shared/defaults/plugin-api"; import { pluginRegistry, type PluginRuntime } from "./registry"; +import { pluginLog } from "@main/utils/logger"; let reqSeq = 0; const nextRequestId = (): string => `r${Date.now().toString(36)}-${++reqSeq}`; @@ -53,25 +53,6 @@ const callOn = ( }); }; -/** 判断某插件的指定源是否支持该动作 */ -const supportsAction = ( - rt: PluginRuntime, - source: string | undefined, - action: PluginAction, -): { ok: boolean; source?: string } => { - if (rt.status.state !== "ready") return { ok: false }; - const sources = rt.status.sources; - if (source) { - const cap: SourceCapability | undefined = sources[source]; - if (!cap) return { ok: false }; - return { ok: cap.actions.includes(action), source }; - } - for (const [key, cap] of Object.entries(sources)) { - if (cap.actions.includes(action)) return { ok: true, source: key }; - } - return { ok: false }; -}; - export const resolveUrl = async (args: PluginResolveUrlArgs): Promise => { const rt = pluginRegistry.getRuntime(args.pluginId); if (!rt) { @@ -79,17 +60,21 @@ export const resolveUrl = async (args: PluginResolveUrlArgs): Promise(rt, "musicUrl", params, ACTION_TIMEOUTS.musicUrl); + try { + return await callOn(rt, "musicUrl", params, ACTION_TIMEOUTS.musicUrl); + } catch (err) { + pluginLog.warn("resolveUrl rejected", args.pluginId, (err as Error)?.message); + throw err; + } }; diff --git a/electron/main/plugins/sandbox.ts b/electron/main/plugins/sandbox.ts index 8b22f576..eda71cf1 100644 --- a/electron/main/plugins/sandbox.ts +++ b/electron/main/plugins/sandbox.ts @@ -36,6 +36,8 @@ export interface SandboxEvents { onFatal: (error: PluginErrorPayload) => void; /** 脚本上报"有新版本" */ onUpdateAvailable: (info: PluginUpdateInfo) => void; + /** sources 增量上报 */ + onSourcesUpdate: (sources: Record) => void; /** 子进程退出(可能是崩溃或主动 kill)。isCrash=true 表示非主动 kill */ onExit: (isCrash: boolean, code: number | null) => void; } @@ -245,6 +247,9 @@ export class Sandbox { case "updateAvailable": this.events.onUpdateAvailable(msg.info); return; + case "sourcesUpdate": + this.events.onSourcesUpdate(msg.sources); + return; case "log": this.events.onLog(msg.level, msg.args); return; diff --git a/electron/main/plugins/sandbox.worker.ts b/electron/main/plugins/sandbox.worker.ts index efd9341c..85c98304 100644 --- a/electron/main/plugins/sandbox.worker.ts +++ b/electron/main/plugins/sandbox.worker.ts @@ -42,7 +42,60 @@ if (!parentPort) { process.exit(1); } -const send = (msg: SandboxOut): void => parentPort.postMessage(msg); +/** + * 深度剥离不可克隆字段 + * 保留 string/number/bool/null/Uint8Array/纯字典/数组;丢函数/symbol; + * Buffer 转 Uint8Array、普通对象用 Object.create(null) 重建以脱掉 vm.Context 原型链 + * @param value - 任意值 + * @param depth - 递归深度上限 + */ +const sanitizeForIpc = (value: unknown, depth = 0): unknown => { + if (depth > 6) return null; + if (value == null) return value; + const t = typeof value; + if (t === "string" || t === "number" || t === "boolean" || t === "bigint") return value; + if (t === "function" || t === "symbol") return undefined; + if (Buffer.isBuffer(value)) return new Uint8Array(value); + if (value instanceof Uint8Array || value instanceof ArrayBuffer) return value; + if (Array.isArray(value)) { + return value.map((v) => sanitizeForIpc(v, depth + 1)).filter((v) => v !== undefined); + } + if (t === "object") { + const out: Record = Object.create(null); + try { + for (const k of Object.keys(value as object)) { + const cleaned = sanitizeForIpc((value as Record)[k], depth + 1); + if (cleaned !== undefined) out[k] = cleaned; + } + } catch { + // Proxy 的 ownKeys 可能抛,直接返回空字典 + } + return out; + } + return undefined; +}; + +/** + * 跨 worker→main 边界发消息 + * 直发;失败 → sanitize 后重发;仍失败 → 抛带 kind 上下文的错误(不是无意义的 "object could not be cloned") + * @param msg - 待发消息 + */ +const send = (msg: SandboxOut): void => { + try { + parentPort.postMessage(msg); + return; + } catch { + // 走 sanitize 重试 + try { + parentPort.postMessage(sanitizeForIpc(msg) as SandboxOut); + return; + } catch (err2) { + throw new Error( + `[sandbox] postMessage failed for kind=${msg.kind}: ${(err2 as Error).message}`, + ); + } + } +}; /** 等待主进程 init 再启动,避免 TDZ */ let initialized = false; @@ -73,7 +126,22 @@ const hostCall = (method: HostCallMethod, args: unknown[]): Promise => const callId = nextCallId(); return new Promise((resolve, reject) => { hostCallWaiters.set(callId, { resolve, reject }); - send({ kind: "hostCall", callId, method, args }); + try { + // 提前 sanitize,防止脚本传 Proxy / 闭包 / vm 对象触发 clone 失败 + send({ + kind: "hostCall", + callId, + method, + args: sanitizeForIpc(args) as unknown[], + }); + } catch (err) { + hostCallWaiters.delete(callId); + reject( + Object.assign(new Error((err as Error).message), { + code: "PLUGIN_ARGS_NOT_CLONEABLE", + }), + ); + } }); }; @@ -89,6 +157,8 @@ const buildSplayer = (init: Extract): HostApi => ({ register: (caps) => { registeredSources = { ...registeredSources, ...caps.sources }; + // 异步 register 时主进程的 status.sources 已经定格,主动通知刷新 + send({ kind: "sourcesUpdate", sources: registeredSources }); }, on: ( @@ -99,10 +169,15 @@ const buildSplayer = (init: Extract): HostApi => ({ }, log: { - debug: (...args) => send({ kind: "log", level: "debug", args }), - info: (...args) => send({ kind: "log", level: "info", args }), - warn: (...args) => send({ kind: "log", level: "warn", args }), - error: (...args) => send({ kind: "log", level: "error", args }), + // 脚本可能 console.log 整个 response 含 Buffer / vm 对象,先 sanitize 再发 + debug: (...args) => + send({ kind: "log", level: "debug", args: sanitizeForIpc(args) as unknown[] }), + info: (...args) => + send({ kind: "log", level: "info", args: sanitizeForIpc(args) as unknown[] }), + warn: (...args) => + send({ kind: "log", level: "warn", args: sanitizeForIpc(args) as unknown[] }), + error: (...args) => + send({ kind: "log", level: "error", args: sanitizeForIpc(args) as unknown[] }), }, storage: { @@ -206,6 +281,10 @@ const runScript = (init: Extract): void => { URLSearchParams, TextEncoder, TextDecoder, + // Web 标准 base64 + // vm.createContext 模式下必须显式注入,否则 ReferenceError 把脚本打成 fatal + btoa: (str: string): string => Buffer.from(str, "binary").toString("base64"), + atob: (str: string): string => Buffer.from(str, "base64").toString("binary"), // console 转发到 log console: { log: splayer.log.info, @@ -227,6 +306,8 @@ const runScript = (init: Extract): void => { handlers, (sources) => { registeredSources = { ...registeredSources, ...sources }; + // 同 register:异步 lx.send('inited') 时也要补发 sourcesUpdate + send({ kind: "sourcesUpdate", sources: registeredSources }); }, (info) => { send({ kind: "updateAvailable", info }); @@ -338,7 +419,13 @@ parentPort.on("message", async (event) => { error: { code: "PLUGIN_CANCELLED", message: "cancelled" }, }); } else { - send({ kind: "result", requestId: msg.requestId, ok: true, data }); + // 兜底 sanitize:lx-shim 已归一过的结果再剥一次原型链,保证跨进程一定 cloneable + send({ + kind: "result", + requestId: msg.requestId, + ok: true, + data: sanitizeForIpc(data), + }); } } catch (err) { inflight.delete(msg.requestId); diff --git a/electron/main/server/broadcast.ts b/electron/main/server/broadcast.ts new file mode 100644 index 00000000..5f3d5427 --- /dev/null +++ b/electron/main/server/broadcast.ts @@ -0,0 +1,47 @@ +/** + * WS 客户端注册表 + 事件广播 + * + * 仅负责"推给外部 WS 客户端"这一件事; + * 给渲染端的推送由调用方各自 sendToMain,两条路径独立。 + */ + +import { serverLog } from "@main/utils/logger"; +import type { WSContext } from "hono/ws"; + +/** 当前在线的 WS 客户端 */ +const wsClients = new Set(); + +/** 高频事件名单:默认不向外部 WS 推送(避免带宽爆炸) */ +const HIGH_FREQ_EVENTS = new Set(["fftData", "position"]); + +export const addWsClient = (ws: WSContext): void => { + wsClients.add(ws); + serverLog.info(`WS 客户端已连接,当前在线 ${wsClients.size}`); +}; + +export const removeWsClient = (ws: WSContext): void => { + if (wsClients.delete(ws)) { + serverLog.info(`WS 客户端已断开,当前在线 ${wsClients.size}`); + } +}; + +export const getWsClientCount = (): number => wsClients.size; + +/** + * 推送一条 player 事件给所有外部 WS 客户端 + * @param event - 事件对象 `{ type, data? }` + */ +export const wsBroadcast = (event: { type: string; data?: unknown }): void => { + if (wsClients.size === 0) return; + if (HIGH_FREQ_EVENTS.has(event.type)) return; + const payload = JSON.stringify({ kind: "event", ...event }); + for (const ws of wsClients) { + try { + ws.send(payload); + } catch (err) { + // 发送失败的多半已经断开但 onClose/onError 未触发;主动清理避免反复抛错 + serverLog.warn("WS 推送失败,移除失效客户端", err); + removeWsClient(ws); + } + } +}; diff --git a/electron/main/server/gate.ts b/electron/main/server/gate.ts new file mode 100644 index 00000000..8517ce4f --- /dev/null +++ b/electron/main/server/gate.ts @@ -0,0 +1,26 @@ +/** + * 外部 API 门禁中间件 + * - externalApi.enabled 总开关:关闭时 /api/* 与 /ws 全部 403 + * - externalApi.wsEnabled 子开关:关闭时 /ws 单独 403,REST 不受影响 + */ + +import type { MiddlewareHandler } from "hono"; +import { store } from "@main/store"; + +/** 总开关 */ +export const externalControlGate: MiddlewareHandler = async (c, next) => { + if (!store.get("externalApi.enabled")) { + return c.json({ error: "external API disabled" }, 403); + } + await next(); + return; +}; + +/** WS 子开关 */ +export const wsGate: MiddlewareHandler = async (c, next) => { + if (!store.get("externalApi.wsEnabled")) { + return c.json({ error: "WebSocket disabled" }, 403); + } + await next(); + return; +}; diff --git a/electron/main/server/index.ts b/electron/main/server/index.ts new file mode 100644 index 00000000..5aeaad46 --- /dev/null +++ b/electron/main/server/index.ts @@ -0,0 +1,113 @@ +/** + * 外部 API 服务器:HTTP (Hono) + WebSocket + */ + +import type { Server } from "node:http"; +import { Hono } from "hono"; +import { serve, upgradeWebSocket } from "@hono/node-server"; +import { WebSocketServer } from "ws"; +import { store } from "@main/store"; +import { serverLog } from "@main/utils/logger"; +import type { ExternalApiStatus } from "@shared/types/settings"; +import { externalControlGate, wsGate } from "./gate"; +import { buildRoutes } from "./routes"; +import { wsHandlers } from "./ws"; + +let runningServer: Server | null = null; +let runningWss: WebSocketServer | null = null; +let runningPort: number | null = null; +let lastError: { code: string; message: string } | null = null; + +export const getServerStatus = (): ExternalApiStatus => ({ + listening: runningServer !== null, + port: runningPort, + error: lastError, +}); + +/** 启动外部 API 服务 */ +export const startServer = (): Promise => { + return new Promise((resolve) => { + if (runningServer) { + resolve(getServerStatus()); + return; + } + + const port = store.get("externalApi.port"); + const hostname = "0.0.0.0"; + + const app = new Hono(); + app.use("/api/*", externalControlGate); + app.route("/api", buildRoutes()); + app.get( + "/ws", + externalControlGate, + wsGate, + upgradeWebSocket(() => wsHandlers), + ); + app.get("/", (c) => c.text("SPlayer Next external API")); + + const wss = new WebSocketServer({ noServer: true }); + let settled = false; + + const server = serve({ + fetch: app.fetch, + port, + hostname, + websocket: { server: wss }, + }) as Server; + + // error / listening 互斥:先到先 settle + server.once("error", (err: NodeJS.ErrnoException) => { + if (settled) return; + settled = true; + const error = { code: err.code ?? "UNKNOWN", message: err.message }; + serverLog.error(`外部 API 监听 ${port} 失败 (${error.code}): ${error.message}`); + wss.close(); + try { + server.close(); + } catch { + // server.close 在 listen 失败的情况下可能抛 ERR_SERVER_NOT_RUNNING,忽略 + } + runningServer = null; + runningWss = null; + runningPort = null; + lastError = error; + resolve(getServerStatus()); + }); + + server.once("listening", () => { + if (settled) return; + settled = true; + runningServer = server; + runningWss = wss; + runningPort = port; + lastError = null; + serverLog.info(`外部 API 已启动: http://${hostname}:${port}`); + resolve(getServerStatus()); + }); + }); +}; + +/** 停止外部 API 服务 */ +export const stopServer = (): Promise => { + if (!runningServer) return Promise.resolve(); + const server = runningServer; + const wss = runningWss; + runningServer = null; + runningWss = null; + runningPort = null; + return new Promise((resolve) => { + wss?.close(); + server.close((err) => { + if (err) serverLog.warn("外部 API 关闭异常:", err); + else serverLog.info("外部 API 已关闭"); + resolve(); + }); + }); +}; + +/** 配置变更后重启服务 */ +export const restartServer = async (): Promise => { + await stopServer(); + return startServer(); +}; diff --git a/electron/main/server/routes.ts b/electron/main/server/routes.ts new file mode 100644 index 00000000..49d65228 --- /dev/null +++ b/electron/main/server/routes.ts @@ -0,0 +1,86 @@ +/** + * 外部 API REST 路由 + * 控制路由:POST,状态查询:GET + */ + +import { Hono } from "hono"; +import { app as electronApp } from "electron"; +import { getPlayer } from "@main/services/engine"; +import { sendToMain } from "@main/utils/broadcast"; +import { toMs } from "@main/utils/time"; +import * as nowPlaying from "@main/services/nowPlaying"; +import { getWsClientCount } from "./broadcast"; + +export const buildRoutes = (): Hono => { + const api = new Hono(); + + api.get("/info", (c) => + c.json({ + name: electronApp.getName(), + version: electronApp.getVersion(), + wsClients: getWsClientCount(), + }), + ); + + api.get("/status", (c) => { + const raw = getPlayer().getStatus(); + return c.json({ + state: raw.state, + position: toMs(raw.position), + duration: toMs(raw.duration), + volume: raw.volume, + isFinished: raw.isFinished, + }); + }); + + api.get("/volume", (c) => c.json({ volume: getPlayer().getVolume() })); + + // 当前播放完整快照 + api.get("/now-playing", (c) => c.json(nowPlaying.snapshot())); + + api.post("/play", (c) => { + getPlayer().play(); + return c.json({ ok: true }); + }); + + api.post("/pause", (c) => { + getPlayer().pause(); + return c.json({ ok: true }); + }); + + api.post("/stop", (c) => { + getPlayer().stop(); + return c.json({ ok: true }); + }); + + api.post("/seek", async (c) => { + const body = (await c.req.json().catch(() => null)) as { positionMs?: number } | null; + const positionMs = Number(body?.positionMs); + if (!Number.isFinite(positionMs) || positionMs < 0) { + return c.json({ error: "positionMs (number, >=0) required" }, 400); + } + await getPlayer().seek(positionMs / 1000); + return c.json({ ok: true }); + }); + + api.post("/volume", async (c) => { + const body = (await c.req.json().catch(() => null)) as { volume?: number } | null; + const volume = Number(body?.volume); + if (!Number.isFinite(volume) || volume < 0 || volume > 1) { + return c.json({ error: "volume (number, 0..1) required" }, 400); + } + getPlayer().setVolume(volume); + return c.json({ ok: true }); + }); + + api.post("/next", (c) => { + sendToMain("player:event", { type: "next" }); + return c.json({ ok: true }); + }); + api.post("/prev", (c) => { + sendToMain("player:event", { type: "prev" }); + return c.json({ ok: true }); + }); + + return api; +}; diff --git a/electron/main/server/ws.ts b/electron/main/server/ws.ts new file mode 100644 index 00000000..7e057aa6 --- /dev/null +++ b/electron/main/server/ws.ts @@ -0,0 +1,95 @@ +/** + * WebSocket 入口:双向通道 + * + * Server → Client: + * - 连接建立:`{ kind: "hello", clients: N }` + * - player 事件:`{ kind: "event", type, data }`(由 wsBroadcast 推) + * - 命令 ack:`{ kind: "ack", op }` / `{ kind: "error", op, error }` + * + * Client → Server:`{ op: "play" | "pause" | "stop" | "next" | "prev" | "seek" | "setVolume", ... }` + */ + +import type { WSContext } from "hono/ws"; +import { getPlayer } from "@main/services/engine"; +import { sendToMain } from "@main/utils/broadcast"; +import { serverLog } from "@main/utils/logger"; +import { addWsClient, removeWsClient, getWsClientCount } from "./broadcast"; + +interface ClientMessage { + op: string; + positionMs?: number; + volume?: number; +} + +const ack = (ws: WSContext, op: string): void => { + ws.send(JSON.stringify({ kind: "ack", op })); +}; + +const fail = (ws: WSContext, op: string, error: string): void => { + ws.send(JSON.stringify({ kind: "error", op, error })); +}; + +const dispatchCommand = async (ws: WSContext, msg: ClientMessage): Promise => { + try { + switch (msg.op) { + case "play": + getPlayer().play(); + return ack(ws, msg.op); + case "pause": + getPlayer().pause(); + return ack(ws, msg.op); + case "stop": + getPlayer().stop(); + return ack(ws, msg.op); + case "next": + sendToMain("player:event", { type: "next" }); + return ack(ws, msg.op); + case "prev": + sendToMain("player:event", { type: "prev" }); + return ack(ws, msg.op); + case "seek": { + const positionMs = Number(msg.positionMs); + if (!Number.isFinite(positionMs) || positionMs < 0) { + return fail(ws, msg.op, "positionMs (number, >=0) required"); + } + await getPlayer().seek(positionMs / 1000); + return ack(ws, msg.op); + } + case "setVolume": { + const volume = Number(msg.volume); + if (!Number.isFinite(volume) || volume < 0 || volume > 1) { + return fail(ws, msg.op, "volume (number, 0..1) required"); + } + getPlayer().setVolume(volume); + return ack(ws, msg.op); + } + default: + return fail(ws, msg.op ?? "?", "unknown op"); + } + } catch (err) { + fail(ws, msg.op ?? "?", err instanceof Error ? err.message : String(err)); + } +}; + +export const wsHandlers = { + onOpen(_evt: Event, ws: WSContext) { + addWsClient(ws); + ws.send(JSON.stringify({ kind: "hello", clients: getWsClientCount() })); + }, + async onMessage(evt: MessageEvent, ws: WSContext) { + let msg: ClientMessage; + try { + msg = JSON.parse(typeof evt.data === "string" ? evt.data : evt.data.toString()); + } catch { + return fail(ws, "?", "invalid json"); + } + await dispatchCommand(ws, msg); + }, + onClose(_evt: CloseEvent, ws: WSContext) { + removeWsClient(ws); + }, + onError(_evt: Event, ws: WSContext) { + serverLog.warn("WS 客户端错误"); + removeWsClient(ws); + }, +}; diff --git a/electron/main/services/nowPlaying.ts b/electron/main/services/nowPlaying.ts index 18dc0263..3542e22a 100644 --- a/electron/main/services/nowPlaying.ts +++ b/electron/main/services/nowPlaying.ts @@ -27,21 +27,34 @@ let currentLyric: LyricLine[] = []; let currentSource: LyricData = null; /** 最近一次播放位置(毫秒) */ let lastPosition = 0; +/** lastPosition 真实成立的墙钟时刻(Date.now 毫秒),用于补偿其过期时长 */ +let lastPositionAt = 0; /** 当前是否处于播放态 */ let playing = false; +/** 当前播放速度倍率(0.5 ~ 2.0) */ +let playSpeed = 1.0; /** 当前曲目对应的歌词偏移(ms,正值为歌词提前) */ let currentLyricOffsetMs = 0; /** 内部事件总线 */ const emitter = new EventEmitter(); -/** 从存储中读取指定曲目的偏移;缺省视为 0 */ +/** + * 从存储中读取指定曲目的偏移 + * @param trackId - 曲目 ID + * @returns 偏移值(毫秒),缺省视为 0 + */ const readOffset = (trackId: string | null | undefined): number => { if (!trackId) return 0; return store.get("player.lyricOffsets")?.[trackId] ?? 0; }; -/** 渲染进程同步当前播放状态 */ +/** + * 同步当前播放状态 + * @param track - 当前曲目 + * @param lyric - 当前歌词 + * @param source - 当前歌词源 + */ export const update = (track: Track | null, lyric: LyricLine[], source: LyricData): void => { const trackChanged = (currentTrack?.id ?? null) !== (track?.id ?? null); currentTrack = track; @@ -59,28 +72,58 @@ export const update = (track: Track | null, lyric: LyricLine[], source: LyricDat emitter.emit("lyric-change", snapshot()); }; -/** 主进程 position 事件入口(原生 5Hz) */ +/** + * 同步播放位置 + * @param positionMs - 播放位置(毫秒) + * @param isPlaying - 是否处于播放态 + */ export const onPosition = (positionMs: number, isPlaying: boolean): void => { lastPosition = positionMs; + lastPositionAt = Date.now(); playing = isPlaying; emitter.emit("position-sync", { position: positionMs, playing: isPlaying, - sendTimestamp: Date.now(), + speed: playSpeed, + sendTimestamp: lastPositionAt, }); }; -/** 播放状态变化时立即同步一次 */ +/** + * 同步播放状态 + * @param isPlaying - 是否处于播放态 + */ export const onPlayStateChange = (isPlaying: boolean): void => { playing = isPlaying; emitter.emit("position-sync", { position: lastPosition, playing: isPlaying, + speed: playSpeed, + // 暂停态接收端不补偿延迟,恢复态不能用陈旧时间戳,故取当前时刻 sendTimestamp: Date.now(), }); }; -/** 单曲偏移的合理上限(毫秒);超过视为误输入,clamp 防止极端值 */ +/** + * 同步播放速度 + * @param speed - 播放速度倍率(0.5 ~ 2.0) + * + * 立即广播一帧 position-sync,让窗口当帧换挡,无需等待下一个 5Hz 周期 + */ +export const onSpeedChange = (speed: number): void => { + playSpeed = Number.isFinite(speed) ? speed : 1.0; + emitter.emit("position-sync", { + position: lastPosition, + playing, + speed: playSpeed, + sendTimestamp: lastPositionAt || Date.now(), + }); +}; + +/** + * 单曲偏移的合理上限(毫秒) + * 超过视为误输入,clamp 防止极端值 + */ const LYRIC_OFFSET_LIMIT_MS = 60_000; /** @@ -105,15 +148,17 @@ export const setLyricOffset = (trackId: string, offsetMs: number): void => { } }; -/** 窗口启动对齐:拉取当前完整状态 */ +/** 拉取当前完整状态 */ export const snapshot = (): NowPlayingSnapshot => ({ track: currentTrack, lyric: currentLyric, source: currentSource, position: lastPosition, playing, + speed: playSpeed, lyricOffsetMs: currentLyricOffsetMs, - sendTimestamp: Date.now(), + // 用 position 的成立时刻,接收端据此补偿其过期时长 + sendTimestamp: lastPositionAt || Date.now(), }); /** 清空 */ diff --git a/electron/main/services/songCache.ts b/electron/main/services/songCache.ts index c71e0a16..cc62a53f 100644 --- a/electron/main/services/songCache.ts +++ b/electron/main/services/songCache.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import fsp from "node:fs/promises"; +import fsp, { type FileHandle } from "node:fs/promises"; import path from "node:path"; import crypto from "node:crypto"; import { Readable } from "node:stream"; @@ -26,6 +26,41 @@ const MAX_CONCURRENT = 2; /** 一批 LRU 淘汰数 */ const EVICT_BATCH = 8; +/** + * 拒绝的响应 Content-Type 前缀(命中即不入缓存) + * 第三方代理常用 200 包一段 HTML/JSON 错误体冒充音频,这里第一道拦截 + */ +const REJECTED_MIME_PREFIXES = ["text/html", "application/json", "application/xml", "text/xml"]; + +const isRejectedMime = (mime: string | null): boolean => { + if (!mime) return false; + const lower = mime.toLowerCase(); + return REJECTED_MIME_PREFIXES.some((prefix) => lower.startsWith(prefix)); +}; + +/** + * 文件头是否像音频 + * + * 反向看首字节,挡 HTML/JSON 错误页冒充音频的常见骗局。 + * 不做正向 magic 枚举(容器太多枚举不全反而漏判),剩余漏网坏文件由 player.ts:228 + * 的解码失败 invalidate 兜底 + */ +const looksLikeAudio = async (filePath: string): Promise => { + let fd: FileHandle | null = null; + try { + fd = await fsp.open(filePath, "r"); + const buf = Buffer.alloc(4); + const { bytesRead } = await fd.read(buf, 0, 4, 0); + if (bytesRead === 0) return false; + // '<' = HTML/XML,'{' = JSON 对象,'[' = JSON 数组 + return buf[0] !== 0x3c && buf[0] !== 0x7b && buf[0] !== 0x5b; + } catch { + return false; + } finally { + if (fd) await fd.close().catch(() => {}); + } +}; + interface InFlight { promise: Promise; controller: AbortController; @@ -186,6 +221,11 @@ const runDownload = async ( } const mime = response.headers.get("content-type"); + // 拦截第三方代理常用 200+html 错误页冒充音频 + if (isRejectedMime(mime)) { + songCacheLog.warn(`[fetch] reject mime key=${cacheKey} mime=${mime}`); + return null; + } const contentLengthHeader = response.headers.get("content-length"); const declaredSize = contentLengthHeader ? Number(contentLengthHeader) : NaN; if (Number.isFinite(declaredSize) && declaredSize > sizeLimitBytes()) { @@ -208,6 +248,12 @@ const runDownload = async ( songCacheLog.warn(`[fetch] post oversize key=${cacheKey} actual=${stat.size}`); return null; } + // 拦截首字节看着像音频才放行 + if (!(await looksLikeAudio(partPath))) { + await fsp.unlink(partPath).catch(() => {}); + songCacheLog.warn(`[fetch] not audio key=${cacheKey} mime=${mime} size=${stat.size}`); + return null; + } await fsp.rename(partPath, finalPath); const now = Date.now(); diff --git a/electron/main/utils/logger.ts b/electron/main/utils/logger.ts index 6a1953cc..8386334e 100644 --- a/electron/main/utils/logger.ts +++ b/electron/main/utils/logger.ts @@ -85,3 +85,5 @@ export const taskbarLog = log.scope("taskbar-lyric"); export const nativeLog = log.scope("native"); export const streamingLog = log.scope("streaming"); export const songCacheLog = log.scope("songCache"); +export const serverLog = log.scope("server"); +export const pluginLog = log.scope("plugin"); diff --git a/electron/main/utils/protocol.ts b/electron/main/utils/protocol.ts index 85d88567..111fcbca 100644 --- a/electron/main/utils/protocol.ts +++ b/electron/main/utils/protocol.ts @@ -14,6 +14,8 @@ export const registerCacheScheme = (): void => { { scheme: SCHEME, privileges: { + standard: true, + corsEnabled: true, secure: true, supportFetchAPI: true, bypassCSP: true, diff --git a/electron/main/window/login.ts b/electron/main/window/login.ts new file mode 100644 index 00000000..c070914b --- /dev/null +++ b/electron/main/window/login.ts @@ -0,0 +1,137 @@ +/** + * NCM 网页登录窗口 + * + * 打开一个独立的 BrowserWindow 加载网易云官方登录页,使用专属 session 分区 + * 隔离 cookie;用户登录成功后从该分区读取 MUSIC_U 等关键 cookie 返回。 + * + * 同一时刻只允许一个登录窗口存在。 + */ + +import { BrowserWindow, session } from "electron"; +import { getMainWindow } from "./main"; +import { coreLog } from "@main/utils/logger"; + +const LOGIN_PARTITION = "persist:netease-login"; +const LOGIN_URL = "https://music.163.com/#/login"; + +/** 关心的关键 cookie;未取到 MUSIC_U 视为未登录 */ +const COOKIE_KEYS = ["MUSIC_U", "__csrf", "NMTID", "MUSIC_A"]; + +/** + * 伪装成普通桌面 Chrome + * 默认 UA 含 "Electron/...",NCM 会判定为不受支持环境,渲染极慢且无法跳转 + */ +const FAKE_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + +let activeWin: BrowserWindow | null = null; +let pollTimer: NodeJS.Timeout | null = null; + +const getLoginSession = (): Electron.Session => session.fromPartition(LOGIN_PARTITION); + +const stopPolling = (): void => { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } +}; + +/** + * 收集登录会话中的 cookies + * @returns 含 MUSIC_U 时返回完整 cookies 对象,否则 null + */ +const collectCookies = async (): Promise | null> => { + const ses = getLoginSession(); + // 按 URL 取,能拿到 domain 和 .domain 两种维度,否则部分 cookie 漏取 + const all = await ses.cookies.get({ url: "https://music.163.com" }); + const musicU = all.find((c) => c.name === "MUSIC_U"); + if (!musicU?.value) return null; + const out: Record = {}; + for (const key of COOKIE_KEYS) { + const hit = all.find((c) => c.name === key); + if (hit?.value) out[key] = hit.value; + } + return out; +}; + +/** + * 打开 NCM 网页登录窗口 + * @returns 登录成功返回 cookies 对象;用户关闭窗口返回 null + */ +export const openNeteaseLoginWindow = async (): Promise | null> => { + // 已存在则先聚焦 + if (activeWin && !activeWin.isDestroyed()) { + activeWin.focus(); + return null; + } + + // 清掉旧的登录会话,避免残留 cookie 干扰 + const ses = getLoginSession(); + await ses.clearStorageData({ storages: ["cookies", "localstorage", "indexdb"] }); + // 整个分区都伪装 UA,否则部分 XHR 仍会被识别成 Electron + ses.setUserAgent(FAKE_UA); + + const parent = getMainWindow() ?? undefined; + + activeWin = new BrowserWindow({ + parent, + modal: false, + width: 1024, + height: 720, + minWidth: 800, + minHeight: 600, + center: true, + title: "登录网易云音乐", + autoHideMenuBar: true, + backgroundColor: "#ffffff", + show: false, + webPreferences: { + session: ses, + // sandbox 模式下 NCM 的 JS 渲染极慢;登录窗口里没有自家代码,关闭沙箱影响可控 + sandbox: false, + spellcheck: false, + backgroundThrottling: false, + nodeIntegration: false, + contextIsolation: true, + }, + }); + + // 顶层导航和 XHR 都使用伪装 UA + activeWin.webContents.setUserAgent(FAKE_UA); + + // 阻止新窗口 + activeWin.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + + return await new Promise | null>((resolve) => { + let settled = false; + const finish = (result: Record | null): void => { + if (settled) return; + settled = true; + stopPolling(); + if (activeWin && !activeWin.isDestroyed()) activeWin.destroy(); + activeWin = null; + resolve(result); + }; + + // ready-to-show 比 did-finish-load 更早,避免被 NCM 大量异步资源拖住显示 + activeWin!.once("ready-to-show", () => activeWin?.show()); + + activeWin!.webContents.once("dom-ready", () => { + pollTimer = setInterval(async () => { + try { + const cookies = await collectCookies(); + if (cookies) finish(cookies); + } catch (err) { + coreLog.warn("[login] poll cookies failed:", err); + } + }, 1000); + }); + + activeWin!.on("closed", () => finish(null)); + + activeWin!.loadURL(LOGIN_URL, { userAgent: FAKE_UA }).catch((err) => { + coreLog.error("[login] loadURL failed:", err); + finish(null); + }); + }); +}; diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 1e05a000..f8b4741f 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -1,6 +1,6 @@ import { ElectronAPI } from "@electron-toolkit/preload"; import { PlayerApi, TrackSource } from "@shared/types/player"; -import { ConfigApi, LocaleCode } from "@shared/types/settings"; +import { ConfigApi, ExternalApiStatus, LocaleCode } from "@shared/types/settings"; import { LibraryApi } from "@shared/types/library"; import { NowPlayingApi } from "@shared/types/nowPlaying"; import { PluginsApi } from "@shared/types/plugin"; @@ -67,6 +67,10 @@ declare global { }; hotkey: HotkeyApi; streaming: StreamingApi; + externalApi: { + restart: () => Promise; + getStatus: () => Promise; + }; }; } } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 6ea978c8..143b5a6d 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -283,6 +283,11 @@ const api = { ipcRenderer.invoke("apis:call", platform, name, params ?? {}), // 清空指定平台的登录态 clearSession: (platform: string) => ipcRenderer.invoke("apis:clearSession", platform), + // 打开官方网页登录窗口 + openLoginWeb: (platform: string) => ipcRenderer.invoke("apis:openLoginWeb", platform), + // 手动写入 cookie 登录 + setCookie: (platform: string, cookie: string) => + ipcRenderer.invoke("apis:setCookie", platform, cookie), }, lyrics: { // 按 id 直取某平台歌词 @@ -358,6 +363,12 @@ const api = { activeServerId: string | null; }): Promise => ipcRenderer.invoke("streaming:saveServers", payload), }, + externalApi: { + // 重启外部 API 服务 + restart: () => ipcRenderer.invoke("externalApi:restart"), + // 查询当前运行状态 + getStatus: () => ipcRenderer.invoke("externalApi:getStatus"), + }, hotkey: { getAll: () => ipcRenderer.invoke("hotkey:getAll"), set: (id: HotkeyActionId, binding: HotkeyBinding) => diff --git a/native/audio-engine/index.d.ts b/native/audio-engine/index.d.ts index f36aefd7..3763f1b9 100644 --- a/native/audio-engine/index.d.ts +++ b/native/audio-engine/index.d.ts @@ -160,7 +160,7 @@ export interface JsMusicMetadata { /** 播放器事件,推送给 JS 侧 */ export interface JsPlayerEvent { - /** 事件类型:"stateChanged" | "ended" | "position" | "fftData" | "outputStalled" */ + /** 事件类型:"stateChanged" | "ended" | "sourceError" | "position" | "fftData" | "outputStalled" */ type: string /** 状态(仅 stateChanged 时有值) */ state?: string diff --git a/native/audio-engine/src/decoder.rs b/native/audio-engine/src/decoder.rs index 18751c1f..1ee27213 100644 --- a/native/audio-engine/src/decoder.rs +++ b/native/audio-engine/src/decoder.rs @@ -378,18 +378,27 @@ fn run_decoding_loop(data: &mut DecoderData, shared: &Shared) { return; } - // 读取下一个数据包 - match data.input_ctx.packets().next() { - Some((stream, packet)) if stream.index() == data.audio_stream_index => { - consecutive_read_failures = 0; - if data.decoder.send_packet(&packet).is_err() { - return; + // 读取下一个数据包;Packet::read 能区分真正的 EOF 与读取失败 + let mut packet = ffmpeg::codec::packet::Packet::empty(); + match packet.read(&mut data.input_ctx) { + Ok(()) => { + if packet.stream() == data.audio_stream_index { + consecutive_read_failures = 0; + if data.decoder.send_packet(&packet).is_err() { + return; + } } + // 非音频包:跳过 } - None => { - // 未读到数据包:可能是真正的文件结束,也可能是网络中断 + Err(ffmpeg::Error::Eof) => { + // 真正的文件结束,刷新解码器 + let _ = data.decoder.send_eof(); + eof_sent = true; + } + Err(err) => { + // 读取失败:网络中断 / URL 失效 consecutive_read_failures += 1; - + warn!(error = %err, retries = consecutive_read_failures, "音频源读取失败"); if consecutive_read_failures <= NETWORK_READ_RETRIES { // 网络流可能恢复,等待后重试 std::thread::sleep(std::time::Duration::from_millis( @@ -397,14 +406,11 @@ fn run_decoding_loop(data: &mut DecoderData, shared: &Shared) { )); continue; } - - // 重试耗尽,刷新解码器 + // 重试耗尽:标记音源失效,仍走 EOF 流程把已缓冲数据放完 + shared.mark_decode_failed(); let _ = data.decoder.send_eof(); eof_sent = true; } - _ => { - // 非音频包,跳过 - } } } Err(err) => { diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index 60eeea81..2272386a 100644 --- a/native/audio-engine/src/lib.rs +++ b/native/audio-engine/src/lib.rs @@ -113,7 +113,7 @@ pub struct JsAudioDevice { #[napi(object)] #[derive(Default)] pub struct JsPlayerEvent { - /// 事件类型:"stateChanged" | "ended" | "position" | "fftData" | "outputStalled" + /// 事件类型:"stateChanged" | "ended" | "sourceError" | "position" | "fftData" | "outputStalled" #[napi(js_name = "type")] pub event_type: String, /// 状态(仅 stateChanged 时有值) @@ -199,6 +199,10 @@ impl AudioPlayer { event_type: "ended".into(), ..Default::default() }, + PlayerEvent::SourceError => JsPlayerEvent { + event_type: "sourceError".into(), + ..Default::default() + }, PlayerEvent::Position { position, duration } => JsPlayerEvent { event_type: "position".into(), position: Some(position), diff --git a/native/audio-engine/src/player.rs b/native/audio-engine/src/player.rs index b9e8cdbc..355f031b 100644 --- a/native/audio-engine/src/player.rs +++ b/native/audio-engine/src/player.rs @@ -23,6 +23,8 @@ pub enum PlayerEvent { StateChanged { state: PlayerState }, /// 播放结束 Ended, + /// 音源失效(网络中断 / URL 过期) + SourceError, /// 位置更新(秒)—— 由内部定时器推送 Position { position: f64, duration: f64 }, /// FFT 频谱数据推送 @@ -322,7 +324,12 @@ impl InnerPlayer { // 检测播放结束:all_consumed 表示 rodio 侧已消费完所有数据 if shared.is_all_consumed() { - cb(PlayerEvent::Ended); + // 解码因读取失败中止 → 音源失效事件;否则正常结束 + if shared.is_decode_failed() { + cb(PlayerEvent::SourceError); + } else { + cb(PlayerEvent::Ended); + } cb(PlayerEvent::StateChanged { state: PlayerState::Stopped, }); @@ -824,8 +831,11 @@ impl InnerPlayer { } /// 停止播放并释放资源 + /// 显式停止:清掉 current_source,避免后续 play() 在 Stopped 态下用残留源复活上一首 + /// (`stop_internal` 是内部过渡用,不清;load() 会立即用新源覆盖) pub fn stop(&mut self) { self.stop_internal(); + self.current_source = None; self.state = PlayerState::Stopped; self.emit(PlayerEvent::StateChanged { state: PlayerState::Stopped, diff --git a/native/audio-engine/src/shared.rs b/native/audio-engine/src/shared.rs index f1042b6b..6d15c088 100644 --- a/native/audio-engine/src/shared.rs +++ b/native/audio-engine/src/shared.rs @@ -29,6 +29,8 @@ pub struct Shared { /// 所有数据已被消费完毕(DecoderSource 返回 None 时设置) /// 比 is_done() 更准确:is_done 只表示缓冲区空,all_consumed 表示 rodio 侧已消费完 all_consumed: AtomicBool, + /// 解码线程因读取失败(网络中断 / URL 失效)中止,区别于正常 EOF + decode_failed: AtomicBool, /// 音量归一化增益因子(线性值,1.0 = 无增益) /// 使用 AtomicU32 + f32::to_bits/from_bits 实现原子 f32 normalization_gain: AtomicU32, @@ -54,6 +56,7 @@ impl Shared { sample_rate, channels, all_consumed: AtomicBool::new(false), + decode_failed: AtomicBool::new(false), normalization_gain: AtomicU32::new(1.0_f32.to_bits()), normalization_enabled: AtomicBool::new(false), interrupt_flag: Mutex::new(None), @@ -112,6 +115,16 @@ impl Shared { self.all_consumed.load(Ordering::Acquire) } + /// 标记解码因读取失败中止(网络中断 / URL 失效) + pub fn mark_decode_failed(&self) { + self.decode_failed.store(true, Ordering::Release); + } + + /// 解码是否因读取失败中止 + pub fn is_decode_failed(&self) -> bool { + self.decode_failed.load(Ordering::Acquire) + } + /// 基于实际消费采样数的精确播放位置(秒) pub fn consumed_position(&self) -> f64 { let samples = self.samples_consumed.load(Ordering::Relaxed); diff --git a/package.json b/package.json index 61fd5c28..720d5dfe 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", + "@hono/node-server": "^2.0.2", "@material/material-color-utilities": "^0.4.0", "@vueuse/core": "^14.2.1", "@vueuse/integrations": "^14.2.1", @@ -55,24 +56,30 @@ "electron-log": "^5.4.3", "electron-updater": "^6.8.3", "font-list": "^2.0.2", + "hono": "^4.12.18", "localforage": "^1.10.0", "pinia": "^3.0.4", "pinia-plugin-persistedstate": "^4.7.1", "reka-ui": "^2.9.7", "sortablejs": "^1.15.7", + "uqr": "^0.1.3", "vue-i18n": "^11.3.2", - "vue-router": "^5.0.4" + "vue-router": "^5.0.4", + "ws": "^8.20.1" }, "devDependencies": { "@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/tsconfig": "^2.0.0", + "@electron/rebuild": "^4.0.4", "@iconify-json/lucide": "^1.2.101", + "@iconify-json/material-symbols": "^1.2.73", "@napi-rs/cli": "^3.6.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.19.17", "@types/sortablejs": "^1.15.9", + "@types/ws": "^8.18.1", "@vitejs/plugin-vue": "^6.0.5", - "electron": "^39.8.7", + "electron": "^41.6.1", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", "eslint": "^9.39.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b0fc125..72ba3b0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,13 @@ importers: dependencies: '@electron-toolkit/preload': specifier: ^3.0.2 - version: 3.0.2(electron@39.8.7) + version: 3.0.2(electron@41.6.1) '@electron-toolkit/utils': specifier: ^4.0.0 - version: 4.0.0(electron@39.8.7) + version: 4.0.0(electron@41.6.1) + '@hono/node-server': + specifier: ^2.0.2 + version: 2.0.2(hono@4.12.18) '@material/material-color-utilities': specifier: ^0.4.0 version: 0.4.0 @@ -22,7 +25,7 @@ importers: version: 14.2.1(vue@3.5.32(typescript@5.9.3)) '@vueuse/integrations': specifier: ^14.2.1 - version: 14.2.1(sortablejs@1.15.7)(vue@3.5.32(typescript@5.9.3)) + version: 14.2.1(qrcode@1.5.4)(sortablejs@1.15.7)(vue@3.5.32(typescript@5.9.3)) atomically: specifier: ^2.1.1 version: 2.1.1 @@ -41,6 +44,9 @@ importers: font-list: specifier: ^2.0.2 version: 2.0.2 + hono: + specifier: ^4.12.18 + version: 4.12.18 localforage: specifier: ^1.10.0 version: 1.10.0 @@ -56,12 +62,18 @@ importers: sortablejs: specifier: ^1.15.7 version: 1.15.7 + uqr: + specifier: ^0.1.3 + version: 0.1.3 vue-i18n: specifier: ^11.3.2 version: 11.3.2(vue@3.5.32(typescript@5.9.3)) vue-router: specifier: ^5.0.4 version: 5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + ws: + specifier: ^8.20.1 + version: 8.20.1 devDependencies: '@electron-toolkit/eslint-config-ts': specifier: ^3.1.0 @@ -69,9 +81,15 @@ importers: '@electron-toolkit/tsconfig': specifier: ^2.0.0 version: 2.0.0(@types/node@22.19.17) + '@electron/rebuild': + specifier: ^4.0.4 + version: 4.0.4 '@iconify-json/lucide': specifier: ^1.2.101 version: 1.2.101 + '@iconify-json/material-symbols': + specifier: ^1.2.73 + version: 1.2.73 '@napi-rs/cli': specifier: ^3.6.1 version: 3.6.1(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@22.19.17) @@ -84,12 +102,15 @@ importers: '@types/sortablejs': specifier: ^1.15.9 version: 1.15.9 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@vitejs/plugin-vue': specifier: ^6.0.5 version: 6.0.5(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) electron: - specifier: ^39.8.7 - version: 39.8.7 + specifier: ^41.6.1 + version: 41.6.1 electron-builder: specifier: ^26.8.1 version: 26.8.1(electron-builder-squirrel-windows@26.8.1) @@ -281,8 +302,8 @@ packages: engines: {node: '>=12.0.0'} hasBin: true - '@electron/rebuild@4.0.3': - resolution: {integrity: sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==} + '@electron/rebuild@4.0.4': + resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==} engines: {node: '>=22.12.0'} hasBin: true @@ -666,6 +687,12 @@ packages: '@floating-ui/vue@1.1.11': resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + '@hono/node-server@2.0.2': + resolution: {integrity: sha512-tXlTi1h/4V7sDe7i97IVP+9re9ZU7wXZZggnR5ucCRclf1+AX6YhGStrR5w8bLj+3Mlyl0pKfBh9gqTqqnGKfQ==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -685,6 +712,9 @@ packages: '@iconify-json/lucide@1.2.101': resolution: {integrity: sha512-JUN7uuSLRG3GK/9c5b8cK9e7sL6EAWDaASIwBOd0zUeKS0ACcokJubo2RMQHyVUVpd8mYkrR3Zd2mkH9ghhw1Q==} + '@iconify-json/material-symbols@1.2.73': + resolution: {integrity: sha512-XCZ9PiO47hofN9p8VTfLyCCJXd95OH4gIkkplkBfeZpz3K6cWYDj7oCy+rnc1AVMmoFyDEyjdEVvr+Oqz9yVTA==} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -847,10 +877,6 @@ packages: resolution: {integrity: sha512-x66fjdH6i+lNYPae5URSQGTjBL68Av6hi09jvC5Ci96iTkwfqrPhCj46aylQZmgMaG89rOZCIKqS7ApC8ZDVjg==} engines: {node: '>= 16'} - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -1235,14 +1261,6 @@ packages: resolution: {integrity: sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ==} engines: {node: '>= 10'} - '@npmcli/agent@3.0.0': - resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/fs@4.0.0': - resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} - engines: {node: ^18.17.0 || >=20.5.0} - '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -1425,10 +1443,6 @@ packages: '@oxc-project/types@0.124.0': resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1631,6 +1645,9 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -1646,6 +1663,9 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -1915,9 +1935,9 @@ packages: engines: {node: '>=10.0.0'} deprecated: this version has critical issues, please update to the latest version - abbrev@3.0.1: - resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} - engines: {node: ^18.17.0 || >=20.5.0} + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1948,18 +1968,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - app-builder-bin@5.0.0-alpha.12: resolution: {integrity: sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==} @@ -2087,10 +2099,6 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} - cacache@19.0.1: - resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} - engines: {node: ^18.17.0 || >=20.5.0} - cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -2107,6 +2115,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001787: resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} @@ -2139,14 +2151,6 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -2160,6 +2164,9 @@ packages: peerDependencies: typanion: '*' + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2167,10 +2174,6 @@ packages: clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2254,6 +2257,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -2265,9 +2272,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -2297,6 +2301,9 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -2324,9 +2331,6 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -2368,8 +2372,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@39.8.7: - resolution: {integrity: sha512-B3TmzbUEeIvrhJ0QcoFp8/tgnVA3vsm0wkdYWzC22hsk9zTVqkzyrrz40cjd0nMTTIrGWxxfDO2tdQTCMe9Bjw==} + electron@41.6.1: + resolution: {integrity: sha512-3y1Q0AkPnqwaJJZV146KlZ63VIVlQVlWdLiArkwnGUOxZAZD5cvag4TMUsu/gN+FZhkkpelgvdTXPZvTb34I8A==} engines: {node: '>= 12.20.55'} hasBin: true @@ -2384,12 +2388,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - encoding@0.1.13: - resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} - end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2590,6 +2588,10 @@ packages: filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2604,10 +2606,6 @@ packages: font-list@2.0.2: resolution: {integrity: sha512-kNG2sY5+Nh5bbWL9kgAi+k8tuXfZ3m+6fjNGF4iUITTyex2S6wITREgr9ZqY1xw2+mFM6Bi5NPEPpRB6jLLOnw==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -2623,6 +2621,10 @@ packages: resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -2635,10 +2637,6 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} - fs-minipass@3.0.3: - resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2680,11 +2678,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -2743,6 +2736,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -2810,10 +2807,6 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2826,14 +2819,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -2853,8 +2838,9 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} @@ -2906,6 +2892,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2926,6 +2915,10 @@ packages: localforage@1.10.0: resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2943,17 +2936,10 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.3.3: resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==} engines: {node: 20 || >=22} @@ -2975,10 +2961,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - make-fetch-happen@14.0.3: - resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} - engines: {node: ^18.17.0 || >=20.5.0} - matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -3003,10 +2985,6 @@ packages: engines: {node: '>=4.0.0'} hasBin: true - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -3033,30 +3011,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass-collect@2.0.1: - resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass-fetch@4.0.1: - resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - minipass-flush@1.0.7: - resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} - engines: {node: '>= 8'} - - minipass-pipeline@1.2.4: - resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} - engines: {node: '>=8'} - - minipass-sized@1.0.3: - resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} - engines: {node: '>=8'} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -3103,10 +3057,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - node-abi@3.89.0: resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} engines: {node: '>=10'} @@ -3124,17 +3074,17 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - node-gyp@11.5.0: - resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} - engines: {node: ^18.17.0 || >=20.5.0} + node-gyp@12.3.0: + resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} - nopt@8.1.0: - resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} - engines: {node: ^18.17.0 || >=20.5.0} + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true normalize-url@6.1.0: @@ -3160,18 +3110,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - oxc-parser@0.124.0: resolution: {integrity: sha512-h07SFj/tp2U3cf3+LFX6MmOguQiM9ahwpGs0ZK5CGhgL8p4kk24etrJKsEzhXAvo7mfvoKTZooZ5MLKAPRmJ1g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3185,17 +3127,25 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} - engines: {node: '>=18'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3222,10 +3172,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -3286,6 +3232,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -3314,9 +3264,9 @@ packages: engines: {node: '>=14'} hasBin: true - proc-log@5.0.0: - resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} - engines: {node: ^18.17.0 || >=20.5.0} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} @@ -3336,6 +3286,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3375,6 +3330,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -3392,10 +3350,6 @@ packages: responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -3458,6 +3412,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3495,14 +3452,6 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sortablejs@1.15.7: resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} @@ -3524,10 +3473,6 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - ssri@12.0.0: - resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} - engines: {node: ^18.17.0 || >=20.5.0} - stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} @@ -3536,10 +3481,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -3547,10 +3488,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -3681,18 +3618,17 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} + unimport@5.7.0: resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} engines: {node: '>=18.12.0'} - unique-filename@4.0.0: - resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - unique-slug@5.0.0: - resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} - engines: {node: ^18.17.0 || >=20.5.0} - universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} @@ -3776,6 +3712,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -3884,15 +3823,15 @@ packages: typescript: optional: true - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3903,21 +3842,38 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -3926,6 +3882,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3945,10 +3904,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -4092,17 +4059,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron-toolkit/preload@3.0.2(electron@39.8.7)': + '@electron-toolkit/preload@3.0.2(electron@41.6.1)': dependencies: - electron: 39.8.7 + electron: 41.6.1 '@electron-toolkit/tsconfig@2.0.0(@types/node@22.19.17)': dependencies: '@types/node': 22.19.17 - '@electron-toolkit/utils@4.0.0(electron@39.8.7)': + '@electron-toolkit/utils@4.0.0(electron@41.6.1)': dependencies: - electron: 39.8.7 + electron: 41.6.1 '@electron/asar@3.4.1': dependencies: @@ -4163,21 +4130,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/rebuild@4.0.3': + '@electron/rebuild@4.0.4': dependencies: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 - detect-libc: 2.1.2 - got: 11.8.6 - graceful-fs: 4.2.11 node-abi: 4.28.0 node-api-version: 0.2.1 - node-gyp: 11.5.0 - ora: 5.4.1 + node-gyp: 12.3.0 read-binary-file-arch: 1.0.6 - semver: 7.7.4 - tar: 7.5.13 - yargs: 17.7.2 transitivePeerDependencies: - supports-color @@ -4197,7 +4157,7 @@ snapshots: dependencies: cross-dirname: 0.1.0 debug: 4.4.3 - fs-extra: 11.3.4 + fs-extra: 11.3.5 minimist: 1.2.8 postject: 1.0.0-alpha.6 transitivePeerDependencies: @@ -4442,6 +4402,10 @@ snapshots: - '@vue/composition-api' - vue + '@hono/node-server@2.0.2(hono@4.12.18)': + dependencies: + hono: 4.12.18 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -4457,6 +4421,10 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify-json/material-symbols@1.2.73': + dependencies: + '@iconify/types': 2.0.0 + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.0': @@ -4610,15 +4578,6 @@ snapshots: '@intlify/shared@11.3.2': {} - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -4923,20 +4882,6 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - '@npmcli/agent@3.0.0': - dependencies: - agent-base: 7.1.4 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 10.4.3 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - '@npmcli/fs@4.0.0': - dependencies: - semver: 7.7.4 - '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.6': @@ -5067,9 +5012,6 @@ snapshots: '@oxc-project/types@0.124.0': {} - '@pkgjs/parseargs@0.11.0': - optional: true - '@polka/url@1.0.0-next.29': {} '@quansync/fs@1.0.0': @@ -5212,6 +5154,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + '@types/plist@3.0.5': dependencies: '@types/node': 22.19.17 @@ -5229,6 +5175,10 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.17 + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.19.17 @@ -5589,12 +5539,13 @@ snapshots: '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3)) vue: 3.5.32(typescript@5.9.3) - '@vueuse/integrations@14.2.1(sortablejs@1.15.7)(vue@3.5.32(typescript@5.9.3))': + '@vueuse/integrations@14.2.1(qrcode@1.5.4)(sortablejs@1.15.7)(vue@3.5.32(typescript@5.9.3))': dependencies: '@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3)) '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3)) vue: 3.5.32(typescript@5.9.3) optionalDependencies: + qrcode: 1.5.4 sortablejs: 1.15.7 '@vueuse/metadata@14.2.1': {} @@ -5605,7 +5556,7 @@ snapshots: '@xmldom/xmldom@0.8.12': {} - abbrev@3.0.1: {} + abbrev@4.0.0: {} acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -5630,14 +5581,10 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - ansi-styles@6.2.3: {} - app-builder-bin@5.0.0-alpha.12: {} app-builder-lib@26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1): @@ -5648,7 +5595,7 @@ snapshots: '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 '@electron/osx-sign': 1.3.3 - '@electron/rebuild': 4.0.3 + '@electron/rebuild': 4.0.4 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 '@types/fs-extra': 9.0.13 @@ -5812,21 +5759,6 @@ snapshots: cac@7.0.0: {} - cacache@19.0.1: - dependencies: - '@npmcli/fs': 4.0.0 - fs-minipass: 3.0.3 - glob: 10.4.5 - lru-cache: 10.4.3 - minipass: 7.1.3 - minipass-collect: 2.0.1 - minipass-flush: 1.0.7 - minipass-pipeline: 1.2.4 - p-map: 7.0.4 - ssri: 12.0.0 - tar: 7.5.13 - unique-filename: 4.0.0 - cacheable-lookup@5.0.4: {} cacheable-request@7.0.4: @@ -5846,6 +5778,9 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: + optional: true + caniuse-lite@1.0.30001787: {} chalk@4.1.2: @@ -5869,12 +5804,6 @@ snapshots: ci-info@4.4.0: {} - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - - cli-spinners@2.9.2: {} - cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -5887,6 +5816,13 @@ snapshots: dependencies: typanion: 3.14.0 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + optional: true + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -5897,8 +5833,6 @@ snapshots: dependencies: mimic-response: 1.0.1 - clone@1.0.4: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5964,6 +5898,9 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: + optional: true + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -5972,10 +5909,6 @@ snapshots: deep-is@0.1.4: {} - defaults@1.0.4: - dependencies: - clone: 1.0.4 - defer-to-connect@2.0.1: {} define-data-property@1.1.4: @@ -6003,6 +5936,9 @@ snapshots: detect-node@2.1.0: optional: true + dijkstrajs@1.0.3: + optional: true + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -6047,8 +5983,6 @@ snapshots: duplexer@0.1.2: {} - eastasianwidth@0.2.0: {} - ejs@3.1.10: dependencies: jake: 10.9.4 @@ -6132,10 +6066,10 @@ snapshots: transitivePeerDependencies: - supports-color - electron@39.8.7: + electron@41.6.1: dependencies: '@electron/get': 2.0.3 - '@types/node': 22.19.17 + '@types/node': 24.12.4 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -6144,13 +6078,6 @@ snapshots: emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - - encoding@0.1.13: - dependencies: - iconv-lite: 0.6.3 - optional: true - end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -6406,6 +6333,12 @@ snapshots: dependencies: minimatch: 5.1.9 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + optional: true + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -6420,11 +6353,6 @@ snapshots: font-list@2.0.2: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -6447,6 +6375,13 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + optional: true + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -6466,10 +6401,6 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fs-minipass@3.0.3: - dependencies: - minipass: 7.1.3 - fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -6513,15 +6444,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.4.5: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -6596,6 +6518,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.12.18: {} + hookable@5.5.3: {} hosted-git-info@4.1.0: @@ -6661,8 +6585,6 @@ snapshots: ini@1.3.8: {} - ip-address@10.1.0: {} - is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -6671,10 +6593,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-interactive@1.0.0: {} - - is-unicode-supported@0.1.0: {} - is-what@5.5.0: {} isbinaryfile@4.0.10: {} @@ -6685,11 +6603,7 @@ snapshots: isexe@3.1.5: {} - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 + isexe@4.0.0: {} jake@10.9.4: dependencies: @@ -6732,6 +6646,13 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + optional: true + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6757,6 +6678,11 @@ snapshots: dependencies: lie: 3.1.1 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + optional: true + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -6769,15 +6695,8 @@ snapshots: lodash@4.18.1: {} - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - lowercase-keys@2.0.0: {} - lru-cache@10.4.3: {} - lru-cache@11.3.3: {} lru-cache@5.1.1: @@ -6806,22 +6725,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - make-fetch-happen@14.0.3: - dependencies: - '@npmcli/agent': 3.0.0 - cacache: 19.0.1 - http-cache-semantics: 4.2.0 - minipass: 7.1.3 - minipass-fetch: 4.0.1 - minipass-flush: 1.0.7 - minipass-pipeline: 1.2.4 - negotiator: 1.0.0 - proc-log: 5.0.0 - promise-retry: 2.0.1 - ssri: 12.0.0 - transitivePeerDependencies: - - supports-color - matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -6839,8 +6742,6 @@ snapshots: mime@2.6.0: {} - mimic-fn@2.1.0: {} - mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -6863,34 +6764,6 @@ snapshots: minimist@1.2.8: {} - minipass-collect@2.0.1: - dependencies: - minipass: 7.1.3 - - minipass-fetch@4.0.1: - dependencies: - minipass: 7.1.3 - minipass-sized: 1.0.3 - minizlib: 3.1.0 - optionalDependencies: - encoding: 0.1.13 - - minipass-flush@1.0.7: - dependencies: - minipass: 3.3.6 - - minipass-pipeline@1.2.4: - dependencies: - minipass: 3.3.6 - - minipass-sized@1.0.3: - dependencies: - minipass: 3.3.6 - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - minipass@7.1.3: {} minizlib@3.1.0: @@ -6926,8 +6799,6 @@ snapshots: natural-compare@1.4.0: {} - negotiator@1.0.0: {} - node-abi@3.89.0: dependencies: semver: 7.7.4 @@ -6945,26 +6816,24 @@ snapshots: node-fetch-native@1.6.7: {} - node-gyp@11.5.0: + node-gyp@12.3.0: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 graceful-fs: 4.2.11 - make-fetch-happen: 14.0.3 - nopt: 8.1.0 - proc-log: 5.0.0 + nopt: 9.0.0 + proc-log: 6.1.0 semver: 7.7.4 tar: 7.5.13 tinyglobby: 0.2.16 - which: 5.0.0 - transitivePeerDependencies: - - supports-color + undici: 6.25.0 + which: 6.0.1 node-releases@2.0.37: {} - nopt@8.1.0: + nopt@9.0.0: dependencies: - abbrev: 3.0.1 + abbrev: 4.0.0 normalize-url@6.1.0: {} @@ -6989,10 +6858,6 @@ snapshots: dependencies: wrappy: 1.0.2 - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7002,18 +6867,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - oxc-parser@0.124.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0): dependencies: '@oxc-project/types': 0.124.0 @@ -7049,15 +6902,26 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + optional: true + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + optional: true + p-locate@5.0.0: dependencies: p-limit: 3.1.0 - p-map@7.0.4: {} + p-try@2.2.0: + optional: true package-json-from-dist@1.0.1: {} @@ -7075,11 +6939,6 @@ snapshots: path-key@3.1.1: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - path-scurry@2.0.2: dependencies: lru-cache: 11.3.3 @@ -7130,6 +6989,9 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@5.0.0: + optional: true + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -7165,7 +7027,7 @@ snapshots: prettier@3.8.1: {} - proc-log@5.0.0: {} + proc-log@6.1.0: {} progress@2.0.3: {} @@ -7187,6 +7049,13 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + optional: true + quansync@0.2.11: {} quansync@1.0.0: {} @@ -7234,6 +7103,9 @@ snapshots: require-directory@2.1.1: {} + require-main-filename@2.0.0: + optional: true + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -7248,11 +7120,6 @@ snapshots: dependencies: lowercase-keys: 2.0.0 - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - retry@0.12.0: {} rfdc@1.4.1: {} @@ -7333,6 +7200,9 @@ snapshots: type-fest: 0.13.1 optional: true + set-blocking@2.0.0: + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -7368,20 +7238,8 @@ snapshots: is-fullwidth-code-point: 3.0.0 optional: true - smart-buffer@4.2.0: {} - - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - - socks@2.8.7: - dependencies: - ip-address: 10.1.0 - smart-buffer: 4.2.0 + smart-buffer@4.2.0: + optional: true sortablejs@1.15.7: {} @@ -7399,10 +7257,6 @@ snapshots: sprintf-js@1.1.3: optional: true - ssri@12.0.0: - dependencies: - minipass: 7.1.3 - stat-mode@1.0.0: {} string-width@4.2.3: @@ -7411,12 +7265,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -7425,10 +7273,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -7573,6 +7417,10 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.16.0: {} + + undici@6.25.0: {} + unimport@5.7.0: dependencies: acorn: 8.16.0 @@ -7590,14 +7438,6 @@ snapshots: unplugin: 2.3.11 unplugin-utils: 0.3.1 - unique-filename@4.0.0: - dependencies: - unique-slug: 5.0.0 - - unique-slug@5.0.0: - dependencies: - imurmurhash: 0.1.4 - universal-user-agent@7.0.3: {} universalify@0.1.2: {} @@ -7686,6 +7526,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uqr@0.1.3: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -7782,14 +7624,13 @@ snapshots: optionalDependencies: typescript: 5.9.3 - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - webpack-virtual-modules@0.6.2: {} when-exit@2.1.5: {} + which-module@2.0.1: + optional: true + which@2.0.2: dependencies: isexe: 2.0.0 @@ -7798,26 +7639,36 @@ snapshots: dependencies: isexe: 3.1.5 + which@6.0.1: + dependencies: + isexe: 4.0.0 + word-wrap@1.2.5: {} - wrap-ansi@7.0.0: + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + optional: true - wrap-ansi@8.1.0: + wrap-ansi@7.0.0: dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 wrappy@1.0.2: {} + ws@8.20.1: {} + xml-name-validator@4.0.0: {} xmlbuilder@15.1.1: {} + y18n@4.0.3: + optional: true + y18n@5.0.8: {} yallist@3.1.1: {} @@ -7828,8 +7679,29 @@ snapshots: yaml@2.8.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + optional: true + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + optional: true + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index 589edf55..d7053101 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -102,6 +102,11 @@ export const defaultSystemConfig: SystemConfig = { streaming: { enabled: true, }, + externalApi: { + enabled: false, + wsEnabled: false, + port: 14558, + }, system: { rememberWindowState: true, taskbarProgress: true, diff --git a/shared/types/apis.ts b/shared/types/apis.ts index 241e9573..078c6189 100644 --- a/shared/types/apis.ts +++ b/shared/types/apis.ts @@ -23,6 +23,24 @@ export interface ApisApi { name: string, params?: Record, ) => Promise; - /** 清空指定平台的登录态(目前仅 netease 有意义) */ + /** + * 清空指定平台的登录态 + * @param platform 音源平台 + */ clearSession: (platform: ApiPlatform) => Promise; + /** + * 打开官方网页登录 + * @param platform 音源平台 + * @returns 登录成功 `{ ok: true }`;用户取消 / 失败 `{ ok: false, error }` + */ + openLoginWeb: (platform: ApiPlatform) => Promise<{ ok: true } | { ok: false; error: string }>; + /** + * 手动写入 cookie 登录(目前仅 netease) + * @param platform 音源平台 + * @param cookie 形如 `MUSIC_U=xxx; __csrf=yyy` 的 cookie 字符串 + */ + setCookie: ( + platform: ApiPlatform, + cookie: string, + ) => Promise<{ ok: true } | { ok: false; error: string }>; } diff --git a/shared/types/errors.ts b/shared/types/errors.ts index 59dd113f..c6fd818f 100644 --- a/shared/types/errors.ts +++ b/shared/types/errors.ts @@ -23,11 +23,17 @@ export enum ErrorCode { FILE_NOT_SELECTED = "FILE_NOT_SELECTED", // 网络相关 - /** 网络连接失败 */ + /** 音源连接失败 */ NETWORK_ERROR = "NETWORK_ERROR", - /** 网络请求超时 */ + /** 音源响应超时 */ NETWORK_TIMEOUT = "NETWORK_TIMEOUT", + // 在线源相关 + /** 无法获取播放地址(API + 插件都失败) */ + URL_RESOLVE_FAILED = "URL_RESOLVE_FAILED", + /** 未找到支持该平台的插件 */ + NO_PLUGIN_AVAILABLE = "NO_PLUGIN_AVAILABLE", + // 扫描相关 /** 未配置扫描目录 */ SCAN_NO_DIRS = "SCAN_NO_DIRS", diff --git a/shared/types/nowPlaying.ts b/shared/types/nowPlaying.ts index e6230e1b..b2f5d284 100644 --- a/shared/types/nowPlaying.ts +++ b/shared/types/nowPlaying.ts @@ -15,9 +15,11 @@ export interface NowPlayingSnapshot { source: LyricData; position: number; playing: boolean; + /** 播放速度倍率(0.5 ~ 2.0) */ + speed: number; /** 当前曲目的歌词偏移(ms,正值为歌词提前) */ lyricOffsetMs: number; - /** 发送时刻的主进程时钟(Date.now 毫秒),接收端用于补偿 IPC 延迟 */ + /** position 真实成立的主进程时钟(Date.now 毫秒),接收端用于补偿其过期时长 */ sendTimestamp: number; } @@ -25,6 +27,8 @@ export interface NowPlayingSnapshot { export interface NowPlayingPositionSync { position: number; playing: boolean; + /** 播放速度倍率(0.5 ~ 2.0) */ + speed: number; sendTimestamp: number; } diff --git a/shared/types/platform.ts b/shared/types/platform.ts index 12b32cfa..91fd0402 100644 --- a/shared/types/platform.ts +++ b/shared/types/platform.ts @@ -1,2 +1,18 @@ /** 平台类型 */ export type Platform = "netease" | "qqmusic" | "kugou"; + +/** 平台简写 */ +export const PLATFORM_SHORT_NAME: Record = { + netease: "NCM", + qqmusic: "QM", + kugou: "KG", +}; + +/** 全部平台 */ +export const ALL_PLATFORMS: Platform[] = ["netease", "qqmusic", "kugou"]; + +const PLATFORM_SET = new Set(ALL_PLATFORMS); + +/** 判断给定 source 是否为在线平台(netease / qqmusic / kugou),同时类型收窄 */ +export const isPlatform = (source: string | undefined): source is Platform => + source !== undefined && PLATFORM_SET.has(source); diff --git a/shared/types/player.ts b/shared/types/player.ts index a5d88b80..7f5acd30 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -10,8 +10,8 @@ export type RepeatMode = "off" | "list" | "one"; /** 随机模式 */ export type ShuffleMode = "off" | "on"; -/** 歌曲来源 */ -export type TrackSource = "local" | "online" | "streaming"; +/** 歌曲来源:本地 / 流媒体 / 在线平台 */ +export type TrackSource = "local" | "streaming" | Platform; /** 歌手 */ export interface Artist { @@ -56,12 +56,22 @@ export interface AudioQuality { codec: string; } +/** + * 付费等级 + * - 0: 免费 + * - 1: VIP + * - 2: 需购买(数字专辑等) + */ +export type TrackFee = 0 | 1 | 2; + /** 歌曲信息 */ export interface Track { + /** 平台 id */ id: string; + /** 平台二级 id */ + extId?: string; + /** 歌曲来源 */ source: TrackSource; - /** 在线平台 */ - platform?: Platform; /** 本地路径 */ path?: string; /** 流媒体服务器实例 ID(仅 source==='streaming') */ @@ -90,13 +100,17 @@ export interface Track { ctime?: number; /** 音质信息 */ quality?: AudioQuality; + /** 付费等级 */ + fee?: TrackFee; + /** 云盘歌曲 */ + cloud?: boolean; } /** 歌曲详细信息 */ export interface TrackDetail { quality: AudioQuality; embeddedLyric?: string; - /** 外部歌词文件列表(同目录下扫描到的所有歌词文件) */ + /** 外部歌词文件列表 */ externalLyrics: { format: LyricFormat; path: string }[]; } @@ -148,6 +162,7 @@ export type PlayerEvent = | { type: "status"; data: PlayerStatus } | { type: "position"; data: { position: number; duration: number } } | { type: "ended" } + | { type: "sourceError" } | { type: "play" } | { type: "pause" } | { type: "next" } diff --git a/shared/types/plugin.ts b/shared/types/plugin.ts index 71cd2b8b..3270232f 100644 --- a/shared/types/plugin.ts +++ b/shared/types/plugin.ts @@ -229,7 +229,9 @@ export type SandboxOut = args: unknown[]; } | { kind: "fatal"; error: PluginErrorPayload } - | { kind: "pong" }; + | { kind: "pong" } + /** sources 增量上报 */ + | { kind: "sourcesUpdate"; sources: Record }; /** worker 调用回宿主的方法名 */ export type HostCallMethod = diff --git a/shared/types/settings.ts b/shared/types/settings.ts index 7f4b2712..43896f85 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -185,6 +185,26 @@ export interface StreamingSettings { enabled: boolean; } +/** 外部 API 服务配置 */ +export interface ExternalApiSettings { + /** 总开关 */ + enabled: boolean; + /** WebSocket 子开关 */ + wsEnabled: boolean; + /** 监听端口 */ + port: number; +} + +/** 外部 API 服务运行时状态 */ +export interface ExternalApiStatus { + /** 是否正在监听 */ + listening: boolean; + /** 实际监听端口 */ + port: number | null; + /** 上次启动失败的错误 */ + error: { code: string; message: string } | null; +} + /** 在线歌词服务配置 */ export interface OnlineLyricSettings { /** 启用在线 TTML 歌词 */ @@ -279,6 +299,8 @@ export interface SystemConfig { cache: CacheSettings; /** 流媒体总开关 */ streaming: StreamingSettings; + /** 外部 API 服务(HTTP + WS) */ + externalApi: ExternalApiSettings; /** 系统配置 */ system: { /** 记忆窗口状态 */ diff --git a/shared/utils/lyricSync.ts b/shared/utils/lyricSync.ts index 3c11a835..d2b4b6e9 100644 --- a/shared/utils/lyricSync.ts +++ b/shared/utils/lyricSync.ts @@ -65,6 +65,29 @@ export const pickPrimaryIndex = (lines: LyricLine[], time: number): number => { return latest; }; +/** + * 计算单词的逐字扫动进度 [0,1] + * + * 含与应用内引擎一致的 preRoll 提前量:每个词在 startTime 之前提前开始扫动, + * 让相邻词亮区衔接而非硬切,也使外部歌词与应用内的起亮时刻对齐 + * + * @param word - 单词时间区间 + * @param lineStartTime - 所属行的起始时间(ms),preRoll 不会越过行首 + * @param currentMs - 当前播放毫秒 + * @returns 扫动进度,0 未开始,1 已完成 + */ +export const getWordSweepProgress = ( + word: { startTime: number; endTime: number }, + lineStartTime: number, + currentMs: number, +): number => { + const wordDuration = Math.abs(word.endTime - word.startTime) || 1; + const preRoll = Math.min(80, wordDuration * 0.3); + const adjustedStart = Math.max(lineStartTime, word.startTime - preRoll); + const adjustedDuration = Math.max(1, word.endTime - adjustedStart); + return Math.max(0, Math.min(1, (currentMs - adjustedStart) / adjustedDuration)); +}; + const LAST_LINE_FALLBACK_MS = 8000; /** diff --git a/src/apis/album/netease.ts b/src/apis/album/netease.ts new file mode 100644 index 00000000..ae1eed42 --- /dev/null +++ b/src/apis/album/netease.ts @@ -0,0 +1,29 @@ +import type { Album, Track } from "@shared/types/player"; +import { netease as neteaseApi } from "@/apis/netease"; +import { ensureOk, songsToTracks, toAlbum } from "@/utils/format/netease"; + +/** + * 拉取专辑:元数据 + 全部曲目 + * @param albumId 专辑 id + */ +export const fetchAlbum = async ( + albumId: string, +): Promise<{ album: Album; tracks: Track[]; description?: string } | null> => { + const body = await neteaseApi.album({ id: albumId }); + const raw = body?.album; + if (!raw) return null; + return { + album: toAlbum(raw), + tracks: songsToTracks(body?.songs), + description: typeof raw.description === "string" ? raw.description : undefined, + }; +}; + +/** + * 收藏 / 取消收藏专辑 + * @param id 专辑 id + * @param subscribe true 收藏 / false 取消 + */ +export const subscribeAlbum = async (id: string, subscribe: boolean): Promise => { + ensureOk(await neteaseApi.album_sub({ id, t: subscribe ? 1 : 2 })); +}; diff --git a/src/apis/artist/netease.ts b/src/apis/artist/netease.ts new file mode 100644 index 00000000..bf47bdcc --- /dev/null +++ b/src/apis/artist/netease.ts @@ -0,0 +1,43 @@ +import type { Album, Artist, Track } from "@shared/types/player"; +import { netease as neteaseApi } from "@/apis/netease"; +import { songsToTracks, toAlbum, toArtist } from "@/utils/format/netease"; + +/** + * 拉取歌手:基本资料 + 热门 50 首 + 全部专辑 + * 两次并行请求(artists + artist_album),专辑用 200 的 limit 覆盖绝大多数情况 + * 更多歌曲通过 fetchArtistSongs 触底分页拉 + * @param artistId 歌手 id + */ +export const fetchArtist = async ( + artistId: string, +): Promise<{ artist: Artist; tracks: Track[]; albums: Album[] } | null> => { + const [profile, albums] = await Promise.all([ + neteaseApi.artists({ id: artistId }), + neteaseApi.artist_album({ id: artistId, limit: 200 }), + ]); + const rawArtist = profile?.artist; + if (!rawArtist) return null; + return { + artist: toArtist(rawArtist), + tracks: songsToTracks(profile?.hotSongs), + albums: (albums?.hotAlbums ?? []).map(toAlbum), + }; +}; + +/** + * 触底加载更多歌手歌曲 + * @param artistId 歌手 id + * @param offset 偏移(首屏 50 首之后从 50 起) + * @param limit 单页数量,默认 50 + */ +export const fetchArtistSongs = async ( + artistId: string, + offset: number, + limit = 50, +): Promise<{ tracks: Track[]; more: boolean }> => { + const body = await neteaseApi.artist_songs({ id: artistId, offset, limit, order: "hot" }); + return { + tracks: songsToTracks(body?.songs), + more: !!body?.more, + }; +}; diff --git a/src/apis/cloud/netease.ts b/src/apis/cloud/netease.ts new file mode 100644 index 00000000..d0a46aa6 --- /dev/null +++ b/src/apis/cloud/netease.ts @@ -0,0 +1,64 @@ +import type { Track } from "@shared/types/player"; +import type { NeteaseSong } from "@/types/netease"; +import { netease as neteaseApi } from "@/apis/netease"; +import { ensureOk, songsToTracks } from "@/utils/format/netease"; + +interface CloudItem { + songId?: number; + fileName?: string; + fileSize?: number; + simpleSong?: NeteaseSong; +} + +interface UserCloudBody { + code?: number; + data?: CloudItem[]; + count?: number; + size?: number; + maxSize?: number; + hasMore?: boolean; +} + +export interface CloudPage { + /** 本页转换后的曲目 */ + tracks: Track[]; + /** 云盘总曲目数 */ + count: number; + /** 已用容量(字节) */ + size: number; + /** 总容量(字节) */ + maxSize: number; + /** 是否还有下一页 */ + hasMore: boolean; +} + +/** + * 拉取一页云盘数据 + * @param offset 偏移 + * @param limit 单页数量 + */ +export const fetchUserCloud = async (offset: number, limit = 200): Promise => { + const body = (await neteaseApi.user_cloud({ offset, limit })) as UserCloudBody; + ensureOk(body); + const songs = (body.data ?? []) + .map((item) => item.simpleSong) + .filter((song): song is NeteaseSong => !!song); + // 标记云盘 + const tracks = songsToTracks(songs).map((track) => ({ ...track, cloud: true })); + return { + tracks, + count: body.count ?? 0, + size: body.size ?? 0, + maxSize: body.maxSize ?? 0, + hasMore: body.hasMore ?? offset + songs.length < (body.count ?? 0), + }; +}; + +/** + * 从云盘删除歌曲 + * @param ids 曲目 id 列表(字符串,会原样下发为数字数组) + */ +export const deleteCloudSongs = async (ids: string[]): Promise => { + if (ids.length === 0) return; + ensureOk(await neteaseApi.user_cloud_del({ id: ids.map(Number) })); +}; diff --git a/src/apis/login/netease.ts b/src/apis/login/netease.ts new file mode 100644 index 00000000..114f09d8 --- /dev/null +++ b/src/apis/login/netease.ts @@ -0,0 +1,84 @@ +/** + * 网易云登录相关 + */ + +import type { UserProfile } from "@/types/user"; +import { netease as neteaseApi } from "@/apis/netease"; + +/** + * 生成扫码登录二维码 key + * @returns 二维码 key + */ +export const qrKey = async (): Promise => { + const body = await neteaseApi.login_qr_key({ timestamp: Date.now() }); + const unikey = body?.data?.unikey; + if (!unikey) throw new Error("qr key missing"); + return unikey; +}; + +export type QrStatusCode = 800 | 801 | 802 | 803; + +export interface QrCheckResult { + code: QrStatusCode; + cookie?: string; + nickname?: string; + avatarUrl?: string; +} + +/** + * 轮询扫码状态 + * - 800 已过期 / 801 待扫码 / 802 待确认 / 803 已确认(含 cookie) + * @param key 二维码 key + * @returns 扫码状态和结果 + */ +export const qrCheck = async (key: string): Promise => { + const body = await neteaseApi.login_qr_check({ key, timestamp: Date.now() }); + const code = (body?.code ?? 801) as QrStatusCode; + return { + code, + cookie: body?.cookie, + nickname: body?.nickname, + avatarUrl: body?.avatarUrl, + }; +}; + +/** + * 二维码内容 + * @param key 二维码 key + * @returns 二维码内容 + */ +export const qrContent = (key: string): string => `https://music.163.com/login?codekey=${key}`; + +/** + * 校验 cookie 并取当前用户 profile + * @returns 已登录返回 profile;未登录或 cookie 失效返回 null + */ +export const fetchLoginStatus = async (): Promise => { + const body = await neteaseApi.login_status(); + const raw = body?.data?.profile as (Partial & { userId?: number }) | undefined; + if (!raw?.userId) return null; + return { + userId: raw.userId, + nickname: raw.nickname ?? "", + avatarUrl: raw.avatarUrl, + backgroundUrl: raw.backgroundUrl, + signature: raw.signature, + vipType: raw.vipType, + gender: raw.gender, + province: raw.province, + city: raw.city, + }; +}; + +/** + * 续期登录 cookie + * set-cookie 由主进程 SESSION_MUTATING 自动写回 SQLite + */ +export const refreshLogin = async (): Promise => { + await neteaseApi.login_refresh(); +}; + +/** 服务端登出(仅打断 server session,不清本地 cookie) */ +export const logoutNetease = async (): Promise => { + await neteaseApi.logout(); +}; diff --git a/src/apis/netease.ts b/src/apis/netease.ts index f8cbd1ee..4af5ec31 100644 --- a/src/apis/netease.ts +++ b/src/apis/netease.ts @@ -2,10 +2,10 @@ * Netease API 渲染端 * * 用 Proxy 把所有接口代理到主进程:`netease.search({keywords})` 实际等于 - * `window.api.apis.call("netease", "search", {keywords})`。 + * `window.api.apis.call("netease", "search", {keywords})` * - * 调用约定:成功 → 返回 body;失败 → 抛 Error。 - * 想取原始响应(含 HTTP status)用 `neteaseRaw`。 + * 调用约定:成功 → 返回 body;失败 → 抛 Error + * 想取原始响应(含 HTTP status)用 `neteaseRaw` */ import type { ApiCallResponse } from "@shared/types/apis"; @@ -30,7 +30,7 @@ export const neteaseRaw = async ( * @param name 接口名 * @param params 接口参数 */ -export const neteaseCall = async ( +export const neteaseCall = async ( name: string, params?: Record, ): Promise => { @@ -38,7 +38,7 @@ export const neteaseCall = async ( return res.body as T; }; -type NeteaseProxy = Record(params?: Record) => Promise>; +type NeteaseProxy = Record(params?: Record) => Promise>; /** * 任意方法调用:`netease.search(...)` / `netease.song_url_v1(...)` @@ -46,7 +46,7 @@ type NeteaseProxy = Record(params?: Record - (params?: Record) => + (params?: Record) => neteaseCall(name, params), }); diff --git a/src/apis/playlist/netease.ts b/src/apis/playlist/netease.ts new file mode 100644 index 00000000..5ed191f2 --- /dev/null +++ b/src/apis/playlist/netease.ts @@ -0,0 +1,143 @@ +import type { Playlist, Track } from "@shared/types/player"; +import { netease as neteaseApi } from "@/apis/netease"; +import { ensureOk, songsToTracks, toPlaylist } from "@/utils/format/netease"; +import { songsByIds } from "@/apis/song/netease"; + +/** song_detail 单批上限 */ +const SONG_DETAIL_BATCH = 500; + +/** fetchPlaylist 可选参数 */ +export interface FetchPlaylistOptions { + /** 元数据回调 */ + onMeta?: (meta: Playlist) => void; + /** 曲目分批回调 */ + onBatch?: (batch: Track[]) => void; + /** 中断信号 */ + signal?: AbortSignal; +} + +/** + * 拉取歌单:元数据 + 全部曲目 + * + * 1) `playlist/detail` 一次返回 元数据 + 前 ~1000 首完整 song + 全量 trackIds + * 2) 若 trackIds 还有未覆盖的,按 500 一批走 `song/detail` 补尾 + * + * @param playlistId 歌单 id + * @param options 元数据/曲目回调与中断信号 + */ +export const fetchPlaylist = async ( + playlistId: string, + options: FetchPlaylistOptions = {}, +): Promise => { + if (options.signal?.aborted) return; + const body = await neteaseApi.playlist_detail({ id: playlistId }); + if (options.signal?.aborted) return; + const raw = body?.playlist; + if (!raw) return; + + options.onMeta?.(toPlaylist(raw)); + + const firstBatch = songsToTracks(raw.tracks); + if (firstBatch.length > 0) options.onBatch?.(firstBatch); + + const have = new Set(firstBatch.map((t) => Number(t.id))); + const missing: number[] = (raw.trackIds ?? []) + .map((item: { id: number }) => item.id) + .filter((tid: number) => !have.has(tid)); + + for (let i = 0; i < missing.length; i += SONG_DETAIL_BATCH) { + if (options.signal?.aborted) return; + const chunk = missing.slice(i, i + SONG_DETAIL_BATCH); + const batch = await songsByIds(chunk); + if (options.signal?.aborted) return; + if (batch.length > 0) options.onBatch?.(batch); + } +}; + +/** + * 新建歌单 + * @param name 歌单名 + * @param privacy 0 公开 / 10 私密 + * @returns 新建的歌单元数据 + */ +export const createPlaylist = async (name: string, privacy: 0 | 10 = 0): Promise => { + const body = ensureOk(await neteaseApi.playlist_create({ name, privacy })); + return toPlaylist(body.playlist); +}; + +/** + * 删除歌单 + * @param id 歌单 id + */ +export const deletePlaylist = async (id: string): Promise => { + ensureOk(await neteaseApi.playlist_delete({ id })); +}; + +/** + * 改歌单名 + * @param id 歌单 id + * @param name 新名称 + */ +export const updatePlaylistName = async (id: string, name: string): Promise => { + ensureOk(await neteaseApi.playlist_name_update({ id, name })); +}; + +/** + * 改歌单描述 + * @param id 歌单 id + * @param desc 新描述(空字符串清空) + */ +export const updatePlaylistDesc = async (id: string, desc: string): Promise => { + ensureOk(await neteaseApi.playlist_desc_update({ id, desc })); +}; + +/** + * 把曲目加入歌单 + * @param playlistId 歌单 id + * @param trackIds 曲目 id 列表 + * @returns 实际加入条数 + */ +export const addToPlaylist = async (playlistId: string, trackIds: string[]): Promise => { + if (trackIds.length === 0) return 0; + const body = ensureOk( + await neteaseApi.playlist_tracks({ + op: "add", + pid: playlistId, + tracks: trackIds.join(","), + }), + ); + return typeof body.count === "number" ? body.count : trackIds.length; +}; + +/** + * 从歌单移除曲目 + * @param playlistId 歌单 id + * @param trackIds 曲目 id 列表 + */ +export const removeFromPlaylist = async (playlistId: string, trackIds: string[]): Promise => { + if (trackIds.length === 0) return; + ensureOk( + await neteaseApi.playlist_tracks({ + op: "del", + pid: playlistId, + tracks: trackIds.join(","), + }), + ); +}; + +/** + * 订阅 / 取消订阅他人歌单 + * @param id 歌单 id + * @param subscribe true 订阅 / false 取消 + */ +export const subscribePlaylist = async (id: string, subscribe: boolean): Promise => { + ensureOk(await neteaseApi.playlist_subscribe({ id, t: subscribe ? 1 : 2 })); +}; + +/** + * 重排自建歌单顺序 + * @param ids 期望顺序的歌单 id 数组 + */ +export const reorderPlaylists = async (ids: string[]): Promise => { + ensureOk(await neteaseApi.playlist_order_update({ ids: JSON.stringify(ids) })); +}; diff --git a/src/apis/search/hot.ts b/src/apis/search/hot.ts new file mode 100644 index 00000000..60fcd834 --- /dev/null +++ b/src/apis/search/hot.ts @@ -0,0 +1,51 @@ +/** + * 热搜(仅网易云),10min 缓存 + */ + +import { neteaseCall } from "@/apis/netease"; + +export interface HotSearchItem { + /** 关键词 */ + keyword: string; + /** 描述/补充 */ + content?: string; + /** 图标 url */ + iconUrl?: string; + /** 热度 */ + score?: number; +} + +interface NeteaseHotDetailResp { + data?: Array<{ + searchWord: string; + score?: number; + content?: string; + iconUrl?: string; + iconType?: number; + }>; +} + +const CACHE_TTL = 10 * 60 * 1000; + +let cache: { items: HotSearchItem[]; at: number } | null = null; + +/** + * 取网易云热搜(按 TTL 缓存) + */ +export const getHotSearches = async (): Promise => { + const now = Date.now(); + if (cache && now - cache.at < CACHE_TTL) return cache.items; + + const res = await neteaseCall("search_hot_detail"); + const items: HotSearchItem[] = (res.data ?? []) + .filter((row) => row.searchWord) + .map((row) => ({ + keyword: row.searchWord, + content: row.content, + iconUrl: row.iconUrl, + score: row.score, + })); + + cache = { items, at: now }; + return items; +}; diff --git a/src/apis/search/index.ts b/src/apis/search/index.ts new file mode 100644 index 00000000..bb0ba0bb --- /dev/null +++ b/src/apis/search/index.ts @@ -0,0 +1,74 @@ +/** + * 在线平台搜索分发 + * 对外暴露 searchSongs / searchAlbums / searchArtists / searchPlaylists + */ + +import type { Track } from "@shared/types/player"; +import type { CoverItem } from "@/types/artist"; +import type { Platform } from "@shared/types/platform"; +import * as netease from "./netease"; +import * as qqmusic from "./qqmusic"; +import * as kugou from "./kugou"; + +/** 搜索结果通用 */ +export interface SearchResult { + items: T[]; + total: number; + hasMore: boolean; +} + +const unsupported = (platform: Platform, category: string): never => { + throw new Error(`Search not yet supported: ${platform}.${category}`); +}; + +/** 搜索单曲 */ +export const searchSongs = ( + platform: Platform, + keyword: string, + offset: number, + limit: number, +): Promise> => { + if (platform === "netease") return netease.songs(keyword, offset, limit); + if (platform === "qqmusic") return qqmusic.songs(keyword, offset, limit); + if (platform === "kugou") return kugou.songs(keyword, offset, limit); + return unsupported(platform, "songs"); +}; + +/** 搜索专辑 */ +export const searchAlbums = ( + platform: Platform, + keyword: string, + offset: number, + limit: number, +): Promise> => { + if (platform === "netease") return netease.albums(keyword, offset, limit); + if (platform === "qqmusic") return qqmusic.albums(keyword, offset, limit); + if (platform === "kugou") return kugou.albums(keyword, offset, limit); + return unsupported(platform, "albums"); +}; + +/** 搜索歌手 */ +export const searchArtists = ( + platform: Platform, + keyword: string, + offset: number, + limit: number, +): Promise> => { + if (platform === "netease") return netease.artists(keyword, offset, limit); + if (platform === "qqmusic") return qqmusic.artists(keyword, offset, limit); + if (platform === "kugou") return kugou.artists(keyword, offset, limit); + return unsupported(platform, "artists"); +}; + +/** 搜索歌单 */ +export const searchPlaylists = ( + platform: Platform, + keyword: string, + offset: number, + limit: number, +): Promise> => { + if (platform === "netease") return netease.playlists(keyword, offset, limit); + if (platform === "qqmusic") return qqmusic.playlists(keyword, offset, limit); + if (platform === "kugou") return kugou.playlists(keyword, offset, limit); + return unsupported(platform, "playlists"); +}; diff --git a/src/apis/search/kugou.ts b/src/apis/search/kugou.ts new file mode 100644 index 00000000..e74d5dce --- /dev/null +++ b/src/apis/search/kugou.ts @@ -0,0 +1,67 @@ +import type { Track } from "@shared/types/player"; +import type { CoverItem } from "@/types/artist"; +import { kugou as kugouApi } from "@/apis/kugou"; +import type { SearchResult } from "./index"; + +interface KGSong { + id: string; + hash: string; + audioId?: number; + name: string; + artist: string; + album?: string; + albumId?: string | number; + cover?: string; + coverOriginal?: string; + duration: number; +} + +interface SongsResp { + total?: number; + songs?: KGSong[]; +} + +const songToTrack = (song: KGSong): Track => ({ + id: song.hash || song.id, + source: "kugou", + title: song.name, + artists: song.artist ? [{ name: song.artist }] : [], + // album.id 暂不暴露(在线专辑详情页未接通),列表里跟着 artist 一起暗显 + album: song.album ? { name: song.album, cover: song.cover } : undefined, + cover: song.cover, + coverOriginal: song.coverOriginal, + duration: song.duration ?? 0, +}); + +const empty = (): SearchResult => ({ items: [], total: 0, hasMore: false }); + +export const songs = async ( + keyword: string, + offset: number, + limit: number, +): Promise> => { + const body = await kugouApi.search({ + keywords: keyword, + page: Math.floor(offset / limit) + 1, + limit, + }); + const items = (body?.songs ?? []).map(songToTrack); + const total = body?.total ?? items.length; + return { items, total, hasMore: offset + items.length < total }; +}; + +export const albums = async ( + _keyword: string, + _offset: number, + _limit: number, +): Promise> => empty(); +export const artists = async ( + _keyword: string, + _offset: number, + _limit: number, +): Promise> => empty(); +export const playlists = async ( + _keyword: string, + _offset: number, + _limit: number, +): Promise> => empty(); diff --git a/src/apis/search/netease.ts b/src/apis/search/netease.ts new file mode 100644 index 00000000..d1b2a34e --- /dev/null +++ b/src/apis/search/netease.ts @@ -0,0 +1,125 @@ +import type { Track } from "@shared/types/player"; +import type { CoverItem } from "@/types/artist"; +import type { NeteaseSong } from "@/types/netease"; +import { netease as neteaseApi } from "@/apis/netease"; +import { songsToTracks, withPicSize } from "@/utils/format/netease"; +import type { SearchResult } from "./index"; + +interface NeteaseAlbum { + id: number; + name: string; + picUrl: string; + artists: { id: number; name: string }[]; + size: number; +} +interface NeteaseArtist { + id: number; + name: string; + picUrl?: string; + img1v1Url?: string; + albumSize?: number; +} +interface NeteasePlaylist { + id: number; + name: string; + coverImgUrl: string; + creator?: { nickname: string }; + trackCount: number; +} + +interface CloudSearchBody { + result?: { + songs?: NeteaseSong[]; + albums?: NeteaseAlbum[]; + artists?: NeteaseArtist[]; + playlists?: NeteasePlaylist[]; + songCount?: number; + albumCount?: number; + artistCount?: number; + playlistCount?: number; + }; +} + +/** cloudsearch type 编码 */ +const TYPE = { songs: 1, albums: 10, artists: 100, playlists: 1000 } as const; + +const call = ( + type: keyof typeof TYPE, + keyword: string, + offset: number, + limit: number, +): Promise => + neteaseApi.cloudsearch({ + keywords: keyword, + type: TYPE[type], + offset, + limit, + }); + +const albumToCover = (album: NeteaseAlbum): CoverItem => ({ + id: String(album.id), + title: album.name, + cover: withPicSize(album.picUrl), + subtitle: (album.artists ?? []).map((artist) => artist.name).join(" / "), + trackCount: album.size ?? 0, +}); + +const artistToCover = (artist: NeteaseArtist): CoverItem => ({ + id: String(artist.id), + title: artist.name, + cover: withPicSize(artist.img1v1Url ?? artist.picUrl), + subtitle: "", + trackCount: artist.albumSize ?? 0, +}); + +const playlistToCover = (playlist: NeteasePlaylist): CoverItem => ({ + id: String(playlist.id), + title: playlist.name, + cover: withPicSize(playlist.coverImgUrl), + subtitle: playlist.creator?.nickname ?? "", + trackCount: playlist.trackCount ?? 0, +}); + +export const songs = async ( + keyword: string, + offset: number, + limit: number, +): Promise> => { + const body = await call("songs", keyword, offset, limit); + const items = songsToTracks(body?.result?.songs); + const total = body?.result?.songCount ?? items.length; + return { items, total, hasMore: offset + items.length < total }; +}; + +export const albums = async ( + keyword: string, + offset: number, + limit: number, +): Promise> => { + const body = await call("albums", keyword, offset, limit); + const items = (body?.result?.albums ?? []).map(albumToCover); + const total = body?.result?.albumCount ?? items.length; + return { items, total, hasMore: offset + items.length < total }; +}; + +export const artists = async ( + keyword: string, + offset: number, + limit: number, +): Promise> => { + const body = await call("artists", keyword, offset, limit); + const items = (body?.result?.artists ?? []).map(artistToCover); + const total = body?.result?.artistCount ?? items.length; + return { items, total, hasMore: offset + items.length < total }; +}; + +export const playlists = async ( + keyword: string, + offset: number, + limit: number, +): Promise> => { + const body = await call("playlists", keyword, offset, limit); + const items = (body?.result?.playlists ?? []).map(playlistToCover); + const total = body?.result?.playlistCount ?? items.length; + return { items, total, hasMore: offset + items.length < total }; +}; diff --git a/src/apis/search/qqmusic.ts b/src/apis/search/qqmusic.ts new file mode 100644 index 00000000..c95cd788 --- /dev/null +++ b/src/apis/search/qqmusic.ts @@ -0,0 +1,79 @@ +import type { Track } from "@shared/types/player"; +import type { CoverItem } from "@/types/artist"; +import { qqmusic as qmApi } from "@/apis/qqmusic"; +import type { SearchResult } from "./index"; + +/** 主进程返回的单曲行 */ +interface QMSong { + id: string; + mid?: string; + name: string; + artist: string; + album?: string; + albumMid?: string; + duration: number; +} + +interface SongsResp { + total?: number; + songs?: QMSong[]; +} + +/** QM 封面 URL:size 为像素,T002 限到 800 以内 */ +const albumCoverByMid = (mid: string, size = 300): string => + `https://y.gtimg.cn/music/photo_new/T002R${size}x${size}M000${mid}.jpg`; + +const songToTrack = (song: QMSong): Track => { + const cover = song.albumMid ? albumCoverByMid(song.albumMid) : undefined; + const coverOriginal = song.albumMid ? albumCoverByMid(song.albumMid, 800) : undefined; + // id 优先 mid + return { + id: song.mid || song.id, + extId: song.mid && song.id !== song.mid ? song.id : undefined, + source: "qqmusic", + title: song.name, + // QM 的 song.artist 是合并字符串("A / B"),没单独 id,列表里会自动暗显 + artists: song.artist ? [{ name: song.artist }] : [], + // 在线专辑详情页还没接通(Artist.vue 的 TODO: online),暂不把 albumMid 当 id 暴露, + // 列表里会跟 artist 一起暗显;mid 只留给封面 URL + album: song.album ? { name: song.album, cover } : undefined, + duration: song.duration ?? 0, + cover, + coverOriginal, + }; +}; + +const empty = (): SearchResult => ({ items: [], total: 0, hasMore: false }); + +export const songs = async ( + keyword: string, + offset: number, + limit: number, +): Promise> => { + const body = await qmApi.search({ + keywords: keyword, + type: 0, + page: Math.floor(offset / limit) + 1, + limit, + }); + const items = (body?.songs ?? []).map(songToTrack); + const total = body?.total ?? items.length; + return { items, total, hasMore: offset + items.length < total }; +}; + +// 在线专辑/歌手/歌单详情页未接通 +export const albums = async ( + _keyword: string, + _offset: number, + _limit: number, +): Promise> => empty(); +export const artists = async ( + _keyword: string, + _offset: number, + _limit: number, +): Promise> => empty(); +export const playlists = async ( + _keyword: string, + _offset: number, + _limit: number, +): Promise> => empty(); diff --git a/src/apis/search/suggest.ts b/src/apis/search/suggest.ts new file mode 100644 index 00000000..ea0eac55 --- /dev/null +++ b/src/apis/search/suggest.ts @@ -0,0 +1,86 @@ +/** + * 搜索建议 + */ + +import { neteaseCall } from "@/apis/netease"; + +export interface SuggestSongItem { + id: number; + name: string; + /** 多个歌手用 " / " 连接 */ + artist?: string; + album?: string; +} + +export interface SuggestSimpleItem { + id: number; + name: string; + subtitle?: string; +} + +export interface SuggestData { + songs: SuggestSongItem[]; + albums: SuggestSimpleItem[]; + artists: SuggestSimpleItem[]; + playlists: SuggestSimpleItem[]; +} + +interface NeteaseSuggestResp { + result?: { + songs?: Array<{ + id: number; + name: string; + artists?: Array<{ name: string }>; + album?: { name: string }; + }>; + albums?: Array<{ + id: number; + name: string; + artist?: { name: string }; + }>; + artists?: Array<{ id: number; name: string }>; + playlists?: Array<{ id: number; name: string }>; + }; +} + +const EMPTY: SuggestData = { songs: [], albums: [], artists: [], playlists: [] }; + +/** + * 取网易云搜索建议 + * @param keyword - 关键词 + * @returns 分类建议;keyword 空 / 接口失败时返回空集 + */ +export const getSearchSuggest = async (keyword: string): Promise => { + const word = keyword.trim(); + if (!word) return { ...EMPTY }; + + const res = await neteaseCall("search_suggest", { + keywords: word, + type: "web", + }); + const raw = res.result ?? {}; + return { + songs: (raw.songs ?? []).map((song) => ({ + id: song.id, + name: song.name, + artist: (song.artists ?? []) + .map((artist) => artist.name) + .filter(Boolean) + .join(" / "), + album: song.album?.name, + })), + albums: (raw.albums ?? []).map((album) => ({ + id: album.id, + name: album.name, + subtitle: album.artist?.name, + })), + artists: (raw.artists ?? []).map((artist) => ({ + id: artist.id, + name: artist.name, + })), + playlists: (raw.playlists ?? []).map((playlist) => ({ + id: playlist.id, + name: playlist.name, + })), + }; +}; diff --git a/src/apis/song/netease.ts b/src/apis/song/netease.ts new file mode 100644 index 00000000..3ca185e6 --- /dev/null +++ b/src/apis/song/netease.ts @@ -0,0 +1,41 @@ +import type { Track } from "@shared/types/player"; +import type { QualityLevel } from "@/utils/quality"; +import { netease as neteaseApi } from "@/apis/netease"; +import { songsToTracks } from "@/utils/format/netease"; + +/** + * 按 ID 批量取歌曲详情 + * @param ids - 网易云 songId 列表 + * @returns 与传入 ids 对应的 Track 列表 + */ +export const songsByIds = async (ids: Array): Promise => { + const cleaned = ids.map((v) => String(v).trim()).filter(Boolean); + if (cleaned.length === 0) return []; + const body = await neteaseApi.song_detail({ ids: cleaned.join(",") }); + return songsToTracks(body?.songs); +}; + +/** 项目音质档位 → 网易云 song/url v1 的 level 参数 */ +const NETEASE_LEVEL: Record = { + lq: "standard", + sq: "higher", + hq: "exhigh", + lossless: "lossless", + "hi-res": "hires", +}; + +/** + * 解析网易云 Track 的可播放 URL + * VIP 试听片段 / 无版权 → 返回 null + * @param track - track.id 为云端 songId + * @param songLevel - 音质偏好;实际可用级别取决于账号权限 + */ +export const resolveNeteaseUrl = async ( + track: Track, + songLevel: QualityLevel, +): Promise => { + const body = await neteaseApi.song_url({ id: track.id, level: NETEASE_LEVEL[songLevel] }); + const item = body?.data?.[0]; + if (!item?.url || item.freeTrialInfo) return null; + return item.url; +}; diff --git a/src/apis/user/netease.ts b/src/apis/user/netease.ts new file mode 100644 index 00000000..cca7b118 --- /dev/null +++ b/src/apis/user/netease.ts @@ -0,0 +1,75 @@ +import type { Album, Artist, Playlist } from "@shared/types/player"; +import type { UserSubcount } from "@/types/user"; +import { netease as neteaseApi } from "@/apis/netease"; +import { ensureOk, toAlbum, toArtist, toPlaylist, toSubcount } from "@/utils/format/netease"; + +const PAGE_SIZE = 50; + +/** + * 通用分页直到拉完 + * @param fetcher 第 N 页拉取函数(offset/limit),返回 `{ data, hasMore }` + * @param extract 单项 raw → Item + */ +const fetchAllPages = async ( + fetcher: (offset: number, limit: number) => Promise<{ data?: any[]; hasMore?: boolean }>, + extract: (raw: any) => Item, +): Promise => { + const all: Item[] = []; + let offset = 0; + while (true) { + const resp = await fetcher(offset, PAGE_SIZE); + const list = resp.data ?? []; + all.push(...list.map(extract)); + if (!resp.hasMore || list.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + return all; +}; + +/** 用户全部歌单 */ +export const fetchUserPlaylists = async (uid: number, total?: number): Promise => { + const body = await neteaseApi.user_playlist({ + uid, + limit: total && total > 0 ? total : 1000, + offset: 0, + }); + return (body?.playlist ?? []).map(toPlaylist); +}; + +/** 用户订阅计数 */ +export const fetchSubcount = async (): Promise => { + const body = await neteaseApi.user_subcount(); + return toSubcount(body ?? {}); +}; + +/** 用户喜欢歌曲 id 列表 */ +export const fetchLikelist = async (uid: number): Promise => { + const body = await neteaseApi.likelist({ uid }); + return ((body?.ids as number[]) ?? []).map(String); +}; + +/** 用户收藏专辑 */ +export const fetchUserAlbums = (): Promise => + fetchAllPages(async (offset, limit) => { + const body = await neteaseApi.album_sublist({ limit, offset }); + return { data: body?.data, hasMore: body?.hasMore }; + }, toAlbum); + +/** 用户收藏歌手 */ +export const fetchUserArtists = (): Promise => + fetchAllPages(async (offset, limit) => { + const body = await neteaseApi.artist_sublist({ limit, offset }); + return { data: body?.data, hasMore: body?.hasMore }; + }, toArtist); + +/** 切换红心 */ +export const toggleLikeSong = async (trackId: string, like: boolean): Promise => { + ensureOk(await neteaseApi.like({ id: trackId, like })); +}; + +/** 用户等级 */ +export const fetchUserLevel = async (): Promise => { + const body = await neteaseApi.user_level(); + const level = body?.data?.level; + return typeof level === "number" ? level : undefined; +}; diff --git a/src/assets/images/ncm.png b/src/assets/images/ncm.png new file mode 100644 index 00000000..4cfc5467 Binary files /dev/null and b/src/assets/images/ncm.png differ diff --git a/src/assets/images/vip.png b/src/assets/images/vip.png new file mode 100644 index 00000000..009df9a1 Binary files /dev/null and b/src/assets/images/vip.png differ diff --git a/src/layouts/components/NavHeader.vue b/src/components/layout/NavHeader.vue similarity index 98% rename from src/layouts/components/NavHeader.vue rename to src/components/layout/NavHeader.vue index c4fdbaa5..5dfc9ea1 100644 --- a/src/layouts/components/NavHeader.vue +++ b/src/components/layout/NavHeader.vue @@ -70,11 +70,13 @@ const onMenuSelect = (key: string): void => { > +
+