Merge pull request #587 from Johannesd3/refactor-spotify-id

Refactor SpotifyId
This commit is contained in:
Sasha Hilton 2021-01-30 21:03:08 +00:00 committed by GitHub
commit 89eaa392a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23

View file

@ -1,4 +1,4 @@
use std; use std::convert::TryInto;
use std::fmt; use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@ -8,6 +8,26 @@ pub enum SpotifyAudioType {
NonPlayable, NonPlayable,
} }
impl From<&str> for SpotifyAudioType {
fn from(v: &str) -> Self {
match v {
"track" => SpotifyAudioType::Track,
"episode" => SpotifyAudioType::Podcast,
_ => SpotifyAudioType::NonPlayable,
}
}
}
impl Into<&str> for SpotifyAudioType {
fn into(self) -> &'static str {
match self {
SpotifyAudioType::Track => "track",
SpotifyAudioType::Podcast => "episode",
SpotifyAudioType::NonPlayable => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SpotifyId { pub struct SpotifyId {
pub id: u128, pub id: u128,
@ -17,104 +37,178 @@ pub struct SpotifyId {
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub struct SpotifyIdError; pub struct SpotifyIdError;
const BASE62_DIGITS: &'static [u8] = const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef";
const BASE16_DIGITS: &'static [u8] = b"0123456789abcdef";
impl SpotifyId { impl SpotifyId {
const SIZE: usize = 16;
const SIZE_BASE16: usize = 32;
const SIZE_BASE62: usize = 22;
fn as_track(n: u128) -> SpotifyId { fn as_track(n: u128) -> SpotifyId {
SpotifyId { SpotifyId {
id: n.to_owned(), id: n,
audio_type: SpotifyAudioType::Track, audio_type: SpotifyAudioType::Track,
} }
} }
pub fn from_base16(id: &str) -> Result<SpotifyId, SpotifyIdError> { /// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`.
let data = id.as_bytes(); ///
/// `src` is expected to be 32 bytes long and encoded using valid characters.
///
/// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
pub fn from_base16(src: &str) -> Result<SpotifyId, SpotifyIdError> {
let mut dst: u128 = 0;
let mut n = 0u128; for c in src.as_bytes() {
for c in data { let p = match c {
let d = match BASE16_DIGITS.iter().position(|e| e == c) { b'0'..=b'9' => c - b'0',
None => return Err(SpotifyIdError), b'a'..=b'f' => c - b'a' + 10,
Some(x) => x as u128, _ => return Err(SpotifyIdError),
}; } as u128;
n = n * 16;
n = n + d; dst <<= 4;
dst += p;
} }
Ok(SpotifyId::as_track(n)) Ok(SpotifyId::as_track(dst))
} }
pub fn from_base62(id: &str) -> Result<SpotifyId, SpotifyIdError> { /// Parses a base62 encoded [Spotify ID] into a `SpotifyId`.
let data = id.as_bytes(); ///
/// `src` is expected to be 22 bytes long and encoded using valid characters.
///
/// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
pub fn from_base62(src: &str) -> Result<SpotifyId, SpotifyIdError> {
let mut dst: u128 = 0;
let mut n = 0u128; for c in src.as_bytes() {
for c in data { let p = match c {
let d = match BASE62_DIGITS.iter().position(|e| e == c) { b'0'..=b'9' => c - b'0',
None => return Err(SpotifyIdError), b'a'..=b'z' => c - b'a' + 10,
Some(x) => x as u128, b'A'..=b'Z' => c - b'A' + 36,
}; _ => return Err(SpotifyIdError),
n = n * 62; } as u128;
n = n + d;
dst *= 62;
dst += p;
} }
Ok(SpotifyId::as_track(n))
Ok(SpotifyId::as_track(dst))
} }
pub fn from_raw(data: &[u8]) -> Result<SpotifyId, SpotifyIdError> { /// Creates a `SpotifyId` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order.
if data.len() != 16 { ///
/// The resulting `SpotifyId` will default to a `SpotifyAudioType::TRACK`.
pub fn from_raw(src: &[u8]) -> Result<SpotifyId, SpotifyIdError> {
match src.try_into() {
Ok(dst) => Ok(SpotifyId::as_track(u128::from_be_bytes(dst))),
Err(_) => Err(SpotifyIdError),
}
}
/// 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.
///
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
pub fn from_uri(src: &str) -> Result<SpotifyId, SpotifyIdError> {
// We expect the ID to be the last colon-delimited item in the URI.
let b = src.as_bytes();
let id_i = b.len() - SpotifyId::SIZE_BASE62;
if b[id_i - 1] != b':' {
return Err(SpotifyIdError); return Err(SpotifyIdError);
};
let mut arr: [u8; 16] = Default::default();
arr.copy_from_slice(&data[0..16]);
Ok(SpotifyId::as_track(u128::from_be_bytes(arr)))
}
pub fn from_uri(uri: &str) -> Result<SpotifyId, SpotifyIdError> {
let parts = uri.split(":").collect::<Vec<&str>>();
let gid = parts.last().unwrap();
if uri.contains(":episode:") {
let mut spotify_id = SpotifyId::from_base62(gid).unwrap();
let _ = std::mem::replace(&mut spotify_id.audio_type, SpotifyAudioType::Podcast);
Ok(spotify_id)
} else if uri.contains(":track:") {
SpotifyId::from_base62(gid)
} else {
// show/playlist/artist/album/??
let mut spotify_id = SpotifyId::from_base62(gid).unwrap();
let _ = std::mem::replace(&mut spotify_id.audio_type, SpotifyAudioType::NonPlayable);
Ok(spotify_id)
} }
let mut id = SpotifyId::from_base62(&src[id_i..])?;
// Slice offset by 8 as we are skipping the "spotify:" prefix.
id.audio_type = src[8..id_i - 1].into();
Ok(id)
} }
/// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE62` (22)
/// character long `String`.
pub fn to_base16(&self) -> String { pub fn to_base16(&self) -> String {
format!("{:032x}", self.id) to_base16(&self.to_raw(), &mut [0u8; SpotifyId::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/#spotify-uris-and-ids
pub fn to_base62(&self) -> String { pub fn to_base62(&self) -> String {
let &SpotifyId { id: mut n, .. } = self; let mut dst = [0u8; 22];
let mut i = 0;
let n = self.id;
let mut data = [0u8; 22]; // The algorithm is based on:
for i in 0..22 { // https://github.com/trezor/trezor-crypto/blob/c316e775a2152db255ace96b6b65ac0f20525ec0/base58.c
data[21 - i] = BASE62_DIGITS[(n % 62) as usize]; //
n /= 62; // 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;
}
} }
std::str::from_utf8(&data).unwrap().to_owned() for b in &mut dst {
} *b = BASE62_DIGITS[*b as usize];
}
pub fn to_uri(&self) -> String { dst.reverse();
match self.audio_type {
SpotifyAudioType::Track => format!("spotify:track:{}", self.to_base62()), unsafe {
SpotifyAudioType::Podcast => format!("spotify:episode:{}", self.to_base62()), // Safety: We are only dealing with ASCII characters.
SpotifyAudioType::NonPlayable => format!("spotify:unknown:{}", self.to_base62()), String::from_utf8_unchecked(dst.to_vec())
} }
} }
pub fn to_raw(&self) -> [u8; 16] { /// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in
/// big-endian order.
pub fn to_raw(&self) -> [u8; SpotifyId::SIZE] {
self.id.to_be_bytes() self.id.to_be_bytes()
} }
/// 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/#spotify-uris-and-ids
pub fn to_uri(&self) -> String {
// 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31
// + unknown size audio_type.
let audio_type: &str = self.audio_type.into();
let mut dst = String::with_capacity(31 + audio_type.len());
dst.push_str("spotify:");
dst.push_str(audio_type);
dst.push(':');
dst.push_str(&self.to_base62());
dst
}
} }
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
@ -122,11 +216,7 @@ pub struct FileId(pub [u8; 20]);
impl FileId { impl FileId {
pub fn to_base16(&self) -> String { pub fn to_base16(&self) -> String {
self.0 to_base16(&self.0, &mut [0u8; 40])
.iter()
.map(|b| format!("{:02x}", b))
.collect::<Vec<String>>()
.concat()
} }
} }
@ -141,3 +231,185 @@ impl fmt::Display for FileId {
f.write_str(&self.to_base16()) f.write_str(&self.to_base16())
} }
} }
#[inline]
fn to_base16(src: &[u8], buf: &mut [u8]) -> String {
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;
}
unsafe {
// Safety: We are only dealing with ASCII characters.
String::from_utf8_unchecked(buf.to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct ConversionCase {
id: u128,
kind: SpotifyAudioType,
uri: &'static str,
base16: &'static str,
base62: &'static str,
raw: &'static [u8],
}
static CONV_VALID: [ConversionCase; 4] = [
ConversionCase {
id: 238762092608182713602505436543891614649,
kind: SpotifyAudioType::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: 204841891221366092811751085145916697048,
kind: SpotifyAudioType::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: 204841891221366092811751085145916697048,
kind: SpotifyAudioType::Podcast,
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: 204841891221366092811751085145916697048,
kind: SpotifyAudioType::NonPlayable,
uri: "spotify:unknown: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; 2] = [
ConversionCase {
id: 0,
kind: SpotifyAudioType::NonPlayable,
// 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: 0,
kind: SpotifyAudioType::NonPlayable,
// Missing colon between ID and type.
uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH",
base16: "--------------------",
base62: "....................",
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_eq!(SpotifyId::from_base62(c.base62), Err(SpotifyIdError));
}
}
#[test]
fn to_base62() {
for c in &CONV_VALID {
let id = SpotifyId {
id: c.id,
audio_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_eq!(SpotifyId::from_base16(c.base16), Err(SpotifyIdError));
}
}
#[test]
fn to_base16() {
for c in &CONV_VALID {
let id = SpotifyId {
id: c.id,
audio_type: c.kind,
};
assert_eq!(id.to_base16(), 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.audio_type, c.kind);
}
for c in &CONV_INVALID {
assert_eq!(SpotifyId::from_uri(c.uri), Err(SpotifyIdError));
}
}
#[test]
fn to_uri() {
for c in &CONV_VALID {
let id = SpotifyId {
id: c.id,
audio_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_eq!(SpotifyId::from_raw(c.raw), Err(SpotifyIdError));
}
}
}