Files
jellytau/src/lib/api/bindings.ts
T
dtourolle 11d9d760d8 feat(player): native video on Linux, and one contract for every player (v0.11.0)
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.
2026-08-23 10:51:45 +02:00

3672 lines
134 KiB
TypeScript

// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
/** user-defined commands **/
export const commands = {
/**
* Play a single media item (audio or video)
*
* Accepts a PlayItemRequest with all optional fields properly defaulted.
* This avoids Tauri's Android serialization issues with complex objects.
*
* @req: UR-003 - Play videos
* @req: UR-004 - Play audio uninterrupted
* @req: UR-005 - Control media playback (play operation)
* @req: DR-009 - Audio player UI
*/
async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_play_item", { item });
},
/**
* Exit background-audio mode: stop the native audio player and return its final
* position so the frontend can reload the WebView `<video>` there (UR-040).
*
* Returns the position in seconds. The sleep timer is intentionally left
* untouched — if it fired while backgrounded, playback is already stopped and
* this simply reports the last position.
*
* What playback should do now that the app is no longer visible.
*
* The caller supplies only what it alone knows -- whether the per-player
* toggle is armed, and whether Android put the window into picture-in-picture.
* Everything else (what is playing, and therefore whether there is a picture to
* lose) is read here, because it is domain state.
*
* The rule itself is in `player::background_policy`; this command is the wire.
* Returning `KeepPlaying` for an empty queue is deliberate: with nothing
* playing there is nothing to pause, and an error would make the frontend
* handle a case that is not a failure.
*
* TRACES: UR-040, UR-041 | DR-225 | UT-212
*/
async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture: boolean) : Promise<BackgroundAction> {
return await TAURI_INVOKE("player_background_action", { backgroundAudioArmed, inPictureInPicture });
},
/**
* Enter background-audio mode: hand playback of the currently-watched video off
* to the native ExoPlayer *audio* path so the audio keeps playing while the app
* is backgrounded/locked, with no client-side video decode (UR-040).
*
* `stream_url` MUST be an audio-only URL (see
* `get_audio_only_stream_url_for_video`). The item is created as
* `MediaType::Audio` so it starts an audio session and loads into the native
* backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
* frontend side, so exactly one audio source is ever active.
*
* This deliberately goes through the queue-based `play_item` path (NOT a
* side-channel) so end-of-track lands in `on_playback_ended`, which already
* honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
* The sleep-timer state is intentionally left untouched by the handoff.
*
* TRACES: UR-040 | DR-052 | UT-061, IT-013
*/
async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
},
/**
* TRACES: UR-040 | DR-052 | UT-061, IT-013
*/
async playerExitBackgroundAudio() : Promise<number> {
return await TAURI_INVOKE("player_exit_background_audio");
},
/**
* Play a queue of media items
*
* @req: UR-004 - Play audio uninterrupted
* @req: UR-005 - Control media playback (queue playback)
* @req: UR-015 - View and manage current audio queue
* @req: DR-005 - Queue manager with shuffle, repeat, history
*/
async playerPlayQueue(request: PlayQueueRequest) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_play_queue", { request });
},
/**
* Play a track from an album - backend fetches all album tracks and builds queue
*/
async playerPlayAlbumTrack(repositoryHandle: string, request: PlayAlbumTrackRequest) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_play_album_track", { repositoryHandle, request });
},
/**
* Play tracks by ID - backend fetches all metadata
*/
async playerPlayTracks(repositoryHandle: string, request: PlayTracksRequest) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_play_tracks", { repositoryHandle, request });
},
async playerPlay() : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_play");
},
async playerPause() : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_pause");
},
async playerToggle() : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_toggle");
},
async playerStop() : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_stop");
},
async playerNext() : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_next");
},
async playerPrevious() : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_previous");
},
async playerSeek(position: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_seek", { position });
},
/**
* Smart video seeking that decides between native and server-side seeking
*
* This command analyzes the current video stream and automatically chooses
* the best seeking strategy:
* - HLS streams: Use native seeking
* - Direct play streams: Use native seeking
* - Transcoded non-HLS: Request new stream URL from server starting at seek position
*
* For native (non-HTML5) backends, this command handles the entire stream reload
* internally. For HTML5 backends, it returns the new URL for the frontend to handle.
*/
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null, useHtml5: boolean) : Promise<VideoSeekResponse> {
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex, useHtml5 });
},
async playerSetVolume(volume: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_volume", { volume });
},
async playerToggleMute() : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_toggle_mute");
},
/**
* Set the active audio track on a native backend directly.
*
* TRACES: UR-021 | IR-019, DR-024
*/
async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_audio_track", { streamIndex });
},
/**
* Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
* Note: Frontend should handle saving series preferences after this command succeeds
*
* The split is the requirement: an HTML5 `<video>` element cannot be told to
* change audio track, so the stream is re-opened at the chosen
* `AudioStreamIndex` and the frontend seeks the reloaded element back to
* `position`; a native backend (ExoPlayer) switches in place by track-group
* index. libmpv implements neither — it is the audio-only backend here and
* leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
* which is why IR-019 is met by these two paths rather than by MPV.
*
* TRACES: UR-021 | IR-019, DR-024
*/
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId });
},
/**
* Set (or clear, with `None`) the active subtitle track on a native backend.
*
* On Android this indexes ExoPlayer's *text track groups* — i.e. the position
* of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
* index. The HTML5 path never reaches here; it toggles its own `<track>`
* children. libmpv implements neither, leaving the trait default in place.
*
* TRACES: UR-020 | IR-018, DR-023
*/
async playerSetSubtitleTrack(streamIndex: number | null) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_subtitle_track", { streamIndex });
},
async playerToggleShuffle() : Promise<QueueStatus> {
return await TAURI_INVOKE("player_toggle_shuffle");
},
async playerCycleRepeat() : Promise<QueueStatus> {
return await TAURI_INVOKE("player_cycle_repeat");
},
async playerGetStatus() : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_get_status");
},
async playerGetQueue() : Promise<QueueStatus> {
return await TAURI_INVOKE("player_get_queue");
},
/**
* Report this platform's playback capabilities to the frontend.
*
* TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
*/
async playerGetCapabilities() : Promise<PlaybackCapabilities> {
return await TAURI_INVOKE("player_get_capabilities");
},
async playerAddToQueue(request: AddToQueueRequest) : Promise<QueueStatus> {
return await TAURI_INVOKE("player_add_to_queue", { request });
},
/**
* Add a track to queue by ID - backend fetches metadata and constructs URLs
*/
async playerAddTrackById(repositoryHandle: string, request: AddTrackByIdRequest) : Promise<QueueStatus> {
return await TAURI_INVOKE("player_add_track_by_id", { repositoryHandle, request });
},
/**
* Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
*/
async playerAddTracksByIds(repositoryHandle: string, request: AddTracksByIdsRequest) : Promise<QueueStatus> {
return await TAURI_INVOKE("player_add_tracks_by_ids", { repositoryHandle, request });
},
async playerRemoveFromQueue(index: number) : Promise<QueueStatus> {
return await TAURI_INVOKE("player_remove_from_queue", { index });
},
async playerMoveInQueue(fromIndex: number, toIndex: number) : Promise<QueueStatus> {
return await TAURI_INVOKE("player_move_in_queue", { fromIndex, toIndex });
},
async playerSkipTo(index: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_skip_to", { index });
},
async playerSetAudioSettings(settings: AudioSettings) : Promise<AudioSettings> {
return await TAURI_INVOKE("player_set_audio_settings", { settings });
},
async playerGetAudioSettings() : Promise<AudioSettings> {
return await TAURI_INVOKE("player_get_audio_settings");
},
/**
* The built-in equalizer presets and their per-band gain curves (dB), for the
* settings UI. The curve numbers are domain data defined by the band layout,
* so the frontend reads them here rather than encoding them.
*
* TRACES: UR-027 | DR-030
*/
async playerGetEqPresets() : Promise<([EqPreset, number[]])[]> {
return await TAURI_INVOKE("player_get_eq_presets");
},
async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
return await TAURI_INVOKE("player_set_video_settings", { settings });
},
async playerGetVideoSettings() : Promise<VideoSettings> {
return await TAURI_INVOKE("player_get_video_settings");
},
/**
* The bandwidth ceilings the quality picker may offer, each with the label and
* one-line detail to show for it, highest first.
*
* The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
* frontend reads them here rather than encoding them — the same arrangement as
* [`player_get_eq_presets`].
*
* TRACES: UR-074 | DR-162
*/
async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string])[]> {
return await TAURI_INVOKE("player_get_streaming_qualities");
},
/**
* Change the bandwidth ceiling of the video that is playing *right now*.
*
* A cap is a property of the stream the server is producing, so unlike a volume
* change it cannot be applied to a stream already in flight — the stream has to
* be re-opened at the new quality and resumed at the current position. That is
* the same reload the transcoded-seek and audio-track paths use, and the same
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
* native backend is reloaded here.
*
* The change applies to **this playback only**. The in-player picker is a
* "this film, this connection" control and its doc has always said so, but it
* used to be implemented by writing the process-wide ceiling — so choosing
* 2 Mbps to get one awkward film moving silently capped every video played
* afterwards for the rest of the process, with the Settings screen still
* showing the old value and nothing in the UI admitting the change. It now
* sets a per-playback override that the next item clears; the durable default
* belongs to Settings, and `player_set_video_settings` is the one that writes
* to the database.
*
* TRACES: UR-074, UR-079 | DR-162, DR-226
*/
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
},
/**
* Set sleep timer mode
*/
async playerSetSleepTimer(mode: SleepTimerMode) : Promise<SleepTimerState> {
return await TAURI_INVOKE("player_set_sleep_timer", { mode });
},
/**
* Cancel sleep timer
*/
async playerCancelSleepTimer() : Promise<SleepTimerState> {
return await TAURI_INVOKE("player_cancel_sleep_timer");
},
/**
* Get current sleep timer state
*/
async playerGetSleepTimer() : Promise<SleepTimerState> {
return await TAURI_INVOKE("player_get_sleep_timer");
},
/**
* Get autoplay settings
*/
async playerGetAutoplaySettings() : Promise<AutoplaySettings> {
return await TAURI_INVOKE("player_get_autoplay_settings");
},
/**
* Set autoplay settings and persist to database
*/
async playerSetAutoplaySettings(userId: string, settings: AutoplaySettings) : Promise<AutoplaySettings> {
return await TAURI_INVOKE("player_set_autoplay_settings", { userId, settings });
},
/**
* Cancel active autoplay countdown
*/
async playerCancelAutoplayCountdown() : Promise<null> {
return await TAURI_INVOKE("player_cancel_autoplay_countdown");
},
/**
* Play next episode (user confirmed from popup)
*/
async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_play_next_episode", { item });
},
/**
* Handle playback ended event - triggers autoplay decision logic
* This is called from:
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
* - Android JNI callback also triggers this logic directly
*
* TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
*/
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
},
/**
* Try to recover playback after a **recoverable** player error, reporting
* whether it was handled.
*
* The frontend's error handler stops the player, which is right for a real
* failure and wrong for a network blip — it turned every hiccup into "playback
* died". This is the echo path for backends that cannot decide in-process:
* MpvBackend is constructed before `PlayerController` exists ([`lib.rs`]), so
* its event thread has no controller to ask. It emits the error, the frontend
* echoes it here, and the decision stays in Rust — the same shape as
* `PlaybackEnded` → `player_on_playback_ended`.
*
* Returns `true` when the stream was re-opened and the caller must NOT stop the
* player; `false` when the error is real and should be surfaced as before.
* Android decides inside its JNI callback and only emits errors it has already
* declined to recover, so this reports `false` for those without a second
* opinion — the shared attempt budget is spent by then either way.
*
* TRACES: UR-004, UR-040 | DR-130 | UT-117
*/
async playerRecoverStream() : Promise<boolean> {
return await TAURI_INVOKE("player_recover_stream");
},
/**
* Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
*/
async playerReportState(state: string, mediaId: string | null) : Promise<null> {
return await TAURI_INVOKE("player_report_state", { state, mediaId });
},
/**
* Report an HTML5 <video> position tick (seconds). The adapter should throttle
* these to roughly match the native backends' ~250ms cadence.
*/
async playerReportPosition(position: number, duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_position", { position, duration });
},
/**
* Report that the HTML5 <video> finished loading and knows its duration.
*/
async playerReportMediaLoaded(duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_media_loaded", { duration });
},
/**
* The on-disk path for a downloaded item, for playback surfaces that resolve
* their own source rather than going through the queue.
*
* The video player is the reason this exists: audio has preferred local files
* since queue construction, but video asks the repository for a stream URL and
* never consults `downloads`, so a downloaded film was still streamed — costing
* bandwidth that had already been spent and failing outright when offline.
*
* Returns `None` when nothing is downloaded *or* the file is missing, so the
* caller falls back to streaming.
*
* TRACES: UR-071 | DR-123 | UT-116
*/
async playerLocalMediaPath(itemId: string) : Promise<string | null> {
return await TAURI_INVOKE("player_local_media_path", { itemId });
},
/**
* Preload upcoming tracks from the queue
* This queues background downloads for the next N tracks that aren't already downloaded
*/
async playerPreloadUpcoming(userId: string, downloadBasePath: string) : Promise<PreloadResult> {
return await TAURI_INVOKE("player_preload_upcoming", { userId, downloadBasePath });
},
/**
* Update SmartCache configuration
*/
async playerSetCacheConfig(config: CacheConfig) : Promise<null> {
return await TAURI_INVOKE("player_set_cache_config", { config });
},
/**
* Get current SmartCache configuration
*/
async playerGetCacheConfig() : Promise<CacheConfig> {
return await TAURI_INVOKE("player_get_cache_config");
},
/**
* Configure Jellyfin API client for automatic playback reporting
*/
async playerConfigureJellyfin(serverUrl: string, accessToken: string, userId: string, deviceId: string) : Promise<null> {
return await TAURI_INVOKE("player_configure_jellyfin", { serverUrl, accessToken, userId, deviceId });
},
/**
* Disable Jellyfin automatic playback reporting
*/
async playerDisableJellyfin() : Promise<null> {
return await TAURI_INVOKE("player_disable_jellyfin");
},
/**
* Get the current media session state
*/
async playerGetSession() : Promise<MediaSessionType> {
return await TAURI_INVOKE("player_get_session");
},
/**
* Dismiss the current media session (returns to Idle)
*/
async playerDismissSession() : Promise<null> {
return await TAURI_INVOKE("player_dismiss_session");
},
/**
* Play items on a remote Jellyfin session (casting)
*/
async remotePlayOnSession(sessionId: string, itemIds: string[], startIndex: number) : Promise<null> {
return await TAURI_INVOKE("remote_play_on_session", { sessionId, itemIds, startIndex });
},
/**
* Send a playback command to a remote session
*/
async remoteSendCommand(sessionId: string, command: string) : Promise<null> {
return await TAURI_INVOKE("remote_send_command", { sessionId, command });
},
/**
* Seek on a remote session
*/
async remoteSessionSeek(sessionId: string, positionTicks: number) : Promise<null> {
return await TAURI_INVOKE("remote_session_seek", { sessionId, positionTicks });
},
/**
* Set volume on a remote session
*/
async remoteSessionSetVolume(sessionId: string, volume: number) : Promise<null> {
return await TAURI_INVOKE("remote_session_set_volume", { sessionId, volume });
},
/**
* Toggle mute on a remote session
*/
async remoteSessionToggleMute(sessionId: string) : Promise<null> {
return await TAURI_INVOKE("remote_session_toggle_mute", { sessionId });
},
/**
* List current LMS sync groups.
*/
async lmsGetSyncGroups() : Promise<LmsSyncGroup[]> {
return await TAURI_INVOKE("lms_get_sync_groups");
},
/**
* Fuse LMS zones into a sync group. `master_mac` keeps playing and the
* `slave_macs` zones join it in sync.
*/
async lmsCreateSyncGroup(masterMac: string, slaveMacs: string[]) : Promise<null> {
return await TAURI_INVOKE("lms_create_sync_group", { masterMac, slaveMacs });
},
/**
* Remove a single LMS zone from its sync group (decouple one player).
*/
async lmsUnsyncPlayer(mac: string) : Promise<null> {
return await TAURI_INVOKE("lms_unsync_player", { mac });
},
/**
* Dissolve an entire LMS sync group, identified by its master's MAC.
*/
async lmsDissolveSyncGroup(masterMac: string) : Promise<null> {
return await TAURI_INVOKE("lms_dissolve_sync_group", { masterMac });
},
/**
* Set polling frequency hint based on UI state
*/
async sessionsSetPollingHint(hint: string) : Promise<null> {
return await TAURI_INVOKE("sessions_set_polling_hint", { hint });
},
/**
* Manually trigger a session poll (for refresh button)
*/
async sessionsPollNow() : Promise<SessionInfo[]> {
return await TAURI_INVOKE("sessions_poll_now");
},
/**
* Get the current playback mode
*/
async playbackModeGetCurrent() : Promise<PlaybackMode> {
return await TAURI_INVOKE("playback_mode_get_current");
},
/**
* Set the playback mode (internal/testing use)
*/
async playbackModeSet(mode: PlaybackMode) : Promise<null> {
return await TAURI_INVOKE("playback_mode_set", { mode });
},
/**
* Check if currently transferring between playback modes
*/
async playbackModeIsTransferring() : Promise<boolean> {
return await TAURI_INVOKE("playback_mode_is_transferring");
},
/**
* Transfer playback from local device to a remote Jellyfin session
*/
async playbackModeTransferToRemote(sessionId: string, position: number | null) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId, position });
},
/**
* Get remote session status (for polling position/duration)
*/
async playbackModeGetRemoteStatus() : Promise<RemoteSessionStatus> {
return await TAURI_INVOKE("playback_mode_get_remote_status");
},
/**
* Transfer playback from remote session back to local device
*
* Parameters:
* - current_item_id: The Jellyfin item ID currently playing on remote
* - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
*/
async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_local", { currentItemId, positionTicks });
},
/**
* Set the transferring flag on the playback mode manager.
*
* Used by the frontend remote->local flow to mark the whole two-step sequence
* as a transfer, so `player_play_tracks` starts LOCAL playback instead of
* casting back to the remote session it's leaving. Always pair `true` with a
* later `false` (including on error) so the flag can't stick.
*/
async playbackModeSetTransferring(transferring: boolean) : Promise<null> {
return await TAURI_INVOKE("playback_mode_set_transferring", { transferring });
},
/**
* Initialize playback reporter (called after login)
*/
async playbackReporterInit(serverUrl: string, userId: string, accessToken: string, deviceId: string) : Promise<null> {
return await TAURI_INVOKE("playback_reporter_init", { serverUrl, userId, accessToken, deviceId });
},
/**
* Destroy playback reporter (called on logout)
*/
async playbackReporterDestroy() : Promise<null> {
return await TAURI_INVOKE("playback_reporter_destroy");
},
/**
* Report playback start
*/
async playbackReportStart(itemId: string, positionSeconds: number, contextType: string | null, contextId: string | null) : Promise<null> {
return await TAURI_INVOKE("playback_report_start", { itemId, positionSeconds, contextType, contextId });
},
/**
* Report playback progress
*/
async playbackReportProgress(itemId: string, positionSeconds: number, isPaused: boolean) : Promise<null> {
return await TAURI_INVOKE("playback_report_progress", { itemId, positionSeconds, isPaused });
},
/**
* Report playback stopped
*/
async playbackReportStopped(itemId: string, positionSeconds: number) : Promise<null> {
return await TAURI_INVOKE("playback_report_stopped", { itemId, positionSeconds });
},
/**
* Mark item as played
*/
async playbackMarkPlayed(itemId: string) : Promise<null> {
return await TAURI_INVOKE("playback_mark_played", { itemId });
},
/**
* Initialize the auth manager (call on app startup)
* Restores session from storage if available
*/
async authInitialize() : Promise<Session | null> {
return await TAURI_INVOKE("auth_initialize");
},
/**
* Connect to a Jellyfin server and get server info
*/
async authConnectToServer(serverUrl: string) : Promise<AuthServerInfo> {
return await TAURI_INVOKE("auth_connect_to_server", { serverUrl });
},
/**
* Login with username and password
*/
async authLogin(serverUrl: string, username: string, password: string, deviceId: string) : Promise<AuthResult> {
return await TAURI_INVOKE("auth_login", { serverUrl, username, password, deviceId });
},
/**
* Verify current session
*/
async authVerifySession(serverUrl: string, userId: string, accessToken: string, deviceId: string) : Promise<boolean> {
return await TAURI_INVOKE("auth_verify_session", { serverUrl, userId, accessToken, deviceId });
},
/**
* Logout (clear session and call Jellyfin logout endpoint)
*/
async authLogout(serverUrl: string, accessToken: string, deviceId: string) : Promise<null> {
return await TAURI_INVOKE("auth_logout", { serverUrl, accessToken, deviceId });
},
/**
* Get current session
*/
async authGetSession() : Promise<Session | null> {
return await TAURI_INVOKE("auth_get_session");
},
/**
* Set current session (for restoration from storage)
*/
async authSetSession(session: Session | null) : Promise<null> {
return await TAURI_INVOKE("auth_set_session", { session });
},
/**
* Start background session verification
*/
async authStartVerification(deviceId: string) : Promise<null> {
return await TAURI_INVOKE("auth_start_verification", { deviceId });
},
/**
* Stop background session verification
*/
async authStopVerification() : Promise<null> {
return await TAURI_INVOKE("auth_stop_verification");
},
/**
* Re-authenticate with password (when session expired)
*/
async authReauthenticate(password: string, deviceId: string) : Promise<AuthResult> {
return await TAURI_INVOKE("auth_reauthenticate", { password, deviceId });
},
/**
* Get or create the device ID.
* Device ID is a UUID v4 that persists across app restarts.
* On first call, generates and stores a new UUID.
* On subsequent calls, retrieves the stored UUID.
*
* # Returns
* - `Ok(String)` - The device ID (UUID v4)
* - `Err(String)` - If database operation fails
*
* TRACES: UR-009 | DR-011
*/
async deviceGetId() : Promise<string> {
return await TAURI_INVOKE("device_get_id");
},
/**
* Set the device ID (primarily for testing or recovery).
* Overwrites any existing device ID.
*
* # Arguments
* * `device_id` - The device ID to store (should be UUID v4 format)
*
* # Returns
* - `Ok(())` - If device ID was stored successfully
* - `Err(String)` - If database operation fails
*
* TRACES: UR-009 | DR-011
*/
async deviceSetId(deviceId: string) : Promise<null> {
return await TAURI_INVOKE("device_set_id", { deviceId });
},
/**
* Check if the server is currently reachable
*/
async connectivityCheckServer() : Promise<boolean> {
return await TAURI_INVOKE("connectivity_check_server");
},
/**
* Set the server URL and trigger an immediate check
*/
async connectivitySetServerUrl(url: string) : Promise<null> {
return await TAURI_INVOKE("connectivity_set_server_url", { url });
},
/**
* Get the current connectivity status
*/
async connectivityGetStatus() : Promise<ConnectivityStatus> {
return await TAURI_INVOKE("connectivity_get_status");
},
/**
* Start monitoring connectivity with adaptive polling
*/
async connectivityStartMonitoring() : Promise<null> {
return await TAURI_INVOKE("connectivity_start_monitoring");
},
/**
* Stop monitoring connectivity
*/
async connectivityStopMonitoring() : Promise<null> {
return await TAURI_INVOKE("connectivity_stop_monitoring");
},
/**
* Mark the server as reachable (called after successful API calls)
*/
async connectivityMarkReachable() : Promise<null> {
return await TAURI_INVOKE("connectivity_mark_reachable");
},
/**
* Mark the server as unreachable (called after failed API calls)
*/
async connectivityMarkUnreachable(error: string | null) : Promise<null> {
return await TAURI_INVOKE("connectivity_mark_unreachable", { error });
},
/**
* Initialize the database and run migrations
*/
async storageInit() : Promise<string> {
return await TAURI_INVOKE("storage_init");
},
/**
* Get storage directory path (parent directory of the database file)
*/
async storageGetPath() : Promise<string> {
return await TAURI_INVOKE("storage_get_path");
},
/**
* Get database file size in bytes
*/
async storageGetSize() : Promise<number | null> {
return await TAURI_INVOKE("storage_get_size");
},
/**
* Get security status (keyring vs encrypted file fallback)
*/
async storageGetSecurityStatus() : Promise<SecurityStatus> {
return await TAURI_INVOKE("storage_get_security_status");
},
/**
* Save a server connection
* Uses INSERT ... ON CONFLICT to avoid triggering CASCADE DELETE on users
*/
async storageSaveServer(id: string, name: string, url: string, version: string | null) : Promise<null> {
return await TAURI_INVOKE("storage_save_server", { id, name, url, version });
},
/**
* Get all saved servers
*/
async storageGetServers() : Promise<ServerInfo[]> {
return await TAURI_INVOKE("storage_get_servers");
},
/**
* Delete a server and all associated data
*/
async storageDeleteServer(serverId: string) : Promise<null> {
return await TAURI_INVOKE("storage_delete_server", { serverId });
},
/**
* Save a user account (token stored in secure storage, not database)
*/
async storageSaveUser(id: string, serverId: string, username: string, accessToken: string | null) : Promise<boolean> {
return await TAURI_INVOKE("storage_save_user", { id, serverId, username, accessToken });
},
/**
* Get users for a server
*/
async storageGetUsers(serverId: string) : Promise<UserInfo[]> {
return await TAURI_INVOKE("storage_get_users", { serverId });
},
/**
* Set a user as active (and deactivate all other users globally)
*/
async storageSetActiveUser(userId: string, serverId: string) : Promise<null> {
return await TAURI_INVOKE("storage_set_active_user", { userId, serverId });
},
/**
* Get the active user for a server
*/
async storageGetActiveUser(serverId: string) : Promise<UserInfo | null> {
return await TAURI_INVOKE("storage_get_active_user", { serverId });
},
/**
* Get the active session (user + server + token) for session restoration
*/
async storageGetActiveSession() : Promise<ActiveSession | null> {
return await TAURI_INVOKE("storage_get_active_session");
},
/**
* Get user's access token from secure storage
*/
async storageGetAccessToken(userId: string) : Promise<string | null> {
return await TAURI_INVOKE("storage_get_access_token", { userId });
},
/**
* Delete a user account and their token from secure storage
*/
async storageDeleteUser(userId: string) : Promise<null> {
return await TAURI_INVOKE("storage_delete_user", { userId });
},
/**
* Update playback progress in local database
* This stores the progress locally for offline access and "continue watching"
*/
async storageUpdatePlaybackProgress(userId: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionMs });
},
/**
* Update playback progress with context in local database
* This stores the progress along with playback context (container vs single)
*/
async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: number, contextType: string | null, contextId: string | null) : Promise<null> {
return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionMs, contextType, contextId });
},
/**
* Mark item as played in local database
*/
async storageMarkPlayed(userId: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("storage_mark_played", { userId, itemId });
},
/**
* Set the watched flag locally for an item **and everything inside it**.
*
* This backs the watched toggle, and is deliberately separate from
* [`storage_mark_played`] — which reports a single track/episode finishing and
* increments `play_count` — because the toggle has two directions and applies
* to containers.
*
* The recursion is what makes the toggle honest offline. Jellyfin applies
* `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
* online the server fixes up the children on the next read; with no server to
* ask, marking a season watched would otherwise tick the season and leave every
* episode inside it unwatched. Targets are drawn from `items` by the same link
* columns the rest of the offline layer uses, so an id that is not cached
* selects nothing and the statement is a no-op rather than a foreign-key error.
*
* Un-marking clears the resume position too, matching the server, so an item
* un-marked offline does not come back offering to resume from a position it is
* no longer meant to have.
*
* `pending_sync = 1` hands the rows to the sync drain.
*
* TRACES: UR-073 | DR-158
*/
async storageSetWatched(userId: string, itemId: string, watched: boolean) : Promise<null> {
return await TAURI_INVOKE("storage_set_watched", { userId, itemId, watched });
},
/**
* Get playback progress for an item
*/
async storageGetPlaybackProgress(userId: string, itemId: string) : Promise<PlaybackProgress | null> {
return await TAURI_INVOKE("storage_get_playback_progress", { userId, itemId });
},
/**
* Mark pending sync as completed for an item
*/
async storageMarkSynced(userId: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("storage_mark_synced", { userId, itemId });
},
/**
* Toggle favorite status for an item in local database
* This updates the is_favorite field and marks it for sync to Jellyfin
*/
async storageToggleFavorite(userId: string, itemId: string, isFavorite: boolean) : Promise<boolean> {
return await TAURI_INVOKE("storage_toggle_favorite", { userId, itemId, isFavorite });
},
/**
* Queue a media item for download
*/
async downloadItem(request: DownloadItemRequest) : Promise<number> {
return await TAURI_INVOKE("download_item", { request });
},
/**
* Queue and start a download in a single atomic operation
* This simplifies the frontend flow by combining multiple steps
*/
async downloadItemAndStart(request: DownloadItemAndStartRequest) : Promise<number> {
return await TAURI_INVOKE("download_item_and_start", { request });
},
/**
* Queue an entire album for download.
*
* Owns the whole operation: the album's track list comes from the server (the
* only place that knows all of it), every track is queued and linked to its
* album, each row's stream URL is resolved here, and the queue is pumped.
*
* The frontend used to do the second half — resolve one URL per track and pair
* it with the returned ids **by position**. That pairing had no basis: the ids
* came back in the backend's own order over a different set of rows, so
* whenever the two lists disagreed a row was handed another track's URL, and
* any track past the end of the shorter list was never started at all. Nothing
* crosses the boundary now except the album id.
*
* TRACES: UR-018, UR-055 | DR-173 | UT-170
*/
async downloadAlbum(handle: string, albumId: string, userId: string, basePath: string) : Promise<number[]> {
return await TAURI_INVOKE("download_album", { handle, albumId, userId, basePath });
},
/**
* Queue a video item (movie or episode) for download with quality preset
*/
async downloadVideo(request: DownloadVideoRequest) : Promise<number> {
return await TAURI_INVOKE("download_video", { request });
},
/**
* Queue all episodes of a series for download
*/
async downloadSeries(seriesId: string, seriesName: string, userId: string, basePath: string, qualityPreset: string | null) : Promise<number[]> {
return await TAURI_INVOKE("download_series", { seriesId, seriesName, userId, basePath, qualityPreset });
},
/**
* Queue all episodes of a specific season for download
*/
async downloadSeason(seasonId: string, seriesName: string, seasonName: string, seasonNumber: number, userId: string, basePath: string, qualityPreset: string | null) : Promise<number[]> {
return await TAURI_INVOKE("download_season", { seasonId, seriesName, seasonName, seasonNumber, userId, basePath, qualityPreset });
},
/**
* Get all downloads for a user, optionally filtered by status
*/
async getDownloads(userId: string, statusFilter: string[] | null) : Promise<DownloadsResponse> {
return await TAURI_INVOKE("get_downloads", { userId, statusFilter });
},
/**
* Pause a download.
*
* Writing `status = 'paused'` is only half of it, and used to be all of it: the
* streaming task knew nothing about the row and kept running, then overwrote it
* with `completed`/`failed` when it finished. The row flicked to "paused" and
* undid itself — the reported "pause does not work". Signalling the worker is
* what actually stops the bytes; it leaves the `.part` file in place so
* [`resume_download`] can continue from it.
*
* A queued (not yet started) download has no worker to signal, and the status
* write alone is enough — the pump skips anything that is not `pending`.
*
* TRACES: UR-055 | DR-168
*/
async pauseDownload(downloadId: number) : Promise<null> {
return await TAURI_INVOKE("pause_download", { downloadId });
},
/**
* Resume a paused download.
*
* Flipping the row back to `pending` is likewise not enough on its own: the
* pump is not a poller, it runs when something calls it, so a resumed download
* sat untouched until some unrelated event happened to pump the queue. That is
* the other half of "resume does not work".
*
* TRACES: UR-055 | DR-168
*/
async resumeDownload(downloadId: number) : Promise<null> {
return await TAURI_INVOKE("resume_download", { downloadId });
},
/**
* Cancel a download
*/
async cancelDownload(downloadId: number) : Promise<null> {
return await TAURI_INVOKE("cancel_download", { downloadId });
},
/**
* Delete a completed download
*/
async deleteDownload(downloadId: number) : Promise<null> {
return await TAURI_INVOKE("delete_download", { downloadId });
},
/**
* Delete all downloads for a user
*/
async deleteAllDownloads(userId: string) : Promise<number> {
return await TAURI_INVOKE("delete_all_downloads", { userId });
},
/**
* Delete all downloads for a specific album
*/
async deleteAlbumDownloads(albumId: string, userId: string) : Promise<number> {
return await TAURI_INVOKE("delete_album_downloads", { albumId, userId });
},
/**
* Remove every completed download at or under a container item.
*
* Works at any level of the Downloaded browse: a leaf (removes just that
* download), an album/season/series (removes all downloaded descendants linked
* via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
* on-disk files. Returns the number of downloads removed. Idempotent.
*
* TRACES: UR-055 | DR-083
*/
async deleteDownloadsUnder(itemId: string, userId: string) : Promise<number> {
return await TAURI_INVOKE("delete_downloads_under", { itemId, userId });
},
/**
* Clear all stale pending/failed/paused downloads
*/
async clearStaleDownloads(userId: string) : Promise<number> {
return await TAURI_INVOKE("clear_stale_downloads", { userId });
},
/**
* Get storage statistics for downloads
*/
async getDownloadStorageStats(userId: string) : Promise<StorageStats> {
return await TAURI_INVOKE("get_download_storage_stats", { userId });
},
/**
* Mark a download as completed
*/
async markDownloadCompleted(downloadId: number, bytesDownloaded: number, filePath: string) : Promise<null> {
return await TAURI_INVOKE("mark_download_completed", { downloadId, bytesDownloaded, filePath });
},
/**
* Mark a download as failed
*/
async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<null> {
return await TAURI_INVOKE("mark_download_failed", { downloadId, errorMessage });
},
/**
* A playable URL for a downloaded file on disk.
*
* Local media is served over a loopback HTTP server rather than handed to the
* webview as a `file://`/asset URL, because the asset protocol cannot stream a
* large file — it answers a range-less request with the whole thing, which
* Chromium abandons. See `media_server` for why real HTTP is used.
*
* The returned URL carries the server's per-session token, so it is only valid
* for this run of the app and must not be persisted.
*
* TRACES: UR-071 | DR-137
*/
async mediaLocalUrl(path: string) : Promise<string> {
return await TAURI_INVOKE("media_local_url", { path });
},
/**
* The stream selection for a downloaded file.
*
* The local-playback counterpart to `repository_get_stream_selection`. A file
* on disk needs no negotiation — it is a direct play over a local transport,
* with no quality ladder, because nothing about it can be re-negotiated — but
* the *frontend must not be the one to say so*. It gets the same
* [`StreamSelection`] shape as a streamed source so the player has one contract
* to consume rather than two, and so no caller has to infer a transport from a
* loopback URL.
*
* TRACES: UR-071, UR-079 | DR-225
*/
async mediaLocalSelection(path: string) : Promise<StreamSelection> {
return await TAURI_INVOKE("media_local_selection", { path });
},
/**
* Start downloading a file immediately
* This command actually downloads the file using the worker
*/
async startDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
return await TAURI_INVOKE("start_download", { downloadId, streamUrl, targetDir });
},
/**
* Enqueue a download with its resolved stream URL, then let the queue pump
* start it (or a higher-priority pending item) when a slot is free.
*
* Unlike [`start_download`], this never errors when the concurrency limit is
* reached: the URL is persisted on the row and the pump will pick it up once a
* slot frees. This is the path bulk operations (album/series/season) use so
* every queued item eventually downloads without the frontend re-issuing it.
*/
async enqueueDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
return await TAURI_INVOKE("enqueue_download", { downloadId, streamUrl, targetDir });
},
/**
* Enqueue a batch of already-queued video downloads, resolving each one's
* transcode URL from the repository using the `quality_preset` stored on the
* row. Then let the pump start them subject to the concurrency limit.
*
* This is the bulk video path (series/season): `download_series`/
* `download_season` insert the rows, then this resolves URLs and enqueues them
* so they actually start. Resolving server-side avoids round-tripping every
* episode URL through the frontend.
*/
async enqueueVideoDownloads(handle: string, downloadIds: number[], targetDir: string) : Promise<null> {
return await TAURI_INVOKE("enqueue_video_downloads", { handle, downloadIds, targetDir });
},
/**
* Walk every library on the server and persist all items to the offline cache
* so the full catalog is browsable offline (greyed out when not downloaded).
*
* Best-effort: a library that fails to fetch is counted and skipped rather than
* aborting the whole sync. Runs libraries sequentially to avoid hammering the
* server. Uses `Recursive=true` so a single request per library returns the
* containers and their playable children.
*/
async syncFullCatalog(handle: string) : Promise<CatalogSyncResult> {
return await TAURI_INVOKE("sync_full_catalog", { handle });
},
/**
* Report the last-synced timestamp so the UI can show a hint / decide whether
* to trigger a fresh sync.
*/
async catalogSyncStatus() : Promise<CatalogSyncStatus> {
return await TAURI_INVOKE("catalog_sync_status");
},
/**
* Control whether offline library queries reveal the full synced catalog
* (greyed-out, non-downloaded media) or only downloaded/local media.
*
* The frontend calls this from the "Show all server media" toggle: pass `true`
* when online, or when offline with the toggle on; pass `false` when offline
* with the toggle off so library pages show downloaded media only. Fixes the
* bug where offline library pages showed every server item regardless of the
* toggle.
*/
async setShowServerCatalog(show: boolean) : Promise<void> {
await TAURI_INVOKE("set_show_server_catalog", { show });
},
/**
* The library preferences currently in force.
*
* Read from the in-memory exclusion set rather than the database: that set is
* what queries actually consult, so reading it is the only answer that cannot
* disagree with what the user is seeing.
*
* TRACES: UR-076 | DR-209
*/
async libraryGetSettings() : Promise<LibrarySettings> {
return await TAURI_INVOKE("library_get_settings");
},
/**
* Replace the library preferences: apply them to every subsequent query and
* persist them.
*
* Returns the sanitised value actually applied, so the picker shows what was
* stored rather than what it sent.
*
* TRACES: UR-076 | DR-209
*/
async librarySetSettings(settings: LibrarySettings) : Promise<LibrarySettings> {
return await TAURI_INVOKE("library_set_settings", { settings });
},
/**
* The folders the user may choose to hide.
*
* Offers each music library and the folders directly inside it. Music is the
* only scope offered because it is the one where a foreign folder — podcasts,
* audiobooks, sound effects — routinely shares a library with the media the
* user actually browses; the scope is decided here rather than in the UI so the
* collection-type table stays out of the frontend
* (see `SearchScope::for_collection_type`).
*
* Reads through `HybridRepository::get_items_unfiltered` so folders that are
* *already* hidden still appear — otherwise the setting could never be undone.
*
* TRACES: UR-076 | DR-209
*/
async libraryGetExclusionCandidates(handle: string) : Promise<ExclusionCandidate[]> {
return await TAURI_INVOKE("library_get_exclusion_candidates", { handle });
},
/**
* Resolve the stream URL for every download row that was queued while offline
* (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
* start. Call this on reconnect.
*
* Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
* 'video') via the pure `get_video_download_url` builder using the row's stored
* `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't
* be resolved are left pending (they retry on the next reconnect).
*/
async resumeQueuedDownloads(handle: string) : Promise<ResumeQueuedResult> {
return await TAURI_INVOKE("resume_queued_downloads", { handle });
},
/**
* Get download manager statistics
*/
async getDownloadManagerStats() : Promise<DownloadManagerStats> {
return await TAURI_INVOKE("get_download_manager_stats");
},
/**
* Set the maximum concurrent downloads
*/
async setMaxConcurrentDownloads(max: number) : Promise<null> {
return await TAURI_INVOKE("set_max_concurrent_downloads", { max });
},
/**
* Get SmartCache statistics
*/
async getSmartCacheStats(userId: string) : Promise<SmartCacheStats> {
return await TAURI_INVOKE("get_smart_cache_stats", { userId });
},
/**
* Update SmartCache configuration
*/
async updateSmartCacheConfig(config: CacheConfig) : Promise<null> {
return await TAURI_INVOKE("update_smart_cache_config", { config });
},
/**
* Get SmartCache configuration
*/
async getSmartCacheConfig() : Promise<CacheConfig> {
return await TAURI_INVOKE("get_smart_cache_config");
},
/**
* Report the device's current network transport (Android → Rust).
*
* The frontend calls this on startup and whenever the native network callback
* fires. Updating to an acceptable network re-pumps the download queue, so a
* queue parked on "waiting for WiFi" drains itself without user action.
*
* TRACES: UR-053 | DR-074
*/
async setNetworkState(network: NetworkStateWrapperArg) : Promise<null> {
return await TAURI_INVOKE("set_network_state", { network });
},
/**
* Whether downloads are currently permitted by the WiFi-only gate.
*
* The downloads UI uses this to render "Waiting for WiFi" on pending rows
* rather than leaving them looking silently stuck.
*
* TRACES: UR-053 | DR-074
*/
async getDownloadsAllowed() : Promise<boolean> {
return await TAURI_INVOKE("get_downloads_allowed");
},
/**
* Get album recommendations based on play history
*/
async getAlbumRecommendations(userId: string) : Promise<AlbumRecommendation[]> {
return await TAURI_INVOKE("get_album_recommendations", { userId });
},
/**
* Get album affinity status for all tracked albums
* This shows the SmartCache's internal play history and threshold status
*/
async getAlbumAffinityStatus() : Promise<AlbumAffinityStatus[]> {
return await TAURI_INVOKE("get_album_affinity_status");
},
/**
* Pin an item's metadata (protects from cache clear)
*/
async pinItem(itemId: string) : Promise<null> {
return await TAURI_INVOKE("pin_item", { itemId });
},
/**
* Unpin an item's metadata
*/
async unpinItem(itemId: string) : Promise<null> {
return await TAURI_INVOKE("unpin_item", { itemId });
},
/**
* Check if an item is pinned
*/
async isItemPinned(itemId: string) : Promise<boolean> {
return await TAURI_INVOKE("is_item_pinned", { itemId });
},
/**
* Check if an item is available offline
*/
async offlineIsAvailable(itemId: string) : Promise<boolean> {
return await TAURI_INVOKE("offline_is_available", { itemId });
},
/**
* Get all offline items for a user
*/
async offlineGetItems(userId: string) : Promise<OfflineItem[]> {
return await TAURI_INVOKE("offline_get_items", { userId });
},
/**
* Search offline items
*/
async offlineSearch(userId: string, query: string) : Promise<OfflineItem[]> {
return await TAURI_INVOKE("offline_search", { userId, query });
},
/**
* Get cached libraries for a server
*/
async storageGetLibraries(serverId: string) : Promise<CachedLibrary[]> {
return await TAURI_INVOKE("storage_get_libraries", { serverId });
},
/**
* Get cached items with optional filtering
*/
async storageGetItems(serverId: string, parentId: string | null, libraryId: string | null, itemType: string | null, limit: number | null, offset: number | null) : Promise<CachedItem[]> {
return await TAURI_INVOKE("storage_get_items", { serverId, parentId, libraryId, itemType, limit, offset });
},
/**
* Get a single cached item by ID
*/
async storageGetItem(itemId: string) : Promise<CachedItem | null> {
return await TAURI_INVOKE("storage_get_item", { itemId });
},
/**
* Search cached items using FTS
*/
async storageSearchItems(serverId: string, query: string, limit: number | null) : Promise<CachedItem[]> {
return await TAURI_INVOKE("storage_search_items", { serverId, query, limit });
},
/**
* Save a library to the cache
*/
async storageSaveLibrary(id: string, serverId: string, name: string, collectionType: string | null, imageTag: string | null, sortOrder: number | null) : Promise<null> {
return await TAURI_INVOKE("storage_save_library", { id, serverId, name, collectionType, imageTag, sortOrder });
},
/**
* Save an item to the cache
*/
async storageSaveItem(item: CachedItem, serverId: string) : Promise<null> {
return await TAURI_INVOKE("storage_save_item", { item, serverId });
},
/**
* Get count of pending sync operations for a user
*/
async storageGetPendingSyncCount(userId: string) : Promise<number> {
return await TAURI_INVOKE("storage_get_pending_sync_count", { userId });
},
/**
* Queue a mutation for sync to server
*/
async syncQueueMutation(userId: string, operation: string, itemId: string | null, payload: string | null) : Promise<number> {
return await TAURI_INVOKE("sync_queue_mutation", { userId, operation, itemId, payload });
},
/**
* Get all pending sync operations for a user
*/
async syncGetPending(userId: string, limit: number | null) : Promise<SyncQueueItem[]> {
return await TAURI_INVOKE("sync_get_pending", { userId, limit });
},
/**
* Mark a sync operation as in progress
*/
async syncMarkProcessing(id: number) : Promise<null> {
return await TAURI_INVOKE("sync_mark_processing", { id });
},
/**
* Mark a sync operation as completed
*/
async syncMarkCompleted(id: number) : Promise<null> {
return await TAURI_INVOKE("sync_mark_completed", { id });
},
/**
* Mark a sync operation as failed with error message
*/
async syncMarkFailed(id: number, error: string) : Promise<null> {
return await TAURI_INVOKE("sync_mark_failed", { id, error });
},
/**
* Get count of pending sync operations for a user
*/
async syncGetPendingCount(userId: string) : Promise<number> {
return await TAURI_INVOKE("sync_get_pending_count", { userId });
},
/**
* Push the queue now, on the user's say-so, instead of waiting for a reconnect.
*
* TRACES: UR-025 | DR-132
*/
async syncProcessPending() : Promise<DrainReport> {
return await TAURI_INVOKE("sync_process_pending");
},
/**
* Delete completed sync operations older than specified days
*/
async syncCleanupCompleted(daysOld: number) : Promise<number> {
return await TAURI_INVOKE("sync_cleanup_completed", { daysOld });
},
/**
* Delete all sync operations for a user (used during logout)
*/
async syncClearUser(userId: string) : Promise<null> {
return await TAURI_INVOKE("sync_clear_user", { userId });
},
/**
* Get cached thumbnail path, returns None if not cached
* Also updates last_accessed timestamp for LRU tracking
*/
async thumbnailGetCached(itemId: string, imageType: string, tag: string) : Promise<string | null> {
return await TAURI_INVOKE("thumbnail_get_cached", { itemId, imageType, tag });
},
/**
* Download and save a thumbnail to cache
* Returns the local file path on success
*/
async thumbnailSave(itemId: string, imageType: string, tag: string, url: string) : Promise<string> {
return await TAURI_INVOKE("thumbnail_save", { itemId, imageType, tag, url });
},
/**
* Get thumbnail cache statistics
*/
async thumbnailGetStats() : Promise<ThumbnailCacheStats> {
return await TAURI_INVOKE("thumbnail_get_stats");
},
/**
* Set thumbnail cache storage limit in bytes
*/
async thumbnailSetLimit(limitBytes: number) : Promise<null> {
return await TAURI_INVOKE("thumbnail_set_limit", { limitBytes });
},
/**
* Clear all cached thumbnails
*/
async thumbnailClearCache() : Promise<null> {
return await TAURI_INVOKE("thumbnail_clear_cache");
},
/**
* Delete cached thumbnails for a specific item
*/
async thumbnailDeleteItem(itemId: string) : Promise<null> {
return await TAURI_INVOKE("thumbnail_delete_item", { itemId });
},
/**
* Get image as base64 data URL, caching if not already cached
* This extends the thumbnail system to serve all images through Rust with automatic caching
*/
async imageGetUrl(repositoryHandle: string, request: GetImageRequest) : Promise<string> {
return await TAURI_INVOKE("image_get_url", { repositoryHandle, request });
},
/**
* Save a person to the cache
*/
async storageSavePerson(person: CachedPerson) : Promise<null> {
return await TAURI_INVOKE("storage_save_person", { person });
},
/**
* Get a cached person by ID
*/
async storageGetPerson(personId: string) : Promise<CachedPerson | null> {
return await TAURI_INVOKE("storage_get_person", { personId });
},
/**
* Save item-person associations (batch)
*/
async storageSaveItemPeople(associations: CachedItemPerson[]) : Promise<null> {
return await TAURI_INVOKE("storage_save_item_people", { associations });
},
/**
* Get people for an item (with person details joined)
*/
async storageGetItemPeople(itemId: string) : Promise<CachedItemPerson[]> {
return await TAURI_INVOKE("storage_get_item_people", { itemId });
},
/**
* Save user's preferred audio track for a series
*/
async storageSaveSeriesAudioPreference(userId: string, seriesId: string, serverId: string, audioTrackDisplayTitle: string | null, audioTrackLanguage: string | null, audioTrackIndex: number | null) : Promise<null> {
return await TAURI_INVOKE("storage_save_series_audio_preference", { userId, seriesId, serverId, audioTrackDisplayTitle, audioTrackLanguage, audioTrackIndex });
},
/**
* Get user's preferred audio track for a series
*/
async storageGetSeriesAudioPreference(userId: string, seriesId: string) : Promise<SeriesAudioPreference | null> {
return await TAURI_INVOKE("storage_get_series_audio_preference", { userId, seriesId });
},
/**
* Create a new repository instance
* Returns a handle (UUID) for accessing the repository
*/
async repositoryCreate(serverUrl: string, userId: string, accessToken: string, serverId: string) : Promise<string> {
return await TAURI_INVOKE("repository_create", { serverUrl, userId, accessToken, serverId });
},
/**
* Destroy a repository instance
*/
async repositoryDestroy(handle: string) : Promise<null> {
return await TAURI_INVOKE("repository_destroy", { handle });
},
/**
* Get libraries
*/
async repositoryGetLibraries(handle: string) : Promise<Library[]> {
return await TAURI_INVOKE("repository_get_libraries", { handle });
},
/**
* Get items in a container (library, folder, album, etc.)
*/
async repositoryGetItems(handle: string, parentId: string, options: GetItemsOptions | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_items", { handle, parentId, options });
},
/**
* Get a single item by ID
*/
async repositoryGetItem(handle: string, itemId: string) : Promise<MediaItem> {
return await TAURI_INVOKE("repository_get_item", { handle, itemId });
},
/**
* Downloaded-only browse: libraries that contain downloaded content.
*
* Backs the Downloads "Downloaded" surface. Never merges server results and is
* authoritative — an empty list means nothing is downloaded.
*
* TRACES: UR-055 | DR-082
*/
async repositoryGetDownloadedLibraries(handle: string) : Promise<Library[]> {
return await TAURI_INVOKE("repository_get_downloaded_libraries", { handle });
},
/**
* Downloaded-only browse: items under a container that are on the device.
*
* TRACES: UR-055 | DR-082, DR-083
*/
async repositoryGetDownloadedItems(handle: string, parentId: string, options: GetItemsOptions | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_downloaded_items", { handle, parentId, options });
},
/**
* On-disk usage of downloaded content (device total, per-item/container bytes).
*
* TRACES: UR-056 | DR-085
*/
async repositoryGetDownloadDiskUsage(handle: string) : Promise<DownloadDiskUsage> {
return await TAURI_INVOKE("repository_get_download_disk_usage", { handle });
},
/**
* Query the optional JRay plugin for the actors on screen at time `t`
* (seconds) in an item. Returns an empty list when JRay isn't installed or
* has no data for the item, so the caller can render nothing without error.
*/
async repositoryJrayActorsAt(handle: string, itemId: string, t: number) : Promise<JRayActor[]> {
return await TAURI_INVOKE("repository_jray_actors_at", { handle, itemId, t });
},
/**
* Get latest items in a library
*/
async repositoryGetLatestItems(handle: string, parentId: string, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_latest_items", { handle, parentId, limit });
},
/**
* Get resume items (continue watching/listening).
*
* The home screen's Continue Watching row and every library's "pick up where
* you left off" hero come through here; each item carries its own resume
* position in `UserData`.
*
* TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038
*/
async repositoryGetResumeItems(handle: string, parentId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_resume_items", { handle, parentId, limit });
},
/**
* Get next up episodes.
*
* TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
*/
async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_next_up_episodes", { handle, seriesId, limit });
},
/**
* Every episode of a series, across all seasons, in series order.
*
* Jellyfin hangs episodes off season folders — except for "flat" series whose
* children are episodes directly. Both shapes are provider vocabulary, so the
* fan-out and its fallback live in Rust rather than being reimplemented in the
* frontend (which is what it used to do).
*
* TRACES: UR-062 | DR-101
*/
async repositoryGetSeriesEpisodes(handle: string, seriesId: string) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_series_episodes", { handle, seriesId });
},
/**
* The episode a viewer should land on when they open a series.
*
* "Current" is domain policy, not layout: an episode in progress, else the
* server's Next Up for the series, else the first unwatched episode, else the
* first. The third rung is what makes this work offline, where Next Up is
* always empty. Returns `None` only when the series has no episodes at all.
*
* TRACES: UR-062 | DR-101
*/
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
},
/**
* Erase the viewer's watch history for an item.
*
* Clears the played flag and the resume position; on a series or season the
* server applies it to everything inside. A series cleared this way is "never
* watched" again, so `repository_get_series_current_episode` returns its
* premiere. Requires the server — offline this fails rather than diverging
* local state the next sync would overwrite.
*
* TRACES: UR-064 | DR-106
*/
async repositoryClearWatchHistory(handle: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("repository_clear_watch_history", { handle, itemId });
},
/**
* Get recently played audio
*/
async repositoryGetRecentlyPlayedAudio(handle: string, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_recently_played_audio", { handle, limit });
},
/**
* Get resume movies
*/
async repositoryGetResumeMovies(handle: string, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_resume_movies", { handle, limit });
},
/**
* Get albums the user hasn't listened to recently ("rediscover")
*/
async repositoryGetRediscoverAlbums(handle: string, parentId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_rediscover_albums", { handle, parentId, limit });
},
/**
* Get genres for a library
*/
async repositoryGetGenres(handle: string, parentId: string | null) : Promise<Genre[]> {
return await TAURI_INVOKE("repository_get_genres", { handle, parentId });
},
/**
* Search for items.
*
* Resolves `SearchOptions::scope` into concrete Jellyfin item types before
* dispatching, so scope taxonomy stays in Rust.
*
* TRACES: UR-049, UR-050 | DR-063
*/
async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
},
/**
* Get playback info for an item
*/
async repositoryGetPlaybackInfo(handle: string, itemId: string) : Promise<PlaybackInfo> {
return await TAURI_INVOKE("repository_get_playback_info", { handle, itemId });
},
/**
* Get a video stream URL.
*
* There is no start-position parameter on purpose: the URL is an HLS playlist
* covering the whole item, and a position on it makes the server reject every
* segment with `400` (DR-181). Callers resume by seeking after load.
*
* TRACES: UR-004 | DR-181 | UT-182
*/
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, audioStreamIndex });
},
/**
* Decide what stream to play for a video, and describe it.
*
* Replaces `repository_get_video_stream_url` for playback. The returned
* [`StreamSelection`] carries the transport explicitly, so the frontend picks
* its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
* carries the quality ladder as it applies to *this* source, so the picker can
* stop offering rungs that produce the same bytes as Original.
*
* No start-position parameter, for the same reason as the URL builder: a
* position on an HLS playlist is copied onto every segment URI and the server
* rejects each with `400` (DR-181). Callers resume by seeking after load.
*
* TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
*/
async repositoryGetStreamSelection(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamSelection> {
return await TAURI_INVOKE("repository_get_stream_selection", { handle, itemId, mediaSourceId, audioStreamIndex });
},
/**
* Get audio stream URL for a track
*/
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
},
/**
* Get an audio-only stream URL for a *video* item (background-audio handoff).
*
* TRACES: UR-040 | JA-032 | UT-061
*/
async repositoryGetAudioOnlyStreamUrlForVideo(handle: string, itemId: string, mediaSourceId: string | null, startTimeSeconds: number | null, audioStreamIndex: number | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_only_stream_url_for_video", { handle, itemId, mediaSourceId, startTimeSeconds, audioStreamIndex });
},
/**
* Get Live TV channels (broadcast / IPTV) for browsing
*/
async repositoryGetLiveTvChannels(handle: string) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_live_tv_channels", { handle });
},
/**
* Get the root list of plugin "Channels"
*/
async repositoryGetChannels(handle: string) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_channels", { handle });
},
/**
* Open a live stream for a Live TV channel / live item
*/
async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStreamInfo> {
return await TAURI_INVOKE("repository_open_live_stream", { handle, itemId });
},
/**
* Report playback start
*/
async repositoryReportPlaybackStart(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionMs });
},
/**
* Report playback progress
*/
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionMs });
},
/**
* Report playback stopped
*
* A stop-report that cannot reach the server is queued rather than dropped:
* this is the position the resume point is built from, and losing it is
* exactly the "it forgot where I was" the sync queue exists to prevent. The
* drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
* failing the command because the *queue* write failed would tell the caller
* the report was lost when the local position was already saved.
*
* TRACES: UR-025 | DR-154 | UT-151
*/
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
},
/**
* Get image URL for an item
*/
async repositoryGetImageUrl(handle: string, itemId: string, imageType: ImageType, options: ImageOptions | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_image_url", { handle, itemId, imageType, options });
},
/**
* Mark an item as favorite
*/
async repositoryMarkFavorite(handle: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("repository_mark_favorite", { handle, itemId });
},
/**
* Unmark an item as favorite
*/
async repositoryUnmarkFavorite(handle: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("repository_unmark_favorite", { handle, itemId });
},
/**
* Everything the viewer has favourited, across libraries, narrowed by scope.
*
* Two-phase like `repository_search`: the local answer returns immediately and
* a background server pass emits `favorites-changed` when the server's set
* differs. Without the second phase a favourite marked in another client shows
* up only on the *second* visit to the page, since the cache-first read hands
* back local rows and the refresh is invisible to the frontend.
*
* TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
*/
async repositoryGetFavorites(handle: string, scope: SearchScope, options: GetItemsOptions | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_favorites", { handle, scope, options });
},
/**
* Get person details
*/
async repositoryGetPerson(handle: string, personId: string) : Promise<MediaItem> {
return await TAURI_INVOKE("repository_get_person", { handle, personId });
},
/**
* Get items by person (actor, director, etc.)
*/
async repositoryGetItemsByPerson(handle: string, personId: string, options: GetItemsOptions | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_items_by_person", { handle, personId, options });
},
/**
* Get similar/related items for a media item
*/
async repositoryGetSimilarItems(handle: string, itemId: string, limit: number | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_similar_items", { handle, itemId, limit });
},
/**
* Get subtitle URL for a media item
*/
async repositoryGetSubtitleUrl(handle: string, itemId: string, mediaSourceId: string, streamIndex: number, format: string) : Promise<string> {
return await TAURI_INVOKE("repository_get_subtitle_url", { handle, itemId, mediaSourceId, streamIndex, format });
},
/**
* Get video download URL with quality preset
*/
async repositoryGetVideoDownloadUrl(handle: string, itemId: string, quality: string, mediaSourceId: string | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_video_download_url", { handle, itemId, quality, mediaSourceId });
},
/**
* Create a new playlist
*/
async playlistCreate(handle: string, name: string, itemIds: string[] | null) : Promise<PlaylistCreatedResult> {
return await TAURI_INVOKE("playlist_create", { handle, name, itemIds });
},
/**
* Delete a playlist
*/
async playlistDelete(handle: string, playlistId: string) : Promise<null> {
return await TAURI_INVOKE("playlist_delete", { handle, playlistId });
},
/**
* Rename a playlist
*/
async playlistRename(handle: string, playlistId: string, name: string) : Promise<null> {
return await TAURI_INVOKE("playlist_rename", { handle, playlistId, name });
},
/**
* Get playlist items with PlaylistItemId
*/
async playlistGetItems(handle: string, playlistId: string) : Promise<PlaylistEntry[]> {
return await TAURI_INVOKE("playlist_get_items", { handle, playlistId });
},
/**
* Add items to a playlist
*/
async playlistAddItems(handle: string, playlistId: string, itemIds: string[]) : Promise<null> {
return await TAURI_INVOKE("playlist_add_items", { handle, playlistId, itemIds });
},
/**
* Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
*/
async playlistRemoveItems(handle: string, playlistId: string, entryIds: string[]) : Promise<null> {
return await TAURI_INVOKE("playlist_remove_items", { handle, playlistId, entryIds });
},
/**
* Move a playlist item to a new position
*/
async playlistMoveItem(handle: string, playlistId: string, itemId: string, newIndex: number) : Promise<null> {
return await TAURI_INVOKE("playlist_move_item", { handle, playlistId, itemId, newIndex });
},
/**
* Current log level and where the files are.
*
* TRACES: UR-078 | DR-218
*/
async diagnosticsGetInfo() : Promise<DiagnosticsInfo> {
return await TAURI_INVOKE("diagnostics_get_info");
},
/**
* Set the log level, for this session and the next.
*
* TRACES: UR-078 | DR-218
*/
async diagnosticsSetLevel(level: string) : Promise<string> {
return await TAURI_INVOKE("diagnostics_set_level", { level });
},
/**
* Write a redacted diagnostics archive and return where it went.
*
* # Blocking I/O
*
* This reads and rewrites every log file. It is an `async` command so it does
* not block the IPC thread, but it must never be called from a player event
* callback — see the deadlock note in CLAUDE.md.
*
* TRACES: UR-078 | DR-218
*/
async diagnosticsExport(serverUrl: string | null) : Promise<DiagnosticsBundle> {
return await TAURI_INVOKE("diagnostics_export", { serverUrl });
},
/**
* Format time in seconds to MM:SS display string
*
* # Arguments
* * `seconds` - Time in seconds
*
* # Returns
* Formatted string like "3:45" or "12:09"
*/
async formatTimeSeconds(seconds: number) : Promise<string> {
return await TAURI_INVOKE("format_time_seconds", { seconds });
},
/**
* Format time in seconds to HH:MM:SS or MM:SS display string
*
* Automatically chooses format based on duration:
* - Less than 1 hour: Returns MM:SS format
* - 1 hour or more: Returns HH:MM:SS format
*
* # Arguments
* * `seconds` - Time in seconds
*
* # Returns
* Formatted string like "1:23:45" or "3:45"
*/
async formatTimeSecondsLong(seconds: number) : Promise<string> {
return await TAURI_INVOKE("format_time_seconds_long", { seconds });
},
/**
* Convert Jellyfin ticks to seconds
*
* # Arguments
* * `ticks` - Time in Jellyfin ticks (10,000,000 ticks = 1 second)
*
* # Returns
* Time in seconds
*/
async convertTicksToSeconds(ticks: number) : Promise<number> {
return await TAURI_INVOKE("convert_ticks_to_seconds", { ticks });
},
/**
* Calculate progress percentage from position and duration
*
* # Arguments
* * `position` - Current position in seconds
* * `duration` - Total duration in seconds
*
* # Returns
* Progress as percentage (0.0 to 100.0)
*/
async calcProgress(position: number, duration: number) : Promise<number> {
return await TAURI_INVOKE("calc_progress", { position, duration });
},
/**
* Convert percentage volume (0-100) to normalized (0.0-1.0)
*
* # Arguments
* * `percent` - Volume as percentage (0 to 100)
*
* # Returns
* Normalized volume (0.0 to 1.0)
*/
async convertPercentToVolume(percent: number) : Promise<number> {
return await TAURI_INVOKE("convert_percent_to_volume", { percent });
}
}
/** user-defined events **/
export const events = __makeEvents__<{
playerStatusEvent: PlayerStatusEvent
}>({
playerStatusEvent: "player-status-event"
})
/** user-defined constants **/
/** user-defined types **/
/**
* Active session info (for session restoration)
*/
export type ActiveSession = { userId: string; username: string; serverId: string; serverUrl: string; serverName: string; accessToken: string }
/**
* Request to add items to queue
*/
export type AddToQueueRequest = { items: PlayItemRequest[]; position: string }
/**
* Request to add a track by ID - backend fetches metadata
*/
export type AddTrackByIdRequest = { trackId: string; position: string }
/**
* Request to add multiple tracks by IDs - backend fetches metadata
*/
export type AddTracksByIdsRequest = { trackIds: string[]; position: string }
/**
* Album affinity status info
*/
export type AlbumAffinityStatus = { albumId: string; uniqueTracksPlayed: number; threshold: number; thresholdReached: boolean }
/**
* Album recommendation info
*/
export type AlbumRecommendation = { album_id: string; album_name: string; tracks_played: number; total_tracks: number; should_download: boolean }
/**
* Storage info for a single album
*/
export type AlbumStorageInfo = { album_id: string; album_name: string; artist_name: string | null; bytes_used: number; track_count: number }
/**
* Artist item with ID and name (for clickable artist links)
*/
export type ArtistItem = { id: string; name: string }
/**
* Audio playback settings
*/
export type AudioSettings = {
/**
* Crossfade duration in seconds (0 = disabled, max 12)
*/
crossfadeDuration: number;
/**
* Enable gapless playback between tracks
*/
gaplessPlayback: boolean;
/**
* Enable volume normalization
*/
normalizeVolume: boolean;
/**
* Target volume level for normalization
*/
volumeLevel: VolumeLevel;
/**
* Enable the graphic equalizer. When false, no EQ filter is applied.
*/
equalizerEnabled?: boolean;
/**
* Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and
* clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`].
*/
equalizerBands?: number[] }
/**
* Response for audio track switching operations
*/
export type AudioTrackSwitchResponse =
/**
* Native backend handled it (Android ExoPlayer)
*/
{ strategy: "native"; success: boolean } |
/**
* HTML5 needs to reload stream with new audio track
*/
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
/**
* Authentication result
*/
export type AuthResult = { user: User; accessToken: string; serverId: string }
/**
* Server information returned from Jellyfin
*/
export type AuthServerInfo = { name: string; version: string; id: string;
/**
* Normalized server URL with protocol and no trailing slash
*/
normalizedUrl: string }
/**
* Autoplay settings (controls next episode behavior)
*/
export type AutoplaySettings = {
/**
* Whether autoplay is enabled for next episodes
*/
enabled: boolean;
/**
* Countdown duration in seconds before auto-playing next episode
*/
countdownSeconds: number;
/**
* Maximum number of episodes to auto-play consecutively (0 = unlimited)
*/
maxEpisodes?: number }
/**
* What the player should do when the app is backgrounded.
*/
export type BackgroundAction =
/**
* Carry on. Music, and video the user explicitly asked to keep hearing
* while it is in a picture-in-picture window.
*/
"keepPlaying" |
/**
* Swap the video stream for an audio-only one and keep playing.
*/
"handOffToAudio" |
/**
* Stop making sound. The user did not ask for background playback.
*/
"pause"
/**
* Smart caching configuration
*/
export type CacheConfig = {
/**
* Enable queue pre-caching
*/
queuePrecacheEnabled: boolean;
/**
* Number of tracks to pre-cache from queue
*/
queuePrecacheCount: number;
/**
* Enable album affinity detection
*/
albumAffinityEnabled: boolean;
/**
* Threshold for album affinity (tracks played before caching)
*/
albumAffinityThreshold: number;
/**
* Storage limit in bytes (0 = unlimited)
*/
storageLimit: number;
/**
* Only cache on WiFi
*/
wifiOnly: boolean;
/**
* How long a temporary (`download_source = 'auto'`) download lives before
* it is reclaimed, in hours. 0 disables expiry, leaving space pressure as
* the only reclaim trigger.
*
* TRACES: UR-071 | DR-127
*/
temporaryTtlHours: number }
/**
* Cached media item returned to frontend
*/
export type CachedItem = { id: string; name: string; itemType: string; parentId: string | null; libraryId: string | null; overview: string | null; genres: string | null; runtimeTicks: number | null; productionYear: number | null; communityRating: number | null; officialRating: string | null; primaryImageTag: string | null; albumId: string | null; albumName: string | null; albumArtist: string | null; artists: string | null; indexNumber: number | null; seriesId: string | null; seriesName: string | null; seasonId: string | null; seasonName: string | null; parentIndexNumber: number | null }
/**
* Item-person association for caching
*/
export type CachedItemPerson = { itemId: string; personId: string; serverId: string; personType: string; role: string | null; sortOrder: number }
/**
* Cached library info returned to frontend
*/
export type CachedLibrary = { id: string; serverId: string; name: string; collectionType: string | null; imageTag: string | null }
/**
* Cached person info returned to frontend
*/
export type CachedPerson = { id: string; serverId: string; name: string; overview: string | null; primaryImageTag: string | null; premiereDate: string | null; endDate: string | null }
export type CatalogSyncResult = {
/**
* Total items persisted to the offline cache across all libraries.
*/
itemsCached: number;
/**
* Libraries that failed to sync (e.g. server hiccup); best-effort.
*/
librariesFailed: number;
/**
* Entries removed because the server no longer has them. Always 0 when any
* library failed, since a partial crawl cannot prove an item is gone.
*/
itemsPruned: number }
export type CatalogSyncStatus = {
/**
* RFC-3339 timestamp of the last successful sync, if any.
*/
lastSyncedAt: string | null }
/**
* Connectivity status
*/
export type ConnectivityStatus = {
/**
* Whether the Jellyfin server is reachable
*/
isServerReachable: boolean;
/**
* Last time we checked server reachability (ISO 8601 string)
*/
lastChecked: string | null;
/**
* Error message from last connectivity check
*/
connectionError: string | null;
/**
* Whether we're currently checking connectivity
*/
isChecking: boolean }
/**
* Where an export landed, so the UI can tell the user where to find it.
*/
export type DiagnosticsBundle = {
/**
* Absolute path to the written archive.
*/
path: string; sizeBytes: number;
/**
* How many log files went in, excluding the environment summary.
*/
fileCount: number }
/**
* Where logs live and how verbose they currently are.
*/
export type DiagnosticsInfo = {
/**
* Directory holding the rotating log files.
*/
logDir: string;
/**
* Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace".
*/
level: string;
/**
* Total bytes currently held by log files.
*/
totalSizeBytes: number }
/**
* On-disk usage of downloaded content, for the Downloads surface.
*
* `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's
* own file size, a container's summed downloaded descendants. `device_total_bytes`
* and `item_count` are the headline figures for the Downloaded surface top bar.
*
* TRACES: UR-056 | DR-085
*/
export type DownloadDiskUsage = {
/**
* item id → bytes on disk (leaf's own size, or a container's subtotal).
*/
sizes: Partial<{ [key in string]: number }>;
/**
* Container id → true when it is only *partially* downloaded (has cached
* children that are not downloaded). Absent/false ⇒ fully downloaded. Lets
* the Downloaded surface badge partial vs. full containers.
*/
partialContainers: Partial<{ [key in string]: boolean }>;
/**
* Sum of all downloaded leaf sizes — the device total.
*/
deviceTotalBytes: number;
/**
* Number of downloaded leaf items (not containers).
*/
itemCount: number }
/**
* Information about a download
*/
export type DownloadInfo = { id: number; itemId: string; userId: string; filePath: string; fileSize: number | null; mimeType: string | null; status: string; progress: number; bytesDownloaded: number; queuedAt: string; startedAt: string | null; completedAt: string | null; errorMessage: string | null; retryCount: number; priority: number; itemName: string | null; artistName: string | null; albumName: string | null; seriesName: string | null; seasonName: string | null; episodeNumber: number | null; seasonNumber: number | null; qualityPreset: string | null; mediaType: string; downloadSource: string }
/**
* Request payload for download_item_and_start (bundled to stay within specta's
* 10-argument command limit).
*/
export type DownloadItemAndStartRequest = { itemId: string; userId: string; streamUrl: string; targetDir: string; itemName: string | null; artistName: string | null; albumName: string | null }
/**
* Request payload for download_item.
*/
export type DownloadItemRequest = { itemId: string; userId: string; filePath: string; mimeType: string | null; priority: number | null; itemName: string | null; artistName: string | null; albumName: string | null; expectedSize: number | null }
/**
* Download manager statistics
*/
export type DownloadManagerStats = { max_concurrent: number; active_count: number; available_slots: number }
/**
* Download statistics computed server-side
*/
export type DownloadStats = { total: number; activeCount: number; queuedCount: number; completedCount: number; failedCount: number; pausedCount: number }
/**
* Request payload for download_video.
*/
export type DownloadVideoRequest = { itemId: string; userId: string; filePath: string; mimeType: string | null; priority: number | null; itemName: string | null; qualityPreset: string | null; seriesName: string | null; seasonName: string | null; episodeNumber: number | null; seasonNumber: number | null }
/**
* Enhanced response with pre-computed stats
*/
export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStats }
/**
* What a drain did, for logging and for the frontend's "Sync now" button.
*/
export type DrainReport = {
/**
* Rows that reached the server and are now `completed`.
*/
pushed: number;
/**
* Rows that failed and will be retried on the next reconnect.
*/
deferred: number;
/**
* Rows that exhausted `MAX_SYNC_ATTEMPTS` and were given up on.
*/
abandoned: number;
/**
* Rows still waiting afterwards (what the badge counts).
*/
remaining: number }
/**
* Built-in equalizer presets. A preset *is* a gain curve defined by the band
* layout above (a domain concept), not a mere label — the curve numbers live
* in Rust so the frontend never encodes the taxonomy.
*
* TRACES: UR-027 | DR-030
*/
export type EqPreset = "flat" | "rock" | "pop" | "jazz" | "classical" | "bassBoost" | "trebleBoost" | "vocal"
/**
* Something the user may choose to hide: a library, or a folder directly
* inside one.
*
* Which containers are *offerable* is a domain question (it depends on the
* library's Jellyfin collection type and on what counts as a folder), so the
* list is assembled here and the frontend renders it verbatim.
*
* TRACES: UR-076 | DR-209
*/
export type ExclusionCandidate = {
/**
* Stable Jellyfin item id — what gets stored when the user picks it.
*/
id: string;
/**
* Display name of the folder (or of the library, for a whole-library entry).
*/
name: string;
/**
* Library this candidate lives in, so the picker can group and disambiguate
* two folders that share a name.
*/
libraryName: string;
/**
* True when the candidate *is* a library rather than a folder inside one.
*/
isLibrary: boolean }
/**
* Genre
*/
export type Genre = { id: string; name: string;
/**
* Number of albums tagged with this genre, when the backend can supply it
* (online only). Lets the frontend rank/pick genres without probing each
* one. `None` when unknown (e.g. offline).
*/
albumCount: number | null }
/**
* Request to get an image URL (with caching)
*/
export type GetImageRequest = { itemId: string; imageType: string; maxWidth?: number | null; maxHeight?: number | null; tag?: string | null }
/**
* Options for querying items
*/
export type GetItemsOptions = { startIndex?: number | null; limit?: number | null; sortBy?: string | null; sortOrder?: string | null; includeItemTypes?: string[] | null; recursive?: boolean | null; fields?: string[] | null; genres?: string[] | null;
/**
* Restrict the listing to favourited items. Backs the per-library
* favourites toggle; composes with every other filter here.
*
* TRACES: UR-067 | DR-116 | UT-104
*/
favoritesOnly?: boolean | null }
/**
* Image options
*/
export type ImageOptions = { maxWidth?: number | null; maxHeight?: number | null; quality?: number | null; tag?: string | null }
/**
* Image type
*/
export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo"
/**
* A single actor returned by the JRay plugin's "context at time t" endpoint.
*
* Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
* `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation;
* the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`.
*/
export type JRayActor = { name: string; imdb_id?: string; tmdb_id?: string; jellyfin_id?: string }
/**
* Library (media collection)
*/
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null;
/**
* The favourites scope this library's contents fall under, or `None` for a
* library kind favourites does not carve up (Live TV, channels, books…).
*
* Derived here rather than in the UI: which collection type maps to which
* scope is Jellyfin vocabulary, and the frontend must not hold a
* collection-type → category table any more than an item-type one. See
* `SearchScope::for_collection_type`.
*
* TRACES: UR-075 | DR-175
*/
favoritesScope?: SearchScope | null }
/**
* Library browsing preferences.
*
* Currently a single list: the folders (or whole libraries) the user has asked
* to keep out of browsing. It is a *list of ids*, never names — names are
* unstable, locale-dependent and non-unique, and the hardcoded name filter this
* setting replaced broke on exactly that. What the ids then hide is decided in
* `repository::exclusions`; this struct is only how the choice is carried and
* persisted.
*
* The default is an empty list: nobody inherits another user's folder layout.
*
* TRACES: UR-076 | DR-209
*/
export type LibrarySettings = {
/**
* Stable item ids of the folders/libraries hidden from browsing.
*
* `#[serde(default)]` so settings JSON persisted before this field existed
* loads as the previous behaviour (nothing hidden).
*/
excludedItemIds?: string[] }
/**
* Live stream information returned from opening a Live TV / channel stream.
*
* Unlike on-demand video, a live channel must be "opened" before it can be
* streamed; the server returns a transcoding URL (already absolute) plus a
* `live_stream_id` that can later be used to close the stream.
*/
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null;
/**
* How to open `stream_url`.
*
* A live channel is always an HLS transcode — the server has to repackage a
* broadcast mux into something a browser can play, and there is no static
* file to direct-play. Saying so here means the player page never has to
* work it out from the URL, which is the whole of DR-225.
*
* TRACES: UR-079 | DR-225
*/
transport: Transport }
/**
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
*
* Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
* follow it in lockstep.
*/
export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?: string[]; slaveNames?: string[] }
/**
* Media item
*/
export type MediaItem = { id: string; name: string;
/**
* Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
*
* Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
* is the neutral replacement. This field stays while the frontend migrates
* off it, then is removed in a later phase. New Rust code should read
* `kind`, not this.
*/
type: string;
/**
* Provider-neutral classification — the replacement for `item_type`.
* Populated by the Jellyfin mapping; defaults to `Other` for the handful of
* construction sites that have not been migrated yet.
*/
kind?: MediaKind;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null;
/**
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date.
*/
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null;
/**
* Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
* `duration_ms`; dual-carried while the frontend migrates
* (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
*/
runTimeTicks?: number | null;
/**
* Duration in milliseconds — the neutral replacement for `runtime_ticks`.
* Ticks never reach the frontend; this does.
*/
durationMs?: number | null;
/**
* Legacy Jellyfin primary image tag. Being replaced by `image_id`;
* dual-carried while the frontend migrates. New code should read `image_id`.
*/
primaryImageTag?: string | null;
/**
* Neutral image identifier the frontend resolves to a URL via the image
* command — the replacement for `primary_image_tag`. Same value today
* (Jellyfin's tag is the id); the rename removes the provider term.
*/
imageId?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
/**
* The kind of a media item — provider-neutral classification.
*
* Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
* (`"Audio"`, `"MusicAlbum"`, …) across the boundary. A closed enum means a
* typo or an unhandled kind is a compile error on the frontend, not a silent
* runtime miss across ~127 comparison sites.
*/
export type MediaKind = "track" | "album" | "artist" | "playlist" | "movie" | "series" | "season" | "episode" | "person" |
/**
* A channel *container* the user drills into (Jellyfin `Channel`).
*/
"channel" | "folder" |
/**
* A live TV channel — playable, but a live stream with no seekable
* timeline (no resume/seek). Jellyfin `TvChannel`/`LiveTvChannel`.
*/
"liveChannel" |
/**
* A playable leaf inside a channel (Jellyfin `ChannelFolderItem` that is
* not itself a folder) — e.g. a plugin-channel VOD item that has no
* dedicated item type but carries its own media streams. Playable and
* seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
* and from `Other` so the UI can route it to playback.
*/
"channelItem" |
/**
* A kind we do not model explicitly. Reached only for provider item types
* that map to nothing meaningful; consumers treat it like an opaque
* container. The mapping must be *total* — it never panics — so this is the
* safe sink for unknown strings. Also the `Default`, so a defaulted
* `MediaItem` (see the dual-carry migration) is inert rather than a lie.
*/
"other"
/**
* Media session type tracking the high-level playback context
*/
export type MediaSessionType =
/**
* No active session - browsing library
*/
{ type: "idle" } |
/**
* Audio playback session (music, audiobooks, podcasts)
* Persists until explicitly dismissed
*/
{ type: "audio"; last_item: PlayerMediaItem | null; is_active: boolean } |
/**
* Movie playback (single video, auto-dismiss on end)
*/
{ type: "movie"; item: PlayerMediaItem; is_active: boolean } |
/**
* TV show playback (supports next episode auto-advance)
*/
{ type: "tv_show"; item: PlayerMediaItem; series_id: string; is_active: boolean }
/**
* Media source information
*/
export type MediaSource = { id: string; name: string; container?: string | null; size?: number | null; bitrate?: number | null; supportsDirectPlay: boolean; supportsDirectStream: boolean; supportsTranscoding: boolean; directStreamUrl?: string | null }
/**
* Media stream information (audio, video, subtitle tracks)
*/
export type MediaStream = {
/**
* Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being
* replaced by `kind`; dual-carried while the frontend migrates.
*/
type: string;
/**
* Provider-neutral stream classification — replaces `stream_type`.
*/
kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean;
/**
* Whether this stream can reach the app as a sidecar it renders itself.
*
* `None` for anything that is not a subtitle — the question does not apply,
* and `false` there would read like a verdict. For a subtitle it is the
* difference between a track the app can draw and one only the server could
* have shown, by burning it into the picture (DR-176) — which this app never
* asks it to do. The vocabulary of *which formats those are* stays in Rust;
* the frontend only reads the answer.
*
* TRACES: UR-020 | DR-176 | UT-168
*/
supportsExternalDelivery?: boolean | null }
export type MediaType = "audio" | "video"
/**
* Lightweight media item for merged playback state
* Converts from both local MediaItem and remote NowPlayingItem
*/
export type MergedMediaItem = { id: string; title: string; artist: string | null; album: string | null; albumId: string | null; duration: number | null; primaryImageTag: string | null;
/**
* Neutral image identifier — replaces `primary_image_tag` (same value).
*/
imageId: string | null; mediaType: string }
/**
* Argument struct for [`set_network_state`].
*
* TRACES: UR-053 | DR-074
*/
export type NetworkStateWrapperArg = { networkType: NetworkType; unmetered: boolean }
/**
* Kind of network transport currently active.
*
* Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
* in sync (the serde rename below is what the frontend sends).
*
* TRACES: UR-053 | DR-074
*/
export type NetworkType =
/**
* No active network.
*/
"none" |
/**
* WiFi (may still be metered — check `unmetered`).
*/
"wifi" |
/**
* Wired ethernet, typical on Android TV and desktop.
*/
"ethernet" |
/**
* Mobile data — never acceptable when wifi-only is enabled.
*/
"cellular" |
/**
* Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
*/
"other" |
/**
* Could not determine the transport.
*/
"unknown"
export type NowPlayingItem = { id: string | null; name: string | null; runTimeTicks: number | null; album: string | null; albumId: string | null; albumArtist: string | null; artists: string[] | null; imageTags: Partial<{ [key in string]: string }> | null; primaryImageTag: string | null; albumPrimaryImageTag: string | null; Type: string | null }
export type OfflineItem = { id: string; name: string; itemType: string; albumId: string | null; albumName: string | null; artists: string | null; runtimeTicks: number | null; primaryImageTag: string | null }
/**
* Person (cast/crew member) - for movies, series, and episodes
*/
export type Person = {
/**
* Deserializes from API's "Id" field (PascalCase), serializes as "id" (camelCase to frontend)
*/
id?: string;
/**
* Deserializes from API's "Name" field (PascalCase), serializes as "name" (camelCase to frontend)
*/
name?: string;
/**
* Person type from Jellyfin API (Actor, Director, Writer, etc.)
* Deserializes from API's "Type" field (PascalCase), serializes as "type" (camelCase to frontend)
*/
type?: string;
/**
* Deserializes from API's "Role" field (PascalCase), serializes as "role" (camelCase to frontend)
*/
role?: string | null;
/**
* Deserializes from API's "PrimaryImageTag" field (PascalCase), serializes as "primaryImageTag" (camelCase to frontend)
*/
primaryImageTag?: string | null }
/**
* Request to play a track from an album (backend fetches all tracks)
*/
export type PlayAlbumTrackRequest = { albumId: string; albumName: string; trackId: string; shuffle: boolean }
/**
* Request to play a single video item
*
* Simplified to video playback only. Audio playback uses player_play_tracks
* to avoid Tauri Android serialization issues with complex objects.
*/
export type PlayItemRequest = { id: string; title: string; streamUrl: string;
/**
* Video codec (e.g., "h264", "hevc") for video media
*/
videoCodec: string;
/**
* Whether the video requires server-side transcoding
*/
needsTranscoding: boolean;
/**
* How this item's stream is fetched, as the backend decided it.
*
* Carried on the queue item so a later seek/reload does not have to guess.
* `None` for items queued by a path that never negotiated (audio tracks,
* direct URLs) and for anything queued before this field existed, where the
* caller falls back to `needs_transcoding` — every transcode this app
* requests is HLS (DR-140), so that fallback is exact rather than a guess.
*
* TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
*/
transport?: Transport | null;
/**
* Optional now-playing metadata. Used by the background-audio handoff so the
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
* existing video-only callers need not send them.
*/
artist?: string | null; primaryImageTag?: string | null; serverId?: string | null;
/**
* Total media duration (seconds). Threaded through the background-audio
* handoff so the lockscreen MediaSession advertises a real duration — a
* zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
*/
durationSeconds?: number | null;
/**
* Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
* background-audio handoff so an episode played as audio-only is still
* recognised as an episode by autoplay (UR-040) and advances to the next one.
*/
itemType?: string | null;
/**
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
* look up the next episode when a background-audio track ends.
*/
seriesId?: string | null;
/**
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
*
* Only the native backends use these: on Android they become the
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
* builds its own `<track>` children instead and ignores this list.
*
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
* *text track groups* — i.e. the position of the sideloaded configuration,
* not the Jellyfin stream index (which is kept on each entry for the UI's
* benefit). So `n` must be a position in this very array, and the array
* must not be reordered or filtered between building it and sending it.
* `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
* list that is sent here, for exactly this reason.
*
* Defaulted so the background-audio handoff and the autoplay/next-episode
* callers, which have no subtitles to offer, need not send the field.
*
* TRACES: UR-020 | IR-016, JA-008 | UT-145
*/
subtitles?: SubtitleTrack[] }
/**
* Queue context for remote transfer - what type of queue is this?
*/
export type PlayQueueContext =
/**
* Playing from a specific album
*/
{ type: "album"; albumId: string; albumName: string } |
/**
* Playing from a specific playlist
*/
{ type: "playlist"; playlistId: string; playlistName: string } |
/**
* Custom queue (search results, manual queue, etc.)
*/
{ type: "custom" }
/**
* Request to play a queue of items
*/
export type PlayQueueRequest = { items: PlayItemRequest[]; startIndex: number; shuffle: boolean;
/**
* Optional context for the queue (album, playlist, or custom)
* Used for remote playback transfer
*/
context?: PlayQueueContext | null }
export type PlayState = { positionTicks?: number | null; canSeek?: boolean | null; isPaused?: boolean | null; isMuted?: boolean | null; volumeLevel?: number | null; repeatMode?: string | null; shuffleMode?: string | null }
/**
* Context information for track playback
*/
export type PlayTracksContext = { type: "playlist"; playlistId: string; playlistName: string } | { type: "search"; searchQuery: string } | { type: "custom"; label: string | null }
/**
* Request to play tracks by ID (backend fetches metadata)
*/
export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext;
/**
* Position (seconds) to resume the starting track from. Used when taking
* over playback from a remote session so we don't restart from 0.
*/
startPosition?: number | null }
/**
* What playback facilities this platform's backend actually provides.
*
* The frontend is presentation-only and must not re-derive backend facts from
* `navigator.userAgent` — that sniffing was a second copy of the same platform
* decision Rust already makes with `cfg!`, and it drifted. These flags are the
* single source of truth; the frontend consumes them.
*
* TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
*/
export type PlaybackCapabilities = {
/**
* True when audio is rendered by a webview `<audio>` element rather than a
* native backend. Native audio exists on Linux (mpv) and Android
* (ExoPlayer); everything else (Windows, future desktops) uses the webview.
*/
usesWebviewAudio: boolean;
/**
* True when video can be rendered by a native surface composited *behind*
* a transparent webview. Android only: ExoPlayer draws into a SurfaceView
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
* compositing), so it stays on the HTML5 element.
*/
supportsNativeVideo: boolean }
/**
* Playback information
*/
export type PlaybackInfo = { mediaSourceId: string; playSessionId: string; streamUrl: string; directPlay: boolean; needsTranscoding: boolean }
/**
* What the server is doing to the source to produce this stream.
*
* Distinct from [`Transport`] because the two are genuinely independent: a
* direct-streamed remux and a transcode can both arrive over HLS, and a direct
* play can arrive progressively or as a local file. Keeping them apart is what
* lets the UI say "this is not costing the server anything" without inferring
* it from a URL shape.
*
* TRACES: UR-079 | DR-228
*/
export type PlaybackKind =
/**
* The source file is served untouched. No server CPU, no quality loss.
*/
{ type: "directPlay" } |
/**
* The container is repackaged but the codecs are copied — cheap, and
* visually identical to the source.
*/
{ type: "directStream" } |
/**
* The server is re-encoding. The only case where a bitrate ceiling can
* actually be honoured, and the only one that costs the server real work.
*/
{ type: "transcode" }
/**
* Playback mode - local device, remote session, or idle
*/
export type PlaybackMode = { type: "local" } | { type: "remote"; session_id: string } | { type: "idle" }
/**
* Playback progress info
*/
export type PlaybackProgress = { itemId: string;
/**
* Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
* converted here so the frontend never sees ticks.
*/
positionMs: number; isPlayed: boolean; isFavorite: boolean; playCount: number }
/**
* Represents a media item that can be played
*
* TRACES: UR-003, UR-004 | DR-002
*/
export type PlayerMediaItem = {
/**
* Unique identifier
*/
id: string;
/**
* Display title
*/
title: string;
/**
* Name (alias for title - for frontend compatibility)
*/
name?: string | null;
/**
* Artist name(s) for audio
*/
artist: string | null;
/**
* Album name for audio
*/
album: string | null;
/**
* Album name (alias - for frontend compatibility)
*/
albumName?: string | null;
/**
* Album ID (Jellyfin ID) for remote transfer context
*/
albumId?: string | null;
/**
* Artist items with IDs for clickable links
*/
artistItems?: ArtistItem[] | null;
/**
* Artists as array of strings (fallback when artist_items not available)
*/
artists?: string[] | null;
/**
* Primary image tag for artwork.
*
* Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
* while the frontend migrates (docs/specs/frontend-domain-model.md).
*/
primaryImageTag?: string | null;
/**
* Neutral image identifier the frontend resolves to a URL — replaces
* `primary_image_tag`.
*/
imageId?: string | null;
/**
* Item type (Audio, Movie, Episode, etc.)
*/
type?: string | null;
/**
* Playlist ID (Jellyfin ID) for remote transfer context
*/
playlistId?: string | null;
/**
* Duration in seconds
*/
duration: number | null;
/**
* URL or path to artwork image
*/
artworkUrl: string | null;
/**
* Type of media
*/
mediaType: MediaType;
/**
* Source of the media
*/
source: PlayerMediaSource;
/**
* Video codec (e.g., "h264", "hevc") for video media
*/
videoCodec?: string | null;
/**
* Whether the video requires server-side transcoding
*/
needsTranscoding?: boolean;
/**
* How this item's stream is fetched, as the backend decided it.
*
* Carried on the queue item so a later seek/reload does not have to guess.
* `None` for items queued by a path that never negotiated (audio tracks,
* direct URLs) and for anything queued before this field existed, where the
* caller falls back to `needs_transcoding` — every transcode this app
* requests is HLS (DR-140), so that fallback is exact rather than a guess.
*
* TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
*/
transport?: Transport | null;
/**
* Video width in pixels
*/
videoWidth?: number | null;
/**
* Video height in pixels
*/
videoHeight?: number | null;
/**
* Available subtitle tracks
*/
subtitles?: SubtitleTrack[];
/**
* Series ID (for TV show episodes) - used for series audio preferences
*/
seriesId?: string | null;
/**
* Server ID - used for series audio preferences
*/
serverId?: string | null }
/**
* TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
*/
export type PlayerMediaSource =
/**
* Streaming from Jellyfin server
*/
{ type: "remote"; stream_url: string; jellyfin_item_id: string } |
/**
* Downloaded/cached locally
*/
{ type: "local"; file_path: string; jellyfin_item_id: string | null } |
/**
* Direct URL (e.g., channel plugins)
*/
{ type: "directurl"; url: string }
/**
* Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)
*
* TRACES: UR-005 | DR-001
*/
export type PlayerState =
/**
* No media loaded
*/
{ kind: "idle" } |
/**
* Media is being loaded/buffered
*/
{ kind: "loading"; media: PlayerMediaItem } |
/**
* Media is playing
*/
{ kind: "playing"; media: PlayerMediaItem; position: number; duration: number } |
/**
* Media is paused
*/
{ kind: "paused"; media: PlayerMediaItem; position: number; duration: number } |
/**
* Seeking to a new position
*/
{ kind: "seeking"; media: PlayerMediaItem; target: number } |
/**
* An error occurred
*/
{ kind: "error"; media: PlayerMediaItem | null; error: string }
/**
* Response for player state queries
*/
export type PlayerStatus = { state: PlayerState; position: number; duration: number | null; volume: number; muted: boolean; shuffle: boolean; repeat: RepeatMode;
/**
* Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
*/
backend: VideoBackend;
/**
* Whether frontend should render HTML5 video element
*/
useHtml5Element: boolean;
/**
* Media item from either local queue or remote session
*/
mergedMedia: MergedMediaItem | null;
/**
* Playing state from either local player or remote session
*/
mergedIsPlaying: boolean;
/**
* Volume from either local player or remote session (0-1 normalized)
*/
mergedVolume: number }
/**
* Events emitted by the player backend to the frontend via Tauri events.
*
* These are distinct from `PlayerEvent` in state.rs, which handles internal
* state machine transitions.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
export type PlayerStatusEvent =
/**
* Playback position updated (emitted periodically during playback)
*/
{ type: "position_update"; position: number; duration: number } |
/**
* Player state changed
*/
{ type: "state_changed"; state: string; media_id: string | null } |
/**
* Media has finished loading and is ready to play
*/
{ type: "media_loaded"; duration: number } |
/**
* Playback has ended naturally (reached end of media)
*/
{ type: "playback_ended" } |
/**
* Buffering state changed
*/
{ type: "buffering"; percent: number } |
/**
* An error occurred during playback
*/
{ type: "error"; message: string; recoverable: boolean } |
/**
* Volume changed
*/
{ type: "volume_changed"; volume: number; muted: boolean } |
/**
* Sleep timer state changed
*/
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
/**
* Time-based sleep timer expired: playback must stop. The backend stops
* its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
* webview outside the backend's control — the frontend pauses it on this
* event.
*/
{ type: "sleep_timer_expired" } |
/**
* Show next episode popup with countdown
*/
{ type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean } |
/**
* Countdown tick (emitted every second during autoplay countdown)
*/
{ type: "countdown_tick"; remaining_seconds: number } |
/**
* Queue changed (items added, removed, reordered, or playback mode changed)
*/
{ type: "queue_changed"; items: PlayerMediaItem[]; current_index: number | null; shuffle: boolean; repeat: RepeatMode; has_next: boolean; has_previous: boolean } |
/**
* Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
*/
{ type: "session_changed"; session: MediaSessionType } |
/**
* Remote sessions updated (for cast/remote control UI)
*/
{ type: "sessions_updated"; sessions: SessionInfo[] } |
/**
* The authoritative playback mode changed in the Rust backend.
*
* The Rust `PlaybackModeManager` is the single source of truth for which
* device playback commands route to (local vs a remote session). The
* frontend keeps a mirror store for the UI; without this event that mirror
* drifts out of sync (e.g. a mode transition happens inside a transfer or a
* local stop that the frontend never learns about), and controls then route
* to the wrong device — the classic "it keeps playing on the remote" bug.
* The frontend reconciles its store to this payload whenever it fires.
*/
{ type: "playback_mode_changed"; mode: string; session_id: string | null } |
/**
* The user asked to disconnect from the remote session and resume locally.
*
* Emitted when the lockscreen Stop button is pressed while casting. The
* frontend owns the two-step remote->local transfer (it must reload the
* media item locally), so the native side only signals intent here.
*/
{ type: "remote_disconnect_requested" } |
/**
* Backend-originated control command targeting the active frontend player
* adapter (the HTML5 <video> that lives in the webview, which Rust cannot
* drive directly). Emitted by control paths like the sleep timer, lockscreen,
* or remote so they can pause/play/seek/stop the webview element.
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
*/
{ type: "control_command"; action: string; position: number | null } |
/**
* Ask the frontend webview `<audio>` element to load and play a stream.
*
* Emitted by `WebviewAudioBackend` on platforms with no native audio
* backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
* element in the webview, mirroring how all video already renders through
* the webview `<video>`. The element then reports its state/position back
* through the `player_report_*` commands, so the Rust controller stays the
* single source of truth. Subsequent play/pause/seek/stop reach the element
* via `ControlCommand`.
*/
{ type: "webview_audio_load"; url: string; media_id: string | null; position: number; autoplay: boolean }
/**
* Result of creating a playlist
*
* @req: JA-019 - Get/create/update playlists
*/
export type PlaylistCreatedResult = { id: string }
/**
* Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId
* needed for remove/reorder operations (distinct from the media item's ID)
*
* @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
*/
export type PlaylistEntry =
/**
* The underlying media item
*/
({ id: string; name: string;
/**
* Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
*
* Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
* is the neutral replacement. This field stays while the frontend migrates
* off it, then is removed in a later phase. New Rust code should read
* `kind`, not this.
*/
type: string;
/**
* Provider-neutral classification — the replacement for `item_type`.
* Populated by the Jellyfin mapping; defaults to `Other` for the handful of
* construction sites that have not been migrated yet.
*/
kind?: MediaKind;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null;
/**
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date.
*/
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null;
/**
* Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
* `duration_ms`; dual-carried while the frontend migrates
* (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
*/
runTimeTicks?: number | null;
/**
* Duration in milliseconds — the neutral replacement for `runtime_ticks`.
* Ticks never reach the frontend; this does.
*/
durationMs?: number | null;
/**
* Legacy Jellyfin primary image tag. Being replaced by `image_id`;
* dual-carried while the frontend migrates. New code should read `image_id`.
*/
primaryImageTag?: string | null;
/**
* Neutral image identifier the frontend resolves to a URL via the image
* command — the replacement for `primary_image_tag`. Same value today
* (Jellyfin's tag is the id); the rename removes the provider term.
*/
imageId?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
/**
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
*/
playlistItemId: string }
/**
* Response for preload operation
*/
export type PreloadResult = {
/**
* Number of tracks queued for preload
*/
queuedCount: number;
/**
* Number of tracks already downloaded
*/
alreadyDownloaded: number;
/**
* Number of tracks skipped (no jellyfin ID or other reasons)
*/
skipped: number }
/**
* One rung of the quality picker, as it applies to *this* media source.
*
* The picker used to be filled from the fixed [`StreamingQuality::ALL`] ladder,
* which meant offering "20 Mbps" for a 1.1 Mbps podcast — eight rungs, six of
* them indistinguishable from Original. `exceeds_source` is what lets the
* frontend render that honestly without knowing anything about bitrates.
*
* TRACES: UR-070, UR-079 | DR-227, DR-121
*/
export type QualityOption = { quality: StreamingQuality;
/**
* Human label ("8 Mbps"). Lives in Rust beside the number it describes.
*/
label: string;
/**
* Secondary line ("1080p").
*/
detail: string;
/**
* True when this rung's ceiling is at or above what the source itself
* carries, so selecting it yields the same stream as `Original`.
*
* The frontend renders these differently (or hides them); it does not
* decide which they are.
*/
exceedsSource: boolean;
/**
* The source's own bitrate, when the server reported one. Presentation
* only — the picker shows "Original (6.7 Mbps)" rather than a bare word.
*/
sourceBitrate: number | null }
/**
* Response for queue queries
*/
export type QueueStatus = { items: PlayerMediaItem[]; currentIndex: number | null; shuffle: boolean; repeat: RepeatMode; hasNext: boolean; hasPrevious: boolean }
/**
* Remote session status for UI updates
*/
export type RemoteSessionStatus = { position: number; duration: number | null; isPlaying: boolean; nowPlayingItem: NowPlayingItem | null }
/**
* The rendition actually negotiated — what the viewer is receiving right now.
*
* `None` on a [`StreamSelection`] when the source is being direct-played as-is:
* there is no *chosen* rendition in that case, only the file itself, and
* reporting the ceiling that happened to be set would misdescribe it.
*
* TRACES: UR-079 | DR-225, DR-226
*/
export type Rendition = {
/**
* The rung of the ladder this stream was built against.
*/
quality: StreamingQuality;
/**
* Total bits per second the stream may use, when a ceiling applies.
*/
maxBitrate: number | null;
/**
* Resolution ceiling, when one applies. `None` preserves the source's.
*/
maxHeight: number | null;
/**
* Video codec the server was asked to produce.
*/
videoCodec: string | null;
/**
* Audio codec the server was asked to produce.
*/
audioCodec: string | null }
/**
* Repeat mode for the queue
*
* TRACES: UR-005 | DR-005
*/
export type RepeatMode = "off" | "all" | "one"
export type ResumeQueuedResult = {
/**
* Rows whose stream URL was resolved and are now pump-eligible.
*/
resolved: number;
/**
* Rows that couldn't be resolved (item metadata / URL lookup failed).
*/
failed: number }
/**
* Options for search queries
*/
export type SearchOptions = { limit?: number | null; includeItemTypes?: string[] | null; searchTerm?: string | null;
/**
* Opaque scope selected by the UI. When set it **wins** over
* `include_item_types`, which remains for the non-search `get_items`
* callers that legitimately request a single concrete type.
*/
scope?: SearchScope | null }
/**
* Search result with pagination
*/
export type SearchResult = { items: MediaItem[]; totalRecordCount: number }
/**
* An opaque search scope the frontend selects; Rust owns what it *means*.
*
* The expansion table below is Jellyfin domain vocabulary: it changes when
* Jellyfin adds or renames an item type, never when the UI is redesigned. It
* previously lived in the frontend (`searchScope.ts`), which is the boundary
* leak documented in docs/specs/scoped-search-boundary.md. The frontend now
* sends the enum and never names an item type in connection with search.
*
* TRACES: UR-049 | DR-063
*/
export type SearchScope = "all" | "music" | "movies" | "tv"
/**
* Security status info
*/
export type SecurityStatus = { usingKeyring: boolean; storageType: string }
/**
* Audio track preference for a series
*/
export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null }
/**
* Server info returned to frontend
*/
export type ServerInfo = { id: string; name: string; url: string; version: string | null }
/**
* Active session for restoration
*/
export type Session = { userId: string; username: string; serverId: string; serverUrl: string; serverName: string; accessToken: string; verified: boolean; needsReauth: boolean }
/**
* Session information from Jellyfin
*/
export type SessionInfo = { id?: string | null; userId?: string | null; userName?: string | null; client?: string | null; deviceName?: string | null; deviceId?: string | null; applicationVersion?: string | null; isActive?: boolean | null; supportsMediaControl?: boolean | null; supportsRemoteControl?: boolean; nowPlayingItem?: NowPlayingItem | null; playState?: PlayState | null; playableMediaTypes?: string[] | null; supportedCommands?: string[] | null }
/**
* Sleep timer mode - determines when playback should stop
* TRACES: UR-026 | DR-029
*/
export type SleepTimerMode =
/**
* Timer is off
*/
{ kind: "off" } |
/**
* Stop after a specific time duration
*/
{ kind: "time"; endTime: number } |
/**
* Stop at the end of current track
*/
{ kind: "endOfTrack" } |
/**
* Stop after N more episodes complete (TV episodes only, not audio tracks)
*/
{ kind: "episodes"; remaining: number }
/**
* Sleep timer state
*/
export type SleepTimerState = { mode: SleepTimerMode; remainingSeconds: number }
/**
* SmartCache statistics
*/
export type SmartCacheStats = { total_size: number; storage_limit: number; available_space: number; items_count: number; config: CacheConfig }
/**
* Storage statistics for downloads
*/
export type StorageStats = { total_bytes: number; total_items: number; albums: AlbumStorageInfo[] }
/**
* The kind of a media stream within an item (audio track, video track,
* subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.
*/
export type StreamKind = "audio" | "video" | "subtitle" |
/**
* Any stream kind we do not model explicitly (e.g. embedded image, data).
*/
"other"
/**
* Response for a mid-playback streaming-quality change.
*
* Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
* has to reload anything, so no strategy branch lives in the UI.
*
* TRACES: UR-074 | DR-162
*/
export type StreamQualityResponse =
/**
* The native backend was reloaded here; nothing left for the frontend to
* *do* — but it still has to be told what was negotiated.
*
* This carried only a position at first, which left the picker on Android
* pinned to the rendition of the *first* stream: the UI derives the rung in
* force from the selection it holds, nothing replaced that selection on the
* native path, and a transcode always has a rendition — so the fallback
* that would have used the requested value was never reached. The stream
* changed and the menu did not.
*
* TRACES: UR-074, UR-079 | DR-226, DR-227
*/
{ strategy: "native"; selection: StreamSelection; position: number } |
/**
* HTML5 must reload its element with this selection.
*/
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
/**
* Everything a player backend needs to open a stream, and everything the UI
* needs to describe it.
*
* Replaces the bare `String` URL that `get_video_stream_url` used to return.
*
* TRACES: UR-079 | DR-225, DR-227, DR-228
*/
export type StreamSelection = {
/**
* The URL (or loopback URL) to open.
*/
url: string;
/**
* How to fetch it. Replaces the `.m3u8` substring check.
*/
transport: Transport;
/**
* What the server is doing to the source to produce it.
*/
playbackKind: PlaybackKind;
/**
* The negotiated rendition; `None` when direct-playing the source as-is.
*/
rendition: Rendition | null;
/**
* What this media source can offer, for the quality picker (DR-227).
*/
available: QualityOption[];
/**
* The media source this selection is for, so a later re-open (quality
* change, audio-track switch, transcoded seek) targets the same one.
*/
mediaSourceId: string | null;
/**
* The transcode identity the server keyed this job by, when there is one.
*/
playSessionId: string | null;
/**
* Whether the server is spending encoder time on this stream.
*
* Derived from [`playback_kind`](Self::playback_kind) rather than left for
* the frontend to compute: "which kinds count as transcoding" is a domain
* rule, and a direct *stream* is a remux that must not be counted. The
* queue's long-standing `needs_transcoding` flag and the seek strategy both
* read this, so there is one answer rather than three.
*
* TRACES: UR-079 | DR-225, DR-228
*/
needsTranscoding: boolean }
/**
* A ceiling on how much bandwidth a *video* stream may consume.
*
* A quality step is a bundle of concrete transcode parameters — total stream
* ceiling, the audio share of it, and the resolution that ceiling can carry —
* not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
* they live here and the frontend only ever names a variant; the labels the
* picker shows are served over IPC by `player_get_streaming_qualities`.
*
* The ladder is deliberately expressed in bandwidth rather than resolution: it
* exists to fit a connection, and the resolution cap is chosen *from* the
* bitrate so the encoder does not spend a small budget on pixels it cannot
* afford. See docs/architecture/01-rust-backend.md ("Streaming quality
* ladder").
*
* TRACES: UR-074 | DR-162
*/
export type StreamingQuality =
/**
* No client-imposed cap — the server may direct-play the source as-is.
*/
"original" | "mbps20" | "mbps10" | "mbps8" | "mbps4" | "mbps2" | "mbps1" | "kbps720"
/**
* Represents a subtitle track
*
* 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
* struct in the player that deliberately keeps snake_case on the wire, because
* the *same* serialization feeds two consumers that both spell `mime_type`:
*
* * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
* with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
* whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
* * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
* from the frontend, and the generated binding (`SubtitleTrack` in
* `bindings.ts`) therefore also declares `mime_type`.
*
* Renaming would not break the build and would not fail the IPC: Kotlin's
* `optString` would just fall back to its default MIME type for every track, so
* the failure would be silent. UT-146 asserts the serialized keys.
*
* TRACES: UR-020 | IR-016, JA-008 | UT-146
*/
export type SubtitleTrack = {
/**
* Stream index in the media source
*/
index: number;
/**
* Subtitle URL
*/
url: string;
/**
* Language code (e.g., "eng", "spa")
*/
language: string | null;
/**
* Display title
*/
label: string | null;
/**
* MIME type (e.g., "text/vtt", "application/x-subrip").
* Snake_case on purpose — see the note on the struct.
*/
mime_type: string }
/**
* Sync queue item returned to frontend
*/
export type SyncQueueItem = { id: number; userId: string; operation: string; itemId: string | null; payload: string | null; status: string; retryCount: number; createdAt: string | null; errorMessage: string | null;
/**
* Cached title of the item the operation is about, when the catalog knows
* it. Resolved here rather than by a per-row frontend fetch — the queue
* list is otherwise a wall of opaque ids.
*
* TRACES: UR-025 | DR-132
*/
itemName: string | null }
/**
* Statistics about the thumbnail cache
*/
export type ThumbnailCacheStats = { totalSizeBytes: number; itemCount: number; limitBytes: number }
/**
* How the bytes of a chosen stream are fetched.
*
* This field exists to delete a substring search. The frontend previously
* decided which loader to attach by testing `url.contains(".m3u8")`, which is a
* domain fact reconstructed in the presentation layer — the same class of leak
* as the item-type taxonomy that `check:boundary` guards, and one that breaks
* silently the moment a server serves a playlist from a path that does not end
* in `.m3u8`, or serves a progressive file from one that does.
*
* Tagged (`{"type":"hls"}`) rather than a bare string so the frontend matches a
* discriminant instead of comparing text.
*
* TRACES: UR-079 | DR-225
*/
export type Transport =
/**
* An HLS playlist. The webview attaches hls.js (or Safari's native loader);
* ExoPlayer uses its HLS media source.
*/
{ type: "hls" } |
/**
* A single progressive HTTP resource, seekable by byte range.
*/
{ type: "progressive" } |
/**
* A file already on disk — a completed download, or the loopback media
* server standing in front of one.
*/
{ type: "localFile" }
/**
* User information
*/
export type User = { id: string; name: string; serverId: string; primaryImageTag: string | null }
/**
* User-specific data for an item (playback state, favorites, etc.)
*/
export type UserData = {
/**
* Legacy Jellyfin resume position in ticks. Being replaced by
* `playback_position_ms`; dual-carried while the frontend migrates
* (docs/specs/frontend-domain-model.md). New code should read the ms field.
*/
playbackPositionTicks?: number | null;
/**
* Resume position in milliseconds — the neutral replacement for
* `playback_position_ticks`. Populated from ticks by the mapping; the
* frontend never divides ticks itself.
*/
playbackPositionMs?: number | null; isPlayed?: boolean | null; isFavorite?: boolean | null; playCount?: number | null; lastPlayedDate?: string | null; playbackContextType?: string | null; playbackContextId?: string | null }
/**
* User info returned to frontend
*/
export type UserInfo = { id: string; serverId: string; username: string; isActive: boolean }
/**
* Backend type for video playback
*/
export type VideoBackend =
/**
* Native backend (ExoPlayer on Android, libmpv on Linux)
*/
"native" |
/**
* HTML5 video element fallback
*/
"html5"
/**
* Response for video seek operations
*/
export type VideoSeekResponse =
/**
* Use native seeking (HLS or direct stream)
*/
{ strategy: "native"; position: number } |
/**
* Reload stream from new position (transcoded non-HLS)
*/
{ strategy: "reloadStream"; selection: StreamSelection; seek_offset: number }
/**
* Video playback settings
*/
export type VideoSettings = {
/**
* Enable auto-play of next episode (with countdown)
*/
autoPlayNextEpisode: boolean;
/**
* Countdown duration in seconds before auto-play (5-30 seconds)
*/
autoPlayCountdownSeconds: number;
/**
* Maximum number of episodes to auto-play consecutively (0 = unlimited)
*/
autoPlayMaxEpisodes?: number;
/**
* Bandwidth ceiling applied to every video stream.
*
* `#[serde(default)]` so settings JSON persisted before this field existed
* loads as the previous behaviour (uncapped).
*
* TRACES: UR-074 | DR-162
*/
streamingQuality?: StreamingQuality }
/**
* Volume normalization levels matching Spotify's presets
*/
export type VolumeLevel =
/**
* Louder output (-11 LUFS)
*/
"loud" |
/**
* Default level (-14 LUFS)
*/
"normal" |
/**
* Quieter output (-23 LUFS)
*/
"quiet"
/** tauri-specta globals **/
import {
invoke as TAURI_INVOKE,
Channel as TAURI_CHANNEL,
} from "@tauri-apps/api/core";
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
type __EventObj__<T> = {
listen: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
once: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: null extends T
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};
export type Result<T, E> =
| { status: "ok"; data: T }
| { status: "error"; error: E };
function __makeEvents__<T extends Record<string, any>>(
mappings: Record<keyof T, string>,
) {
return new Proxy(
{} as unknown as {
[K in keyof T]: __EventObj__<T[K]> & {
(handle: __WebviewWindow__): __EventObj__<T[K]>;
};
},
{
get: (_, event) => {
const name = mappings[event as keyof T];
return new Proxy((() => {}) as any, {
apply: (_, __, [window]: [__WebviewWindow__]) => ({
listen: (arg: any) => window.listen(name, arg),
once: (arg: any) => window.once(name, arg),
emit: (arg: any) => window.emit(name, arg),
}),
get: (_, command: keyof __EventObj__<any>) => {
switch (command) {
case "listen":
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
case "once":
return (arg: any) => TAURI_API_EVENT.once(name, arg);
case "emit":
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
}
},
});
},
},
);
}