mirror of
https://github.com/librespot-org/librespot.git
synced 2025-10-03 09:49:31 +02:00

This was a huge effort by photovoltex@gmail.com with help from the community. Over 140 commits were squashed. Below, their commit messages are kept unchanged. --- * dealer wrapper for ease of use * improve sending protobuf requests * replace connect config with connect_state config * start integrating dealer into spirc * payload handling, gzip support * put connect state consistent * formatting * request payload handling, gzip support * expose dealer::protocol, move request in own file * integrate handle of connect-state commands * spirc: remove ident field * transfer playing state better * spirc: remove remote_update stream * spirc: replace command sender with connect state update * spirc: remove device state and remaining unused methods * spirc: remove mercury sender * add repeat track state * ConnectState: add methods to replace state in spirc * spirc: move context into connect_state, update load and next * spirc: remove state, adjust remaining methods * spirc: handle more dealer request commands * revert rustfmt.toml * spirc: impl shuffle - impl shuffle again - extracted fill up of next tracks in own method - moved queue revision update into next track fill up - removed unused method `set_playing_track_index` - added option to specify index when resetting the playback context - reshuffle after repeat context * spirc: handle device became inactive * dealer: adjust payload handling * spirc: better set volume handling * dealer: box PlayCommand (clippy warning) * dealer: always respect queued tracks * spirc: update duration of track * ConnectState: update more restrictions * cleanup * spirc: handle queue requests * spirc: skip next with track * proto: exclude spirc.proto - move "deserialize_with" functions into own file - replace TrackRef with ProvidedTrack * spirc: stabilize transfer/context handling * core: cleanup some remains * connect: improvements to code structure and performance - use VecDeque for next and prev tracks * connect: delayed volume update * connect: move context resolve into own function * connect: load context asynchronous * connect: handle reconnect - might currently steal the active devices playback * connect: some fixes and adjustments - fix wrong offset when transferring playback - fix missing displayed context in web-player - remove access_token from log - send correct state reason when updating volume - queue track correctly - fix wrong assumption for skip_to * connect: replace error case with option * connect: use own context state * connect: more stabilising - handle SkipTo having no Index - handle no transferred restrictions - handle no transferred index - update state before shutdown, for smoother reacquiring * connect: working autoplay * connect: handle repeat context/track * connect: some quick fixes - found self-named uid in collection after reconnecting * connect: handle add_to_queue via set_queue * fix clippy warnings * fix check errors, fix/update example * fix 1.75 specific error * connect: position update improvements * connect: handle unavailable * connect: fix incorrect status handling for desktop and mobile * core: fix dealer reconnect - actually acquire new token - use login5 token retrieval * connect: split state into multiple files * connect: encapsulate provider logic * connect: remove public access to next and prev tracks * connect: remove public access to player * connect: move state only commands into own file * connect: improve logging * connect: handle transferred queue again * connect: fix all-features specific error * connect: extract transfer handling into own file * connect: remove old context model * connect: handle more transfer cases correctly * connect: do auth_token pre-acquiring earlier * connect: handle play with skip_to by uid * connect: simplified cluster update log * core/connect: add remaining set value commands * connect: position update workaround/fix * connect: some queue cleanups * connect: add uid to queue * connect: duration as volume delay const * connect: some adjustments and todo cleanups - send volume update before general update - simplify queue revision to use the track uri - argument why copying the prev/next tracks is fine * connect: handle shuffle from set_options * connect: handle context update * connect: move other structs into model.rs * connect: reduce SpircCommand visibility * connect: fix visibility of model * connect: fix: shuffle on startup isn't applied * connect: prevent loading a context with no tracks * connect: use the first page of a context * connect: improve context resolving - support multiple pages - support page_url of context - handle single track * connect: prevent integer underflow * connect: rename method for better clarity * connect: handle mutate and update messages * connect: fix 1.75 problems * connect: fill, instead of replace next page * connect: reduce context update to single method * connect: remove unused SpircError, handle local files * connect: reduce nesting, adjust initial transfer handling * connect: don't update volume initially * core: disable trace logging of handled mercury responses * core/connect: prevent takeover from other clients, handle session-update * connect: add queue-uid for set_queue command * connect: adjust fields for PlayCommand * connect: preserve context position after update_context * connect: unify metadata modification - only handle `is_queued` `true` items for queue * connect: polish request command handling - reply to all request endpoints - adjust some naming - add some docs * connect: add uid to tracks without * connect: simpler update of current index * core/connect: update log msg, fix wrong behavior - handle became inactive separately - remove duplicate stop - adjust docs for websocket request * core: add option to request without metrics and salt * core/context: adjust context requests and update - search should now return the expected context - removed workaround for single track playback - move local playback check into update_context - check track uri for invalid characters - early return with `?` * connect: handle possible search context uri * connect: remove logout support - handle logout command - disable support for logout - add todos for logout * connect: adjust detailed tracks/context handling - always allow next - handle no prev track available - separate active and fill up context * connect: adjust context resolve handling, again * connect: add autoplay metadata to tracks - transfer into autoplay again * core/connect: cleanup session after spirc stops * update CHANGELOG.md * playback: fix clippy warnings * connect: adjust metadata - unify naming - move more metadata infos into metadata.rs * connect: add delimiter between context and autoplay playback * connect: stop and resume correctly * connect: adjust context resolving - improved certain logging parts - preload autoplay when autoplay attribute mutates - fix transfer context uri - fix typo - handle empty strings for resolve uri - fix unexpected stop of playback * connect: ignore failure during stop * connect: revert resolve_uri changes * connect: correct context reset * connect: reduce boiler code * connect: fix some incorrect states - uid getting replaced by empty value - shuffle/repeat clearing autoplay context - fill_up updating and using incorrect index * core: adjust incorrect separator * connect: move `add_to_queue` and `mark_unavailable` into tracks.rs * connect: refactor - directly modify PutStateRequest - replace `next_tracks`, `prev_tracks`, `player` and `device` with `request` - provide helper methods for the removed fields * connect: adjust handling of context metadata/restrictions * connect: fix incorrect context states * connect: become inactive when no cluster is reported * update CHANGELOG.md * core/playback: preemptively fix clippy warnings * connect: minor adjustment to session changed * connect: change return type changing active context * connect: handle unavailable contexts * connect: fix previous restrictions blocking load with shuffle * connect: update comments and logging * core/connect: reduce some more duplicate code * more docs around the dealer
769 lines
24 KiB
Rust
769 lines
24 KiB
Rust
use std::{fmt, ops::Deref};
|
|
|
|
use thiserror::Error;
|
|
|
|
use crate::Error;
|
|
|
|
use librespot_protocol as protocol;
|
|
|
|
// re-export FileId for historic reasons, when it was part of this mod
|
|
pub use crate::FileId;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum SpotifyItemType {
|
|
Album,
|
|
Artist,
|
|
Episode,
|
|
Playlist,
|
|
Show,
|
|
Track,
|
|
Local,
|
|
Unknown,
|
|
}
|
|
|
|
impl From<&str> for SpotifyItemType {
|
|
fn from(v: &str) -> Self {
|
|
match v {
|
|
"album" => Self::Album,
|
|
"artist" => Self::Artist,
|
|
"episode" => Self::Episode,
|
|
"playlist" => Self::Playlist,
|
|
"show" => Self::Show,
|
|
"track" => Self::Track,
|
|
"local" => Self::Local,
|
|
_ => 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::Show => "show",
|
|
SpotifyItemType::Track => "track",
|
|
SpotifyItemType::Local => "local",
|
|
_ => "unknown",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct SpotifyId {
|
|
pub id: u128,
|
|
pub item_type: SpotifyItemType,
|
|
}
|
|
|
|
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SpotifyIdError {
|
|
#[error("ID cannot be parsed")]
|
|
InvalidId,
|
|
#[error("not a valid Spotify URI")]
|
|
InvalidFormat,
|
|
#[error("URI does not belong to Spotify")]
|
|
InvalidRoot,
|
|
}
|
|
|
|
impl From<SpotifyIdError> for Error {
|
|
fn from(err: SpotifyIdError) -> Self {
|
|
Error::invalid_argument(err)
|
|
}
|
|
}
|
|
|
|
pub type SpotifyIdResult = Result<SpotifyId, Error>;
|
|
pub type NamedSpotifyIdResult = Result<NamedSpotifyId, Error>;
|
|
|
|
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;
|
|
|
|
/// Returns whether this `SpotifyId` is for a playable audio item, if known.
|
|
pub fn is_playable(&self) -> bool {
|
|
matches!(
|
|
self.item_type,
|
|
SpotifyItemType::Episode | SpotifyItemType::Track
|
|
)
|
|
}
|
|
|
|
/// 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) -> SpotifyIdResult {
|
|
if src.len() != 32 {
|
|
return Err(SpotifyIdError::InvalidId.into());
|
|
}
|
|
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(SpotifyIdError::InvalidId.into()),
|
|
} as u128;
|
|
|
|
dst <<= 4;
|
|
dst += p;
|
|
}
|
|
|
|
Ok(Self {
|
|
id: 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) -> SpotifyIdResult {
|
|
if src.len() != 22 {
|
|
return Err(SpotifyIdError::InvalidId.into());
|
|
}
|
|
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(SpotifyIdError::InvalidId.into()),
|
|
} as u128;
|
|
|
|
dst = dst.checked_mul(62).ok_or(SpotifyIdError::InvalidId)?;
|
|
dst = dst.checked_add(p).ok_or(SpotifyIdError::InvalidId)?;
|
|
}
|
|
|
|
Ok(Self {
|
|
id: dst,
|
|
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]) -> SpotifyIdResult {
|
|
match src.try_into() {
|
|
Ok(dst) => Ok(Self {
|
|
id: u128::from_be_bytes(dst),
|
|
item_type: SpotifyItemType::Unknown,
|
|
}),
|
|
Err(_) => Err(SpotifyIdError::InvalidId.into()),
|
|
}
|
|
}
|
|
|
|
/// 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(src: &str) -> SpotifyIdResult {
|
|
// Basic: `spotify:{type}:{id}`
|
|
// Named: `spotify:user:{user}:{type}:{id}`
|
|
// Local: `spotify:local:{artist}:{album_title}:{track_title}:{duration_in_seconds}`
|
|
let mut parts = src.split(':');
|
|
|
|
let scheme = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
|
|
|
|
let item_type = {
|
|
let next = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
|
|
if next == "user" {
|
|
let _username = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
|
|
parts.next().ok_or(SpotifyIdError::InvalidFormat)?
|
|
} else {
|
|
next
|
|
}
|
|
};
|
|
|
|
let id = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
|
|
|
|
if scheme != "spotify" {
|
|
return Err(SpotifyIdError::InvalidRoot.into());
|
|
}
|
|
|
|
let item_type = item_type.into();
|
|
|
|
// Local files have a variable-length ID: https://developer.spotify.com/documentation/general/guides/local-files-spotify-playlists/
|
|
// TODO: find a way to add this local file ID to SpotifyId.
|
|
// One possible solution would be to copy the contents of `id` to a new String field in SpotifyId,
|
|
// but then we would need to remove the derived Copy trait, which would be a breaking change.
|
|
if item_type == SpotifyItemType::Local {
|
|
return Ok(Self { item_type, id: 0 });
|
|
}
|
|
|
|
if id.len() != Self::SIZE_BASE62 {
|
|
return Err(SpotifyIdError::InvalidId.into());
|
|
}
|
|
|
|
Ok(Self {
|
|
item_type,
|
|
..Self::from_base62(id)?
|
|
})
|
|
}
|
|
|
|
/// 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, Error> {
|
|
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
|
|
#[allow(clippy::wrong_self_convention)]
|
|
pub fn to_base62(&self) -> Result<String, Error> {
|
|
let mut dst = [0u8; 22];
|
|
let mut i = 0;
|
|
let n = self.id;
|
|
|
|
// 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()).map_err(|_| SpotifyIdError::InvalidId.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) -> [u8; Self::SIZE] {
|
|
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/concepts/spotify-uris-ids
|
|
#[allow(clippy::wrong_self_convention)]
|
|
pub fn to_uri(&self) -> Result<String, Error> {
|
|
// 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(':');
|
|
let base_62 = self.to_base62()?;
|
|
dst.push_str(&base_62);
|
|
|
|
Ok(dst)
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for SpotifyId {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_tuple("SpotifyId")
|
|
.field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for SpotifyId {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Eq, Hash)]
|
|
pub struct NamedSpotifyId {
|
|
pub inner_id: SpotifyId,
|
|
pub username: String,
|
|
}
|
|
|
|
impl NamedSpotifyId {
|
|
pub fn from_uri(src: &str) -> NamedSpotifyIdResult {
|
|
let uri_parts: Vec<&str> = src.split(':').collect();
|
|
|
|
// At minimum, should be `spotify:user:{username}:{type}:{id}`
|
|
if uri_parts.len() < 5 {
|
|
return Err(SpotifyIdError::InvalidFormat.into());
|
|
}
|
|
|
|
if uri_parts[0] != "spotify" {
|
|
return Err(SpotifyIdError::InvalidRoot.into());
|
|
}
|
|
|
|
if uri_parts[1] != "user" {
|
|
return Err(SpotifyIdError::InvalidFormat.into());
|
|
}
|
|
|
|
Ok(Self {
|
|
inner_id: SpotifyId::from_uri(src)?,
|
|
username: uri_parts[2].to_owned(),
|
|
})
|
|
}
|
|
|
|
pub fn to_uri(&self) -> Result<String, Error> {
|
|
let item_type: &str = self.inner_id.item_type.into();
|
|
let mut dst = String::with_capacity(37 + self.username.len() + item_type.len());
|
|
dst.push_str("spotify:user:");
|
|
dst.push_str(&self.username);
|
|
dst.push(':');
|
|
dst.push_str(item_type);
|
|
dst.push(':');
|
|
let base_62 = self.to_base62()?;
|
|
dst.push_str(&base_62);
|
|
|
|
Ok(dst)
|
|
}
|
|
|
|
pub fn from_spotify_id(id: SpotifyId, username: &str) -> Self {
|
|
Self {
|
|
inner_id: id,
|
|
username: username.to_owned(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Deref for NamedSpotifyId {
|
|
type Target = SpotifyId;
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.inner_id
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for NamedSpotifyId {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_tuple("NamedSpotifyId")
|
|
.field(
|
|
&self
|
|
.inner_id
|
|
.to_uri()
|
|
.unwrap_or_else(|_| "invalid id".into()),
|
|
)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for NamedSpotifyId {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(
|
|
&self
|
|
.inner_id
|
|
.to_uri()
|
|
.unwrap_or_else(|_| "invalid id".into()),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&[u8]> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(src: &[u8]) -> Result<Self, Self::Error> {
|
|
Self::from_raw(src)
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&str> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(src: &str) -> Result<Self, Self::Error> {
|
|
Self::from_base62(src)
|
|
}
|
|
}
|
|
|
|
impl TryFrom<String> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(src: String) -> Result<Self, Self::Error> {
|
|
Self::try_from(src.as_str())
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&Vec<u8>> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(src: &Vec<u8>) -> Result<Self, Self::Error> {
|
|
Self::try_from(src.as_slice())
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&protocol::metadata::Album> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(album: &protocol::metadata::Album) -> Result<Self, Self::Error> {
|
|
Ok(Self {
|
|
item_type: SpotifyItemType::Album,
|
|
..Self::from_raw(album.gid())?
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&protocol::metadata::Artist> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(artist: &protocol::metadata::Artist) -> Result<Self, Self::Error> {
|
|
Ok(Self {
|
|
item_type: SpotifyItemType::Artist,
|
|
..Self::from_raw(artist.gid())?
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&protocol::metadata::Episode> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(episode: &protocol::metadata::Episode) -> Result<Self, Self::Error> {
|
|
Ok(Self {
|
|
item_type: SpotifyItemType::Episode,
|
|
..Self::from_raw(episode.gid())?
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&protocol::metadata::Track> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(track: &protocol::metadata::Track) -> Result<Self, Self::Error> {
|
|
Ok(Self {
|
|
item_type: SpotifyItemType::Track,
|
|
..Self::from_raw(track.gid())?
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&protocol::metadata::Show> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(show: &protocol::metadata::Show) -> Result<Self, Self::Error> {
|
|
Ok(Self {
|
|
item_type: SpotifyItemType::Show,
|
|
..Self::from_raw(show.gid())?
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result<Self, Self::Error> {
|
|
Ok(Self {
|
|
item_type: SpotifyItemType::Artist,
|
|
..Self::from_raw(artist.artist_gid())?
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&protocol::playlist4_external::Item> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(item: &protocol::playlist4_external::Item) -> Result<Self, Self::Error> {
|
|
Ok(Self {
|
|
item_type: SpotifyItemType::Track,
|
|
..Self::from_uri(item.uri())?
|
|
})
|
|
}
|
|
}
|
|
|
|
// Note that this is the unique revision of an item's metadata on a playlist,
|
|
// not the ID of that item or playlist.
|
|
impl TryFrom<&protocol::playlist4_external::MetaItem> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result<Self, Self::Error> {
|
|
Self::try_from(item.revision())
|
|
}
|
|
}
|
|
|
|
// Note that this is the unique revision of a playlist, not the ID of that playlist.
|
|
impl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(
|
|
playlist: &protocol::playlist4_external::SelectedListContent,
|
|
) -> Result<Self, Self::Error> {
|
|
Self::try_from(playlist.revision())
|
|
}
|
|
}
|
|
|
|
// TODO: check meaning and format of this field in the wild. This might be a FileId,
|
|
// which is why we now don't create a separate `Playlist` enum value yet and choose
|
|
// to discard any item type.
|
|
impl TryFrom<&protocol::playlist_annotate3::TranscodedPicture> for SpotifyId {
|
|
type Error = crate::Error;
|
|
fn try_from(
|
|
picture: &protocol::playlist_annotate3::TranscodedPicture,
|
|
) -> Result<Self, Self::Error> {
|
|
Self::from_base62(picture.uri())
|
|
}
|
|
}
|
|
|
|
pub fn to_base16(src: &[u8], buf: &mut [u8]) -> Result<String, Error> {
|
|
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(|_| SpotifyIdError::InvalidId.into())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
struct ConversionCase {
|
|
id: u128,
|
|
kind: SpotifyItemType,
|
|
uri: &'static str,
|
|
base16: &'static str,
|
|
base62: &'static str,
|
|
raw: &'static [u8],
|
|
}
|
|
|
|
static CONV_VALID: [ConversionCase; 5] = [
|
|
ConversionCase {
|
|
id: 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: 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: 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: 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,
|
|
],
|
|
},
|
|
ConversionCase {
|
|
id: 0,
|
|
kind: SpotifyItemType::Local,
|
|
uri: "spotify:local:0000000000000000000000",
|
|
base16: "00000000000000000000000000000000",
|
|
base62: "0000000000000000000000",
|
|
raw: &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
},
|
|
];
|
|
|
|
static CONV_INVALID: [ConversionCase; 5] = [
|
|
ConversionCase {
|
|
id: 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: 0,
|
|
kind: SpotifyItemType::Unknown,
|
|
// Missing colon between ID and type.
|
|
uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH",
|
|
base16: "--------------------",
|
|
base62: "....................",
|
|
raw: &[
|
|
// Invalid length.
|
|
154, 27, 28, 251,
|
|
],
|
|
},
|
|
ConversionCase {
|
|
id: 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: 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: 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,
|
|
item_type: c.kind,
|
|
};
|
|
|
|
assert_eq!(id.to_base62().unwrap(), 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,
|
|
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 from_local_uri() {
|
|
let actual = SpotifyId::from_uri("spotify:local:xyz:123").unwrap();
|
|
|
|
assert_eq!(actual.id, 0);
|
|
assert_eq!(actual.item_type, SpotifyItemType::Local);
|
|
}
|
|
|
|
#[test]
|
|
fn from_named_uri() {
|
|
let actual =
|
|
NamedSpotifyId::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI")
|
|
.unwrap();
|
|
|
|
assert_eq!(actual.id, 136159921382084734723401526672209703396);
|
|
assert_eq!(actual.item_type, SpotifyItemType::Playlist);
|
|
assert_eq!(actual.username, "spotify");
|
|
}
|
|
|
|
#[test]
|
|
fn to_uri() {
|
|
for c in &CONV_VALID {
|
|
let id = SpotifyId {
|
|
id: c.id,
|
|
item_type: c.kind,
|
|
};
|
|
|
|
assert_eq!(id.to_uri().unwrap(), 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());
|
|
}
|
|
}
|
|
}
|