use std::{borrow::Cow, fmt}; use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; use strum::Display; use thiserror::Error; /// Spotify ID or URI parsing error /// /// See also [`Id`] for details. #[derive(Debug, PartialEq, Eq, Clone, Copy, Display, Error)] pub enum IdError { /// Spotify URI prefix is not `spotify:` or `spotify/`. InvalidPrefix, /// Spotify URI can't be split into type and id parts (e.g., it has invalid /// separator). InvalidFormat, /// Spotify URI has invalid type name, or id has invalid type in a given /// context (e.g. a method expects a track id, but artist id is provided). InvalidType, /// Spotify id is invalid (empty or contains invalid characters). InvalidId, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SpotifyItemType { Album, Artist, Episode, Playlist, User, Show, Track, Local, Concert, Prerelease, Songwriter, Unknown, } impl SpotifyItemType { fn try_coerce(self, typ: SpotifyItemType) -> Result { if self == SpotifyItemType::Unknown || self == typ { Ok(typ) } else { Err(IdError::InvalidType) } } } impl From<&str> for SpotifyItemType { fn from(v: &str) -> Self { match v { "album" => Self::Album, "artist" => Self::Artist, "episode" => Self::Episode, "playlist" => Self::Playlist, "user" => Self::User, "show" => Self::Show, "track" => Self::Track, "local" => Self::Local, "concert" => Self::Concert, "prerelease" => Self::Prerelease, "songwriter" => Self::Songwriter, _ => Self::Unknown, } } } impl From for &str { fn from(item_type: SpotifyItemType) -> &'static str { match item_type { SpotifyItemType::Album => "album", SpotifyItemType::Artist => "artist", SpotifyItemType::Episode => "episode", SpotifyItemType::Playlist => "playlist", SpotifyItemType::User => "user", SpotifyItemType::Show => "show", SpotifyItemType::Track => "track", SpotifyItemType::Local => "local", SpotifyItemType::Concert => "concert", SpotifyItemType::Prerelease => "prerelease", SpotifyItemType::Songwriter => "songwriter", SpotifyItemType::Unknown => "unknown", } } } impl std::fmt::Display for SpotifyItemType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str((*self).into()) } } #[derive(Clone, PartialEq, Eq, Hash)] pub struct SpotifyId { id: SpotifyIdInner, pub item_type: SpotifyItemType, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] enum SpotifyIdInner { /// Numeric Spotify IDs (either base62 or base16-encoded) Numeric(u128), /// Textual Spotify IDs (used only for user IDs, since they may be the username for older accounts) Textual(String), } const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef"; impl SpotifyId { const SIZE: usize = 16; const SIZE_BASE16: usize = 32; const SIZE_BASE62: usize = 22; /// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`. /// /// `src` is expected to be 32 bytes long and encoded using valid characters. /// /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids pub fn from_base16(src: &str) -> Result { if src.len() != 32 { return Err(IdError::InvalidId); } let mut dst: u128 = 0; for c in src.as_bytes() { let p = match c { b'0'..=b'9' => c - b'0', b'a'..=b'f' => c - b'a' + 10, _ => return Err(IdError::InvalidId), } as u128; dst <<= 4; dst += p; } Ok(Self { id: SpotifyIdInner::Numeric(dst), item_type: SpotifyItemType::Unknown, }) } /// Parses a base62 encoded [Spotify ID] into a `u128`. /// /// `src` is expected to be 22 bytes long and encoded using valid characters. /// /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids pub fn from_base62(src: &str) -> Result { if src.len() != Self::SIZE_BASE62 { return Err(IdError::InvalidId); } let mut dst: u128 = 0; for c in src.as_bytes() { let p = match c { b'0'..=b'9' => c - b'0', b'a'..=b'z' => c - b'a' + 10, b'A'..=b'Z' => c - b'A' + 36, _ => return Err(IdError::InvalidId), } as u128; dst = dst.checked_mul(62).ok_or(IdError::InvalidId)?; dst = dst.checked_add(p).ok_or(IdError::InvalidId)?; } Ok(Self { id: SpotifyIdInner::Numeric(dst), item_type: SpotifyItemType::Unknown, }) } fn validate_textual_id(src: &str) -> Result<(), IdError> { // forbidden chars: : /&@'" if src.contains(['/', '&', '@', '\'', '"']) { return Err(IdError::InvalidId); } Ok(()) } /// Parse a textual Spotify ID (used for user IDs, which use the base62 format /// for new accounts and the username for legacy accounts). pub fn from_textual(src: &str) -> Result { if src.is_empty() { return Err(IdError::InvalidId); } Self::from_base62(src).or_else(|_| { Self::validate_textual_id(src)?; Ok(Self { id: SpotifyIdInner::Textual(src.to_owned()), item_type: SpotifyItemType::Unknown, }) }) } /// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order. /// /// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`. pub fn from_raw(src: &[u8]) -> Result { match src.try_into() { Ok(dst) => Ok(Self { id: SpotifyIdInner::Numeric(u128::from_be_bytes(dst)), item_type: SpotifyItemType::Unknown, }), Err(_) => Err(IdError::InvalidId), } } /// Parses a [Spotify URI] into a `SpotifyId`. /// /// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}` /// can be arbitrary while `{id}` is a 22-character long, base62 encoded Spotify ID. /// /// Note that this should not be used for playlists, which have the form of /// `spotify:playlist:{id}`. /// /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids pub fn from_uri(uri: &str) -> Result { let mut chars = uri .strip_prefix("spotify") .ok_or(IdError::InvalidPrefix)? .chars(); let sep = match chars.next() { Some(ch) if ch == '/' || ch == ':' => ch, _ => return Err(IdError::InvalidPrefix), }; let rest = chars.as_str(); let (tpe, id) = rest.split_once(sep).ok_or(IdError::InvalidFormat)?; let item_type = SpotifyItemType::from(tpe); if id.is_empty() { return Err(IdError::InvalidId); } match item_type { SpotifyItemType::User => { if let Ok(id) = Self::from_base62(id) { return Ok(Self { item_type, ..id }); } let decoded = urlencoding::decode(id) .map_err(|_| IdError::InvalidId)? .into_owned(); Self::validate_textual_id(&decoded)?; Ok(Self { id: SpotifyIdInner::Textual(decoded), item_type, }) } SpotifyItemType::Local => Ok(Self { id: SpotifyIdInner::Textual(id.to_owned()), item_type, }), _ => Ok(Self { item_type, ..Self::from_base62(id)? }), } } pub fn from_id_or_uri(id_or_uri: &str) -> Result { match Self::from_uri(id_or_uri) { Ok(id) => Ok(id), Err(IdError::InvalidPrefix) => Self::from_textual(id_or_uri), Err(error) => Err(error), } } /// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32) /// character long `String`. #[allow(clippy::wrong_self_convention)] pub fn to_base16(&self) -> Result { to_base16(&self.to_raw()?, &mut [0u8; Self::SIZE_BASE16]) } /// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22) /// character long `String`. /// /// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids pub fn to_base62(&self) -> Cow<'_, str> { self._to_base62(false) } fn _to_base62(&self, uri: bool) -> Cow<'_, str> { match &self.id { SpotifyIdInner::Numeric(n) => { let mut dst = [0u8; 22]; let mut i = 0; // The algorithm is based on: // https://github.com/trezor/trezor-crypto/blob/c316e775a2152db255ace96b6b65ac0f20525ec0/base58.c // // We are not using naive division of self.id as it is an u128 and div + mod are software // emulated at runtime (and unoptimized into mul + shift) on non-128bit platforms, // making them very expensive. // // Trezor's algorithm allows us to stick to arithmetic on native registers making this // an order of magnitude faster. Additionally, as our sizes are known, instead of // dealing with the ID on a byte by byte basis, we decompose it into four u32s and // use 64-bit arithmetic on them for an additional speedup. for shift in &[96, 64, 32, 0] { let mut carry = (n >> shift) as u32 as u64; for b in &mut dst[..i] { carry += (*b as u64) << 32; *b = (carry % 62) as u8; carry /= 62; } while carry > 0 { dst[i] = (carry % 62) as u8; carry /= 62; i += 1; } } for b in &mut dst { *b = BASE62_DIGITS[*b as usize]; } dst.reverse(); String::from_utf8(dst.to_vec()).unwrap().into() } SpotifyIdInner::Textual(id) => { if uri { urlencoding::encode(id) } else { id.into() } } } } /// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in /// big-endian order. #[allow(clippy::wrong_self_convention)] pub fn to_raw(&self) -> Result<[u8; Self::SIZE], IdError> { match &self.id { SpotifyIdInner::Numeric(n) => Ok(n.to_be_bytes()), SpotifyIdInner::Textual(_) => Err(IdError::InvalidId), } } /// Returns the `SpotifyId` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`, /// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded /// Spotify ID. /// /// If the `SpotifyId` has an associated type unrecognized by the library, `{type}` will /// be encoded as `unknown`. /// /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids #[allow(clippy::wrong_self_convention)] pub fn to_uri(&self) -> String { // 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31 // + unknown size item_type. let item_type: &str = self.item_type.into(); let mut dst = String::with_capacity(31 + item_type.len()); dst.push_str("spotify:"); dst.push_str(item_type); dst.push(':'); dst.push_str(&self._to_base62(true)); dst } pub fn to_uri_of_type(&self, typ: SpotifyItemType) -> Result { // 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31 // + unknown size item_type. let item_type: &str = self.item_type.try_coerce(typ)?.into(); let mut dst = String::with_capacity(31 + item_type.len()); dst.push_str("spotify:"); dst.push_str(item_type); dst.push(':'); dst.push_str(&self._to_base62(true)); Ok(dst) } pub fn is_textual(&self) -> bool { match self.id { SpotifyIdInner::Numeric(_) => false, SpotifyIdInner::Textual(_) => true, } } pub fn into_type(self, typ: SpotifyItemType) -> Self { Self { id: self.id, item_type: typ, } } pub fn track(self) -> Self { self.into_type(SpotifyItemType::Track) } pub fn artist(self) -> Self { self.into_type(SpotifyItemType::Artist) } pub fn album(self) -> Self { self.into_type(SpotifyItemType::Album) } pub fn playlist(self) -> Self { self.into_type(SpotifyItemType::Playlist) } pub fn user(self) -> Self { self.into_type(SpotifyItemType::User) } pub fn show(self) -> Self { self.into_type(SpotifyItemType::Show) } pub fn episode(self) -> Self { self.into_type(SpotifyItemType::Episode) } } impl fmt::Debug for SpotifyId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple("SpotifyId").field(&self.to_uri()).finish() } } impl fmt::Display for SpotifyId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.to_base62()) } } impl Serialize for SpotifyId { fn serialize(&self, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&self.to_uri()) } } impl<'de> Deserialize<'de> for SpotifyId { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { struct SpotifyIdVisitor; impl Visitor<'_> for SpotifyIdVisitor { type Value = SpotifyId; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("Spotify ID or URI") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { SpotifyId::from_id_or_uri(v) .map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`"))) } } deserializer.deserialize_str(SpotifyIdVisitor) } } /// Specific serializers/deserializers for Spotify ID types pub mod ser { use super::*; struct SpecificIdVisitor; impl Visitor<'_> for SpecificIdVisitor { type Value = SpotifyId; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("Spotify ID") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { SpotifyId::from_textual(v).map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`"))) } } struct OptionalSpecificIdVisitor; impl Visitor<'_> for OptionalSpecificIdVisitor { type Value = Option; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("Spotify ID or null") } fn visit_none(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_unit(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { SpotifyId::from_textual(v) .map(Some) .map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`"))) } } macro_rules! specific_serializers { ($($name:ident => $itype:expr),+) => { $( pub mod $name { use super::*; pub fn serialize(x: &SpotifyId, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&x.to_base62()) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let mut id = deserializer.deserialize_str(SpecificIdVisitor)?; id.item_type = $itype; Ok(id) } pub mod option { use super::*; pub fn serialize(x: &Option, serializer: S) -> Result where S: Serializer, { match x { Some(x) => serializer.serialize_str(&x.to_base62()), None => serializer.serialize_none(), } } pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let mut id = deserializer.deserialize_any(OptionalSpecificIdVisitor)?; if let Some(id) = id.as_mut() { id.item_type = $itype; } Ok(id) } } } )+ }; } specific_serializers!( artist => SpotifyItemType::Artist, album => SpotifyItemType::Album, track => SpotifyItemType::Track, playlist => SpotifyItemType::Playlist, user => SpotifyItemType::User, show => SpotifyItemType::Show, episode => SpotifyItemType::Episode, concert => SpotifyItemType::Concert, prerelease => SpotifyItemType::Prerelease ); } pub(crate) fn to_base16(src: &[u8], buf: &mut [u8]) -> Result { let mut i = 0; for v in src { buf[i] = BASE16_DIGITS[(v >> 4) as usize]; buf[i + 1] = BASE16_DIGITS[(v & 0x0f) as usize]; i += 2; } String::from_utf8(buf.to_vec()).map_err(|_| IdError::InvalidId) } #[cfg(test)] mod tests { use super::*; struct ConversionCase { id: SpotifyIdInner, kind: SpotifyItemType, uri: &'static str, base16: &'static str, base62: &'static str, raw: &'static [u8], } static CONV_VALID: [ConversionCase; 4] = [ ConversionCase { id: SpotifyIdInner::Numeric(238762092608182713602505436543891614649), kind: SpotifyItemType::Track, uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH", base16: "b39fe8081e1f4c54be38e8d6f9f12bb9", base62: "5sWHDYs0csV6RS48xBl0tH", raw: &[ 179, 159, 232, 8, 30, 31, 76, 84, 190, 56, 232, 214, 249, 241, 43, 185, ], }, ConversionCase { id: SpotifyIdInner::Numeric(204841891221366092811751085145916697048), kind: SpotifyItemType::Track, uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216, ], }, ConversionCase { id: SpotifyIdInner::Numeric(204841891221366092811751085145916697048), kind: SpotifyItemType::Episode, uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216, ], }, ConversionCase { id: SpotifyIdInner::Numeric(204841891221366092811751085145916697048), kind: SpotifyItemType::Show, uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216, ], }, ]; static CONV_INVALID: [ConversionCase; 5] = [ ConversionCase { id: SpotifyIdInner::Numeric(0), kind: SpotifyItemType::Unknown, // Invalid ID in the URI. uri: "spotify:arbitrarywhatever:5sWHDYs0Bl0tH", base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9", base62: "!!!!!Ys0csV6RS48xBl0tH", raw: &[ // Invalid length. 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 5, 3, 108, 119, 187, 233, 216, 255, ], }, ConversionCase { id: SpotifyIdInner::Numeric(0), kind: SpotifyItemType::Unknown, // Missing colon between ID and type. uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH", base16: "--------------------", base62: "....................", raw: &[ // Invalid length. 154, 27, 28, 251, ], }, ConversionCase { id: SpotifyIdInner::Numeric(0), kind: SpotifyItemType::Unknown, // Uri too short uri: "spotify:azb:aRS48xBl0tH", // too long, should return error but not panic overflow base16: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // too long, should return error but not panic overflow base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", raw: &[ // Invalid length. 154, 27, 28, 251, ], }, ConversionCase { id: SpotifyIdInner::Numeric(0), kind: SpotifyItemType::Unknown, // Uri too short uri: "spotify:azb:aRS48xBl0tH", base16: "--------------------", // too short to encode a 128 bits int base62: "aa", raw: &[ // Invalid length. 154, 27, 28, 251, ], }, ConversionCase { id: SpotifyIdInner::Numeric(0), kind: SpotifyItemType::Unknown, uri: "cleary invalid uri", base16: "--------------------", // too high of a value, this would need a 132 bits int base62: "ZZZZZZZZZZZZZZZZZZZZZZ", raw: &[ // Invalid length. 154, 27, 28, 251, ], }, ]; #[test] fn from_base62() { for c in &CONV_VALID { assert_eq!(SpotifyId::from_base62(c.base62).unwrap().id, c.id); } for c in &CONV_INVALID { assert!(SpotifyId::from_base62(c.base62).is_err(),); } } #[test] fn to_base62() { for c in &CONV_VALID { let id = SpotifyId { id: c.id.clone(), item_type: c.kind, }; assert_eq!(id.to_base62(), c.base62); } } #[test] fn from_base16() { for c in &CONV_VALID { assert_eq!(SpotifyId::from_base16(c.base16).unwrap().id, c.id); } for c in &CONV_INVALID { assert!(SpotifyId::from_base16(c.base16).is_err(),); } } #[test] fn to_base16() { for c in &CONV_VALID { let id = SpotifyId { id: c.id.clone(), item_type: c.kind, }; assert_eq!(id.to_base16().unwrap(), c.base16); } } #[test] fn from_uri() { for c in &CONV_VALID { let actual = SpotifyId::from_uri(c.uri).unwrap(); assert_eq!(actual.id, c.id); assert_eq!(actual.item_type, c.kind); } for c in &CONV_INVALID { assert!(SpotifyId::from_uri(c.uri).is_err()); } } #[test] fn to_uri() { for c in &CONV_VALID { let id = SpotifyId { id: c.id.clone(), item_type: c.kind, }; assert_eq!(id.to_uri(), c.uri); } } #[test] fn from_raw() { for c in &CONV_VALID { assert_eq!(SpotifyId::from_raw(c.raw).unwrap().id, c.id); } for c in &CONV_INVALID { assert!(SpotifyId::from_raw(c.raw).is_err()); } } #[test] fn user_id() { let id = SpotifyId::from_uri("spotify:user:fabianakhavan").unwrap(); assert_eq!( id, SpotifyId { id: SpotifyIdInner::Textual("fabianakhavan".to_string()), item_type: SpotifyItemType::User } ); } #[test] fn from_serde() { #[derive(Serialize, Deserialize)] struct SpotifyUriS { uri: SpotifyId, } let spotify_id = SpotifyId::from_uri("spotify:artist:1EfwyuCzDQpCslZc8C9gkG").unwrap(); let json = r#"{"uri":"spotify:artist:1EfwyuCzDQpCslZc8C9gkG"}"#; let got_id = serde_json::from_str::(json).unwrap().uri; assert_eq!(got_id, spotify_id); let got_json = serde_json::to_string(&SpotifyUriS { uri: spotify_id }).unwrap(); assert_eq!(got_json, json); } }