ziplayer 0.2.7-dev.0 → 0.2.7-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AI-Guide.md +407 -756
  2. package/README.md +275 -10
  3. package/dist/extensions/BaseExtension.d.ts +1 -0
  4. package/dist/extensions/BaseExtension.d.ts.map +1 -1
  5. package/dist/extensions/BaseExtension.js.map +1 -1
  6. package/dist/extensions/index.d.ts +38 -3
  7. package/dist/extensions/index.d.ts.map +1 -1
  8. package/dist/extensions/index.js +259 -41
  9. package/dist/extensions/index.js.map +1 -1
  10. package/dist/persistence/PersistenceManager.d.ts +95 -0
  11. package/dist/persistence/PersistenceManager.d.ts.map +1 -0
  12. package/dist/persistence/PersistenceManager.js +968 -0
  13. package/dist/persistence/PersistenceManager.js.map +1 -0
  14. package/dist/plugins/BasePlugin.js +1 -1
  15. package/dist/plugins/BasePlugin.js.map +1 -1
  16. package/dist/plugins/index.d.ts +19 -4
  17. package/dist/plugins/index.d.ts.map +1 -1
  18. package/dist/plugins/index.js +204 -113
  19. package/dist/plugins/index.js.map +1 -1
  20. package/dist/structures/FilterManager.js +3 -3
  21. package/dist/structures/FilterManager.js.map +1 -1
  22. package/dist/structures/Player.d.ts +65 -14
  23. package/dist/structures/Player.d.ts.map +1 -1
  24. package/dist/structures/Player.js +330 -88
  25. package/dist/structures/Player.js.map +1 -1
  26. package/dist/structures/PlayerManager.d.ts +127 -91
  27. package/dist/structures/PlayerManager.d.ts.map +1 -1
  28. package/dist/structures/PlayerManager.js +437 -124
  29. package/dist/structures/PlayerManager.js.map +1 -1
  30. package/dist/structures/Queue.d.ts +136 -31
  31. package/dist/structures/Queue.d.ts.map +1 -1
  32. package/dist/structures/Queue.js +265 -46
  33. package/dist/structures/Queue.js.map +1 -1
  34. package/dist/types/index.d.ts +46 -6
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/types/index.js +1 -0
  37. package/dist/types/index.js.map +1 -1
  38. package/dist/types/persistence.d.ts +74 -0
  39. package/dist/types/persistence.d.ts.map +1 -0
  40. package/dist/types/persistence.js +3 -0
  41. package/dist/types/persistence.js.map +1 -0
  42. package/package.json +3 -2
  43. package/src/extensions/BaseExtension.ts +1 -0
  44. package/src/extensions/index.ts +320 -37
  45. package/src/persistence/PersistenceManager.ts +1073 -0
  46. package/src/plugins/BasePlugin.ts +1 -1
  47. package/src/plugins/index.ts +248 -133
  48. package/src/structures/FilterManager.ts +3 -3
  49. package/src/structures/Player.ts +358 -94
  50. package/src/structures/PlayerManager.ts +535 -129
  51. package/src/structures/Queue.ts +300 -55
  52. package/src/types/index.ts +52 -10
  53. package/src/types/persistence.ts +83 -0
  54. package/src/types/plugin.ts +1 -1
@@ -0,0 +1,1073 @@
1
+ import { EventEmitter } from "events";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import * as zlib from "zlib";
5
+ import { promisify } from "util";
6
+ import type {
7
+ SerializedPlayer,
8
+ SerializedQueue,
9
+ SerializedTrack,
10
+ PersistenceOptions,
11
+ PersistenceProvider,
12
+ DestroyedRecord,
13
+ BackupInfo,
14
+ } from "../types";
15
+ import type { Player } from "../structures/Player";
16
+ import type { PlayerManager } from "../structures/PlayerManager";
17
+ import type { Track } from "../types";
18
+
19
+ const gzip = promisify(zlib.gzip);
20
+ const gunzip = promisify(zlib.gunzip);
21
+
22
+ // File provider implementation with enhanced backup management
23
+ class FileProvider implements PersistenceProvider {
24
+ private basePath: string;
25
+ private maxBackups: number;
26
+ private maxTotalBackups: number;
27
+ private backupRetentionDays: number;
28
+
29
+ constructor(
30
+ basePath: string,
31
+ options: {
32
+ maxBackups?: number;
33
+ maxTotalBackups?: number;
34
+ backupRetentionDays?: number;
35
+ } = {},
36
+ ) {
37
+ this.basePath = basePath;
38
+ this.maxBackups = options.maxBackups ?? 5;
39
+ this.maxTotalBackups = options.maxTotalBackups ?? 50;
40
+ this.backupRetentionDays = options.backupRetentionDays ?? 7;
41
+
42
+ if (!fs.existsSync(basePath)) {
43
+ fs.mkdirSync(basePath, { recursive: true });
44
+ }
45
+ }
46
+
47
+ private getFilePath(key: string): string {
48
+ return path.join(this.basePath, `${key}.json`);
49
+ }
50
+
51
+ private getBackupPath(key: string, timestamp: number): string {
52
+ return path.join(this.basePath, `${key}_backup_${timestamp}.json`);
53
+ }
54
+
55
+ private getAllBackups(): BackupInfo[] {
56
+ const files = fs.readdirSync(this.basePath);
57
+ const backups: BackupInfo[] = [];
58
+
59
+ for (const file of files) {
60
+ const match = file.match(/^(.+)_backup_(\d+)\.json(\.gz)?$/);
61
+ if (match) {
62
+ const [, key, timestampStr] = match;
63
+ const timestamp = parseInt(timestampStr, 10);
64
+ const filePath = path.join(this.basePath, file);
65
+ const stats = fs.statSync(filePath);
66
+ const isCompressed = file.endsWith(".gz");
67
+
68
+ backups.push({
69
+ key,
70
+ path: filePath,
71
+ timestamp,
72
+ size: stats.size,
73
+ isCompressed,
74
+ });
75
+ }
76
+ }
77
+
78
+ return backups.sort((a, b) => b.timestamp - a.timestamp);
79
+ }
80
+
81
+ private getBackupsByKey(key: string): BackupInfo[] {
82
+ return this.getAllBackups().filter((b) => b.key === key);
83
+ }
84
+
85
+ private cleanOldBackups(key: string): void {
86
+ const backups = this.getBackupsByKey(key);
87
+
88
+ // Delete old backups exceeding maxBackups per player
89
+ for (let i = this.maxBackups; i < backups.length; i++) {
90
+ try {
91
+ fs.unlinkSync(backups[i].path);
92
+ console.log(`[Persistence] Deleted old backup: ${path.basename(backups[i].path)}`);
93
+ } catch (err) {
94
+ console.error(`[Persistence] Failed to delete backup: ${backups[i].path}`, err);
95
+ }
96
+ }
97
+ }
98
+
99
+ private cleanOldBackupsByAge(): void {
100
+ const now = Date.now();
101
+ const retentionMs = this.backupRetentionDays * 24 * 60 * 60 * 1000;
102
+ const backups = this.getAllBackups();
103
+
104
+ let deletedCount = 0;
105
+ for (const backup of backups) {
106
+ if (now - backup.timestamp > retentionMs) {
107
+ try {
108
+ fs.unlinkSync(backup.path);
109
+ deletedCount++;
110
+ } catch (err) {
111
+ console.error(`[Persistence] Failed to delete old backup: ${backup.path}`, err);
112
+ }
113
+ }
114
+ }
115
+
116
+ if (deletedCount > 0) {
117
+ console.log(`[Persistence] Deleted ${deletedCount} backups older than ${this.backupRetentionDays} days`);
118
+ }
119
+ }
120
+
121
+ private cleanTotalBackupsLimit(): void {
122
+ let backups = this.getAllBackups();
123
+
124
+ if (backups.length <= this.maxTotalBackups) return;
125
+
126
+ // Delete oldest backups
127
+ const toDelete = backups.slice(this.maxTotalBackups);
128
+ let deletedCount = 0;
129
+
130
+ for (const backup of toDelete) {
131
+ try {
132
+ fs.unlinkSync(backup.path);
133
+ deletedCount++;
134
+ } catch (err) {
135
+ console.error(`[Persistence] Failed to delete backup: ${backup.path}`, err);
136
+ }
137
+ }
138
+
139
+ if (deletedCount > 0) {
140
+ console.log(`[Persistence] Deleted ${deletedCount} backups (exceeded limit ${this.maxTotalBackups})`);
141
+ }
142
+ }
143
+
144
+ // NEW: Clean all backups for a specific player
145
+ async cleanAllBackupsForPlayer(key: string): Promise<number> {
146
+ const backups = this.getBackupsByKey(key);
147
+ let deletedCount = 0;
148
+
149
+ for (const backup of backups) {
150
+ try {
151
+ fs.unlinkSync(backup.path);
152
+ deletedCount++;
153
+ } catch (err) {
154
+ console.error(`[Persistence] Failed to delete backup: ${backup.path}`, err);
155
+ }
156
+ }
157
+
158
+ if (deletedCount > 0) {
159
+ console.log(`[Persistence] Deleted ${deletedCount} backups for player: ${key}`);
160
+ }
161
+
162
+ return deletedCount;
163
+ }
164
+
165
+ // NEW: Clean all backups
166
+ async cleanAllBackups(): Promise<number> {
167
+ const backups = this.getAllBackups();
168
+ let deletedCount = 0;
169
+
170
+ for (const backup of backups) {
171
+ try {
172
+ fs.unlinkSync(backup.path);
173
+ deletedCount++;
174
+ } catch (err) {
175
+ console.error(`[Persistence] Failed to delete backup: ${backup.path}`, err);
176
+ }
177
+ }
178
+
179
+ if (deletedCount > 0) {
180
+ console.log(`[Persistence] Deleted all ${deletedCount} backups`);
181
+ }
182
+
183
+ return deletedCount;
184
+ }
185
+
186
+ // NEW: Get backup statistics
187
+ getBackupStats(): {
188
+ totalBackups: number;
189
+ totalSize: number;
190
+ oldestBackup: number | null;
191
+ newestBackup: number | null;
192
+ backupsByPlayer: Map<string, number>;
193
+ } {
194
+ const backups = this.getAllBackups();
195
+ let totalSize = 0;
196
+ let oldestBackup: number | null = null;
197
+ let newestBackup: number | null = null;
198
+ const backupsByPlayer = new Map<string, number>();
199
+
200
+ for (const backup of backups) {
201
+ totalSize += backup.size;
202
+ if (oldestBackup === null || backup.timestamp < oldestBackup) oldestBackup = backup.timestamp;
203
+ if (newestBackup === null || backup.timestamp > newestBackup) newestBackup = backup.timestamp;
204
+ backupsByPlayer.set(backup.key, (backupsByPlayer.get(backup.key) || 0) + 1);
205
+ }
206
+
207
+ return {
208
+ totalBackups: backups.length,
209
+ totalSize,
210
+ oldestBackup,
211
+ newestBackup,
212
+ backupsByPlayer,
213
+ };
214
+ }
215
+
216
+ async save(key: string, data: any, compress: boolean = false): Promise<void> {
217
+ const filePath = this.getFilePath(key);
218
+ let content = JSON.stringify(data, null, 2);
219
+
220
+ if (compress) {
221
+ const compressed = await gzip(content);
222
+ content = compressed.toString("base64");
223
+ fs.writeFileSync(filePath + ".gz", content);
224
+ return;
225
+ }
226
+
227
+ // Create backup before overwriting
228
+ if (fs.existsSync(filePath)) {
229
+ const backupPath = this.getBackupPath(key, Date.now());
230
+ fs.copyFileSync(filePath, backupPath);
231
+ this.cleanOldBackups(key);
232
+ this.cleanTotalBackupsLimit();
233
+ this.cleanOldBackupsByAge();
234
+ }
235
+
236
+ fs.writeFileSync(filePath, content);
237
+ }
238
+
239
+ async load(key: string): Promise<any> {
240
+ const filePath = this.getFilePath(key);
241
+ const gzPath = filePath + ".gz";
242
+
243
+ if (fs.existsSync(gzPath)) {
244
+ const compressed = fs.readFileSync(gzPath, "utf8");
245
+ const buffer = Buffer.from(compressed, "base64");
246
+ const decompressed = await gunzip(buffer);
247
+ return JSON.parse(decompressed.toString());
248
+ }
249
+
250
+ if (fs.existsSync(filePath)) {
251
+ const content = fs.readFileSync(filePath, "utf8");
252
+ return JSON.parse(content);
253
+ }
254
+
255
+ return null;
256
+ }
257
+
258
+ async delete(key: string): Promise<void> {
259
+ const filePath = this.getFilePath(key);
260
+ const gzPath = filePath + ".gz";
261
+
262
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
263
+ if (fs.existsSync(gzPath)) fs.unlinkSync(gzPath);
264
+
265
+ // Also delete backups for this player
266
+ await this.cleanAllBackupsForPlayer(key);
267
+ }
268
+
269
+ async list(): Promise<string[]> {
270
+ const files = fs.readdirSync(this.basePath);
271
+ return files
272
+ .filter((f) => {
273
+ // Exclude backup files
274
+ if (f.includes("_backup_")) return false;
275
+ return f.endsWith(".json") || f.endsWith(".json.gz");
276
+ })
277
+ .map((f) => f.replace(/\.json(\.gz)?$/, ""));
278
+ }
279
+
280
+ async restoreBackup(key: string, backupTimestamp?: number): Promise<boolean> {
281
+ let backupFile: string | null = null;
282
+
283
+ if (backupTimestamp) {
284
+ const specific = this.getBackupPath(key, backupTimestamp);
285
+ if (fs.existsSync(specific)) {
286
+ backupFile = specific;
287
+ }
288
+ } else {
289
+ // Get latest backup
290
+ const backups = this.getBackupsByKey(key);
291
+ if (backups.length > 0) {
292
+ backupFile = backups[0].path;
293
+ }
294
+ }
295
+
296
+ if (backupFile && fs.existsSync(backupFile)) {
297
+ const content = fs.readFileSync(backupFile, "utf8");
298
+ const data = JSON.parse(content);
299
+ await this.save(key, data);
300
+ return true;
301
+ }
302
+
303
+ return false;
304
+ }
305
+ }
306
+
307
+ // Custom provider for database integration (giữ nguyên)
308
+ class CustomProvider implements PersistenceProvider {
309
+ constructor(
310
+ private saveFn: (key: string, data: any) => Promise<void>,
311
+ private loadFn: (key: string) => Promise<any>,
312
+ private deleteFn?: (key: string) => Promise<void>,
313
+ private listFn?: () => Promise<string[]>,
314
+ ) {}
315
+
316
+ async save(key: string, data: any): Promise<void> {
317
+ await this.saveFn(key, data);
318
+ }
319
+
320
+ async load(key: string): Promise<any> {
321
+ return await this.loadFn(key);
322
+ }
323
+
324
+ async delete(key: string): Promise<void> {
325
+ if (this.deleteFn) {
326
+ await this.deleteFn(key);
327
+ }
328
+ }
329
+
330
+ async list(): Promise<string[]> {
331
+ if (this.listFn) {
332
+ return await this.listFn();
333
+ }
334
+ return [];
335
+ }
336
+ }
337
+
338
+ export class PersistenceManager extends EventEmitter {
339
+ private manager: PlayerManager;
340
+ private options: PersistenceOptions;
341
+ private provider: FileProvider | CustomProvider;
342
+ private saveInterval: NodeJS.Timeout | null = null;
343
+ private isSaving: boolean = false;
344
+ private isRestoring: boolean = false;
345
+ private destroyedPlayers: Map<string, DestroyedRecord> = new Map();
346
+ private restoredPlayers: Set<string> = new Set();
347
+ private backupCleanupDone: boolean = false;
348
+
349
+ constructor(manager: PlayerManager, options: PersistenceOptions) {
350
+ super();
351
+ this.manager = manager;
352
+
353
+ // Default options
354
+ this.options = {
355
+ enabled: true,
356
+ provider: "file",
357
+ saveInterval: 60000,
358
+ autoLoad: true,
359
+ autoRestoreOnRestart: true,
360
+ restoreDelay: 5000,
361
+ maxBackups: 5,
362
+ maxTotalBackups: 10,
363
+ autoCleanupBackupsOnStart: true,
364
+ backupRetentionDays: 2,
365
+ compress: false,
366
+ filePath: "./players_data",
367
+ };
368
+
369
+ // Merge manually
370
+ if (options.enabled !== undefined) this.options.enabled = options.enabled;
371
+ if (options.provider !== undefined) this.options.provider = options.provider;
372
+ if (options.saveInterval !== undefined) this.options.saveInterval = options.saveInterval;
373
+ if (options.autoLoad !== undefined) this.options.autoLoad = options.autoLoad;
374
+ if (options.autoRestoreOnRestart !== undefined) this.options.autoRestoreOnRestart = options.autoRestoreOnRestart;
375
+ if (options.restoreDelay !== undefined) this.options.restoreDelay = options.restoreDelay;
376
+ if (options.maxBackups !== undefined) this.options.maxBackups = options.maxBackups;
377
+ if (options.maxTotalBackups !== undefined) this.options.maxTotalBackups = options.maxTotalBackups;
378
+ if (options.autoCleanupBackupsOnStart !== undefined)
379
+ this.options.autoCleanupBackupsOnStart = options.autoCleanupBackupsOnStart;
380
+ if (options.backupRetentionDays !== undefined) this.options.backupRetentionDays = options.backupRetentionDays;
381
+ if (options.compress !== undefined) this.options.compress = options.compress;
382
+ if (options.filePath !== undefined) this.options.filePath = options.filePath;
383
+ if (options.redisUrl !== undefined) this.options.redisUrl = options.redisUrl;
384
+ if (options.redisPrefix !== undefined) this.options.redisPrefix = options.redisPrefix;
385
+ if (options.save !== undefined) this.options.save = options.save;
386
+ if (options.load !== undefined) this.options.load = options.load;
387
+ if (options.delete !== undefined) this.options.delete = options.delete;
388
+ if (options.list !== undefined) this.options.list = options.list;
389
+
390
+ this.provider = this.createProvider();
391
+
392
+ // Hook into player destroy events
393
+ this.setupDestroyTracking();
394
+
395
+ // Clean up old backups on start
396
+ if (this.options.enabled && this.options.autoCleanupBackupsOnStart) {
397
+ this.cleanupBackupsOnStart().catch((err) => {
398
+ this.debug("Backup cleanup on start error:", err);
399
+ });
400
+ }
401
+
402
+ if (this.options.enabled) {
403
+ this.startAutoSave();
404
+
405
+ if (this.options.autoLoad) {
406
+ this.loadAll().catch((err) => {
407
+ this.debug("Auto-load error:", err);
408
+ });
409
+ }
410
+ }
411
+ }
412
+
413
+ private setupDestroyTracking(): void {
414
+ this.manager.on("playerDestroy", (player: Player) => {
415
+ this.markAsDestroyed(player.guildId);
416
+ });
417
+ }
418
+
419
+ private createProvider(): FileProvider | CustomProvider {
420
+ switch (this.options.provider) {
421
+ case "file":
422
+ return new FileProvider(this.options.filePath!, {
423
+ maxBackups: this.options.maxBackups,
424
+ maxTotalBackups: this.options.maxTotalBackups,
425
+ backupRetentionDays: this.options.backupRetentionDays,
426
+ });
427
+ case "redis":
428
+ throw new Error("Redis provider not implemented yet");
429
+ case "database":
430
+ if (!this.options.save || !this.options.load) {
431
+ throw new Error("Database provider requires save/load functions");
432
+ }
433
+ return new CustomProvider(
434
+ async (key: string, data: any) => {
435
+ if (this.options.save) {
436
+ const saveFn = this.options.save as any;
437
+ if (saveFn.length === 1) {
438
+ await saveFn({ key, data });
439
+ } else {
440
+ await saveFn(key, data);
441
+ }
442
+ }
443
+ },
444
+ async (key: string) => {
445
+ if (this.options.load) {
446
+ const loadFn = this.options.load as any;
447
+ if (loadFn.length === 0) {
448
+ const allData = await loadFn();
449
+ return allData?.get?.(key) || allData?.[key] || null;
450
+ } else {
451
+ return await loadFn(key);
452
+ }
453
+ }
454
+ return null;
455
+ },
456
+ this.options.delete,
457
+ this.options.list,
458
+ );
459
+ default:
460
+ return new FileProvider(this.options.filePath!, {
461
+ maxBackups: this.options.maxBackups,
462
+ maxTotalBackups: this.options.maxTotalBackups,
463
+ backupRetentionDays: this.options.backupRetentionDays,
464
+ });
465
+ }
466
+ }
467
+
468
+ private debug(message: any, ...params: any[]): void {
469
+ if (this.manager.debugEnabled) {
470
+ this.manager.emit("debug", `[Persistence] ${message}`, ...params);
471
+ }
472
+ }
473
+
474
+ private startAutoSave(): void {
475
+ if (this.saveInterval) {
476
+ clearInterval(this.saveInterval);
477
+ }
478
+
479
+ this.saveInterval = setInterval(() => {
480
+ this.saveAll().catch((err) => {
481
+ this.debug("Auto-save error:", err);
482
+ this.emit("error", err);
483
+ });
484
+ }, this.options.saveInterval);
485
+
486
+ this.debug(`Auto-save started (interval: ${this.options.saveInterval}ms)`);
487
+ }
488
+
489
+ // NEW: Cleanup backups on startup
490
+ private async cleanupBackupsOnStart(): Promise<void> {
491
+ if (this.backupCleanupDone) return;
492
+
493
+ this.debug("Starting backup cleanup on startup...");
494
+
495
+ // Only works for file provider
496
+ if (this.provider instanceof FileProvider) {
497
+ try {
498
+ // Clean old backups by age
499
+ // This is already handled in FileProvider, but we can log stats
500
+ const stats = this.provider.getBackupStats();
501
+
502
+ this.debug(`Backup stats before cleanup:`, {
503
+ totalBackups: stats.totalBackups,
504
+ totalSizeMB: (stats.totalSize / 1024 / 1024).toFixed(2),
505
+ oldestBackup: stats.oldestBackup ? new Date(stats.oldestBackup).toISOString() : null,
506
+ newestBackup: stats.newestBackup ? new Date(stats.newestBackup).toISOString() : null,
507
+ backupsByPlayer: Object.fromEntries(stats.backupsByPlayer),
508
+ });
509
+
510
+ // Emit stats event
511
+ this.emit("backupStats", stats);
512
+
513
+ // The cleanup is already happening in FileProvider.save()
514
+ // But we can do a one-time deep cleanup on start
515
+ if (this.options.backupRetentionDays && this.options.backupRetentionDays > 0) {
516
+ // Force a cleanup pass
517
+ const deletedCount = await this.cleanOldBackupsByAge();
518
+ if (deletedCount > 0) {
519
+ this.debug(`Cleaned up ${deletedCount} old backups on startup`);
520
+ }
521
+ }
522
+
523
+ // Enforce total backup limit
524
+ const totalLimitDeleted = await this.enforceTotalBackupLimit();
525
+ if (totalLimitDeleted > 0) {
526
+ this.debug(`Enforced backup limit: deleted ${totalLimitDeleted} backups`);
527
+ }
528
+
529
+ this.backupCleanupDone = true;
530
+ this.emit("backupCleanupDone");
531
+ } catch (error) {
532
+ this.debug("Backup cleanup error:", error);
533
+ }
534
+ } else {
535
+ this.debug("Backup cleanup only supported for file provider");
536
+ }
537
+ }
538
+
539
+ // NEW: Clean old backups by age
540
+ private async cleanOldBackupsByAge(): Promise<number> {
541
+ if (!(this.provider instanceof FileProvider)) return 0;
542
+
543
+ const retentionMs = (this.options.backupRetentionDays ?? 2) * 24 * 60 * 60 * 1000;
544
+ const now = Date.now();
545
+ const backups = (this.provider as any).getAllBackups();
546
+ let deletedCount = 0;
547
+
548
+ for (const backup of backups) {
549
+ if (now - backup.timestamp > retentionMs) {
550
+ try {
551
+ fs.unlinkSync(backup.path);
552
+ deletedCount++;
553
+ } catch (err) {
554
+ this.debug(`Failed to delete old backup: ${backup.path}`, err);
555
+ }
556
+ }
557
+ }
558
+
559
+ if (deletedCount > 0) {
560
+ this.debug(`Deleted ${deletedCount} backups older than ${this.options.backupRetentionDays} days`);
561
+ }
562
+
563
+ return deletedCount;
564
+ }
565
+
566
+ // NEW: Enforce total backup limit
567
+ private async enforceTotalBackupLimit(): Promise<number> {
568
+ if (!(this.provider instanceof FileProvider)) return 0;
569
+
570
+ const backups = (this.provider as any).getAllBackups();
571
+ const maxTotal = this.options.maxTotalBackups ?? 50;
572
+
573
+ if (backups.length <= maxTotal) return 0;
574
+
575
+ const toDelete = backups.slice(maxTotal);
576
+ let deletedCount = 0;
577
+
578
+ for (const backup of toDelete) {
579
+ try {
580
+ fs.unlinkSync(backup.path);
581
+ deletedCount++;
582
+ } catch (err) {
583
+ this.debug(`Failed to delete backup: ${backup.path}`, err);
584
+ }
585
+ }
586
+
587
+ if (deletedCount > 0) {
588
+ this.debug(`Deleted ${deletedCount} backups (exceeded limit ${maxTotal})`);
589
+ }
590
+
591
+ return deletedCount;
592
+ }
593
+
594
+ private markAsDestroyed(guildId: string): void {
595
+ this.destroyedPlayers.set(guildId, {
596
+ guildId,
597
+ destroyedAt: Date.now(),
598
+ reason: "player_destroy",
599
+ });
600
+ this.debug(`Marked player as destroyed: ${guildId}`);
601
+
602
+ this.saveDestroyedStatus().catch((err) => {
603
+ this.debug("Failed to save destroyed status:", err);
604
+ });
605
+ }
606
+
607
+ private isDestroyed(guildId: string): boolean {
608
+ return this.destroyedPlayers.has(guildId);
609
+ }
610
+
611
+ private async saveDestroyedStatus(): Promise<void> {
612
+ const destroyedData = Array.from(this.destroyedPlayers.values());
613
+ await this.provider.save("__destroyed_players__", destroyedData);
614
+ }
615
+
616
+ private async loadDestroyedStatus(): Promise<void> {
617
+ try {
618
+ const data = await this.provider.load("__destroyed_players__");
619
+ if (data && Array.isArray(data)) {
620
+ this.destroyedPlayers.clear();
621
+ for (const record of data) {
622
+ this.destroyedPlayers.set(record.guildId, record);
623
+ }
624
+ this.debug(`Loaded ${this.destroyedPlayers.size} destroyed player records`);
625
+ }
626
+ } catch (error) {
627
+ this.debug("Failed to load destroyed status:", error);
628
+ }
629
+ }
630
+
631
+ private async clearDestroyedStatus(guildId: string): Promise<void> {
632
+ this.destroyedPlayers.delete(guildId);
633
+ await this.saveDestroyedStatus();
634
+ }
635
+
636
+ private serializeTrack(track: Track): SerializedTrack {
637
+ const serialized: SerializedTrack = {
638
+ id: track.id,
639
+ title: track.title,
640
+ url: track.url,
641
+ source: track.source,
642
+ duration: track.duration,
643
+ thumbnail: track.thumbnail,
644
+ requestedBy: track.requestedBy,
645
+ isLive: track.isLive || false,
646
+ };
647
+
648
+ const trackAny = track as any;
649
+ if (trackAny.author) serialized.author = trackAny.author;
650
+ if (trackAny.artwork) serialized.artwork = trackAny.artwork;
651
+
652
+ const excludedFields = new Set([
653
+ "id",
654
+ "title",
655
+ "url",
656
+ "source",
657
+ "duration",
658
+ "thumbnail",
659
+ "requestedBy",
660
+ "isLive",
661
+ "author",
662
+ "artwork",
663
+ ]);
664
+ for (const key of Object.keys(trackAny)) {
665
+ if (!excludedFields.has(key) && trackAny[key] !== undefined) {
666
+ (serialized as any)[key] = trackAny[key];
667
+ }
668
+ }
669
+
670
+ return serialized;
671
+ }
672
+
673
+ private serializeQueue(player: Player): SerializedQueue {
674
+ return {
675
+ tracks: player.upcomingTracks.map((t) => this.serializeTrack(t)),
676
+ current: player.currentTrack ? this.serializeTrack(player.currentTrack) : null,
677
+ history: player.previousTracks.map((t) => this.serializeTrack(t)),
678
+ loopMode: player.queue.loop(),
679
+ autoPlay: player.queue.autoPlay(),
680
+ position: player.getTime().current,
681
+ };
682
+ }
683
+
684
+ private serializePlayer(player: Player): SerializedPlayer {
685
+ let filters: string[] = [];
686
+ try {
687
+ const filterString = (player as any).filter?.getFilterString();
688
+ if (filterString) {
689
+ filters = filterString.split(",").filter(Boolean);
690
+ }
691
+ } catch (e) {}
692
+
693
+ return {
694
+ guildId: player.guildId,
695
+ queue: this.serializeQueue(player),
696
+ volume: player.volume,
697
+ isPlaying: player.isPlaying,
698
+ isPaused: player.isPaused,
699
+ options: player.options,
700
+ filters: filters.length > 0 ? filters : undefined,
701
+ lastUpdate: Date.now(),
702
+ version: "1.0.0",
703
+ wasDestroyed: false,
704
+ };
705
+ }
706
+
707
+ private deserializeTrack(data: SerializedTrack): Track {
708
+ const track: any = {
709
+ id: data.id,
710
+ title: data.title,
711
+ url: data.url,
712
+ source: data.source,
713
+ duration: data.duration,
714
+ thumbnail: data.thumbnail,
715
+ requestedBy: data.requestedBy,
716
+ isLive: data.isLive || false,
717
+ };
718
+
719
+ if (data.author) track.author = data.author;
720
+ if (data.artwork) track.artwork = data.artwork;
721
+
722
+ for (const key of Object.keys(data)) {
723
+ if (
724
+ !["id", "title", "url", "source", "duration", "thumbnail", "requestedBy", "isLive", "author", "artwork"].includes(key)
725
+ ) {
726
+ track[key] = (data as any)[key];
727
+ }
728
+ }
729
+
730
+ return track as Track;
731
+ }
732
+
733
+ /**
734
+ * Save a single player
735
+ */
736
+ async savePlayer(player: Player): Promise<boolean> {
737
+ if (!this.options.enabled) return false;
738
+
739
+ if (this.isDestroyed(player.guildId)) {
740
+ this.debug(`Skipping save for destroyed player: ${player.guildId}`);
741
+ return false;
742
+ }
743
+
744
+ try {
745
+ const data = this.serializePlayer(player);
746
+ await this.provider.save(player.guildId, data, this.options.compress);
747
+ this.debug(`Saved player: ${player.guildId}`);
748
+ this.emit("playerSaved", player.guildId);
749
+ return true;
750
+ } catch (error) {
751
+ this.debug(`Failed to save player ${player.guildId}:`, error);
752
+ this.emit("error", error);
753
+ return false;
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Save all players
759
+ */
760
+ async saveAll(): Promise<Map<string, boolean>> {
761
+ if (!this.options.enabled || this.isSaving) return new Map();
762
+
763
+ this.isSaving = true;
764
+ const results = new Map<string, boolean>();
765
+
766
+ try {
767
+ const players = this.manager.getAll();
768
+ this.debug(`Saving ${players.length} players...`);
769
+
770
+ const batchSize = 5;
771
+ for (let i = 0; i < players.length; i += batchSize) {
772
+ const batch = players.slice(i, i + batchSize);
773
+ const promises = batch.map((p) => this.savePlayer(p));
774
+ const batchResults = await Promise.all(promises);
775
+ batch.forEach((p, idx) => results.set(p.guildId, batchResults[idx]));
776
+ }
777
+
778
+ this.debug(`Saved ${results.size} players`);
779
+ this.emit("savedAll", results);
780
+ } finally {
781
+ this.isSaving = false;
782
+ }
783
+
784
+ return results;
785
+ }
786
+
787
+ /**
788
+ * Load a single player
789
+ */
790
+ async loadPlayer(guildId: string, restorePosition: boolean = true, skipIfDestroyed: boolean = true): Promise<boolean> {
791
+ if (!this.options.enabled) return false;
792
+
793
+ if (skipIfDestroyed && this.isDestroyed(guildId)) {
794
+ this.debug(`Skipping load for destroyed player: ${guildId}`);
795
+ this.emit("playerSkipped", guildId, "destroyed");
796
+ return false;
797
+ }
798
+
799
+ if (this.restoredPlayers.has(guildId)) {
800
+ this.debug(`Skipping already restored player: ${guildId}`);
801
+ return true;
802
+ }
803
+
804
+ try {
805
+ const data = await this.provider.load(guildId);
806
+ if (!data) return false;
807
+
808
+ if (data.wasDestroyed === true) {
809
+ this.debug(`Skipping load for player marked as destroyed: ${guildId}`);
810
+ return false;
811
+ }
812
+
813
+ let player = this.manager.get(guildId);
814
+ if (!player) {
815
+ player = await this.manager.create(guildId, data.options);
816
+ }
817
+
818
+ const queue = data.queue as SerializedQueue;
819
+
820
+ player.queue.clear();
821
+ player.queue.loop(queue.loopMode);
822
+ player.queue.autoPlay(queue.autoPlay);
823
+
824
+ if (queue.tracks.length > 0) {
825
+ const tracks = queue.tracks.map((t) => this.deserializeTrack(t));
826
+ player.queue.addMultiple(tracks);
827
+ }
828
+
829
+ if (queue.current && player.connection) {
830
+ const currentTrack = this.deserializeTrack(queue.current);
831
+ player.queue.willNextTrack(currentTrack);
832
+
833
+ if (restorePosition && queue.position && queue.position > 0) {
834
+ await player.refreshPlayerResource(true, queue.position);
835
+ } else {
836
+ await player.play(currentTrack);
837
+ }
838
+ }
839
+
840
+ player.setVolume(data.volume);
841
+
842
+ if (data.filters && data.filters.length > 0) {
843
+ try {
844
+ const filterManager = (player as any).filter;
845
+ if (filterManager && typeof filterManager.applyFilters === "function") {
846
+ await filterManager.applyFilters(data.filters);
847
+ }
848
+ } catch (e) {
849
+ this.debug(`Failed to restore filters for ${guildId}:`, e);
850
+ }
851
+ }
852
+
853
+ this.restoredPlayers.add(guildId);
854
+ await this.clearDestroyedStatus(guildId);
855
+
856
+ this.debug(`Loaded player: ${guildId}`);
857
+ this.emit("playerLoaded", guildId, data);
858
+ return true;
859
+ } catch (error) {
860
+ this.debug(`Failed to load player ${guildId}:`, error);
861
+ this.emit("error", error);
862
+ return false;
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Load all saved players with auto-restore logic
868
+ */
869
+ async loadAll(restorePosition: boolean = true): Promise<Map<string, boolean>> {
870
+ if (!this.options.enabled) return new Map();
871
+
872
+ await this.loadDestroyedStatus();
873
+
874
+ const results = new Map<string, boolean>();
875
+
876
+ try {
877
+ const keys = await this.provider.list();
878
+ const playerKeys = keys.filter((k) => k !== "__destroyed_players__");
879
+ this.debug(`Found ${playerKeys.length} saved players`);
880
+
881
+ if (this.options.autoRestoreOnRestart) {
882
+ this.debug(`Auto-restore enabled, restoring players after ${this.options.restoreDelay}ms delay...`);
883
+
884
+ if (this.options.restoreDelay && this.options.restoreDelay > 0) {
885
+ await new Promise((resolve) => setTimeout(resolve, this.options.restoreDelay));
886
+ }
887
+
888
+ for (const guildId of playerKeys) {
889
+ const success = await this.loadPlayer(guildId, restorePosition, true);
890
+ results.set(guildId, success);
891
+ }
892
+ } else {
893
+ for (const guildId of playerKeys) {
894
+ const success = await this.loadPlayer(guildId, restorePosition, true);
895
+ results.set(guildId, success);
896
+ }
897
+ }
898
+
899
+ this.emit("loadedAll", results);
900
+ } catch (error) {
901
+ this.debug("Failed to load players:", error);
902
+ this.emit("error", error);
903
+ }
904
+
905
+ return results;
906
+ }
907
+
908
+ /**
909
+ * Mark a player as destroyed
910
+ */
911
+ async markPlayerDestroyed(guildId: string, reason?: string): Promise<void> {
912
+ this.destroyedPlayers.set(guildId, {
913
+ guildId,
914
+ destroyedAt: Date.now(),
915
+ reason: reason || "manual_destroy",
916
+ });
917
+
918
+ try {
919
+ const data = await this.provider.load(guildId);
920
+ if (data) {
921
+ data.wasDestroyed = true;
922
+ data.destroyedAt = Date.now();
923
+ await this.provider.save(guildId, data, this.options.compress);
924
+ }
925
+ } catch (error) {
926
+ this.debug(`Failed to mark player data as destroyed: ${guildId}`, error);
927
+ }
928
+
929
+ await this.saveDestroyedStatus();
930
+ this.debug(`Marked player as destroyed (won't restore on restart): ${guildId}`);
931
+ this.emit("playerMarkedDestroyed", guildId);
932
+ }
933
+
934
+ /**
935
+ * Clear destroyed status for a player
936
+ */
937
+ async clearDestroyed(guildId: string): Promise<void> {
938
+ await this.clearDestroyedStatus(guildId);
939
+
940
+ try {
941
+ const data = await this.provider.load(guildId);
942
+ if (data) {
943
+ data.wasDestroyed = false;
944
+ delete data.destroyedAt;
945
+ await this.provider.save(guildId, data, this.options.compress);
946
+ }
947
+ } catch (error) {
948
+ this.debug(`Failed to clear destroyed flag in data: ${guildId}`, error);
949
+ }
950
+
951
+ this.debug(`Cleared destroyed status for: ${guildId}`);
952
+ this.emit("playerDestroyedCleared", guildId);
953
+ }
954
+
955
+ /**
956
+ * Delete a player's saved data
957
+ */
958
+ async deletePlayer(guildId: string): Promise<boolean> {
959
+ if (!this.options.enabled) return false;
960
+
961
+ try {
962
+ await this.provider.delete(guildId);
963
+ await this.clearDestroyedStatus(guildId);
964
+ this.restoredPlayers.delete(guildId);
965
+ this.debug(`Deleted saved data for: ${guildId}`);
966
+ this.emit("playerDeleted", guildId);
967
+ return true;
968
+ } catch (error) {
969
+ this.debug(`Failed to delete player ${guildId}:`, error);
970
+ return false;
971
+ }
972
+ }
973
+
974
+ /**
975
+ * Restore from backup
976
+ */
977
+ async restoreBackup(guildId: string, timestamp?: number): Promise<boolean> {
978
+ if (!(this.provider instanceof FileProvider)) {
979
+ this.debug("Restore from backup only supported for file provider");
980
+ return false;
981
+ }
982
+
983
+ const success = await (this.provider as FileProvider).restoreBackup(guildId, timestamp);
984
+ if (success) {
985
+ await this.clearDestroyed(guildId);
986
+ }
987
+ return success;
988
+ }
989
+
990
+ /**
991
+ * Clean all backups for a specific player
992
+ */
993
+ async cleanBackupsForPlayer(guildId: string): Promise<number> {
994
+ if (!(this.provider instanceof FileProvider)) {
995
+ this.debug("Backup cleanup only supported for file provider");
996
+ return 0;
997
+ }
998
+
999
+ const deleted = await (this.provider as FileProvider).cleanAllBackupsForPlayer(guildId);
1000
+ this.debug(`Cleaned ${deleted} backups for player: ${guildId}`);
1001
+ this.emit("backupsCleaned", guildId, deleted);
1002
+ return deleted;
1003
+ }
1004
+
1005
+ /**
1006
+ * Clean all backups
1007
+ */
1008
+ async cleanAllBackups(): Promise<number> {
1009
+ if (!(this.provider instanceof FileProvider)) {
1010
+ this.debug("Backup cleanup only supported for file provider");
1011
+ return 0;
1012
+ }
1013
+
1014
+ const deleted = await (this.provider as FileProvider).cleanAllBackups();
1015
+ this.debug(`Cleaned all ${deleted} backups`);
1016
+ this.emit("allBackupsCleaned", deleted);
1017
+ return deleted;
1018
+ }
1019
+
1020
+ /**
1021
+ * Get backup statistics
1022
+ */
1023
+ getBackupStats(): {
1024
+ totalBackups: number;
1025
+ totalSizeMB: number;
1026
+ oldestBackup: Date | null;
1027
+ newestBackup: Date | null;
1028
+ backupsByPlayer: Record<string, number>;
1029
+ } | null {
1030
+ if (!(this.provider instanceof FileProvider)) {
1031
+ this.debug("Backup stats only supported for file provider");
1032
+ return null;
1033
+ }
1034
+
1035
+ const stats = (this.provider as FileProvider).getBackupStats();
1036
+
1037
+ return {
1038
+ totalBackups: stats.totalBackups,
1039
+ totalSizeMB: stats.totalSize / 1024 / 1024,
1040
+ oldestBackup: stats.oldestBackup ? new Date(stats.oldestBackup) : null,
1041
+ newestBackup: stats.newestBackup ? new Date(stats.newestBackup) : null,
1042
+ backupsByPlayer: Object.fromEntries(stats.backupsByPlayer),
1043
+ };
1044
+ }
1045
+
1046
+ /**
1047
+ * Stop auto-save and clean up
1048
+ */
1049
+ async shutdown(): Promise<void> {
1050
+ if (this.saveInterval) {
1051
+ clearInterval(this.saveInterval);
1052
+ this.saveInterval = null;
1053
+ }
1054
+
1055
+ await this.saveAll();
1056
+ await this.saveDestroyedStatus();
1057
+ this.debug("Persistence manager shut down");
1058
+ }
1059
+
1060
+ /**
1061
+ * Get list of destroyed players
1062
+ */
1063
+ getDestroyedPlayers(): DestroyedRecord[] {
1064
+ return Array.from(this.destroyedPlayers.values());
1065
+ }
1066
+
1067
+ /**
1068
+ * Check if auto-restore is enabled
1069
+ */
1070
+ isAutoRestoreEnabled(): boolean {
1071
+ return this.options.autoRestoreOnRestart === true;
1072
+ }
1073
+ }