1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
7#[serde(tag = "type", rename_all = "lowercase")]
8pub enum QueueContext {
9 Album {
11 album_id: String,
12 album_name: String,
13 },
14 Playlist {
16 playlist_id: String,
17 playlist_name: String,
18 },
19 #[default]
22 Custom,
23}
24
25#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
44pub struct SubtitleTrack {
45 pub index: i32,
47 pub url: String,
49 pub language: Option<String>,
51 pub label: Option<String>,
53 pub mime_type: String,
56}
57
58#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
62#[serde(rename_all = "camelCase")]
63#[specta(rename = "PlayerMediaItem")]
64pub struct MediaItem {
65 pub id: String,
67 pub title: String,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub name: Option<String>,
72 pub artist: Option<String>,
74 pub album: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub album_name: Option<String>,
79 #[serde(default)]
81 pub album_id: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub artists: Option<Vec<String>>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub primary_image_tag: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub image_id: Option<String>,
98 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
100 pub item_type: Option<String>,
101 #[serde(default)]
103 pub playlist_id: Option<String>,
104 pub duration: Option<f64>,
106 pub artwork_url: Option<String>,
108 pub media_type: MediaType,
110 pub source: MediaSource,
112 #[serde(default)]
114 pub video_codec: Option<String>,
115 #[serde(default)]
117 pub needs_transcoding: bool,
118 #[serde(default)]
128 pub transport: Option<crate::repository::Transport>,
129
130 #[serde(default)]
132 pub video_width: Option<u32>,
133 #[serde(default)]
135 pub video_height: Option<u32>,
136 #[serde(default)]
138 pub subtitles: Vec<SubtitleTrack>,
139 #[serde(default)]
141 pub series_id: Option<String>,
142 #[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#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
156#[serde(tag = "type", rename_all = "lowercase")]
157#[specta(rename = "PlayerMediaSource")]
158pub enum MediaSource {
159 Remote {
161 stream_url: String,
162 jellyfin_item_id: String,
163 },
164 Local {
166 file_path: PathBuf,
167 jellyfin_item_id: Option<String>,
169 },
170 DirectUrl { url: String },
172}
173
174impl MediaItem {
175 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 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 #[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 #[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 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 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 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 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 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 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}