diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 2912b61..fb54b87 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -154,39 +154,37 @@ export class BotInstance extends EventEmitter { 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 { - 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 { 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; - }; - 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, reason: string): void { - const seen = new Set(); - 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 { this.tsClient.on("textMessage", (msg: TS3TextMessage) => { this.handleTextMessage(msg).catch((err) => { @@ -199,11 +197,10 @@ export class BotInstance extends EventEmitter { // completed (hanging handshake → 60s library idle timeout) and // this.connected was never flipped to true. Previously this handler // short-circuited on !this.connected, leaving player stuck as "playing". - const queued = this.queue.list(); this.connected = false; this.player.stop(); - this.cleanupLocalSongs(queued, "disconnected"); this.queue.clear(); + this.sweepLocalAudio("disconnected"); // A lifecycle change must not leave a stale auto-resume armed. this.autoPaused = false; // Only emit externally once per lifecycle so clients don't see a @@ -284,10 +281,9 @@ export class BotInstance extends EventEmitter { disconnect(): void { this._cancelIdleTimer(); - const queued = this.queue.list(); this.player.stop(); - this.cleanupLocalSongs(queued, "disconnected"); this.queue.clear(); + this.sweepLocalAudio("disconnected"); this.connected = false; if (!this.disconnectEmitted) { this.disconnectEmitted = true; @@ -675,7 +671,6 @@ export class BotInstance extends EventEmitter { const previous = this.queue.current(); if (previous && !this.isSameSong(previous, song0)) { this.player.stop(); - this.cleanupLocalSong(previous, "replaced"); } this.queue.clear(); this.disableFmMode(); @@ -685,6 +680,10 @@ export class BotInstance extends EventEmitter { // Reset failure counter on user-initiated play this.player.resetFailures(); 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}`; return `Now playing: ${song0.name} - ${song0.artist}`; } @@ -761,11 +760,10 @@ export class BotInstance extends EventEmitter { } private cmdStop(): string { - const queued = this.queue.list(); this.player.stop(); - this.cleanupLocalSongs(queued, "stopped"); this.autoPaused = false; this.queue.clear(); + this.sweepLocalAudio("stopped"); this.disableFmMode(); this.profileManager.onSongChange(null).catch((err) => { this.logger.warn({ err }, "Profile restore failed on stop"); @@ -822,10 +820,9 @@ export class BotInstance extends EventEmitter { } private cmdClear(): string { - const queued = this.queue.list(); this.player.stop(); - this.cleanupLocalSongs(queued, "queue_cleared"); this.queue.clear(); + this.sweepLocalAudio("queue_cleared"); this.disableFmMode(); this.profileManager.onSongChange(null).catch((err) => { 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 "; const removed = this.queue.remove(index); 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"); return `Removed: ${removed.name}`; } @@ -899,9 +898,7 @@ export class BotInstance extends EventEmitter { const songs = await provider.getPlaylistSongs(playlistId); if (songs.length === 0) return "Playlist is empty or not found"; - const previousQueue = this.queue.list(); this.player.stop(); - this.cleanupLocalSongs(previousQueue, "queue_replaced"); this.queue.clear(); this.disableFmMode(); for (const song of songs) { @@ -909,6 +906,7 @@ export class BotInstance extends EventEmitter { } const first = this.queue.play(); if (first) await this.resolveAndPlay(first); + this.sweepLocalAudio("queue_replaced"); this.emit("stateChange"); 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); if (songs.length === 0) return "Album is empty or not found"; - const previousQueue = this.queue.list(); this.player.stop(); - this.cleanupLocalSongs(previousQueue, "queue_replaced"); this.queue.clear(); this.disableFmMode(); for (const song of songs) { @@ -947,6 +943,7 @@ export class BotInstance extends EventEmitter { } const first = this.queue.play(); if (first) await this.resolveAndPlay(first); + this.sweepLocalAudio("queue_replaced"); this.emit("stateChange"); return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; } @@ -969,9 +966,7 @@ export class BotInstance extends EventEmitter { if (songs.length === 0) return "No FM songs available (need to login first)"; - const previousQueue = this.queue.list(); this.player.stop(); - this.cleanupLocalSongs(previousQueue, "queue_replaced"); this.queue.clear(); for (const song of songs) { this.queue.add({ ...song, platform: provider.platform }); @@ -983,6 +978,7 @@ export class BotInstance extends EventEmitter { const first = this.queue.play(); if (first) await this.resolveAndPlay(first); + this.sweepLocalAudio("queue_replaced"); this.emit("stateChange"); const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM"; return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; @@ -1005,9 +1001,7 @@ export class BotInstance extends EventEmitter { filtered = result.songs.slice(0, 20); } - const previousQueue = this.queue.list(); this.player.stop(); - this.cleanupLocalSongs(previousQueue, "queue_replaced"); this.queue.clear(); this.disableFmMode(); for (const song of filtered) { @@ -1018,6 +1012,7 @@ export class BotInstance extends EventEmitter { const first = this.queue.play(); if (first) await this.resolveAndPlay(first); + this.sweepLocalAudio("queue_replaced"); this.emit("stateChange"); 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 { if (this.isAdvancing || !this.connected) return false; - const previous = this.queue.current(); this.isAdvancing = true; let started = false; try { @@ -1167,10 +1161,10 @@ export class BotInstance extends EventEmitter { this.emit("stateChange"); return started; } finally { - const current = started ? this.queue.current() : null; - if (previous && !this.isSameSong(previous, current)) { - this.cleanupLocalSong(previous, "playback_finished"); - } + // Reference-aware sweep: a finished local song that still sits in the + // queue (sequential history, loop/repeat, or queued on another bot) is + // preserved; only uploads no longer referenced anywhere are deleted. + this.sweepLocalAudio("playback_finished"); this.isAdvancing = false; } } diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 4144752..bc7e711 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -100,6 +100,12 @@ export class BotManager extends EventEmitter { this.bilibiliProvider = bilibiliProvider; this.youtubeProvider = new YouTubeProvider(); 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) => void; + }>; + referenceable.setInUseResolver?.(() => this.getReferencedLocalSongIds()); this.database = database; this.config = config; this.logger = logger; @@ -214,6 +220,18 @@ export class BotManager extends EventEmitter { 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 { + const ids = new Set(); + 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 { const oldBot = this.bots.get(id); if (!oldBot) throw new Error(`Bot ${id} not found`); diff --git a/src/music/local.test.ts b/src/music/local.test.ts new file mode 100644 index 0000000..465143c --- /dev/null +++ b/src/music/local.test.ts @@ -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[]) { + 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(["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(["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(["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(["a"]); + p.setInUseResolver(() => refs); + + await p.getSongUrl("a"); + p.sweepUnreferenced(); + expect(existsSync(a.filePath)).toBe(true); // still queued + + refs = new Set(); // 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()); + + // 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(["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(["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(); + }); +}); diff --git a/src/music/local.ts b/src/music/local.ts index 64fd069..4d03088 100644 --- a/src/music/local.ts +++ b/src/music/local.ts @@ -34,6 +34,16 @@ const AUDIO_EXTENSIONS = new Set([ ".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 { filePath: string; originalName: string; @@ -43,24 +53,24 @@ interface LocalSongRecord extends Song { } function safeFileName(name: string): string { - const base = path.basename(name || "audio"); - return base + const base = path.basename(name || "audio") .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") .replace(/\s+/g, " ") - .trim() - .slice(0, 160) || "audio"; + .trim(); + 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 { 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 { return new Promise((resolve) => { const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], { @@ -99,14 +109,43 @@ export class LocalMusicProvider implements MusicProvider { private readonly uploadDir: string; private readonly indexPath: string; 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(); + /** 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 = () => new Set(); + /** 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(); - constructor(uploadDir: string) { + constructor(uploadDir: string, options: LocalMusicProviderOptions = {}) { this.uploadDir = uploadDir; 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 }); 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): void { + this.inUseResolver = resolver; + } + + private referencedIds(): Set | null { + try { + return this.inUseResolver() ?? new Set(); + } catch { + // Resolver failure → references unknown → refuse to delete anything. + return null; + } + } + private loadIndex(): void { try { const raw = readFileSync(this.indexPath, "utf8"); @@ -129,15 +168,19 @@ export class LocalMusicProvider implements MusicProvider { mimeType?: string; }): Promise { const originalName = safeFileName(input.originalName || "audio"); - if (!isSupportedAudio(originalName, input.mimeType)) { - throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm"); + const ext = path.extname(originalName).toLowerCase(); + // 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) { throw new Error("上传文件为空"); } const id = crypto.randomUUID(); - const ext = path.extname(originalName).toLowerCase() || ".audio"; const storedName = `${id}${ext}`; const filePath = path.join(this.uploadDir, storedName); writeFileSync(filePath, input.buffer); @@ -160,6 +203,9 @@ export class LocalMusicProvider implements MusicProvider { this.records.unshift(song); 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); } @@ -181,6 +227,9 @@ export class LocalMusicProvider implements MusicProvider { async getSongUrl(songId: string): Promise { const record = this.records.find((r) => r.id === songId); 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 }; } @@ -189,19 +238,109 @@ export class LocalMusicProvider implements MusicProvider { return record && existsSync(record.filePath) ? this.toSong(record) : null; } - async deleteSong(songId: string): Promise { - const index = this.records.findIndex((r) => r.id === songId); - if (index < 0) return false; - - const [record] = this.records.splice(index, 1); - this.saveIndex(); - - if (record?.filePath) { - rmSync(record.filePath, { force: true }); + /** + * Reference-aware cleanup: delete only files that have been played at least + * 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, + * the song being re-started, the same upload queued on another bot) is kept. + * Returns the ids that were deleted. + */ + sweepUnreferenced(): string[] { + 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; } + /** 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 { // 本地文件按原始音质播放。 } diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 609b189..68e9b59 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -297,7 +297,6 @@ export function createPlayerRouter( // Stop current playback bot.getPlayer().stop(); - bot.cleanupQueuedLocalSongs?.("queue_replaced"); bot.getPlayer().resetFailures(); const songs = await provider.getPlaylistSongs(playlistId); @@ -330,11 +329,14 @@ export function createPlayerRouter( } const queue = bot.getQueueManager(); - bot.cleanupQueuedLocalSongs?.("queue_replaced"); queue.clear(); for (const song of queueable) { 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 const mode = queue.getMode(); @@ -388,7 +390,6 @@ export function createPlayerRouter( // Stop current playback bot.getPlayer().stop(); - bot.cleanupQueuedLocalSongs?.("queue_replaced"); bot.getPlayer().resetFailures(); const songs = await provider.getAlbumSongs(albumId); @@ -414,11 +415,12 @@ export function createPlayerRouter( } const queue = bot.getQueueManager(); - bot.cleanupQueuedLocalSongs?.("queue_replaced"); queue.clear(); for (const song of queueable) { queue.add({ ...song, platform: provider.platform }); } + // Sweep AFTER the queue is rebuilt (see play-playlist). + bot.cleanupQueuedLocalSongs?.("queue_replaced"); const mode = queue.getMode(); let first; @@ -464,13 +466,15 @@ export function createPlayerRouter( } const queue = bot.getQueueManager(); bot.getPlayer().stop(); - bot.cleanupQueuedLocalSongs?.("queue_replaced"); queue.clear(); queue.add(song); queue.play(); bot.getPlayer().resetFailures(); 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) { res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` }); return;