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 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 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 #[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 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 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 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 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 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 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}