Skip to main content

mfp_core/
protocol.rs

1//! The newline-delimited JSON wire protocol. This module is its definition.
2//!
3//! One JSON value per line in both directions. A client writes [`Request`] lines and reads
4//! [`Frame`] lines, each either a [`Response`] echoing a request's `id` or an unsolicited
5//! [`EventFrame`] carrying a full [`StateSnapshot`].
6//!
7//! Field names and value spellings are the contract: new fields may be added and readers
8//! must ignore ones they do not recognise, but nothing already named may be renamed or
9//! respelled.
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, ErrorCode, Result};
14use crate::model::Catalog;
15
16/// One request line: `{"id":1,"cmd":{"type":"pause"}}`.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct Request {
19    /// A client-chosen number the response echoes.
20    pub id: i64,
21    pub cmd: Command,
22}
23
24/// The command surface the daemon accepts.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27pub enum Command {
28    /// Start the named episode, or resume the loaded one when no slug is given.
29    Play {
30        #[serde(default, skip_serializing_if = "Option::is_none")]
31        slug: Option<String>,
32    },
33    Pause,
34    Toggle,
35    Stop,
36    /// Seek to an absolute position or by a signed offset. Exactly one of the two.
37    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    /// Begin or resume an offline download; returns before the transfer finishes.
46    Download {
47        slug: String,
48    },
49    CancelDownload {
50        slug: String,
51    },
52    /// Remove an episode's local copy, so no client has to unlink a file the daemon is
53    /// accounting for.
54    DeleteDownload {
55        slug: String,
56    },
57    /// Mark an episode a favourite. Marking one already marked is not an error.
58    Favourite {
59        slug: String,
60    },
61    /// Unmark a favourite. Unmarking one not marked is not an error.
62    Unfavourite {
63        slug: String,
64    },
65    ListFavourites,
66    /// Read the interface's display preferences, and set whichever of them are given.
67    ///
68    /// One command rather than a read and a write: an omitted field is left alone, so a
69    /// read is this command with the field absent and a client never has to read a
70    /// preference back before changing it. These belong to the interface, not to playback,
71    /// and setting one has no effect on what the daemon is playing.
72    ///
73    /// Unknown fields are ignored, so a client still sending the cookie notice's
74    /// dismissal - a preference this interface no longer has - is answered rather than
75    /// refused.
76    Preferences {
77        #[serde(default, skip_serializing_if = "Option::is_none")]
78        inverted_palette: Option<bool>,
79    },
80    Status,
81    /// Return the catalog the daemon holds, so no client has to read its cache file.
82    ListCatalog,
83    /// Start receiving pushed state events on this connection.
84    Subscribe {
85        /// Whether this connection wants snapshots pushed for spectrum changes.
86        ///
87        /// Defaults to false so a client that does not draw an analyser is never woken
88        /// twenty times a second for a field it ignores.
89        #[serde(default)]
90        spectrum: bool,
91    },
92    /// Stop audio, persist state, and exit. The only command that ends the process.
93    Shutdown,
94}
95
96/// Every `cmd.type` spelling the daemon recognises.
97///
98/// Used to tell an unrecognised command from a recognised one with bad arguments, which
99/// the spec answers with different codes.
100pub 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    /// Checks arguments that parsed structurally but must still fall inside their permitted
146    /// domain.
147    ///
148    /// `seek` takes exactly one of `position_secs` or `delta_secs`, and that one must be a
149    /// duration the engine can hold. Anything else is [`ErrorCode::InvalidParams`].
150    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
167/// The exclusive bound on a seek, in seconds.
168///
169/// A seek becomes a [`std::time::Duration`] in the audio engine, and
170/// `Duration::from_secs_f64` panics at or above 2^64 seconds and on a NaN, taking the audio
171/// thread down for the rest of the process. The domain is refused here instead.
172const SEEK_SECS_LIMIT: f64 = u64::MAX as f64;
173
174/// Whether a seek argument is a duration the engine can hold.
175fn seek_within_range(value: f64, minimum: f64) -> Result<()> {
176    // both comparisons are false for a NaN, which is the point: it is as fatal to the
177    // engine as an out-of-range magnitude
178    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/// One response line. Carries exactly one of `result` or `error`, and echoes the
187/// request's `id`, or `null` when the id could not be recovered from the line.
188#[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    /// A successful response carrying `{"type":"ok"}`.
199    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    /// A failure response. `id` is `None` when the request line was too malformed to
224    /// recover one from.
225    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/// The interface's own display preferences.
245///
246/// Recorded beside playback positions and favourites rather than in a second store of the
247/// interface's own, but not part of [`StateSnapshot`]: they change when a user presses a
248/// key, not while audio plays, and nothing subscribed needs them pushed.
249#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(default)]
251pub struct Preferences {
252    pub inverted_palette: bool,
253}
254
255/// A structured failure. Clients branch on `code` and never parse `message`.
256#[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/// One event line: `{"event":{"type":"state","state":{...}}}`. Events carry no `id`.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct EventFrame {
280    pub event: Event,
281}
282
283///
284/// The size difference between the two variants buys nothing to box away: an event is
285/// built, serialised, and dropped, and never held in bulk.
286#[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    /// An event kind this release does not know, which a newer daemon pushed.
295    #[serde(other)]
296    Unknown,
297}
298
299/// One line read from the daemon, which is either a response or a pushed event.
300///
301/// Events are tried first: an event line has an `event` field and no response has one.
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303#[serde(untagged)]
304pub enum Frame {
305    Event(EventFrame),
306    Response(Response),
307}
308
309/// What playback is doing right now.
310#[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    /// A seek has been accepted and audio has not yet resumed at the target. Distinct from
319    /// every other state so a client never shows a position that does not correspond to
320    /// audible audio.
321    Seeking,
322    Error,
323    /// A state this release does not know, which a newer daemon reported.
324    #[serde(other)]
325    Unknown,
326}
327
328/// Where the audio for the loaded episode is coming from.
329#[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    /// A source this release does not know, which a newer daemon reported.
336    #[serde(other)]
337    Unknown,
338}
339
340/// The loaded episode, as much of it as a snapshot needs to name.
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342pub struct EpisodeRef {
343    /// The episode's stable identifier, as [`crate::model::Episode::id`] derives it.
344    pub slug: String,
345    pub title: String,
346    pub duration_secs: f64,
347}
348
349/// What a download is doing.
350#[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    /// A state this release does not know, which a newer daemon reported.
360    #[serde(other)]
361    Unknown,
362}
363
364/// One entry in a snapshot's `downloads` array.
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366pub struct DownloadProgress {
367    pub slug: String,
368    pub downloaded_bytes: u64,
369    /// The byte length the feed declares for the enclosure.
370    pub total_bytes: u64,
371    pub state: DownloadState,
372    /// Populated only when `state` is `failed`.
373    pub error: Option<ErrorObject>,
374}
375
376/// How many frequency bins a [`Spectrum`] carries.
377///
378/// One per bin of a 2048-point FFT, which is what Web Audio's default `fftSize` yields and
379/// therefore what the site's analyser is calibrated against.
380pub const SPECTRUM_BINS: usize = 1024;
381
382/// The lower bound of the decibel range a [`Spectrum`] maps onto `0..=255`.
383pub const SPECTRUM_MIN_DB: f32 = -114.0;
384
385/// The upper bound of the decibel range a [`Spectrum`] maps onto `0..=255`.
386pub const SPECTRUM_MAX_DB: f32 = -30.0;
387
388/// The exponential smoothing applied against the previous frame.
389pub const SPECTRUM_SMOOTHING: f32 = 0.666;
390
391/// A frequency spectrum of the audio currently being played.
392///
393/// Each byte is one bin's magnitude in decibels, scaled linearly from [`SPECTRUM_MIN_DB`]
394/// to [`SPECTRUM_MAX_DB`] onto `0..=255` and clamped - the same contract as Web Audio's
395/// `getByteFrequencyData`, so a client can run the site's own mapping arithmetic unchanged.
396/// Bins run from lowest frequency to highest.
397///
398/// Carried base64-encoded rather than as an array of 1024 numbers: roughly 1.4 KB per
399/// snapshot instead of 4 KB, and the snapshot stays readable.
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct Spectrum(pub Vec<u8>);
402
403impl Spectrum {
404    /// A spectrum reporting no signal in any bin.
405    pub fn silent() -> Self {
406        Self(vec![0; SPECTRUM_BINS])
407    }
408
409    /// The bin at `index`, or 0 when the index is past the end.
410    ///
411    /// Not an error: the client's bin table is fixed while the length here is a wire value,
412    /// so a short frame reads as silence rather than panicking mid-draw.
413    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/// The complete player state.
442///
443/// Every event carries one of these in full; the protocol defines no deltas, so a client
444/// that discards everything it has seen can still render from the next snapshot alone.
445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
446#[non_exhaustive]
447pub struct StateSnapshot {
448    pub playback: PlaybackState,
449    pub episode: Option<EpisodeRef>,
450    /// The position of the audio actually being produced. Never reset or left stale by a
451    /// seek, and never advanced past what is audible.
452    pub position_secs: f64,
453    /// The target of the outstanding seek, and `null` when no seek is outstanding. At
454    /// most one seek is outstanding at a time; a later seek supersedes an earlier one.
455    pub seek_target_secs: Option<f64>,
456    pub duration_secs: Option<f64>,
457    /// True while `duration_secs` comes from the feed rather than from decoding.
458    pub duration_approximate: bool,
459    pub seekable: bool,
460    pub source: Option<Source>,
461    pub downloads: Vec<DownloadProgress>,
462    /// Every episode the listener has marked a favourite, by episode identifier.
463    #[serde(default)]
464    pub favourites: Vec<String>,
465    /// The spectrum of the audio being played.
466    ///
467    /// Absent when nothing is loaded, when playback is paused or stopped, and while a seek
468    /// is in flight, so a client can tell "no audio to analyse" from "silence in the
469    /// audio". A client that ignores it is unaffected by its presence.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub spectrum: Option<Spectrum>,
472    pub error: Option<ErrorObject>,
473}
474
475impl StateSnapshot {
476    /// A snapshot with nothing loaded.
477    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/// A request line that could not be turned into a [`Request`], together with the response
496/// the daemon owes for it.
497#[derive(Debug, Clone, PartialEq)]
498pub struct RequestError {
499    /// The request's id where it could be recovered from the line, and `None` otherwise.
500    pub id: Option<i64>,
501    pub error: ErrorObject,
502}
503
504impl RequestError {
505    /// The response line to write back. The connection stays open either way.
506    pub fn response(&self) -> Response {
507        Response {
508            id: self.id,
509            result: None,
510            error: Some(self.error.clone()),
511        }
512    }
513}
514
515/// Parses one request line, telling a malformed line from an unrecognised command from bad
516/// arguments, each of which the spec answers with a different code.
517///
518/// Not JSON, not an object, or missing `id` or `cmd` is [`ErrorCode::InvalidRequest`] with
519/// `id` `null`; an unrecognised `cmd.type` is [`ErrorCode::UnknownCommand`]; anything else
520/// is [`ErrorCode::InvalidParams`].
521pub 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    /// What a snapshot costs to copy and to put on the wire.
629    ///
630    /// The measured baseline, so a regression shows up as a number rather than a hunch:
631    /// 231 ns to clone and 1 us to serialise, for a line of 1732 bytes. The daemon pushes
632    /// twenty a second, so the serialise is 0.002% of a core.
633    #[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    /// Reading is the command with no field, which must therefore be the wire default, not
769    /// something a client has to spell out.
770    #[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    /// The cookie notice is gone and so is the preference recording its dismissal. A client
785    /// too old to know that must still be answered rather than refused, which is serde's
786    /// default for an unknown field - verified here rather than assumed.
787    #[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    /// Absent preferences are the defaults, which is what a daemon never told otherwise
807    /// reports.
808    #[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        // an older daemon's line carries volume and muted; a newer client must read the rest
885        // of it rather than fail on two fields it no longer knows
886        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        // loudness belongs to the operating system's mixer, and a caller written against the
915        // older surface must fail loudly rather than believe it changed something
916        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        // 1e20 seconds is past what `Duration::from_secs_f64` accepts, and it reached the
936        // engine unchecked before this was validated
937        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    /// JSON writes neither, so these reach `validate` only from a caller built in process.
954    #[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    /// `COMMAND_TYPES` is what `parse_request` gates on, and it is maintained by hand. A
990    /// variant missing from it is answered `unknown_command` by a daemon that implements it,
991    /// with nothing else to catch the drift.
992    #[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    /// The protocol carries no version, so an added spelling must cost a client that one
1045    /// value rather than the whole line.
1046    #[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        // everything beside the three unknown spellings still arrived
1059        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}