1use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, ErrorCode, Result};
14use crate::model::Catalog;
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct Request {
19 pub id: i64,
21 pub cmd: Command,
22}
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27pub enum Command {
28 Play {
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 slug: Option<String>,
32 },
33 Pause,
34 Toggle,
35 Stop,
36 Seek {
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 position_secs: Option<f64>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 delta_secs: Option<f64>,
42 },
43 Next,
44 Previous,
45 Download {
47 slug: String,
48 },
49 CancelDownload {
50 slug: String,
51 },
52 DeleteDownload {
55 slug: String,
56 },
57 Favourite {
59 slug: String,
60 },
61 Unfavourite {
63 slug: String,
64 },
65 ListFavourites,
66 Preferences {
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 inverted_palette: Option<bool>,
79 },
80 Status,
81 ListCatalog,
83 Subscribe {
85 #[serde(default)]
90 spectrum: bool,
91 },
92 Shutdown,
94}
95
96pub const COMMAND_TYPES: &[&str] = &[
101 "play",
102 "pause",
103 "toggle",
104 "stop",
105 "seek",
106 "next",
107 "previous",
108 "download",
109 "cancel_download",
110 "delete_download",
111 "favourite",
112 "unfavourite",
113 "list_favourites",
114 "preferences",
115 "status",
116 "list_catalog",
117 "subscribe",
118 "shutdown",
119];
120
121impl Command {
122 pub fn type_name(&self) -> &'static str {
123 match self {
124 Self::Play { .. } => "play",
125 Self::Pause => "pause",
126 Self::Toggle => "toggle",
127 Self::Stop => "stop",
128 Self::Seek { .. } => "seek",
129 Self::Next => "next",
130 Self::Previous => "previous",
131 Self::Download { .. } => "download",
132 Self::CancelDownload { .. } => "cancel_download",
133 Self::DeleteDownload { .. } => "delete_download",
134 Self::Favourite { .. } => "favourite",
135 Self::Unfavourite { .. } => "unfavourite",
136 Self::ListFavourites => "list_favourites",
137 Self::Preferences { .. } => "preferences",
138 Self::Status => "status",
139 Self::ListCatalog => "list_catalog",
140 Self::Subscribe { .. } => "subscribe",
141 Self::Shutdown => "shutdown",
142 }
143 }
144
145 pub fn validate(&self) -> Result<()> {
151 match self {
152 Self::Seek {
153 position_secs,
154 delta_secs,
155 } => match (position_secs, delta_secs) {
156 (Some(position), None) => seek_within_range(*position, 0.0),
157 (None, Some(delta)) => seek_within_range(*delta, -SEEK_SECS_LIMIT),
158 _ => Err(Error::InvalidParams(
159 "seek takes exactly one of position_secs or delta_secs".into(),
160 )),
161 },
162 _ => Ok(()),
163 }
164 }
165}
166
167const SEEK_SECS_LIMIT: f64 = u64::MAX as f64;
173
174fn seek_within_range(value: f64, minimum: f64) -> Result<()> {
176 if value >= minimum && value < SEEK_SECS_LIMIT {
179 return Ok(());
180 }
181 Err(Error::InvalidParams(format!(
182 "{value} is not a seekable number of seconds"
183 )))
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct Response {
190 pub id: Option<i64>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub result: Option<ResultBody>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub error: Option<ErrorObject>,
195}
196
197impl Response {
198 pub fn ok(id: i64) -> Self {
200 Self {
201 id: Some(id),
202 result: Some(ResultBody::Ok),
203 error: None,
204 }
205 }
206
207 pub fn state(id: i64, state: StateSnapshot) -> Self {
208 Self {
209 id: Some(id),
210 result: Some(ResultBody::State { state }),
211 error: None,
212 }
213 }
214
215 pub fn catalog(id: i64, catalog: Catalog) -> Self {
216 Self {
217 id: Some(id),
218 result: Some(ResultBody::Catalog { catalog }),
219 error: None,
220 }
221 }
222
223 pub fn failure(id: Option<i64>, error: &Error) -> Self {
226 Self {
227 id,
228 result: None,
229 error: Some(ErrorObject::from(error)),
230 }
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235#[serde(tag = "type", rename_all = "snake_case")]
236pub enum ResultBody {
237 Ok,
238 State { state: StateSnapshot },
239 Catalog { catalog: Catalog },
240 Favourites { favourites: Vec<String> },
241 Preferences { preferences: Preferences },
242}
243
244#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(default)]
251pub struct Preferences {
252 pub inverted_palette: bool,
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257pub struct ErrorObject {
258 pub code: ErrorCode,
259 pub message: String,
260}
261
262impl From<&Error> for ErrorObject {
263 fn from(error: &Error) -> Self {
264 Self {
265 code: error.code(),
266 message: error.to_string(),
267 }
268 }
269}
270
271impl From<Error> for ErrorObject {
272 fn from(error: Error) -> Self {
273 Self::from(&error)
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct EventFrame {
280 pub event: Event,
281}
282
283#[allow(clippy::large_enum_variant)]
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288#[serde(tag = "type", rename_all = "snake_case")]
289#[non_exhaustive]
290pub enum Event {
291 State {
292 state: StateSnapshot,
293 },
294 #[serde(other)]
296 Unknown,
297}
298
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303#[serde(untagged)]
304pub enum Frame {
305 Event(EventFrame),
306 Response(Response),
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case")]
312#[non_exhaustive]
313pub enum PlaybackState {
314 Stopped,
315 Loading,
316 Playing,
317 Paused,
318 Seeking,
322 Error,
323 #[serde(other)]
325 Unknown,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(rename_all = "snake_case")]
331#[non_exhaustive]
332pub enum Source {
333 Stream,
334 Local,
335 #[serde(other)]
337 Unknown,
338}
339
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342pub struct EpisodeRef {
343 pub slug: String,
345 pub title: String,
346 pub duration_secs: f64,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
351#[serde(rename_all = "snake_case")]
352#[non_exhaustive]
353pub enum DownloadState {
354 Queued,
355 Running,
356 Completed,
357 Failed,
358 Cancelled,
359 #[serde(other)]
361 Unknown,
362}
363
364#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366pub struct DownloadProgress {
367 pub slug: String,
368 pub downloaded_bytes: u64,
369 pub total_bytes: u64,
371 pub state: DownloadState,
372 pub error: Option<ErrorObject>,
374}
375
376pub const SPECTRUM_BINS: usize = 1024;
381
382pub const SPECTRUM_MIN_DB: f32 = -114.0;
384
385pub const SPECTRUM_MAX_DB: f32 = -30.0;
387
388pub const SPECTRUM_SMOOTHING: f32 = 0.666;
390
391#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct Spectrum(pub Vec<u8>);
402
403impl Spectrum {
404 pub fn silent() -> Self {
406 Self(vec![0; SPECTRUM_BINS])
407 }
408
409 pub fn bin(&self, index: usize) -> u8 {
414 self.0.get(index).copied().unwrap_or(0)
415 }
416}
417
418impl Serialize for Spectrum {
419 fn serialize<S: serde::Serializer>(
420 &self,
421 serializer: S,
422 ) -> std::result::Result<S::Ok, S::Error> {
423 use base64::Engine as _;
424 serializer.serialize_str(&base64::engine::general_purpose::STANDARD.encode(&self.0))
425 }
426}
427
428impl<'de> Deserialize<'de> for Spectrum {
429 fn deserialize<D: serde::Deserializer<'de>>(
430 deserializer: D,
431 ) -> std::result::Result<Self, D::Error> {
432 use base64::Engine as _;
433 let encoded = String::deserialize(deserializer)?;
434 base64::engine::general_purpose::STANDARD
435 .decode(encoded.as_bytes())
436 .map(Self)
437 .map_err(serde::de::Error::custom)
438 }
439}
440
441#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
446#[non_exhaustive]
447pub struct StateSnapshot {
448 pub playback: PlaybackState,
449 pub episode: Option<EpisodeRef>,
450 pub position_secs: f64,
453 pub seek_target_secs: Option<f64>,
456 pub duration_secs: Option<f64>,
457 pub duration_approximate: bool,
459 pub seekable: bool,
460 pub source: Option<Source>,
461 pub downloads: Vec<DownloadProgress>,
462 #[serde(default)]
464 pub favourites: Vec<String>,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub spectrum: Option<Spectrum>,
472 pub error: Option<ErrorObject>,
473}
474
475impl StateSnapshot {
476 pub fn stopped() -> Self {
478 Self {
479 playback: PlaybackState::Stopped,
480 episode: None,
481 position_secs: 0.0,
482 seek_target_secs: None,
483 duration_secs: None,
484 duration_approximate: true,
485 seekable: false,
486 source: None,
487 downloads: Vec::new(),
488 favourites: Vec::new(),
489 spectrum: None,
490 error: None,
491 }
492 }
493}
494
495#[derive(Debug, Clone, PartialEq)]
498pub struct RequestError {
499 pub id: Option<i64>,
501 pub error: ErrorObject,
502}
503
504impl RequestError {
505 pub fn response(&self) -> Response {
507 Response {
508 id: self.id,
509 result: None,
510 error: Some(self.error.clone()),
511 }
512 }
513}
514
515pub fn parse_request(line: &str) -> std::result::Result<Request, RequestError> {
522 fn reject(id: Option<i64>, error: Error) -> RequestError {
523 RequestError {
524 id,
525 error: ErrorObject::from(&error),
526 }
527 }
528
529 let value: serde_json::Value = serde_json::from_str(line)
530 .map_err(|error| reject(None, Error::InvalidRequest(error.to_string())))?;
531
532 let object = value.as_object().ok_or_else(|| {
533 reject(
534 None,
535 Error::InvalidRequest("a request must be a JSON object".into()),
536 )
537 })?;
538
539 let id = object
540 .get("id")
541 .and_then(serde_json::Value::as_i64)
542 .ok_or_else(|| {
543 reject(
544 None,
545 Error::InvalidRequest("a request must carry a numeric id".into()),
546 )
547 })?;
548
549 let cmd = object.get("cmd").ok_or_else(|| {
550 reject(
551 Some(id),
552 Error::InvalidRequest("a request must carry a cmd object".into()),
553 )
554 })?;
555
556 let type_name = cmd
557 .as_object()
558 .and_then(|cmd| cmd.get("type"))
559 .and_then(serde_json::Value::as_str)
560 .ok_or_else(|| {
561 reject(
562 Some(id),
563 Error::InvalidRequest("cmd must be an object with a type string".into()),
564 )
565 })?;
566
567 if !COMMAND_TYPES.contains(&type_name) {
568 return Err(reject(
569 Some(id),
570 Error::UnknownCommand(type_name.to_owned()),
571 ));
572 }
573
574 let command: Command = serde_json::from_value(cmd.clone())
575 .map_err(|error| reject(Some(id), Error::InvalidParams(error.to_string())))?;
576 command
577 .validate()
578 .map_err(|error| reject(Some(id), error))?;
579
580 Ok(Request { id, cmd: command })
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 fn snapshot() -> StateSnapshot {
588 StateSnapshot {
589 favourites: Vec::new(),
590 spectrum: None,
591 playback: PlaybackState::Seeking,
592 episode: Some(EpisodeRef {
593 slug: "seventynine".into(),
594 title: "Episode 79".into(),
595 duration_secs: 14_400.0,
596 }),
597 position_secs: 1800.0,
598 seek_target_secs: Some(5400.0),
599 duration_secs: Some(14_400.0),
600 duration_approximate: true,
601 seekable: true,
602 source: Some(Source::Stream),
603 downloads: vec![DownloadProgress {
604 slug: "seventyeight".into(),
605 downloaded_bytes: 1024,
606 total_bytes: 4096,
607 state: DownloadState::Running,
608 error: None,
609 }],
610 error: None,
611 }
612 }
613
614 #[test]
615 fn a_request_serialises_to_the_shape_the_spec_names() {
616 let request = Request {
617 id: 1,
618 cmd: Command::Play {
619 slug: Some("seventynine".into()),
620 },
621 };
622 assert_eq!(
623 serde_json::to_string(&request).unwrap(),
624 r#"{"id":1,"cmd":{"type":"play","slug":"seventynine"}}"#
625 );
626 }
627
628 #[test]
634 #[ignore = "benchmark, run explicitly"]
635 fn bench_snapshot_wire_cost() {
636 use std::time::Instant;
637 let mut snapshot = StateSnapshot::stopped();
638 snapshot.playback = PlaybackState::Playing;
639 snapshot.spectrum = Some(Spectrum((0..=255u8).cycle().take(SPECTRUM_BINS).collect()));
640 snapshot.favourites = (0..12).map(|n| format!("episode-{n}")).collect();
641
642 const N: u32 = 20_000;
643 let start = Instant::now();
644 for _ in 0..N {
645 let _ = snapshot.clone();
646 }
647 println!("clone: {:?} per snapshot", start.elapsed() / N);
648
649 let start = Instant::now();
650 for _ in 0..N {
651 let _ = serde_json::to_string(&snapshot).unwrap();
652 }
653 println!("serialize: {:?} per snapshot", start.elapsed() / N);
654
655 let line = serde_json::to_string(&snapshot).unwrap();
656 println!("line size: {} bytes", line.len());
657
658 let start = Instant::now();
659 for _ in 0..N {
660 let _: StateSnapshot = serde_json::from_str(&line).unwrap();
661 }
662 println!("parse: {:?} per snapshot", start.elapsed() / N);
663 }
664
665 #[test]
666 fn a_spectrum_round_trips_as_base64() {
667 let spectrum = Spectrum((0..=255u8).cycle().take(SPECTRUM_BINS).collect());
668 let json = serde_json::to_string(&spectrum).unwrap();
669 assert!(
670 json.starts_with('"') && json.ends_with('"'),
671 "{json} is not a JSON string"
672 );
673 assert_eq!(serde_json::from_str::<Spectrum>(&json).unwrap(), spectrum);
674 }
675
676 #[test]
677 fn a_spectrum_is_carried_far_more_compactly_than_an_array_of_numbers() {
678 let spectrum = Spectrum(vec![200; SPECTRUM_BINS]);
679 let encoded = serde_json::to_string(&spectrum).unwrap().len();
680 let as_numbers = serde_json::to_string(&vec![200u8; SPECTRUM_BINS])
681 .unwrap()
682 .len();
683 assert!(
684 encoded < as_numbers,
685 "base64 form ({encoded}) is not smaller than the array form ({as_numbers})"
686 );
687 }
688
689 #[test]
690 fn a_spectrum_reads_past_its_end_as_silence() {
691 let spectrum = Spectrum(vec![9; 4]);
692 assert_eq!(spectrum.bin(3), 9);
693 assert_eq!(spectrum.bin(4), 0);
694 assert_eq!(spectrum.bin(SPECTRUM_BINS), 0);
695 }
696
697 #[test]
698 fn a_silent_spectrum_floors_every_bin() {
699 let spectrum = Spectrum::silent();
700 assert_eq!(spectrum.0.len(), SPECTRUM_BINS);
701 assert!(spectrum.0.iter().all(|&bin| bin == 0));
702 }
703
704 #[test]
705 fn a_snapshot_carrying_a_spectrum_and_favourites_round_trips() {
706 let mut snapshot = StateSnapshot::stopped();
707 snapshot.favourites = vec!["seventynine".into(), "sixtytwo".into()];
708 snapshot.spectrum = Some(Spectrum(vec![7; SPECTRUM_BINS]));
709 let json = serde_json::to_string(&snapshot).unwrap();
710 assert_eq!(
711 serde_json::from_str::<StateSnapshot>(&json).unwrap(),
712 snapshot
713 );
714 }
715
716 #[test]
717 fn a_stopped_snapshot_omits_the_spectrum_entirely() {
718 let json = serde_json::to_string(&StateSnapshot::stopped()).unwrap();
719 assert!(
720 !json.contains("spectrum"),
721 "{json} names an absent spectrum"
722 );
723 }
724
725 #[test]
726 fn a_snapshot_without_the_new_fields_still_deserialises() {
727 let json = r#"{
728 "playback": "stopped",
729 "episode": null,
730 "position_secs": 0.0,
731 "seek_target_secs": null,
732 "duration_secs": null,
733 "duration_approximate": true,
734 "volume": 0.8,
735 "muted": false,
736 "seekable": false,
737 "source": null,
738 "downloads": [],
739 "error": null
740 }"#;
741 let snapshot: StateSnapshot = serde_json::from_str(json).unwrap();
742 assert!(snapshot.favourites.is_empty());
743 assert!(snapshot.spectrum.is_none());
744 }
745
746 #[test]
747 fn the_favourites_result_body_round_trips() {
748 let body = ResultBody::Favourites {
749 favourites: vec!["seventynine".into()],
750 };
751 let json = serde_json::to_string(&body).unwrap();
752 assert!(json.contains(r#""type":"favourites""#), "{json}");
753 assert_eq!(serde_json::from_str::<ResultBody>(&json).unwrap(), body);
754 }
755
756 #[test]
757 fn the_preferences_result_body_round_trips() {
758 let body = ResultBody::Preferences {
759 preferences: Preferences {
760 inverted_palette: true,
761 },
762 };
763 let json = serde_json::to_string(&body).unwrap();
764 assert!(json.contains(r#""type":"preferences""#), "{json}");
765 assert_eq!(serde_json::from_str::<ResultBody>(&json).unwrap(), body);
766 }
767
768 #[test]
771 fn preferences_with_no_field_is_a_read_and_omits_it_on_the_wire() {
772 let request = parse_request(r#"{"id":1,"cmd":{"type":"preferences"}}"#).unwrap();
773 assert_eq!(
774 request.cmd,
775 Command::Preferences {
776 inverted_palette: None,
777 }
778 );
779
780 let json = serde_json::to_string(&request.cmd).unwrap();
781 assert!(!json.contains("inverted_palette"), "{json}");
782 }
783
784 #[test]
788 fn a_client_still_sending_the_dismissed_notice_is_parsed_rather_than_refused() {
789 let request = parse_request(
790 r#"{"id":1,"cmd":{"type":"preferences","inverted_palette":true,"notice_dismissed":true}}"#,
791 )
792 .expect("the old field was refused");
793 assert_eq!(
794 request.cmd,
795 Command::Preferences {
796 inverted_palette: Some(true),
797 }
798 );
799
800 let preferences: Preferences =
801 serde_json::from_str(r#"{"inverted_palette":true,"notice_dismissed":true}"#)
802 .expect("the old field was refused");
803 assert!(preferences.inverted_palette);
804 }
805
806 #[test]
809 fn absent_preferences_deserialise_to_the_defaults() {
810 let preferences: Preferences = serde_json::from_str("{}").unwrap();
811 assert_eq!(preferences, Preferences::default());
812 assert!(!preferences.inverted_palette);
813 }
814
815 #[test]
816 fn subscribe_defaults_to_no_spectrum_so_an_old_client_is_not_woken_for_it() {
817 let request = parse_request(r#"{"id":1,"cmd":{"type":"subscribe"}}"#).unwrap();
818 assert_eq!(request.cmd, Command::Subscribe { spectrum: false });
819 }
820
821 #[test]
822 fn an_unparseable_line_is_invalid_request_with_a_null_id() {
823 let rejected = parse_request("not json").unwrap_err();
824 assert_eq!(rejected.id, None);
825 assert_eq!(rejected.error.code, ErrorCode::InvalidRequest);
826 assert_eq!(
827 serde_json::to_string(&rejected.response()).unwrap(),
828 format!(
829 r#"{{"id":null,"error":{{"code":"invalid_request","message":{}}}}}"#,
830 serde_json::to_string(&rejected.error.message).unwrap()
831 )
832 );
833 }
834
835 #[test]
836 fn a_line_that_is_not_an_object_is_invalid_request() {
837 assert_eq!(
838 parse_request("[1,2,3]").unwrap_err().error.code,
839 ErrorCode::InvalidRequest
840 );
841 }
842
843 #[test]
844 fn a_missing_id_or_cmd_is_invalid_request() {
845 assert_eq!(
846 parse_request(r#"{"cmd":{"type":"pause"}}"#)
847 .unwrap_err()
848 .error
849 .code,
850 ErrorCode::InvalidRequest
851 );
852 let rejected = parse_request(r#"{"id":7}"#).unwrap_err();
853 assert_eq!(rejected.id, Some(7));
854 assert_eq!(rejected.error.code, ErrorCode::InvalidRequest);
855 }
856
857 #[test]
858 fn an_unrecognised_command_keeps_its_id() {
859 let rejected = parse_request(r#"{"id":7,"cmd":{"type":"teleport"}}"#).unwrap_err();
860 assert_eq!(rejected.id, Some(7));
861 assert_eq!(rejected.error.code, ErrorCode::UnknownCommand);
862 }
863
864 #[test]
865 fn a_seek_with_neither_or_both_forms_is_invalid_params() {
866 assert_eq!(
867 parse_request(r#"{"id":1,"cmd":{"type":"seek"}}"#)
868 .unwrap_err()
869 .error
870 .code,
871 ErrorCode::InvalidParams
872 );
873 assert_eq!(
874 parse_request(r#"{"id":1,"cmd":{"type":"seek","position_secs":1,"delta_secs":2}}"#)
875 .unwrap_err()
876 .error
877 .code,
878 ErrorCode::InvalidParams
879 );
880 }
881
882 #[test]
883 fn a_snapshot_from_an_older_daemon_still_deserialises() {
884 let json = r#"{
887 "playback": "playing",
888 "episode": null,
889 "position_secs": 12.0,
890 "seek_target_secs": null,
891 "duration_secs": null,
892 "duration_approximate": true,
893 "volume": 0.8,
894 "muted": true,
895 "seekable": true,
896 "source": "stream",
897 "downloads": [],
898 "error": null
899 }"#;
900 let snapshot: StateSnapshot = serde_json::from_str(json).unwrap();
901 assert_eq!(snapshot.position_secs, 12.0);
902 assert_eq!(snapshot.playback, PlaybackState::Playing);
903 }
904
905 #[test]
906 fn a_snapshot_carries_no_loudness_at_all() {
907 let json = serde_json::to_string(&StateSnapshot::stopped()).unwrap();
908 assert!(!json.contains("volume"), "{json}");
909 assert!(!json.contains("muted"), "{json}");
910 }
911
912 #[test]
913 fn volume_is_no_longer_a_command_the_daemon_knows() {
914 for line in [
917 r#"{"id":1,"cmd":{"type":"volume"}}"#,
918 r#"{"id":1,"cmd":{"type":"volume","level":0.5}}"#,
919 r#"{"id":1,"cmd":{"type":"volume","delta":0.05}}"#,
920 r#"{"id":1,"cmd":{"type":"volume","muted":true}}"#,
921 r#"{"id":1,"cmd":{"type":"mute","state":"on"}}"#,
922 ] {
923 assert_eq!(
924 parse_request(line).unwrap_err().error.code,
925 ErrorCode::UnknownCommand,
926 "{line} was not rejected as unknown"
927 );
928 }
929 assert!(!COMMAND_TYPES.contains(&"volume"));
930 assert!(!COMMAND_TYPES.contains(&"mute"));
931 }
932
933 #[test]
934 fn a_seek_the_audio_engine_could_not_hold_is_invalid_params() {
935 for line in [
938 r#"{"id":1,"cmd":{"type":"seek","position_secs":1e20}}"#,
939 r#"{"id":1,"cmd":{"type":"seek","position_secs":-1.0}}"#,
940 r#"{"id":1,"cmd":{"type":"seek","delta_secs":1e20}}"#,
941 r#"{"id":1,"cmd":{"type":"seek","delta_secs":-1e20}}"#,
942 ] {
943 let rejected = parse_request(line).unwrap_err();
944 assert_eq!(
945 rejected.error.code,
946 ErrorCode::InvalidParams,
947 "{line} was not refused"
948 );
949 assert_eq!(rejected.id, Some(1));
950 }
951 }
952
953 #[test]
955 fn a_seek_to_a_non_finite_position_is_invalid_params() {
956 for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
957 let command = Command::Seek {
958 position_secs: Some(value),
959 delta_secs: None,
960 };
961 assert_eq!(
962 command.validate().unwrap_err().code(),
963 ErrorCode::InvalidParams,
964 "{value} was not refused"
965 );
966 }
967 }
968
969 #[test]
970 fn a_seek_inside_the_range_is_accepted() {
971 let request = parse_request(r#"{"id":1,"cmd":{"type":"seek","position_secs":1800.5}}"#)
972 .expect("an ordinary seek was refused");
973 assert_eq!(
974 request.cmd,
975 Command::Seek {
976 position_secs: Some(1800.5),
977 delta_secs: None,
978 }
979 );
980 assert!(
981 parse_request(r#"{"id":1,"cmd":{"type":"seek","delta_secs":-30.0}}"#)
982 .unwrap()
983 .cmd
984 .validate()
985 .is_ok()
986 );
987 }
988
989 #[test]
993 fn every_command_variant_is_named_in_command_types() {
994 let every_variant = [
995 Command::Play { slug: None },
996 Command::Pause,
997 Command::Toggle,
998 Command::Stop,
999 Command::Seek {
1000 position_secs: Some(0.0),
1001 delta_secs: None,
1002 },
1003 Command::Next,
1004 Command::Previous,
1005 Command::Download {
1006 slug: String::new(),
1007 },
1008 Command::CancelDownload {
1009 slug: String::new(),
1010 },
1011 Command::DeleteDownload {
1012 slug: String::new(),
1013 },
1014 Command::Favourite {
1015 slug: String::new(),
1016 },
1017 Command::Unfavourite {
1018 slug: String::new(),
1019 },
1020 Command::ListFavourites,
1021 Command::Preferences {
1022 inverted_palette: None,
1023 },
1024 Command::Status,
1025 Command::ListCatalog,
1026 Command::Subscribe { spectrum: false },
1027 Command::Shutdown,
1028 ];
1029
1030 for command in &every_variant {
1031 assert!(
1032 COMMAND_TYPES.contains(&command.type_name()),
1033 "{} is not in COMMAND_TYPES",
1034 command.type_name()
1035 );
1036 }
1037 assert_eq!(
1038 COMMAND_TYPES.len(),
1039 every_variant.len(),
1040 "COMMAND_TYPES names a command no variant spells"
1041 );
1042 }
1043
1044 #[test]
1047 fn a_snapshot_spelling_this_release_does_not_know_reads_as_unknown() {
1048 let mut json = serde_json::to_value(snapshot()).unwrap();
1049 json["playback"] = serde_json::json!("buffering");
1050 json["source"] = serde_json::json!("satellite");
1051 json["downloads"][0]["state"] = serde_json::json!("verifying");
1052
1053 let snapshot: StateSnapshot = serde_json::from_value(json).unwrap();
1054
1055 assert_eq!(snapshot.playback, PlaybackState::Unknown);
1056 assert_eq!(snapshot.source, Some(Source::Unknown));
1057 assert_eq!(snapshot.downloads[0].state, DownloadState::Unknown);
1058 assert_eq!(snapshot.position_secs, 1800.0);
1060 assert_eq!(snapshot.episode.unwrap().slug, "seventynine");
1061 }
1062
1063 #[test]
1064 fn an_event_kind_this_release_does_not_know_reads_as_unknown() {
1065 let line = r#"{"event":{"type":"chapter","index":3}}"#;
1066 assert_eq!(
1067 serde_json::from_str::<Frame>(line).unwrap(),
1068 Frame::Event(EventFrame {
1069 event: Event::Unknown
1070 })
1071 );
1072 }
1073
1074 #[test]
1075 fn a_download_missing_its_slug_is_invalid_params() {
1076 assert_eq!(
1077 parse_request(r#"{"id":1,"cmd":{"type":"download"}}"#)
1078 .unwrap_err()
1079 .error
1080 .code,
1081 ErrorCode::InvalidParams
1082 );
1083 }
1084
1085 #[test]
1086 fn a_delete_download_missing_its_slug_is_invalid_params() {
1087 assert_eq!(
1088 parse_request(r#"{"id":1,"cmd":{"type":"delete_download"}}"#)
1089 .unwrap_err()
1090 .error
1091 .code,
1092 ErrorCode::InvalidParams
1093 );
1094 }
1095
1096 #[test]
1097 fn the_eviction_and_catalog_request_lines_parse() {
1098 assert_eq!(
1099 parse_request(r#"{"id":1,"cmd":{"type":"delete_download","slug":"seventynine"}}"#)
1100 .unwrap(),
1101 Request {
1102 id: 1,
1103 cmd: Command::DeleteDownload {
1104 slug: "seventynine".into()
1105 }
1106 }
1107 );
1108 assert_eq!(
1109 parse_request(r#"{"id":2,"cmd":{"type":"list_catalog"}}"#).unwrap(),
1110 Request {
1111 id: 2,
1112 cmd: Command::ListCatalog
1113 }
1114 );
1115 }
1116
1117 #[test]
1118 fn a_catalog_response_serialises_to_the_shape_the_spec_names_and_round_trips() {
1119 let catalog = Catalog {
1120 info: Vec::new(),
1121 episodes: vec![crate::model::Episode {
1122 bundle_title: None,
1123 special: false,
1124 title: "Episode 79".into(),
1125 link: "https://musicforprogramming.net/seventynine".into(),
1126 enclosure_url: "https://datashat.net/music_for_programming_79.mp3".into(),
1127 byte_len: 441_000_000,
1128 duration_secs: 14_400,
1129 published_at: 1_700_000_000,
1130 slug: Some("seventynine".into()),
1131 order: Some(79),
1132 tracklist: None,
1133 body: None,
1134 links: None,
1135 }],
1136 fetched_at: 1_700_000_000,
1137 enriched: true,
1138 };
1139 let response = Response::catalog(3, catalog);
1140 let json = serde_json::to_string(&response).unwrap();
1141 assert!(json.starts_with(r#"{"id":3,"result":{"type":"catalog","catalog":{"#));
1142 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), response);
1143 }
1144
1145 #[test]
1146 fn an_ok_response_serialises_to_the_shape_the_spec_names() {
1147 assert_eq!(
1148 serde_json::to_string(&Response::ok(1)).unwrap(),
1149 r#"{"id":1,"result":{"type":"ok"}}"#
1150 );
1151 }
1152
1153 #[test]
1154 fn an_error_response_serialises_to_the_shape_the_spec_names() {
1155 assert_eq!(
1156 serde_json::to_string(&Response::failure(Some(2), &Error::NotPlaying)).unwrap(),
1157 r#"{"id":2,"error":{"code":"not_playing","message":"nothing is loaded"}}"#
1158 );
1159 }
1160
1161 #[test]
1162 fn a_snapshot_carries_every_field_the_spec_names() {
1163 let json = serde_json::to_value(snapshot()).unwrap();
1164 let object = json.as_object().unwrap();
1165 for field in [
1166 "playback",
1167 "episode",
1168 "position_secs",
1169 "seek_target_secs",
1170 "duration_secs",
1171 "duration_approximate",
1172 "seekable",
1173 "source",
1174 "downloads",
1175 "error",
1176 ] {
1177 assert!(object.contains_key(field), "snapshot is missing {field}");
1178 }
1179 assert_eq!(object["playback"], "seeking");
1180 assert_eq!(object["source"], "stream");
1181 assert_eq!(object["seek_target_secs"], 5400.0);
1182
1183 let episode = object["episode"].as_object().unwrap();
1184 for field in ["slug", "title", "duration_secs"] {
1185 assert!(episode.contains_key(field), "episode is missing {field}");
1186 }
1187
1188 let download = object["downloads"][0].as_object().unwrap();
1189 for field in ["slug", "downloaded_bytes", "total_bytes", "state"] {
1190 assert!(download.contains_key(field), "download is missing {field}");
1191 }
1192 assert_eq!(download["state"], "running");
1193 }
1194
1195 #[test]
1196 fn absent_snapshot_values_serialise_as_null_rather_than_being_omitted() {
1197 let json = serde_json::to_value(StateSnapshot::stopped()).unwrap();
1198 for field in [
1199 "episode",
1200 "seek_target_secs",
1201 "duration_secs",
1202 "source",
1203 "error",
1204 ] {
1205 assert_eq!(json[field], serde_json::Value::Null, "{field} was omitted");
1206 }
1207 assert_eq!(json["playback"], "stopped");
1208 }
1209
1210 #[test]
1211 fn every_playback_spelling_matches_the_spec() {
1212 let spellings = [
1213 (PlaybackState::Stopped, "stopped"),
1214 (PlaybackState::Loading, "loading"),
1215 (PlaybackState::Playing, "playing"),
1216 (PlaybackState::Paused, "paused"),
1217 (PlaybackState::Seeking, "seeking"),
1218 (PlaybackState::Error, "error"),
1219 ];
1220 for (state, spelling) in spellings {
1221 assert_eq!(serde_json::to_value(state).unwrap(), spelling);
1222 }
1223 }
1224
1225 #[test]
1226 fn every_download_state_spelling_round_trips() {
1227 for (state, spelling) in [
1228 (DownloadState::Queued, "queued"),
1229 (DownloadState::Running, "running"),
1230 (DownloadState::Completed, "completed"),
1231 (DownloadState::Failed, "failed"),
1232 (DownloadState::Cancelled, "cancelled"),
1233 ] {
1234 assert_eq!(serde_json::to_value(state).unwrap(), spelling);
1235 assert_eq!(
1236 serde_json::from_value::<DownloadState>(spelling.into()).unwrap(),
1237 state
1238 );
1239 }
1240 }
1241
1242 #[test]
1243 fn a_state_response_round_trips() {
1244 let response = Response::state(6, snapshot());
1245 let json = serde_json::to_string(&response).unwrap();
1246 assert!(json.starts_with(r#"{"id":6,"result":{"type":"state","state":{"#));
1247 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), response);
1248 }
1249
1250 #[test]
1251 fn an_event_serialises_to_the_shape_the_spec_names_and_carries_no_id() {
1252 let frame = EventFrame {
1253 event: Event::State { state: snapshot() },
1254 };
1255 let json = serde_json::to_string(&frame).unwrap();
1256 assert!(json.starts_with(r#"{"event":{"type":"state","state":{"#));
1257 assert!(!json.contains(r#""id":"#));
1258 assert_eq!(serde_json::from_str::<EventFrame>(&json).unwrap(), frame);
1259 }
1260
1261 #[test]
1262 fn a_frame_tells_events_from_responses() {
1263 let event = EventFrame {
1264 event: Event::State { state: snapshot() },
1265 };
1266 let event_line = serde_json::to_string(&event).unwrap();
1267 assert_eq!(
1268 serde_json::from_str::<Frame>(&event_line).unwrap(),
1269 Frame::Event(event)
1270 );
1271
1272 let response = Response::ok(1);
1273 let response_line = serde_json::to_string(&response).unwrap();
1274 assert_eq!(
1275 serde_json::from_str::<Frame>(&response_line).unwrap(),
1276 Frame::Response(response)
1277 );
1278
1279 let failure = Response::failure(None, &Error::InvalidRequest("bad".into()));
1280 let failure_line = serde_json::to_string(&failure).unwrap();
1281 assert_eq!(
1282 serde_json::from_str::<Frame>(&failure_line).unwrap(),
1283 Frame::Response(failure)
1284 );
1285 }
1286
1287 #[test]
1288 fn a_snapshot_survives_fields_a_client_does_not_recognise() {
1289 let mut json = serde_json::to_value(snapshot()).unwrap();
1290 json["a_field_from_a_later_release"] = serde_json::json!("ignored");
1291 assert_eq!(
1292 serde_json::from_value::<StateSnapshot>(json).unwrap(),
1293 snapshot()
1294 );
1295 }
1296
1297 #[test]
1298 fn no_line_a_daemon_writes_contains_a_newline() {
1299 for line in [
1300 serde_json::to_string(&Response::state(1, snapshot())).unwrap(),
1301 serde_json::to_string(&EventFrame {
1302 event: Event::State { state: snapshot() },
1303 })
1304 .unwrap(),
1305 ] {
1306 assert!(!line.contains('\n'));
1307 }
1308 }
1309}