1use crate::{Error, SpotifyId};
2use std::{borrow::Cow, fmt, str::FromStr, time::Duration};
3use thiserror::Error;
4
5use librespot_protocol as protocol;
6
7const SPOTIFY_ITEM_TYPE_ALBUM: &str = "album";
8const SPOTIFY_ITEM_TYPE_ARTIST: &str = "artist";
9const SPOTIFY_ITEM_TYPE_EPISODE: &str = "episode";
10const SPOTIFY_ITEM_TYPE_PLAYLIST: &str = "playlist";
11const SPOTIFY_ITEM_TYPE_SHOW: &str = "show";
12const SPOTIFY_ITEM_TYPE_TRACK: &str = "track";
13const SPOTIFY_ITEM_TYPE_LOCAL: &str = "local";
14const SPOTIFY_ITEM_TYPE_UNKNOWN: &str = "unknown";
15
16#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
17pub enum SpotifyUriError {
18 #[error("not a valid Spotify URI")]
19 InvalidFormat,
20 #[error("URI does not belong to Spotify")]
21 InvalidRoot,
22}
23
24impl From<SpotifyUriError> for Error {
25 fn from(err: SpotifyUriError) -> Self {
26 Error::invalid_argument(err)
27 }
28}
29
30pub type SpotifyUriResult = Result<SpotifyUri, Error>;
31
32#[derive(Clone, PartialEq, Eq, Hash)]
33pub enum SpotifyUri {
34 Album {
35 id: SpotifyId,
36 },
37 Artist {
38 id: SpotifyId,
39 },
40 Episode {
41 id: SpotifyId,
42 },
43 Playlist {
44 user: Option<String>,
45 id: SpotifyId,
46 },
47 Show {
48 id: SpotifyId,
49 },
50 Track {
51 id: SpotifyId,
52 },
53 Local {
54 artist: String,
55 album_title: String,
56 track_title: String,
57 duration: std::time::Duration,
58 },
59 Unknown {
60 kind: Cow<'static, str>,
61 id: String,
62 },
63}
64
65impl SpotifyUri {
66 pub fn is_playable(&self) -> bool {
68 matches!(
69 self,
70 SpotifyUri::Episode { .. } | SpotifyUri::Track { .. } | SpotifyUri::Local { .. }
71 )
72 }
73
74 pub fn item_type(&self) -> &'static str {
76 match &self {
77 SpotifyUri::Album { .. } => SPOTIFY_ITEM_TYPE_ALBUM,
78 SpotifyUri::Artist { .. } => SPOTIFY_ITEM_TYPE_ARTIST,
79 SpotifyUri::Episode { .. } => SPOTIFY_ITEM_TYPE_EPISODE,
80 SpotifyUri::Playlist { .. } => SPOTIFY_ITEM_TYPE_PLAYLIST,
81 SpotifyUri::Show { .. } => SPOTIFY_ITEM_TYPE_SHOW,
82 SpotifyUri::Track { .. } => SPOTIFY_ITEM_TYPE_TRACK,
83 SpotifyUri::Local { .. } => SPOTIFY_ITEM_TYPE_LOCAL,
84 SpotifyUri::Unknown { .. } => SPOTIFY_ITEM_TYPE_UNKNOWN,
85 }
86 }
87
88 pub fn to_id(&self) -> Result<String, Error> {
91 match &self {
92 SpotifyUri::Album { id }
93 | SpotifyUri::Artist { id }
94 | SpotifyUri::Episode { id }
95 | SpotifyUri::Playlist { id, .. }
96 | SpotifyUri::Show { id }
97 | SpotifyUri::Track { id } => id.to_base62(),
98 SpotifyUri::Local {
99 artist,
100 album_title,
101 track_title,
102 duration,
103 } => {
104 let duration_secs = duration.as_secs();
105 Ok(format!(
106 "{artist}:{album_title}:{track_title}:{duration_secs}"
107 ))
108 }
109 SpotifyUri::Unknown { id, .. } => Ok(id.clone()),
110 }
111 }
112
113 pub fn from_uri(src: &str) -> SpotifyUriResult {
124 let mut parts = src.split(':');
128
129 let scheme = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;
130
131 if scheme != "spotify" {
132 return Err(SpotifyUriError::InvalidRoot.into());
133 }
134
135 let mut username: Option<String> = None;
136
137 let item_type = {
138 let next = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;
139 if next == "user" {
140 username.replace(
141 parts
142 .next()
143 .ok_or(SpotifyUriError::InvalidFormat)?
144 .to_owned(),
145 );
146 parts.next().ok_or(SpotifyUriError::InvalidFormat)?
147 } else {
148 next
149 }
150 };
151
152 let name = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;
153
154 match item_type {
155 SPOTIFY_ITEM_TYPE_ALBUM => Ok(Self::Album {
156 id: SpotifyId::from_base62(name)?,
157 }),
158 SPOTIFY_ITEM_TYPE_ARTIST => Ok(Self::Artist {
159 id: SpotifyId::from_base62(name)?,
160 }),
161 SPOTIFY_ITEM_TYPE_EPISODE => Ok(Self::Episode {
162 id: SpotifyId::from_base62(name)?,
163 }),
164 SPOTIFY_ITEM_TYPE_PLAYLIST => Ok(Self::Playlist {
165 id: SpotifyId::from_base62(name)?,
166 user: username,
167 }),
168 SPOTIFY_ITEM_TYPE_SHOW => Ok(Self::Show {
169 id: SpotifyId::from_base62(name)?,
170 }),
171 SPOTIFY_ITEM_TYPE_TRACK => Ok(Self::Track {
172 id: SpotifyId::from_base62(name)?,
173 }),
174 SPOTIFY_ITEM_TYPE_LOCAL => {
175 let artist = name;
176 let album_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;
177 let track_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;
178 let duration_secs = parts
179 .next()
180 .and_then(|f| u64::from_str(f).ok())
181 .ok_or(SpotifyUriError::InvalidFormat)?;
182
183 Ok(Self::Local {
184 artist: artist.to_owned(),
185 album_title: album_title.to_owned(),
186 track_title: track_title.to_owned(),
187 duration: Duration::from_secs(duration_secs),
188 })
189 }
190 _ => Ok(Self::Unknown {
191 kind: item_type.to_owned().into(),
192 id: name.to_owned(),
193 }),
194 }
195 }
196
197 pub fn to_uri(&self) -> Result<String, Error> {
209 let item_type = self.item_type();
210 let name = self.to_id()?;
211
212 if let SpotifyUri::Playlist {
213 id,
214 user: Some(user),
215 } = self
216 {
217 Ok(format!("spotify:user:{user}:{item_type}:{id}"))
218 } else {
219 Ok(format!("spotify:{item_type}:{name}"))
220 }
221 }
222
223 #[deprecated(since = "0.8.0", note = "use to_name instead")]
229 pub fn to_base62(&self) -> Result<String, Error> {
230 self.to_id()
231 }
232}
233
234impl fmt::Debug for SpotifyUri {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 f.debug_tuple("SpotifyUri")
237 .field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
238 .finish()
239 }
240}
241
242impl fmt::Display for SpotifyUri {
243 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244 f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
245 }
246}
247
248impl TryFrom<&protocol::metadata::Album> for SpotifyUri {
249 type Error = crate::Error;
250 fn try_from(album: &protocol::metadata::Album) -> Result<Self, Self::Error> {
251 Ok(Self::Album {
252 id: SpotifyId::from_raw(album.gid())?,
253 })
254 }
255}
256
257impl TryFrom<&protocol::metadata::Artist> for SpotifyUri {
258 type Error = crate::Error;
259 fn try_from(artist: &protocol::metadata::Artist) -> Result<Self, Self::Error> {
260 Ok(Self::Artist {
261 id: SpotifyId::from_raw(artist.gid())?,
262 })
263 }
264}
265
266impl TryFrom<&protocol::metadata::Episode> for SpotifyUri {
267 type Error = crate::Error;
268 fn try_from(episode: &protocol::metadata::Episode) -> Result<Self, Self::Error> {
269 Ok(Self::Episode {
270 id: SpotifyId::from_raw(episode.gid())?,
271 })
272 }
273}
274
275impl TryFrom<&protocol::metadata::Track> for SpotifyUri {
276 type Error = crate::Error;
277 fn try_from(track: &protocol::metadata::Track) -> Result<Self, Self::Error> {
278 Ok(Self::Track {
279 id: SpotifyId::from_raw(track.gid())?,
280 })
281 }
282}
283
284impl TryFrom<&protocol::metadata::Show> for SpotifyUri {
285 type Error = crate::Error;
286 fn try_from(show: &protocol::metadata::Show) -> Result<Self, Self::Error> {
287 Ok(Self::Show {
288 id: SpotifyId::from_raw(show.gid())?,
289 })
290 }
291}
292
293impl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyUri {
294 type Error = crate::Error;
295 fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result<Self, Self::Error> {
296 Ok(Self::Artist {
297 id: SpotifyId::from_raw(artist.artist_gid())?,
298 })
299 }
300}
301
302impl TryFrom<&protocol::playlist4_external::Item> for SpotifyUri {
303 type Error = crate::Error;
304 fn try_from(item: &protocol::playlist4_external::Item) -> Result<Self, Self::Error> {
305 Self::from_uri(item.uri())
306 }
307}
308
309impl TryFrom<&protocol::playlist4_external::MetaItem> for SpotifyUri {
312 type Error = crate::Error;
313 fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result<Self, Self::Error> {
314 Ok(Self::Unknown {
315 kind: "MetaItem".into(),
316 id: SpotifyId::try_from(item.revision())?.to_base62()?,
317 })
318 }
319}
320
321impl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyUri {
323 type Error = crate::Error;
324 fn try_from(
325 playlist: &protocol::playlist4_external::SelectedListContent,
326 ) -> Result<Self, Self::Error> {
327 Ok(Self::Unknown {
328 kind: "SelectedListContent".into(),
329 id: SpotifyId::try_from(playlist.revision())?.to_base62()?,
330 })
331 }
332}
333
334impl TryFrom<&protocol::playlist_annotate3::TranscodedPicture> for SpotifyUri {
338 type Error = crate::Error;
339 fn try_from(
340 picture: &protocol::playlist_annotate3::TranscodedPicture,
341 ) -> Result<Self, Self::Error> {
342 Ok(Self::Unknown {
343 kind: "TranscodedPicture".into(),
344 id: picture.uri().to_owned(),
345 })
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 struct ConversionCase {
354 parsed: SpotifyUri,
355 uri: &'static str,
356 base62: &'static str,
357 }
358
359 static CONV_VALID: [ConversionCase; 4] = [
360 ConversionCase {
361 parsed: SpotifyUri::Track {
362 id: SpotifyId {
363 id: 238762092608182713602505436543891614649,
364 },
365 },
366 uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH",
367 base62: "5sWHDYs0csV6RS48xBl0tH",
368 },
369 ConversionCase {
370 parsed: SpotifyUri::Track {
371 id: SpotifyId {
372 id: 204841891221366092811751085145916697048,
373 },
374 },
375 uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4",
376 base62: "4GNcXTGWmnZ3ySrqvol3o4",
377 },
378 ConversionCase {
379 parsed: SpotifyUri::Episode {
380 id: SpotifyId {
381 id: 204841891221366092811751085145916697048,
382 },
383 },
384 uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4",
385 base62: "4GNcXTGWmnZ3ySrqvol3o4",
386 },
387 ConversionCase {
388 parsed: SpotifyUri::Show {
389 id: SpotifyId {
390 id: 204841891221366092811751085145916697048,
391 },
392 },
393 uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4",
394 base62: "4GNcXTGWmnZ3ySrqvol3o4",
395 },
396 ];
397
398 static CONV_INVALID: [ConversionCase; 5] = [
399 ConversionCase {
400 parsed: SpotifyUri::Track {
401 id: SpotifyId { id: 0 },
402 },
403 uri: "spotify:track:5sWHDYs0Bl0tH",
405 base62: "!!!!!Ys0csV6RS48xBl0tH",
406 },
407 ConversionCase {
408 parsed: SpotifyUri::Track {
409 id: SpotifyId { id: 0 },
410 },
411 uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH",
413 base62: "....................",
414 },
415 ConversionCase {
416 parsed: SpotifyUri::Track {
417 id: SpotifyId { id: 0 },
418 },
419 uri: "spotify:track:aRS48xBl0tH",
421 base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
423 },
424 ConversionCase {
425 parsed: SpotifyUri::Track {
426 id: SpotifyId { id: 0 },
427 },
428 uri: "spotify:track:aRS48xBl0tH",
430 base62: "aa",
432 },
433 ConversionCase {
434 parsed: SpotifyUri::Track {
435 id: SpotifyId { id: 0 },
436 },
437 uri: "cleary invalid uri",
438 base62: "ZZZZZZZZZZZZZZZZZZZZZZ",
440 },
441 ];
442
443 struct ItemTypeCase {
444 uri: SpotifyUri,
445 expected_type: &'static str,
446 }
447
448 static ITEM_TYPES: [ItemTypeCase; 6] = [
449 ItemTypeCase {
450 uri: SpotifyUri::Album {
451 id: SpotifyId { id: 0 },
452 },
453 expected_type: "album",
454 },
455 ItemTypeCase {
456 uri: SpotifyUri::Artist {
457 id: SpotifyId { id: 0 },
458 },
459 expected_type: "artist",
460 },
461 ItemTypeCase {
462 uri: SpotifyUri::Episode {
463 id: SpotifyId { id: 0 },
464 },
465 expected_type: "episode",
466 },
467 ItemTypeCase {
468 uri: SpotifyUri::Playlist {
469 user: None,
470 id: SpotifyId { id: 0 },
471 },
472 expected_type: "playlist",
473 },
474 ItemTypeCase {
475 uri: SpotifyUri::Show {
476 id: SpotifyId { id: 0 },
477 },
478 expected_type: "show",
479 },
480 ItemTypeCase {
481 uri: SpotifyUri::Track {
482 id: SpotifyId { id: 0 },
483 },
484 expected_type: "track",
485 },
486 ];
487
488 #[test]
489 fn to_id() {
490 for c in &CONV_VALID {
491 assert_eq!(c.parsed.to_id().unwrap(), c.base62);
492 }
493 }
494
495 #[test]
496 fn item_type() {
497 for i in &ITEM_TYPES {
498 assert_eq!(i.uri.item_type(), i.expected_type);
499 }
500
501 let local_file = SpotifyUri::Local {
505 artist: "".to_owned(),
506 album_title: "".to_owned(),
507 track_title: "".to_owned(),
508 duration: Default::default(),
509 };
510
511 assert_eq!(local_file.item_type(), "local");
512
513 let unknown = SpotifyUri::Unknown {
514 kind: "not used".into(),
515 id: "".to_owned(),
516 };
517
518 assert_eq!(unknown.item_type(), "unknown");
519 }
520
521 #[test]
522 fn from_uri() {
523 for c in &CONV_VALID {
524 let actual = SpotifyUri::from_uri(c.uri).unwrap();
525
526 assert_eq!(actual, c.parsed);
527 }
528
529 for c in &CONV_INVALID {
530 assert!(SpotifyUri::from_uri(c.uri).is_err());
531 }
532 }
533
534 #[test]
535 fn from_invalid_type_uri() {
536 let actual =
537 SpotifyUri::from_uri("spotify:arbitrarywhatever:5sWHDYs0csV6RS48xBl0tH").unwrap();
538
539 assert_eq!(
540 actual,
541 SpotifyUri::Unknown {
542 kind: "arbitrarywhatever".into(),
543 id: "5sWHDYs0csV6RS48xBl0tH".to_owned()
544 }
545 )
546 }
547
548 #[test]
549 fn from_local_uri() {
550 let actual = SpotifyUri::from_uri(
551 "spotify:local:David+Wise:Donkey+Kong+Country%3A+Tropical+Freeze:Snomads+Island:127",
552 )
553 .unwrap();
554
555 assert_eq!(
556 actual,
557 SpotifyUri::Local {
558 artist: "David+Wise".to_owned(),
559 album_title: "Donkey+Kong+Country%3A+Tropical+Freeze".to_owned(),
560 track_title: "Snomads+Island".to_owned(),
561 duration: Duration::from_secs(127),
562 }
563 );
564 }
565
566 #[test]
567 fn from_local_uri_missing_fields() {
568 let actual = SpotifyUri::from_uri("spotify:local:::Snomads+Island:127").unwrap();
569
570 assert_eq!(
571 actual,
572 SpotifyUri::Local {
573 artist: "".to_owned(),
574 album_title: "".to_owned(),
575 track_title: "Snomads+Island".to_owned(),
576 duration: Duration::from_secs(127),
577 }
578 );
579 }
580
581 #[test]
582 fn from_named_uri() {
583 let actual =
584 SpotifyUri::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI").unwrap();
585
586 let SpotifyUri::Playlist { ref user, id } = actual else {
587 panic!("wrong id type");
588 };
589
590 assert_eq!(*user, Some("spotify".to_owned()));
591 assert_eq!(
592 id,
593 SpotifyId {
594 id: 136159921382084734723401526672209703396
595 },
596 );
597 }
598
599 #[test]
600 fn to_uri() {
601 for c in &CONV_VALID {
602 assert_eq!(c.parsed.to_uri().unwrap(), c.uri);
603 }
604 }
605
606 #[test]
607 fn to_named_uri() {
608 let string = "spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI";
609
610 let actual =
611 SpotifyUri::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI").unwrap();
612
613 assert_eq!(actual.to_uri().unwrap(), string);
614 }
615}