Skip to main content

jellytau_lib/repository/
mod.rs

1pub mod capabilities;
2pub mod device_profile;
3pub mod endpoints;
4/// User-chosen browsing exclusions (UR-076 / DR-209).
5pub mod exclusions;
6#[cfg(test)]
7mod generation_tests;
8pub mod hybrid;
9pub mod offline;
10pub mod online;
11pub mod series_progress;
12#[cfg(test)]
13pub mod server_fixture;
14/// Backend-owned stream selection (UR-079 / DR-225).
15pub mod stream_selection;
16pub mod types;
17
18pub use hybrid::HybridRepository;
19pub use offline::OfflineRepository;
20pub use online::{JRayActor, OnlineRepository};
21pub use stream_selection::{StreamSelection, Transport};
22pub use types::*;
23
24use async_trait::async_trait;
25
26/// Repository trait for media access (online, offline, or hybrid)
27///
28/// @req: UR-002 - Access media when online or offline
29/// @req: UR-007 - Navigate media in library
30/// @req: UR-008 - Search media across libraries
31/// @req: IR-010 - Jellyfin API client for library browsing
32/// @req: DR-012 - Local database for media metadata cache
33/// @req: DR-013 - Repository pattern for online/offline data access
34#[async_trait]
35pub trait MediaRepository: Send + Sync {
36    /// Get all libraries
37    ///
38    /// @req: UR-007 - Navigate media in library
39    /// @req: JA-003 - Get user library views
40    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError>;
41
42    /// Get items in a library or parent
43    ///
44    /// @req: UR-007 - Navigate media in library
45    /// @req: JA-004 - Get library items (paginated)
46    async fn get_items(
47        &self,
48        parent_id: &str,
49        options: Option<GetItemsOptions>,
50    ) -> Result<SearchResult, RepoError>;
51
52    /// Get a single item by ID
53    ///
54    /// @req: UR-007 - Navigate media in library
55    /// @req: JA-005 - Get item details and metadata
56    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError>;
57
58    /// Get latest items in a library
59    ///
60    /// @req: UR-024 - View recently added content on server
61    /// @req: JA-016 - Get recently added items
62    async fn get_latest_items(
63        &self,
64        parent_id: &str,
65        limit: Option<usize>,
66    ) -> Result<Vec<MediaItem>, RepoError>;
67
68    /// Get resume items (continue watching/listening)
69    ///
70    /// @req: UR-019 - Resume playback from where you left off
71    /// @req: UR-023 - View "Next Up" / Continue Watching on home screen
72    /// @req: JA-015 - Get "Continue Watching" items
73    async fn get_resume_items(
74        &self,
75        parent_id: Option<&str>,
76        limit: Option<usize>,
77    ) -> Result<Vec<MediaItem>, RepoError>;
78
79    /// Get next up episodes
80    ///
81    /// @req: UR-023 - View "Next Up" / Continue Watching; auto-play next episode
82    /// @req: JA-014 - Get "Next Up" items
83    async fn get_next_up_episodes(
84        &self,
85        series_id: Option<&str>,
86        limit: Option<usize>,
87    ) -> Result<Vec<MediaItem>, RepoError>;
88
89    /// Get recently played audio
90    async fn get_recently_played_audio(
91        &self,
92        limit: Option<usize>,
93    ) -> Result<Vec<MediaItem>, RepoError>;
94
95    /// Get albums the user has played, but not recently ("rediscover" / haven't
96    /// listened to in a while). Returns albums sorted by least-recently played
97    /// first, optionally restricted to a parent library.
98    async fn get_rediscover_albums(
99        &self,
100        parent_id: Option<&str>,
101        limit: Option<usize>,
102    ) -> Result<Vec<MediaItem>, RepoError>;
103
104    /// Get resume movies
105    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
106
107    /// Get genres
108    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError>;
109
110    /// Search for items
111    ///
112    /// @req: UR-008 - Search media across libraries
113    /// @req: JA-006 - Search across libraries
114    async fn search(
115        &self,
116        query: &str,
117        options: Option<SearchOptions>,
118    ) -> Result<SearchResult, RepoError>;
119
120    /// Get playback info for streaming
121    ///
122    /// @req: UR-003 - Play videos
123    /// @req: UR-004 - Play audio uninterrupted
124    /// @req: JA-007 - Get playback info and stream URL
125    async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError>;
126
127    /// Get audio stream URL for a track
128    ///
129    /// @req: UR-004 - Play audio uninterrupted
130    /// @req: JA-007 - Get playback info and stream URL
131    async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
132
133    /// Get an audio-only stream URL for a *video* item (background-audio handoff).
134    ///
135    /// Used when autoplay advances to the next episode while the app is playing a
136    /// video in audio-only mode in the background: the backend needs the next
137    /// episode's audio-only URL without any frontend round-trip. Online-only;
138    /// offline/cache repositories return an error.
139    ///
140    /// TRACES: UR-040 | JA-032
141    async fn get_audio_only_stream_url_for_video(
142        &self,
143        item_id: &str,
144        media_source_id: Option<&str>,
145        start_time_seconds: Option<f64>,
146        audio_stream_index: Option<i32>,
147    ) -> Result<String, RepoError>;
148
149    /// Get Live TV channels (broadcast / IPTV) for browsing.
150    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
151
152    /// Get the root list of plugin "Channels" (Jellyfin Channels feature).
153    /// Drill-down into a channel reuses `get_items(channel_id, ...)`.
154    async fn get_channels(&self) -> Result<SearchResult, RepoError>;
155
156    /// Open a live stream (Live TV channel or live channel item) for playback.
157    ///
158    /// Returns the server transcoding URL plus identifiers needed to manage the
159    /// stream. Required before a live channel can be played over HLS.
160    async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError>;
161
162    /// Report playback start
163    ///
164    /// @req: UR-025 - Sync watch history and progress back to Jellyfin
165    /// @req: JA-010 - Report playback start
166    async fn report_playback_start(
167        &self,
168        item_id: &str,
169        position_ticks: i64,
170    ) -> Result<(), RepoError>;
171
172    /// Report playback progress
173    ///
174    /// @req: UR-025 - Sync watch history and progress back to Jellyfin
175    /// @req: JA-011 - Report playback progress (periodic)
176    async fn report_playback_progress(
177        &self,
178        item_id: &str,
179        position_ticks: i64,
180    ) -> Result<(), RepoError>;
181
182    /// Report playback stopped
183    ///
184    /// @req: UR-025 - Sync watch history and progress back to Jellyfin
185    /// @req: JA-012 - Report playback stopped
186    async fn report_playback_stopped(
187        &self,
188        item_id: &str,
189        position_ticks: i64,
190    ) -> Result<(), RepoError>;
191
192    /// Get image URL (synchronous - just constructs URL)
193    fn get_image_url(
194        &self,
195        item_id: &str,
196        image_type: ImageType,
197        options: Option<ImageOptions>,
198    ) -> String;
199
200    /// Get subtitle URL (synchronous - just constructs URL)
201    /// Called by frontend via Tauri invoke (getSubtitleUrl in VideoPlayer.svelte)
202    #[allow(dead_code)]
203    fn get_subtitle_url(
204        &self,
205        item_id: &str,
206        media_source_id: &str,
207        stream_index: i32,
208        format: &str,
209    ) -> String;
210
211    /// Build the URL a video download is fetched from. Synchronous — it only
212    /// constructs a URL, so it stays testable without a server. Reach it through
213    /// [`resolve_video_download_url`] rather than calling it directly.
214    ///
215    /// `source_audio_codec` is the codec of the audio track the server would
216    /// serve (see [`served_audio_codec`]); `None` when it is not known. At
217    /// `original` quality it decides whether the file can be copied byte-for-byte
218    /// or has to have its audio re-encoded on the way down — a downloaded file is
219    /// played back with no server in reach, so it has to be decodable *here*.
220    ///
221    /// TRACES: UR-071 | DR-171
222    #[allow(dead_code)]
223    fn get_video_download_url(
224        &self,
225        item_id: &str,
226        quality: &str,
227        media_source_id: Option<&str>,
228        source_audio_codec: Option<&str>,
229    ) -> String;
230
231    /// Mark item as favorite
232    async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
233
234    /// Unmark item as favorite
235    async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
236
237    /// Everything the viewer has favourited, across every library.
238    ///
239    /// Separate from `get_items` because favourites span libraries and
240    /// `get_items` is `ParentId`-shaped. `scope` is the opaque enum the
241    /// frontend sends; this layer expands it to item types (DR-063) so no
242    /// Jellyfin taxonomy is needed on the other side of the IPC boundary.
243    ///
244    /// TRACES: UR-067 | DR-115, JA-033 | UT-100, UT-101
245    async fn get_favorites(
246        &self,
247        scope: SearchScope,
248        options: Option<GetItemsOptions>,
249    ) -> Result<SearchResult, RepoError>;
250
251    /// Erase the viewer's watch history for an item: clear its played flag and
252    /// its resume position. On a container (series, season) this applies to
253    /// everything inside it, so a series is returned to "never watched" and
254    /// reopens on its premiere.
255    ///
256    /// TRACES: UR-064 | DR-106
257    async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
258
259    /// Mark an item played — the inverse of `clear_watch_history`. Needed by the
260    /// sync-queue drain, which replays `mark_played` rows queued while the
261    /// server was unreachable; reporting a stop at a made-up position was the
262    /// previous stand-in and does not set the played flag reliably.
263    ///
264    /// TRACES: UR-025 | DR-131 | JA-035
265    async fn mark_played(&self, item_id: &str) -> Result<(), RepoError>;
266
267    /// Get person details
268    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
269
270    /// Get items by person (filmography)
271    async fn get_items_by_person(
272        &self,
273        person_id: &str,
274        options: Option<GetItemsOptions>,
275    ) -> Result<SearchResult, RepoError>;
276
277    /// Get similar/related items for a movie or show
278    ///
279    /// @req: UR-009 - Discover similar content based on current item
280    async fn get_similar_items(
281        &self,
282        item_id: &str,
283        limit: Option<usize>,
284    ) -> Result<SearchResult, RepoError>;
285
286    // ===== Playlist Methods =====
287
288    /// Create a new playlist on the server
289    ///
290    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
291    /// @req: JA-019 - Get/create/update playlists
292    async fn create_playlist(
293        &self,
294        name: &str,
295        item_ids: &[String],
296    ) -> Result<PlaylistCreatedResult, RepoError>;
297
298    /// Delete a playlist
299    ///
300    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
301    /// @req: JA-019 - Get/create/update playlists
302    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
303
304    /// Rename a playlist
305    ///
306    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
307    /// @req: JA-019 - Get/create/update playlists
308    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
309
310    /// Get playlist items with PlaylistItemId (needed for remove/reorder)
311    ///
312    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
313    /// @req: JA-019 - Get/create/update playlists
314    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
315
316    /// Add items to a playlist
317    ///
318    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
319    /// @req: JA-020 - Add/remove items from playlist
320    async fn add_to_playlist(
321        &self,
322        playlist_id: &str,
323        item_ids: &[String],
324    ) -> Result<(), RepoError>;
325
326    /// Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs)
327    ///
328    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
329    /// @req: JA-020 - Add/remove items from playlist
330    async fn remove_from_playlist(
331        &self,
332        playlist_id: &str,
333        entry_ids: &[String],
334    ) -> Result<(), RepoError>;
335
336    /// Move a playlist item to a new position
337    ///
338    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
339    /// @req: JA-020 - Add/remove items from playlist
340    async fn move_playlist_item(
341        &self,
342        playlist_id: &str,
343        item_id: &str,
344        new_index: u32,
345    ) -> Result<(), RepoError>;
346}
347
348/// The audio codec the server would serve for `item_id` — the default track, or
349/// the first when none is marked, matching the track Jellyfin picks.
350///
351/// `None` when the item has no audio, names no codec, or cannot be fetched. A
352/// caller must read that as "unknown", never as "fine": it is the input to a
353/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
354/// exactly as it was.
355///
356/// TRACES: UR-071 | DR-171 | UT-166
357pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
358    let item = repo.get_item(item_id).await.ok()?;
359    let audio: Vec<(Option<&str>, bool)> = item
360        .media_streams
361        .as_deref()
362        .unwrap_or_default()
363        .iter()
364        .filter(|s| s.stream_type == "Audio")
365        .map(|s| (s.codec.as_deref(), s.is_default))
366        .collect();
367
368    device_profile::served_audio_codec(&audio).map(str::to_string)
369}
370
371/// Resolve the download URL for a video, applying the audio-codec policy that
372/// keeps the saved file playable offline (DR-171).
373///
374/// Every video download goes through here rather than calling the builder
375/// directly: the builder is pure and cannot look the codec up, and a caller that
376/// forgets to is exactly how the silent downloads shipped.
377///
378/// TRACES: UR-071 | DR-171
379pub async fn resolve_video_download_url(
380    repo: &dyn MediaRepository,
381    item_id: &str,
382    quality: &str,
383    media_source_id: Option<&str>,
384) -> String {
385    let codec = served_audio_codec(repo, item_id).await;
386    repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
387}