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    /// Get the Jellyfin item ID if available
176    pub fn jellyfin_id(&self) -> Option<&str> {
177        match &self.source {
178            MediaSource::Remote {
179                jellyfin_item_id, ..
180            } => Some(jellyfin_item_id),
181            MediaSource::Local {
182                jellyfin_item_id, ..
183            } => jellyfin_item_id.as_deref(),
184            MediaSource::DirectUrl { .. } => None,
185        }
186    }
187
188    /// The URL or path an engine should open.
189    ///
190    /// Not gated to Android any more. It was, back when only ExoPlayer needed
191    /// direct URL access — and that gate is why a byte-identical copy was later
192    /// added for the cross-platform `MediaPlayer::open` path without anyone
193    /// noticing this existed: it is invisible in a Linux build, so nothing
194    /// warned. Two matches over `MediaSource` meant a new variant could be
195    /// handled in one and forgotten in the other, silently.
196    ///
197    /// TRACES: UR-081 | DR-245, DR-255
198    pub fn playback_url(&self) -> String {
199        match &self.source {
200            MediaSource::Remote { stream_url, .. } => stream_url.clone(),
201            MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
202            MediaSource::DirectUrl { url } => url.clone(),
203        }
204    }
205}
206
207impl MediaItem {
208    /// A minimal item for tests.
209    ///
210    /// The struct has twenty-odd fields, almost none of which any given test
211    /// cares about, and repeating the literal per test is how a new field ends
212    /// up added in thirty places. Set what matters on the result.
213    ///
214    /// TRACES: UR-081 | DR-243
215    #[cfg(any(test, feature = "conformance"))]
216    pub fn sample(id: &str, url: &str) -> Self {
217        Self {
218            transport: None,
219            id: id.to_string(),
220            title: id.to_string(),
221            name: None,
222            artist: None,
223            album: None,
224            album_name: None,
225            album_id: None,
226            artist_items: None,
227            artists: None,
228            primary_image_tag: None,
229            image_id: None,
230            item_type: None,
231            playlist_id: None,
232            duration: None,
233            artwork_url: None,
234            media_type: MediaType::Video,
235            source: MediaSource::DirectUrl {
236                url: url.to_string(),
237            },
238            video_codec: None,
239            needs_transcoding: false,
240            video_width: None,
241            video_height: None,
242            subtitles: vec![],
243            series_id: None,
244            server_id: None,
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::path::PathBuf;
253
254    #[test]
255    fn test_queue_context_album() {
256        let context = QueueContext::Album {
257            album_id: "album-123".to_string(),
258            album_name: "Test Album".to_string(),
259        };
260
261        assert!(matches!(context, QueueContext::Album { .. }));
262    }
263
264    #[test]
265    fn test_queue_context_playlist() {
266        let context = QueueContext::Playlist {
267            playlist_id: "playlist-456".to_string(),
268            playlist_name: "Test Playlist".to_string(),
269        };
270
271        assert!(matches!(context, QueueContext::Playlist { .. }));
272    }
273
274    #[test]
275    fn test_queue_context_custom() {
276        let context = QueueContext::Custom;
277        assert!(matches!(context, QueueContext::Custom));
278    }
279
280    #[test]
281    fn test_queue_context_serialization() {
282        let context = QueueContext::Album {
283            album_id: "alb-001".to_string(),
284            album_name: "Album 001".to_string(),
285        };
286
287        let json = serde_json::to_string(&context);
288        assert!(json.is_ok());
289        let serialized = json.unwrap();
290        assert!(serialized.contains("album"));
291    }
292
293    #[test]
294    fn test_queue_context_default() {
295        let context = QueueContext::default();
296        assert!(matches!(context, QueueContext::Custom));
297    }
298
299    #[test]
300    fn test_queue_context_clone() {
301        let context = QueueContext::Album {
302            album_id: "clone-alb".to_string(),
303            album_name: "Clone Album".to_string(),
304        };
305
306        let cloned = context.clone();
307        assert_eq!(context, cloned);
308    }
309
310    #[test]
311    fn test_subtitle_track_creation() {
312        let track = SubtitleTrack {
313            index: 0,
314            url: "https://example.com/subs.vtt".to_string(),
315            language: Some("eng".to_string()),
316            label: Some("English".to_string()),
317            mime_type: "text/vtt".to_string(),
318        };
319
320        assert_eq!(track.index, 0);
321        assert_eq!(track.language, Some("eng".to_string()));
322    }
323
324    #[test]
325    fn test_subtitle_track_without_language() {
326        let track = SubtitleTrack {
327            index: 1,
328            url: "https://example.com/subs.srt".to_string(),
329            language: None,
330            label: Some("Subtitles".to_string()),
331            mime_type: "application/x-subrip".to_string(),
332        };
333
334        assert!(track.language.is_none());
335        assert!(track.label.is_some());
336    }
337
338    #[test]
339    fn test_subtitle_track_serialization() {
340        let track = SubtitleTrack {
341            index: 0,
342            url: "url.vtt".to_string(),
343            language: Some("eng".to_string()),
344            label: None,
345            mime_type: "text/vtt".to_string(),
346        };
347
348        let json = serde_json::to_string(&track);
349        assert!(json.is_ok());
350        let serialized = json.unwrap();
351        assert!(serialized.contains("0"));
352        assert!(serialized.contains("eng"));
353    }
354
355    #[test]
356    fn test_media_type_audio() {
357        let media_type = MediaType::Audio;
358        let json = serde_json::to_string(&media_type).unwrap();
359        assert!(json.contains("audio"));
360    }
361
362    #[test]
363    fn test_media_type_video() {
364        let media_type = MediaType::Video;
365        let json = serde_json::to_string(&media_type).unwrap();
366        assert!(json.contains("video"));
367    }
368
369    #[test]
370    fn test_media_source_remote() {
371        let source = MediaSource::Remote {
372            stream_url: "https://server.com/video.mp4".to_string(),
373            jellyfin_item_id: "item-123".to_string(),
374        };
375
376        assert!(matches!(source, MediaSource::Remote { .. }));
377    }
378
379    #[test]
380    fn test_media_source_local() {
381        let source = MediaSource::Local {
382            file_path: PathBuf::from("/path/to/video.mp4"),
383            jellyfin_item_id: Some("item-456".to_string()),
384        };
385
386        assert!(matches!(source, MediaSource::Local { .. }));
387    }
388
389    #[test]
390    fn test_media_source_local_without_jellyfin_id() {
391        let source = MediaSource::Local {
392            file_path: PathBuf::from("/downloads/audio.mp3"),
393            jellyfin_item_id: None,
394        };
395
396        assert!(matches!(source, MediaSource::Local { .. }));
397    }
398
399    #[test]
400    fn test_media_source_direct_url() {
401        let source = MediaSource::DirectUrl {
402            url: "https://external.com/stream".to_string(),
403        };
404
405        assert!(matches!(source, MediaSource::DirectUrl { .. }));
406    }
407
408    #[test]
409    fn test_media_source_serialization() {
410        let source = MediaSource::DirectUrl {
411            url: "https://example.com/stream".to_string(),
412        };
413
414        let json = serde_json::to_string(&source);
415        assert!(json.is_ok());
416        let serialized = json.unwrap();
417        assert!(serialized.contains("directurl"));
418    }
419
420    #[test]
421    fn test_media_item_creation_minimal() {
422        let item = MediaItem {
423            // Audio and direct-URL items never negotiate a transport.
424            transport: None,
425            id: "item-1".to_string(),
426            title: "Test Item".to_string(),
427            name: None,
428            artist: None,
429            album: None,
430            album_name: None,
431            album_id: None,
432            artist_items: None,
433            artists: None,
434            primary_image_tag: None,
435            image_id: None,
436            item_type: None,
437            playlist_id: None,
438            duration: None,
439            artwork_url: None,
440            media_type: MediaType::Video,
441            source: MediaSource::DirectUrl {
442                url: "https://example.com/video".to_string(),
443            },
444            video_codec: None,
445            needs_transcoding: false,
446            video_width: None,
447            video_height: None,
448            subtitles: vec![],
449            series_id: None,
450            server_id: None,
451        };
452
453        assert_eq!(item.id, "item-1");
454        assert_eq!(item.title, "Test Item");
455        assert!(!item.needs_transcoding);
456    }
457
458    #[test]
459    fn test_media_item_jellyfin_id() {
460        let item = MediaItem {
461            // Audio and direct-URL items never negotiate a transport.
462            transport: None,
463            id: "item-2".to_string(),
464            title: "Test".to_string(),
465            name: None,
466            artist: None,
467            album: None,
468            album_name: None,
469            album_id: None,
470            artist_items: None,
471            artists: None,
472            primary_image_tag: None,
473            image_id: None,
474            item_type: None,
475            playlist_id: None,
476            duration: None,
477            artwork_url: None,
478            media_type: MediaType::Audio,
479            source: MediaSource::Remote {
480                stream_url: "https://server/stream".to_string(),
481                jellyfin_item_id: "jf-id-123".to_string(),
482            },
483            video_codec: None,
484            needs_transcoding: false,
485            video_width: None,
486            video_height: None,
487            subtitles: vec![],
488            series_id: None,
489            server_id: None,
490        };
491
492        assert_eq!(item.jellyfin_id(), Some("jf-id-123"));
493    }
494
495    #[test]
496    fn test_media_item_jellyfin_id_local() {
497        let item = MediaItem {
498            // Audio and direct-URL items never negotiate a transport.
499            transport: None,
500            id: "item-3".to_string(),
501            title: "Local".to_string(),
502            name: None,
503            artist: None,
504            album: None,
505            album_name: None,
506            album_id: None,
507            artist_items: None,
508            artists: None,
509            primary_image_tag: None,
510            image_id: None,
511            item_type: None,
512            playlist_id: None,
513            duration: None,
514            artwork_url: None,
515            media_type: MediaType::Video,
516            source: MediaSource::Local {
517                file_path: PathBuf::from("/local/video.mp4"),
518                jellyfin_item_id: Some("jf-local".to_string()),
519            },
520            video_codec: None,
521            needs_transcoding: false,
522            video_width: None,
523            video_height: None,
524            subtitles: vec![],
525            series_id: None,
526            server_id: None,
527        };
528
529        assert_eq!(item.jellyfin_id(), Some("jf-local"));
530    }
531
532    #[test]
533    fn test_media_item_jellyfin_id_direct_url() {
534        let item = MediaItem {
535            // Audio and direct-URL items never negotiate a transport.
536            transport: None,
537            id: "item-4".to_string(),
538            title: "Direct".to_string(),
539            name: None,
540            artist: None,
541            album: None,
542            album_name: None,
543            album_id: None,
544            artist_items: None,
545            artists: None,
546            primary_image_tag: None,
547            image_id: None,
548            item_type: None,
549            playlist_id: None,
550            duration: None,
551            artwork_url: None,
552            media_type: MediaType::Video,
553            source: MediaSource::DirectUrl {
554                url: "https://external.com/media".to_string(),
555            },
556            video_codec: None,
557            needs_transcoding: false,
558            video_width: None,
559            video_height: None,
560            subtitles: vec![],
561            series_id: None,
562            server_id: None,
563        };
564
565        assert_eq!(item.jellyfin_id(), None);
566    }
567
568    #[test]
569    fn test_media_item_with_subtitles() {
570        let sub = SubtitleTrack {
571            index: 0,
572            url: "subs.vtt".to_string(),
573            language: Some("eng".to_string()),
574            label: Some("English".to_string()),
575            mime_type: "text/vtt".to_string(),
576        };
577
578        let item = MediaItem {
579            // Audio and direct-URL items never negotiate a transport.
580            transport: None,
581            id: "item-subs".to_string(),
582            title: "With Subs".to_string(),
583            name: None,
584            artist: None,
585            album: None,
586            album_name: None,
587            album_id: None,
588            artist_items: None,
589            artists: None,
590            primary_image_tag: None,
591            image_id: None,
592            item_type: None,
593            playlist_id: None,
594            duration: None,
595            artwork_url: None,
596            media_type: MediaType::Video,
597            source: MediaSource::DirectUrl {
598                url: "video.mp4".to_string(),
599            },
600            video_codec: None,
601            needs_transcoding: false,
602            video_width: None,
603            video_height: None,
604            subtitles: vec![sub],
605            series_id: None,
606            server_id: None,
607        };
608
609        assert_eq!(item.subtitles.len(), 1);
610        assert_eq!(item.subtitles[0].language, Some("eng".to_string()));
611    }
612
613    #[test]
614    fn test_media_item_serialization() {
615        let item = MediaItem {
616            // Audio and direct-URL items never negotiate a transport.
617            transport: None,
618            id: "serial-item".to_string(),
619            title: "Serial Test".to_string(),
620            name: Some("Name".to_string()),
621            artist: None,
622            album: None,
623            album_name: None,
624            album_id: None,
625            artist_items: None,
626            artists: None,
627            primary_image_tag: None,
628            image_id: None,
629            item_type: Some("Movie".to_string()),
630            playlist_id: None,
631            duration: Some(120.0),
632            artwork_url: None,
633            media_type: MediaType::Video,
634            source: MediaSource::DirectUrl {
635                url: "https://example.com/movie.mp4".to_string(),
636            },
637            video_codec: Some("h264".to_string()),
638            needs_transcoding: false,
639            video_width: Some(1920),
640            video_height: Some(1080),
641            subtitles: vec![],
642            series_id: None,
643            server_id: None,
644        };
645
646        let json = serde_json::to_string(&item);
647        assert!(json.is_ok());
648        let serialized = json.unwrap();
649        assert!(serialized.contains("serial-item"));
650        assert!(serialized.contains("Serial Test"));
651    }
652}