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)]
120 pub video_width: Option<u32>,
121 #[serde(default)]
123 pub video_height: Option<u32>,
124 #[serde(default)]
126 pub subtitles: Vec<SubtitleTrack>,
127 #[serde(default)]
129 pub series_id: Option<String>,
130 #[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#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
144#[serde(tag = "type", rename_all = "lowercase")]
145#[specta(rename = "PlayerMediaSource")]
146pub enum MediaSource {
147 Remote {
149 stream_url: String,
150 jellyfin_item_id: String,
151 },
152 Local {
154 file_path: PathBuf,
155 jellyfin_item_id: Option<String>,
157 },
158 DirectUrl { url: String },
160}
161
162impl MediaItem {
163 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 #[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}