Skip to content

Commit 035c76a

Browse files
authored
Merge pull request #25 from SPlayer-Dev/feat/lastfm
feat: 支持连接 Last.fm(记录播放、正在播放、喜欢同步)
2 parents d668c95 + a1df90e commit 035c76a

22 files changed

Lines changed: 925 additions & 7 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ build
66
.eslintcache
77
.claude
88
.agents
9+
docs/superpowers
910
skills-lock.json
1011
*.log*
1112
.vscode/settings.json

‎components.d.ts‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ declare module 'vue' {
6767
IconLucideArrowRight: typeof import('~icons/lucide/arrow-right')['default']
6868
IconLucideArrowUp: typeof import('~icons/lucide/arrow-up')['default']
6969
IconLucideArrowUpCircle: typeof import('~icons/lucide/arrow-up-circle')['default']
70-
IconLucideCalendarDays: typeof import('~icons/lucide/calendar-days')['default']
7170
IconLucideCheck: typeof import('~icons/lucide/check')['default']
7271
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
7372
IconLucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default']
@@ -81,9 +80,7 @@ declare module 'vue' {
8180
IconLucideExternalLink: typeof import('~icons/lucide/external-link')['default']
8281
IconLucideFlame: typeof import('~icons/lucide/flame')['default']
8382
IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default']
84-
IconLucideFolderPlus: typeof import('~icons/lucide/folder-plus')['default']
8583
IconLucideGithub: typeof import('~icons/lucide/github')['default']
86-
IconLucideHardDrive: typeof import('~icons/lucide/hard-drive')['default']
8784
IconLucideHeadphones: typeof import('~icons/lucide/headphones')['default']
8885
IconLucideHeartOff: typeof import('~icons/lucide/heart-off')['default']
8986
IconLucideHistory: typeof import('~icons/lucide/history')['default']
@@ -93,7 +90,6 @@ declare module 'vue' {
9390
IconLucideLink: typeof import('~icons/lucide/link')['default']
9491
IconLucideListMusic: typeof import('~icons/lucide/list-music')['default']
9592
IconLucideLocate: typeof import('~icons/lucide/locate')['default']
96-
IconLucideLock: typeof import('~icons/lucide/lock')['default']
9793
IconLucideLogOut: typeof import('~icons/lucide/log-out')['default']
9894
IconLucideMaximize: typeof import('~icons/lucide/maximize')['default']
9995
IconLucideMic: typeof import('~icons/lucide/mic')['default']
@@ -106,7 +102,6 @@ declare module 'vue' {
106102
IconLucidePlus: typeof import('~icons/lucide/plus')['default']
107103
IconLucidePower: typeof import('~icons/lucide/power')['default']
108104
IconLucidePuzzle: typeof import('~icons/lucide/puzzle')['default']
109-
IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default']
110105
IconLucideRepeat: typeof import('~icons/lucide/repeat')['default']
111106
IconLucideRepeat1: typeof import('~icons/lucide/repeat1')['default']
112107
IconLucideScanLine: typeof import('~icons/lucide/scan-line')['default']
@@ -119,8 +114,6 @@ declare module 'vue' {
119114
IconLucideSkipForward: typeof import('~icons/lucide/skip-forward')['default']
120115
IconLucideTextQuote: typeof import('~icons/lucide/text-quote')['default']
121116
IconLucideTrash2: typeof import('~icons/lucide/trash2')['default']
122-
IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default']
123-
IconLucideUnlock: typeof import('~icons/lucide/unlock')['default']
124117
IconLucideUser: typeof import('~icons/lucide/user')['default']
125118
IconLucideVolume1: typeof import('~icons/lucide/volume1')['default']
126119
IconLucideVolume2: typeof import('~icons/lucide/volume2')['default']
@@ -136,6 +129,7 @@ declare module 'vue' {
136129
IconSpHeartMode: typeof import('~icons/sp/heart-mode')['default']
137130
IconSpLossless: typeof import('~icons/sp/lossless')['default']
138131
IconSpPlayOrder: typeof import('~icons/sp/play-order')['default']
132+
LastfmPanel: typeof import('./src/components/settings/custom/LastfmPanel.vue')['default']
139133
LoginCookieDialog: typeof import('./src/components/modals/LoginCookieDialog.vue')['default']
140134
LoginDialog: typeof import('./src/components/modals/LoginDialog.vue')['default']
141135
LyricActions: typeof import('./src/components/player/FullPlayer/LyricActions.vue')['default']

‎electron/main/core/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { electronApp, optimizer } from "@electron-toolkit/utils";
33
import { createMainWindow, restoreLyricWindows } from "@main/window";
44
import { registerIpcHandlers } from "@main/ipc";
55
import { init as initMedia, shutdown as shutdownMedia } from "@main/services/media";
6+
import { init as initLastfm } from "@main/services/lastfm";
67
import { initGlobalHotkey } from "@main/services/globalHotkey";
78
import { initDatabase, closeDatabase } from "@main/database";
89
import { init as initSongCache } from "@main/services/songCache";
@@ -66,6 +67,8 @@ export const initApp = (): void => {
6667
registerIpcHandlers();
6768
// 初始化系统媒体控件
6869
initMedia();
70+
// 初始化 Last.fm 集成
71+
initLastfm();
6972
// 初始化插件系统(扫描并启动已启用的插件)
7073
pluginRegistry.init();
7174
// 创建主窗口

‎electron/main/ipc/config.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
disable as disableMedia,
99
reloadDiscordConfig,
1010
} from "@main/services/media";
11+
import { reloadConfig as reloadLastfmConfig } from "@main/services/lastfm";
1112
import {
1213
setNormalizationEnabled,
1314
setEqualizerEnabled,
@@ -37,6 +38,12 @@ const applyConfigChange = (keyPath: string, value: unknown): void => {
3738
case "media.discord.displayMode":
3839
reloadDiscordConfig();
3940
break;
41+
case "lastfm.enabled":
42+
case "lastfm.scrobble":
43+
case "lastfm.nowPlaying":
44+
case "lastfm.loveSync":
45+
reloadLastfmConfig();
46+
break;
4047
case "player.loudnessNormalization":
4148
setNormalizationEnabled(value as boolean);
4249
break;

‎electron/main/ipc/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { registerLyricsIpc } from "./lyrics";
1010
import { registerHotkeyIpc } from "./hotkey";
1111
import { registerThemeIpc } from "./theme";
1212
import { registerStreamingIpc } from "./streaming";
13+
import { registerLastfmIpc } from "./lastfm";
1314
import { registerCacheIpc } from "./cache";
1415
import { registerExternalApiIpc } from "./externalApi";
1516
import { registerStatsIpc } from "./stats";
@@ -28,6 +29,7 @@ export const registerIpcHandlers = (): void => {
2829
registerHotkeyIpc();
2930
registerThemeIpc();
3031
registerStreamingIpc();
32+
registerLastfmIpc();
3133
registerCacheIpc();
3234
registerExternalApiIpc();
3335
registerStatsIpc();

‎electron/main/ipc/lastfm.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ipcMain } from "electron";
2+
import * as lastfm from "@main/services/lastfm";
3+
4+
/** 注册 Last.fm 相关 IPC */
5+
export const registerLastfmIpc = (): void => {
6+
ipcMain.handle("lastfm:connect", () => lastfm.connect());
7+
ipcMain.handle("lastfm:cancelConnect", () => lastfm.cancelConnect());
8+
ipcMain.handle("lastfm:disconnect", () => lastfm.disconnect());
9+
ipcMain.handle("lastfm:getStatus", () => lastfm.getStatus());
10+
ipcMain.handle("lastfm:love", (_event, artist: string, track: string, loved: boolean) =>
11+
lastfm.love(artist, track, loved),
12+
);
13+
};

‎electron/main/ipc/player.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { toCacheUrl } from "@main/utils/protocol";
66
import { toMs } from "@main/utils/time";
77
import * as mediaService from "@main/services/media";
88
import * as nowPlaying from "@main/services/nowPlaying";
9+
import * as lastfm from "@main/services/lastfm";
910
import { fetchBytes } from "@main/utils/fetchBytes";
1011
import { getPlayer, resetPlayer, onPlayerCreated } from "@main/services/engine";
1112
import { startDevicePolling, stopDevicePolling } from "@main/services/device";
@@ -58,6 +59,7 @@ const registerNativeEvents = (inst: InstanceType<AudioEngineModule["AudioPlayer"
5859
setTaskbarProgress(-1);
5960
}
6061
nowPlaying.onPlayStateChange(state === "playing");
62+
lastfm.onState(state === "playing");
6163
const statusEvent = {
6264
type: "status",
6365
data: {
@@ -76,6 +78,7 @@ const registerNativeEvents = (inst: InstanceType<AudioEngineModule["AudioPlayer"
7678
sendToMain("player:event", { type: "ended" });
7779
wsBroadcast({ type: "ended" });
7880
mediaService.setPlayState({ status: "Paused" });
81+
lastfm.onEnded();
7982
setTaskbarProgress(-1);
8083
break;
8184
}
@@ -97,6 +100,7 @@ const registerNativeEvents = (inst: InstanceType<AudioEngineModule["AudioPlayer"
97100
wsBroadcast(positionEvent);
98101
mediaService.setTimeline({ currentMs: posMs, totalMs: durMs });
99102
nowPlaying.onPosition(posMs, true);
103+
lastfm.onPosition();
100104
if (store.get("system.taskbarProgress") && durMs > 0) setTaskbarProgress(posMs / durMs);
101105
break;
102106
}
@@ -193,6 +197,18 @@ export const registerPlayerIpc = (): void => {
193197
// 本地封面
194198
const localCover = isRemote ? null : (inst.getCoverRaw() ?? null);
195199
applyDisplay(displayTitle, displayArtist, displayAlbum, localCover ?? undefined, durationMs);
200+
// Last.fm
201+
const primaryArtist =
202+
authoritative?.artists?.[0]?.name ??
203+
parseArtists(meta.artist ?? "")[0]?.name ??
204+
displayArtist;
205+
lastfm.onTrackLoaded({
206+
title: displayTitle,
207+
artist: primaryArtist,
208+
album: displayAlbum,
209+
durationMs,
210+
autoPlay,
211+
});
196212
// 远端高清封面
197213
if (coverUrl) {
198214
void fetchBytes(coverUrl).then((buf) => {
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { createHash } from "node:crypto";
2+
3+
/**
4+
* Last.fm 底层签名 HTTP 客户端
5+
* API 文档: https://www.last.fm/api
6+
*/
7+
8+
const API_URL = "https://ws.audioscrobbler.com/2.0/";
9+
10+
// 应用级凭证
11+
const LASTFM_API_KEY = "79fa364d995b13c2b21bdd65b7e7054f";
12+
const LASTFM_API_SECRET = "6d2ecc338d0a0802669e529f14b8034f";
13+
14+
/** Last.fm JSON 响应中我们关心的字段 */
15+
interface LastfmResponse {
16+
error?: number;
17+
message?: string;
18+
token?: string;
19+
session?: { name: string; key: string };
20+
}
21+
22+
/**
23+
* 生成浏览器授权地址
24+
* @param token - auth.getToken 拿到的临时令牌
25+
* @returns 授权页 URL
26+
*/
27+
export const getAuthUrl = (token: string): string =>
28+
`https://www.last.fm/api/auth/?api_key=${LASTFM_API_KEY}&token=${token}`;
29+
30+
/**
31+
* 生成 api_sig:除 format 外按字母序拼 key+value,末尾接 secret 后取 md5
32+
* 注意:MD5 是 Last.fm 签名协议的强制要求(服务端同样以 md5 校验),并非用于
33+
* 加密/防篡改的安全用途,无法替换为更强算法,否则所有请求会被 Last.fm 拒绝。
34+
* @param params - 待签名参数(不含 api_sig / format)
35+
* @returns 32 位十六进制签名
36+
*/
37+
const sign = (params: Record<string, string>): string => {
38+
const base = Object.keys(params)
39+
.filter((key) => key !== "format")
40+
.sort()
41+
.map((key) => `${key}${params[key]}`)
42+
.join("");
43+
return createHash("md5")
44+
.update(base + LASTFM_API_SECRET, "utf-8")
45+
.digest("hex");
46+
};
47+
48+
/**
49+
* 组装请求参数:补 method/api_key,按需签名,最后补 format
50+
* @param method - API 方法名
51+
* @param params - 业务参数
52+
* @param signed - 是否需要签名
53+
* @returns URLSearchParams
54+
*/
55+
const buildParams = (
56+
method: string,
57+
params: Record<string, string>,
58+
signed: boolean,
59+
): URLSearchParams => {
60+
const base: Record<string, string> = { method, api_key: LASTFM_API_KEY, ...params };
61+
if (signed) base.api_sig = sign(base);
62+
base.format = "json";
63+
return new URLSearchParams(base);
64+
};
65+
66+
/** GET 请求(读操作) */
67+
const get = async (
68+
method: string,
69+
params: Record<string, string> = {},
70+
signed = false,
71+
): Promise<LastfmResponse> => {
72+
const qs = buildParams(method, params, signed);
73+
const res = await fetch(`${API_URL}?${qs.toString()}`);
74+
const data = (await res.json()) as LastfmResponse;
75+
if (data.error) throw new Error(`Last.fm ${data.error}: ${data.message ?? "未知错误"}`);
76+
return data;
77+
};
78+
79+
/** POST 请求(写操作,必签名) */
80+
const post = async (
81+
method: string,
82+
params: Record<string, string> = {},
83+
): Promise<LastfmResponse> => {
84+
const body = buildParams(method, params, true);
85+
const res = await fetch(API_URL, {
86+
method: "POST",
87+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
88+
body: body.toString(),
89+
});
90+
const data = (await res.json()) as LastfmResponse;
91+
if (data.error) throw new Error(`Last.fm ${data.error}: ${data.message ?? "未知错误"}`);
92+
return data;
93+
};
94+
95+
/** 已授权会话 */
96+
export interface LastfmSession {
97+
/** 用户名 */
98+
name: string;
99+
/** 会话密钥(不过期) */
100+
key: string;
101+
}
102+
103+
/**
104+
* 获取临时授权令牌
105+
* @returns token 字符串
106+
*/
107+
export const getToken = async (): Promise<string> => {
108+
const data = await get("auth.getToken", {}, true);
109+
if (!data.token) throw new Error("无法获取 Last.fm token");
110+
return data.token;
111+
};
112+
113+
/**
114+
* 用授权令牌换取会话(用户在浏览器授权后才会成功)
115+
* @param token - getToken 拿到的令牌
116+
* @returns 会话信息
117+
*/
118+
export const getSession = async (token: string): Promise<LastfmSession> => {
119+
const data = await get("auth.getSession", { token }, true);
120+
if (!data.session?.key) throw new Error("尚未授权");
121+
return { name: data.session.name, key: data.session.key };
122+
};
123+
124+
/**
125+
* 上报「正在播放」
126+
* @param sessionKey - 会话密钥
127+
* @param track - 歌曲名
128+
* @param artist - 主艺人名
129+
* @param album - 专辑名
130+
* @param durationSec - 时长(秒)
131+
*/
132+
export const updateNowPlaying = async (
133+
sessionKey: string,
134+
track: string,
135+
artist: string,
136+
album?: string,
137+
durationSec?: number,
138+
): Promise<void> => {
139+
const params: Record<string, string> = { sk: sessionKey, track, artist };
140+
if (album) params.album = album;
141+
if (durationSec) params.duration = String(durationSec);
142+
await post("track.updateNowPlaying", params);
143+
};
144+
145+
/**
146+
* 记录播放(scrobble)
147+
* @param sessionKey - 会话密钥
148+
* @param track - 歌曲名
149+
* @param artist - 主艺人名
150+
* @param timestamp - 开始播放的 Unix 时间戳(秒)
151+
* @param album - 专辑名
152+
* @param durationSec - 时长(秒)
153+
*/
154+
export const scrobble = async (
155+
sessionKey: string,
156+
track: string,
157+
artist: string,
158+
timestamp: number,
159+
album?: string,
160+
durationSec?: number,
161+
): Promise<void> => {
162+
const params: Record<string, string> = {
163+
sk: sessionKey,
164+
track,
165+
artist,
166+
timestamp: String(timestamp),
167+
};
168+
if (album) params.album = album;
169+
if (durationSec) params.duration = String(durationSec);
170+
await post("track.scrobble", params);
171+
};
172+
173+
/**
174+
* 喜欢 / 取消喜欢
175+
* @param sessionKey - 会话密钥
176+
* @param track - 歌曲名
177+
* @param artist - 主艺人名
178+
* @param loved - true 为 Love,false 为 Unlove
179+
*/
180+
export const love = async (
181+
sessionKey: string,
182+
track: string,
183+
artist: string,
184+
loved: boolean,
185+
): Promise<void> => {
186+
await post(loved ? "track.love" : "track.unlove", { sk: sessionKey, track, artist });
187+
};

0 commit comments

Comments
 (0)