feat(qq): add radar FM stream

This commit is contained in:
lTinchl committed 2026-06-06 21:09:57 +08:00
1 parent a861b41809
commit e0d17cf404
6 files changed
+217 -84

No files matched your search

+40 -21
View File
@@ -68,6 +68,7 @@ export class BotInstance extends EventEmitter {
private channelUserCount = 0; private channelUserCount = 0;
private profileManager: BotProfileManager; private profileManager: BotProfileManager;
private isFmMode = false; private isFmMode = false;
private fmProvider: MusicProvider | null = null;
constructor(options: BotInstanceOptions) { constructor(options: BotInstanceOptions) {
super(); super();
@@ -319,7 +320,7 @@ export class BotInstance extends EventEmitter {
case "album": case "album":
return this.cmdAlbum(cmd); return this.cmdAlbum(cmd);
case "fm": case "fm":
return this.cmdFm(); return this.cmdFm(cmd);
case "artist": case "artist":
return this.cmdArtist(cmd); return this.cmdArtist(cmd);
case "vote": case "vote":
@@ -343,6 +344,11 @@ export class BotInstance extends EventEmitter {
return platform === "qq" ? this.qqProvider : this.neteaseProvider; return platform === "qq" ? this.qqProvider : this.neteaseProvider;
} }
private disableFmMode(): void {
this.isFmMode = false;
this.fmProvider = null;
}
private getProvider(flags: Set<string>): MusicProvider { private getProvider(flags: Set<string>): MusicProvider {
if (flags.has("b")) return this.bilibiliProvider; if (flags.has("b")) return this.bilibiliProvider;
if (flags.has("q")) return this.qqProvider; if (flags.has("q")) return this.qqProvider;
@@ -390,10 +396,8 @@ export class BotInstance extends EventEmitter {
platform: song.platform, platform: song.platform,
coverUrl: song.coverUrl, coverUrl: song.coverUrl,
}); });
// Update bot presence (fire-and-forget — never blocks playback) // Keep TeamSpeak-side profile updates on the same path for play/next/FM.
this.profileManager.onSongChange(song).catch((err) => { await this.syncProfileToSong(song);
this.logger.warn({ err }, "Profile update failed after song change");
});
this.emit("stateChange"); this.emit("stateChange");
return true; return true;
} catch (err) { } catch (err) {
@@ -402,6 +406,14 @@ export class BotInstance extends EventEmitter {
} }
} }
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
try {
await this.profileManager.onSongChange(song);
} catch (err) {
this.logger.warn({ err }, "Profile update failed after song change");
}
}
private async cmdPlay(cmd: ParsedCommand): Promise<string> { private async cmdPlay(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !play <song name or URL>"; if (!cmd.args) return "Usage: !play <song name or URL>";
const provider = this.getProvider(cmd.flags); const provider = this.getProvider(cmd.flags);
@@ -411,7 +423,7 @@ export class BotInstance extends EventEmitter {
const song = result.songs[0]; const song = result.songs[0];
this.queue.clear(); this.queue.clear();
this.isFmMode = false; this.disableFmMode();
this.queue.add({ ...song, platform: provider.platform }); this.queue.add({ ...song, platform: provider.platform });
this.queue.play(); this.queue.play();
@@ -496,7 +508,7 @@ export class BotInstance extends EventEmitter {
private cmdStop(): string { private cmdStop(): string {
this.player.stop(); this.player.stop();
this.queue.clear(); this.queue.clear();
this.isFmMode = false; this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => { this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on stop"); this.logger.warn({ err }, "Profile restore failed on stop");
}); });
@@ -554,7 +566,7 @@ export class BotInstance extends EventEmitter {
private cmdClear(): string { private cmdClear(): string {
this.player.stop(); this.player.stop();
this.queue.clear(); this.queue.clear();
this.isFmMode = false; this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => { this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on clear"); 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"; if (songs.length === 0) return "Playlist is empty or not found";
this.queue.clear(); this.queue.clear();
this.isFmMode = false; this.disableFmMode();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); 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"; if (songs.length === 0) return "Album is empty or not found";
this.queue.clear(); this.queue.clear();
this.isFmMode = false; this.disableFmMode();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); 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"}`; return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
} }
private async cmdFm(): Promise<string> { private async cmdFm(cmd: ParsedCommand): Promise<string> {
if (!this.neteaseProvider.getPersonalFm) { return this.startFm(this.getProvider(cmd.flags));
return "Personal FM is only available for NetEase Cloud Music";
} }
const songs = await this.neteaseProvider.getPersonalFm();
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> {
if (!provider.getPersonalFm) {
return `Personal FM is not available for ${provider.platform}`;
}
const songs = await provider.getPersonalFm();
if (songs.length === 0) if (songs.length === 0)
return "No FM songs available (need to login first)"; return "No FM songs available (need to login first)";
this.queue.clear(); this.queue.clear();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: "netease" }); this.queue.add({ ...song, platform: provider.platform });
} }
this.queue.setMode(PlayMode.Random); this.queue.setMode(PlayMode.Random);
this.isFmMode = true; this.isFmMode = true;
this.fmProvider = provider;
this.player.resetFailures(); this.player.resetFailures();
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
this.emit("stateChange"); 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<string> { private async cmdArtist(cmd: ParsedCommand): Promise<string> {
@@ -712,7 +730,7 @@ export class BotInstance extends EventEmitter {
} }
this.queue.clear(); this.queue.clear();
this.isFmMode = false; this.disableFmMode();
for (const song of filtered) { for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add({ ...song, platform: provider.platform });
} }
@@ -726,14 +744,15 @@ export class BotInstance extends EventEmitter {
} }
private async refillFm(): Promise<void> { private async refillFm(): Promise<void> {
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return; const provider = this.fmProvider;
if (!this.isFmMode || !provider?.getPersonalFm) return;
try { try {
const songs = await this.neteaseProvider.getPersonalFm(); const songs = await provider.getPersonalFm();
if (songs.length === 0) return; if (songs.length === 0) return;
for (const song of songs) { 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) { } catch (err) {
this.logger.error({ err }, "Failed to refill FM queue"); this.logger.error({ err }, "Failed to refill FM queue");
} }
+25 -1
View File
@@ -1,7 +1,31 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { mapQqAlbums } from "./qq.js"; import { mapQqAlbums, mapQqSongs } from "./qq.js";
describe("QQ adapter", () => { 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", () => { it("mapQqAlbums maps albumMID-style raw entries", () => {
const raw = [ const raw = [
{ {
+106 -46
View File
@@ -39,6 +39,24 @@ const qqFavApi = axios.create({
headers: { referer: "https://y.qq.com/" }, 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[] { export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
return raw.map((a) => { return raw.map((a) => {
@@ -72,6 +90,7 @@ export class QQMusicProvider implements MusicProvider {
private api: AxiosInstance; private api: AxiosInstance;
private cookie = ""; private cookie = "";
private quality = "exhigh"; private quality = "exhigh";
private radarPage = 1;
constructor(baseUrl: string) { constructor(baseUrl: string) {
this.api = axios.create({ this.api = axios.create({
@@ -92,6 +111,30 @@ export class QQMusicProvider implements MusicProvider {
return this.cookie ? { cookie: this.cookie } : {}; return this.cookie ? { cookie: this.cookie } : {};
} }
private get directCookieHeaders(): Record<string, string> {
return this.cookie ? { Cookie: this.cookie } : {};
}
private buildMusicuPayload(module: string, method: string, param: Record<string, unknown>): Record<string, unknown> {
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<SearchResult> { async search(query: string, limit = 20): Promise<SearchResult> {
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums + // Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61 // 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 ?? []; res.data?.req_0?.data?.body?.song?.list ?? [];
if (songList.length === 0) return null; if (songList.length === 0) return null;
const songs: Song[] = songList.map((s: any) => ({ const songs = mapQqSongs(songList);
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 albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? []; const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums = mapQqAlbums(albumList); const albums = mapQqAlbums(albumList);
@@ -203,17 +236,7 @@ export class QQMusicProvider implements MusicProvider {
? (songRes.value.data?.data?.song?.list ?? []) ? (songRes.value.data?.data?.song?.list ?? [])
: []; : [];
const songs: Song[] = songList.map((s: any) => ({ const songs = mapQqSongs(songList);
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 albumList: any[] = const albumList: any[] =
albumRes.status === "fulfilled" albumRes.status === "fulfilled"
@@ -339,19 +362,7 @@ export class QQMusicProvider implements MusicProvider {
}); });
const cdlist = res.data?.response?.cdlist ?? []; const cdlist = res.data?.response?.cdlist ?? [];
if (cdlist.length === 0) return []; if (cdlist.length === 0) return [];
return (cdlist[0].songlist ?? []).map((s: any) => ({ return mapQqSongs(cdlist[0].songlist ?? []);
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",
}));
} }
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> { async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
@@ -386,17 +397,7 @@ export class QQMusicProvider implements MusicProvider {
const res = await this.api.get("/getAlbumInfo", { const res = await this.api.get("/getAlbumInfo", {
params: { albummid: albumId, ...this.cookieParams }, params: { albummid: albumId, ...this.cookieParams },
}); });
return (res.data?.response?.data?.list ?? []).map((s: any) => ({ return mapQqSongs(res.data?.response?.data?.list ?? []);
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",
}));
} }
async getLyrics(songId: string): Promise<LyricLine[]> { async getLyrics(songId: string): Promise<LyricLine[]> {
@@ -522,6 +523,65 @@ export class QQMusicProvider implements MusicProvider {
} }
} }
async getPersonalFm(): Promise<Song[]> {
const radarSongs = await this.getRadarRecommendSongs();
if (radarSongs.length > 0) return radarSongs;
return this.getGuessRecommendSongs();
}
private async getRadarRecommendSongs(): Promise<Song[]> {
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<Song[]> {
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<Playlist[]> { async getUserPlaylists(): Promise<Playlist[]> {
if (!this.cookie) return []; if (!this.cookie) return [];
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie); const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
+16
View File
@@ -87,6 +87,22 @@ export function createPlayerRouter(
router.post("/:botId/stop", simpleCommand("!stop")); router.post("/:botId/stop", simpleCommand("!stop"));
router.post("/:botId/clear", simpleCommand("!clear")); 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) => { router.post("/:botId/volume", async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
+11
View File
@@ -397,6 +397,17 @@ export const usePlayerStore = defineStore('player', {
if (bot) bot.playMode = mode; 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() { async fetchHomeData() {
// Always check auth status first — if it changed since the cached // Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the // fetch (e.g., user logged in/out as a different account), the
+19 -16
View File
@@ -21,7 +21,7 @@
<!-- 私人FM --> <!-- 私人FM -->
<section class="section"> <section class="section">
<h2 class="section-title">私人FM</h2> <h2 class="section-title">私人FM</h2>
<div class="fm-card hover-scale" @click="playFm"> <div class="fm-card hover-scale" @click="playFm('netease')">
<div class="fm-icon-wrapper"> <div class="fm-icon-wrapper">
<Icon icon="mdi:radio" class="fm-icon" /> <Icon icon="mdi:radio" class="fm-icon" />
</div> </div>
@@ -31,6 +31,16 @@
</div> </div>
<Icon icon="mdi:play-circle" class="fm-play-icon" /> <Icon icon="mdi:play-circle" class="fm-play-icon" />
</div> </div>
<div v-if="store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
<div class="fm-icon-wrapper qq">
<Icon icon="mdi:radar" class="fm-icon" />
</div>
<div class="fm-info">
<div class="fm-title">QQ音乐雷达</div>
<div class="fm-desc">猜你喜欢 / 雷达推荐歌曲流</div>
</div>
<Icon icon="mdi:play-circle" class="fm-play-icon" />
</div>
</section> </section>
<!-- 每日推荐 --> <!-- 每日推荐 -->
@@ -126,8 +136,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import axios from 'axios'; import { usePlayerStore, type Source } from '../stores/player.js';
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js'; import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
import CoverArt from '../components/CoverArt.vue'; import CoverArt from '../components/CoverArt.vue';
import SourceTabs from '../components/SourceTabs.vue'; import SourceTabs from '../components/SourceTabs.vue';
@@ -180,19 +189,8 @@ const visibleUserPlaylists = computed(() =>
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT) : currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
); );
async function playFm() { async function playFm(platform: Source) {
try { await store.startFm(platform);
const res = await axios.get('/api/music/personal/fm');
const songs: Song[] = res.data.songs;
if (songs.length > 0) {
await store.play(songs[0].name, songs[0].platform);
for (let i = 1; i < songs.length; i++) {
await store.addToQueue(songs[i].name, songs[i].platform);
}
}
} catch {
// Ignore
}
} }
onMounted(() => { onMounted(() => {
@@ -318,6 +316,7 @@ onMounted(() => {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
cursor: pointer; cursor: pointer;
transition: background var(--transition-fast); transition: background var(--transition-fast);
margin-bottom: 12px;
&:hover { &:hover {
background: var(--hover-bg); background: var(--hover-bg);
@@ -333,6 +332,10 @@ onMounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
&.qq {
background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
}
} }
.fm-icon { .fm-icon {