// 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 { invoke } from "@tauri-apps/api/core"; import type { QualityPreset } from "./quality-presets"; import { QUALITY_PRESETS } from "./quality-presets"; import { validateItemId, validateImageType, validateMediaSourceId, validateNumericParam, validateQueryParamValue } from "$lib/utils/validation"; import type { Library, MediaItem, SearchResult, GetItemsOptions, SearchOptions, PlaybackInfo, ImageType, ImageOptions, Genre, } 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 invoke("repository_create", { 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 invoke("repository_destroy", { handle: 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 invoke("repository_get_libraries", { handle: this.ensureHandle(), }); } async getItems(parentId: string, options?: GetItemsOptions): Promise { return invoke("repository_get_items", { handle: this.ensureHandle(), parentId, options: options ?? null, }); } async getItem(itemId: string): Promise { return invoke("repository_get_item", { handle: this.ensureHandle(), itemId, }); } async getLatestItems(parentId: string, limit?: number): Promise { return invoke("repository_get_latest_items", { handle: this.ensureHandle(), parentId, limit: limit ?? null, }); } async getResumeItems(parentId?: string, limit?: number): Promise { return invoke("repository_get_resume_items", { handle: this.ensureHandle(), parentId: parentId ?? null, limit: limit ?? null, }); } async getNextUpEpisodes(seriesId?: string, limit?: number): Promise { return invoke("repository_get_next_up_episodes", { handle: this.ensureHandle(), seriesId: seriesId ?? null, limit: limit ?? null, }); } async getRecentlyPlayedAudio(limit?: number): Promise { return invoke("repository_get_recently_played_audio", { handle: this.ensureHandle(), limit: limit ?? null, }); } async getResumeMovies(limit?: number): Promise { return invoke("repository_get_resume_movies", { handle: this.ensureHandle(), limit: limit ?? null, }); } async getGenres(parentId?: string): Promise { return invoke("repository_get_genres", { handle: this.ensureHandle(), parentId: parentId ?? null, }); } async search(query: string, options?: SearchOptions): Promise { return invoke("repository_search", { handle: this.ensureHandle(), query, options: options ?? null, }); } // ===== Playback Methods (all via Rust) ===== async getPlaybackInfo(itemId: string): Promise { return invoke("repository_get_playback_info", { handle: this.ensureHandle(), itemId, }); } async reportPlaybackStart(itemId: string, positionTicks: number): Promise { return invoke("repository_report_playback_start", { handle: this.ensureHandle(), itemId, positionTicks, }); } async reportPlaybackProgress(itemId: string, positionTicks: number): Promise { return invoke("repository_report_playback_progress", { handle: this.ensureHandle(), itemId, positionTicks, }); } async reportPlaybackStopped(itemId: string, positionTicks: number): Promise { return invoke("repository_report_playback_stopped", { handle: this.ensureHandle(), itemId, positionTicks, }); } // ===== Stream URL Methods (via Rust) ===== async getAudioStreamUrl(itemId: string): Promise { return invoke("repository_get_audio_stream_url", { handle: this.ensureHandle(), itemId, }); } async getVideoStreamUrl( itemId: string, mediaSourceId?: string, startTimeSeconds?: number, audioStreamIndex?: number ): Promise { return invoke("repository_get_video_stream_url", { handle: this.ensureHandle(), itemId, mediaSourceId: mediaSourceId ?? null, startTimeSeconds: startTimeSeconds ?? null, audioStreamIndex: audioStreamIndex ?? null, }); } // ===== 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 invoke("repository_get_image_url", { handle: this.ensureHandle(), itemId, imageType, options: options ?? null, }); } /** * Get subtitle URL - constructs URL synchronously (no server call) */ getSubtitleUrl(itemId: string, mediaSourceId: string, streamIndex: number, format: string = "vtt"): string { if (!this._serverUrl || !this._accessToken) { throw new Error("Repository not initialized - call create() first"); } // Validate inputs to prevent injection attacks validateItemId(itemId); validateMediaSourceId(mediaSourceId); const index = validateNumericParam(streamIndex, 0, 1000, "streamIndex"); // Validate format - only allow safe subtitle formats if (!/^[a-z]+$/.test(format)) { throw new Error("Invalid subtitle format"); } return `${this._serverUrl}/Videos/${itemId}/${mediaSourceId}/Subtitles/${index}/Stream.${format}?api_key=${this._accessToken}`; } /** * Get video download URL with quality preset - constructs URL synchronously * Used for offline downloads */ getVideoDownloadUrl( itemId: string, quality: QualityPreset = "original", mediaSourceId?: string ): string { if (!this._serverUrl || !this._accessToken) { throw new Error("Repository not initialized - call create() first"); } // Validate itemId and mediaSourceId validateItemId(itemId); if (mediaSourceId) { validateMediaSourceId(mediaSourceId); } const preset = QUALITY_PRESETS[quality]; if (quality === "original" || !preset.videoBitrate) { // Direct stream for original quality const params = new URLSearchParams({ api_key: this._accessToken, Static: "true", audioStreamIndex: "0", }); if (mediaSourceId) { params.append("MediaSourceId", mediaSourceId); } return `${this._serverUrl}/Videos/${itemId}/stream?${params.toString()}`; } // Transcoded download with quality preset const params = new URLSearchParams({ api_key: this._accessToken, DeviceId: localStorage.getItem("jellytau_device_id") || "jellytau", Container: "mp4", VideoCodec: "h264", AudioCodec: "aac", AudioStreamIndex: "0", VideoBitrate: preset.videoBitrate.toString(), AudioBitrate: preset.audioBitrate.toString(), MaxHeight: preset.maxHeight?.toString() ?? "", TranscodingMaxAudioChannels: "2", }); if (mediaSourceId) { params.append("MediaSourceId", mediaSourceId); } return `${this._serverUrl}/Videos/${itemId}/stream.mp4?${params.toString()}`; } // ===== Favorite Methods (via Rust) ===== async markFavorite(itemId: string): Promise { return invoke("repository_mark_favorite", { handle: this.ensureHandle(), itemId, }); } async unmarkFavorite(itemId: string): Promise { return invoke("repository_unmark_favorite", { handle: this.ensureHandle(), itemId, }); } // ===== Person Methods (via Rust) ===== async getPerson(personId: string): Promise { return invoke("repository_get_person", { handle: this.ensureHandle(), personId, }); } async getItemsByPerson(personId: string, options?: GetItemsOptions): Promise { return invoke("repository_get_items_by_person", { handle: this.ensureHandle(), personId, options: options ?? null, }); } async getSimilarItems(itemId: string, limit?: number): Promise { return invoke("repository_get_similar_items", { handle: this.ensureHandle(), itemId, limit: limit ?? null, }); } // ===== Getters ===== get serverUrl(): string { if (!this._serverUrl) { throw new Error("Repository not initialized - call create() first"); } return this._serverUrl; } }