mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
382 lines
14 KiB
Rust
382 lines
14 KiB
Rust
pub mod device_profile;
|
|
/// User-chosen browsing exclusions (UR-076 / DR-209).
|
|
pub mod exclusions;
|
|
pub mod hybrid;
|
|
pub mod offline;
|
|
pub mod online;
|
|
pub mod series_progress;
|
|
/// Backend-owned stream selection (UR-079 / DR-225).
|
|
pub mod stream_selection;
|
|
pub mod types;
|
|
|
|
pub use hybrid::HybridRepository;
|
|
pub use offline::OfflineRepository;
|
|
pub use online::{JRayActor, OnlineRepository};
|
|
pub use stream_selection::{StreamSelection, Transport};
|
|
pub use types::*;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
/// Repository trait for media access (online, offline, or hybrid)
|
|
///
|
|
/// @req: UR-002 - Access media when online or offline
|
|
/// @req: UR-007 - Navigate media in library
|
|
/// @req: UR-008 - Search media across libraries
|
|
/// @req: IR-010 - Jellyfin API client for library browsing
|
|
/// @req: DR-012 - Local database for media metadata cache
|
|
/// @req: DR-013 - Repository pattern for online/offline data access
|
|
#[async_trait]
|
|
pub trait MediaRepository: Send + Sync {
|
|
/// Get all libraries
|
|
///
|
|
/// @req: UR-007 - Navigate media in library
|
|
/// @req: JA-003 - Get user library views
|
|
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError>;
|
|
|
|
/// Get items in a library or parent
|
|
///
|
|
/// @req: UR-007 - Navigate media in library
|
|
/// @req: JA-004 - Get library items (paginated)
|
|
async fn get_items(
|
|
&self,
|
|
parent_id: &str,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, RepoError>;
|
|
|
|
/// Get a single item by ID
|
|
///
|
|
/// @req: UR-007 - Navigate media in library
|
|
/// @req: JA-005 - Get item details and metadata
|
|
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError>;
|
|
|
|
/// Get latest items in a library
|
|
///
|
|
/// @req: UR-024 - View recently added content on server
|
|
/// @req: JA-016 - Get recently added items
|
|
async fn get_latest_items(
|
|
&self,
|
|
parent_id: &str,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError>;
|
|
|
|
/// Get resume items (continue watching/listening)
|
|
///
|
|
/// @req: UR-019 - Resume playback from where you left off
|
|
/// @req: UR-023 - View "Next Up" / Continue Watching on home screen
|
|
/// @req: JA-015 - Get "Continue Watching" items
|
|
async fn get_resume_items(
|
|
&self,
|
|
parent_id: Option<&str>,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError>;
|
|
|
|
/// Get next up episodes
|
|
///
|
|
/// @req: UR-023 - View "Next Up" / Continue Watching; auto-play next episode
|
|
/// @req: JA-014 - Get "Next Up" items
|
|
async fn get_next_up_episodes(
|
|
&self,
|
|
series_id: Option<&str>,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError>;
|
|
|
|
/// Get recently played audio
|
|
async fn get_recently_played_audio(
|
|
&self,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError>;
|
|
|
|
/// Get albums the user has played, but not recently ("rediscover" / haven't
|
|
/// listened to in a while). Returns albums sorted by least-recently played
|
|
/// first, optionally restricted to a parent library.
|
|
async fn get_rediscover_albums(
|
|
&self,
|
|
parent_id: Option<&str>,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError>;
|
|
|
|
/// Get resume movies
|
|
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
|
|
|
|
/// Get genres
|
|
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError>;
|
|
|
|
/// Search for items
|
|
///
|
|
/// @req: UR-008 - Search media across libraries
|
|
/// @req: JA-006 - Search across libraries
|
|
async fn search(
|
|
&self,
|
|
query: &str,
|
|
options: Option<SearchOptions>,
|
|
) -> Result<SearchResult, RepoError>;
|
|
|
|
/// Get playback info for streaming
|
|
///
|
|
/// @req: UR-003 - Play videos
|
|
/// @req: UR-004 - Play audio uninterrupted
|
|
/// @req: JA-007 - Get playback info and stream URL
|
|
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError>;
|
|
|
|
/// Get audio stream URL for a track
|
|
///
|
|
/// @req: UR-004 - Play audio uninterrupted
|
|
/// @req: JA-007 - Get playback info and stream URL
|
|
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
|
|
|
|
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
|
|
///
|
|
/// Used when autoplay advances to the next episode while the app is playing a
|
|
/// video in audio-only mode in the background: the backend needs the next
|
|
/// episode's audio-only URL without any frontend round-trip. Online-only;
|
|
/// offline/cache repositories return an error.
|
|
///
|
|
/// TRACES: UR-040 | JA-032
|
|
async fn get_audio_only_stream_url_for_video(
|
|
&self,
|
|
item_id: &str,
|
|
media_source_id: Option<&str>,
|
|
start_time_seconds: Option<f64>,
|
|
audio_stream_index: Option<i32>,
|
|
) -> Result<String, RepoError>;
|
|
|
|
/// Get Live TV channels (broadcast / IPTV) for browsing.
|
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
|
|
|
|
/// Get the root list of plugin "Channels" (Jellyfin Channels feature).
|
|
/// Drill-down into a channel reuses `get_items(channel_id, ...)`.
|
|
async fn get_channels(&self) -> Result<SearchResult, RepoError>;
|
|
|
|
/// Open a live stream (Live TV channel or live channel item) for playback.
|
|
///
|
|
/// Returns the server transcoding URL plus identifiers needed to manage the
|
|
/// stream. Required before a live channel can be played over HLS.
|
|
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError>;
|
|
|
|
/// Report playback start
|
|
///
|
|
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
|
/// @req: JA-010 - Report playback start
|
|
async fn report_playback_start(
|
|
&self,
|
|
item_id: &str,
|
|
position_ticks: i64,
|
|
) -> Result<(), RepoError>;
|
|
|
|
/// Report playback progress
|
|
///
|
|
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
|
/// @req: JA-011 - Report playback progress (periodic)
|
|
async fn report_playback_progress(
|
|
&self,
|
|
item_id: &str,
|
|
position_ticks: i64,
|
|
) -> Result<(), RepoError>;
|
|
|
|
/// Report playback stopped
|
|
///
|
|
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
|
/// @req: JA-012 - Report playback stopped
|
|
async fn report_playback_stopped(
|
|
&self,
|
|
item_id: &str,
|
|
position_ticks: i64,
|
|
) -> Result<(), RepoError>;
|
|
|
|
/// Get image URL (synchronous - just constructs URL)
|
|
fn get_image_url(
|
|
&self,
|
|
item_id: &str,
|
|
image_type: ImageType,
|
|
options: Option<ImageOptions>,
|
|
) -> String;
|
|
|
|
/// Get subtitle URL (synchronous - just constructs URL)
|
|
/// Called by frontend via Tauri invoke (getSubtitleUrl in VideoPlayer.svelte)
|
|
#[allow(dead_code)]
|
|
fn get_subtitle_url(
|
|
&self,
|
|
item_id: &str,
|
|
media_source_id: &str,
|
|
stream_index: i32,
|
|
format: &str,
|
|
) -> String;
|
|
|
|
/// Build the URL a video download is fetched from. Synchronous — it only
|
|
/// constructs a URL, so it stays testable without a server. Reach it through
|
|
/// [`resolve_video_download_url`] rather than calling it directly.
|
|
///
|
|
/// `source_audio_codec` is the codec of the audio track the server would
|
|
/// serve (see [`served_audio_codec`]); `None` when it is not known. At
|
|
/// `original` quality it decides whether the file can be copied byte-for-byte
|
|
/// or has to have its audio re-encoded on the way down — a downloaded file is
|
|
/// played back with no server in reach, so it has to be decodable *here*.
|
|
///
|
|
/// TRACES: UR-071 | DR-171
|
|
#[allow(dead_code)]
|
|
fn get_video_download_url(
|
|
&self,
|
|
item_id: &str,
|
|
quality: &str,
|
|
media_source_id: Option<&str>,
|
|
source_audio_codec: Option<&str>,
|
|
) -> String;
|
|
|
|
/// Mark item as favorite
|
|
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
|
|
|
/// Unmark item as favorite
|
|
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
|
|
|
/// Everything the viewer has favourited, across every library.
|
|
///
|
|
/// Separate from `get_items` because favourites span libraries and
|
|
/// `get_items` is `ParentId`-shaped. `scope` is the opaque enum the
|
|
/// frontend sends; this layer expands it to item types (DR-063) so no
|
|
/// Jellyfin taxonomy is needed on the other side of the IPC boundary.
|
|
///
|
|
/// TRACES: UR-067 | DR-115, JA-033 | UT-100, UT-101
|
|
async fn get_favorites(
|
|
&self,
|
|
scope: SearchScope,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, RepoError>;
|
|
|
|
/// Erase the viewer's watch history for an item: clear its played flag and
|
|
/// its resume position. On a container (series, season) this applies to
|
|
/// everything inside it, so a series is returned to "never watched" and
|
|
/// reopens on its premiere.
|
|
///
|
|
/// TRACES: UR-064 | DR-106
|
|
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
|
|
|
|
/// Mark an item played — the inverse of `clear_watch_history`. Needed by the
|
|
/// sync-queue drain, which replays `mark_played` rows queued while the
|
|
/// server was unreachable; reporting a stop at a made-up position was the
|
|
/// previous stand-in and does not set the played flag reliably.
|
|
///
|
|
/// TRACES: UR-025 | DR-131 | JA-035
|
|
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError>;
|
|
|
|
/// Get person details
|
|
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
|
|
|
/// Get items by person (filmography)
|
|
async fn get_items_by_person(
|
|
&self,
|
|
person_id: &str,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, RepoError>;
|
|
|
|
/// Get similar/related items for a movie or show
|
|
///
|
|
/// @req: UR-009 - Discover similar content based on current item
|
|
async fn get_similar_items(
|
|
&self,
|
|
item_id: &str,
|
|
limit: Option<usize>,
|
|
) -> Result<SearchResult, RepoError>;
|
|
|
|
// ===== Playlist Methods =====
|
|
|
|
/// Create a new playlist on the server
|
|
///
|
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
|
/// @req: JA-019 - Get/create/update playlists
|
|
async fn create_playlist(
|
|
&self,
|
|
name: &str,
|
|
item_ids: &[String],
|
|
) -> Result<PlaylistCreatedResult, RepoError>;
|
|
|
|
/// Delete a playlist
|
|
///
|
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
|
/// @req: JA-019 - Get/create/update playlists
|
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
|
|
|
|
/// Rename a playlist
|
|
///
|
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
|
/// @req: JA-019 - Get/create/update playlists
|
|
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
|
|
|
|
/// Get playlist items with PlaylistItemId (needed for remove/reorder)
|
|
///
|
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
|
/// @req: JA-019 - Get/create/update playlists
|
|
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
|
|
|
|
/// Add items to a playlist
|
|
///
|
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
|
/// @req: JA-020 - Add/remove items from playlist
|
|
async fn add_to_playlist(
|
|
&self,
|
|
playlist_id: &str,
|
|
item_ids: &[String],
|
|
) -> Result<(), RepoError>;
|
|
|
|
/// Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs)
|
|
///
|
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
|
/// @req: JA-020 - Add/remove items from playlist
|
|
async fn remove_from_playlist(
|
|
&self,
|
|
playlist_id: &str,
|
|
entry_ids: &[String],
|
|
) -> Result<(), RepoError>;
|
|
|
|
/// Move a playlist item to a new position
|
|
///
|
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
|
/// @req: JA-020 - Add/remove items from playlist
|
|
async fn move_playlist_item(
|
|
&self,
|
|
playlist_id: &str,
|
|
item_id: &str,
|
|
new_index: u32,
|
|
) -> Result<(), RepoError>;
|
|
}
|
|
|
|
/// The audio codec the server would serve for `item_id` — the default track, or
|
|
/// the first when none is marked, matching the track Jellyfin picks.
|
|
///
|
|
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
|
|
/// caller must read that as "unknown", never as "fine": it is the input to a
|
|
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
|
|
/// exactly as it was.
|
|
///
|
|
/// TRACES: UR-071 | DR-171 | UT-166
|
|
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
|
|
let item = repo.get_item(item_id).await.ok()?;
|
|
let audio: Vec<(Option<&str>, bool)> = item
|
|
.media_streams
|
|
.as_deref()
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter(|s| s.stream_type == "Audio")
|
|
.map(|s| (s.codec.as_deref(), s.is_default))
|
|
.collect();
|
|
|
|
device_profile::served_audio_codec(&audio).map(str::to_string)
|
|
}
|
|
|
|
/// Resolve the download URL for a video, applying the audio-codec policy that
|
|
/// keeps the saved file playable offline (DR-171).
|
|
///
|
|
/// Every video download goes through here rather than calling the builder
|
|
/// directly: the builder is pure and cannot look the codec up, and a caller that
|
|
/// forgets to is exactly how the silent downloads shipped.
|
|
///
|
|
/// TRACES: UR-071 | DR-171
|
|
pub async fn resolve_video_download_url(
|
|
repo: &dyn MediaRepository,
|
|
item_id: &str,
|
|
quality: &str,
|
|
media_source_id: Option<&str>,
|
|
) -> String {
|
|
let codec = served_audio_codec(repo, item_id).await;
|
|
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
|
|
}
|