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.
- package/AI-Guide.md +407 -756
- package/README.md +275 -10
- package/dist/extensions/BaseExtension.d.ts +1 -0
- package/dist/extensions/BaseExtension.d.ts.map +1 -1
- package/dist/extensions/BaseExtension.js.map +1 -1
- package/dist/extensions/index.d.ts +38 -3
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +259 -41
- package/dist/extensions/index.js.map +1 -1
- package/dist/persistence/PersistenceManager.d.ts +95 -0
- package/dist/persistence/PersistenceManager.d.ts.map +1 -0
- package/dist/persistence/PersistenceManager.js +968 -0
- package/dist/persistence/PersistenceManager.js.map +1 -0
- package/dist/plugins/BasePlugin.js +1 -1
- package/dist/plugins/BasePlugin.js.map +1 -1
- package/dist/plugins/index.d.ts +19 -4
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +204 -113
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/FilterManager.js +3 -3
- package/dist/structures/FilterManager.js.map +1 -1
- package/dist/structures/Player.d.ts +65 -14
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +330 -88
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +127 -91
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +437 -124
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/structures/Queue.d.ts +136 -31
- package/dist/structures/Queue.d.ts.map +1 -1
- package/dist/structures/Queue.js +265 -46
- package/dist/structures/Queue.js.map +1 -1
- package/dist/types/index.d.ts +46 -6
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/persistence.d.ts +74 -0
- package/dist/types/persistence.d.ts.map +1 -0
- package/dist/types/persistence.js +3 -0
- package/dist/types/persistence.js.map +1 -0
- package/package.json +3 -2
- package/src/extensions/BaseExtension.ts +1 -0
- package/src/extensions/index.ts +320 -37
- package/src/persistence/PersistenceManager.ts +1073 -0
- package/src/plugins/BasePlugin.ts +1 -1
- package/src/plugins/index.ts +248 -133
- package/src/structures/FilterManager.ts +3 -3
- package/src/structures/Player.ts +358 -94
- package/src/structures/PlayerManager.ts +535 -129
- package/src/structures/Queue.ts +300 -55
- package/src/types/index.ts +52 -10
- package/src/types/persistence.ts +83 -0
- 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
|
+
}
|