@redseat/api 0.3.11 → 0.3.14
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/client.md +9 -2
- package/dist/client.d.ts +30 -2
- package/dist/client.js +269 -32
- package/dist/interfaces.d.ts +69 -0
- package/dist/interfaces.js +16 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +3 -3
- package/dist/sse-types.d.ts +25 -5
- package/package.json +1 -1
- package/server.md +28 -8
package/client.md
CHANGED
|
@@ -329,6 +329,12 @@ Connects to the server's SSE endpoint for real-time updates.
|
|
|
329
329
|
- `maxReconnectAttempts?`: Maximum reconnect attempts (default: unlimited)
|
|
330
330
|
- `initialReconnectDelay?`: Initial reconnect delay in ms (default: `1000`)
|
|
331
331
|
- `maxReconnectDelay?`: Maximum reconnect delay in ms (default: `30000`)
|
|
332
|
+
- `reconnectOnPageVisible?`: Reconnect when tab becomes visible after being hidden (default: `true`)
|
|
333
|
+
- `reconnectVisibleAfterMs?`: Minimum hidden duration before reconnect on visible (default: `30000`)
|
|
334
|
+
- `reconnectOnOnline?`: Reconnect when browser goes online (default: `true`)
|
|
335
|
+
- `reconnectOnFocus?`: Reconnect on window focus when connection is unhealthy (default: `true`)
|
|
336
|
+
- `reconnectOnAuthError?`: Retry reconnect after auth (`401`) errors (default: `true`)
|
|
337
|
+
- `maxAuthReconnectAttempts?`: Maximum auth reconnect attempts (default: `5`)
|
|
332
338
|
|
|
333
339
|
**Example:**
|
|
334
340
|
```typescript
|
|
@@ -345,7 +351,9 @@ await client.connectSSE({
|
|
|
345
351
|
autoReconnect: true,
|
|
346
352
|
maxReconnectAttempts: 10,
|
|
347
353
|
initialReconnectDelay: 2000,
|
|
348
|
-
maxReconnectDelay: 60000
|
|
354
|
+
maxReconnectDelay: 60000,
|
|
355
|
+
reconnectOnPageVisible: true,
|
|
356
|
+
reconnectVisibleAfterMs: 45000
|
|
349
357
|
});
|
|
350
358
|
```
|
|
351
359
|
|
|
@@ -667,4 +675,3 @@ client.dispose();
|
|
|
667
675
|
- [ServerApi Documentation](server.md) - Uses RedseatClient for server operations
|
|
668
676
|
- [LibraryApi Documentation](libraries.md) - Uses RedseatClient for library operations
|
|
669
677
|
- [README](README.md) - Package overview
|
|
670
|
-
|
package/dist/client.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { IServer } from './interfaces.js';
|
|
|
5
5
|
import { SSEConnectionState, SSEConnectionOptions, SSEConnectionError, SSELibraryEvent, SSELibraryStatusEvent, SSEMediasEvent, SSEUploadProgressEvent, SSEConvertProgressEvent, SSEEpisodesEvent, SSESeriesEvent, SSEMoviesEvent, SSEPeopleEvent, SSETagsEvent, SSEBackupsEvent, SSEBackupFilesEvent, SSEMediaRatingEvent, SSEMediaProgressEvent, SSEPlayersListEvent, SSEWatchedEvent, SSEUnwatchedEvent, SSERequestProcessingEvent } from './sse-types.js';
|
|
6
6
|
export interface ClientOptions {
|
|
7
7
|
server: IServer;
|
|
8
|
-
getIdToken: () => Promise<string>;
|
|
8
|
+
getIdToken: (forceRefresh?: boolean) => Promise<string>;
|
|
9
9
|
refreshThreshold?: number;
|
|
10
10
|
timeout?: number;
|
|
11
11
|
redseatUrl?: string;
|
|
@@ -20,10 +20,19 @@ export declare class RedseatClient {
|
|
|
20
20
|
private localServerUrl?;
|
|
21
21
|
private tokenData?;
|
|
22
22
|
private tokenRefreshPromise?;
|
|
23
|
+
private localDetectionAbortController?;
|
|
24
|
+
private disposed;
|
|
23
25
|
private sseAbortController?;
|
|
24
26
|
private sseReconnectTimeout?;
|
|
27
|
+
private sseActivityTimeout?;
|
|
25
28
|
private sseReconnectAttempts;
|
|
29
|
+
private sseAuthReconnectAttempts;
|
|
26
30
|
private sseOptions?;
|
|
31
|
+
private sseShouldReconnect;
|
|
32
|
+
private sseIsConnecting;
|
|
33
|
+
private sseLifecycleRegistered;
|
|
34
|
+
private sseHiddenAt?;
|
|
35
|
+
private readonly SSE_ACTIVITY_TIMEOUT;
|
|
27
36
|
private readonly _sseConnectionState;
|
|
28
37
|
private readonly _sseError;
|
|
29
38
|
private readonly _sseEvents;
|
|
@@ -92,6 +101,15 @@ export declare class RedseatClient {
|
|
|
92
101
|
* Returns public server info (IServer).
|
|
93
102
|
*/
|
|
94
103
|
getServer(serverId: string): Promise<IServer>;
|
|
104
|
+
private readonly onDocumentVisibilityChange;
|
|
105
|
+
private readonly onWindowPageShow;
|
|
106
|
+
private readonly onWindowOnline;
|
|
107
|
+
private readonly onWindowFocus;
|
|
108
|
+
private shouldAttemptReconnect;
|
|
109
|
+
private clearSSEReconnectTimeout;
|
|
110
|
+
private registerSSELifecycleListeners;
|
|
111
|
+
private unregisterSSELifecycleListeners;
|
|
112
|
+
private forceReconnectSSE;
|
|
95
113
|
/**
|
|
96
114
|
* Connects to the server's SSE endpoint for real-time updates.
|
|
97
115
|
* Automatically manages authentication and reconnection.
|
|
@@ -103,7 +121,7 @@ export declare class RedseatClient {
|
|
|
103
121
|
*/
|
|
104
122
|
disconnectSSE(): void;
|
|
105
123
|
/**
|
|
106
|
-
* Disposes of all
|
|
124
|
+
* Disposes of all resources and completes observables.
|
|
107
125
|
* Call this when the client is no longer needed.
|
|
108
126
|
*/
|
|
109
127
|
dispose(): void;
|
|
@@ -119,6 +137,16 @@ export declare class RedseatClient {
|
|
|
119
137
|
* Builds the SSE endpoint URL with optional library filters
|
|
120
138
|
*/
|
|
121
139
|
private buildSSEUrl;
|
|
140
|
+
/**
|
|
141
|
+
* Resets the activity timeout timer.
|
|
142
|
+
* Called when data is received from the SSE stream.
|
|
143
|
+
* If no data is received within the timeout period, triggers reconnection.
|
|
144
|
+
*/
|
|
145
|
+
private resetActivityTimeout;
|
|
146
|
+
/**
|
|
147
|
+
* Clears the activity timeout timer.
|
|
148
|
+
*/
|
|
149
|
+
private clearActivityTimeout;
|
|
122
150
|
/**
|
|
123
151
|
* Processes the SSE stream and emits events
|
|
124
152
|
*/
|
package/dist/client.js
CHANGED
|
@@ -36,7 +36,13 @@ export class RedseatClient {
|
|
|
36
36
|
}));
|
|
37
37
|
}
|
|
38
38
|
constructor(options) {
|
|
39
|
+
this.disposed = false;
|
|
39
40
|
this.sseReconnectAttempts = 0;
|
|
41
|
+
this.sseAuthReconnectAttempts = 0;
|
|
42
|
+
this.sseShouldReconnect = false;
|
|
43
|
+
this.sseIsConnecting = false;
|
|
44
|
+
this.sseLifecycleRegistered = false;
|
|
45
|
+
this.SSE_ACTIVITY_TIMEOUT = 90000; // 90 seconds - reconnect if no data received
|
|
40
46
|
// RxJS subjects for SSE
|
|
41
47
|
this._sseConnectionState = new BehaviorSubject('disconnected');
|
|
42
48
|
this._sseError = new Subject();
|
|
@@ -64,6 +70,62 @@ export class RedseatClient {
|
|
|
64
70
|
this.watched$ = this.createEventStream('watched');
|
|
65
71
|
this.unwatched$ = this.createEventStream('unwatched');
|
|
66
72
|
this.requestProcessing$ = this.createEventStream('request_processing');
|
|
73
|
+
// ==================== SSE Methods ====================
|
|
74
|
+
this.onDocumentVisibilityChange = () => {
|
|
75
|
+
if (!this.shouldAttemptReconnect() || typeof document === 'undefined') {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (document.visibilityState === 'hidden') {
|
|
79
|
+
this.sseHiddenAt = Date.now();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!(this.sseOptions?.reconnectOnPageVisible ?? true)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const hiddenAt = this.sseHiddenAt;
|
|
86
|
+
this.sseHiddenAt = undefined;
|
|
87
|
+
if (hiddenAt === undefined) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const hiddenDuration = Date.now() - hiddenAt;
|
|
91
|
+
const minHiddenDuration = this.sseOptions?.reconnectVisibleAfterMs ?? 30000;
|
|
92
|
+
if (hiddenDuration >= minHiddenDuration) {
|
|
93
|
+
this.forceReconnectSSE();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
this.onWindowPageShow = (event) => {
|
|
97
|
+
if (!this.shouldAttemptReconnect()) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!(this.sseOptions?.reconnectOnPageVisible ?? true)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (event.persisted || this.sseConnectionState !== 'connected') {
|
|
104
|
+
this.forceReconnectSSE();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
this.onWindowOnline = () => {
|
|
108
|
+
if (!this.shouldAttemptReconnect()) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!(this.sseOptions?.reconnectOnOnline ?? true)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (this.sseConnectionState !== 'connected') {
|
|
115
|
+
this.forceReconnectSSE();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
this.onWindowFocus = () => {
|
|
119
|
+
if (!this.shouldAttemptReconnect()) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!(this.sseOptions?.reconnectOnFocus ?? true)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (this.sseConnectionState !== 'connected') {
|
|
126
|
+
this.forceReconnectSSE();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
67
129
|
this.server = options.server;
|
|
68
130
|
this.redseatUrl = options.redseatUrl;
|
|
69
131
|
this.getIdToken = options.getIdToken;
|
|
@@ -72,16 +134,19 @@ export class RedseatClient {
|
|
|
72
134
|
this.baseUrl = this.getRegularServerUrl();
|
|
73
135
|
this.axios = axios.create({
|
|
74
136
|
baseURL: this.baseUrl,
|
|
75
|
-
timeout: options.timeout ??
|
|
137
|
+
timeout: options.timeout ?? 30000 // 30 second default
|
|
76
138
|
});
|
|
77
139
|
// Detect local URL asynchronously and update axios instance
|
|
78
|
-
this.
|
|
140
|
+
this.localDetectionAbortController = new AbortController();
|
|
141
|
+
this.detectLocalUrl(this.localDetectionAbortController.signal).then((url) => {
|
|
142
|
+
if (this.disposed)
|
|
143
|
+
return; // Don't update if disposed
|
|
79
144
|
if (url !== this.baseUrl) {
|
|
80
145
|
this.baseUrl = url;
|
|
81
146
|
this.axios.defaults.baseURL = url;
|
|
82
147
|
}
|
|
83
148
|
}).catch(() => {
|
|
84
|
-
// If detection fails, use regular URL (already set)
|
|
149
|
+
// If detection fails or aborted, use regular URL (already set)
|
|
85
150
|
});
|
|
86
151
|
// Request interceptor: check token expiration and refresh if needed
|
|
87
152
|
this.axios.interceptors.request.use(async (config) => {
|
|
@@ -117,7 +182,7 @@ export class RedseatClient {
|
|
|
117
182
|
}
|
|
118
183
|
return `https://${base}`;
|
|
119
184
|
}
|
|
120
|
-
async detectLocalUrl() {
|
|
185
|
+
async detectLocalUrl(signal) {
|
|
121
186
|
let localBase = `local.${this.server.url}`;
|
|
122
187
|
if (this.server.port) {
|
|
123
188
|
localBase = `${localBase}:${this.server.port}`;
|
|
@@ -127,7 +192,8 @@ export class RedseatClient {
|
|
|
127
192
|
console.log('trying local server url', localUrl);
|
|
128
193
|
const response = await axios.get(`${localUrl}/ping`, {
|
|
129
194
|
timeout: 200,
|
|
130
|
-
headers: { "Referrer-Policy": 'origin-when-cross-origin' }
|
|
195
|
+
headers: { "Referrer-Policy": 'origin-when-cross-origin' },
|
|
196
|
+
signal
|
|
131
197
|
});
|
|
132
198
|
if (response.status === 200) {
|
|
133
199
|
console.log('local server detected');
|
|
@@ -162,7 +228,8 @@ export class RedseatClient {
|
|
|
162
228
|
}
|
|
163
229
|
this.tokenRefreshPromise = (async () => {
|
|
164
230
|
try {
|
|
165
|
-
|
|
231
|
+
// Force refresh the Firebase idToken to ensure it's not stale/cached
|
|
232
|
+
const idToken = await this.getIdToken(true);
|
|
166
233
|
// Use fetchServerToken which uses the global axios instance
|
|
167
234
|
// The token endpoint is on the frontend server, not the backend server
|
|
168
235
|
const newToken = await fetchServerToken(this.serverId, idToken, this.redseatUrl);
|
|
@@ -275,7 +342,52 @@ export class RedseatClient {
|
|
|
275
342
|
});
|
|
276
343
|
return response.data;
|
|
277
344
|
}
|
|
278
|
-
|
|
345
|
+
shouldAttemptReconnect() {
|
|
346
|
+
return !this.disposed && this.sseShouldReconnect && (this.sseOptions?.autoReconnect ?? true);
|
|
347
|
+
}
|
|
348
|
+
clearSSEReconnectTimeout() {
|
|
349
|
+
if (this.sseReconnectTimeout) {
|
|
350
|
+
clearTimeout(this.sseReconnectTimeout);
|
|
351
|
+
this.sseReconnectTimeout = undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
registerSSELifecycleListeners() {
|
|
355
|
+
if (this.sseLifecycleRegistered || typeof window === 'undefined' || typeof document === 'undefined') {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
document.addEventListener('visibilitychange', this.onDocumentVisibilityChange);
|
|
359
|
+
window.addEventListener('pageshow', this.onWindowPageShow);
|
|
360
|
+
window.addEventListener('online', this.onWindowOnline);
|
|
361
|
+
window.addEventListener('focus', this.onWindowFocus);
|
|
362
|
+
this.sseLifecycleRegistered = true;
|
|
363
|
+
if (document.visibilityState === 'hidden') {
|
|
364
|
+
this.sseHiddenAt = Date.now();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
unregisterSSELifecycleListeners() {
|
|
368
|
+
if (!this.sseLifecycleRegistered || typeof window === 'undefined' || typeof document === 'undefined') {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
document.removeEventListener('visibilitychange', this.onDocumentVisibilityChange);
|
|
372
|
+
window.removeEventListener('pageshow', this.onWindowPageShow);
|
|
373
|
+
window.removeEventListener('online', this.onWindowOnline);
|
|
374
|
+
window.removeEventListener('focus', this.onWindowFocus);
|
|
375
|
+
this.sseLifecycleRegistered = false;
|
|
376
|
+
this.sseHiddenAt = undefined;
|
|
377
|
+
}
|
|
378
|
+
forceReconnectSSE() {
|
|
379
|
+
if (!this.shouldAttemptReconnect()) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
this.clearSSEReconnectTimeout();
|
|
383
|
+
if (this.sseAbortController) {
|
|
384
|
+
this.sseAbortController.abort();
|
|
385
|
+
this.sseAbortController = undefined;
|
|
386
|
+
}
|
|
387
|
+
this.sseReconnectAttempts = 0;
|
|
388
|
+
this._sseConnectionState.next('reconnecting');
|
|
389
|
+
void this._connectSSE();
|
|
390
|
+
}
|
|
279
391
|
/**
|
|
280
392
|
* Connects to the server's SSE endpoint for real-time updates.
|
|
281
393
|
* Automatically manages authentication and reconnection.
|
|
@@ -288,30 +400,44 @@ export class RedseatClient {
|
|
|
288
400
|
autoReconnect: true,
|
|
289
401
|
initialReconnectDelay: 1000,
|
|
290
402
|
maxReconnectDelay: 30000,
|
|
403
|
+
reconnectOnPageVisible: true,
|
|
404
|
+
reconnectVisibleAfterMs: 30000,
|
|
405
|
+
reconnectOnOnline: true,
|
|
406
|
+
reconnectOnFocus: true,
|
|
407
|
+
reconnectOnAuthError: true,
|
|
408
|
+
maxAuthReconnectAttempts: 5,
|
|
291
409
|
...options
|
|
292
410
|
};
|
|
293
411
|
this.sseReconnectAttempts = 0;
|
|
412
|
+
this.sseAuthReconnectAttempts = 0;
|
|
413
|
+
this.sseShouldReconnect = true;
|
|
414
|
+
this.registerSSELifecycleListeners();
|
|
294
415
|
await this._connectSSE();
|
|
295
416
|
}
|
|
296
417
|
/**
|
|
297
418
|
* Disconnects from the SSE endpoint and cleans up resources.
|
|
298
419
|
*/
|
|
299
420
|
disconnectSSE() {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
421
|
+
this.sseShouldReconnect = false;
|
|
422
|
+
this.sseIsConnecting = false;
|
|
423
|
+
this.sseAuthReconnectAttempts = 0;
|
|
424
|
+
this.clearSSEReconnectTimeout();
|
|
425
|
+
this.clearActivityTimeout();
|
|
304
426
|
if (this.sseAbortController) {
|
|
305
427
|
this.sseAbortController.abort();
|
|
306
428
|
this.sseAbortController = undefined;
|
|
307
429
|
}
|
|
430
|
+
this.unregisterSSELifecycleListeners();
|
|
308
431
|
this._sseConnectionState.next('disconnected');
|
|
309
432
|
}
|
|
310
433
|
/**
|
|
311
|
-
* Disposes of all
|
|
434
|
+
* Disposes of all resources and completes observables.
|
|
312
435
|
* Call this when the client is no longer needed.
|
|
313
436
|
*/
|
|
314
437
|
dispose() {
|
|
438
|
+
this.disposed = true;
|
|
439
|
+
this.localDetectionAbortController?.abort();
|
|
440
|
+
this.localDetectionAbortController = undefined;
|
|
315
441
|
this.disconnectSSE();
|
|
316
442
|
this._sseConnectionState.complete();
|
|
317
443
|
this._sseError.complete();
|
|
@@ -327,6 +453,14 @@ export class RedseatClient {
|
|
|
327
453
|
* Internal method to establish SSE connection
|
|
328
454
|
*/
|
|
329
455
|
async _connectSSE() {
|
|
456
|
+
if (this.disposed || !this.sseShouldReconnect || this.sseIsConnecting) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (this.sseConnectionState === 'connected' && this.sseAbortController) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this.clearSSEReconnectTimeout();
|
|
463
|
+
this.sseIsConnecting = true;
|
|
330
464
|
this._sseConnectionState.next('connecting');
|
|
331
465
|
try {
|
|
332
466
|
// Ensure we have a valid token
|
|
@@ -335,28 +469,54 @@ export class RedseatClient {
|
|
|
335
469
|
throw new Error('No authentication token available');
|
|
336
470
|
}
|
|
337
471
|
const url = this.buildSSEUrl();
|
|
338
|
-
|
|
472
|
+
const abortController = new AbortController();
|
|
473
|
+
this.sseAbortController = abortController;
|
|
339
474
|
console.log("SSSEEEE URL", url);
|
|
340
|
-
|
|
475
|
+
let response = await fetch(url, {
|
|
341
476
|
method: 'GET',
|
|
342
477
|
headers: {
|
|
343
478
|
'Authorization': `Bearer ${this.tokenData.token}`,
|
|
344
479
|
'Accept': 'text/event-stream',
|
|
345
480
|
'Cache-Control': 'no-cache'
|
|
346
481
|
},
|
|
347
|
-
signal:
|
|
482
|
+
signal: abortController.signal
|
|
348
483
|
});
|
|
349
484
|
if (!response.ok) {
|
|
350
485
|
if (response.status === 401) {
|
|
351
|
-
|
|
486
|
+
// Try refreshing token and retry ONCE before giving up
|
|
487
|
+
try {
|
|
488
|
+
console.log('SSE 401 - attempting token refresh and retry');
|
|
489
|
+
await this.refreshToken();
|
|
490
|
+
// Retry the connection with the fresh token
|
|
491
|
+
const retryResponse = await fetch(url, {
|
|
492
|
+
method: 'GET',
|
|
493
|
+
headers: {
|
|
494
|
+
'Authorization': `Bearer ${this.tokenData.token}`,
|
|
495
|
+
'Accept': 'text/event-stream',
|
|
496
|
+
'Cache-Control': 'no-cache'
|
|
497
|
+
},
|
|
498
|
+
signal: this.sseAbortController.signal
|
|
499
|
+
});
|
|
500
|
+
if (!retryResponse.ok) {
|
|
501
|
+
throw Object.assign(new Error('Authentication failed after token refresh'), { type: 'auth' });
|
|
502
|
+
}
|
|
503
|
+
// Use the retry response instead
|
|
504
|
+
response = retryResponse;
|
|
505
|
+
}
|
|
506
|
+
catch (retryError) {
|
|
507
|
+
throw Object.assign(new Error('Authentication failed'), { type: 'auth' });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
throw Object.assign(new Error(`Server returned ${response.status}`), { type: 'server' });
|
|
352
512
|
}
|
|
353
|
-
throw Object.assign(new Error(`Server returned ${response.status}`), { type: 'server' });
|
|
354
513
|
}
|
|
355
514
|
if (!response.body) {
|
|
356
515
|
throw Object.assign(new Error('No response body'), { type: 'server' });
|
|
357
516
|
}
|
|
358
517
|
this._sseConnectionState.next('connected');
|
|
359
518
|
this.sseReconnectAttempts = 0;
|
|
519
|
+
this.sseAuthReconnectAttempts = 0;
|
|
360
520
|
// Process the stream in the background (don't await - it runs forever until disconnected)
|
|
361
521
|
this.processSSEStream(response.body).catch(err => {
|
|
362
522
|
if (err?.name !== 'AbortError') {
|
|
@@ -369,8 +529,12 @@ export class RedseatClient {
|
|
|
369
529
|
// Intentionally disconnected
|
|
370
530
|
return;
|
|
371
531
|
}
|
|
532
|
+
this.sseAbortController = undefined;
|
|
372
533
|
this.handleSSEError(error);
|
|
373
534
|
}
|
|
535
|
+
finally {
|
|
536
|
+
this.sseIsConnecting = false;
|
|
537
|
+
}
|
|
374
538
|
}
|
|
375
539
|
/**
|
|
376
540
|
* Builds the SSE endpoint URL with optional library filters
|
|
@@ -384,6 +548,39 @@ export class RedseatClient {
|
|
|
384
548
|
}
|
|
385
549
|
return url;
|
|
386
550
|
}
|
|
551
|
+
/**
|
|
552
|
+
* Resets the activity timeout timer.
|
|
553
|
+
* Called when data is received from the SSE stream.
|
|
554
|
+
* If no data is received within the timeout period, triggers reconnection.
|
|
555
|
+
*/
|
|
556
|
+
resetActivityTimeout() {
|
|
557
|
+
if (this.sseActivityTimeout) {
|
|
558
|
+
clearTimeout(this.sseActivityTimeout);
|
|
559
|
+
}
|
|
560
|
+
this.sseActivityTimeout = setTimeout(() => {
|
|
561
|
+
if (this.disposed) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
// No activity within timeout period - connection is likely stale
|
|
565
|
+
console.log('SSE activity timeout - reconnecting');
|
|
566
|
+
this._sseConnectionState.next('disconnected');
|
|
567
|
+
// Abort current connection and schedule reconnect
|
|
568
|
+
if (this.sseAbortController) {
|
|
569
|
+
this.sseAbortController.abort();
|
|
570
|
+
this.sseAbortController = undefined;
|
|
571
|
+
}
|
|
572
|
+
this.scheduleReconnect();
|
|
573
|
+
}, this.SSE_ACTIVITY_TIMEOUT);
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Clears the activity timeout timer.
|
|
577
|
+
*/
|
|
578
|
+
clearActivityTimeout() {
|
|
579
|
+
if (this.sseActivityTimeout) {
|
|
580
|
+
clearTimeout(this.sseActivityTimeout);
|
|
581
|
+
this.sseActivityTimeout = undefined;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
387
584
|
/**
|
|
388
585
|
* Processes the SSE stream and emits events
|
|
389
586
|
*/
|
|
@@ -391,15 +588,22 @@ export class RedseatClient {
|
|
|
391
588
|
const reader = body.getReader();
|
|
392
589
|
const decoder = new TextDecoder();
|
|
393
590
|
let buffer = '';
|
|
591
|
+
// Start activity timeout tracking
|
|
592
|
+
this.resetActivityTimeout();
|
|
394
593
|
try {
|
|
395
594
|
while (true) {
|
|
396
595
|
const { done, value } = await reader.read();
|
|
397
596
|
if (done) {
|
|
398
597
|
// Stream ended - server closed connection
|
|
598
|
+
this.clearActivityTimeout();
|
|
399
599
|
this._sseConnectionState.next('disconnected');
|
|
400
|
-
this.
|
|
600
|
+
if (this.shouldAttemptReconnect()) {
|
|
601
|
+
this.scheduleReconnect();
|
|
602
|
+
}
|
|
401
603
|
break;
|
|
402
604
|
}
|
|
605
|
+
// Data received - reset activity timeout
|
|
606
|
+
this.resetActivityTimeout();
|
|
403
607
|
buffer += decoder.decode(value, { stream: true });
|
|
404
608
|
const { events, remainingBuffer } = this.parseSSEBuffer(buffer);
|
|
405
609
|
buffer = remainingBuffer;
|
|
@@ -410,12 +614,14 @@ export class RedseatClient {
|
|
|
410
614
|
}
|
|
411
615
|
}
|
|
412
616
|
catch (error) {
|
|
617
|
+
this.clearActivityTimeout();
|
|
413
618
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
414
619
|
return;
|
|
415
620
|
}
|
|
416
621
|
throw error;
|
|
417
622
|
}
|
|
418
623
|
finally {
|
|
624
|
+
this.sseAbortController = undefined;
|
|
419
625
|
reader.releaseLock();
|
|
420
626
|
}
|
|
421
627
|
}
|
|
@@ -489,6 +695,9 @@ export class RedseatClient {
|
|
|
489
695
|
* Handles SSE errors and triggers reconnection if appropriate
|
|
490
696
|
*/
|
|
491
697
|
handleSSEError(error) {
|
|
698
|
+
if (this.disposed || !this.sseShouldReconnect) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
492
701
|
const sseError = {
|
|
493
702
|
type: 'network',
|
|
494
703
|
message: error instanceof Error ? error.message : 'Unknown error',
|
|
@@ -501,40 +710,68 @@ export class RedseatClient {
|
|
|
501
710
|
}
|
|
502
711
|
this._sseConnectionState.next('error');
|
|
503
712
|
this._sseError.next(sseError);
|
|
504
|
-
// Don't reconnect on auth errors
|
|
505
713
|
if (sseError.type === 'auth') {
|
|
714
|
+
if (!(this.sseOptions?.reconnectOnAuthError ?? true)) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const maxAuthReconnectAttempts = this.sseOptions?.maxAuthReconnectAttempts ?? 5;
|
|
718
|
+
if (this.sseAuthReconnectAttempts >= maxAuthReconnectAttempts) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
this.sseAuthReconnectAttempts++;
|
|
722
|
+
this.scheduleReconnect();
|
|
506
723
|
return;
|
|
507
724
|
}
|
|
725
|
+
this.sseAuthReconnectAttempts = 0;
|
|
508
726
|
this.scheduleReconnect();
|
|
509
727
|
}
|
|
510
728
|
/**
|
|
511
729
|
* Schedules a reconnection attempt with exponential backoff
|
|
512
730
|
*/
|
|
513
731
|
scheduleReconnect() {
|
|
514
|
-
if (!this.
|
|
732
|
+
if (!this.shouldAttemptReconnect()) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const options = this.sseOptions;
|
|
736
|
+
if (!options) {
|
|
515
737
|
return;
|
|
516
738
|
}
|
|
517
|
-
if (
|
|
518
|
-
this.sseReconnectAttempts >=
|
|
739
|
+
if (options.maxReconnectAttempts !== undefined &&
|
|
740
|
+
this.sseReconnectAttempts >= options.maxReconnectAttempts) {
|
|
519
741
|
return;
|
|
520
742
|
}
|
|
521
|
-
|
|
522
|
-
|
|
743
|
+
if (this.sseReconnectTimeout) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const initialDelay = options.initialReconnectDelay ?? 1000;
|
|
747
|
+
const maxDelay = options.maxReconnectDelay ?? 30000;
|
|
523
748
|
// Exponential backoff with jitter
|
|
524
749
|
const exponentialDelay = initialDelay * Math.pow(2, this.sseReconnectAttempts);
|
|
525
750
|
const jitter = Math.random() * 1000;
|
|
526
751
|
const delay = Math.min(exponentialDelay + jitter, maxDelay);
|
|
527
752
|
this._sseConnectionState.next('reconnecting');
|
|
528
753
|
this.sseReconnectAttempts++;
|
|
529
|
-
this.sseReconnectTimeout = setTimeout(
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
}
|
|
534
|
-
catch {
|
|
535
|
-
// Token refresh failed, will try again on next reconnect
|
|
754
|
+
this.sseReconnectTimeout = setTimeout(() => {
|
|
755
|
+
this.sseReconnectTimeout = undefined;
|
|
756
|
+
if (!this.shouldAttemptReconnect()) {
|
|
757
|
+
return;
|
|
536
758
|
}
|
|
537
|
-
|
|
759
|
+
void (async () => {
|
|
760
|
+
try {
|
|
761
|
+
await this.refreshToken();
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
console.log('SSE reconnect: token refresh failed, scheduling another retry', error);
|
|
765
|
+
this._sseError.next({
|
|
766
|
+
type: 'auth',
|
|
767
|
+
message: `Token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
768
|
+
timestamp: Date.now()
|
|
769
|
+
});
|
|
770
|
+
this.scheduleReconnect();
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
await this._connectSSE();
|
|
774
|
+
})();
|
|
538
775
|
}, delay);
|
|
539
776
|
}
|
|
540
777
|
}
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -130,6 +130,16 @@ export declare enum LibraryRole {
|
|
|
130
130
|
share = "share",
|
|
131
131
|
admin = "admin"
|
|
132
132
|
}
|
|
133
|
+
export interface UserMapping {
|
|
134
|
+
[key: string]: unknown;
|
|
135
|
+
}
|
|
136
|
+
export interface ServerLibrarySettings {
|
|
137
|
+
faceThreshold?: number;
|
|
138
|
+
ignoreGroups?: boolean;
|
|
139
|
+
preductionModel?: string;
|
|
140
|
+
mapProgress?: UserMapping[];
|
|
141
|
+
dataPath?: string;
|
|
142
|
+
}
|
|
133
143
|
export interface ILibrary {
|
|
134
144
|
id?: string;
|
|
135
145
|
name: string;
|
|
@@ -137,6 +147,7 @@ export interface ILibrary {
|
|
|
137
147
|
source?: LibrarySources;
|
|
138
148
|
roles?: LibraryRole[];
|
|
139
149
|
crypt?: boolean;
|
|
150
|
+
settings: ServerLibrarySettings;
|
|
140
151
|
hidden?: boolean;
|
|
141
152
|
status?: string;
|
|
142
153
|
}
|
|
@@ -798,3 +809,61 @@ export interface IRsRequestProcessing {
|
|
|
798
809
|
/** Creation timestamp */
|
|
799
810
|
added: number;
|
|
800
811
|
}
|
|
812
|
+
export declare enum VideoOverlayPosition {
|
|
813
|
+
topLeft = "topLeft",
|
|
814
|
+
topRight = "topRight",
|
|
815
|
+
topCenter = "topCenter",
|
|
816
|
+
bottomLeft = "bottomLeft",
|
|
817
|
+
bottomRight = "bottomRight",
|
|
818
|
+
bottomCenter = "bottomCenter",
|
|
819
|
+
Center = "center"
|
|
820
|
+
}
|
|
821
|
+
export declare enum VideoOverlayType {
|
|
822
|
+
watermark = "watermark",
|
|
823
|
+
file = "file"
|
|
824
|
+
}
|
|
825
|
+
export interface VideoOverlay {
|
|
826
|
+
type: VideoOverlayType;
|
|
827
|
+
path: string;
|
|
828
|
+
position?: VideoOverlayPosition;
|
|
829
|
+
margin?: number;
|
|
830
|
+
ratio: number;
|
|
831
|
+
opacity: number;
|
|
832
|
+
}
|
|
833
|
+
export interface VideoTextOverlay {
|
|
834
|
+
text: string;
|
|
835
|
+
fontColor: string;
|
|
836
|
+
font?: string;
|
|
837
|
+
position: VideoOverlayPosition;
|
|
838
|
+
marginHorizontal?: number;
|
|
839
|
+
marginVertical?: number;
|
|
840
|
+
fontSize: number;
|
|
841
|
+
opacity?: number;
|
|
842
|
+
shadowColor: string;
|
|
843
|
+
shadowOpacity?: number;
|
|
844
|
+
start?: number;
|
|
845
|
+
end?: number;
|
|
846
|
+
}
|
|
847
|
+
export interface VideoConvertInterval {
|
|
848
|
+
start: number;
|
|
849
|
+
duration: number;
|
|
850
|
+
/** Defaults to current input */
|
|
851
|
+
input?: string;
|
|
852
|
+
}
|
|
853
|
+
export interface VideoConvertRequest {
|
|
854
|
+
id: string;
|
|
855
|
+
format: string;
|
|
856
|
+
codec?: string;
|
|
857
|
+
crf?: number;
|
|
858
|
+
noAudio?: boolean;
|
|
859
|
+
width?: string;
|
|
860
|
+
height?: string;
|
|
861
|
+
framerate?: number;
|
|
862
|
+
cropWidth?: number;
|
|
863
|
+
cropHeight?: number;
|
|
864
|
+
aspectRatio?: string;
|
|
865
|
+
aspectRatioAlignment?: 'center' | 'left' | 'right';
|
|
866
|
+
overlay?: VideoOverlay;
|
|
867
|
+
texts?: VideoTextOverlay[];
|
|
868
|
+
intervals?: VideoConvertInterval[];
|
|
869
|
+
}
|
package/dist/interfaces.js
CHANGED
|
@@ -88,3 +88,19 @@ export var RsRequestMethod;
|
|
|
88
88
|
RsRequestMethod["Delete"] = "delete";
|
|
89
89
|
RsRequestMethod["Head"] = "head";
|
|
90
90
|
})(RsRequestMethod || (RsRequestMethod = {}));
|
|
91
|
+
// ========== Video Convert Types ==========
|
|
92
|
+
export var VideoOverlayPosition;
|
|
93
|
+
(function (VideoOverlayPosition) {
|
|
94
|
+
VideoOverlayPosition["topLeft"] = "topLeft";
|
|
95
|
+
VideoOverlayPosition["topRight"] = "topRight";
|
|
96
|
+
VideoOverlayPosition["topCenter"] = "topCenter";
|
|
97
|
+
VideoOverlayPosition["bottomLeft"] = "bottomLeft";
|
|
98
|
+
VideoOverlayPosition["bottomRight"] = "bottomRight";
|
|
99
|
+
VideoOverlayPosition["bottomCenter"] = "bottomCenter";
|
|
100
|
+
VideoOverlayPosition["Center"] = "center";
|
|
101
|
+
})(VideoOverlayPosition || (VideoOverlayPosition = {}));
|
|
102
|
+
export var VideoOverlayType;
|
|
103
|
+
(function (VideoOverlayType) {
|
|
104
|
+
VideoOverlayType["watermark"] = "watermark";
|
|
105
|
+
VideoOverlayType["file"] = "file";
|
|
106
|
+
})(VideoOverlayType || (VideoOverlayType = {}));
|
package/dist/server.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export declare class ServerApi {
|
|
|
11
11
|
timeout?: number;
|
|
12
12
|
}): Promise<any>;
|
|
13
13
|
addLibrary(library: Partial<ILibrary>): Promise<ILibrary>;
|
|
14
|
-
|
|
14
|
+
updateLibrary(libraryId: string, library: Partial<ILibrary>): Promise<ILibrary>;
|
|
15
15
|
getPlugins(): Promise<IPlugin[]>;
|
|
16
16
|
getCredentials(): Promise<ICredential[]>;
|
|
17
17
|
saveCredential(credential: ICredential): Promise<ICredential>;
|
package/dist/server.js
CHANGED
|
@@ -18,9 +18,9 @@ export class ServerApi {
|
|
|
18
18
|
const res = await this.client.post('/libraries', library);
|
|
19
19
|
return res.data;
|
|
20
20
|
}
|
|
21
|
-
async
|
|
22
|
-
const res = await this.client.
|
|
23
|
-
return res.data
|
|
21
|
+
async updateLibrary(libraryId, library) {
|
|
22
|
+
const res = await this.client.patch(`/libraries/${libraryId}`, library);
|
|
23
|
+
return res.data;
|
|
24
24
|
}
|
|
25
25
|
async getPlugins() {
|
|
26
26
|
const res = await this.client.get('/plugins');
|
package/dist/sse-types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ILibrary, IFile, IEpisode, ISerie, IMovie, IPerson, ITag, IBackup, IWatched, IUnwatched, IRsRequestProcessing } from './interfaces.js';
|
|
1
|
+
import { ILibrary, IFile, IEpisode, ISerie, IMovie, IPerson, ITag, IBackup, IWatched, IUnwatched, IRsRequestProcessing, VideoConvertRequest } from './interfaces.js';
|
|
2
2
|
export type ElementAction = 'Added' | 'Updated' | 'Deleted';
|
|
3
3
|
export type SSEConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
|
|
4
4
|
export interface SSEConnectionOptions {
|
|
@@ -12,6 +12,18 @@ export interface SSEConnectionOptions {
|
|
|
12
12
|
initialReconnectDelay?: number;
|
|
13
13
|
/** Maximum reconnect delay in ms (default: 30000) */
|
|
14
14
|
maxReconnectDelay?: number;
|
|
15
|
+
/** Reconnect when tab becomes visible after being hidden for a while (default: true) */
|
|
16
|
+
reconnectOnPageVisible?: boolean;
|
|
17
|
+
/** Minimum hidden time before reconnecting on visible, in ms (default: 30000) */
|
|
18
|
+
reconnectVisibleAfterMs?: number;
|
|
19
|
+
/** Reconnect when browser comes back online (default: true) */
|
|
20
|
+
reconnectOnOnline?: boolean;
|
|
21
|
+
/** Reconnect on window focus when disconnected/error (default: true) */
|
|
22
|
+
reconnectOnFocus?: boolean;
|
|
23
|
+
/** Retry reconnection on auth (401) errors (default: true) */
|
|
24
|
+
reconnectOnAuthError?: boolean;
|
|
25
|
+
/** Maximum auth-error reconnect attempts before giving up (default: 5) */
|
|
26
|
+
maxAuthReconnectAttempts?: number;
|
|
15
27
|
}
|
|
16
28
|
export interface SSEConnectionError {
|
|
17
29
|
type: 'network' | 'auth' | 'server' | 'parse';
|
|
@@ -50,12 +62,20 @@ export interface SSEUploadProgressEvent {
|
|
|
50
62
|
progress: RsProgress;
|
|
51
63
|
remainingSecondes?: number;
|
|
52
64
|
}
|
|
65
|
+
export type RsVideoTranscodeStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'canceled';
|
|
66
|
+
export interface ConvertProgress {
|
|
67
|
+
id: string;
|
|
68
|
+
filename: string;
|
|
69
|
+
convertedId: string | null;
|
|
70
|
+
done: boolean;
|
|
71
|
+
percent: number;
|
|
72
|
+
status: RsVideoTranscodeStatus;
|
|
73
|
+
estimatedRemainingSeconds: number | null;
|
|
74
|
+
request: VideoConvertRequest | null;
|
|
75
|
+
}
|
|
53
76
|
export interface SSEConvertProgressEvent {
|
|
54
77
|
library: string;
|
|
55
|
-
|
|
56
|
-
progress: number;
|
|
57
|
-
status: string;
|
|
58
|
-
message?: string;
|
|
78
|
+
progress: ConvertProgress;
|
|
59
79
|
}
|
|
60
80
|
export interface SSEEpisodesEvent {
|
|
61
81
|
library: string;
|
package/package.json
CHANGED
package/server.md
CHANGED
|
@@ -6,6 +6,7 @@ The `ServerApi` class provides server-level operations for managing libraries, s
|
|
|
6
6
|
|
|
7
7
|
`ServerApi` handles operations that are not specific to a single library, such as:
|
|
8
8
|
- Listing and creating libraries
|
|
9
|
+
- Updating libraries
|
|
9
10
|
- Getting current user information
|
|
10
11
|
- Managing server settings
|
|
11
12
|
- Listing plugins and credentials
|
|
@@ -66,6 +67,12 @@ Creates a new library.
|
|
|
66
67
|
- `type`: Library type - `'photos'`, `'shows'`, `'movies'`, or `'iptv'` (required)
|
|
67
68
|
- `source`: Optional source type
|
|
68
69
|
- `crypt`: Optional boolean to enable encryption
|
|
70
|
+
- `settings`: Optional library settings object:
|
|
71
|
+
- `faceThreshold?: number`
|
|
72
|
+
- `ignoreGroups?: boolean`
|
|
73
|
+
- `preductionModel?: string` prediction model to use for tagging
|
|
74
|
+
- `mapProgress?: Record<string, any>[]` allow to map view progress from a user to another user
|
|
75
|
+
- `dataPath?: string` custom path to store library running data like thumnails, cache, portraits...
|
|
69
76
|
- `hidden`: Optional boolean to hide library
|
|
70
77
|
|
|
71
78
|
**Returns:** Promise resolving to the created `ILibrary` object with generated `id`
|
|
@@ -75,24 +82,33 @@ Creates a new library.
|
|
|
75
82
|
const newLibrary = await serverApi.addLibrary({
|
|
76
83
|
name: 'My Photos',
|
|
77
84
|
type: 'photos',
|
|
78
|
-
crypt: true // Enable encryption
|
|
85
|
+
crypt: true, // Enable encryption
|
|
86
|
+
settings: {
|
|
87
|
+
faceThreshold: 0.7
|
|
88
|
+
}
|
|
79
89
|
});
|
|
80
90
|
console.log(`Created library with ID: ${newLibrary.id}`);
|
|
81
91
|
```
|
|
82
92
|
|
|
83
|
-
### `
|
|
93
|
+
### `updateLibrary(libraryId: string, library: Partial<ILibrary>): Promise<ILibrary>`
|
|
84
94
|
|
|
85
|
-
|
|
95
|
+
Updates an existing library.
|
|
86
96
|
|
|
87
97
|
**Parameters:**
|
|
88
|
-
- `
|
|
98
|
+
- `libraryId`: The library ID to update
|
|
99
|
+
- `library`: Partial library object containing fields to update (for example `name`, `source`, or `settings`)
|
|
89
100
|
|
|
90
|
-
**Returns:** Promise resolving to the
|
|
101
|
+
**Returns:** Promise resolving to the updated `ILibrary` object
|
|
91
102
|
|
|
92
103
|
**Example:**
|
|
93
104
|
```typescript
|
|
94
|
-
const
|
|
95
|
-
|
|
105
|
+
const updatedLibrary = await serverApi.updateLibrary('library-123', {
|
|
106
|
+
name: 'Renamed Library',
|
|
107
|
+
settings: {
|
|
108
|
+
faceThreshold: 0.8
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
console.log(`Updated library: ${updatedLibrary.name}`);
|
|
96
112
|
```
|
|
97
113
|
|
|
98
114
|
### `getPlugins(): Promise<any[]>`
|
|
@@ -509,6 +525,11 @@ const newLibrary = await serverApi.addLibrary({
|
|
|
509
525
|
crypt: true
|
|
510
526
|
});
|
|
511
527
|
|
|
528
|
+
// Update the library settings
|
|
529
|
+
const updatedLibrary = await serverApi.updateLibrary(newLibrary.id!, {
|
|
530
|
+
name: 'Encrypted Family Photos'
|
|
531
|
+
});
|
|
532
|
+
|
|
512
533
|
// Get server settings
|
|
513
534
|
const maxUpload = await serverApi.getSetting('max_upload_size');
|
|
514
535
|
console.log(`Max upload size: ${maxUpload}`);
|
|
@@ -535,4 +556,3 @@ try {
|
|
|
535
556
|
- [RedseatClient Documentation](client.md) - HTTP client used by ServerApi
|
|
536
557
|
- [LibraryApi Documentation](libraries.md) - Library-specific operations
|
|
537
558
|
- [README](README.md) - Package overview
|
|
538
|
-
|