@involvex/youtube-music-cli 0.0.18 → 0.0.19
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/CHANGELOG.md +2 -0
- package/dist/source/cli.js +123 -8
- package/dist/source/components/import/ImportLayout.d.ts +1 -0
- package/dist/source/components/import/ImportLayout.js +119 -0
- package/dist/source/components/import/ImportProgress.d.ts +6 -0
- package/dist/source/components/import/ImportProgress.js +73 -0
- package/dist/source/components/layouts/MainLayout.js +7 -0
- package/dist/source/components/settings/Settings.js +6 -2
- package/dist/source/services/config/config.service.js +10 -0
- package/dist/source/services/import/import.service.d.ts +44 -0
- package/dist/source/services/import/import.service.js +272 -0
- package/dist/source/services/import/spotify.service.d.ts +40 -0
- package/dist/source/services/import/spotify.service.js +171 -0
- package/dist/source/services/import/track-matcher.service.d.ts +60 -0
- package/dist/source/services/import/track-matcher.service.js +271 -0
- package/dist/source/services/import/youtube-import.service.d.ts +17 -0
- package/dist/source/services/import/youtube-import.service.js +84 -0
- package/dist/source/services/web/static-file.service.d.ts +31 -0
- package/dist/source/services/web/static-file.service.js +174 -0
- package/dist/source/services/web/web-server-manager.d.ts +66 -0
- package/dist/source/services/web/web-server-manager.js +219 -0
- package/dist/source/services/web/web-streaming.service.d.ts +88 -0
- package/dist/source/services/web/web-streaming.service.js +290 -0
- package/dist/source/services/web/websocket.server.d.ts +58 -0
- package/dist/source/services/web/websocket.server.js +253 -0
- package/dist/source/stores/player.store.js +24 -0
- package/dist/source/types/cli.types.d.ts +8 -0
- package/dist/source/types/config.types.d.ts +2 -0
- package/dist/source/types/import.types.d.ts +72 -0
- package/dist/source/types/import.types.js +2 -0
- package/dist/source/types/web.types.d.ts +89 -0
- package/dist/source/types/web.types.js +2 -0
- package/dist/source/utils/constants.d.ts +1 -0
- package/dist/source/utils/constants.js +1 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +8 -3
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { getWebSocketServer } from "./websocket.server.js";
|
|
2
|
+
import { getWebStreamingService } from "./web-streaming.service.js";
|
|
3
|
+
import { getConfigService } from "../config/config.service.js";
|
|
4
|
+
import { getImportService } from "../import/import.service.js";
|
|
5
|
+
import { logger } from "../logger/logger.service.js";
|
|
6
|
+
class WebServerManager {
|
|
7
|
+
config;
|
|
8
|
+
isRunning = false;
|
|
9
|
+
cleanupHooks = [];
|
|
10
|
+
constructor() {
|
|
11
|
+
// Load config or use defaults
|
|
12
|
+
const configService = getConfigService();
|
|
13
|
+
const savedConfig = configService.get('webServer');
|
|
14
|
+
this.config = savedConfig ?? {
|
|
15
|
+
enabled: false,
|
|
16
|
+
host: 'localhost',
|
|
17
|
+
port: 8080,
|
|
18
|
+
enableCors: true,
|
|
19
|
+
allowedOrigins: ['*'],
|
|
20
|
+
auth: { enabled: false },
|
|
21
|
+
};
|
|
22
|
+
// Save default config if not present
|
|
23
|
+
if (!savedConfig) {
|
|
24
|
+
configService.set('webServer', this.config);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Start the web server
|
|
29
|
+
*/
|
|
30
|
+
async start(options) {
|
|
31
|
+
if (this.isRunning) {
|
|
32
|
+
logger.warn('WebServerManager', 'Server already running');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// Apply CLI options
|
|
36
|
+
const finalConfig = { ...this.config };
|
|
37
|
+
if (options) {
|
|
38
|
+
if (options.host !== undefined) {
|
|
39
|
+
finalConfig.host = options.host;
|
|
40
|
+
}
|
|
41
|
+
if (options.port !== undefined) {
|
|
42
|
+
finalConfig.port = options.port;
|
|
43
|
+
}
|
|
44
|
+
if (options.auth !== undefined) {
|
|
45
|
+
finalConfig.auth.enabled = true;
|
|
46
|
+
finalConfig.auth.token = options.auth;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
logger.info('WebServerManager', 'Starting web server', finalConfig);
|
|
50
|
+
try {
|
|
51
|
+
const wsServer = getWebSocketServer();
|
|
52
|
+
// Set up command handler
|
|
53
|
+
const cleanupCommand = this.setupCommandHandler();
|
|
54
|
+
this.cleanupHooks.push(cleanupCommand);
|
|
55
|
+
// Set up import handler
|
|
56
|
+
const cleanupImport = this.setupImportHandler();
|
|
57
|
+
this.cleanupHooks.push(cleanupImport);
|
|
58
|
+
// Start the server
|
|
59
|
+
await wsServer.start({
|
|
60
|
+
config: finalConfig,
|
|
61
|
+
onCommand: this.handleCommand.bind(this),
|
|
62
|
+
onImportRequest: this.handleImportRequest.bind(this),
|
|
63
|
+
});
|
|
64
|
+
this.isRunning = true;
|
|
65
|
+
// Set up graceful shutdown
|
|
66
|
+
this.setupShutdownHooks();
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
logger.error('WebServerManager', 'Failed to start server', {
|
|
70
|
+
error: error instanceof Error ? error.message : String(error),
|
|
71
|
+
});
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Stop the web server
|
|
77
|
+
*/
|
|
78
|
+
async stop() {
|
|
79
|
+
if (!this.isRunning) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
logger.info('WebServerManager', 'Stopping web server');
|
|
83
|
+
// Clean up hooks
|
|
84
|
+
for (const cleanup of this.cleanupHooks) {
|
|
85
|
+
cleanup();
|
|
86
|
+
}
|
|
87
|
+
this.cleanupHooks = [];
|
|
88
|
+
// Stop the WebSocket server
|
|
89
|
+
const wsServer = getWebSocketServer();
|
|
90
|
+
await wsServer.stop();
|
|
91
|
+
this.isRunning = false;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Set up player command handler
|
|
95
|
+
*/
|
|
96
|
+
setupCommandHandler() {
|
|
97
|
+
const streamingService = getWebStreamingService();
|
|
98
|
+
const unsubscribe = streamingService.onMessage(message => {
|
|
99
|
+
if (message.type === 'command') {
|
|
100
|
+
this.handleCommand(message.action);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return unsubscribe;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Set up import progress handler
|
|
107
|
+
*/
|
|
108
|
+
setupImportHandler() {
|
|
109
|
+
const importService = getImportService();
|
|
110
|
+
const streamingService = getWebStreamingService();
|
|
111
|
+
const unsubscribe = importService.onProgress(progress => {
|
|
112
|
+
streamingService.onImportProgress(progress);
|
|
113
|
+
});
|
|
114
|
+
return unsubscribe;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Handle command from web client
|
|
118
|
+
*/
|
|
119
|
+
handleCommand(action) {
|
|
120
|
+
// This will be handled by the player store
|
|
121
|
+
logger.debug('WebServerManager', 'Received command from client', { action });
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Handle import request from web client
|
|
125
|
+
*/
|
|
126
|
+
async handleImportRequest(source, url, name) {
|
|
127
|
+
logger.info('WebServerManager', 'Import request from client', {
|
|
128
|
+
source,
|
|
129
|
+
url,
|
|
130
|
+
name,
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
const importService = getImportService();
|
|
134
|
+
await importService.importPlaylist(source, url, name);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
logger.error('WebServerManager', 'Import failed', {
|
|
138
|
+
source,
|
|
139
|
+
url,
|
|
140
|
+
error: error instanceof Error ? error.message : String(error),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Update player state (call this when player state changes)
|
|
146
|
+
*/
|
|
147
|
+
updateState(state) {
|
|
148
|
+
if (!this.isRunning)
|
|
149
|
+
return;
|
|
150
|
+
const streamingService = getWebStreamingService();
|
|
151
|
+
streamingService.onStateChange(state);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Set up graceful shutdown hooks
|
|
155
|
+
*/
|
|
156
|
+
setupShutdownHooks() {
|
|
157
|
+
const shutdown = async () => {
|
|
158
|
+
await this.stop();
|
|
159
|
+
};
|
|
160
|
+
process.on('beforeExit', shutdown);
|
|
161
|
+
process.on('SIGINT', shutdown);
|
|
162
|
+
process.on('SIGTERM', shutdown);
|
|
163
|
+
this.cleanupHooks.push(() => {
|
|
164
|
+
process.off('beforeExit', shutdown);
|
|
165
|
+
process.off('SIGINT', shutdown);
|
|
166
|
+
process.off('SIGTERM', shutdown);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Check if server is running
|
|
171
|
+
*/
|
|
172
|
+
isServerRunning() {
|
|
173
|
+
return this.isRunning;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Get server URL
|
|
177
|
+
*/
|
|
178
|
+
getServerUrl() {
|
|
179
|
+
const wsServer = getWebSocketServer();
|
|
180
|
+
return wsServer.getServerUrl();
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Get server statistics
|
|
184
|
+
*/
|
|
185
|
+
getStats() {
|
|
186
|
+
if (!this.isRunning) {
|
|
187
|
+
return { running: false };
|
|
188
|
+
}
|
|
189
|
+
const streamingService = getWebStreamingService();
|
|
190
|
+
const stats = streamingService.getStats();
|
|
191
|
+
return {
|
|
192
|
+
running: true,
|
|
193
|
+
url: this.getServerUrl(),
|
|
194
|
+
clients: stats.clients,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Update configuration
|
|
199
|
+
*/
|
|
200
|
+
updateConfig(config) {
|
|
201
|
+
this.config = { ...this.config, ...config };
|
|
202
|
+
const configService = getConfigService();
|
|
203
|
+
configService.set('webServer', this.config);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Get current configuration
|
|
207
|
+
*/
|
|
208
|
+
getConfig() {
|
|
209
|
+
return { ...this.config };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Singleton instance
|
|
213
|
+
let webServerManagerInstance = null;
|
|
214
|
+
export function getWebServerManager() {
|
|
215
|
+
if (!webServerManagerInstance) {
|
|
216
|
+
webServerManagerInstance = new WebServerManager();
|
|
217
|
+
}
|
|
218
|
+
return webServerManagerInstance;
|
|
219
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { PlayerState } from '../../types/player.types.ts';
|
|
2
|
+
import type { ServerMessage, ClientMessage } from '../../types/web.types.ts';
|
|
3
|
+
import type { ImportProgress, ImportResult } from '../../types/import.types.ts';
|
|
4
|
+
type MessageHandler = (message: ClientMessage) => void;
|
|
5
|
+
declare class WebStreamingService {
|
|
6
|
+
private clients;
|
|
7
|
+
private clientInfo;
|
|
8
|
+
private messageHandlers;
|
|
9
|
+
private prevState;
|
|
10
|
+
private readonly UPDATE_THROTTLE_MS;
|
|
11
|
+
private lastUpdateTime;
|
|
12
|
+
private pendingUpdate;
|
|
13
|
+
private updateTimer;
|
|
14
|
+
/**
|
|
15
|
+
* Add a client connection
|
|
16
|
+
*/
|
|
17
|
+
addClient(clientId: string, ws: WebSocket, authenticated?: boolean): void;
|
|
18
|
+
/**
|
|
19
|
+
* Remove a client connection
|
|
20
|
+
*/
|
|
21
|
+
removeClient(clientId: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Check if a client exists
|
|
24
|
+
*/
|
|
25
|
+
hasClient(clientId: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Get client count
|
|
28
|
+
*/
|
|
29
|
+
getClientCount(): number;
|
|
30
|
+
/**
|
|
31
|
+
* Get all connected clients
|
|
32
|
+
*/
|
|
33
|
+
getClients(): string[];
|
|
34
|
+
/**
|
|
35
|
+
* Handle incoming message from a client
|
|
36
|
+
*/
|
|
37
|
+
handleClientMessage(clientId: string, message: unknown): void;
|
|
38
|
+
/**
|
|
39
|
+
* Register a handler for incoming client messages
|
|
40
|
+
*/
|
|
41
|
+
onMessage(handler: MessageHandler): () => void;
|
|
42
|
+
/**
|
|
43
|
+
* Send a message to a specific client
|
|
44
|
+
*/
|
|
45
|
+
private sendToClient;
|
|
46
|
+
/**
|
|
47
|
+
* Broadcast a message to all connected clients
|
|
48
|
+
*/
|
|
49
|
+
broadcast(message: ServerMessage): void;
|
|
50
|
+
/**
|
|
51
|
+
* Compute partial state delta (only changed fields)
|
|
52
|
+
*/
|
|
53
|
+
private computeDelta;
|
|
54
|
+
/**
|
|
55
|
+
* Update and broadcast player state (throttled)
|
|
56
|
+
*/
|
|
57
|
+
onStateChange(state: PlayerState): void;
|
|
58
|
+
/**
|
|
59
|
+
* Send pending state update to all clients
|
|
60
|
+
*/
|
|
61
|
+
private sendPendingUpdate;
|
|
62
|
+
/**
|
|
63
|
+
* Broadcast import progress
|
|
64
|
+
*/
|
|
65
|
+
onImportProgress(progress: ImportProgress): void;
|
|
66
|
+
/**
|
|
67
|
+
* Broadcast import result
|
|
68
|
+
*/
|
|
69
|
+
onImportResult(result: ImportResult): void;
|
|
70
|
+
/**
|
|
71
|
+
* Broadcast error message
|
|
72
|
+
*/
|
|
73
|
+
sendError(error: string, code?: string): void;
|
|
74
|
+
/**
|
|
75
|
+
* Disconnect all clients
|
|
76
|
+
*/
|
|
77
|
+
disconnectAll(): void;
|
|
78
|
+
/**
|
|
79
|
+
* Get server statistics
|
|
80
|
+
*/
|
|
81
|
+
getStats(): {
|
|
82
|
+
clients: number;
|
|
83
|
+
totalConnections: number;
|
|
84
|
+
uptime: number;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export declare function getWebStreamingService(): WebStreamingService;
|
|
88
|
+
export {};
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { logger } from "../logger/logger.service.js";
|
|
2
|
+
class WebStreamingService {
|
|
3
|
+
clients = new Map();
|
|
4
|
+
clientInfo = new Map();
|
|
5
|
+
messageHandlers = new Set();
|
|
6
|
+
prevState = null;
|
|
7
|
+
UPDATE_THROTTLE_MS = 250; // Throttle updates to 4/sec
|
|
8
|
+
lastUpdateTime = 0;
|
|
9
|
+
pendingUpdate = null;
|
|
10
|
+
updateTimer = null;
|
|
11
|
+
/**
|
|
12
|
+
* Add a client connection
|
|
13
|
+
*/
|
|
14
|
+
addClient(clientId, ws, authenticated = true) {
|
|
15
|
+
this.clients.set(clientId, ws);
|
|
16
|
+
this.clientInfo.set(clientId, {
|
|
17
|
+
id: clientId,
|
|
18
|
+
authenticated,
|
|
19
|
+
connectedAt: Date.now(),
|
|
20
|
+
lastHeartbeat: Date.now(),
|
|
21
|
+
});
|
|
22
|
+
logger.info('WebStreamingService', 'Client connected', {
|
|
23
|
+
clientId,
|
|
24
|
+
authenticated,
|
|
25
|
+
});
|
|
26
|
+
// Send initial state if available
|
|
27
|
+
if (this.prevState) {
|
|
28
|
+
this.sendToClient(clientId, {
|
|
29
|
+
type: 'state-update',
|
|
30
|
+
state: this.prevState,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// Send connection event
|
|
34
|
+
this.broadcast({
|
|
35
|
+
type: 'event',
|
|
36
|
+
event: 'client-connected',
|
|
37
|
+
data: { clientId, clientCount: this.clients.size },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Remove a client connection
|
|
42
|
+
*/
|
|
43
|
+
removeClient(clientId) {
|
|
44
|
+
const hadClient = this.clients.has(clientId);
|
|
45
|
+
this.clients.delete(clientId);
|
|
46
|
+
this.clientInfo.delete(clientId);
|
|
47
|
+
if (hadClient) {
|
|
48
|
+
logger.info('WebStreamingService', 'Client disconnected', {
|
|
49
|
+
clientId,
|
|
50
|
+
remainingClients: this.clients.size,
|
|
51
|
+
});
|
|
52
|
+
this.broadcast({
|
|
53
|
+
type: 'event',
|
|
54
|
+
event: 'client-disconnected',
|
|
55
|
+
data: { clientId, clientCount: this.clients.size },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check if a client exists
|
|
61
|
+
*/
|
|
62
|
+
hasClient(clientId) {
|
|
63
|
+
return this.clients.has(clientId);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Get client count
|
|
67
|
+
*/
|
|
68
|
+
getClientCount() {
|
|
69
|
+
return this.clients.size;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Get all connected clients
|
|
73
|
+
*/
|
|
74
|
+
getClients() {
|
|
75
|
+
return Array.from(this.clients.keys());
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Handle incoming message from a client
|
|
79
|
+
*/
|
|
80
|
+
handleClientMessage(clientId, message) {
|
|
81
|
+
try {
|
|
82
|
+
const msg = message;
|
|
83
|
+
// Update client heartbeat
|
|
84
|
+
const info = this.clientInfo.get(clientId);
|
|
85
|
+
if (info) {
|
|
86
|
+
info.lastHeartbeat = Date.now();
|
|
87
|
+
this.clientInfo.set(clientId, info);
|
|
88
|
+
}
|
|
89
|
+
// Emit to all handlers
|
|
90
|
+
for (const handler of this.messageHandlers) {
|
|
91
|
+
handler(msg);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.error('WebStreamingService', 'Failed to handle client message', {
|
|
96
|
+
clientId,
|
|
97
|
+
error: error instanceof Error ? error.message : String(error),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Register a handler for incoming client messages
|
|
103
|
+
*/
|
|
104
|
+
onMessage(handler) {
|
|
105
|
+
this.messageHandlers.add(handler);
|
|
106
|
+
return () => {
|
|
107
|
+
this.messageHandlers.delete(handler);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Send a message to a specific client
|
|
112
|
+
*/
|
|
113
|
+
sendToClient(clientId, message) {
|
|
114
|
+
const ws = this.clients.get(clientId);
|
|
115
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
ws.send(JSON.stringify(message));
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
logger.error('WebStreamingService', 'Failed to send to client', {
|
|
123
|
+
clientId,
|
|
124
|
+
error: error instanceof Error ? error.message : String(error),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Broadcast a message to all connected clients
|
|
130
|
+
*/
|
|
131
|
+
broadcast(message) {
|
|
132
|
+
const data = JSON.stringify(message);
|
|
133
|
+
for (const [clientId, ws] of this.clients) {
|
|
134
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
135
|
+
try {
|
|
136
|
+
ws.send(data);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
logger.error('WebStreamingService', 'Failed to broadcast to client', {
|
|
140
|
+
clientId,
|
|
141
|
+
error: error instanceof Error ? error.message : String(error),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Compute partial state delta (only changed fields)
|
|
149
|
+
*/
|
|
150
|
+
computeDelta(newState) {
|
|
151
|
+
if (!this.prevState) {
|
|
152
|
+
return newState;
|
|
153
|
+
}
|
|
154
|
+
const delta = {};
|
|
155
|
+
// Check each field for changes
|
|
156
|
+
const fields = [
|
|
157
|
+
'currentTrack',
|
|
158
|
+
'isPlaying',
|
|
159
|
+
'volume',
|
|
160
|
+
'speed',
|
|
161
|
+
'progress',
|
|
162
|
+
'duration',
|
|
163
|
+
'queue',
|
|
164
|
+
'queuePosition',
|
|
165
|
+
'repeat',
|
|
166
|
+
'shuffle',
|
|
167
|
+
'isLoading',
|
|
168
|
+
'error',
|
|
169
|
+
];
|
|
170
|
+
for (const field of fields) {
|
|
171
|
+
const prevValue = this.prevState[field];
|
|
172
|
+
const newValue = newState[field];
|
|
173
|
+
// Deep comparison for objects/arrays
|
|
174
|
+
if (typeof prevValue === 'object' && prevValue !== null) {
|
|
175
|
+
if (JSON.stringify(prevValue) !== JSON.stringify(newValue)) {
|
|
176
|
+
delta[field] = newValue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else if (prevValue !== newValue) {
|
|
180
|
+
delta[field] = newValue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return delta;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Update and broadcast player state (throttled)
|
|
187
|
+
*/
|
|
188
|
+
onStateChange(state) {
|
|
189
|
+
this.prevState = { ...state };
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
const delta = this.computeDelta(state);
|
|
192
|
+
// Skip if no changes
|
|
193
|
+
if (Object.keys(delta).length === 0) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Merge with pending update
|
|
197
|
+
this.pendingUpdate = { ...(this.pendingUpdate || {}), ...delta };
|
|
198
|
+
// Clear existing timer
|
|
199
|
+
if (this.updateTimer) {
|
|
200
|
+
clearTimeout(this.updateTimer);
|
|
201
|
+
}
|
|
202
|
+
// If throttle period passed, send immediately
|
|
203
|
+
if (now - this.lastUpdateTime >= this.UPDATE_THROTTLE_MS) {
|
|
204
|
+
this.sendPendingUpdate();
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
// Otherwise, schedule update
|
|
208
|
+
this.updateTimer = setTimeout(() => {
|
|
209
|
+
this.sendPendingUpdate();
|
|
210
|
+
}, this.UPDATE_THROTTLE_MS - (now - this.lastUpdateTime));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Send pending state update to all clients
|
|
215
|
+
*/
|
|
216
|
+
sendPendingUpdate() {
|
|
217
|
+
if (!this.pendingUpdate)
|
|
218
|
+
return;
|
|
219
|
+
this.broadcast({
|
|
220
|
+
type: 'state-update',
|
|
221
|
+
state: this.pendingUpdate,
|
|
222
|
+
});
|
|
223
|
+
this.pendingUpdate = null;
|
|
224
|
+
this.lastUpdateTime = Date.now();
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Broadcast import progress
|
|
228
|
+
*/
|
|
229
|
+
onImportProgress(progress) {
|
|
230
|
+
this.broadcast({
|
|
231
|
+
type: 'import-progress',
|
|
232
|
+
data: progress,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Broadcast import result
|
|
237
|
+
*/
|
|
238
|
+
onImportResult(result) {
|
|
239
|
+
this.broadcast({
|
|
240
|
+
type: 'import-result',
|
|
241
|
+
data: result,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Broadcast error message
|
|
246
|
+
*/
|
|
247
|
+
sendError(error, code) {
|
|
248
|
+
this.broadcast({
|
|
249
|
+
type: 'error',
|
|
250
|
+
error,
|
|
251
|
+
code,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Disconnect all clients
|
|
256
|
+
*/
|
|
257
|
+
disconnectAll() {
|
|
258
|
+
for (const [, ws] of this.clients) {
|
|
259
|
+
try {
|
|
260
|
+
ws.close();
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Ignore
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
this.clients.clear();
|
|
267
|
+
this.clientInfo.clear();
|
|
268
|
+
logger.info('WebStreamingService', 'All clients disconnected');
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Get server statistics
|
|
272
|
+
*/
|
|
273
|
+
getStats() {
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
const oldestClient = Array.from(this.clientInfo.values()).sort((a, b) => a.connectedAt - b.connectedAt)[0];
|
|
276
|
+
return {
|
|
277
|
+
clients: this.clients.size,
|
|
278
|
+
totalConnections: this.clientInfo.size,
|
|
279
|
+
uptime: oldestClient ? now - oldestClient.connectedAt : 0,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Singleton instance
|
|
284
|
+
let webStreamingServiceInstance = null;
|
|
285
|
+
export function getWebStreamingService() {
|
|
286
|
+
if (!webStreamingServiceInstance) {
|
|
287
|
+
webStreamingServiceInstance = new WebStreamingService();
|
|
288
|
+
}
|
|
289
|
+
return webStreamingServiceInstance;
|
|
290
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { WebServerConfig } from '../../types/web.types.ts';
|
|
2
|
+
interface WebSocketServerOptions {
|
|
3
|
+
config: WebServerConfig;
|
|
4
|
+
onCommand?: (action: unknown) => void;
|
|
5
|
+
onImportRequest?: (source: 'spotify' | 'youtube', url: string, name?: string) => void;
|
|
6
|
+
}
|
|
7
|
+
declare class WebSocketServerClass {
|
|
8
|
+
private httpServer;
|
|
9
|
+
private wsServer;
|
|
10
|
+
private config;
|
|
11
|
+
private streamingService;
|
|
12
|
+
private staticFileService;
|
|
13
|
+
private onCommand?;
|
|
14
|
+
private onImportRequest?;
|
|
15
|
+
constructor();
|
|
16
|
+
/**
|
|
17
|
+
* Start the WebSocket server
|
|
18
|
+
*/
|
|
19
|
+
start(options: WebSocketServerOptions): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Handle HTTP requests (for static file serving)
|
|
22
|
+
*/
|
|
23
|
+
private handleHttpRequest;
|
|
24
|
+
/**
|
|
25
|
+
* Handle WebSocket connection
|
|
26
|
+
*/
|
|
27
|
+
private handleWebSocketConnection;
|
|
28
|
+
/**
|
|
29
|
+
* Handle commands from clients
|
|
30
|
+
*/
|
|
31
|
+
private handleClientCommand;
|
|
32
|
+
/**
|
|
33
|
+
* Send message to a specific WebSocket client
|
|
34
|
+
*/
|
|
35
|
+
private sendToClient;
|
|
36
|
+
/**
|
|
37
|
+
* Generate a unique client ID
|
|
38
|
+
*/
|
|
39
|
+
private generateClientId;
|
|
40
|
+
/**
|
|
41
|
+
* Extract auth token from request
|
|
42
|
+
*/
|
|
43
|
+
private extractAuthToken;
|
|
44
|
+
/**
|
|
45
|
+
* Stop the server
|
|
46
|
+
*/
|
|
47
|
+
stop(): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Check if server is running
|
|
50
|
+
*/
|
|
51
|
+
isRunning(): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Get server URL
|
|
54
|
+
*/
|
|
55
|
+
getServerUrl(): string;
|
|
56
|
+
}
|
|
57
|
+
export declare function getWebSocketServer(): WebSocketServerClass;
|
|
58
|
+
export {};
|