mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(qq): add radar FM stream
This commit is contained in:
1 parent
a861b41809
commit
e0d17cf404
6 files changed
+217
-84
No files matched your search
+40
-21
@@ -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";
|
}
|
||||||
|
|
||||||
|
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> {
|
||||||
|
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)
|
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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user