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    /// Video width in pixels
119    #[serde(default)]
120    pub video_width: Option<u32>,
121    /// Video height in pixels
122    #[serde(default)]
123    pub video_height: Option<u32>,
124    /// Available subtitle tracks
125    #[serde(default)]
126    pub subtitles: Vec<SubtitleTrack>,
127    /// Series ID (for TV show episodes) - used for series audio preferences
128    #[serde(default)]
129    pub series_id: Option<String>,
130    /// Server ID - used for series audio preferences
131    #[serde(default)]
132    pub server_id: Option<String>,
133}
134
135#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "lowercase")]
137pub enum MediaType {
138    Audio,
139    Video,
140}
141
142/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
143#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
144#[serde(tag = "type", rename_all = "lowercase")]
145#[specta(rename = "PlayerMediaSource")]
146pub enum MediaSource {
147    /// Streaming from Jellyfin server
148    Remote {
149        stream_url: String,
150        jellyfin_item_id: String,
151    },
152    /// Downloaded/cached locally
153    Local {
154        file_path: PathBuf,
155        /// Original Jellyfin ID for sync-back
156        jellyfin_item_id: Option<String>,
157    },
158    /// Direct URL (e.g., channel plugins)
159    DirectUrl { url: String },
160}
161
162impl MediaItem {
163    /// Get the Jellyfin item ID if available
164    pub fn jellyfin_id(&self) -> Option<&str> {
165        match &self.source {
166            MediaSource::Remote {
167                jellyfin_item_id, ..
168            } => Some(jellyfin_item_id),
169            MediaSource::Local {
170                jellyfin_item_id, ..
171            } => jellyfin_item_id.as_deref(),
172            MediaSource::DirectUrl { .. } => None,
173        }
174    }
175
176    /// Get the playback URL or file path
177    ///
178    /// Only available on Android where ExoPlayer needs direct URL access
179    #[cfg(target_os = "android")]
180    pub fn playback_url(&self) -> String {
181        match &self.source {
182            MediaSource::Remote { stream_url, .. } => stream_url.clone(),
183            MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
184            MediaSource::DirectUrl { url } => url.clone(),
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::path::PathBuf;
193
194    #[test]
195    fn test_queue_context_album() {
196        let context = QueueContext::Album {
197            album_id: "album-123".to_string(),
198            album_name: "Test Album".to_string(),
199        };
200
201        assert!(matches!(context, QueueContext::Album { .. }));
202    }
203
204    #[test]
205    fn test_queue_context_playlist() {
206        let context = QueueContext::Playlist {
207            playlist_id: "playlist-456".to_string(),
208            playlist_name: "Test Playlist".to_string(),
209        };
210
211        assert!(matches!(context, QueueContext::Playlist { .. }));
212    }
213
214    #[test]
215    fn test_queue_context_custom() {
216        let context = QueueContext::Custom;
217        assert!(matches!(context, QueueContext::Custom));
218    }
219
220    #[test]
221    fn test_queue_context_serialization() {
222        let context = QueueContext::Album {
223            album_id: "alb-001".to_string(),
224            album_name: "Album 001".to_string(),
225        };
226
227        let json = serde_json::to_string(&context);
228        assert!(json.is_ok());
229        let serialized = json.unwrap();
230        assert!(serialized.contains("album"));
231    }
232
233    #[test]
234    fn test_queue_context_default() {
235        let context = QueueContext::default();
236        assert!(matches!(context, QueueContext::Custom));
237    }
238
239    #[test]
240    fn test_queue_context_clone() {
241        let context = QueueContext::Album {
242            album_id: "clone-alb".to_string(),
243            album_name: "Clone Album".to_string(),
244        };
245
246        let cloned = context.clone();
247        assert_eq!(context, cloned);
248    }
249
250    #[test]
251    fn test_subtitle_track_creation() {
252        let track = SubtitleTrack {
253            index: 0,
254            url: "https://example.com/subs.vtt".to_string(),
255            language: Some("eng".to_string()),
256            label: Some("English".to_string()),
257            mime_type: "text/vtt".to_string(),
258        };
259
260        assert_eq!(track.index, 0);
261        assert_eq!(track.language, Some("eng".to_string()));
262    }
263
264    #[test]
265    fn test_subtitle_track_without_language() {
266        let track = SubtitleTrack {
267            index: 1,
268            url: "https://example.com/subs.srt".to_string(),
269            language: None,
270            label: Some("Subtitles".to_string()),
271            mime_type: "application/x-subrip".to_string(),
272        };
273
274        assert!(track.language.is_none());
275        assert!(track.label.is_some());
276    }
277
278    #[test]
279    fn test_subtitle_track_serialization() {
280        let track = SubtitleTrack {
281            index: 0,
282            url: "url.vtt".to_string(),
283            language: Some("eng".to_string()),
284            label: None,
285            mime_type: "text/vtt".to_string(),
286        };
287
288        let json = serde_json::to_string(&track);
289        assert!(json.is_ok());
290        let serialized = json.unwrap();
291        assert!(serialized.contains("0"));
292        assert!(serialized.contains("eng"));
293    }
294
295    #[test]
296    fn test_media_type_audio() {
297        let media_type = MediaType::Audio;
298        let json = serde_json::to_string(&media_type).unwrap();
299        assert!(json.contains("audio"));
300    }
301
302    #[test]
303    fn test_media_type_video() {
304        let media_type = MediaType::Video;
305        let json = serde_json::to_string(&media_type).unwrap();
306        assert!(json.contains("video"));
307    }
308
309    #[test]
310    fn test_media_source_remote() {
311        let source = MediaSource::Remote {
312            stream_url: "https://server.com/video.mp4".to_string(),
313            jellyfin_item_id: "item-123".to_string(),
314        };
315
316        assert!(matches!(source, MediaSource::Remote { .. }));
317    }
318
319    #[test]
320    fn test_media_source_local() {
321        let source = MediaSource::Local {
322            file_path: PathBuf::from("/path/to/video.mp4"),
323            jellyfin_item_id: Some("item-456".to_string()),
324        };
325
326        assert!(matches!(source, MediaSource::Local { .. }));
327    }
328
329    #[test]
330    fn test_media_source_local_without_jellyfin_id() {
331        let source = MediaSource::Local {
332            file_path: PathBuf::from("/downloads/audio.mp3"),
333            jellyfin_item_id: None,
334        };
335
336        assert!(matches!(source, MediaSource::Local { .. }));
337    }
338
339    #[test]
340    fn test_media_source_direct_url() {
341        let source = MediaSource::DirectUrl {
342            url: "https://external.com/stream".to_string(),
343        };
344
345        assert!(matches!(source, MediaSource::DirectUrl { .. }));
346    }
347
348    #[test]
349    fn test_media_source_serialization() {
350        let source = MediaSource::DirectUrl {
351            url: "https://example.com/stream".to_string(),
352        };
353
354        let json = serde_json::to_string(&source);
355        assert!(json.is_ok());
356        let serialized = json.unwrap();
357        assert!(serialized.contains("directurl"));
358    }
359
360    #[test]
361    fn test_media_item_creation_minimal() {
362        let item = MediaItem {
363            id: "item-1".to_string(),
364            title: "Test Item".to_string(),
365            name: None,
366            artist: None,
367            album: None,
368            album_name: None,
369            album_id: None,
370            artist_items: None,
371            artists: None,
372            primary_image_tag: None,
373            image_id: None,
374            item_type: None,
375            playlist_id: None,
376            duration: None,
377            artwork_url: None,
378            media_type: MediaType::Video,
379            source: MediaSource::DirectUrl {
380                url: "https://example.com/video".to_string(),
381            },
382            video_codec: None,
383            needs_transcoding: false,
384            video_width: None,
385            video_height: None,
386            subtitles: vec![],
387            series_id: None,
388            server_id: None,
389        };
390
391        assert_eq!(item.id, "item-1");
392        assert_eq!(item.title, "Test Item");
393        assert!(!item.needs_transcoding);
394    }
395
396    #[test]
397    fn test_media_item_jellyfin_id() {
398        let item = MediaItem {
399            id: "item-2".to_string(),
400            title: "Test".to_string(),
401            name: None,
402            artist: None,
403            album: None,
404            album_name: None,
405            album_id: None,
406            artist_items: None,
407            artists: None,
408            primary_image_tag: None,
409            image_id: None,
410            item_type: None,
411            playlist_id: None,
412            duration: None,
413            artwork_url: None,
414            media_type: MediaType::Audio,
415            source: MediaSource::Remote {
416                stream_url: "https://server/stream".to_string(),
417                jellyfin_item_id: "jf-id-123".to_string(),
418            },
419            video_codec: None,
420            needs_transcoding: false,
421            video_width: None,
422            video_height: None,
423            subtitles: vec![],
424            series_id: None,
425            server_id: None,
426        };
427
428        assert_eq!(item.jellyfin_id(), Some("jf-id-123"));
429    }
430
431    #[test]
432    fn test_media_item_jellyfin_id_local() {
433        let item = MediaItem {
434            id: "item-3".to_string(),
435            title: "Local".to_string(),
436            name: None,
437            artist: None,
438            album: None,
439            album_name: None,
440            album_id: None,
441            artist_items: None,
442            artists: None,
443            primary_image_tag: None,
444            image_id: None,
445            item_type: None,
446            playlist_id: None,
447            duration: None,
448            artwork_url: None,
449            media_type: MediaType::Video,
450            source: MediaSource::Local {
451                file_path: PathBuf::from("/local/video.mp4"),
452                jellyfin_item_id: Some("jf-local".to_string()),
453            },
454            video_codec: None,
455            needs_transcoding: false,
456            video_width: None,
457            video_height: None,
458            subtitles: vec![],
459            series_id: None,
460            server_id: None,
461        };
462
463        assert_eq!(item.jellyfin_id(), Some("jf-local"));
464    }
465
466    #[test]
467    fn test_media_item_jellyfin_id_direct_url() {
468        let item = MediaItem {
469            id: "item-4".to_string(),
470            title: "Direct".to_string(),
471            name: None,
472            artist: None,
473            album: None,
474            album_name: None,
475            album_id: None,
476            artist_items: None,
477            artists: None,
478            primary_image_tag: None,
479            image_id: None,
480            item_type: None,
481            playlist_id: None,
482            duration: None,
483            artwork_url: None,
484            media_type: MediaType::Video,
485            source: MediaSource::DirectUrl {
486                url: "https://external.com/media".to_string(),
487            },
488            video_codec: None,
489            needs_transcoding: false,
490            video_width: None,
491            video_height: None,
492            subtitles: vec![],
493            series_id: None,
494            server_id: None,
495        };
496
497        assert_eq!(item.jellyfin_id(), None);
498    }
499
500    #[test]
501    fn test_media_item_with_subtitles() {
502        let sub = SubtitleTrack {
503            index: 0,
504            url: "subs.vtt".to_string(),
505            language: Some("eng".to_string()),
506            label: Some("English".to_string()),
507            mime_type: "text/vtt".to_string(),
508        };
509
510        let item = MediaItem {
511            id: "item-subs".to_string(),
512            title: "With Subs".to_string(),
513            name: None,
514            artist: None,
515            album: None,
516            album_name: None,
517            album_id: None,
518            artist_items: None,
519            artists: None,
520            primary_image_tag: None,
521            image_id: None,
522            item_type: None,
523            playlist_id: None,
524            duration: None,
525            artwork_url: None,
526            media_type: MediaType::Video,
527            source: MediaSource::DirectUrl {
528                url: "video.mp4".to_string(),
529            },
530            video_codec: None,
531            needs_transcoding: false,
532            video_width: None,
533            video_height: None,
534            subtitles: vec![sub],
535            series_id: None,
536            server_id: None,
537        };
538
539        assert_eq!(item.subtitles.len(), 1);
540        assert_eq!(item.subtitles[0].language, Some("eng".to_string()));
541    }
542
543    #[test]
544    fn test_media_item_serialization() {
545        let item = MediaItem {
546            id: "serial-item".to_string(),
547            title: "Serial Test".to_string(),
548            name: Some("Name".to_string()),
549            artist: None,
550            album: None,
551            album_name: None,
552            album_id: None,
553            artist_items: None,
554            artists: None,
555            primary_image_tag: None,
556            image_id: None,
557            item_type: Some("Movie".to_string()),
558            playlist_id: None,
559            duration: Some(120.0),
560            artwork_url: None,
561            media_type: MediaType::Video,
562            source: MediaSource::DirectUrl {
563                url: "https://example.com/movie.mp4".to_string(),
564            },
565            video_codec: Some("h264".to_string()),
566            needs_transcoding: false,
567            video_width: Some(1920),
568            video_height: Some(1080),
569            subtitles: vec![],
570            series_id: None,
571            server_id: None,
572        };
573
574        let json = serde_json::to_string(&item);
575        assert!(json.is_ok());
576        let serialized = json.unwrap();
577        assert!(serialized.contains("serial-item"));
578        assert!(serialized.contains("Serial Test"));
579    }
580}