Files
jellytau/src/lib/api/repository-client.ts
T
dtourolle 58f2506966
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
Opening a series dumped the viewer at the top of season 1, and its Play
button played nothing at all: it resolved `$libraryItems[0]` — the first
*season* by SortName — and navigated to `/player/<seasonId>`, which the
player route bounced straight back to `/library/<seasonId>`.

The backend could already answer "where is this viewer in this show":
`repository_get_next_up_episodes` has accepted a `series_id` since it was
written and no caller had ever passed one.

Backend (DR-101, DR-106)
- `repository/series_progress.rs`: `pick_current_episode` — in progress,
  else Next Up, else first unwatched, else the premiere. The third rung is
  the offline path, where Next Up is always empty. `sort_series_order` puts
  specials (season 0) after the numbered seasons.
- `repository_get_series_episodes` takes over the season fan-out and the
  flat-series fallback, which were domain knowledge living in the frontend.
- `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a
  container, also zeroes resume). Offline it refuses rather than diverging
  state the next sync would undo.

Frontend (DR-102, DR-103, DR-104, DR-107)
- Seasons collapse; only the current one is expanded, and the current
  episode is badged and scrolled into view.
- Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's
  focus view, where Play commits (ux-flows §5B.5).
- Seasons are no longer a destination: `/library/<seasonId>` redirects to
  `/library/<seriesId>#season-N`, and every inbound link follows.
- The "More Episodes" strip spans the whole series, so a season finale
  offers the next premiere instead of dead-ending (§5B.2).
- Clear-history buttons on the series hero and each season header.

Routes (DR-105)
- `/library/tv` and `/library/movies` absorb their all-titles and genres
  pages as `?view=` tabs; the four legacy routes redirect. 6 video routes
  become 2, and `/library/shows/genres` stops being the odd one out.

Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and
`libraryView.ts` so it is unit-tested rather than buried in components.
Spec: docs/specs/series-current-episode-navigation.md
2026-08-03 20:37:43 +02:00

367 lines
12 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 } 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);
}
// ===== 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;
}
}