@redseat/api 0.4.6 → 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;
@@ -202,6 +203,7 @@ export interface ServerLibraryForUpdate {
202
203
  settings?: ServerLibrarySettings;
203
204
  credentials?: string;
204
205
  plugin?: string;
206
+ password?: string;
205
207
  }
206
208
  export interface ILibrary {
207
209
  id?: string;
@@ -216,6 +218,7 @@ export interface ILibrary {
216
218
  plugin?: string;
217
219
  hidden?: boolean;
218
220
  status?: string;
221
+ password?: string;
219
222
  }
220
223
  export interface ITag {
221
224
  id: string;
@@ -451,6 +454,7 @@ export interface ExternalImage {
451
454
  voteAverage?: number;
452
455
  voteCount?: number;
453
456
  width?: number;
457
+ matchType?: RsLookupMatchType;
454
458
  }
455
459
  export interface Relations {
456
460
  peopleDetails?: IPerson[];
@@ -478,6 +482,7 @@ export interface SearchRelations {
478
482
  export interface SearchStreamResultBase {
479
483
  relations?: SearchRelations;
480
484
  images?: ExternalImage[];
485
+ matchType?: RsLookupMatchType;
481
486
  }
482
487
  export type SearchStreamResult<K extends string, T> = SearchStreamResultBase & {
483
488
  metadata?: Partial<Record<K, T>>;
@@ -486,30 +491,60 @@ export type SearchResult<K extends string, T> = SearchStreamResult<K, T>;
486
491
  export type BookSearchResult = SearchResult<'book', IBook>;
487
492
  export type MovieSearchResult = SearchResult<'movie', IMovie>;
488
493
  export type SerieSearchResult = SearchResult<'serie', ISerie>;
494
+ export type PersonSearchResult = SearchResult<'person', IPerson>;
489
495
  export type BookSearchStreamResult = SearchStreamResult<'book', IBook>;
490
496
  export type MovieSearchStreamResult = SearchStreamResult<'movie', IMovie>;
491
497
  export type SerieSearchStreamResult = SearchStreamResult<'serie', ISerie>;
498
+ export type PersonSearchStreamResult = SearchStreamResult<'person', IPerson>;
492
499
  export type LookupSearchStreamResult = SearchStreamResult<'book' | 'movie' | 'serie', IBook | IMovie | ISerie>;
493
- export type GroupedSearchStreamPayload<T> = Record<string, T[]>;
500
+ export type RsLookupMatchType = 'exactId' | 'exactText';
501
+ export interface SourceSearchStreamResult {
502
+ request: RsRequest;
503
+ matchType?: RsLookupMatchType;
504
+ }
505
+ export interface SseSearchStreamEvent<T> {
506
+ sourceId: string;
507
+ sourceName: string;
508
+ results: T[];
509
+ nextPageKey?: string | null;
510
+ }
494
511
  export interface SearchStreamCallbacks<T> {
495
- onResults?: (results: GroupedSearchStreamPayload<T>) => void;
512
+ onResults?: (event: SseSearchStreamEvent<T>) => void;
496
513
  onFinished?: () => void;
497
514
  onError?: (error: unknown) => void;
498
515
  }
499
516
  export interface IChannel {
500
517
  id: string;
501
518
  name: string;
502
- series: string[];
503
- lang?: string;
504
- versions: IChannelVersion[];
505
- type?: FileTypes;
519
+ tvgId?: string;
520
+ logo?: string;
521
+ tags?: string[];
522
+ channelNumber?: number;
523
+ modified?: number;
524
+ added?: number;
525
+ variants?: IChannelVariant[];
506
526
  }
507
- export interface IChannelVersion {
527
+ export interface IChannelVariant {
508
528
  id: string;
509
- name: string;
510
- source: string;
529
+ channelRef: string;
511
530
  quality?: string;
512
- 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;
513
548
  }
514
549
  export interface UnassignedFace {
515
550
  id: string;
@@ -566,9 +601,6 @@ export interface MergePeopleResponse {
566
601
  export interface ClusterFacesResponse {
567
602
  clusters: number;
568
603
  }
569
- export interface IChannelUpdate {
570
- [key: string]: any;
571
- }
572
604
  export declare enum PluginAuthType {
573
605
  OAUTH = "oauth",
574
606
  URL = "url",
@@ -927,6 +959,7 @@ export interface RsGroupDownload {
927
959
  groupMime?: string;
928
960
  requests: RsRequest[];
929
961
  infos?: MediaForUpdate;
962
+ matchType?: RsLookupMatchType;
930
963
  }
931
964
  /**
932
965
  * Request processing status from plugin-based download/processing.
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, 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.
@@ -78,6 +80,10 @@ export declare class LibraryApi {
78
80
  setKey(passPhrase: string): Promise<void>;
79
81
  private getUrl;
80
82
  private openSearchStream;
83
+ searchAllStream(name: string, callbacks: SearchStreamCallbacks<LookupSearchStreamResult>, options?: {
84
+ source?: string[];
85
+ pageKey?: string;
86
+ }): () => void;
81
87
  getTags(query?: {
82
88
  name?: string;
83
89
  parent?: string;
@@ -141,6 +147,7 @@ export declare class LibraryApi {
141
147
  createPerson(person: Partial<IPerson>): Promise<IPerson>;
142
148
  removePerson(personId: string): Promise<void>;
143
149
  updatePerson(personId: string, updates: Partial<IPerson>): Promise<IPerson>;
150
+ getPerson(personId: string): Promise<IPerson>;
144
151
  createSerie(serie: Partial<ISerie>): Promise<ISerie>;
145
152
  createEpisode(episode: Partial<IEpisode>): Promise<IEpisode>;
146
153
  serieScrap(serieId: string, date?: number): Promise<void>;
@@ -160,7 +167,10 @@ export declare class LibraryApi {
160
167
  getMovieProgress(movieId: string): Promise<IViewProgress>;
161
168
  setMovieProgress(movieId: string, progress: number): Promise<void>;
162
169
  searchMovies(name: string): Promise<MovieSearchResult[]>;
163
- searchMoviesStream(name: string, callbacks: SearchStreamCallbacks<MovieSearchStreamResult>): () => void;
170
+ searchMoviesStream(name: string, callbacks: SearchStreamCallbacks<MovieSearchStreamResult>, options?: {
171
+ source?: string[];
172
+ pageKey?: string;
173
+ }): () => void;
164
174
  movieRename(movieId: string, newName: string): Promise<IMovie>;
165
175
  updateMoviePoster(movieId: string, poster: FormData, type: string): Promise<void>;
166
176
  updateMovieImageFetch(movieId: string, image: ExternalImage): Promise<void>;
@@ -180,7 +190,10 @@ export declare class LibraryApi {
180
190
  removeBook(bookId: string): Promise<void>;
181
191
  updateBook(bookId: string, updates: Partial<IBook>): Promise<IBook>;
182
192
  searchBooks(name: string): Promise<BookSearchResult[]>;
183
- searchBooksStream(name: string, callbacks: SearchStreamCallbacks<BookSearchStreamResult>): () => void;
193
+ searchBooksStream(name: string, callbacks: SearchStreamCallbacks<BookSearchStreamResult>, options?: {
194
+ source?: string[];
195
+ pageKey?: string;
196
+ }): () => void;
184
197
  bookRename(bookId: string, newName: string): Promise<IBook>;
185
198
  getBookImages(bookId: string): Promise<ExternalImage[]>;
186
199
  updateBookPoster(bookId: string, poster: FormData): Promise<void>;
@@ -188,19 +201,39 @@ export declare class LibraryApi {
188
201
  addTagToMedia(mediaId: string, tagId: string): Promise<void>;
189
202
  removeTagFromMedia(mediaId: string, tagId: string): Promise<void>;
190
203
  getCryptChallenge(): Promise<string>;
191
- 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;
192
216
  personRename(personId: string, newName: string): Promise<IPerson>;
193
217
  personUpdate(personId: string, updates: Partial<IPerson>): Promise<IPerson>;
194
218
  personAddAlt(personId: string, alt: string): Promise<IPerson>;
195
219
  personRemoveAlt(personId: string, alt: string): Promise<IPerson>;
196
220
  personAddSocial(personId: string, social: any): Promise<IPerson>;
197
221
  personRemoveSocial(personId: string, social: any): Promise<IPerson>;
222
+ personAddOtherId(personId: string, otherId: string): Promise<IPerson>;
223
+ personRemoveOtherId(personId: string, otherId: string): Promise<IPerson>;
198
224
  serieRename(serieId: string, newName: string): Promise<ISerie>;
199
225
  serieAddAlt(serieId: string, alt: string): Promise<ISerie>;
200
226
  serieRemoveAlt(serieId: string, alt: string): Promise<ISerie>;
201
227
  updatePersonPortrait(personId: string, portrait: FormData): Promise<void>;
228
+ searchPeopleStream(name: string, callbacks: SearchStreamCallbacks<PersonSearchStreamResult>, options?: {
229
+ source?: string[];
230
+ pageKey?: string;
231
+ }): () => void;
202
232
  searchSeries(name: string): Promise<SerieSearchResult[]>;
203
- searchSeriesStream(name: string, callbacks: SearchStreamCallbacks<SerieSearchStreamResult>): () => void;
233
+ searchSeriesStream(name: string, callbacks: SearchStreamCallbacks<SerieSearchStreamResult>, options?: {
234
+ source?: string[];
235
+ pageKey?: string;
236
+ }): () => void;
204
237
  setEpisodeWatched(serieId: string, season: number, number: number, date: number): Promise<void>;
205
238
  getEpisodeWatched(serieId: string, season: number, episode: number): Promise<IWatched>;
206
239
  getEpisodeProgress(serieId: string, season: number, episode: number): Promise<IViewProgress>;
@@ -229,6 +262,22 @@ export declare class LibraryApi {
229
262
  * @returns Array of grouped download requests. Use `.flatMap(g => g.requests)` to get a flat list of RsRequest items.
230
263
  */
231
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;
232
281
  /**
233
282
  * Adds a searched episode media to the database for download/storage.
234
283
  * @param serieId - The series identifier
@@ -372,7 +421,6 @@ export declare class LibraryApi {
372
421
  mediaUpdateProgress(mediaId: string, progress: number): Promise<{
373
422
  progress: number;
374
423
  }>;
375
- mediaUpdateChannel(mediaId: string, update: IChannelUpdate): Promise<IFile[]>;
376
424
  refreshMedia(mediaId: string): Promise<IFile[]>;
377
425
  aiTagMedia(mediaId: string): Promise<IFile[]>;
378
426
  mediaUpdate(mediaId: string, update: MediaForUpdate): Promise<IFile[]>;
@@ -456,11 +504,14 @@ export declare class LibraryApi {
456
504
  * Upload a group of media files from URLs
457
505
  * @param download - The group download request containing requests array
458
506
  * @param options - Optional settings (spawn: true to run in background)
459
- * @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
460
508
  */
461
509
  uploadGroup(download: RsGroupDownload, options?: {
462
510
  spawn?: boolean;
463
511
  }): Promise<IFile[] | {
464
512
  downloading: boolean;
513
+ } | {
514
+ needFileSelection: true;
515
+ request: RsRequest;
465
516
  }>;
466
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.
@@ -124,6 +125,14 @@ export class LibraryApi {
124
125
  }
125
126
  }, finish, (error) => callbacks.onError?.(error));
126
127
  }
128
+ searchAllStream(name, callbacks, options) {
129
+ const params = { name };
130
+ if (options?.source?.length)
131
+ params.source = options.source.join(',');
132
+ if (options?.pageKey)
133
+ params.pageKey = options.pageKey;
134
+ return this.openSearchStream(this.getUrl('/searchstream'), params, callbacks);
135
+ }
127
136
  async getTags(query) {
128
137
  const params = {};
129
138
  if (query) {
@@ -357,6 +366,10 @@ export class LibraryApi {
357
366
  const res = await this.client.patch(this.getUrl(`/people/${personId}`), updates);
358
367
  return res.data;
359
368
  }
369
+ async getPerson(personId) {
370
+ const res = await this.client.get(this.getUrl(`/people/${personId}`));
371
+ return res.data;
372
+ }
360
373
  async createSerie(serie) {
361
374
  const res = await this.client.post(this.getUrl('/series'), serie);
362
375
  return res.data;
@@ -425,8 +438,13 @@ export class LibraryApi {
425
438
  const res = await this.client.get(this.getUrl(`/movies/search?name=${name}`));
426
439
  return res.data;
427
440
  }
428
- searchMoviesStream(name, callbacks) {
429
- return this.openSearchStream(this.getUrl('/movies/searchstream'), { name }, callbacks);
441
+ searchMoviesStream(name, callbacks, options) {
442
+ const params = { name };
443
+ if (options?.source?.length)
444
+ params.source = options.source.join(',');
445
+ if (options?.pageKey)
446
+ params.pageKey = options.pageKey;
447
+ return this.openSearchStream(this.getUrl('/movies/searchstream'), params, callbacks);
430
448
  }
431
449
  async movieRename(movieId, newName) {
432
450
  const res = await this.client.patch(this.getUrl(`/movies/${movieId}`), {
@@ -493,8 +511,13 @@ export class LibraryApi {
493
511
  const res = await this.client.get(this.getUrl(`/books/search?name=${name}`));
494
512
  return res.data;
495
513
  }
496
- searchBooksStream(name, callbacks) {
497
- return this.openSearchStream(this.getUrl('/books/searchstream'), { name }, callbacks);
514
+ searchBooksStream(name, callbacks, options) {
515
+ const params = { name };
516
+ if (options?.source?.length)
517
+ params.source = options.source.join(',');
518
+ if (options?.pageKey)
519
+ params.pageKey = options.pageKey;
520
+ return this.openSearchStream(this.getUrl('/books/searchstream'), params, callbacks);
498
521
  }
499
522
  async bookRename(bookId, newName) {
500
523
  const res = await this.client.patch(this.getUrl(`/books/${bookId}`), {
@@ -522,10 +545,62 @@ export class LibraryApi {
522
545
  const res = await this.client.get(this.getUrl('/cryptchallenge'));
523
546
  return res.data.value;
524
547
  }
525
- async getChannels() {
526
- 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 });
555
+ return res.data;
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'), {});
527
570
  return res.data;
528
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
+ }
529
604
  async personRename(personId, newName) {
530
605
  const res = await this.client.patch(this.getUrl(`/people/${personId}`), {
531
606
  name: newName
@@ -560,6 +635,18 @@ export class LibraryApi {
560
635
  });
561
636
  return res.data;
562
637
  }
638
+ async personAddOtherId(personId, otherId) {
639
+ const res = await this.client.patch(this.getUrl(`/people/${personId}`), {
640
+ addOtherids: [otherId]
641
+ });
642
+ return res.data;
643
+ }
644
+ async personRemoveOtherId(personId, otherId) {
645
+ const res = await this.client.patch(this.getUrl(`/people/${personId}`), {
646
+ removeOtherids: [otherId]
647
+ });
648
+ return res.data;
649
+ }
563
650
  async serieRename(serieId, newName) {
564
651
  const res = await this.client.patch(this.getUrl(`/series/${serieId}`), {
565
652
  name: newName
@@ -581,12 +668,25 @@ export class LibraryApi {
581
668
  async updatePersonPortrait(personId, portrait) {
582
669
  await this.client.post(this.getUrl(`/people/${personId}/image?size=thumb&type=poster`), portrait);
583
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
+ }
584
679
  async searchSeries(name) {
585
680
  const res = await this.client.get(this.getUrl(`/series/search?name=${name}`));
586
681
  return res.data;
587
682
  }
588
- searchSeriesStream(name, callbacks) {
589
- return this.openSearchStream(this.getUrl('/series/searchstream'), { name }, callbacks);
683
+ searchSeriesStream(name, callbacks, options) {
684
+ const params = { name };
685
+ if (options?.source?.length)
686
+ params.source = options.source.join(',');
687
+ if (options?.pageKey)
688
+ params.pageKey = options.pageKey;
689
+ return this.openSearchStream(this.getUrl('/series/searchstream'), params, callbacks);
590
690
  }
591
691
  async setEpisodeWatched(serieId, season, number, date) {
592
692
  await this.client.post(this.getUrl(`/series/${serieId}/seasons/${season}/episodes/${number}/watched`), { date });
@@ -635,6 +735,38 @@ export class LibraryApi {
635
735
  const res = await this.client.get(this.getUrl(`/movies/${movieId}/search`), q ? { params: { q } } : undefined);
636
736
  return res.data;
637
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
+ }
638
770
  /**
639
771
  * Adds a searched episode media to the database for download/storage.
640
772
  * @param serieId - The series identifier
@@ -884,10 +1016,6 @@ export class LibraryApi {
884
1016
  const res = await this.client.patch(this.getUrl(`/medias/${mediaId}/progress`), { progress });
885
1017
  return res.data;
886
1018
  }
887
- async mediaUpdateChannel(mediaId, update) {
888
- const res = await this.client.patch(this.getUrl(`/medias/${mediaId}/channel`), update);
889
- return res.data;
890
- }
891
1019
  async refreshMedia(mediaId) {
892
1020
  const res = await this.client.get(this.getUrl(`/medias/${mediaId}/metadata/refresh`));
893
1021
  return res.data;
@@ -1141,14 +1269,14 @@ export class LibraryApi {
1141
1269
  * Upload a group of media files from URLs
1142
1270
  * @param download - The group download request containing requests array
1143
1271
  * @param options - Optional settings (spawn: true to run in background)
1144
- * @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
1145
1273
  */
1146
1274
  async uploadGroup(download, options) {
1147
1275
  const params = {};
1148
1276
  if (options?.spawn) {
1149
1277
  params.spawn = 'true';
1150
1278
  }
1151
- 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 });
1152
1280
  return res.data;
1153
1281
  }
1154
1282
  }
package/dist/server.d.ts CHANGED
@@ -15,7 +15,10 @@ export declare class ServerApi {
15
15
  updateLibrary(libraryId: string, library: ServerLibraryForUpdate): Promise<ILibrary>;
16
16
  deleteLibrary(libraryId: string, deleteMediaContent?: boolean): Promise<void>;
17
17
  getPlugins(): Promise<IPlugin[]>;
18
- searchLookupStream(query: string, type: string, callbacks: SearchStreamCallbacks<LookupSearchStreamResult>): () => void;
18
+ searchLookupStream(query: string, type: string, callbacks: SearchStreamCallbacks<LookupSearchStreamResult>, options?: {
19
+ source?: string[];
20
+ pageKey?: string;
21
+ }): () => void;
19
22
  getCredentials(): Promise<ICredential[]>;
20
23
  saveCredential(credential: ICredential): Promise<ICredential>;
21
24
  updateCredential(credential: ICredential): Promise<ICredential>;
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');
@@ -73,8 +57,13 @@ export class ServerApi {
73
57
  const res = await this.client.get('/plugins');
74
58
  return res.data;
75
59
  }
76
- searchLookupStream(query, type, callbacks) {
77
- return this.openSearchStream('/plugins/lookup/searchstream', { q: query, type }, callbacks);
60
+ searchLookupStream(query, type, callbacks, options) {
61
+ const params = { q: query, type };
62
+ if (options?.source?.length)
63
+ params.source = options.source.join(',');
64
+ if (options?.pageKey)
65
+ params.pageKey = options.pageKey;
66
+ return this.openSearchStream('/plugins/lookup/searchstream', params, callbacks);
78
67
  }
79
68
  async getCredentials() {
80
69
  const res = await this.client.get('/credentials');
@@ -10,6 +10,7 @@ export interface RawSSEEvent {
10
10
  /**
11
11
  * Parses a raw SSE buffer into discrete events, returning any incomplete trailing data.
12
12
  * Follows the SSE spec: events are separated by blank lines, fields are `field: value`.
13
+ * Handles \r\n, \r, and \n line endings per the SSE specification.
13
14
  */
14
15
  export declare function parseSSEBuffer(buffer: string): {
15
16
  events: RawSSEEvent[];
package/dist/sse-fetch.js CHANGED
@@ -1,58 +1,60 @@
1
1
  /**
2
2
  * Parses a raw SSE buffer into discrete events, returning any incomplete trailing data.
3
3
  * Follows the SSE spec: events are separated by blank lines, fields are `field: value`.
4
+ * Handles \r\n, \r, and \n line endings per the SSE specification.
4
5
  */
5
6
  export function parseSSEBuffer(buffer) {
6
7
  const events = [];
7
- const lines = buffer.split('\n');
8
- let processedUpTo = 0;
9
- let eventType = '';
10
- let eventData = '';
11
- let eventId;
12
- let eventRetry;
13
- for (let i = 0; i < lines.length; i++) {
14
- const line = lines[i];
15
- // Incomplete last line — keep in buffer
16
- if (i === lines.length - 1 && !buffer.endsWith('\n')) {
8
+ const normalised = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
9
+ // Split into blocks separated by blank lines (\n\n).
10
+ // Only complete blocks (followed by \n\n) are processed;
11
+ // any trailing incomplete block stays in the buffer.
12
+ let lastEventEnd = 0;
13
+ const blockBoundary = '\n\n';
14
+ let searchFrom = 0;
15
+ while (true) {
16
+ const idx = normalised.indexOf(blockBoundary, searchFrom);
17
+ if (idx === -1)
17
18
  break;
18
- }
19
- processedUpTo += line.length + 1; // +1 for '\n'
20
- if (line === '') {
21
- // Blank line = end of event
22
- if (eventType) {
23
- events.push({ event: eventType, data: eventData, id: eventId, retry: eventRetry });
19
+ const block = normalised.slice(lastEventEnd, idx);
20
+ lastEventEnd = idx + blockBoundary.length;
21
+ searchFrom = lastEventEnd;
22
+ let eventType = '';
23
+ let eventData = '';
24
+ let eventId;
25
+ let eventRetry;
26
+ for (const line of block.split('\n')) {
27
+ if (line === '' || line.startsWith(':'))
28
+ continue;
29
+ const colonIdx = line.indexOf(':');
30
+ if (colonIdx === -1)
31
+ continue;
32
+ const field = line.slice(0, colonIdx);
33
+ let value = line.slice(colonIdx + 1);
34
+ if (value.startsWith(' '))
35
+ value = value.slice(1);
36
+ switch (field) {
37
+ case 'event':
38
+ eventType = value;
39
+ break;
40
+ case 'data':
41
+ eventData = eventData ? eventData + '\n' + value : value;
42
+ break;
43
+ case 'id':
44
+ eventId = value;
45
+ break;
46
+ case 'retry':
47
+ eventRetry = parseInt(value, 10);
48
+ break;
24
49
  }
25
- eventType = '';
26
- eventData = '';
27
- eventId = undefined;
28
- eventRetry = undefined;
29
- continue;
30
50
  }
31
- if (line.startsWith(':'))
32
- continue; // SSE comment
33
- const colonIdx = line.indexOf(':');
34
- if (colonIdx === -1)
35
- continue;
36
- const field = line.slice(0, colonIdx);
37
- let value = line.slice(colonIdx + 1);
38
- if (value.startsWith(' '))
39
- value = value.slice(1);
40
- switch (field) {
41
- case 'event':
42
- eventType = value;
43
- break;
44
- case 'data':
45
- eventData = eventData ? eventData + '\n' + value : value;
46
- break;
47
- case 'id':
48
- eventId = value;
49
- break;
50
- case 'retry':
51
- eventRetry = parseInt(value, 10);
52
- break;
51
+ if (eventType) {
52
+ events.push({ event: eventType, data: eventData, id: eventId, retry: eventRetry });
53
53
  }
54
54
  }
55
- return { events, remainingBuffer: buffer.slice(processedUpTo) };
55
+ // Note: remainingBuffer is from the normalised string. Callers must replace
56
+ // their buffer entirely (not use offset-based splicing on the original input).
57
+ return { events, remainingBuffer: normalised.slice(lastEventEnd) };
56
58
  }
57
59
  /**
58
60
  * Opens a one-shot fetch-based SSE stream (no reconnection).
@@ -87,6 +89,14 @@ export function openFetchSSEStream(url, onEvent, onDone, onError) {
87
89
  while (true) {
88
90
  const { done, value } = await reader.read();
89
91
  if (done) {
92
+ // Flush any remaining buffered event when the stream closes
93
+ // (server may close without a trailing blank line)
94
+ if (buffer.trim()) {
95
+ const { events: finalEvents } = parseSSEBuffer(buffer + '\n\n');
96
+ for (const event of finalEvents) {
97
+ onEvent(event.event, event.data);
98
+ }
99
+ }
90
100
  onDone?.();
91
101
  break;
92
102
  }
@@ -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.4.6",
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",