Files
jellytau/src/lib/api/repository-client.ts
T
dtourolle c0c6c5023e
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m30s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
fix(player): resume a transcoded video by seeking, not by asking for a stream that starts mid-item
A resumed transcode played nothing at all: every segment came back 400, hls.js
exhausted its retries and gave up, while the same episode from the beginning was
fine.

Jellyfin builds each segment URI by echoing the master playlist's query string
into it, and its segment handler opens by rejecting any request carrying
StartTimeTicks > 0 (ArgumentException → 400). So one resume position on the
playlist is copied onto every hls1/main/N.ts and 400s all of them — the `> 0`
being exactly why starting from the beginning survived.

HLS does not need the parameter: a playlist spans the whole item and asking for
segment N *is* the seek. It is removed from the URL builder entirely rather than
conditionalised — the builder cannot know whether its response will be
segmented — and the position becomes a seek issued once the player has loaded.
The progressive /Audio/universal builder behind the background-audio handoff has
no segments and keeps its StartTimeTicks, which is why audio-only handoffs
resumed correctly and video ones did not.

Completing that across the boundary, since the URL no longer starts where the
caller asked:

- reloadSource(url, position) now means "reload and resume AT this absolute
  position": it seeks the element once the source is playable and clears the
  transcode offset to zero. It previously set the offset to the position and
  seeked nothing, which was correct only while the URL itself began there —
  left in place it would have shown 20:00 on the scrubber while the opening
  titles played, with no seek ever happening.
- The transcoded resume path in the player page collapses into the same
  "seek after load" branch direct streams already used.
- VideoPlayer's background-audio return does the same: no base, seek to the
  absolute position.
- The stale test asserting StartTimeTicks is present is rewritten to keep its
  other half (an HLS master playlist, never a progressive stream.mp4, carrying
  the chosen source and audio track).

TRACES: UR-004, UR-005, UR-019, UR-021, UR-074 | DR-181 | UT-182, UT-183
2026-08-16 11:08:42 +02:00

389 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);
}
/**
* 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;
}
}