Merge PR #121: show requester names in play history

Merges feature/play-history-requester (@Fa1nttt) into main.

The PR records the WebUI/TeamSpeak requester on queued songs and persists it
to play history (schema migration for requestedBy), rendering it as a badge in
SongCard (gray for 游客/guest).

Conflicts (frontend platform union) resolved to keep both 'spotify' (from #118)
and the new requestedBy/playedAt fields.

Integration fix: the Spotify playback branch in resolveAndPlay (added by #118,
which did not exist on the PR's base) also records play history — added
`requestedBy: song.requestedBy` there so Spotify tracks carry attribution too,
matching the non-Spotify path.

Verified on the merged tree: tsc --noEmit clean, full suite 1309/1309, web build clean.

Co-Authored-By: Fa1nttt <noreply@github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed 2026-07-04 23:17:16 +08:00
commit b9767a7471
7 files changed
+102 -39

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ export interface QueuedSong {
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
requestedBy?: string;
}
export class PlayQueue {
+44 -25
View File
@@ -149,6 +149,7 @@ export class BotInstance extends EventEmitter {
private profileManager: BotProfileManager;
private isFmMode = false;
private fmProvider: MusicProvider | null = null;
private fmRequesterName: string | undefined;
/** Results of the most recent !search, for "#N" selection (issue #90). */
private lastSearchResults: Song[] = [];
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
@@ -576,7 +577,8 @@ export class BotInstance extends EventEmitter {
async executeCommand(
cmd: ParsedCommand,
msg?: TS3TextMessage
msg?: TS3TextMessage,
requesterName = this.requesterNameFromMessage(msg),
): Promise<string | null> {
// Reject commands that would push audio when the bot isn't connected:
// otherwise ffmpeg spawns and voice goes to a half-initialized or
@@ -604,12 +606,12 @@ export class BotInstance extends EventEmitter {
case "find":
return this.cmdSearch(cmd);
case "play":
return this.cmdPlay(cmd);
return this.cmdPlay(cmd, requesterName);
case "add":
return this.cmdAdd(cmd);
return this.cmdAdd(cmd, requesterName);
case "playnext":
case "pn":
return this.cmdPlayNext(cmd);
return this.cmdPlayNext(cmd, requesterName);
case "pause":
return this.cmdPause();
case "resume":
@@ -635,13 +637,13 @@ export class BotInstance extends EventEmitter {
case "mode":
return this.cmdMode(cmd);
case "playlist":
return this.cmdPlaylist(cmd);
return this.cmdPlaylist(cmd, requesterName);
case "album":
return this.cmdAlbum(cmd);
return this.cmdAlbum(cmd, requesterName);
case "fm":
return this.cmdFm(cmd);
return this.cmdFm(cmd, requesterName);
case "artist":
return this.cmdArtist(cmd);
return this.cmdArtist(cmd, requesterName);
case "vote":
return this.cmdVote(msg);
case "lyrics":
@@ -669,6 +671,7 @@ export class BotInstance extends EventEmitter {
private disableFmMode(): void {
this.isFmMode = false;
this.fmProvider = null;
this.fmRequesterName = undefined;
}
private getProvider(flags: Set<string>): MusicProvider {
@@ -680,6 +683,19 @@ export class BotInstance extends EventEmitter {
return this.neteaseProvider;
}
private requesterNameFromMessage(msg?: TS3TextMessage): string | undefined {
const name = msg?.invokerName?.trim();
return name || undefined;
}
private withRequester<T extends Song | QueuedSong>(
song: T,
requesterName?: string,
): T & { requestedBy?: string } {
const requestedBy = requesterName?.trim();
return requestedBy ? { ...song, requestedBy } : { ...song };
}
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */
async resolveAndPlay(song: QueuedSong): Promise<boolean> {
if (!this.connected) {
@@ -790,6 +806,7 @@ export class BotInstance extends EventEmitter {
album: song.album,
platform: song.platform,
coverUrl: song.coverUrl,
requestedBy: song.requestedBy,
});
await this.syncProfileToSong(song);
this.emit("stateChange");
@@ -817,6 +834,7 @@ export class BotInstance extends EventEmitter {
album: song.album,
platform: song.platform,
coverUrl: song.coverUrl,
requestedBy: song.requestedBy,
});
// Keep TeamSpeak-side profile updates on the same path for play/next/FM.
await this.syncProfileToSong(song);
@@ -889,7 +907,7 @@ export class BotInstance extends EventEmitter {
].join("\n");
}
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
@@ -900,7 +918,7 @@ export class BotInstance extends EventEmitter {
}
this.queue.clear();
this.disableFmMode();
this.queue.add({ ...song0 });
this.queue.add(this.withRequester(song0, requesterName));
this.queue.play();
// Reset failure counter on user-initiated play
@@ -914,14 +932,14 @@ export class BotInstance extends EventEmitter {
return `Now playing: ${song0.name} - ${song0.artist}`;
}
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const s = song!;
const wasIdle = this.player.getState() === "idle";
this.queue.add({ ...s });
this.queue.add(this.withRequester(s, requesterName));
// If nothing was playing, start this newly-added song immediately.
// Matches /api/player/:id/add-by-id behavior so both add paths feel
@@ -938,7 +956,7 @@ export class BotInstance extends EventEmitter {
return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`;
}
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> {
private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
@@ -954,7 +972,7 @@ export class BotInstance extends EventEmitter {
this.queue.getCurrentIndex() < 0
? this.queue.size()
: this.queue.getCurrentIndex() + 1;
this.queue.addNext({ ...s });
this.queue.addNext(this.withRequester(s, requesterName));
if (wasIdle) {
this.queue.playAt(insertedAt);
@@ -1119,7 +1137,7 @@ export class BotInstance extends EventEmitter {
return `Play mode set to: ${cmd.args}`;
}
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
@@ -1164,7 +1182,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear();
this.disableFmMode();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
}
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
@@ -1173,7 +1191,7 @@ export class BotInstance extends EventEmitter {
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
}
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
private async cmdAlbum(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !album <album name or ID>";
const provider = this.getProvider(cmd.flags);
@@ -1201,7 +1219,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear();
this.disableFmMode();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
}
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
@@ -1210,11 +1228,11 @@ export class BotInstance extends EventEmitter {
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
}
private async cmdFm(cmd: ParsedCommand): Promise<string> {
return this.startFm(this.getProvider(cmd.flags));
private async cmdFm(cmd: ParsedCommand, requesterName?: string): Promise<string> {
return this.startFm(this.getProvider(cmd.flags), requesterName);
}
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> {
async startFm(provider: MusicProvider = this.neteaseProvider, requesterName?: string): Promise<string> {
// Match the !fm chat-command guard: refuse before mutating the queue when
// offline, so the web /fm route can't wipe the queue + flip into FM mode
// while nothing can actually play.
@@ -1231,11 +1249,12 @@ export class BotInstance extends EventEmitter {
this.player.stop();
this.queue.clear();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
}
this.queue.setMode(PlayMode.Random);
this.isFmMode = true;
this.fmProvider = provider;
this.fmRequesterName = requesterName?.trim() || undefined;
this.player.resetFailures();
const first = this.queue.play();
@@ -1246,7 +1265,7 @@ export class BotInstance extends EventEmitter {
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
}
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
private async cmdArtist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !artist <artist name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 50);
@@ -1267,7 +1286,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear();
this.disableFmMode();
for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform });
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
}
this.queue.setMode(PlayMode.Loop);
this.player.resetFailures();
@@ -1286,7 +1305,7 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getPersonalFm();
if (songs.length === 0) return;
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, this.fmRequesterName));
}
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
} catch (err) {
+2
View File
@@ -60,6 +60,7 @@ describe("database", () => {
album: "Test Album",
platform: "netease",
coverUrl: "https://example.com/cover.jpg",
requestedBy: "alice",
});
botDb.addPlayHistory({
@@ -76,6 +77,7 @@ describe("database", () => {
expect(history).toHaveLength(2);
expect(history[0].songName).toBe("Another Song");
expect(history[1].songName).toBe("Test Song");
expect(history[1].requestedBy).toBe("alice");
});
it("saves and loads bot instances", () => {
+11 -3
View File
@@ -10,6 +10,7 @@ export interface PlayHistoryEntry {
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
coverUrl: string;
requestedBy?: string;
}
export interface PlayHistoryRecord extends PlayHistoryEntry {
@@ -124,6 +125,12 @@ function migrateSchema(db: Database.Database): void {
if (!userColNames.includes("role")) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
}
const historyColumns = db.prepare("PRAGMA table_info(play_history)").all() as Array<{ name: string }>;
const historyColNames = historyColumns.map((c) => c.name);
if (!historyColNames.includes("requestedBy")) {
db.exec("ALTER TABLE play_history ADD COLUMN requestedBy TEXT NOT NULL DEFAULT ''");
}
}
function initTables(db: Database.Database): void {
@@ -137,6 +144,7 @@ function initTables(db: Database.Database): void {
album TEXT NOT NULL,
platform TEXT NOT NULL,
coverUrl TEXT NOT NULL,
requestedBy TEXT NOT NULL DEFAULT '',
playedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -265,8 +273,8 @@ export function createDatabase(dbPath: string): BotDatabase {
ensureGuestUser(db);
const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl)
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl, requestedBy)
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl, @requestedBy)
`);
const selectHistory = db.prepare(`
@@ -338,7 +346,7 @@ export function createDatabase(dbPath: string): BotDatabase {
db,
addPlayHistory(record) {
insertHistory.run(record);
insertHistory.run({ ...record, requestedBy: record.requestedBy ?? "" });
},
getPlayHistory(botId, limit) {
+17 -11
View File
@@ -53,6 +53,11 @@ export function createPlayerRouter(
res.status(403).json({ error: "本地音频播放已关闭" });
}
function requesterName(req: any): string {
const name = req.user?.username;
return typeof name === "string" && name.trim() ? name.trim() : "游客";
}
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
try {
const bot = (req as any).bot;
@@ -66,7 +71,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "Invalid command" });
return;
}
const response = await bot.executeCommand(cmd);
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -82,7 +87,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "Invalid command" });
return;
}
const response = await bot.executeCommand(cmd);
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -120,7 +125,7 @@ export function createPlayerRouter(
? platform
: "netease"
);
const message = await bot.startFm(provider);
const message = await bot.startFm(provider, requesterName(req));
res.json({
ok:
!message.startsWith("No FM songs") &&
@@ -271,7 +276,7 @@ export function createPlayerRouter(
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
"!"
)!;
const response = await bot.executeCommand(cmd);
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -332,7 +337,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
}
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
// released and deleted, but an empty/failed playlist (early return above)
@@ -418,7 +423,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
}
// Sweep AFTER the queue is rebuilt (see play-playlist).
bot.cleanupQueuedLocalSongs?.("queue_replaced");
@@ -468,7 +473,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
bot.getPlayer().stop();
queue.clear();
queue.add(song);
queue.add({ ...song, requestedBy: requesterName(req) });
queue.play();
bot.getPlayer().resetFailures();
@@ -513,7 +518,7 @@ export function createPlayerRouter(
// after natural track end without queue.clear()).
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
queue.addNext({ ...song, requestedBy: requesterName(req) });
if (wasIdle) {
// Promote the just-added song to current and start it.
@@ -555,7 +560,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
queue.addNext({ ...song, requestedBy: requesterName(req) });
queue.playAt(insertedAt);
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
@@ -587,7 +592,7 @@ export function createPlayerRouter(
const body = await bot.runExclusive(async () => {
const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle";
queue.add(song);
queue.add({ ...song, requestedBy: requesterName(req) });
// If nothing was playing, start this newly-added song immediately.
if (wasIdle) {
@@ -627,7 +632,7 @@ export function createPlayerRouter(
}
const queue = bot.getQueueManager();
queue.add({ ...song, platform: provider.platform });
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
// If nothing is playing, start the first song
if (bot.getPlayer().getState() === "idle") {
@@ -678,6 +683,7 @@ export function createPlayerRouter(
coverUrl: r.coverUrl,
platform: r.platform,
playedAt: r.playedAt,
requestedBy: r.requestedBy,
}));
res.json({ history });
});