Search's instant leg read only downloaded items, so with no downloads it returned nothing and every keystroke fell through to a full Recursive=true server query. It now reads the whole synced catalog through the same availability CTE get_items uses, gated on the same include_catalog_browse flag so search and browse cannot diverge. (UR-065, DR-108) Also fixes three defects found while confirming that: - items_fts grew by a full duplicate index every catalog pass. INSERT OR REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement took a fresh rowid and inserted a second entry. Now a real upsert, with migration 021 rebuilding existing indexes. (DR-110) - DELETE FROM items existed nowhere, so server-side deletions never propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types, skipping downloaded items, and refusing to run after a partial crawl because items.parent_id cascades. (DR-110) - The index omitted MusicArtist, Playlist and People, which search groups results by. Adds them plus people_fts (migration 022). (DR-111) Re-indexing moves from a frontend startup call to a Rust background task with a 6h TTL, so a long session no longer searches a stale catalog and a restart no longer forces a crawl regardless of freshness. (DR-109, IR-030) Downloads gain a lifetime tier. Eviction selected every completed row by age with no download_source filter, so hitting the storage limit deleted the oldest download -- typically one saved deliberately for offline -- to make room for a precached track. It now reclaims only 'auto' rows, and expired ones are reclaimed first, before live cache is evicted. (DR-126, DR-127) Downloaded video and audio-only handoffs now play from disk instead of streaming; the video path had never consulted downloads at all. No transcode is involved: MPV runs video=no and ExoPlayer has no surface for an Audio item. (DR-123 in part, DR-128) FTS queries are built as quoted phrases so apostrophes, hyphens and slashes are data rather than operator syntax, and the item-type filter is bound rather than interpolated. Specs: docs/specs/catalog-index-search.md, docs/specs/read-through-media-cache.md Includes concurrently-developed favourites browsing and background-audio stream-end handling; the two workstreams share offline.rs, lib.rs and online.rs, so no subset of files builds independently.
381 lines
13 KiB
TypeScript
381 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";
|
|
|
|
/**
|
|
* 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> {
|
|
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<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);
|
|
}
|
|
|
|
async getVideoStreamUrl(
|
|
itemId: string,
|
|
mediaSourceId?: string,
|
|
startTimeSeconds?: number,
|
|
audioStreamIndex?: number
|
|
): Promise<string> {
|
|
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<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;
|
|
}
|
|
}
|