Files
jellytau/src/lib/api/repository-client.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

415 lines
13 KiB
TypeScript

// Thin TypeScript wrapper for Rust repository implementation
// All API calls go through Tauri commands in src-tauri/src/commands/repository.rs
// NO direct HTTP calls - everything routes through Rust backend
import { commands } from "./bindings";
import type { JRayActor, DownloadDiskUsage, SearchScope } from "./bindings";
import type { QualityPreset } from "./quality-presets";
import type {
Library,
MediaItem,
SearchResult,
GetItemsOptions,
SearchOptions,
PlaybackInfo,
LiveStreamInfo,
ImageType,
ImageOptions,
Genre,
PlaylistEntry,
PlaylistCreatedResult,
} from "./types";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("RepositoryClient");
/**
* Repository client - thin wrapper over Rust HybridRepository
* Uses handle-based system: create() returns a UUID handle for all operations
*/
export class RepositoryClient {
private handle: string | null = null;
private _serverUrl: string | null = null;
private _accessToken: string | null = null;
/**
* Create a new repository instance in Rust
* Returns the repository handle for subsequent operations
*/
async create(
serverUrl: string,
userId: string,
accessToken: string,
serverId: string,
): Promise<string> {
log.debug("Creating Rust repository...");
this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
// Store for URL construction
this._serverUrl = serverUrl;
this._accessToken = accessToken;
log.debug("Repository created with handle:", this.handle);
return this.handle;
}
/**
* Destroy the repository instance in Rust
* Call this on logout or when switching servers
*/
async destroy(): Promise<void> {
if (this.handle) {
await commands.repositoryDestroy(this.handle);
this.handle = null;
this._serverUrl = null;
this._accessToken = null;
}
}
private ensureHandle(): string {
if (!this.handle) {
throw new Error("Repository not initialized - call create() first");
}
return this.handle;
}
/**
* Get the repository handle for passing to backend commands
*/
getHandle(): string {
return this.ensureHandle();
}
// ===== Library Methods (all via Rust) =====
async getLibraries(): Promise<Library[]> {
return commands.repositoryGetLibraries(this.ensureHandle());
}
async getItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult> {
return commands.repositoryGetItems(this.ensureHandle(), parentId, options ?? null);
}
async getItem(itemId: string): Promise<MediaItem> {
return commands.repositoryGetItem(this.ensureHandle(), itemId);
}
/**
* Downloaded-only browse: libraries that contain downloaded content.
* Never merges server results; an empty list is authoritative.
* TRACES: UR-055 | DR-082
*/
async getDownloadedLibraries(): Promise<Library[]> {
return commands.repositoryGetDownloadedLibraries(this.ensureHandle());
}
/**
* Downloaded-only browse: items under a container that are on the device.
* TRACES: UR-055 | DR-082, DR-083
*/
async getDownloadedItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult> {
return commands.repositoryGetDownloadedItems(this.ensureHandle(), parentId, options ?? null);
}
/**
* On-disk usage of downloaded content (device total + per-item/container bytes).
* TRACES: UR-056 | DR-085
*/
async getDownloadDiskUsage(): Promise<DownloadDiskUsage> {
return commands.repositoryGetDownloadDiskUsage(this.ensureHandle());
}
/**
* Query the optional JRay plugin for the actors on screen at time `t`
* (seconds) in an item. Resolves to an empty array when JRay isn't installed
* or has no data for the item.
*/
async jrayActorsAt(itemId: string, t: number): Promise<JRayActor[]> {
return commands.repositoryJrayActorsAt(this.ensureHandle(), itemId, t);
}
async getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetLatestItems(this.ensureHandle(), parentId, limit ?? null);
}
async getResumeItems(parentId?: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetResumeItems(this.ensureHandle(), parentId ?? null, limit ?? null);
}
async getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetNextUpEpisodes(
this.ensureHandle(),
seriesId ?? null,
limit ?? null,
);
}
/**
* Every episode of a series, across all seasons, already in series order.
* The backend owns the season fan-out and the flat-series fallback.
*
* TRACES: UR-062 | DR-101
*/
async getSeriesEpisodes(seriesId: string): Promise<MediaItem[]> {
return commands.repositoryGetSeriesEpisodes(this.ensureHandle(), seriesId);
}
/**
* The episode the viewer should land on when opening this series. `null` only
* when the series has no episodes.
*
* TRACES: UR-062 | DR-101
*/
async getSeriesCurrentEpisode(seriesId: string): Promise<MediaItem | null> {
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
}
/**
* Erase watch history for an item. On a series or season the server applies
* it to everything inside, so the container returns to "never watched".
* Requires the server — this fails offline rather than diverging local state.
*
* TRACES: UR-064 | DR-106
*/
async clearWatchHistory(itemId: string): Promise<void> {
await commands.repositoryClearWatchHistory(this.ensureHandle(), itemId);
}
async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null);
}
async getResumeMovies(limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetResumeMovies(this.ensureHandle(), limit ?? null);
}
/** Albums the user has played but not listened to recently ("rediscover"). */
async getRediscoverAlbums(parentId?: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetRediscoverAlbums(
this.ensureHandle(),
parentId ?? null,
limit ?? null,
);
}
async getGenres(parentId?: string): Promise<Genre[]> {
return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null);
}
async search(query: string, options?: SearchOptions, requestId = 0): Promise<SearchResult> {
return commands.repositorySearch(this.ensureHandle(), query, options ?? null, requestId);
}
// ===== Playback Methods (all via Rust) =====
async getPlaybackInfo(itemId: string): Promise<PlaybackInfo> {
return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId);
}
async reportPlaybackStart(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionMs);
}
async reportPlaybackProgress(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionMs);
}
async reportPlaybackStopped(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionMs);
}
// ===== Stream URL Methods (via Rust) =====
async getAudioStreamUrl(itemId: string): Promise<string> {
return commands.repositoryGetAudioStreamUrl(this.ensureHandle(), itemId);
}
/**
* A video stream URL, which always begins at the **start of the item**.
*
* There is deliberately no position parameter: the URL is an HLS playlist, and
* a start position on it makes Jellyfin reject every segment behind it with
* `400` (DR-181). Resume and transcoded seeking are performed by seeking the
* player once the stream has loaded.
*
* TRACES: UR-004 | DR-181 | UT-182
*/
async getVideoStreamUrl(
itemId: string,
mediaSourceId?: string,
audioStreamIndex?: number,
): Promise<string> {
return commands.repositoryGetVideoStreamUrl(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
audioStreamIndex ?? null,
);
}
/**
* Audio-only stream URL for a video item, for the background-audio handoff.
* The server extracts just the audio track — no video is decoded on-device.
* TRACES: UR-040 | JA-032
*/
async getAudioOnlyStreamUrlForVideo(
itemId: string,
mediaSourceId?: string,
startTimeSeconds?: number,
audioStreamIndex?: number,
): Promise<string> {
return commands.repositoryGetAudioOnlyStreamUrlForVideo(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
startTimeSeconds ?? null,
audioStreamIndex ?? null,
);
}
// ===== Live TV / Channels =====
/** Browse Live TV channels (broadcast / IPTV). */
async getLiveTvChannels(): Promise<MediaItem[]> {
return commands.repositoryGetLiveTvChannels(this.ensureHandle());
}
/** Browse the root list of plugin "Channels". Drill-down uses getItems(channelId). */
async getChannels(): Promise<SearchResult> {
return commands.repositoryGetChannels(this.ensureHandle());
}
/** Open a live stream for a Live TV channel / live item before HLS playback. */
async openLiveStream(itemId: string): Promise<LiveStreamInfo> {
return commands.repositoryOpenLiveStream(this.ensureHandle(), itemId);
}
// ===== URL Construction Methods (sync, no server call) =====
/**
* Get image URL from backend
* The Rust backend constructs and returns the URL with proper credentials handling
*/
async getImageUrl(
itemId: string,
imageType: ImageType = "Primary",
options?: ImageOptions,
): Promise<string> {
return commands.repositoryGetImageUrl(this.ensureHandle(), itemId, imageType, options ?? null);
}
/**
* Get subtitle URL from backend
* The Rust backend constructs and returns the URL with proper credentials handling
*/
async getSubtitleUrl(
itemId: string,
mediaSourceId: string,
streamIndex: number,
format: string = "vtt",
): Promise<string> {
return commands.repositoryGetSubtitleUrl(
this.ensureHandle(),
itemId,
mediaSourceId,
streamIndex,
format,
);
}
/**
* Get video download URL with quality preset from backend
* The Rust backend constructs and returns the URL with proper credentials handling
* Used for offline downloads and transcoding
*/
async getVideoDownloadUrl(
itemId: string,
quality: QualityPreset = "original",
mediaSourceId?: string,
): Promise<string> {
return commands.repositoryGetVideoDownloadUrl(
this.ensureHandle(),
itemId,
quality,
mediaSourceId ?? null,
);
}
// ===== Favorite Methods (via Rust) =====
async markFavorite(itemId: string): Promise<void> {
await commands.repositoryMarkFavorite(this.ensureHandle(), itemId);
}
async unmarkFavorite(itemId: string): Promise<void> {
await commands.repositoryUnmarkFavorite(this.ensureHandle(), itemId);
}
/**
* Everything favourited, across libraries, narrowed by an opaque scope the
* backend expands into item types. The frontend never names a Jellyfin type
* here — see docs/specs/scoped-search-boundary.md.
*
* Resolves with the local answer; a later `favorites-changed` event reports
* ids the server disagreed with.
*
* TRACES: UR-067 | DR-115
*/
async getFavorites(scope: SearchScope, options?: GetItemsOptions): Promise<SearchResult> {
return commands.repositoryGetFavorites(this.ensureHandle(), scope, options ?? null);
}
// ===== Person Methods (via Rust) =====
async getPerson(personId: string): Promise<MediaItem> {
return commands.repositoryGetPerson(this.ensureHandle(), personId);
}
async getItemsByPerson(personId: string, options?: GetItemsOptions): Promise<SearchResult> {
return commands.repositoryGetItemsByPerson(this.ensureHandle(), personId, options ?? null);
}
async getSimilarItems(itemId: string, limit?: number): Promise<SearchResult> {
return commands.repositoryGetSimilarItems(this.ensureHandle(), itemId, limit ?? null);
}
// ===== Playlist Methods (via Rust) =====
async createPlaylist(name: string, itemIds?: string[]): Promise<PlaylistCreatedResult> {
return commands.playlistCreate(this.ensureHandle(), name, itemIds ?? null);
}
async deletePlaylist(playlistId: string): Promise<void> {
await commands.playlistDelete(this.ensureHandle(), playlistId);
}
async renamePlaylist(playlistId: string, name: string): Promise<void> {
await commands.playlistRename(this.ensureHandle(), playlistId, name);
}
async getPlaylistItems(playlistId: string): Promise<PlaylistEntry[]> {
return commands.playlistGetItems(this.ensureHandle(), playlistId);
}
async addToPlaylist(playlistId: string, itemIds: string[]): Promise<void> {
await commands.playlistAddItems(this.ensureHandle(), playlistId, itemIds);
}
async removeFromPlaylist(playlistId: string, entryIds: string[]): Promise<void> {
await commands.playlistRemoveItems(this.ensureHandle(), playlistId, entryIds);
}
async movePlaylistItem(playlistId: string, itemId: string, newIndex: number): Promise<void> {
await commands.playlistMoveItem(this.ensureHandle(), playlistId, itemId, newIndex);
}
// ===== Getters =====
get serverUrl(): string {
if (!this._serverUrl) {
throw new Error("Repository not initialized - call create() first");
}
return this._serverUrl;
}
}