librespot_core/
spotify_id.rs

1use std::fmt;
2
3use thiserror::Error;
4
5use crate::{Error, SpotifyUri};
6
7// re-export FileId for historic reasons, when it was part of this mod
8pub use crate::FileId;
9
10#[derive(Clone, Copy, PartialEq, Eq, Hash)]
11pub struct SpotifyId {
12    pub id: u128,
13}
14
15#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
16pub enum SpotifyIdError {
17    #[error("ID cannot be parsed")]
18    InvalidId,
19    #[error("not a valid Spotify ID")]
20    InvalidFormat,
21}
22
23impl From<SpotifyIdError> for Error {
24    fn from(err: SpotifyIdError) -> Self {
25        Error::invalid_argument(err)
26    }
27}
28
29pub type SpotifyIdResult = Result<SpotifyId, Error>;
30
31const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
32const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef";
33
34impl SpotifyId {
35    const SIZE: usize = 16;
36    const SIZE_BASE16: usize = 32;
37    const SIZE_BASE62: usize = 22;
38
39    /// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`.
40    ///
41    /// `src` is expected to be 32 bytes long and encoded using valid characters.
42    ///
43    /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
44    pub fn from_base16(src: &str) -> SpotifyIdResult {
45        if src.len() != 32 {
46            return Err(SpotifyIdError::InvalidId.into());
47        }
48        let mut dst: u128 = 0;
49
50        for c in src.as_bytes() {
51            let p = match c {
52                b'0'..=b'9' => c - b'0',
53                b'a'..=b'f' => c - b'a' + 10,
54                _ => return Err(SpotifyIdError::InvalidId.into()),
55            } as u128;
56
57            dst <<= 4;
58            dst += p;
59        }
60
61        Ok(Self { id: dst })
62    }
63
64    /// Parses a base62 encoded [Spotify ID] into a `u128`.
65    ///
66    /// `src` is expected to be 22 bytes long and encoded using valid characters.
67    ///
68    /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
69    pub fn from_base62(src: &str) -> SpotifyIdResult {
70        if src.len() != Self::SIZE_BASE62 {
71            return Err(SpotifyIdError::InvalidId.into());
72        }
73        let mut dst: u128 = 0;
74
75        for c in src.as_bytes() {
76            let p = match c {
77                b'0'..=b'9' => c - b'0',
78                b'a'..=b'z' => c - b'a' + 10,
79                b'A'..=b'Z' => c - b'A' + 36,
80                _ => return Err(SpotifyIdError::InvalidId.into()),
81            } as u128;
82
83            dst = dst.checked_mul(62).ok_or(SpotifyIdError::InvalidId)?;
84            dst = dst.checked_add(p).ok_or(SpotifyIdError::InvalidId)?;
85        }
86
87        Ok(Self { id: dst })
88    }
89
90    /// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order.
91    ///
92    /// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`.
93    pub fn from_raw(src: &[u8]) -> SpotifyIdResult {
94        match src.try_into() {
95            Ok(dst) => Ok(Self {
96                id: u128::from_be_bytes(dst),
97            }),
98            Err(_) => Err(SpotifyIdError::InvalidId.into()),
99        }
100    }
101
102    /// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32)
103    /// character long `String`.
104    #[allow(clippy::wrong_self_convention)]
105    pub fn to_base16(&self) -> Result<String, Error> {
106        to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16])
107    }
108
109    /// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22)
110    /// character long `String`.
111    ///
112    /// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
113    #[allow(clippy::wrong_self_convention)]
114    pub fn to_base62(&self) -> Result<String, Error> {
115        let mut dst = [0u8; 22];
116        let mut i = 0;
117        let n = self.id;
118
119        // The algorithm is based on:
120        // https://github.com/trezor/trezor-crypto/blob/c316e775a2152db255ace96b6b65ac0f20525ec0/base58.c
121        //
122        // We are not using naive division of self.id as it is an u128 and div + mod are software
123        // emulated at runtime (and unoptimized into mul + shift) on non-128bit platforms,
124        // making them very expensive.
125        //
126        // Trezor's algorithm allows us to stick to arithmetic on native registers making this
127        // an order of magnitude faster. Additionally, as our sizes are known, instead of
128        // dealing with the ID on a byte by byte basis, we decompose it into four u32s and
129        // use 64-bit arithmetic on them for an additional speedup.
130        for shift in &[96, 64, 32, 0] {
131            let mut carry = (n >> shift) as u32 as u64;
132
133            for b in &mut dst[..i] {
134                carry += (*b as u64) << 32;
135                *b = (carry % 62) as u8;
136                carry /= 62;
137            }
138
139            while carry > 0 {
140                dst[i] = (carry % 62) as u8;
141                carry /= 62;
142                i += 1;
143            }
144        }
145
146        for b in &mut dst {
147            *b = BASE62_DIGITS[*b as usize];
148        }
149
150        dst.reverse();
151
152        String::from_utf8(dst.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into())
153    }
154
155    /// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in
156    /// big-endian order.
157    #[allow(clippy::wrong_self_convention)]
158    pub fn to_raw(&self) -> [u8; Self::SIZE] {
159        self.id.to_be_bytes()
160    }
161}
162
163impl fmt::Debug for SpotifyId {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        f.debug_tuple("SpotifyId")
166            .field(&self.to_base62().unwrap_or_else(|_| "invalid uri".into()))
167            .finish()
168    }
169}
170
171impl fmt::Display for SpotifyId {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.write_str(&self.to_base62().unwrap_or_else(|_| "invalid uri".into()))
174    }
175}
176
177impl TryFrom<&[u8]> for SpotifyId {
178    type Error = crate::Error;
179    fn try_from(src: &[u8]) -> Result<Self, Self::Error> {
180        Self::from_raw(src)
181    }
182}
183
184impl TryFrom<&str> for SpotifyId {
185    type Error = crate::Error;
186    fn try_from(src: &str) -> Result<Self, Self::Error> {
187        Self::from_base62(src)
188    }
189}
190
191impl TryFrom<String> for SpotifyId {
192    type Error = crate::Error;
193    fn try_from(src: String) -> Result<Self, Self::Error> {
194        Self::try_from(src.as_str())
195    }
196}
197
198impl TryFrom<&Vec<u8>> for SpotifyId {
199    type Error = crate::Error;
200    fn try_from(src: &Vec<u8>) -> Result<Self, Self::Error> {
201        Self::try_from(src.as_slice())
202    }
203}
204
205impl TryFrom<&SpotifyUri> for SpotifyId {
206    type Error = crate::Error;
207    fn try_from(value: &SpotifyUri) -> Result<Self, Self::Error> {
208        match value {
209            SpotifyUri::Album { id }
210            | SpotifyUri::Artist { id }
211            | SpotifyUri::Episode { id }
212            | SpotifyUri::Playlist { id, .. }
213            | SpotifyUri::Show { id }
214            | SpotifyUri::Track { id } => Ok(*id),
215            SpotifyUri::Local { .. } | SpotifyUri::Unknown { .. } => {
216                Err(SpotifyIdError::InvalidFormat.into())
217            }
218        }
219    }
220}
221
222pub fn to_base16(src: &[u8], buf: &mut [u8]) -> Result<String, Error> {
223    let mut i = 0;
224    for v in src {
225        buf[i] = BASE16_DIGITS[(v >> 4) as usize];
226        buf[i + 1] = BASE16_DIGITS[(v & 0x0f) as usize];
227        i += 2;
228    }
229
230    String::from_utf8(buf.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into())
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    struct ConversionCase {
238        id: u128,
239        base16: &'static str,
240        base62: &'static str,
241        raw: &'static [u8],
242    }
243
244    static CONV_VALID: [ConversionCase; 5] = [
245        ConversionCase {
246            id: 238762092608182713602505436543891614649,
247            base16: "b39fe8081e1f4c54be38e8d6f9f12bb9",
248            base62: "5sWHDYs0csV6RS48xBl0tH",
249            raw: &[
250                179, 159, 232, 8, 30, 31, 76, 84, 190, 56, 232, 214, 249, 241, 43, 185,
251            ],
252        },
253        ConversionCase {
254            id: 204841891221366092811751085145916697048,
255            base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
256            base62: "4GNcXTGWmnZ3ySrqvol3o4",
257            raw: &[
258                154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
259            ],
260        },
261        ConversionCase {
262            id: 204841891221366092811751085145916697048,
263            base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
264            base62: "4GNcXTGWmnZ3ySrqvol3o4",
265            raw: &[
266                154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
267            ],
268        },
269        ConversionCase {
270            id: 204841891221366092811751085145916697048,
271            base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
272            base62: "4GNcXTGWmnZ3ySrqvol3o4",
273            raw: &[
274                154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
275            ],
276        },
277        ConversionCase {
278            id: 0,
279            base16: "00000000000000000000000000000000",
280            base62: "0000000000000000000000",
281            raw: &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
282        },
283    ];
284
285    static CONV_INVALID: [ConversionCase; 5] = [
286        ConversionCase {
287            id: 0,
288            base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9",
289            base62: "!!!!!Ys0csV6RS48xBl0tH",
290            raw: &[
291                // Invalid length.
292                154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 5, 3, 108, 119, 187, 233, 216, 255,
293            ],
294        },
295        ConversionCase {
296            id: 0,
297            base16: "--------------------",
298            base62: "....................",
299            raw: &[
300                // Invalid length.
301                154, 27, 28, 251,
302            ],
303        },
304        ConversionCase {
305            id: 0,
306            // too long, should return error but not panic overflow
307            base16: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
308            // too long, should return error but not panic overflow
309            base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
310            raw: &[
311                // Invalid length.
312                154, 27, 28, 251,
313            ],
314        },
315        ConversionCase {
316            id: 0,
317            base16: "--------------------",
318            // too short to encode a 128 bits int
319            base62: "aa",
320            raw: &[
321                // Invalid length.
322                154, 27, 28, 251,
323            ],
324        },
325        ConversionCase {
326            id: 0,
327            base16: "--------------------",
328            // too high of a value, this would need a 132 bits int
329            base62: "ZZZZZZZZZZZZZZZZZZZZZZ",
330            raw: &[
331                // Invalid length.
332                154, 27, 28, 251,
333            ],
334        },
335    ];
336
337    #[test]
338    fn from_base62() {
339        for c in &CONV_VALID {
340            assert_eq!(SpotifyId::from_base62(c.base62).unwrap().id, c.id);
341        }
342
343        for c in &CONV_INVALID {
344            assert!(SpotifyId::from_base62(c.base62).is_err(),);
345        }
346    }
347
348    #[test]
349    fn to_base62() {
350        for c in &CONV_VALID {
351            let id = SpotifyId { id: c.id };
352
353            assert_eq!(id.to_base62().unwrap(), c.base62);
354        }
355    }
356
357    #[test]
358    fn from_base16() {
359        for c in &CONV_VALID {
360            assert_eq!(SpotifyId::from_base16(c.base16).unwrap().id, c.id);
361        }
362
363        for c in &CONV_INVALID {
364            assert!(SpotifyId::from_base16(c.base16).is_err(),);
365        }
366    }
367
368    #[test]
369    fn to_base16() {
370        for c in &CONV_VALID {
371            let id = SpotifyId { id: c.id };
372
373            assert_eq!(id.to_base16().unwrap(), c.base16);
374        }
375    }
376
377    #[test]
378    fn from_raw() {
379        for c in &CONV_VALID {
380            assert_eq!(SpotifyId::from_raw(c.raw).unwrap().id, c.id);
381        }
382
383        for c in &CONV_INVALID {
384            assert!(SpotifyId::from_raw(c.raw).is_err());
385        }
386    }
387}