diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 31f49b4..3dbb3e0 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -14,6 +14,7 @@ export interface QueuedSong { url?: string; // resolved lazily at play time coverUrl: string; duration: number; // seconds + requestedBy?: string; } export class PlayQueue { diff --git a/src/bot/instance.ts b/src/bot/instance.ts index d9c8855..4508cff 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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 { // 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): 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( + 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 { 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 { + private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise { if (!cmd.args) return `Usage: ${this.config.commandPrefix}play | 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 { + private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise { if (!cmd.args) return `Usage: ${this.config.commandPrefix}add | 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 { + private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise { if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext | 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 { + private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise { if (!cmd.args) return "Usage: !playlist "; 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 { + private async cmdAlbum(cmd: ParsedCommand, requesterName?: string): Promise { if (!cmd.args) return "Usage: !album "; 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 { - return this.startFm(this.getProvider(cmd.flags)); + private async cmdFm(cmd: ParsedCommand, requesterName?: string): Promise { + return this.startFm(this.getProvider(cmd.flags), requesterName); } - async startFm(provider: MusicProvider = this.neteaseProvider): Promise { + async startFm(provider: MusicProvider = this.neteaseProvider, requesterName?: string): Promise { // 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 { + private async cmdArtist(cmd: ParsedCommand, requesterName?: string): Promise { if (!cmd.args) return "Usage: !artist "; 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) { diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 84788e3..7520a4f 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -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", () => { diff --git a/src/data/database.ts b/src/data/database.ts index 5bf375c..7fd8421 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -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) { diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 673fd4a..bd49cce 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -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 }); }); diff --git a/web/src/components/SongCard.vue b/web/src/components/SongCard.vue index e79889d..706c34c 100644 --- a/web/src/components/SongCard.vue +++ b/web/src/components/SongCard.vue @@ -9,6 +9,11 @@ class="platform-badge" :class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : 'badge-netease'" >{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }} + {{ song.requestedBy }}
{{ song.artist }}
@@ -115,6 +120,26 @@ function formatDuration(seconds: number): string { line-height: 1.4; } +.requester-badge { + flex-shrink: 0; + max-width: 96px; + font-size: var(--fs-micro); + font-weight: var(--fw-semi); + padding: 1px 5px; + border-radius: var(--radius-xs); + line-height: 1.4; + background: var(--color-online-15); + color: var(--color-online); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.requester-badge-guest { + background: rgba(156, 163, 175, 0.18); + color: rgba(156, 163, 175, 0.95); +} + .badge-netease { background: var(--brand-netease-15); color: var(--brand-netease); diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 496eb7d..a7b7c18 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -11,6 +11,8 @@ export interface Song { duration: number; coverUrl: string; platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify'; + requestedBy?: string; + playedAt?: string; } export type Source = 'netease' | 'qq' | 'kugou' | 'spotify';