ziplayer 0.2.7-dev.1 → 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.
@@ -3,7 +3,15 @@ import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import * as zlib from "zlib";
5
5
  import { promisify } from "util";
6
- import type { SerializedPlayer, SerializedQueue, SerializedTrack, PersistenceOptions, PersistenceProvider } from "../types";
6
+ import type {
7
+ SerializedPlayer,
8
+ SerializedQueue,
9
+ SerializedTrack,
10
+ PersistenceOptions,
11
+ PersistenceProvider,
12
+ DestroyedRecord,
13
+ BackupInfo,
14
+ } from "../types";
7
15
  import type { Player } from "../structures/Player";
8
16
  import type { PlayerManager } from "../structures/PlayerManager";
9
17
  import type { Track } from "../types";
@@ -11,14 +19,26 @@ import type { Track } from "../types";
11
19
  const gzip = promisify(zlib.gzip);
12
20
  const gunzip = promisify(zlib.gunzip);
13
21
 
14
- // File provider implementation
15
- export class FileProvider implements PersistenceProvider {
22
+ // File provider implementation with enhanced backup management
23
+ class FileProvider implements PersistenceProvider {
16
24
  private basePath: string;
17
25
  private maxBackups: number;
26
+ private maxTotalBackups: number;
27
+ private backupRetentionDays: number;
18
28
 
19
- constructor(basePath: string, maxBackups: number = 5) {
29
+ constructor(
30
+ basePath: string,
31
+ options: {
32
+ maxBackups?: number;
33
+ maxTotalBackups?: number;
34
+ backupRetentionDays?: number;
35
+ } = {},
36
+ ) {
20
37
  this.basePath = basePath;
21
- this.maxBackups = maxBackups;
38
+ this.maxBackups = options.maxBackups ?? 5;
39
+ this.maxTotalBackups = options.maxTotalBackups ?? 50;
40
+ this.backupRetentionDays = options.backupRetentionDays ?? 7;
41
+
22
42
  if (!fs.existsSync(basePath)) {
23
43
  fs.mkdirSync(basePath, { recursive: true });
24
44
  }
@@ -32,18 +52,165 @@ export class FileProvider implements PersistenceProvider {
32
52
  return path.join(this.basePath, `${key}_backup_${timestamp}.json`);
33
53
  }
34
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
+
35
85
  private cleanOldBackups(key: string): void {
36
- const backups = fs
37
- .readdirSync(this.basePath)
38
- .filter((f) => f.startsWith(key) && f.includes("backup"))
39
- .sort()
40
- .reverse();
86
+ const backups = this.getBackupsByKey(key);
41
87
 
42
- // Keep only maxBackups most recent
88
+ // Delete old backups exceeding maxBackups per player
43
89
  for (let i = this.maxBackups; i < backups.length; i++) {
44
- const backupPath = path.join(this.basePath, backups[i]);
45
- fs.unlinkSync(backupPath);
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);
46
205
  }
206
+
207
+ return {
208
+ totalBackups: backups.length,
209
+ totalSize,
210
+ oldestBackup,
211
+ newestBackup,
212
+ backupsByPlayer,
213
+ };
47
214
  }
48
215
 
49
216
  async save(key: string, data: any, compress: boolean = false): Promise<void> {
@@ -62,6 +229,8 @@ export class FileProvider implements PersistenceProvider {
62
229
  const backupPath = this.getBackupPath(key, Date.now());
63
230
  fs.copyFileSync(filePath, backupPath);
64
231
  this.cleanOldBackups(key);
232
+ this.cleanTotalBackupsLimit();
233
+ this.cleanOldBackupsByAge();
65
234
  }
66
235
 
67
236
  fs.writeFileSync(filePath, content);
@@ -92,11 +261,20 @@ export class FileProvider implements PersistenceProvider {
92
261
 
93
262
  if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
94
263
  if (fs.existsSync(gzPath)) fs.unlinkSync(gzPath);
264
+
265
+ // Also delete backups for this player
266
+ await this.cleanAllBackupsForPlayer(key);
95
267
  }
96
268
 
97
269
  async list(): Promise<string[]> {
98
270
  const files = fs.readdirSync(this.basePath);
99
- return files.filter((f) => f.endsWith(".json") || f.endsWith(".json.gz")).map((f) => f.replace(/\.json(\.gz)?$/, ""));
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)?$/, ""));
100
278
  }
101
279
 
102
280
  async restoreBackup(key: string, backupTimestamp?: number): Promise<boolean> {
@@ -109,13 +287,9 @@ export class FileProvider implements PersistenceProvider {
109
287
  }
110
288
  } else {
111
289
  // Get latest backup
112
- const backups = fs
113
- .readdirSync(this.basePath)
114
- .filter((f) => f.startsWith(key) && f.includes("backup"))
115
- .sort()
116
- .reverse();
290
+ const backups = this.getBackupsByKey(key);
117
291
  if (backups.length > 0) {
118
- backupFile = path.join(this.basePath, backups[0]);
292
+ backupFile = backups[0].path;
119
293
  }
120
294
  }
121
295
 
@@ -130,7 +304,7 @@ export class FileProvider implements PersistenceProvider {
130
304
  }
131
305
  }
132
306
 
133
- // Custom provider for database integration
307
+ // Custom provider for database integration (giữ nguyên)
134
308
  class CustomProvider implements PersistenceProvider {
135
309
  constructor(
136
310
  private saveFn: (key: string, data: any) => Promise<void>,
@@ -164,30 +338,46 @@ class CustomProvider implements PersistenceProvider {
164
338
  export class PersistenceManager extends EventEmitter {
165
339
  private manager: PlayerManager;
166
340
  private options: PersistenceOptions;
167
- private provider: PersistenceProvider;
341
+ private provider: FileProvider | CustomProvider;
168
342
  private saveInterval: NodeJS.Timeout | null = null;
169
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;
170
348
 
171
349
  constructor(manager: PlayerManager, options: PersistenceOptions) {
172
350
  super();
173
351
  this.manager = manager;
174
- // Fix: Don't use spread that causes duplicate 'enabled'
352
+
353
+ // Default options
175
354
  this.options = {
176
355
  enabled: true,
177
356
  provider: "file",
178
357
  saveInterval: 60000,
179
358
  autoLoad: true,
359
+ autoRestoreOnRestart: true,
360
+ restoreDelay: 5000,
180
361
  maxBackups: 5,
362
+ maxTotalBackups: 10,
363
+ autoCleanupBackupsOnStart: true,
364
+ backupRetentionDays: 2,
181
365
  compress: false,
182
366
  filePath: "./players_data",
183
367
  };
184
368
 
185
- // Merge options manually to avoid spread duplication
369
+ // Merge manually
186
370
  if (options.enabled !== undefined) this.options.enabled = options.enabled;
187
371
  if (options.provider !== undefined) this.options.provider = options.provider;
188
372
  if (options.saveInterval !== undefined) this.options.saveInterval = options.saveInterval;
189
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;
190
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;
191
381
  if (options.compress !== undefined) this.options.compress = options.compress;
192
382
  if (options.filePath !== undefined) this.options.filePath = options.filePath;
193
383
  if (options.redisUrl !== undefined) this.options.redisUrl = options.redisUrl;
@@ -199,6 +389,16 @@ export class PersistenceManager extends EventEmitter {
199
389
 
200
390
  this.provider = this.createProvider();
201
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
+
202
402
  if (this.options.enabled) {
203
403
  this.startAutoSave();
204
404
 
@@ -210,28 +410,33 @@ export class PersistenceManager extends EventEmitter {
210
410
  }
211
411
  }
212
412
 
213
- private createProvider(): PersistenceProvider {
413
+ private setupDestroyTracking(): void {
414
+ this.manager.on("playerDestroy", (player: Player) => {
415
+ this.markAsDestroyed(player.guildId);
416
+ });
417
+ }
418
+
419
+ private createProvider(): FileProvider | CustomProvider {
214
420
  switch (this.options.provider) {
215
421
  case "file":
216
- return new FileProvider(this.options.filePath!, this.options.maxBackups);
422
+ return new FileProvider(this.options.filePath!, {
423
+ maxBackups: this.options.maxBackups,
424
+ maxTotalBackups: this.options.maxTotalBackups,
425
+ backupRetentionDays: this.options.backupRetentionDays,
426
+ });
217
427
  case "redis":
218
- // Implement Redis provider if needed
219
428
  throw new Error("Redis provider not implemented yet");
220
429
  case "database":
221
430
  if (!this.options.save || !this.options.load) {
222
431
  throw new Error("Database provider requires save/load functions");
223
432
  }
224
- // Fix: Pass the save and load functions with correct signatures
225
433
  return new CustomProvider(
226
434
  async (key: string, data: any) => {
227
435
  if (this.options.save) {
228
- // Call with single object argument if that's expected
229
436
  const saveFn = this.options.save as any;
230
437
  if (saveFn.length === 1) {
231
- // Save function expects { key, data }
232
438
  await saveFn({ key, data });
233
439
  } else {
234
- // Save function expects (key, data)
235
440
  await saveFn(key, data);
236
441
  }
237
442
  }
@@ -240,11 +445,9 @@ export class PersistenceManager extends EventEmitter {
240
445
  if (this.options.load) {
241
446
  const loadFn = this.options.load as any;
242
447
  if (loadFn.length === 0) {
243
- // Load function expects no args, returns all data
244
448
  const allData = await loadFn();
245
449
  return allData?.get?.(key) || allData?.[key] || null;
246
450
  } else {
247
- // Load function expects key
248
451
  return await loadFn(key);
249
452
  }
250
453
  }
@@ -254,7 +457,11 @@ export class PersistenceManager extends EventEmitter {
254
457
  this.options.list,
255
458
  );
256
459
  default:
257
- return new FileProvider(this.options.filePath!, this.options.maxBackups);
460
+ return new FileProvider(this.options.filePath!, {
461
+ maxBackups: this.options.maxBackups,
462
+ maxTotalBackups: this.options.maxTotalBackups,
463
+ backupRetentionDays: this.options.backupRetentionDays,
464
+ });
258
465
  }
259
466
  }
260
467
 
@@ -279,8 +486,154 @@ export class PersistenceManager extends EventEmitter {
279
486
  this.debug(`Auto-save started (interval: ${this.options.saveInterval}ms)`);
280
487
  }
281
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
+
282
636
  private serializeTrack(track: Track): SerializedTrack {
283
- // Create base object with required fields only (avoid duplication)
284
637
  const serialized: SerializedTrack = {
285
638
  id: track.id,
286
639
  title: track.title,
@@ -292,12 +645,10 @@ export class PersistenceManager extends EventEmitter {
292
645
  isLive: track.isLive || false,
293
646
  };
294
647
 
295
- // Add optional fields if they exist on the track
296
648
  const trackAny = track as any;
297
649
  if (trackAny.author) serialized.author = trackAny.author;
298
650
  if (trackAny.artwork) serialized.artwork = trackAny.artwork;
299
651
 
300
- // Add any extra metadata (excluding fields we already set)
301
652
  const excludedFields = new Set([
302
653
  "id",
303
654
  "title",
@@ -331,16 +682,13 @@ export class PersistenceManager extends EventEmitter {
331
682
  }
332
683
 
333
684
  private serializePlayer(player: Player): SerializedPlayer {
334
- // Get filters safely - access through public method
335
685
  let filters: string[] = [];
336
686
  try {
337
687
  const filterString = (player as any).filter?.getFilterString();
338
688
  if (filterString) {
339
689
  filters = filterString.split(",").filter(Boolean);
340
690
  }
341
- } catch (e) {
342
- // Filter may not be accessible
343
- }
691
+ } catch (e) {}
344
692
 
345
693
  return {
346
694
  guildId: player.guildId,
@@ -352,11 +700,11 @@ export class PersistenceManager extends EventEmitter {
352
700
  filters: filters.length > 0 ? filters : undefined,
353
701
  lastUpdate: Date.now(),
354
702
  version: "1.0.0",
703
+ wasDestroyed: false,
355
704
  };
356
705
  }
357
706
 
358
707
  private deserializeTrack(data: SerializedTrack): Track {
359
- // Create base track object
360
708
  const track: any = {
361
709
  id: data.id,
362
710
  title: data.title,
@@ -368,11 +716,9 @@ export class PersistenceManager extends EventEmitter {
368
716
  isLive: data.isLive || false,
369
717
  };
370
718
 
371
- // Add optional fields if they exist
372
719
  if (data.author) track.author = data.author;
373
720
  if (data.artwork) track.artwork = data.artwork;
374
721
 
375
- // Add any extra metadata from serialized data
376
722
  for (const key of Object.keys(data)) {
377
723
  if (
378
724
  !["id", "title", "url", "source", "duration", "thumbnail", "requestedBy", "isLive", "author", "artwork"].includes(key)
@@ -390,9 +736,14 @@ export class PersistenceManager extends EventEmitter {
390
736
  async savePlayer(player: Player): Promise<boolean> {
391
737
  if (!this.options.enabled) return false;
392
738
 
739
+ if (this.isDestroyed(player.guildId)) {
740
+ this.debug(`Skipping save for destroyed player: ${player.guildId}`);
741
+ return false;
742
+ }
743
+
393
744
  try {
394
745
  const data = this.serializePlayer(player);
395
- await this.provider.save(player.guildId, data);
746
+ await this.provider.save(player.guildId, data, this.options.compress);
396
747
  this.debug(`Saved player: ${player.guildId}`);
397
748
  this.emit("playerSaved", player.guildId);
398
749
  return true;
@@ -416,7 +767,6 @@ export class PersistenceManager extends EventEmitter {
416
767
  const players = this.manager.getAll();
417
768
  this.debug(`Saving ${players.length} players...`);
418
769
 
419
- // Save in parallel with limit
420
770
  const batchSize = 5;
421
771
  for (let i = 0; i < players.length; i += batchSize) {
422
772
  const batch = players.slice(i, i + batchSize);
@@ -437,39 +787,49 @@ export class PersistenceManager extends EventEmitter {
437
787
  /**
438
788
  * Load a single player
439
789
  */
440
- async loadPlayer(guildId: string, restorePosition: boolean = true): Promise<boolean> {
790
+ async loadPlayer(guildId: string, restorePosition: boolean = true, skipIfDestroyed: boolean = true): Promise<boolean> {
441
791
  if (!this.options.enabled) return false;
442
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
+
443
804
  try {
444
805
  const data = await this.provider.load(guildId);
445
806
  if (!data) return false;
446
807
 
447
- // Check if player already exists
808
+ if (data.wasDestroyed === true) {
809
+ this.debug(`Skipping load for player marked as destroyed: ${guildId}`);
810
+ return false;
811
+ }
812
+
448
813
  let player = this.manager.get(guildId);
449
814
  if (!player) {
450
815
  player = await this.manager.create(guildId, data.options);
451
816
  }
452
817
 
453
- // Restore queue
454
818
  const queue = data.queue as SerializedQueue;
455
819
 
456
- // Clear current queue
457
820
  player.queue.clear();
458
821
  player.queue.loop(queue.loopMode);
459
822
  player.queue.autoPlay(queue.autoPlay);
460
823
 
461
- // Restore tracks
462
824
  if (queue.tracks.length > 0) {
463
- const tracks = queue.tracks.map((t: SerializedTrack) => this.deserializeTrack(t));
825
+ const tracks = queue.tracks.map((t) => this.deserializeTrack(t));
464
826
  player.queue.addMultiple(tracks);
465
827
  }
466
828
 
467
- // Restore current track if exists
468
829
  if (queue.current && player.connection) {
469
830
  const currentTrack = this.deserializeTrack(queue.current);
470
831
  player.queue.willNextTrack(currentTrack);
471
832
 
472
- // Restore playback position if requested
473
833
  if (restorePosition && queue.position && queue.position > 0) {
474
834
  await player.refreshPlayerResource(true, queue.position);
475
835
  } else {
@@ -477,10 +837,8 @@ export class PersistenceManager extends EventEmitter {
477
837
  }
478
838
  }
479
839
 
480
- // Restore volume
481
840
  player.setVolume(data.volume);
482
841
 
483
- // Restore filters - safely access through public method
484
842
  if (data.filters && data.filters.length > 0) {
485
843
  try {
486
844
  const filterManager = (player as any).filter;
@@ -492,6 +850,9 @@ export class PersistenceManager extends EventEmitter {
492
850
  }
493
851
  }
494
852
 
853
+ this.restoredPlayers.add(guildId);
854
+ await this.clearDestroyedStatus(guildId);
855
+
495
856
  this.debug(`Loaded player: ${guildId}`);
496
857
  this.emit("playerLoaded", guildId, data);
497
858
  return true;
@@ -503,20 +864,36 @@ export class PersistenceManager extends EventEmitter {
503
864
  }
504
865
 
505
866
  /**
506
- * Load all saved players
867
+ * Load all saved players with auto-restore logic
507
868
  */
508
869
  async loadAll(restorePosition: boolean = true): Promise<Map<string, boolean>> {
509
870
  if (!this.options.enabled) return new Map();
510
871
 
872
+ await this.loadDestroyedStatus();
873
+
511
874
  const results = new Map<string, boolean>();
512
875
 
513
876
  try {
514
877
  const keys = await this.provider.list();
515
- this.debug(`Found ${keys.length} saved players`);
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
+ }
516
887
 
517
- for (const guildId of keys) {
518
- const success = await this.loadPlayer(guildId, restorePosition);
519
- results.set(guildId, success);
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
+ }
520
897
  }
521
898
 
522
899
  this.emit("loadedAll", results);
@@ -528,6 +905,53 @@ export class PersistenceManager extends EventEmitter {
528
905
  return results;
529
906
  }
530
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
+
531
955
  /**
532
956
  * Delete a player's saved data
533
957
  */
@@ -536,6 +960,8 @@ export class PersistenceManager extends EventEmitter {
536
960
 
537
961
  try {
538
962
  await this.provider.delete(guildId);
963
+ await this.clearDestroyedStatus(guildId);
964
+ this.restoredPlayers.delete(guildId);
539
965
  this.debug(`Deleted saved data for: ${guildId}`);
540
966
  this.emit("playerDeleted", guildId);
541
967
  return true;
@@ -554,7 +980,67 @@ export class PersistenceManager extends EventEmitter {
554
980
  return false;
555
981
  }
556
982
 
557
- return await (this.provider as FileProvider).restoreBackup(guildId, timestamp);
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
+ };
558
1044
  }
559
1045
 
560
1046
  /**
@@ -567,6 +1053,21 @@ export class PersistenceManager extends EventEmitter {
567
1053
  }
568
1054
 
569
1055
  await this.saveAll();
1056
+ await this.saveDestroyedStatus();
570
1057
  this.debug("Persistence manager shut down");
571
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
+ }
572
1073
  }