mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
443 lines
14 KiB
TypeScript
443 lines
14 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 { DownloadDiskUsage, JRayActor, SearchScope, StreamSelection } from "./bindings";
|
|
import type { QualityPreset } from "./quality-presets";
|
|
import type {
|
|
Library,
|
|
MediaItem,
|
|
SearchResult,
|
|
GetItemsOptions,
|
|
SearchOptions,
|
|
PlaybackInfo,
|
|
LiveStreamInfo,
|
|
ImageType,
|
|
ImageOptions,
|
|
Genre,
|
|
PlaylistEntry,
|
|
PlaylistCreatedResult,
|
|
} from "./types";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("RepositoryClient");
|
|
|
|
/**
|
|
* 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> {
|
|
log.debug("Creating Rust repository...");
|
|
this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
|
|
|
|
// Store for URL construction
|
|
this._serverUrl = serverUrl;
|
|
this._accessToken = accessToken;
|
|
|
|
log.debug("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,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Decide what stream to play, and describe it.
|
|
*
|
|
* The playback counterpart to {@link getVideoStreamUrl}, which returns only a
|
|
* URL and therefore forces its caller to work out the rest. This returns the
|
|
* transport (so the player picks a loader from a tagged enum rather than by
|
|
* searching the URL for `.m3u8`), the playback kind (direct play / direct
|
|
* stream / transcode), and the quality ladder as it applies to this source.
|
|
*
|
|
* No position parameter, for the same reason as {@link getVideoStreamUrl}: a
|
|
* start position on an HLS playlist makes Jellyfin reject every segment behind
|
|
* it with `400` (DR-181). Resume by seeking once loaded.
|
|
*
|
|
* TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
|
|
*/
|
|
async getStreamSelection(
|
|
itemId: string,
|
|
mediaSourceId?: string | null,
|
|
audioStreamIndex?: number | null,
|
|
): Promise<StreamSelection> {
|
|
return commands.repositoryGetStreamSelection(
|
|
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;
|
|
}
|
|
}
|