A transcode is produced as it is sent — chunked, with no Content-Length — and the worker reported progress 0.0 for its whole duration: an empty bar reading "0%" while the byte count climbed for an hour. That is the case every film whose audio must be re-encoded lands in. The backend already fetches the item to decide the audio policy, and that item carries what a prediction needs: the source's size (an `original` download copies the picture, so the output is the source give or take the audio track) and its runtime (a preset re-encodes at fixed rates, so the size is rate × runtime — from a preset table the URL builder now shares, so the two cannot drift). The prediction is made where the URL is resolved and persisted as the row's file_size. The worker uses it only when the response has no length; the server's figure always wins; an estimated bar is capped at 99% so a low prediction never shows a finished download still running; and the Completed event now carries the bytes actually written so the frontend stops persisting the row's file_size as the final size. The row renders three honest states: exact "42%", estimated "~42%" with "X / ~Y", or — with no total at all — an indeterminate band and the bytes so far, never "0%". The single-video button joins the series/season buttons on the enqueue path so all three resolve, and predict, in one place. DR-290, UT-252, UT-253, UT-254. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
424 lines
15 KiB
Rust
424 lines
15 KiB
Rust
pub mod capabilities;
|
|
pub mod device_profile;
|
|
pub mod endpoints;
|
|
/// User-chosen browsing exclusions (UR-076 / DR-209).
|
|
pub mod exclusions;
|
|
#[cfg(test)]
|
|
mod generation_tests;
|
|
pub mod hybrid;
|
|
pub mod offline;
|
|
pub mod online;
|
|
pub mod series_progress;
|
|
#[cfg(test)]
|
|
pub mod server_fixture;
|
|
/// 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 [`resolve_video_download`]); `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>;
|
|
}
|
|
|
|
/// A video download, resolved: the URL to fetch and, where the item told us
|
|
/// enough, how many bytes to expect from it.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ResolvedVideoDownload {
|
|
pub url: String,
|
|
/// Predicted size (see `download::estimate`), used as the progress total
|
|
/// when the response carries no `Content-Length` — a transcode never does.
|
|
pub expected_bytes: Option<u64>,
|
|
}
|
|
|
|
/// Resolve the download URL for a video, applying the audio-codec policy that
|
|
/// keeps the saved file playable offline (DR-171), and predict its size from
|
|
/// the same item lookup (DR-290).
|
|
///
|
|
/// 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, DR-290
|
|
pub async fn resolve_video_download(
|
|
repo: &dyn MediaRepository,
|
|
item_id: &str,
|
|
quality: &str,
|
|
media_source_id: Option<&str>,
|
|
) -> ResolvedVideoDownload {
|
|
let item = repo.get_item(item_id).await.ok();
|
|
|
|
let audio: Vec<(Option<&str>, bool)> = item
|
|
.as_ref()
|
|
.and_then(|i| i.media_streams.as_deref())
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter(|s| s.stream_type == "Audio")
|
|
.map(|s| (s.codec.as_deref(), s.is_default))
|
|
.collect();
|
|
// The default track, or the first when none is marked, matching the track
|
|
// Jellyfin picks. `None` reads as "unknown", never as "fine": it feeds a
|
|
// policy that only *adds* a transcode, so an unknown codec leaves behaviour
|
|
// exactly as it was. TRACES: UR-071 | DR-171 | UT-166
|
|
let codec = device_profile::served_audio_codec(&audio);
|
|
|
|
// The source that will be served: the one asked for, else the first —
|
|
// the same choice Jellyfin makes when no `mediaSourceId` is given.
|
|
let source_size = item.as_ref().and_then(|i| {
|
|
let sources = i.media_sources.as_deref()?;
|
|
let source = match media_source_id {
|
|
Some(id) => sources.iter().find(|s| s.id == id),
|
|
None => sources.first(),
|
|
};
|
|
source?.size
|
|
});
|
|
let expected_bytes = crate::download::estimate::expected_download_bytes(
|
|
quality,
|
|
item.as_ref().and_then(|i| i.runtime_ticks),
|
|
source_size,
|
|
);
|
|
|
|
ResolvedVideoDownload {
|
|
url: repo.get_video_download_url(item_id, quality, media_source_id, codec),
|
|
expected_bytes,
|
|
}
|
|
}
|
|
|
|
/// [`resolve_video_download`] for callers that only need the URL.
|
|
///
|
|
/// 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 {
|
|
resolve_video_download(repo, item_id, quality, media_source_id)
|
|
.await
|
|
.url
|
|
}
|