@redseat/api 0.2.7 → 0.2.8

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
@@ -310,6 +310,302 @@ The following methods are used internally and should not be called directly:
310
310
  - `detectLocalUrl()` - Detects local development server
311
311
  - `getRegularServerUrl()` - Gets production server URL
312
312
 
313
+ ---
314
+
315
+ ## Server-Sent Events (SSE)
316
+
317
+ The `RedseatClient` provides real-time event streaming using Server-Sent Events (SSE). Events are delivered as RxJS Observables for easy integration with reactive programming patterns.
318
+
319
+ ### Connection Management
320
+
321
+ #### `connectSSE(options?: SSEConnectionOptions): Promise<void>`
322
+
323
+ Connects to the server's SSE endpoint for real-time updates.
324
+
325
+ **Parameters:**
326
+ - `options`: Optional connection configuration:
327
+ - `libraries?`: Array of library IDs to filter events to specific libraries
328
+ - `autoReconnect?`: Enable auto-reconnect on disconnect (default: `true`)
329
+ - `maxReconnectAttempts?`: Maximum reconnect attempts (default: unlimited)
330
+ - `initialReconnectDelay?`: Initial reconnect delay in ms (default: `1000`)
331
+ - `maxReconnectDelay?`: Maximum reconnect delay in ms (default: `30000`)
332
+
333
+ **Example:**
334
+ ```typescript
335
+ // Connect with default options
336
+ await client.connectSSE();
337
+
338
+ // Connect with library filter
339
+ await client.connectSSE({
340
+ libraries: ['library-123', 'library-456']
341
+ });
342
+
343
+ // Connect with custom reconnection settings
344
+ await client.connectSSE({
345
+ autoReconnect: true,
346
+ maxReconnectAttempts: 10,
347
+ initialReconnectDelay: 2000,
348
+ maxReconnectDelay: 60000
349
+ });
350
+ ```
351
+
352
+ #### `disconnectSSE(): void`
353
+
354
+ Disconnects from the SSE endpoint and cleans up resources.
355
+
356
+ **Example:**
357
+ ```typescript
358
+ client.disconnectSSE();
359
+ ```
360
+
361
+ #### `dispose(): void`
362
+
363
+ Disposes of all SSE resources and completes all observables. Call this when the client is no longer needed.
364
+
365
+ **Example:**
366
+ ```typescript
367
+ client.dispose();
368
+ ```
369
+
370
+ #### `sseConnectionState: SSEConnectionState`
371
+
372
+ Gets the current SSE connection state. Possible values:
373
+ - `'disconnected'` - Not connected
374
+ - `'connecting'` - Connection in progress
375
+ - `'connected'` - Successfully connected
376
+ - `'reconnecting'` - Attempting to reconnect after disconnect
377
+ - `'error'` - Connection error occurred
378
+
379
+ **Example:**
380
+ ```typescript
381
+ if (client.sseConnectionState === 'connected') {
382
+ console.log('SSE is connected');
383
+ }
384
+ ```
385
+
386
+ ### Connection State Observables
387
+
388
+ #### `sseConnectionState$: Observable<SSEConnectionState>`
389
+
390
+ Observable that emits connection state changes.
391
+
392
+ **Example:**
393
+ ```typescript
394
+ client.sseConnectionState$.subscribe(state => {
395
+ console.log('SSE connection state:', state);
396
+ });
397
+ ```
398
+
399
+ #### `sseConnected$: Observable<boolean>`
400
+
401
+ Observable that emits `true` when connected, `false` otherwise.
402
+
403
+ **Example:**
404
+ ```typescript
405
+ client.sseConnected$.subscribe(connected => {
406
+ if (connected) {
407
+ console.log('SSE connected');
408
+ } else {
409
+ console.log('SSE disconnected');
410
+ }
411
+ });
412
+ ```
413
+
414
+ #### `sseError$: Observable<SSEConnectionError>`
415
+
416
+ Observable that emits connection errors.
417
+
418
+ **Example:**
419
+ ```typescript
420
+ client.sseError$.subscribe(error => {
421
+ console.error(`SSE error (${error.type}): ${error.message}`);
422
+ });
423
+ ```
424
+
425
+ ### Event Streams
426
+
427
+ All event streams are typed RxJS Observables. Subscribe to receive real-time updates from the server.
428
+
429
+ #### `library$: Observable<SSELibraryEvent>`
430
+
431
+ Emits when libraries are added, updated, or deleted.
432
+
433
+ ```typescript
434
+ client.library$.subscribe(event => {
435
+ console.log(`Library ${event.action}: ${event.library.name}`);
436
+ });
437
+ ```
438
+
439
+ #### `libraryStatus$: Observable<SSELibraryStatusEvent>`
440
+
441
+ Emits library status updates (e.g., scanning progress).
442
+
443
+ ```typescript
444
+ client.libraryStatus$.subscribe(event => {
445
+ console.log(`Library ${event.library}: ${event.message} (${event.progress}%)`);
446
+ });
447
+ ```
448
+
449
+ #### `medias$: Observable<SSEMediasEvent>`
450
+
451
+ Emits when media files are added, updated, or deleted.
452
+
453
+ ```typescript
454
+ client.medias$.subscribe(event => {
455
+ for (const { action, media } of event.medias) {
456
+ console.log(`Media ${action}: ${media.name}`);
457
+ }
458
+ });
459
+ ```
460
+
461
+ #### `mediasProgress$: Observable<SSEMediasProgressEvent>`
462
+
463
+ Emits media processing progress updates.
464
+
465
+ ```typescript
466
+ client.mediasProgress$.subscribe(event => {
467
+ console.log(`Media ${event.mediaId}: ${event.progress}%`);
468
+ });
469
+ ```
470
+
471
+ #### `convertProgress$: Observable<SSEConvertProgressEvent>`
472
+
473
+ Emits video conversion progress updates.
474
+
475
+ ```typescript
476
+ client.convertProgress$.subscribe(event => {
477
+ console.log(`Converting ${event.mediaId}: ${event.progress}% - ${event.status}`);
478
+ });
479
+ ```
480
+
481
+ #### `episodes$: Observable<SSEEpisodesEvent>`
482
+
483
+ Emits when episodes are added, updated, or deleted.
484
+
485
+ ```typescript
486
+ client.episodes$.subscribe(event => {
487
+ for (const { action, episode } of event.episodes) {
488
+ console.log(`Episode ${action}: ${episode.name}`);
489
+ }
490
+ });
491
+ ```
492
+
493
+ #### `series$: Observable<SSESeriesEvent>`
494
+
495
+ Emits when series are added, updated, or deleted.
496
+
497
+ ```typescript
498
+ client.series$.subscribe(event => {
499
+ for (const { action, serie } of event.series) {
500
+ console.log(`Series ${action}: ${serie.name}`);
501
+ }
502
+ });
503
+ ```
504
+
505
+ #### `movies$: Observable<SSEMoviesEvent>`
506
+
507
+ Emits when movies are added, updated, or deleted.
508
+
509
+ ```typescript
510
+ client.movies$.subscribe(event => {
511
+ for (const { action, movie } of event.movies) {
512
+ console.log(`Movie ${action}: ${movie.name}`);
513
+ }
514
+ });
515
+ ```
516
+
517
+ #### `people$: Observable<SSEPeopleEvent>`
518
+
519
+ Emits when people are added, updated, or deleted.
520
+
521
+ ```typescript
522
+ client.people$.subscribe(event => {
523
+ for (const { action, person } of event.people) {
524
+ console.log(`Person ${action}: ${person.name}`);
525
+ }
526
+ });
527
+ ```
528
+
529
+ #### `tags$: Observable<SSETagsEvent>`
530
+
531
+ Emits when tags are added, updated, or deleted.
532
+
533
+ ```typescript
534
+ client.tags$.subscribe(event => {
535
+ for (const { action, tag } of event.tags) {
536
+ console.log(`Tag ${action}: ${tag.name}`);
537
+ }
538
+ });
539
+ ```
540
+
541
+ #### `backups$: Observable<SSEBackupsEvent>`
542
+
543
+ Emits backup status updates.
544
+
545
+ ```typescript
546
+ client.backups$.subscribe(event => {
547
+ console.log(`Backup ${event.backup.name}: ${event.backup.status}`);
548
+ });
549
+ ```
550
+
551
+ #### `backupFiles$: Observable<SSEBackupFilesEvent>`
552
+
553
+ Emits backup file progress updates.
554
+
555
+ ```typescript
556
+ client.backupFiles$.subscribe(event => {
557
+ console.log(`Backing up ${event.file}: ${event.progress}%`);
558
+ });
559
+ ```
560
+
561
+ ### Complete SSE Example
562
+
563
+ ```typescript
564
+ import { RedseatClient } from '@redseat/api';
565
+ import { Subscription } from 'rxjs';
566
+
567
+ const client = new RedseatClient({
568
+ server: { id: 'server-123', url: 'example.com' },
569
+ getIdToken: async () => await getFirebaseToken()
570
+ });
571
+
572
+ // Track subscriptions for cleanup
573
+ const subscriptions: Subscription[] = [];
574
+
575
+ // Monitor connection state
576
+ subscriptions.push(
577
+ client.sseConnectionState$.subscribe(state => {
578
+ console.log('SSE state:', state);
579
+ })
580
+ );
581
+
582
+ // Handle errors
583
+ subscriptions.push(
584
+ client.sseError$.subscribe(error => {
585
+ console.error('SSE error:', error.message);
586
+ })
587
+ );
588
+
589
+ // Subscribe to events
590
+ subscriptions.push(
591
+ client.medias$.subscribe(event => {
592
+ for (const { action, media } of event.medias) {
593
+ console.log(`Media ${action}: ${media.name}`);
594
+ }
595
+ })
596
+ );
597
+
598
+ // Connect to SSE
599
+ await client.connectSSE();
600
+
601
+ // ... later, cleanup
602
+ subscriptions.forEach(sub => sub.unsubscribe());
603
+ client.disconnectSSE();
604
+
605
+ // Or dispose completely when client is no longer needed
606
+ client.dispose();
607
+ ```
608
+
313
609
  ## See Also
314
610
 
315
611
  - [ServerApi Documentation](server.md) - Uses RedseatClient for server operations
package/dist/client.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Method, AxiosRequestConfig } from 'axios';
2
+ import { Observable } from 'rxjs';
2
3
  import { IToken } from './auth.js';
3
4
  import { IServer } from './interfaces.js';
5
+ import { SSEConnectionState, SSEConnectionOptions, SSEConnectionError, SSELibraryEvent, SSELibraryStatusEvent, SSEMediasEvent, SSEMediasProgressEvent, SSEConvertProgressEvent, SSEEpisodesEvent, SSESeriesEvent, SSEMoviesEvent, SSEPeopleEvent, SSETagsEvent, SSEBackupsEvent, SSEBackupFilesEvent } from './sse-types.js';
4
6
  export interface ClientOptions {
5
7
  server: IServer;
6
8
  getIdToken: () => Promise<string>;
@@ -18,6 +20,32 @@ export declare class RedseatClient {
18
20
  private localServerUrl?;
19
21
  private tokenData?;
20
22
  private tokenRefreshPromise?;
23
+ private sseAbortController?;
24
+ private sseReconnectTimeout?;
25
+ private sseReconnectAttempts;
26
+ private sseOptions?;
27
+ private readonly _sseConnectionState;
28
+ private readonly _sseError;
29
+ private readonly _sseEvents;
30
+ readonly sseConnectionState$: Observable<SSEConnectionState>;
31
+ readonly sseError$: Observable<SSEConnectionError>;
32
+ readonly sseConnected$: Observable<boolean>;
33
+ readonly library$: Observable<SSELibraryEvent>;
34
+ readonly libraryStatus$: Observable<SSELibraryStatusEvent>;
35
+ readonly medias$: Observable<SSEMediasEvent>;
36
+ readonly mediasProgress$: Observable<SSEMediasProgressEvent>;
37
+ readonly convertProgress$: Observable<SSEConvertProgressEvent>;
38
+ readonly episodes$: Observable<SSEEpisodesEvent>;
39
+ readonly series$: Observable<SSESeriesEvent>;
40
+ readonly movies$: Observable<SSEMoviesEvent>;
41
+ readonly people$: Observable<SSEPeopleEvent>;
42
+ readonly tags$: Observable<SSETagsEvent>;
43
+ readonly backups$: Observable<SSEBackupsEvent>;
44
+ readonly backupFiles$: Observable<SSEBackupFilesEvent>;
45
+ /**
46
+ * Creates a typed observable for a specific SSE event type
47
+ */
48
+ private createEventStream;
21
49
  constructor(options: ClientOptions);
22
50
  private getRegularServerUrl;
23
51
  private detectLocalUrl;
@@ -57,4 +85,47 @@ export declare class RedseatClient {
57
85
  * Returns public server info (IServer).
58
86
  */
59
87
  getServer(serverId: string): Promise<IServer>;
88
+ /**
89
+ * Connects to the server's SSE endpoint for real-time updates.
90
+ * Automatically manages authentication and reconnection.
91
+ * @param options - Connection options including library filters and reconnection settings
92
+ */
93
+ connectSSE(options?: SSEConnectionOptions): Promise<void>;
94
+ /**
95
+ * Disconnects from the SSE endpoint and cleans up resources.
96
+ */
97
+ disconnectSSE(): void;
98
+ /**
99
+ * Disposes of all SSE resources and completes observables.
100
+ * Call this when the client is no longer needed.
101
+ */
102
+ dispose(): void;
103
+ /**
104
+ * Gets the current SSE connection state
105
+ */
106
+ get sseConnectionState(): SSEConnectionState;
107
+ /**
108
+ * Internal method to establish SSE connection
109
+ */
110
+ private _connectSSE;
111
+ /**
112
+ * Builds the SSE endpoint URL with optional library filters
113
+ */
114
+ private buildSSEUrl;
115
+ /**
116
+ * Processes the SSE stream and emits events
117
+ */
118
+ private processSSEStream;
119
+ /**
120
+ * Parses the SSE buffer and extracts complete events
121
+ */
122
+ private parseSSEBuffer;
123
+ /**
124
+ * Handles SSE errors and triggers reconnection if appropriate
125
+ */
126
+ private handleSSEError;
127
+ /**
128
+ * Schedules a reconnection attempt with exponential backoff
129
+ */
130
+ private scheduleReconnect;
60
131
  }
package/dist/client.js CHANGED
@@ -1,7 +1,36 @@
1
1
  import axios from 'axios';
2
+ import { BehaviorSubject, Subject, filter, map } from 'rxjs';
2
3
  import { fetchServerToken } from './auth.js';
3
4
  export class RedseatClient {
5
+ /**
6
+ * Creates a typed observable for a specific SSE event type
7
+ */
8
+ createEventStream(eventName) {
9
+ return this._sseEvents.pipe(filter((event) => event.event === eventName), map(event => event.data));
10
+ }
4
11
  constructor(options) {
12
+ this.sseReconnectAttempts = 0;
13
+ // RxJS subjects for SSE
14
+ this._sseConnectionState = new BehaviorSubject('disconnected');
15
+ this._sseError = new Subject();
16
+ this._sseEvents = new Subject();
17
+ // Public observables for SSE connection state
18
+ this.sseConnectionState$ = this._sseConnectionState.asObservable();
19
+ this.sseError$ = this._sseError.asObservable();
20
+ this.sseConnected$ = this._sseConnectionState.pipe(map(state => state === 'connected'));
21
+ // Typed event streams
22
+ this.library$ = this.createEventStream('library');
23
+ this.libraryStatus$ = this.createEventStream('library-status');
24
+ this.medias$ = this.createEventStream('medias');
25
+ this.mediasProgress$ = this.createEventStream('medias_progress');
26
+ this.convertProgress$ = this.createEventStream('convert_progress');
27
+ this.episodes$ = this.createEventStream('episodes');
28
+ this.series$ = this.createEventStream('series');
29
+ this.movies$ = this.createEventStream('movies');
30
+ this.people$ = this.createEventStream('people');
31
+ this.tags$ = this.createEventStream('tags');
32
+ this.backups$ = this.createEventStream('backups');
33
+ this.backupFiles$ = this.createEventStream('backups-files');
5
34
  this.server = options.server;
6
35
  this.redseatUrl = options.redseatUrl;
7
36
  this.getIdToken = options.getIdToken;
@@ -213,4 +242,260 @@ export class RedseatClient {
213
242
  });
214
243
  return response.data;
215
244
  }
245
+ // ==================== SSE Methods ====================
246
+ /**
247
+ * Connects to the server's SSE endpoint for real-time updates.
248
+ * Automatically manages authentication and reconnection.
249
+ * @param options - Connection options including library filters and reconnection settings
250
+ */
251
+ async connectSSE(options) {
252
+ // Disconnect any existing connection
253
+ this.disconnectSSE();
254
+ this.sseOptions = {
255
+ autoReconnect: true,
256
+ initialReconnectDelay: 1000,
257
+ maxReconnectDelay: 30000,
258
+ ...options
259
+ };
260
+ this.sseReconnectAttempts = 0;
261
+ await this._connectSSE();
262
+ }
263
+ /**
264
+ * Disconnects from the SSE endpoint and cleans up resources.
265
+ */
266
+ disconnectSSE() {
267
+ if (this.sseReconnectTimeout) {
268
+ clearTimeout(this.sseReconnectTimeout);
269
+ this.sseReconnectTimeout = undefined;
270
+ }
271
+ if (this.sseAbortController) {
272
+ this.sseAbortController.abort();
273
+ this.sseAbortController = undefined;
274
+ }
275
+ this._sseConnectionState.next('disconnected');
276
+ }
277
+ /**
278
+ * Disposes of all SSE resources and completes observables.
279
+ * Call this when the client is no longer needed.
280
+ */
281
+ dispose() {
282
+ this.disconnectSSE();
283
+ this._sseConnectionState.complete();
284
+ this._sseError.complete();
285
+ this._sseEvents.complete();
286
+ }
287
+ /**
288
+ * Gets the current SSE connection state
289
+ */
290
+ get sseConnectionState() {
291
+ return this._sseConnectionState.value;
292
+ }
293
+ /**
294
+ * Internal method to establish SSE connection
295
+ */
296
+ async _connectSSE() {
297
+ this._sseConnectionState.next('connecting');
298
+ try {
299
+ // Ensure we have a valid token
300
+ await this.ensureValidToken();
301
+ if (!this.tokenData) {
302
+ throw new Error('No authentication token available');
303
+ }
304
+ const url = this.buildSSEUrl();
305
+ this.sseAbortController = new AbortController();
306
+ const response = await fetch(url, {
307
+ method: 'GET',
308
+ headers: {
309
+ 'Authorization': `Bearer ${this.tokenData.token}`,
310
+ 'Accept': 'text/event-stream',
311
+ 'Cache-Control': 'no-cache'
312
+ },
313
+ signal: this.sseAbortController.signal
314
+ });
315
+ if (!response.ok) {
316
+ if (response.status === 401) {
317
+ throw Object.assign(new Error('Authentication failed'), { type: 'auth' });
318
+ }
319
+ throw Object.assign(new Error(`Server returned ${response.status}`), { type: 'server' });
320
+ }
321
+ if (!response.body) {
322
+ throw Object.assign(new Error('No response body'), { type: 'server' });
323
+ }
324
+ this._sseConnectionState.next('connected');
325
+ this.sseReconnectAttempts = 0;
326
+ // Process the stream
327
+ await this.processSSEStream(response.body);
328
+ }
329
+ catch (error) {
330
+ if (error instanceof Error && error.name === 'AbortError') {
331
+ // Intentionally disconnected
332
+ return;
333
+ }
334
+ this.handleSSEError(error);
335
+ }
336
+ }
337
+ /**
338
+ * Builds the SSE endpoint URL with optional library filters
339
+ */
340
+ buildSSEUrl() {
341
+ let url = `${this.baseUrl}/sse`;
342
+ if (this.sseOptions?.libraries && this.sseOptions.libraries.length > 0) {
343
+ const params = new URLSearchParams();
344
+ this.sseOptions.libraries.forEach(lib => params.append('library', lib));
345
+ url = `${url}?${params.toString()}`;
346
+ }
347
+ return url;
348
+ }
349
+ /**
350
+ * Processes the SSE stream and emits events
351
+ */
352
+ async processSSEStream(body) {
353
+ const reader = body.getReader();
354
+ const decoder = new TextDecoder();
355
+ let buffer = '';
356
+ try {
357
+ while (true) {
358
+ const { done, value } = await reader.read();
359
+ if (done) {
360
+ // Stream ended - server closed connection
361
+ this._sseConnectionState.next('disconnected');
362
+ this.scheduleReconnect();
363
+ break;
364
+ }
365
+ buffer += decoder.decode(value, { stream: true });
366
+ const { events, remainingBuffer } = this.parseSSEBuffer(buffer);
367
+ buffer = remainingBuffer;
368
+ for (const event of events) {
369
+ this._sseEvents.next(event);
370
+ }
371
+ }
372
+ }
373
+ catch (error) {
374
+ if (error instanceof Error && error.name === 'AbortError') {
375
+ return;
376
+ }
377
+ throw error;
378
+ }
379
+ finally {
380
+ reader.releaseLock();
381
+ }
382
+ }
383
+ /**
384
+ * Parses the SSE buffer and extracts complete events
385
+ */
386
+ parseSSEBuffer(buffer) {
387
+ const events = [];
388
+ const lines = buffer.split('\n');
389
+ let currentEvent = {};
390
+ let processedUpTo = 0;
391
+ for (let i = 0; i < lines.length; i++) {
392
+ const line = lines[i];
393
+ // Check if this is an incomplete line (last line without newline)
394
+ if (i === lines.length - 1 && !buffer.endsWith('\n')) {
395
+ break;
396
+ }
397
+ processedUpTo += line.length + 1; // +1 for the newline
398
+ if (line === '') {
399
+ // Empty line marks end of event
400
+ if (currentEvent.event && currentEvent.data !== undefined) {
401
+ events.push(currentEvent);
402
+ }
403
+ currentEvent = {};
404
+ continue;
405
+ }
406
+ if (line.startsWith(':')) {
407
+ // Comment, ignore
408
+ continue;
409
+ }
410
+ const colonIndex = line.indexOf(':');
411
+ if (colonIndex === -1) {
412
+ continue;
413
+ }
414
+ const field = line.slice(0, colonIndex);
415
+ // Value starts after colon, skip optional space after colon
416
+ let value = line.slice(colonIndex + 1);
417
+ if (value.startsWith(' ')) {
418
+ value = value.slice(1);
419
+ }
420
+ switch (field) {
421
+ case 'event':
422
+ currentEvent.event = value;
423
+ break;
424
+ case 'data':
425
+ try {
426
+ currentEvent.data = JSON.parse(value);
427
+ }
428
+ catch {
429
+ this._sseError.next({
430
+ type: 'parse',
431
+ message: `Failed to parse SSE data: ${value}`,
432
+ timestamp: Date.now()
433
+ });
434
+ }
435
+ break;
436
+ case 'id':
437
+ currentEvent.id = value;
438
+ break;
439
+ case 'retry':
440
+ currentEvent.retry = parseInt(value, 10);
441
+ break;
442
+ }
443
+ }
444
+ return {
445
+ events,
446
+ remainingBuffer: buffer.slice(processedUpTo)
447
+ };
448
+ }
449
+ /**
450
+ * Handles SSE errors and triggers reconnection if appropriate
451
+ */
452
+ handleSSEError(error) {
453
+ const sseError = {
454
+ type: 'network',
455
+ message: error instanceof Error ? error.message : 'Unknown error',
456
+ originalError: error instanceof Error ? error : undefined,
457
+ timestamp: Date.now()
458
+ };
459
+ // Determine error type
460
+ if (error && typeof error === 'object' && 'type' in error) {
461
+ sseError.type = error.type;
462
+ }
463
+ this._sseConnectionState.next('error');
464
+ this._sseError.next(sseError);
465
+ // Don't reconnect on auth errors
466
+ if (sseError.type === 'auth') {
467
+ return;
468
+ }
469
+ this.scheduleReconnect();
470
+ }
471
+ /**
472
+ * Schedules a reconnection attempt with exponential backoff
473
+ */
474
+ scheduleReconnect() {
475
+ if (!this.sseOptions?.autoReconnect) {
476
+ return;
477
+ }
478
+ if (this.sseOptions.maxReconnectAttempts !== undefined &&
479
+ this.sseReconnectAttempts >= this.sseOptions.maxReconnectAttempts) {
480
+ return;
481
+ }
482
+ const initialDelay = this.sseOptions.initialReconnectDelay ?? 1000;
483
+ const maxDelay = this.sseOptions.maxReconnectDelay ?? 30000;
484
+ // Exponential backoff with jitter
485
+ const exponentialDelay = initialDelay * Math.pow(2, this.sseReconnectAttempts);
486
+ const jitter = Math.random() * 1000;
487
+ const delay = Math.min(exponentialDelay + jitter, maxDelay);
488
+ this._sseConnectionState.next('reconnecting');
489
+ this.sseReconnectAttempts++;
490
+ this.sseReconnectTimeout = setTimeout(async () => {
491
+ // Refresh token before reconnecting
492
+ try {
493
+ await this.refreshToken();
494
+ }
495
+ catch {
496
+ // Token refresh failed, will try again on next reconnect
497
+ }
498
+ this._connectSSE();
499
+ }, delay);
500
+ }
216
501
  }
package/dist/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export * from './server.js';
6
6
  export * from './crypto.js';
7
7
  export * from './encryption.js';
8
8
  export * from './upload.js';
9
+ export * from './sse-types.js';
package/dist/index.js CHANGED
@@ -6,3 +6,4 @@ export * from './server.js';
6
6
  export * from './crypto.js';
7
7
  export * from './encryption.js';
8
8
  export * from './upload.js';
9
+ export * from './sse-types.js';
package/dist/library.d.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import { Observable } from 'rxjs';
1
2
  import { IFile, ITag, IPerson, ISerie, IMovie, MediaRequest, IEpisode, ExternalImage, IBackupFile, ILibrary, SerieInMedia, DeletedQuery, RsDeleted, MovieSort, RsSort, SqlOrder, RsRequest, DetectedFaceResult, UnassignFaceResponse, RsGroupDownload } from './interfaces.js';
3
+ import { SSEMediasEvent, SSEMediasProgressEvent, SSEConvertProgressEvent, SSEEpisodesEvent, SSESeriesEvent, SSEMoviesEvent, SSEPeopleEvent, SSETagsEvent, SSELibraryStatusEvent } from './sse-types.js';
2
4
  import { EncryptFileOptions, EncryptedFile } from './encryption.js';
3
5
  export interface MediaForUpdate {
4
6
  name?: string;
@@ -74,6 +76,15 @@ export interface LibraryHttpClient {
74
76
  }>;
75
77
  getFullUrl(path: string, params?: Record<string, string>): string;
76
78
  getAuthToken(): string;
79
+ readonly medias$?: Observable<SSEMediasEvent>;
80
+ readonly mediasProgress$?: Observable<SSEMediasProgressEvent>;
81
+ readonly convertProgress$?: Observable<SSEConvertProgressEvent>;
82
+ readonly episodes$?: Observable<SSEEpisodesEvent>;
83
+ readonly series$?: Observable<SSESeriesEvent>;
84
+ readonly movies$?: Observable<SSEMoviesEvent>;
85
+ readonly people$?: Observable<SSEPeopleEvent>;
86
+ readonly tags$?: Observable<SSETagsEvent>;
87
+ readonly libraryStatus$?: Observable<SSELibraryStatusEvent>;
77
88
  }
78
89
  export declare class LibraryApi {
79
90
  private client;
@@ -81,7 +92,27 @@ export declare class LibraryApi {
81
92
  private library;
82
93
  private key?;
83
94
  private keyText?;
95
+ private disposed;
96
+ readonly medias$: Observable<SSEMediasEvent>;
97
+ readonly mediasProgress$: Observable<SSEMediasProgressEvent>;
98
+ readonly convertProgress$: Observable<SSEConvertProgressEvent>;
99
+ readonly episodes$: Observable<SSEEpisodesEvent>;
100
+ readonly series$: Observable<SSESeriesEvent>;
101
+ readonly movies$: Observable<SSEMoviesEvent>;
102
+ readonly people$: Observable<SSEPeopleEvent>;
103
+ readonly tags$: Observable<SSETagsEvent>;
104
+ readonly libraryStatus$: Observable<SSELibraryStatusEvent>;
84
105
  constructor(client: LibraryHttpClient, libraryId: string, library: ILibrary);
106
+ /**
107
+ * Creates a library-filtered stream from a client stream.
108
+ * Returns EMPTY if the client doesn't have SSE support.
109
+ */
110
+ private createLibraryFilteredStream;
111
+ /**
112
+ * Marks this LibraryApi as disposed.
113
+ * After calling dispose(), the filtered streams will stop emitting events.
114
+ */
115
+ dispose(): void;
85
116
  setKey(passPhrase: string): Promise<void>;
86
117
  private getUrl;
87
118
  getTags(query?: {
package/dist/library.js CHANGED
@@ -1,10 +1,39 @@
1
+ import { filter, EMPTY } from 'rxjs';
1
2
  import { deriveKey, encryptText as encryptTextUtil, decryptText as decryptTextUtil, encryptBuffer, decryptBuffer, encryptFile as encryptFileUtil, decryptFile as decryptFileUtil, decryptFileThumb, encryptFilename as encryptFilenameUtil, getRandomIV as getRandomIVUtil } from './encryption.js';
2
3
  import { uint8ArrayFromBase64 } from './crypto.js';
3
4
  export class LibraryApi {
4
5
  constructor(client, libraryId, library) {
5
6
  this.client = client;
6
7
  this.libraryId = libraryId;
8
+ this.disposed = false;
7
9
  this.library = library;
10
+ // Create library-filtered streams
11
+ this.medias$ = this.createLibraryFilteredStream(client.medias$);
12
+ this.mediasProgress$ = this.createLibraryFilteredStream(client.mediasProgress$);
13
+ this.convertProgress$ = this.createLibraryFilteredStream(client.convertProgress$);
14
+ this.episodes$ = this.createLibraryFilteredStream(client.episodes$);
15
+ this.series$ = this.createLibraryFilteredStream(client.series$);
16
+ this.movies$ = this.createLibraryFilteredStream(client.movies$);
17
+ this.people$ = this.createLibraryFilteredStream(client.people$);
18
+ this.tags$ = this.createLibraryFilteredStream(client.tags$);
19
+ this.libraryStatus$ = this.createLibraryFilteredStream(client.libraryStatus$);
20
+ }
21
+ /**
22
+ * Creates a library-filtered stream from a client stream.
23
+ * Returns EMPTY if the client doesn't have SSE support.
24
+ */
25
+ createLibraryFilteredStream(source$) {
26
+ if (!source$) {
27
+ return EMPTY;
28
+ }
29
+ return source$.pipe(filter(event => !this.disposed && event.library === this.libraryId));
30
+ }
31
+ /**
32
+ * Marks this LibraryApi as disposed.
33
+ * After calling dispose(), the filtered streams will stop emitting events.
34
+ */
35
+ dispose() {
36
+ this.disposed = true;
8
37
  }
9
38
  async setKey(passPhrase) {
10
39
  // Derive keys
@@ -0,0 +1,121 @@
1
+ import { ILibrary, IFile, IEpisode, ISerie, IMovie, IPerson, ITag, IBackup } from './interfaces.js';
2
+ export type ElementAction = 'Added' | 'Updated' | 'Deleted';
3
+ export type SSEConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
4
+ export interface SSEConnectionOptions {
5
+ /** Filter to specific library IDs */
6
+ libraries?: string[];
7
+ /** Enable auto-reconnect on disconnect (default: true) */
8
+ autoReconnect?: boolean;
9
+ /** Maximum reconnect attempts (default: unlimited) */
10
+ maxReconnectAttempts?: number;
11
+ /** Initial reconnect delay in ms (default: 1000) */
12
+ initialReconnectDelay?: number;
13
+ /** Maximum reconnect delay in ms (default: 30000) */
14
+ maxReconnectDelay?: number;
15
+ }
16
+ export interface SSEConnectionError {
17
+ type: 'network' | 'auth' | 'server' | 'parse';
18
+ message: string;
19
+ originalError?: Error;
20
+ timestamp: number;
21
+ }
22
+ export interface SSELibraryEvent {
23
+ action: ElementAction;
24
+ library: ILibrary;
25
+ }
26
+ export interface SSELibraryStatusEvent {
27
+ message: string;
28
+ library: string;
29
+ progress?: number;
30
+ }
31
+ export interface SSEMediasEvent {
32
+ library: string;
33
+ medias: {
34
+ action: ElementAction;
35
+ media: IFile;
36
+ }[];
37
+ }
38
+ export interface SSEMediasProgressEvent {
39
+ library: string;
40
+ mediaId: string;
41
+ progress: number;
42
+ status?: string;
43
+ }
44
+ export interface SSEConvertProgressEvent {
45
+ library: string;
46
+ mediaId: string;
47
+ progress: number;
48
+ status: string;
49
+ message?: string;
50
+ }
51
+ export interface SSEEpisodesEvent {
52
+ library: string;
53
+ episodes: {
54
+ action: ElementAction;
55
+ episode: IEpisode;
56
+ }[];
57
+ }
58
+ export interface SSESeriesEvent {
59
+ library: string;
60
+ series: {
61
+ action: ElementAction;
62
+ serie: ISerie;
63
+ }[];
64
+ }
65
+ export interface SSEMoviesEvent {
66
+ library: string;
67
+ movies: {
68
+ action: ElementAction;
69
+ movie: IMovie;
70
+ }[];
71
+ }
72
+ export interface SSEPeopleEvent {
73
+ library: string;
74
+ people: {
75
+ action: ElementAction;
76
+ person: IPerson;
77
+ }[];
78
+ }
79
+ export interface SSETagsEvent {
80
+ library: string;
81
+ tags: {
82
+ action: ElementAction;
83
+ tag: ITag;
84
+ }[];
85
+ }
86
+ export interface IBackupWithStatus extends IBackup {
87
+ status?: 'running' | 'completed' | 'failed';
88
+ progress?: number;
89
+ message?: string;
90
+ }
91
+ export interface SSEBackupsEvent {
92
+ backup: IBackupWithStatus;
93
+ }
94
+ export interface SSEBackupFilesEvent {
95
+ library?: string;
96
+ file: string;
97
+ progress: number;
98
+ status?: string;
99
+ message?: string;
100
+ }
101
+ export interface SSEEventMap {
102
+ 'library': SSELibraryEvent;
103
+ 'library-status': SSELibraryStatusEvent;
104
+ 'medias': SSEMediasEvent;
105
+ 'medias_progress': SSEMediasProgressEvent;
106
+ 'convert_progress': SSEConvertProgressEvent;
107
+ 'episodes': SSEEpisodesEvent;
108
+ 'series': SSESeriesEvent;
109
+ 'movies': SSEMoviesEvent;
110
+ 'people': SSEPeopleEvent;
111
+ 'tags': SSETagsEvent;
112
+ 'backups': SSEBackupsEvent;
113
+ 'backups-files': SSEBackupFilesEvent;
114
+ }
115
+ export type SSEEventName = keyof SSEEventMap;
116
+ export interface SSEEvent<T extends SSEEventName = SSEEventName> {
117
+ id?: string;
118
+ event: T;
119
+ data: SSEEventMap[T];
120
+ retry?: number;
121
+ }
@@ -0,0 +1 @@
1
+ export {};
package/libraries.md CHANGED
@@ -66,6 +66,172 @@ if (library.crypt) {
66
66
 
67
67
  **Note:** This method verifies the key by attempting to decrypt a media thumbnail. If decryption fails, it throws an error.
68
68
 
69
+ ### `dispose(): void`
70
+
71
+ Marks the LibraryApi as disposed. After calling dispose(), the filtered event streams will stop emitting events.
72
+
73
+ **Example:**
74
+
75
+ ```typescript
76
+ libraryApi.dispose();
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Real-Time Events (SSE)
82
+
83
+ `LibraryApi` provides library-filtered event streams that automatically filter events to only include those relevant to the library. These streams require the `RedseatClient` to be connected to SSE.
84
+
85
+ ### Event Streams
86
+
87
+ All event streams are RxJS Observables that emit events only for this library.
88
+
89
+ #### `medias$: Observable<SSEMediasEvent>`
90
+
91
+ Emits when media files in this library are added, updated, or deleted.
92
+
93
+ ```typescript
94
+ libraryApi.medias$.subscribe(event => {
95
+ for (const { action, media } of event.medias) {
96
+ console.log(`Media ${action}: ${media.name}`);
97
+ }
98
+ });
99
+ ```
100
+
101
+ #### `mediasProgress$: Observable<SSEMediasProgressEvent>`
102
+
103
+ Emits media processing progress updates for this library.
104
+
105
+ ```typescript
106
+ libraryApi.mediasProgress$.subscribe(event => {
107
+ console.log(`Media ${event.mediaId}: ${event.progress}%`);
108
+ });
109
+ ```
110
+
111
+ #### `convertProgress$: Observable<SSEConvertProgressEvent>`
112
+
113
+ Emits video conversion progress updates for this library.
114
+
115
+ ```typescript
116
+ libraryApi.convertProgress$.subscribe(event => {
117
+ console.log(`Converting ${event.mediaId}: ${event.progress}% - ${event.status}`);
118
+ });
119
+ ```
120
+
121
+ #### `episodes$: Observable<SSEEpisodesEvent>`
122
+
123
+ Emits when episodes in this library are added, updated, or deleted.
124
+
125
+ ```typescript
126
+ libraryApi.episodes$.subscribe(event => {
127
+ for (const { action, episode } of event.episodes) {
128
+ console.log(`Episode ${action}: ${episode.name}`);
129
+ }
130
+ });
131
+ ```
132
+
133
+ #### `series$: Observable<SSESeriesEvent>`
134
+
135
+ Emits when series in this library are added, updated, or deleted.
136
+
137
+ ```typescript
138
+ libraryApi.series$.subscribe(event => {
139
+ for (const { action, serie } of event.series) {
140
+ console.log(`Series ${action}: ${serie.name}`);
141
+ }
142
+ });
143
+ ```
144
+
145
+ #### `movies$: Observable<SSEMoviesEvent>`
146
+
147
+ Emits when movies in this library are added, updated, or deleted.
148
+
149
+ ```typescript
150
+ libraryApi.movies$.subscribe(event => {
151
+ for (const { action, movie } of event.movies) {
152
+ console.log(`Movie ${action}: ${movie.name}`);
153
+ }
154
+ });
155
+ ```
156
+
157
+ #### `people$: Observable<SSEPeopleEvent>`
158
+
159
+ Emits when people in this library are added, updated, or deleted.
160
+
161
+ ```typescript
162
+ libraryApi.people$.subscribe(event => {
163
+ for (const { action, person } of event.people) {
164
+ console.log(`Person ${action}: ${person.name}`);
165
+ }
166
+ });
167
+ ```
168
+
169
+ #### `tags$: Observable<SSETagsEvent>`
170
+
171
+ Emits when tags in this library are added, updated, or deleted.
172
+
173
+ ```typescript
174
+ libraryApi.tags$.subscribe(event => {
175
+ for (const { action, tag } of event.tags) {
176
+ console.log(`Tag ${action}: ${tag.name}`);
177
+ }
178
+ });
179
+ ```
180
+
181
+ #### `libraryStatus$: Observable<SSELibraryStatusEvent>`
182
+
183
+ Emits status updates for this library (e.g., scanning progress).
184
+
185
+ ```typescript
186
+ libraryApi.libraryStatus$.subscribe(event => {
187
+ console.log(`Status: ${event.message} (${event.progress}%)`);
188
+ });
189
+ ```
190
+
191
+ ### Complete SSE Example with LibraryApi
192
+
193
+ ```typescript
194
+ import { RedseatClient, LibraryApi, ServerApi } from '@redseat/api';
195
+ import { Subscription } from 'rxjs';
196
+
197
+ const client = new RedseatClient({
198
+ server: { id: 'server-123', url: 'example.com' },
199
+ getIdToken: async () => await getFirebaseToken()
200
+ });
201
+
202
+ // Connect to SSE first
203
+ await client.connectSSE();
204
+
205
+ // Get library and create LibraryApi
206
+ const serverApi = new ServerApi(client);
207
+ const libraries = await serverApi.getLibraries();
208
+ const library = libraries[0];
209
+ const libraryApi = new LibraryApi(client, library.id!, library);
210
+
211
+ // Track subscriptions
212
+ const subscriptions: Subscription[] = [];
213
+
214
+ // Subscribe to library-specific events
215
+ subscriptions.push(
216
+ libraryApi.medias$.subscribe(event => {
217
+ for (const { action, media } of event.medias) {
218
+ console.log(`[${library.name}] Media ${action}: ${media.name}`);
219
+ }
220
+ })
221
+ );
222
+
223
+ subscriptions.push(
224
+ libraryApi.libraryStatus$.subscribe(event => {
225
+ console.log(`[${library.name}] ${event.message}`);
226
+ })
227
+ );
228
+
229
+ // ... later, cleanup
230
+ subscriptions.forEach(sub => sub.unsubscribe());
231
+ libraryApi.dispose();
232
+ client.disconnectSSE();
233
+ ```
234
+
69
235
  ---
70
236
 
71
237
  ## Tags
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseat/api",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "TypeScript API client library for interacting with Redseat servers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",