1use std::{fmt, ops::Deref};
2
3use thiserror::Error;
4
5use crate::Error;
6
7use librespot_protocol as protocol;
8
9pub use crate::FileId;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum SpotifyItemType {
14 Album,
15 Artist,
16 Episode,
17 Playlist,
18 Show,
19 Track,
20 Local,
21 Unknown,
22}
23
24impl From<&str> for SpotifyItemType {
25 fn from(v: &str) -> Self {
26 match v {
27 "album" => Self::Album,
28 "artist" => Self::Artist,
29 "episode" => Self::Episode,
30 "playlist" => Self::Playlist,
31 "show" => Self::Show,
32 "track" => Self::Track,
33 "local" => Self::Local,
34 _ => Self::Unknown,
35 }
36 }
37}
38
39impl From<SpotifyItemType> for &str {
40 fn from(item_type: SpotifyItemType) -> &'static str {
41 match item_type {
42 SpotifyItemType::Album => "album",
43 SpotifyItemType::Artist => "artist",
44 SpotifyItemType::Episode => "episode",
45 SpotifyItemType::Playlist => "playlist",
46 SpotifyItemType::Show => "show",
47 SpotifyItemType::Track => "track",
48 SpotifyItemType::Local => "local",
49 _ => "unknown",
50 }
51 }
52}
53
54#[derive(Clone, Copy, PartialEq, Eq, Hash)]
55pub struct SpotifyId {
56 pub id: u128,
57 pub item_type: SpotifyItemType,
58}
59
60#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
61pub enum SpotifyIdError {
62 #[error("ID cannot be parsed")]
63 InvalidId,
64 #[error("not a valid Spotify URI")]
65 InvalidFormat,
66 #[error("URI does not belong to Spotify")]
67 InvalidRoot,
68}
69
70impl From<SpotifyIdError> for Error {
71 fn from(err: SpotifyIdError) -> Self {
72 Error::invalid_argument(err)
73 }
74}
75
76pub type SpotifyIdResult = Result<SpotifyId, Error>;
77pub type NamedSpotifyIdResult = Result<NamedSpotifyId, Error>;
78
79const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
80const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef";
81
82impl SpotifyId {
83 const SIZE: usize = 16;
84 const SIZE_BASE16: usize = 32;
85 const SIZE_BASE62: usize = 22;
86
87 pub fn is_playable(&self) -> bool {
89 matches!(
90 self.item_type,
91 SpotifyItemType::Episode | SpotifyItemType::Track
92 )
93 }
94
95 pub fn from_base16(src: &str) -> SpotifyIdResult {
101 if src.len() != 32 {
102 return Err(SpotifyIdError::InvalidId.into());
103 }
104 let mut dst: u128 = 0;
105
106 for c in src.as_bytes() {
107 let p = match c {
108 b'0'..=b'9' => c - b'0',
109 b'a'..=b'f' => c - b'a' + 10,
110 _ => return Err(SpotifyIdError::InvalidId.into()),
111 } as u128;
112
113 dst <<= 4;
114 dst += p;
115 }
116
117 Ok(Self {
118 id: dst,
119 item_type: SpotifyItemType::Unknown,
120 })
121 }
122
123 pub fn from_base62(src: &str) -> SpotifyIdResult {
129 if src.len() != 22 {
130 return Err(SpotifyIdError::InvalidId.into());
131 }
132 let mut dst: u128 = 0;
133
134 for c in src.as_bytes() {
135 let p = match c {
136 b'0'..=b'9' => c - b'0',
137 b'a'..=b'z' => c - b'a' + 10,
138 b'A'..=b'Z' => c - b'A' + 36,
139 _ => return Err(SpotifyIdError::InvalidId.into()),
140 } as u128;
141
142 dst = dst.checked_mul(62).ok_or(SpotifyIdError::InvalidId)?;
143 dst = dst.checked_add(p).ok_or(SpotifyIdError::InvalidId)?;
144 }
145
146 Ok(Self {
147 id: dst,
148 item_type: SpotifyItemType::Unknown,
149 })
150 }
151
152 pub fn from_raw(src: &[u8]) -> SpotifyIdResult {
156 match src.try_into() {
157 Ok(dst) => Ok(Self {
158 id: u128::from_be_bytes(dst),
159 item_type: SpotifyItemType::Unknown,
160 }),
161 Err(_) => Err(SpotifyIdError::InvalidId.into()),
162 }
163 }
164
165 pub fn from_uri(src: &str) -> SpotifyIdResult {
175 let mut parts = src.split(':');
179
180 let scheme = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
181
182 let item_type = {
183 let next = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
184 if next == "user" {
185 let _username = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
186 parts.next().ok_or(SpotifyIdError::InvalidFormat)?
187 } else {
188 next
189 }
190 };
191
192 let id = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
193
194 if scheme != "spotify" {
195 return Err(SpotifyIdError::InvalidRoot.into());
196 }
197
198 let item_type = item_type.into();
199
200 if item_type == SpotifyItemType::Local {
205 return Ok(Self { item_type, id: 0 });
206 }
207
208 if id.len() != Self::SIZE_BASE62 {
209 return Err(SpotifyIdError::InvalidId.into());
210 }
211
212 Ok(Self {
213 item_type,
214 ..Self::from_base62(id)?
215 })
216 }
217
218 #[allow(clippy::wrong_self_convention)]
221 pub fn to_base16(&self) -> Result<String, Error> {
222 to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16])
223 }
224
225 #[allow(clippy::wrong_self_convention)]
230 pub fn to_base62(&self) -> Result<String, Error> {
231 let mut dst = [0u8; 22];
232 let mut i = 0;
233 let n = self.id;
234
235 for shift in &[96, 64, 32, 0] {
247 let mut carry = (n >> shift) as u32 as u64;
248
249 for b in &mut dst[..i] {
250 carry += (*b as u64) << 32;
251 *b = (carry % 62) as u8;
252 carry /= 62;
253 }
254
255 while carry > 0 {
256 dst[i] = (carry % 62) as u8;
257 carry /= 62;
258 i += 1;
259 }
260 }
261
262 for b in &mut dst {
263 *b = BASE62_DIGITS[*b as usize];
264 }
265
266 dst.reverse();
267
268 String::from_utf8(dst.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into())
269 }
270
271 #[allow(clippy::wrong_self_convention)]
274 pub fn to_raw(&self) -> [u8; Self::SIZE] {
275 self.id.to_be_bytes()
276 }
277
278 #[allow(clippy::wrong_self_convention)]
287 pub fn to_uri(&self) -> Result<String, Error> {
288 let item_type: &str = self.item_type.into();
291 let mut dst = String::with_capacity(31 + item_type.len());
292 dst.push_str("spotify:");
293 dst.push_str(item_type);
294 dst.push(':');
295 let base_62 = self.to_base62()?;
296 dst.push_str(&base_62);
297
298 Ok(dst)
299 }
300}
301
302impl fmt::Debug for SpotifyId {
303 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304 f.debug_tuple("SpotifyId")
305 .field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
306 .finish()
307 }
308}
309
310impl fmt::Display for SpotifyId {
311 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312 f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
313 }
314}
315
316#[derive(Clone, PartialEq, Eq, Hash)]
317pub struct NamedSpotifyId {
318 pub inner_id: SpotifyId,
319 pub username: String,
320}
321
322impl NamedSpotifyId {
323 pub fn from_uri(src: &str) -> NamedSpotifyIdResult {
324 let uri_parts: Vec<&str> = src.split(':').collect();
325
326 if uri_parts.len() < 5 {
328 return Err(SpotifyIdError::InvalidFormat.into());
329 }
330
331 if uri_parts[0] != "spotify" {
332 return Err(SpotifyIdError::InvalidRoot.into());
333 }
334
335 if uri_parts[1] != "user" {
336 return Err(SpotifyIdError::InvalidFormat.into());
337 }
338
339 Ok(Self {
340 inner_id: SpotifyId::from_uri(src)?,
341 username: uri_parts[2].to_owned(),
342 })
343 }
344
345 pub fn to_uri(&self) -> Result<String, Error> {
346 let item_type: &str = self.inner_id.item_type.into();
347 let mut dst = String::with_capacity(37 + self.username.len() + item_type.len());
348 dst.push_str("spotify:user:");
349 dst.push_str(&self.username);
350 dst.push(':');
351 dst.push_str(item_type);
352 dst.push(':');
353 let base_62 = self.to_base62()?;
354 dst.push_str(&base_62);
355
356 Ok(dst)
357 }
358
359 pub fn from_spotify_id(id: SpotifyId, username: &str) -> Self {
360 Self {
361 inner_id: id,
362 username: username.to_owned(),
363 }
364 }
365}
366
367impl Deref for NamedSpotifyId {
368 type Target = SpotifyId;
369 fn deref(&self) -> &Self::Target {
370 &self.inner_id
371 }
372}
373
374impl fmt::Debug for NamedSpotifyId {
375 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376 f.debug_tuple("NamedSpotifyId")
377 .field(
378 &self
379 .inner_id
380 .to_uri()
381 .unwrap_or_else(|_| "invalid id".into()),
382 )
383 .finish()
384 }
385}
386
387impl fmt::Display for NamedSpotifyId {
388 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
389 f.write_str(
390 &self
391 .inner_id
392 .to_uri()
393 .unwrap_or_else(|_| "invalid id".into()),
394 )
395 }
396}
397
398impl TryFrom<&[u8]> for SpotifyId {
399 type Error = crate::Error;
400 fn try_from(src: &[u8]) -> Result<Self, Self::Error> {
401 Self::from_raw(src)
402 }
403}
404
405impl TryFrom<&str> for SpotifyId {
406 type Error = crate::Error;
407 fn try_from(src: &str) -> Result<Self, Self::Error> {
408 Self::from_base62(src)
409 }
410}
411
412impl TryFrom<String> for SpotifyId {
413 type Error = crate::Error;
414 fn try_from(src: String) -> Result<Self, Self::Error> {
415 Self::try_from(src.as_str())
416 }
417}
418
419impl TryFrom<&Vec<u8>> for SpotifyId {
420 type Error = crate::Error;
421 fn try_from(src: &Vec<u8>) -> Result<Self, Self::Error> {
422 Self::try_from(src.as_slice())
423 }
424}
425
426impl TryFrom<&protocol::spirc::TrackRef> for SpotifyId {
427 type Error = crate::Error;
428 fn try_from(track: &protocol::spirc::TrackRef) -> Result<Self, Self::Error> {
429 match SpotifyId::from_raw(track.gid()) {
430 Ok(mut id) => {
431 id.item_type = SpotifyItemType::Track;
432 Ok(id)
433 }
434 Err(_) => SpotifyId::from_uri(track.uri()),
435 }
436 }
437}
438
439impl TryFrom<&protocol::metadata::Album> for SpotifyId {
440 type Error = crate::Error;
441 fn try_from(album: &protocol::metadata::Album) -> Result<Self, Self::Error> {
442 Ok(Self {
443 item_type: SpotifyItemType::Album,
444 ..Self::from_raw(album.gid())?
445 })
446 }
447}
448
449impl TryFrom<&protocol::metadata::Artist> for SpotifyId {
450 type Error = crate::Error;
451 fn try_from(artist: &protocol::metadata::Artist) -> Result<Self, Self::Error> {
452 Ok(Self {
453 item_type: SpotifyItemType::Artist,
454 ..Self::from_raw(artist.gid())?
455 })
456 }
457}
458
459impl TryFrom<&protocol::metadata::Episode> for SpotifyId {
460 type Error = crate::Error;
461 fn try_from(episode: &protocol::metadata::Episode) -> Result<Self, Self::Error> {
462 Ok(Self {
463 item_type: SpotifyItemType::Episode,
464 ..Self::from_raw(episode.gid())?
465 })
466 }
467}
468
469impl TryFrom<&protocol::metadata::Track> for SpotifyId {
470 type Error = crate::Error;
471 fn try_from(track: &protocol::metadata::Track) -> Result<Self, Self::Error> {
472 Ok(Self {
473 item_type: SpotifyItemType::Track,
474 ..Self::from_raw(track.gid())?
475 })
476 }
477}
478
479impl TryFrom<&protocol::metadata::Show> for SpotifyId {
480 type Error = crate::Error;
481 fn try_from(show: &protocol::metadata::Show) -> Result<Self, Self::Error> {
482 Ok(Self {
483 item_type: SpotifyItemType::Show,
484 ..Self::from_raw(show.gid())?
485 })
486 }
487}
488
489impl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyId {
490 type Error = crate::Error;
491 fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result<Self, Self::Error> {
492 Ok(Self {
493 item_type: SpotifyItemType::Artist,
494 ..Self::from_raw(artist.artist_gid())?
495 })
496 }
497}
498
499impl TryFrom<&protocol::playlist4_external::Item> for SpotifyId {
500 type Error = crate::Error;
501 fn try_from(item: &protocol::playlist4_external::Item) -> Result<Self, Self::Error> {
502 Ok(Self {
503 item_type: SpotifyItemType::Track,
504 ..Self::from_uri(item.uri())?
505 })
506 }
507}
508
509impl TryFrom<&protocol::playlist4_external::MetaItem> for SpotifyId {
512 type Error = crate::Error;
513 fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result<Self, Self::Error> {
514 Self::try_from(item.revision())
515 }
516}
517
518impl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyId {
520 type Error = crate::Error;
521 fn try_from(
522 playlist: &protocol::playlist4_external::SelectedListContent,
523 ) -> Result<Self, Self::Error> {
524 Self::try_from(playlist.revision())
525 }
526}
527
528impl TryFrom<&protocol::playlist_annotate3::TranscodedPicture> for SpotifyId {
532 type Error = crate::Error;
533 fn try_from(
534 picture: &protocol::playlist_annotate3::TranscodedPicture,
535 ) -> Result<Self, Self::Error> {
536 Self::from_base62(picture.uri())
537 }
538}
539
540pub fn to_base16(src: &[u8], buf: &mut [u8]) -> Result<String, Error> {
541 let mut i = 0;
542 for v in src {
543 buf[i] = BASE16_DIGITS[(v >> 4) as usize];
544 buf[i + 1] = BASE16_DIGITS[(v & 0x0f) as usize];
545 i += 2;
546 }
547
548 String::from_utf8(buf.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into())
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 struct ConversionCase {
556 id: u128,
557 kind: SpotifyItemType,
558 uri: &'static str,
559 base16: &'static str,
560 base62: &'static str,
561 raw: &'static [u8],
562 }
563
564 static CONV_VALID: [ConversionCase; 5] = [
565 ConversionCase {
566 id: 238762092608182713602505436543891614649,
567 kind: SpotifyItemType::Track,
568 uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH",
569 base16: "b39fe8081e1f4c54be38e8d6f9f12bb9",
570 base62: "5sWHDYs0csV6RS48xBl0tH",
571 raw: &[
572 179, 159, 232, 8, 30, 31, 76, 84, 190, 56, 232, 214, 249, 241, 43, 185,
573 ],
574 },
575 ConversionCase {
576 id: 204841891221366092811751085145916697048,
577 kind: SpotifyItemType::Track,
578 uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4",
579 base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
580 base62: "4GNcXTGWmnZ3ySrqvol3o4",
581 raw: &[
582 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
583 ],
584 },
585 ConversionCase {
586 id: 204841891221366092811751085145916697048,
587 kind: SpotifyItemType::Episode,
588 uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4",
589 base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
590 base62: "4GNcXTGWmnZ3ySrqvol3o4",
591 raw: &[
592 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
593 ],
594 },
595 ConversionCase {
596 id: 204841891221366092811751085145916697048,
597 kind: SpotifyItemType::Show,
598 uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4",
599 base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
600 base62: "4GNcXTGWmnZ3ySrqvol3o4",
601 raw: &[
602 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
603 ],
604 },
605 ConversionCase {
606 id: 0,
607 kind: SpotifyItemType::Local,
608 uri: "spotify:local:0000000000000000000000",
609 base16: "00000000000000000000000000000000",
610 base62: "0000000000000000000000",
611 raw: &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
612 },
613 ];
614
615 static CONV_INVALID: [ConversionCase; 5] = [
616 ConversionCase {
617 id: 0,
618 kind: SpotifyItemType::Unknown,
619 uri: "spotify:arbitrarywhatever:5sWHDYs0Bl0tH",
621 base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9",
622 base62: "!!!!!Ys0csV6RS48xBl0tH",
623 raw: &[
624 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 5, 3, 108, 119, 187, 233, 216, 255,
626 ],
627 },
628 ConversionCase {
629 id: 0,
630 kind: SpotifyItemType::Unknown,
631 uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH",
633 base16: "--------------------",
634 base62: "....................",
635 raw: &[
636 154, 27, 28, 251,
638 ],
639 },
640 ConversionCase {
641 id: 0,
642 kind: SpotifyItemType::Unknown,
643 uri: "spotify:azb:aRS48xBl0tH",
645 base16: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
647 base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
649 raw: &[
650 154, 27, 28, 251,
652 ],
653 },
654 ConversionCase {
655 id: 0,
656 kind: SpotifyItemType::Unknown,
657 uri: "spotify:azb:aRS48xBl0tH",
659 base16: "--------------------",
660 base62: "aa",
662 raw: &[
663 154, 27, 28, 251,
665 ],
666 },
667 ConversionCase {
668 id: 0,
669 kind: SpotifyItemType::Unknown,
670 uri: "cleary invalid uri",
671 base16: "--------------------",
672 base62: "ZZZZZZZZZZZZZZZZZZZZZZ",
674 raw: &[
675 154, 27, 28, 251,
677 ],
678 },
679 ];
680
681 #[test]
682 fn from_base62() {
683 for c in &CONV_VALID {
684 assert_eq!(SpotifyId::from_base62(c.base62).unwrap().id, c.id);
685 }
686
687 for c in &CONV_INVALID {
688 assert!(SpotifyId::from_base62(c.base62).is_err(),);
689 }
690 }
691
692 #[test]
693 fn to_base62() {
694 for c in &CONV_VALID {
695 let id = SpotifyId {
696 id: c.id,
697 item_type: c.kind,
698 };
699
700 assert_eq!(id.to_base62().unwrap(), c.base62);
701 }
702 }
703
704 #[test]
705 fn from_base16() {
706 for c in &CONV_VALID {
707 assert_eq!(SpotifyId::from_base16(c.base16).unwrap().id, c.id);
708 }
709
710 for c in &CONV_INVALID {
711 assert!(SpotifyId::from_base16(c.base16).is_err(),);
712 }
713 }
714
715 #[test]
716 fn to_base16() {
717 for c in &CONV_VALID {
718 let id = SpotifyId {
719 id: c.id,
720 item_type: c.kind,
721 };
722
723 assert_eq!(id.to_base16().unwrap(), c.base16);
724 }
725 }
726
727 #[test]
728 fn from_uri() {
729 for c in &CONV_VALID {
730 let actual = SpotifyId::from_uri(c.uri).unwrap();
731
732 assert_eq!(actual.id, c.id);
733 assert_eq!(actual.item_type, c.kind);
734 }
735
736 for c in &CONV_INVALID {
737 assert!(SpotifyId::from_uri(c.uri).is_err());
738 }
739 }
740
741 #[test]
742 fn from_local_uri() {
743 let actual = SpotifyId::from_uri("spotify:local:xyz:123").unwrap();
744
745 assert_eq!(actual.id, 0);
746 assert_eq!(actual.item_type, SpotifyItemType::Local);
747 }
748
749 #[test]
750 fn from_named_uri() {
751 let actual =
752 NamedSpotifyId::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI")
753 .unwrap();
754
755 assert_eq!(actual.id, 136159921382084734723401526672209703396);
756 assert_eq!(actual.item_type, SpotifyItemType::Playlist);
757 assert_eq!(actual.username, "spotify");
758 }
759
760 #[test]
761 fn to_uri() {
762 for c in &CONV_VALID {
763 let id = SpotifyId {
764 id: c.id,
765 item_type: c.kind,
766 };
767
768 assert_eq!(id.to_uri().unwrap(), c.uri);
769 }
770 }
771
772 #[test]
773 fn from_raw() {
774 for c in &CONV_VALID {
775 assert_eq!(SpotifyId::from_raw(c.raw).unwrap().id, c.id);
776 }
777
778 for c in &CONV_INVALID {
779 assert!(SpotifyId::from_raw(c.raw).is_err());
780 }
781 }
782}