Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ build
.eslintcache
.claude
.agents
docs/superpowers
skills-lock.json
*.log*
.vscode/settings.json
Expand Down
8 changes: 1 addition & 7 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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']
Expand All @@ -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']
Expand All @@ -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']
Expand All @@ -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']
Expand All @@ -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']
Expand Down
3 changes: 3 additions & 0 deletions electron/main/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -66,6 +67,8 @@ export const initApp = (): void => {
registerIpcHandlers();
// 初始化系统媒体控件
initMedia();
// 初始化 Last.fm 集成
initLastfm();
// 初始化插件系统(扫描并启动已启用的插件)
pluginRegistry.init();
// 创建主窗口
Expand Down
7 changes: 7 additions & 0 deletions electron/main/ipc/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
disable as disableMedia,
reloadDiscordConfig,
} from "@main/services/media";
import { reloadConfig as reloadLastfmConfig } from "@main/services/lastfm";
import {
setNormalizationEnabled,
setEqualizerEnabled,
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions electron/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,6 +29,7 @@ export const registerIpcHandlers = (): void => {
registerHotkeyIpc();
registerThemeIpc();
registerStreamingIpc();
registerLastfmIpc();
registerCacheIpc();
registerExternalApiIpc();
registerStatsIpc();
Expand Down
13 changes: 13 additions & 0 deletions electron/main/ipc/lastfm.ts
Original file line number Diff line number Diff line change
@@ -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),
);
};
16 changes: 16 additions & 0 deletions electron/main/ipc/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -58,6 +59,7 @@ const registerNativeEvents = (inst: InstanceType<AudioEngineModule["AudioPlayer"
setTaskbarProgress(-1);
}
nowPlaying.onPlayStateChange(state === "playing");
lastfm.onState(state === "playing");
const statusEvent = {
type: "status",
data: {
Expand All @@ -76,6 +78,7 @@ const registerNativeEvents = (inst: InstanceType<AudioEngineModule["AudioPlayer"
sendToMain("player:event", { type: "ended" });
wsBroadcast({ type: "ended" });
mediaService.setPlayState({ status: "Paused" });
lastfm.onEnded();
setTaskbarProgress(-1);
break;
}
Expand All @@ -97,6 +100,7 @@ const registerNativeEvents = (inst: InstanceType<AudioEngineModule["AudioPlayer"
wsBroadcast(positionEvent);
mediaService.setTimeline({ currentMs: posMs, totalMs: durMs });
nowPlaying.onPosition(posMs, true);
lastfm.onPosition();
if (store.get("system.taskbarProgress") && durMs > 0) setTaskbarProgress(posMs / durMs);
break;
}
Expand Down Expand Up @@ -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) => {
Expand Down
187 changes: 187 additions & 0 deletions electron/main/services/lastfm/client.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
imsyy marked this conversation as resolved.

/** 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, string>): 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")

Check failure

Code scanning / CodeQL

Use of a broken or weak cryptographic algorithm High

A broken or weak cryptographic algorithm
depends on
sensitive data from an access to LASTFM_API_SECRET
.
Comment thread
imsyy marked this conversation as resolved.
Dismissed
.digest("hex");
};

/**
* 组装请求参数:补 method/api_key,按需签名,最后补 format
* @param method - API 方法名
* @param params - 业务参数
* @param signed - 是否需要签名
* @returns URLSearchParams
*/
const buildParams = (
method: string,
params: Record<string, string>,
signed: boolean,
): URLSearchParams => {
const base: Record<string, string> = { 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<string, string> = {},
signed = false,
): Promise<LastfmResponse> => {
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<string, string> = {},
): Promise<LastfmResponse> => {
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<string> => {
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<LastfmSession> => {
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<void> => {
const params: Record<string, string> = { 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<void> => {
const params: Record<string, string> = {
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<void> => {
await post(loved ? "track.love" : "track.unlove", { sk: sessionKey, track, artist });
};
Loading
Loading