Skip to main content

wavyzon_plugin/
track.rs

1//! The track model shared by every source, the player and the HTTP API.
2
3use base64::Engine;
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use serde::{Deserialize, Serialize};
6
7use crate::Failure;
8
9/// A playable (or mirrorable) recording.
10///
11/// Tracks are encoded as unpadded base64url JSON, so **any Wavyzon node can
12/// play any track**, whichever node resolved it.
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "camelCase")]
15pub struct Track {
16    /// Name of the source that owns `identifier` (`soundcloud`, `http`, …).
17    pub source: String,
18    /// Source-specific identifier.
19    pub identifier: String,
20    /// Missing decodes as empty, as in Cadence (hand-built encoded tracks).
21    #[serde(default)]
22    pub title: String,
23    #[serde(default)]
24    pub author: String,
25    #[serde(default, skip_serializing_if = "String::is_empty")]
26    pub album: String,
27    #[serde(default)]
28    pub duration_ms: i64,
29    #[serde(default, skip_serializing_if = "String::is_empty")]
30    pub isrc: String,
31    #[serde(default, skip_serializing_if = "String::is_empty")]
32    pub artwork_url: String,
33    #[serde(default, skip_serializing_if = "String::is_empty")]
34    pub uri: String,
35    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
36    pub is_stream: bool,
37}
38
39impl Track {
40    /// `source:identifier`, unique across the node.
41    pub fn key(&self) -> String {
42        format!("{}:{}", self.source, self.identifier)
43    }
44
45    /// Encode as unpadded base64url JSON.
46    pub fn encode(&self) -> String {
47        let raw = serde_json::to_vec(self).expect("track serializes");
48        URL_SAFE_NO_PAD.encode(raw)
49    }
50
51    /// Decode a track produced by [`Track::encode`].
52    pub fn decode(encoded: &str) -> Result<Track, Failure> {
53        let raw = URL_SAFE_NO_PAD
54            .decode(encoded.trim_end_matches('='))
55            .map_err(|e| Failure::common("decode", "decode track", Some(e.to_string())))?;
56        let t: Track =
57            serde_json::from_slice(&raw).map_err(|e| Failure::common("decode", "decode track", Some(e.to_string())))?;
58        if t.source.is_empty() || t.identifier.is_empty() {
59            return Err(Failure::common(
60                "decode",
61                "decode track: missing source or identifier",
62                None,
63            ));
64        }
65        Ok(t)
66    }
67}
68
69/// What a `loadtracks` call produced.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum LoadType {
73    Track,
74    Playlist,
75    Search,
76    Empty,
77    Error,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
81#[serde(rename_all = "camelCase")]
82pub struct Playlist {
83    pub name: String,
84    #[serde(default, skip_serializing_if = "String::is_empty")]
85    pub artwork_url: String,
86    #[serde(default, skip_serializing_if = "String::is_empty")]
87    pub uri: String,
88    pub total: usize,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct LoadResult {
94    #[serde(rename = "loadType")]
95    pub kind: LoadType,
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub tracks: Vec<Track>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub playlist: Option<Playlist>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub error: Option<Failure>,
102}
103
104impl LoadResult {
105    pub fn empty() -> Self {
106        Self {
107            kind: LoadType::Empty,
108            tracks: vec![],
109            playlist: None,
110            error: None,
111        }
112    }
113    pub fn single(t: Track) -> Self {
114        Self {
115            kind: LoadType::Track,
116            tracks: vec![t],
117            playlist: None,
118            error: None,
119        }
120    }
121    pub fn results(tracks: Vec<Track>) -> Self {
122        if tracks.is_empty() {
123            return Self::empty();
124        }
125        Self {
126            kind: LoadType::Search,
127            tracks,
128            playlist: None,
129            error: None,
130        }
131    }
132    pub fn playlist(p: Playlist, tracks: Vec<Track>) -> Self {
133        Self {
134            kind: LoadType::Playlist,
135            tracks,
136            playlist: Some(p),
137            error: None,
138        }
139    }
140    pub fn error(f: Failure) -> Self {
141        Self {
142            kind: LoadType::Error,
143            tracks: vec![],
144            playlist: None,
145            error: Some(f),
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn roundtrip() {
156        let t = Track {
157            source: "http".into(),
158            identifier: "https://example.com/a.mp3".into(),
159            title: "A".into(),
160            author: "B".into(),
161            duration_ms: 1000,
162            ..Default::default()
163        };
164        let enc = t.encode();
165        assert!(!enc.contains('='));
166        assert_eq!(Track::decode(&enc).unwrap(), t);
167        assert_eq!(t.key(), "http:https://example.com/a.mp3");
168    }
169
170    #[test]
171    fn rejects_incomplete() {
172        let enc = URL_SAFE_NO_PAD.encode(br#"{"source":"","identifier":"x","title":"","author":""}"#);
173        assert!(Track::decode(&enc).is_err());
174        assert!(Track::decode("!!!").is_err());
175    }
176
177    #[test]
178    fn title_and_author_are_optional() {
179        let enc = URL_SAFE_NO_PAD.encode(br#"{"source":"soundcloud","identifier":"123"}"#);
180        let t = Track::decode(&enc).unwrap();
181        assert_eq!((t.title.as_str(), t.author.as_str()), ("", ""));
182    }
183}