mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
feat: show requester names in play history
This commit is contained in:
1 parent
f8743a904f
commit
cb66d77e9c
7 files changed
+101
-39
No files matched your search
@@ -14,6 +14,7 @@ export interface QueuedSong {
|
||||
url?: string; // resolved lazily at play time
|
||||
coverUrl: string;
|
||||
duration: number; // seconds
|
||||
requestedBy?: string;
|
||||
}
|
||||
|
||||
export class PlayQueue {
|
||||
|
||||
+43
-25
@@ -85,6 +85,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 赋值。 */
|
||||
@@ -446,7 +447,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
|
||||
@@ -474,12 +476,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":
|
||||
@@ -505,13 +507,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":
|
||||
@@ -538,6 +540,7 @@ export class BotInstance extends EventEmitter {
|
||||
private disableFmMode(): void {
|
||||
this.isFmMode = false;
|
||||
this.fmProvider = null;
|
||||
this.fmRequesterName = undefined;
|
||||
}
|
||||
|
||||
private getProvider(flags: Set<string>): MusicProvider {
|
||||
@@ -548,6 +551,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) {
|
||||
@@ -596,6 +612,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);
|
||||
@@ -668,7 +685,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;
|
||||
@@ -679,7 +696,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
|
||||
@@ -693,14 +710,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
|
||||
@@ -717,7 +734,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;
|
||||
@@ -733,7 +750,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);
|
||||
@@ -862,7 +879,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);
|
||||
|
||||
@@ -907,7 +924,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);
|
||||
@@ -916,7 +933,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);
|
||||
|
||||
@@ -944,7 +961,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);
|
||||
@@ -953,11 +970,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.
|
||||
@@ -974,11 +991,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();
|
||||
@@ -989,7 +1007,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);
|
||||
@@ -1010,7 +1028,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();
|
||||
@@ -1029,7 +1047,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) {
|
||||
|
||||
@@ -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
@@ -10,6 +10,7 @@ export interface PlayHistoryEntry {
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
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
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : '网易云' }}</span>
|
||||
<span
|
||||
v-if="song.requestedBy"
|
||||
class="requester-badge"
|
||||
:class="{ 'requester-badge-guest': song.requestedBy === '游客' }"
|
||||
>{{ song.requestedBy }}</span>
|
||||
</div>
|
||||
<div class="song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface Song {
|
||||
duration: number;
|
||||
coverUrl: string;
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou';
|
||||
requestedBy?: string;
|
||||
playedAt?: string;
|
||||
}
|
||||
|
||||
export type Source = 'netease' | 'qq' | 'kugou';
|
||||
|
||||
Reference in new issue
Block a user