diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 5d7201c..0612761 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -68,6 +68,7 @@ export class BotInstance extends EventEmitter { private channelUserCount = 0; private profileManager: BotProfileManager; private isFmMode = false; + private fmProvider: MusicProvider | null = null; constructor(options: BotInstanceOptions) { super(); @@ -319,7 +320,7 @@ export class BotInstance extends EventEmitter { case "album": return this.cmdAlbum(cmd); case "fm": - return this.cmdFm(); + return this.cmdFm(cmd); case "artist": return this.cmdArtist(cmd); case "vote": @@ -343,6 +344,11 @@ export class BotInstance extends EventEmitter { return platform === "qq" ? this.qqProvider : this.neteaseProvider; } + private disableFmMode(): void { + this.isFmMode = false; + this.fmProvider = null; + } + private getProvider(flags: Set): MusicProvider { if (flags.has("b")) return this.bilibiliProvider; if (flags.has("q")) return this.qqProvider; @@ -390,10 +396,8 @@ export class BotInstance extends EventEmitter { platform: song.platform, coverUrl: song.coverUrl, }); - // Update bot presence (fire-and-forget — never blocks playback) - this.profileManager.onSongChange(song).catch((err) => { - this.logger.warn({ err }, "Profile update failed after song change"); - }); + // Keep TeamSpeak-side profile updates on the same path for play/next/FM. + await this.syncProfileToSong(song); this.emit("stateChange"); return true; } catch (err) { @@ -402,6 +406,14 @@ export class BotInstance extends EventEmitter { } } + private async syncProfileToSong(song: QueuedSong | null): Promise { + try { + await this.profileManager.onSongChange(song); + } catch (err) { + this.logger.warn({ err }, "Profile update failed after song change"); + } + } + private async cmdPlay(cmd: ParsedCommand): Promise { if (!cmd.args) return "Usage: !play "; const provider = this.getProvider(cmd.flags); @@ -411,7 +423,7 @@ export class BotInstance extends EventEmitter { const song = result.songs[0]; this.queue.clear(); - this.isFmMode = false; + this.disableFmMode(); this.queue.add({ ...song, platform: provider.platform }); this.queue.play(); @@ -496,7 +508,7 @@ export class BotInstance extends EventEmitter { private cmdStop(): string { this.player.stop(); this.queue.clear(); - this.isFmMode = false; + this.disableFmMode(); this.profileManager.onSongChange(null).catch((err) => { this.logger.warn({ err }, "Profile restore failed on stop"); }); @@ -554,7 +566,7 @@ export class BotInstance extends EventEmitter { private cmdClear(): string { this.player.stop(); this.queue.clear(); - this.isFmMode = false; + this.disableFmMode(); this.profileManager.onSongChange(null).catch((err) => { this.logger.warn({ err }, "Profile restore failed on clear"); }); @@ -627,7 +639,7 @@ export class BotInstance extends EventEmitter { if (songs.length === 0) return "Playlist is empty or not found"; this.queue.clear(); - this.isFmMode = false; + this.disableFmMode(); for (const song of songs) { this.queue.add({ ...song, platform: provider.platform }); } @@ -662,7 +674,7 @@ export class BotInstance extends EventEmitter { if (songs.length === 0) return "Album is empty or not found"; this.queue.clear(); - this.isFmMode = false; + this.disableFmMode(); for (const song of songs) { this.queue.add({ ...song, platform: provider.platform }); } @@ -672,26 +684,32 @@ export class BotInstance extends EventEmitter { return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; } - private async cmdFm(): Promise { - if (!this.neteaseProvider.getPersonalFm) { - return "Personal FM is only available for NetEase Cloud Music"; + private async cmdFm(cmd: ParsedCommand): Promise { + return this.startFm(this.getProvider(cmd.flags)); + } + + async startFm(provider: MusicProvider = this.neteaseProvider): Promise { + if (!provider.getPersonalFm) { + return `Personal FM is not available for ${provider.platform}`; } - const songs = await this.neteaseProvider.getPersonalFm(); + const songs = await provider.getPersonalFm(); if (songs.length === 0) return "No FM songs available (need to login first)"; this.queue.clear(); for (const song of songs) { - this.queue.add({ ...song, platform: "netease" }); + this.queue.add({ ...song, platform: provider.platform }); } this.queue.setMode(PlayMode.Random); this.isFmMode = true; + this.fmProvider = provider; this.player.resetFailures(); const first = this.queue.play(); if (first) await this.resolveAndPlay(first); this.emit("stateChange"); - return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; + const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM"; + return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; } private async cmdArtist(cmd: ParsedCommand): Promise { @@ -712,7 +730,7 @@ export class BotInstance extends EventEmitter { } this.queue.clear(); - this.isFmMode = false; + this.disableFmMode(); for (const song of filtered) { this.queue.add({ ...song, platform: provider.platform }); } @@ -726,14 +744,15 @@ export class BotInstance extends EventEmitter { } private async refillFm(): Promise { - if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return; + const provider = this.fmProvider; + if (!this.isFmMode || !provider?.getPersonalFm) return; try { - const songs = await this.neteaseProvider.getPersonalFm(); + const songs = await provider.getPersonalFm(); if (songs.length === 0) return; for (const song of songs) { - this.queue.add({ ...song, platform: "netease" }); + this.queue.add({ ...song, platform: provider.platform }); } - this.logger.debug({ count: songs.length }, "FM queue refilled"); + this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled"); } catch (err) { this.logger.error({ err }, "Failed to refill FM queue"); } diff --git a/src/music/qq.test.ts b/src/music/qq.test.ts index 4f606cf..fb1c6ca 100644 --- a/src/music/qq.test.ts +++ b/src/music/qq.test.ts @@ -1,7 +1,31 @@ import { describe, it, expect } from "vitest"; -import { mapQqAlbums } from "./qq.js"; +import { mapQqAlbums, mapQqSongs } from "./qq.js"; describe("QQ adapter", () => { + it("mapQqSongs maps QQMusicApi-style song entries", () => { + const out = mapQqSongs([ + { + mid: "001abc", + name: "Radar Song", + singer: [{ name: "Singer A" }, { name: "Singer B" }], + album: { name: "Album A", mid: "alb001" }, + interval: 243, + }, + ]); + + expect(out).toEqual([ + { + id: "001abc", + name: "Radar Song", + artist: "Singer A / Singer B", + album: "Album A", + duration: 243, + coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg", + platform: "qq", + }, + ]); + }); + it("mapQqAlbums maps albumMID-style raw entries", () => { const raw = [ { diff --git a/src/music/qq.ts b/src/music/qq.ts index 30dddec..29ff01e 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -39,6 +39,24 @@ const qqFavApi = axios.create({ headers: { referer: "https://y.qq.com/" }, }); +export function mapQqSongs(raw: any[] | null | undefined): Song[] { + if (!Array.isArray(raw)) return []; + return raw.map((s) => { + const albumMid = s.album?.mid ?? s.album?.pmid ?? s.albummid ?? s.albumMid ?? ""; + return { + id: String(s.mid ?? s.songmid ?? s.songMID ?? s.id ?? s.songid ?? s.songId ?? ""), + name: s.title ?? s.name ?? s.songname ?? "", + artist: (s.singer ?? s.singers ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "), + album: s.album?.name ?? s.album?.title ?? s.albumname ?? "", + duration: s.interval ?? Math.round((s.duration ?? 0) / 1000), + coverUrl: albumMid + ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg` + : "", + platform: "qq" as const, + }; + }).filter((s) => s.id); +} + export function mapQqAlbums(raw: any[] | null | undefined): Album[] { if (!Array.isArray(raw)) return []; return raw.map((a) => { @@ -72,6 +90,7 @@ export class QQMusicProvider implements MusicProvider { private api: AxiosInstance; private cookie = ""; private quality = "exhigh"; + private radarPage = 1; constructor(baseUrl: string) { this.api = axios.create({ @@ -92,6 +111,30 @@ export class QQMusicProvider implements MusicProvider { return this.cookie ? { cookie: this.cookie } : {}; } + private get directCookieHeaders(): Record { + return this.cookie ? { Cookie: this.cookie } : {}; + } + + private buildMusicuPayload(module: string, method: string, param: Record): Record { + const uinMatch = /(?:^|; )(?:uin|qqmusic_uin)=o?0?(\d+)/.exec(this.cookie); + const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie); + return { + comm: { + ct: 24, + cv: 4747474, + platform: "yqq.json", + uin: uinMatch ? uinMatch[1] : "0", + g_tk: pSkeyMatch ? computeGtk(pSkeyMatch[1]) : 5381, + format: "json", + inCharset: "utf-8", + outCharset: "utf-8", + notice: 0, + need_new_code: 1, + }, + req_0: { module, method, param }, + }; + } + async search(query: string, limit = 20): Promise { // Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums + // playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61 @@ -141,17 +184,7 @@ export class QQMusicProvider implements MusicProvider { res.data?.req_0?.data?.body?.song?.list ?? []; if (songList.length === 0) return null; - const songs: Song[] = songList.map((s: any) => ({ - id: String(s.mid ?? s.id), - name: s.title ?? s.name ?? "", - artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), - album: s.album?.name ?? s.album?.title ?? "", - duration: s.interval ?? 0, - coverUrl: s.album?.mid - ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg` - : "", - platform: "qq", - })); + const songs = mapQqSongs(songList); const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? []; const albums = mapQqAlbums(albumList); @@ -203,17 +236,7 @@ export class QQMusicProvider implements MusicProvider { ? (songRes.value.data?.data?.song?.list ?? []) : []; - const songs: Song[] = songList.map((s: any) => ({ - id: String(s.songmid ?? s.songid ?? ""), - name: s.songname ?? s.name ?? "", - artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), - album: s.albumname ?? s.album?.name ?? "", - duration: s.interval ?? 0, - coverUrl: s.albummid - ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg` - : "", - platform: "qq", - })); + const songs = mapQqSongs(songList); const albumList: any[] = albumRes.status === "fulfilled" @@ -339,19 +362,7 @@ export class QQMusicProvider implements MusicProvider { }); const cdlist = res.data?.response?.cdlist ?? []; if (cdlist.length === 0) return []; - return (cdlist[0].songlist ?? []).map((s: any) => ({ - id: String(s.mid ?? s.songmid ?? s.songid), - name: s.songname ?? s.name ?? "", - artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), - album: s.albumname ?? "", - duration: s.interval ?? 0, - coverUrl: s.album?.mid - ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg` - : s.albummid - ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg` - : "", - platform: "qq", - })); + return mapQqSongs(cdlist[0].songlist ?? []); } async getPlaylistDetail(playlistId: string): Promise { @@ -386,17 +397,7 @@ export class QQMusicProvider implements MusicProvider { const res = await this.api.get("/getAlbumInfo", { params: { albummid: albumId, ...this.cookieParams }, }); - return (res.data?.response?.data?.list ?? []).map((s: any) => ({ - id: String(s.songmid ?? s.songid), - name: s.songname ?? "", - artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), - album: s.albumname ?? "", - duration: s.interval ?? 0, - coverUrl: s.albummid - ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg` - : "", - platform: "qq", - })); + return mapQqSongs(res.data?.response?.data?.list ?? []); } async getLyrics(songId: string): Promise { @@ -522,6 +523,65 @@ export class QQMusicProvider implements MusicProvider { } } + async getPersonalFm(): Promise { + const radarSongs = await this.getRadarRecommendSongs(); + if (radarSongs.length > 0) return radarSongs; + return this.getGuessRecommendSongs(); + } + + private async getRadarRecommendSongs(): Promise { + try { + const page = this.radarPage; + const res = await qqMusicuApi.post( + "/cgi-bin/musicu.fcg", + this.buildMusicuPayload( + "music.recommend.TrackRelationServer", + "GetRadarSong", + { + Page: page, + ReqType: 0, + FavSongs: [], + EntranceSongs: [], + } + ), + { headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } } + ); + const tracks = (res.data?.req_0?.data?.VecSongs ?? []) + .map((item: any) => item?.Track) + .filter(Boolean); + const songs = mapQqSongs(tracks); + if (songs.length > 0) { + this.radarPage = page + 1; + } + return songs; + } catch { + return []; + } + } + + private async getGuessRecommendSongs(): Promise { + try { + const res = await qqMusicuApi.post( + "/cgi-bin/musicu.fcg", + this.buildMusicuPayload( + "music.radioProxy.MbTrackRadioSvr", + "get_radio_track", + { + id: 99, + num: 5, + from: 0, + scene: 0, + song_ids: [], + } + ), + { headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } } + ); + return mapQqSongs(res.data?.req_0?.data?.Tracks ?? []); + } catch { + return []; + } + } + async getUserPlaylists(): Promise { if (!this.cookie) return []; const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie); diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 4f0930b..e4fa8e8 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -87,6 +87,22 @@ export function createPlayerRouter( router.post("/:botId/stop", simpleCommand("!stop")); router.post("/:botId/clear", simpleCommand("!clear")); + router.post("/:botId/fm", async (req, res) => { + try { + const bot = (req as any).bot; + const { platform } = req.body; + const provider = bot.getProviderFor( + platform === "bilibili" || platform === "qq" || platform === "youtube" + ? platform + : "netease" + ); + const message = await bot.startFm(provider); + res.json({ ok: !message.startsWith("No FM songs") && !message.includes("not available"), message }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + router.post("/:botId/volume", async (req, res) => { try { const bot = (req as any).bot; diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 083262c..096927c 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -397,6 +397,17 @@ export const usePlayerStore = defineStore('player', { if (bot) bot.playMode = mode; }, + async startFm(platform: Source = 'netease') { + if (!this.activeBotId) return; + const res = await axios.post(`/api/player/${this.activeBotId}/fm`, { platform }); + if (res.data?.message) { + this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); + } + this._setTiming(this.activeBotId, { serverElapsed: 0 }); + this._syncAfterAction(); + this.fetchQueue(); + }, + async fetchHomeData() { // Always check auth status first — if it changed since the cached // fetch (e.g., user logged in/out as a different account), the diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue index bb32d84..369805e 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -21,7 +21,7 @@

私人FM

-
+
@@ -31,6 +31,16 @@
+
+
+ +
+
+
QQ音乐雷达
+
猜你喜欢 / 雷达推荐歌曲流
+
+ +
@@ -126,8 +136,7 @@