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.
- 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
| 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 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-starteddelete-removing-tracked-mediadelete-media-progress:{current}/{total}delete-cleaning-local-cachedelete-cleaning-database-filesdelete-completeddelete-failed: ...
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
};// 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[];
}
interface UploadProgressMessage {
library: string;
mediaId: string;
progress: number;
// ... additional fields
}
interface ConvertMessage {
library: string;
mediaId: string;
progress: number;
status: string;
}
// 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)
// IMPORTANT: The `id` field uses external IDs, NOT local database IDs.
// Format: "provider:id" (e.g., "imdb:tt1234567", "trakt:123456", "tmdb:550")
// For movies: Uses the best available external ID (priority: imdb > trakt > tmdb > tvdb > slug)
// For episodes: Uses external IDs or falls back to local "redseat:{id}" if no external IDs exist
interface Watched {
type: string; // MediaType: "movie", "episode", etc.
id: string; // External ID in format "provider:value" (e.g., "imdb:tt1234567")
userRef?: string;
date: number; // Timestamp when content was watched
modified: number;
}
// Unwatched events (user-specific)
// NOTE: Different structure from Watched - contains ALL possible IDs for client matching
interface Unwatched {
type: string; // MediaType: "movie", "episode", etc.
ids: string[]; // All possible IDs in format "provider:value" (e.g., ["imdb:tt1234567", "trakt:12345", "tmdb:550"])
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 };// 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 { mediaId, progress, status } = data.ConvertProgress;
console.log(`Converting ${mediaId}: ${progress}% - ${status}`);
}
});
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 ALL possible IDs for the content
console.log(`Unmarked as watched: ${unwatched.type} with IDs: ${unwatched.ids.join(', ')}`);
}
});// 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);
// ...
});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');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 endpoints support SSE streaming so clients receive results progressively as each provider responds, instead of waiting for all providers to finish.
| 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 |
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 provider at a time. For series and movies, Trakt results are sent first, followed by each plugin (e.g., Anilist). For books, only plugin results are sent (no Trakt).
Each results event contains one provider's results:
{"trakt": [{"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.
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. { "trakt": [...] } 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();
};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:
{
"trakt": [{"metadata": {"movie": { ... }}, "images": []}],
"Anilist": [{"metadata": {"movie": { ... }}, "images": [...]}]
}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.
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.
The watched and unwatched events use external IDs (from providers like IMDb, Trakt, TMDb) rather than local database IDs. This allows watch history to be portable across different servers and sync with external services.
ID Format: provider:value
| Provider | Example | Content Types |
|---|---|---|
imdb |
imdb:tt1234567 |
Movies, Episodes |
trakt |
trakt:123456 |
Movies, Episodes, Series |
tmdb |
tmdb:550 |
Movies, Episodes, Series |
tvdb |
tvdb:78901 |
Episodes, Series |
slug |
slug:the-matrix |
Movies, Series |
redseat |
redseat:abc123 |
Local fallback (episodes only) |
ID Selection Priority:
- Movies: Uses the best external ID (priority: imdb > trakt > tmdb > slug)
- Episodes: Uses external IDs, or falls back to local
redseat:ID if no external IDs exist
Movies: POST /libraries/:libraryId/movies/:id/watched
{ "date": 1705766400000 }Episodes: POST /libraries/:libraryId/series/:serieId/seasons/:season/episodes/:number/watched
{ "date": 1705766400000 }Direct History (requires knowing the external ID): POST /users/me/history
{
"type": "movie",
"id": "imdb:tt1234567",
"date": 1705766400000
}Movies: DELETE /libraries/:libraryId/movies/:id/watched
Episodes: DELETE /libraries/:libraryId/series/:serieId/seasons/:season/episodes/:number/watched
Direct History (with multiple possible IDs): DELETE /users/me/history
{
"type": "movie",
"ids": ["imdb:tt1234567", "trakt:12345", "tmdb:550"]
}The delete endpoints accept multiple IDs because the watched entry could have been created with any of the available external IDs. The server will try to delete entries matching any of the provided IDs.
// 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
}
});Since SSE events use external IDs, you need to match them against your local content's external IDs:
interface LocalMovie {
id: string; // Local database ID
imdb?: string; // "tt1234567"
trakt?: number; // 12345
tmdb?: number; // 550
}
// For Watched events (single ID)
function isMatchingWatchedEvent(movie: LocalMovie, eventId: string): boolean {
const [provider, value] = eventId.split(':');
switch (provider) {
case 'imdb': return movie.imdb === value;
case 'trakt': return movie.trakt?.toString() === value;
case 'tmdb': return movie.tmdb?.toString() === value;
default: return false;
}
}
// For Unwatched events (array of IDs)
function isMatchingUnwatchedEvent(movie: LocalMovie, eventIds: string[]): boolean {
return eventIds.some(eventId => isMatchingWatchedEvent(movie, eventId));
}When clients are offline or disconnected from SSE, they can miss unwatched events. The REST API provides a mechanism to sync these missed deletions.
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.
// 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();| 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) |
[
{
"type": "movie",
"id": "imdb:tt1234567",
"userRef": "user123",
"date": 1705766400000,
"modified": 1705852800000
},
{
"type": "movie",
"id": "trakt:98765",
"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