diff --git a/.gitignore b/.gitignore index cf992102..4320b95d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ build .eslintcache .claude .agents +docs/superpowers skills-lock.json *.log* .vscode/settings.json diff --git a/components.d.ts b/components.d.ts index d4ffc62f..3d8f507f 100644 --- a/components.d.ts +++ b/components.d.ts @@ -67,7 +67,6 @@ declare module 'vue' { IconLucideArrowRight: typeof import('~icons/lucide/arrow-right')['default'] IconLucideArrowUp: typeof import('~icons/lucide/arrow-up')['default'] IconLucideArrowUpCircle: typeof import('~icons/lucide/arrow-up-circle')['default'] - IconLucideCalendarDays: typeof import('~icons/lucide/calendar-days')['default'] IconLucideCheck: typeof import('~icons/lucide/check')['default'] IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] IconLucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default'] @@ -81,9 +80,7 @@ declare module 'vue' { IconLucideExternalLink: typeof import('~icons/lucide/external-link')['default'] IconLucideFlame: typeof import('~icons/lucide/flame')['default'] IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default'] - IconLucideFolderPlus: typeof import('~icons/lucide/folder-plus')['default'] IconLucideGithub: typeof import('~icons/lucide/github')['default'] - IconLucideHardDrive: typeof import('~icons/lucide/hard-drive')['default'] IconLucideHeadphones: typeof import('~icons/lucide/headphones')['default'] IconLucideHeartOff: typeof import('~icons/lucide/heart-off')['default'] IconLucideHistory: typeof import('~icons/lucide/history')['default'] @@ -93,7 +90,6 @@ declare module 'vue' { IconLucideLink: typeof import('~icons/lucide/link')['default'] IconLucideListMusic: typeof import('~icons/lucide/list-music')['default'] IconLucideLocate: typeof import('~icons/lucide/locate')['default'] - IconLucideLock: typeof import('~icons/lucide/lock')['default'] IconLucideLogOut: typeof import('~icons/lucide/log-out')['default'] IconLucideMaximize: typeof import('~icons/lucide/maximize')['default'] IconLucideMic: typeof import('~icons/lucide/mic')['default'] @@ -106,7 +102,6 @@ declare module 'vue' { IconLucidePlus: typeof import('~icons/lucide/plus')['default'] IconLucidePower: typeof import('~icons/lucide/power')['default'] IconLucidePuzzle: typeof import('~icons/lucide/puzzle')['default'] - IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default'] IconLucideRepeat: typeof import('~icons/lucide/repeat')['default'] IconLucideRepeat1: typeof import('~icons/lucide/repeat1')['default'] IconLucideScanLine: typeof import('~icons/lucide/scan-line')['default'] @@ -119,8 +114,6 @@ declare module 'vue' { IconLucideSkipForward: typeof import('~icons/lucide/skip-forward')['default'] IconLucideTextQuote: typeof import('~icons/lucide/text-quote')['default'] IconLucideTrash2: typeof import('~icons/lucide/trash2')['default'] - IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default'] - IconLucideUnlock: typeof import('~icons/lucide/unlock')['default'] IconLucideUser: typeof import('~icons/lucide/user')['default'] IconLucideVolume1: typeof import('~icons/lucide/volume1')['default'] IconLucideVolume2: typeof import('~icons/lucide/volume2')['default'] @@ -136,6 +129,7 @@ declare module 'vue' { IconSpHeartMode: typeof import('~icons/sp/heart-mode')['default'] IconSpLossless: typeof import('~icons/sp/lossless')['default'] IconSpPlayOrder: typeof import('~icons/sp/play-order')['default'] + LastfmPanel: typeof import('./src/components/settings/custom/LastfmPanel.vue')['default'] LoginCookieDialog: typeof import('./src/components/modals/LoginCookieDialog.vue')['default'] LoginDialog: typeof import('./src/components/modals/LoginDialog.vue')['default'] LyricActions: typeof import('./src/components/player/FullPlayer/LyricActions.vue')['default'] diff --git a/electron/main/core/index.ts b/electron/main/core/index.ts index ce84e0f6..68f1b72c 100644 --- a/electron/main/core/index.ts +++ b/electron/main/core/index.ts @@ -3,6 +3,7 @@ import { electronApp, optimizer } from "@electron-toolkit/utils"; import { createMainWindow, restoreLyricWindows } from "@main/window"; import { registerIpcHandlers } from "@main/ipc"; import { init as initMedia, shutdown as shutdownMedia } from "@main/services/media"; +import { init as initLastfm } from "@main/services/lastfm"; import { initGlobalHotkey } from "@main/services/globalHotkey"; import { initDatabase, closeDatabase } from "@main/database"; import { init as initSongCache } from "@main/services/songCache"; @@ -66,6 +67,8 @@ export const initApp = (): void => { registerIpcHandlers(); // 初始化系统媒体控件 initMedia(); + // 初始化 Last.fm 集成 + initLastfm(); // 初始化插件系统(扫描并启动已启用的插件) pluginRegistry.init(); // 创建主窗口 diff --git a/electron/main/ipc/config.ts b/electron/main/ipc/config.ts index 78484655..a6e309dc 100644 --- a/electron/main/ipc/config.ts +++ b/electron/main/ipc/config.ts @@ -8,6 +8,7 @@ import { disable as disableMedia, reloadDiscordConfig, } from "@main/services/media"; +import { reloadConfig as reloadLastfmConfig } from "@main/services/lastfm"; import { setNormalizationEnabled, setEqualizerEnabled, @@ -37,6 +38,12 @@ const applyConfigChange = (keyPath: string, value: unknown): void => { case "media.discord.displayMode": reloadDiscordConfig(); break; + case "lastfm.enabled": + case "lastfm.scrobble": + case "lastfm.nowPlaying": + case "lastfm.loveSync": + reloadLastfmConfig(); + break; case "player.loudnessNormalization": setNormalizationEnabled(value as boolean); break; diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index a278cb5f..915fa5c4 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -10,6 +10,7 @@ import { registerLyricsIpc } from "./lyrics"; import { registerHotkeyIpc } from "./hotkey"; import { registerThemeIpc } from "./theme"; import { registerStreamingIpc } from "./streaming"; +import { registerLastfmIpc } from "./lastfm"; import { registerCacheIpc } from "./cache"; import { registerExternalApiIpc } from "./externalApi"; import { registerStatsIpc } from "./stats"; @@ -28,6 +29,7 @@ export const registerIpcHandlers = (): void => { registerHotkeyIpc(); registerThemeIpc(); registerStreamingIpc(); + registerLastfmIpc(); registerCacheIpc(); registerExternalApiIpc(); registerStatsIpc(); diff --git a/electron/main/ipc/lastfm.ts b/electron/main/ipc/lastfm.ts new file mode 100644 index 00000000..7b464184 --- /dev/null +++ b/electron/main/ipc/lastfm.ts @@ -0,0 +1,13 @@ +import { ipcMain } from "electron"; +import * as lastfm from "@main/services/lastfm"; + +/** 注册 Last.fm 相关 IPC */ +export const registerLastfmIpc = (): void => { + ipcMain.handle("lastfm:connect", () => lastfm.connect()); + ipcMain.handle("lastfm:cancelConnect", () => lastfm.cancelConnect()); + ipcMain.handle("lastfm:disconnect", () => lastfm.disconnect()); + ipcMain.handle("lastfm:getStatus", () => lastfm.getStatus()); + ipcMain.handle("lastfm:love", (_event, artist: string, track: string, loved: boolean) => + lastfm.love(artist, track, loved), + ); +}; diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index 859b03d4..28180f43 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -6,6 +6,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 * as lastfm from "@main/services/lastfm"; import { fetchBytes } from "@main/utils/fetchBytes"; import { getPlayer, resetPlayer, onPlayerCreated } from "@main/services/engine"; import { startDevicePolling, stopDevicePolling } from "@main/services/device"; @@ -58,6 +59,7 @@ const registerNativeEvents = (inst: InstanceType 0) setTaskbarProgress(posMs / durMs); break; } @@ -193,6 +197,18 @@ export const registerPlayerIpc = (): void => { // 本地封面 const localCover = isRemote ? null : (inst.getCoverRaw() ?? null); applyDisplay(displayTitle, displayArtist, displayAlbum, localCover ?? undefined, durationMs); + // Last.fm + const primaryArtist = + authoritative?.artists?.[0]?.name ?? + parseArtists(meta.artist ?? "")[0]?.name ?? + displayArtist; + lastfm.onTrackLoaded({ + title: displayTitle, + artist: primaryArtist, + album: displayAlbum, + durationMs, + autoPlay, + }); // 远端高清封面 if (coverUrl) { void fetchBytes(coverUrl).then((buf) => { diff --git a/electron/main/services/lastfm/client.ts b/electron/main/services/lastfm/client.ts new file mode 100644 index 00000000..b1cdfcdb --- /dev/null +++ b/electron/main/services/lastfm/client.ts @@ -0,0 +1,187 @@ +import { createHash } from "node:crypto"; + +/** + * Last.fm 底层签名 HTTP 客户端 + * API 文档: https://www.last.fm/api + */ + +const API_URL = "https://ws.audioscrobbler.com/2.0/"; + +// 应用级凭证 +const LASTFM_API_KEY = "79fa364d995b13c2b21bdd65b7e7054f"; +const LASTFM_API_SECRET = "6d2ecc338d0a0802669e529f14b8034f"; + +/** Last.fm JSON 响应中我们关心的字段 */ +interface LastfmResponse { + error?: number; + message?: string; + token?: string; + session?: { name: string; key: string }; +} + +/** + * 生成浏览器授权地址 + * @param token - auth.getToken 拿到的临时令牌 + * @returns 授权页 URL + */ +export const getAuthUrl = (token: string): string => + `https://www.last.fm/api/auth/?api_key=${LASTFM_API_KEY}&token=${token}`; + +/** + * 生成 api_sig:除 format 外按字母序拼 key+value,末尾接 secret 后取 md5 + * 注意:MD5 是 Last.fm 签名协议的强制要求(服务端同样以 md5 校验),并非用于 + * 加密/防篡改的安全用途,无法替换为更强算法,否则所有请求会被 Last.fm 拒绝。 + * @param params - 待签名参数(不含 api_sig / format) + * @returns 32 位十六进制签名 + */ +const sign = (params: Record): string => { + const base = Object.keys(params) + .filter((key) => key !== "format") + .sort() + .map((key) => `${key}${params[key]}`) + .join(""); + return createHash("md5") + .update(base + LASTFM_API_SECRET, "utf-8") + .digest("hex"); +}; + +/** + * 组装请求参数:补 method/api_key,按需签名,最后补 format + * @param method - API 方法名 + * @param params - 业务参数 + * @param signed - 是否需要签名 + * @returns URLSearchParams + */ +const buildParams = ( + method: string, + params: Record, + signed: boolean, +): URLSearchParams => { + const base: Record = { method, api_key: LASTFM_API_KEY, ...params }; + if (signed) base.api_sig = sign(base); + base.format = "json"; + return new URLSearchParams(base); +}; + +/** GET 请求(读操作) */ +const get = async ( + method: string, + params: Record = {}, + signed = false, +): Promise => { + const qs = buildParams(method, params, signed); + const res = await fetch(`${API_URL}?${qs.toString()}`); + const data = (await res.json()) as LastfmResponse; + if (data.error) throw new Error(`Last.fm ${data.error}: ${data.message ?? "未知错误"}`); + return data; +}; + +/** POST 请求(写操作,必签名) */ +const post = async ( + method: string, + params: Record = {}, +): Promise => { + const body = buildParams(method, params, true); + const res = await fetch(API_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + const data = (await res.json()) as LastfmResponse; + if (data.error) throw new Error(`Last.fm ${data.error}: ${data.message ?? "未知错误"}`); + return data; +}; + +/** 已授权会话 */ +export interface LastfmSession { + /** 用户名 */ + name: string; + /** 会话密钥(不过期) */ + key: string; +} + +/** + * 获取临时授权令牌 + * @returns token 字符串 + */ +export const getToken = async (): Promise => { + const data = await get("auth.getToken", {}, true); + if (!data.token) throw new Error("无法获取 Last.fm token"); + return data.token; +}; + +/** + * 用授权令牌换取会话(用户在浏览器授权后才会成功) + * @param token - getToken 拿到的令牌 + * @returns 会话信息 + */ +export const getSession = async (token: string): Promise => { + const data = await get("auth.getSession", { token }, true); + if (!data.session?.key) throw new Error("尚未授权"); + return { name: data.session.name, key: data.session.key }; +}; + +/** + * 上报「正在播放」 + * @param sessionKey - 会话密钥 + * @param track - 歌曲名 + * @param artist - 主艺人名 + * @param album - 专辑名 + * @param durationSec - 时长(秒) + */ +export const updateNowPlaying = async ( + sessionKey: string, + track: string, + artist: string, + album?: string, + durationSec?: number, +): Promise => { + const params: Record = { sk: sessionKey, track, artist }; + if (album) params.album = album; + if (durationSec) params.duration = String(durationSec); + await post("track.updateNowPlaying", params); +}; + +/** + * 记录播放(scrobble) + * @param sessionKey - 会话密钥 + * @param track - 歌曲名 + * @param artist - 主艺人名 + * @param timestamp - 开始播放的 Unix 时间戳(秒) + * @param album - 专辑名 + * @param durationSec - 时长(秒) + */ +export const scrobble = async ( + sessionKey: string, + track: string, + artist: string, + timestamp: number, + album?: string, + durationSec?: number, +): Promise => { + const params: Record = { + sk: sessionKey, + track, + artist, + timestamp: String(timestamp), + }; + if (album) params.album = album; + if (durationSec) params.duration = String(durationSec); + await post("track.scrobble", params); +}; + +/** + * 喜欢 / 取消喜欢 + * @param sessionKey - 会话密钥 + * @param track - 歌曲名 + * @param artist - 主艺人名 + * @param loved - true 为 Love,false 为 Unlove + */ +export const love = async ( + sessionKey: string, + track: string, + artist: string, + loved: boolean, +): Promise => { + await post(loved ? "track.love" : "track.unlove", { sk: sessionKey, track, artist }); +}; diff --git a/electron/main/services/lastfm/credentials.ts b/electron/main/services/lastfm/credentials.ts new file mode 100644 index 00000000..88217d38 --- /dev/null +++ b/electron/main/services/lastfm/credentials.ts @@ -0,0 +1,87 @@ +import fs from "node:fs"; +import path from "node:path"; +import { app, safeStorage } from "electron"; +import { writeFileSync as atomicWriteSync } from "atomically"; +import { lastfmLog } from "@main/utils/logger"; + +/** 凭证文件 */ +const STORAGE_FILE = path.join(app.getPath("userData"), "lastfm.json"); + +/** 解密后的凭证 */ +export interface LastfmCredentials { + username: string; + sessionKey: string; +} + +/** 持久化形态 */ +interface PersistedCredentials { + username: string; + encryptedSessionKey: string; +} + +/** 加密会话密钥 */ +const encrypt = (plain: string): string => { + if (!plain) return ""; + if (!safeStorage.isEncryptionAvailable()) { + lastfmLog.warn("safeStorage 不可用,sessionKey 将以 base64 明文落盘"); + return Buffer.from(plain, "utf-8").toString("base64"); + } + return safeStorage.encryptString(plain).toString("base64"); +}; + +/** 解密会话密钥 */ +const decrypt = (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 ""; + } +}; + +/** + * 读取本地凭证 + * @returns 凭证;不存在或损坏时返回 null + */ +export const load = (): LastfmCredentials | null => { + try { + const raw = JSON.parse(fs.readFileSync(STORAGE_FILE, "utf-8")) as PersistedCredentials; + const sessionKey = decrypt(raw.encryptedSessionKey); + if (!raw.username || !sessionKey) return null; + return { username: raw.username, sessionKey }; + } catch { + return null; + } +}; + +/** + * 保存凭证 + * @param username - 用户名 + * @param sessionKey - 会话密钥 + */ +export const save = (username: string, sessionKey: string): void => { + try { + const dir = path.dirname(STORAGE_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const data: PersistedCredentials = { + username, + encryptedSessionKey: encrypt(sessionKey), + }; + atomicWriteSync(STORAGE_FILE, JSON.stringify(data, null, 2)); + } catch (err) { + lastfmLog.error("写入 lastfm.json 失败:", err); + } +}; + +/** 清除凭证 */ +export const clear = (): void => { + try { + if (fs.existsSync(STORAGE_FILE)) fs.rmSync(STORAGE_FILE); + } catch (err) { + lastfmLog.error("删除 lastfm.json 失败:", err); + } +}; diff --git a/electron/main/services/lastfm/index.ts b/electron/main/services/lastfm/index.ts new file mode 100644 index 00000000..08c146d5 --- /dev/null +++ b/electron/main/services/lastfm/index.ts @@ -0,0 +1,157 @@ +import { shell } from "electron"; +import { store } from "@main/store"; +import { lastfmLog } from "@main/utils/logger"; +import * as client from "./client"; +import * as credentials from "./credentials"; +import * as scrobbler from "./scrobbler"; +import type { LastfmStatus, LastfmConnectResult } from "@shared/types/lastfm"; + +/** 当前会话(内存态) */ +let session: credentials.LastfmCredentials | null = null; +/** 是否有授权轮询在进行 */ +let connecting = false; +/** 取消授权标志 */ +let cancelFlag = false; + +/** 读取实时配置 */ +const cfg = () => store.get("lastfm"); + +/** 延时 */ +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** 启动初始化:载入本地凭证并接线 scrobbler 回调 */ +export const init = (): void => { + session = credentials.load(); + if (session) lastfmLog.info(`已载入 Last.fm 凭证: ${session.username}`); + scrobbler.setHandlers({ + onNowPlaying: (track) => { + const config = cfg(); + if (!config.enabled || !config.nowPlaying || !session) return; + client + .updateNowPlaying( + session.sessionKey, + track.title, + track.artist, + track.album || undefined, + track.durationSec || undefined, + ) + .catch((err) => lastfmLog.warn("updateNowPlaying 失败:", err)); + }, + onScrobble: (track) => { + const config = cfg(); + if (!config.enabled || !config.scrobble || !session) return; + client + .scrobble( + session.sessionKey, + track.title, + track.artist, + track.timestamp, + track.album || undefined, + track.durationSec || undefined, + ) + .then(() => lastfmLog.debug(`scrobble: ${track.artist} - ${track.title}`)) + .catch((err) => lastfmLog.warn("scrobble 失败:", err)); + }, + }); +}; + +/** 配置变更副作用:关闭总开关时复位状态机 */ +export const reloadConfig = (): void => { + if (!cfg().enabled) scrobbler.reset(); +}; + +/** 新曲目加载(来自 player:load) */ +export const onTrackLoaded = (meta: { + title: string; + artist: string; + album: string; + durationMs: number; + autoPlay: boolean; +}): void => { + if (cfg().enabled) scrobbler.onTrackLoaded(meta); +}; + +/** 播放/暂停变化 */ +export const onState = (playing: boolean): void => { + if (cfg().enabled) scrobbler.onState(playing); +}; + +/** 进度推进 */ +export const onPosition = (): void => { + if (cfg().enabled) scrobbler.onPosition(); +}; + +/** 播放结束 */ +export const onEnded = (): void => { + if (cfg().enabled) scrobbler.onEnded(); +}; + +/** 查询连接状态 */ +export const getStatus = (): LastfmStatus => ({ + connected: Boolean(session), + username: session?.username ?? "", +}); + +/** 取消正在进行的授权 */ +export const cancelConnect = (): void => { + cancelFlag = true; +}; + +/** + * 发起授权:getToken → 打开浏览器 → 轮询 getSession(上限约 120s) + * @returns 连接结果 + */ +export const connect = async (): Promise => { + if (connecting) return { connected: false, reason: "error" }; + connecting = true; + cancelFlag = false; + try { + const token = await client.getToken(); + await shell.openExternal(client.getAuthUrl(token)); + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + if (cancelFlag) return { connected: false, reason: "canceled" }; + await delay(3000); + if (cancelFlag) return { connected: false, reason: "canceled" }; + try { + const result = await client.getSession(token); + session = { username: result.name, sessionKey: result.key }; + credentials.save(result.name, result.key); + lastfmLog.info(`已连接 Last.fm: ${result.name}`); + return { connected: true, username: result.name }; + } catch (err) { + // error 14 = 用户尚未授权 + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes("Last.fm 14")) lastfmLog.warn("getSession 轮询出错:", err); + } + } + return { connected: false, reason: "timeout" }; + } catch (err) { + lastfmLog.error("连接失败:", err); + return { connected: false, reason: "error" }; + } finally { + connecting = false; + } +}; + +/** 断开并清除凭证 */ +export const disconnect = (): void => { + session = null; + credentials.clear(); + scrobbler.reset(); + lastfmLog.info("已断开 Last.fm 连接"); +}; + +/** + * 同步喜欢(来自渲染端 like 切换);未连接 / 未开启时静默 + * @param artist - 主艺人名 + * @param track - 歌曲名 + * @param loved - true 为 Love,false 为 Unlove + */ +export const love = (artist: string, track: string, loved: boolean): void => { + const config = cfg(); + if (!config.enabled || !config.loveSync || !session || !artist || !track) return; + client + .love(session.sessionKey, track, artist, loved) + .catch((err) => lastfmLog.warn("love 失败:", err)); +}; diff --git a/electron/main/services/lastfm/scrobbler.ts b/electron/main/services/lastfm/scrobbler.ts new file mode 100644 index 00000000..4e30a294 --- /dev/null +++ b/electron/main/services/lastfm/scrobbler.ts @@ -0,0 +1,144 @@ +/** scrobbler 对外通知的曲目数据 */ +export interface ScrobbleTrack { + /** 歌曲名 */ + title: string; + /** 主艺人名 */ + artist: string; + /** 专辑名(可能为空) */ + album: string; + /** 时长(秒) */ + durationSec: number; + /** 开始播放的 Unix 时间戳(秒),scrobble 用 */ + timestamp: number; +} + +/** index.ts 注入的回调:内部做配置/会话判定 */ +export interface ScrobblerHandlers { + /** 应上报「正在播放」时调用 */ + onNowPlaying: (track: ScrobbleTrack) => void; + /** 达到 scrobble 阈值时调用 */ + onScrobble: (track: ScrobbleTrack) => void; +} + +/** 时长低于该值(秒)不 scrobble */ +const SCROBBLE_MIN_DURATION_SEC = 30; +/** scrobble 最长等待(秒):min(时长/2, 240) */ +const SCROBBLE_MAX_WAIT_SEC = 240; + +let handlers: ScrobblerHandlers | null = null; +let current: ScrobbleTrack | null = null; +/** 暂停前已累计的播放毫秒 */ +let playedMs = 0; +/** 本段播放开始的墙钟时间戳(ms);未在播放时为 null */ +let playSince: number | null = null; +/** 当前曲目是否已 scrobble */ +let scrobbled = false; +/** 当前曲目是否已发过 now playing */ +let nowPlayingSent = false; + +/** 注入回调 */ +export const setHandlers = (next: ScrobblerHandlers): void => { + handlers = next; +}; + +/** 当前累计实际播放毫秒 */ +const elapsedMs = (): number => playedMs + (playSince != null ? Date.now() - playSince : 0); + +/** 达标则 scrobble 一次 */ +const maybeScrobble = (): void => { + if (!current || scrobbled) return; + if (current.durationSec <= SCROBBLE_MIN_DURATION_SEC) return; + const thresholdMs = Math.min(current.durationSec / 2, SCROBBLE_MAX_WAIT_SEC) * 1000; + if (elapsedMs() >= thresholdMs) { + scrobbled = true; + handlers?.onScrobble(current); + } +}; + +/** 首次实际播放时上报 now playing */ +const sendNowPlaying = (): void => { + if (!current || nowPlayingSent) return; + nowPlayingSent = true; + handlers?.onNowPlaying(current); +}; + +/** 结算当前曲目(切歌/结束前调用),达标则补 scrobble,并清空状态 */ +const flush = (): void => { + if (playSince != null) { + playedMs += Date.now() - playSince; + playSince = null; + } + maybeScrobble(); + current = null; + playedMs = 0; + scrobbled = false; + nowPlayingSent = false; +}; + +/** + * 新曲目加载 + * @param meta - 曲目元数据 + 是否自动播放 + */ +export const onTrackLoaded = (meta: { + title: string; + artist: string; + album: string; + durationMs: number; + autoPlay: boolean; +}): void => { + flush(); + if (meta.durationMs <= 0 || !meta.title) { + current = null; + return; + } + current = { + title: meta.title, + artist: meta.artist, + album: meta.album, + durationSec: Math.round(meta.durationMs / 1000), + timestamp: Math.floor(Date.now() / 1000), + }; + playedMs = 0; + scrobbled = false; + nowPlayingSent = false; + if (meta.autoPlay) { + playSince = Date.now(); + sendNowPlaying(); + } else { + playSince = null; + } +}; + +/** + * 播放/暂停状态变化 + * @param playing - 是否正在播放 + */ +export const onState = (playing: boolean): void => { + if (!current) return; + if (playing) { + if (playSince == null) playSince = Date.now(); + sendNowPlaying(); + } else if (playSince != null) { + playedMs += Date.now() - playSince; + playSince = null; + } +}; + +/** 播放进度推进(驱动阈值检查) */ +export const onPosition = (): void => { + maybeScrobble(); +}; + +/** 自然播放结束 */ +export const onEnded = (): void => { + flush(); +}; + +/** 复位(断开连接 / 关闭总开关时) */ +export const reset = (): void => { + current = null; + playedMs = 0; + playSince = null; + scrobbled = false; + nowPlayingSent = false; +}; diff --git a/electron/main/utils/logger.ts b/electron/main/utils/logger.ts index 8386334e..b0e8822f 100644 --- a/electron/main/utils/logger.ts +++ b/electron/main/utils/logger.ts @@ -87,3 +87,4 @@ export const streamingLog = log.scope("streaming"); export const songCacheLog = log.scope("songCache"); export const serverLog = log.scope("server"); export const pluginLog = log.scope("plugin"); +export const lastfmLog = log.scope("lastfm"); diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 55ba11e1..21689ed1 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -14,6 +14,7 @@ import { } from "@shared/types/window"; import { HotkeyApi } from "@shared/types/hotkey"; import { StreamingApi } from "@shared/types/streaming"; +import { LastfmApi } from "@shared/types/lastfm"; import { IpcResponse } from "@shared/types/player"; import { StatsApi } from "@shared/types/stats"; @@ -69,6 +70,7 @@ declare global { stats: StatsApi; hotkey: HotkeyApi; streaming: StreamingApi; + lastfm: LastfmApi; externalApi: { restart: () => Promise; getStatus: () => Promise; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 7305c822..350c7e0a 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -368,6 +368,19 @@ const api = { activeServerId: string | null; }): Promise => ipcRenderer.invoke("streaming:saveServers", payload), }, + lastfm: { + // 发起授权 + connect: () => ipcRenderer.invoke("lastfm:connect"), + // 取消授权轮询 + cancelConnect: () => ipcRenderer.invoke("lastfm:cancelConnect"), + // 断开并清除凭证 + disconnect: () => ipcRenderer.invoke("lastfm:disconnect"), + // 查询连接状态 + getStatus: () => ipcRenderer.invoke("lastfm:getStatus"), + // 同步喜欢 + love: (artist: string, track: string, loved: boolean) => + ipcRenderer.invoke("lastfm:love", artist, track, loved), + }, externalApi: { // 重启外部 API 服务 restart: () => ipcRenderer.invoke("externalApi:restart"), diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index 715049a6..efdb170b 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -102,6 +102,12 @@ export const defaultSystemConfig: SystemConfig = { streaming: { enabled: true, }, + lastfm: { + enabled: false, + scrobble: true, + nowPlaying: true, + loveSync: true, + }, externalApi: { enabled: false, wsEnabled: false, diff --git a/shared/types/lastfm.ts b/shared/types/lastfm.ts new file mode 100644 index 00000000..2c2f7468 --- /dev/null +++ b/shared/types/lastfm.ts @@ -0,0 +1,31 @@ +/** Last.fm 连接状态 */ +export interface LastfmStatus { + /** 是否已连接(存在 session key) */ + connected: boolean; + /** 已连接的用户名 */ + username: string; +} + +/** connect() 结果 */ +export interface LastfmConnectResult { + /** 是否连接成功 */ + connected: boolean; + /** 成功时的用户名 */ + username?: string; + /** 失败原因 */ + reason?: "timeout" | "canceled" | "error"; +} + +/** 渲染进程 Last.fm API(window.api.lastfm) */ +export interface LastfmApi { + /** 发起授权:打开浏览器并轮询会话,成功后持久化 */ + connect: () => Promise; + /** 取消正在进行的授权轮询 */ + cancelConnect: () => Promise; + /** 断开并清除本地凭证 */ + disconnect: () => Promise; + /** 查询当前连接状态 */ + getStatus: () => Promise; + /** 同步喜欢:loved=true 为 Love,false 为 Unlove */ + love: (artist: string, track: string, loved: boolean) => Promise; +} diff --git a/shared/types/settings.ts b/shared/types/settings.ts index 68dc96bf..f12b4ef5 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -70,6 +70,18 @@ export interface DiscordSettings { displayMode: DiscordDisplayMode; } +/** Last.fm 集成配置 */ +export interface LastfmSettings { + /** 总开关 */ + enabled: boolean; + /** 记录播放(scrobble) */ + scrobble: boolean; + /** 正在播放上报 */ + nowPlaying: boolean; + /** 喜欢同步 */ + loveSync: boolean; +} + /** 媒体集成配置 */ export interface MediaSettings { /** 是否启用系统媒体控件(SMTC / MPRIS / MPNowPlaying) */ @@ -305,6 +317,8 @@ export interface SystemConfig { cache: CacheSettings; /** 流媒体总开关 */ streaming: StreamingSettings; + /** Last.fm 集成配置 */ + lastfm: LastfmSettings; /** 外部 API 服务(HTTP + WS) */ externalApi: ExternalApiSettings; /** 系统配置 */ diff --git a/src/components/settings/custom/LastfmPanel.vue b/src/components/settings/custom/LastfmPanel.vue new file mode 100644 index 00000000..7394483e --- /dev/null +++ b/src/components/settings/custom/LastfmPanel.vue @@ -0,0 +1,119 @@ + + + diff --git a/src/composables/useFavorite.ts b/src/composables/useFavorite.ts index e2b44f02..6fe6aa36 100644 --- a/src/composables/useFavorite.ts +++ b/src/composables/useFavorite.ts @@ -1,6 +1,7 @@ import type { Track } from "@shared/types/player"; import { useLibraryStore } from "@/stores/library"; import { useUserStore } from "@/stores/user"; +import { useSettingsStore } from "@/stores/settings"; import { toast } from "@/composables/useToast"; import { useI18n } from "vue-i18n"; @@ -23,8 +24,21 @@ const recordFavoriteChange = (track: Track, liked: boolean): void => { export const useFavorite = () => { const library = useLibraryStore(); const user = useUserStore(); + const settings = useSettingsStore(); const { t } = useI18n(); + /** + * 同步喜欢到 Last.fm(未开启/未连接时由主进程静默兜底) + * @param track - 歌曲 + * @param loved - 是否已喜欢 + */ + const syncLastfmLove = (track: Track, loved: boolean): void => { + if (!settings.system.lastfm.enabled || !settings.system.lastfm.loveSync) return; + const artist = track.artists?.[0]?.name ?? ""; + if (!artist || !track.title) return; + void window.api.lastfm.love(artist, track.title, loved).catch(() => {}); + }; + /** * 当前 Track 是否已收藏 * @param track 歌曲 @@ -59,6 +73,7 @@ export const useFavorite = () => { if (track.source === "local") { const next = library.toggleLike(track.id); recordFavoriteChange(track, next); + syncLastfmLove(track, next); toast.success(t(next ? "liked.toast.added" : "liked.toast.removed")); return; } @@ -74,6 +89,7 @@ export const useFavorite = () => { return; } recordFavoriteChange(track, !wasLiked); + syncLastfmLove(track, !wasLiked); toast.success(t(wasLiked ? "liked.toast.removed" : "liked.toast.added")); return; } diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 58047804..1fdc2a8d 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -570,6 +570,7 @@ "hotkeys": "Hotkeys", "musicSpectrum": "Music Spectrum", "discord": "Discord RPC", + "lastfm": "Last.fm", "externalApi": "External API", "systemConfig": "System", "pluginsList": "Installed Plugins", @@ -1127,6 +1128,38 @@ "details": "Song Name", "state": "Artist Name" }, + "lastfmEnabled": { + "label": "Last.fm", + "description": "Connect your Last.fm account to scrobble and sync loves" + }, + "lastfmScrobble": { + "label": "Scrobbling", + "description": "Record a play to Last.fm after listening for a while" + }, + "lastfmNowPlaying": { + "label": "Now Playing", + "description": "Send the currently playing track to Last.fm" + }, + "lastfmLoveSync": { + "label": "Sync Loves", + "description": "Mirror your likes as Last.fm Love / Unlove" + }, + "lastfm": { + "connect": "Connect account", + "connecting": "Finish authorization in your browser…", + "cancel": "Cancel", + "disconnect": "Disconnect", + "connectedAs": "Connected: {name}", + "notConnected": "No Last.fm account connected", + "connectHint": "Clicking connect opens the authorization page in your browser", + "toast": { + "connected": "Connected to Last.fm: {name}", + "timeout": "Authorization timed out, please retry", + "canceled": "Authorization canceled", + "failed": "Connection failed, please retry", + "disconnected": "Disconnected from Last.fm" + } + }, "rememberWindowState": { "label": "Remember Window State", "description": "Restore window size, position and maximized state on next launch" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 80378024..1190c2a0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -558,6 +558,7 @@ "hotkeys": "快捷键", "musicSpectrum": "音乐频谱", "discord": "Discord RPC", + "lastfm": "Last.fm", "externalApi": "外部 API", "systemConfig": "系统配置", "pluginsList": "已安装插件", @@ -1115,6 +1116,38 @@ "details": "歌曲名", "state": "歌手名" }, + "lastfmEnabled": { + "label": "Last.fm", + "description": "连接 Last.fm 账号,记录播放并同步喜欢" + }, + "lastfmScrobble": { + "label": "记录播放(Scrobble)", + "description": "播放一段时间后自动记录到 Last.fm" + }, + "lastfmNowPlaying": { + "label": "正在播放", + "description": "向 Last.fm 同步当前正在播放的歌曲" + }, + "lastfmLoveSync": { + "label": "同步喜欢", + "description": "点击喜欢时同步为 Last.fm 的 Love / Unlove" + }, + "lastfm": { + "connect": "连接账号", + "connecting": "请在浏览器中完成授权…", + "cancel": "取消", + "disconnect": "断开连接", + "connectedAs": "已连接:{name}", + "notConnected": "尚未连接 Last.fm 账号", + "connectHint": "点击连接后将在浏览器中打开授权页面", + "toast": { + "connected": "已连接到 Last.fm:{name}", + "timeout": "授权超时,请重试", + "canceled": "已取消授权", + "failed": "连接失败,请重试", + "disconnected": "已断开 Last.fm 连接" + } + }, "rememberWindowState": { "label": "记忆窗口状态", "description": "下次启动时恢复窗口的大小、位置和最大化状态" diff --git a/src/settings/categories/services.ts b/src/settings/categories/services.ts index 3962d9aa..0c59b518 100644 --- a/src/settings/categories/services.ts +++ b/src/settings/categories/services.ts @@ -1,5 +1,6 @@ import type { SettingCategory } from "@/types/settings-schema"; import ExternalApiPanel from "@/components/settings/custom/ExternalApiPanel.vue"; +import LastfmPanel from "@/components/settings/custom/LastfmPanel.vue"; import IconLucideGlobe from "~icons/lucide/globe"; const servicesCategory: SettingCategory = { @@ -47,6 +48,44 @@ const servicesCategory: SettingCategory = { }, ], }, + { + id: "lastfm", + items: [ + { + key: "lastfmEnabled", + type: "switch", + binding: { store: "settings", path: "system.lastfm.enabled" }, + defaultValue: false, + children: [ + { + key: "lastfmAccount", + type: "custom", + component: LastfmPanel, + fullWidth: true, + keywords: ["settings.lastfm.connect", "settings.lastfm.disconnect"], + }, + { + key: "lastfmScrobble", + type: "switch", + binding: { store: "settings", path: "system.lastfm.scrobble" }, + defaultValue: true, + }, + { + key: "lastfmNowPlaying", + type: "switch", + binding: { store: "settings", path: "system.lastfm.nowPlaying" }, + defaultValue: true, + }, + { + key: "lastfmLoveSync", + type: "switch", + binding: { store: "settings", path: "system.lastfm.loveSync" }, + defaultValue: true, + }, + ], + }, + ], + }, { id: "externalApi", tag: { text: "Beta" },