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 [`resolve_video_download`]); `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/// A video download, resolved: the URL to fetch and, where the item told us
349/// enough, how many bytes to expect from it.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct ResolvedVideoDownload {
352 pub url: String,
353 /// Predicted size (see `download::estimate`), used as the progress total
354 /// when the response carries no `Content-Length` — a transcode never does.
355 pub expected_bytes: Option<u64>,
356}
357
358/// Resolve the download URL for a video, applying the audio-codec policy that
359/// keeps the saved file playable offline (DR-171), and predict its size from
360/// the same item lookup (DR-290).
361///
362/// Every video download goes through here rather than calling the builder
363/// directly: the builder is pure and cannot look the codec up, and a caller that
364/// forgets to is exactly how the silent downloads shipped.
365///
366/// TRACES: UR-071 | DR-171, DR-290
367pub async fn resolve_video_download(
368 repo: &dyn MediaRepository,
369 item_id: &str,
370 quality: &str,
371 media_source_id: Option<&str>,
372) -> ResolvedVideoDownload {
373 let item = repo.get_item(item_id).await.ok();
374
375 let audio: Vec<(Option<&str>, bool)> = item
376 .as_ref()
377 .and_then(|i| i.media_streams.as_deref())
378 .unwrap_or_default()
379 .iter()
380 .filter(|s| s.stream_type == "Audio")
381 .map(|s| (s.codec.as_deref(), s.is_default))
382 .collect();
383 // The default track, or the first when none is marked, matching the track
384 // Jellyfin picks. `None` reads as "unknown", never as "fine": it feeds a
385 // policy that only *adds* a transcode, so an unknown codec leaves behaviour
386 // exactly as it was. TRACES: UR-071 | DR-171 | UT-166
387 let codec = device_profile::served_audio_codec(&audio);
388
389 // The source that will be served: the one asked for, else the first —
390 // the same choice Jellyfin makes when no `mediaSourceId` is given.
391 let source_size = item.as_ref().and_then(|i| {
392 let sources = i.media_sources.as_deref()?;
393 let source = match media_source_id {
394 Some(id) => sources.iter().find(|s| s.id == id),
395 None => sources.first(),
396 };
397 source?.size
398 });
399 let expected_bytes = crate::download::estimate::expected_download_bytes(
400 quality,
401 item.as_ref().and_then(|i| i.runtime_ticks),
402 source_size,
403 );
404
405 ResolvedVideoDownload {
406 url: repo.get_video_download_url(item_id, quality, media_source_id, codec),
407 expected_bytes,
408 }
409}
410
411/// [`resolve_video_download`] for callers that only need the URL.
412///
413/// TRACES: UR-071 | DR-171
414pub async fn resolve_video_download_url(
415 repo: &dyn MediaRepository,
416 item_id: &str,
417 quality: &str,
418 media_source_id: Option<&str>,
419) -> String {
420 resolve_video_download(repo, item_id, quality, media_source_id)
421 .await
422 .url
423}