librespot_core/
spotify_uri.rs

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    /// Returns whether this `SpotifyUri` is for a playable audio item, if known.
67    pub fn is_playable(&self) -> bool {
68        matches!(
69            self,
70            SpotifyUri::Episode { .. } | SpotifyUri::Track { .. } | SpotifyUri::Local { .. }
71        )
72    }
73
74    /// Gets the item type of this URI as a static string
75    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    /// Gets the ID of this URI. The resource ID is the component of the URI that identifies
89    /// the resource after its type label. If `self` is a named ID, the user will be omitted.
90    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    /// Parses a [Spotify URI] into a `SpotifyUri`.
114    ///
115    /// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}`
116    /// can be arbitrary while `{id}` is in a format that varies based on the `{type}`:
117    ///
118    ///  - For most item types, a 22-character long, base62 encoded Spotify ID is expected.
119    ///  - For local files, an arbitrary length string with the fields
120    ///    `{artist}:{album_title}:{track_title}:{duration_in_seconds}` is expected.
121    ///
122    /// Spotify URI: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
123    pub fn from_uri(src: &str) -> SpotifyUriResult {
124        // Basic: `spotify:{type}:{id}`
125        // Named: `spotify:user:{user}:{type}:{id}`
126        // Local: `spotify:local:{artist}:{album_title}:{track_title}:{duration_in_seconds}`
127        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    /// Returns the `SpotifyUri` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`,
198    /// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded
199    /// Spotify ID.
200    ///
201    /// If the `SpotifyUri` has an associated type unrecognized by the library, `{type}` will
202    /// be encoded as `unknown`.
203    ///
204    /// If the `SpotifyUri` is named, it will be returned in the form
205    /// `spotify:user:{user}:{type}:{id}`.
206    ///
207    /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
208    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    /// Gets the name of this URI. The resource name is the component of the URI that identifies
224    /// the resource after its type label. If `self` is a named ID, the user will be omitted.
225    ///
226    /// Deprecated: not all IDs can be represented in Base62, so this function has been renamed to
227    /// [SpotifyUri::to_id], which this implementation forwards to.
228    #[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
309// Note that this is the unique revision of an item's metadata on a playlist,
310// not the ID of that item or playlist.
311impl 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
321// Note that this is the unique revision of a playlist, not the ID of that playlist.
322impl 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
334// TODO: check meaning and format of this field in the wild. This might be a FileId,
335// which is why we now don't create a separate `Playlist` enum value yet and choose
336// to discard any item type.
337impl 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            // Invalid ID in the URI.
404            uri: "spotify:track:5sWHDYs0Bl0tH",
405            base62: "!!!!!Ys0csV6RS48xBl0tH",
406        },
407        ConversionCase {
408            parsed: SpotifyUri::Track {
409                id: SpotifyId { id: 0 },
410            },
411            // Missing colon between ID and type.
412            uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH",
413            base62: "....................",
414        },
415        ConversionCase {
416            parsed: SpotifyUri::Track {
417                id: SpotifyId { id: 0 },
418            },
419            // Uri too short
420            uri: "spotify:track:aRS48xBl0tH",
421            // too long, should return error but not panic overflow
422            base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
423        },
424        ConversionCase {
425            parsed: SpotifyUri::Track {
426                id: SpotifyId { id: 0 },
427            },
428            // Uri too short
429            uri: "spotify:track:aRS48xBl0tH",
430            // too short to encode a 128 bits int
431            base62: "aa",
432        },
433        ConversionCase {
434            parsed: SpotifyUri::Track {
435                id: SpotifyId { id: 0 },
436            },
437            uri: "cleary invalid uri",
438            // too high of a value, this would need a 132 bits int
439            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        // These need to use methods that can't be used in the static context like to_owned() and
502        // into().
503
504        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}