From 932daba0cda5f2240d7874549d6936594560ed24 Mon Sep 17 00:00:00 2001 From: imsyy Date: Fri, 8 May 2026 18:48:00 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E8=87=AA?= =?UTF-8?q?=E5=BB=BA=E6=B5=81=E5=AA=92=E4=BD=93=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=EF=BC=88Subsonic/Jellyfin/Emby=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 类型层:TrackSource 增加 streaming;Track 增加 serverId/originalId; LoadResult 重构为 detail+mediaInfo,PlayerApi 新增 setNowPlayingMeta - 主进程 services/streaming:subsonic(覆盖 navidrome/opensubsonic)、 jellyfin、emby 三套客户端 + safeStorage 加密 - 主进程 store/ipc:服务器配置存 settings.json 的 streaming 节, IPC 暴露 listServers/addServer/testConnection/resolveUrl/getLyrics 等 - player IPC 契约重构:load 不再合成 Track,渲染层通过 setNowPlayingMeta 下发权威元数据用于 SMTC/托盘 - 渲染层接线:loadTrack 按 source 分发解析,载入后 enrichTrack 合并 引擎元数据;lyricLoader 接 streaming 分支取 LRC Co-Authored-By: Claude Opus 4.7 (1M context) --- electron/main/ipc/index.ts | 2 + electron/main/ipc/player.ts | 79 +++++--- electron/main/ipc/streaming.ts | 184 +++++++++++++++++++ electron/main/services/streaming/auth.ts | 62 +++++++ electron/main/services/streaming/emby.ts | 23 +++ electron/main/services/streaming/index.ts | 60 ++++++ electron/main/services/streaming/jellyfin.ts | 138 ++++++++++++++ electron/main/services/streaming/subsonic.ts | 154 ++++++++++++++++ electron/main/store/streaming.ts | 116 ++++++++++++ electron/main/utils/logger.ts | 1 + electron/preload/index.d.ts | 2 + electron/preload/index.ts | 17 ++ shared/defaults/settings.ts | 4 + shared/types/player.ts | 24 ++- shared/types/settings.ts | 11 ++ shared/types/streaming.ts | 87 +++++++++ src/core/player.ts | 57 ++++-- src/services/audioLoader.ts | 16 +- src/services/lyricLoader.ts | 14 +- src/stores/media.ts | 19 +- 20 files changed, 1016 insertions(+), 54 deletions(-) create mode 100644 electron/main/ipc/streaming.ts create mode 100644 electron/main/services/streaming/auth.ts create mode 100644 electron/main/services/streaming/emby.ts create mode 100644 electron/main/services/streaming/index.ts create mode 100644 electron/main/services/streaming/jellyfin.ts create mode 100644 electron/main/services/streaming/subsonic.ts create mode 100644 electron/main/store/streaming.ts create mode 100644 shared/types/streaming.ts diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index 7887400f..ea4f49d8 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -9,6 +9,7 @@ import { registerApisIpc } from "./apis"; import { registerLyricsIpc } from "./lyrics"; import { registerHotkeyIpc } from "./hotkey"; import { registerThemeIpc } from "./theme"; +import { registerStreamingIpc } from "./streaming"; /** 注册所有 IPC 处理 */ export const registerIpcHandlers = (): void => { @@ -23,4 +24,5 @@ export const registerIpcHandlers = (): void => { registerLyricsIpc(); registerHotkeyIpc(); registerThemeIpc(); + registerStreamingIpc(); }; diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index fb71a35e..da27f40f 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { app, ipcMain, powerMonitor } from "electron"; import { sendToMain } from "@main/utils/broadcast"; @@ -16,10 +15,17 @@ import { appName } from "@main/utils/config"; import { parseArtists, parseAlbum, formatArtists } from "@main/utils/metadata"; import { playerLog } from "@main/utils/logger"; import { ErrorCode } from "@shared/types/errors"; -import type { RepeatMode, ShuffleMode } from "@shared/types/player"; +import type { RepeatMode, ShuffleMode, Track } from "@shared/types/player"; import type { MediaEvent } from "@main/services/media"; import { JsPlayerEvent } from "@splayer/audio-engine"; +/** + * 渲染进程下发的当前曲目元数据,用于 SMTC/托盘/窗口标题。 + * 在 player:load 之前由渲染进程通过 player:setNowPlayingMeta 设置。 + * 缺失时(如直接通过文件关联打开)回退到 audio-engine 解析出的 tag。 + */ +let nowPlayingMeta: Track | null = null; + type AudioEngineModule = typeof import("@splayer/audio-engine"); /** 返回失败响应,附带日志 */ @@ -114,6 +120,17 @@ export const registerPlayerIpc = (): void => { // 注册实例创建/重建时的回调 onPlayerCreated(registerNativeEvents); onPlayerCreated(() => startDevicePolling()); + // 渲染层在 load 之前下发当前曲目的"权威"元数据,用于 SMTC/托盘/标题。 + // 这样 streaming/online 等类型的 Track 不会被 audio-engine 解析出的稀疏 tag 覆盖。 + ipcMain.handle("player:setNowPlayingMeta", (_event, track: Track) => { + try { + nowPlayingMeta = track; + return { success: true }; + } catch (error) { + return fail(ErrorCode.UNKNOWN, error); + } + }); + // 加载音频文件 ipcMain.handle("player:load", (_event, source: string, autoPlay = true) => { try { @@ -129,41 +146,36 @@ export const registerPlayerIpc = (): void => { }, }); const meta = inst.load(source, autoPlay); - // 提前解析元数据 - const artists = parseArtists(meta.artist ?? ""); - const artistStr = formatArtists(artists); - const trackTitle = meta.title || source.split(/[/\\]/).pop() || source; - const trackAlbum = parseAlbum(meta.album ?? ""); const durationMs = toMs(meta.duration); - const trackId = createHash("sha256").update(source).digest("hex").slice(0, 16); - // 高清封面更新系统媒体控件 + // 高清封面(系统媒体控件需要 raw 字节,渲染层显示用 toCacheUrl) const coverData = inst.getCoverRaw() ?? undefined; + const coverUrl = toCacheUrl(meta.cover); + // SMTC/托盘元数据:优先用渲染层下发的 nowPlayingMeta;缺失时回退到引擎解析结果 + const fallbackArtists = parseArtists(meta.artist ?? ""); + const fallbackTitle = meta.title || source.split(/[/\\]/).pop() || source; + const fallbackAlbumName = parseAlbum(meta.album ?? "")?.name ?? ""; + const displayTitle = nowPlayingMeta?.title ?? fallbackTitle; + const displayArtist = nowPlayingMeta + ? formatArtists(nowPlayingMeta.artists ?? []) + : formatArtists(fallbackArtists); + const displayAlbum = nowPlayingMeta?.album?.name ?? fallbackAlbumName; mediaService.setMetadata({ - title: trackTitle, - artist: artistStr, - album: trackAlbum?.name ?? "", + title: displayTitle, + artist: displayArtist, + album: displayAlbum, coverData, durationMs, }); const playState = autoPlay ? "Playing" : "Paused"; mediaService.setPlayState({ status: playState }); - // 更新窗口标题和托盘 - const displayTitle = artistStr ? `${trackTitle} - ${artistStr}` : trackTitle || appName; - getMainWindow()?.setTitle(displayTitle); - setTraySongName(displayTitle); + // 窗口标题和托盘 + const headerTitle = displayArtist + ? `${displayTitle} - ${displayArtist}` + : displayTitle || appName; + getMainWindow()?.setTitle(headerTitle); + setTraySongName(headerTitle); setTrayPlayState(autoPlay ? "playing" : "paused"); const data = { - track: { - id: trackId, - source: "local", - path: source, - title: trackTitle, - comment: meta.comment ?? undefined, - artists, - album: trackAlbum, - duration: durationMs, - cover: toCacheUrl(meta.cover), - }, detail: { quality: { sampleRate: meta.originalSampleRate, @@ -175,8 +187,19 @@ export const registerPlayerIpc = (): void => { embeddedLyric: meta.embeddedLyric, externalLyrics: meta.externalLyrics, }, + mediaInfo: { + duration: durationMs, + cover: coverUrl, + quality: { + sampleRate: meta.originalSampleRate, + channels: meta.channels, + bitsPerSample: meta.bitsPerSample, + bitRate: meta.bitRate, + codec: meta.codec, + }, + }, }; - playerLog.debug(`加载成功: ${trackTitle}`); + playerLog.debug(`加载成功: ${displayTitle}`); return { success: true, data }; } catch (error) { const msg = error instanceof Error ? error.message : String(error); diff --git a/electron/main/ipc/streaming.ts b/electron/main/ipc/streaming.ts new file mode 100644 index 00000000..10a1ef4c --- /dev/null +++ b/electron/main/ipc/streaming.ts @@ -0,0 +1,184 @@ +import { ipcMain } from "electron"; +import type { Track } from "@shared/types/player"; +import type { + StreamingServerConfig, + StreamingServerInput, +} from "@shared/types/streaming"; +import * as svc from "@main/services/streaming"; +import { + addConfig, + getActiveId, + getConfig, + listConfigs, + patchConfig, + removeConfig, + setActiveId, + toSummary, + updateConfig, +} from "@main/store/streaming"; +import { streamingLog as log } from "@main/utils/logger"; + +/** 把任意 error 转成 IPC 字符串 */ +const errStr = (err: unknown): string => (err instanceof Error ? err.message : String(err)); + +/** 输入校验 */ +const validateInput = (input: StreamingServerInput): string | null => { + if (!input.name?.trim()) return "服务器名称不能为空"; + if (!input.url?.trim()) return "URL 不能为空"; + if (!/^https?:\/\//i.test(input.url.trim())) return "URL 必须以 http:// 或 https:// 开头"; + if (!input.username) return "用户名不能为空"; + if (!input.password) return "密码不能为空"; + return null; +}; + +/** + * 确保 Jellyfin/Emby 配置持有有效 accessToken;缺失时执行登录并写回。 + * Subsonic 系无需此步。 + */ +const ensureAuthenticated = async ( + cfg: StreamingServerConfig, +): Promise => { + if (!svc.needsAccessToken(cfg.type)) return cfg; + if (cfg.accessToken && cfg.userId) return cfg; + const { accessToken, userId } = await svc.authenticate(cfg); + patchConfig(cfg.id, { accessToken, userId, lastConnected: Date.now() }); + return { ...cfg, accessToken, userId, lastConnected: Date.now() }; +}; + +export const registerStreamingIpc = (): void => { + // 列出全部 + ipcMain.handle("streaming:listServers", () => { + try { + return { success: true, data: listConfigs().map(toSummary) }; + } catch (err) { + return { success: false, error: errStr(err) }; + } + }); + + // 当前激活服务器 + ipcMain.handle("streaming:getActiveServer", () => { + try { + const id = getActiveId(); + if (!id) return { success: true, data: null }; + const cfg = getConfig(id); + return { success: true, data: cfg ? toSummary(cfg) : null }; + } catch (err) { + return { success: false, error: errStr(err) }; + } + }); + + ipcMain.handle("streaming:setActiveServer", (_e, id: string | null) => { + try { + setActiveId(id); + return { success: true }; + } catch (err) { + return { success: false, error: errStr(err) }; + } + }); + + // 添加 + ipcMain.handle("streaming:addServer", (_e, input: StreamingServerInput) => { + const invalid = validateInput(input); + if (invalid) return { success: false, error: invalid }; + try { + const cfg = addConfig(input); + return { success: true, data: toSummary(cfg) }; + } catch (err) { + return { success: false, error: errStr(err) }; + } + }); + + // 更新(局部) + ipcMain.handle( + "streaming:updateServer", + (_e, id: string, patch: Partial) => { + try { + const cfg = updateConfig(id, patch); + if (!cfg) return { success: false, error: "服务器配置不存在" }; + return { success: true, data: toSummary(cfg) }; + } catch (err) { + return { success: false, error: errStr(err) }; + } + }, + ); + + // 移除 + ipcMain.handle("streaming:removeServer", (_e, id: string) => { + try { + const ok = removeConfig(id); + return ok ? { success: true } : { success: false, error: "服务器配置不存在" }; + } catch (err) { + return { success: false, error: errStr(err) }; + } + }); + + // 预飞行连通性测试(不写盘) + ipcMain.handle("streaming:testConnection", async (_e, input: StreamingServerInput) => { + const invalid = validateInput(input); + if (invalid) return { success: false, error: invalid }; + try { + // 临时 cfg,借用 addConfig 的密码加密但不落盘 + const tempCfg: StreamingServerConfig = { + id: "__test__", + name: input.name, + type: input.type, + url: input.url.replace(/\/+$/, ""), + username: input.username, + // 临时使用 raw 前缀让 decrypt 能还原 + passwordEncrypted: `raw:${Buffer.from(input.password, "utf8").toString("base64")}`, + }; + // Jellyfin/Emby 需要登录后才知道账号有效 + if (svc.needsAccessToken(input.type)) { + try { + await svc.authenticate(tempCfg); + } catch (err) { + return { success: true, data: { ok: false, error: errStr(err) } }; + } + } + const result = await svc.ping(tempCfg); + return { success: true, data: result }; + } catch (err) { + return { success: false, error: errStr(err) }; + } + }); + + // 解析播放 URL + ipcMain.handle("streaming:resolveUrl", async (_e, track: Track) => { + try { + if (track.source !== "streaming") { + return { success: false, error: "track.source 不是 streaming" }; + } + if (!track.serverId || !track.originalId) { + return { success: false, error: "track 缺少 serverId 或 originalId" }; + } + const cfg = getConfig(track.serverId); + if (!cfg) return { success: false, error: "服务器配置不存在" }; + const ready = await ensureAuthenticated(cfg); + const url = svc.getStreamUrl(ready, track.originalId); + return { success: true, data: url }; + } catch (err) { + log.warn("resolveUrl 失败:", err); + return { success: false, error: errStr(err) }; + } + }); + + // 获取歌词 + ipcMain.handle("streaming:getLyrics", async (_e, track: Track) => { + try { + if (track.source !== "streaming" || !track.serverId || !track.originalId) { + return { success: true, data: null }; + } + const cfg = getConfig(track.serverId); + if (!cfg) return { success: true, data: null }; + const ready = await ensureAuthenticated(cfg); + const text = await svc.getLyrics(ready, track.originalId, { + artist: track.artists?.[0]?.name, + title: track.title, + }); + return { success: true, data: text }; + } catch (err) { + log.debug("getLyrics 失败(视为无歌词):", err); + return { success: true, data: null }; + } + }); +}; diff --git a/electron/main/services/streaming/auth.ts b/electron/main/services/streaming/auth.ts new file mode 100644 index 00000000..0e5ffc6d --- /dev/null +++ b/electron/main/services/streaming/auth.ts @@ -0,0 +1,62 @@ +import { safeStorage } from "electron"; +import { streamingLog as log } from "../../utils/logger"; + +/** + * 安全存储是否可用。Linux 在没有 keyring 时会返回 false。 + * 不可用时退化为 base64 明文(仍可正常工作,但相当于明文)。 + */ +let availabilityCache: boolean | null = null; +const isAvailable = (): boolean => { + if (availabilityCache !== null) return availabilityCache; + try { + availabilityCache = safeStorage.isEncryptionAvailable(); + if (!availabilityCache) { + log.warn("safeStorage 不可用,密码将以 base64 形式存储(非加密)"); + } + } catch (err) { + log.warn("safeStorage 探测失败,退化为 base64", err); + availabilityCache = false; + } + return availabilityCache; +}; + +/** 加密前缀,用于区分加密内容和 base64 fallback */ +const ENC_PREFIX = "enc:"; +const RAW_PREFIX = "raw:"; + +/** 把明文密码加密为可入库的字符串 */ +export const encrypt = (plain: string): string => { + if (!plain) return ""; + if (isAvailable()) { + try { + const buf = safeStorage.encryptString(plain); + return `${ENC_PREFIX}${buf.toString("base64")}`; + } catch (err) { + log.error("加密失败,退化为 base64", err); + } + } + return `${RAW_PREFIX}${Buffer.from(plain, "utf8").toString("base64")}`; +}; + +/** 还原密码;解密失败时返回空串 */ +export const decrypt = (stored: string): string => { + if (!stored) return ""; + if (stored.startsWith(ENC_PREFIX)) { + try { + const buf = Buffer.from(stored.slice(ENC_PREFIX.length), "base64"); + return safeStorage.decryptString(buf); + } catch (err) { + log.error("解密失败", err); + return ""; + } + } + if (stored.startsWith(RAW_PREFIX)) { + try { + return Buffer.from(stored.slice(RAW_PREFIX.length), "base64").toString("utf8"); + } catch { + return ""; + } + } + // 兼容裸字符串(不应出现,安全兜底) + return stored; +}; diff --git a/electron/main/services/streaming/emby.ts b/electron/main/services/streaming/emby.ts new file mode 100644 index 00000000..bbd7d040 --- /dev/null +++ b/electron/main/services/streaming/emby.ts @@ -0,0 +1,23 @@ +/** + * Emby 服务客户端。 + * Emby 与 Jellyfin 的 REST API 高度兼容(鉴权流程、资源端点几乎一致), + * 这里直接复用 jellyfin.ts 的 ping/authenticate/getLyrics,仅在 stream URL + * 端点选择上做微调(Emby 习惯用 /Audio/{id}/stream,universal 也通常可用)。 + */ +import type { StreamingServerConfig } from "@shared/types/streaming"; + +export { ping, authenticate, getLyrics } from "./jellyfin"; + +const baseUrl = (cfg: StreamingServerConfig): string => cfg.url.replace(/\/+$/, ""); + +export const getStreamUrl = (cfg: StreamingServerConfig, originalId: string): string => { + if (!cfg.accessToken) { + throw new Error("缺少 accessToken,需要先 authenticate"); + } + const params = new URLSearchParams({ + UserId: cfg.userId ?? "", + api_key: cfg.accessToken, + Static: "true", + }); + return `${baseUrl(cfg)}/Audio/${originalId}/stream?${params.toString()}`; +}; diff --git a/electron/main/services/streaming/index.ts b/electron/main/services/streaming/index.ts new file mode 100644 index 00000000..f748d298 --- /dev/null +++ b/electron/main/services/streaming/index.ts @@ -0,0 +1,60 @@ +/** + * 流媒体服务模块统一入口。 + * 按 cfg.type 路由到对应实现。 + */ +import type { + StreamingPingResult, + StreamingServerConfig, + StreamingServerType, +} from "@shared/types/streaming"; +import * as subsonic from "./subsonic"; +import * as jellyfin from "./jellyfin"; +import * as emby from "./emby"; + +/** 是否走 Subsonic 协议(subsonic / navidrome / opensubsonic) */ +const isSubsonic = (type: StreamingServerType): boolean => + type === "subsonic" || type === "navidrome" || type === "opensubsonic"; + +/** 是否需要 accessToken 鉴权(jellyfin/emby) */ +export const needsAccessToken = (type: StreamingServerType): boolean => + type === "jellyfin" || type === "emby"; + +/** 连通性测试 */ +export const ping = (cfg: StreamingServerConfig): Promise => { + if (isSubsonic(cfg.type)) return subsonic.ping(cfg); + if (cfg.type === "jellyfin") return jellyfin.ping(cfg); + if (cfg.type === "emby") return emby.ping(cfg); + return Promise.resolve({ ok: false, error: `不支持的服务器类型: ${cfg.type}` }); +}; + +/** + * Jellyfin/Emby 的密码登录。Subsonic 系不需要,调用方应当先用 needsAccessToken 判断。 + * 返回值需要由调用方持久化到 config 上。 + */ +export const authenticate = async ( + cfg: StreamingServerConfig, +): Promise<{ accessToken: string; userId: string }> => { + if (cfg.type === "jellyfin") return jellyfin.authenticate(cfg); + if (cfg.type === "emby") return emby.authenticate(cfg); + throw new Error(`${cfg.type} 不需要 accessToken 鉴权`); +}; + +/** 构造可直接喂给 audio-engine 的播放 URL */ +export const getStreamUrl = (cfg: StreamingServerConfig, originalId: string): string => { + if (isSubsonic(cfg.type)) return subsonic.getStreamUrl(cfg, originalId); + if (cfg.type === "jellyfin") return jellyfin.getStreamUrl(cfg, originalId); + if (cfg.type === "emby") return emby.getStreamUrl(cfg, originalId); + throw new Error(`不支持的服务器类型: ${cfg.type}`); +}; + +/** 取流媒体歌词;不可用一律返回 null(由上层走兜底) */ +export const getLyrics = async ( + cfg: StreamingServerConfig, + originalId: string, + hint?: { artist?: string; title?: string }, +): Promise => { + if (isSubsonic(cfg.type)) return subsonic.getLyrics(cfg, originalId, hint); + if (cfg.type === "jellyfin") return jellyfin.getLyrics(cfg, originalId); + if (cfg.type === "emby") return emby.getLyrics(cfg, originalId); + return null; +}; diff --git a/electron/main/services/streaming/jellyfin.ts b/electron/main/services/streaming/jellyfin.ts new file mode 100644 index 00000000..15bab8e2 --- /dev/null +++ b/electron/main/services/streaming/jellyfin.ts @@ -0,0 +1,138 @@ +import { randomBytes } from "node:crypto"; +import type { StreamingPingResult, StreamingServerConfig } from "@shared/types/streaming"; +import { decrypt } from "./auth"; +import { streamingLog as log } from "../../utils/logger"; + +const CLIENT_NAME = "SPlayer-Next"; +const CLIENT_VERSION = "1.0.0"; +const DEVICE_NAME = "SPlayer Desktop"; +const REQUEST_TIMEOUT = 15000; + +/** 设备 ID:进程级稳定,重启后变化(Jellyfin 用作播放历史归并) */ +let deviceIdCache: string | null = null; +const deviceId = (): string => { + if (!deviceIdCache) deviceIdCache = `splayer-next-${randomBytes(8).toString("hex")}`; + return deviceIdCache; +}; + +const baseUrl = (cfg: StreamingServerConfig): string => cfg.url.replace(/\/+$/, ""); + +/** Jellyfin 的鉴权头格式 */ +const authHeader = (cfg: StreamingServerConfig): string => { + const parts = [ + `Client="${CLIENT_NAME}"`, + `Device="${DEVICE_NAME}"`, + `DeviceId="${deviceId()}"`, + `Version="${CLIENT_VERSION}"`, + ]; + if (cfg.accessToken) parts.push(`Token="${cfg.accessToken}"`); + return `MediaBrowser ${parts.join(", ")}`; +}; + +const fetchJson = async ( + url: string, + init: RequestInit, + timeout = REQUEST_TIMEOUT, +): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +}; + +/** + * 用账号密码换 accessToken / userId。 + * 调用方负责把返回值持久化到 config 上。 + */ +export const authenticate = async ( + cfg: StreamingServerConfig, +): Promise<{ accessToken: string; userId: string }> => { + const password = decrypt(cfg.passwordEncrypted); + const res = await fetchJson(`${baseUrl(cfg)}/Users/AuthenticateByName`, { + method: "POST", + headers: { + "Content-Type": "application/json", + // 此时 cfg.accessToken 通常为空,authHeader 不附带 Token + "X-Emby-Authorization": authHeader(cfg), + }, + body: JSON.stringify({ Username: cfg.username, Pw: password }), + }); + if (!res.ok) throw new Error(`登录失败 HTTP ${res.status}`); + const json = (await res.json()) as { AccessToken?: string; User?: { Id?: string } }; + const accessToken = json.AccessToken; + const userId = json.User?.Id; + if (!accessToken || !userId) throw new Error("登录响应缺少 AccessToken/UserId"); + return { accessToken, userId }; +}; + +/** 服务器探活(不需要 token) */ +export const ping = async (cfg: StreamingServerConfig): Promise => { + try { + const res = await fetchJson(`${baseUrl(cfg)}/System/Info/Public`, { method: "GET" }); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; + const json = (await res.json()) as { Version?: string }; + return { ok: true, version: json.Version }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.warn(`jellyfin ping 失败 [${cfg.url}]:`, msg); + return { ok: false, error: msg }; + } +}; + +/** + * 构造音频流 URL。token 走 query 参数,便于 audio-engine 直接消费。 + * universal 端点会按客户端 capabilities 决定直出还是转码;这里默认请求直出。 + */ +export const getStreamUrl = (cfg: StreamingServerConfig, originalId: string): string => { + if (!cfg.accessToken) { + throw new Error("缺少 accessToken,需要先 authenticate"); + } + const params = new URLSearchParams({ + UserId: cfg.userId ?? "", + DeviceId: deviceId(), + api_key: cfg.accessToken, + // 直出:不指定 AudioCodec/MaxStreamingBitrate,让服务器返回原始流 + Static: "true", + }); + return `${baseUrl(cfg)}/Audio/${originalId}/universal?${params.toString()}`; +}; + +/** + * 获取歌词。Jellyfin 10.8+ 提供 /Audio/{id}/Lyrics。 + * 旧版本或无歌词时返回 null。 + */ +export const getLyrics = async ( + cfg: StreamingServerConfig, + originalId: string, +): Promise => { + if (!cfg.accessToken) return null; + try { + const res = await fetchJson(`${baseUrl(cfg)}/Audio/${originalId}/Lyrics`, { + method: "GET", + headers: { "X-Emby-Authorization": authHeader(cfg) }, + }); + if (!res.ok) return null; + const json = (await res.json()) as { + Lyrics?: { Start?: number; Text: string }[]; + }; + const lines = json.Lyrics ?? []; + if (lines.length === 0) return null; + // Jellyfin 时间戳单位是 100ns ticks + return lines + .map((l) => { + const ms = Math.max(0, Math.floor((l.Start ?? 0) / 10000)); + const mm = Math.floor(ms / 60000); + const ss = Math.floor((ms % 60000) / 1000); + const xx = Math.floor((ms % 1000) / 10); + const ts = `[${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}.${String(xx).padStart(2, "0")}]`; + return `${ts}${l.Text ?? ""}`; + }) + .join("\n"); + } catch (err) { + log.debug("jellyfin getLyrics 失败", err); + return null; + } +}; diff --git a/electron/main/services/streaming/subsonic.ts b/electron/main/services/streaming/subsonic.ts new file mode 100644 index 00000000..11e6e4c9 --- /dev/null +++ b/electron/main/services/streaming/subsonic.ts @@ -0,0 +1,154 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { StreamingPingResult, StreamingServerConfig } from "@shared/types/streaming"; +import { decrypt } from "./auth"; +import { streamingLog as log } from "../../utils/logger"; + +/** Subsonic API 客户端版本号;必须 ≥ 服务器最低支持版本 */ +const API_VERSION = "1.16.1"; +/** 客户端标识,会出现在服务器播放历史中 */ +const CLIENT_NAME = "SPlayer-Next"; +/** 请求超时(毫秒) */ +const REQUEST_TIMEOUT = 15000; + +interface SubsonicAuthParams { + u: string; + t: string; + s: string; + v: string; + c: string; + f: string; +} + +/** 生成随机 salt(12 字符 hex) */ +const newSalt = (): string => randomBytes(6).toString("hex"); + +/** md5(password + salt) */ +const md5Token = (password: string, salt: string): string => + createHash("md5").update(password + salt).digest("hex"); + +/** 标准化 baseUrl,去掉尾斜杠 */ +const baseUrl = (cfg: StreamingServerConfig): string => cfg.url.replace(/\/+$/, ""); + +/** 生成本次请求的鉴权参数 */ +const authParams = (cfg: StreamingServerConfig): SubsonicAuthParams => { + const password = decrypt(cfg.passwordEncrypted); + const salt = newSalt(); + return { + u: cfg.username, + t: md5Token(password, salt), + s: salt, + v: API_VERSION, + c: CLIENT_NAME, + f: "json", + }; +}; + +/** 拼接带鉴权参数的完整 URL */ +const buildUrl = ( + cfg: StreamingServerConfig, + endpoint: string, + extra: Record = {}, +): string => { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(authParams(cfg))) params.set(k, v); + for (const [k, v] of Object.entries(extra)) params.set(k, String(v)); + return `${baseUrl(cfg)}/rest/${endpoint}?${params.toString()}`; +}; + +/** 解析 subsonic-response 包装;失败时抛错 */ +const parseResponse = async (res: Response): Promise => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + const wrap = json?.["subsonic-response"]; + if (!wrap) throw new Error("响应缺少 subsonic-response 包装"); + if (wrap.status !== "ok") { + throw new Error(wrap.error?.message || `Subsonic error code ${wrap.error?.code}`); + } + return wrap as T; +}; + +/** 带超时的 fetch */ +const fetchWithTimeout = async (url: string, init?: RequestInit): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +}; + +/** + * 连通性测试。成功返回服务器版本号。 + */ +export const ping = async (cfg: StreamingServerConfig): Promise => { + try { + const res = await fetchWithTimeout(buildUrl(cfg, "ping")); + const wrap = await parseResponse<{ version?: string; serverVersion?: string }>(res); + return { ok: true, version: wrap.serverVersion ?? wrap.version }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.warn(`subsonic ping 失败 [${cfg.url}]:`, msg); + return { ok: false, error: msg }; + } +}; + +/** + * 构造歌曲流播放 URL(含鉴权参数)。 + * Subsonic 的 stream 接口支持 HTTP Range,audio-engine 的 FFmpeg backend 可直接消费。 + */ +export const getStreamUrl = (cfg: StreamingServerConfig, originalId: string): string => + buildUrl(cfg, "stream", { id: originalId }); + +/** + * 获取歌词(LRC 文本)。 + * 优先 OpenSubsonic 扩展接口 getLyricsBySongId(结构化),失败时回退 getLyrics(artist+title 字符串匹配)。 + * 任一拿不到都返回 null(不抛错),让上层走本地/embedded 兜底。 + */ +export const getLyrics = async ( + cfg: StreamingServerConfig, + originalId: string, + hint?: { artist?: string; title?: string }, +): Promise => { + // 优先试 getLyricsBySongId(OpenSubsonic) + try { + const res = await fetchWithTimeout(buildUrl(cfg, "getLyricsBySongId", { id: originalId })); + const wrap = await parseResponse<{ + lyricsList?: { structuredLyrics?: { line?: { start?: number; value: string }[] }[] }; + }>(res); + const structured = wrap.lyricsList?.structuredLyrics?.[0]?.line ?? []; + if (structured.length > 0) { + // 转 LRC:start 是毫秒 + return structured + .map((l) => { + const ms = Math.max(0, l.start ?? 0); + const mm = Math.floor(ms / 60000); + const ss = Math.floor((ms % 60000) / 1000); + const xx = Math.floor((ms % 1000) / 10); + const ts = `[${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}.${String(xx).padStart(2, "0")}]`; + return `${ts}${l.value ?? ""}`; + }) + .join("\n"); + } + } catch (err) { + log.debug("getLyricsBySongId 失败,尝试 getLyrics", err); + } + + // 回退 getLyrics(旧 Subsonic) + if (hint?.artist || hint?.title) { + try { + const res = await fetchWithTimeout( + buildUrl(cfg, "getLyrics", { + artist: hint.artist ?? "", + title: hint.title ?? "", + }), + ); + const wrap = await parseResponse<{ lyrics?: { value?: string } }>(res); + const text = wrap.lyrics?.value; + if (text && text.trim()) return text; + } catch (err) { + log.debug("getLyrics 也失败", err); + } + } + return null; +}; diff --git a/electron/main/store/streaming.ts b/electron/main/store/streaming.ts new file mode 100644 index 00000000..61d3fa25 --- /dev/null +++ b/electron/main/store/streaming.ts @@ -0,0 +1,116 @@ +/** + * 流媒体服务器配置存储。 + * 复用主配置 store(settings.json)的 streaming 节,所有写盘走同一个原子写。 + */ +import { randomUUID } from "node:crypto"; +import type { + StreamingServerConfig, + StreamingServerInput, + StreamingServerSummary, +} from "@shared/types/streaming"; +import { encrypt } from "../services/streaming/auth"; +import { store } from "./index"; + +/** 规范化服务器 URL:去掉尾斜杠 */ +const normalizeUrl = (url: string): string => url.trim().replace(/\/+$/, ""); + +/** 读全部服务器(含加密密码) */ +export const listConfigs = (): StreamingServerConfig[] => { + return [...store.get("streaming.servers")]; +}; + +/** 取单个 */ +export const getConfig = (id: string): StreamingServerConfig | null => { + return store.get("streaming.servers").find((s) => s.id === id) ?? null; +}; + +/** 转 summary(剥密码) */ +export const toSummary = (cfg: StreamingServerConfig): StreamingServerSummary => ({ + id: cfg.id, + name: cfg.name, + type: cfg.type, + url: cfg.url, + username: cfg.username, + hasToken: !!cfg.accessToken, + lastConnected: cfg.lastConnected, +}); + +/** 整体回写(接受新数组引用,触发 store 写盘) */ +const writeAll = (servers: StreamingServerConfig[]): void => { + store.set("streaming.servers", servers); +}; + +/** 添加;password 必填,会立即加密后落盘 */ +export const addConfig = (input: StreamingServerInput): StreamingServerConfig => { + const cfg: StreamingServerConfig = { + id: randomUUID(), + name: input.name.trim(), + type: input.type, + url: normalizeUrl(input.url), + username: input.username, + passwordEncrypted: encrypt(input.password), + }; + writeAll([...listConfigs(), cfg]); + return cfg; +}; + +/** + * 局部更新;password 字段为 undefined 时保留原密码。 + * 改动 URL/账号会清空 accessToken/userId(强制重新登录)。 + */ +export const updateConfig = ( + id: string, + patch: Partial, +): StreamingServerConfig | null => { + const list = listConfigs(); + const idx = list.findIndex((s) => s.id === id); + if (idx < 0) return null; + const old = list[idx]; + const credentialsChanged = + (patch.url !== undefined && normalizeUrl(patch.url) !== old.url) || + (patch.username !== undefined && patch.username !== old.username) || + patch.password !== undefined; + const next: StreamingServerConfig = { + ...old, + name: patch.name?.trim() ?? old.name, + type: patch.type ?? old.type, + url: patch.url !== undefined ? normalizeUrl(patch.url) : old.url, + username: patch.username ?? old.username, + passwordEncrypted: + patch.password !== undefined ? encrypt(patch.password) : old.passwordEncrypted, + accessToken: credentialsChanged ? undefined : old.accessToken, + userId: credentialsChanged ? undefined : old.userId, + lastConnected: old.lastConnected, + }; + list[idx] = next; + writeAll(list); + return next; +}; + +/** 移除;如果是当前激活的服务器,激活 ID 一并清空 */ +export const removeConfig = (id: string): boolean => { + const list = listConfigs(); + const next = list.filter((s) => s.id !== id); + if (next.length === list.length) return false; + writeAll(next); + if (store.get("streaming.activeServerId") === id) { + store.set("streaming.activeServerId", null); + } + return true; +}; + +/** 内部:仅修改单条 config 的部分字段(accessToken/lastConnected 等运行时回填) */ +export const patchConfig = (id: string, patch: Partial): void => { + const list = listConfigs(); + const idx = list.findIndex((s) => s.id === id); + if (idx < 0) return; + list[idx] = { ...list[idx], ...patch }; + writeAll(list); +}; + +/** 激活服务器 */ +export const getActiveId = (): string | null => store.get("streaming.activeServerId"); +export const setActiveId = (id: string | null): void => { + if (id !== null && !getConfig(id)) return; + store.set("streaming.activeServerId", id); +}; diff --git a/electron/main/utils/logger.ts b/electron/main/utils/logger.ts index b25b3a89..0573e934 100644 --- a/electron/main/utils/logger.ts +++ b/electron/main/utils/logger.ts @@ -83,3 +83,4 @@ export const ipcLog = log.scope("ipc"); export const libraryLog = log.scope("library"); export const taskbarLog = log.scope("taskbar-lyric"); export const nativeLog = log.scope("native"); +export const streamingLog = log.scope("streaming"); diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index bc7d4c8c..819a6d55 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -13,6 +13,7 @@ import { TaskbarLyricApi, } from "@shared/types/window"; import { HotkeyApi } from "@shared/types/hotkey"; +import { StreamingApi } from "@shared/types/streaming"; declare global { interface Window { @@ -45,6 +46,7 @@ declare global { clearBackgroundImages: () => Promise; }; hotkey: HotkeyApi; + streaming: StreamingApi; }; } } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 47a9dabc..3fc6ae3b 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -3,6 +3,8 @@ import { electronAPI } from "@electron-toolkit/preload"; import type { TaskbarLyricSettings } from "@shared/types/settings"; import type { PluginInfo, PluginResolveUrlArgs } from "@shared/types/plugin"; import type { HotkeyActionId, HotkeyBinding, HotkeyConflict } from "@shared/types/hotkey"; +import type { Track } from "@shared/types/player"; +import type { StreamingServerInput } from "@shared/types/streaming"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -22,6 +24,8 @@ const api = { player: { // 加载音频(本地路径或网络地址) load: (source: string, autoPlay = true) => ipcRenderer.invoke("player:load", source, autoPlay), + // 在 load 之前下发当前曲目元数据(SMTC/托盘/标题用),保证 streaming/online 等不被引擎 tag 覆盖 + setNowPlayingMeta: (track: Track) => ipcRenderer.invoke("player:setNowPlayingMeta", track), // 恢复播放 play: () => ipcRenderer.invoke("player:play"), // 暂停播放 @@ -302,6 +306,19 @@ const api = { // 清空已缓存的背景图 clearBackgroundImages: (): Promise => ipcRenderer.invoke("theme:clearBackgroundImages"), }, + streaming: { + listServers: () => ipcRenderer.invoke("streaming:listServers"), + getActiveServer: () => ipcRenderer.invoke("streaming:getActiveServer"), + setActiveServer: (id: string | null) => ipcRenderer.invoke("streaming:setActiveServer", id), + addServer: (input: StreamingServerInput) => ipcRenderer.invoke("streaming:addServer", input), + updateServer: (id: string, patch: Partial) => + ipcRenderer.invoke("streaming:updateServer", id, patch), + removeServer: (id: string) => ipcRenderer.invoke("streaming:removeServer", id), + testConnection: (input: StreamingServerInput) => + ipcRenderer.invoke("streaming:testConnection", input), + resolveUrl: (track: Track) => ipcRenderer.invoke("streaming:resolveUrl", track), + getLyrics: (track: Track) => ipcRenderer.invoke("streaming:getLyrics", track), + }, hotkey: { getAll: () => ipcRenderer.invoke("hotkey:getAll"), set: (id: HotkeyActionId, binding: HotkeyBinding) => diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index c7266a06..5ad7ac90 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -88,6 +88,10 @@ export const defaultSystemConfig: SystemConfig = { enableOnlineTTMLLyric: false, amllDbServer: "https://amlldb.bikonoo.com/%p/%s.ttml", }, + streaming: { + servers: [], + activeServerId: null, + }, system: { rememberWindowState: true, taskbarProgress: true, diff --git a/shared/types/player.ts b/shared/types/player.ts index 8b5cdf65..85bb8591 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -11,7 +11,7 @@ export type RepeatMode = "off" | "list" | "one"; export type ShuffleMode = "off" | "on"; /** 歌曲来源 */ -export type TrackSource = "local" | "online"; +export type TrackSource = "local" | "online" | "streaming"; /** 歌手 */ export interface Artist { @@ -43,6 +43,10 @@ export interface Track { platform?: Platform; /** 本地路径 */ path?: string; + /** 流媒体服务器实例 ID(仅 source==='streaming') */ + serverId?: string; + /** 流媒体服务器原生 ID(仅 source==='streaming') */ + originalId?: string; /** 标题 */ title: string; /** 注释/副标题 */ @@ -75,10 +79,21 @@ export interface TrackDetail { externalLyrics: { format: LyricFormat; path: string }[]; } +/** 播放器加载后从音频流提取出的可覆盖元数据 */ +export interface MediaInfo { + /** 时长(毫秒) */ + duration: number; + /** 缩略封面(cache:// URL 或 base64) */ + cover?: string; + /** 音质信息 */ + quality?: AudioQuality; +} + /** 播放器加载后返回的完整数据 */ export interface LoadResult { - track: Track; detail: TrackDetail; + /** 引擎从音频流提取的元数据,用于 enrich 渲染层已持有的 Track */ + mediaInfo: MediaInfo; } /** 播放器状态快照 */ @@ -123,6 +138,11 @@ export interface IpcResponse { export interface PlayerApi { /** 加载音频(本地路径或网络地址) */ load: (source: string, autoPlay?: boolean) => Promise>; + /** + * 设置当前播放曲目的元数据,用于 SMTC/托盘等系统媒体集成。 + * 应当在 load 之前调用,确保 status 推送/setMetadata 用到正确信息。 + */ + setNowPlayingMeta: (track: Track) => Promise; /** 恢复播放 */ play: () => Promise; /** 暂停播放 */ diff --git a/shared/types/settings.ts b/shared/types/settings.ts index ddd4c7df..7503ac85 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -1,5 +1,6 @@ import type { PluginsConfig } from "./plugin"; import type { HotkeyConfig } from "./hotkey"; +import type { StreamingServerConfig } from "./streaming"; /** 支持的语言代码 */ export type LocaleCode = "zh-CN" | "en-US"; @@ -185,6 +186,14 @@ export interface OnlineLyricSettings { amllDbServer: string; } +/** 流媒体服务器配置 */ +export interface StreamingSettings { + /** 已配置的服务器列表,密码字段已加密 */ + servers: StreamingServerConfig[]; + /** 当前激活的服务器 ID,未选择时为 null */ + activeServerId: string | null; +} + /** 主窗口几何 */ export interface MainWindowState { width: number; @@ -243,6 +252,8 @@ export interface SystemConfig { taskbarLyric: TaskbarLyricSettings; /** 在线歌词服务配置 */ lyric: OnlineLyricSettings; + /** 流媒体服务器配置 */ + streaming: StreamingSettings; /** 系统配置 */ system: { /** 记忆窗口状态 */ diff --git a/shared/types/streaming.ts b/shared/types/streaming.ts new file mode 100644 index 00000000..a166760c --- /dev/null +++ b/shared/types/streaming.ts @@ -0,0 +1,87 @@ +import type { IpcResponse, Track } from "./player"; + +/** 支持的媒体服务器类型 */ +export type StreamingServerType = + | "subsonic" + | "navidrome" + | "opensubsonic" + | "jellyfin" + | "emby"; + +/** + * 完整服务器配置(持久化形态,密码已加密) + * 仅在主进程内流转,不暴露给渲染进程 + */ +export interface StreamingServerConfig { + /** crypto.randomUUID() */ + id: string; + name: string; + type: StreamingServerType; + /** 服务器地址,规范化为不带尾斜杠 */ + url: string; + username: string; + /** safeStorage 加密后 base64;不可用时退化为 base64 明文 */ + passwordEncrypted: string; + /** Jellyfin/Emby 鉴权后回填,过期重新登录 */ + accessToken?: string; + /** Jellyfin/Emby 鉴权后回填的用户 ID */ + userId?: string; + /** 最后一次连接成功的时间戳(ms) */ + lastConnected?: number; +} + +/** 渲染进程视图:剥离敏感字段 */ +export interface StreamingServerSummary { + id: string; + name: string; + type: StreamingServerType; + url: string; + username: string; + /** 是否已持有有效 accessToken(仅 jellyfin/emby 用) */ + hasToken: boolean; + lastConnected?: number; +} + +/** 添加/更新/连通性测试时由渲染层提交的明文 payload */ +export interface StreamingServerInput { + name: string; + type: StreamingServerType; + url: string; + username: string; + /** 明文密码:通过 contextBridge 单次传入,主进程立即加密存盘 */ + password: string; +} + +/** 连通性测试结果 */ +export interface StreamingPingResult { + ok: boolean; + /** 服务器报告的版本号 */ + version?: string; + /** 失败时的错误描述 */ + error?: string; +} + +/** 流媒体相关 IPC */ +export interface StreamingApi { + /** 列出全部已配置的服务器(不含密码) */ + listServers: () => Promise>; + /** 获取当前激活服务器 */ + getActiveServer: () => Promise>; + /** 设置激活服务器;传 null 清除 */ + setActiveServer: (id: string | null) => Promise; + /** 添加服务器,返回带 id 的 summary */ + addServer: (input: StreamingServerInput) => Promise>; + /** 局部更新;password 字段为 undefined 时保留原密码 */ + updateServer: ( + id: string, + patch: Partial, + ) => Promise>; + /** 移除服务器 */ + removeServer: (id: string) => Promise; + /** 不写入配置的预飞行测试 */ + testConnection: (input: StreamingServerInput) => Promise>; + /** 解析 Track 为可直接喂给 player:load 的 URL(含鉴权参数) */ + resolveUrl: (track: Track) => Promise>; + /** 取流媒体歌词(LRC 文本或 null) */ + getLyrics: (track: Track) => Promise>; +} diff --git a/src/core/player.ts b/src/core/player.ts index 6ae4ad9d..2407994f 100644 --- a/src/core/player.ts +++ b/src/core/player.ts @@ -46,10 +46,12 @@ export const load = async (source: string, autoPlay = true): Promise => { + if (track.source === "local") return track.path ?? null; + if (track.source === "streaming") { + const res = await window.api.streaming.resolveUrl(track); + if (res.success && res.data) return res.data; + if (res.error) handleError(res.error); + return null; + } + // TODO: online(netease/qqmusic/kugou) + return null; +}; + /** * 加载指定 Track 到播放器 * 乐观更新:立即显示歌曲信息,快速切歌时只有最后一次 load 生效 - * @param track - 要播放的 Track,为 null 或无 path 时忽略 + * @param track - 要播放的 Track,为 null 时忽略 */ const loadTrack = async (track: Track | null): Promise => { - if (!track?.path) return; + if (!track) return; + const source = await resolveTrackSource(track); + if (!source) return; // 乐观更新:同步写入 track,开启歌词加载周期 useMediaStore().setTrack(track); + // 把权威元数据下发主进程,让 SMTC/托盘/标题不被引擎 tag 覆盖 + window.api.player.setNowPlayingMeta(track); lyricLoader.beginLoad(); - await load(track.path); + await load(source); }; /** 恢复播放 */ @@ -576,15 +600,20 @@ export const initPlayer = async (): Promise => { if (unsubscribe) unsubscribe(); unsubscribe = window.api.player.onEvent(handleEvent); const lastTrack = status.currentTrack; - if (lastTrack?.path) { + if (lastTrack) { const lastPosition = status.position; - // 先设置 track 信息(确保播放条显示),再尝试 load + // 先设置 track 信息(确保播放条显示),再尝试解析 source 并 load useMediaStore().setTrack(lastTrack); - lyricLoader.beginLoad(); - const result = await load(lastTrack.path, settings.system.player.autoPlay); - // load 成功且需要恢复进度时 seek - if (result && settings.system.player.rememberLastTrack && lastPosition > 0) { - await seek(lastPosition); + const source = await resolveTrackSource(lastTrack); + if (source) { + window.api.player.setNowPlayingMeta(lastTrack); + lyricLoader.beginLoad(); + const result = await load(source, settings.system.player.autoPlay); + if (result && settings.system.player.rememberLastTrack && lastPosition > 0) { + await seek(lastPosition); + } + } else { + status.state = "idle"; } } else { status.state = "idle"; diff --git a/src/services/audioLoader.ts b/src/services/audioLoader.ts index 0413dbfc..e4ddf6d7 100644 --- a/src/services/audioLoader.ts +++ b/src/services/audioLoader.ts @@ -1,23 +1,23 @@ /** * 音频加载服务 * - * 纯数据获取:通过 IPC 调用 Rust 引擎加载音频,返回歌曲信息。 - * 不操作任何 store,不管理状态。 - * 后续可扩展:网络音源解析、音质选择、缓存管理等。 + * 纯数据获取:通过 IPC 调用 Rust 引擎加载音频,返回从音频流提取的元数据。 + * 调用方持有 Track 身份;本服务仅返回引擎能解析的辅助信息(detail + mediaInfo), + * 不再合成 Track。 */ -import type { Track, TrackDetail } from "@shared/types/player"; +import type { MediaInfo, TrackDetail } from "@shared/types/player"; export interface AudioLoadResult { - track: Track; detail: TrackDetail; + mediaInfo: MediaInfo; } /** - * 加载音频源,返回歌曲信息和详情 + * 加载音频源,返回歌曲详情和引擎提取的元数据 * @param source - 音频文件路径或网络地址 * @param autoPlay - 是否自动播放 - * @returns 成功返回 { track, detail },失败返回 null + * @returns 成功返回 { detail, mediaInfo },失败返回 null + error */ export const loadAudio = async ( source: string, @@ -25,7 +25,7 @@ export const loadAudio = async ( ): Promise<{ data: AudioLoadResult | null; error?: string }> => { const result = await window.api.player.load(source, autoPlay); if (result.success && result.data) { - return { data: { track: result.data.track, detail: result.data.detail } }; + return { data: { detail: result.data.detail, mediaInfo: result.data.mediaInfo } }; } return { data: null, error: result.error }; }; diff --git a/src/services/lyricLoader.ts b/src/services/lyricLoader.ts index 74f111dd..75ef1289 100644 --- a/src/services/lyricLoader.ts +++ b/src/services/lyricLoader.ts @@ -259,6 +259,18 @@ export const loadForTrack = async (detail: TrackDetail | null): Promise => commit(token, null, null); return; } + // 流媒体服务器:仅取流媒体提供的 LRC,不再走本地/平台匹配 + if (track.source === "streaming") { + const res = await window.api.streaming.getLyrics(track); + if (token !== currentToken) return; + const text = res.success ? res.data : null; + if (text && text.trim()) { + commit(token, { source: "external", format: "lrc" }, { content: text }); + } else { + commit(token, null, null); + } + return; + } // 本地文件 const local = detail ? await readLocal(detail) : null; if (token !== currentToken) return; @@ -287,7 +299,7 @@ const refreshPreference = async (): Promise => { const token = currentToken; const media = useMediaStore(); const track = media.track; - if (!track || track.source === "online") return; + if (!track || track.source === "online" || track.source === "streaming") return; const detail = media.detail; const local = detail ? await readLocal(detail) : null; diff --git a/src/stores/media.ts b/src/stores/media.ts index a65adf8d..79a434bb 100644 --- a/src/stores/media.ts +++ b/src/stores/media.ts @@ -1,4 +1,4 @@ -import type { Track, TrackDetail } from "@shared/types/player"; +import type { MediaInfo, Track, TrackDetail } from "@shared/types/player"; import type { LyricData, LyricFormat, LyricInput, LyricLine } from "@shared/types/lyrics"; import { findLyricIndex } from "@shared/utils/lyric"; import { watchLyricPreference } from "@/services/lyricLoader"; @@ -56,6 +56,22 @@ export const useMediaStore = defineStore("media", () => { detail.value = newDetail ?? null; }; + /** + * 把 audio-engine 解析出的元数据合并到当前 Track 上。 + * 保留身份字段(id/source/serverId/originalId/platform/path); + * 仅对未设置或空值的展示字段做兜底填充(cover/quality/duration)。 + */ + const enrichTrack = (info: MediaInfo, newDetail?: TrackDetail): void => { + if (!track.value) return; + track.value = { + ...track.value, + duration: track.value.duration > 0 ? track.value.duration : info.duration, + cover: track.value.cover ?? info.cover, + quality: track.value.quality ?? info.quality, + }; + if (newDetail) detail.value = newDetail; + }; + /** 重置歌词状态 */ const resetLyricState = (): void => { activeLyric.value = null; @@ -122,6 +138,7 @@ export const useMediaStore = defineStore("media", () => { lyricLoading, lyricIndex, setTrack, + enrichTrack, resetLyricState, setLyric, updateLyricIndex, From 65cf4b0a4cea98211fffefe304e78aac490390f8 Mon Sep 17 00:00:00 2001 From: imsyy Date: Sat, 9 May 2026 01:45:42 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=E5=9F=BA=E7=A1=80=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=B5=81=E5=AA=92=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.d.ts | 1 + electron/main/ipc/player.ts | 81 ++-- electron/main/ipc/streaming.ts | 187 +------- electron/main/services/streaming/auth.ts | 62 --- electron/main/services/streaming/emby.ts | 23 - electron/main/services/streaming/index.ts | 60 --- electron/main/services/streaming/jellyfin.ts | 138 ------ electron/main/services/streaming/subsonic.ts | 154 ------- electron/main/store/streaming.ts | 116 ----- electron/main/utils/fetchBytes.ts | 36 ++ electron/preload/index.ts | 21 +- shared/defaults/settings.ts | 4 - shared/types/player.ts | 20 +- shared/types/settings.ts | 11 - shared/types/streaming.ts | 115 +++-- src/components/list/SongList.vue | 4 +- .../player/FullPlayer/PlayerData.vue | 17 +- .../settings/custom/StreamingServerList.vue | 361 +++++++++++++++ src/core/player.ts | 32 +- src/i18n/locales/en-US.json | 62 ++- src/i18n/locales/zh-CN.json | 62 ++- src/layouts/MainLayout.vue | 13 +- src/layouts/components/SideBar.vue | 12 +- src/pages/Artist.vue | 29 +- src/pages/Collection.vue | 32 ++ src/pages/Streaming/Albums.vue | 56 +++ src/pages/Streaming/Artists.vue | 57 +++ src/pages/Streaming/Index.vue | 235 ++++++++++ src/pages/Streaming/Playlists.vue | 56 +++ src/pages/Streaming/Songs.vue | 71 +++ src/router/index.ts | 27 ++ src/services/audioLoader.ts | 16 +- src/services/lyricLoader.ts | 14 +- src/services/streaming/emby.ts | 50 ++ src/services/streaming/errors.ts | 26 ++ src/services/streaming/http.ts | 33 ++ src/services/streaming/index.ts | 131 ++++++ src/services/streaming/jellyfin.ts | 286 ++++++++++++ src/services/streaming/subsonic.ts | 224 +++++++++ src/services/streaming/transform.ts | 290 ++++++++++++ src/settings/categories/streaming.ts | 24 + src/settings/schema.ts | 2 + src/stores/media.ts | 6 +- src/stores/streaming.ts | 436 ++++++++++++++++++ src/utils/md5.ts | 152 ++++++ src/utils/navigate.ts | 29 +- 46 files changed, 2961 insertions(+), 913 deletions(-) delete mode 100644 electron/main/services/streaming/auth.ts delete mode 100644 electron/main/services/streaming/emby.ts delete mode 100644 electron/main/services/streaming/index.ts delete mode 100644 electron/main/services/streaming/jellyfin.ts delete mode 100644 electron/main/services/streaming/subsonic.ts delete mode 100644 electron/main/store/streaming.ts create mode 100644 electron/main/utils/fetchBytes.ts create mode 100644 src/components/settings/custom/StreamingServerList.vue create mode 100644 src/pages/Streaming/Albums.vue create mode 100644 src/pages/Streaming/Artists.vue create mode 100644 src/pages/Streaming/Index.vue create mode 100644 src/pages/Streaming/Playlists.vue create mode 100644 src/pages/Streaming/Songs.vue create mode 100644 src/services/streaming/emby.ts create mode 100644 src/services/streaming/errors.ts create mode 100644 src/services/streaming/http.ts create mode 100644 src/services/streaming/index.ts create mode 100644 src/services/streaming/jellyfin.ts create mode 100644 src/services/streaming/subsonic.ts create mode 100644 src/services/streaming/transform.ts create mode 100644 src/settings/categories/streaming.ts create mode 100644 src/stores/streaming.ts create mode 100644 src/utils/md5.ts diff --git a/components.d.ts b/components.d.ts index 0c5cfd76..9aca5c7c 100644 --- a/components.d.ts +++ b/components.d.ts @@ -180,6 +180,7 @@ declare module 'vue' { SToast: typeof import('./src/components/ui/SToast.vue')['default'] STooltip: typeof import('./src/components/ui/STooltip.vue')['default'] StorageManager: typeof import('./src/components/settings/custom/StorageManager.vue')['default'] + StreamingServerList: typeof import('./src/components/settings/custom/StreamingServerList.vue')['default'] STree: typeof import('./src/components/ui/STree.vue')['default'] SVirtualList: typeof import('./src/components/ui/SVirtualList.vue')['default'] SwitchRoot: typeof import('reka-ui')['SwitchRoot'] diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index da27f40f..dd14dd16 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -5,6 +5,7 @@ import { toCacheUrl } from "@main/utils/protocol"; import { toMs } from "@main/utils/time"; import * as mediaService from "@main/services/media"; import * as nowPlaying from "@main/services/nowPlaying"; +import { fetchBytes } from "@main/utils/fetchBytes"; import { getPlayer, resetPlayer, onPlayerCreated } from "@main/services/engine"; import { startDevicePolling, stopDevicePolling } from "@main/services/device"; import { getThumbar } from "@main/services/thumbar"; @@ -15,17 +16,10 @@ import { appName } from "@main/utils/config"; import { parseArtists, parseAlbum, formatArtists } from "@main/utils/metadata"; import { playerLog } from "@main/utils/logger"; import { ErrorCode } from "@shared/types/errors"; -import type { RepeatMode, ShuffleMode, Track } from "@shared/types/player"; +import type { LoadOptions, RepeatMode, ShuffleMode } from "@shared/types/player"; import type { MediaEvent } from "@main/services/media"; import { JsPlayerEvent } from "@splayer/audio-engine"; -/** - * 渲染进程下发的当前曲目元数据,用于 SMTC/托盘/窗口标题。 - * 在 player:load 之前由渲染进程通过 player:setNowPlayingMeta 设置。 - * 缺失时(如直接通过文件关联打开)回退到 audio-engine 解析出的 tag。 - */ -let nowPlayingMeta: Track | null = null; - type AudioEngineModule = typeof import("@splayer/audio-engine"); /** 返回失败响应,附带日志 */ @@ -120,19 +114,13 @@ export const registerPlayerIpc = (): void => { // 注册实例创建/重建时的回调 onPlayerCreated(registerNativeEvents); onPlayerCreated(() => startDevicePolling()); - // 渲染层在 load 之前下发当前曲目的"权威"元数据,用于 SMTC/托盘/标题。 - // 这样 streaming/online 等类型的 Track 不会被 audio-engine 解析出的稀疏 tag 覆盖。 - ipcMain.handle("player:setNowPlayingMeta", (_event, track: Track) => { - try { - nowPlayingMeta = track; - return { success: true }; - } catch (error) { - return fail(ErrorCode.UNKNOWN, error); - } - }); // 加载音频文件 - ipcMain.handle("player:load", (_event, source: string, autoPlay = true) => { + // 歌曲加载并返回元数据 + 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"; try { const inst = getPlayer(); sendToMain("player:event", { @@ -147,27 +135,28 @@ export const registerPlayerIpc = (): void => { }); const meta = inst.load(source, autoPlay); const durationMs = toMs(meta.duration); - // 高清封面(系统媒体控件需要 raw 字节,渲染层显示用 toCacheUrl) - const coverData = inst.getCoverRaw() ?? undefined; - const coverUrl = toCacheUrl(meta.cover); - // SMTC/托盘元数据:优先用渲染层下发的 nowPlayingMeta;缺失时回退到引擎解析结果 - const fallbackArtists = parseArtists(meta.artist ?? ""); + // SMTC/托盘元数据 const fallbackTitle = meta.title || source.split(/[/\\]/).pop() || source; - const fallbackAlbumName = parseAlbum(meta.album ?? "")?.name ?? ""; - const displayTitle = nowPlayingMeta?.title ?? fallbackTitle; - const displayArtist = nowPlayingMeta - ? formatArtists(nowPlayingMeta.artists ?? []) - : formatArtists(fallbackArtists); - const displayAlbum = nowPlayingMeta?.album?.name ?? fallbackAlbumName; + const displayTitle = authoritative?.title ?? fallbackTitle; + const displayArtist = authoritative + ? formatArtists(authoritative.artists ?? []) + : formatArtists(parseArtists(meta.artist ?? "")); + const displayAlbum = authoritative?.album?.name ?? parseAlbum(meta.album ?? "")?.name ?? ""; + // 高清封面 + let coverData: Buffer | null = null; + if (isStreaming && authoritative?.cover) { + coverData = await fetchBytes(authoritative.cover); + } else { + coverData = inst.getCoverRaw() ?? null; + } mediaService.setMetadata({ title: displayTitle, artist: displayArtist, album: displayAlbum, - coverData, + coverData: coverData ?? undefined, durationMs, }); - const playState = autoPlay ? "Playing" : "Paused"; - mediaService.setPlayState({ status: playState }); + mediaService.setPlayState({ status: autoPlay ? "Playing" : "Paused" }); // 窗口标题和托盘 const headerTitle = displayArtist ? `${displayTitle} - ${displayArtist}` @@ -175,28 +164,24 @@ export const registerPlayerIpc = (): void => { getMainWindow()?.setTitle(headerTitle); setTraySongName(headerTitle); setTrayPlayState(autoPlay ? "playing" : "paused"); + const quality = { + sampleRate: meta.originalSampleRate, + channels: meta.channels, + bitsPerSample: meta.bitsPerSample, + bitRate: meta.bitRate, + codec: meta.codec, + }; const data = { detail: { - quality: { - sampleRate: meta.originalSampleRate, - channels: meta.channels, - bitsPerSample: meta.bitsPerSample, - bitRate: meta.bitRate, - codec: meta.codec, - }, + quality, embeddedLyric: meta.embeddedLyric, externalLyrics: meta.externalLyrics, }, + // streaming 不回传 cover:避免覆盖渲染层已经持有的远端 URL mediaInfo: { duration: durationMs, - cover: coverUrl, - quality: { - sampleRate: meta.originalSampleRate, - channels: meta.channels, - bitsPerSample: meta.bitsPerSample, - bitRate: meta.bitRate, - codec: meta.codec, - }, + cover: isStreaming ? undefined : toCacheUrl(meta.cover), + quality, }, }; playerLog.debug(`加载成功: ${displayTitle}`); diff --git a/electron/main/ipc/streaming.ts b/electron/main/ipc/streaming.ts index 10a1ef4c..e1d23db2 100644 --- a/electron/main/ipc/streaming.ts +++ b/electron/main/ipc/streaming.ts @@ -1,184 +1,17 @@ -import { ipcMain } from "electron"; -import type { Track } from "@shared/types/player"; -import type { - StreamingServerConfig, - StreamingServerInput, -} from "@shared/types/streaming"; -import * as svc from "@main/services/streaming"; -import { - addConfig, - getActiveId, - getConfig, - listConfigs, - patchConfig, - removeConfig, - setActiveId, - toSummary, - updateConfig, -} from "@main/store/streaming"; -import { streamingLog as log } from "@main/utils/logger"; - -/** 把任意 error 转成 IPC 字符串 */ -const errStr = (err: unknown): string => (err instanceof Error ? err.message : String(err)); - -/** 输入校验 */ -const validateInput = (input: StreamingServerInput): string | null => { - if (!input.name?.trim()) return "服务器名称不能为空"; - if (!input.url?.trim()) return "URL 不能为空"; - if (!/^https?:\/\//i.test(input.url.trim())) return "URL 必须以 http:// 或 https:// 开头"; - if (!input.username) return "用户名不能为空"; - if (!input.password) return "密码不能为空"; - return null; -}; - /** - * 确保 Jellyfin/Emby 配置持有有效 accessToken;缺失时执行登录并写回。 - * Subsonic 系无需此步。 + * 流媒体相关 IPC:仅提供"拉远端字节"能力给 SMTC 高清封面。 + * + * 服务器配置、网络调用、列表/搜索/歌词等全部在渲染层完成。 */ -const ensureAuthenticated = async ( - cfg: StreamingServerConfig, -): Promise => { - if (!svc.needsAccessToken(cfg.type)) return cfg; - if (cfg.accessToken && cfg.userId) return cfg; - const { accessToken, userId } = await svc.authenticate(cfg); - patchConfig(cfg.id, { accessToken, userId, lastConnected: Date.now() }); - return { ...cfg, accessToken, userId, lastConnected: Date.now() }; -}; +import { ipcMain } from "electron"; +import { fetchBytes } from "@main/utils/fetchBytes"; export const registerStreamingIpc = (): void => { - // 列出全部 - ipcMain.handle("streaming:listServers", () => { - try { - return { success: true, data: listConfigs().map(toSummary) }; - } catch (err) { - return { success: false, error: errStr(err) }; - } - }); - - // 当前激活服务器 - ipcMain.handle("streaming:getActiveServer", () => { - try { - const id = getActiveId(); - if (!id) return { success: true, data: null }; - const cfg = getConfig(id); - return { success: true, data: cfg ? toSummary(cfg) : null }; - } catch (err) { - return { success: false, error: errStr(err) }; - } - }); - - ipcMain.handle("streaming:setActiveServer", (_e, id: string | null) => { - try { - setActiveId(id); - return { success: true }; - } catch (err) { - return { success: false, error: errStr(err) }; - } - }); - - // 添加 - ipcMain.handle("streaming:addServer", (_e, input: StreamingServerInput) => { - const invalid = validateInput(input); - if (invalid) return { success: false, error: invalid }; - try { - const cfg = addConfig(input); - return { success: true, data: toSummary(cfg) }; - } catch (err) { - return { success: false, error: errStr(err) }; - } - }); - - // 更新(局部) - ipcMain.handle( - "streaming:updateServer", - (_e, id: string, patch: Partial) => { - try { - const cfg = updateConfig(id, patch); - if (!cfg) return { success: false, error: "服务器配置不存在" }; - return { success: true, data: toSummary(cfg) }; - } catch (err) { - return { success: false, error: errStr(err) }; - } - }, - ); - - // 移除 - ipcMain.handle("streaming:removeServer", (_e, id: string) => { - try { - const ok = removeConfig(id); - return ok ? { success: true } : { success: false, error: "服务器配置不存在" }; - } catch (err) { - return { success: false, error: errStr(err) }; - } - }); - - // 预飞行连通性测试(不写盘) - ipcMain.handle("streaming:testConnection", async (_e, input: StreamingServerInput) => { - const invalid = validateInput(input); - if (invalid) return { success: false, error: invalid }; - try { - // 临时 cfg,借用 addConfig 的密码加密但不落盘 - const tempCfg: StreamingServerConfig = { - id: "__test__", - name: input.name, - type: input.type, - url: input.url.replace(/\/+$/, ""), - username: input.username, - // 临时使用 raw 前缀让 decrypt 能还原 - passwordEncrypted: `raw:${Buffer.from(input.password, "utf8").toString("base64")}`, - }; - // Jellyfin/Emby 需要登录后才知道账号有效 - if (svc.needsAccessToken(input.type)) { - try { - await svc.authenticate(tempCfg); - } catch (err) { - return { success: true, data: { ok: false, error: errStr(err) } }; - } - } - const result = await svc.ping(tempCfg); - return { success: true, data: result }; - } catch (err) { - return { success: false, error: errStr(err) }; - } - }); - - // 解析播放 URL - ipcMain.handle("streaming:resolveUrl", async (_e, track: Track) => { - try { - if (track.source !== "streaming") { - return { success: false, error: "track.source 不是 streaming" }; - } - if (!track.serverId || !track.originalId) { - return { success: false, error: "track 缺少 serverId 或 originalId" }; - } - const cfg = getConfig(track.serverId); - if (!cfg) return { success: false, error: "服务器配置不存在" }; - const ready = await ensureAuthenticated(cfg); - const url = svc.getStreamUrl(ready, track.originalId); - return { success: true, data: url }; - } catch (err) { - log.warn("resolveUrl 失败:", err); - return { success: false, error: errStr(err) }; - } - }); - - // 获取歌词 - ipcMain.handle("streaming:getLyrics", async (_e, track: Track) => { - try { - if (track.source !== "streaming" || !track.serverId || !track.originalId) { - return { success: true, data: null }; - } - const cfg = getConfig(track.serverId); - if (!cfg) return { success: true, data: null }; - const ready = await ensureAuthenticated(cfg); - const text = await svc.getLyrics(ready, track.originalId, { - artist: track.artists?.[0]?.name, - title: track.title, - }); - return { success: true, data: text }; - } catch (err) { - log.debug("getLyrics 失败(视为无歌词):", err); - return { success: true, data: null }; + ipcMain.handle("streaming:fetchCoverBytes", async (_e, url: string) => { + if (typeof url !== "string" || !/^https?:\/\//i.test(url)) { + return { success: false, error: "无效的 URL" }; } + const buf = await fetchBytes(url); + return { success: true, data: buf }; }); }; diff --git a/electron/main/services/streaming/auth.ts b/electron/main/services/streaming/auth.ts deleted file mode 100644 index 0e5ffc6d..00000000 --- a/electron/main/services/streaming/auth.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { safeStorage } from "electron"; -import { streamingLog as log } from "../../utils/logger"; - -/** - * 安全存储是否可用。Linux 在没有 keyring 时会返回 false。 - * 不可用时退化为 base64 明文(仍可正常工作,但相当于明文)。 - */ -let availabilityCache: boolean | null = null; -const isAvailable = (): boolean => { - if (availabilityCache !== null) return availabilityCache; - try { - availabilityCache = safeStorage.isEncryptionAvailable(); - if (!availabilityCache) { - log.warn("safeStorage 不可用,密码将以 base64 形式存储(非加密)"); - } - } catch (err) { - log.warn("safeStorage 探测失败,退化为 base64", err); - availabilityCache = false; - } - return availabilityCache; -}; - -/** 加密前缀,用于区分加密内容和 base64 fallback */ -const ENC_PREFIX = "enc:"; -const RAW_PREFIX = "raw:"; - -/** 把明文密码加密为可入库的字符串 */ -export const encrypt = (plain: string): string => { - if (!plain) return ""; - if (isAvailable()) { - try { - const buf = safeStorage.encryptString(plain); - return `${ENC_PREFIX}${buf.toString("base64")}`; - } catch (err) { - log.error("加密失败,退化为 base64", err); - } - } - return `${RAW_PREFIX}${Buffer.from(plain, "utf8").toString("base64")}`; -}; - -/** 还原密码;解密失败时返回空串 */ -export const decrypt = (stored: string): string => { - if (!stored) return ""; - if (stored.startsWith(ENC_PREFIX)) { - try { - const buf = Buffer.from(stored.slice(ENC_PREFIX.length), "base64"); - return safeStorage.decryptString(buf); - } catch (err) { - log.error("解密失败", err); - return ""; - } - } - if (stored.startsWith(RAW_PREFIX)) { - try { - return Buffer.from(stored.slice(RAW_PREFIX.length), "base64").toString("utf8"); - } catch { - return ""; - } - } - // 兼容裸字符串(不应出现,安全兜底) - return stored; -}; diff --git a/electron/main/services/streaming/emby.ts b/electron/main/services/streaming/emby.ts deleted file mode 100644 index bbd7d040..00000000 --- a/electron/main/services/streaming/emby.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Emby 服务客户端。 - * Emby 与 Jellyfin 的 REST API 高度兼容(鉴权流程、资源端点几乎一致), - * 这里直接复用 jellyfin.ts 的 ping/authenticate/getLyrics,仅在 stream URL - * 端点选择上做微调(Emby 习惯用 /Audio/{id}/stream,universal 也通常可用)。 - */ -import type { StreamingServerConfig } from "@shared/types/streaming"; - -export { ping, authenticate, getLyrics } from "./jellyfin"; - -const baseUrl = (cfg: StreamingServerConfig): string => cfg.url.replace(/\/+$/, ""); - -export const getStreamUrl = (cfg: StreamingServerConfig, originalId: string): string => { - if (!cfg.accessToken) { - throw new Error("缺少 accessToken,需要先 authenticate"); - } - const params = new URLSearchParams({ - UserId: cfg.userId ?? "", - api_key: cfg.accessToken, - Static: "true", - }); - return `${baseUrl(cfg)}/Audio/${originalId}/stream?${params.toString()}`; -}; diff --git a/electron/main/services/streaming/index.ts b/electron/main/services/streaming/index.ts deleted file mode 100644 index f748d298..00000000 --- a/electron/main/services/streaming/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 流媒体服务模块统一入口。 - * 按 cfg.type 路由到对应实现。 - */ -import type { - StreamingPingResult, - StreamingServerConfig, - StreamingServerType, -} from "@shared/types/streaming"; -import * as subsonic from "./subsonic"; -import * as jellyfin from "./jellyfin"; -import * as emby from "./emby"; - -/** 是否走 Subsonic 协议(subsonic / navidrome / opensubsonic) */ -const isSubsonic = (type: StreamingServerType): boolean => - type === "subsonic" || type === "navidrome" || type === "opensubsonic"; - -/** 是否需要 accessToken 鉴权(jellyfin/emby) */ -export const needsAccessToken = (type: StreamingServerType): boolean => - type === "jellyfin" || type === "emby"; - -/** 连通性测试 */ -export const ping = (cfg: StreamingServerConfig): Promise => { - if (isSubsonic(cfg.type)) return subsonic.ping(cfg); - if (cfg.type === "jellyfin") return jellyfin.ping(cfg); - if (cfg.type === "emby") return emby.ping(cfg); - return Promise.resolve({ ok: false, error: `不支持的服务器类型: ${cfg.type}` }); -}; - -/** - * Jellyfin/Emby 的密码登录。Subsonic 系不需要,调用方应当先用 needsAccessToken 判断。 - * 返回值需要由调用方持久化到 config 上。 - */ -export const authenticate = async ( - cfg: StreamingServerConfig, -): Promise<{ accessToken: string; userId: string }> => { - if (cfg.type === "jellyfin") return jellyfin.authenticate(cfg); - if (cfg.type === "emby") return emby.authenticate(cfg); - throw new Error(`${cfg.type} 不需要 accessToken 鉴权`); -}; - -/** 构造可直接喂给 audio-engine 的播放 URL */ -export const getStreamUrl = (cfg: StreamingServerConfig, originalId: string): string => { - if (isSubsonic(cfg.type)) return subsonic.getStreamUrl(cfg, originalId); - if (cfg.type === "jellyfin") return jellyfin.getStreamUrl(cfg, originalId); - if (cfg.type === "emby") return emby.getStreamUrl(cfg, originalId); - throw new Error(`不支持的服务器类型: ${cfg.type}`); -}; - -/** 取流媒体歌词;不可用一律返回 null(由上层走兜底) */ -export const getLyrics = async ( - cfg: StreamingServerConfig, - originalId: string, - hint?: { artist?: string; title?: string }, -): Promise => { - if (isSubsonic(cfg.type)) return subsonic.getLyrics(cfg, originalId, hint); - if (cfg.type === "jellyfin") return jellyfin.getLyrics(cfg, originalId); - if (cfg.type === "emby") return emby.getLyrics(cfg, originalId); - return null; -}; diff --git a/electron/main/services/streaming/jellyfin.ts b/electron/main/services/streaming/jellyfin.ts deleted file mode 100644 index 15bab8e2..00000000 --- a/electron/main/services/streaming/jellyfin.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { randomBytes } from "node:crypto"; -import type { StreamingPingResult, StreamingServerConfig } from "@shared/types/streaming"; -import { decrypt } from "./auth"; -import { streamingLog as log } from "../../utils/logger"; - -const CLIENT_NAME = "SPlayer-Next"; -const CLIENT_VERSION = "1.0.0"; -const DEVICE_NAME = "SPlayer Desktop"; -const REQUEST_TIMEOUT = 15000; - -/** 设备 ID:进程级稳定,重启后变化(Jellyfin 用作播放历史归并) */ -let deviceIdCache: string | null = null; -const deviceId = (): string => { - if (!deviceIdCache) deviceIdCache = `splayer-next-${randomBytes(8).toString("hex")}`; - return deviceIdCache; -}; - -const baseUrl = (cfg: StreamingServerConfig): string => cfg.url.replace(/\/+$/, ""); - -/** Jellyfin 的鉴权头格式 */ -const authHeader = (cfg: StreamingServerConfig): string => { - const parts = [ - `Client="${CLIENT_NAME}"`, - `Device="${DEVICE_NAME}"`, - `DeviceId="${deviceId()}"`, - `Version="${CLIENT_VERSION}"`, - ]; - if (cfg.accessToken) parts.push(`Token="${cfg.accessToken}"`); - return `MediaBrowser ${parts.join(", ")}`; -}; - -const fetchJson = async ( - url: string, - init: RequestInit, - timeout = REQUEST_TIMEOUT, -): Promise => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeout); - try { - return await fetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timer); - } -}; - -/** - * 用账号密码换 accessToken / userId。 - * 调用方负责把返回值持久化到 config 上。 - */ -export const authenticate = async ( - cfg: StreamingServerConfig, -): Promise<{ accessToken: string; userId: string }> => { - const password = decrypt(cfg.passwordEncrypted); - const res = await fetchJson(`${baseUrl(cfg)}/Users/AuthenticateByName`, { - method: "POST", - headers: { - "Content-Type": "application/json", - // 此时 cfg.accessToken 通常为空,authHeader 不附带 Token - "X-Emby-Authorization": authHeader(cfg), - }, - body: JSON.stringify({ Username: cfg.username, Pw: password }), - }); - if (!res.ok) throw new Error(`登录失败 HTTP ${res.status}`); - const json = (await res.json()) as { AccessToken?: string; User?: { Id?: string } }; - const accessToken = json.AccessToken; - const userId = json.User?.Id; - if (!accessToken || !userId) throw new Error("登录响应缺少 AccessToken/UserId"); - return { accessToken, userId }; -}; - -/** 服务器探活(不需要 token) */ -export const ping = async (cfg: StreamingServerConfig): Promise => { - try { - const res = await fetchJson(`${baseUrl(cfg)}/System/Info/Public`, { method: "GET" }); - if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; - const json = (await res.json()) as { Version?: string }; - return { ok: true, version: json.Version }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.warn(`jellyfin ping 失败 [${cfg.url}]:`, msg); - return { ok: false, error: msg }; - } -}; - -/** - * 构造音频流 URL。token 走 query 参数,便于 audio-engine 直接消费。 - * universal 端点会按客户端 capabilities 决定直出还是转码;这里默认请求直出。 - */ -export const getStreamUrl = (cfg: StreamingServerConfig, originalId: string): string => { - if (!cfg.accessToken) { - throw new Error("缺少 accessToken,需要先 authenticate"); - } - const params = new URLSearchParams({ - UserId: cfg.userId ?? "", - DeviceId: deviceId(), - api_key: cfg.accessToken, - // 直出:不指定 AudioCodec/MaxStreamingBitrate,让服务器返回原始流 - Static: "true", - }); - return `${baseUrl(cfg)}/Audio/${originalId}/universal?${params.toString()}`; -}; - -/** - * 获取歌词。Jellyfin 10.8+ 提供 /Audio/{id}/Lyrics。 - * 旧版本或无歌词时返回 null。 - */ -export const getLyrics = async ( - cfg: StreamingServerConfig, - originalId: string, -): Promise => { - if (!cfg.accessToken) return null; - try { - const res = await fetchJson(`${baseUrl(cfg)}/Audio/${originalId}/Lyrics`, { - method: "GET", - headers: { "X-Emby-Authorization": authHeader(cfg) }, - }); - if (!res.ok) return null; - const json = (await res.json()) as { - Lyrics?: { Start?: number; Text: string }[]; - }; - const lines = json.Lyrics ?? []; - if (lines.length === 0) return null; - // Jellyfin 时间戳单位是 100ns ticks - return lines - .map((l) => { - const ms = Math.max(0, Math.floor((l.Start ?? 0) / 10000)); - const mm = Math.floor(ms / 60000); - const ss = Math.floor((ms % 60000) / 1000); - const xx = Math.floor((ms % 1000) / 10); - const ts = `[${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}.${String(xx).padStart(2, "0")}]`; - return `${ts}${l.Text ?? ""}`; - }) - .join("\n"); - } catch (err) { - log.debug("jellyfin getLyrics 失败", err); - return null; - } -}; diff --git a/electron/main/services/streaming/subsonic.ts b/electron/main/services/streaming/subsonic.ts deleted file mode 100644 index 11e6e4c9..00000000 --- a/electron/main/services/streaming/subsonic.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import type { StreamingPingResult, StreamingServerConfig } from "@shared/types/streaming"; -import { decrypt } from "./auth"; -import { streamingLog as log } from "../../utils/logger"; - -/** Subsonic API 客户端版本号;必须 ≥ 服务器最低支持版本 */ -const API_VERSION = "1.16.1"; -/** 客户端标识,会出现在服务器播放历史中 */ -const CLIENT_NAME = "SPlayer-Next"; -/** 请求超时(毫秒) */ -const REQUEST_TIMEOUT = 15000; - -interface SubsonicAuthParams { - u: string; - t: string; - s: string; - v: string; - c: string; - f: string; -} - -/** 生成随机 salt(12 字符 hex) */ -const newSalt = (): string => randomBytes(6).toString("hex"); - -/** md5(password + salt) */ -const md5Token = (password: string, salt: string): string => - createHash("md5").update(password + salt).digest("hex"); - -/** 标准化 baseUrl,去掉尾斜杠 */ -const baseUrl = (cfg: StreamingServerConfig): string => cfg.url.replace(/\/+$/, ""); - -/** 生成本次请求的鉴权参数 */ -const authParams = (cfg: StreamingServerConfig): SubsonicAuthParams => { - const password = decrypt(cfg.passwordEncrypted); - const salt = newSalt(); - return { - u: cfg.username, - t: md5Token(password, salt), - s: salt, - v: API_VERSION, - c: CLIENT_NAME, - f: "json", - }; -}; - -/** 拼接带鉴权参数的完整 URL */ -const buildUrl = ( - cfg: StreamingServerConfig, - endpoint: string, - extra: Record = {}, -): string => { - const params = new URLSearchParams(); - for (const [k, v] of Object.entries(authParams(cfg))) params.set(k, v); - for (const [k, v] of Object.entries(extra)) params.set(k, String(v)); - return `${baseUrl(cfg)}/rest/${endpoint}?${params.toString()}`; -}; - -/** 解析 subsonic-response 包装;失败时抛错 */ -const parseResponse = async (res: Response): Promise => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = await res.json(); - const wrap = json?.["subsonic-response"]; - if (!wrap) throw new Error("响应缺少 subsonic-response 包装"); - if (wrap.status !== "ok") { - throw new Error(wrap.error?.message || `Subsonic error code ${wrap.error?.code}`); - } - return wrap as T; -}; - -/** 带超时的 fetch */ -const fetchWithTimeout = async (url: string, init?: RequestInit): Promise => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); - try { - return await fetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timer); - } -}; - -/** - * 连通性测试。成功返回服务器版本号。 - */ -export const ping = async (cfg: StreamingServerConfig): Promise => { - try { - const res = await fetchWithTimeout(buildUrl(cfg, "ping")); - const wrap = await parseResponse<{ version?: string; serverVersion?: string }>(res); - return { ok: true, version: wrap.serverVersion ?? wrap.version }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.warn(`subsonic ping 失败 [${cfg.url}]:`, msg); - return { ok: false, error: msg }; - } -}; - -/** - * 构造歌曲流播放 URL(含鉴权参数)。 - * Subsonic 的 stream 接口支持 HTTP Range,audio-engine 的 FFmpeg backend 可直接消费。 - */ -export const getStreamUrl = (cfg: StreamingServerConfig, originalId: string): string => - buildUrl(cfg, "stream", { id: originalId }); - -/** - * 获取歌词(LRC 文本)。 - * 优先 OpenSubsonic 扩展接口 getLyricsBySongId(结构化),失败时回退 getLyrics(artist+title 字符串匹配)。 - * 任一拿不到都返回 null(不抛错),让上层走本地/embedded 兜底。 - */ -export const getLyrics = async ( - cfg: StreamingServerConfig, - originalId: string, - hint?: { artist?: string; title?: string }, -): Promise => { - // 优先试 getLyricsBySongId(OpenSubsonic) - try { - const res = await fetchWithTimeout(buildUrl(cfg, "getLyricsBySongId", { id: originalId })); - const wrap = await parseResponse<{ - lyricsList?: { structuredLyrics?: { line?: { start?: number; value: string }[] }[] }; - }>(res); - const structured = wrap.lyricsList?.structuredLyrics?.[0]?.line ?? []; - if (structured.length > 0) { - // 转 LRC:start 是毫秒 - return structured - .map((l) => { - const ms = Math.max(0, l.start ?? 0); - const mm = Math.floor(ms / 60000); - const ss = Math.floor((ms % 60000) / 1000); - const xx = Math.floor((ms % 1000) / 10); - const ts = `[${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}.${String(xx).padStart(2, "0")}]`; - return `${ts}${l.value ?? ""}`; - }) - .join("\n"); - } - } catch (err) { - log.debug("getLyricsBySongId 失败,尝试 getLyrics", err); - } - - // 回退 getLyrics(旧 Subsonic) - if (hint?.artist || hint?.title) { - try { - const res = await fetchWithTimeout( - buildUrl(cfg, "getLyrics", { - artist: hint.artist ?? "", - title: hint.title ?? "", - }), - ); - const wrap = await parseResponse<{ lyrics?: { value?: string } }>(res); - const text = wrap.lyrics?.value; - if (text && text.trim()) return text; - } catch (err) { - log.debug("getLyrics 也失败", err); - } - } - return null; -}; diff --git a/electron/main/store/streaming.ts b/electron/main/store/streaming.ts deleted file mode 100644 index 61d3fa25..00000000 --- a/electron/main/store/streaming.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * 流媒体服务器配置存储。 - * 复用主配置 store(settings.json)的 streaming 节,所有写盘走同一个原子写。 - */ -import { randomUUID } from "node:crypto"; -import type { - StreamingServerConfig, - StreamingServerInput, - StreamingServerSummary, -} from "@shared/types/streaming"; -import { encrypt } from "../services/streaming/auth"; -import { store } from "./index"; - -/** 规范化服务器 URL:去掉尾斜杠 */ -const normalizeUrl = (url: string): string => url.trim().replace(/\/+$/, ""); - -/** 读全部服务器(含加密密码) */ -export const listConfigs = (): StreamingServerConfig[] => { - return [...store.get("streaming.servers")]; -}; - -/** 取单个 */ -export const getConfig = (id: string): StreamingServerConfig | null => { - return store.get("streaming.servers").find((s) => s.id === id) ?? null; -}; - -/** 转 summary(剥密码) */ -export const toSummary = (cfg: StreamingServerConfig): StreamingServerSummary => ({ - id: cfg.id, - name: cfg.name, - type: cfg.type, - url: cfg.url, - username: cfg.username, - hasToken: !!cfg.accessToken, - lastConnected: cfg.lastConnected, -}); - -/** 整体回写(接受新数组引用,触发 store 写盘) */ -const writeAll = (servers: StreamingServerConfig[]): void => { - store.set("streaming.servers", servers); -}; - -/** 添加;password 必填,会立即加密后落盘 */ -export const addConfig = (input: StreamingServerInput): StreamingServerConfig => { - const cfg: StreamingServerConfig = { - id: randomUUID(), - name: input.name.trim(), - type: input.type, - url: normalizeUrl(input.url), - username: input.username, - passwordEncrypted: encrypt(input.password), - }; - writeAll([...listConfigs(), cfg]); - return cfg; -}; - -/** - * 局部更新;password 字段为 undefined 时保留原密码。 - * 改动 URL/账号会清空 accessToken/userId(强制重新登录)。 - */ -export const updateConfig = ( - id: string, - patch: Partial, -): StreamingServerConfig | null => { - const list = listConfigs(); - const idx = list.findIndex((s) => s.id === id); - if (idx < 0) return null; - const old = list[idx]; - const credentialsChanged = - (patch.url !== undefined && normalizeUrl(patch.url) !== old.url) || - (patch.username !== undefined && patch.username !== old.username) || - patch.password !== undefined; - const next: StreamingServerConfig = { - ...old, - name: patch.name?.trim() ?? old.name, - type: patch.type ?? old.type, - url: patch.url !== undefined ? normalizeUrl(patch.url) : old.url, - username: patch.username ?? old.username, - passwordEncrypted: - patch.password !== undefined ? encrypt(patch.password) : old.passwordEncrypted, - accessToken: credentialsChanged ? undefined : old.accessToken, - userId: credentialsChanged ? undefined : old.userId, - lastConnected: old.lastConnected, - }; - list[idx] = next; - writeAll(list); - return next; -}; - -/** 移除;如果是当前激活的服务器,激活 ID 一并清空 */ -export const removeConfig = (id: string): boolean => { - const list = listConfigs(); - const next = list.filter((s) => s.id !== id); - if (next.length === list.length) return false; - writeAll(next); - if (store.get("streaming.activeServerId") === id) { - store.set("streaming.activeServerId", null); - } - return true; -}; - -/** 内部:仅修改单条 config 的部分字段(accessToken/lastConnected 等运行时回填) */ -export const patchConfig = (id: string, patch: Partial): void => { - const list = listConfigs(); - const idx = list.findIndex((s) => s.id === id); - if (idx < 0) return; - list[idx] = { ...list[idx], ...patch }; - writeAll(list); -}; - -/** 激活服务器 */ -export const getActiveId = (): string | null => store.get("streaming.activeServerId"); -export const setActiveId = (id: string | null): void => { - if (id !== null && !getConfig(id)) return; - store.set("streaming.activeServerId", id); -}; diff --git a/electron/main/utils/fetchBytes.ts b/electron/main/utils/fetchBytes.ts new file mode 100644 index 00000000..d26bc9e0 --- /dev/null +++ b/electron/main/utils/fetchBytes.ts @@ -0,0 +1,36 @@ +/** + * 把任意远端 URL 拉成字节,给 SMTC 高清封面用 + * + * 主进程要拿封面字节是因为系统媒体集成 API 接受 Buffer, + * 而渲染层的 Blob 跨进程传输不方便。其它 streaming 调用都在渲染层完成 + */ + +/** 默认 15s 超时 */ +const DEFAULT_TIMEOUT_MS = 15_000; +/** 最大允许 5MB */ +const MAX_BYTES = 5 * 1024 * 1024; + +/** + * 获取远端 URL 的字节 + * @param url + * @param timeoutMs + * @returns + */ +export const fetchBytes = async ( + url: string, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok) return null; + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length === 0 || buf.length > MAX_BYTES) return null; + return buf; + } catch { + return null; + } finally { + clearTimeout(timer); + } +}; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 3fc6ae3b..a34da1ef 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -3,8 +3,7 @@ import { electronAPI } from "@electron-toolkit/preload"; import type { TaskbarLyricSettings } from "@shared/types/settings"; import type { PluginInfo, PluginResolveUrlArgs } from "@shared/types/plugin"; import type { HotkeyActionId, HotkeyBinding, HotkeyConflict } from "@shared/types/hotkey"; -import type { Track } from "@shared/types/player"; -import type { StreamingServerInput } from "@shared/types/streaming"; +import type { LoadOptions } from "@shared/types/player"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -23,9 +22,8 @@ const api = { }, player: { // 加载音频(本地路径或网络地址) - load: (source: string, autoPlay = true) => ipcRenderer.invoke("player:load", source, autoPlay), - // 在 load 之前下发当前曲目元数据(SMTC/托盘/标题用),保证 streaming/online 等不被引擎 tag 覆盖 - setNowPlayingMeta: (track: Track) => ipcRenderer.invoke("player:setNowPlayingMeta", track), + load: (source: string, options?: LoadOptions) => + ipcRenderer.invoke("player:load", source, options ?? {}), // 恢复播放 play: () => ipcRenderer.invoke("player:play"), // 暂停播放 @@ -307,17 +305,8 @@ const api = { clearBackgroundImages: (): Promise => ipcRenderer.invoke("theme:clearBackgroundImages"), }, streaming: { - listServers: () => ipcRenderer.invoke("streaming:listServers"), - getActiveServer: () => ipcRenderer.invoke("streaming:getActiveServer"), - setActiveServer: (id: string | null) => ipcRenderer.invoke("streaming:setActiveServer", id), - addServer: (input: StreamingServerInput) => ipcRenderer.invoke("streaming:addServer", input), - updateServer: (id: string, patch: Partial) => - ipcRenderer.invoke("streaming:updateServer", id, patch), - removeServer: (id: string) => ipcRenderer.invoke("streaming:removeServer", id), - testConnection: (input: StreamingServerInput) => - ipcRenderer.invoke("streaming:testConnection", input), - resolveUrl: (track: Track) => ipcRenderer.invoke("streaming:resolveUrl", track), - getLyrics: (track: Track) => ipcRenderer.invoke("streaming:getLyrics", track), + // 把远端封面 URL 拉成字节,给 SMTC 高清封面用 + fetchCoverBytes: (url: string) => ipcRenderer.invoke("streaming:fetchCoverBytes", url), }, hotkey: { getAll: () => ipcRenderer.invoke("hotkey:getAll"), diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index 5ad7ac90..c7266a06 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -88,10 +88,6 @@ export const defaultSystemConfig: SystemConfig = { enableOnlineTTMLLyric: false, amllDbServer: "https://amlldb.bikonoo.com/%p/%s.ttml", }, - streaming: { - servers: [], - activeServerId: null, - }, system: { rememberWindowState: true, taskbarProgress: true, diff --git a/shared/types/player.ts b/shared/types/player.ts index 85bb8591..41f12231 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -96,6 +96,17 @@ export interface LoadResult { mediaInfo: MediaInfo; } +/** player:load 的可选参数 */ +export interface LoadOptions { + /** 是否自动播放,默认 true */ + autoPlay?: boolean; + /** + * 渲染层下发的权威 Track 元数据,用于 SMTC/托盘/窗口标题。 + * streaming/online 源应当下发;本地源缺省时主进程回退到引擎解析的 tag。 + */ + meta?: Track; +} + /** 播放器状态快照 */ export interface PlayerStatus { state: PlayerState; @@ -136,13 +147,8 @@ export interface IpcResponse { /** 播放器 API */ export interface PlayerApi { - /** 加载音频(本地路径或网络地址) */ - load: (source: string, autoPlay?: boolean) => Promise>; - /** - * 设置当前播放曲目的元数据,用于 SMTC/托盘等系统媒体集成。 - * 应当在 load 之前调用,确保 status 推送/setMetadata 用到正确信息。 - */ - setNowPlayingMeta: (track: Track) => Promise; + /** 加载音频(本地路径或网络地址)。可选下发权威 meta 用于 SMTC/托盘 */ + load: (source: string, options?: LoadOptions) => Promise>; /** 恢复播放 */ play: () => Promise; /** 暂停播放 */ diff --git a/shared/types/settings.ts b/shared/types/settings.ts index 7503ac85..ddd4c7df 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -1,6 +1,5 @@ import type { PluginsConfig } from "./plugin"; import type { HotkeyConfig } from "./hotkey"; -import type { StreamingServerConfig } from "./streaming"; /** 支持的语言代码 */ export type LocaleCode = "zh-CN" | "en-US"; @@ -186,14 +185,6 @@ export interface OnlineLyricSettings { amllDbServer: string; } -/** 流媒体服务器配置 */ -export interface StreamingSettings { - /** 已配置的服务器列表,密码字段已加密 */ - servers: StreamingServerConfig[]; - /** 当前激活的服务器 ID,未选择时为 null */ - activeServerId: string | null; -} - /** 主窗口几何 */ export interface MainWindowState { width: number; @@ -252,8 +243,6 @@ export interface SystemConfig { taskbarLyric: TaskbarLyricSettings; /** 在线歌词服务配置 */ lyric: OnlineLyricSettings; - /** 流媒体服务器配置 */ - streaming: StreamingSettings; /** 系统配置 */ system: { /** 记忆窗口状态 */ diff --git a/shared/types/streaming.ts b/shared/types/streaming.ts index a166760c..eb69e0a1 100644 --- a/shared/types/streaming.ts +++ b/shared/types/streaming.ts @@ -1,16 +1,10 @@ -import type { IpcResponse, Track } from "./player"; +import type { Track, IpcResponse } from "./player"; -/** 支持的媒体服务器类型 */ -export type StreamingServerType = - | "subsonic" - | "navidrome" - | "opensubsonic" - | "jellyfin" - | "emby"; +/** 支持的流媒体服务器类型 */ +export type StreamingServerType = "subsonic" | "navidrome" | "opensubsonic" | "jellyfin" | "emby"; /** - * 完整服务器配置(持久化形态,密码已加密) - * 仅在主进程内流转,不暴露给渲染进程 + * 服务器配置 */ export interface StreamingServerConfig { /** crypto.randomUUID() */ @@ -20,9 +14,9 @@ export interface StreamingServerConfig { /** 服务器地址,规范化为不带尾斜杠 */ url: string; username: string; - /** safeStorage 加密后 base64;不可用时退化为 base64 明文 */ - passwordEncrypted: string; - /** Jellyfin/Emby 鉴权后回填,过期重新登录 */ + /** 明文密码 */ + password: string; + /** Jellyfin/Emby 鉴权后回填,过期时由 store 重新登录 */ accessToken?: string; /** Jellyfin/Emby 鉴权后回填的用户 ID */ userId?: string; @@ -30,58 +24,77 @@ export interface StreamingServerConfig { lastConnected?: number; } -/** 渲染进程视图:剥离敏感字段 */ -export interface StreamingServerSummary { - id: string; - name: string; - type: StreamingServerType; - url: string; - username: string; - /** 是否已持有有效 accessToken(仅 jellyfin/emby 用) */ - hasToken: boolean; - lastConnected?: number; -} - -/** 添加/更新/连通性测试时由渲染层提交的明文 payload */ +/** 添加/编辑表单提交时的 payload(id/token 由 store 生成、回填) */ export interface StreamingServerInput { name: string; type: StreamingServerType; url: string; username: string; - /** 明文密码:通过 contextBridge 单次传入,主进程立即加密存盘 */ password: string; } /** 连通性测试结果 */ export interface StreamingPingResult { ok: boolean; - /** 服务器报告的版本号 */ + /** 服务器版本号 */ version?: string; - /** 失败时的错误描述 */ + /** 失败描述 */ error?: string; } -/** 流媒体相关 IPC */ +/** Jellyfin/Emby 登录返回 */ +export interface StreamingAuthResult { + accessToken: string; + userId: string; +} + +/** 列表请求通用参数 */ +export interface StreamingListParams { + offset?: number; + limit?: number; +} + +/** 流媒体专辑(用于 CoverList) */ +export interface StreamingAlbum { + id: string; + name: string; + artist?: string; + cover?: string; + songCount?: number; + year?: number; +} + +/** 流媒体歌手(用于 CoverList) */ +export interface StreamingArtist { + id: string; + name: string; + avatar?: string; + albumCount?: number; +} + +/** 流媒体歌单(用于 CoverList) */ +export interface StreamingPlaylist { + id: string; + name: string; + cover?: string; + description?: string; + songCount?: number; + owner?: string; +} + +/** 搜索结果聚合 */ +export interface StreamingSearchResult { + songs: Track[]; + albums: StreamingAlbum[]; + artists: StreamingArtist[]; +} + +/** + * 主进程暴露给渲染层的 streaming IPC 接口 + * + * 仅一个职责:把远端封面 URL 拉成字节给 SMTC 用(系统媒体集成需要 Buffer)。 + * 其它流媒体操作(鉴权/浏览/搜索/歌词)都在渲染层 src/services/streaming 完成。 + */ export interface StreamingApi { - /** 列出全部已配置的服务器(不含密码) */ - listServers: () => Promise>; - /** 获取当前激活服务器 */ - getActiveServer: () => Promise>; - /** 设置激活服务器;传 null 清除 */ - setActiveServer: (id: string | null) => Promise; - /** 添加服务器,返回带 id 的 summary */ - addServer: (input: StreamingServerInput) => Promise>; - /** 局部更新;password 字段为 undefined 时保留原密码 */ - updateServer: ( - id: string, - patch: Partial, - ) => Promise>; - /** 移除服务器 */ - removeServer: (id: string) => Promise; - /** 不写入配置的预飞行测试 */ - testConnection: (input: StreamingServerInput) => Promise>; - /** 解析 Track 为可直接喂给 player:load 的 URL(含鉴权参数) */ - resolveUrl: (track: Track) => Promise>; - /** 取流媒体歌词(LRC 文本或 null) */ - getLyrics: (track: Track) => Promise>; + fetchCoverBytes: (url: string) => Promise>; } diff --git a/src/components/list/SongList.vue b/src/components/list/SongList.vue index b2634d24..1ea45611 100644 --- a/src/components/list/SongList.vue +++ b/src/components/list/SongList.vue @@ -465,7 +465,7 @@ defineExpose({ v-for="(artist, i) in item.artists" :key="artist.id ?? i" class="cursor-pointer transition-opacity hover:opacity-70" - @click.stop="navigateToArtist(artist.name)" + @click.stop="navigateToArtist(artist.name, { source, artistId: artist.id })" > {{ artist.name }} / @@ -482,7 +482,7 @@ defineExpose({ v-if="showAlbum" class="flex-1 min-w-0 truncate text-sm cursor-pointer transition-opacity hover:opacity-70" :class="playingId === item.id ? 'text-primary/70' : 'text-on-surface'" - @click.stop="navigateToAlbum(item.album?.name)" + @click.stop="navigateToAlbum(item.album?.name, { source, albumId: item.album?.id })" > {{ item.album?.name }} diff --git a/src/components/player/FullPlayer/PlayerData.vue b/src/components/player/FullPlayer/PlayerData.vue index a5437e28..178a5ac0 100644 --- a/src/components/player/FullPlayer/PlayerData.vue +++ b/src/components/player/FullPlayer/PlayerData.vue @@ -36,14 +36,23 @@ const lyricSourceOptions = computed(() => [ /** 跳转到专辑页 */ const goToAlbum = () => { - const name = media.track?.album?.name; - if (!name) return; + const track = media.track; + if (!track?.album?.name) return; status.isExpanded = false; - navigateToAlbum(name); + navigateToAlbum(track.album.name, { source: track.source, albumId: track.album.id }); }; /** 来源标签 */ -const sourceLabel = computed(() => (media.track?.source === "online" ? "ONLINE" : "LOCAL")); +const sourceLabel = computed(() => { + switch (media.track?.source) { + case "online": + return "ONLINE"; + case "streaming": + return "STREAMING"; + default: + return "LOCAL"; + } +}); /** 音质等级标签 */ const qualityLabel = computed(() => getQualityLabel(media.detail?.quality)); diff --git a/src/components/settings/custom/StreamingServerList.vue b/src/components/settings/custom/StreamingServerList.vue new file mode 100644 index 00000000..ae53d2fd --- /dev/null +++ b/src/components/settings/custom/StreamingServerList.vue @@ -0,0 +1,361 @@ + + + diff --git a/src/core/player.ts b/src/core/player.ts index 2407994f..010185a8 100644 --- a/src/core/player.ts +++ b/src/core/player.ts @@ -3,6 +3,7 @@ import type { RepeatMode, ShuffleMode } from "@/stores/status"; import { useMediaStore } from "@/stores/media"; import { useSettingsStore } from "@/stores/settings"; import { useStatusStore } from "@/stores/status"; +import { useStreamingStore } from "@/stores/streaming"; import * as queue from "@/stores/queue"; import * as playback from "@/services/playback"; import * as lyricLoader from "@/services/lyricLoader"; @@ -24,10 +25,15 @@ const SKIP_ON_ERROR_DELAY_MS = 1000; /** * 加载音频源 * @param source - 音频文件路径或网络地址 - * @param autoPlay - 是否自动播放,默认 true,false 时加载后暂停 + * @param autoPlay - 是否自动播放 + * @param meta - 渲染层下发给主进程的权威 Track(用于 SMTC/托盘) * @returns 加载成功返回 Track,失败返回 null */ -export const load = async (source: string, autoPlay = true): Promise => { +export const load = async ( + source: string, + autoPlay = true, + meta?: Track, +): Promise => { const status = useStatusStore(); const token = ++loadToken; status.trackLoading = true; @@ -40,7 +46,7 @@ export const load = async (source: string, autoPlay = true): Promise => { if (track.source === "local") return track.path ?? null; if (track.source === "streaming") { - const res = await window.api.streaming.resolveUrl(track); - if (res.success && res.data) return res.data; - if (res.error) handleError(res.error); - return null; + try { + return await useStreamingStore().getStreamUrl(track); + } catch (err) { + handleError(err instanceof Error ? err.message : String(err)); + return null; + } } // TODO: online(netease/qqmusic/kugou) return null; @@ -119,10 +127,9 @@ const loadTrack = async (track: Track | null): Promise => { if (!source) return; // 乐观更新:同步写入 track,开启歌词加载周期 useMediaStore().setTrack(track); - // 把权威元数据下发主进程,让 SMTC/托盘/标题不被引擎 tag 覆盖 - window.api.player.setNowPlayingMeta(track); lyricLoader.beginLoad(); - await load(source); + // meta 随 load 一起下发:SMTC/托盘/标题用 track 上的权威字段 + await load(source, true, track); }; /** 恢复播放 */ @@ -606,9 +613,8 @@ export const initPlayer = async (): Promise => { useMediaStore().setTrack(lastTrack); const source = await resolveTrackSource(lastTrack); if (source) { - window.api.player.setNowPlayingMeta(lastTrack); lyricLoader.beginLoad(); - const result = await load(source, settings.system.player.autoPlay); + const result = await load(source, settings.system.player.autoPlay, lastTrack); if (result && settings.system.player.rememberLastTrack && lastPosition > 0) { await seek(lastPosition); } diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 83fc6b3d..f69b78d9 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -13,7 +13,9 @@ "error": "Error", "playAll": "Play All", "totalSongs": "{count} songs", - "totalAlbums": "{count} albums" + "totalAlbums": "{count} albums", + "totalArtists": "{count} artists", + "totalPlaylists": "{count} playlists" }, "equalizer": { "title": "Equalizer", @@ -70,11 +72,65 @@ "nav": { "home": "Home", "library": "Library", + "streaming": "Streaming", "settings": "Settings", "reload": "Reload", "devtools": "Developer Tools", "globalSettings": "Global Settings" }, + "streaming": { + "hint": "Connect to your Subsonic / Navidrome / Jellyfin / Emby server", + "hintDetail": "Server credentials are stored locally in your browser only", + "tabs": { + "songs": "Songs", + "albums": "Albums", + "artists": "Artists", + "playlists": "Playlists" + }, + "empty": { + "noServer": "No streaming server configured", + "addHint": "Click the button at the top right to add one", + "goToSettings": "Go to settings", + "notConnected": "Not connected to any server", + "noResults": "No results" + }, + "server": { + "type": "Server type", + "name": "Server name", + "namePlaceholder": "Friendly name for this server", + "url": "Server URL", + "username": "Username", + "password": "Password", + "test": "Test connection", + "testOk": "Connection succeeded", + "testFail": "Connection failed", + "add": "Add server", + "added": "Server added", + "edit": "Edit server", + "updated": "Server updated", + "delete": "Delete", + "removed": "Server removed", + "deleteConfirmTitle": "Delete server", + "deleteConfirm": "Delete server \"{name}\"? This cannot be undone.", + "connect": "Connect", + "connected": "Connected", + "connectFailed": "Connection failed", + "disconnect": "Disconnect", + "disconnected": "Disconnected", + "active": "Active", + "lastConnected": "Last connected", + "errors": { + "nameEmpty": "Please enter a server name", + "urlInvalid": "Please enter a valid URL (http:// or https://)", + "usernameEmpty": "Please enter a username", + "passwordEmpty": "Please enter a password" + } + }, + "actions": { + "refresh": "Refresh", + "search": "Search" + } + }, "player": { "play": "Play", "pause": "Pause", @@ -175,6 +231,7 @@ "appearance": "Appearance", "hotkeys": "Hotkey Settings", "services": "Services", + "streaming": "Streaming", "plugins": "Plugin Manager" }, "section": { @@ -201,7 +258,8 @@ "discord": "Discord RPC", "systemConfig": "System", "reset": "Reset", - "pluginsList": "Installed Plugins" + "pluginsList": "Installed Plugins", + "servers": "Servers" }, "language": { "label": "Language", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 4e7ea0cd..446db6d4 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -13,7 +13,9 @@ "error": "操作失败", "playAll": "播放全部", "totalSongs": "共 {count} 首", - "totalAlbums": "共 {count} 张专辑" + "totalAlbums": "共 {count} 张专辑", + "totalArtists": "共 {count} 位歌手", + "totalPlaylists": "共 {count} 个歌单" }, "equalizer": { "title": "均衡器", @@ -70,11 +72,65 @@ "nav": { "home": "首页", "library": "音乐库", + "streaming": "流媒体", "settings": "设置", "reload": "热重载", "devtools": "开发者工具", "globalSettings": "全局设置" }, + "streaming": { + "hint": "接入自建的 Subsonic / Navidrome / Jellyfin / Emby 服务器", + "hintDetail": "服务器配置(含密码)保存在本地浏览器,不上传任何远端服务", + "tabs": { + "songs": "歌曲", + "albums": "专辑", + "artists": "歌手", + "playlists": "歌单" + }, + "empty": { + "noServer": "尚未配置流媒体服务器", + "addHint": "点击右上角添加一个开始使用", + "goToSettings": "前往设置添加", + "notConnected": "未连接到服务器", + "noResults": "没有找到内容" + }, + "server": { + "type": "服务器类型", + "name": "服务器名称", + "namePlaceholder": "用于识别的别名", + "url": "服务器地址", + "username": "用户名", + "password": "密码", + "test": "测试连接", + "testOk": "连接成功", + "testFail": "连接失败", + "add": "添加服务器", + "added": "服务器已添加", + "edit": "编辑服务器", + "updated": "服务器已更新", + "delete": "删除", + "removed": "服务器已删除", + "deleteConfirmTitle": "删除服务器", + "deleteConfirm": "确定删除服务器「{name}」吗?此操作不可撤销。", + "connect": "连接", + "connected": "已连接", + "connectFailed": "连接失败", + "disconnect": "断开", + "disconnected": "已断开", + "active": "当前激活", + "lastConnected": "最近连接", + "errors": { + "nameEmpty": "请输入服务器名称", + "urlInvalid": "请输入合法的 URL(http:// 或 https://)", + "usernameEmpty": "请输入用户名", + "passwordEmpty": "请输入密码" + } + }, + "actions": { + "refresh": "刷新", + "search": "搜索" + } + }, "player": { "play": "播放", "pause": "暂停", @@ -175,6 +231,7 @@ "appearance": "外观设置", "hotkeys": "快捷键配置", "services": "网络与服务", + "streaming": "流媒体", "plugins": "插件管理" }, "section": { @@ -201,7 +258,8 @@ "discord": "Discord RPC", "systemConfig": "系统配置", "reset": "重置", - "pluginsList": "已安装插件" + "pluginsList": "已安装插件", + "servers": "服务器管理" }, "language": { "label": "界面语言", diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 05861a13..9c09ed1c 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -19,6 +19,17 @@ const routeTransitionName = computed(() => { return transition === "none" ? "" : `route-${transition}`; }); +/** + * 顶级路由组件的 key: + * - 路由链含 :param(如 /collection/:source/:type/:id)→ 按 fullPath,让参数变化触发重建 + * - 全静态路由(如 /streaming/songs)→ 按顶级 children path,让嵌套子路由切换时外层不重建 + */ +const route = useRoute(); +const routeKey = computed(() => { + const hasParam = route.matched.some((m) => m.path.includes(":")); + return hasParam ? route.fullPath : (route.matched[1]?.path ?? route.fullPath); +}); + /** 侧边栏样式 */ const sidebarClass = computed(() => { const classes: string[] = []; @@ -87,7 +98,7 @@ const playerBarInnerClass = computed(() => {
- +
diff --git a/src/layouts/components/SideBar.vue b/src/layouts/components/SideBar.vue index 6c7ee008..24d1867f 100644 --- a/src/layouts/components/SideBar.vue +++ b/src/layouts/components/SideBar.vue @@ -7,6 +7,7 @@ import IconLucideMusic from "~icons/lucide/music"; import IconLucideUser from "~icons/lucide/user"; import IconLucideDisc3 from "~icons/lucide/disc-3"; import IconLucideFolder from "~icons/lucide/folder"; +import IconLucideServer from "~icons/lucide/server"; import IconLucideListMusic from "~icons/lucide/list-music"; import IconLucidePlus from "~icons/lucide/plus"; import SButton from "@/components/ui/SButton.vue"; @@ -25,12 +26,17 @@ const handleCreate = async () => { }; const menuItems = computed(() => [ + // 本地音乐分组 { key: "/", label: t("nav.home"), icon: markRaw(IconLucideHome) }, { key: "/library", label: t("nav.library"), icon: markRaw(IconLucideMusic) }, { key: "/artists/local", label: t("artist.label"), icon: markRaw(IconLucideUser) }, { key: "/albums/local", label: t("album.label"), icon: markRaw(IconLucideDisc3) }, { key: "/folders", label: t("folder.label"), icon: markRaw(IconLucideFolder) }, - { key: "divider", type: "divider" }, + // 流媒体 + { key: "divider-streaming", type: "divider" }, + { key: "/streaming", label: t("nav.streaming"), icon: markRaw(IconLucideServer) }, + // 歌单分组 + { key: "divider-playlist", type: "divider" }, { key: "playlist-group", type: "group", @@ -56,6 +62,10 @@ const menuItems = computed(() => [ ]); const activeKey = computed(() => { + // 流媒体 + if (route.path.startsWith("/streaming")) return "/streaming"; + if (route.path.startsWith("/collection/streaming/")) return "/streaming"; + if (route.path.startsWith("/artist/streaming/")) return "/streaming"; // 专辑详情页归属专辑列表 if (route.path.startsWith("/collection/local/album/")) return "/albums/local"; // 音乐库子页面 diff --git a/src/pages/Artist.vue b/src/pages/Artist.vue index f4b56367..1b01346f 100644 --- a/src/pages/Artist.vue +++ b/src/pages/Artist.vue @@ -3,6 +3,7 @@ import type { TrackSource } from "@shared/types/player"; import type { ArtistProfile, CoverItem } from "@/types/artist"; import { useLibraryStore } from "@/stores/library"; import { useSettingsStore } from "@/stores/settings"; +import { useStreamingStore } from "@/stores/streaming"; import { navigateToAlbum } from "@/utils/navigate"; import SongList from "@/components/list/SongList.vue"; import { formatTime } from "@/utils/time"; @@ -18,6 +19,7 @@ import IconLucideListChecks from "~icons/lucide/list-checks"; const { t } = useI18n(); const route = useRoute(); const libraryStore = useLibraryStore(); +const streamingStore = useStreamingStore(); const { appearance } = useSettingsStore(); const tabTransitionName = computed(() => { @@ -59,6 +61,31 @@ const loadArtist = async () => { artist.value = { ...artist.value, avatar: res.data }; } } + } else if (source === "streaming") { + const artistId = decodeURIComponent(id); + const cached = streamingStore.artists.find((a) => a.id === artistId); + const albumList = await streamingStore.fetchArtistAlbums(artistId); + // 一次性把所有专辑的曲目拉回来聚合 + const trackLists = await Promise.all( + albumList.map((al) => streamingStore.fetchAlbumSongs(al.id).catch(() => [])), + ); + const tracks = trackLists.flat(); + artist.value = { + id: artistId, + name: cached?.name ?? artistId, + avatar: cached?.avatar, + source, + tracks, + albums: albumList.map((al) => ({ + id: al.id, + title: al.name, + cover: al.cover, + subtitle: al.year ? String(al.year) : (al.artist ?? ""), + trackCount: al.songCount ?? 0, + })), + trackCount: tracks.length, + albumCount: albumList.length, + }; } // TODO: online }; @@ -239,7 +266,7 @@ const albumItems = computed(() => { :items="albumItems" :padding-x="20" :padding-bottom="24" - @click="(item) => navigateToAlbum(item.title)" + @click="(item) => navigateToAlbum(item.title, { source, albumId: item.id })" /> diff --git a/src/pages/Collection.vue b/src/pages/Collection.vue index 7cf9a618..92f1d1e5 100644 --- a/src/pages/Collection.vue +++ b/src/pages/Collection.vue @@ -4,6 +4,7 @@ import type { Collection, CollectionType } from "@/types/collection"; import type { DropdownMenuItem } from "@/components/ui/SDropdownMenu.vue"; import { usePlaylistStore } from "@/stores/playlist"; import { useLibraryStore } from "@/stores/library"; +import { useStreamingStore } from "@/stores/streaming"; import SongList from "@/components/list/SongList.vue"; import { formatTime } from "@/utils/time"; import * as player from "@/core/player"; @@ -18,6 +19,7 @@ const { t } = useI18n(); const route = useRoute(); const playlistStore = usePlaylistStore(); const libraryStore = useLibraryStore(); +const streamingStore = useStreamingStore(); const source = route.params.source as TrackSource; const type = route.params.type as CollectionType; @@ -49,6 +51,36 @@ const loadCollection = async () => { } else if (source === "local" && type === "album") { const albumName = decodeURIComponent(id); collection.value = await libraryStore.getAlbumCollection(albumName); + } else if (source === "streaming") { + const originalId = decodeURIComponent(id); + if (type === "album") { + const album = streamingStore.albums.find((a) => a.id === originalId); + const tracks = await streamingStore.fetchAlbumSongs(originalId); + collection.value = { + id: originalId, + type, + source, + title: album?.name ?? originalId, + cover: album?.cover ?? tracks[0]?.cover, + creator: album?.artist, + tracks, + trackCount: tracks.length, + }; + } else if (type === "playlist") { + const pl = streamingStore.playlists.find((p) => p.id === originalId); + const tracks = await streamingStore.fetchPlaylistSongs(originalId); + collection.value = { + id: originalId, + type, + source, + title: pl?.name ?? originalId, + cover: pl?.cover ?? tracks[0]?.cover, + description: pl?.description, + creator: pl?.owner, + tracks, + trackCount: tracks.length, + }; + } } // TODO: online / radio }; diff --git a/src/pages/Streaming/Albums.vue b/src/pages/Streaming/Albums.vue new file mode 100644 index 00000000..4a663cfb --- /dev/null +++ b/src/pages/Streaming/Albums.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/pages/Streaming/Artists.vue b/src/pages/Streaming/Artists.vue new file mode 100644 index 00000000..37fa8c6f --- /dev/null +++ b/src/pages/Streaming/Artists.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/pages/Streaming/Index.vue b/src/pages/Streaming/Index.vue new file mode 100644 index 00000000..2b5c178c --- /dev/null +++ b/src/pages/Streaming/Index.vue @@ -0,0 +1,235 @@ + + + diff --git a/src/pages/Streaming/Playlists.vue b/src/pages/Streaming/Playlists.vue new file mode 100644 index 00000000..67244b7d --- /dev/null +++ b/src/pages/Streaming/Playlists.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/pages/Streaming/Songs.vue b/src/pages/Streaming/Songs.vue new file mode 100644 index 00000000..a56d927c --- /dev/null +++ b/src/pages/Streaming/Songs.vue @@ -0,0 +1,71 @@ + + + diff --git a/src/router/index.ts b/src/router/index.ts index 52df0eed..e49dc71d 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -42,6 +42,33 @@ const router = createRouter({ name: "folders", component: () => import("@/pages/Folders.vue"), }, + { + path: "streaming", + component: () => import("@/pages/Streaming/Index.vue"), + redirect: "/streaming/songs", + children: [ + { + path: "songs", + name: "streaming-songs", + component: () => import("@/pages/Streaming/Songs.vue"), + }, + { + path: "albums", + name: "streaming-albums", + component: () => import("@/pages/Streaming/Albums.vue"), + }, + { + path: "artists", + name: "streaming-artists", + component: () => import("@/pages/Streaming/Artists.vue"), + }, + { + path: "playlists", + name: "streaming-playlists", + component: () => import("@/pages/Streaming/Playlists.vue"), + }, + ], + }, ], }, ], diff --git a/src/services/audioLoader.ts b/src/services/audioLoader.ts index e4ddf6d7..750e75b7 100644 --- a/src/services/audioLoader.ts +++ b/src/services/audioLoader.ts @@ -2,11 +2,12 @@ * 音频加载服务 * * 纯数据获取:通过 IPC 调用 Rust 引擎加载音频,返回从音频流提取的元数据。 - * 调用方持有 Track 身份;本服务仅返回引擎能解析的辅助信息(detail + mediaInfo), - * 不再合成 Track。 + * 调用方持有 Track 身份;本服务仅返回引擎能解析的辅助信息(detail + mediaInfo)。 + * options.meta 用于让主进程在 SMTC/托盘上用渲染层的权威元数据, + * 避免被引擎稀疏 tag 覆盖(streaming/online 必须下发)。 */ -import type { MediaInfo, TrackDetail } from "@shared/types/player"; +import type { LoadOptions, MediaInfo, TrackDetail } from "@shared/types/player"; export interface AudioLoadResult { detail: TrackDetail; @@ -14,16 +15,15 @@ export interface AudioLoadResult { } /** - * 加载音频源,返回歌曲详情和引擎提取的元数据 + * 加载音频源 * @param source - 音频文件路径或网络地址 - * @param autoPlay - 是否自动播放 - * @returns 成功返回 { detail, mediaInfo },失败返回 null + error + * @param options - 加载选项(autoPlay / meta) */ export const loadAudio = async ( source: string, - autoPlay = true, + options?: LoadOptions, ): Promise<{ data: AudioLoadResult | null; error?: string }> => { - const result = await window.api.player.load(source, autoPlay); + const result = await window.api.player.load(source, options); if (result.success && result.data) { return { data: { detail: result.data.detail, mediaInfo: result.data.mediaInfo } }; } diff --git a/src/services/lyricLoader.ts b/src/services/lyricLoader.ts index 75ef1289..b2932d64 100644 --- a/src/services/lyricLoader.ts +++ b/src/services/lyricLoader.ts @@ -8,6 +8,7 @@ import type { Platform } from "@shared/types/platform"; import { bestExternalIndex, detectFormat } from "@/utils/lyric/parse"; import { useMediaStore } from "@/stores/media"; import { useSettingsStore } from "@/stores/settings"; +import { useStreamingStore } from "@/stores/streaming"; import { DEFAULT_LYRIC_FORMAT_ORDER, DEFAULT_LYRIC_SOURCE_ORDER } from "@/types/settings"; /** 一次在线 fetch 的结果 */ @@ -259,13 +260,20 @@ export const loadForTrack = async (detail: TrackDetail | null): Promise => commit(token, null, null); return; } - // 流媒体服务器:仅取流媒体提供的 LRC,不再走本地/平台匹配 + // 流媒体服务器 if (track.source === "streaming") { - const res = await window.api.streaming.getLyrics(track); + const text = await useStreamingStore().getLyrics(track); if (token !== currentToken) return; - const text = res.success ? res.data : null; if (text && text.trim()) { commit(token, { source: "external", format: "lrc" }, { content: text }); + return; + } + if (detail?.embeddedLyric) { + commit( + token, + { source: "embedded", format: detectFormat(detail.embeddedLyric) }, + { content: detail.embeddedLyric }, + ); } else { commit(token, null, null); } diff --git a/src/services/streaming/emby.ts b/src/services/streaming/emby.ts new file mode 100644 index 00000000..bc851989 --- /dev/null +++ b/src/services/streaming/emby.ts @@ -0,0 +1,50 @@ +/** + * Emby 客户端(渲染层) + * + * Emby 与 Jellyfin REST API 高度兼容,复用大部分实现。 + * 流播放 URL 同样用 /Audio/{id}/universal 端点,仅多一个 Static=true 参数 + * (Emby 文档建议显式声明直接流,与老项目一致)。 + */ +import type { StreamingServerConfig } from "@shared/types/streaming"; +import { StreamingAuthError } from "./errors"; +import { normalizeBase } from "./http"; + +export { + ping, + authenticate, + listAlbums, + listArtists, + listPlaylists, + listSongs, + getAlbumSongs, + getPlaylistSongs, + getArtistAlbums, + search, + getLyrics, +} from "./jellyfin"; + +/** Emby 用稳定 deviceId(基于 cfg.id) */ +const deviceId = (cfg: StreamingServerConfig): string => `splayer-next-${cfg.id}`; + +export const getStreamUrl = async ( + cfg: StreamingServerConfig, + originalId: string, +): Promise => { + if (!cfg.accessToken) throw new StreamingAuthError("缺少 accessToken"); + const params = new URLSearchParams({ + UserId: cfg.userId ?? "", + DeviceId: deviceId(cfg), + MaxStreamingBitrate: "140000000", + Container: "opus,webm|opus,ts|mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg", + TranscodingContainer: "ts", + TranscodingProtocol: "hls", + AudioCodec: "aac", + PlaySessionId: Date.now().toString(), + api_key: cfg.accessToken, + StartTimeTicks: "0", + EnableRedirection: "true", + EnableRemoteMedia: "true", + Static: "true", + }); + return `${normalizeBase(cfg.url)}/Audio/${originalId}/universal?${params.toString()}`; +}; diff --git a/src/services/streaming/errors.ts b/src/services/streaming/errors.ts new file mode 100644 index 00000000..82c9b195 --- /dev/null +++ b/src/services/streaming/errors.ts @@ -0,0 +1,26 @@ +/** + * 流媒体客户端的错误类型 + * + * 用具体类替代字符串模式匹配,store 的 withAutoReauth 可以用 instanceof 精确判断。 + */ + +/** 鉴权失败(HTTP 401 / accessToken 缺失或过期) */ +export class StreamingAuthError extends Error { + readonly name = "StreamingAuthError"; +} + +/** 协议错误(响应缺字段、status != ok 等) */ +export class StreamingProtocolError extends Error { + readonly name = "StreamingProtocolError"; +} + +/** HTTP 错误(非 2xx,且非 401) */ +export class StreamingHttpError extends Error { + readonly name = "StreamingHttpError"; + constructor( + readonly status: number, + message?: string, + ) { + super(message ?? `HTTP ${status}`); + } +} diff --git a/src/services/streaming/http.ts b/src/services/streaming/http.ts new file mode 100644 index 00000000..149d80c0 --- /dev/null +++ b/src/services/streaming/http.ts @@ -0,0 +1,33 @@ +/** + * 渲染层 fetch 工具:统一超时、错误抽取、auth 错误识别 + */ +import { StreamingAuthError, StreamingHttpError } from "./errors"; + +const REQUEST_TIMEOUT = 15_000; + +/** 带超时的 fetch */ +export const fetchWithTimeout = async ( + url: string, + init?: RequestInit, + timeout = REQUEST_TIMEOUT, +): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +}; + +/** 401 抛 StreamingAuthError,其它非 2xx 抛 StreamingHttpError */ +export const ensureOk = (res: Response): void => { + if (res.ok) return; + if (res.status === 401 || res.status === 403) { + throw new StreamingAuthError(`HTTP ${res.status}`); + } + throw new StreamingHttpError(res.status); +}; + +/** 标准化 baseUrl,去掉尾斜杠 */ +export const normalizeBase = (url: string): string => url.replace(/\/+$/, ""); diff --git a/src/services/streaming/index.ts b/src/services/streaming/index.ts new file mode 100644 index 00000000..0348ae9d --- /dev/null +++ b/src/services/streaming/index.ts @@ -0,0 +1,131 @@ +/** + * 流媒体客户端统一入口(渲染层) + * + * 按 cfg.type 分发到具体协议实现。所有方法都是纯函数, + * 第一个参数都是 cfg;不持有状态。 + */ +import type { Track } from "@shared/types/player"; +import type { + StreamingAlbum, + StreamingArtist, + StreamingAuthResult, + StreamingListParams, + StreamingPingResult, + StreamingPlaylist, + StreamingSearchResult, + StreamingServerConfig, + StreamingServerType, +} from "@shared/types/streaming"; +import * as subsonic from "./subsonic"; +import * as jellyfin from "./jellyfin"; +import * as emby from "./emby"; + +const isSubsonic = (type: StreamingServerType): boolean => + type === "subsonic" || type === "navidrome" || type === "opensubsonic"; + +export const needsAccessToken = (type: StreamingServerType): boolean => + type === "jellyfin" || type === "emby"; + +const unsupported = (cfg: StreamingServerConfig) => new Error(`不支持的服务器类型: ${cfg.type}`); + +export const ping = (cfg: StreamingServerConfig): Promise => { + if (isSubsonic(cfg.type)) return subsonic.ping(cfg); + if (cfg.type === "jellyfin") return jellyfin.ping(cfg); + if (cfg.type === "emby") return emby.ping(cfg); + return Promise.resolve({ ok: false, error: `不支持的服务器类型: ${cfg.type}` }); +}; + +export const authenticate = (cfg: StreamingServerConfig): Promise => { + if (cfg.type === "jellyfin") return jellyfin.authenticate(cfg); + if (cfg.type === "emby") return emby.authenticate(cfg); + return Promise.reject(new Error(`${cfg.type} 不需要 accessToken 鉴权`)); +}; + +export const listAlbums = ( + cfg: StreamingServerConfig, + params?: StreamingListParams, +): Promise => { + if (isSubsonic(cfg.type)) return subsonic.listAlbums(cfg, params); + if (cfg.type === "jellyfin") return jellyfin.listAlbums(cfg, params); + if (cfg.type === "emby") return emby.listAlbums(cfg, params); + throw unsupported(cfg); +}; + +export const listArtists = (cfg: StreamingServerConfig): Promise => { + if (isSubsonic(cfg.type)) return subsonic.listArtists(cfg); + if (cfg.type === "jellyfin") return jellyfin.listArtists(cfg); + if (cfg.type === "emby") return emby.listArtists(cfg); + throw unsupported(cfg); +}; + +export const listPlaylists = (cfg: StreamingServerConfig): Promise => { + if (isSubsonic(cfg.type)) return subsonic.listPlaylists(cfg); + if (cfg.type === "jellyfin") return jellyfin.listPlaylists(cfg); + if (cfg.type === "emby") return emby.listPlaylists(cfg); + throw unsupported(cfg); +}; + +export const listSongs = ( + cfg: StreamingServerConfig, + params?: StreamingListParams, +): Promise => { + if (isSubsonic(cfg.type)) return subsonic.listSongs(cfg, params); + if (cfg.type === "jellyfin") return jellyfin.listSongs(cfg, params); + if (cfg.type === "emby") return emby.listSongs(cfg, params); + throw unsupported(cfg); +}; + +export const getAlbumSongs = (cfg: StreamingServerConfig, albumId: string): Promise => { + if (isSubsonic(cfg.type)) return subsonic.getAlbumSongs(cfg, albumId); + if (cfg.type === "jellyfin") return jellyfin.getAlbumSongs(cfg, albumId); + if (cfg.type === "emby") return emby.getAlbumSongs(cfg, albumId); + throw unsupported(cfg); +}; + +export const getPlaylistSongs = ( + cfg: StreamingServerConfig, + playlistId: string, +): Promise => { + if (isSubsonic(cfg.type)) return subsonic.getPlaylistSongs(cfg, playlistId); + if (cfg.type === "jellyfin") return jellyfin.getPlaylistSongs(cfg, playlistId); + if (cfg.type === "emby") return emby.getPlaylistSongs(cfg, playlistId); + throw unsupported(cfg); +}; + +export const getArtistAlbums = ( + cfg: StreamingServerConfig, + artistId: string, +): Promise => { + if (isSubsonic(cfg.type)) return subsonic.getArtistAlbums(cfg, artistId); + if (cfg.type === "jellyfin") return jellyfin.getArtistAlbums(cfg, artistId); + if (cfg.type === "emby") return emby.getArtistAlbums(cfg, artistId); + throw unsupported(cfg); +}; + +export const search = ( + cfg: StreamingServerConfig, + query: string, +): Promise => { + if (isSubsonic(cfg.type)) return subsonic.search(cfg, query); + if (cfg.type === "jellyfin") return jellyfin.search(cfg, query); + if (cfg.type === "emby") return emby.search(cfg, query); + throw unsupported(cfg); +}; + +export const getStreamUrl = (cfg: StreamingServerConfig, originalId: string): Promise => { + if (isSubsonic(cfg.type)) return subsonic.getStreamUrl(cfg, originalId); + if (cfg.type === "jellyfin") return jellyfin.getStreamUrl(cfg, originalId); + if (cfg.type === "emby") return emby.getStreamUrl(cfg, originalId); + throw unsupported(cfg); +}; + +export const getLyrics = ( + cfg: StreamingServerConfig, + originalId: string, + hint?: { artist?: string; title?: string }, +): Promise => { + if (isSubsonic(cfg.type)) return subsonic.getLyrics(cfg, originalId, hint); + if (cfg.type === "jellyfin") return jellyfin.getLyrics(cfg, originalId); + if (cfg.type === "emby") return emby.getLyrics(cfg, originalId); + return Promise.resolve(null); +}; diff --git a/src/services/streaming/jellyfin.ts b/src/services/streaming/jellyfin.ts new file mode 100644 index 00000000..2d03327e --- /dev/null +++ b/src/services/streaming/jellyfin.ts @@ -0,0 +1,286 @@ +/** + * Jellyfin 客户端(渲染层) + * + * 鉴权:POST /Users/AuthenticateByName 拿 AccessToken/UserId;后续请求带 X-Emby-Authorization + */ +import type { Track } from "@shared/types/player"; +import type { + StreamingAlbum, + StreamingArtist, + StreamingAuthResult, + StreamingListParams, + StreamingPingResult, + StreamingPlaylist, + StreamingSearchResult, + StreamingServerConfig, +} from "@shared/types/streaming"; +import { StreamingAuthError, StreamingProtocolError } from "./errors"; +import { ensureOk, fetchWithTimeout, normalizeBase } from "./http"; +import { + type JellyItem, + formatLrcTimestamp, + jellyItemToAlbum, + jellyItemToArtist, + jellyItemToPlaylist, + jellyItemToTrack, +} from "./transform"; + +const CLIENT_NAME = "SPlayer-Next"; +const CLIENT_VERSION = "1.0.0"; +const DEVICE_NAME = "SPlayer Desktop"; + +/** 派生稳定 deviceId(基于 cfg.id),用于服务器侧播放历史归并 */ +const deviceId = (cfg: StreamingServerConfig): string => `splayer-next-${cfg.id}`; + +const buildAuthHeader = (cfg: StreamingServerConfig): string => { + const parts = [ + `Client="${CLIENT_NAME}"`, + `Device="${DEVICE_NAME}"`, + `DeviceId="${deviceId(cfg)}"`, + `Version="${CLIENT_VERSION}"`, + ]; + if (cfg.accessToken) parts.push(`Token="${cfg.accessToken}"`); + return `MediaBrowser ${parts.join(", ")}`; +}; + +const headers = (cfg: StreamingServerConfig): Record => ({ + "Content-Type": "application/json", + "X-Emby-Authorization": buildAuthHeader(cfg), +}); + +const callApi = async ( + cfg: StreamingServerConfig, + path: string, + init?: RequestInit, +): Promise => { + const url = `${normalizeBase(cfg.url)}/${path.replace(/^\//, "")}`; + const res = await fetchWithTimeout(url, { + ...init, + headers: { ...headers(cfg), ...(init?.headers ?? {}) }, + }); + ensureOk(res); + if (res.status === 204) return null as T; + return (await res.json()) as T; +}; + +/* ───────────── 公开 API ───────────── */ + +export const ping = async (cfg: StreamingServerConfig): Promise => { + try { + const json = await callApi<{ Version?: string }>(cfg, "System/Info/Public"); + return { ok: true, version: json?.Version }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +}; + +export const authenticate = async (cfg: StreamingServerConfig): Promise => { + const json = await callApi<{ AccessToken?: string; User?: { Id?: string } }>( + cfg, + "Users/AuthenticateByName", + { + method: "POST", + body: JSON.stringify({ Username: cfg.username, Pw: cfg.password }), + }, + ); + const accessToken = json?.AccessToken; + const userId = json?.User?.Id; + if (!accessToken || !userId) { + throw new StreamingProtocolError("登录响应缺少 AccessToken/UserId"); + } + return { accessToken, userId }; +}; + +const requireAuth = (cfg: StreamingServerConfig): string => { + if (!cfg.accessToken || !cfg.userId) { + throw new StreamingAuthError("缺少 accessToken / userId"); + } + return cfg.userId; +}; + +/** + * 流播放 URL(universal 端点)。 + * + * universal 端点会按客户端能力(Container/AudioCodec/MaxStreamingBitrate) + * 决定直出还是转码。MaxStreamingBitrate 给得很大,意图是优先直出原始流。 + * 参数集与老项目(dev-new/SPlayer)一致以保证兼容性。 + */ +export const getStreamUrl = async ( + cfg: StreamingServerConfig, + originalId: string, +): Promise => { + if (!cfg.accessToken) throw new StreamingAuthError("缺少 accessToken"); + const params = new URLSearchParams({ + UserId: cfg.userId ?? "", + DeviceId: deviceId(cfg), + MaxStreamingBitrate: "140000000", + Container: "opus,webm|opus,ts|mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg", + TranscodingContainer: "ts", + TranscodingProtocol: "hls", + AudioCodec: "aac", + PlaySessionId: Date.now().toString(), + api_key: cfg.accessToken, + StartTimeTicks: "0", + EnableRedirection: "true", + EnableRemoteMedia: "true", + }); + return `${normalizeBase(cfg.url)}/Audio/${originalId}/universal?${params.toString()}`; +}; + +const fetchUserItems = async ( + cfg: StreamingServerConfig, + userId: string, + query: Record, +): Promise<{ Items?: JellyItem[] }> => { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(query)) params.set(k, String(v)); + return callApi(cfg, `Users/${userId}/Items?${params.toString()}`); +}; + +export const listAlbums = async ( + cfg: StreamingServerConfig, + params?: StreamingListParams, +): Promise => { + const userId = requireAuth(cfg); + const data = await fetchUserItems(cfg, userId, { + IncludeItemTypes: "MusicAlbum", + Recursive: "true", + SortBy: "SortName", + SortOrder: "Ascending", + Limit: params?.limit ?? 500, + StartIndex: params?.offset ?? 0, + }); + return (data.Items ?? []).map((it) => jellyItemToAlbum(cfg, it)); +}; + +export const listArtists = async (cfg: StreamingServerConfig): Promise => { + const userId = requireAuth(cfg); + // Jellyfin 的 /Artists 端点按 AlbumArtist 字段聚合返回所有歌手 + // (URL 格式与老项目 SPlayer 对齐以保兼容性) + const data = await callApi<{ Items?: JellyItem[]; TotalRecordCount?: number }>( + cfg, + `Artists?userId=${userId}&Recursive=true&SortBy=Name&SortOrder=Ascending`, + ); + return (data.Items ?? []).map((it) => jellyItemToArtist(cfg, it)); +}; + +export const listPlaylists = async (cfg: StreamingServerConfig): Promise => { + const userId = requireAuth(cfg); + const data = await fetchUserItems(cfg, userId, { + IncludeItemTypes: "Playlist", + Recursive: "true", + SortBy: "SortName", + }); + return (data.Items ?? []).map((it) => jellyItemToPlaylist(cfg, it)); +}; + +export const listSongs = async ( + cfg: StreamingServerConfig, + params?: StreamingListParams, +): Promise => { + const userId = requireAuth(cfg); + const data = await fetchUserItems(cfg, userId, { + IncludeItemTypes: "Audio", + Recursive: "true", + SortBy: "Random", + Fields: "MediaSources", + Limit: params?.limit ?? 100, + StartIndex: params?.offset ?? 0, + }); + return (data.Items ?? []).map((it) => jellyItemToTrack(cfg, it)); +}; + +export const getAlbumSongs = async ( + cfg: StreamingServerConfig, + albumId: string, +): Promise => { + const userId = requireAuth(cfg); + const data = await fetchUserItems(cfg, userId, { + ParentId: albumId, + IncludeItemTypes: "Audio", + Fields: "MediaSources", + SortBy: "ParentIndexNumber,IndexNumber,SortName", + }); + return (data.Items ?? []).map((it) => jellyItemToTrack(cfg, it)); +}; + +export const getPlaylistSongs = async ( + cfg: StreamingServerConfig, + playlistId: string, +): Promise => { + const userId = requireAuth(cfg); + const params = new URLSearchParams({ UserId: userId, Fields: "MediaSources" }); + const data = await callApi<{ Items?: JellyItem[] }>( + cfg, + `Playlists/${playlistId}/Items?${params.toString()}`, + ); + return (data.Items ?? []).map((it) => jellyItemToTrack(cfg, it)); +}; + +export const getArtistAlbums = async ( + cfg: StreamingServerConfig, + artistId: string, +): Promise => { + const userId = requireAuth(cfg); + const data = await fetchUserItems(cfg, userId, { + AlbumArtistIds: artistId, + IncludeItemTypes: "MusicAlbum", + Recursive: "true", + SortBy: "ProductionYear,SortName", + SortOrder: "Descending", + }); + return (data.Items ?? []).map((it) => jellyItemToAlbum(cfg, it)); +}; + +export const search = async ( + cfg: StreamingServerConfig, + query: string, +): Promise => { + const userId = requireAuth(cfg); + const fetchByType = async ( + type: "Audio" | "MusicAlbum" | "MusicArtist", + ): Promise => { + const data = await fetchUserItems(cfg, userId, { + IncludeItemTypes: type, + Recursive: "true", + SearchTerm: query, + Fields: "MediaSources", + Limit: 50, + }); + return data.Items ?? []; + }; + const [songs, albums, artists] = await Promise.all([ + fetchByType("Audio"), + fetchByType("MusicAlbum"), + fetchByType("MusicArtist"), + ]); + return { + songs: songs.map((it) => jellyItemToTrack(cfg, it)), + albums: albums.map((it) => jellyItemToAlbum(cfg, it)), + artists: artists.map((it) => jellyItemToArtist(cfg, it)), + }; +}; + +/** + * Jellyfin 10.8+ /Audio/{id}/Lyrics → { Lyrics: { Start, Text }[] } + * Start 是 100ns ticks。失败/无歌词都返回 null。 + */ +export const getLyrics = async ( + cfg: StreamingServerConfig, + originalId: string, +): Promise => { + if (!cfg.accessToken) return null; + try { + const json = await callApi<{ Lyrics?: { Start?: number; Text?: string }[] }>( + cfg, + `Audio/${originalId}/Lyrics`, + ); + const lines = json?.Lyrics ?? []; + if (lines.length === 0) return null; + return lines + .map((l) => `${formatLrcTimestamp(Math.floor((l.Start ?? 0) / 10000))}${l.Text ?? ""}`) + .join("\n"); + } catch { + return null; + } +}; diff --git a/src/services/streaming/subsonic.ts b/src/services/streaming/subsonic.ts new file mode 100644 index 00000000..0a983c02 --- /dev/null +++ b/src/services/streaming/subsonic.ts @@ -0,0 +1,224 @@ +/** + * Subsonic / Navidrome / OpenSubsonic 客户端(渲染层) + * + * 鉴权:每次请求生成 salt + md5(password+salt) 作为 query 参数 + */ +import type { Track } from "@shared/types/player"; +import type { + StreamingAlbum, + StreamingArtist, + StreamingListParams, + StreamingPingResult, + StreamingPlaylist, + StreamingSearchResult, + StreamingServerConfig, +} from "@shared/types/streaming"; +import { md5 } from "@/utils/md5"; +import { StreamingProtocolError } from "./errors"; +import { ensureOk, fetchWithTimeout, normalizeBase } from "./http"; +import { + type SubsonicAlbum, + type SubsonicArtist, + type SubsonicAuthBuilder, + type SubsonicPlaylist, + type SubsonicSong, + formatLrcTimestamp, + subsonicAlbumToView, + subsonicArtistToView, + subsonicPlaylistToView, + subsonicSongToTrack, + subsonicStreamUrl, +} from "./transform"; + +const API_VERSION = "1.16.1"; +const CLIENT_NAME = "SPlayer-Next"; + +/** 12 字符 hex salt */ +const newSalt = (): string => { + const bytes = new Uint8Array(6); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +}; + +/** 每个请求新生成 salt+token */ +const buildAuth: SubsonicAuthBuilder = (cfg) => { + const salt = newSalt(); + return new URLSearchParams({ + u: cfg.username, + t: md5(cfg.password + salt), + s: salt, + v: API_VERSION, + c: CLIENT_NAME, + f: "json", + }); +}; + +const buildUrl = ( + cfg: StreamingServerConfig, + endpoint: string, + extra: Record = {}, +): string => { + const params = buildAuth(cfg); + for (const [k, v] of Object.entries(extra)) params.set(k, String(v)); + return `${normalizeBase(cfg.url)}/rest/${endpoint}?${params.toString()}`; +}; + +/** subsonic-response 包装解析;status==='ok' 时返回包装本身 */ +const callApi = async >( + cfg: StreamingServerConfig, + endpoint: string, + extra: Record = {}, +): Promise => { + const res = await fetchWithTimeout(buildUrl(cfg, endpoint, extra)); + ensureOk(res); + const json = (await res.json()) as { "subsonic-response"?: Record }; + const wrap = json?.["subsonic-response"]; + if (!wrap) throw new StreamingProtocolError("响应缺少 subsonic-response 包装"); + if (wrap.status !== "ok") { + const err = wrap.error as { message?: string; code?: number } | undefined; + throw new StreamingProtocolError(err?.message || `Subsonic error code ${err?.code}`); + } + return wrap as T; +}; + +/* ───────────── 公开 API ───────────── */ + +export const ping = async (cfg: StreamingServerConfig): Promise => { + try { + const wrap = await callApi<{ version?: string; serverVersion?: string }>(cfg, "ping"); + return { ok: true, version: wrap.serverVersion ?? wrap.version }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +}; + +export const getStreamUrl = async ( + cfg: StreamingServerConfig, + originalId: string, +): Promise => subsonicStreamUrl(cfg, originalId, buildAuth); + +export const listAlbums = async ( + cfg: StreamingServerConfig, + params?: StreamingListParams, +): Promise => { + const wrap = await callApi<{ albumList2?: { album?: SubsonicAlbum[] } }>(cfg, "getAlbumList2", { + type: "alphabeticalByName", + size: params?.limit ?? 500, + offset: params?.offset ?? 0, + }); + return (wrap.albumList2?.album ?? []).map((a) => subsonicAlbumToView(cfg, a, buildAuth)); +}; + +export const listArtists = async (cfg: StreamingServerConfig): Promise => { + const wrap = await callApi<{ + artists?: { index?: { artist?: SubsonicArtist[] }[] }; + }>(cfg, "getArtists"); + const out: StreamingArtist[] = []; + for (const idx of wrap.artists?.index ?? []) { + for (const ar of idx.artist ?? []) out.push(subsonicArtistToView(cfg, ar, buildAuth)); + } + return out; +}; + +export const listPlaylists = async (cfg: StreamingServerConfig): Promise => { + const wrap = await callApi<{ playlists?: { playlist?: SubsonicPlaylist[] } }>( + cfg, + "getPlaylists", + ); + return (wrap.playlists?.playlist ?? []).map((p) => subsonicPlaylistToView(cfg, p, buildAuth)); +}; + +export const listSongs = async ( + cfg: StreamingServerConfig, + params?: StreamingListParams, +): Promise => { + // Subsonic 没有"全部歌曲"端点;getRandomSongs 充当首页内容 + const wrap = await callApi<{ randomSongs?: { song?: SubsonicSong[] } }>(cfg, "getRandomSongs", { + size: params?.limit ?? 100, + }); + return (wrap.randomSongs?.song ?? []).map((s) => subsonicSongToTrack(cfg, s, buildAuth)); +}; + +export const getAlbumSongs = async ( + cfg: StreamingServerConfig, + albumId: string, +): Promise => { + const wrap = await callApi<{ album?: SubsonicAlbum }>(cfg, "getAlbum", { id: albumId }); + return (wrap.album?.song ?? []).map((s) => subsonicSongToTrack(cfg, s, buildAuth)); +}; + +export const getPlaylistSongs = async ( + cfg: StreamingServerConfig, + playlistId: string, +): Promise => { + const wrap = await callApi<{ playlist?: SubsonicPlaylist }>(cfg, "getPlaylist", { + id: playlistId, + }); + return (wrap.playlist?.entry ?? []).map((s) => subsonicSongToTrack(cfg, s, buildAuth)); +}; + +export const getArtistAlbums = async ( + cfg: StreamingServerConfig, + artistId: string, +): Promise => { + const wrap = await callApi<{ artist?: { album?: SubsonicAlbum[] } }>(cfg, "getArtist", { + id: artistId, + }); + return (wrap.artist?.album ?? []).map((a) => subsonicAlbumToView(cfg, a, buildAuth)); +}; + +export const search = async ( + cfg: StreamingServerConfig, + query: string, +): Promise => { + const wrap = await callApi<{ + searchResult3?: { + song?: SubsonicSong[]; + album?: SubsonicAlbum[]; + artist?: SubsonicArtist[]; + }; + }>(cfg, "search3", { query }); + return { + songs: (wrap.searchResult3?.song ?? []).map((s) => subsonicSongToTrack(cfg, s, buildAuth)), + albums: (wrap.searchResult3?.album ?? []).map((a) => subsonicAlbumToView(cfg, a, buildAuth)), + artists: (wrap.searchResult3?.artist ?? []).map((a) => subsonicArtistToView(cfg, a, buildAuth)), + }; +}; + +/** + * 优先 OpenSubsonic 的 getLyricsBySongId(结构化)→ 转 LRC; + * 失败回退旧端点 getLyrics(artist+title)。任一拿不到都返回 null。 + */ +export const getLyrics = async ( + cfg: StreamingServerConfig, + originalId: string, + hint?: { artist?: string; title?: string }, +): Promise => { + try { + const wrap = await callApi<{ + lyricsList?: { structuredLyrics?: { line?: { start?: number; value: string }[] }[] }; + }>(cfg, "getLyricsBySongId", { id: originalId }); + const structured = wrap.lyricsList?.structuredLyrics?.[0]?.line ?? []; + if (structured.length > 0) { + return structured + .map((l) => `${formatLrcTimestamp(l.start ?? 0)}${l.value ?? ""}`) + .join("\n"); + } + } catch { + // 旧 Subsonic 没有 getLyricsBySongId,下面回退 + } + + if (hint?.artist || hint?.title) { + try { + const wrap = await callApi<{ lyrics?: { value?: string } }>(cfg, "getLyrics", { + artist: hint.artist ?? "", + title: hint.title ?? "", + }); + const text = wrap.lyrics?.value; + if (text && text.trim()) return text; + } catch { + // 没有就没有 + } + } + return null; +}; diff --git a/src/services/streaming/transform.ts b/src/services/streaming/transform.ts new file mode 100644 index 00000000..31619c80 --- /dev/null +++ b/src/services/streaming/transform.ts @@ -0,0 +1,290 @@ +/** + * 流媒体响应 → 统一 Track / Album / Artist / Playlist 类型 + * + * 各服务器返回结构差异巨大;这里把所有映射逻辑集中,便于校对。 + */ +import type { Artist, Track } from "@shared/types/player"; +import type { + StreamingAlbum, + StreamingArtist, + StreamingPlaylist, + StreamingServerConfig, +} from "@shared/types/streaming"; + +/** 常见的歌手分隔符 */ +const ARTIST_SEPARATOR = /\s*(?:feat\.?|ft\.?)\s+|[/&;,×|、,]\s*/i; + +/** 把"周杰伦/林俊杰"风格字符串拆成 Artist 数组 */ +const parseArtists = (raw: string): Artist[] => { + if (!raw) return []; + return raw + .split(ARTIST_SEPARATOR) + .map((name) => name.trim()) + .filter(Boolean) + .map((name) => ({ name })); +}; + +/** 生成稳定的 Track.id:${cfg.id}:${originalId} */ +export const trackId = (cfg: StreamingServerConfig, originalId: string): string => + `${cfg.id}:${originalId}`; + +/** 秒 → 毫秒(subsonic 用秒) */ +export const secToMs = (s?: number): number => Math.max(0, Math.floor((s ?? 0) * 1000)); + +/* ───────────── Subsonic ───────────── */ + +export interface SubsonicSong { + id: string; + title: string; + artist?: string; + album?: string; + albumId?: string; + artistId?: string; + duration?: number; + bitRate?: number; + samplingRate?: number; + bitDepth?: number; + channelCount?: number; + suffix?: string; + size?: number; + coverArt?: string; + year?: number; +} + +export interface SubsonicAlbum { + id: string; + name: string; + artist?: string; + artistId?: string; + coverArt?: string; + songCount?: number; + year?: number; + song?: SubsonicSong[]; +} + +export interface SubsonicArtist { + id: string; + name: string; + albumCount?: number; + coverArt?: string; +} + +export interface SubsonicPlaylist { + id: string; + name: string; + comment?: string; + songCount?: number; + coverArt?: string; + owner?: string; + entry?: SubsonicSong[]; +} + +/** 每次请求都要新生成 salt+token,所以传一个 builder 闭包 */ +export type SubsonicAuthBuilder = (cfg: StreamingServerConfig) => URLSearchParams; + +const subsonicCoverUrl = ( + cfg: StreamingServerConfig, + coverArtId: string | undefined, + buildAuth: SubsonicAuthBuilder, + size?: number, +): string | undefined => { + if (!coverArtId) return undefined; + const base = cfg.url.replace(/\/+$/, ""); + const params = buildAuth(cfg); + params.set("id", coverArtId); + if (size) params.set("size", String(size)); + return `${base}/rest/getCoverArt?${params.toString()}`; +}; + +export const subsonicStreamUrl = ( + cfg: StreamingServerConfig, + songId: string, + buildAuth: SubsonicAuthBuilder, +): string => { + const base = cfg.url.replace(/\/+$/, ""); + const params = buildAuth(cfg); + params.set("id", songId); + return `${base}/rest/stream?${params.toString()}`; +}; + +export const subsonicSongToTrack = ( + cfg: StreamingServerConfig, + song: SubsonicSong, + buildAuth: SubsonicAuthBuilder, +): Track => ({ + id: trackId(cfg, song.id), + source: "streaming", + serverId: cfg.id, + originalId: song.id, + title: song.title || "", + artists: parseArtists(song.artist ?? ""), + album: song.album ? { id: song.albumId, name: song.album } : undefined, + duration: secToMs(song.duration), + cover: subsonicCoverUrl(cfg, song.coverArt, buildAuth, 300), + fileSize: song.size, + quality: { + sampleRate: song.samplingRate ?? 0, + channels: song.channelCount ?? 2, + bitsPerSample: song.bitDepth ?? 0, + bitRate: song.bitRate ? song.bitRate * 1000 : 0, + codec: song.suffix ?? "", + }, +}); + +export const subsonicAlbumToView = ( + cfg: StreamingServerConfig, + album: SubsonicAlbum, + buildAuth: SubsonicAuthBuilder, +): StreamingAlbum => ({ + id: album.id, + name: album.name, + artist: album.artist, + cover: subsonicCoverUrl(cfg, album.coverArt ?? album.id, buildAuth, 300), + songCount: album.songCount, + year: album.year, +}); + +export const subsonicArtistToView = ( + cfg: StreamingServerConfig, + artist: SubsonicArtist, + buildAuth: SubsonicAuthBuilder, +): StreamingArtist => ({ + id: artist.id, + name: artist.name, + avatar: subsonicCoverUrl(cfg, artist.coverArt ?? artist.id, buildAuth, 300), + albumCount: artist.albumCount, +}); + +export const subsonicPlaylistToView = ( + cfg: StreamingServerConfig, + pl: SubsonicPlaylist, + buildAuth: SubsonicAuthBuilder, +): StreamingPlaylist => ({ + id: pl.id, + name: pl.name, + description: pl.comment, + cover: subsonicCoverUrl(cfg, pl.coverArt ?? pl.id, buildAuth, 300), + songCount: pl.songCount, + owner: pl.owner, +}); + +/* ───────────── Jellyfin / Emby ───────────── */ + +export interface JellyItem { + Id: string; + Name?: string; + Type?: string; + Album?: string; + AlbumId?: string; + AlbumArtist?: string; + AlbumArtistId?: string; + Artists?: string[]; + ArtistItems?: { Id: string; Name: string }[]; + RunTimeTicks?: number; + ProductionYear?: number; + ChildCount?: number; + ImageTags?: { Primary?: string }; + AlbumPrimaryImageTag?: string; + MediaSources?: { + Container?: string; + Bitrate?: number; + Size?: number; + MediaStreams?: { + Type?: string; + SampleRate?: number; + BitDepth?: number; + Channels?: number; + Codec?: string; + }[]; + }[]; +} + +const jellyTicksToMs = (ticks?: number): number => { + if (!ticks) return 0; + // RunTimeTicks 是 100ns ticks:1ms = 10000 ticks + return Math.floor(ticks / 10_000); +}; + +export const jellyImageUrl = ( + cfg: StreamingServerConfig, + itemId: string, + tag?: string, + maxHeight?: number, +): string | undefined => { + if (!cfg.accessToken) return undefined; + const base = cfg.url.replace(/\/+$/, ""); + const params = new URLSearchParams({ api_key: cfg.accessToken }); + if (tag) params.set("tag", tag); + if (maxHeight) params.set("maxHeight", String(maxHeight)); + return `${base}/Items/${itemId}/Images/Primary?${params.toString()}`; +}; + +export const jellyItemToTrack = (cfg: StreamingServerConfig, item: JellyItem): Track => { + const audioStream = item.MediaSources?.[0]?.MediaStreams?.find((s) => s.Type === "Audio"); + const mediaSrc = item.MediaSources?.[0]; + // Jellyfin/Emby 对 audio item:扫描 ID3 嵌入封面后,audio 自己会有 ImageTags.Primary。 + // 没有自己的 imageTag 就不显示封面 —— 与老项目一致,避免 fallback 到 album 时 + // 触发 album 自身没图的 404 刷屏(用 truthy 判断同时排除空字符串)。 + const imageTag = item.ImageTags?.Primary; + const cover = imageTag ? jellyImageUrl(cfg, item.Id, imageTag, 300) : undefined; + return { + id: trackId(cfg, item.Id), + source: "streaming", + serverId: cfg.id, + originalId: item.Id, + title: item.Name ?? "", + artists: + item.ArtistItems?.map((a) => ({ id: a.Id, name: a.Name })) ?? + item.Artists?.map((name) => ({ name })) ?? + [], + album: item.Album ? { id: item.AlbumId, name: item.Album } : undefined, + duration: jellyTicksToMs(item.RunTimeTicks), + cover, + fileSize: mediaSrc?.Size, + quality: { + sampleRate: audioStream?.SampleRate ?? 0, + channels: audioStream?.Channels ?? 2, + bitsPerSample: audioStream?.BitDepth ?? 0, + bitRate: mediaSrc?.Bitrate ?? 0, + codec: audioStream?.Codec ?? mediaSrc?.Container ?? "", + }, + }; +}; + +export const jellyItemToAlbum = (cfg: StreamingServerConfig, item: JellyItem): StreamingAlbum => ({ + id: item.Id, + name: item.Name ?? "", + artist: item.AlbumArtist, + cover: jellyImageUrl(cfg, item.Id, item.ImageTags?.Primary, 300), + songCount: item.ChildCount, + year: item.ProductionYear, +}); + +export const jellyItemToArtist = ( + cfg: StreamingServerConfig, + item: JellyItem, +): StreamingArtist => ({ + id: item.Id, + name: item.Name ?? "", + avatar: jellyImageUrl(cfg, item.Id, item.ImageTags?.Primary, 300), + albumCount: item.ChildCount, +}); + +export const jellyItemToPlaylist = ( + cfg: StreamingServerConfig, + item: JellyItem, +): StreamingPlaylist => ({ + id: item.Id, + name: item.Name ?? "", + cover: jellyImageUrl(cfg, item.Id, item.ImageTags?.Primary, 300), + songCount: item.ChildCount, +}); + +/** 把毫秒数格式化为 LRC 时间戳 [mm:ss.xx] */ +export const formatLrcTimestamp = (ms: number): string => { + const safe = Math.max(0, ms); + const mm = Math.floor(safe / 60000); + const ss = Math.floor((safe % 60000) / 1000); + const xx = Math.floor((safe % 1000) / 10); + return `[${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}.${String(xx).padStart(2, "0")}]`; +}; diff --git a/src/settings/categories/streaming.ts b/src/settings/categories/streaming.ts new file mode 100644 index 00000000..2aadb2e0 --- /dev/null +++ b/src/settings/categories/streaming.ts @@ -0,0 +1,24 @@ +import type { SettingCategory } from "@/types/settings-schema"; +import StreamingServerList from "@/components/settings/custom/StreamingServerList.vue"; +import IconLucideServer from "~icons/lucide/server"; + +const streamingCategory: SettingCategory = { + id: "streaming", + icon: IconLucideServer, + sections: [ + { + id: "servers", + items: [ + { + key: "streamingServerList", + type: "custom", + component: StreamingServerList, + fullWidth: true, + keywords: ["streaming.server.add", "streaming.server.test", "streaming.server.connect"], + }, + ], + }, + ], +}; + +export default streamingCategory; diff --git a/src/settings/schema.ts b/src/settings/schema.ts index c881d676..d58a10ea 100644 --- a/src/settings/schema.ts +++ b/src/settings/schema.ts @@ -6,6 +6,7 @@ import lyricCategory from "./categories/lyric"; import externalLyricCategory from "./categories/externalLyric"; import hotkeysCategory from "./categories/hotkeys"; import servicesCategory from "./categories/services"; +import streamingCategory from "./categories/streaming"; import pluginsCategory from "./categories/plugins"; /** 设置项 schema:左侧分类(category)→ 区块(section)→ 设置项(item)三级结构。 @@ -18,5 +19,6 @@ export const settingsSchema: SettingCategory[] = [ externalLyricCategory, hotkeysCategory, servicesCategory, + streamingCategory, pluginsCategory, ]; diff --git a/src/stores/media.ts b/src/stores/media.ts index 79a434bb..099fc595 100644 --- a/src/stores/media.ts +++ b/src/stores/media.ts @@ -59,14 +59,16 @@ export const useMediaStore = defineStore("media", () => { /** * 把 audio-engine 解析出的元数据合并到当前 Track 上。 * 保留身份字段(id/source/serverId/originalId/platform/path); - * 仅对未设置或空值的展示字段做兜底填充(cover/quality/duration)。 + * 对未设置/空值的展示字段做兜底填充(duration/quality)。 + * streaming 源的 cover/title/artist/album 已经是服务器返回的权威值,绝不被引擎覆盖。 */ const enrichTrack = (info: MediaInfo, newDetail?: TrackDetail): void => { if (!track.value) return; + const isStreaming = track.value.source === "streaming"; track.value = { ...track.value, duration: track.value.duration > 0 ? track.value.duration : info.duration, - cover: track.value.cover ?? info.cover, + cover: isStreaming ? track.value.cover : (track.value.cover ?? info.cover), quality: track.value.quality ?? info.quality, }; if (newDetail) detail.value = newDetail; diff --git a/src/stores/streaming.ts b/src/stores/streaming.ts new file mode 100644 index 00000000..662671d8 --- /dev/null +++ b/src/stores/streaming.ts @@ -0,0 +1,436 @@ +import localforage from "localforage"; +import type { Track } from "@shared/types/player"; +import type { + StreamingAlbum, + StreamingArtist, + StreamingListParams, + StreamingPingResult, + StreamingPlaylist, + StreamingSearchResult, + StreamingServerConfig, + StreamingServerInput, + StreamingServerType, +} from "@shared/types/streaming"; +import * as client from "@/services/streaming"; +import { StreamingAuthError } from "@/services/streaming/errors"; + +const NEEDS_AUTH: StreamingServerType[] = ["jellyfin", "emby"]; +const needsAccessToken = (type: StreamingServerType): boolean => NEEDS_AUTH.includes(type); + +/** 浏览缓存:每个 serverId 一个 cache 条目 */ +interface ServerCache { + songs: Track[]; + albums: StreamingAlbum[]; + artists: StreamingArtist[]; + playlists: StreamingPlaylist[]; + /** 最后更新时间 */ + updatedAt: number; +} + +const cacheDb = localforage.createInstance({ name: "splayer", storeName: "streaming-cache" }); +const cacheKey = (serverId: string): string => `cache:${serverId}`; + +export const useStreamingStore = defineStore( + "streaming", + () => { + /** 服务器列表(明文持久化在 localStorage) */ + const servers = ref([]); + /** 当前激活服务器 ID */ + const activeServerId = ref(null); + /** 连接状态(仅运行时) */ + const connectionStatus = ref<{ connected: boolean; error?: string }>({ connected: false }); + /** 是否正在拉数据 */ + const loading = ref(false); + + /** 运行时缓存(启动从 IndexedDB 水合,浏览时回写) */ + const songs = shallowRef([]); + const albums = shallowRef([]); + const artists = shallowRef([]); + const playlists = shallowRef([]); + /** 缓存最后更新时间(ms),UI 可据此判断是否过期 */ + const lastFetchedAt = ref(0); + /** 是否已从 IndexedDB 完成首次水合 */ + const hydrated = ref(false); + + const activeServer = computed( + () => servers.value.find((s) => s.id === activeServerId.value) ?? null, + ); + const hasServer = computed(() => servers.value.length > 0); + const isConnected = computed(() => connectionStatus.value.connected); + + /** 当前激活服务器的缓存键 */ + const currentCacheKey = (): string | null => + activeServerId.value ? cacheKey(activeServerId.value) : null; + + /** 把内存中的列表写回 IndexedDB(按当前激活服务器) */ + const persistCache = (): void => { + const key = currentCacheKey(); + if (!key) return; + const snapshot: ServerCache = { + songs: toRaw(songs.value), + albums: toRaw(albums.value), + artists: toRaw(artists.value), + playlists: toRaw(playlists.value), + updatedAt: Date.now(), + }; + lastFetchedAt.value = snapshot.updatedAt; + cacheDb.setItem(key, snapshot).catch(() => {}); + }; + + /** 从 IndexedDB 读出当前激活服务器的缓存到内存 */ + const hydrateFromCache = async (): Promise => { + const key = currentCacheKey(); + if (!key) { + clearMemoryLists(); + hydrated.value = true; + return; + } + const cached = await cacheDb.getItem(key).catch(() => null); + if (cached) { + songs.value = cached.songs; + albums.value = cached.albums; + artists.value = cached.artists; + playlists.value = cached.playlists; + lastFetchedAt.value = cached.updatedAt; + } else { + clearMemoryLists(); + } + hydrated.value = true; + }; + + /** 仅清内存数据,不动 IndexedDB(IndexedDB 在 removeServer 时清) */ + const clearMemoryLists = (): void => { + songs.value = []; + albums.value = []; + artists.value = []; + playlists.value = []; + lastFetchedAt.value = 0; + }; + + const normalizeUrl = (url: string): string => url.trim().replace(/\/+$/, ""); + + const requireActiveCfg = (): StreamingServerConfig => { + const cfg = activeServer.value; + if (!cfg) throw new Error("没有激活的流媒体服务器"); + return cfg; + }; + + /** 把 patch 应用到指定 server */ + const patchServer = (id: string, patch: Partial): void => { + const idx = servers.value.findIndex((s) => s.id === id); + if (idx < 0) return; + const next = [...servers.value]; + next[idx] = { ...next[idx], ...patch }; + servers.value = next; + }; + + /** 添加服务器 */ + const addServer = (input: StreamingServerInput): StreamingServerConfig => { + const cfg: StreamingServerConfig = { + id: crypto.randomUUID(), + name: input.name.trim(), + type: input.type, + url: normalizeUrl(input.url), + username: input.username, + password: input.password, + }; + servers.value = [...servers.value, cfg]; + return cfg; + }; + + /** 局部更新;改 url/username/password/type 会清空 token */ + const updateServer = (id: string, patch: Partial): void => { + const idx = servers.value.findIndex((s) => s.id === id); + if (idx < 0) return; + const old = servers.value[idx]; + const credentialsChanged = + (patch.url !== undefined && normalizeUrl(patch.url) !== old.url) || + (patch.username !== undefined && patch.username !== old.username) || + (patch.password !== undefined && patch.password !== old.password) || + (patch.type !== undefined && patch.type !== old.type); + const next: StreamingServerConfig = { + ...old, + name: patch.name?.trim() ?? old.name, + type: patch.type ?? old.type, + url: patch.url !== undefined ? normalizeUrl(patch.url) : old.url, + username: patch.username ?? old.username, + password: patch.password ?? old.password, + accessToken: credentialsChanged ? undefined : old.accessToken, + userId: credentialsChanged ? undefined : old.userId, + }; + const list = [...servers.value]; + list[idx] = next; + servers.value = list; + }; + + /** 移除服务器,激活 ID + IndexedDB 缓存一并清空 */ + const removeServer = (id: string): void => { + servers.value = servers.value.filter((s) => s.id !== id); + cacheDb.removeItem(cacheKey(id)).catch(() => {}); + if (activeServerId.value === id) { + activeServerId.value = null; + connectionStatus.value = { connected: false }; + clearMemoryLists(); + } + }; + + /** 测试连接(不写 store)。jellyfin/emby 先 authenticate 再 ping */ + const testConnection = async (input: StreamingServerInput): Promise => { + const tempCfg: StreamingServerConfig = { + id: "__test__", + name: input.name, + type: input.type, + url: normalizeUrl(input.url), + username: input.username, + password: input.password, + }; + try { + if (needsAccessToken(input.type)) { + const auth = await client.authenticate(tempCfg); + tempCfg.accessToken = auth.accessToken; + tempCfg.userId = auth.userId; + } + return await client.ping(tempCfg); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }; + + /** + * 连接到指定服务器:jellyfin/emby 自动登录拿 token,subsonic 系仅 ping。 + */ + const connectToServer = async (id: string): Promise => { + const cfg = servers.value.find((s) => s.id === id); + if (!cfg) return false; + try { + if (needsAccessToken(cfg.type)) { + const auth = await client.authenticate(cfg); + patchServer(id, { accessToken: auth.accessToken, userId: auth.userId }); + } + const fresh = servers.value.find((s) => s.id === id)!; + const ping = await client.ping(fresh); + if (!ping.ok) { + connectionStatus.value = { connected: false, error: ping.error }; + return false; + } + patchServer(id, { lastConnected: Date.now() }); + connectionStatus.value = { connected: true }; + return true; + } catch (err) { + connectionStatus.value = { + connected: false, + error: err instanceof Error ? err.message : String(err), + }; + return false; + } + }; + + /** + * 设为激活服务器并触发连接。 + * 如果 id 和当前激活相同(用户在断开状态下重新点击连接),仍走重连流程。 + */ + const setActiveServer = async (id: string | null): Promise => { + if (id !== activeServerId.value) { + activeServerId.value = id; + connectionStatus.value = { connected: false }; + hydrated.value = false; + await hydrateFromCache(); + } + if (!id) return; + await connectToServer(id); + }; + + /** 断开当前激活服务器(内存数据保留作为缓存显示,但 token 清空) */ + const disconnect = (): void => { + const id = activeServerId.value; + if (id) patchServer(id, { accessToken: undefined, userId: undefined }); + connectionStatus.value = { connected: false }; + }; + + /** + * 包装:执行 fn;遇到 StreamingAuthError 自动重登重试一次。 + * 仅 jellyfin/emby 走重登;subsonic 系密码错就是错,没有 token 概念。 + */ + const withAutoReauthFor = async ( + cfg: StreamingServerConfig, + fn: (cfg: StreamingServerConfig) => Promise, + ): Promise => { + try { + return await fn(cfg); + } catch (err) { + if (!(err instanceof StreamingAuthError) || !needsAccessToken(cfg.type)) throw err; + const ok = await connectToServer(cfg.id); + if (!ok) throw err; + const refreshed = servers.value.find((s) => s.id === cfg.id); + if (!refreshed) throw err; + return fn(refreshed); + } + }; + + /** 用激活服务器执行 */ + const withActive = (fn: (cfg: StreamingServerConfig) => Promise): Promise => + withAutoReauthFor(requireActiveCfg(), fn); + + /* ───────────── 浏览(写入运行时缓存) ───────────── */ + + const fetchAlbums = async (params?: StreamingListParams): Promise => { + loading.value = true; + try { + albums.value = await withActive((cfg) => client.listAlbums(cfg, params)); + persistCache(); + } catch (err) { + console.error("[streaming] fetchAlbums failed:", err); + } finally { + loading.value = false; + } + }; + + const fetchArtists = async (): Promise => { + loading.value = true; + try { + const result = await withActive((cfg) => client.listArtists(cfg)); + console.debug(`[streaming] fetchArtists: got ${result.length} artists`); + artists.value = result; + persistCache(); + } catch (err) { + console.error("[streaming] fetchArtists failed:", err); + } finally { + loading.value = false; + } + }; + + const fetchPlaylists = async (): Promise => { + loading.value = true; + try { + playlists.value = await withActive((cfg) => client.listPlaylists(cfg)); + persistCache(); + } catch (err) { + console.error("[streaming] fetchPlaylists failed:", err); + } finally { + loading.value = false; + } + }; + + const fetchSongs = async (params?: StreamingListParams): Promise => { + loading.value = true; + try { + songs.value = await withActive((cfg) => client.listSongs(cfg, params)); + persistCache(); + } catch (err) { + console.error("[streaming] fetchSongs failed:", err); + } finally { + loading.value = false; + } + }; + + /* ───────────── 详情(不写入缓存) ───────────── */ + + const fetchAlbumSongs = (albumId: string): Promise => + withActive((cfg) => client.getAlbumSongs(cfg, albumId)); + + const fetchPlaylistSongs = (playlistId: string): Promise => + withActive((cfg) => client.getPlaylistSongs(cfg, playlistId)); + + const fetchArtistAlbums = (artistId: string): Promise => + withActive((cfg) => client.getArtistAlbums(cfg, artistId)); + + const search = (query: string): Promise => + withActive((cfg) => client.search(cfg, query)); + + /* ───────────── 给 player.ts / lyricLoader.ts 用 ───────────── */ + + /** Track.serverId 找 cfg;找不到抛错 */ + const findCfgForTrack = (track: Track): StreamingServerConfig => { + if (track.source !== "streaming" || !track.serverId || !track.originalId) { + throw new Error("非流媒体 Track"); + } + const cfg = servers.value.find((s) => s.id === track.serverId); + if (!cfg) throw new Error("找不到服务器配置"); + return cfg; + }; + + const getStreamUrl = async (track: Track): Promise => { + const cfg = findCfgForTrack(track); + // 未连接时尝试自动重连一次;仍失败则阻止播放 + // (subsonic 的 stream URL 不依赖 token,单靠 cfg 就能生成, + // 必须在此处把关,否则脱机也能拼出 URL 让 audio-engine 去试) + if (cfg.id === activeServerId.value && !connectionStatus.value.connected) { + const ok = await connectToServer(cfg.id); + if (!ok) { + throw new Error(connectionStatus.value.error ?? "未连接到流媒体服务器"); + } + } + const fresh = servers.value.find((s) => s.id === cfg.id) ?? cfg; + return withAutoReauthFor(fresh, (c) => client.getStreamUrl(c, track.originalId!)); + }; + + const getLyrics = async (track: Track): Promise => { + try { + const cfg = findCfgForTrack(track); + return await withAutoReauthFor(cfg, (c) => + client.getLyrics(c, track.originalId!, { + artist: track.artists?.[0]?.name, + title: track.title, + }), + ); + } catch { + return null; + } + }; + + /** + * 初始化:从 IndexedDB 水合当前激活服务器的缓存。 + * 应当在应用启动时(或 streaming 页面挂载时)调用一次。 + * 重复调用安全。 + */ + const init = async (): Promise => { + if (hydrated.value) return; + await hydrateFromCache(); + }; + + return { + // state + servers, + activeServerId, + activeServer, + connectionStatus, + loading, + hasServer, + isConnected, + hydrated, + lastFetchedAt, + songs, + albums, + artists, + playlists, + // lifecycle + init, + // server management + addServer, + updateServer, + removeServer, + setActiveServer, + connectToServer, + disconnect, + testConnection, + // browse + fetchAlbums, + fetchArtists, + fetchPlaylists, + fetchSongs, + fetchAlbumSongs, + fetchPlaylistSongs, + fetchArtistAlbums, + search, + // for player.ts / lyricLoader.ts + getStreamUrl, + getLyrics, + }; + }, + { + persist: { + storage: localStorage, + pick: ["servers", "activeServerId"], + }, + }, +); diff --git a/src/utils/md5.ts b/src/utils/md5.ts new file mode 100644 index 00000000..d8ab14d4 --- /dev/null +++ b/src/utils/md5.ts @@ -0,0 +1,152 @@ +/** + * MD5 实现(RFC 1321) + * + * 给 Subsonic 鉴权用:md5(password + salt) → hex 字符串。 + * Web Crypto 不支持 MD5,所以自己实现一个最小版本。 + */ + +const safeAdd = (x: number, y: number): number => { + const lsw = (x & 0xffff) + (y & 0xffff); + const msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xffff); +}; + +const rol = (n: number, c: number): number => (n << c) | (n >>> (32 - c)); + +const cmn = (q: number, a: number, b: number, x: number, s: number, t: number): number => + safeAdd(rol(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b); + +const ff = (a: number, b: number, c: number, d: number, x: number, s: number, t: number): number => + cmn((b & c) | (~b & d), a, b, x, s, t); +const gg = (a: number, b: number, c: number, d: number, x: number, s: number, t: number): number => + cmn((b & d) | (c & ~d), a, b, x, s, t); +const hh = (a: number, b: number, c: number, d: number, x: number, s: number, t: number): number => + cmn(b ^ c ^ d, a, b, x, s, t); +const ii = (a: number, b: number, c: number, d: number, x: number, s: number, t: number): number => + cmn(c ^ (b | ~d), a, b, x, s, t); + +const md5cycle = (x: number[], k: number[]): void => { + let [a, b, c, d] = x; + + a = ff(a, b, c, d, k[0], 7, -680876936); + d = ff(d, a, b, c, k[1], 12, -389564586); + c = ff(c, d, a, b, k[2], 17, 606105819); + b = ff(b, c, d, a, k[3], 22, -1044525330); + a = ff(a, b, c, d, k[4], 7, -176418897); + d = ff(d, a, b, c, k[5], 12, 1200080426); + c = ff(c, d, a, b, k[6], 17, -1473231341); + b = ff(b, c, d, a, k[7], 22, -45705983); + a = ff(a, b, c, d, k[8], 7, 1770035416); + d = ff(d, a, b, c, k[9], 12, -1958414417); + c = ff(c, d, a, b, k[10], 17, -42063); + b = ff(b, c, d, a, k[11], 22, -1990404162); + a = ff(a, b, c, d, k[12], 7, 1804603682); + d = ff(d, a, b, c, k[13], 12, -40341101); + c = ff(c, d, a, b, k[14], 17, -1502002290); + b = ff(b, c, d, a, k[15], 22, 1236535329); + + a = gg(a, b, c, d, k[1], 5, -165796510); + d = gg(d, a, b, c, k[6], 9, -1069501632); + c = gg(c, d, a, b, k[11], 14, 643717713); + b = gg(b, c, d, a, k[0], 20, -373897302); + a = gg(a, b, c, d, k[5], 5, -701558691); + d = gg(d, a, b, c, k[10], 9, 38016083); + c = gg(c, d, a, b, k[15], 14, -660478335); + b = gg(b, c, d, a, k[4], 20, -405537848); + a = gg(a, b, c, d, k[9], 5, 568446438); + d = gg(d, a, b, c, k[14], 9, -1019803690); + c = gg(c, d, a, b, k[3], 14, -187363961); + b = gg(b, c, d, a, k[8], 20, 1163531501); + a = gg(a, b, c, d, k[13], 5, -1444681467); + d = gg(d, a, b, c, k[2], 9, -51403784); + c = gg(c, d, a, b, k[7], 14, 1735328473); + b = gg(b, c, d, a, k[12], 20, -1926607734); + + a = hh(a, b, c, d, k[5], 4, -378558); + d = hh(d, a, b, c, k[8], 11, -2022574463); + c = hh(c, d, a, b, k[11], 16, 1839030562); + b = hh(b, c, d, a, k[14], 23, -35309556); + a = hh(a, b, c, d, k[1], 4, -1530992060); + d = hh(d, a, b, c, k[4], 11, 1272893353); + c = hh(c, d, a, b, k[7], 16, -155497632); + b = hh(b, c, d, a, k[10], 23, -1094730640); + a = hh(a, b, c, d, k[13], 4, 681279174); + d = hh(d, a, b, c, k[0], 11, -358537222); + c = hh(c, d, a, b, k[3], 16, -722521979); + b = hh(b, c, d, a, k[6], 23, 76029189); + a = hh(a, b, c, d, k[9], 4, -640364487); + d = hh(d, a, b, c, k[12], 11, -421815835); + c = hh(c, d, a, b, k[15], 16, 530742520); + b = hh(b, c, d, a, k[2], 23, -995338651); + + a = ii(a, b, c, d, k[0], 6, -198630844); + d = ii(d, a, b, c, k[7], 10, 1126891415); + c = ii(c, d, a, b, k[14], 15, -1416354905); + b = ii(b, c, d, a, k[5], 21, -57434055); + a = ii(a, b, c, d, k[12], 6, 1700485571); + d = ii(d, a, b, c, k[3], 10, -1894986606); + c = ii(c, d, a, b, k[10], 15, -1051523); + b = ii(b, c, d, a, k[1], 21, -2054922799); + a = ii(a, b, c, d, k[8], 6, 1873313359); + d = ii(d, a, b, c, k[15], 10, -30611744); + c = ii(c, d, a, b, k[6], 15, -1560198380); + b = ii(b, c, d, a, k[13], 21, 1309151649); + a = ii(a, b, c, d, k[4], 6, -145523070); + d = ii(d, a, b, c, k[11], 10, -1120210379); + c = ii(c, d, a, b, k[2], 15, 718787259); + b = ii(b, c, d, a, k[9], 21, -343485551); + + x[0] = safeAdd(x[0], a); + x[1] = safeAdd(x[1], b); + x[2] = safeAdd(x[2], c); + x[3] = safeAdd(x[3], d); +}; + +const md51 = (s: string): number[] => { + const bytes = new TextEncoder().encode(s); + const n = bytes.length; + const state = [1732584193, -271733879, -1732584194, 271733878]; + let i: number; + for (i = 64; i <= n; i += 64) { + md5cycle(state, md5blk(bytes.subarray(i - 64, i))); + } + const tail = new Array(16).fill(0); + const tailBytes = bytes.subarray(i - 64); + let j: number; + for (j = 0; j < tailBytes.length; j++) { + tail[j >> 2] |= tailBytes[j] << ((j & 3) << 3); + } + tail[j >> 2] |= 0x80 << ((j & 3) << 3); + if (j > 55) { + md5cycle(state, tail); + for (let k = 0; k < 16; k++) tail[k] = 0; + } + // 长度(bits) + const lenBits = n * 8; + tail[14] = lenBits >>> 0; + tail[15] = Math.floor(lenBits / 0x100000000); + md5cycle(state, tail); + return state; +}; + +const md5blk = (chunk: Uint8Array): number[] => { + const blk = new Array(16); + for (let i = 0; i < 16; i++) { + blk[i] = + chunk[i * 4] | (chunk[i * 4 + 1] << 8) | (chunk[i * 4 + 2] << 16) | (chunk[i * 4 + 3] << 24); + } + return blk; +}; + +const HEX = "0123456789abcdef"; + +const rhex = (n: number): string => { + let s = ""; + for (let j = 0; j < 4; j++) { + s += HEX[(n >> (j * 8 + 4)) & 0x0f] + HEX[(n >> (j * 8)) & 0x0f]; + } + return s; +}; + +/** MD5(s) → 32 字符小写 hex 字符串 */ +export const md5 = (s: string): string => md51(s).map(rhex).join(""); diff --git a/src/utils/navigate.ts b/src/utils/navigate.ts index f9b8b057..c2813968 100644 --- a/src/utils/navigate.ts +++ b/src/utils/navigate.ts @@ -1,19 +1,34 @@ +import type { TrackSource } from "@shared/types/player"; import router from "@/router"; -/** 跳转到本地专辑页 */ -export const navigateToAlbum = (albumName?: string) => { - if (!albumName?.trim()) return; +/** + * 跳转到专辑页 + * - local:用 albumName 作为 id + * - streaming:必须传 albumId(服务器原生 ID),albumName 仅用作 fallback + */ +export const navigateToAlbum = ( + albumName?: string, + options: { source?: TrackSource; albumId?: string } = {}, +) => { + const source = options.source ?? "local"; + const id = source === "streaming" ? (options.albumId ?? albumName) : albumName; + if (!id?.trim()) return; router.push({ name: "collection", - params: { source: "local", type: "album", id: encodeURIComponent(albumName) }, + params: { source, type: "album", id: encodeURIComponent(id) }, }); }; /** 跳转到歌手页 */ -export const navigateToArtist = (artistName?: string, source: "local" | "online" = "local") => { - if (!artistName?.trim()) return; +export const navigateToArtist = ( + artistName?: string, + options: { source?: TrackSource; artistId?: string } = {}, +) => { + const source = options.source ?? "local"; + const id = source === "streaming" ? (options.artistId ?? artistName) : artistName; + if (!id?.trim()) return; router.push({ name: "artist", - params: { source, id: encodeURIComponent(artistName) }, + params: { source, id: encodeURIComponent(id) }, }); }; From e6cf3f6901341d202e5b02c5bc4a254ac0777a9c Mon Sep 17 00:00:00 2001 From: imsyy Date: Sat, 9 May 2026 15:55:58 +0800 Subject: [PATCH 03/14] =?UTF-8?q?fix:=20=E5=87=AD=E8=AF=81=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.d.ts | 1 + electron/main/ipc/streaming.ts | 103 +++- electron/preload/index.ts | 7 + shared/defaults/settings.ts | 3 + shared/types/player.ts | 21 + shared/types/settings.ts | 8 + shared/types/streaming.ts | 56 +- src/components/ui/SSelect.vue | 10 +- src/i18n/locales/en-US.json | 21 +- src/i18n/locales/zh-CN.json | 21 +- src/layouts/components/SideBar.vue | 12 +- src/pages/Artist.vue | 8 +- src/pages/Streaming/Albums.vue | 8 +- src/pages/Streaming/Artists.vue | 6 +- src/pages/Streaming/Index.vue | 110 ++-- src/pages/Streaming/Playlists.vue | 10 +- src/services/streaming/emby.ts | 22 +- src/services/streaming/errors.ts | 27 +- src/services/streaming/http.ts | 29 +- src/services/streaming/index.ts | 97 ++- src/services/streaming/jellyfin.ts | 153 ++++- src/services/streaming/subsonic.ts | 145 ++++- src/services/streaming/transform.ts | 141 +++-- src/settings/categories/streaming.ts | 18 +- src/settings/schema.ts | 4 +- src/stores/streaming.ts | 872 +++++++++++++++------------ 26 files changed, 1255 insertions(+), 658 deletions(-) diff --git a/components.d.ts b/components.d.ts index 9aca5c7c..df35b06c 100644 --- a/components.d.ts +++ b/components.d.ts @@ -67,6 +67,7 @@ declare module 'vue' { IconLucideChevronUp: typeof import('~icons/lucide/chevron-up')['default'] IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] IconLucideCircleX: typeof import('~icons/lucide/circle-x')['default'] + IconLucideCog: typeof import('~icons/lucide/cog')['default'] IconLucideDatabase: typeof import('~icons/lucide/database')['default'] IconLucideDisc3: typeof import('~icons/lucide/disc3')['default'] IconLucideEllipsis: typeof import('~icons/lucide/ellipsis')['default'] diff --git a/electron/main/ipc/streaming.ts b/electron/main/ipc/streaming.ts index e1d23db2..203792f2 100644 --- a/electron/main/ipc/streaming.ts +++ b/electron/main/ipc/streaming.ts @@ -1,10 +1,74 @@ /** - * 流媒体相关 IPC:仅提供"拉远端字节"能力给 SMTC 高清封面。 + * 流媒体相关 IPC: + * - fetchCoverBytes:把远端封面 URL 拉成字节给 SMTC 用 + * - loadServers / saveServers:服务器配置持久化(密码用 safeStorage 加密) * - * 服务器配置、网络调用、列表/搜索/歌词等全部在渲染层完成。 + * 服务器配置之外的网络调用(鉴权/列表/搜索/歌词)仍在渲染层完成。 */ -import { ipcMain } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { app, ipcMain, safeStorage } from "electron"; +import { writeFileSync as atomicWriteSync } from "atomically"; import { fetchBytes } from "@main/utils/fetchBytes"; +import type { StreamingServerConfig } from "@shared/types/streaming"; + +const STORAGE_FILE = path.join(app.getPath("userData"), "streaming.json"); + +/** 持久化形态:密码加密、accessToken/userId 不持久化(每次会话重新登录) */ +interface PersistedServer + extends Omit { + encryptedPassword: string; +} + +interface PersistedState { + servers: PersistedServer[]; + activeServerId: string | null; +} + +const readPersisted = (): PersistedState => { + try { + const raw = JSON.parse(fs.readFileSync(STORAGE_FILE, "utf-8")) as PersistedState; + if (!Array.isArray(raw?.servers)) return { servers: [], activeServerId: null }; + return { servers: raw.servers, activeServerId: raw.activeServerId ?? null }; + } catch { + return { servers: [], activeServerId: null }; + } +}; + +const writePersisted = (data: PersistedState): void => { + try { + const dir = path.dirname(STORAGE_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + atomicWriteSync(STORAGE_FILE, JSON.stringify(data, null, 2)); + } catch (err) { + console.error("[streaming] write streaming.json failed:", err); + } +}; + +/** + * safeStorage 加密:Windows 走 DPAPI、macOS 走 Keychain、Linux 走 libsecret/kwallet。 + * 不可用时退化为 base64(仅遮蔽,未真正加密;用户场景多见于无 secret service 的 Linux)。 + */ +const encryptPassword = (plain: string): string => { + if (!plain) return ""; + if (!safeStorage.isEncryptionAvailable()) { + return Buffer.from(plain, "utf-8").toString("base64"); + } + return safeStorage.encryptString(plain).toString("base64"); +}; + +const decryptPassword = (encrypted: string): string => { + if (!encrypted) return ""; + try { + const buf = Buffer.from(encrypted, "base64"); + if (!safeStorage.isEncryptionAvailable()) { + return buf.toString("utf-8"); + } + return safeStorage.decryptString(buf); + } catch { + return ""; + } +}; export const registerStreamingIpc = (): void => { ipcMain.handle("streaming:fetchCoverBytes", async (_e, url: string) => { @@ -14,4 +78,37 @@ export const registerStreamingIpc = (): void => { const buf = await fetchBytes(url); return { success: true, data: buf }; }); + + ipcMain.handle("streaming:loadServers", () => { + const persisted = readPersisted(); + const servers: StreamingServerConfig[] = persisted.servers.map((s) => ({ + id: s.id, + name: s.name, + type: s.type, + url: s.url, + username: s.username, + password: decryptPassword(s.encryptedPassword), + lastConnected: s.lastConnected, + })); + return { servers, activeServerId: persisted.activeServerId }; + }); + + ipcMain.handle( + "streaming:saveServers", + ( + _e, + payload: { servers: StreamingServerConfig[]; activeServerId: string | null }, + ): void => { + const servers: PersistedServer[] = (payload?.servers ?? []).map((s) => ({ + id: s.id, + name: s.name, + type: s.type, + url: s.url, + username: s.username, + encryptedPassword: encryptPassword(s.password), + lastConnected: s.lastConnected, + })); + writePersisted({ servers, activeServerId: payload?.activeServerId ?? null }); + }, + ); }; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index a34da1ef..6b15bcfc 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -307,6 +307,13 @@ const api = { streaming: { // 把远端封面 URL 拉成字节,给 SMTC 高清封面用 fetchCoverBytes: (url: string) => ipcRenderer.invoke("streaming:fetchCoverBytes", url), + // 加载服务器配置(密码已解密) + loadServers: () => ipcRenderer.invoke("streaming:loadServers"), + // 持久化服务器配置(密码经 safeStorage 加密) + saveServers: (payload: { + servers: unknown[]; + activeServerId: string | null; + }): Promise => ipcRenderer.invoke("streaming:saveServers", payload), }, hotkey: { getAll: () => ipcRenderer.invoke("hotkey:getAll"), diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index c7266a06..52de4116 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -88,6 +88,9 @@ export const defaultSystemConfig: SystemConfig = { enableOnlineTTMLLyric: false, amllDbServer: "https://amlldb.bikonoo.com/%p/%s.ttml", }, + streaming: { + enabled: true, + }, system: { rememberWindowState: true, taskbarProgress: true, diff --git a/shared/types/player.ts b/shared/types/player.ts index 41f12231..a5d88b80 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -18,12 +18,33 @@ export interface Artist { id?: string; name: string; avatar?: string; + /** 名下专辑数 */ + albumCount?: number; } /** 专辑 */ export interface Album { id?: string; name: string; + /** 封面 URL */ + cover?: string; + /** 专辑歌手字符串 */ + artist?: string; + /** 曲目数 */ + trackCount?: number; + /** 发行年份 */ + year?: number; +} + +/** 歌单 */ +export interface Playlist { + id?: string; + name: string; + cover?: string; + description?: string; + trackCount?: number; + /** 创建者 */ + owner?: string; } /** 音质信息 */ diff --git a/shared/types/settings.ts b/shared/types/settings.ts index ddd4c7df..8a670457 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -177,6 +177,12 @@ export interface LibrarySettings { scanDirs: string[]; } +/** 流媒体总开关 */ +export interface StreamingSettings { + /** 启用流媒体;关闭后侧边栏隐藏入口 */ + enabled: boolean; +} + /** 在线歌词服务配置 */ export interface OnlineLyricSettings { /** 启用在线 TTML 歌词 */ @@ -243,6 +249,8 @@ export interface SystemConfig { taskbarLyric: TaskbarLyricSettings; /** 在线歌词服务配置 */ lyric: OnlineLyricSettings; + /** 流媒体总开关 */ + streaming: StreamingSettings; /** 系统配置 */ system: { /** 记忆窗口状态 */ diff --git a/shared/types/streaming.ts b/shared/types/streaming.ts index eb69e0a1..2b6156f8 100644 --- a/shared/types/streaming.ts +++ b/shared/types/streaming.ts @@ -1,4 +1,4 @@ -import type { Track, IpcResponse } from "./player"; +import type { Album, Artist, IpcResponse, Track } from "./player"; /** 支持的流媒体服务器类型 */ export type StreamingServerType = "subsonic" | "navidrome" | "opensubsonic" | "jellyfin" | "emby"; @@ -16,7 +16,7 @@ export interface StreamingServerConfig { username: string; /** 明文密码 */ password: string; - /** Jellyfin/Emby 鉴权后回填,过期时由 store 重新登录 */ + /** Jellyfin/Emby 鉴权后回填 */ accessToken?: string; /** Jellyfin/Emby 鉴权后回填的用户 ID */ userId?: string; @@ -24,7 +24,7 @@ export interface StreamingServerConfig { lastConnected?: number; } -/** 添加/编辑表单提交时的 payload(id/token 由 store 生成、回填) */ +/** 添加/编辑表单提交时的 payload */ export interface StreamingServerInput { name: string; type: StreamingServerType; @@ -33,6 +33,9 @@ export interface StreamingServerInput { password: string; } +/** 错误归类 */ +export type StreamingErrorCode = "auth" | "network" | "protocol" | "unknown"; + /** 连通性测试结果 */ export interface StreamingPingResult { ok: boolean; @@ -40,6 +43,8 @@ export interface StreamingPingResult { version?: string; /** 失败描述 */ error?: string; + /** 失败归类(仅 ok=false 时有意义) */ + code?: StreamingErrorCode; } /** Jellyfin/Emby 登录返回 */ @@ -54,47 +59,28 @@ export interface StreamingListParams { limit?: number; } -/** 流媒体专辑(用于 CoverList) */ -export interface StreamingAlbum { - id: string; - name: string; - artist?: string; - cover?: string; - songCount?: number; - year?: number; -} - -/** 流媒体歌手(用于 CoverList) */ -export interface StreamingArtist { - id: string; - name: string; - avatar?: string; - albumCount?: number; -} - -/** 流媒体歌单(用于 CoverList) */ -export interface StreamingPlaylist { - id: string; - name: string; - cover?: string; - description?: string; - songCount?: number; - owner?: string; -} - /** 搜索结果聚合 */ export interface StreamingSearchResult { songs: Track[]; - albums: StreamingAlbum[]; - artists: StreamingArtist[]; + albums: Album[]; + artists: Artist[]; } /** * 主进程暴露给渲染层的 streaming IPC 接口 * - * 仅一个职责:把远端封面 URL 拉成字节给 SMTC 用(系统媒体集成需要 Buffer)。 - * 其它流媒体操作(鉴权/浏览/搜索/歌词)都在渲染层 src/services/streaming 完成。 + * - fetchCoverBytes:把远端封面 URL 拉成字节给 SMTC 用(系统媒体集成需要 Buffer) + * - loadServers / saveServers:服务器配置持久化,密码经 safeStorage 加密写入 + * `{userData}/streaming.json`;accessToken/userId 不持久化 */ export interface StreamingApi { fetchCoverBytes: (url: string) => Promise>; + loadServers: () => Promise<{ + servers: StreamingServerConfig[]; + activeServerId: string | null; + }>; + saveServers: (payload: { + servers: StreamingServerConfig[]; + activeServerId: string | null; + }) => Promise; } diff --git a/src/components/ui/SSelect.vue b/src/components/ui/SSelect.vue index d7ad682f..f1ba8c5c 100644 --- a/src/components/ui/SSelect.vue +++ b/src/components/ui/SSelect.vue @@ -13,12 +13,15 @@ export interface SSelectProps { options?: SSelectOption[]; disabled?: boolean; placeholder?: string; + /** 全圆角胶囊形 */ + round?: boolean; } const props = withDefaults(defineProps(), { options: () => [], disabled: false, placeholder: "", + round: false, }); const emit = defineEmits<{ @@ -42,13 +45,16 @@ const handleChange = (val: string) => { @update:model-value="handleChange" > {{ selectedLabel }} - + diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index f69b78d9..7360c0e3 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -79,8 +79,14 @@ "globalSettings": "Global Settings" }, "streaming": { - "hint": "Connect to your Subsonic / Navidrome / Jellyfin / Emby server", - "hintDetail": "Server credentials are stored locally in your browser only", + "hint": "Connect to a streaming platform", + "hintDetail": "Server credentials are encrypted by the OS and stored locally; nothing is uploaded", + "errorCode": { + "auth": "Authentication failed", + "network": "Network error", + "protocol": "Protocol error", + "unknown": "Unknown error" + }, "tabs": { "songs": "Songs", "albums": "Albums", @@ -128,7 +134,8 @@ }, "actions": { "refresh": "Refresh", - "search": "Search" + "search": "Search", + "settings": "Streaming settings" } }, "player": { @@ -231,7 +238,7 @@ "appearance": "Appearance", "hotkeys": "Hotkey Settings", "services": "Services", - "streaming": "Streaming", + "mediaSource": "Media Sources", "plugins": "Plugin Manager" }, "section": { @@ -259,7 +266,7 @@ "systemConfig": "System", "reset": "Reset", "pluginsList": "Installed Plugins", - "servers": "Servers" + "streaming": "Streaming" }, "language": { "label": "Language", @@ -790,6 +797,10 @@ "label": "Taskbar Progress", "description": "Show playback progress on the taskbar icon" }, + "streamingEnabled": { + "label": "Enable streaming", + "description": "When disabled, the streaming entry is hidden from the sidebar" + }, "closeAction": { "label": "Close Button Action", "description": "What to do when the title bar close button is clicked", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 446db6d4..04aefd14 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -79,8 +79,14 @@ "globalSettings": "全局设置" }, "streaming": { - "hint": "接入自建的 Subsonic / Navidrome / Jellyfin / Emby 服务器", - "hintDetail": "服务器配置(含密码)保存在本地浏览器,不上传任何远端服务", + "hint": "连接到流媒体平台", + "hintDetail": "服务器配置经系统加密后保存在本地,不上传任何远端服务", + "errorCode": { + "auth": "鉴权失败", + "network": "网络错误", + "protocol": "协议错误", + "unknown": "未知错误" + }, "tabs": { "songs": "歌曲", "albums": "专辑", @@ -128,7 +134,8 @@ }, "actions": { "refresh": "刷新", - "search": "搜索" + "search": "搜索", + "settings": "流媒体配置" } }, "player": { @@ -231,7 +238,7 @@ "appearance": "外观设置", "hotkeys": "快捷键配置", "services": "网络与服务", - "streaming": "流媒体", + "mediaSource": "媒体源配置", "plugins": "插件管理" }, "section": { @@ -259,7 +266,7 @@ "systemConfig": "系统配置", "reset": "重置", "pluginsList": "已安装插件", - "servers": "服务器管理" + "streaming": "流媒体管理" }, "language": { "label": "界面语言", @@ -790,6 +797,10 @@ "label": "任务栏播放进度", "description": "在任务栏图标上显示当前播放进度" }, + "streamingEnabled": { + "label": "启用流媒体", + "description": "关闭后将隐藏侧边栏的流媒体入口" + }, "closeAction": { "label": "关闭按钮行为", "description": "点击标题栏关闭按钮时执行的操作", diff --git a/src/layouts/components/SideBar.vue b/src/layouts/components/SideBar.vue index 24d1867f..21418524 100644 --- a/src/layouts/components/SideBar.vue +++ b/src/layouts/components/SideBar.vue @@ -15,7 +15,7 @@ import SButton from "@/components/ui/SButton.vue"; const { t } = useI18n(); const router = useRouter(); const route = useRoute(); -const { appearance } = useSettingsStore(); +const { appearance, system: systemSettings } = useSettingsStore(); const playlistStore = usePlaylistStore(); const handleCreate = async () => { @@ -32,9 +32,13 @@ const menuItems = computed(() => [ { key: "/artists/local", label: t("artist.label"), icon: markRaw(IconLucideUser) }, { key: "/albums/local", label: t("album.label"), icon: markRaw(IconLucideDisc3) }, { key: "/folders", label: t("folder.label"), icon: markRaw(IconLucideFolder) }, - // 流媒体 - { key: "divider-streaming", type: "divider" }, - { key: "/streaming", label: t("nav.streaming"), icon: markRaw(IconLucideServer) }, + // 流媒体(受 system.streaming.enabled 总开关控制) + ...(systemSettings.streaming.enabled + ? ([ + { key: "divider-streaming", type: "divider" }, + { key: "/streaming", label: t("nav.streaming"), icon: markRaw(IconLucideServer) }, + ] satisfies SMenuItem[]) + : []), // 歌单分组 { key: "divider-playlist", type: "divider" }, { diff --git a/src/pages/Artist.vue b/src/pages/Artist.vue index 1b01346f..e1fc9e38 100644 --- a/src/pages/Artist.vue +++ b/src/pages/Artist.vue @@ -67,7 +67,9 @@ const loadArtist = async () => { const albumList = await streamingStore.fetchArtistAlbums(artistId); // 一次性把所有专辑的曲目拉回来聚合 const trackLists = await Promise.all( - albumList.map((al) => streamingStore.fetchAlbumSongs(al.id).catch(() => [])), + albumList + .filter((al) => !!al.id) + .map((al) => streamingStore.fetchAlbumSongs(al.id!).catch(() => [])), ); const tracks = trackLists.flat(); artist.value = { @@ -77,11 +79,11 @@ const loadArtist = async () => { source, tracks, albums: albumList.map((al) => ({ - id: al.id, + id: al.id ?? "", title: al.name, cover: al.cover, subtitle: al.year ? String(al.year) : (al.artist ?? ""), - trackCount: al.songCount ?? 0, + trackCount: al.trackCount ?? 0, })), trackCount: tracks.length, albumCount: albumList.length, diff --git a/src/pages/Streaming/Albums.vue b/src/pages/Streaming/Albums.vue index 4a663cfb..fa1d0d16 100644 --- a/src/pages/Streaming/Albums.vue +++ b/src/pages/Streaming/Albums.vue @@ -1,6 +1,6 @@