fix(local-audio): reference-aware cleanup, upload quota, stricter validation

Uploaded local files were deleted whenever a track left the current slot,
with no check on whether the file was still needed — causing data loss in
several flows. Replace with reference-aware cleanup: a file is deleted only
once it has been played AND is no longer referenced by ANY bot's queue
(BotManager.getReferencedLocalSongIds wired into the provider via
setInUseResolver), with the sweep run AFTER each queue mutation.

Fixes:
- play-song replay no longer deletes the file it is about to play
- loop / repeat-all / prev no longer destroy uploads mid-cycle
- a shared upload queued on multiple bots is not deleted while still in use
- !play / play-playlist / play-album clean the whole replaced queue, and an
  empty/failed playlist/album load keeps the previous queue + files intact
- bound disk use with an upload quota (evict oldest UNREFERENCED files)
- validate uploads by extension against the audio whitelist (never trust the
  client Content-Type); the stored extension is always a known audio type

Deletion now unlinks the file FIRST and drops the record only on success,
with a bounded non-blocking retry for briefly-locked files (Windows/ffmpeg),
so a failed unlink never orphans a file or diverges index.json. The quota
never evicts the just-uploaded file, and long filenames keep their extension.

Adds src/music/local.test.ts covering the cleanup lifecycle, quota eviction,
and upload validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-06-30 15:58:22 +08:00
1 parent e12cbf8863
commit e849db2286
5 files changed
+453 -77

No files matched your search

+43 -49
View File
@@ -154,39 +154,37 @@ export class BotInstance extends EventEmitter {
return this.config.localAudioEnabled !== false; return this.config.localAudioEnabled !== false;
} }
/**
* Reference-aware cleanup of uploaded local audio files. Delegates to the
* local provider, which deletes a file only when it has been played AND is
* no longer referenced by ANY bot's queue — so loop replays, prev, the song
* being re-started, and the same upload queued on another bot are all safe.
* Call this AFTER the queue mutation, so released songs are unreferenced
* (and deleted) while songs that remain queued are preserved.
*/
cleanupQueuedLocalSongs(reason: string): void { cleanupQueuedLocalSongs(reason: string): void {
this.cleanupLocalSongs(this.queue.list(), reason); this.sweepLocalAudio(reason);
}
private sweepLocalAudio(reason: string): void {
const provider = this.localProvider as MusicProvider & {
sweepUnreferenced?: () => string[];
};
if (typeof provider.sweepUnreferenced !== "function") return;
try {
const deleted = provider.sweepUnreferenced();
if (deleted.length) {
this.logger.info({ count: deleted.length, reason }, "Cleaned up local audio files");
}
} catch (err) {
this.logger.warn({ err, reason }, "Local audio cleanup failed");
}
} }
private isSameSong(a: QueuedSong | Song | null | undefined, b: QueuedSong | Song | null | undefined): boolean { private isSameSong(a: QueuedSong | Song | null | undefined, b: QueuedSong | Song | null | undefined): boolean {
return !!a && !!b && a.platform === b.platform && a.id === b.id; return !!a && !!b && a.platform === b.platform && a.id === b.id;
} }
private cleanupLocalSong(song: QueuedSong | Song | null | undefined, reason: string): void {
if (!song || song.platform !== "local") return;
const cleanupProvider = this.localProvider as MusicProvider & {
deleteSong?: (songId: string) => Promise<boolean>;
};
if (typeof cleanupProvider.deleteSong !== "function") return;
cleanupProvider.deleteSong(song.id).then((deleted) => {
if (deleted) {
this.logger.info({ songId: song.id, name: song.name, reason }, "Deleted local audio file");
}
}).catch((err) => {
this.logger.warn({ err, songId: song.id, name: song.name, reason }, "Failed to delete local audio file");
});
}
private cleanupLocalSongs(songs: Array<QueuedSong | Song>, reason: string): void {
const seen = new Set<string>();
for (const song of songs) {
if (song.platform !== "local" || seen.has(song.id)) continue;
seen.add(song.id);
this.cleanupLocalSong(song, reason);
}
}
private setupTsEvents(): void { private setupTsEvents(): void {
this.tsClient.on("textMessage", (msg: TS3TextMessage) => { this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
this.handleTextMessage(msg).catch((err) => { this.handleTextMessage(msg).catch((err) => {
@@ -199,11 +197,10 @@ export class BotInstance extends EventEmitter {
// completed (hanging handshake → 60s library idle timeout) and // completed (hanging handshake → 60s library idle timeout) and
// this.connected was never flipped to true. Previously this handler // this.connected was never flipped to true. Previously this handler
// short-circuited on !this.connected, leaving player stuck as "playing". // short-circuited on !this.connected, leaving player stuck as "playing".
const queued = this.queue.list();
this.connected = false; this.connected = false;
this.player.stop(); this.player.stop();
this.cleanupLocalSongs(queued, "disconnected");
this.queue.clear(); this.queue.clear();
this.sweepLocalAudio("disconnected");
// A lifecycle change must not leave a stale auto-resume armed. // A lifecycle change must not leave a stale auto-resume armed.
this.autoPaused = false; this.autoPaused = false;
// Only emit externally once per lifecycle so clients don't see a // Only emit externally once per lifecycle so clients don't see a
@@ -284,10 +281,9 @@ export class BotInstance extends EventEmitter {
disconnect(): void { disconnect(): void {
this._cancelIdleTimer(); this._cancelIdleTimer();
const queued = this.queue.list();
this.player.stop(); this.player.stop();
this.cleanupLocalSongs(queued, "disconnected");
this.queue.clear(); this.queue.clear();
this.sweepLocalAudio("disconnected");
this.connected = false; this.connected = false;
if (!this.disconnectEmitted) { if (!this.disconnectEmitted) {
this.disconnectEmitted = true; this.disconnectEmitted = true;
@@ -675,7 +671,6 @@ export class BotInstance extends EventEmitter {
const previous = this.queue.current(); const previous = this.queue.current();
if (previous && !this.isSameSong(previous, song0)) { if (previous && !this.isSameSong(previous, song0)) {
this.player.stop(); this.player.stop();
this.cleanupLocalSong(previous, "replaced");
} }
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
@@ -685,6 +680,10 @@ export class BotInstance extends EventEmitter {
// Reset failure counter on user-initiated play // Reset failure counter on user-initiated play
this.player.resetFailures(); this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!); const ok = await this.resolveAndPlay(this.queue.current()!);
// Sweep AFTER the new song is queued+resolved: the replaced songs are no
// longer referenced (and get deleted), but song0 — if it is the same local
// upload that was already playing — stays referenced and is preserved.
this.sweepLocalAudio("replaced");
if (!ok) return `Cannot play: ${song0.name}`; if (!ok) return `Cannot play: ${song0.name}`;
return `Now playing: ${song0.name} - ${song0.artist}`; return `Now playing: ${song0.name} - ${song0.artist}`;
} }
@@ -761,11 +760,10 @@ export class BotInstance extends EventEmitter {
} }
private cmdStop(): string { private cmdStop(): string {
const queued = this.queue.list();
this.player.stop(); this.player.stop();
this.cleanupLocalSongs(queued, "stopped");
this.autoPaused = false; this.autoPaused = false;
this.queue.clear(); this.queue.clear();
this.sweepLocalAudio("stopped");
this.disableFmMode(); 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");
@@ -822,10 +820,9 @@ export class BotInstance extends EventEmitter {
} }
private cmdClear(): string { private cmdClear(): string {
const queued = this.queue.list();
this.player.stop(); this.player.stop();
this.cleanupLocalSongs(queued, "queue_cleared");
this.queue.clear(); this.queue.clear();
this.sweepLocalAudio("queue_cleared");
this.disableFmMode(); 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");
@@ -839,7 +836,9 @@ export class BotInstance extends EventEmitter {
if (isNaN(index) || index < 0) return "Usage: !remove <number>"; if (isNaN(index) || index < 0) return "Usage: !remove <number>";
const removed = this.queue.remove(index); const removed = this.queue.remove(index);
if (!removed) return "Invalid position"; if (!removed) return "Invalid position";
this.cleanupLocalSong(removed, "removed_from_queue"); // Sweep after the entry is gone — the file is deleted only if no other
// queue position (or bot) still references this upload.
this.sweepLocalAudio("removed_from_queue");
this.emit("stateChange"); this.emit("stateChange");
return `Removed: ${removed.name}`; return `Removed: ${removed.name}`;
} }
@@ -899,9 +898,7 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getPlaylistSongs(playlistId); const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) return "Playlist is empty or not found"; if (songs.length === 0) return "Playlist is empty or not found";
const previousQueue = this.queue.list();
this.player.stop(); this.player.stop();
this.cleanupLocalSongs(previousQueue, "queue_replaced");
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of songs) { for (const song of songs) {
@@ -909,6 +906,7 @@ export class BotInstance extends EventEmitter {
} }
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange"); this.emit("stateChange");
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
} }
@@ -937,9 +935,7 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getAlbumSongs(albumId); const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) return "Album is empty or not found"; if (songs.length === 0) return "Album is empty or not found";
const previousQueue = this.queue.list();
this.player.stop(); this.player.stop();
this.cleanupLocalSongs(previousQueue, "queue_replaced");
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of songs) { for (const song of songs) {
@@ -947,6 +943,7 @@ export class BotInstance extends EventEmitter {
} }
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange"); this.emit("stateChange");
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
} }
@@ -969,9 +966,7 @@ export class BotInstance extends EventEmitter {
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)";
const previousQueue = this.queue.list();
this.player.stop(); this.player.stop();
this.cleanupLocalSongs(previousQueue, "queue_replaced");
this.queue.clear(); this.queue.clear();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add({ ...song, platform: provider.platform });
@@ -983,6 +978,7 @@ export class BotInstance extends EventEmitter {
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange"); this.emit("stateChange");
const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM"; const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM";
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
@@ -1005,9 +1001,7 @@ export class BotInstance extends EventEmitter {
filtered = result.songs.slice(0, 20); filtered = result.songs.slice(0, 20);
} }
const previousQueue = this.queue.list();
this.player.stop(); this.player.stop();
this.cleanupLocalSongs(previousQueue, "queue_replaced");
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of filtered) { for (const song of filtered) {
@@ -1018,6 +1012,7 @@ export class BotInstance extends EventEmitter {
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange"); this.emit("stateChange");
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`; return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
} }
@@ -1122,7 +1117,6 @@ export class BotInstance extends EventEmitter {
*/ */
async playNext(maxRetries = 3): Promise<boolean> { async playNext(maxRetries = 3): Promise<boolean> {
if (this.isAdvancing || !this.connected) return false; if (this.isAdvancing || !this.connected) return false;
const previous = this.queue.current();
this.isAdvancing = true; this.isAdvancing = true;
let started = false; let started = false;
try { try {
@@ -1167,10 +1161,10 @@ export class BotInstance extends EventEmitter {
this.emit("stateChange"); this.emit("stateChange");
return started; return started;
} finally { } finally {
const current = started ? this.queue.current() : null; // Reference-aware sweep: a finished local song that still sits in the
if (previous && !this.isSameSong(previous, current)) { // queue (sequential history, loop/repeat, or queued on another bot) is
this.cleanupLocalSong(previous, "playback_finished"); // preserved; only uploads no longer referenced anywhere are deleted.
} this.sweepLocalAudio("playback_finished");
this.isAdvancing = false; this.isAdvancing = false;
} }
} }
+18
View File
@@ -100,6 +100,12 @@ export class BotManager extends EventEmitter {
this.bilibiliProvider = bilibiliProvider; this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider(); this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider; this.localProvider = localProvider ?? neteaseProvider;
// Let the local provider see which uploads are still referenced by any
// bot's queue, so it never deletes a file another queue/bot still needs.
const referenceable = this.localProvider as Partial<{
setInUseResolver: (resolver: () => Set<string>) => void;
}>;
referenceable.setInUseResolver?.(() => this.getReferencedLocalSongIds());
this.database = database; this.database = database;
this.config = config; this.config = config;
this.logger = logger; this.logger = logger;
@@ -214,6 +220,18 @@ export class BotManager extends EventEmitter {
return Array.from(this.bots.values()); return Array.from(this.bots.values());
} }
/** Local upload ids still referenced by any bot's queue. The local provider
* uses this to avoid deleting a file another queue/bot is still using. */
getReferencedLocalSongIds(): Set<string> {
const ids = new Set<string>();
for (const bot of this.bots.values()) {
for (const song of bot.getQueueManager().list()) {
if (song.platform === "local") ids.add(song.id);
}
}
return ids;
}
async startBot(id: string): Promise<void> { async startBot(id: string): Promise<void> {
const oldBot = this.bots.get(id); const oldBot = this.bots.get(id);
if (!oldBot) throw new Error(`Bot ${id} not found`); if (!oldBot) throw new Error(`Bot ${id} not found`);
+221
View File
@@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalMusicProvider } from "./local.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "local-audio-test-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// Seed real files + an index.json so we can exercise the cleanup lifecycle
// without invoking the ffmpeg duration probe that uploadAudio runs.
function makeRecord(id: string, bytes = 16) {
const filePath = join(dir, `${id}.mp3`);
writeFileSync(filePath, Buffer.alloc(bytes, 1));
return {
id,
name: id,
artist: "本地上传",
album: "本地音乐",
duration: 0,
coverUrl: "",
platform: "local" as const,
filePath,
originalName: `${id}.mp3`,
uploadedAt: "1970-01-01T00:00:00.000Z",
size: bytes,
mimeType: "audio/mpeg",
};
}
function seed(records: ReturnType<typeof makeRecord>[]) {
writeFileSync(join(dir, "index.json"), JSON.stringify(records), "utf8");
}
describe("LocalMusicProvider cleanup lifecycle", () => {
it("sweep keeps referenced and never-played files, deletes only played+unreferenced", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
const c = makeRecord("c");
seed([a, b, c]);
const p = new LocalMusicProvider(dir);
const refs = new Set<string>(["a"]); // "a" still sits in a queue somewhere
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // played, but referenced
await p.getSongUrl("b"); // played and unreferenced
// "c" was never played (e.g. uploaded but not queued)
const deleted = p.sweepUnreferenced();
expect(deleted).toEqual(["b"]);
expect(existsSync(a.filePath)).toBe(true); // referenced → kept
expect(existsSync(b.filePath)).toBe(false); // played + unreferenced → deleted
expect(existsSync(c.filePath)).toBe(true); // never played → kept
});
it("a played song still in the queue survives the sweep and stays replayable (loop / prev)", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
const refs = new Set<string>(["a"]); // loop queue still references it
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // first pass plays it
p.sweepUnreferenced(); // "playback_finished" sweep
expect(existsSync(a.filePath)).toBe(true);
expect((await p.getSongUrl("a"))?.url).toBe(a.filePath); // next loop pass works
});
it("re-playing a queued local song does not delete it (play-song order)", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
// Mirror the fixed endpoint order: the song is (re)added to the queue
// BEFORE the sweep runs, so it is referenced when we sweep.
const refs = new Set<string>(["a"]);
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // played once
p.sweepUnreferenced(); // sweep fired after the replay re-queued it
expect(existsSync(a.filePath)).toBe(true);
expect(await p.getSongUrl("a")).not.toBeNull();
});
it("deletes a played file once it leaves every queue", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
let refs = new Set<string>(["a"]);
p.setInUseResolver(() => refs);
await p.getSongUrl("a");
p.sweepUnreferenced();
expect(existsSync(a.filePath)).toBe(true); // still queued
refs = new Set<string>(); // queue cleared
p.sweepUnreferenced();
expect(existsSync(a.filePath)).toBe(false); // now removed
expect(await p.getSongUrl("a")).toBeNull();
});
it("never deletes anything when the reference resolver throws", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
p.setInUseResolver(() => {
throw new Error("manager unavailable");
});
await p.getSongUrl("a");
expect(p.sweepUnreferenced()).toEqual([]);
expect(existsSync(a.filePath)).toBe(true);
});
});
describe("LocalMusicProvider upload validation", () => {
it("rejects a spoofed Content-Type with a non-audio extension", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("malicious"),
originalName: "evil.exe",
mimeType: "application/octet-stream",
}),
).rejects.toThrow();
});
it("rejects an unknown extension even when the mime claims audio", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("x"),
originalName: "evil.html",
mimeType: "audio/mpeg",
}),
).rejects.toThrow();
});
it("rejects an empty file", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
).rejects.toThrow();
});
});
describe("LocalMusicProvider quota", () => {
it("evicts oldest unreferenced uploads beyond maxFiles", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
seed([b, a]); // newest-first: b newer than a
const p = new LocalMusicProvider(dir, { maxFiles: 2 });
p.setInUseResolver(() => new Set<string>());
// Upload a third valid file → over the 2-file cap → evict the oldest ("a").
await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
expect(existsSync(a.filePath)).toBe(false); // oldest evicted
expect(existsSync(b.filePath)).toBe(true);
const result = await p.search("");
expect(result.songs.map((s) => s.id).sort()).not.toContain("a");
});
it("does not evict a referenced upload even when over the cap", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
seed([b, a]);
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
p.setInUseResolver(() => new Set<string>(["a"])); // "a" is queued
await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
expect(existsSync(a.filePath)).toBe(true); // protected: still queued
});
it("never evicts the just-uploaded file, even when every older file is referenced", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
p.setInUseResolver(() => new Set<string>(["a"])); // the only older file is queued
const song = await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
// The returned song must actually exist and be playable — not a phantom.
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
describe("LocalMusicProvider filename handling", () => {
it("accepts a long filename without dropping its extension", async () => {
const p = new LocalMusicProvider(dir);
const longName = "x".repeat(300) + ".mp3";
// Must not throw the "unsupported format" error — the extension survives.
const song = await p.uploadAudio({
buffer: Buffer.alloc(16, 1),
originalName: longName,
mimeType: "audio/mpeg",
});
expect(song.id).toBeTruthy();
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
+162 -23
View File
@@ -34,6 +34,16 @@ const AUDIO_EXTENSIONS = new Set([
".ape", ".ape",
]); ]);
const DEFAULT_MAX_FILES = 200;
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
export interface LocalMusicProviderOptions {
/** Max number of uploaded files kept on disk (oldest unreferenced evicted). */
maxFiles?: number;
/** Max total bytes of uploaded files kept on disk. */
maxTotalBytes?: number;
}
interface LocalSongRecord extends Song { interface LocalSongRecord extends Song {
filePath: string; filePath: string;
originalName: string; originalName: string;
@@ -43,24 +53,24 @@ interface LocalSongRecord extends Song {
} }
function safeFileName(name: string): string { function safeFileName(name: string): string {
const base = path.basename(name || "audio"); const base = path.basename(name || "audio")
return base
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim() .trim();
.slice(0, 160) || "audio"; if (!base) return "audio";
// Cap the total length but ALWAYS preserve the extension — truncating the
// whole string would drop a trailing ".mp3" on a long filename and make the
// file fail extension validation.
const ext = path.extname(base);
const stem = ext ? base.slice(0, base.length - ext.length) : base;
const safeStem = stem.slice(0, Math.max(1, 160 - ext.length)) || "audio";
return `${safeStem}${ext}`;
} }
function titleFromFileName(name: string): string { function titleFromFileName(name: string): string {
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频"; return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
} }
function isSupportedAudio(name: string, mimeType?: string): boolean {
const ext = path.extname(name).toLowerCase();
if (AUDIO_EXTENSIONS.has(ext)) return true;
return !!mimeType && (mimeType.startsWith("audio/") || mimeType === "video/webm");
}
async function probeDurationSeconds(filePath: string): Promise<number> { async function probeDurationSeconds(filePath: string): Promise<number> {
return new Promise((resolve) => { return new Promise((resolve) => {
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], { const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
@@ -99,14 +109,43 @@ export class LocalMusicProvider implements MusicProvider {
private readonly uploadDir: string; private readonly uploadDir: string;
private readonly indexPath: string; private readonly indexPath: string;
private records: LocalSongRecord[] = []; private records: LocalSongRecord[] = [];
private readonly maxFiles: number;
private readonly maxTotalBytes: number;
/** Ids that have been resolved for playback at least once; only these are
* eligible for reference-aware cleanup, so freshly uploaded files that are
* not yet queued/played survive in the search list. */
private playedIds = new Set<string>();
/** Returns the set of local song ids still referenced by any bot's queue.
* Deletion never removes a file whose id this set contains. */
private inUseResolver: () => Set<string> = () => new Set<string>();
/** Ids with an in-flight retry-delete scheduled (file briefly locked, e.g.
* ffmpeg on Windows still releasing a just-stopped track). */
private retrying = new Set<string>();
constructor(uploadDir: string) { constructor(uploadDir: string, options: LocalMusicProviderOptions = {}) {
this.uploadDir = uploadDir; this.uploadDir = uploadDir;
this.indexPath = path.join(uploadDir, "index.json"); this.indexPath = path.join(uploadDir, "index.json");
this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
mkdirSync(uploadDir, { recursive: true }); mkdirSync(uploadDir, { recursive: true });
this.loadIndex(); this.loadIndex();
} }
/** Wire the resolver the BotManager uses to report which uploads are still
* queued anywhere. Must be set before any cleanup can delete files. */
setInUseResolver(resolver: () => Set<string>): void {
this.inUseResolver = resolver;
}
private referencedIds(): Set<string> | null {
try {
return this.inUseResolver() ?? new Set<string>();
} catch {
// Resolver failure → references unknown → refuse to delete anything.
return null;
}
}
private loadIndex(): void { private loadIndex(): void {
try { try {
const raw = readFileSync(this.indexPath, "utf8"); const raw = readFileSync(this.indexPath, "utf8");
@@ -129,15 +168,19 @@ export class LocalMusicProvider implements MusicProvider {
mimeType?: string; mimeType?: string;
}): Promise<Song> { }): Promise<Song> {
const originalName = safeFileName(input.originalName || "audio"); const originalName = safeFileName(input.originalName || "audio");
if (!isSupportedAudio(originalName, input.mimeType)) { const ext = path.extname(originalName).toLowerCase();
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm"); // Validate by the (sanitised) file extension only — never trust the
// client-supplied Content-Type. This also guarantees the STORED extension
// is one of the known audio types, so a spoofed header cannot persist an
// arbitrary-extension blob on disk.
if (!AUDIO_EXTENSIONS.has(ext)) {
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等");
} }
if (!input.buffer || input.buffer.length === 0) { if (!input.buffer || input.buffer.length === 0) {
throw new Error("上传文件为空"); throw new Error("上传文件为空");
} }
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const ext = path.extname(originalName).toLowerCase() || ".audio";
const storedName = `${id}${ext}`; const storedName = `${id}${ext}`;
const filePath = path.join(this.uploadDir, storedName); const filePath = path.join(this.uploadDir, storedName);
writeFileSync(filePath, input.buffer); writeFileSync(filePath, input.buffer);
@@ -160,6 +203,9 @@ export class LocalMusicProvider implements MusicProvider {
this.records.unshift(song); this.records.unshift(song);
this.saveIndex(); this.saveIndex();
// Never evict the file we just accepted, even if every older file is still
// queued — returning success for a file we deleted would be a phantom entry.
this.enforceQuota(id);
return this.toSong(song); return this.toSong(song);
} }
@@ -181,6 +227,9 @@ export class LocalMusicProvider implements MusicProvider {
async getSongUrl(songId: string): Promise<SongUrlResult | null> { async getSongUrl(songId: string): Promise<SongUrlResult | null> {
const record = this.records.find((r) => r.id === songId); const record = this.records.find((r) => r.id === songId);
if (!record || !existsSync(record.filePath)) return null; if (!record || !existsSync(record.filePath)) return null;
// A song that is actually resolved for playback becomes eligible for
// cleanup once it is no longer referenced by any queue.
this.playedIds.add(songId);
return { url: record.filePath }; return { url: record.filePath };
} }
@@ -189,19 +238,109 @@ export class LocalMusicProvider implements MusicProvider {
return record && existsSync(record.filePath) ? this.toSong(record) : null; return record && existsSync(record.filePath) ? this.toSong(record) : null;
} }
async deleteSong(songId: string): Promise<boolean> { /**
const index = this.records.findIndex((r) => r.id === songId); * Reference-aware cleanup: delete only files that have been played at least
if (index < 0) return false; * once AND are no longer referenced by any bot's queue. Safe to call after
* any queue mutation — a file still queued anywhere (loop replay, prev,
const [record] = this.records.splice(index, 1); * the song being re-started, the same upload queued on another bot) is kept.
this.saveIndex(); * Returns the ids that were deleted.
*/
if (record?.filePath) { sweepUnreferenced(): string[] {
rmSync(record.filePath, { force: true }); const inUse = this.referencedIds();
if (!inUse) return [];
const deleted: string[] = [];
for (let i = this.records.length - 1; i >= 0; i--) {
const r = this.records[i];
if (!this.playedIds.has(r.id) || inUse.has(r.id)) continue;
if (this.unlinkRecordAt(i)) {
deleted.push(r.id);
} else {
// File still locked (e.g. ffmpeg just-stopped on Windows) — keep the
// record and retry shortly; never orphan it or abort the rest.
this.scheduleRetry(r.id);
}
} }
if (deleted.length) this.saveIndex();
return deleted;
}
/** Evict oldest, never-referenced uploads until under the file-count and
* total-byte caps. Bounds disk use from uploads that are never played.
* `protectId` is never evicted (the file just uploaded in this same call). */
private enforceQuota(protectId?: string): void {
if (this.records.length <= this.maxFiles &&
this.totalBytes() <= this.maxTotalBytes) {
return;
}
const inUse = this.referencedIds();
if (!inUse) return; // can't safely evict without knowing references
let count = this.records.length;
let bytes = this.totalBytes();
let changed = false;
for (let i = this.records.length - 1;
i >= 0 && (count > this.maxFiles || bytes > this.maxTotalBytes);
i--) {
const r = this.records[i];
if (inUse.has(r.id) || r.id === protectId) continue; // never evict these
const size = r.size || 0;
if (this.unlinkRecordAt(i)) {
count--;
bytes -= size;
changed = true;
}
}
if (changed) this.saveIndex();
}
/**
* Delete the backing file for records[index] and drop the record from memory.
* Deletes the FILE FIRST, then mutates state only on success, so a failed
* unlink leaves the record intact (file + index stay consistent) instead of
* orphaning the file. Returns true if the file is gone (deleted or already
* absent), false if it is still present (locked). Never throws; does NOT
* persist the index — callers batch saveIndex().
*/
private unlinkRecordAt(index: number): boolean {
const r = this.records[index];
try {
rmSync(r.filePath, { force: true });
} catch {
// rmSync force:true only swallows ENOENT; EBUSY/EPERM/EACCES throw. If
// the file genuinely vanished anyway, fall through and drop the record.
if (existsSync(r.filePath)) return false;
}
this.records.splice(index, 1);
this.playedIds.delete(r.id);
this.retrying.delete(r.id);
return true; return true;
} }
/** Schedule a bounded, non-blocking retry to delete a briefly-locked file.
* Uses unref'd timers so it never keeps the process alive. */
private scheduleRetry(id: string, attempt = 1): void {
if (attempt === 1 && this.retrying.has(id)) return;
this.retrying.add(id);
const MAX_ATTEMPTS = 6;
const timer = setTimeout(() => {
const index = this.records.findIndex((r) => r.id === id);
if (index < 0) { this.retrying.delete(id); return; } // already removed
const inUse = this.referencedIds();
if (!inUse || inUse.has(id)) { this.retrying.delete(id); return; } // unknown or re-queued
if (this.unlinkRecordAt(index)) {
this.saveIndex();
} else if (attempt < MAX_ATTEMPTS) {
this.scheduleRetry(id, attempt + 1);
} else {
this.retrying.delete(id); // give up; next sweep/quota will retry
}
}, 500 * attempt);
if (typeof timer.unref === "function") timer.unref();
}
private totalBytes(): number {
return this.records.reduce((n, r) => n + (r.size || 0), 0);
}
setQuality(_quality: string): void { setQuality(_quality: string): void {
// 本地文件按原始音质播放。 // 本地文件按原始音质播放。
} }
+9 -5
View File
@@ -297,7 +297,6 @@ export function createPlayerRouter(
// Stop current playback // Stop current playback
bot.getPlayer().stop(); bot.getPlayer().stop();
bot.cleanupQueuedLocalSongs?.("queue_replaced");
bot.getPlayer().resetFailures(); bot.getPlayer().resetFailures();
const songs = await provider.getPlaylistSongs(playlistId); const songs = await provider.getPlaylistSongs(playlistId);
@@ -330,11 +329,14 @@ export function createPlayerRouter(
} }
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
bot.cleanupQueuedLocalSongs?.("queue_replaced");
queue.clear(); queue.clear();
for (const song of queueable) { for (const song of queueable) {
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform });
} }
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
// released and deleted, but an empty/failed playlist (early return above)
// leaves the previous queue — and its files — intact.
bot.cleanupQueuedLocalSongs?.("queue_replaced");
// Use queue.play() for sequential, or pick random index for random modes // Use queue.play() for sequential, or pick random index for random modes
const mode = queue.getMode(); const mode = queue.getMode();
@@ -388,7 +390,6 @@ export function createPlayerRouter(
// Stop current playback // Stop current playback
bot.getPlayer().stop(); bot.getPlayer().stop();
bot.cleanupQueuedLocalSongs?.("queue_replaced");
bot.getPlayer().resetFailures(); bot.getPlayer().resetFailures();
const songs = await provider.getAlbumSongs(albumId); const songs = await provider.getAlbumSongs(albumId);
@@ -414,11 +415,12 @@ export function createPlayerRouter(
} }
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
bot.cleanupQueuedLocalSongs?.("queue_replaced");
queue.clear(); queue.clear();
for (const song of queueable) { for (const song of queueable) {
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform });
} }
// Sweep AFTER the queue is rebuilt (see play-playlist).
bot.cleanupQueuedLocalSongs?.("queue_replaced");
const mode = queue.getMode(); const mode = queue.getMode();
let first; let first;
@@ -464,13 +466,15 @@ export function createPlayerRouter(
} }
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
bot.getPlayer().stop(); bot.getPlayer().stop();
bot.cleanupQueuedLocalSongs?.("queue_replaced");
queue.clear(); queue.clear();
queue.add(song); queue.add(song);
queue.play(); queue.play();
bot.getPlayer().resetFailures(); bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!); const ok = await bot.resolveAndPlay(queue.current()!);
// Sweep AFTER the new song is queued+resolved, so replaying a local song
// that was still in the queue doesn't delete the file we're about to play.
bot.cleanupQueuedLocalSongs?.("queue_replaced");
if (!ok) { if (!ok) {
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` }); res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
return; return;