@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 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 SSE resources and completes observables.
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 ?? 0
137
+ timeout: options.timeout ?? 30000 // 30 second default
76
138
  });
77
139
  // Detect local URL asynchronously and update axios instance
78
- this.detectLocalUrl().then((url) => {
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
- const idToken = await this.getIdToken();
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
- // ==================== SSE Methods ====================
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
- if (this.sseReconnectTimeout) {
301
- clearTimeout(this.sseReconnectTimeout);
302
- this.sseReconnectTimeout = undefined;
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 SSE resources and completes observables.
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
- this.sseAbortController = new AbortController();
472
+ const abortController = new AbortController();
473
+ this.sseAbortController = abortController;
339
474
  console.log("SSSEEEE URL", url);
340
- const response = await fetch(url, {
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: this.sseAbortController.signal
482
+ signal: abortController.signal
348
483
  });
349
484
  if (!response.ok) {
350
485
  if (response.status === 401) {
351
- throw Object.assign(new Error('Authentication failed'), { type: 'auth' });
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.scheduleReconnect();
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.sseOptions?.autoReconnect) {
732
+ if (!this.shouldAttemptReconnect()) {
733
+ return;
734
+ }
735
+ const options = this.sseOptions;
736
+ if (!options) {
515
737
  return;
516
738
  }
517
- if (this.sseOptions.maxReconnectAttempts !== undefined &&
518
- this.sseReconnectAttempts >= this.sseOptions.maxReconnectAttempts) {
739
+ if (options.maxReconnectAttempts !== undefined &&
740
+ this.sseReconnectAttempts >= options.maxReconnectAttempts) {
519
741
  return;
520
742
  }
521
- const initialDelay = this.sseOptions.initialReconnectDelay ?? 1000;
522
- const maxDelay = this.sseOptions.maxReconnectDelay ?? 30000;
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(async () => {
530
- // Refresh token before reconnecting
531
- try {
532
- await this.refreshToken();
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
- this._connectSSE();
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
  }
@@ -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
+ }
@@ -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
- getSetting(key: string): Promise<string>;
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 getSetting(key) {
22
- const res = await this.client.get(`/settings/${key}`);
23
- return res.data.value;
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');
@@ -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
- mediaId: string;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseat/api",
3
- "version": "0.3.11",
3
+ "version": "0.3.14",
4
4
  "description": "TypeScript API client library for interacting with Redseat servers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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
- ### `getSetting(key: string): Promise<string>`
93
+ ### `updateLibrary(libraryId: string, library: Partial<ILibrary>): Promise<ILibrary>`
84
94
 
85
- Retrieves a server setting value by key.
95
+ Updates an existing library.
86
96
 
87
97
  **Parameters:**
88
- - `key`: The setting key to retrieve
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 setting value as a string
101
+ **Returns:** Promise resolving to the updated `ILibrary` object
91
102
 
92
103
  **Example:**
93
104
  ```typescript
94
- const maxUploadSize = await serverApi.getSetting('max_upload_size');
95
- console.log(`Max upload size: ${maxUploadSize}`);
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
-