Files
jellytau/src-tauri/src/commands/player/mod.rs
T
dtourolle 109700b949 feat(playback): let Rust decide what stream to play, and say so
Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.

One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.

Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:

  Linux / WebKitGTK (h264 only, 2ch)          3/40 —  7% direct play
  Android / ExoPlayer (hevc, ac3/eac3, 6ch)  34/40 — 85% direct play

The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.

DR-219  StreamSelection: url + tagged Transport (hls/progressive/localFile)
        + PlaybackKind (directPlay/directStream/transcode) + the negotiated
        rendition + this source's ladder + a needs_transcoding flag derived
        in Rust so the rule is answered once. Both enums are serde-tagged
        so the frontend matches a discriminant, not a substring. The paths
        that never negotiate get the same shape from Rust rather than
        assembling one — media_local_selection for a downloaded file,
        LiveStreamInfo.transport for a live channel — so there is no second
        place where a transport is decided.

DR-220  The ceiling becomes two levels: a durable device default (Settings,
        persisted) and a per-playback override the in-player picker sets.
        The picker had called itself a "this film, this connection" control
        since it was written but wrote the process-wide default, so dropping
        one awkward film to 2 Mbps silently capped every video played
        afterwards for the rest of the process, with Settings still showing
        the old value. The override is cleared whenever playback moves to a
        new item, which stops it surviving into an autoplayed next episode.
        effective_streaming_quality() is the single resolution point.

DR-221  The quality picker is filled from what this media source can offer.
        Rust marks a rung exceeds_source when its ceiling is at or above the
        source's own bitrate — such a rung is another way to spell Original
        — and the frontend does not draw those. Original is never marked; a
        source whose bitrate the server does not report marks nothing, which
        keeps every rung offered.

DR-222  Direct play and direct stream are negotiated, with two client-side
        overrides on top because the server's answer is right about the file
        and wrong about what this app will do with it: undecodable audio
        (Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
        codec but ignores its audio codec, so it offers direct play for an
        E-AC-3 track the webview renders in silence) and a viewer-pinned
        audio track the file does not default to. A direct stream is a remux
        and is deliberately not counted as transcoding.

DR-223  Dropped on measurement, not deferred. A master playlist from this
        server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
        the single rendition the request asked for rather than publishing a
        ladder. So there is no adaptation for hls.js to be preserving and
        none mpv would lose — the claim that there was, in
        playback-backend-unification.md, does not hold. Recorded rather than
        deleted because it is a measurement: a server that does publish a
        ladder would change the answer.

DR-224  Every backend consumes the same selection. The queue item carries
        the transport, so player_seek_video picks its seek strategy from the
        backend's decision instead of the last stream_url.contains(".m3u8")
        in the codebase. Items queued by a path that never negotiated carry
        None and fall back to needs_transcoding, which is exact rather than
        a guess because every transcode this app requests is HLS (DR-140).

The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.

Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.

The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.

Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
2026-08-22 13:45:03 +02:00

3501 lines
131 KiB
Rust

//! TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001
// Cohesive command clusters live in their own submodules and are re-exported so
// the command names remain at `commands::player::*` (invoke_handler unchanged).
mod queue;
mod remote;
mod session;
mod settings;
mod timers;
pub use queue::*;
pub use remote::*;
pub use session::*;
pub use settings::*;
pub use timers::*;
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tauri::State;
use tokio::sync::Mutex as TokioMutex;
use super::DatabaseWrapper;
use crate::download::cache::{CacheConfig, SmartCache};
use crate::jellyfin::{JellyfinClient, JellyfinConfig};
use crate::player::{
determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
};
use crate::repository::{
types::{GetItemsOptions, ImageOptions, ImageType},
MediaRepository, StreamSelection,
};
use crate::settings::VideoSettings;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// SmartCache wrapper for Tauri state management
pub struct SmartCacheWrapper(pub Mutex<SmartCache>);
/// Player state wrapper for Tauri.
///
/// Uses Arc to allow sharing with the MediaSession handler on Android
/// for lockscreen control integration.
///
/// @req: UR-005 - Control media playback
/// @req: DR-001 - Player state machine
pub struct PlayerStateWrapper(pub Arc<TokioMutex<PlayerController>>);
/// Media session manager wrapper for Tauri state management
///
/// @req: DR-009 - Audio player UI (mini player, full screen)
pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
/// Video settings state wrapper for Tauri
///
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
/// Response for player state queries
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerStatus {
pub state: PlayerState,
pub position: f64,
pub duration: Option<f64>,
pub volume: f32,
pub muted: bool,
pub shuffle: bool,
pub repeat: RepeatMode,
/// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
pub backend: VideoBackend,
/// Whether frontend should render HTML5 video element
pub use_html5_element: bool,
// Merged fields (prefer remote session when available)
/// Media item from either local queue or remote session
pub merged_media: Option<MergedMediaItem>,
/// Playing state from either local player or remote session
pub merged_is_playing: bool,
/// Volume from either local player or remote session (0-1 normalized)
pub merged_volume: f32,
}
/// Lightweight media item for merged playback state
/// Converts from both local MediaItem and remote NowPlayingItem
#[derive(specta::Type, Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MergedMediaItem {
pub id: String,
pub title: String,
pub artist: Option<String>,
pub album: Option<String>,
pub album_id: Option<String>,
pub duration: Option<f64>,
pub primary_image_tag: Option<String>,
/// Neutral image identifier — replaces `primary_image_tag` (same value).
pub image_id: Option<String>,
pub media_type: String,
}
// Convert from local MediaItem
impl From<&crate::player::MediaItem> for MergedMediaItem {
fn from(item: &crate::player::MediaItem) -> Self {
Self {
id: item.id.clone(),
title: item.title.clone(),
artist: item.artist.clone(),
album: item.album.clone(),
album_id: item.album_id.clone(),
duration: item.duration,
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
media_type: match item.media_type {
crate::player::MediaType::Audio => "audio".to_string(),
crate::player::MediaType::Video => "video".to_string(),
},
}
}
}
// Convert from remote NowPlayingItem
impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self {
Self {
id: item.id.clone().unwrap_or_default(),
title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()),
artist: item
.album_artist
.clone()
.or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())),
album: item.album.clone(),
album_id: item.album_id.clone(),
duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
media_type: item
.item_type
.clone()
.unwrap_or_else(|| "audio".to_string())
.to_lowercase(),
}
}
}
/// Response for queue queries
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QueueStatus {
pub items: Vec<MediaItem>,
pub current_index: Option<usize>,
pub shuffle: bool,
pub repeat: RepeatMode,
pub has_next: bool,
pub has_previous: bool,
}
/// Backend type for video playback
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VideoBackend {
/// Native backend (ExoPlayer on Android, libmpv on Linux)
Native,
/// HTML5 video element fallback
Html5,
}
/// 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.
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayItemRequest {
pub id: String,
pub title: String,
pub stream_url: String,
/// Video codec (e.g., "h264", "hevc") for video media
pub video_codec: String,
/// Whether the video requires server-side transcoding
pub needs_transcoding: bool,
/// How this item's stream is fetched, as the backend decided it.
///
/// Carried on the queue item so a later seek/reload does not have to guess.
/// `None` for items queued by a path that never negotiated (audio tracks,
/// direct URLs) and for anything queued before this field existed, where the
/// caller falls back to `needs_transcoding` — every transcode this app
/// requests is HLS (DR-140), so that fallback is exact rather than a guess.
///
/// TRACES: UR-003, UR-004, UR-079 | DR-224, DR-229
#[serde(default)]
pub transport: Option<crate::repository::Transport>,
/// 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.
#[serde(default)]
pub artist: Option<String>,
#[serde(default)]
pub primary_image_tag: Option<String>,
#[serde(default)]
pub server_id: Option<String>,
/// 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.
#[serde(default)]
pub duration_seconds: Option<f64>,
/// 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.
#[serde(default)]
pub item_type: Option<String>,
/// Series ID for TV episodes. Needed alongside `item_type` so the backend can
/// look up the next episode when a background-audio track ends.
#[serde(default)]
pub series_id: Option<String>,
/// 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
#[serde(default)]
pub subtitles: Vec<crate::player::SubtitleTrack>,
}
/// Queue context for remote transfer - what type of queue is this?
#[derive(specta::Type, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PlayQueueContext {
/// Playing from a specific album
Album {
#[serde(rename = "albumId")]
album_id: String,
#[serde(rename = "albumName")]
album_name: String,
},
/// Playing from a specific playlist
Playlist {
#[serde(rename = "playlistId")]
playlist_id: String,
#[serde(rename = "playlistName")]
playlist_name: String,
},
/// Custom queue (search results, manual queue, etc.)
Custom,
}
/// Request to play a queue of items
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayQueueRequest {
pub items: Vec<PlayItemRequest>,
pub start_index: usize,
pub shuffle: bool,
/// Optional context for the queue (album, playlist, or custom)
/// Used for remote playback transfer
#[serde(default)]
pub context: Option<PlayQueueContext>,
}
/// Request to play a track from an album (backend fetches all tracks)
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayAlbumTrackRequest {
pub album_id: String,
pub album_name: String,
pub track_id: String,
pub shuffle: bool,
}
/// Request to play tracks by ID (backend fetches metadata)
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayTracksRequest {
pub track_ids: Vec<String>,
pub start_index: usize,
pub shuffle: bool,
pub 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.
#[serde(default)]
pub start_position: Option<f64>,
}
/// Context information for track playback
#[derive(specta::Type, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PlayTracksContext {
Playlist {
#[serde(rename = "playlistId")]
playlist_id: String,
#[serde(rename = "playlistName")]
playlist_name: String,
},
Search {
#[serde(rename = "searchQuery")]
#[allow(dead_code)] // Used for deserialization, may be used later for analytics
search_query: String,
},
Custom {
#[serde(rename = "label")]
#[allow(dead_code)] // Used for deserialization, may be used later for UI display
label: Option<String>,
},
}
/// Response for video seek operations
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum VideoSeekResponse {
/// Use native seeking (HLS or direct stream)
Native {
/// Confirmed position after seek
position: f64,
},
/// Reload stream from new position (transcoded non-HLS)
ReloadStream {
/// What to open, and how — transport included, so the frontend picks
/// its loader from a tagged enum rather than by searching the URL for
/// `.m3u8`. TRACES: UR-079 | DR-224
selection: StreamSelection,
/// `seek_offset` carries the position to RESUME AT, not a base to add to
/// the element's clock. The reloaded stream starts at the item's zero —
/// a position on an HLS playlist makes the server 400 every segment
/// behind it (DR-181) — so the adapter reaches the position by seeking
/// the element and leaves the transcode offset at zero.
seek_offset: f64,
},
}
/// Response for audio track switching operations
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum AudioTrackSwitchResponse {
/// Native backend handled it (Android ExoPlayer)
Native {
/// Confirmation message
success: bool,
},
/// HTML5 needs to reload stream with new audio track
ReloadStream {
/// What to open, and how. TRACES: UR-079 | DR-224
selection: StreamSelection,
/// Current position to resume from
position: f64,
},
}
/// 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
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum StreamQualityResponse {
/// The native backend was reloaded here; nothing left for the frontend.
Native {
/// Position playback resumed at.
position: f64,
},
/// HTML5 must reload its element with this selection.
ReloadStream {
/// What to open, and how — already negotiated against the requested
/// ceiling. Carries `available` too, so a picker opened after a quality
/// change still describes the source correctly.
/// TRACES: UR-070, UR-079 | DR-224, DR-226
selection: StreamSelection,
/// Position to resume from.
position: f64,
},
}
/// Helper function to create MediaItem from video request
///
/// PlayItemRequest is now video-only, so we create a video MediaItem.
/// Audio playback uses player_play_tracks which fetches full metadata from backend.
pub(super) async fn create_media_item(
req: PlayItemRequest,
db: Option<&DatabaseWrapper>,
) -> Result<MediaItem, String> {
// For video-only requests, we use the item ID as the jellyfin ID
let jellyfin_id = req.id.clone();
// Check if item is downloaded locally
let local_path = if let Some(db_wrapper) = db {
check_for_local_download(db_wrapper, &jellyfin_id).await?
} else {
None
};
let source = if let Some(path) = local_path {
MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(jellyfin_id.clone()),
}
} else {
MediaSource::Remote {
stream_url: req.stream_url,
jellyfin_item_id: jellyfin_id.clone(),
}
};
Ok(MediaItem {
id: req.id.clone(),
title: req.title.clone(),
name: Some(req.title.clone()),
artist: None, // Not available from video-only request
album: None, // Not available from video-only request
album_name: None, // Not available from video-only request
album_id: None, // Not available from video-only request
artist_items: None, // Not available from video-only request
artists: None, // Not available from video-only request
primary_image_tag: None, // Not available from video-only request
image_id: None,
item_type: None, // Not available from video-only request
playlist_id: None, // Not available from video-only request
duration: None, // Not available from video-only request
artwork_url: None, // Not available from video-only request
media_type: crate::player::MediaType::Video, // Video-only request
source,
video_codec: Some(req.video_codec),
needs_transcoding: req.needs_transcoding,
// The caller's negotiated transport, when it had one. TRACES: UR-079 | DR-229
transport: req.transport,
video_width: None, // Not available from video-only request
video_height: None, // Not available from video-only request
// Sideloaded subtitles, in the order the frontend sent them — that order
// is what `player_set_subtitle_track(n)` indexes into on Android.
// TRACES: UR-020 | IR-016 | UT-145
subtitles: req.subtitles,
series_id: None, // Not available from video-only request
server_id: None, // Not available from video-only request
})
}
/// Pick the source for an audio-only handoff.
///
/// A downloaded file wins over the audio-only stream URL. No transcode or audio
/// extraction is involved or wanted: the native backends already play a video
/// container without decoding its video — the Linux MPV backend is configured
/// with `video: no`, and ExoPlayer simply has no surface to render to when the
/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
/// CPU and battery, need an encoder the project does not ship, and leave a
/// second artifact to keep in step with the first.
///
/// TRACES: UR-071 | DR-128 | UT-119
pub(super) fn background_audio_source(
local_path: Option<String>,
stream_url: String,
item_id: &str,
) -> MediaSource {
match local_path {
Some(path) => MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(item_id.to_string()),
},
None => MediaSource::Remote {
stream_url,
jellyfin_item_id: item_id.to_string(),
},
}
}
/// How a background-audio handoff must start playback, given where its audio
/// actually begins.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
pub(super) struct BackgroundAudioPlan {
/// The position the stream's own zero corresponds to, recorded as the
/// handoff base so later readings can be shifted back to the episode's
/// timeline.
pub base_seconds: f64,
/// Where to seek after loading, if the source does not already start there.
pub seek_to: Option<f64>,
}
/// Decide the base and the seek for a handoff at `position_seconds`.
///
/// The two sources start in different places. An audio-only **stream** is built
/// with `StartTimeTicks`, so the server makes the handoff point that stream's
/// zero: the base is the handoff position, and seeking would skip *past* the
/// content by that much again. A downloaded **file** has no such parameter and
/// begins at the episode's own zero, so it needs the opposite — no base, and a
/// real seek. Treating a file like a stream is why backgrounding a downloaded
/// episode restarted it from 0:00 while the lockscreen showed the right time.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
pub(super) fn background_audio_plan(
is_local_file: bool,
position_seconds: f64,
) -> BackgroundAudioPlan {
let position = position_seconds.max(0.0);
if is_local_file {
BackgroundAudioPlan {
base_seconds: 0.0,
seek_to: (position > 0.0).then_some(position),
}
} else {
BackgroundAudioPlan {
base_seconds: position,
seek_to: None,
}
}
}
/// Resolve the on-disk file backing a completed download, if there is one.
///
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
/// deletion, a cleared cache directory, a restored database). Every caller wants
/// "can I play this from disk right now", so existence is checked here rather
/// than trusted from the row.
///
/// Split out from [`check_for_local_download`] so the resolution is testable
/// without a `DatabaseWrapper`, and reusable by the video path.
///
/// TRACES: UR-071 | DR-123 | UT-116
pub(super) async fn resolve_local_media_path<S: DatabaseService>(
db_service: &Arc<S>,
item_id: &str,
) -> Result<Option<String>, String> {
let query = Query::with_params(
"SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
vec![QueryParam::String(item_id.to_string())],
);
let path: Option<String> = db_service
.query_optional(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
match path {
Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
Some(file_path) => {
warn!(
"[Player] Download entry exists in DB but file not found: {}",
file_path
);
Ok(None)
}
None => Ok(None),
}
}
/// Check if an item has a completed download
pub(super) async fn check_for_local_download(
db: &DatabaseWrapper,
item_id: &str,
) -> Result<Option<String>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
resolve_local_media_path(&db_service, item_id).await
}
/// 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
#[tauri::command]
#[specta::specta]
pub async fn player_local_media_path(
db: State<'_, DatabaseWrapper>,
item_id: String,
) -> Result<Option<String>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
resolve_local_media_path(&db_service, &item_id).await
}
/// Re-point queued streaming items at completed local downloads.
///
/// Sources are resolved once when the queue is built, so downloads that finish
/// while it plays (preloaded upcoming tracks) — or that existed before the
/// connection dropped — would otherwise keep streaming. Called before advancing
/// so the next track always prefers the on-disk copy.
///
/// Returns the number of items switched to a local source.
pub(super) async fn refresh_queue_local_sources(
controller: &PlayerController,
db: &DatabaseWrapper,
) -> Result<usize, String> {
// Collect remote item IDs first; the queue lock must not be held across awaits.
let remote_ids: Vec<String> = {
let queue = controller.queue();
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock
.items()
.iter()
.filter_map(|item| match &item.source {
MediaSource::Remote {
jellyfin_item_id, ..
} => Some(jellyfin_item_id.clone()),
_ => None,
})
.collect()
};
if remote_ids.is_empty() {
return Ok(0);
}
let mut local_paths: Vec<(String, String)> = Vec::new();
for id in remote_ids {
if let Some(path) = check_for_local_download(db, &id).await? {
local_paths.push((id, path));
}
}
if local_paths.is_empty() {
return Ok(0);
}
let queue = controller.queue();
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
let mut switched = 0;
for item in queue_lock.items_mut() {
if let MediaSource::Remote {
jellyfin_item_id, ..
} = &item.source
{
if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
info!(
"[Player] Switching queued track {} to local download: {}",
id, path
);
item.source = MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(id.clone()),
};
switched += 1;
}
}
}
Ok(switched)
}
/// 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
#[tauri::command]
#[specta::specta]
pub async fn player_play_item(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
item: PlayItemRequest,
) -> Result<PlayerStatus, String> {
info!(
"player_play_item called: {} - {}",
item.title, item.stream_url
);
// A ceiling chosen from the in-player picker belongs to the playback it was
// chosen for. Starting a different item returns to the device default —
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
// session, which is the defect DR-225 exists to close.
//
// TRACES: UR-074, UR-079 | DR-225
crate::repository::online::clear_playback_quality_override();
// Create media item, checking for local download first
let media_item = create_media_item(item, Some(&db)).await?;
// Start appropriate session based on media type
{
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
match media_item.media_type {
MediaType::Audio => {
session_mgr.start_audio_session(media_item.clone());
}
MediaType::Video => {
// For single video items, treat as Movie (no series_id available here)
session_mgr.start_movie_session(media_item.clone());
}
}
}
let controller = player.0.lock().await;
// On Linux, video plays in the WebKitGTK HTML5 <video> element (see
// get_player_status -> use_html5_element). The MPV backend has no embedded
// window, so loading the stream into it would only start a redundant decode
// (and the frontend would immediately stop it). Only load into the native
// backend on platforms that actually render video through it (e.g. Android).
#[cfg(not(target_os = "linux"))]
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
{
// Keep the queue in sync for UI/remote-transfer without starting MPV.
controller
.set_current_item(media_item)
.map_err(|e| e.to_string())?;
}
// Emit queue changed event
controller.emit_queue_changed();
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
Ok(get_player_status(&controller))
}
/// 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
#[tauri::command]
#[specta::specta]
pub async fn player_enter_background_audio(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
item: PlayItemRequest,
position_seconds: f64,
) -> Result<PlayerStatus, String> {
info!(
"player_enter_background_audio: {} @ {:.1}s",
item.title, position_seconds
);
// Prefer the downloaded file over the audio-only stream URL the frontend
// resolved. Handing the native backend a local video container yields
// audio-only playback for free — no transcode, no second artifact.
// TRACES: UR-071 | DR-128
let local_path = check_for_local_download(&db, &item.id).await?;
if local_path.is_some() {
info!(
"player_enter_background_audio: using downloaded file for {}",
item.id
);
}
// A downloaded file starts at the episode's zero; a stream starts at the
// handoff point. Only one of them has a base, and only the other needs a seek.
let plan = background_audio_plan(local_path.is_some(), position_seconds);
let source = background_audio_source(local_path, item.stream_url, &item.id);
// Build an AUDIO media item pointing at the audio-only stream. We do not use
// create_media_item() because that hardcodes MediaType::Video; background
// audio must be Audio so no video decode is started.
let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: item.id.clone(),
title: item.title.clone(),
name: Some(item.title.clone()),
artist: item.artist.clone(),
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
// Carry episode identity so autoplay can advance to the next episode when
// this audio-only handoff ends while backgrounded (UR-040).
item_type: item.item_type.clone(),
playlist_id: None,
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
duration: item.duration_seconds,
artwork_url: None,
media_type: MediaType::Audio,
source,
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: item.series_id.clone(),
server_id: item.server_id.clone(),
};
{
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
session_mgr.start_audio_session(media_item.clone());
}
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
// relative to the stream's StartTimeTicks zero, but the metadata duration is
// absolute, so shift the reported position back to absolute for the scrubber.
let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
let controller = player.0.lock().await;
// Remember where the video was: for a stream the audio's zero == this
// position (the URL was built with StartTimeTicks=position_seconds), so on
// exit we add this base to the native player's relative position to get the
// absolute one. The controller owns it so a backend-driven advance to the
// next episode clears it along with the stream it described.
controller.enter_background_audio(plan.base_seconds);
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
// Seek ONLY a local file. The audio-only URL already starts at the handoff
// position via StartTimeTicks — its timeline begins at 0 == that point — so
// seeking a stream would jump PAST the content by the handoff position again.
if let Some(seek_to) = plan.seek_to {
info!(
"player_enter_background_audio: seeking the downloaded file to {:.1}s",
seek_to
);
controller.seek(seek_to).map_err(|e| e.to_string())?;
}
controller.emit_queue_changed();
if let Some(emitter) = controller.event_emitter() {
let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
Ok(get_player_status(&controller))
}
/// Exit background-audio mode: stop the native audio player and return its final
/// position so the frontend can reload the WebView `<video>` there (UR-040).
///
/// Returns the position in seconds. The sleep timer is intentionally left
/// untouched — if it fired while backgrounded, playback is already stopped and
/// this simply reports the last position.
///
/// What playback should do now that the app is no longer visible.
///
/// The caller supplies only what it alone knows -- whether the per-player
/// toggle is armed, and whether Android put the window into picture-in-picture.
/// Everything else (what is playing, and therefore whether there is a picture to
/// lose) is read here, because it is domain state.
///
/// The rule itself is in `player::background_policy`; this command is the wire.
/// Returning `KeepPlaying` for an empty queue is deliberate: with nothing
/// playing there is nothing to pause, and an error would make the frontend
/// handle a case that is not a failure.
///
/// TRACES: UR-040, UR-041 | DR-224 | UT-211
#[tauri::command]
#[specta::specta]
pub async fn player_background_action(
player: State<'_, PlayerStateWrapper>,
background_audio_armed: bool,
in_picture_in_picture: bool,
) -> Result<crate::player::background_policy::BackgroundAction, String> {
use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
let is_video = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
match queue.current() {
Some(item) => is_video_media(item.media_type),
None => return Ok(BackgroundAction::KeepPlaying),
}
};
let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
info!(
"[player_background_action] video={} armed={} pip={} -> {:?}",
is_video, background_audio_armed, in_picture_in_picture, action
);
Ok(action)
}
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
#[tauri::command]
#[specta::specta]
pub async fn player_exit_background_audio(
player: State<'_, PlayerStateWrapper>,
) -> Result<f64, String> {
let controller = player.0.lock().await;
// Read the position BEFORE clearing either base. The position tick applies the
// base natively, so a tick landing between "base cleared" and "position read"
// would hand back a relative position — the whole bug, reintroduced at the one
// moment it matters most. Capturing into a `let` before stop() is also the
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
// (DR-159)
//
// `absolute_position` rather than `position`, because a tick that has not
// landed *yet* is the same hazard from the other side: returning to the
// foreground while the audio-only transcode is still opening read 0.0, and
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
// beginning. Flooring at the handoff base cannot overshoot: the stream is
// physically incapable of being behind its own starting point. (DR-178)
let absolute = controller.absolute_position();
// Now safe to tear the handoff down, native side first.
let _ = crate::player::set_lockscreen_position_offset(0.0);
controller.exit_background_audio();
controller.stop().map_err(|e| e.to_string())?;
info!(
"player_exit_background_audio: resuming the video at {:.1}s",
absolute
);
Ok(absolute)
}
/// 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
#[tauri::command]
#[specta::specta]
pub async fn player_play_queue(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
request: PlayQueueRequest,
) -> Result<PlayerStatus, String> {
info!(
"player_play_queue called: {} items, start_index: {}, shuffle: {}",
request.items.len(),
request.start_index,
request.shuffle
);
// A ceiling chosen from the in-player picker belongs to the playback it was
// chosen for. Starting a different item returns to the device default —
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
// session, which is the defect DR-225 exists to close.
//
// TRACES: UR-074, UR-079 | DR-225
crate::repository::online::clear_playback_quality_override();
// Handle shuffle first
if request.shuffle {
let controller = player.0.lock().await;
controller.toggle_shuffle();
}
// Convert request context to internal QueueContext
let queue_context = match request.context {
Some(PlayQueueContext::Album {
album_id,
album_name,
}) => QueueContext::Album {
album_id,
album_name,
},
Some(PlayQueueContext::Playlist {
playlist_id,
playlist_name,
}) => QueueContext::Playlist {
playlist_id,
playlist_name,
},
Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
};
// Create media items, checking for local downloads (must not hold locks during await)
let mut items: Vec<MediaItem> = Vec::new();
for req in request.items {
items.push(create_media_item(req, Some(&db)).await?);
}
// Start appropriate session based on first item's media type
if let Some(first_item) = items.get(request.start_index) {
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
match first_item.media_type {
MediaType::Audio => {
session_mgr.start_audio_session(first_item.clone());
}
MediaType::Video => {
// Queue of videos treated as movie session (TV episodes use different flow)
session_mgr.start_movie_session(first_item.clone());
}
}
}
// Now play the queue and get status
let controller = player.0.lock().await;
info!(
"player_play_queue: Calling controller.play_queue with {} items at index {}",
items.len(),
request.start_index
);
controller
.play_queue(items, request.start_index)
.map_err(|e| e.to_string())?;
// Set the queue context for remote transfer
{
let queue_arc = controller.queue();
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
queue.set_context(queue_context);
}
// Emit queue changed event
controller.emit_queue_changed();
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_play(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send play command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client.send_session_command(session_id, "Unpause").await?;
} else {
// Local playback
let controller = player.0.lock().await;
controller.play().map_err(|e| e.to_string())?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_pause(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send pause command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client.send_session_command(session_id, "Pause").await?;
} else {
// Local playback
let controller = player.0.lock().await;
controller.pause().map_err(|e| e.to_string())?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_toggle(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send toggle command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client.send_session_command(session_id, "PlayPause").await?;
} else {
// Local playback
let controller = player.0.lock().await;
controller.toggle_playback().map_err(|e| e.to_string())?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_stop(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send stop command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client.send_session_command(session_id, "Stop").await?;
// Stopping the remote session ends the cast, so the manager returns to
// Idle — same as a local stop. This is also what hands OS volume control
// back to this device: set_mode releases the Android remote volume
// provider on any exit from remote mode. Without it the mode stayed
// Remote and the system volume slider remained stuck on the remote
// session with no way back to the local speaker.
playback_mode
.0
.set_mode(crate::playback_mode::PlaybackMode::Idle);
} else {
// Local playback
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
// A genuine local stop returns the manager to Idle so it no longer
// reports Local (or a stale Remote) — otherwise a later play/pause would
// route to the wrong device.
playback_mode
.0
.set_mode(crate::playback_mode::PlaybackMode::Idle);
// Handle session state based on type (local playback only)
{
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
let current_session = session_mgr.current().clone();
match current_session {
crate::player::MediaSessionType::Movie { .. } => {
// Movies auto-dismiss when stopped
session_mgr.movie_session_ended();
}
crate::player::MediaSessionType::Audio { .. } => {
// Audio persists as inactive (user can resume later)
session_mgr.audio_session_inactive();
}
crate::player::MediaSessionType::TvShow { .. } => {
// TV shows mark episode ended (waiting for next or dismiss)
session_mgr.tv_session_episode_ended();
}
crate::player::MediaSessionType::Idle => {
// Already idle, no-op
}
}
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
}
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_next(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> {
debug!("[player_next] Command called from frontend");
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send next track command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client.send_session_command(session_id, "NextTrack").await?;
} else {
// Local playback
let controller = player.0.lock().await;
// Prefer downloads that completed since the queue was built
if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
warn!("[player_next] Failed to refresh local sources: {}", e);
}
controller.next().map_err(|e| e.to_string())?;
controller.emit_queue_changed();
// Update audio session track if in audio session
let current_item = {
let queue = controller.queue();
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock.current().cloned()
};
if let Some(item) = current_item {
if item.media_type == MediaType::Audio {
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
session_mgr.update_audio_track(item);
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
}
}
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_previous(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send previous track command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client
.send_session_command(session_id, "PreviousTrack")
.await?;
} else {
// Local playback
let controller = player.0.lock().await;
// Prefer downloads that completed since the queue was built
if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
warn!("[player_previous] Failed to refresh local sources: {}", e);
}
controller.previous().map_err(|e| e.to_string())?;
controller.emit_queue_changed();
// Update audio session track if in audio session
let current_item = {
let queue = controller.queue();
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock.current().cloned()
};
if let Some(item) = current_item {
if item.media_type == MediaType::Audio {
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
session_mgr.update_audio_track(item);
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
}
}
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_seek(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
position: f64,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send seek command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
let position_ticks = (position * 10_000_000.0) as i64;
client.session_seek(session_id, position_ticks).await?;
} else {
// Local playback. seek_absolute, not seek: the position came from the UI,
// which shows the whole item, so during a background-audio handoff it has
// to be resolved against the episode's timeline rather than the handoff
// stream's. (DR-159)
let controller = player.0.lock().await;
controller.seek_absolute(position).await?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
/// Smart video seeking that decides between native and server-side seeking
///
/// This command analyzes the current video stream and automatically chooses
/// the best seeking strategy:
/// - HLS streams: Use native seeking
/// - Direct play streams: Use native seeking
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
///
/// For native (non-HTML5) backends, this command handles the entire stream reload
/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
#[tauri::command]
#[specta::specta]
pub async fn player_seek_video(
player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
repository_handle: String,
position: f64,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
use_html5: bool,
) -> Result<VideoSeekResponse, String> {
info!(
"[player_seek_video] Seeking to {} seconds (use_html5: {})",
position, use_html5
);
// Get repository
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Get current playing item to analyze stream characteristics
// Clone what we need to avoid holding locks across await points
let (needs_transcoding, jellyfin_item_id, is_local, transport) = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let current_item = queue.current().ok_or("No item currently playing")?;
if current_item.media_type != MediaType::Video {
return Err("Current item is not a video".to_string());
}
let jellyfin_id = current_item
.jellyfin_id()
.ok_or("Current video has no Jellyfin ID")?
.to_string();
// The URL itself is no longer read here: the seek strategy now comes
// from the item's own `transport`, not from inspecting the string.
let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
let needs_trans = current_item.needs_transcoding;
let transport = current_item.transport;
(needs_trans, jellyfin_id, is_local_file, transport)
}; // Locks are dropped here
// The transport comes from the backend's own decision, not from searching
// the URL for `.m3u8` — Rust built that URL and knows what it is. Items
// queued without one fall back to `needs_transcoding`, which is exact:
// every transcode this app requests is HLS (DR-140).
//
// TRACES: UR-004, UR-079 | DR-224, DR-229
let is_hls = match transport {
Some(crate::repository::Transport::Hls) => true,
Some(crate::repository::Transport::Progressive)
| Some(crate::repository::Transport::LocalFile) => false,
None => needs_transcoding,
};
let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);
info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, is_hls, needs_transcoding, use_html5, strategy);
match strategy {
VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
// Local files and native backend streams - call backend.seek()
info!("[player_seek_video] Using backend native seek");
let controller = player.0.lock().await;
controller.seek(position).map_err(|e| e.to_string())?;
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5NativeSeek => {
// HTML5 backend with HLS or direct play - frontend handles seeking
// We don't call backend.seek() because video is in HTML5 element, not in MPV
info!("[player_seek_video] HTML5 native seek - returning position for frontend");
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5ReloadStream => {
// Transcoded non-HLS with HTML5 - frontend handles stream reload
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
info!(
"[player_seek_video] Selected {:?} over {:?} for position {}",
selection.playback_kind, selection.transport, position
);
Ok(VideoSeekResponse::ReloadStream {
selection,
seek_offset: position,
})
}
VideoSeekStrategy::BackendReloadStream => {
// Transcoded non-HLS with native backend - backend handles stream reload
info!("[player_seek_video] Backend reload stream - requesting new stream URL");
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let new_url = selection.url.clone();
info!("[player_seek_video] Got new selection, handling reload internally");
// Stop current playback
{
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
}
// Update the stream URL in the queue
{
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
if !queue.update_current_stream_url(new_url.clone()) {
return Err("Failed to update stream URL in queue".to_string());
}
}
// Reload the player with the updated item from queue
{
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
if let Some(updated_item) = queue.current() {
controller
.load_and_play(updated_item)
.map_err(|e| e.to_string())?;
} else {
return Err("No current item after URL update".to_string());
}
// The re-opened stream begins at zero — the position cannot ride
// along in the URL without 400ing every segment (DR-181) — so the
// seek that the reload was asked for happens here.
controller.seek(position).map_err(|e| e.to_string())?;
}
info!(
"[player_seek_video] Stream reloaded successfully at position {}",
position
);
Ok(VideoSeekResponse::Native { position })
}
}
}
/// 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
#[tauri::command]
#[specta::specta]
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
// input. Folding the rest into a struct would change the IPC contract and the
// generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn player_switch_audio_track(
player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
repository_handle: String,
stream_index: i32,
array_index: i32,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
) -> Result<AudioTrackSwitchResponse, String> {
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
stream_index, array_index, use_html5);
if use_html5 {
// HTML5 backend needs stream reload
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Get current item to find Jellyfin ID
let jellyfin_item_id = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let current_item = queue.current().ok_or("No item currently playing")?;
current_item
.jellyfin_id()
.ok_or("Current item has no Jellyfin ID")?
.to_string()
};
// Select a stream carrying the chosen audio track. It starts at zero —
// an HLS playlist cannot carry a position (DR-181) — and `position`
// below tells the frontend where to seek the reloaded element back to.
//
// Pinning a track is itself a reason the source cannot be direct-played:
// the file has one default track and the viewer asked for another, so
// the negotiation returns a transcode. That decision lives in
// `decide_playback_kind`, not here.
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
Some(stream_index),
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
Ok(AudioTrackSwitchResponse::ReloadStream {
selection,
position: current_position.unwrap_or(0.0),
})
} else {
// Native backend (Android ExoPlayer) - use array index
let controller = player.0.lock().await;
controller
.set_audio_track(array_index)
.map_err(|e| e.to_string())?;
Ok(AudioTrackSwitchResponse::Native { success: true })
}
}
/// Change the bandwidth ceiling of the video that is playing *right now*.
///
/// A cap is a property of the stream the server is producing, so unlike a volume
/// change it cannot be applied to a stream already in flight — the stream has to
/// be re-opened at the new quality and resumed at the current position. That is
/// the same reload the transcoded-seek and audio-track paths use, and the same
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
/// native backend is reloaded here.
///
/// The change applies to **this playback only**. The in-player picker is a
/// "this film, this connection" control and its doc has always said so, but it
/// used to be implemented by writing the process-wide ceiling — so choosing
/// 2 Mbps to get one awkward film moving silently capped every video played
/// afterwards for the rest of the process, with the Settings screen still
/// showing the old value and nothing in the UI admitting the change. It now
/// sets a per-playback override that the next item clears; the durable default
/// belongs to Settings, and `player_set_video_settings` is the one that writes
/// to the database.
///
/// TRACES: UR-074, UR-079 | DR-162, DR-225
#[tauri::command]
#[specta::specta]
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
// input. Folding the rest into a struct would change the IPC contract and the
// generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn player_set_stream_quality(
player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
repository_handle: String,
quality: crate::settings::StreamingQuality,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
) -> Result<StreamQualityResponse, String> {
info!(
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
quality.label(),
use_html5,
current_position
);
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
let jellyfin_item_id = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let current_item = queue.current().ok_or("No item currently playing")?;
if current_item.media_type != MediaType::Video {
return Err("Current item is not a video".to_string());
}
current_item
.jellyfin_id()
.ok_or("Current item has no Jellyfin ID")?
.to_string()
};
// Set the ceiling *before* negotiating — the negotiation and every URL
// builder resolve through `effective_streaming_quality`, and they have to
// agree or the cap leaks (a negotiation authorising a direct play the URL
// builder then never gets to constrain).
//
// Deliberately the *override*, not the device default: see the doc above.
// TRACES: UR-074, UR-079 | DR-225
crate::repository::online::set_playback_quality_override(quality);
let position = current_position.unwrap_or(0.0);
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let new_url = selection.url.clone();
if use_html5 {
return Ok(StreamQualityResponse::ReloadStream {
selection,
position,
});
}
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
// position without 400ing every segment — DR-181), so it is seeked back to
// where the picture was.
{
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
let queue_arc = controller.queue();
{
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
if !queue.update_current_stream_url(new_url.clone()) {
return Err("Failed to update stream URL in queue".to_string());
}
}
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let updated_item = queue.current().ok_or("No current item after URL update")?;
controller
.load_and_play(updated_item)
.map_err(|e| e.to_string())?;
if position > 0.0 {
controller.seek(position).map_err(|e| e.to_string())?;
}
}
Ok(StreamQualityResponse::Native { position })
}
/// Set the active audio track on a native backend directly.
///
/// TRACES: UR-021 | IR-019, DR-024
#[tauri::command]
#[specta::specta]
pub async fn player_set_audio_track(
player: State<'_, PlayerStateWrapper>,
stream_index: i32,
) -> Result<PlayerStatus, String> {
let controller = player.0.lock().await;
controller
.set_audio_track(stream_index)
.map_err(|e| e.to_string())?;
Ok(get_player_status(&controller))
}
/// 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
#[tauri::command]
#[specta::specta]
pub async fn player_set_subtitle_track(
player: State<'_, PlayerStateWrapper>,
stream_index: Option<i32>,
) -> Result<PlayerStatus, String> {
let controller = player.0.lock().await;
controller
.set_subtitle_track(stream_index)
.map_err(|e| e.to_string())?;
Ok(get_player_status(&controller))
}
/// Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend
/// works in.
///
/// NaN is handled before the clamp rather than by it: `f32::clamp` returns NaN
/// for a NaN input (it only panics on NaN *bounds*), and NaN then survives every
/// comparison downstream, so a backend clamp cannot catch it either. It is
/// treated as "no volume asked for" and floored to 0.0.
///
/// TRACES: DR-212 | UT-206
fn normalize_volume(volume: f32) -> f32 {
if volume.is_nan() {
0.0
} else {
volume.clamp(0.0, 1.0)
}
}
#[tauri::command]
#[specta::specta]
pub async fn player_set_volume(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
volume: f32,
) -> Result<PlayerStatus, String> {
// Clamp at the boundary as well as in each backend: the remote branch below
// never reaches a backend clamp, and `(f32::INFINITY * 100.0) as i32` would
// hand the server i32::MAX as a volume percentage.
// TRACES: DR-212 | UT-206
let volume = normalize_volume(volume);
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send volume command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Convert 0-1 range to 0-100 for Jellyfin API
let volume_percent = (volume * 100.0) as i32;
client
.session_set_volume(session_id, volume_percent)
.await?;
} else {
// Local playback
let controller = player.0.lock().await;
controller.set_volume(volume).map_err(|e| e.to_string())?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_toggle_mute(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send toggle mute command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client
.send_session_command(session_id, "ToggleMute")
.await?;
} else {
// Local playback
// TODO: Implement toggle_mute in PlayerController
// let controller = player.0.lock().await;
// controller.toggle_mute().map_err(|e| e.to_string())?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_toggle_shuffle(
player: State<'_, PlayerStateWrapper>,
) -> Result<QueueStatus, String> {
let controller = player.0.lock().await;
controller.toggle_shuffle();
controller.emit_queue_changed();
Ok(get_queue_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_cycle_repeat(
player: State<'_, PlayerStateWrapper>,
) -> Result<QueueStatus, String> {
let controller = player.0.lock().await;
controller.cycle_repeat();
controller.emit_queue_changed();
Ok(get_queue_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_get_status(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
let mode = playback_mode.0.get_mode();
// Get base local status
let controller = player.0.lock().await;
let mut status = get_player_status(&controller);
// Get local media from queue
let local_media = {
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
queue.current().map(MergedMediaItem::from)
};
let local_is_playing = status.state.is_playing();
let local_volume = status.volume;
// If in remote mode, fetch session and merge state
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
let client = {
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
drop(controller); // Release lock before async call
match client.get_session(&session_id).await {
Ok(Some(session)) => {
log::info!("[PlayerCommands] Merging remote session state");
// Merge media item
status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
// Merge isPlaying (NOT isPaused!)
status.merged_is_playing = session
.play_state
.as_ref()
.map(|ps| !ps.is_paused.unwrap_or(true))
.unwrap_or(false);
// Merge position (convert ticks to seconds)
status.position = session
.play_state
.as_ref()
.and_then(|ps| ps.position_ticks)
.map(|ticks| ticks as f64 / 10_000_000.0)
.unwrap_or(0.0);
// Merge duration (convert ticks to seconds)
status.duration = session
.now_playing_item
.as_ref()
.and_then(|item| item.run_time_ticks)
.map(|ticks| ticks as f64 / 10_000_000.0);
// Merge volume (convert 0-100 → 0-1)
status.merged_volume = session
.play_state
.as_ref()
.and_then(|ps| ps.volume_level)
.map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
.unwrap_or(1.0);
return Ok(status);
}
Ok(None) => {
log::warn!("[PlayerCommands] Remote session not found, using local state");
}
Err(e) => {
log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
}
}
}
// Local playback or fallback
status.merged_media = local_media;
status.merged_is_playing = local_is_playing;
status.merged_volume = local_volume;
Ok(status)
}
#[tauri::command]
#[specta::specta]
pub async fn player_get_queue(
player: State<'_, PlayerStateWrapper>,
) -> Result<QueueStatus, String> {
let controller = player.0.lock().await;
Ok(get_queue_status(&controller))
}
/// 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
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct 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.
pub uses_webview_audio: bool,
/// 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.
pub supports_native_video: bool,
}
/// Report this platform's playback capabilities to the frontend.
///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
#[tauri::command]
#[specta::specta]
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
// Mirrors the cfg gates the backends themselves are built under.
let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio,
supports_native_video: cfg!(target_os = "android"),
})
}
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
// Determine backend at compile time based on platform
let (backend, use_html5_element) = if cfg!(target_os = "android") {
// Android uses ExoPlayer native backend
(VideoBackend::Native, false)
} else {
// Linux and other platforms use HTML5 video element in frontend
(VideoBackend::Html5, true)
};
PlayerStatus {
state: controller.state(),
// The position on the item's timeline, whichever of the three paths is
// rendering it — the native backend answers for only one of them, and
// reads 0 for webview video and for a handoff that has not ticked yet.
// TRACES: UR-005 | DR-178
position: controller.absolute_position(),
duration: controller.duration(),
volume: controller.volume(),
muted: controller.muted(),
shuffle: controller.is_shuffle(),
repeat: controller.repeat_mode(),
backend,
use_html5_element,
// Merged fields initialized to defaults (will be set by player_get_status)
merged_media: None,
merged_is_playing: false,
merged_volume: controller.volume(),
}
}
pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
let queue = controller.queue();
let queue_lock = queue.lock_safe();
QueueStatus {
items: queue_lock.items().to_vec(),
current_index: queue_lock.current_index(),
shuffle: queue_lock.is_shuffle(),
repeat: queue_lock.repeat_mode(),
has_next: queue_lock.has_next(),
has_previous: queue_lock.has_previous(),
}
}
/// Start a freshly-built queue on the active remote session.
///
/// Used by the "play tracks"/"play album track" commands when we're in remote
/// mode: instead of starting local MPV playback, we cast the selected tracks to
/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
/// play_on_session call, but for a brand-new selection (so there's no resume
/// position - playback starts from the chosen track's beginning).
///
/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
/// the start index is adjusted to the remaining Jellyfin items. Returns an error
/// if the selected track itself has no Jellyfin ID.
async fn play_selection_on_remote(
controller: &PlayerController,
session_id: &str,
media_items: &[MediaItem],
start_index: usize,
) -> Result<(), String> {
// Collect Jellyfin IDs, tracking where the selected track lands after any
// local-only items are dropped.
let mut jellyfin_ids: Vec<String> = Vec::new();
let mut adjusted_index: Option<usize> = None;
for (i, item) in media_items.iter().enumerate() {
if let Some(id) = item.jellyfin_id() {
if i == start_index {
adjusted_index = Some(jellyfin_ids.len());
}
jellyfin_ids.push(id.to_string());
}
}
let start_index =
adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
if jellyfin_ids.is_empty() {
return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
}
let client = {
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Fresh selection: start from the beginning of the chosen track.
client
.play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
.await
.map_err(|e| format!("Failed to start playback on remote session: {}", e))
}
/// Play a track from an album - backend fetches all album tracks and builds queue
#[tauri::command]
#[specta::specta]
pub async fn player_play_album_track(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String,
request: PlayAlbumTrackRequest,
) -> Result<PlayerStatus, String> {
info!(
"player_play_album_track called: album_id={}, track_id={}, shuffle={}",
request.album_id, request.track_id, request.shuffle
);
// Get repository (hybrid - supports offline/online)
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Fetch all tracks from the album via hybrid repository
info!(
"Fetching tracks for album {} via repository",
request.album_id
);
let album_items = repository
.get_items(
&request.album_id,
Some(GetItemsOptions {
limit: Some(1000),
fields: Some(vec![
"PrimaryImageAspectRatio".to_string(),
"Overview".to_string(),
"MediaStreams".to_string(),
]),
..Default::default()
}),
)
.await
.map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
info!("Found {} items in album", album_items.items.len());
// Filter to only Audio items and sort by index
let mut tracks: Vec<_> = album_items
.items
.into_iter()
.filter(|item| item.item_type == "Audio")
.collect();
tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
info!("Found {} audio tracks in album", tracks.len());
if tracks.is_empty() {
return Err("No audio tracks found in album".to_string());
}
// Debug: Log all track IDs and their indices
info!("Album has {} tracks after sorting:", tracks.len());
for (idx, track) in tracks.iter().enumerate() {
info!(" [{}] {} (ID: {})", idx, track.name, track.id);
}
// Validate the requested track exists in the album (its position in the
// final queue is computed after building, since offline tracks are skipped).
info!("Looking for track_id: {}", request.track_id);
let album_index = tracks
.iter()
.position(|t| t.id == request.track_id)
.ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
info!(
"Track {} is at index {} in album",
request.track_id, album_index
);
// Convert tracks to MediaItems
let mut media_items = Vec::new();
for track in tracks {
// Check for local download first
let jellyfin_id = &track.id;
let local_path = check_for_local_download(&db, jellyfin_id).await?;
let source = if let Some(path) = local_path {
MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(jellyfin_id.clone()),
}
} else {
// Non-downloaded track: needs a stream URL from the server. When the
// server is unreachable (offline), skip this track rather than failing
// the whole album — downloaded tracks must still be playable.
match repository.get_audio_stream_url(&track.id).await {
Ok(stream_url) => MediaSource::Remote {
stream_url,
jellyfin_item_id: jellyfin_id.clone(),
},
Err(e) => {
warn!(
"[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
track.name, track.id, e
);
continue;
}
}
};
let primary_image_tag_for_url = track.primary_image_tag.clone();
let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: track.id.clone(),
title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility
artist: track
.album_artist
.clone()
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
album: Some(request.album_name.clone()),
album_name: Some(request.album_name.clone()), // Frontend compatibility
album_id: Some(request.album_id.clone()),
artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
image_id: track.primary_image_tag.clone(),
item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
artwork_url: primary_image_tag_for_url.map(|tag| {
repository.get_image_url(
&request.album_id,
ImageType::Primary,
Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}),
)
}),
media_type: MediaType::Audio,
source,
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
media_items.push(media_item);
}
if media_items.is_empty() {
return Err("No playable tracks available (offline and nothing downloaded)".to_string());
}
// Tracks with no local download and no reachable server were skipped above,
// so positions shifted. Re-locate the requested track in the built queue.
// If the tapped track itself was skipped, fall back to the first item.
let start_index = media_items
.iter()
.position(|item| item.id == request.track_id)
.unwrap_or(0);
info!(
"Built queue with {} media items, starting at index {}",
media_items.len(),
start_index
);
// Handle shuffle before setting queue
if request.shuffle {
let controller = player.0.lock().await;
controller.toggle_shuffle();
}
// Start audio session with the first item
if let Some(first_item) = media_items.get(start_index) {
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
session_mgr.start_audio_session(first_item.clone());
}
let controller = player.0.lock().await;
// When controlling a remote session, cast the selection there instead of
// starting local MPV playback. We still load the queue locally (below) so
// the queue/context stay in sync for the UI and for transferring back.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
controller
.set_queue(media_items, start_index)
.map_err(|e| e.to_string())?;
} else {
// Local playback is now authoritative (see player_play_tracks); set it
// before starting so the mode-changed event precedes the state events.
playback_mode
.0
.set_mode(crate::playback_mode::PlaybackMode::Local);
controller
.play_queue(media_items, start_index)
.map_err(|e| e.to_string())?;
}
// Set the queue context for remote transfer
{
let queue_arc = controller.queue();
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
queue.set_context(QueueContext::Album {
album_id: request.album_id.clone(),
album_name: request.album_name.clone(),
});
}
// Emit queue changed event
controller.emit_queue_changed();
// Log final queue state
{
let queue_arc = controller.queue();
let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
info!(
"player_play_album_track: Queue now has {} items, current_index: {:?}",
queue_lock.items().len(),
queue_lock.current_index()
);
}
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
Ok(get_player_status(&controller))
}
/// Play tracks by ID - backend fetches all metadata
#[tauri::command]
#[specta::specta]
pub async fn player_play_tracks(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String,
request: PlayTracksRequest,
) -> Result<PlayerStatus, String> {
// A ceiling chosen from the in-player picker belongs to the playback it was
// chosen for. Starting a different item returns to the device default —
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
// session, which is the defect DR-225 exists to close.
//
// TRACES: UR-074, UR-079 | DR-225
crate::repository::online::clear_playback_quality_override();
info!(
"player_play_tracks called: {} tracks, start_index={}, shuffle={}",
request.track_ids.len(),
request.start_index,
request.shuffle
);
// Validate input
if request.track_ids.is_empty() {
return Err("No tracks provided".to_string());
}
// Get repository
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Fetch metadata for all tracks
let mut media_items = Vec::new();
for track_id in &request.track_ids {
// Fetch track metadata from repository
let track = repository
.get_item(track_id)
.await
.map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
// Check for local download
let local_path = check_for_local_download(&db, track_id).await?;
// Build MediaSource
let source = if let Some(path) = local_path {
MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(track.id.clone()),
}
} else {
let stream_url = repository
.get_audio_stream_url(track_id)
.await
.map_err(|e| format!("Failed to get stream URL: {}", e))?;
MediaSource::Remote {
stream_url,
jellyfin_item_id: track.id.clone(),
}
};
// Transform to MediaItem with frontend-compatible fields
let primary_image_tag_for_url = track.primary_image_tag.clone();
let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: track.id.clone(),
title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility
artist: track
.album_artist
.clone()
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
album: track.album_name.clone(),
album_name: track.album_name.clone(), // Frontend compatibility
album_id: track.album_id.clone(),
artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
image_id: track.primary_image_tag.clone(),
item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None, // Set based on context below
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
artwork_url: primary_image_tag_for_url.and_then(|tag| {
track.album_id.as_ref().map(|album_id| {
repository.get_image_url(
album_id,
ImageType::Primary,
Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}),
)
})
}),
media_type: MediaType::Audio,
source,
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
media_items.push(media_item);
}
info!("Built queue with {} media items", media_items.len());
// Map context and set playlist_id
let queue_context = match request.context {
PlayTracksContext::Playlist {
playlist_id,
playlist_name,
} => {
for item in &mut media_items {
item.playlist_id = Some(playlist_id.clone());
}
QueueContext::Playlist {
playlist_id,
playlist_name,
}
}
PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
};
// Handle shuffle
if request.shuffle {
let controller = player.0.lock().await;
controller.toggle_shuffle();
}
// Start session
if let Some(first_item) = media_items.get(request.start_index) {
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
session_mgr.start_audio_session(first_item.clone());
}
let controller = player.0.lock().await;
// When controlling a remote session, cast the selection there instead of
// starting local MPV playback. Skip this while a transfer is in flight: the
// transfer-to-local path calls this command to load the queue locally and
// the mode is still Remote until the transfer completes - routing it back to
// the remote would undo the transfer.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id }
if !playback_mode.0.is_transferring() =>
{
Some(session_id)
}
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
.await?;
controller
.set_queue(media_items, request.start_index)
.map_err(|e| e.to_string())?;
} else {
// Starting local playback makes Local the authoritative mode. Without
// this, a prior Remote mode lingers in the manager and later play/pause
// commands route back to the (stopped) remote session. Set it BEFORE
// starting playback so the PlaybackModeChanged event reaches the frontend
// ahead of the state_changed events it will emit — otherwise the frontend
// (still thinking it's remote) filters those state events out. Skip during
// a transfer: transfer_to_local drives the mode itself once complete.
if !playback_mode.0.is_transferring() {
playback_mode
.0
.set_mode(crate::playback_mode::PlaybackMode::Local);
}
controller
.play_queue_from(media_items, request.start_index, request.start_position)
.map_err(|e| e.to_string())?;
}
// Set queue context
{
let queue_arc = controller.queue();
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
queue.set_context(queue_context);
}
// Emit events
controller.emit_queue_changed();
if let Some(emitter) = controller.event_emitter() {
let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
info!("player_play_tracks completed successfully");
Ok(get_player_status(&controller))
}
/// Response for preload operation
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreloadResult {
/// Number of tracks queued for preload
pub queued_count: usize,
/// Number of tracks already downloaded
pub already_downloaded: usize,
/// Number of tracks skipped (no jellyfin ID or other reasons)
pub skipped: usize,
}
/// Preload upcoming tracks from the queue
/// This queues background downloads for the next N tracks that aren't already downloaded
#[tauri::command]
#[specta::specta]
pub async fn player_preload_upcoming(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
smart_cache: State<'_, SmartCacheWrapper>,
download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
app: tauri::AppHandle,
user_id: String,
_download_base_path: String,
) -> Result<PreloadResult, String> {
// The pump only starts rows that carry both a stream URL and a target dir,
// so resolve the same storage root the user-initiated download paths use
// (storage_get_path = the database's parent directory).
let (db_service, target_dir) = {
let database = db.0.lock().map_err(|e| e.to_string())?;
let target_dir = database
.path()
.parent()
.ok_or_else(|| "Database path has no parent directory".to_string())?
.to_string_lossy()
.to_string();
(Arc::new(database.service()), target_dir)
};
// Get cache settings
let precache_count = {
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
if !cache.should_precache_queue() {
return Ok(PreloadResult {
queued_count: 0,
already_downloaded: 0,
skipped: 0,
});
}
cache.queue_precache_count()
};
// Get upcoming items from queue
let upcoming_items: Vec<MediaItem> = {
let controller = player.0.lock().await;
let queue = controller.queue();
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock
.get_upcoming(precache_count)
.into_iter()
.cloned()
.collect()
};
if upcoming_items.is_empty() {
return Ok(PreloadResult {
queued_count: 0,
already_downloaded: 0,
skipped: 0,
});
}
let mut queued_count = 0;
let mut already_downloaded = 0;
let mut skipped = 0;
// Process each upcoming item
for item in upcoming_items {
// Only process items with Remote source (not already local). The
// source already carries the resolved stream URL — reuse it so the
// pump can start the download without any extra resolution step.
let (jellyfin_id, stream_url) = match &item.source {
MediaSource::Remote {
jellyfin_item_id,
stream_url,
} => (jellyfin_item_id.clone(), stream_url.clone()),
MediaSource::Local { .. } => {
already_downloaded += 1;
continue;
}
MediaSource::DirectUrl { .. } => {
skipped += 1;
continue;
}
};
// Check if already downloaded or actively in flight. Stale pending rows
// without a stream URL are NOT skipped here — the upsert below heals
// them so the pump can finally start them.
let query = Query::with_params(
"SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
AND (status IN ('completed', 'downloading')
OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
vec![
QueryParam::String(jellyfin_id.clone()),
QueryParam::String(user_id.clone()),
],
);
let is_downloaded: Option<String> = db_service
.query_optional(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
if is_downloaded.is_some() {
already_downloaded += 1;
continue;
}
// Queue for download with low priority (preload priority = -100) so
// user-initiated downloads always win a pump slot first.
let album_dir = item
.album
.as_deref()
.filter(|a| !a.is_empty())
.unwrap_or("Unknown Album");
let file_path = format!(
"downloads/{}/{}.mp3",
sanitize_filename(album_dir),
sanitize_filename(&item.title)
);
// Insert with the stream URL + target dir the pump needs to start it.
// On conflict, heal pre-existing rows that were queued without a URL
// (they could never start) instead of leaving them stuck.
let insert_query = Query::with_params(
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir)
VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
ON CONFLICT(item_id, user_id) DO UPDATE SET
stream_url = excluded.stream_url,
target_dir = excluded.target_dir
WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
vec![
QueryParam::String(jellyfin_id),
QueryParam::String(user_id.clone()),
QueryParam::String(file_path),
QueryParam::String(item.title.clone()),
item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
QueryParam::String(stream_url),
QueryParam::String(target_dir.clone()),
],
);
match db_service.execute(insert_query).await {
Ok(rows) if rows > 0 => {
info!(
"[Preload] Queued download for: {} - {}",
item.artist.as_deref().unwrap_or("Unknown"),
item.title
);
queued_count += 1;
}
Ok(_) => {
// Row already exists (conflict), count as already queued
already_downloaded += 1;
}
Err(e) => {
error!("[Preload] Failed to queue {}: {}", item.title, e);
skipped += 1;
}
}
}
// Kick the pump so the queued preloads actually start; without this they'd
// only begin once some other download activity pumps the queue.
if queued_count > 0 {
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
}
info!(
"[Preload] Result: queued={}, already_downloaded={}, skipped={}",
queued_count, already_downloaded, skipped
);
Ok(PreloadResult {
queued_count,
already_downloaded,
skipped,
})
}
/// Sanitize filename by removing invalid characters
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => c,
})
.collect()
}
/// Update SmartCache configuration
#[tauri::command]
#[specta::specta]
pub async fn player_set_cache_config(
smart_cache: State<'_, SmartCacheWrapper>,
config: CacheConfig,
) -> Result<(), String> {
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
cache.update_config(config);
Ok(())
}
/// Get current SmartCache configuration
#[tauri::command]
#[specta::specta]
pub async fn player_get_cache_config(
smart_cache: State<'_, SmartCacheWrapper>,
) -> Result<CacheConfig, String> {
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
Ok(cache.get_config().unwrap_or_default())
}
/// Configure Jellyfin API client for automatic playback reporting
#[tauri::command]
#[specta::specta]
pub async fn player_configure_jellyfin(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
server_url: String,
access_token: String,
user_id: String,
device_id: String,
) -> Result<(), String> {
log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
let config = JellyfinConfig {
server_url,
access_token,
device_id,
};
// Legacy client (used for remote session control / casting).
let client = JellyfinClient::new(config.clone())?;
// Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
// actually report through. Without this, Start/Progress/Stopped never reach
// Jellyfin, so playback position never syncs and you can't resume on another
// device. The reporter shares the player controller's Arc, so populating it
// here lights up reporting on both desktop and Android, on every auth path
// that configures the player (login / restore / reauth).
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let reporter_client = JellyfinClient::new(config)?;
let reporter = crate::playback_reporting::PlaybackReporter::new(
db_service,
Arc::new(TokioMutex::new(Some(reporter_client))),
user_id,
);
let controller = player.0.lock().await;
controller.set_jellyfin_client(Some(client));
controller.set_playback_reporter(Some(reporter)).await;
log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
Ok(())
}
/// Disable Jellyfin automatic playback reporting
#[tauri::command]
#[specta::specta]
pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
log::info!("[PlayerCommand] Disabling Jellyfin client");
let controller = player.0.lock().await;
controller.set_jellyfin_client(None);
controller.set_playback_reporter(None).await;
log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
Ok(())
}
#[cfg(test)]
mod tests {
use crate::utils::lock::MutexSafe;
/// UT-206 — the volume the command hands on is always a real number in
/// 0.0..=1.0.
///
/// Every backend clamps for itself, but the remote branch of
/// `player_set_volume` reaches no backend at all: it does
/// `(volume * 100.0) as i32`, which turns infinity into `i32::MAX` and NaN
/// into 0. NaN also survives `f32::clamp` unchanged, so clamping alone is
/// not enough — it has to be tested for.
///
/// TRACES: DR-212 | UT-206
#[test]
fn test_normalize_volume_clamps_and_rejects_nan() {
use super::normalize_volume;
// In-range values pass through untouched.
assert_eq!(normalize_volume(0.0), 0.0);
assert_eq!(normalize_volume(0.5), 0.5);
assert_eq!(normalize_volume(1.0), 1.0);
// Out of range clamps to the same 0.0..=1.0 the backends use.
assert_eq!(normalize_volume(-0.5), 0.0);
assert_eq!(normalize_volume(42.0), 1.0);
assert_eq!(normalize_volume(f32::INFINITY), 1.0);
assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
// NaN is not a volume; it must not reach the Jellyfin percentage
// conversion or a backend.
let from_nan = normalize_volume(f32::NAN);
assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
assert_eq!(from_nan, 0.0);
// Whatever comes out survives the remote branch's percentage cast.
for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
let percent = (normalize_volume(input) * 100.0) as i32;
assert!((0..=100).contains(&percent), "input {input} gave {percent}");
}
}
/// The subtitle list the frontend resolved must survive the IPC hop and end
/// up on the `MediaItem` the native backend loads.
///
/// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
/// then dropped it on the floor — `PlayItemRequest` had no field to put it
/// in — so `create_media_item` always produced `subtitles: vec![]`,
/// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
/// `MediaItem` with zero `SubtitleConfiguration`s. Every later
/// `setSubtitleTrack(n)` then found no text track groups and logged
/// "Invalid subtitle track index".
///
/// The payload below is exactly what the frontend sends: camelCase for the
/// top-level command params (Tauri v2 converts them), and the subtitle
/// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
///
/// TRACES: UR-020 | IR-016 | UT-145
#[tokio::test]
async fn test_play_item_request_carries_subtitles_into_media_item() {
use super::{create_media_item, PlayItemRequest};
let payload = serde_json::json!({
"id": "ep-1",
"title": "Pilot",
"streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
"videoCodec": "h264",
"needsTranscoding": false,
"subtitles": [
{
"index": 2,
"url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
"language": "eng",
"label": "English (SRT)",
"mime_type": "text/vtt"
},
{
"index": 3,
"url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
"language": null,
"label": null,
"mime_type": "text/vtt"
}
]
});
let req: PlayItemRequest =
serde_json::from_value(payload).expect("frontend payload must deserialize");
assert_eq!(
req.subtitles.len(),
2,
"PlayItemRequest must carry the subtitle tracks, not silently ignore them"
);
let media = create_media_item(req, None).await.unwrap();
assert_eq!(
media.subtitles.len(),
2,
"create_media_item must thread the tracks onto the MediaItem the backend loads"
);
assert_eq!(media.subtitles[0].index, 2);
assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
assert_eq!(media.subtitles[0].mime_type, "text/vtt");
// Order is the contract: `player_set_subtitle_track(n)` is a position in
// this list (see the note on `PlayItemRequest::subtitles`).
assert_eq!(media.subtitles[1].index, 3);
assert!(media.subtitles[1].language.is_none());
}
/// A request without subtitles must still deserialize — the field is
/// defaulted so the background-audio handoff and the autoplay/next-episode
/// callers keep compiling and sending what they always sent.
///
/// TRACES: UR-020 | IR-016 | UT-145
#[tokio::test]
async fn test_play_item_request_without_subtitles_defaults_to_empty() {
use super::{create_media_item, PlayItemRequest};
let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
"id": "movie-1",
"title": "Movie",
"streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
"videoCodec": "h264",
"needsTranscoding": false
}))
.expect("a subtitle-less payload must still deserialize");
assert!(req.subtitles.is_empty());
assert!(create_media_item(req, None)
.await
.unwrap()
.subtitles
.is_empty());
}
/// The JSON handed to Kotlin over JNI must use the keys
/// `JellyTauPlayer.load()` actually reads.
///
/// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
/// house IPC rule) is to camelCase nested structs too — but
/// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
/// the field would not fail to compile or fail the IPC; it would silently
/// fall back to the default MIME type for every track, so this is asserted
/// on the exact bytes `android/mod.rs` sends.
///
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
#[test]
fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
use crate::player::media::SubtitleTrack;
let subtitles = vec![SubtitleTrack {
index: 2,
url: "https://jelly.example/subs.vtt".to_string(),
language: Some("eng".to_string()),
label: Some("English".to_string()),
mime_type: "text/vtt".to_string(),
}];
// Exactly what player/android/mod.rs passes to loadWithMetadata.
let json = serde_json::to_string(&subtitles).unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
let obj = parsed[0].as_object().unwrap();
for key in ["url", "language", "label", "mime_type"] {
assert!(
obj.contains_key(key),
"JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
obj.keys().collect::<Vec<_>>()
);
}
assert!(
!obj.contains_key("mimeType"),
"camelCasing mime_type silently drops every track's MIME type on Android"
);
}
/// The audio-only handoff must play a downloaded file when there is one,
/// rather than fetching an audio-only stream for media already on disk.
///
/// TRACES: UR-071 | DR-128 | UT-119
#[test]
fn test_background_audio_source_prefers_local_file() {
use super::background_audio_source;
use crate::player::MediaSource;
use std::path::PathBuf;
let local = background_audio_source(
Some("/downloads/ep1.mkv".to_string()),
"https://server/audio-only".to_string(),
"ep-1",
);
match local {
MediaSource::Local {
file_path,
jellyfin_item_id,
} => {
assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
// The Jellyfin id must survive so progress still syncs back.
assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
}
other => panic!("expected a local source, got {:?}", other),
}
let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
match remote {
MediaSource::Remote {
stream_url,
jellyfin_item_id,
} => {
assert_eq!(stream_url, "https://server/audio-only");
assert_eq!(jellyfin_item_id, "ep-1");
}
other => panic!("expected a remote source, got {:?}", other),
}
}
/// The two sources start in different places, so the handoff cannot treat
/// them alike.
///
/// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
/// the handoff point that stream's zero: the base is the handoff position and
/// seeking would jump past the content. A *downloaded file* has no such
/// parameter — it starts at the episode's own zero — so basing it at the
/// handoff position claims 18 minutes of audio that is about to play from the
/// beginning. That is the downloaded-episode version of "it restarts when the
/// screen sleeps", and it needs the opposite treatment: no base, and a seek.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
#[test]
fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
use super::background_audio_plan;
let local = background_audio_plan(true, 1104.0);
assert_eq!(local.base_seconds, 0.0);
assert_eq!(local.seek_to, Some(1104.0));
let streamed = background_audio_plan(false, 1104.0);
assert_eq!(streamed.base_seconds, 1104.0);
assert_eq!(
streamed.seek_to, None,
"the URL already starts at the handoff point; seeking again skips past it"
);
}
/// Handing off at the very start has nothing to seek to and nothing to base:
/// both sources are already where they need to be.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
#[test]
fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
use super::background_audio_plan;
for local in [true, false] {
let plan = background_audio_plan(local, 0.0);
assert_eq!(plan.base_seconds, 0.0);
assert_eq!(plan.seek_to, None);
}
}
/// A downloaded item must resolve to its file, and a `downloads` row whose
/// file has gone must resolve to `None` so the caller falls back to
/// streaming instead of handing the player a path that cannot be opened.
///
/// TRACES: UR-071 | DR-123 | UT-116
#[tokio::test]
async fn test_resolve_local_media_path() {
use super::resolve_local_media_path;
use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
[],
)
.unwrap();
let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
// A real file on disk, so the existence check passes.
let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
std::fs::write(&present, b"x").unwrap();
let present_str = present.to_string_lossy().to_string();
for (item, status, path) in [
("downloaded", "completed", present_str.as_str()),
("still-going", "downloading", present_str.as_str()),
(
"file-gone",
"completed",
"/nonexistent/jellytau/missing.mp4",
),
] {
db_service
.execute(Query::with_params(
"INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
vec![
crate::storage::db_service::QueryParam::String(item.to_string()),
crate::storage::db_service::QueryParam::String(status.to_string()),
crate::storage::db_service::QueryParam::String(path.to_string()),
],
))
.await
.unwrap();
}
assert_eq!(
resolve_local_media_path(&db_service, "downloaded")
.await
.unwrap()
.as_deref(),
Some(present_str.as_str()),
"a completed download with its file present must resolve"
);
assert_eq!(
resolve_local_media_path(&db_service, "still-going")
.await
.unwrap(),
None,
"an in-progress download is not playable from disk"
);
assert_eq!(
resolve_local_media_path(&db_service, "file-gone")
.await
.unwrap(),
None,
"a row whose file has gone must fall back to streaming, not hand over a dead path"
);
assert_eq!(
resolve_local_media_path(&db_service, "never-heard-of-it")
.await
.unwrap(),
None
);
let _ = std::fs::remove_file(&present);
}
/// Queue items enqueued as Remote must flip to Local once a completed
/// download exists on disk — this is what makes preloaded tracks (and
/// offline playback after a connection drop) actually use the cache.
#[tokio::test]
async fn test_refresh_queue_local_sources_switches_completed_downloads() {
use super::{refresh_queue_local_sources, DatabaseWrapper};
use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
use crate::storage::Database;
use std::sync::Mutex;
// A real file on disk for the completed download; a missing file for
// the second entry to prove nonexistent files are not switched.
let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("track-a.mp3");
std::fs::write(&existing, b"audio").unwrap();
let missing = dir.join("track-b-missing.mp3");
let _ = std::fs::remove_file(&missing);
let database = Database::open_in_memory().unwrap();
{
let conn = database.connection();
let conn = conn.lock_safe();
conn.execute_batch(&format!(
r#"
INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('track-a', 'user1', '{}', 'completed');
INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('track-b', 'user1', '{}', 'completed');
"#,
existing.display(),
missing.display()
))
.unwrap();
}
let db = DatabaseWrapper(Mutex::new(database));
let make_item = |id: &str| MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: id.to_string(),
title: id.to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url: format!("http://test/Audio/{}/stream", id),
jellyfin_item_id: id.to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
let controller = PlayerController::default();
controller
.set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
.unwrap();
let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
assert_eq!(switched, 1, "only the download whose file exists switches");
let queue = controller.queue();
let queue_lock = queue.lock_safe();
match &queue_lock.items()[0].source {
MediaSource::Local {
file_path,
jellyfin_item_id,
} => {
assert_eq!(file_path, &existing);
assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
}
other => panic!("track-a should be local, got {:?}", other),
}
assert!(
matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
"track-b's file is missing, it must stay remote"
);
}
/// Test track index finding in album
/// This reproduces the bug where clicking songs 1-5 always played song 13
#[test]
fn test_find_track_index_in_album() {
// Create mock album tracks
#[derive(Clone)]
struct MockTrack {
id: String,
name: String,
index_number: Option<i32>,
}
let mut tracks = [
MockTrack {
id: "track1".to_string(),
name: "Song 1".to_string(),
index_number: Some(1),
},
MockTrack {
id: "track2".to_string(),
name: "Song 2".to_string(),
index_number: Some(2),
},
MockTrack {
id: "track3".to_string(),
name: "Song 3".to_string(),
index_number: Some(3),
},
MockTrack {
id: "track4".to_string(),
name: "Song 4".to_string(),
index_number: Some(4),
},
MockTrack {
id: "track5".to_string(),
name: "Song 5".to_string(),
index_number: Some(5),
},
];
// Sort by index (same as the real code does)
tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
// Test finding track 1 (should be index 0)
let index1 = tracks.iter().position(|t| t.id == "track1");
assert_eq!(index1, Some(0), "Track 1 should be at index 0");
assert_eq!(
tracks[0].name, "Song 1",
"Track at index 0 should carry its name"
);
// Test finding track 3 (should be index 2)
let index3 = tracks.iter().position(|t| t.id == "track3");
assert_eq!(index3, Some(2), "Track 3 should be at index 2");
// Test finding track 5 (should be index 4)
let index5 = tracks.iter().position(|t| t.id == "track5");
assert_eq!(index5, Some(4), "Track 5 should be at index 4");
// Test finding non-existent track
let index_none = tracks.iter().position(|t| t.id == "nonexistent");
assert_eq!(index_none, None, "Non-existent track should return None");
}
/// Test that track order is preserved when iterating
#[test]
fn test_track_iteration_order() {
// Simulate the loop that builds MediaItems
let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
// Find where "id3" is in the original list
let target_index = track_ids.iter().position(|&id| id == "id3");
assert_eq!(
target_index,
Some(2),
"id3 should be at index 2 in original list"
);
// Simulate building the MediaItems vector
let mut media_items = Vec::new();
for id in &track_ids {
media_items.push(id.to_string());
}
// Verify the order is preserved
assert_eq!(media_items.len(), 5);
assert_eq!(media_items[0], "id1");
assert_eq!(media_items[2], "id3");
assert_eq!(media_items[4], "id5");
// The start_index found earlier should still be valid
assert_eq!(media_items[target_index.unwrap()], "id3");
}
/// Test album track sorting behavior
#[test]
fn test_album_track_sorting() {
#[derive(Clone, Debug)]
struct MockTrack {
id: String,
name: String,
index_number: Option<i32>,
}
// Create tracks in random order (not sorted)
let mut tracks = [
MockTrack {
id: "id5".to_string(),
name: "Track 5".to_string(),
index_number: Some(5),
},
MockTrack {
id: "id1".to_string(),
name: "Track 1".to_string(),
index_number: Some(1),
},
MockTrack {
id: "id3".to_string(),
name: "Track 3".to_string(),
index_number: Some(3),
},
MockTrack {
id: "id2".to_string(),
name: "Track 2".to_string(),
index_number: Some(2),
},
MockTrack {
id: "id4".to_string(),
name: "Track 4".to_string(),
index_number: Some(4),
},
];
// User clicks track "id1" before sorting - what index is it?
let requested_track_id = "id1";
// Sort the tracks (same as real code)
tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
// Now find the index AFTER sorting
let start_index = tracks.iter().position(|t| t.id == requested_track_id);
// Track "id1" should be at index 0 after sorting
assert_eq!(
start_index,
Some(0),
"After sorting, track id1 should be at index 0"
);
// Verify all tracks are in correct order
assert_eq!(tracks[0].id, "id1");
assert_eq!(
tracks[0].name, "Track 1",
"Sorted track should retain its name"
);
assert_eq!(tracks[1].id, "id2");
assert_eq!(tracks[2].id, "id3");
assert_eq!(tracks[3].id, "id4");
assert_eq!(tracks[4].id, "id5");
}
}