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}