@redseat/api 0.5.0 → 0.6.0

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/dist/client.d.ts CHANGED
@@ -2,7 +2,7 @@ import { Method, AxiosRequestConfig } from 'axios';
2
2
  import { Observable } from 'rxjs';
3
3
  import { IToken } from './auth.js';
4
4
  import { IServer } from './interfaces.js';
5
- import { SSEConnectionState, SSEConnectionOptions, SSEConnectionError, SSELibraryEvent, SSELibraryStatusEvent, SSEMediasEvent, SSEUploadProgressEvent, SSEConvertProgressEvent, SSEEpisodesEvent, SSESeriesEvent, SSEMoviesEvent, SSEBooksEvent, SSEPeopleEvent, SSETagsEvent, SSEBackupsEvent, SSEBackupFilesEvent, SSEMediaRatingEvent, SSEMediaProgressEvent, SSEPlayersListEvent, SSEWatchedEvent, SSEUnwatchedEvent, SSERequestProcessingEvent } from './sse-types.js';
5
+ import { SSEConnectionState, SSEConnectionOptions, SSEConnectionError, SSELibraryEvent, SSELibraryStatusEvent, SSEMediasEvent, SSEUploadProgressEvent, SSEConvertProgressEvent, SSEEpisodesEvent, SSESeriesEvent, SSEMoviesEvent, SSEBooksEvent, SSEPeopleEvent, SSETagsEvent, SSEBackupsEvent, SSEBackupFilesEvent, SSEMediaRatingEvent, SSEMediaProgressEvent, SSEPlayersListEvent, SSEWatchedEvent, SSEUnwatchedEvent, SSERequestProcessingEvent, SSEChannelsEvent } from './sse-types.js';
6
6
  export interface ClientOptions {
7
7
  server: IServer;
8
8
  getIdToken: (forceRefresh?: boolean) => Promise<string>;
@@ -25,6 +25,7 @@ export declare class RedseatClient {
25
25
  private sseAbortController?;
26
26
  private sseReconnectTimeout?;
27
27
  private sseActivityTimeout?;
28
+ private sseHealthCheckInterval?;
28
29
  private sseReconnectAttempts;
29
30
  private sseAuthReconnectAttempts;
30
31
  private sseOptions?;
@@ -33,6 +34,7 @@ export declare class RedseatClient {
33
34
  private sseLifecycleRegistered;
34
35
  private sseHiddenAt?;
35
36
  private readonly SSE_ACTIVITY_TIMEOUT;
37
+ private readonly SSE_HEALTH_CHECK_INTERVAL;
36
38
  private readonly _sseConnectionState;
37
39
  private readonly _sseError;
38
40
  private readonly _sseEvents;
@@ -58,6 +60,7 @@ export declare class RedseatClient {
58
60
  readonly watched$: Observable<SSEWatchedEvent>;
59
61
  readonly unwatched$: Observable<SSEUnwatchedEvent>;
60
62
  readonly requestProcessing$: Observable<SSERequestProcessingEvent>;
63
+ readonly channels$: Observable<SSEChannelsEvent>;
61
64
  /**
62
65
  * Creates a typed observable for a specific SSE event type.
63
66
  * Unwraps the nested data structure from the server (e.g., {uploadProgress: {...}} -> {...})
@@ -92,6 +95,11 @@ export declare class RedseatClient {
92
95
  * @throws Error if not authenticated
93
96
  */
94
97
  getAuthToken(): string;
98
+ /**
99
+ * Ensures the token is valid (refreshing if needed) and returns it.
100
+ * Use this when you need a guaranteed-fresh token outside of axios interceptors.
101
+ */
102
+ getValidAuthToken(): Promise<string>;
95
103
  /**
96
104
  * Fetches all servers for the authenticated user.
97
105
  * Returns IServerPrivate for owned servers, IServer for shared servers.
@@ -148,6 +156,16 @@ export declare class RedseatClient {
148
156
  * Clears the activity timeout timer.
149
157
  */
150
158
  private clearActivityTimeout;
159
+ /**
160
+ * Starts a periodic health check that detects when the reconnect chain
161
+ * has silently died (no pending reconnect, not connecting, not connected)
162
+ * and forces a new reconnection attempt.
163
+ */
164
+ private startSSEHealthCheck;
165
+ /**
166
+ * Stops the periodic health check.
167
+ */
168
+ private stopSSEHealthCheck;
151
169
  /**
152
170
  * Processes the SSE stream and emits events
153
171
  */
package/dist/client.js CHANGED
@@ -25,6 +25,7 @@ export class RedseatClient {
25
25
  'people': 'people',
26
26
  'tags': 'tags',
27
27
  'request_processing': 'requestProcessing',
28
+ 'channels': 'channels',
28
29
  };
29
30
  const wrapperKey = wrapperMap[eventName];
30
31
  return this._sseEvents.pipe(filter((event) => event.event === eventName), map(event => {
@@ -45,6 +46,7 @@ export class RedseatClient {
45
46
  this.sseIsConnecting = false;
46
47
  this.sseLifecycleRegistered = false;
47
48
  this.SSE_ACTIVITY_TIMEOUT = 90000; // 90 seconds - reconnect if no data received
49
+ this.SSE_HEALTH_CHECK_INTERVAL = 60000; // 60 seconds - periodic connection check
48
50
  // RxJS subjects for SSE
49
51
  this._sseConnectionState = new BehaviorSubject('disconnected');
50
52
  this._sseError = new Subject();
@@ -73,6 +75,7 @@ export class RedseatClient {
73
75
  this.watched$ = this.createEventStream('watched');
74
76
  this.unwatched$ = this.createEventStream('unwatched');
75
77
  this.requestProcessing$ = this.createEventStream('request_processing');
78
+ this.channels$ = this.createEventStream('channels');
76
79
  // ==================== SSE Methods ====================
77
80
  this.onDocumentVisibilityChange = () => {
78
81
  if (!this.shouldAttemptReconnect() || typeof document === 'undefined') {
@@ -317,6 +320,14 @@ export class RedseatClient {
317
320
  }
318
321
  return this.tokenData.token;
319
322
  }
323
+ /**
324
+ * Ensures the token is valid (refreshing if needed) and returns it.
325
+ * Use this when you need a guaranteed-fresh token outside of axios interceptors.
326
+ */
327
+ async getValidAuthToken() {
328
+ await this.ensureValidToken();
329
+ return this.getAuthToken();
330
+ }
320
331
  /**
321
332
  * Fetches all servers for the authenticated user.
322
333
  * Returns IServerPrivate for owned servers, IServer for shared servers.
@@ -383,13 +394,21 @@ export class RedseatClient {
383
394
  return;
384
395
  }
385
396
  this.clearSSEReconnectTimeout();
397
+ const wasConnecting = this.sseIsConnecting;
386
398
  if (this.sseAbortController) {
387
399
  this.sseAbortController.abort();
388
400
  this.sseAbortController = undefined;
389
401
  }
390
402
  this.sseReconnectAttempts = 0;
391
403
  this._sseConnectionState.next('reconnecting');
392
- void this._connectSSE();
404
+ if (wasConnecting) {
405
+ // _connectSSE is mid-fetch; aborting it will make it catch AbortError and return.
406
+ // Schedule a reconnect so a new attempt happens after it finishes.
407
+ this.scheduleReconnect();
408
+ }
409
+ else {
410
+ void this._connectSSE();
411
+ }
393
412
  }
394
413
  /**
395
414
  * Connects to the server's SSE endpoint for real-time updates.
@@ -415,6 +434,7 @@ export class RedseatClient {
415
434
  this.sseAuthReconnectAttempts = 0;
416
435
  this.sseShouldReconnect = true;
417
436
  this.registerSSELifecycleListeners();
437
+ this.startSSEHealthCheck();
418
438
  await this._connectSSE();
419
439
  }
420
440
  /**
@@ -426,6 +446,7 @@ export class RedseatClient {
426
446
  this.sseAuthReconnectAttempts = 0;
427
447
  this.clearSSEReconnectTimeout();
428
448
  this.clearActivityTimeout();
449
+ this.stopSSEHealthCheck();
429
450
  if (this.sseAbortController) {
430
451
  this.sseAbortController.abort();
431
452
  this.sseAbortController = undefined;
@@ -528,11 +549,15 @@ export class RedseatClient {
528
549
  });
529
550
  }
530
551
  catch (error) {
552
+ this.sseAbortController = undefined;
531
553
  if (error instanceof Error && error.name === 'AbortError') {
532
- // Intentionally disconnected
554
+ // Aborted — if we should still reconnect and no reconnect is pending, schedule one.
555
+ // This handles the case where forceReconnectSSE aborted us mid-fetch.
556
+ if (this.shouldAttemptReconnect() && !this.sseReconnectTimeout) {
557
+ this.scheduleReconnect();
558
+ }
533
559
  return;
534
560
  }
535
- this.sseAbortController = undefined;
536
561
  this.handleSSEError(error);
537
562
  }
538
563
  finally {
@@ -584,6 +609,37 @@ export class RedseatClient {
584
609
  this.sseActivityTimeout = undefined;
585
610
  }
586
611
  }
612
+ /**
613
+ * Starts a periodic health check that detects when the reconnect chain
614
+ * has silently died (no pending reconnect, not connecting, not connected)
615
+ * and forces a new reconnection attempt.
616
+ */
617
+ startSSEHealthCheck() {
618
+ this.stopSSEHealthCheck();
619
+ this.sseHealthCheckInterval = setInterval(() => {
620
+ if (this.disposed || !this.sseShouldReconnect) {
621
+ this.stopSSEHealthCheck();
622
+ return;
623
+ }
624
+ const state = this.sseConnectionState;
625
+ if (state !== 'connected' &&
626
+ state !== 'connecting' &&
627
+ !this.sseReconnectTimeout &&
628
+ !this.sseIsConnecting) {
629
+ console.log('SSE health check: connection dead with no pending reconnect, forcing reconnect');
630
+ this.forceReconnectSSE();
631
+ }
632
+ }, this.SSE_HEALTH_CHECK_INTERVAL);
633
+ }
634
+ /**
635
+ * Stops the periodic health check.
636
+ */
637
+ stopSSEHealthCheck() {
638
+ if (this.sseHealthCheckInterval) {
639
+ clearInterval(this.sseHealthCheckInterval);
640
+ this.sseHealthCheckInterval = undefined;
641
+ }
642
+ }
587
643
  /**
588
644
  * Processes the SSE stream and emits events
589
645
  */
@@ -638,6 +694,10 @@ export class RedseatClient {
638
694
  throw error;
639
695
  }
640
696
  finally {
697
+ // Ensure the underlying connection is fully closed before releasing
698
+ if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
699
+ this.sseAbortController.abort();
700
+ }
641
701
  this.sseAbortController = undefined;
642
702
  reader.releaseLock();
643
703
  }
@@ -713,15 +773,29 @@ export class RedseatClient {
713
773
  }
714
774
  catch (error) {
715
775
  console.log('SSE reconnect: token refresh failed, scheduling another retry', error);
716
- this._sseError.next({
717
- type: 'auth',
718
- message: `Token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
719
- timestamp: Date.now()
720
- });
776
+ try {
777
+ this._sseError.next({
778
+ type: 'auth',
779
+ message: `Token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
780
+ timestamp: Date.now()
781
+ });
782
+ }
783
+ catch { /* prevent subscriber errors from breaking the chain */ }
721
784
  this.scheduleReconnect();
722
785
  return;
723
786
  }
724
- await this._connectSSE();
787
+ try {
788
+ await this._connectSSE();
789
+ }
790
+ catch (error) {
791
+ // Safety net: _connectSSE has its own try/catch, but if anything
792
+ // escapes (e.g. a subscriber throwing in RxJS next()), don't let
793
+ // it silently kill the reconnect chain.
794
+ console.error('SSE reconnect: unexpected error in _connectSSE', error);
795
+ if (this.shouldAttemptReconnect()) {
796
+ this.scheduleReconnect();
797
+ }
798
+ }
725
799
  })();
726
800
  }, delay);
727
801
  }
@@ -194,6 +194,7 @@ export interface ServerLibrarySettings {
194
194
  preductionModel?: string;
195
195
  mapProgress?: UserMapping[];
196
196
  dataPath?: string;
197
+ epgUrl?: string;
197
198
  }
198
199
  export interface ServerLibraryForUpdate {
199
200
  name?: string;
@@ -490,11 +491,17 @@ export type SearchResult<K extends string, T> = SearchStreamResult<K, T>;
490
491
  export type BookSearchResult = SearchResult<'book', IBook>;
491
492
  export type MovieSearchResult = SearchResult<'movie', IMovie>;
492
493
  export type SerieSearchResult = SearchResult<'serie', ISerie>;
494
+ export type PersonSearchResult = SearchResult<'person', IPerson>;
493
495
  export type BookSearchStreamResult = SearchStreamResult<'book', IBook>;
494
496
  export type MovieSearchStreamResult = SearchStreamResult<'movie', IMovie>;
495
497
  export type SerieSearchStreamResult = SearchStreamResult<'serie', ISerie>;
498
+ export type PersonSearchStreamResult = SearchStreamResult<'person', IPerson>;
496
499
  export type LookupSearchStreamResult = SearchStreamResult<'book' | 'movie' | 'serie', IBook | IMovie | ISerie>;
497
500
  export type RsLookupMatchType = 'exactId' | 'exactText';
501
+ export interface SourceSearchStreamResult {
502
+ request: RsRequest;
503
+ matchType?: RsLookupMatchType;
504
+ }
498
505
  export interface SseSearchStreamEvent<T> {
499
506
  sourceId: string;
500
507
  sourceName: string;
@@ -509,17 +516,35 @@ export interface SearchStreamCallbacks<T> {
509
516
  export interface IChannel {
510
517
  id: string;
511
518
  name: string;
512
- series: string[];
513
- lang?: string;
514
- versions: IChannelVersion[];
515
- type?: FileTypes;
519
+ tvgId?: string;
520
+ logo?: string;
521
+ tags?: string[];
522
+ channelNumber?: number;
523
+ modified?: number;
524
+ added?: number;
525
+ variants?: IChannelVariant[];
516
526
  }
517
- export interface IChannelVersion {
527
+ export interface IChannelVariant {
518
528
  id: string;
519
- name: string;
520
- source: string;
529
+ channelRef: string;
521
530
  quality?: string;
522
- description?: string;
531
+ streamUrl: string;
532
+ modified?: number;
533
+ added?: number;
534
+ }
535
+ export interface M3uImportResult {
536
+ channelsAdded: number;
537
+ channelsUpdated: number;
538
+ channelsRemoved: number;
539
+ moviesAdded: number;
540
+ seriesAdded: number;
541
+ episodesAdded: number;
542
+ groupsCreated: number;
543
+ totalParsed: number;
544
+ }
545
+ export interface ChannelQuery {
546
+ tag?: string;
547
+ name?: string;
523
548
  }
524
549
  export interface UnassignedFace {
525
550
  id: string;
@@ -576,9 +601,6 @@ export interface MergePeopleResponse {
576
601
  export interface ClusterFacesResponse {
577
602
  clusters: number;
578
603
  }
579
- export interface IChannelUpdate {
580
- [key: string]: any;
581
- }
582
604
  export declare enum PluginAuthType {
583
605
  OAUTH = "oauth",
584
606
  URL = "url",
package/dist/library.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Observable } from 'rxjs';
2
2
  import type { AxiosResponse } from 'axios';
3
- import { IFile, ITag, IPerson, ISerie, IMovie, IBook, MediaRequest, IEpisode, ExternalImage, IBackupFile, ILibrary, DeletedQuery, RsDeleted, MovieSort, BookSort, RsSort, SqlOrder, RsRequest, DetectedFaceResult, UnassignFaceResponse, RsGroupDownload, IViewProgress, IWatched, IRsRequestProcessing, BookSearchStreamResult, MovieSearchStreamResult, SerieSearchStreamResult, LookupSearchStreamResult, BookSearchResult, MovieSearchResult, SerieSearchResult, ItemWithRelations, SearchStreamCallbacks, MediaForUpdate, IChannelUpdate } from './interfaces.js';
4
- import { SSEMediasEvent, SSEUploadProgressEvent, SSEConvertProgressEvent, SSEEpisodesEvent, SSESeriesEvent, SSEMoviesEvent, SSEBooksEvent, SSEPeopleEvent, SSETagsEvent, SSELibraryStatusEvent, SSEMediaRatingEvent, SSEMediaProgressEvent, SSERequestProcessingEvent } from './sse-types.js';
3
+ import { IFile, ITag, IPerson, ISerie, IMovie, IBook, MediaRequest, IEpisode, ExternalImage, IBackupFile, ILibrary, DeletedQuery, RsDeleted, MovieSort, BookSort, RsSort, SqlOrder, RsRequest, DetectedFaceResult, UnassignFaceResponse, RsGroupDownload, IViewProgress, IWatched, IRsRequestProcessing, BookSearchStreamResult, MovieSearchStreamResult, SerieSearchStreamResult, PersonSearchStreamResult, LookupSearchStreamResult, SourceSearchStreamResult, BookSearchResult, MovieSearchResult, SerieSearchResult, ItemWithRelations, SearchStreamCallbacks, MediaForUpdate, type IChannel, type M3uImportResult, type ChannelQuery } from './interfaces.js';
4
+ import { SSEMediasEvent, SSEUploadProgressEvent, SSEConvertProgressEvent, SSEEpisodesEvent, SSESeriesEvent, SSEMoviesEvent, SSEBooksEvent, SSEPeopleEvent, SSETagsEvent, SSELibraryStatusEvent, SSEMediaRatingEvent, SSEMediaProgressEvent, SSERequestProcessingEvent, SSEChannelsEvent } from './sse-types.js';
5
5
  import { EncryptFileOptions, EncryptedFile } from './encryption.js';
6
6
  export interface LibraryHttpClient {
7
7
  get<T = unknown>(url: string, config?: any): Promise<{
@@ -37,6 +37,7 @@ export interface LibraryHttpClient {
37
37
  readonly mediaRating$?: Observable<SSEMediaRatingEvent>;
38
38
  readonly mediaProgress$?: Observable<SSEMediaProgressEvent>;
39
39
  readonly requestProcessing$?: Observable<SSERequestProcessingEvent>;
40
+ readonly channels$?: Observable<SSEChannelsEvent>;
40
41
  }
41
42
  export interface UploadMediaMultipartOptions {
42
43
  file: Blob;
@@ -64,6 +65,7 @@ export declare class LibraryApi {
64
65
  readonly mediaRating$: Observable<SSEMediaRatingEvent>;
65
66
  readonly mediaProgress$: Observable<SSEMediaProgressEvent>;
66
67
  readonly requestProcessing$: Observable<SSERequestProcessingEvent>;
68
+ readonly channels$: Observable<SSEChannelsEvent>;
67
69
  constructor(client: LibraryHttpClient, libraryId: string, library: ILibrary);
68
70
  /**
69
71
  * Creates a library-filtered stream from a client stream.
@@ -145,6 +147,7 @@ export declare class LibraryApi {
145
147
  createPerson(person: Partial<IPerson>): Promise<IPerson>;
146
148
  removePerson(personId: string): Promise<void>;
147
149
  updatePerson(personId: string, updates: Partial<IPerson>): Promise<IPerson>;
150
+ getPerson(personId: string): Promise<IPerson>;
148
151
  createSerie(serie: Partial<ISerie>): Promise<ISerie>;
149
152
  createEpisode(episode: Partial<IEpisode>): Promise<IEpisode>;
150
153
  serieScrap(serieId: string, date?: number): Promise<void>;
@@ -198,7 +201,18 @@ export declare class LibraryApi {
198
201
  addTagToMedia(mediaId: string, tagId: string): Promise<void>;
199
202
  removeTagFromMedia(mediaId: string, tagId: string): Promise<void>;
200
203
  getCryptChallenge(): Promise<string>;
201
- getChannels(): Promise<any[]>;
204
+ getChannels(query?: ChannelQuery): Promise<IChannel[]>;
205
+ getChannel(channelId: string): Promise<IChannel>;
206
+ deleteChannel(channelId: string): Promise<void>;
207
+ importChannels(url?: string): Promise<M3uImportResult>;
208
+ refreshChannels(): Promise<M3uImportResult>;
209
+ getChannelStreamUrl(channelId: string, quality?: string): string;
210
+ getChannelHlsUrl(channelId: string, quality?: string): string;
211
+ stopChannelHls(channelId: string): Promise<void>;
212
+ getAuthToken(): string;
213
+ addTagToChannel(channelId: string, tagId: string): Promise<void>;
214
+ removeTagFromChannel(channelId: string, tagId: string): Promise<void>;
215
+ getChannelImageUrl(channelId: string, size?: string): string;
202
216
  personRename(personId: string, newName: string): Promise<IPerson>;
203
217
  personUpdate(personId: string, updates: Partial<IPerson>): Promise<IPerson>;
204
218
  personAddAlt(personId: string, alt: string): Promise<IPerson>;
@@ -211,6 +225,10 @@ export declare class LibraryApi {
211
225
  serieAddAlt(serieId: string, alt: string): Promise<ISerie>;
212
226
  serieRemoveAlt(serieId: string, alt: string): Promise<ISerie>;
213
227
  updatePersonPortrait(personId: string, portrait: FormData): Promise<void>;
228
+ searchPeopleStream(name: string, callbacks: SearchStreamCallbacks<PersonSearchStreamResult>, options?: {
229
+ source?: string[];
230
+ pageKey?: string;
231
+ }): () => void;
214
232
  searchSeries(name: string): Promise<SerieSearchResult[]>;
215
233
  searchSeriesStream(name: string, callbacks: SearchStreamCallbacks<SerieSearchStreamResult>, options?: {
216
234
  source?: string[];
@@ -244,6 +262,22 @@ export declare class LibraryApi {
244
262
  * @returns Array of grouped download requests. Use `.flatMap(g => g.requests)` to get a flat list of RsRequest items.
245
263
  */
246
264
  searchMovieMedias(movieId: string, q?: string): Promise<RsGroupDownload[]>;
265
+ searchEpisodeMediasStream(serieId: string, season: number, episode: number, callbacks: SearchStreamCallbacks<SourceSearchStreamResult>, options?: {
266
+ source?: string[];
267
+ pageKey?: string;
268
+ }): () => void;
269
+ searchSeasonMediasStream(serieId: string, season: number, callbacks: SearchStreamCallbacks<SourceSearchStreamResult>, options?: {
270
+ source?: string[];
271
+ pageKey?: string;
272
+ }): () => void;
273
+ searchMovieMediasStream(movieId: string, callbacks: SearchStreamCallbacks<SourceSearchStreamResult>, options?: {
274
+ source?: string[];
275
+ pageKey?: string;
276
+ }): () => void;
277
+ searchBookMediasStream(bookId: string, callbacks: SearchStreamCallbacks<SourceSearchStreamResult>, options?: {
278
+ source?: string[];
279
+ pageKey?: string;
280
+ }): () => void;
247
281
  /**
248
282
  * Adds a searched episode media to the database for download/storage.
249
283
  * @param serieId - The series identifier
@@ -387,7 +421,6 @@ export declare class LibraryApi {
387
421
  mediaUpdateProgress(mediaId: string, progress: number): Promise<{
388
422
  progress: number;
389
423
  }>;
390
- mediaUpdateChannel(mediaId: string, update: IChannelUpdate): Promise<IFile[]>;
391
424
  refreshMedia(mediaId: string): Promise<IFile[]>;
392
425
  aiTagMedia(mediaId: string): Promise<IFile[]>;
393
426
  mediaUpdate(mediaId: string, update: MediaForUpdate): Promise<IFile[]>;
@@ -471,11 +504,14 @@ export declare class LibraryApi {
471
504
  * Upload a group of media files from URLs
472
505
  * @param download - The group download request containing requests array
473
506
  * @param options - Optional settings (spawn: true to run in background)
474
- * @returns Array of created media files when spawn=false, or { downloading: true } when spawn=true
507
+ * @returns Array of created media files, { downloading: true } when spawn=true, or { needFileSelection, request } when file selection is required
475
508
  */
476
509
  uploadGroup(download: RsGroupDownload, options?: {
477
510
  spawn?: boolean;
478
511
  }): Promise<IFile[] | {
479
512
  downloading: boolean;
513
+ } | {
514
+ needFileSelection: true;
515
+ request: RsRequest;
480
516
  }>;
481
517
  }
package/dist/library.js CHANGED
@@ -22,6 +22,7 @@ export class LibraryApi {
22
22
  this.mediaRating$ = this.createLibraryFilteredStream(client.mediaRating$);
23
23
  this.mediaProgress$ = this.createLibraryFilteredStream(client.mediaProgress$);
24
24
  this.requestProcessing$ = this.createLibraryFilteredStream(client.requestProcessing$);
25
+ this.channels$ = this.createLibraryFilteredStream(client.channels$);
25
26
  }
26
27
  /**
27
28
  * Creates a library-filtered stream from a client stream.
@@ -365,6 +366,10 @@ export class LibraryApi {
365
366
  const res = await this.client.patch(this.getUrl(`/people/${personId}`), updates);
366
367
  return res.data;
367
368
  }
369
+ async getPerson(personId) {
370
+ const res = await this.client.get(this.getUrl(`/people/${personId}`));
371
+ return res.data;
372
+ }
368
373
  async createSerie(serie) {
369
374
  const res = await this.client.post(this.getUrl('/series'), serie);
370
375
  return res.data;
@@ -540,10 +545,62 @@ export class LibraryApi {
540
545
  const res = await this.client.get(this.getUrl('/cryptchallenge'));
541
546
  return res.data.value;
542
547
  }
543
- async getChannels() {
544
- const res = await this.client.get(this.getUrl('/channels'));
548
+ async getChannels(query) {
549
+ const params = {};
550
+ if (query?.tag)
551
+ params.tag = query.tag;
552
+ if (query?.name)
553
+ params.name = query.name;
554
+ const res = await this.client.get(this.getUrl('/channels'), { params });
545
555
  return res.data;
546
556
  }
557
+ async getChannel(channelId) {
558
+ const res = await this.client.get(this.getUrl(`/channels/${channelId}`));
559
+ return res.data;
560
+ }
561
+ async deleteChannel(channelId) {
562
+ await this.client.delete(this.getUrl(`/channels/${channelId}`));
563
+ }
564
+ async importChannels(url) {
565
+ const res = await this.client.post(this.getUrl('/channels/import'), { url });
566
+ return res.data;
567
+ }
568
+ async refreshChannels() {
569
+ const res = await this.client.post(this.getUrl('/channels/refresh'), {});
570
+ return res.data;
571
+ }
572
+ getChannelStreamUrl(channelId, quality) {
573
+ const token = this.client.getAuthToken();
574
+ const params = { token };
575
+ if (quality)
576
+ params.quality = quality;
577
+ return this.client.getFullUrl(this.getUrl(`/channels/${channelId}/stream`), params);
578
+ }
579
+ getChannelHlsUrl(channelId, quality) {
580
+ const token = this.client.getAuthToken();
581
+ const params = { token };
582
+ if (quality)
583
+ params.quality = quality;
584
+ return this.client.getFullUrl(this.getUrl(`/channels/${channelId}/hls/playlist.m3u8`), params);
585
+ }
586
+ async stopChannelHls(channelId) {
587
+ await this.client.delete(this.getUrl(`/channels/${channelId}/hls`));
588
+ }
589
+ getAuthToken() {
590
+ return this.client.getAuthToken();
591
+ }
592
+ async addTagToChannel(channelId, tagId) {
593
+ await this.client.post(this.getUrl(`/channels/${channelId}/tags`), { tagId });
594
+ }
595
+ async removeTagFromChannel(channelId, tagId) {
596
+ await this.client.delete(this.getUrl(`/channels/${channelId}/tags/${tagId}`));
597
+ }
598
+ getChannelImageUrl(channelId, size) {
599
+ const params = {};
600
+ if (size)
601
+ params.size = size;
602
+ return this.client.getFullUrl(this.getUrl(`/channels/${channelId}/image`), params);
603
+ }
547
604
  async personRename(personId, newName) {
548
605
  const res = await this.client.patch(this.getUrl(`/people/${personId}`), {
549
606
  name: newName
@@ -611,6 +668,14 @@ export class LibraryApi {
611
668
  async updatePersonPortrait(personId, portrait) {
612
669
  await this.client.post(this.getUrl(`/people/${personId}/image?size=thumb&type=poster`), portrait);
613
670
  }
671
+ searchPeopleStream(name, callbacks, options) {
672
+ const params = { name };
673
+ if (options?.source?.length)
674
+ params.source = options.source.join(',');
675
+ if (options?.pageKey)
676
+ params.pageKey = options.pageKey;
677
+ return this.openSearchStream(this.getUrl('/people/searchstream'), params, callbacks);
678
+ }
614
679
  async searchSeries(name) {
615
680
  const res = await this.client.get(this.getUrl(`/series/search?name=${name}`));
616
681
  return res.data;
@@ -670,6 +735,38 @@ export class LibraryApi {
670
735
  const res = await this.client.get(this.getUrl(`/movies/${movieId}/search`), q ? { params: { q } } : undefined);
671
736
  return res.data;
672
737
  }
738
+ searchEpisodeMediasStream(serieId, season, episode, callbacks, options) {
739
+ const params = {};
740
+ if (options?.source?.length)
741
+ params.source = options.source.join(',');
742
+ if (options?.pageKey)
743
+ params.pageKey = options.pageKey;
744
+ return this.openSearchStream(this.getUrl(`/series/${serieId}/seasons/${season}/episodes/${episode}/searchstream`), params, callbacks);
745
+ }
746
+ searchSeasonMediasStream(serieId, season, callbacks, options) {
747
+ const params = {};
748
+ if (options?.source?.length)
749
+ params.source = options.source.join(',');
750
+ if (options?.pageKey)
751
+ params.pageKey = options.pageKey;
752
+ return this.openSearchStream(this.getUrl(`/series/${serieId}/seasons/${season}/searchstream`), params, callbacks);
753
+ }
754
+ searchMovieMediasStream(movieId, callbacks, options) {
755
+ const params = {};
756
+ if (options?.source?.length)
757
+ params.source = options.source.join(',');
758
+ if (options?.pageKey)
759
+ params.pageKey = options.pageKey;
760
+ return this.openSearchStream(this.getUrl(`/movies/${movieId}/searchstream`), params, callbacks);
761
+ }
762
+ searchBookMediasStream(bookId, callbacks, options) {
763
+ const params = {};
764
+ if (options?.source?.length)
765
+ params.source = options.source.join(',');
766
+ if (options?.pageKey)
767
+ params.pageKey = options.pageKey;
768
+ return this.openSearchStream(this.getUrl(`/books/${bookId}/searchstream`), params, callbacks);
769
+ }
673
770
  /**
674
771
  * Adds a searched episode media to the database for download/storage.
675
772
  * @param serieId - The series identifier
@@ -919,10 +1016,6 @@ export class LibraryApi {
919
1016
  const res = await this.client.patch(this.getUrl(`/medias/${mediaId}/progress`), { progress });
920
1017
  return res.data;
921
1018
  }
922
- async mediaUpdateChannel(mediaId, update) {
923
- const res = await this.client.patch(this.getUrl(`/medias/${mediaId}/channel`), update);
924
- return res.data;
925
- }
926
1019
  async refreshMedia(mediaId) {
927
1020
  const res = await this.client.get(this.getUrl(`/medias/${mediaId}/metadata/refresh`));
928
1021
  return res.data;
@@ -1176,14 +1269,14 @@ export class LibraryApi {
1176
1269
  * Upload a group of media files from URLs
1177
1270
  * @param download - The group download request containing requests array
1178
1271
  * @param options - Optional settings (spawn: true to run in background)
1179
- * @returns Array of created media files when spawn=false, or { downloading: true } when spawn=true
1272
+ * @returns Array of created media files, { downloading: true } when spawn=true, or { needFileSelection, request } when file selection is required
1180
1273
  */
1181
1274
  async uploadGroup(download, options) {
1182
1275
  const params = {};
1183
1276
  if (options?.spawn) {
1184
1277
  params.spawn = 'true';
1185
1278
  }
1186
- const res = await this.client.post(this.getUrl('/medias/download'), download, { params });
1279
+ const res = await this.client.post(this.getUrl('/medias/download'), download, { params, timeout: 2 * 60 * 60 * 1000 });
1187
1280
  return res.data;
1188
1281
  }
1189
1282
  }
package/dist/server.js CHANGED
@@ -1,50 +1,34 @@
1
+ import { openFetchSSEStream } from './sse-fetch.js';
1
2
  export class ServerApi {
2
3
  constructor(client) {
3
4
  this.client = client;
4
5
  }
5
6
  openSearchStream(path, params, callbacks) {
6
- const EventSourceCtor = globalThis.EventSource;
7
- if (!EventSourceCtor) {
8
- callbacks.onError?.(new Error('EventSource is not available in this runtime.'));
9
- return () => undefined;
10
- }
11
7
  const url = this.client.getFullUrl(path, {
12
8
  ...params,
13
9
  token: this.client.getAuthToken()
14
10
  });
15
- const source = new EventSourceCtor(url);
16
- let closed = false;
17
- const close = () => {
18
- if (closed) {
19
- return;
11
+ let finished = false;
12
+ const finish = () => {
13
+ if (!finished) {
14
+ finished = true;
15
+ callbacks.onFinished?.();
20
16
  }
21
- closed = true;
22
- source.removeEventListener('results', onResults);
23
- source.removeEventListener('finished', onFinished);
24
- source.onerror = null;
25
- source.close();
26
17
  };
27
- const onResults = (event) => {
28
- try {
29
- const payload = JSON.parse(event.data);
30
- callbacks.onResults?.(payload);
18
+ return openFetchSSEStream(url, (eventType, data) => {
19
+ if (eventType === 'results') {
20
+ try {
21
+ const payload = JSON.parse(data);
22
+ callbacks.onResults?.(payload);
23
+ }
24
+ catch (error) {
25
+ callbacks.onError?.(error);
26
+ }
31
27
  }
32
- catch (error) {
33
- callbacks.onError?.(error);
34
- close();
28
+ else if (eventType === 'finished') {
29
+ finish();
35
30
  }
36
- };
37
- const onFinished = () => {
38
- callbacks.onFinished?.();
39
- close();
40
- };
41
- source.addEventListener('results', onResults);
42
- source.addEventListener('finished', onFinished);
43
- source.onerror = (error) => {
44
- callbacks.onError?.(error);
45
- close();
46
- };
47
- return close;
31
+ }, () => finish(), (error) => callbacks.onError?.(error));
48
32
  }
49
33
  async getLibraries() {
50
34
  const res = await this.client.get('/libraries');
@@ -1,4 +1,4 @@
1
- import { ILibrary, IFile, IEpisode, ISerie, IMovie, IBook, IPerson, ITag, IBackup, IWatched, IUnwatched, IRsRequestProcessing, VideoConvertRequest, type ItemWithRelations } from './interfaces.js';
1
+ import { ILibrary, IFile, IEpisode, ISerie, IMovie, IBook, IPerson, ITag, IBackup, IWatched, IUnwatched, IRsRequestProcessing, VideoConvertRequest, type ItemWithRelations, type IChannel } 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 {
@@ -98,6 +98,13 @@ export interface SSEMoviesEvent {
98
98
  movie: ItemWithRelations<IMovie>;
99
99
  }[];
100
100
  }
101
+ export interface SSEChannelsEvent {
102
+ library: string;
103
+ channels: {
104
+ action: ElementAction;
105
+ channel: IChannel;
106
+ }[];
107
+ }
101
108
  export interface SSEBooksEvent {
102
109
  library: string;
103
110
  books: {
@@ -205,6 +212,7 @@ export interface SSEEventMap {
205
212
  'watched': SSEWatchedEvent;
206
213
  'unwatched': SSEUnwatchedEvent;
207
214
  'request_processing': SSERequestProcessingEvent;
215
+ 'channels': SSEChannelsEvent;
208
216
  }
209
217
  export type SSEEventName = keyof SSEEventMap;
210
218
  export interface SSEEvent<T extends SSEEventName = SSEEventName> {
package/libraries.md CHANGED
@@ -1812,22 +1812,59 @@ Updates the watch progress for a media file.
1812
1812
  await libraryApi.mediaUpdateProgress('media-id', 50);
1813
1813
  ```
1814
1814
 
1815
- ### `mediaUpdateChannel(mediaId: string, update: IChannelUpdate): Promise<IFile[]>`
1815
+ ### `getChannels(query?: ChannelQuery): Promise<IChannel[]>`
1816
1816
 
1817
- Updates channel information for a media file.
1817
+ Lists all channels in the library, optionally filtered by group tag or name.
1818
1818
 
1819
1819
  **Parameters:**
1820
1820
 
1821
- - `mediaId`: The ID of the media
1822
- - `update`: Channel update object
1821
+ - `query` (optional): Filter options (`{ tag?: string, name?: string }`)
1823
1822
 
1824
- **Returns:** Promise resolving to an array of updated `IFile` objects
1823
+ **Returns:** Promise resolving to an array of `IChannel` objects
1825
1824
 
1826
- **Example:**
1825
+ ### `getChannel(channelId: string): Promise<IChannel>`
1827
1826
 
1828
- ```typescript
1829
- await libraryApi.mediaUpdateChannel('media-id', { channel: 'new-channel' });
1830
- ```
1827
+ Gets a single channel with its variants.
1828
+
1829
+ ### `deleteChannel(channelId: string): Promise<void>`
1830
+
1831
+ Deletes a channel. Requires admin role.
1832
+
1833
+ ### `importChannels(url?: string): Promise<M3uImportResult>`
1834
+
1835
+ Imports channels from an M3U playlist URL. If no URL is provided, uses the library's configured source.
1836
+
1837
+ **Returns:** Promise resolving to `M3uImportResult` with counts of added/updated/removed channels and groups.
1838
+
1839
+ ### `refreshChannels(): Promise<M3uImportResult>`
1840
+
1841
+ Refreshes channels from the library's configured M3U source URL.
1842
+
1843
+ ### `getChannelStreamUrl(channelId: string, quality?: string): string`
1844
+
1845
+ Returns the URL for streaming a channel via MPEG2-TS proxy. Supports quality selection (`4K`, `FHD`, `HD`, `SD`).
1846
+
1847
+ ### `getChannelHlsUrl(channelId: string, quality?: string): string`
1848
+
1849
+ Returns the HLS playlist URL for streaming a channel. The server remuxes the MPEG-TS source into HLS segments using FFmpeg. Supports quality selection (`4K`, `FHD`, `HD`, `SD`).
1850
+
1851
+ **Note:** HLS sessions consume server resources. Call `stopChannelHls()` when playback ends.
1852
+
1853
+ ### `stopChannelHls(channelId: string): Promise<void>`
1854
+
1855
+ Stops the server-side HLS session for a channel, terminating FFmpeg and cleaning up temporary segments.
1856
+
1857
+ ### `addTagToChannel(channelId: string, tagId: string): Promise<void>`
1858
+
1859
+ Adds a tag to a channel.
1860
+
1861
+ ### `removeTagFromChannel(channelId: string, tagId: string): Promise<void>`
1862
+
1863
+ Removes a tag from a channel.
1864
+
1865
+ ### `getChannelImageUrl(channelId: string, size?: string): string`
1866
+
1867
+ Returns the URL for a channel's image/logo.
1831
1868
 
1832
1869
  ### `refreshMedia(mediaId: string): Promise<IFile[]>`
1833
1870
 
@@ -2466,16 +2503,17 @@ Gets a cryptographic challenge for encryption verification.
2466
2503
  const challenge = await libraryApi.getCryptChallenge();
2467
2504
  ```
2468
2505
 
2469
- ### `getChannels(): Promise<any[]>`
2506
+ ### `getChannels(query?: ChannelQuery): Promise<IChannel[]>`
2470
2507
 
2471
- Gets all channels (for IPTV libraries).
2508
+ Gets all channels (for IPTV libraries), optionally filtered.
2472
2509
 
2473
- **Returns:** Promise resolving to an array of channel objects
2510
+ **Returns:** Promise resolving to an array of `IChannel` objects
2474
2511
 
2475
2512
  **Example:**
2476
2513
 
2477
2514
  ```typescript
2478
2515
  const channels = await libraryApi.getChannels();
2516
+ const filtered = await libraryApi.getChannels({ tag: 'tag-id' });
2479
2517
  ```
2480
2518
 
2481
2519
  ### `getWatermarks(): Promise<string[]>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseat/api",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "TypeScript API client library for interacting with Redseat servers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",