spotify_id.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. use std::convert::TryInto;
  2. use std::fmt;
  3. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
  4. pub enum SpotifyAudioType {
  5. Track,
  6. Podcast,
  7. NonPlayable,
  8. }
  9. impl From<&str> for SpotifyAudioType {
  10. fn from(v: &str) -> Self {
  11. match v {
  12. "track" => SpotifyAudioType::Track,
  13. "episode" => SpotifyAudioType::Podcast,
  14. _ => SpotifyAudioType::NonPlayable,
  15. }
  16. }
  17. }
  18. impl Into<&str> for SpotifyAudioType {
  19. fn into(self) -> &'static str {
  20. match self {
  21. SpotifyAudioType::Track => "track",
  22. SpotifyAudioType::Podcast => "episode",
  23. SpotifyAudioType::NonPlayable => "unknown",
  24. }
  25. }
  26. }
  27. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
  28. pub struct SpotifyId {
  29. pub id: u128,
  30. pub audio_type: SpotifyAudioType,
  31. }
  32. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
  33. pub struct SpotifyIdError;
  34. const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
  35. const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef";
  36. impl SpotifyId {
  37. const SIZE: usize = 16;
  38. const SIZE_BASE16: usize = 32;
  39. const SIZE_BASE62: usize = 22;
  40. fn as_track(n: u128) -> SpotifyId {
  41. SpotifyId {
  42. id: n,
  43. audio_type: SpotifyAudioType::Track,
  44. }
  45. }
  46. /// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`.
  47. ///
  48. /// `src` is expected to be 32 bytes long and encoded using valid characters.
  49. ///
  50. /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
  51. pub fn from_base16(src: &str) -> Result<SpotifyId, SpotifyIdError> {
  52. let mut dst: u128 = 0;
  53. for c in src.as_bytes() {
  54. let p = match c {
  55. b'0'..=b'9' => c - b'0',
  56. b'a'..=b'f' => c - b'a' + 10,
  57. _ => return Err(SpotifyIdError),
  58. } as u128;
  59. dst <<= 4;
  60. dst += p;
  61. }
  62. Ok(SpotifyId::as_track(dst))
  63. }
  64. /// Parses a base62 encoded [Spotify ID] into a `SpotifyId`.
  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/#spotify-uris-and-ids
  69. pub fn from_base62(src: &str) -> Result<SpotifyId, SpotifyIdError> {
  70. let mut dst: u128 = 0;
  71. for c in src.as_bytes() {
  72. let p = match c {
  73. b'0'..=b'9' => c - b'0',
  74. b'a'..=b'z' => c - b'a' + 10,
  75. b'A'..=b'Z' => c - b'A' + 36,
  76. _ => return Err(SpotifyIdError),
  77. } as u128;
  78. dst *= 62;
  79. dst += p;
  80. }
  81. Ok(SpotifyId::as_track(dst))
  82. }
  83. /// Creates a `SpotifyId` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order.
  84. ///
  85. /// The resulting `SpotifyId` will default to a `SpotifyAudioType::TRACK`.
  86. pub fn from_raw(src: &[u8]) -> Result<SpotifyId, SpotifyIdError> {
  87. match src.try_into() {
  88. Ok(dst) => Ok(SpotifyId::as_track(u128::from_be_bytes(dst))),
  89. Err(_) => Err(SpotifyIdError),
  90. }
  91. }
  92. /// Parses a [Spotify URI] into a `SpotifyId`.
  93. ///
  94. /// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}`
  95. /// can be arbitrary while `{id}` is a 22-character long, base62 encoded Spotify ID.
  96. ///
  97. /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
  98. pub fn from_uri(src: &str) -> Result<SpotifyId, SpotifyIdError> {
  99. // We expect the ID to be the last colon-delimited item in the URI.
  100. let b = src.as_bytes();
  101. let id_i = b.len() - SpotifyId::SIZE_BASE62;
  102. if b[id_i - 1] != b':' {
  103. return Err(SpotifyIdError);
  104. }
  105. let mut id = SpotifyId::from_base62(&src[id_i..])?;
  106. // Slice offset by 8 as we are skipping the "spotify:" prefix.
  107. id.audio_type = src[8..id_i - 1].into();
  108. Ok(id)
  109. }
  110. /// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE62` (22)
  111. /// character long `String`.
  112. pub fn to_base16(&self) -> String {
  113. to_base16(&self.to_raw(), &mut [0u8; SpotifyId::SIZE_BASE16])
  114. }
  115. /// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22)
  116. /// character long `String`.
  117. ///
  118. /// [canonically]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
  119. pub fn to_base62(&self) -> String {
  120. let mut dst = [0u8; 22];
  121. let mut i = 0;
  122. let n = self.id;
  123. // The algorithm is based on:
  124. // https://github.com/trezor/trezor-crypto/blob/c316e775a2152db255ace96b6b65ac0f20525ec0/base58.c
  125. //
  126. // We are not using naive division of self.id as it is an u128 and div + mod are software
  127. // emulated at runtime (and unoptimized into mul + shift) on non-128bit platforms,
  128. // making them very expensive.
  129. //
  130. // Trezor's algorithm allows us to stick to arithmetic on native registers making this
  131. // an order of magnitude faster. Additionally, as our sizes are known, instead of
  132. // dealing with the ID on a byte by byte basis, we decompose it into four u32s and
  133. // use 64-bit arithmetic on them for an additional speedup.
  134. for shift in &[96, 64, 32, 0] {
  135. let mut carry = (n >> shift) as u32 as u64;
  136. for b in &mut dst[..i] {
  137. carry += (*b as u64) << 32;
  138. *b = (carry % 62) as u8;
  139. carry /= 62;
  140. }
  141. while carry > 0 {
  142. dst[i] = (carry % 62) as u8;
  143. carry /= 62;
  144. i += 1;
  145. }
  146. }
  147. for b in &mut dst {
  148. *b = BASE62_DIGITS[*b as usize];
  149. }
  150. dst.reverse();
  151. unsafe {
  152. // Safety: We are only dealing with ASCII characters.
  153. String::from_utf8_unchecked(dst.to_vec())
  154. }
  155. }
  156. /// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in
  157. /// big-endian order.
  158. pub fn to_raw(&self) -> [u8; SpotifyId::SIZE] {
  159. self.id.to_be_bytes()
  160. }
  161. /// Returns the `SpotifyId` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`,
  162. /// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded
  163. /// Spotify ID.
  164. ///
  165. /// If the `SpotifyId` has an associated type unrecognized by the library, `{type}` will
  166. /// be encoded as `unknown`.
  167. ///
  168. /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
  169. pub fn to_uri(&self) -> String {
  170. // 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31
  171. // + unknown size audio_type.
  172. let audio_type: &str = self.audio_type.into();
  173. let mut dst = String::with_capacity(31 + audio_type.len());
  174. dst.push_str("spotify:");
  175. dst.push_str(audio_type);
  176. dst.push(':');
  177. dst.push_str(&self.to_base62());
  178. dst
  179. }
  180. }
  181. #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
  182. pub struct FileId(pub [u8; 20]);
  183. impl FileId {
  184. pub fn to_base16(&self) -> String {
  185. to_base16(&self.0, &mut [0u8; 40])
  186. }
  187. }
  188. impl fmt::Debug for FileId {
  189. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  190. f.debug_tuple("FileId").field(&self.to_base16()).finish()
  191. }
  192. }
  193. impl fmt::Display for FileId {
  194. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  195. f.write_str(&self.to_base16())
  196. }
  197. }
  198. #[inline]
  199. fn to_base16(src: &[u8], buf: &mut [u8]) -> String {
  200. let mut i = 0;
  201. for v in src {
  202. buf[i] = BASE16_DIGITS[(v >> 4) as usize];
  203. buf[i + 1] = BASE16_DIGITS[(v & 0x0f) as usize];
  204. i += 2;
  205. }
  206. unsafe {
  207. // Safety: We are only dealing with ASCII characters.
  208. String::from_utf8_unchecked(buf.to_vec())
  209. }
  210. }
  211. #[cfg(test)]
  212. mod tests {
  213. use super::*;
  214. struct ConversionCase {
  215. id: u128,
  216. kind: SpotifyAudioType,
  217. uri: &'static str,
  218. base16: &'static str,
  219. base62: &'static str,
  220. raw: &'static [u8],
  221. }
  222. static CONV_VALID: [ConversionCase; 4] = [
  223. ConversionCase {
  224. id: 238762092608182713602505436543891614649,
  225. kind: SpotifyAudioType::Track,
  226. uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH",
  227. base16: "b39fe8081e1f4c54be38e8d6f9f12bb9",
  228. base62: "5sWHDYs0csV6RS48xBl0tH",
  229. raw: &[
  230. 179, 159, 232, 8, 30, 31, 76, 84, 190, 56, 232, 214, 249, 241, 43, 185,
  231. ],
  232. },
  233. ConversionCase {
  234. id: 204841891221366092811751085145916697048,
  235. kind: SpotifyAudioType::Track,
  236. uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4",
  237. base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
  238. base62: "4GNcXTGWmnZ3ySrqvol3o4",
  239. raw: &[
  240. 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
  241. ],
  242. },
  243. ConversionCase {
  244. id: 204841891221366092811751085145916697048,
  245. kind: SpotifyAudioType::Podcast,
  246. uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4",
  247. base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
  248. base62: "4GNcXTGWmnZ3ySrqvol3o4",
  249. raw: &[
  250. 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
  251. ],
  252. },
  253. ConversionCase {
  254. id: 204841891221366092811751085145916697048,
  255. kind: SpotifyAudioType::NonPlayable,
  256. uri: "spotify:unknown:4GNcXTGWmnZ3ySrqvol3o4",
  257. base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
  258. base62: "4GNcXTGWmnZ3ySrqvol3o4",
  259. raw: &[
  260. 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
  261. ],
  262. },
  263. ];
  264. static CONV_INVALID: [ConversionCase; 2] = [
  265. ConversionCase {
  266. id: 0,
  267. kind: SpotifyAudioType::NonPlayable,
  268. // Invalid ID in the URI.
  269. uri: "spotify:arbitrarywhatever:5sWHDYs0Bl0tH",
  270. base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9",
  271. base62: "!!!!!Ys0csV6RS48xBl0tH",
  272. raw: &[
  273. // Invalid length.
  274. 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 5, 3, 108, 119, 187, 233, 216, 255,
  275. ],
  276. },
  277. ConversionCase {
  278. id: 0,
  279. kind: SpotifyAudioType::NonPlayable,
  280. // Missing colon between ID and type.
  281. uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH",
  282. base16: "--------------------",
  283. base62: "....................",
  284. raw: &[
  285. // Invalid length.
  286. 154, 27, 28, 251,
  287. ],
  288. },
  289. ];
  290. #[test]
  291. fn from_base62() {
  292. for c in &CONV_VALID {
  293. assert_eq!(SpotifyId::from_base62(c.base62).unwrap().id, c.id);
  294. }
  295. for c in &CONV_INVALID {
  296. assert_eq!(SpotifyId::from_base62(c.base62), Err(SpotifyIdError));
  297. }
  298. }
  299. #[test]
  300. fn to_base62() {
  301. for c in &CONV_VALID {
  302. let id = SpotifyId {
  303. id: c.id,
  304. audio_type: c.kind,
  305. };
  306. assert_eq!(id.to_base62(), c.base62);
  307. }
  308. }
  309. #[test]
  310. fn from_base16() {
  311. for c in &CONV_VALID {
  312. assert_eq!(SpotifyId::from_base16(c.base16).unwrap().id, c.id);
  313. }
  314. for c in &CONV_INVALID {
  315. assert_eq!(SpotifyId::from_base16(c.base16), Err(SpotifyIdError));
  316. }
  317. }
  318. #[test]
  319. fn to_base16() {
  320. for c in &CONV_VALID {
  321. let id = SpotifyId {
  322. id: c.id,
  323. audio_type: c.kind,
  324. };
  325. assert_eq!(id.to_base16(), c.base16);
  326. }
  327. }
  328. #[test]
  329. fn from_uri() {
  330. for c in &CONV_VALID {
  331. let actual = SpotifyId::from_uri(c.uri).unwrap();
  332. assert_eq!(actual.id, c.id);
  333. assert_eq!(actual.audio_type, c.kind);
  334. }
  335. for c in &CONV_INVALID {
  336. assert_eq!(SpotifyId::from_uri(c.uri), Err(SpotifyIdError));
  337. }
  338. }
  339. #[test]
  340. fn to_uri() {
  341. for c in &CONV_VALID {
  342. let id = SpotifyId {
  343. id: c.id,
  344. audio_type: c.kind,
  345. };
  346. assert_eq!(id.to_uri(), c.uri);
  347. }
  348. }
  349. #[test]
  350. fn from_raw() {
  351. for c in &CONV_VALID {
  352. assert_eq!(SpotifyId::from_raw(c.raw).unwrap().id, c.id);
  353. }
  354. for c in &CONV_INVALID {
  355. assert_eq!(SpotifyId::from_raw(c.raw), Err(SpotifyIdError));
  356. }
  357. }
  358. }