pub mod device_profile; pub mod hybrid; pub mod offline; pub mod online; pub mod series_progress; pub mod types; pub use hybrid::HybridRepository; pub use offline::OfflineRepository; pub use online::{JRayActor, OnlineRepository}; 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, 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, ) -> Result; /// 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; /// 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, ) -> Result, 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, ) -> Result, 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, ) -> Result, RepoError>; /// Get recently played audio async fn get_recently_played_audio( &self, limit: Option, ) -> Result, 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, ) -> Result, RepoError>; /// Get resume movies async fn get_resume_movies(&self, limit: Option) -> Result, RepoError>; /// Get genres async fn get_genres(&self, parent_id: Option<&str>) -> Result, 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, ) -> Result; /// 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; /// 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; /// 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, audio_stream_index: Option, ) -> Result; /// Get Live TV channels (broadcast / IPTV) for browsing. async fn get_live_tv_channels(&self) -> Result, 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; /// 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; /// 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, ) -> 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, ) -> Result; /// 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; /// Get items by person (filmography) async fn get_items_by_person( &self, person_id: &str, options: Option, ) -> Result; /// 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, ) -> Result; // ===== 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; /// 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, 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 { 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()) }