831 lines
26 KiB
Rust
831 lines
26 KiB
Rust
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<Self, IdError> {
|
|
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<SpotifyItemType> 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<Self, IdError> {
|
|
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<Self, IdError> {
|
|
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<Self, IdError> {
|
|
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<Self, IdError> {
|
|
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<Self, IdError> {
|
|
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<Self, IdError> {
|
|
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<String, IdError> {
|
|
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<String, IdError> {
|
|
// 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
serializer.serialize_str(&self.to_uri())
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for SpotifyId {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
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<E>(self, v: &str) -> Result<Self::Value, E>
|
|
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<E>(self, v: &str) -> Result<Self::Value, E>
|
|
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<SpotifyId>;
|
|
|
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
|
formatter.write_str("Spotify ID or null")
|
|
}
|
|
|
|
fn visit_none<E>(self) -> Result<Self::Value, E>
|
|
where
|
|
E: serde::de::Error,
|
|
{
|
|
Ok(None)
|
|
}
|
|
|
|
fn visit_unit<E>(self) -> Result<Self::Value, E>
|
|
where
|
|
E: serde::de::Error,
|
|
{
|
|
Ok(None)
|
|
}
|
|
|
|
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
|
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<S>(x: &SpotifyId, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
serializer.serialize_str(&x.to_base62())
|
|
}
|
|
|
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<SpotifyId, D::Error>
|
|
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<S>(x: &Option<SpotifyId>, serializer: S) -> Result<S::Ok, S::Error>
|
|
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<Option<SpotifyId>, 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<String, IdError> {
|
|
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::<SpotifyUriS>(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);
|
|
}
|
|
}
|