ziplayer 0.2.7-dev.1 → 0.2.7-dev.3

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;
@@ -196,9 +386,20 @@ export class PersistenceManager extends EventEmitter {
196
386
  if (options.load !== undefined) this.options.load = options.load;
197
387
  if (options.delete !== undefined) this.options.delete = options.delete;
198
388
  if (options.list !== undefined) this.options.list = options.list;
389
+ if (options.autoConnect !== undefined) this.options.autoConnect = options.autoConnect;
199
390
 
200
391
  this.provider = this.createProvider();
201
392
 
393
+ // Hook into player destroy events
394
+ this.setupDestroyTracking();
395
+
396
+ // Clean up old backups on start
397
+ if (this.options.enabled && this.options.autoCleanupBackupsOnStart) {
398
+ this.cleanupBackupsOnStart().catch((err) => {
399
+ this.debug("Backup cleanup on start error:", err);
400
+ });
401
+ }
402
+
202
403
  if (this.options.enabled) {
203
404
  this.startAutoSave();
204
405
 
@@ -210,28 +411,33 @@ export class PersistenceManager extends EventEmitter {
210
411
  }
211
412
  }
212
413
 
213
- private createProvider(): PersistenceProvider {
414
+ private setupDestroyTracking(): void {
415
+ this.manager.on("playerDestroy", (player: Player) => {
416
+ this.markAsDestroyed(player.guildId);
417
+ });
418
+ }
419
+
420
+ private createProvider(): FileProvider | CustomProvider {
214
421
  switch (this.options.provider) {
215
422
  case "file":
216
- return new FileProvider(this.options.filePath!, this.options.maxBackups);
423
+ return new FileProvider(this.options.filePath!, {
424
+ maxBackups: this.options.maxBackups,
425
+ maxTotalBackups: this.options.maxTotalBackups,
426
+ backupRetentionDays: this.options.backupRetentionDays,
427
+ });
217
428
  case "redis":
218
- // Implement Redis provider if needed
219
429
  throw new Error("Redis provider not implemented yet");
220
430
  case "database":
221
431
  if (!this.options.save || !this.options.load) {
222
432
  throw new Error("Database provider requires save/load functions");
223
433
  }
224
- // Fix: Pass the save and load functions with correct signatures
225
434
  return new CustomProvider(
226
435
  async (key: string, data: any) => {
227
436
  if (this.options.save) {
228
- // Call with single object argument if that's expected
229
437
  const saveFn = this.options.save as any;
230
438
  if (saveFn.length === 1) {
231
- // Save function expects { key, data }
232
439
  await saveFn({ key, data });
233
440
  } else {
234
- // Save function expects (key, data)
235
441
  await saveFn(key, data);
236
442
  }
237
443
  }
@@ -240,11 +446,9 @@ export class PersistenceManager extends EventEmitter {
240
446
  if (this.options.load) {
241
447
  const loadFn = this.options.load as any;
242
448
  if (loadFn.length === 0) {
243
- // Load function expects no args, returns all data
244
449
  const allData = await loadFn();
245
450
  return allData?.get?.(key) || allData?.[key] || null;
246
451
  } else {
247
- // Load function expects key
248
452
  return await loadFn(key);
249
453
  }
250
454
  }
@@ -254,7 +458,11 @@ export class PersistenceManager extends EventEmitter {
254
458
  this.options.list,
255
459
  );
256
460
  default:
257
- return new FileProvider(this.options.filePath!, this.options.maxBackups);
461
+ return new FileProvider(this.options.filePath!, {
462
+ maxBackups: this.options.maxBackups,
463
+ maxTotalBackups: this.options.maxTotalBackups,
464
+ backupRetentionDays: this.options.backupRetentionDays,
465
+ });
258
466
  }
259
467
  }
260
468
 
@@ -279,8 +487,154 @@ export class PersistenceManager extends EventEmitter {
279
487
  this.debug(`Auto-save started (interval: ${this.options.saveInterval}ms)`);
280
488
  }
281
489
 
490
+ // NEW: Cleanup backups on startup
491
+ private async cleanupBackupsOnStart(): Promise<void> {
492
+ if (this.backupCleanupDone) return;
493
+
494
+ this.debug("Starting backup cleanup on startup...");
495
+
496
+ // Only works for file provider
497
+ if (this.provider instanceof FileProvider) {
498
+ try {
499
+ // Clean old backups by age
500
+ // This is already handled in FileProvider, but we can log stats
501
+ const stats = this.provider.getBackupStats();
502
+
503
+ this.debug(`Backup stats before cleanup:`, {
504
+ totalBackups: stats.totalBackups,
505
+ totalSizeMB: (stats.totalSize / 1024 / 1024).toFixed(2),
506
+ oldestBackup: stats.oldestBackup ? new Date(stats.oldestBackup).toISOString() : null,
507
+ newestBackup: stats.newestBackup ? new Date(stats.newestBackup).toISOString() : null,
508
+ backupsByPlayer: Object.fromEntries(stats.backupsByPlayer),
509
+ });
510
+
511
+ // Emit stats event
512
+ this.emit("backupStats", stats);
513
+
514
+ // The cleanup is already happening in FileProvider.save()
515
+ // But we can do a one-time deep cleanup on start
516
+ if (this.options.backupRetentionDays && this.options.backupRetentionDays > 0) {
517
+ // Force a cleanup pass
518
+ const deletedCount = await this.cleanOldBackupsByAge();
519
+ if (deletedCount > 0) {
520
+ this.debug(`Cleaned up ${deletedCount} old backups on startup`);
521
+ }
522
+ }
523
+
524
+ // Enforce total backup limit
525
+ const totalLimitDeleted = await this.enforceTotalBackupLimit();
526
+ if (totalLimitDeleted > 0) {
527
+ this.debug(`Enforced backup limit: deleted ${totalLimitDeleted} backups`);
528
+ }
529
+
530
+ this.backupCleanupDone = true;
531
+ this.emit("backupCleanupDone");
532
+ } catch (error) {
533
+ this.debug("Backup cleanup error:", error);
534
+ }
535
+ } else {
536
+ this.debug("Backup cleanup only supported for file provider");
537
+ }
538
+ }
539
+
540
+ // NEW: Clean old backups by age
541
+ private async cleanOldBackupsByAge(): Promise<number> {
542
+ if (!(this.provider instanceof FileProvider)) return 0;
543
+
544
+ const retentionMs = (this.options.backupRetentionDays ?? 2) * 24 * 60 * 60 * 1000;
545
+ const now = Date.now();
546
+ const backups = (this.provider as any).getAllBackups();
547
+ let deletedCount = 0;
548
+
549
+ for (const backup of backups) {
550
+ if (now - backup.timestamp > retentionMs) {
551
+ try {
552
+ fs.unlinkSync(backup.path);
553
+ deletedCount++;
554
+ } catch (err) {
555
+ this.debug(`Failed to delete old backup: ${backup.path}`, err);
556
+ }
557
+ }
558
+ }
559
+
560
+ if (deletedCount > 0) {
561
+ this.debug(`Deleted ${deletedCount} backups older than ${this.options.backupRetentionDays} days`);
562
+ }
563
+
564
+ return deletedCount;
565
+ }
566
+
567
+ // NEW: Enforce total backup limit
568
+ private async enforceTotalBackupLimit(): Promise<number> {
569
+ if (!(this.provider instanceof FileProvider)) return 0;
570
+
571
+ const backups = (this.provider as any).getAllBackups();
572
+ const maxTotal = this.options.maxTotalBackups ?? 50;
573
+
574
+ if (backups.length <= maxTotal) return 0;
575
+
576
+ const toDelete = backups.slice(maxTotal);
577
+ let deletedCount = 0;
578
+
579
+ for (const backup of toDelete) {
580
+ try {
581
+ fs.unlinkSync(backup.path);
582
+ deletedCount++;
583
+ } catch (err) {
584
+ this.debug(`Failed to delete backup: ${backup.path}`, err);
585
+ }
586
+ }
587
+
588
+ if (deletedCount > 0) {
589
+ this.debug(`Deleted ${deletedCount} backups (exceeded limit ${maxTotal})`);
590
+ }
591
+
592
+ return deletedCount;
593
+ }
594
+
595
+ private markAsDestroyed(guildId: string): void {
596
+ this.destroyedPlayers.set(guildId, {
597
+ guildId,
598
+ destroyedAt: Date.now(),
599
+ reason: "player_destroy",
600
+ });
601
+ this.debug(`Marked player as destroyed: ${guildId}`);
602
+
603
+ this.saveDestroyedStatus().catch((err) => {
604
+ this.debug("Failed to save destroyed status:", err);
605
+ });
606
+ }
607
+
608
+ private isDestroyed(guildId: string): boolean {
609
+ return this.destroyedPlayers.has(guildId);
610
+ }
611
+
612
+ private async saveDestroyedStatus(): Promise<void> {
613
+ const destroyedData = Array.from(this.destroyedPlayers.values());
614
+ await this.provider.save("__destroyed_players__", destroyedData);
615
+ }
616
+
617
+ private async loadDestroyedStatus(): Promise<void> {
618
+ try {
619
+ const data = await this.provider.load("__destroyed_players__");
620
+ if (data && Array.isArray(data)) {
621
+ this.destroyedPlayers.clear();
622
+ for (const record of data) {
623
+ this.destroyedPlayers.set(record.guildId, record);
624
+ }
625
+ this.debug(`Loaded ${this.destroyedPlayers.size} destroyed player records`);
626
+ }
627
+ } catch (error) {
628
+ this.debug("Failed to load destroyed status:", error);
629
+ }
630
+ }
631
+
632
+ private async clearDestroyedStatus(guildId: string): Promise<void> {
633
+ this.destroyedPlayers.delete(guildId);
634
+ await this.saveDestroyedStatus();
635
+ }
636
+
282
637
  private serializeTrack(track: Track): SerializedTrack {
283
- // Create base object with required fields only (avoid duplication)
284
638
  const serialized: SerializedTrack = {
285
639
  id: track.id,
286
640
  title: track.title,
@@ -292,12 +646,10 @@ export class PersistenceManager extends EventEmitter {
292
646
  isLive: track.isLive || false,
293
647
  };
294
648
 
295
- // Add optional fields if they exist on the track
296
649
  const trackAny = track as any;
297
650
  if (trackAny.author) serialized.author = trackAny.author;
298
651
  if (trackAny.artwork) serialized.artwork = trackAny.artwork;
299
652
 
300
- // Add any extra metadata (excluding fields we already set)
301
653
  const excludedFields = new Set([
302
654
  "id",
303
655
  "title",
@@ -331,17 +683,13 @@ export class PersistenceManager extends EventEmitter {
331
683
  }
332
684
 
333
685
  private serializePlayer(player: Player): SerializedPlayer {
334
- // Get filters safely - access through public method
335
686
  let filters: string[] = [];
336
687
  try {
337
688
  const filterString = (player as any).filter?.getFilterString();
338
689
  if (filterString) {
339
690
  filters = filterString.split(",").filter(Boolean);
340
691
  }
341
- } catch (e) {
342
- // Filter may not be accessible
343
- }
344
-
692
+ } catch (e) {}
345
693
  return {
346
694
  guildId: player.guildId,
347
695
  queue: this.serializeQueue(player),
@@ -352,11 +700,12 @@ 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,
704
+ channelConnection: player.channelConnection,
355
705
  };
356
706
  }
357
707
 
358
708
  private deserializeTrack(data: SerializedTrack): Track {
359
- // Create base track object
360
709
  const track: any = {
361
710
  id: data.id,
362
711
  title: data.title,
@@ -368,11 +717,9 @@ export class PersistenceManager extends EventEmitter {
368
717
  isLive: data.isLive || false,
369
718
  };
370
719
 
371
- // Add optional fields if they exist
372
720
  if (data.author) track.author = data.author;
373
721
  if (data.artwork) track.artwork = data.artwork;
374
722
 
375
- // Add any extra metadata from serialized data
376
723
  for (const key of Object.keys(data)) {
377
724
  if (
378
725
  !["id", "title", "url", "source", "duration", "thumbnail", "requestedBy", "isLive", "author", "artwork"].includes(key)
@@ -390,9 +737,14 @@ export class PersistenceManager extends EventEmitter {
390
737
  async savePlayer(player: Player): Promise<boolean> {
391
738
  if (!this.options.enabled) return false;
392
739
 
740
+ if (this.isDestroyed(player.guildId)) {
741
+ this.debug(`Skipping save for destroyed player: ${player.guildId}`);
742
+ return false;
743
+ }
744
+
393
745
  try {
394
746
  const data = this.serializePlayer(player);
395
- await this.provider.save(player.guildId, data);
747
+ await this.provider.save(player.guildId, data, this.options.compress);
396
748
  this.debug(`Saved player: ${player.guildId}`);
397
749
  this.emit("playerSaved", player.guildId);
398
750
  return true;
@@ -416,7 +768,6 @@ export class PersistenceManager extends EventEmitter {
416
768
  const players = this.manager.getAll();
417
769
  this.debug(`Saving ${players.length} players...`);
418
770
 
419
- // Save in parallel with limit
420
771
  const batchSize = 5;
421
772
  for (let i = 0; i < players.length; i += batchSize) {
422
773
  const batch = players.slice(i, i + batchSize);
@@ -437,39 +788,58 @@ export class PersistenceManager extends EventEmitter {
437
788
  /**
438
789
  * Load a single player
439
790
  */
440
- async loadPlayer(guildId: string, restorePosition: boolean = true): Promise<boolean> {
791
+ async loadPlayer(guildId: string, restorePosition: boolean = true, skipIfDestroyed: boolean = true): Promise<boolean> {
441
792
  if (!this.options.enabled) return false;
442
793
 
794
+ if (skipIfDestroyed && this.isDestroyed(guildId)) {
795
+ this.debug(`Skipping load for destroyed player: ${guildId}`);
796
+ this.emit("playerSkipped", guildId, "destroyed");
797
+ return false;
798
+ }
799
+
800
+ if (this.restoredPlayers.has(guildId)) {
801
+ this.debug(`Skipping already restored player: ${guildId}`);
802
+ return true;
803
+ }
804
+
443
805
  try {
444
806
  const data = await this.provider.load(guildId);
445
807
  if (!data) return false;
446
808
 
447
- // Check if player already exists
809
+ if (data.wasDestroyed === true) {
810
+ this.debug(`Skipping load for player marked as destroyed: ${guildId}`);
811
+ return false;
812
+ }
813
+
448
814
  let player = this.manager.get(guildId);
449
815
  if (!player) {
450
816
  player = await this.manager.create(guildId, data.options);
451
817
  }
818
+ // try connecting to the voice channel if we have one saved and autoConnect is enabled
819
+ if (data.options?.autoConnect && player.connection === null) {
820
+ try {
821
+ const voicechannel = await data.channelConnection;
822
+ await player.connect(voicechannel);
823
+ } catch (err) {
824
+ this.debug(`Failed to auto-connect player ${guildId} on load:`, err);
825
+ }
826
+ }
452
827
 
453
- // Restore queue
454
828
  const queue = data.queue as SerializedQueue;
455
829
 
456
- // Clear current queue
457
830
  player.queue.clear();
458
831
  player.queue.loop(queue.loopMode);
459
832
  player.queue.autoPlay(queue.autoPlay);
460
833
 
461
- // Restore tracks
462
834
  if (queue.tracks.length > 0) {
463
- const tracks = queue.tracks.map((t: SerializedTrack) => this.deserializeTrack(t));
835
+ const tracks = queue.tracks.map((t) => this.deserializeTrack(t));
464
836
  player.queue.addMultiple(tracks);
465
837
  }
466
838
 
467
- // Restore current track if exists
468
839
  if (queue.current && player.connection) {
469
840
  const currentTrack = this.deserializeTrack(queue.current);
470
841
  player.queue.willNextTrack(currentTrack);
471
842
 
472
- // Restore playback position if requested
473
843
  if (restorePosition && queue.position && queue.position > 0) {
474
844
  await player.refreshPlayerResource(true, queue.position);
475
845
  } else {
@@ -477,10 +847,8 @@ export class PersistenceManager extends EventEmitter {
477
847
  }
478
848
  }
479
849
 
480
- // Restore volume
481
850
  player.setVolume(data.volume);
482
851
 
483
- // Restore filters - safely access through public method
484
852
  if (data.filters && data.filters.length > 0) {
485
853
  try {
486
854
  const filterManager = (player as any).filter;
@@ -492,6 +860,9 @@ export class PersistenceManager extends EventEmitter {
492
860
  }
493
861
  }
494
862
 
863
+ this.restoredPlayers.add(guildId);
864
+ await this.clearDestroyedStatus(guildId);
865
+
495
866
  this.debug(`Loaded player: ${guildId}`);
496
867
  this.emit("playerLoaded", guildId, data);
497
868
  return true;
@@ -503,23 +874,33 @@ export class PersistenceManager extends EventEmitter {
503
874
  }
504
875
 
505
876
  /**
506
- * Load all saved players
877
+ * Load all saved players with auto-restore logic
507
878
  */
508
879
  async loadAll(restorePosition: boolean = true): Promise<Map<string, boolean>> {
509
880
  if (!this.options.enabled) return new Map();
510
881
 
882
+ await this.loadDestroyedStatus();
883
+
511
884
  const results = new Map<string, boolean>();
512
885
 
513
886
  try {
514
887
  const keys = await this.provider.list();
515
- this.debug(`Found ${keys.length} saved players`);
888
+ const playerKeys = keys.filter((k) => k !== "__destroyed_players__");
889
+ this.debug(`Found ${playerKeys.length} saved players`);
516
890
 
517
- for (const guildId of keys) {
518
- const success = await this.loadPlayer(guildId, restorePosition);
519
- results.set(guildId, success);
520
- }
891
+ if (this.options.autoRestoreOnRestart) {
892
+ this.debug(`Auto-restore enabled, restoring players after ${this.options.restoreDelay}ms delay...`);
521
893
 
522
- this.emit("loadedAll", results);
894
+ if (this.options.restoreDelay && this.options.restoreDelay > 0) {
895
+ await new Promise((resolve) => setTimeout(resolve, this.options.restoreDelay));
896
+ }
897
+
898
+ for (const guildId of playerKeys) {
899
+ const success = await this.loadPlayer(guildId, restorePosition, true);
900
+ results.set(guildId, success);
901
+ }
902
+ this.emit("loadedAll", results);
903
+ }
523
904
  } catch (error) {
524
905
  this.debug("Failed to load players:", error);
525
906
  this.emit("error", error);
@@ -528,6 +909,53 @@ export class PersistenceManager extends EventEmitter {
528
909
  return results;
529
910
  }
530
911
 
912
+ /**
913
+ * Mark a player as destroyed
914
+ */
915
+ async markPlayerDestroyed(guildId: string, reason?: string): Promise<void> {
916
+ this.destroyedPlayers.set(guildId, {
917
+ guildId,
918
+ destroyedAt: Date.now(),
919
+ reason: reason || "manual_destroy",
920
+ });
921
+
922
+ try {
923
+ const data = await this.provider.load(guildId);
924
+ if (data) {
925
+ data.wasDestroyed = true;
926
+ data.destroyedAt = Date.now();
927
+ await this.provider.save(guildId, data, this.options.compress);
928
+ }
929
+ } catch (error) {
930
+ this.debug(`Failed to mark player data as destroyed: ${guildId}`, error);
931
+ }
932
+
933
+ await this.saveDestroyedStatus();
934
+ this.debug(`Marked player as destroyed (won't restore on restart): ${guildId}`);
935
+ this.emit("playerMarkedDestroyed", guildId);
936
+ }
937
+
938
+ /**
939
+ * Clear destroyed status for a player
940
+ */
941
+ async clearDestroyed(guildId: string): Promise<void> {
942
+ await this.clearDestroyedStatus(guildId);
943
+
944
+ try {
945
+ const data = await this.provider.load(guildId);
946
+ if (data) {
947
+ data.wasDestroyed = false;
948
+ delete data.destroyedAt;
949
+ await this.provider.save(guildId, data, this.options.compress);
950
+ }
951
+ } catch (error) {
952
+ this.debug(`Failed to clear destroyed flag in data: ${guildId}`, error);
953
+ }
954
+
955
+ this.debug(`Cleared destroyed status for: ${guildId}`);
956
+ this.emit("playerDestroyedCleared", guildId);
957
+ }
958
+
531
959
  /**
532
960
  * Delete a player's saved data
533
961
  */
@@ -536,6 +964,8 @@ export class PersistenceManager extends EventEmitter {
536
964
 
537
965
  try {
538
966
  await this.provider.delete(guildId);
967
+ await this.clearDestroyedStatus(guildId);
968
+ this.restoredPlayers.delete(guildId);
539
969
  this.debug(`Deleted saved data for: ${guildId}`);
540
970
  this.emit("playerDeleted", guildId);
541
971
  return true;
@@ -554,7 +984,67 @@ export class PersistenceManager extends EventEmitter {
554
984
  return false;
555
985
  }
556
986
 
557
- return await (this.provider as FileProvider).restoreBackup(guildId, timestamp);
987
+ const success = await (this.provider as FileProvider).restoreBackup(guildId, timestamp);
988
+ if (success) {
989
+ await this.clearDestroyed(guildId);
990
+ }
991
+ return success;
992
+ }
993
+
994
+ /**
995
+ * Clean all backups for a specific player
996
+ */
997
+ async cleanBackupsForPlayer(guildId: string): Promise<number> {
998
+ if (!(this.provider instanceof FileProvider)) {
999
+ this.debug("Backup cleanup only supported for file provider");
1000
+ return 0;
1001
+ }
1002
+
1003
+ const deleted = await (this.provider as FileProvider).cleanAllBackupsForPlayer(guildId);
1004
+ this.debug(`Cleaned ${deleted} backups for player: ${guildId}`);
1005
+ this.emit("backupsCleaned", guildId, deleted);
1006
+ return deleted;
1007
+ }
1008
+
1009
+ /**
1010
+ * Clean all backups
1011
+ */
1012
+ async cleanAllBackups(): Promise<number> {
1013
+ if (!(this.provider instanceof FileProvider)) {
1014
+ this.debug("Backup cleanup only supported for file provider");
1015
+ return 0;
1016
+ }
1017
+
1018
+ const deleted = await (this.provider as FileProvider).cleanAllBackups();
1019
+ this.debug(`Cleaned all ${deleted} backups`);
1020
+ this.emit("allBackupsCleaned", deleted);
1021
+ return deleted;
1022
+ }
1023
+
1024
+ /**
1025
+ * Get backup statistics
1026
+ */
1027
+ getBackupStats(): {
1028
+ totalBackups: number;
1029
+ totalSizeMB: number;
1030
+ oldestBackup: Date | null;
1031
+ newestBackup: Date | null;
1032
+ backupsByPlayer: Record<string, number>;
1033
+ } | null {
1034
+ if (!(this.provider instanceof FileProvider)) {
1035
+ this.debug("Backup stats only supported for file provider");
1036
+ return null;
1037
+ }
1038
+
1039
+ const stats = (this.provider as FileProvider).getBackupStats();
1040
+
1041
+ return {
1042
+ totalBackups: stats.totalBackups,
1043
+ totalSizeMB: stats.totalSize / 1024 / 1024,
1044
+ oldestBackup: stats.oldestBackup ? new Date(stats.oldestBackup) : null,
1045
+ newestBackup: stats.newestBackup ? new Date(stats.newestBackup) : null,
1046
+ backupsByPlayer: Object.fromEntries(stats.backupsByPlayer),
1047
+ };
558
1048
  }
559
1049
 
560
1050
  /**
@@ -567,6 +1057,21 @@ export class PersistenceManager extends EventEmitter {
567
1057
  }
568
1058
 
569
1059
  await this.saveAll();
1060
+ await this.saveDestroyedStatus();
570
1061
  this.debug("Persistence manager shut down");
571
1062
  }
1063
+
1064
+ /**
1065
+ * Get list of destroyed players
1066
+ */
1067
+ getDestroyedPlayers(): DestroyedRecord[] {
1068
+ return Array.from(this.destroyedPlayers.values());
1069
+ }
1070
+
1071
+ /**
1072
+ * Check if auto-restore is enabled
1073
+ */
1074
+ isAutoRestoreEnabled(): boolean {
1075
+ return this.options.autoRestoreOnRestart === true;
1076
+ }
572
1077
  }