Skip to content

Commit d265b18

Browse files
authored
Merge pull request #631 from lrst6963/dev
✨ feat: 添加lastfm支持
2 parents f902955 + c97b078 commit d265b18

11 files changed

Lines changed: 759 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
- ⏭️ 音乐渐入渐出
7676
- 🔄 支持 PWA
7777
- 💬 支持评论区
78+
- 🎵 支持 Last.fm Scrobble(播放记录上报)
7879
- ~~📱 移动端基础适配~~
7980

8081
## 🖼️ screenshots

components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ declare module 'vue' {
157157
SongUnlockManager: typeof import('./src/components/Modal/Setting/SongUnlockManager.vue')['default']
158158
SvgIcon: typeof import('./src/components/Global/SvgIcon.vue')['default']
159159
TextContainer: typeof import('./src/components/Global/TextContainer.vue')['default']
160+
ThirdSetting: typeof import('./src/components/Setting/ThirdSetting.vue')['default']
160161
UpdateApp: typeof import('./src/components/Modal/UpdateApp.vue')['default']
161162
UpdatePlaylist: typeof import('./src/components/Modal/UpdatePlaylist.vue')['default']
162163
User: typeof import('./src/components/Layout/User.vue')['default']

src/api/lastfm.ts

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import axios, { AxiosInstance } from "axios";
2+
import md5 from "md5";
3+
import { useSettingStore } from "@/stores";
4+
5+
/**
6+
* Last.fm API 封装
7+
* API 文档: https://www.last.fm/api
8+
*/
9+
10+
const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";
11+
12+
// Last.fm API 客户端
13+
const lastfmClient: AxiosInstance = axios.create({
14+
baseURL: LASTFM_API_URL,
15+
timeout: 15000,
16+
});
17+
18+
/**
19+
* 获取 API 配置
20+
*/
21+
const getApiConfig = () => {
22+
const settingStore = useSettingStore();
23+
return {
24+
apiKey: settingStore.lastfm.apiKey,
25+
apiSecret: settingStore.lastfm.apiSecret,
26+
};
27+
};
28+
29+
/**
30+
* 准备请求参数
31+
* @param method API 方法名
32+
* @param params 参数
33+
* @returns 请求参数
34+
*/
35+
const prepareRequestParams = (method: string, params: Record<string, string | number> = {}) => {
36+
const { apiKey } = getApiConfig();
37+
const requestParams: Record<string, string | number> = {
38+
method,
39+
api_key: apiKey,
40+
format: "json",
41+
...params,
42+
};
43+
return requestParams;
44+
};
45+
46+
/**
47+
* 生成 API 签名
48+
* @param params 参数对象
49+
*/
50+
const generateSignature = (params: Record<string, string | number>): string => {
51+
const { apiSecret } = getApiConfig();
52+
// 排除 format 参数,按字母顺序排序
53+
const sorted = Object.keys(params)
54+
.filter((key) => key !== "format")
55+
.sort()
56+
.map((key) => `${key}${params[key]}`)
57+
.join("");
58+
return md5(sorted + apiSecret);
59+
};
60+
61+
/**
62+
* Last.fm API 请求
63+
* @param method API 方法名
64+
* @param params 参数
65+
* @param needAuth 是否需要签名
66+
*/
67+
const lastfmRequest = async (
68+
method: string,
69+
params: Record<string, string | number> = {},
70+
needAuth: boolean = false,
71+
) => {
72+
const requestParams = prepareRequestParams(method, params);
73+
74+
if (needAuth) {
75+
requestParams.api_sig = generateSignature(requestParams);
76+
}
77+
78+
try {
79+
const response = await lastfmClient.get("", { params: requestParams });
80+
return response.data;
81+
} catch (error) {
82+
console.error("Last.fm API 错误:", error);
83+
throw error;
84+
}
85+
};
86+
87+
/**
88+
* Last.fm API POST 请求(用于需要签名的写操作)
89+
* @param method API 方法名
90+
* @param params 参数
91+
*/
92+
const lastfmPostRequest = async (method: string, params: Record<string, string | number> = {}) => {
93+
const requestParams = prepareRequestParams(method, params);
94+
95+
requestParams.api_sig = generateSignature(requestParams);
96+
97+
try {
98+
const formData = new URLSearchParams();
99+
Object.entries(requestParams).forEach(([key, value]) => {
100+
formData.append(key, String(value));
101+
});
102+
103+
const response = await lastfmClient.post("", formData, {
104+
headers: {
105+
"Content-Type": "application/x-www-form-urlencoded",
106+
},
107+
});
108+
return response.data;
109+
} catch (error) {
110+
console.error("Last.fm API POST 错误:", error);
111+
throw error;
112+
}
113+
};
114+
115+
/**
116+
* 获取认证令牌
117+
*/
118+
export const getAuthToken = async () => {
119+
return await lastfmRequest("auth.getToken", {}, true);
120+
};
121+
122+
/**
123+
* 获取认证 URL
124+
* @param token 认证令牌
125+
*/
126+
export const getAuthUrl = (token: string): string => {
127+
const { apiKey } = getApiConfig();
128+
return `https://www.last.fm/api/auth/?api_key=${apiKey}&token=${token}`;
129+
};
130+
131+
/**
132+
* 获取会话密钥
133+
* @param token 认证令牌
134+
*/
135+
export const getSession = async (token: string) => {
136+
return await lastfmRequest("auth.getSession", { token }, true);
137+
};
138+
139+
/**
140+
* 获取用户信息
141+
* @param user 用户名
142+
*/
143+
export const getUserInfo = async (user: string) => {
144+
return await lastfmRequest("user.getInfo", { user });
145+
};
146+
147+
/**
148+
* 获取用户最近播放的歌曲
149+
* @param user 用户名
150+
* @param limit 数量限制
151+
*/
152+
export const getUserRecentTracks = async (user: string, limit: number = 50) => {
153+
return await lastfmRequest("user.getRecentTracks", { user, limit });
154+
};
155+
156+
/**
157+
* 获取用户喜欢的艺术家
158+
* @param user 用户名
159+
* @param limit 数量限制
160+
*/
161+
export const getUserTopArtists = async (user: string, limit: number = 50) => {
162+
return await lastfmRequest("user.getTopArtists", { user, limit });
163+
};
164+
165+
/**
166+
* 获取用户喜欢的歌曲
167+
* @param user 用户名
168+
* @param limit 数量限制
169+
*/
170+
export const getUserTopTracks = async (user: string, limit: number = 50) => {
171+
return await lastfmRequest("user.getTopTracks", { user, limit });
172+
};
173+
174+
/**
175+
* 更新正在播放的歌曲
176+
* @param sessionKey 会话密钥
177+
* @param track 歌曲名
178+
* @param artist 艺术家名
179+
* @param album 专辑名
180+
* @param duration 时长(秒)
181+
*/
182+
export const updateNowPlaying = async (
183+
sessionKey: string,
184+
track: string,
185+
artist: string,
186+
album?: string,
187+
duration?: number,
188+
) => {
189+
const params: Record<string, string | number> = {
190+
sk: sessionKey,
191+
track,
192+
artist,
193+
};
194+
195+
if (album) params.album = album;
196+
if (duration) params.duration = duration;
197+
198+
return await lastfmPostRequest("track.updateNowPlaying", params);
199+
};
200+
201+
/**
202+
* Scrobble 歌曲(记录播放)
203+
* @param sessionKey 会话密钥
204+
* @param track 歌曲名
205+
* @param artist 艺术家名
206+
* @param timestamp 播放时间戳(秒)
207+
* @param album 专辑名
208+
* @param duration 时长(秒)
209+
*/
210+
export const scrobbleTrack = async (
211+
sessionKey: string,
212+
track: string,
213+
artist: string,
214+
timestamp: number,
215+
album?: string,
216+
duration?: number,
217+
) => {
218+
const params: Record<string, string | number> = {
219+
sk: sessionKey,
220+
track,
221+
artist,
222+
timestamp,
223+
};
224+
225+
if (album) params.album = album;
226+
if (duration) params.duration = duration;
227+
228+
return await lastfmPostRequest("track.scrobble", params);
229+
};
230+
231+
/**
232+
* 喜欢歌曲
233+
* @param sessionKey 会话密钥
234+
* @param track 歌曲名
235+
* @param artist 艺术家名
236+
*/
237+
export const loveTrack = async (sessionKey: string, track: string, artist: string) => {
238+
return await lastfmPostRequest("track.love", {
239+
sk: sessionKey,
240+
track,
241+
artist,
242+
});
243+
};
244+
245+
/**
246+
* 取消喜欢歌曲
247+
* @param sessionKey 会话密钥
248+
* @param track 歌曲名
249+
* @param artist 艺术家名
250+
*/
251+
export const unloveTrack = async (sessionKey: string, track: string, artist: string) => {
252+
return await lastfmPostRequest("track.unlove", {
253+
sk: sessionKey,
254+
track,
255+
artist,
256+
});
257+
};
258+
259+
/**
260+
* 获取歌曲信息
261+
* @param track 歌曲名
262+
* @param artist 艺术家名
263+
*/
264+
export const getTrackInfo = async (track: string, artist: string) => {
265+
return await lastfmRequest("track.getInfo", { track, artist });
266+
};
267+
268+
/**
269+
* 获取相似歌曲
270+
* @param track 歌曲名
271+
* @param artist 艺术家名
272+
* @param limit 数量限制
273+
*/
274+
export const getSimilarTracks = async (track: string, artist: string, limit: number = 30) => {
275+
return await lastfmRequest("track.getSimilar", { track, artist, limit });
276+
};
277+
278+
/**
279+
* 获取艺术家信息
280+
* @param artist 艺术家名
281+
*/
282+
export const getArtistInfo = async (artist: string) => {
283+
return await lastfmRequest("artist.getInfo", { artist });
284+
};
285+
286+
/**
287+
* 获取相似艺术家
288+
* @param artist 艺术家名
289+
* @param limit 数量限制
290+
*/
291+
export const getSimilarArtists = async (artist: string, limit: number = 30) => {
292+
return await lastfmRequest("artist.getSimilar", { artist, limit });
293+
};
294+
295+
/**
296+
* 获取艺术家热门歌曲
297+
* @param artist 艺术家名
298+
* @param limit 数量限制
299+
*/
300+
export const getArtistTopTracks = async (artist: string, limit: number = 50) => {
301+
return await lastfmRequest("artist.getTopTracks", { artist, limit });
302+
};
303+
304+
/**
305+
* 获取专辑信息
306+
* @param artist 艺术家名
307+
* @param album 专辑名
308+
*/
309+
export const getAlbumInfo = async (artist: string, album: string) => {
310+
return await lastfmRequest("album.getInfo", { artist, album });
311+
};

src/assets/icons/Extension.svg

Lines changed: 1 addition & 0 deletions
Loading

src/components/Setting/MainSetting.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
<KeyboardSetting v-else-if="activeKey === 'keyboard'" />
4242
<!-- 本地 -->
4343
<LocalSetting v-else-if="activeKey === 'local'" />
44+
<!-- 第三方 -->
45+
<ThirdSetting v-else-if="activeKey === 'third'" />
4446
<!-- 其他 -->
4547
<OtherSetting v-else-if="activeKey === 'other'" />
4648
<!-- 关于 -->
@@ -96,6 +98,11 @@ const menuOptions: MenuOption[] = [
9698
show: isElectron,
9799
icon: renderIcon("Storage"),
98100
},
101+
{
102+
key: "third",
103+
label: "第三方设置",
104+
icon: renderIcon("Extension"),
105+
},
99106
{
100107
key: "other",
101108
label: "其他设置",

src/components/Setting/OtherSetting.vue

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@
102102
</n-collapse-transition>
103103
</div>
104104
<div v-if="isElectron" class="set-list">
105-
<n-h3 prefix="bar">
106-
备份与恢复
105+
<n-h3 prefix="bar">
106+
备份与恢复
107107
<n-tag type="warning" size="small" round>Beta</n-tag>
108108
</n-h3>
109109
<n-card class="set-item">
@@ -192,7 +192,7 @@ const exportSettings = async () => {
192192
"setting-store": localStorage.getItem("setting-store"),
193193
"shortcut-store": localStorage.getItem("shortcut-store"),
194194
};
195-
195+
196196
const result = await window.api.store.export(rendererData);
197197
console.log("[Frontend] Export result:", result);
198198
if (result) {
@@ -222,14 +222,14 @@ const importSettings = async () => {
222222
try {
223223
const data = await window.api.store.import();
224224
console.log("[Frontend] Import data:", data);
225-
225+
226226
if (data) {
227227
// 恢复渲染进程数据
228228
if (data.renderer) {
229229
if (data.renderer["setting-store"]) localStorage.setItem("setting-store", data.renderer["setting-store"]);
230230
if (data.renderer["shortcut-store"]) localStorage.setItem("shortcut-store", data.renderer["shortcut-store"]);
231231
}
232-
232+
233233
window.$message.success("设置导入成功,即将重启");
234234
setTimeout(() => {
235235
window.location.reload();

0 commit comments

Comments
 (0)