tauri-specta propagates Rust doc comments into bindings.ts as JSDoc, so adding TRACES comments to command functions changes generated output. Regeneration happens at build time, so this was left dirty by the branch that added them. Doc-comment-only: no signature or exported-symbol changes. Also records the audit corrections made during device verification (B1 mechanism, B7 re-framing, B8, D3 magnitude).
3235 lines
118 KiB
TypeScript
3235 lines
118 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 });
|
|
},
|
|
/**
|
|
* 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 });
|
|
},
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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 (.m3u8): 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 *and* to everything started afterwards
|
|
* (it sets the process-wide ceiling), but it is deliberately **not** persisted:
|
|
* the in-player picker is a "this film, this connection" control, and the
|
|
* durable default belongs to Settings. `player_set_video_settings` is the one
|
|
* that writes to the database.
|
|
*
|
|
* TRACES: UR-074 | DR-162
|
|
*/
|
|
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 });
|
|
},
|
|
/**
|
|
* 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 });
|
|
},
|
|
/**
|
|
* 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 });
|
|
},
|
|
/**
|
|
* 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 });
|
|
},
|
|
/**
|
|
* 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"; new_url: string; 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 }
|
|
/**
|
|
* 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 }
|
|
/**
|
|
* 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"
|
|
/**
|
|
* 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 }
|
|
/**
|
|
* 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 }
|
|
/**
|
|
* 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;
|
|
/**
|
|
* 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 }
|
|
/**
|
|
* 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;
|
|
/**
|
|
* 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 }
|
|
/**
|
|
* 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 }
|
|
/**
|
|
* 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.
|
|
*/
|
|
{ strategy: "native"; position: number } |
|
|
/**
|
|
* HTML5 must reload its element with this URL.
|
|
*/
|
|
{ strategy: "reloadStream"; new_url: string; position: number }
|
|
/**
|
|
* 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/specs/streaming-bitrate-cap.md.
|
|
*
|
|
* 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 }
|
|
/**
|
|
* 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"; new_url: string; 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);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
},
|
|
);
|
|
}
|