Skip to main content

jellytau_lib/player/
media.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4/// Context for the current queue - where did the queue items come from?
5/// This is used for remote playback transfer to send album/playlist context.
6#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
7#[serde(tag = "type", rename_all = "lowercase")]
8pub enum QueueContext {
9    /// Playing from a specific album
10    Album {
11        album_id: String,
12        album_name: String,
13    },
14    /// Playing from a specific playlist
15    Playlist {
16        playlist_id: String,
17        playlist_name: String,
18    },
19    /// Custom queue (search results, manual queue, etc.)
20    /// Will create a temporary playlist on remote transfer
21    #[default]
22    Custom,
23}
24
25/// Represents a subtitle track
26///
27/// 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
28/// struct in the player that deliberately keeps snake_case on the wire, because
29/// the *same* serialization feeds two consumers that both spell `mime_type`:
30///
31/// * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
32///   with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
33///   whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
34/// * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
35///   from the frontend, and the generated binding (`SubtitleTrack` in
36///   `bindings.ts`) therefore also declares `mime_type`.
37///
38/// Renaming would not break the build and would not fail the IPC: Kotlin's
39/// `optString` would just fall back to its default MIME type for every track, so
40/// the failure would be silent. UT-146 asserts the serialized keys.
41///
42/// TRACES: UR-020 | IR-016, JA-008 | UT-146
43#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
44pub struct SubtitleTrack {
45    /// Stream index in the media source
46    pub index: i32,
47    /// Subtitle URL
48    pub url: String,
49    /// Language code (e.g., "eng", "spa")
50    pub language: Option<String>,
51    /// Display title
52    pub label: Option<String>,
53    /// MIME type (e.g., "text/vtt", "application/x-subrip").
54    /// Snake_case on purpose — see the note on the struct.
55    pub mime_type: String,
56}
57
58/// Represents a media item that can be played
59///
60/// TRACES: UR-003, UR-004 | DR-002
61#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
62#[serde(rename_all = "camelCase")]
63#[specta(rename = "PlayerMediaItem")]
64pub struct MediaItem {
65    /// Unique identifier
66    pub id: String,
67    /// Display title
68    pub title: String,
69    /// Name (alias for title - for frontend compatibility)
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub name: Option<String>,
72    /// Artist name(s) for audio
73    pub artist: Option<String>,
74    /// Album name for audio
75    pub album: Option<String>,
76    /// Album name (alias - for frontend compatibility)
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub album_name: Option<String>,
79    /// Album ID (Jellyfin ID) for remote transfer context
80    #[serde(default)]
81    pub album_id: Option<String>,
82    /// Artist items with IDs for clickable links
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
85    /// Artists as array of strings (fallback when artist_items not available)
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub artists: Option<Vec<String>>,
88    /// Primary image tag for artwork.
89    ///
90    /// Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
91    /// while the frontend migrates (docs/specs/frontend-domain-model.md).
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub primary_image_tag: Option<String>,
94    /// Neutral image identifier the frontend resolves to a URL — replaces
95    /// `primary_image_tag`.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub image_id: Option<String>,
98    /// Item type (Audio, Movie, Episode, etc.)
99    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
100    pub item_type: Option<String>,
101    /// Playlist ID (Jellyfin ID) for remote transfer context
102    #[serde(default)]
103    pub playlist_id: Option<String>,
104    /// Duration in seconds
105    pub duration: Option<f64>,
106    /// URL or path to artwork image
107    pub artwork_url: Option<String>,
108    /// Type of media
109    pub media_type: MediaType,
110    /// Source of the media
111    pub source: MediaSource,
112    /// Video codec (e.g., "h264", "hevc") for video media
113    #[serde(default)]
114    pub video_codec: Option<String>,
115    /// Whether the video requires server-side transcoding
116    #[serde(default)]
117    pub needs_transcoding: bool,
118    /// How this item's stream is fetched, as the backend decided it.
119    ///
120    /// Carried on the queue item so a later seek/reload does not have to guess.
121    /// `None` for items queued by a path that never negotiated (audio tracks,
122    /// direct URLs) and for anything queued before this field existed, where the
123    /// caller falls back to `needs_transcoding` — every transcode this app
124    /// requests is HLS (DR-140), so that fallback is exact rather than a guess.
125    ///
126    /// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
127    #[serde(default)]
128    pub transport: Option<crate::repository::Transport>,
129
130    /// Video width in pixels
131    #[serde(default)]
132    pub video_width: Option<u32>,
133    /// Video height in pixels
134    #[serde(default)]
135    pub video_height: Option<u32>,
136    /// Available subtitle tracks
137    #[serde(default)]
138    pub subtitles: Vec<SubtitleTrack>,
139    /// Series ID (for TV show episodes) - used for series audio preferences
140    #[serde(default)]
141    pub series_id: Option<String>,
142    /// Server ID - used for series audio preferences
143    #[serde(default)]
144    pub server_id: Option<String>,
145}
146
147#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "lowercase")]
149pub enum MediaType {
150    Audio,
151    Video,
152}
153
154/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
155#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
156#[serde(tag = "type", rename_all = "lowercase")]
157#[specta(rename = "PlayerMediaSource")]
158pub enum MediaSource {
159    /// Streaming from Jellyfin server
160    Remote {
161        stream_url: String,
162        jellyfin_item_id: String,
163    },
164    /// Downloaded/cached locally
165    Local {
166        file_path: PathBuf,
167        /// Original Jellyfin ID for sync-back
168        jellyfin_item_id: Option<String>,
169    },
170    /// Direct URL (e.g., channel plugins)
171    DirectUrl { url: String },
172}
173
174impl MediaItem {
175    /// The URL or path an engine should open.
176    ///
177    /// TRACES: UR-081 | DR-245
178    pub fn playable_url(&self) -> String {
179        match &self.source {
180            MediaSource::Remote { stream_url, .. } => stream_url.clone(),
181            MediaSource::Local { file_path, .. } => file_path.to_string_lossy().into_owned(),
182            MediaSource::DirectUrl { url } => url.clone(),
183        }
184    }
185}
186
187impl MediaItem {
188    /// Get the Jellyfin item ID if available
189    pub fn jellyfin_id(&self) -> Option<&str> {
190        match &self.source {
191            MediaSource::Remote {
192                jellyfin_item_id, ..
193            } => Some(jellyfin_item_id),
194            MediaSource::Local {
195                jellyfin_item_id, ..
196            } => jellyfin_item_id.as_deref(),
197            MediaSource::DirectUrl { .. } => None,
198        }
199    }
200
201    /// Get the playback URL or file path
202    ///
203    /// Only available on Android where ExoPlayer needs direct URL access
204    #[cfg(target_os = "android")]
205    pub fn playback_url(&self) -> String {
206        match &self.source {
207            MediaSource::Remote { stream_url, .. } => stream_url.clone(),
208            MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
209            MediaSource::DirectUrl { url } => url.clone(),
210        }
211    }
212}
213
214impl MediaItem {
215    /// A minimal item for tests.
216    ///
217    /// The struct has twenty-odd fields, almost none of which any given test
218    /// cares about, and repeating the literal per test is how a new field ends
219    /// up added in thirty places. Set what matters on the result.
220    ///
221    /// TRACES: UR-081 | DR-243
222    #[cfg(any(test, feature = "conformance"))]
223    pub fn sample(id: &str, url: &str) -> Self {
224        Self {
225            transport: None,
226            id: id.to_string(),
227            title: id.to_string(),
228            name: None,
229            artist: None,
230            album: None,
231            album_name: None,
232            album_id: None,
233            artist_items: None,
234            artists: None,
235            primary_image_tag: None,
236            image_id: None,
237            item_type: None,
238            playlist_id: None,
239            duration: None,
240            artwork_url: None,
241            media_type: MediaType::Video,
242            source: MediaSource::DirectUrl {
243                url: url.to_string(),
244            },
245            video_codec: None,
246            needs_transcoding: false,
247            video_width: None,
248            video_height: None,
249            subtitles: vec![],
250            series_id: None,
251            server_id: None,
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use std::path::PathBuf;
260
261    #[test]
262    fn test_queue_context_album() {
263        let context = QueueContext::Album {
264            album_id: "album-123".to_string(),
265            album_name: "Test Album".to_string(),
266        };
267
268        assert!(matches!(context, QueueContext::Album { .. }));
269    }
270
271    #[test]
272    fn test_queue_context_playlist() {
273        let context = QueueContext::Playlist {
274            playlist_id: "playlist-456".to_string(),
275            playlist_name: "Test Playlist".to_string(),
276        };
277
278        assert!(matches!(context, QueueContext::Playlist { .. }));
279    }
280
281    #[test]
282    fn test_queue_context_custom() {
283        let context = QueueContext::Custom;
284        assert!(matches!(context, QueueContext::Custom));
285    }
286
287    #[test]
288    fn test_queue_context_serialization() {
289        let context = QueueContext::Album {
290            album_id: "alb-001".to_string(),
291            album_name: "Album 001".to_string(),
292        };
293
294        let json = serde_json::to_string(&context);
295        assert!(json.is_ok());
296        let serialized = json.unwrap();
297        assert!(serialized.contains("album"));
298    }
299
300    #[test]
301    fn test_queue_context_default() {
302        let context = QueueContext::default();
303        assert!(matches!(context, QueueContext::Custom));
304    }
305
306    #[test]
307    fn test_queue_context_clone() {
308        let context = QueueContext::Album {
309            album_id: "clone-alb".to_string(),
310            album_name: "Clone Album".to_string(),
311        };
312
313        let cloned = context.clone();
314        assert_eq!(context, cloned);
315    }
316
317    #[test]
318    fn test_subtitle_track_creation() {
319        let track = SubtitleTrack {
320            index: 0,
321            url: "https://example.com/subs.vtt".to_string(),
322            language: Some("eng".to_string()),
323            label: Some("English".to_string()),
324            mime_type: "text/vtt".to_string(),
325        };
326
327        assert_eq!(track.index, 0);
328        assert_eq!(track.language, Some("eng".to_string()));
329    }
330
331    #[test]
332    fn test_subtitle_track_without_language() {
333        let track = SubtitleTrack {
334            index: 1,
335            url: "https://example.com/subs.srt".to_string(),
336            language: None,
337            label: Some("Subtitles".to_string()),
338            mime_type: "application/x-subrip".to_string(),
339        };
340
341        assert!(track.language.is_none());
342        assert!(track.label.is_some());
343    }
344
345    #[test]
346    fn test_subtitle_track_serialization() {
347        let track = SubtitleTrack {
348            index: 0,
349            url: "url.vtt".to_string(),
350            language: Some("eng".to_string()),
351            label: None,
352            mime_type: "text/vtt".to_string(),
353        };
354
355        let json = serde_json::to_string(&track);
356        assert!(json.is_ok());
357        let serialized = json.unwrap();
358        assert!(serialized.contains("0"));
359        assert!(serialized.contains("eng"));
360    }
361
362    #[test]
363    fn test_media_type_audio() {
364        let media_type = MediaType::Audio;
365        let json = serde_json::to_string(&media_type).unwrap();
366        assert!(json.contains("audio"));
367    }
368
369    #[test]
370    fn test_media_type_video() {
371        let media_type = MediaType::Video;
372        let json = serde_json::to_string(&media_type).unwrap();
373        assert!(json.contains("video"));
374    }
375
376    #[test]
377    fn test_media_source_remote() {
378        let source = MediaSource::Remote {
379            stream_url: "https://server.com/video.mp4".to_string(),
380            jellyfin_item_id: "item-123".to_string(),
381        };
382
383        assert!(matches!(source, MediaSource::Remote { .. }));
384    }
385
386    #[test]
387    fn test_media_source_local() {
388        let source = MediaSource::Local {
389            file_path: PathBuf::from("/path/to/video.mp4"),
390            jellyfin_item_id: Some("item-456".to_string()),
391        };
392
393        assert!(matches!(source, MediaSource::Local { .. }));
394    }
395
396    #[test]
397    fn test_media_source_local_without_jellyfin_id() {
398        let source = MediaSource::Local {
399            file_path: PathBuf::from("/downloads/audio.mp3"),
400            jellyfin_item_id: None,
401        };
402
403        assert!(matches!(source, MediaSource::Local { .. }));
404    }
405
406    #[test]
407    fn test_media_source_direct_url() {
408        let source = MediaSource::DirectUrl {
409            url: "https://external.com/stream".to_string(),
410        };
411
412        assert!(matches!(source, MediaSource::DirectUrl { .. }));
413    }
414
415    #[test]
416    fn test_media_source_serialization() {
417        let source = MediaSource::DirectUrl {
418            url: "https://example.com/stream".to_string(),
419        };
420
421        let json = serde_json::to_string(&source);
422        assert!(json.is_ok());
423        let serialized = json.unwrap();
424        assert!(serialized.contains("directurl"));
425    }
426
427    #[test]
428    fn test_media_item_creation_minimal() {
429        let item = MediaItem {
430            // Audio and direct-URL items never negotiate a transport.
431            transport: None,
432            id: "item-1".to_string(),
433            title: "Test Item".to_string(),
434            name: None,
435            artist: None,
436            album: None,
437            album_name: None,
438            album_id: None,
439            artist_items: None,
440            artists: None,
441            primary_image_tag: None,
442            image_id: None,
443            item_type: None,
444            playlist_id: None,
445            duration: None,
446            artwork_url: None,
447            media_type: MediaType::Video,
448            source: MediaSource::DirectUrl {
449                url: "https://example.com/video".to_string(),
450            },
451            video_codec: None,
452            needs_transcoding: false,
453            video_width: None,
454            video_height: None,
455            subtitles: vec![],
456            series_id: None,
457            server_id: None,
458        };
459
460        assert_eq!(item.id, "item-1");
461        assert_eq!(item.title, "Test Item");
462        assert!(!item.needs_transcoding);
463    }
464
465    #[test]
466    fn test_media_item_jellyfin_id() {
467        let item = MediaItem {
468            // Audio and direct-URL items never negotiate a transport.
469            transport: None,
470            id: "item-2".to_string(),
471            title: "Test".to_string(),
472            name: None,
473            artist: None,
474            album: None,
475            album_name: None,
476            album_id: None,
477            artist_items: None,
478            artists: None,
479            primary_image_tag: None,
480            image_id: None,
481            item_type: None,
482            playlist_id: None,
483            duration: None,
484            artwork_url: None,
485            media_type: MediaType::Audio,
486            source: MediaSource::Remote {
487                stream_url: "https://server/stream".to_string(),
488                jellyfin_item_id: "jf-id-123".to_string(),
489            },
490            video_codec: None,
491            needs_transcoding: false,
492            video_width: None,
493            video_height: None,
494            subtitles: vec![],
495            series_id: None,
496            server_id: None,
497        };
498
499        assert_eq!(item.jellyfin_id(), Some("jf-id-123"));
500    }
501
502    #[test]
503    fn test_media_item_jellyfin_id_local() {
504        let item = MediaItem {
505            // Audio and direct-URL items never negotiate a transport.
506            transport: None,
507            id: "item-3".to_string(),
508            title: "Local".to_string(),
509            name: None,
510            artist: None,
511            album: None,
512            album_name: None,
513            album_id: None,
514            artist_items: None,
515            artists: None,
516            primary_image_tag: None,
517            image_id: None,
518            item_type: None,
519            playlist_id: None,
520            duration: None,
521            artwork_url: None,
522            media_type: MediaType::Video,
523            source: MediaSource::Local {
524                file_path: PathBuf::from("/local/video.mp4"),
525                jellyfin_item_id: Some("jf-local".to_string()),
526            },
527            video_codec: None,
528            needs_transcoding: false,
529            video_width: None,
530            video_height: None,
531            subtitles: vec![],
532            series_id: None,
533            server_id: None,
534        };
535
536        assert_eq!(item.jellyfin_id(), Some("jf-local"));
537    }
538
539    #[test]
540    fn test_media_item_jellyfin_id_direct_url() {
541        let item = MediaItem {
542            // Audio and direct-URL items never negotiate a transport.
543            transport: None,
544            id: "item-4".to_string(),
545            title: "Direct".to_string(),
546            name: None,
547            artist: None,
548            album: None,
549            album_name: None,
550            album_id: None,
551            artist_items: None,
552            artists: None,
553            primary_image_tag: None,
554            image_id: None,
555            item_type: None,
556            playlist_id: None,
557            duration: None,
558            artwork_url: None,
559            media_type: MediaType::Video,
560            source: MediaSource::DirectUrl {
561                url: "https://external.com/media".to_string(),
562            },
563            video_codec: None,
564            needs_transcoding: false,
565            video_width: None,
566            video_height: None,
567            subtitles: vec![],
568            series_id: None,
569            server_id: None,
570        };
571
572        assert_eq!(item.jellyfin_id(), None);
573    }
574
575    #[test]
576    fn test_media_item_with_subtitles() {
577        let sub = SubtitleTrack {
578            index: 0,
579            url: "subs.vtt".to_string(),
580            language: Some("eng".to_string()),
581            label: Some("English".to_string()),
582            mime_type: "text/vtt".to_string(),
583        };
584
585        let item = MediaItem {
586            // Audio and direct-URL items never negotiate a transport.
587            transport: None,
588            id: "item-subs".to_string(),
589            title: "With Subs".to_string(),
590            name: None,
591            artist: None,
592            album: None,
593            album_name: None,
594            album_id: None,
595            artist_items: None,
596            artists: None,
597            primary_image_tag: None,
598            image_id: None,
599            item_type: None,
600            playlist_id: None,
601            duration: None,
602            artwork_url: None,
603            media_type: MediaType::Video,
604            source: MediaSource::DirectUrl {
605                url: "video.mp4".to_string(),
606            },
607            video_codec: None,
608            needs_transcoding: false,
609            video_width: None,
610            video_height: None,
611            subtitles: vec![sub],
612            series_id: None,
613            server_id: None,
614        };
615
616        assert_eq!(item.subtitles.len(), 1);
617        assert_eq!(item.subtitles[0].language, Some("eng".to_string()));
618    }
619
620    #[test]
621    fn test_media_item_serialization() {
622        let item = MediaItem {
623            // Audio and direct-URL items never negotiate a transport.
624            transport: None,
625            id: "serial-item".to_string(),
626            title: "Serial Test".to_string(),
627            name: Some("Name".to_string()),
628            artist: None,
629            album: None,
630            album_name: None,
631            album_id: None,
632            artist_items: None,
633            artists: None,
634            primary_image_tag: None,
635            image_id: None,
636            item_type: Some("Movie".to_string()),
637            playlist_id: None,
638            duration: Some(120.0),
639            artwork_url: None,
640            media_type: MediaType::Video,
641            source: MediaSource::DirectUrl {
642                url: "https://example.com/movie.mp4".to_string(),
643            },
644            video_codec: Some("h264".to_string()),
645            needs_transcoding: false,
646            video_width: Some(1920),
647            video_height: Some(1080),
648            subtitles: vec![],
649            series_id: None,
650            server_id: None,
651        };
652
653        let json = serde_json::to_string(&item);
654        assert!(json.is_ok());
655        let serialized = json.unwrap();
656        assert!(serialized.contains("serial-item"));
657        assert!(serialized.contains("Serial Test"));
658    }
659}