// 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"; /** * 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 { console.log("[RepositoryClient] Creating Rust repository..."); this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId); // Store for URL construction this._serverUrl = serverUrl; this._accessToken = accessToken; console.log("[RepositoryClient] 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 { 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 { return commands.repositoryGetLibraries(this.ensureHandle()); } async getItems(parentId: string, options?: GetItemsOptions): Promise { return commands.repositoryGetItems(this.ensureHandle(), parentId, options ?? null); } async getItem(itemId: string): Promise { 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 { 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 { 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 { 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 { return commands.repositoryJrayActorsAt(this.ensureHandle(), itemId, t); } async getLatestItems(parentId: string, limit?: number): Promise { return commands.repositoryGetLatestItems(this.ensureHandle(), parentId, limit ?? null); } async getResumeItems(parentId?: string, limit?: number): Promise { return commands.repositoryGetResumeItems(this.ensureHandle(), parentId ?? null, limit ?? null); } async getNextUpEpisodes(seriesId?: string, limit?: number): Promise { 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 { 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 { 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 { await commands.repositoryClearWatchHistory(this.ensureHandle(), itemId); } async getRecentlyPlayedAudio(limit?: number): Promise { return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null); } async getResumeMovies(limit?: number): Promise { 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 { return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null); } async getGenres(parentId?: string): Promise { return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null); } async search(query: string, options?: SearchOptions, requestId = 0): Promise { return commands.repositorySearch(this.ensureHandle(), query, options ?? null, requestId); } // ===== Playback Methods (all via Rust) ===== async getPlaybackInfo(itemId: string): Promise { return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId); } async reportPlaybackStart(itemId: string, positionMs: number): Promise { await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionMs); } async reportPlaybackProgress(itemId: string, positionMs: number): Promise { await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionMs); } async reportPlaybackStopped(itemId: string, positionMs: number): Promise { await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionMs); } // ===== Stream URL Methods (via Rust) ===== async getAudioStreamUrl(itemId: string): Promise { return commands.repositoryGetAudioStreamUrl(this.ensureHandle(), itemId); } async getVideoStreamUrl( itemId: string, mediaSourceId?: string, startTimeSeconds?: number, audioStreamIndex?: number ): Promise { return commands.repositoryGetVideoStreamUrl( this.ensureHandle(), itemId, mediaSourceId ?? null, startTimeSeconds ?? 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 { return commands.repositoryGetAudioOnlyStreamUrlForVideo( this.ensureHandle(), itemId, mediaSourceId ?? null, startTimeSeconds ?? null, audioStreamIndex ?? null ); } // ===== Live TV / Channels ===== /** Browse Live TV channels (broadcast / IPTV). */ async getLiveTvChannels(): Promise { return commands.repositoryGetLiveTvChannels(this.ensureHandle()); } /** Browse the root list of plugin "Channels". Drill-down uses getItems(channelId). */ async getChannels(): Promise { return commands.repositoryGetChannels(this.ensureHandle()); } /** Open a live stream for a Live TV channel / live item before HLS playback. */ async openLiveStream(itemId: string): Promise { 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 { 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 { 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 { return commands.repositoryGetVideoDownloadUrl(this.ensureHandle(), itemId, quality, mediaSourceId ?? null); } // ===== Favorite Methods (via Rust) ===== async markFavorite(itemId: string): Promise { await commands.repositoryMarkFavorite(this.ensureHandle(), itemId); } async unmarkFavorite(itemId: string): Promise { 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 { return commands.repositoryGetFavorites(this.ensureHandle(), scope, options ?? null); } // ===== Person Methods (via Rust) ===== async getPerson(personId: string): Promise { return commands.repositoryGetPerson(this.ensureHandle(), personId); } async getItemsByPerson(personId: string, options?: GetItemsOptions): Promise { return commands.repositoryGetItemsByPerson(this.ensureHandle(), personId, options ?? null); } async getSimilarItems(itemId: string, limit?: number): Promise { return commands.repositoryGetSimilarItems(this.ensureHandle(), itemId, limit ?? null); } // ===== Playlist Methods (via Rust) ===== async createPlaylist(name: string, itemIds?: string[]): Promise { return commands.playlistCreate(this.ensureHandle(), name, itemIds ?? null); } async deletePlaylist(playlistId: string): Promise { await commands.playlistDelete(this.ensureHandle(), playlistId); } async renamePlaylist(playlistId: string, name: string): Promise { await commands.playlistRename(this.ensureHandle(), playlistId, name); } async getPlaylistItems(playlistId: string): Promise { return commands.playlistGetItems(this.ensureHandle(), playlistId); } async addToPlaylist(playlistId: string, itemIds: string[]): Promise { await commands.playlistAddItems(this.ensureHandle(), playlistId, itemIds); } async removeFromPlaylist(playlistId: string, entryIds: string[]): Promise { await commands.playlistRemoveItems(this.ensureHandle(), playlistId, entryIds); } async movePlaylistItem(playlistId: string, itemId: string, newIndex: number): Promise { 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; } }