Skip to content

Latest commit

 

History

History
829 lines (671 loc) · 24.5 KB

File metadata and controls

829 lines (671 loc) · 24.5 KB

Server-Sent Events (SSE) API

This document describes the SSE endpoint for receiving real-time events from the server. SSE provides an alternative to Socket.IO for clients that prefer a simpler, HTTP-native approach.

Overview

  • Endpoint: GET /sse
  • Authentication: Requires valid authentication (same as other API endpoints)
  • Protocol: Standard SSE (EventSource API)
  • Direction: Server-to-client only (one-way)

Both SSE and Socket.IO broadcast the same events. Choose SSE when you need:

  • Simpler client implementation
  • HTTP/2 multiplexing benefits
  • Native browser EventSource support
  • One-way server push only

Query Parameters

Parameter Type Description
libraries string (optional) Comma-separated list of library IDs to filter events

Example: /sse?libraries=lib1,lib2 will only receive events for those libraries.

Event Types

Event Name Description Required Permission
library Library created/updated/deleted Library read access
library-status Library status changes Library admin
medias Media items created/updated/deleted Library read access
upload_progress Upload progress Library read access
convert_progress Video conversion progress Library read access
episodes Episodes created/updated/deleted Library read access
series Series created/updated/deleted Library read access
movies Movies created/updated/deleted Library read access
books Books created/updated/deleted Library read access
people People created/updated/deleted Library read access
tags Tags created/updated/deleted Library read access
backups Backup job events Library admin or server admin
backups-files Backup file progress Server admin only
media_progress Playback position tracking User-specific (only progress owner)
media_rating Rating changes (media, movies, series, episodes, books, people) User-specific (only rating owner)
watched Content marked as watched User-specific (only watched owner)
unwatched Content unmarked as watched User-specific (only watched owner)
request_processing Request processing status updates Library read access

library-status is also used for async library deletion lifecycle updates. Current messages include:

  • delete-started
  • delete-removing-tracked-media
  • delete-media-progress:{current}/{total}
  • delete-cleaning-local-cache
  • delete-cleaning-database-files
  • delete-completed
  • delete-failed: ...

TypeScript Client Examples

Basic Connection

const eventSource = new EventSource('/sse', {
  // Include credentials if using cookies for auth
  withCredentials: true
});

// Or with token-based auth (depends on your auth setup)
// You may need to pass the token via query param or use fetch-event-source library
const eventSource = new EventSource('/sse?token=' + authToken);

eventSource.onopen = () => {
  console.log('SSE connection established');
};

eventSource.onerror = (error) => {
  console.error('SSE error:', error);
  // EventSource will automatically reconnect
};

Type Definitions

// Base action type for CRUD events
type ElementAction = 'Deleted' | 'Added' | 'Updated';

// Library events
interface LibraryMessage {
  action: ElementAction;
  library: ServerLibrary;
}

interface LibraryStatusMessage {
  message: string;
  library: string;
  progress?: number;
}

// Media events
interface MediasMessage {
  library: string;
  medias: MediaWithAction[];
}

interface MediaWithAction {
  action: ElementAction;
  // All Media fields are present at the top level (flattened), plus optional relations
  media: Media & { relations?: Relations };
}

interface Relations {
  people?: MediaItemReference[];
  peopleDetails?: Person[];
  tags?: MediaItemReference[];
  tagsDetails?: Tag[];
  series?: FileEpisode[];
  seriesDetails?: Serie[];
  movies?: string[];
  moviesDetails?: Movie[];
  books?: string[];
  booksDetails?: Book[];
}

type RsProgressType =
  | 'download'
  | 'transfert'
  | 'analysing'
  | 'finished'
  | { duplicate: string };

interface RsProgress {
  id: string;
  total?: number;
  current?: number;
  filename?: string;
  type: RsProgressType;
}

interface UploadProgressMessage {
  library: string;
  progress: RsProgress;
  remainingSecondes?: number;
}

interface ConvertProgress {
  id: string;
  filename: string;
  convertedId?: string | null;
  done: boolean;
  percent: number;
  status: string; // queued | pending | downloading | processing | completed | failed | canceled
  remainingSecondes?: number | null;
  request?: VideoConvertRequest | null;
}

interface ConvertMessage {
  library: string;
  progress: ConvertProgress;
}

// Content events
interface EpisodesMessage {
  library: string;
  episodes: EpisodeWithAction[];
}

interface SeriesMessage {
  library: string;
  series: SerieWithAction[];
}

interface MoviesMessage {
  library: string;
  movies: MovieWithAction[];
}

interface BooksMessage {
  library: string;
  books: BookWithAction[];
}

interface PeopleMessage {
  library: string;
  people: PersonWithAction[];
}

interface TagMessage {
  library: string;
  tags: TagWithAction[];
}

// Backup events
interface BackupMessage {
  backup: BackupWithStatus;
}

interface BackupFileProgress {
  library?: string;
  file: string;
  progress: number;
}

// Media progress (user-specific)
interface MediaProgress {
  userRef: string;
  mediaRef: string;
  progress: number;
  modified: number;
}

interface MediasProgressMessage {
  library: string;
  progress: MediaProgress;
}

// Rating events (user-specific)
// Supports rating media, movies, series, episodes, and books.
// The `type` field indicates the entity type being rated.
// For episodes, `refId` uses the format "serieRef:season:number".
interface Rating {
  type: string;   // ElementType: "media", "movie", "serie", "episode", "book", "person"
  refId: string;  // Reference ID of the rated entity
  userRef: string;
  rating: number;
  modified: number;
}

interface MediasRatingMessage {
  library: string;
  rating: Rating;
}

// Watched events (user-specific). IDs include the media type and identity scheme.
interface Watched {
  type: string;  // MediaType: "movie", "episode", etc.
  id: string;    // e.g. "movie:imdb/tt1234567" or "episode:redseat/seriesId/1/2"
  userRef?: string;
  date: number;  // Timestamp when content was watched
  modified: number;
}

// Unwatched events (user-specific)
// NOTE: Different structure from Watched because the delete API accepts multiple IDs.
interface Unwatched {
  type: string;     // MediaType: "movie", "episode", etc.
  ids: string[];    // History IDs marked as deleted
  userRef?: string;
  modified: number;
}

// Request processing events
interface RequestProcessingMessage {
  library: string;
  processings: RequestProcessingWithAction[];
}

interface RequestProcessingWithAction {
  action: ElementAction;
  processing: RsRequestProcessing;
}

interface RsRequestProcessing {
  id: string;           // Internal nanoid for this processing record
  processingId: string; // Plugin's processing ID
  pluginId: string;     // ID of the plugin handling this request
  progress: number;     // 0-100 progress percentage
  status: string;       // "pending", "processing", "paused", "finished", "error"
  error?: string;       // Error message if status is "error"
  eta?: number;         // UTC timestamp (ms) for estimated completion
  mediaRef?: string;    // Optional reference to the media this processing is for
  originalRequest?: RsRequest; // The original request that started this processing
  modified: number;     // Last modified timestamp
  added: number;        // Creation timestamp
}

// Wrapper type matching the SSE event structure
type SseEvent =
  | { Library: LibraryMessage }
  | { LibraryStatus: LibraryStatusMessage }
  | { Medias: MediasMessage }
  | { UploadProgress: UploadProgressMessage }
  | { ConvertProgress: ConvertMessage }
  | { Episodes: EpisodesMessage }
  | { Series: SeriesMessage }
  | { Movies: MoviesMessage }
  | { Books: BooksMessage }
  | { People: PeopleMessage }
  | { Tags: TagMessage }
  | { Backups: BackupMessage }
  | { BackupsFiles: BackupFileProgress }
  | { MediaProgress: MediasProgressMessage }
  | { MediaRating: MediasRatingMessage }
  | { Watched: Watched }
  | { Unwatched: Unwatched }   // Note: different structure than Watched
  | { RequestProcessing: RequestProcessingMessage };

Listening to Events

// Listen to specific event types
eventSource.addEventListener('medias', (event) => {
  const data: SseEvent = JSON.parse(event.data);
  if ('Medias' in data) {
    const message = data.Medias;
    console.log(`Library ${message.library} medias updated:`, message.medias);

    message.medias.forEach(({ action, media }) => {
      switch (action) {
        case 'Added':
          console.log('New media:', media.id);
          break;
        case 'Updated':
          console.log('Updated media:', media.id);
          break;
        case 'Deleted':
          console.log('Deleted media:', media.id);
          break;
      }
    });
  }
});

eventSource.addEventListener('library-status', (event) => {
  const data: SseEvent = JSON.parse(event.data);
  if ('LibraryStatus' in data) {
    const { library, message, progress } = data.LibraryStatus;
    console.log(`Library ${library}: ${message} (${progress}%)`);
  }
});

eventSource.addEventListener('convert_progress', (event) => {
  const data: SseEvent = JSON.parse(event.data);
  if ('ConvertProgress' in data) {
    const { library, progress } = data.ConvertProgress;
    const eta = progress.remainingSecondes ? `, ~${progress.remainingSecondes}s remaining` : '';
    console.log(`Converting in ${library}: ${progress.filename} ${(progress.percent * 100).toFixed(2)}% - ${progress.status}${eta}`);
  }
});

eventSource.addEventListener('media_progress', (event) => {
  const data: SseEvent = JSON.parse(event.data);
  if ('MediaProgress' in data) {
    const { library, progress } = data.MediaProgress;
    console.log(`Progress update in ${library}: ${progress.mediaRef} at ${progress.progress}ms`);
  }
});

eventSource.addEventListener('watched', (event) => {
  const data: SseEvent = JSON.parse(event.data);
  if ('Watched' in data) {
    const watched = data.Watched;
    console.log(`Marked as watched: ${watched.type} ${watched.id} on ${new Date(watched.date)}`);
  }
});

eventSource.addEventListener('unwatched', (event) => {
  const data: SseEvent = JSON.parse(event.data);
  if ('Unwatched' in data) {
    const unwatched = data.Unwatched;
    // Unwatched events contain the history IDs marked as deleted
    console.log(`Unmarked as watched: ${unwatched.type} with IDs: ${unwatched.ids.join(', ')}`);
  }
});

Library Filtering

// Only receive events for specific libraries
const libraries = ['photo-library', 'video-library'];
const eventSource = new EventSource(`/sse?libraries=${libraries.join(',')}`);

eventSource.addEventListener('medias', (event) => {
  // Will only receive medias events for the specified libraries
  const data: SseEvent = JSON.parse(event.data);
  // ...
});

Reconnection Handling

class SseClient {
  private eventSource: EventSource | null = null;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 5;
  private baseDelay = 1000;

  connect(url: string) {
    this.eventSource = new EventSource(url);

    this.eventSource.onopen = () => {
      console.log('Connected');
      this.reconnectAttempts = 0;
    };

    this.eventSource.onerror = (error) => {
      console.error('Connection error:', error);

      // EventSource auto-reconnects, but you can add custom logic
      if (this.eventSource?.readyState === EventSource.CLOSED) {
        this.handleReconnect(url);
      }
    };

    // Add your event listeners
    this.setupEventListeners();
  }

  private handleReconnect(url: string) {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('Max reconnection attempts reached');
      return;
    }

    const delay = this.baseDelay * Math.pow(2, this.reconnectAttempts);
    this.reconnectAttempts++;

    console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
    setTimeout(() => this.connect(url), delay);
  }

  private setupEventListeners() {
    if (!this.eventSource) return;

    const events = [
      'library', 'library-status', 'medias', 'upload_progress',
      'convert_progress', 'episodes', 'series', 'movies', 'books',
      'people', 'tags', 'backups', 'backups-files', 'media_progress',
      'media_rating', 'watched', 'unwatched', 'request_processing'
    ];

    events.forEach(eventName => {
      this.eventSource!.addEventListener(eventName, (event) => {
        this.handleEvent(eventName, JSON.parse(event.data));
      });
    });
  }

  private handleEvent(eventName: string, data: SseEvent) {
    // Dispatch to your application's event handlers
    console.log(`Received ${eventName}:`, data);
  }

  disconnect() {
    this.eventSource?.close();
    this.eventSource = null;
  }
}

// Usage
const client = new SseClient();
client.connect('/sse');

React Hook Example

import { useEffect, useState, useCallback } from 'react';

interface UseSseOptions {
  libraries?: string[];
  onEvent?: (eventName: string, data: SseEvent) => void;
}

function useSse(options: UseSseOptions = {}) {
  const [isConnected, setIsConnected] = useState(false);
  const [error, setError] = useState<Event | null>(null);

  useEffect(() => {
    const params = new URLSearchParams();
    if (options.libraries?.length) {
      params.set('libraries', options.libraries.join(','));
    }

    const url = `/sse${params.toString() ? '?' + params.toString() : ''}`;
    const eventSource = new EventSource(url);

    eventSource.onopen = () => {
      setIsConnected(true);
      setError(null);
    };

    eventSource.onerror = (err) => {
      setError(err);
      if (eventSource.readyState === EventSource.CLOSED) {
        setIsConnected(false);
      }
    };

    // Subscribe to all event types
    const eventTypes = [
      'library', 'library-status', 'medias', 'upload_progress',
      'convert_progress', 'episodes', 'series', 'movies', 'books',
      'people', 'tags', 'backups', 'backups-files', 'media_progress',
      'media_rating', 'watched', 'unwatched', 'request_processing'
    ];

    eventTypes.forEach(eventName => {
      eventSource.addEventListener(eventName, (event) => {
        const data = JSON.parse(event.data);
        options.onEvent?.(eventName, data);
      });
    });

    return () => {
      eventSource.close();
    };
  }, [options.libraries?.join(',')]);

  return { isConnected, error };
}

// Usage in a component
function MediaLibrary({ libraryId }: { libraryId: string }) {
  const [medias, setMedias] = useState<Media[]>([]);

  const handleEvent = useCallback((eventName: string, data: SseEvent) => {
    if (eventName === 'medias' && 'Medias' in data) {
      const { medias: updates } = data.Medias;
      setMedias(current => {
        // Apply updates to current state
        const updated = [...current];
        updates.forEach(({ action, media }) => {
          const index = updated.findIndex(m => m.id === media.id);
          if (action === 'Added' && index === -1) {
            updated.push(media);
          } else if (action === 'Updated' && index !== -1) {
            updated[index] = media;
          } else if (action === 'Deleted' && index !== -1) {
            updated.splice(index, 1);
          }
        });
        return updated;
      });
    }
  }, []);

  const { isConnected } = useSse({
    libraries: [libraryId],
    onEvent: handleEvent
  });

  return (
    <div>
      <span>Status: {isConnected ? 'Connected' : 'Disconnected'}</span>
      {/* Render medias */}
    </div>
  );
}

Search Stream Endpoints

Search endpoints support SSE streaming so clients receive results progressively as each provider responds, instead of waiting for all providers to finish.

Endpoints

Endpoint Query Parameters Description
GET /libraries/:libraryId/series/searchstream name (required), ids (optional) Stream series search results
GET /libraries/:libraryId/movies/searchstream name (required), ids (optional) Stream movie search results
GET /libraries/:libraryId/books/searchstream name (required), ids (optional) Stream book search results
GET /libraries/:libraryId/people/searchstream name (required), ids (optional) Stream people search results

How It Works

Each SSE event has event type results. The data is a JSON object with a single key: the provider name, and the value is an array of results from that provider.

Results arrive one metadata plugin at a time.

Event Format

Each results event contains one provider's results:

{"TMDB": [{"metadata": {"serie": { ... }}, "images": []}]}

Then a second event for the next provider:

{"Anilist": [{"metadata": {"serie": { ... }}, "images": [{"url": "...", "kind": "poster"}]}]}

The metadata field is a tagged enum with one of: serie, movie, book, episode, person, media.

TypeScript Example

const params = new URLSearchParams({ name: 'one piece' });
const eventSource = new EventSource(
  `/libraries/${libraryId}/series/searchstream?${params}`
);

// Accumulate results by provider
const resultsByProvider: Record<string, SearchResult[]> = {};

eventSource.addEventListener('results', (event) => {
  const data = JSON.parse(event.data);
  // data is e.g. { "TMDB": [...] } or { "Anilist": [...] }
  for (const [provider, results] of Object.entries(data)) {
    resultsByProvider[provider] = results;
  }
  // Update UI with new results
  renderResults(resultsByProvider);
});

eventSource.onerror = () => {
  // Stream finished or errored — close the connection
  eventSource.close();
};

Non-Streaming Alternative

The same search is available as a regular JSON endpoint that returns all providers at once:

Endpoint Description
GET /libraries/:libraryId/series/search Returns all results grouped by provider
GET /libraries/:libraryId/movies/search Returns all results grouped by provider
GET /libraries/:libraryId/books/search Returns all results grouped by provider
GET /libraries/:libraryId/people/search Returns all results grouped by provider

Response format:

{
  "TMDB": [{"metadata": {"movie": { ... }}, "images": []}],
  "Anilist": [{"metadata": {"movie": { ... }}, "images": [...]}]
}

Keepalive

The server sends a keepalive ping every 30 seconds to prevent connection timeouts. The ping is sent as a comment (:ping) which is ignored by the EventSource API.

Error Handling

When a client falls behind and misses events (lag), the server will skip the missed events and continue with new ones. Consider implementing periodic full-sync if you need guaranteed delivery of all events.

Watched/Unwatched Events

Understanding the ID Format

History IDs include the media type so IDs from different domains cannot collide.

Content Format Example
Movie with IMDb ID movie:imdb/<imdbId> movie:imdb/tt1234567
Movie without IMDb ID movie:redseat/<movieId> movie:redseat/abc123
Series progress parent series:redseat/<seriesId> series:redseat/series123
Episode episode:redseat/<seriesId>/<season>/<episode> episode:redseat/series123/1/2

Episode IDs deliberately use the immutable local series ID and numeric season/episode tuple. Plugin metadata refreshes therefore cannot change watched state or progress IDs.

REST API Endpoints

Mark as Watched

Movies: POST /libraries/:libraryId/movies/:id/watched

{ "date": 1705766400000 }

Episodes: POST /libraries/:libraryId/series/:serieId/seasons/:season/episodes/:number/watched

{ "date": 1705766400000 }

Direct History: POST /users/me/history

{
  "type": "movie",
  "id": "movie:imdb/tt1234567",
  "date": 1705766400000
}

For movie compatibility, imdb:tt1234567 is also accepted and normalized to the typed form. Other media should send the typed history ID returned by the history API or SSE event.

Unmark as Watched (Remove from History)

Movies: DELETE /libraries/:libraryId/movies/:id/watched

Episodes: DELETE /libraries/:libraryId/series/:serieId/seasons/:season/episodes/:number/watched

Direct History: DELETE /users/me/history

{
  "type": "movie",
  "ids": ["movie:imdb/tt1234567"]
}

The delete endpoint accepts an array so clients can delete more than one known history ID in one request.

Example: Handling Watch State Changes

// Track local watch state
const watchedItems = new Map<string, boolean>();

eventSource.addEventListener('watched', (event) => {
  const data = JSON.parse(event.data);
  if ('Watched' in data) {
    const { type, id, date } = data.Watched;
    console.log(`Marked as watched: ${type} ${id} on ${new Date(date)}`);
    watchedItems.set(id, true);
    // Update UI to show as watched
  }
});

eventSource.addEventListener('unwatched', (event) => {
  const data = JSON.parse(event.data);
  if ('Unwatched' in data) {
    const { type, ids } = data.Unwatched;
    console.log(`Unmarked as watched: ${type} with IDs: ${ids.join(', ')}`);
    // Remove all matching IDs from watched state
    ids.forEach(id => watchedItems.delete(id));
    // Update UI to show as unwatched
  }
});

Matching SSE Events to Local Content

Match SSE events against the same typed history ID used by the REST API:

interface LocalMovie {
  id: string;        // Local database ID
  imdb?: string;     // "tt1234567"
}

function isMatchingWatchedEvent(movie: LocalMovie, eventId: string): boolean {
  const historyId = movie.imdb
    ? `movie:imdb/${movie.imdb}`
    : `movie:redseat/${movie.id}`;
  return eventId === historyId;
}

// For Unwatched events (array of IDs)
function isMatchingUnwatchedEvent(movie: LocalMovie, eventIds: string[]): boolean {
  return eventIds.some(eventId => isMatchingWatchedEvent(movie, eventId));
}

Offline Sync for Watch History

When clients are offline or disconnected from SSE, they can miss unwatched events. The REST API provides a mechanism to sync these missed deletions.

How It Works

  • date > 0: Item is actively watched (timestamp indicates when it was watched)
  • date = 0: Item was unwatched/deleted (soft-deleted, kept for sync purposes)

When content is marked as unwatched, instead of being deleted from the database, the date field is set to 0 and the modified timestamp is updated. This allows clients to fetch all changes (including deletions) via the history API.

Client Sync Flow

// 1. Store last sync timestamp locally
let lastSyncTimestamp = localStorage.getItem('lastHistorySync') || '0';

// 2. Fetch all history changes since last sync, including deleted items
async function syncHistory() {
  const response = await fetch(
    `/users/me/history?after=${lastSyncTimestamp}&includeDeleted=true`
  );
  const items: Watched[] = await response.json();

  for (const item of items) {
    if (item.date > 0) {
      // Active watched item - add or update in local state
      addToLocalWatched(item);
    } else {
      // Deleted item (date = 0) - remove from local state
      removeFromLocalWatched(item.type, item.id);
    }

    // Track highest modified timestamp for next sync
    if (item.modified > parseInt(lastSyncTimestamp)) {
      lastSyncTimestamp = item.modified.toString();
    }
  }

  localStorage.setItem('lastHistorySync', lastSyncTimestamp);
}

// 3. Call on app startup and periodically while online
syncHistory();

API Query Parameters

Parameter Type Description
after number Only return items modified after this timestamp (milliseconds)
includeDeleted boolean Include items with date=0 (unwatched). Default: false
types string[] Filter by content types (e.g., movie, episode)

Example Response with Deleted Items

[
  {
    "type": "movie",
    "id": "movie:imdb/tt1234567",
    "userRef": "user123",
    "date": 1705766400000,
    "modified": 1705852800000
  },
  {
    "type": "movie",
    "id": "movie:imdb/tt9876543",
    "userRef": "user123",
    "date": 0,
    "modified": 1705939200000
  }
]

In this response:

  • First item: Movie was watched at timestamp 1705766400000
  • Second item: Movie was unwatched (date=0), client should remove it from local state