Skip to content

Commit f3a3463

Browse files
authored
Make SpotifyId base62 / base16 methods infallible (#1635)
* make SpotifyId::to_base62() infallible The only error case is a non-utf8 string, which is impossible since we're building it from a fixed alphabet. This allows removing a bunch of unnecessary error handling branches. * make {SpotifyId, FileId}::to_base16() infallible too * fix core unit tests * changelog entry
1 parent d36f9f1 commit f3a3463

9 files changed

Lines changed: 168 additions & 264 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- [core] Made `SpotifyId::to_base62`, `SpotifyId::to_base16`, `FileId::to_base16`, `SpotifyUri::to_id` infallible (breaking)
13+
1014
## [0.8.0] - 2025-11-10
1115

1216
### Added

core/src/cache.rs

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -378,17 +378,12 @@ impl Cache {
378378
}
379379

380380
pub fn file_path(&self, file: FileId) -> Option<PathBuf> {
381-
match file.to_base16() {
382-
Ok(name) => self.audio_location.as_ref().map(|location| {
383-
let mut path = location.join(&name[0..2]);
384-
path.push(&name[2..]);
385-
path
386-
}),
387-
Err(e) => {
388-
warn!("Invalid FileId: {e}");
389-
None
390-
}
391-
}
381+
self.audio_location.as_ref().map(|location| {
382+
let name = file.to_base16();
383+
let mut path = location.join(&name[0..2]);
384+
path.push(&name[2..]);
385+
path
386+
})
392387
}
393388

394389
pub fn file(&self, file: FileId) -> Option<File> {

core/src/file_id.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
use std::fmt;
1+
use std::fmt::{self, Write};
22

33
use librespot_protocol as protocol;
44

5-
use crate::{Error, spotify_id::to_base16};
6-
75
const RAW_LEN: usize = 20;
86

97
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -21,8 +19,12 @@ impl FileId {
2119
}
2220

2321
#[allow(clippy::wrong_self_convention)]
24-
pub fn to_base16(&self) -> Result<String, Error> {
25-
to_base16(&self.0, &mut [0u8; 40])
22+
pub fn to_base16(&self) -> String {
23+
let mut s = String::new();
24+
for b in &self.0 {
25+
write!(&mut s, "{b:02x}").unwrap();
26+
}
27+
s
2628
}
2729
}
2830

@@ -34,7 +36,7 @@ impl fmt::Debug for FileId {
3436

3537
impl fmt::Display for FileId {
3638
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37-
f.write_str(&self.to_base16().unwrap_or_default())
39+
f.write_str(&self.to_base16())
3840
}
3941
}
4042

core/src/spclient.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ impl SpClient {
645645
}
646646

647647
pub async fn get_lyrics(&self, track_id: &SpotifyId) -> SpClientResult {
648-
let endpoint = format!("/color-lyrics/v2/track/{}", track_id.to_base62()?);
648+
let endpoint = format!("/color-lyrics/v2/track/{}", track_id.to_base62());
649649

650650
self.request_as_json(&Method::GET, &endpoint, None, None)
651651
.await
@@ -658,7 +658,7 @@ impl SpClient {
658658
) -> SpClientResult {
659659
let endpoint = format!(
660660
"/color-lyrics/v2/track/{}/image/spotify:image:{}",
661-
track_id.to_base62()?,
661+
track_id.to_base62(),
662662
image_id
663663
);
664664

@@ -667,7 +667,7 @@ impl SpClient {
667667
}
668668

669669
pub async fn get_playlist(&self, playlist_id: &SpotifyId) -> SpClientResult {
670-
let endpoint = format!("/playlist/v2/playlist/{}", playlist_id.to_base62()?);
670+
let endpoint = format!("/playlist/v2/playlist/{}", playlist_id.to_base62());
671671

672672
self.request(&Method::GET, &endpoint, None, None).await
673673
}
@@ -750,7 +750,7 @@ impl SpClient {
750750
let previous_track_str = previous_tracks
751751
.iter()
752752
.map(|track| track.to_base62())
753-
.collect::<Result<Vec<_>, _>>()?
753+
.collect::<Vec<_>>()
754754
.join(",");
755755
// better than checking `previous_tracks.len() > 0` because the `filter_map` could still return 0 items
756756
if !previous_track_str.is_empty() {
@@ -773,7 +773,7 @@ impl SpClient {
773773
pub async fn get_audio_storage(&self, file_id: &FileId) -> SpClientResult {
774774
let endpoint = format!(
775775
"/storage-resolve/files/audio/interactive/{}",
776-
file_id.to_base16()?
776+
file_id.to_base16()
777777
);
778778
self.request(&Method::GET, &endpoint, None, None).await
779779
}
@@ -819,7 +819,7 @@ impl SpClient {
819819
.get_user_attribute(ATTRIBUTE)
820820
.ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?;
821821

822-
let mut url = template.replace("{id}", &preview_id.to_base16()?);
822+
let mut url = template.replace("{id}", &preview_id.to_base16());
823823
let separator = match url.find('?') {
824824
Some(_) => "&",
825825
None => "?",
@@ -837,7 +837,7 @@ impl SpClient {
837837
.get_user_attribute(ATTRIBUTE)
838838
.ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?;
839839

840-
let url = template.replace("{file_id}", &file_id.to_base16()?);
840+
let url = template.replace("{file_id}", &file_id.to_base16());
841841

842842
self.request_url(&url).await
843843
}
@@ -848,7 +848,7 @@ impl SpClient {
848848
.session()
849849
.get_user_attribute(ATTRIBUTE)
850850
.ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?;
851-
let url = template.replace("{file_id}", &image_id.to_base16()?);
851+
let url = template.replace("{file_id}", &image_id.to_base16());
852852

853853
self.request_url(&url).await
854854
}

core/src/spotify_id.rs

Lines changed: 14 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,9 @@ impl From<SpotifyIdError> for Error {
2929
pub type SpotifyIdResult = Result<SpotifyId, Error>;
3030

3131
const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
32-
const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef";
3332

3433
impl SpotifyId {
3534
const SIZE: usize = 16;
36-
const SIZE_BASE16: usize = 32;
3735
const SIZE_BASE62: usize = 22;
3836

3937
/// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`.
@@ -45,20 +43,9 @@ impl SpotifyId {
4543
if src.len() != 32 {
4644
return Err(SpotifyIdError::InvalidId.into());
4745
}
48-
let mut dst: u128 = 0;
49-
50-
for c in src.as_bytes() {
51-
let p = match c {
52-
b'0'..=b'9' => c - b'0',
53-
b'a'..=b'f' => c - b'a' + 10,
54-
_ => return Err(SpotifyIdError::InvalidId.into()),
55-
} as u128;
56-
57-
dst <<= 4;
58-
dst += p;
59-
}
46+
let id = u128::from_str_radix(src, 16).map_err(|_| SpotifyIdError::InvalidId)?;
6047

61-
Ok(Self { id: dst })
48+
Ok(Self { id })
6249
}
6350

6451
/// Parses a base62 encoded [Spotify ID] into a `u128`.
@@ -99,19 +86,18 @@ impl SpotifyId {
9986
}
10087
}
10188

102-
/// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32)
103-
/// character long `String`.
89+
/// Returns the `SpotifyId` as a base16 (hex) encoded, 32-character long `String`.
10490
#[allow(clippy::wrong_self_convention)]
105-
pub fn to_base16(&self) -> Result<String, Error> {
106-
to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16])
91+
pub fn to_base16(&self) -> String {
92+
format!("{:032x}", self.id)
10793
}
10894

10995
/// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22)
11096
/// character long `String`.
11197
///
11298
/// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
11399
#[allow(clippy::wrong_self_convention)]
114-
pub fn to_base62(&self) -> Result<String, Error> {
100+
pub fn to_base62(&self) -> String {
115101
let mut dst = [0u8; 22];
116102
let mut i = 0;
117103
let n = self.id;
@@ -143,13 +129,12 @@ impl SpotifyId {
143129
}
144130
}
145131

146-
for b in &mut dst {
147-
*b = BASE62_DIGITS[*b as usize];
132+
let mut s = String::with_capacity(dst.len());
133+
for &b in dst.iter().rev() {
134+
s.push(BASE62_DIGITS[b as usize] as char);
148135
}
149136

150-
dst.reverse();
151-
152-
String::from_utf8(dst.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into())
137+
s
153138
}
154139

155140
/// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in
@@ -162,15 +147,13 @@ impl SpotifyId {
162147

163148
impl fmt::Debug for SpotifyId {
164149
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165-
f.debug_tuple("SpotifyId")
166-
.field(&self.to_base62().unwrap_or_else(|_| "invalid uri".into()))
167-
.finish()
150+
f.debug_tuple("SpotifyId").field(&self.to_base62()).finish()
168151
}
169152
}
170153

171154
impl fmt::Display for SpotifyId {
172155
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173-
f.write_str(&self.to_base62().unwrap_or_else(|_| "invalid uri".into()))
156+
f.write_str(&self.to_base62())
174157
}
175158
}
176159

@@ -219,17 +202,6 @@ impl TryFrom<&SpotifyUri> for SpotifyId {
219202
}
220203
}
221204

222-
pub fn to_base16(src: &[u8], buf: &mut [u8]) -> Result<String, Error> {
223-
let mut i = 0;
224-
for v in src {
225-
buf[i] = BASE16_DIGITS[(v >> 4) as usize];
226-
buf[i + 1] = BASE16_DIGITS[(v & 0x0f) as usize];
227-
i += 2;
228-
}
229-
230-
String::from_utf8(buf.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into())
231-
}
232-
233205
#[cfg(test)]
234206
mod tests {
235207
use super::*;
@@ -350,7 +322,7 @@ mod tests {
350322
for c in &CONV_VALID {
351323
let id = SpotifyId { id: c.id };
352324

353-
assert_eq!(id.to_base62().unwrap(), c.base62);
325+
assert_eq!(id.to_base62(), c.base62);
354326
}
355327
}
356328

@@ -370,7 +342,7 @@ mod tests {
370342
for c in &CONV_VALID {
371343
let id = SpotifyId { id: c.id };
372344

373-
assert_eq!(id.to_base16().unwrap(), c.base16);
345+
assert_eq!(id.to_base16(), c.base16);
374346
}
375347
}
376348

core/src/spotify_uri.rs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ impl SpotifyUri {
8787

8888
/// Gets the ID of this URI. The resource ID is the component of the URI that identifies
8989
/// 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> {
90+
pub fn to_id(&self) -> String {
9191
match &self {
9292
SpotifyUri::Album { id }
9393
| SpotifyUri::Artist { id }
@@ -102,11 +102,9 @@ impl SpotifyUri {
102102
duration,
103103
} => {
104104
let duration_secs = duration.as_secs();
105-
Ok(format!(
106-
"{artist}:{album_title}:{track_title}:{duration_secs}"
107-
))
105+
format!("{artist}:{album_title}:{track_title}:{duration_secs}")
108106
}
109-
SpotifyUri::Unknown { id, .. } => Ok(id.clone()),
107+
SpotifyUri::Unknown { id, .. } => id.clone(),
110108
}
111109
}
112110

@@ -207,7 +205,7 @@ impl SpotifyUri {
207205
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
208206
pub fn to_uri(&self) -> Result<String, Error> {
209207
let item_type = self.item_type();
210-
let name = self.to_id()?;
208+
let name = self.to_id();
211209

212210
if let SpotifyUri::Playlist {
213211
id,
@@ -226,7 +224,7 @@ impl SpotifyUri {
226224
/// Deprecated: not all IDs can be represented in Base62, so this function has been renamed to
227225
/// [SpotifyUri::to_id], which this implementation forwards to.
228226
#[deprecated(since = "0.8.0", note = "use to_name instead")]
229-
pub fn to_base62(&self) -> Result<String, Error> {
227+
pub fn to_base62(&self) -> String {
230228
self.to_id()
231229
}
232230
}
@@ -313,7 +311,7 @@ impl TryFrom<&protocol::playlist4_external::MetaItem> for SpotifyUri {
313311
fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result<Self, Self::Error> {
314312
Ok(Self::Unknown {
315313
kind: "MetaItem".into(),
316-
id: SpotifyId::try_from(item.revision())?.to_base62()?,
314+
id: SpotifyId::try_from(item.revision())?.to_base62(),
317315
})
318316
}
319317
}
@@ -326,7 +324,7 @@ impl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyUri
326324
) -> Result<Self, Self::Error> {
327325
Ok(Self::Unknown {
328326
kind: "SelectedListContent".into(),
329-
id: SpotifyId::try_from(playlist.revision())?.to_base62()?,
327+
id: SpotifyId::try_from(playlist.revision())?.to_base62(),
330328
})
331329
}
332330
}
@@ -488,7 +486,7 @@ mod tests {
488486
#[test]
489487
fn to_id() {
490488
for c in &CONV_VALID {
491-
assert_eq!(c.parsed.to_id().unwrap(), c.base62);
489+
assert_eq!(c.parsed.to_id(), c.base62);
492490
}
493491
}
494492

metadata/src/playlist/annotation.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ impl PlaylistAnnotation {
5858
let uri = format!(
5959
"hm://playlist-annotate/v1/annotation/user/{}/playlist/{}",
6060
username,
61-
playlist_id.to_base62()?
61+
playlist_id.to_base62()
6262
);
6363
<Self as MercuryRequest>::request(session, &uri).await
6464
}

playback/src/player.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -988,10 +988,7 @@ impl PlayerTrackLoader {
988988
Ok(audio) => match self.find_available_alternative(audio).await {
989989
Some(audio) => audio,
990990
None => {
991-
warn!(
992-
"spotify:track:<{}> is not available",
993-
track_id.to_base62().unwrap_or_default()
994-
);
991+
warn!("spotify:track:<{}> is not available", track_id.to_base62());
995992
return None;
996993
}
997994
},

0 commit comments

Comments
 (0)