ziplayer 0.2.7-dev.0 → 0.2.7-dev.1
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 +265 -11
- 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 +61 -0
- package/dist/persistence/PersistenceManager.d.ts.map +1 -0
- package/dist/persistence/PersistenceManager.js +551 -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 +64 -14
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +326 -88
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +125 -91
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +406 -111
- 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 +39 -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 +55 -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 +572 -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 +352 -94
- package/src/structures/PlayerManager.ts +488 -116
- package/src/structures/Queue.ts +300 -55
- package/src/types/index.ts +43 -10
- package/src/types/persistence.ts +65 -0
- package/src/types/plugin.ts +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { LoopMode, PlayerOptions } from ".";
|
|
2
|
+
export interface SerializedTrack {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
url: string;
|
|
6
|
+
source: string;
|
|
7
|
+
duration: number;
|
|
8
|
+
thumbnail?: string;
|
|
9
|
+
author?: string;
|
|
10
|
+
requestedBy?: string;
|
|
11
|
+
isLive?: boolean;
|
|
12
|
+
artwork?: string;
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
}
|
|
15
|
+
export interface SerializedQueue {
|
|
16
|
+
tracks: SerializedTrack[];
|
|
17
|
+
current: SerializedTrack | null;
|
|
18
|
+
history: SerializedTrack[];
|
|
19
|
+
loopMode: LoopMode;
|
|
20
|
+
autoPlay: boolean;
|
|
21
|
+
position?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface SerializedPlayer {
|
|
24
|
+
guildId: string;
|
|
25
|
+
queue: SerializedQueue;
|
|
26
|
+
volume: number;
|
|
27
|
+
isPlaying: boolean;
|
|
28
|
+
isPaused: boolean;
|
|
29
|
+
options: PlayerOptions;
|
|
30
|
+
filters?: string[];
|
|
31
|
+
lastUpdate: number;
|
|
32
|
+
version: string;
|
|
33
|
+
}
|
|
34
|
+
export interface PersistenceOptions {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
provider?: "file" | "redis" | "database";
|
|
37
|
+
saveInterval?: number;
|
|
38
|
+
autoLoad?: boolean;
|
|
39
|
+
maxBackups?: number;
|
|
40
|
+
compress?: boolean;
|
|
41
|
+
filePath?: string;
|
|
42
|
+
redisUrl?: string;
|
|
43
|
+
redisPrefix?: string;
|
|
44
|
+
save?: (data: Map<string, SerializedPlayer>) => Promise<void>;
|
|
45
|
+
load?: () => Promise<Map<string, SerializedPlayer>>;
|
|
46
|
+
delete?: (guildId: string) => Promise<void>;
|
|
47
|
+
list?: () => Promise<string[]>;
|
|
48
|
+
}
|
|
49
|
+
export interface PersistenceProvider {
|
|
50
|
+
save(key: string, data: any, compress?: boolean): Promise<void>;
|
|
51
|
+
load(key: string): Promise<any>;
|
|
52
|
+
delete(key: string): Promise<void>;
|
|
53
|
+
list(): Promise<string[]>;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=persistence.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../src/types/persistence.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,QAAQ,EAAE,aAAa,EAAE,MAAM,GAAG,CAAC;AAExD,MAAM,WAAW,eAAe;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACnB;AAED,MAAM,WAAW,eAAe;IAC/B,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,OAAO,EAAE,eAAe,GAAG,IAAI,CAAC;IAChC,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,QAAQ,EAAE,QAAQ,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,eAAe,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,aAAa,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,UAAU,CAAC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IAGnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAGlB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IAGrB,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D,IAAI,CAAC,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC;IACpD,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,IAAI,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,mBAAmB;IACnC,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChE,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAChC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAC1B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence.js","sourceRoot":"","sources":["../../src/types/persistence.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ziplayer",
|
|
3
|
-
"version": "0.2.7-dev.
|
|
3
|
+
"version": "0.2.7-dev.1",
|
|
4
4
|
"description": "A modular Discord voice player with plugin system",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ZiPlayer",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"voice",
|
|
11
11
|
"@ziplayer/plugin"
|
|
12
12
|
],
|
|
13
|
-
"homepage": "https://player.ziji.
|
|
13
|
+
"homepage": "https://player.ziji.best",
|
|
14
14
|
"bugs": {
|
|
15
15
|
"url": "https://github.com/ZiProject/ZiPlayer/issues"
|
|
16
16
|
},
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"discord-api-types": "^0.38.37",
|
|
32
32
|
"ffmpeg-static": "^5.2.0",
|
|
33
33
|
"libsodium-wrappers": "^0.7.15",
|
|
34
|
+
"lru-cache": "^11.3.6",
|
|
34
35
|
"opusscript": "^0.1.1",
|
|
35
36
|
"prism-media": "^1.3.5"
|
|
36
37
|
},
|
|
@@ -14,6 +14,7 @@ import type { Player } from "../structures/Player";
|
|
|
14
14
|
export abstract class BaseExtension implements SourceExtension {
|
|
15
15
|
abstract name: string;
|
|
16
16
|
abstract version: string;
|
|
17
|
+
priority?: number; // Higher = run first
|
|
17
18
|
abstract player: Player | null;
|
|
18
19
|
abstract active(alas: any): boolean | Promise<boolean>;
|
|
19
20
|
|
package/src/extensions/index.ts
CHANGED
|
@@ -16,19 +16,57 @@ import { BaseExtension } from "./BaseExtension";
|
|
|
16
16
|
|
|
17
17
|
export { BaseExtension } from "./BaseExtension";
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
interface ExtensionCacheEntry<T> {
|
|
20
|
+
data: T;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ExtensionMetadata {
|
|
26
|
+
name: string;
|
|
27
|
+
priority: number;
|
|
28
|
+
registeredAt: number;
|
|
29
|
+
hasSearch: boolean;
|
|
30
|
+
hasStream: boolean;
|
|
31
|
+
hasBeforePlay: boolean;
|
|
32
|
+
hasAfterPlay: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
export class ExtensionManager {
|
|
21
36
|
private extensions: Map<string, BaseExtension>;
|
|
37
|
+
private extensionMetadata: Map<string, ExtensionMetadata>;
|
|
22
38
|
private player: Player;
|
|
23
39
|
private manager: PlayerManager;
|
|
24
40
|
private extensionContext: ExtensionContext;
|
|
25
41
|
|
|
42
|
+
// Caches for different operations
|
|
43
|
+
private searchCache: Map<string, ExtensionCacheEntry<SearchResult>>;
|
|
44
|
+
private streamCache: Map<string, ExtensionCacheEntry<StreamInfo>>;
|
|
45
|
+
|
|
46
|
+
// Cache TTLs
|
|
47
|
+
private readonly SEARCH_CACHE_TTL = 60 * 1000; // 1 minute
|
|
48
|
+
private readonly STREAM_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
49
|
+
private readonly MAX_CACHE_SIZE = 100;
|
|
50
|
+
|
|
51
|
+
// Pending requests for deduplication
|
|
52
|
+
private pendingSearches: Map<string, Promise<SearchResult | null>>;
|
|
53
|
+
private pendingStreams: Map<string, Promise<StreamInfo | null>>;
|
|
54
|
+
|
|
26
55
|
constructor(player: Player, manager: PlayerManager) {
|
|
27
56
|
this.player = player;
|
|
28
57
|
this.manager = manager;
|
|
29
58
|
this.extensions = new Map();
|
|
59
|
+
this.extensionMetadata = new Map();
|
|
60
|
+
this.searchCache = new Map();
|
|
61
|
+
this.streamCache = new Map();
|
|
62
|
+
this.pendingSearches = new Map();
|
|
63
|
+
this.pendingStreams = new Map();
|
|
30
64
|
this.extensionContext = Object.freeze({ player, manager });
|
|
65
|
+
|
|
66
|
+
// Auto-cleanup caches periodically
|
|
67
|
+
setInterval(() => this.cleanupCaches(), 5 * 60 * 1000);
|
|
31
68
|
}
|
|
69
|
+
|
|
32
70
|
debug(message?: any, ...optionalParams: any[]): void {
|
|
33
71
|
if (this.manager.debugEnabled) {
|
|
34
72
|
this.manager.emit("debug", `[ExtensionManager] ${message}`, ...optionalParams);
|
|
@@ -37,29 +75,55 @@ export class ExtensionManager {
|
|
|
37
75
|
|
|
38
76
|
register(extension: BaseExtension): void {
|
|
39
77
|
if (this.extensions.has(extension.name)) {
|
|
78
|
+
this.debug(`Extension ${extension.name} already registered, skipping`);
|
|
40
79
|
return;
|
|
41
80
|
}
|
|
81
|
+
|
|
42
82
|
if (!extension.player) {
|
|
43
83
|
extension.player = this.player;
|
|
44
84
|
}
|
|
85
|
+
|
|
86
|
+
// Set default priority if not set
|
|
87
|
+
extension.priority ??= 0;
|
|
88
|
+
|
|
89
|
+
// Store metadata for optimization
|
|
90
|
+
const metadata: ExtensionMetadata = {
|
|
91
|
+
name: extension.name,
|
|
92
|
+
priority: extension.priority,
|
|
93
|
+
registeredAt: Date.now(),
|
|
94
|
+
hasSearch: typeof (extension as any).provideSearch === "function",
|
|
95
|
+
hasStream: typeof (extension as any).provideStream === "function",
|
|
96
|
+
hasBeforePlay: typeof (extension as any).beforePlay === "function",
|
|
97
|
+
hasAfterPlay: typeof (extension as any).afterPlay === "function",
|
|
98
|
+
};
|
|
99
|
+
|
|
45
100
|
this.extensions.set(extension.name, extension);
|
|
101
|
+
this.extensionMetadata.set(extension.name, metadata);
|
|
102
|
+
this.invokeExtensionLifecycle(extension, "onRegister");
|
|
103
|
+
this.debug(`Registered extension: ${extension.name} (priority: ${extension.priority})`);
|
|
46
104
|
}
|
|
47
105
|
|
|
48
106
|
unregister(extension: BaseExtension): boolean {
|
|
49
107
|
const name = extension.name;
|
|
50
108
|
const result = this.extensions.delete(name);
|
|
51
109
|
if (result) {
|
|
110
|
+
this.extensionMetadata.delete(name);
|
|
52
111
|
this.invokeExtensionLifecycle(extension, "onDestroy");
|
|
112
|
+
this.debug(`Unregistered extension: ${name}`);
|
|
53
113
|
}
|
|
54
114
|
return result;
|
|
55
115
|
}
|
|
56
116
|
|
|
57
117
|
destroy(): void {
|
|
58
|
-
this.debug(`
|
|
118
|
+
this.debug(`Destroying all extensions`);
|
|
59
119
|
for (const extension of this.extensions.values()) {
|
|
60
120
|
this.unregister(extension);
|
|
61
121
|
}
|
|
62
122
|
this.extensions.clear();
|
|
123
|
+
this.extensionMetadata.clear();
|
|
124
|
+
this.clearAllCaches();
|
|
125
|
+
this.pendingSearches.clear();
|
|
126
|
+
this.pendingStreams.clear();
|
|
63
127
|
}
|
|
64
128
|
|
|
65
129
|
get(name: string): BaseExtension | undefined {
|
|
@@ -67,15 +131,41 @@ export class ExtensionManager {
|
|
|
67
131
|
}
|
|
68
132
|
|
|
69
133
|
getAll(): BaseExtension[] {
|
|
70
|
-
|
|
134
|
+
// Sort by priority (higher first)
|
|
135
|
+
return Array.from(this.extensions.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getMetadata(name: string): ExtensionMetadata | undefined {
|
|
139
|
+
return this.extensionMetadata.get(name);
|
|
71
140
|
}
|
|
72
141
|
|
|
73
|
-
|
|
74
|
-
return this.
|
|
142
|
+
getAllMetadata(): ExtensionMetadata[] {
|
|
143
|
+
return Array.from(this.extensionMetadata.values()).sort((a, b) => b.priority - a.priority);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
findExtension(query: unknown): BaseExtension | undefined {
|
|
147
|
+
return this.getAll().find((extension) => extension.active?.(query) ?? false);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
findExtensionsByCapability(capability: "search" | "stream" | "beforePlay" | "afterPlay"): BaseExtension[] {
|
|
151
|
+
const capabilityMap = {
|
|
152
|
+
search: "hasSearch",
|
|
153
|
+
stream: "hasStream",
|
|
154
|
+
beforePlay: "hasBeforePlay",
|
|
155
|
+
afterPlay: "hasAfterPlay",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const metaKey = capabilityMap[capability];
|
|
159
|
+
return this.getAll().filter((ext) => {
|
|
160
|
+
const meta = this.extensionMetadata.get(ext.name);
|
|
161
|
+
return meta?.[metaKey as keyof ExtensionMetadata] ?? false;
|
|
162
|
+
});
|
|
75
163
|
}
|
|
76
164
|
|
|
77
165
|
clear(): void {
|
|
78
166
|
this.extensions.clear();
|
|
167
|
+
this.extensionMetadata.clear();
|
|
168
|
+
this.clearAllCaches();
|
|
79
169
|
}
|
|
80
170
|
|
|
81
171
|
private invokeExtensionLifecycle(extension: BaseExtension | undefined, hook: "onRegister" | "onDestroy"): void {
|
|
@@ -85,60 +175,204 @@ export class ExtensionManager {
|
|
|
85
175
|
try {
|
|
86
176
|
const result = fn.call(extension, this.extensionContext);
|
|
87
177
|
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
88
|
-
(result as Promise<unknown>).catch((err) => this.debug(`
|
|
178
|
+
(result as Promise<unknown>).catch((err) => this.debug(`Extension ${extension.name} ${hook} error:`, err));
|
|
89
179
|
}
|
|
90
180
|
} catch (err) {
|
|
91
|
-
this.debug(`
|
|
181
|
+
this.debug(`Extension ${extension.name} ${hook} error:`, err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private getCacheKey(prefix: string, ...parts: string[]): string {
|
|
186
|
+
return `${prefix}:${parts.join(":")}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private cleanupCaches(): void {
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
|
|
192
|
+
// Clean search cache
|
|
193
|
+
for (const [key, entry] of this.searchCache) {
|
|
194
|
+
if (now >= entry.expiresAt) {
|
|
195
|
+
this.searchCache.delete(key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Clean stream cache
|
|
200
|
+
for (const [key, entry] of this.streamCache) {
|
|
201
|
+
if (now >= entry.expiresAt) {
|
|
202
|
+
this.streamCache.delete(key);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.debug(`Cache cleanup completed - Search: ${this.searchCache.size}, Stream: ${this.streamCache.size}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private clearAllCaches(): void {
|
|
210
|
+
this.searchCache.clear();
|
|
211
|
+
this.streamCache.clear();
|
|
212
|
+
this.debug("All caches cleared");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private getCachedSearch(query: string): SearchResult | null {
|
|
216
|
+
const key = this.getCacheKey("search", query.toLowerCase().trim());
|
|
217
|
+
const cached = this.searchCache.get(key);
|
|
218
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
219
|
+
this.debug(`[Cache] Search hit for: ${query}`);
|
|
220
|
+
return cached.data;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private setCachedSearch(query: string, result: SearchResult): void {
|
|
226
|
+
if (this.searchCache.size >= this.MAX_CACHE_SIZE) {
|
|
227
|
+
// Remove oldest entries (LRU approximation)
|
|
228
|
+
const oldest = Array.from(this.searchCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
|
|
229
|
+
if (oldest) this.searchCache.delete(oldest[0]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const key = this.getCacheKey("search", query.toLowerCase().trim());
|
|
233
|
+
this.searchCache.set(key, {
|
|
234
|
+
data: result,
|
|
235
|
+
timestamp: Date.now(),
|
|
236
|
+
expiresAt: Date.now() + this.SEARCH_CACHE_TTL,
|
|
237
|
+
});
|
|
238
|
+
this.debug(`[Cache] Search stored for: ${query}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private getCachedStream(track: Track): StreamInfo | null {
|
|
242
|
+
const key = this.getCacheKey("stream", track.url || track.id || track.title);
|
|
243
|
+
const cached = this.streamCache.get(key);
|
|
244
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
245
|
+
this.debug(`[Cache] Stream hit for: ${track.title}`);
|
|
246
|
+
return cached.data;
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private setCachedStream(track: Track, stream: StreamInfo): void {
|
|
252
|
+
if (this.streamCache.size >= this.MAX_CACHE_SIZE) {
|
|
253
|
+
const oldest = Array.from(this.streamCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
|
|
254
|
+
if (oldest) this.streamCache.delete(oldest[0]);
|
|
92
255
|
}
|
|
256
|
+
|
|
257
|
+
const key = this.getCacheKey("stream", track.url || track.id || track.title);
|
|
258
|
+
this.streamCache.set(key, {
|
|
259
|
+
data: stream,
|
|
260
|
+
timestamp: Date.now(),
|
|
261
|
+
expiresAt: Date.now() + this.STREAM_CACHE_TTL,
|
|
262
|
+
});
|
|
263
|
+
this.debug(`[Cache] Stream stored for: ${track.title}`);
|
|
93
264
|
}
|
|
94
265
|
|
|
95
266
|
async provideSearch(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
267
|
+
if (!query) return null;
|
|
268
|
+
|
|
269
|
+
// Check cache first
|
|
270
|
+
const cached = this.getCachedSearch(query);
|
|
271
|
+
if (cached) return cached;
|
|
272
|
+
|
|
273
|
+
// Deduplicate concurrent requests
|
|
274
|
+
const cacheKey = this.getCacheKey("search", query.toLowerCase().trim());
|
|
275
|
+
if (this.pendingSearches.has(cacheKey)) {
|
|
276
|
+
this.debug(`[Dedupe] Waiting for pending search: ${query}`);
|
|
277
|
+
return this.pendingSearches.get(cacheKey)!;
|
|
278
|
+
}
|
|
279
|
+
|
|
96
280
|
const request: ExtensionSearchRequest = { query, requestedBy };
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
281
|
+
const searchPromise = (async () => {
|
|
282
|
+
// Only query extensions that have provideSearch capability
|
|
283
|
+
const searchExtensions = this.findExtensionsByCapability("search");
|
|
284
|
+
|
|
285
|
+
for (const extension of searchExtensions) {
|
|
286
|
+
const hook = (extension as any).provideSearch;
|
|
287
|
+
if (typeof hook !== "function") continue;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
291
|
+
if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
|
|
292
|
+
this.debug(`Extension ${extension.name} handled search for: ${query}`);
|
|
293
|
+
this.setCachedSearch(query, result as SearchResult);
|
|
294
|
+
return result as SearchResult;
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
this.debug(`Extension ${extension.name} provideSearch error:`, err);
|
|
105
298
|
}
|
|
106
|
-
} catch (err) {
|
|
107
|
-
this.debug(`[Player] Extension ${extension.name} provideSearch error:`, err);
|
|
108
299
|
}
|
|
300
|
+
return null;
|
|
301
|
+
})();
|
|
302
|
+
|
|
303
|
+
this.pendingSearches.set(cacheKey, searchPromise);
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
return await searchPromise;
|
|
307
|
+
} finally {
|
|
308
|
+
this.pendingSearches.delete(cacheKey);
|
|
109
309
|
}
|
|
110
|
-
return null;
|
|
111
310
|
}
|
|
112
311
|
|
|
113
312
|
async provideStream(track: Track): Promise<StreamInfo | null> {
|
|
313
|
+
if (!track) return null;
|
|
314
|
+
|
|
315
|
+
// Check cache first
|
|
316
|
+
const cached = this.getCachedStream(track);
|
|
317
|
+
if (cached) return cached;
|
|
318
|
+
|
|
319
|
+
// Deduplicate concurrent requests
|
|
320
|
+
const cacheKey = this.getCacheKey("stream", track.url || track.id || track.title);
|
|
321
|
+
if (this.pendingStreams.has(cacheKey)) {
|
|
322
|
+
this.debug(`[Dedupe] Waiting for pending stream: ${track.title}`);
|
|
323
|
+
return this.pendingStreams.get(cacheKey)!;
|
|
324
|
+
}
|
|
325
|
+
|
|
114
326
|
const request: ExtensionStreamRequest = { track };
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
327
|
+
const streamPromise = (async () => {
|
|
328
|
+
// Only query extensions that have provideStream capability
|
|
329
|
+
const streamExtensions = this.findExtensionsByCapability("stream");
|
|
330
|
+
|
|
331
|
+
for (const extension of streamExtensions) {
|
|
332
|
+
const hook = (extension as any).provideStream;
|
|
333
|
+
if (typeof hook !== "function") continue;
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
337
|
+
if (result && (result as StreamInfo).stream) {
|
|
338
|
+
this.debug(`Extension ${extension.name} provided stream for: ${track.title}`);
|
|
339
|
+
this.setCachedStream(track, result as StreamInfo);
|
|
340
|
+
return result as StreamInfo;
|
|
341
|
+
}
|
|
342
|
+
} catch (err) {
|
|
343
|
+
this.debug(`Extension ${extension.name} provideStream error:`, err);
|
|
123
344
|
}
|
|
124
|
-
} catch (err) {
|
|
125
|
-
this.debug(`[Player] Extension ${extension.name} provideStream error:`, err);
|
|
126
345
|
}
|
|
346
|
+
return null;
|
|
347
|
+
})();
|
|
348
|
+
|
|
349
|
+
this.pendingStreams.set(cacheKey, streamPromise);
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
return await streamPromise;
|
|
353
|
+
} finally {
|
|
354
|
+
this.pendingStreams.delete(cacheKey);
|
|
127
355
|
}
|
|
128
|
-
return null;
|
|
129
356
|
}
|
|
130
357
|
|
|
131
|
-
async
|
|
358
|
+
async beforePlayHooks(
|
|
132
359
|
initial: ExtensionPlayRequest,
|
|
133
360
|
): Promise<{ request: ExtensionPlayRequest; response: ExtensionPlayResponse }> {
|
|
134
361
|
const request: ExtensionPlayRequest = { ...initial };
|
|
135
362
|
const response: ExtensionPlayResponse = {};
|
|
136
|
-
|
|
363
|
+
|
|
364
|
+
// Only query extensions that have beforePlay capability
|
|
365
|
+
const beforePlayExtensions = this.findExtensionsByCapability("beforePlay");
|
|
366
|
+
|
|
367
|
+
for (const extension of beforePlayExtensions) {
|
|
137
368
|
const hook = (extension as any).beforePlay;
|
|
138
369
|
if (typeof hook !== "function") continue;
|
|
370
|
+
|
|
139
371
|
try {
|
|
140
372
|
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
141
373
|
if (!result) continue;
|
|
374
|
+
|
|
375
|
+
// Merge results
|
|
142
376
|
if (result.query !== undefined) {
|
|
143
377
|
request.query = result.query;
|
|
144
378
|
response.query = result.query;
|
|
@@ -164,27 +398,76 @@ export class ExtensionManager {
|
|
|
164
398
|
if (result.handled) break;
|
|
165
399
|
}
|
|
166
400
|
} catch (err) {
|
|
167
|
-
this.debug(`
|
|
401
|
+
this.debug(`Extension ${extension.name} beforePlay error:`, err);
|
|
168
402
|
}
|
|
169
403
|
}
|
|
404
|
+
|
|
170
405
|
return { request, response };
|
|
171
406
|
}
|
|
172
407
|
|
|
173
|
-
async
|
|
174
|
-
|
|
408
|
+
async afterPlayHooks(payload: ExtensionAfterPlayPayload): Promise<void> {
|
|
409
|
+
const afterPlayExtensions = this.findExtensionsByCapability("afterPlay");
|
|
410
|
+
if (afterPlayExtensions.length === 0) return;
|
|
411
|
+
|
|
412
|
+
// Create immutable payload
|
|
175
413
|
const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
|
|
176
414
|
if (safeTracks) {
|
|
177
415
|
Object.freeze(safeTracks);
|
|
178
416
|
}
|
|
179
417
|
const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
|
|
180
|
-
|
|
418
|
+
|
|
419
|
+
// Execute hooks in parallel for better performance
|
|
420
|
+
const hooks = afterPlayExtensions.map(async (extension) => {
|
|
181
421
|
const hook = (extension as any).afterPlay;
|
|
182
|
-
if (typeof hook !== "function")
|
|
422
|
+
if (typeof hook !== "function") return;
|
|
423
|
+
|
|
183
424
|
try {
|
|
184
425
|
await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
|
|
185
426
|
} catch (err) {
|
|
186
|
-
this.debug(`
|
|
427
|
+
this.debug(`Extension ${extension.name} afterPlay error:`, err);
|
|
187
428
|
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
await Promise.allSettled(hooks);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Get extension statistics
|
|
436
|
+
*/
|
|
437
|
+
getStats(): object {
|
|
438
|
+
const metadata = this.getAllMetadata();
|
|
439
|
+
return {
|
|
440
|
+
totalExtensions: this.extensions.size,
|
|
441
|
+
extensions: metadata.map((m) => ({
|
|
442
|
+
name: m.name,
|
|
443
|
+
priority: m.priority,
|
|
444
|
+
capabilities: {
|
|
445
|
+
search: m.hasSearch,
|
|
446
|
+
stream: m.hasStream,
|
|
447
|
+
beforePlay: m.hasBeforePlay,
|
|
448
|
+
afterPlay: m.hasAfterPlay,
|
|
449
|
+
},
|
|
450
|
+
})),
|
|
451
|
+
cacheStats: {
|
|
452
|
+
searchCacheSize: this.searchCache.size,
|
|
453
|
+
streamCacheSize: this.streamCache.size,
|
|
454
|
+
pendingSearches: this.pendingSearches.size,
|
|
455
|
+
pendingStreams: this.pendingStreams.size,
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Clear specific cache
|
|
462
|
+
*/
|
|
463
|
+
clearCache(type?: "search" | "stream"): void {
|
|
464
|
+
if (!type || type === "search") {
|
|
465
|
+
this.searchCache.clear();
|
|
466
|
+
this.debug("Search cache cleared");
|
|
467
|
+
}
|
|
468
|
+
if (!type || type === "stream") {
|
|
469
|
+
this.streamCache.clear();
|
|
470
|
+
this.debug("Stream cache cleared");
|
|
188
471
|
}
|
|
189
472
|
}
|
|
190
473
|
}
|